IntelliJ IDEA Plugin Development: What Have I Learned

Almost a year has passed since I began developing a Docker and Kubernetes security scanner deeply integrated into an IDE. I have collected many different thoughts on IntelliJ IDEA plugin development, from building inspections to debugging and maintaining them.

My article IntelliJ Plugin: Building Docker Security Analysis Tools was the first article in my blog and was more generic. In this article, I explain a bit more about the technical staff that I’m using in my work. I hope you will enjoy the article. Let’s dive into IntelliJ IDEA plugin development.

The Cloud Security plugin for IntelliJ IDEA contains a lot of bundled rules pointing to Docker and Kubernetes Security. To implement them, we need to use a mechanism called Inspections which provides a tool for static code analysis.

Quick Intro to Inspections

Inspections in the JetBrains IDEs are special classes that check your code for specific issues. Inspections are defined by creating a class that extends LocalInspectionTool or its inheritance. The class will be used to implement logic inspection, which is a common task in IntelliJ IDEA plugin development

Analysis performed with special Visitor classes. Those classes provide a way to analyze the whole file or specific parts of the analyzed file. These visitors’ classes are used for walking across the file, getting their PSI elements.

PSI is the Program Structure Interface, the syntactic and semantic code model that powers many of the platform’s features, especially code inspections. For example, different Docker commands represent classes like DockerFileFromCommand, DockerFileRunCommand, etc.

The PsiElement is a primitive in a PSI world. The PsiElement is an interface for any other PSI classes, such as PsiFile or PsiComment, from the PsiElementVisitor interface. There are a lot of different PSI elements that have already been implemented.

By receiving the needed PSI elements, we could analyze them and highlight those elements in the document.

In the Docker scanner plugin, I used PSI classes from the IntelliJ Docker plugin to analyze Docker files and commands. There are ways you could find what PSI elements you should analyze (from junior to senior approach):

  • Implement a visitor and log each PSI element with its text.
  • Stop in debug mode in the visitor and watch what PSI elements are visited.
  • Do the same, but with auto tests (I’ll explain them in more detail further)
  • Use the PsiViewer feature by enabling internal functions. There is a guide.

Note: To use PSI elements (their classes) from external plugins like Docker, you have to declare them as a dependency. Read more on the IntelliJ Platform SDK site.

IntelliJ IDEA Inspections Best Practices

Let’s build a synthetic inspection to show how it works. Let’s try to find sudo rm -rf / in RUN commands and highlight it.

const val MALICIOUS_RM_RF_COMMAND = "rm -rf /"
class SudoRmRfInspection : LocalInspectionTool() {
    override fun buildVisitor(
        holder: ProblemsHolder,
        isOnTheFly: Boolean,
    ): PsiElementVisitor =
        object : PsiElementVisitor() {
            override fun visitElement(element: PsiElement) {
                if (element is DockerFileRunCommand) {
                    if (element.text.contains(MALICIOUS_RM_RF_COMMAND)) {
                        holder.registerProblem(
                            element,
                            "Malicious rm -rf / command found",
                            ProblemHighlightType.ERROR,
                        )
                    }
                }
                super.visitElement(element)
            }
        }
}

There is no rocket science in this step. We did an Inspection and overrode the build visitor function with its visitor. Each time our visitor visits elements, we check whether it is a Docker run command or not. When we’ve got it, we simply look inside the element’s text, compare it with malicious text, and highlight the malicious text by registering the problem in the holder.

While straightforward, this approach has design drawbacks and can be improved according to best practices in IntelliJ IDEA plugin development for cleaner and more efficient inspections.

Do not use raw PsiElementVisitor

The PsiElementVisitor is too low-level. You’ve got to find the proper inheritance of the element visitor or implement it yourself. In the plugin, I’m using DockerFileVisitor from the Docker plugin, but at the start, I implemented my own and switched to a plugin one later.

The better way to find a proper visitor is by looking into sources. If you know what inspections are provided by a plugin, you could find the implementation in the sources. This is how I found that I could use.

const val MALICIOUS_RM_RF_COMMAND = "rm -rf /"
class SudoRmRfInspection : LocalInspectionTool() {
    override fun buildVisitor(
        holder: ProblemsHolder,
        isOnTheFly: Boolean,
    ): PsiElementVisitor =
        object : DockerFileVisitor() {
            override fun visitRunCommand(o: DockerFileRunCommand) {
                if (o.text.contains(MALICIOUS_RM_RF_COMMAND)) {
                    holder.registerProblem(
                        o,
                        "Malicious rm -rf / command found",
                        ProblemHighlightType.ERROR,
                    )
                }
            }
        }
}

Verifications should be flexible

Verification in code using contains made inspection less flexible. One space between, and an inspection couldn’t find this problem. When implementing verification, you should care about as many variants of the problem as you can imagine. Do not point only to one pattern. There could be a lot of them. The best you could do is collect these cases and cover them with automatic tests.

This practice also improves your knowledge about inspection, as you can learn a lot about the problem to provide quality inspection.

Take care of plugin internalization

You should care about internalizing your strings and not use them directly as written. You should create your own resource bundle and read strings from it; it will improve the internationalization process. There is a more detailed guide.

Proper internalization is essential for delivering polished, globally usable plugins in professional IntelliJ IDEA plugin development

// SecurityPluginBundle.kt
@NonNls
private const val BUNDLE = "messages.SecurityPluginBundle"
object SecurityPluginBundle : DynamicBundle(BUNDLE) {
    @JvmStatic
    fun message(
        @PropertyKey(resourceBundle = BUNDLE) key: String,
        vararg params: Any,
    ) = getMessage(key, *params)
    @Suppress("unused")
    @JvmStatic
    fun messagePointer(
        @PropertyKey(resourceBundle = BUNDLE) key: String,
        vararg params: Any,
    ) = getLazyMessage(key, *params)
}
// SecurityPluginBundle.properties
inspection.text=Malicious rm -rf / command found
// Usage
SecurityPluginBundle.message("inspection.text")

Take care of performance

Using getText from PSI elements could cost you performance traversing the whole tree under the given element, and concatenates strings, consider using textMatches() [proof]. Actually, it wouldn’t be dramatic in the case of using for small PSI elements. In the proof I provided, you could learn more about performance.

Hold in mind that your inspection code will execute almost every change in the code by default. There shouldn’t be slow operations. Imagine the user typing 100 symbols in a minute. Your inspection could be executed 100 times.

However, this tip applies not only to plugin development but also to common software development. Try to avoid premature optimization as it is a source of evil :)

// old
o.text.contains(MALICIOUS_RM_RF_COMMAND)
// new
o.textMatches(MALICIOUS_RM_RF_COMMAND)

Extend an IntelliJ IDEA plugin with extension points

Let’s stop roasting example inspections and move forward. The next cool thing that I implemented in the plugin is using extension points for faster inspection development, but let’s try to find out what it is and what problem it could solve.

Imagine you need to implement one more inspection, I would say it will be using sudo in run commands. From this point, you have two ways, and one with:

Implement one more inspection. Actually, it would be the easiest path, but each time you create an inspection in the plugin, you have to take care of at least the following things:

  • Register it in the plugin.xml (it’s pretty easy)
  • Write an HTML file with a description (sooo boring!)

Implement additional verification in the previous inspection and rename the inspection class to be more generic. By using this approach, you slightly update the HTML file, and it works. But what if you need to implement 20 inspections? It would be a mess in the class.

If you don’t want to register inspections each time, you could use the latest approach by using extension points. What are the extension points? Extension points provide a way for other plugins to extend your plugin functionality, and you could use them in your own way.

There are two types of extension points: interface and bean extensions. We need to focus on the first one. In the plugin, you could declare an interface and register it in plugin.xml, and implement its class. With this approach, you could dynamically load all the implementations of the interface.

Here’s a small example of how you might declare an extension point interface and its implementations to speed up inspection development in intellij idea plugin development:

const val MALICIOUS_RM_RF_COMMAND = "rm -rf /"
// Declaring interface of extension point
interface DockerfileRunAnalyzer {
    fun handle(
        runCommand: String,
        psiElement: PsiElement,
        holder: ProblemsHolder,
    )
}
// Make an analyzer class and extend it with our extension point
class RmRfAnalyzer: DockerfileRunAnalyzer{
    override fun handle(
        runCommand: String,
        psiElement: PsiElement,
        holder: ProblemsHolder,
    ) {
        if (runCommand.contains(MALICIOUS_RM_RF_COMMAND)) {
            holder.registerProblem(
                psiElement,
                SecurityPluginBundle.message("inspection.text"),
                ProblemHighlightType.ERROR
            )
        }
    }
}
// Read all extension points and run them
class RunCommandInspection : LocalInspectionTool() {
    override fun buildVisitor(
        holder: ProblemsHolder,
        isOnTheFly: Boolean,
    ): PsiElementVisitor =
        object : DockerFileVisitor() {
            override fun visitRunCommand(o: DockerFileRunCommand) {
                val extensionPointName =
                    ExtensionPointName.create<DockerfileRunAnalyzer>("dev.protsenko.security-linter.dockerFileRunAnalyzer")
                
                val runCommand = o.text
                
                for (extension in extensionPointName.extensions) {
                    extension.handle(runCommand, o, holder)
                }
            }
        }
}

What was changed? Now, to implement inspections that analyze run commands, you only have to implement the analyzer class and register it in the extension points. With this solution, you won’t have a mess in one class if you need to implement many inspections.

What additional pros of this approach? With 20 dedicated inspections, you will spawn 20 different inspections instead of one. The examples are less complex than the real ones. With real ones, you have to detect complex cases by covering more IntelliJ platform methods.

There are some cons to using it. It’s more about user experience. If you have only one inspection for 20 different rules, the user could disable all of them, not just what they need. Implementing suppression as a code feature in your inspection would also be a problem. Suppressing one problem will extend to every problem in this inspection. In this case, you have to provide a more complex UI for your plugin instead of relying on platform ones.

The last problems were acceptable to me because they improved performance and skipped the routine building of HTML inspection description files.

The IntelliJ IDEA testing approach

Tests are the foundation for your plugin. You must write them. Basic inspections have many test cases, and testing them manually is painful. What if you need to test 40 test cases after refactoring? It’s shooting yourself in the foot! Stop this violence, keep calm, read testing documentation, and start writing tests.

I wrote many test cases and have 85% test coverage on the code lines to ensure everything worked correctly. This significantly increased my development speed and protected me from technical debt.

However, tests did not catch some issues, such as compatibility problems with different IDE versions. Initially, I adapted my plugin for version 233 and newer. I practiced TDD, developed inspections based on test feedback, and performed manual testing on the targeted IDE version (233). However, I was quite surprised when I installed the plugin on the latest IDE version and saw that my highlighting blinked or disappeared occasionally.

The good test shouldn’t take a lot of your time

There are many ways to develop tests. I highly recommend learning carefully what the IntelliJ platform offers for testing because it has an excellent framework for this. As the IntelliJ plugin developer, I highly recommend writing tests that cover inspections and, if possible, writing unit tests for helper methods and other code.

In the code base I’m using BasePlatformTestCase , and extend my classes from it. I’m using an approach with my abstract class InspectionBaseTest, which provides everything I need to test inspections.

Let’s see the result of my work:

class DFS030ApkNoCacheTest(
    override val ruleFolderName: String = "DFS030",
    override val customFiles: Set<String> = emptySet(),
    override val targetInspection: LocalInspectionTool = DockerfileRunInspection(),
) : DockerHighlightingBaseTest()

As you can see, I only set a folder name with test data and provided an inspection for testing. Each test verifies valid and invalid files. If I want to cover more cases, I specify test files in the custom files field.

That’s all! You don’t have to spend time writing a lot of code to test something. Just prepare your base depending on your logic and inspections, and reuse it. Here is my suggestion – spend time on your testing framework on your needs.

Act as a user to test a plugin

Automated tests are good, they are very helpful, and save you nerves during refactorings. However, even if you are close to 100% testing coverage, you have to manually test how your inspections or functionality work.

This shouldn’t be a surprise, but tests covered only a small percentage of things, depending on your cases. During manual testing, ask yourself:

  • Is it easy to set up and use your plugin?
  • Is highlighting consistent? It could blink, inspection messages could be in different formats, delays due to poor performance could occur, etc.
  • Are there any other problems during using your plugin?
  • Is it suitable to record a video demo of your product? This is a really good question because I tried to do this, but found different issues with it.

Just be a user of what you’re developing. Manual testing should be performed every time you develop and release something. Spend 10 minutes testing your changes to make sure everything is all right.

Managing releases for the IntelliJ IDEA plugin

I’m trying to be consistent in the plugin updates. Every one or two weeks, I publish updates with new inspections or bug fixes. To be consistent in the updates, I have several steps that I should perform before uploading the plugin.

  • Plan and develop inspections.
  • Cover it with test automation and perform a manual one.
  • Update the version and its changelog.
  • Commit and push changes to GitHub.
  • With GitHub actions, I do one more automated test run to double-check.
  • Write a documentation page about new inspections in my blog.
  • …and finally build the plugin and upload it.

Planning and Development

I have nothing special. I did research on possible inspections and just took them one by one. For Docker inspections, I analyzed Trivy and Hadolint rulesets, and for Kubernetes, I used Pod Security Standards with a look at Kubescape rego rules.

I used GitHub issues to store lists of rules, but lately, I have declined this solution as no one wants to contribute or create issues. Now, I hold the list of planned features in Apple Notes and choose what I want to do from this list.

The Plugin version and its changelog

Regarding the version and changelog, if you’ve used the plugin template from scratch like me, you’re probably familiar with how it works, but if not, I’ll tell you.

The plugin template comes with preconfigured Gradle files and properties. If you’re updating the plugin version, you have to update the changelog file in the proper format. Changes in the changelog file automatically come to the release notes on the marketplace for your plugin. If you haven’t done this, the build will fail. Proper versioning and changelog upkeep are essential best practices in IntelliJ IDEA plugin development to ensure smooth releases and marketplace integration.

A bit about GitHub actions

Regarding GitHub actions, if you can use it, use it for building and running your tests. Double-checking wouldn’t be redundant, as you need to deliver good releases.

Write documentation and content

Publishing updates involves many things, but there is more. With new inspections, I should write documentation on the detected problem. Each highlighted inspection provides a link to its documentation to give a user comprehensive information about the problem.

From the start, I created a template for my article with a strong structure, but step by step, I’m trying to find a more comfortable format for users. These pages are read not only by plugin users but also by users from search engines like Google. So, recently, I started writing this documentation for different user groups and optimized it for search engines.

Next Steps in IntelliJ IDEA Plugin Development

Developing plugins for JetBrains products is a wide area with many nuances. However, the journey into IntelliJ plugin development is very exciting and fun. Developing the plugin as a side project on weekends was a good idea, as I wanted to build something meaningful to me, share my knowledge about development, and strengthen my expertise in Docker and Kubernetes Security and Maintainability problems.

If you want more, I highly recommend reading my IntelliJ Plugin: Building Docker Security Analysis Tools. To learn about new articles, follow me on LinkedIn.

Avatar photo
Dmitry Protsenko

Senior Software Engineer
Specialized on Java / Kotlin and CyberSecurity
Author of this blog

Articles: 34