Development Plugins Tips & Tricks

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

In Part 1 and Part 2 of this series, we discussed some of the basics of WebStorm plugin development. This time we’ll see how you can create a more complex integration for a web framework. Let’s build an advanced integration for Stimulus! This part of the tutorial will focus more on explaining the key concepts of creating the plugin and its features rather than a step-by-step implementation. To see the final version of our plugin, see this GitHub repository.

Before moving on, please make sure you’ve familiarized yourself with the basics of WebStorm plugin development covered in Part 1 and Part 2. Otherwise, you might find it difficult to follow the steps below.

Step 0: Overview of the framework

Let’s start with taking a closer look at Stimulus and making a list of things we could work on.

Controllers

The core idea of the framework is based on the mapping between JavaScript controllers and data attributes that reference the controllers by name.

test.html

<div data-controller="test"></div>

controllers/test_controller.js

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {}

Controllers provide life-cycle methods to handle the behavior of the element. They also encapsulate logic related to actions, targets, values, and CSS classes.

There are two potential points of integration. The first one is code completion for Stimulus data attributes, with data-controller being one of them. The second part is the binding between the JavaScript controller and data-controller attributes.

Actions data attributes

Actions provide a way to handle DOM events in your controllers:

test.html

...
 <button data-action="click->test#act">…</button>

controllers/test_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
 act(event) { console.log(“acting”) }
}

As a part of the integration, we can show completion suggestions for the data-action attributes, and bind the values to the corresponding elements of controllers. In the example above we need to recognize that in the action attribute, the click-> part is an event action name, test is defined as the controller name, and #act is the act method of the controller.

Targets data attributes

Targets provide a way to access some DOM elements by names specified in special data attributes.

test.html

...
<input type="checkbox" data-test-target="hello">

The controller must define the corresponding value in the static targets field:

controllers/test_controller.js

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
    static targets = [ "hello" ]
    initialize() {console.log(this.helloTarget)}
}

For the new plugin we can bind the data target attributes to the static target field values. Also, we can provide code assistance in controllers based on the targets field, so users can access the values with this.test1Target.

Optionally, we can add a validation for the attributes to check that the corresponding target value is defined.

Values data attributes

Values are special attributes that can be defined on elements with controllers, for providing additional controller-specific information:

test.html

<div data-controller="test"
    data-test-url-value="/test1">
</div>

Controllers should define the corresponding static field with description of the values:

controllers/test_controller.js

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
 static values = {
   url: String
 }

 initialize() {console.log(this.urlValue)}
}

Note that String in the example above isn’t a TypeScript type – it’s the JavaScript String global function.

We can handle the value attributes in a similar way to targets: implement the binding between the data attributes and the static values field as well as coding assistance for this.urlValue references. Optionally, we can add a validation for the data attribute that checks that a corresponding property in the values field definition exists.

Classes data attributes

Syntactically, the class data attributes work similarly to targets and values. First of all, we need to define the class data attributes on the controller element:

test.html

<form data-controller="test"
     data-test-loading-class="clazz">

After that, we need to define the static field for classes in the controller:

controllers/test_controller.js

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
 static classes = [ "loading" ]

 initialize() { console.log(this.loadingClass) }
}

The plugin support should be exactly the same as for targets and values: bindings, coding assistance, and, optionally, validation. But since in this case we know that the attribute value is CSS, we can also provide code completion and navigation for CSS classes.

Plugin feature list

We’ve identified the most important parts of the framework, so now we can build the full list of the required features:

  • Binding between the controller definitions in the HTML and JavaScript implementations.
  • Code completion for data attributes.
  • Code completion for targets, classes, and values data attributes based on the static field definition of the controller.
  • Code completion and navigation for CSS classes in the class data attributes.
  • Resolution for targets, classes, and values after this, for example this.testTarget in controllers based on the static fields.

Step 1: Set up a new plugin project

This part is covered by steps 1–8 in the first blog post of the series. Please follow those instructions and then move on to Step 2 below.

Step 2: Create bindings for data-controller attributes

References

We need to link the value of the data-controller attribute to the corresponding JavaScript declaration. WebStorm uses the Reference abstraction for that purpose. References are similar to hyperlinks in HTML: we add some meta-information on the code side.

References can be used for any place in your code:

  • WebStorm creates a reference for the foo name in JavaScript functions called like foo().
  • Another example of using references in WebStorm is the code navigation for tag names: if you call Go to Declaration for the tag name div, WebStorm will navigate you to the corresponding rnc declaration of the tag, which WebStorm uses for code-insights in HTML files.
  • The JavaScript import path inside from is a more complex example of references. The references are chained: WebStorm creates references for all parts divided by the separator /, and right references depend on the left ones.

In our case, we need to provide a reference for the attribute value of the data-controller and implement a logic for resolving the reference, in order to create the link between the attribute value and the JavaScript declaration.

First, we need to create a new class that corresponds to the reference that we would like to create. Let’s call it StimulusControllerReference.

class StimulusControllerReference(private val name: String, psiElement: PsiElement, range: TextRange) :
   PsiReferenceBase<PsiElement>(psiElement, range, true) { }

Second, we need to specify the place where the reference is created using PsiReferenceContributor. The contributors use some PSI-based patterns for defining the exact place where the reference should be created:

class StimulusReferenceContributor : PsiReferenceContributor() {
   override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) {
       registrar.registerReferenceProvider(
           XmlPatterns.xmlAttributeValue().withLocalName("data-controller"),
           /* the reference provider implementation StimulusControllerReferenceProvider */
       )
   }
}

class ControllerReferenceProvider : PsiReferenceProvider() {
   override fun getReferencesByElement(element: PsiElement, context: ProcessingContext): Array<PsiReference> {
       if (element !is XmlAttributeValue) return emptyArray()
       val value = element.value
       return arrayOf(StimulusControllerReference(value, element, TextRange(1, 1 + value.length)))
   }
}

Since PsiReferenceContributor is an extension point, we need to register the new implementation in plugin.xml.

After registering the reference, we need to implement the resolution logic for it. In other words, we need to write code that searches the original JavaScript file that is used for the controller with the name specified in data-controller attributes.

Reference resolution

There is an attribute value with the controller name, and we need to find the corresponding JavaScript declaration that is used for the controller. Stimulus documentation states that for the controller with the name test, we need to find a file with the name test_controller.js, and the file should contain the default export of the controller class. The simplest solution could be to start from the root directory and visit all directories recursively, searching in the directory for the file named test_controller.js. This is a working solution, but it can be extremely slow for big projects, especially if there are a lot of controllers in an HTML file.

The best solution for such tasks in WebStorm is indexes.

Index

The first time any project is opened, WebStorm visits all project files, creates a PSI, extracts all the important information about symbols, and stores the information in special persistent key-value stores called indexes.

For our task, we could create an index of all controllers, and then it would be relatively easy to get a controller declaration by name – at any moment after the indexing. Fortunately, in our case we don’t need to create a new index, as we can use the existing file names index for this purpose.

To find the corresponding controller declaration, we need to make a call to the file name index to find the file named test_controller.js, and then return the corresponding result:

class StimulusControllerReference(private val name: String, psiElement: PsiElement, range: TextRange) :
   PsiReferenceBase<PsiElement>(psiElement, range, true) {
   override fun resolve(): PsiElement? = FilenameIndex.getFilesByName(
       element.project,
       "${name}_controller.js",
       GlobalSearchScope.projectScope(element.project)
   ).firstOrNull()
}

Code completion for simple references

For simple references we can also provide code completion by overriding the getVariants() method of the reference.

override fun getVariants(): Array<LookupElement> =
   FileTypeIndex.getFiles(JavaScriptFileType.INSTANCE, 
GlobalSearchScope.projectScope(element.project))
       .mapNotNull { element.manager.findFile(it) as JSFile }
       .map { StringUtil.trimEnd(it.name, "_controller.js") }
       .map { LookupElementBuilder.create(it) }
       .toTypedArray()

In more sophisticated cases, when you need to control the context of the suggestions, you can use the CompletionContributor extension point instead.

We’ve created a new reference, implemented the resolution logic, and linked the reference to the data-controller attribute values. It is time to check if it works. You can create a simple project with several controllers and check that the new plugin suggests the controller names in code completion and that navigation works for a specified controller.

controllers_check

Step 3: Create binding for data-action attributes

The data-action attributes can be implemented in a similar way, with the only difference being that the attributes should contain two references: the first one for the controller name and the second one for the method name, which is based on the first one.

So, for the attribute:

<button data-action="click->test#act">…</button>

…we need to create the first reference for the test substring and the second reference for the act substring.

Step 4: Create binding for data-*-target attributes

We could also create a new reference for targets, but in this case it is much better to choose a different way. WebStorm’s HTML API provides a way to work with attributes that have a limited set of values. For such attributes, WebStorm automatically validates the attribute values and shows code completion. Since the targets usually are used for several fixed values, we can extend the HTML support by providing the corresponding data-*-target attribute with a set of possible attribute values.

There are a number of ways to contribute a new set of attributes to HTML tags, including WebTypes that we used in the first part of this series. In this case, the simplest way is to use XmlAttributeDescriptorsProvider.

The extension point XmlAttributeDescriptorsProvider works similarly to PsiReferenceContributor. We need to check that the current place is acceptable for some data-*-target attributes. If it’s true, we need to return a new description of the attribute. The description is an inheritor of the XmlAttributeDescriptor class. It encapsulates the basic information: the linked PSI element for the attribute (similar to the results of reference resolution), type, and the set of values:

class StimulusAttributeDescriptorsProvider : XmlAttributeDescriptorsProvider {
   override fun getAttributeDescriptors(context: XmlTag): Array<XmlAttributeDescriptor> {
       return emptyArray()
   }
}

First, we need to find the corresponding controllers that are available for the tag. We can do this by traversing the parent hierarchy of tags, and checking data-controller attributes for them. After collecting the controllers we need to find the corresponding JavaScript classes. We can re-use the logic behind controller attribute resolution, extracting it into a new method:

private fun findControllersByName(context: PsiElement, name: String): Array<PsiFile> =
   FilenameIndex.getFilesByName(
       context.project,
       "${name}_controller.js",
       GlobalSearchScope.projectScope(context.project)
   )

After getting the controller JavaScript class we need to check the existence of the static field targets.

If the field exists, we can create a new instance of XmlAttributeDescriptor with the data-%NameOfController%-target attribute. For restricting the possible values of an attribute, the isEnumerated and getEnumeratedValues methods should be overridden:

class TargetsFieldAttributeDescriptor(private val field: JSField) : BasicXmlAttributeDescriptor() {
…
override fun isEnumerated(): Boolean = true
override fun getEnumeratedValues(): Array<String> = getLiteralValues(field)
…
}

The getLiteralValues() method can be implemented as follows:

fun getLiteralValues(field: JSField?) = (field?.initializer as? JSArrayLiteralExpression)
   ?.expressions?.mapNotNull { it as? JSLiteralExpression }
   ?.mapNotNull { it.stringValue }
   ?.toTypedArray() ?: emptyArray()

After implementing this part, we also need to register the extension in the plugin.xml file by adding the following:

<xml.attributeDescriptorsProvider order="first"
implementation="stimulus.lang.StimulusAttributeDescriptorsProvider"/>

It’s important to specify order=”first” because data attributes also have a more generic handler.

Now we can check that the implementation works correctly.

register_the_extension

Step 5: Create bindings for data-*-value attributes

Data values are similar to the target attributes. The main difference is that the attribute name is a composite entity with a controller name and a name from the values static field. So, in this case we can generate a new XmlAttributeDescriptor based on the object properties of the static field values. Also, we don’t need to update the isEnumerated and getEnumeratedValues methods since we expect that the attribute can contain any string values.

Step 6: Create binding for data-*-class attributes

CSS data classes are very similar to data-*-values. We can use the same ideas for creating composite names based on controller names and names from the classes field.

Step 7: Add resolution for targets, values, classes in JavaScript

There are several ways we can extend the IDE’s support for JavaScript. First of all, we can contribute a new reference for JavaScript code, similar to the data-controller values. On the other hand, the most flexible and powerful way is to use the FrameworkIndexingHandler extension point. It provides an opportunity to extend the JavaScript type evaluation mechanisms.

Since the type system of the JavaScript support is so big that it deserves its own article, let’s use the first solution with a new reference.

We need to write and register an extension of PsiReferenceContributor, and implement resolution logic for the new reference, based on the values, classes, and targets static fields. We won’t go into details this time, because it’s very similar to step 2.

extension_of_PsiReferenceContributor_and_resolution_logic

That’s it! We’ve implemented the most important parts of the new integration.

Further improvements

There are still a lot of ways to improve the plugin. Here’s what we could implement:

  1. TypeScript support and / or support for file extensions other than .js.
  2. Support for the Find Usages feature. This can be improved by using the JavaScript class declaration instead of the actual file.
  3. Integration with the Unused global symbol JavaScript inspection. Some of the issues can be solved by supporting Find Usages. Others can be handled through the ImplicitUsageProvider extension point.
  4. As users can have several controllers for the same data-controller attribute, we need to fix our code accordingly. The same is true for data-*-action attributes.

The final version of the plugin on GitHub contains these improvements, so check it out if you are interested.

Conclusion

In this blog post series, we’ve given you three real-life examples of developing plugins for WebStorm. We hope this makes it easier for you to get started if you ever decide to develop your own plugin.

If you have any questions about plugin development, feel free to post them in the JetBrains Platform Slack or give a nudge to @JBPlatform on Twitter.

The WebStorm team

image description