Статьи

Как собрать решатель символов для Java в Clojure

На прошлой неделе мы увидели,  как создавать модели кода Java из исходных и JAR-файлов . Теперь мы хотим иметь возможность решать символы: на практике мы хотим связать ссылки на символы в нашем исходном коде с объявлениями символов либо в исходных файлах Java, либо в jar-файлах.

Ссылки на символы и объявления символов

Прежде всего мы должны учитывать, что один и тот же символ может указывать на разные объявления в разных контекстах. Рассмотрим этот абсурдный кусок кода:

package me.tomassetti;

public class Example {

    private int a;
    
    void foo(long a){
        a += 1;
        for (int i=0; i<10; i++){
            new Object(){
                char a;
                
                void bar(long l){
                    long a = 0;
                    a += 2;
                }
            }.bar(a);
            
        }
    }
    
}

Он содержит несколько объявлений символа  a :

  • в строке 5 оно объявлено как поле типа  int
  • в строке 7 он объявлен как параметр типа  long
  • в строке 11 он объявлен как поле анонимного класса и имеет тип  char
  • в строке 14 он объявлен как локальная переменная типа  long

У нас также есть две ссылки на символ  а :

  • в строке 8, когда мы увеличиваем   на 1
  • в строке 15, когда мы увеличиваем   на 2

Теперь, чтобы решить символы, мы можем выяснить, какие ссылки на символы ссылаются на какие объявления символов. Так что мы можем делать такие вещи, как понимание типа символа, и какие операции могут быть выполнены над символом.

Общие правила

Принцип определения символа прост (но реализация может быть… хитрой):  учитывая ссылку на символ, мы ищем ближайшую соответствующую декларацию. Пока мы не найдем его, мы продолжаем двигаться дальше от эталона, поднимаясь в AST.

В нашем примере мы сопоставили бы ссылку в строке 15 с объявлением в строке 14. Мы также сопоставили бы ссылку в строке 8 с объявлением в строке 7. Просто, а?

Рассмотрим другой пример:

package me.tomassetti;

class A {
    public void foo(){
        System.out.println("I am the external A");
    }    
}

public class Example {
    
    A a;

    class A {
        public void foo(){
            System.out.println("I am the internal A");
        }
    }

    void foo(A a){
        final int A = 10;
        new A().foo();
    }

}

Теперь у нас есть различные определения  A , в некоторых случаях как Класс (строки 3 и 13), в других как переменные (строка 20). Ссылка в строке 21 ( new A (). Foo (); ) соответствует объявлению в строке 13, а не объявлению в строке 20, поскольку мы можем использовать только объявления типов для сопоставления ссылок на символы внутри оператора создания нового объекта. Вещи начинают становиться не так просто …

Есть и другие вещи, которые следует учитывать:

  • операторы import: импорт  com.foo.A разрешает  ссылки на A с объявлениями класса  A в пакете  com.foo
  • классы в одном пакете, на которые можно ссылаться простым именем, тогда как для других мы должны использовать полное имя
  • классы в пакете по умолчанию нельзя ссылать вне пакета по умолчанию
  • поля и методы должны быть унаследованы (для методов мы должны учитывать перегрузку и переопределение, не путая их)
  • при сопоставлении методов мы должны учитывать совместимость параметров (совсем не просто)
  • и тд и тп

Хотя принцип прост, существует множество правил, исключений и вещей, которые необходимо учитывать для правильного разрешения символов. Приятно то, что мы можем легко начать, а затем усовершенствовать решение.

Создай наш решатель символов

Одно замечание: в этом посте я объясню подход я в настоящее время используется , чтобы построить свой символ решатель для  effectivejava . Я не говорю, что это идеальное решение, и я уверен, что со временем улучшу это решение. Однако этот подход, по-видимому, охватывает большое количество случаев, и я думаю, что это не ужасное решение.

Простая ссылка на имя (что бы оно ни указывало на переменную, параметр, поле и т. Д.) Представляется в виде  NameExpr  в AST, создаваемом  JavaParser . Мы начнем с создания функции, которая принимает   узел NameExpr и возвращает соответствующее объявление, если таковое может быть найдено. Функция  solveNameExpr в  основном просто вызывает функцию  solveSymbol,  передавая три параметра:

  1. сам узел AST: он представляет  область,  в которой нужно решить символ
  2. ноль: это представляет дополнительную контекстную информацию, в этом случае она не нужна
  3. имя должно быть решено: ну, это должно быть само за себя
(defn solveNameExpr
  "given an instance of com.github.javaparser.ast.expr.NameExpr returns the declaration it refers to,
   if it can be found, nil otherwise"
  [nameExpr]
  (let [name (.getName nameExpr)]
    (solveSymbol nameExpr nil name)))

Мы начнем с объявления протокола (который чем-то похож на интерфейс Java) для концепции области видимости:

(defprotocol Scope
  ; for example in a BlockStmt containing statements [a b c d e], when solving symbols in the context of c
  ; it will contains only statements preceeding it [a b]
  (solveSymbol [this context nameToSolve])
  ; solveClass solve on a subset of the elements of solveSymbol
  (solveClass [this context nameToSolve]))

Основная идея заключается в том, что в каждой области видимости мы пытаемся найти объявления, соответствующие символу, который нужно решить, если мы не найдем их, мы делегируем родительскую область видимости. Мы указываем реализацию по умолчанию для узла AST ( com.github.javaparser.ast.Node ), который просто делегирует родителю. Для некоторых типов узлов мы предоставляем конкретную реализацию.

(extend-protocol Scope
  com.github.javaparser.ast.Node
  (solveSymbol [this context nameToSolve]
    (solveSymbol (.getParentNode this) this nameToSolve))
  (solveClass [this context nameToSolve]
    (solveClass (.getParentNode this) this nameToSolve)))

Для  BlockStmt  мы смотрим среди инструкций, предшествующих той, которая проверялась для объявлений переменных. Текущий оператор проверки передается как  контекст . Если вы заинтересованы в функциях, используемых этим, просто посмотрите код эффективной Java: он находится на GitHub :)

(extend-protocol Scope
  BlockStmt
  (solveSymbol [this context nameToSolve]
    (let [elementsToConsider (if (nil? context) (.getStmts this) (preceedingChildren (.getStmts this) context))
          decls (map (partial declare-symbol? nameToSolve) elementsToConsider)]
      (or (first decls) (solveSymbol (.getParentNode this) this nameToSolve)))))

Для  MethodDeclaration  мы смотрим среди параметров

(defn solve-among-parameters [method nameToSolve]
  (let [parameters (.getParameters method)
        matchingParameters (filter (fn [p] (= nameToSolve (.getName (.getId p)))) parameters)]
    (first matchingParameters)))

(extend-protocol Scope
  com.github.javaparser.ast.body.MethodDeclaration
  (solveSymbol [this context nameToSolve]
    (or (solve-among-parameters this nameToSolve)
      (solveSymbol (.getParentNode this) nil nameToSolve)))
  (solveClass [this context nameToSolve]
    (solveClass (.getParentNode this) nil nameToSolve)))

Для  ClassOrInterfaceDeclaration  мы смотрим среди полей класса или интерфейса (у классов могут быть статические поля).

(defn solveAmongVariableDeclarator
  [nameToSolve variableDeclarator]
  (let [id (.getId variableDeclarator)]
    (when (= nameToSolve (.getName id))
      id)))

(defn- solveAmongFieldDeclaration
  "Consider one single com.github.javaparser.ast.body.FieldDeclaration, which corresponds to possibly multiple fields"
  [fieldDeclaration nameToSolve]
  (let [variables (.getVariables fieldDeclaration)
        solvedSymbols (map (partial solveAmongVariableDeclarator nameToSolve) variables)
        solvedSymbols' (remove nil? solvedSymbols)]
    (first solvedSymbols')))

(defn- solveAmongDeclaredFields [this nameToSolve]
  (let [members (.getMembers this)
        declaredFields (filter (partial instance? com.github.javaparser.ast.body.FieldDeclaration) members)
        solvedSymbols (map (fn [c] (solveAmongFieldDeclaration c nameToSolve)) declaredFields)
        solvedSymbols' (remove nil? solvedSymbols)]
    (first solvedSymbols')))

(extend-protocol Scope
  com.github.javaparser.ast.body.ClassOrInterfaceDeclaration
  (solveSymbol [this context nameToSolve]
    (let [amongDeclaredFields (solveAmongDeclaredFields this nameToSolve)]
      (if (and (nil? amongDeclaredFields) (not (.isInterface this)) (not (empty? (.getExtends this))))
        (let [superclass (first (.getExtends this))
              superclassName (.getName superclass)
              superclassDecl (solveClass this this superclassName)]
          (if (nil? superclassDecl)
            (throw (RuntimeException. (str "Superclass not solved: " superclassName)))
            (let [inheritedFields (allFields superclassDecl)
                  solvedSymbols'' (filter (fn [f] (= nameToSolve (fieldName f))) inheritedFields)]
              (first solvedSymbols''))))
        amongDeclaredFields)))
  (solveClass [this context nameToSolve]
    (solveClass (.getParentNode this) nil nameToSolve)))

Для  CompilationUnit  мы ищем другие классы в том же пакете (оба используют их простые или квалифицированные имена), мы рассматриваем операторы импорта и ищем типы, объявленные в файле.

(defn qNameToSimpleName [qualifiedName]
  (last (clojure.string/split qualifiedName #"\.")))

(defn importQName [importDecl]
  (str (.getName importDecl)))

(defn isImportMatchingSimpleName? [simpleName importDecl]
  (= simpleName (qNameToSimpleName (importQName importDecl))))

(defn solveImportedClass 
  "Try to solve the classname by looking among the imported classes"
  [cu nameToSolve]
  (let [imports (.getImports cu)
        relevantImports (filter (partial isImportMatchingSimpleName? nameToSolve) imports)
        importNames (map (fn [i] (.getName (.getName i))) imports)
        correspondingClasses (map typeSolver importNames)]
    (first correspondingClasses)))

(extend-protocol Scope
  com.github.javaparser.ast.CompilationUnit
  (solveClass [this context nameToSolve]
    (let [typesInCu (topLevelTypes this)
          ; match types in cu using their simple name
          compatibleTypes (filter (fn [t] (= nameToSolve (getName t))) typesInCu)
          ; match types in cu using their qualified name
          compatibleTypes' (filter (fn [t] (= nameToSolve (getQName t))) typesInCu)]
      (or (first compatibleTypes)
          (first compatibleTypes')
          (solveImportedClass this nameToSolve)
          (solveClassInPackage (getClassPackage this) nameToSolve)
          ; we solve in nil context: it means look for absolute names
          (solveClass nil nil nameToSolve)))))

Если нам не удается решить символ в области действия модуля компиляции,  родительский элемент  для делегирования отсутствует, поэтому мы используем  нулевую  область действия, которая представляет отсутствие области действия. В этом случае могут быть решены только абсолютные имена, такие как канонические имена классов. Мы делаем это с  помощью  функции typeSolverTypeSolver  необходимо знать , какой путь к классам , чтобы использовать и в основном смотрят на классы из исходных файлов каталогов и JAR. Также в этом случае не стесняйтесь копаться в коде Java.

(extend-protocol Scope
  nil
  (solveClass [this context nameToSolve] (typeSolver nameToSolve)))

Выводы

Я думаю, что Clojure отлично подходит для постройки решений постепенно: мы можем реализовывать различные методы протоколов по мере продвижения вперед. Создавайте простые тесты и улучшайте их по одному.

Мы создали это простое решение итеративно, и до сих пор оно отлично работает для наших целей. Мы будем продолжать писать больше тестовых случаев, и нам, возможно, придется реорганизовать одну или две вещи, но я думаю, что мы движемся в правильном общем направлении.

В будущем может быть полезно извлечь этот преобразователь символов в отдельную библиотеку, которая будет использоваться с JavaParser для выполнения статического анализа и рефакторинга.