Writing IntelliJ plugins

By Piotr Czekaj

Piotr is a Senior Software Engineer at AUTO1 Group.

< Back to list
Coding

Background

During analysis of why some services take a long time to build, several problems have been found, mostly related to number of Spring contexts that were created during integration tests. More about those problems can be found in previous blog post. In order to decrease the likelihood that slow tests will appear at AUTO1, we implemented an IntelliJ plugin which warns a developer about possible problems when he writes code and provides quick fixes if they are applicable.

Plugin inspections

  • warns about usage of @DirtiesContext annotation which slows tests down because it forces creation of new Spring context
  • warns about specifying the same list of profiles but in different order in @ActiveProfiles (slows tests down because different Spring context would have to be created for each combination)
  • warns if number of distinct profile combinations used in @ActiveProfiles is greater than 3
  • warns if it finds @FeignClient (or DTO used by client) not documented with Swagger annotations
  • warns if remote HTTP call is used inside method annotated with @Transactional

During the plugin development, we realized that resources on writing IntelliJ plugins are scarce, and therefore decided to create a step by step tutorial on how to implement @DirtiesContext inspection.

Create plugin

In our tutorial we will be using the community edition of IntelliJ 2018.3.5. Final source code is available on github. There are several ways on how to create IntelliJ plugin. The recommended one is to use Intellij plugin for Gradle and that’s what we will use in this tutorial. Let’s start with creating new project using File > New > Project..., select Gradle and make sure that in Additional Libraries and Frameworks both Java and Intellij Platform Plugin are selected. If you cannot see “Gradle” or “Intellij Platform Plugin” then please make sure that both “Gradle” and “Plugin DevKit” IntelliJ plugins are installed. Continue next steps of the wizard using default values, use any groupId and artifactId. After some time IntelliJ will download dependencies and create an empty plugin.

Inspection

Our inspection should warn a developer each time there is a class annotated with org.springframework.test.annotation.DirtiesContext and provide quick fix which deletes @DirtiesContext. If Spring is not your thing then any other annotation can be used instead.

There are two kinds of inspections:

Local inspections are executed in the background when file is opened, in general they have access to currently open file. Local inspection cannot report problem for not currently processed file. Inspection class has to extend com.intellij.codeInspection.LocalInspectionTool or one of subclasses. Global inspections work only in batch mode when analysis is manually triggered via Analyze > Inspect Code and see complete graph of references between classes and can report problem for any file. Inspection class has to extend com.intellij.codeInspection.GlobalInspectionTool or one of subclasses.

We don’t need to access complete graph of references and we would like to give a hint that something is wrong as soon as possible so local inspection is a better choice for our use case. First problem that we encounter is to pick proper base class. One approach is to ask IntelliJ to show class Hierarchy of LocalInspectionTool and take a look what other inspections are extending. In this case AbstractBaseJavaLocalInspectionTool seems to be a good choice since most of Java inspections are based on it.

Create new class named DirtiesContextInspection with following content:

public class DirtiesContextFirstVersionInspection extends AbstractBaseJavaLocalInspectionTool {
    private static final String DIRTIES_CONTEXT = "org.springframework.test.annotation.DirtiesContext";

    private static final String DESCRIPTION_TEMPLATE = "Usage of @DirtiesContext makes integration tests slower";

    public String getDisplayName() {
        return "Usage of @DirtiesContext is not recommended";
    }

    public String getGroupDisplayName() {
        return GroupNames.PERFORMANCE_GROUP_NAME;
    }

    public String getShortName() {
        return "DirtiesContext";
    }

    public boolean isEnabledByDefault() {
        return true;
    }

    public PsiElementVisitor buildVisitor(@NotNull final ProblemsHolder holder, 
                                          boolean isOnTheFly) {
        return new JavaElementVisitor() {

            public void visitAnnotation(PsiAnnotation annotation) {
                super.visitAnnotation(annotation);

                String qualifiedName = annotation.getQualifiedName();

                if (DIRTIES_CONTEXT.equals(qualifiedName)) {
                    holder.registerProblem(annotation, DESCRIPTION_TEMPLATE);
                }
            }
        };
    }
}

The most interesting thing happens in a visitor which is notified each time a Java annotation is encountered in source code. By overriding different methods, plugin can process methods, classes, imports etc. In our case we check if fully qualified name of annotation matches our expectations and register a problem when that’s the case. Later on we will change this class to include also a quick fix. Creation of inspection class is not enough to make it available in IntelliJ - it’s also needed to register inspection in plugin.xml which among other things describes what plugin does, what other plugins are required and in which version of IntelliJ it can be used.

It’s possible to register each inspection one by one in plugin.xml under extensions tag and configure inspection using xml but we find it easier to register inspectionToolProvider and configure using it in Java code.

Create the following class to implement our inspection provider:

public class CodeInspectionProvider implements InspectionToolProvider {
    public Class[] getInspectionClasses() {
        return new Class[]{
                DirtiesContextInspection.class
        };
    }
}

And register it in plugin.xml:

   <extensions defaultExtensionNs="com.intellij">
        <inspectionToolProvider implementation="com.auto1.intellij.tutorial.CodeInspectionProvider"/>
    </extensions>

Now it’s time to check our inspection in action by running runIde gradle task. This could be done from terminal by executing ./gradlew runIde but it’s better to use Gradle view in IntelliJ since it allows to start IDE in debug mode (if needed just right click on task and select Debug, you can create run configuration to speed-up in the future). New IntelliJ instance should show up, if you already have sources of some project that uses Spring Boot then open it, otherwise you could create a new project. Annotate some class with @DirtiesContext annotation and observe inspection marker showing up. In case of problems logs can be found at build/idea-sandbox/system/log/.

Adding quick fix

Many inspections report not only problems but also provide automatic ways of fixing issues. In case of DirtiesContext, it’s not possible to provide safe way of removing it because DirtiesContext is often used when bean holds some state which makes tests dependent on each other. Usually we want to remove the annotation and then figure out the "dirty parts" and clean them up in an elegant way. Since the second part is hard to automate we will provide quick fix which only removes annotation.

Go back to DirtiesContextInspection class and add quick fix:

   private static class DeleteQuickFix implements LocalQuickFix {
        public String getName() {
            return "Removes usage of @DirtiesContext";
        }

        public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) {
            descriptor.getPsiElement().delete();
        }

        public String getFamilyName() {
            return getName();
        }
    }

Next step is to pass quick fix when problem is registered:

holder.registerProblem(annotation, DESCRIPTION_TEMPLATE, new DeleteQuickFix());

Executing runIde Gradle task should prove that quick fix works as expected.

Internationalization

Up to this point we have used hardcoded strings inside inspection name and description. To allow the plugin to be accessible in different languages we can externalize the messages. For this purpose we can use properties file. First create PluginBundle class:

public class PluginBundle {
    private static Reference<ResourceBundle> ourBundle;

    private static final String BUNDLE = "com.auto1.intellij.tutorial.PluginBundle";

    private PluginBundle() { }

    public static String message(@NotNull @PropertyKey(resourceBundle = BUNDLE) String key, 
                                 @NotNull Object... params) {
        return CommonBundle.message(getBundle(), key, params);
    }

    private static ResourceBundle getBundle() {
        ResourceBundle bundle = com.intellij.reference.SoftReference.dereference(ourBundle);
        if (bundle == null) {
            bundle = ResourceBundle.getBundle(BUNDLE);
            ourBundle = new SoftReference<>(bundle);
        }
        return bundle;
    }
}

Then create a new file matching BUNDLE constant, in my case it will be src/main/resources/com/auto1/intellij/tutorial/PluginBundle.properties with content:

inspection.dirties.context.display.name=Usage of @DirtiesContext is not recommended
inspection.dirties.context.problem.descriptor=Usage of @DirtiesContext makes integration tests slower
inspection.dirties.context.use.quickfix=Removes usage of @DirtiesContext

Register bundle in plugin.xml:

<resource-bundle>com.auto1.intellij.tutorial.PluginBundle</resource-bundle>

And finally use bundle in inspection, for example:

    public String getDisplayName() {
        return PluginBundle.message("inspection.dirties.context.display.name");
    }

Testing

We picked LightPlatformCodeInsightFixtureTestCase as base for our tests because it is recommended in the documentation. Unfortunately, testing appeared harder to set up properly than expected.

First problem was that our tests couldn’t see classes from JDK, which was fixed by specifying project descriptor to use internal JDK:

    protected LightProjectDescriptor getProjectDescriptor() {
        return new LightProjectDescriptor() {
            public Sdk getSdk() {
                return JavaAwareProjectJdkTableImpl.getInstanceEx().getInternalJdk();
            }
        };
    }

Second problem was that visitor received incorrect fully qualified class name of the annotation. Instead of org.springframework.test.annotation.DirtiesContext, it got DirtiesContext while it worked fine for real project in IDE. It turns out that such behaviour occurs when test project doesn’t see definition of some class. This is fixable by either hardcoding problematic class into test or by adding dependency as library to the project. Second approach avoids copying source code from other projects and seems to be more interesting so it will be presented here. In order to download dependency jar we use ShrinkWrap library:

private File[] getMavenArtifacts(String... mavenArtifacts) {
        File[] files = Maven.resolver()
                            .resolve(mavenArtifacts)
                            .withoutTransitivity()
                            .asFile();
        if (files.length == 0) {
            throw new IllegalArgumentException("Failed to resolve artifacts " + Arrays.toString(mavenArtifacts));
        }
        return files;
    }

When dependency is resolved and downloaded into local Maven cache it can be added as library with code listed below:

   protected void attachMavenLibrary(String mavenArtifact) {
        File[] jars = getMavenArtifacts(mavenArtifact);
        Arrays.stream(jars).forEach(jar -> {
            String name = jar.getName();
            PsiTestUtil.addLibrary(myFixture.getProjectDisposable(), myModule, name, jar.getParent(), name);
        });
    }

It’s important to use myFixture.getProjectDisposable() instead of myFixture.getProject() otherwise there is an exception during test shutdown:

com.intellij.openapi.util.TraceableDisposable$DisposalException: Virtual pointer 'jar:///somePath/.m2/repository/org/springframework/spring-test/5.1.5.RELEASE/spring-test-5.1.5.RELEASE.jar!/' hasn't been disposed

Next surprise is that by default test searches for test data in strange location inside IntelliJ home folder which can be fixed with overriding getTestDataPath. Since input files most likely won’t compile because of missing imports and possible usage of special markers like we don’t use src/test/java folder to store them:

    @Override
    protected String getTestDataPath() {
        return "src/test/testData";
    }

Inspection tests can be done by providing file to analyse and resulting file that should be created after given quick fix has been applied. Inspection tests use configureByFile to load input file, doHighlighting to trigger source code analysis, launchAction to execute quick fix and finally checkResultByFile to compare results against after file.

    myFixture.configureByFile(testName + ".java");
    myFixture.enableInspections(new DirtiesContextInspection());
    myFixture.doHighlighting();
    IntentionAction quickFixAction = myFixture.findSingleIntention(intentionHint);
    myFixture.launchAction(quickFixAction);
    myFixture.checkResultByFile(testName + ".after.java");

Complete source code can be found at github github

Next steps

If you would like to share your plugin with other developers, you can publish to JetBrains plugin repository as described in the documentation. Other simple option is to execute buildPlugin Gradle task which will create plugin zip file inside build/distributions and then install it via Install Plugin from disk... available inside Preferences > Plugins (in IntelliJ 2018.3 is available through "gears icon").

When working on your own ideas you might run into a situation when you don't know how to implement some functionality. In this situation you could try to find the answer using links provided in section below. What also worked for us was reading source code of inspections available as part of community edition of IntelliJ, often there is an existing inspection which does a similar thing to what you might want to do.

Links

Stories you might like:
By Artur Yolchyan

Usage cases for spring-batch.

By Mariusz Nowak

Improved Common Table Expressions in recent release of Postgres

By Mariusz Nowak

Learn how to query JSON data from Postgres 12 to improve Java application's performance.