Development Plugins Tips & Tricks

Building a Plugin for WebStorm – Tutorial for JavaScript Developers, Part 2

In Part 1 of this series, we discussed plugin structures and plugin templates and demonstrated how to build a simple plugin without writing any code. In this blog post, we are going to talk about another very important aspect of plugins: actions.

We plan to build a simple version of the eCSStractor plugin, which will take HTML content and generate CSS code with class names from it.

So for the following HTML:

Our plugin will generate the resulting CSS file:

.hello1 {}
.hello2 {}

Please note that it won’t be possible to customize our new plugin. The original eCSStractor plugin supports BEM, LESS files, and a lot of other things, but we’ve decided to strip out that functionality to make the example as simple as possible.

Step 0: Prerequisites

The plugin we are building in this part of the tutorial is more complex than what we did in part 1, and to build it, we are going to need to use Kotlin. Before getting started, please take a look at the requirements for building Kotlin-based plugins for WebStorm. To complete this tutorial, you’ll need to have an understanding of Java/Kotlin basics: packages, imports, classes/interfaces, and objects, as well as familiarity with Swing-related concepts like the event dispatching thread.

If you are not familiar with Kotlin, you can start learning it with the free Kotlin Basics track from JetBrains Academy.

Step 1: Prepare plugin for development

We need to create a new plugin project and set up a basic plugin build in IntelliJ IDEA. We have actually already covered this part with the first 7 steps in the previous blog post. Once you’ve got a plugin template, configured Java, customized the plugin build, and updated your plugin.xml file, you can move on to the next step.

Please note, that since the 2021.3 release is so close, we can extend the compatibility of the plugin to the new version in ​​gradle.properties:

pluginSinceBuild = 212
pluginUntilBuild = 213.*

Step 2: Specify dependencies

Our new plugin will work with CSS, so we need to specify the CSS plugin as a dependency.

To do this, we need to include the following code in our plugin.xml file:

<depends>com.intellij.css</depends>
<depends>com.intellij.modules.xml</depends>

We also need to add this code to gradle.properties:

platformPlugins = com.intellij.css

There’s no need to add a HTML/XML dependency in the gradle.properties file because it is already part of the platform, but we still need to add the dependency in the plugin.xml file, to show that we plan to work with the functionality.

Step 3: Add a new Kotlin class for the action

We plan to implement a new custom action to provide the necessary functionality.

First of all, we need to create the corresponding directory structure for the source files. Source files should be stored under the src/main directory. Since we can use any JVM language in IntelliJ IDEA, sources are usually split by language, for example java or kotlin.

We want to use Kotlin for this plugin, so let’s create a kotlin directory under src/main. Next we’ll need to create a top-level package under the kotlin directory, using the context menu. Right-click on the directory and select New | Package. We can use any name for it, and in this case extract.css is a good option. Let’s also create a sub-package called actions inside extract.css to give our stored classes a bit more structure.

extractCSSaction

Create ExtractCSSAction.kt file with the following content:

package extract.css.actions

import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.ui.Messages

class ExtractCSSAction : AnAction() {
  override fun actionPerformed(e: AnActionEvent) {
    Messages.showMessageDialog(e.project, "Hello, extract css", "Welcome", null)
  }
}

We are not going to take a closer look at the implementation because our main goal is to make an action for testing. The real implementation will be covered later.

Step 4: Register the action

After creating the action class we need to register it in the plugin.xml file. In the resources/META-INF/plugin.xml file, add the action registration inside the top level <idea-plugin> tag:

<actions>
  <action text="Extract CSS" class="extract.css.actions.ExtractCSSAction" />
</actions>

In this sample, text is the name of the action and class is the full class name that will be used for the implementation of the action.

Step 5: Run the action

Let’s run our plugin and make sure that the action works. We provided instructions for running and debugging plugins in the previous post, see the “Running and debugging” section in Step 9:

We just need to click the Run or Debug button for the corresponding Run Plugin configuration on the navigation bar.

Remember that when you run or debug the plugin, you will have two open IDEs: the first one is for developing the plugin, and the second one is for testing it.

After starting the test IDE, we have to either open a project or create one, and run the action we’ve just created. The action doesn’t have a shortcut assigned to it, and it hasn’t been added to any menus. This means that the only way to run it is to enter the action name, Extract CSS, into Find Action (⇧⌘A / Ctrl+Shift+A) or Search Everywhere (⇧⇧ / Shift+Shift).

run-the-action

After running the action you will see this note from Messages.showMessageDialog().

hello-action

Step 6: Process the HTML file

Now, we are ready to implement a “real” action, instead of just the test one. But let’s first remove the line with Messages.showMessageDialog from actionPerformed().

You can probably guess that actionPerformed() contains the implementation of the action. That’s where we’ll need to write all the required code.

Algorithmically, we need to:

  1. Take the context file and make sure that it is HTML.
  2. Collect all attributes with the name class from the file.
  3. Create a new CSS file that contains the class names defined in the attributes.

So, let’s start at the beginning. The actionPerformed() method receives only one parameter e: AnActionEvent, so the context file should be inside of the object.

AST and PSI

The IDE does not usually operate with regular file content, but rather with abstraction over the files. For this reason, we are going to work with a PSI (Program Structure Interface), which is an abstraction over the AST (Abstract Syntax Tree). The PSI is similar to the AST and represents it in some way, since it has the same getParent(), getChildren() methods, but it also provides a simpler way to work with trees.

As an example, let’s consider a tag with sub tags:

<div>
  div1 
  <div>div2</div>
</div>

If we want to get all the nested tags using the AST, we’ll need to iterate the children and find all of the tags among them. On the other hand, we have the PSI interface’s XmlTag, which has the #getSubTags() method. Of course, the method under the hood still uses the AST to get the sub tags, but from the client-side it is a much more convenient way to work with trees.

So first of all, we want to get the corresponding PSI of the file from the context of the action.

We can do this with the following code:

val xmlFile = e.getData(CommonDataKeys.PSI_FILE) as? XmlFile ?: return

The IntelliJ Platform doesn’t have a separate interface for HTML files. All PSI HTML files implement the XmlFile interface, but it is ok to make the action available for XML files too.

Project

We want to process the action inside an open project, so we need to make sure that the corresponding object exists:

val project = e.project ?: return

This is rather useful because we need to pass the project to several other methods.

Visiting

We have now extracted the file, so the next thing we need to do is find all attributes with the name class.

Of course, we could process the file structure manually: take the file, get all the sub tags, process their attributes, then process any attributes for sub tags of sub tags, and so on. But since we have to do this kind of traversing all the time in the IDE, there are actually classes in it we can use to make the process easier. For XML files, we can use the XmlRecursiveElementWalkingVisitor class that already implements recursive visiting. There is a visitor class available for every supported language in the IDE, e.g. JavaRecursiveElementWalkingVisitor, JSRecursiveWalkingElementVisitor, etc.

We need to override the visitXmlAttribute() method and pass the visitor to the acceptChildren method of xmlFile:

xmlFile.acceptChildren(object : XmlRecursiveElementWalkingVisitor() {
  override fun visitXmlAttribute(attribute: XmlAttribute) {
  }
})

This code already visits all the attributes in the file. Next we need to make sure that the current attribute has the name class, and we need to save the value of the attribute somewhere.

val classNames = mutableSetOf<String>()
xmlFile.acceptChildren(object : XmlRecursiveElementVisitor() {
  override fun visitXmlAttribute(attribute: XmlAttribute?) {
    if (attribute.name == "class") {
      classNames.addAll(attribute.value?.split(" ")?.filter(String::isNotBlank) ?: emptyList())
    }
  }
})

That’s it! We’ve collected all the CSS class names that need to be processed, so our next step is to create a new CSS file.

Step 7: Generate the CSS file

First of all, we need to create the final file content:

val newContent = classes.joinToString("\n") { ".$it {}" }

Next we need to create a new CSS file with that content. But before we do that, let’s cover a couple of important things.

There are several concepts that we have to understand in order to develop plugins that work with models inside of the IDE.

Read and write locks

In general, code-related data structures in the IntelliJ Platform are covered by a single reader/writer lock.

The read lock is required every time we need to read something from the model. Any access to the AST or the PSI should be wrapped with a read lock.

The write lock is required any time we need to write in the model, for example to create a new file or to modify the AST through refactoring. The write lock must be called from the EDT (Event Dispatching Thread). Note that code called from the EDT always has read lock by default, without acquiring it explicitly.

The IntelliJ Platform has a lot of extension points that you can use to customize the behavior of the IDE, and a lot of these extension points are already called inside of read actions. You need to check the read lock manually for every extension point from the documentation or using the debugger.

Fortunately, in our case, the read lock has already been acquired. Moreover, the actionPerformed() method is called on EDT by default, so we can take the write lock without moving the code to another thread.

All the operations inside the write action must be wrapped by some command for providing more detailed information in the Local History and Undo/Redo actions.

The next step is to start a write action and call the command that creates the required CSS file.

We can use the WriteCommandAction builder class, which provides a way to both define command and call the write action at the same time:

WriteCommandAction
    .writeCommandAction(project)
    .withName("Creating CSS File")
    .run<Exception> {

    }
    

We need to write code that creates a CSS file inside the run {} body. For the sake of simplicity, let’s create a new CSS file that has the same name as the current HTML file, in the same directory.

WriteCommandAction
  .writeCommandAction(project)
  .withName("Create CSS File")
  .run<Exception> {
    val newName = "${FileUtil.getNameWithoutExtension(xmlFile.name)}.css"
    val newFile = PsiFileFactory.getInstance(project)
      .createFileFromText(newName, CssFileType.INSTANCE, newContent)
    val directory = xmlFile.parent ?: return@run
    directory.add(newFile)
  }

The logic is done. Now let’s run the plugin and see how it works.

  • Run the plugin.
  • Create a new HTML file with the content: <div class=”hello1 hello2”>test</div>.
  • Run our Extract CSS action using Find Action or Search Everywhere.
  • Check the created CSS file alongside the HTML file in the same directory.

If you’ve done everything properly the CSS file will have the following content:

.hello1 {}
.hello2 {}

Step 8: Cosmetics (optional)

Our plugin works but there are several things that could be improved.

Open the new file

It would be great to be able to open the created file after the action. We can use the navigate() method of the added file to do this. Please note that the add(newFile) method will create a new CSS file instance instead of the current one, so we need to call navigate() for the result of the add() operation instead of the newFile object:

(directory.add(newFile) as? XmlFile)?.navigate(true)

Reformat new files

After creating the new files, it would also be great to be able to reformat them according to the project’s CSS code style:

val directory = xmlFile.parent ?: return@run
val actualFile = directory.add(newFile) as? PsiFile ?: return@run
CodeStyleManager.getInstance(project).reformat(actualFile)
actualFile.navigate(true)

Assign a shortcut

With our original implementation, the new action is only available from the searches, which isn’t particularly convenient. Let’s remedy this by assigning a shortcut to the action. We can use the shortcut that is used in the original eCSStractor plugin: ⌘⇧X / Ctrl+Shift+X. To assign a shortcut to an action, we need to edit the plugin.xml file. Inside of the action registration, we have to add the sub tag <keyboard-shortcut> with the corresponding options:

<action text="Extract CSS" class="extract.css.actions.ExtractCSSAction">
  <keyboard-shortcut first-keystroke="control shift X" keymap="$default"/>
</action>

The keymap attribute, which specifies the keymap, is required.

assign-shortcut

You can assign different shortcuts for different keymaps. The value $default corresponds to the default keymap, which is the base for all other keymaps.

Add the action to the context menu

In some cases, it is much easier to run an action from the context menu instead of using the shortcut. To do this, just add an additional nested add-to-group tag inside of the action tag.

<action text="Extract CSS" class="extract.css.actions.ExtractCSSAction">
  <keyboard-shortcut first-keystroke="control shift X" keymap="$default" />
  <add-to-group group-id="EditorPopupMenu" />
</action>

The name EditorPopupMenu identifies where we want to display the action: the editor context menu. A lot of possible variants can be specified in the group-id, and almost all of them are available through code completion and have self-describing names.

add-action

Reduce the context of the action

The action is available for all files, but it only works in HTML(XML). This means we can restrict the availability of the action.

The update() method of the AnAction class is used for controlling the behavior. We need to override the method in our ExtractCSSAction class and set visibility info based on the current context:

override fun update(e: AnActionEvent) {
  e.presentation.isEnabledAndVisible =
    e.getData(CommonDataKeys.PSI_FILE) is XmlFile && e.project != null
}

Further improvements

The first version of the plugin is very simple, and there are still a lot of ways to improve it:

  • Provide an option to extract classes to the clipboard.
  • Provide an option to generate the output in BEM format, as it is done in the original plugin.
  • Provide an option to generate LESS / SASS code instead of plain CSS.
  • Provide an option to run the action for a selected fragment of code.
  • Support extracting id-s.
  • Support JSX.

Since there is a request from the community, our team is currently working on improving the plugin ourselves, and we plan to implement all of the features mentioned above. You can always check the source code of the latest version of the plugin here.

That’s it for the second part of the series. We hope that this part has helped you understand some of the basic concepts related to actions and language-oriented plugins. Make sure to check out the third and last part of the series, where we build something more sophisticated for real JS framework support.

If you have any questions or comments, please leave them below or reach out to us on Twitter. You can also contact us through our dedicated Slack channel for plugin developers.

The WebStorm team

image description