Beim JUG Saxony Day, unserer alljährlichen JUG Konferenz, hatten wir ein interessantes Gespräch über die Möglichkeiten der JavaParser Bibliothek. Ich hatte sie bisher nur für das Parsen und Visualiseren von Quellcode innerhalb von jQAssistant benutzt und ich wusste um die API zu Inspektion und Abfragen der geparsten Informationen.
Mein Freund Jens Nerche erzählte mir aber über den Einsatz von JavaParser für die Modifikation von geparstem Code, also eine Art automatisches Refactoring, was ich natürlich sehr interessant fand. Damit einhergehend ist zu erwähnen dass JavaParser in diesem Kontext das originale Layout des Quelltextes erhalten kann, also nur die minimalen Änderungen des Refactorings im generierten Code erscheinen. Und nicht zuletzt kann man JavaParser auch einsetzen, um neuen JavaCode zu generieren, zum Beispiel aus einem semantischen Modell einer DSL.
Da ich denke, dass so ein Werkzeug für alle Leser nützlich sein könnte, möchte ich es in dieser Kolumne etwas näher vorstellen.
Java Parser ist ein Open-Source Projekt, man hat als Nutzer die freie Wahl zwischen der LGPL als auch der Apache v2 Lizenz. Da das Tool meist während des Entwicklungsprozesses und nicht während der Laufzeit von Projekten eingesetzt wird, ist das nur bedingt relevant.
Es besteht aus einem Parser der einen Abstrakten Syntaxbaum (AST) aufbaut, den man mittels Methoden direkt auf den AST-Knoten navigieren und inspizieren kann. Um größere Analysen typsicher vorzunehmen, wird aber der Visitor-Ansatz empfohlen, für den in JavaParser verschiedene Basisklassen vorliegen. Dabei wird zwischen modifizierenden und nur-lesenden Visitoren unterschieden, die während ihrer Traversierung jeweils auch Zustandsinformationen aufsammeln können. Für Modifikationen können an jedem AST-Knoten mittels entsprechender APIs neue Attribute oder sogar neue Syntax-Knoten eingefügt oder entfernt werden.
Um den AST wieder textuell auzugeben, kann man zwischen einem konfigurierbaren "Pretty-Printing" oder einer Wiederherstellung des Ursprungslayouts wählen.
Jeder Quelltext kann als hierarchischer Baum verschiedener, geschachtelter Elemente (Knoten) dargestellt werden, wobei bestimmte Informationen implizit in der Struktur gehalten werden (z.B. Klammern, Operatoren oder throws
), andere wie Modifikatoren können als Attribute abgelegt sein.
Komplexe Knoten stellen Klassen, Methoden, Variablendeklarationen und Ausdrücke dar, die wiederum aus Knoten bestehen. Terminale Elemente oder Blattknoten sind zum Beispiel Namen, Typen oder Literale. Dieser Baum ergibt sich direkt aus den Regeln der Grammatik der Sprache.
Für meine Tests benutze ich praktischerweise jshell
mit einem jar-Bundle aus dem [javasymbolsolver-maven-sample] jshell -c target/javasymbolsolver-maven-sample-1.0-SNAPSHOT-shaded.jar
Im Listing 1 sieht man, wie die AST-Struktur der Klasse A
ausgegeben werden kann.
import com.github.javaparser.*;
import com.github.javaparser.ast.*;
var cu = StaticJavaParser.parse("class A { { System.out.println(40+2); } }");
void print(Node node, int level) {
System.out.printf("%"+level+"s %s%n","",node.getClass().getSimpleName());
node.getChildNodes().forEach(c -> print(c, level+1));
}
print(cu,1);
// Ausgabe Syntaxbaum
CompilationUnit
ClassOrInterfaceDeclaration
SimpleName
InitializerDeclaration
BlockStmt
ExpressionStmt
MethodCallExpr
FieldAccessExpr
NameExpr
SimpleName
SimpleName
SimpleName
BinaryExpr
IntegerLiteralExpr
IntegerLiteralExpr
Der einfachste Parser im Paket ist der StaticJavaParser
dessen parse
Methode man mit Strings, Dateien, Readern usw. aufrufen kann und als Ergebnis eine CompilationUnit
erhält.
// find /Users/mh/d/java/neo/neo4j -name "*.java" | wc -l -> 7638 files
var sourceDir = "/Users/mh/d/java/neo/neo4j";
import java.nio.*;
import java.nio.file.*;
import java.nio.file.attribute.*;
var matcher = FileSystems.getDefault().getPathMatcher("glob:**.java");
var fileNames = new ArrayList<Path>();
var start = System.currentTimeMillis();
Files.walkFileTree(Paths.get(sourceDir), new SimpleFileVisitor<Path>() {
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (matcher.matches(file)) fileNames.add(file);
return FileVisitResult.CONTINUE;
}
});
List<CompilationUnit> files = fileNames.parallelStream()
.map(file -> { try { return StaticJavaParser.parse(file); }
catch(IOException ioe) { return null; } })
.filter(cu -> cu != null)
.collect(Collectors.toList());
System.out.printf("%d files %d parsed in %d seconds%n",
files.size(),fileNames.size(),
TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()-start));
// Ergebnis: 7638 files 7638 parsed in 88 seconds
Die CompiliationUnit
steht für die Information, die aus einer Java-Datei gewonnen werden kann, also Paketdeklaration, Imports, alle Klassendefinitionen mit ihrem Variablen, Methoden und inneren Klassen sowie deren Bestandteilen.
Jede CompiliationUnit
ist auch ein Knoten (Node
) des Syntaxbaums, stellt also dieselben Basismethoden wie die Node
Superklasse bereit:
Relevante Methoden der Node
-Klasse:
-
Kommentare:
getAllContainedComments(), getComment(), getOrphanComments()
-
Navigation:
getParentNode(), getParentNodeForChildren(), getChildNodes(), getChildNodesByType(), getNodesByType(type)
-
Beispiel:
cu.getNodesByType(SimpleName.class) → [A, System, out, println]
-
Metadaten:
getMetaModel(), getDataKeys(), getData(key), getParsed()
-
Bereiche:
getRange(), getBegin(), getEnd(), getTokenRange()
-
Beispiel:
n.getRange() → Optional[(line 1,col 1)-(line 1,col 41)]
Mit dieser API können wir die CompiliationUnit auch visuell als Graph rendern:
var graph = cu.getNodesByType(Node.class).stream()
.filter(n -> n.getParentNode().isPresent())
.map(n -> String.format("%s->%s",
n.getParentNode().get().getClass().getSimpleName(),
n.getClass().getSimpleName()))
.collect(Collectors.joining( "\n", "digraph G {", "}" ));
try (var fw = new FileWriter("graph.dot")) { fw.write(graph); }
// dot graph.dot -Tpng -o graph.png && open graph.png
Zusätzlich stehen in der CompiliationUnit
noch folgende Informationen zur Verfügung, entsprechend ihrer Rolle in der Java-Sprachgrammatik:
getModule()
getPackageDeclaration()
getImports()
getImport(index)
getPrimaryType()
getPrimaryTypeName()
getStorage()
getAnnotationDeclarationByName(name)
getClassByName(name)
getEnumByName(name)
getInterfaceByName(name)
getTypes() -> List<ClassOrInterfaceDefinition>
getType(index)
Adäquat dazu haben die anderen, konkreten AST-Knoten spezielle Methoden zum Lesen und Veränderung ihres Zustands und Struktur.
Durch die Unterstützung der Java-Streams API auf allen Elementen kann man mit deren Methoden wie filter, map, anyMatch
usw. eine relative flüssige Abfragedefinition erstellen.
Hier im Beispiel finden wir alle Testklassen in unserem Quellcodeverzeichnis und analysieren dann ihre @Test
annotierten Methoden auf das Fehlen eines Methodenaurufs der mit "assert" beginnt, d.h. Testmethoden ohne Assertions.
Das ist eine relevante, komplexe Suche, die in echten Projekten oft Abgründe zutage bringt und mit den Mitteln der IDE schwer zu handhaben ist. Man kann ein Tool wie [jQAssistant] nutzen, um solche und andere Validierungen in den Buildprozess zu integrieren.
Ohne echte Typeinformationen können wir aber nicht feststellen aus welchen Paketen bzw. Klassen die Annotationen und aufgerufenen Methoden stammen, daher nur die Überprüfung auf textuelle Übereinstimmung. Dazus würde der JavaSymbolSolver genutzt, der weiter unten vorgestellt wird.
import com.github.javaparser.ast.body.*;
import com.github.javaparser.ast.expr.*;
var cu = StaticJavaParser.parse("import junit.*; class MyTest { @Test public void testAnswer() { assertEquals(42, 3*4+5*6); } @Test public void testUniverse() { /* assertNotNull(universe); */ } }");
var files = Collections.singletonList(cu);
// Anzahl Testklassen
files.stream().flatMap( cu ->
cu.getNodesByType(ClassOrInterfaceDeclaration.class).stream()
.filter( cls -> cls.getNameAsString().endsWith("Test") ) )
.count();
// @Test annotierte Methoden einer Klasse
cls.getMethods().stream().filter( m -> m.getAnnotationByName("Test").isPresent() )
.map(MethodDeclaration::getSignature)
.forEach(System.out::println);
// @Test Methoden der "Test"klassen ohne "assert*" Aufrufe
files.stream().flatMap( cu ->
cu.getNodesByType(ClassOrInterfaceDeclaration.class).stream()
.filter( cls -> cls.getNameAsString().endsWith("Test") ) )
.flatMap( cls -> cls.getMethods().stream()
.filter( m -> m.getAnnotationByName("Test").isPresent() &&
m.getBody().flatMap(b ->
b.findFirst(MethodCallExpr.class,
mc -> mc.getNameAsString().startsWith("assert")))
.isEmpty()))
.map( m -> m.findAncestor(ClassOrInterfaceDeclaration.class).get()
.getNameAsString()+"."+m.getNameAsString())
.forEach(System.out::println);
Ein Syntaxbaum ist ein komplexe Struktur mit vielen Detailinformationen in beliebig rekursiver Tieefe an denen man oft nicht einmal interessiert ist. Statt nun selbst manuell und mühselig sich durch diesen Baum zu navigieren, kann man die Visitor Infrastruktur von JavaParser nutzen. Diese stellen sicher, dass jede Stelle des Baums erreicht wird und man einen typsicheren "Callback" bekommt. Durch die Vielzahl existierender Typen im AST und in den Visitoren wird mit einem Adapter eine Basisimplementierung in der man nur relevante Methoden überschreiben muss.
Jeder Visitor kann mit einem konfigurierbaren Typparameter ein Zustandsobjekt durch alle Aufrufe hindurchreichen, in dem man Informationen aggregieren oder Entscheidungskriterien festhalten bzw. zugreifbar machen kann.
Wenn wir unser Beispiel auf einen Visitor umschreiben, können wir alle @Test
annotierten Methoden, die keine Assertions enthalten in einer Liste aufsammeln.
Es wird zwischen einem nur-lesenden VoidVisitor<State>
Visitor der keine Modifikationen vornimmt und dem Gegenstück ModifierVisitor<State>
unterschieden.
Im letzteren kann jede Visitor-Methode einen "neuen" Zustand des Syntaxknotens zurückgeben in dem Modifikationen vorgenommen worden.
import com.github.javaparser.ast.visitor.VoidVisitorAdapter;
class NoAssertMethodVisitor extends VoidVisitorAdapter<List<MethodDeclaration>> {
public void visit(MethodDeclaration m, List<MethodDeclaration> missingAssertMethods) {
super.visit(m, missingAssertMethods);
if (m.getAnnotationByName("Test").isPresent() &&
m.getBody().flatMap(b ->
b.findFirst(MethodCallExpr.class,
mc -> mc.getNameAsString().startsWith("assert")))
.isEmpty()) {
missingAssertMethods.add(m);
}
}
}
var missing = new ArrayList<MethodDeclaration>();
var visitor = new NoAssertMethodVisitor();
files.forEach(cu -> visitor.visit(cu, missing));
Es wird empfohlen, die Supermethode der Adapter aufzurufen, da über diese die Traversierung erfolgt. Das ist leider nicht das beste Design für einen Visitor, da normalerweise die Traversierung unabhängig von den Callbacks sein sollte, kann aber im Einzelfall auch nützlich sein, wenn man ganze Teilbäume ausblenden will.
Laut der Autoren der Bibliothek sind Kommentare unerwarteterweise das komplexeste Feature. Zum einen können sie überall im Quellcode stehen, und sind in der Sprachspezifikation keinem Grammatikelement explizit zugordnet. Zum anderen werden sie normalerweise von Parsern genau wie Leerräume und literale Token ignoriert. Da JavaParser aber auch zur Modifikation / Reproduktion von existierendem Code eingesetzt wird, sollten Kommentare erhalten werden.
Im Allgemeinen werden Kommentare in Javadoc /** ... **/
, Block /* ... */
und Zeilenkommentare //...
unterschieden.
Kommentare enthalten auch Informationen welchem Bereich sie im Quelltext abdecken.
Spannend ist, wie Kommentare anderen Syntaxelementen zugeordnet werden.
Im Regelfall werden sie dem nächstfolgenden Syntaxelement getCommentedNode
zugewiesen, dieses erhält dann auch einen Verweis auf den Kommentarknoten getComment
.
Falls es kein "nächstes" Element gibt oder ein Kommentar folgt, ist der aktuelle Kommentar verwaist ("orphan").
Diese sind dann im übergeordneten Syntaxelement mit getOrphanedComments
zugreifbar.
Nur Zeilenkommentare die auf einer Zeile einem Syntaxelement folgen werden diesem Vorgänger zugewiesen, z.B. int masse; // in Gramm
Vieles davon ist aber auch wieder konfigurierbar.
cu.getAllContainedComments().forEach(c->
System.out.printf("In %s: %d..%d type %s orphan %s%n%s%n",
c.findAncestor(ClassOrInterfaceDeclaration.class)
.map( cid -> cid.getNameAsString()).orElse("k.A."),
c.getRange().get().begin.line,
c.getRange().get().end.line,
c.getClass().getSimpleName(),
c.isOrphan(),
c.getContent()))
// Beispielausgabe:
In k.A.: 1..1 type BlockComment orphan false
always true
Jeder Syntaxknoten kann mittels toString()
in eine sinnvoll formatierte (und konfigurierbare) Textform überführt werden.
Solange das für Anschauungszwecke, Code-Generierung oder temporäre Nutzung (z.b. vor Kompilierung) erfolgt, ist diese API das Mittel der Wahl.
import com.github.javaparser.*;
var cu = StaticJavaParser.parse(
"class A { \n/* always true */ private int defaultValue = 42; \n" +
"public int answer(String question) { return defaultValue; }\n}");
System.out.println(cu);
// Ausgabe
class A {
/* always true */
private int defaultValue = 42;
public int answer(String question) {
return defaultValue;
}
}
Mittels der PrettyPrinterConfiguration
kann dies Ausgabe angepasst werden, man kann sogar mit einem eigenen PrettyPrintVisitor
die Darstellung bestimmter Programmteile ausblenden oder massiv verändern.
Damit könnte man z.B. nur die Outline von Klassen rendern, ohne Methodenrümpfe.
Für die Nutzbarkeit des ausgegebenen Quellcodes trägt man dann aber selbst die Verantwortung.
import com.github.javaparser.printer.*;
import com.github.javaparser.ast.body.*;
import com.github.javaparser.ast.stmt.*;
PrettyPrinterConfiguration conf = new PrettyPrinterConfiguration()
.setIndentSize(1)
.setIndentType(PrettyPrinterConfiguration.IndentType.SPACES)
.setPrintComments(false);
conf.setVisitorFactory(prettyPrinterConfiguration -> new PrettyPrintVisitor(conf) {
public void visit(BlockStmt body, Void nothing) { }
});
System.out.println(new PrettyPrinter(conf).print(cu));
// Ausgabe
class A {
private int defaultValue = 42;
public int answer(String question)
}
Für die Modifikation existierenden Codes möchte man oft so viel Originallayout wie möglich erhalten.
Dazu nutzt JavaParser intern eine reichhaltigere Repräsentation des Codes, in dem alle Leerräume, Klammern, Einrückungen usw. als zusätzliche Token
zwischen den Syntaxelementen gespeichert werden.
Damit werden bei der Änderung bzw. Erweiterung von Knoten nur diese Elemente beeinflusst, alle anderen Formatierungen bleiben erhalten. Das führt zu einer Minimierung des Unterschieds zwischen Start- und Zielzustand.
Neu hinzugefügte und veränderte Elemente werden mit dem "Pretty-Printer" formatiert.
import com.github.javaparser.printer.lexicalpreservation.*;
LexicalPreservingPrinter.setup(cu);
System.out.println(LexicalPreservingPrinter.print(cu));
// Ausgabe (genau wie der originale Quelltext)
class A {
/* always true */ private int defaultValue = 42;
public int answer(String question) { return defaultValue; }
}
Für viele Refactorings sind Java IDEs gut geeignet und können diese sicher ausführen. JavaParser glänzt für komplexere Operationen, die potentiell mehrere punktuelle Änderungen über ein breites Spektrum von zu modifizierenden Stellen ausführen müssen.
In unserem Beispiel wollen wir die schon gefundenen Methoden ohne Assertions mit einem "fail()" Aufruf versehen, so dass sie im nächsten Testlauf garantiert auffallen.
Dazu können wir am Anfang des Methodenköpers einfach den Methodenaufruf einfügen und zusätzlich noch die Datei um den notwendigen Import ergänzen.
Das kann zum einen über die hier gezeigte imperative API geschehen, aber auch über den erwähnten ModifierVisitor
.
import com.github.javaparser.ast.stmt.*;
import com.github.javaparser.ast.expr.*;
// Methoden mit fehlenden Asserts
List<MethodDeclaration>> missing = ...
missing.forEach(m -> {
m.getBody().ifPresent(b -> {
var stmts = b.getStatements();
var msg = new StringLiteralExpr("Method "+m.getSignature()+" has no assert");
stmts.addFirst(new ExpressionStmt(new MethodCallExpr("Assert.fail", msg)));
});
m.findCompilationUnit().ifPresent(cu -> cu.addImport("org.junit.Assert"));
})
// Ausgabe für unser Beispiel
import junit.*;
import org.junit.Assert;
class MyTest {
@Test
public void testAnswer() {
assertEquals(42, 3 * 4 + 5 * 6);
}
@Test
public void testUniverse() {
/* assertNotNull(universe); */
Assert.fail("Method testUniverse() has no assert");
}
}
Der Parser selbst kann keine Symbole auflösen, er kennt nur Ausdrücke, die als Elemente des Syntaxbaumes existieren, so kann deren Herkunft und Typ unbekannt sein.
Für die Bestimmung von Symbolen wie Klassen, Variablen und Methoden ist mehr Kontext notwendig, besonders wenn diese aus direkt oder indirekt aus verschiedenen Quellen stammen könnten.
Dafür ist der JavaSymbolSolver
zuständig, der seit neuestem mit dem JavaParser zusammen in einem Bundle ausgeliefert wird.
Er ist dafür zuständig, benannte Symbole aufzulösen.
Um Informationen von externen, vollqualifizierten Typen aufzulösen kann dieser mit einem entsprechenden TypeSolver
konfiguriert werden:
-
JavaParserTypeSolver
für Informationen aus Quellcodedateien, die aus einem Basisverzeichnis (mit Unterverzeichnissen) geparst werden -
JarTypeSolver
für Klassen aus externen JAR-Dateien -
ReflectionTypeSolver
für Klassen aus dem JDK -
CombinedTypeSolver
um mehrere TypeSolver zu kombinieren
Mit der JavaParser Bibliothek kann man sehr schnell Java Quellcode in einen gut navigierbaren Objektbaum parsen. Dieser ist vielseitig nutzbar - für Analysen, für die strukturelle Suche nach bestimmten Mustern, partielle Modifikationen oder Codegenerierung.
Auch größere Projekte, wie zum Beispiel Neo4j sind schnell geparst.
-
Website: https://javaparser.org
-
Beispielprojekt: https://github.com/javaparser/javaparser-maven-sample
-
Buch: "JavaParser: Visited", Smith, van Bruggen,Tomassetti, LeanPub, 2019 https://leanpub.com/javaparservisited
-
JavaDoc http://www.javadoc.io/doc/com.github.javaparser/javaparser-core