21 апр. 2021 г.

IntelliJ Kotlin plugin internals: Run test gutter icon - The story of one small optimization

Disclaimer: Hope I will fill the gaps later on in next posts.

The problem

KTIJ-408: Show "run test" gutter icon before semantic analysis

Goal

to show run test gutter icons as fast as it is possible and don't wait till entire analysis is done.
Run test icons are on the left side

Background

For Kotlin language (as for any other programming language) we have our own, Kotlin specific, AST (Abstract Syntax Tree) representation. IntelliJ platfrom uses PSI (Program Structure Interface) , for the sake of simplicity it could be considered as AST on steroids. Actually it works in AST-mode when you open a file in an editor.

Kotlin syntax significantly differs from Java syntax, so it should not surprise that there is a Kotlin PSI instead of Java PSI:
fun foo() = "foo"
E.g. Kotlin syntax allows to skip a return type when it could be inferred from an expression.

For Kotlin there is a KtFile (that is surprise, descendant of PsiFile) but KtClass and KtNamedFunction are not descendants of PsiClass and PsiMethod correspondingly.

Light Classes

Kotlin, to look like Java in IntelliJ, uses a mimic technique known as Light Classes:
Basic idea of LightClasses is to wrap Kotlin PSI into Java PSI. You can turn a KtClass into a PsiClass with PsiMethods.

The hidden elephant is in that some types have to be inferred from a code.

Like in example above: the return type of foo() function is String. To get it we have to perform an analysis (resolve types or just simple run resolve).

I.e. to build Light Classes we have to have fully resolved code.

Actually, IntelliJ Kotlin plugin has a Top-Down single threaded resolution. It is quite expensive and usually it is performed during syntax highlighting with extensions used by GeneralHighlightingPass (Note: gonna describe highlighting process and highlight passes linearization more detailed).

In short: GeneralHighlightingPass runs resolve (that is a single threaded). Anyone, who wants to get resolve, has to wait. When resolve is done, result of resolve is cached and reused by all other requesters like Light Class builder.

To rephrase:
you cannot get LightClasses earlier than resolve i.e. syntax highlighting.
Note: to be honest you get a lazy LightClass but in a general case to iterate over all methods or super types it relies on resolve.

Run test gutter icons

To show run test gutter icons (e.g. for JUnit4) IntelliJ Java plugin uses JUnitUtil#isJUnit4TestClass(PsiClass,boolean) (there are similar methods for JUnit3, JUnit5, TestNG etc).

Instead of reinventing the wheel IntelliJ Kotlin plugin utilize a huge and rich legacy of its Java brother.

Now, when you know some background of IntelliJ Kotlin plugin, you know why do you have quite noticeable delay between opening a file and seeing test run gutter icons: to use JUnitUtil#isJUnit4TestClass(PsiClass,boolean) we have to convert Kotlin PSI into Java PSI using LightClasses that is built on top of resolve.

Let's have a look on how this method is implemented:
for (final PsiMethod method : psiClass.getAllMethods()) {
  ProgressManager.checkCanceled();
  if (TestUtils.isExplicitlyJUnit4TestAnnotated(method) || 
    JUnitRecognizer.willBeAnnotatedAfterCompilation(method))
      return true;
}

I.e. a class is considered as a test class if there is at least one method annotated with @org.junit.Test.

But, It is possible to figure out from Kotlin PSI if a class has at least one method function (in terms of Kotlin) annotated with @org.junit.Test.
Well, there is a small problem: usually people use @Test annotation rather fully qualified name as @org.junit.Test or even Kotlin alias @kotlin.test.Test that points to the same.
It is possible to resolve fqName looking into a import list of a file. Again it is just a PSI operation.

The same approach could be applied for test methods functions: if it has @org.junit.Test, it has public visibility etc.

Sounds great, but there are some pitfalls: e.g. what if class extends some other class that could have test functions ?

Indeed, in this case this approach does not work and we have to fallback to the LightClasses solution.

Shall we fallback in all cases if we can't say is it a test class (or test function) or not ?

No. There are several cases when we know that class could not be a test class (besides the case when we don't have a junit4 dependency for the module):
  • Class does not have any super classes (except Any i.e. Object)
  • Data class
  • Private class
  • class without any public functions
Run test gutter icon on Kotlin PSI in action:

before change is on the left - after is on the right

Комментариев нет: