Developing an IntelliJ / WebStorm JavaScript plugin

Andres Dominguez
7 min readDec 3, 2016

--

I am writing this post because I want to encourage developers to write more JavaScript plugins for the IDEA platform. At the time of this writing there are 25 JavaScript plugins available for download. Three of them are mine.

In this post I will describe how to write a JavaScript plugin using IntelliJ. The plugin will do something very simple: it will inject Angular 1.x injectables in a Jasmine test given the current selection.

When I write a unit test sometimes I need to spy on a service or some other type of injectable. For example, imagine that you have an Angular controller called UserCtrl that uses a userService to manage users. I want to test that the userService.update() function gets called when I call UserCtrl.save().

In the suite I need to inject the userService and assign it to a variable so I can use it inside a test. The plugin will automate the injection task by:

  1. Adding a variable at the top-level describe.
  2. Adding a parameter inside the inject() function.
  3. Assigning the injectable to the variable.

The following GIF shows the implemented plugin. You start by selecting a string with the name of the service. When you run the plugin it will generate the missing code.

Plugin injecting Angular injectable

Creating a new project and an action

I will take you through the basic steps to create a plugin. You can find the official documentation here: Creating Your First Plugin.

You will need to download IntelliJ’s ultimate edition. I am using IntelliJ IDEA 2016.3. Once you have it installed you need to create a new project of type IntelliJ Platform Plugin.

Create a plugin project

Then create an Action. This action will manipulate the JavaScript file. Right click on the src/ directory and select New > Action. Enter the required information and, optionally, select a shortcut.

Create an action for you plugin

IntelliJ will create an action class and it will add the mapping for this action to the plugin manifest file plugin.xml.

Enabling your plugin for all platforms and adding JavaScript support

The Idea platform has several products, such as IntelliJ, PHPStorm, PyCharm, WebStorm, and so on. The manifest file specifies which products are compatible with your plugin (Learn more about compatibility). I want to enable this plugin for all the products. I also want to use the PSI library for JavaScript. Go to the resources/META-INF/plugin.xml file and make the following changes:

<idea-plugin version="2">

...

<!-- Enable plugin for all products -->
<depends>com.intellij.modules.lang</depends>
<!-- This enables the PSI capability for JavaScript -->
<depends>JavaScript</depends>

...

</idea-plugin>

Now you need to include the JavaScript PSI library so you can parse and manipulate JS code.

JavaScript PSI

The Program Structure Interface (PSI) is an IntelliJ AST API to read and manipulate code for different programming languages. IntelliJ provides a JavaScript PSI implementation. You need to add the JavaScript language jar files to your SDK classpath. The jar files are located under IntelliJ’s home directory <IJ_HOME>/plugins/JavaScriptLanguage/lib. If you are using a Mac the path is: /Applications/IntelliJ IDEA.app/Contents/…. Make sure you include all the jars under the JavaScriptLanguage/lib/ directory.

IntelliJ’s JavaScript PSI jar files

Understanding the JavaScript PSI

To explore the PSI structure of a JavaScript file you need to install the PsiViewer plugin. The plugin helps you understand the node types and the relationship between nodes in the tree. Each node is a subclass of PsiElement. Elements have a parent, siblings, and children. The PsiViewer information will be very useful when you start writing the plugin code. Let’s start with an empty Jasmine test suite. Here I am using the PsiViewer to examine the tree.

PSIViewer

The Jasmine test has a top-level suite declared using the describe() global function.

describe('My suite', () => {
... // beforeEach, tests, etc.
});

The PSI representation of this file looks like the following tree. I omitted some nodes for simplification purposes.

PsiFile: test.js
|
+- JSExpressionStatement: describe('', ())
|
+- JSCallExpression: describe call
|
+- JSArgumentList: describe args ['My suite', () => {}]
|
+- JSLiteralExpression: 'My suite'
+- JSFunctionExpression: () => {}
|
+- JSArgumentList: [] since there are no arguments
+- JSBlockStatement: body of () => {}

The root of the tree is the PsiFile. The psi file represents the current source code file. The file has one child for the JavaScript expression containing the describe. The rest of the children represent the elements in the describe: an argument list containing the name of the suite and a function for the body of the suite.

Adding an injectable to the inject function

The first step to implement the plugin is to add an argument to the inject() function. The name of the injectable will come from the currently selected text.

To get the currently selected text you can use the Caret object. The caret represents the IDE’s cursor(s). The generated plugin action class extends the abstract class AnAction. You need to implement the actionPerformed(AnActionEvent e) method. actionPerformed() is called when you execute the plugin. The method receives an event that gives you access to different elements in the IDE, such as the caret, current file, project, etc. Add the following code to the action an debug the plugin to make sure that everything works.

public class InjectTestAction extends AnAction {
@Override
public void actionPerformed(AnActionEvent e) {
// Get the selection to generate the injectable name.
Caret caret = e.getData(PlatformDataKeys.CARET);
String injectableWithUnderscores =
"_" + caret.getSelectedText() + "_";
}
}

I chose to surround the name of the injectable with underscores so you don’t have to provide a different name for the assigned variable you will use in your test. Now we will add the _SELECTION_ text as a parameter of the inject function:

describe('...', () => {
beforeEach(inject((_<SELECTION>_) => { // Insert value here
<SELECTION>
}));
});

You need to find an element of type JSCallExpression whose text starts with “inject”. Then you will find the child JSArgumentList element to add an argument. You will use the PsiTreeUtil class to find the argument list.

Finding elements using PsiTreeUtil

The PsiTreeUtil class provides methods to traverse the PSI tree. Most of the methods use a PsiElement as a starting point for the traversal. You can look for ancestors or children of an element.

To find the argument list PsiElement of the inject() function you can use PsiTreeUtil.findChildrenOfType(). You will look for PSI elements of type JSCallExpression starting with the “inject”. Once you find the element you extract the parameter list using the findChildOfType(element, type) method.

Create a method that takes a PsiFile as the starting search element. The method looks JSCallExpression elements, then it uses element.getText() to test if the current element is the inject() function.

Once you have the parameter list element you can modify the JS file. To change the current file you need to use the Document object. TheDocument.insertString() method inserts a String at a specified offset. The offset is the number of characters from the beginning of the document to a specific location in the source file. If you want to know the offset of an element you can call element.getTextRange().

You will insert two values in the JavaScript file: first, the selection value surrounded by underscores next to the cursor; second, the injectable value at the inject() parameter list.

describe('...', () => {
beforeEach(inject(([_SELECTION_]) => {
SELECTION[ = _SELECTION_;]
}));
});

Modifying the JavaScript file

Now you are ready to modify the document. get the Document from the AnActionEvent object and then call document.insertString() twice to add the text. The method takes an offset and a CharSequence as arguments. Enter the following code for the action and debug the plugin to make sure everything works.

When you run this code you will get the following error:

Assertion failed: Write access is allowed inside write-action only

IntelliJ requires that any operations modifying the document must be wrapped in a command. The command allows you to undo and revert multiple changes at once. Here is the modified version of the action. This version has extra logic to deal with multiple parameters in the inject function.

Adding a variable in the describe block

The final step is to add a variable declaration in the describe block. The solution uses the same mechanism: first, find the JSBlockStatement element of the top-level describe; second, insert a string with the selected text to the statement. Here is an example of how the text insertion would look like:

describe('...', () => {
let SELECTED_TEXT;

beforeEach(module(...));
});

I will skip this step because I want to keep this post short. I decided to implement the variable creation in a standalone Action called AddVarAction. You can see the plugin code on github: https://github.com/andresdominguez/inject-test

Building and install the plugin

Now you are ready to test the plugin as a standalone jar file. To build the plugin right-click on the root of the project and select “Prepare Plugin Module <project name> For Deployment”. IntelliJ will generate a jar file in the same directory. Now install the plugin jar file to make sure that everything works. Here I am installing the plugin in WebStorm to do my testing.

Install plugin from disk

Conclusion

Building a plugin can seem overwhelming at first. If you are feeling lost just look at the source code, read the documentation, watch youtube videos, or go to github and look for similar examples. Also, don’t be afraid to ask JetBrains. They are super helpful and they respond quickly.

If you have feedback about this article please post a comment. I would love to see more people contributing JavaScript plugins.

Helpful videos

Dmitry’s video is incredibly good. I would start by watching this first.

--

--