Runtime Class Loading to Support a Changing API

by Matt Cholick

I maintain an IntelliJ plugin that improves the experience of writing Spock specifications. A challenge of this project is supporting multiple & incompatible IntelliJ API versions in a single codebase. The solution is simple in retrospect (it's an example of the adapter pattern in the wild), but it originally took a bit of thought and example hunting. I was in the code again today to fix support for a new version, and I decided to document how I originally solved the problem.

The fundamental issue is that my compiled code could be loaded in a JVM runtime environment with any of several different API versions present. My solution was to break up the project into four parts:

  • A main project that doesn't depend on any varying API calls and is therefore compatible across all API versions. The main project also has code that loads the appropriate adapter implementation based on the runtime environment it finds itself in. In this case, I'm able to take advantage of the IntelliJ PicoContainer for service lookup, but the reflection API or dependency injection also have what's needed.
  • A set of abstract adapters that provide an API for the main project to use. This project also doesn't depend on any code that varies across API versions.
  • Sets of classes that implement the abstract adapters for each supported API versions. Each set of adapters wraps changing API calls and is compiled against a specific API version.

The simplest case to deal with is a refactor where something in the API moves. This is also what actually broke this last version. My main code needs the Groovy instance of com.intellij.lang.Language. This instance moved in IntelliJ 14.

This code was constant until 14, so in this case I'm adding a new adapter. In the adapter module, I have an abstract class LanguageLookup.java:

package com.cholick.idea.spock;

import com.intellij.lang.Language;
import com.intellij.openapi.components.ServiceManager;

public abstract class LanguageLookup {
    public static LanguageLookup getInstance() {
        return ServiceManager.getService(LanguageLookup.class);
    }
    public abstract Language groovy();
}

The lowest IntelliJ API version that I support is 11. Looking up the Groovy language instance is constant across 11-13, so the first concrete adapter lives in the module compiled against the IntelliJ 11 API. LanguageLookup11.java:

package com.cholick.idea.spock;

import com.intellij.lang.Language;
import org.jetbrains.plugins.groovy.GroovyFileType;

public class LanguageLookup11 extends LanguageLookup {
    public Language groovy() {
        return GroovyFileType.GROOVY_LANGUAGE;
    }
}

The newest API introduced the breaking change, so a second concrete adapter lives in a module compiled against version 14 of their API. LanguageLookup14.java

package com.cholick.idea.spock;

import com.intellij.lang.Language;
import org.jetbrains.plugins.groovy.GroovyLanguage;

public class LanguageLookup14 extends LanguageLookup {
    public Language groovy() {
        return GroovyLanguage.INSTANCE;
    }
}

Finally, the main project has a class SpockPluginLoader.java that registers the proper adapter class based on the runtime API that's loaded (I omitted several methods not specifically relevant to the example):

package com.cholick.idea.spock.adapter;

import com.cholick.idea.spock.LanguageLookup;
import com.cholick.idea.spock.LanguageLookup11;
import com.cholick.idea.spock.LanguageLookup14;
import com.intellij.openapi.application.ApplicationInfo;
import com.intellij.openapi.components.ApplicationComponent;
import com.intellij.openapi.components.impl.ComponentManagerImpl;
import org.jetbrains.annotations.NotNull;
import org.picocontainer.MutablePicoContainer;

public class SpockPluginLoader implements ApplicationComponent {
    private ComponentManagerImpl componentManager;

    SpockPluginLoader(@NotNull ComponentManagerImpl componentManager) {
        this.componentManager = componentManager;
    }

    @Override
    public void initComponent() {
        MutablePicoContainer picoContainer = componentManager.getPicoContainer();
        registerLanguageLookup(picoContainer);
    }

    private void registerLanguageLookup(MutablePicoContainer picoContainer) {
        if(isAtLeast14()) {
            picoContainer.registerComponentInstance(LanguageLookup.class.getName(), new LanguageLookup14());
        } else {
            picoContainer.registerComponentInstance(LanguageLookup.class.getName(), new LanguageLookup11());
        }
    }

    private IntelliJVersion getVersion() {
        int version = ApplicationInfo.getInstance().getBuild().getBaselineVersion();
        if (version >= 138) {
            return IntelliJVersion.V14;
        } else if (version >= 130) {
            return IntelliJVersion.V13;
        } else if (version >= 120) {
            return IntelliJVersion.V12;
        }
        return IntelliJVersion.V11;
    }

    private boolean isAtLeast14() {
        return getVersion().compareTo(IntelliJVersion.V14) >= 0;
    }

    enum IntelliJVersion {
        V11, V12, V13, V14
    }
}

Finally, in code where I need the Groovy com.intellij.lang.Language, I get a hold of the LanguageLookup service and call its groovy method

...
Language groovy = LanguageLookup.getInstance().groovy();
if (PsiUtilBase.getLanguageAtOffset(file, offset).isKindOf(groovy)) {
...

This solution allows the same compiled plugin JAR to support IntelliJ's varying API across versions 11-14. I imagine that Android developers commonly implement solutions like this, but it's something I'd never had to write as a web application developer.