Die Notwendigkeit der statischen Analyse von Quellcode …
Die meisten Java-Entwickler (und nicht nur) haben zumindest eine Art statisches Analysetool verwendet, um eine Aufgabe wie (um nur einige zu nennen) auszuführen:
- Ableiten von Quellcodemetriken wie Codezeilen oder zyklomatische Komplexität;
- Entdecken von Fehlern, Schwachstellen oder Code-Smells wie ungenutzten Variablen (was beliebte IDEs typischerweise tun);
- Durchführen eines automatisierten Refactorings oder einer Code-Vervollständigung;
- Durchsetzung von Code- und Qualitätsstandards.
Um eine statische Codeanalyse durchzuführen, benötigen wir normalerweise eine geeignete Darstellung des Quellcodes, die für die Analyse geeignet ist. Eine Programmiersprache kann durch eine formale Grammatik beschrieben werden. Darüber hinaus kann ein Parser erstellt oder generiert werden, indem man den Regeln einer formalen Grammatik folgt, um aus dem Quellcode eine ordnungsgemäße Darstellung (normalerweise einen Analysebaum) zu erstellen. Abhängig von der Art der Sprache, die wir darstellen möchten, können wir unterschiedliche Arten formaler Grammatiken verwenden:
- reguläre Grammatiken (d. h. reguläre Ausdrücke): Sie sind in den meisten Programmiersprachen verfügbar, werden aber normalerweise für grundlegendere Analyseaufgaben verwendet. In vielen Fällen sind sie nicht zum Parsen einer modernen Programmiersprache geeignet, da sie langsam und schwer zu warten sind;
- kontextfreie Grammatiken (z. B. BNF oder eBNF): Eines der bekanntesten Formate ist BNF (und seine Varianten), ein Parser kann auch durch die Grammatikregeln generiert werden;
- andere formale Grammatiken (z. B. PEG).
Es kommt häufig vor, dass in der Anfangszeit für verschiedene Tools zur statischen Codeanalyse das manuelle Schreiben eines Parsers erforderlich war, was keine triviale Aufgabe ist …
Parser-Generatoren zur Rettung …
Es können Tools erstellt werden, um Parser auf Grundlage kontextfreier Grammatikregeln zu generieren. Dies ist beispielsweise bei Tools wie LEX und YACC der Fall, die in C geschrieben sind und Code in C generieren. Auf hoher Ebene wird die Parsergenerierung durch das folgende Diagramm veranschaulicht:
In den Anfangstagen von Java hat Sun Microsystems einen Parser-Generator namens Jack entwickelt, der später in JavaCC (was für Java Compiler-Compiler steht) umbenannt wurde. Eine weitere beliebte Alternative zum Generieren eines Parsers für eine Java-Grammatik ist ANTLR (ANother Tool for Language Recognition). Beide dieser Parsergeneratoren werden gut unterstützt und sind in Java geschrieben. JavaCC (ähnlich wie YACC für C) kann Grammatikregeln mit Java-Code kombinieren, der in den generierten Parser aufgenommen wird. Allerdings bietet JavaCC nur Codegenerierung für Java, während ANTLR ein Allzweckprogramm ist, über eine große Anzahl von Grammatiken für eine Reihe von Programmiersprachen verfügt und die Möglichkeit bietet, Parser in verschiedenen Sprachen zu generieren. Beide Tools arbeiten mit formalen Grammatiken in eBNF. Betrachtet man beispielsweise das obige allgemeine Diagramm, sieht der Prozess der Parsergenerierung in Antlr anhand eines einfachen Beispiels eines Ausdrucksparsergenerators folgendermaßen aus:
Der vom Parser selbst generierte Analysebaum erfordert mehr Aufwand hinsichtlich der Codeanalyse. Aus diesem Grund bieten Parsergeneratoren normalerweise die Möglichkeit, eine prägnantere Darstellung zu generieren, die zusätzliche Symbole eliminiert und zusätzliche Funktionen zur Symbolauflösung bietet: den AST (abstrakter Syntaxbaum). Der Prozess der Verwendung von ANTLR oder JavaCC in einem Standard-Maven/Gradle-Projekt ist sehr ähnlich. Zum Beispiel für ANTLR:
- Antlr4-Abhängigkeit und Plugin in Maven/Gradle-Build-Datei hinzufügen;
<dependency>
<groupId>org.antlr</groupId>
<artifactId>antlr4-runtime</artifactId>
<version>4.7.1</version>
</dependency>
…
<plugin>
<groupId>org.antlr</groupId>
<artifactId>antlr4-maven-plugin</artifactId>
<version>4.7.1</version>
<executions>
<execution>
<goals>
<goal>antlr4</goal>
</goals>
</execution>
</execution>
</plugin>
…
- Erstellen Sie Grammatikdateien unter src/main/antlr4 (im G4-Format, Java 20-Grammatikdateien verfügbar unter https://github.com/antlr/grammars-v4/tree/master/java/java20);
- Generieren Sie Lexer und Parser mithilfe des Maven/Gradle-Builds.
Sobald dies vorhanden ist, kann der generierte Parser verwendet werden, um einen Analysebaum zu generieren, an den beispielsweise ein Listener für bestimmte Ausführungen während des Analysevorgangs angehängt werden kann. Beispiel mit einem von ANTLR generierten Parser:
String content = "public class Example { public void func(int x){ return x + 10; } }";
Java20Lexer lexer = new Java20Lexer(CharStreams.fromString(content));
CommonTokenStream tokens = new CommonTokenStream(lexer);
Java20Parser parser = new Java20Parser(tokens);
ParseTree tree = parser.compilationUnit();
ParseTreeWalker walker = new ParseTreeWalker();
ExprListener listener = new ExprListener();
walker.walk(listener, tree);
Eine alternative Möglichkeit zum Erstellen eines Parsers ist eine Parsing Expression Grammar (PEG). Eine Bibliothek, die diesen Ansatz implementiert (die Grammatikregeln werden direkt als Teil der Anwendung in Java-Code geschrieben), ist Parboiled.
Java-Bibliotheken zur Rettung …
Parsergeneratoren und PEG-Parser sind ziemlich allgemein. Möglicherweise sind sie auch nicht auf dem neuesten Stand der gewünschten Java-Version. Alternativ kann eine spezialisierte Parsing-Bibliothek wie JavaParser oder Eclipse JDT verwendet werden.
javaparser
Es basiert auf JavaCC, wird gut gepflegt und bietet Unterstützung für JDK 21. Es bietet eine verbesserte Symbolauflösung und generiert einen AST aus dem Quellcode. Darüber hinaus bietet es die Möglichkeit, den AST über eine von der Bibliothek bereitgestellte DSL abzufragen, Code aus dem AST zu generieren oder ihn zu ändern. Der Einstieg in die Verwendung der Bibliothek ist ganz einfach. Das folgende Beispiel zählt die Anzahl der Methoden in einer Klasse:
public static int countMethods(File file) throws FileNotFoundException {
CompilationUnit cu = StaticJavaParser.parse(file);
int count = 0;
for (Node node : cu.findAll(MethodDeclaration.class)) {
count++;
}
return count;
}
Um mit der Verwendung von JavaParser zu beginnen, reicht es aus, die folgende Abhängigkeit einzubinden:
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-symbol-solver-core</artifactId>
<version>3.26.3</version>
</dependency>
Eclipse JDT
Eclipse JDT (Java Developer Tools) ist der Hauptantrieb des Java-Editors in der Eclipse IDE, der erweiterte Funktionen wie partielle Kompilierung, Code-Vervollständigung usw. bietet. In früheren Zeiten von Eclipse war es nicht so einfach, JDT außerhalb der Eclipse IDE zu verwenden, vor allem, weil hierfür auch eine Reihe zusätzlicher Abhängigkeiten mitgezogen werden mussten. Jetzt ist Eclipse JDT als eigenständige Bibliothek über die folgende Abhängigkeit verfügbar:
<dependency>
<groupId>org.eclipse.jdt</groupId>
<artifactId>org.eclipse.jdt.core</artifactId>
<version>3.36.0</version>
</dependency>
Das folgende Beispiel implementiert eine Methode zum Zählen der Anzahl der Methoden in einer Java-Klasse:
public static int countMethods(File file)
throws IOException, MalformedTreeException, BadLocationException {
String source = FileUtils.readFileToString(file, Charset.defaultCharset());
Document document = new Document(source);
ASTParser parser = ASTParser.newParser(AST.JLS21);
parser.setSource(document.get().toCharArray());
CompilationUnit unit = (CompilationUnit) parser.createAST(null);
int count = 0;
List<AbstractTypeDeclaration> types = unit.types();
for (AbstractTypeDeclaration type : types) {
if (type.getNodeType() == ASTNode.TYPE_DECLARATION) {
List<BodyDeclaration> bodies = type.bodyDeclarations();
for (BodyDeclaration body : bodies) {
if (body.getNodeType() == ASTNode.METHOD_DECLARATION) {
count++;
}
}
}
}
return count;
}
Wie Sie sehen, stehen Ihnen mehrere Optionen zur Auswahl, um mit dem Schreiben Ihres eigenen Tools zur statischen Analyse von Java-Code zu beginnen.