Für Kotlin und Java auf einmal
Wie Android Lint und andere statische Code-Analyse-Tools funktionieren
Initial wurde Android Lint dafür geschaffen, hauptsächlich Android-spezifische Probleme in Java-Code zu finden. Um den Java-Code zu analysieren, hat Android Lint einen Java-spezifischen Abstract Syntax Tree (oder einfach AST, mehr dazu auf Wikipedia) erstellt, der eine Baumdarstellung des Quellcodes ist. Andere statische Code-Analyse-Tools verwenden ebenfalls einen sprachspezifischen AST für die Analyse;
Checkstyle: Java-, ktlint: Kotlin-, detekt: Kotlin-spezifisch. Die Tools durchlaufen diesen AST und finden Fehler, indem sie dessen Knoten und deren Attribute überprüfen.
Wie Android Lint zwei Sprachen gleichzeitig überprüfen kann
Der Unterschied zwischen Android Lint und anderen Tools besteht darin, dass, als Kotlin ebenfalls eine unterstützte Sprache für Android wurde, Android Lint begann, auch Kotlin hinsichtlich der Regeln zu unterstützen, die wir bereits für Java hatten. Dazu wurde der sogenannte Universal Abstract Syntax Tree (entwickelt von JetBrains) eingeführt, der eine Baumrepräsentation des Codes bereitstellt, die sowohl für Kotlin als auch für Java gleichzeitig angewendet werden kann, also auf einer höheren Abstraktionsebene liegt als ein sprachspezifischer AST. Für weitere Informationen empfehle ich diesen Vortragsteil von Tor Norbye – dem Schöpfer und Maintainer von Android Lint.
Beide, UAST und AST, liefern High-Level-Details über den Quellcode. Sie enthalten keine Informationen über Whitespaces oder geschweifte Klammern, aber das reicht in der Regel für die Android-spezifischen Regeln aus. Sehen Sie sich unten ein UAST-Beispiel an:
Die UAST-Bibliothek enthält alle sprachspezifischen Ausdrücke, bietet aber auch allgemeine Schnittstellen für diese. Beispiel für den if-Ausdruck:
PSI-Baum (Program Structure Interface Tree)
Der PSI-Baum baut im Fall von Android Lint auf dem UAST auf (bei anderen Tools auf dem sprachspezifischen AST), und das ist der Baum, der mehr Details über die Struktur des Codes enthält, wie z.B. Whitespaces, Klammern und ähnliche Elemente.
In Android Lint sind die PSI-Ausdrücke in der Implementierung der UAST-Ausdrücke enthalten, die die Knoten des UAST sind. Z.B. org.jetbrains.kotlin.psi.KtIfExpression kann über KotlinUIfExpression aufgerufen werden.
Es gibt sogar ein praktisches intelliJ Plugin: PsiViewer, das das Debuggen von PSI-Baum-basierten Code-Analyseregeln erleichtern kann. Im folgenden Beispiel mit dem gleichen Codeschnipsel können wir sehen, dass die beiden Sprachen unterschiedliche sprachspezifische Token in den Bäumen haben:
Verwendung von UAST und PSI Tree
Ich brauche sowohl UAST als auch PSI Tree, um meine Formatierungsregel zu erstellen, denn UAST ist großartig, um zu filtern und Knoten, die mich interessieren, für beide Sprachen gleichzeitig zu besuchen, aber wie ich bereits erwähnt habe, liefert es keine Informationen über die Formatierung, z.B. Whitespace-Informationen, die für mich essentiell sind. UAST konzentriert sich eher auf eine höhere Abstraktionsebene, ist also primär nicht zum Erstellen von Formatierungsregeln geeignet.
Seit dem Gradle-Plugin 3.4 und der zugehörigen Android-Lint-Version 26.4.0 können wir die PSI-Baumrepräsentation jedes Knotens und seiner Umgebung erhalten, nicht nur in Java, sondern auch in Kotlin. Das macht es möglich, Android Lint für meine Formatierungsregel zu verwenden.
Wie die Regel implementiert wird
Zunächst muss ich mein Issue erstellen, wobei ich den Scope auf JAVA_FILE setze, was in Android Lint derzeit sowohl Java als auch Kotlin bedeutet. (Hier können wir auch andere Arten von Dateien wie XML- oder Gradle-Dateien einstellen, da Android Lint auch diese prüfen kann.)
Dann, in meiner Detektor-Klasse, Ich zähle die Knoten, an denen ich interessiert bin, in der Funktion getApplicableUastTypes auf, damit Android Lint weiß, dass es die zugehörigen überschriebenen Besuchsmethoden wie visitForEachExpression aufrufen soll. In jeder visit-Methode rufe ich einfach meine Checker-Methode auf.
In meiner Checker-Methode beschreibt der Forward-Parameter, ob ich den Zeilenumbruch vor oder nach der „Block-Anweisung“ prüfen will. Im Methodenrumpf untersuche ich die Zeilenumbruch-Anzahl der Whitespaces um die „Blockanweisung“, die ich besuche. Dazu führe ich im Wesentlichen drei Schritte aus:
- Erst prüfe ich mit der Methode firstBlockLevelNode, welches der erste Block-Level-Knoten ist, um den herum ich die Whitespaces prüfen möchte, denn wenn ich einen if-Block für die Zuweisung eines Wertes an eine Variable verwende, wie hier,
Lint würde den Whitespace direkt vor dem if-Schlüsselwort untersuchen, aber ich bin an dem Whitespace vor dem val-Schlüsselwort interessiert. In diesem Fall ist also die erste Anweisung auf Blockebene die Wertzuweisung, die meine if-Anweisung umschließt.
- Zweitens prüfe ich in der Methode firstRelevantWhiteSpaceNode, was der erste relevante Whitespace-Knoten ist, an dem wir die Zeilenumbrüche zählen sollten. Manchmal gibt es keinen relevanten Whitespace zu prüfen, denn wenn mein Block am Anfang oder Ende einer Methode oder eines anderen Blocks steht, dann ist das in Ordnung und ich kann auf weitere Untersuchungen verzichten. Siehe:
An dieser Stelle verwende ich bereits die PSI-Knoten, denn ich möchte Whitespace-Informationen prüfen, die in UAST nicht zur Verfügung stehen. Dazu benötige ich die sourcePsi-Eigenschaft des UAST-Knotens auf Blockebene.
Ein Grenzfall ist, wenn sich ein Kommentar direkt über meiner Blockanweisung befindet. Hier möchte ich den Whitespace über dem Kommentar prüfen, so dass der erste relevante Whitespace über der Kommentaranweisung liegt.
- Schließlich zähle ich die Anzahl der Zeilenumbrüche im relevanten Whitespace; wenn sie nicht größer als 1 ist, melde ich ein Problem.
Das Ergebnis
Als Ergebnis erwarte ich die folgenden Warnungen in der IDE, so dass es für jeden Entwickler klar ist, dass eine zusätzliche Zeile hinzugefügt werden muss. Ich kann diese Warnungen auch im Lint-Report erhalten, wenn ich den Task lint gradle ausführe. Außerdem kann ich sogar den Schweregrad auf Fehler erhöhen, wenn ich den Merge-Request-Job blockieren möchte.
Fazit
Nach der Integration der benutzerdefinierten Regel, müssen wir uns nicht mehr mit diesem frustrierenden Problem beschäftigen; Stattdessen können wir unsere Gehirnleistung darauf verwenden, komplexere Probleme zu finden, wenn wir den Code des anderen überprüfen. Noch besser ist, dass wir zu 100 % garantieren können, dass dieser Fehler nicht versehentlich in unsere Codebasis gelangt, weil wir unseren MR-Job so konfigurieren können, dass er fehlschlägt, wenn jemand es versäumt, diesen Fehler zu beheben.
Auch wenn Android Lint nicht primär für Formatierungsregeln gedacht ist, war es in meinem Fall sehr praktisch, weil es sowohl für Java als auch für Kotlin verwendet werden kann: kein doppeltes Schreiben, Pflegen und Integrieren von Regeln erforderlich.
Auf der anderen Seite müssen wir feststellen, dass diese Regel eine sehr einfache ist, da ich nur Whitespaces und geschweifte Klammern auf PSI-Ebene prüfen muss, die in beiden Sprachen gleich sind. Deshalb muss ich keinen sprachspezifischen Code schreiben. Sollte ich dennoch sprachspezifischen Code schreiben müssen (z. B. die Behandlung des Elvis-Operators in Kotlin oder des Ternary-Operators in Java), würde ich trotzdem in Erwägung ziehen, Android Lint gegenüber dem Schreiben eines einzelnen ktlint und einer Checkstyle-Regel zu bevorzugen, weil ich dann wahrscheinlich immer noch viel weniger Arbeit hätte.
Nächster Teil…
Wenn Ihnen der erste Teil des Artikels gefallen hat, lesen Sie bitte auch den zweiten Teil, in dem ich detailliert auf die Integration meiner Regel in Android- (und Nicht-Android-) Projekte eingehe, wie man die bereits bestehenden Probleme in unserer Codebasis behebt und wie wir unsere Regeln unit-testen und debuggen können.