19 июл. 2021 г.

IntelliJ Kotlin plugin internals: On-the-flight diagnostics reporting

Background

Highlighting of a Kotlin file prior to Kotlin 1.5 (and Kotlin IntelliJ plugins based on Kotlin before 1.5) in oversimplified way was sequential:
  • run a compiler for a kotlin file
  • collects diagnostics (i.e. errors and warnings) for each PSI element
  • highlight them in a file

Goal

Provide feedback on compilation errors (or warning) as soon as possible.

Impl, details and trade-offs

The biggest concern in a provided highlighting process:

why we have to wait till the end of kotlin file analysis to show errors and warnings?

If you ever compile some file in a console using gcc or javac directly (w/o make or any other build managers), you definitely recall that compilers print diagnostic messages during compilation while entire compilation process for a single file could takes some seconds more.

That's the general idea behind the improvement covered by KT-37702: Code analysis speed: on-the-fly analysis diagnostics reporting

. There were some obstacles to implement this feature:
  • No callback from front-end of compiler to report diagnostics
  • Quick fixes. Many quick fix action use the type of expression in their descriptions, to provide the type we have to analyse expression that reported diagnostic during resolve.
    In short: it's a recursion.
  • Diagnostics could be suppressed by @Suppress("DIAGNTOSTIC_NAME").
    Same problem with recursion as we need resolve the type of @Suppress. Sure some heuristics could be used but it can't cover general case.

How these problems were addressed ?
  • To add a callback we have to step into front-end of compiler.
    • Compilers (in the classic theory of compilers) are splitted into two parts: front-end and back-end.
    • In short: back-end generates a target code (binary/byte/bit code) from a properly built and resolved AST.
    • Front-end has to perform type inference (e.g. to specify type for value q in expression val q = 41 + 1), check AST with ControlFlowAnalysis (e.g. there is a return point from non-void function), DataFlowAnalysis etc.
      In short: namely Front-End reports all diagnostics from a compiler and would not go futher to Back-end if there is something completely wrong like broken compilation.
  • Quick-fixes could be calculated at the end of entire resolve. I think it is a reasonable trade-off: user gets faster feedback as red or yellow underlined code while all extra functionality stays the same as it was before.
  • A bit ironic approach to address @Suppress problem: in this case diagnostics reported to the suppressed block are shown after the end of resolve. So, if you suppress anything for entire file - you are switching off on-the-flight diagnostic reporting.

Demo


On-the-flight diagnostics reporting in action.

Annotator vs HighlightVisitor

To highlight a file Annotator extension point was used:

public interface Annotator {
  void annotate(@NotNull PsiElement element, 
      @NotNull AnnotationHolder holder);
}

GeneralHighlightingPass invokes suitable Annotators for a file. As well it defines an order of PSI elements of a file to highlight for Annotators. E.g. it starts with actually visible PSI elements.

So, it does not work well with on-the-flight diagnostics reporting as compiler reports diagnostics in completely different order than GeneralHighlightingPass does.

I.e. Annotator is not suitable for our goal. But GeneralHighlightingPass works with another type of extension point HighlightVisitor:

public interface HighlightVisitor {

  boolean suitableForFile(@NotNull PsiFile file);

  void visit(@NotNull PsiElement element);

  boolean analyze(@NotNull PsiFile file,
     boolean updateWholeFile, 
     @NotNull HighlightInfoHolder holder,
     @NotNull Runnable action);

}

That suits for us quite well. Implementing HighlightVisitor#analyze we are free to highlight a file in any order:
highlight PSI elements those are reported on-the-fly and all left-overs like suppressed blocks and generate quick-fixes when resolve is ready.

Note: on-the-fly diagnostic reporting improves perceptual performance as it reduces the latency between the change in a file (like typing) and the 1st diagnostic to be highlighted.

Surprise side effect

Performance chart: total highlighting time of files from Kotlin project.
Noticeable change in the middle: on-the-fly diagnostic reporting is committed.

The biggest side effect happened due to revision of highlighting process of Kotlin files.

Results of resolve, diagnostics and so-called bindingContext (a magic map with lots of details on PSI like its inferred type etc), are intensively used by many things in a plugin: highlighting, autocompletion, inspection, intensions, quick fixes, gutter icons etc.
Kotlin maintains its resolution cache per file via PerFileAnalysisCache. I.e. to get results of the resolve for any element we need to get a reference to containingFile, or be precise containingKtFile.

So, interested highlighting process steps (before the change) are:
  • GeneralHighlightingPass runs an Annotator that visit every PSI element of a file
  • For each PSI element containingKtFile is invoked to get a resolve results from PerFileAnalysisCache.
    • To get access to containingKtFile we have to traverse over a tree from an PSI element to its root.

HighlightVisitor#analyze provides a reference to a file, therefore it is redundant to invoke containingKtFile on every PSI element.
The effect depends on a number of PSI elements and roughly depends on a file size. That's the reason why we don't see noticeable performance boost for a small files.

Summary

On-the-fly diagnostic reporting improves:
  • perceptual performance, reducing latency between a change in a file and actual highlighting of the 1st error or warning.
  • actual performance of total highlighting process due to elimination of a big number of containingKtFile invocations.

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