Java con ANTLR

1. Panoramica

In questo tutorial, faremo una rapida panoramica del generatore di parser ANTLR e mostreremo alcune applicazioni del mondo reale.

2. ANTLR

ANTLR (ANother Tool for Language Recognition) è uno strumento per l'elaborazione del testo strutturato.

Lo fa dandoci accesso alle primitive di elaborazione del linguaggio come lesser, grammatiche e parser, nonché al runtime per elaborare il testo in base a essi.

Viene spesso utilizzato per creare strumenti e framework. Ad esempio, Hibernate utilizza ANTLR per analizzare ed elaborare le query HQL ed Elasticsearch lo utilizza per Painless.

E Java è solo un legame. ANTLR offre anche collegamenti per C #, Python, JavaScript, Go, C ++ e Swift.

3. Configurazione

Prima di tutto, iniziamo aggiungendo antlr-runtime al nostro pom.xml :

 org.antlr antlr4-runtime 4.7.1 

E anche il plug-in antlr-maven:

 org.antlr antlr4-maven-plugin 4.7.1    antlr4    

È compito del plugin generare codice dalle grammatiche che specifichiamo.

4. Come funziona?

Fondamentalmente, quando vogliamo creare il parser utilizzando il plugin ANTLR Maven, dobbiamo seguire tre semplici passaggi:

  • preparare un file di grammatica
  • generare sorgenti
  • creare l'ascoltatore

Quindi, vediamo questi passaggi in azione.

5. Utilizzo di una grammatica esistente

Usiamo prima ANTLR per analizzare il codice per metodi con un cattivo case:

public class SampleClass { public void DoSomethingElse() { //... } }

In poche parole, convalideremo che tutti i nomi dei metodi nel nostro codice inizino con una lettera minuscola.

5.1. Prepara un file di grammatica

La cosa bella è che ci sono già diversi file di grammatica che possono soddisfare i nostri scopi.

Usiamo il file grammaticale Java8.g4 che abbiamo trovato nel repository grammaticale Github di ANTLR.

Possiamo creare la directory src / main / antlr4 e scaricarla lì.

5.2. Genera sorgenti

ANTLR funziona generando codice Java corrispondente ai file grammaticali che gli diamo e il plugin maven lo rende facile:

mvn package

Per impostazione predefinita, questo genererà diversi file nella directory target / generated-sources / antlr4 :

  • Java8.interp
  • Java8Listener.java
  • Java8BaseListener.java
  • Java8Lexer.java
  • Java8Lexer.interp
  • Java8Parser.java
  • Java8.tokens
  • Java8Lexer.tokens

Notare che i nomi di quei file sono basati sul nome del file di grammatica .

Avremo bisogno dei file Java8Lexer e Java8Parser in seguito durante il test. Per ora, però, abbiamo bisogno di Java8BaseListener per creare il nostro MethodUppercaseListener .

5.3. Creazione di MethodUppercaseListener

Based on the Java8 grammar that we used, Java8BaseListener has several methods that we can override, each one corresponding to a heading in the grammar file.

For example, the grammar defines the method name, parameter list, and throws clause like so:

methodDeclarator : Identifier '(' formalParameterList? ')' dims? ;

And so Java8BaseListener has a method enterMethodDeclarator which will be invoked each time this pattern is encountered.

So, let's override enterMethodDeclarator, pull out the Identifier, and perform our check:

public class UppercaseMethodListener extends Java8BaseListener { private List errors = new ArrayList(); // ... getter for errors @Override public void enterMethodDeclarator(Java8Parser.MethodDeclaratorContext ctx) { TerminalNode node = ctx.Identifier(); String methodName = node.getText(); if (Character.isUpperCase(methodName.charAt(0))) { String error = String.format("Method %s is uppercased!", methodName); errors.add(error); } } }

5.4. Testing

Now, let's do some testing. First, we construct the lexer:

String javaClassContent = "public class SampleClass { void DoSomething(){} }"; Java8Lexer java8Lexer = new Java8Lexer(CharStreams.fromString(javaClassContent));

Then, we instantiate the parser:

CommonTokenStream tokens = new CommonTokenStream(lexer); Java8Parser parser = new Java8Parser(tokens); ParseTree tree = parser.compilationUnit();

And then, the walker and the listener:

ParseTreeWalker walker = new ParseTreeWalker(); UppercaseMethodListener listener= new UppercaseMethodListener();

Lastly, we tell ANTLR to walk through our sample class:

walker.walk(listener, tree); assertThat(listener.getErrors().size(), is(1)); assertThat(listener.getErrors().get(0), is("Method DoSomething is uppercased!"));

6. Building Our Grammar

Now, let's try something just a little bit more complex, like parsing log files:

2018-May-05 14:20:18 INFO some error occurred 2018-May-05 14:20:19 INFO yet another error 2018-May-05 14:20:20 INFO some method started 2018-May-05 14:20:21 DEBUG another method started 2018-May-05 14:20:21 DEBUG entering awesome method 2018-May-05 14:20:24 ERROR Bad thing happened

Because we have a custom log format, we're going to first need to create our own grammar.

6.1. Prepare a Grammar File

First, let's see if we can create a mental map of what each log line looks like in our file.

Or if we go one more level deep, we might say:

:= …

And so on. It's important to consider this so we can decide at what level of granularity we want to parse the text.

A grammar file is basically a set of lexer and parser rules. Simply put, lexer rules describe the syntax of the grammar while parser rules describe the semantics.

Let's start by defining fragments which are reusable building blocks for lexer rules.

fragment DIGIT : [0-9]; fragment TWODIGIT : DIGIT DIGIT; fragment LETTER : [A-Za-z];

Next, let's define the remainings lexer rules:

DATE : TWODIGIT TWODIGIT '-' LETTER LETTER LETTER '-' TWODIGIT; TIME : TWODIGIT ':' TWODIGIT ':' TWODIGIT; TEXT : LETTER+ ; CRLF : '\r'? '\n' | '\r';

With these building blocks in place, we can build parser rules for the basic structure:

log : entry+; entry : timestamp ' ' level ' ' message CRLF;

And then we'll add the details for timestamp:

timestamp : DATE ' ' TIME;

For level:

level : 'ERROR' | 'INFO' | 'DEBUG';

And for message:

message : (TEXT | ' ')+;

And that's it! Our grammar is ready to use. We will put it under the src/main/antlr4 directory as before.

6.2.Generate Sources

Recall that this is just a quick mvn package, and that this will create several files like LogBaseListener, LogParser, and so on, based on the name of our grammar.

6.3. Create Our Log Listener

Now, we are ready to implement our listener, which we'll ultimately use to parse a log file into Java objects.

So, let's start with a simple model class for the log entry:

public class LogEntry { private LogLevel level; private String message; private LocalDateTime timestamp; // getters and setters }

Now, we need to subclass LogBaseListener as before:

public class LogListener extends LogBaseListener { private List entries = new ArrayList(); private LogEntry current;

current will hold onto the current log line, which we can reinitialize each time we enter a logEntry, again based on our grammar:

 @Override public void enterEntry(LogParser.EntryContext ctx) { this.current = new LogEntry(); }

Next, we'll use enterTimestamp, enterLevel, and enterMessage for setting the appropriate LogEntry properties:

 @Override public void enterTimestamp(LogParser.TimestampContext ctx) { this.current.setTimestamp( LocalDateTime.parse(ctx.getText(), DEFAULT_DATETIME_FORMATTER)); } @Override public void enterMessage(LogParser.MessageContext ctx) { this.current.setMessage(ctx.getText()); } @Override public void enterLevel(LogParser.LevelContext ctx) { this.current.setLevel(LogLevel.valueOf(ctx.getText())); }

And finally, let's use the exitEntry method in order to create and add our new LogEntry:

 @Override public void exitLogEntry(LogParser.EntryContext ctx) { this.entries.add(this.current); }

Note, by the way, that our LogListener isn't threadsafe!

6.4. Testing

And now we can test again as we did last time:

@Test public void whenLogContainsOneErrorLogEntry_thenOneErrorIsReturned() throws Exception { String logLine; // instantiate the lexer, the parser, and the walker LogListener listener = new LogListener(); walker.walk(listener, logParser.log()); LogEntry entry = listener.getEntries().get(0); assertThat(entry.getLevel(), is(LogLevel.ERROR)); assertThat(entry.getMessage(), is("Bad thing happened")); assertThat(entry.getTimestamp(), is(LocalDateTime.of(2018,5,5,14,20,24))); }

7. Conclusion

In questo articolo, ci siamo concentrati su come creare il parser personalizzato per la propria lingua utilizzando ANTLR.

Abbiamo anche visto come utilizzare i file grammaticali esistenti e applicarli per attività molto semplici come il linting del codice.

Come sempre, tutto il codice utilizzato qui può essere trovato su GitHub.