Статьи

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

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

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
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. Просто, а?

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
  • классы в одном пакете, на которые можно ссылаться простым именем, тогда как для других мы должны использовать полное имя
  • классы в пакете по умолчанию нельзя ссылать вне пакета по умолчанию
  • поля и методы должны быть унаследованы (для методов мы должны учитывать перегрузку и переопределение, не путая их)
  • при сопоставлении методов мы должны учитывать совместимость параметров (совсем не просто)
  • и тд и тп

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

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

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

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

  1. сам узел AST: он представляет область, в которой нужно решить символ
  2. ноль: это представляет дополнительную контекстную информацию, в этом случае она не нужна
  3. имя должно быть решено: ну, это должно быть само за себя
1
2
3
4
5
6
(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) для концепции области видимости:

1
2
3
4
5
6
(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 ), который просто делегирует родителю. Для некоторых типов узлов мы предоставляем конкретную реализацию.

1
2
3
4
5
6
(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 🙂

1
2
3
4
5
6
(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 мы смотрим среди параметров

01
02
03
04
05
06
07
08
09
10
11
12
(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 мы смотрим среди полей класса или интерфейса (у классов могут быть статические поля).

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
(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  (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 мы ищем другие классы в том же пакете (оба используют их простые или квалифицированные имена), мы рассматриваем операторы импорта и ищем типы, объявленные в файле.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
(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)))))

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

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

Выводы

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

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

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