Localizing an IntelliJ Plugin  General

Localizing an IntelliJ Plugin

This post explains how to translate user-visible texts of your JetBrains plugin. Learn how to translate actions, configurables, inspections and other elements of your plugin and how to match the language of the installed language pack.

Introduction

JetBrains’ IDEs are available in English, Simplified Chinese, Japanese and Korean. Wouldn’t it be great if your plugin automatically adapted to this language?

Well, it’s possible – but there’s no “automatically” for you this time 😉

I recently added support for Simplified Chinese to my plugin BashSupport Pro. This wasn’t as straight-forward as you might expect. Actually, almost nothing worked out-of-the-box, and it was a frustrating experience.

By sharing what I learned I hope to make things a bit easier for others 😄

What are Language Packs?

The language of the user interface is controlled by the installed language pack.

To switch the language of your IDE’s user interface, install a language pack from the Marketplace. Only one language should be installed at a time, for example Simplified Chinese and Japanese shouldn’t be installed at the same time. Only one of the available language packs will be used to provide the localized resources to the IDE and plugins. The installation of a language pack requires a restart – switching the language without a restart is not possible.

2020.1 was the first version with support for a localized user interface. This article is about 2021.3 and later, because the language packs have evolved a bit. But, to a degree, it’s technically possible to support even older major versions.

Technically, a language pack is a plugin. It implements a single extension point and bundles localized resources for all products and plugins it supports.

For example, the Chinese language pack is doing this in its plugin.xml file:

1
2
3
 <extensions defaultExtensionNs="com.intellij">
   <languageBundle locale="zh"/>
 </extensions>

So far, JetBrains is offering three different language packs:

Each of the packs supports multiple JetBrains products and even plugins.

It’s JetBrains products and plugins and that is the major pain point here – 3rd-party plugins can’t use the infrastructure created for language packs. You have to write some code to make things work in your plugin.

Contents of a Language Pack

As mentioned above, a single pack supports multiple JetBrains products and JetBrains plugins. That’s why the same language pack plugin is installed in IntelliJ IDEA, GoLand or PhpStorm.

A language pack contains the following resources:

  • Message bundles, e.g. actions or tool window labels
  • File templates
  • Inspection descriptions
  • Intention descriptions
  • Postfix templates
  • Searchable options
  • Daily tips

Why Most Things Don’t Work for 3rd-party Plugins

The centerpiece of localization is DynamicBundle. It’s widely used by the SDK to localize most user-visible texts.

The official SDK guide even explains how to localize your IDE. But, as far as I understand, this is only about the localization of the IDE itself and was most likely written before language packs were invented.

DynamicBundle is a ResourceBundle implementation and most likely you’re already familiar with this concept. Unfortunately, it always delegates the loading of localized resources to the language pack’s classloader.

The consequence is that it properly loads the English base bundle from your plugin’s resources. But as soon as a language pack is installed, the localized variant of the base bundle is searched in the contents of the language pack plugin. Of course, it’s not there – unless you’re working on a JetBrains plugin or product 😉

This is actually a clever concept – you can keep the same resource paths in your code and provide a localized copy with a language pack plugin. But unfortunately this only works for JetBrains’ products and plugins because adding 3rd-party content to a language pack doesn’t make sense, of course.

There’s a workaround – you can make your own bundle implementation.

Localizing Your Plugin

Create Your Own Message Bundle

First, you need to add your own base implementation of a message bundle. It must use your plugin’s own classloader to lookup the localized variants of your .properties files.

  1. Create the base implementation.
    There’s already some code in the sample plugin repository: DynamicPluginBundle.
  2. Create the message bundle for your .properties file.
    Extend the base implementation, pass the path to your message file to the super constructor. Optionally, add a few helper methods. PluginBundle is similar to what I’m using in production code.

Here’s the code of PluginBundle, it’s easy to read. Personally, I prefer to have a static helper method, because this allows to add @PropertyKey(resourceBundle = "messages.pluginBundle") to the message key parameter – this gives you nice code completions from your .properties file in IntelliJ IDEA.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
 public class PluginBundle extends DynamicPluginBundle {
   public static final PluginBundle INSTANCE = new PluginBundle();
  
   @NotNull 
   public static String get(@NotNull 
                            @PropertyKey(resourceBundle = "messages.pluginBundle") 
                            String key, 
                            Object @NotNull ... params) {
       return INSTANCE.getMessage(key, params);
   }
   
   // removed similar implementation for lazy messages
   
   private PluginBundle() {
       super("messages.pluginBundle");
   }
}

Actions

The official SDK guide explains how to localize your actions: Localizing Actions and Groups.

You have to work a bit harder to make things work for own plugin. Retrieve the localized text and description using code and pass it to the constructor of AnAction.

Make sure to use the pattern for the property key as shown in the official guide: action.<your action ID>.text and action.<your action ID>.description. As soon as JetBrains supports localization of non-JetBrains plugins out-of-the-box, you can drop the call to super.

Here’s some code, taken from our sample plugin:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import static dev.ja.samples.localization.PluginBundle.lazy;

public class SampleAction extends AnAction {
    public SampleAction() {
        super(lazy("action.dev.ja.sampleAction.text"), 
                lazy("action.dev.ja.sampleAction.description"), 
                null);
    }
    
    // removed actionPerformed
}

There are a few elements you can’t translate, at least I wouldn’t know how to do this. All of these use a DynamicBundle, which breaks things for our plugin (again):

Inspections

Inspections are similar. As far as I understand the code, inspections configured in your plugin.xml are wrapped by an InspectionToolWrapper. This wrapper is responsible to create an instance of your implementation and also manages the user-visible texts.

To localize the name and group name of an inspection, you have to override methods getDisplayName() and getGroupDisplayName(): sample implementation. Don’t forget to remove attributes bundle=, key= and groupKey= from your plugin.xml.

What’s left now is the description, which is shown in the settings dialog, for example. At first, I was excited that there seemed to be a simple way to provide it: InspectionToolWrapper calls loadDescription() of the inspection implementation.

But unfortunately there’s this line. Unless you manage to cause an IOException, your implementation’s loadDescription() is never called. Instead, our lovely DyanmicBundle is called, which only speaks English with 3rd-party plugins 😉

The only way I could find to provide a translated description is to override getStaticDescription(). This code demonstrates how to do this. This should work well enough, but it’s a hack, and I hope that there will be an official solution in one of the next major versions.

Intentions

Refer to the official SDK guide to learn how to configure your intentions.

It’s possible to localize the name of the intention by calling setText() with a value retrieved from your message bundle. Override getFamilyName() to provide a translated family name.

Unfortunately, I’m not aware of any way to provide a translated intention description. The description is stored at intentionDescriptions/<your intention directory name>/description.html. If my understanding of the code is correct, then ResourceTextDescriptor is responsible to load it and delegates to our beloved friend DynamicBundle, which still stubbornly refuses to speak anything else than English.

Here’s a sample implementation of a localized intention.

Configurables

As you might expect, localizing a configurable isn’t possible using the XML-based configuration. Instead, you can override getDisplayName() to provide a translated tree item in the settings dialog.

Here’s a sample implementation of a localized configurable.

Live Templates

By default, live template have a static description, which is stored in the XML snippet. The intellij-community sources use a key= attribute instead of description= to localize the text (example).

I couldn’t find any reference to it in the official SDK guide, but it seems to be using DynamicBundle again.

I’m not aware of any other way to translate the description of your live templates.

File Templates

Even the language packs don’t have translated display names of file templates, so I don’t think it’s possible (yet).

It seems to be possible to localize the HTML description of file templates, but again only with a language pack. I’m feeling too tired now to dig into intellij-community to find if that’s actually the case. But I’m pretty sure you don’t want me to mention again that DyanmicBundle is NOT speaking Chinese 😉

There’s More!

Congratulations, you’ve made it and localized your plugin as much as possible!

But there’s still more to do! Unless you’re speaking Chinese, Korean or Japanese you still need to find a translator…

So far, I only have experience with the localization into Simplified Chinese.

For BashSupport Pro, which contains many references to technical terms, I wanted a native speaker of Chinese who actually understands what shell scripts are and do. The natural choice would be a developer who’s also a professional translator for Simplified Chinese.
I couldn’t find such a unique person, but found a kind Chinese developer instead who was willing to translate the plugin.

If you’re developing a paid plugin, you most likely have a website – at least you should have one 😉
I suppose that developers, who prefer the Chinese language pack in the IDE, would also like a Chinese website instead of English. For BashSupport Pro, there’s a translation of the website.

Even though it’s difficult to measure the impact of a localized plugin and website, I consider it professional to integrate with the IDE as much as possible.

I used Upwork and the JetBrains’ plugin developer Slack to find suitable translators for website and plugin. BashSupport Pro has about 800 different messages, including ShellCheck messages. Altogether, the total cost for both translation was in the range of 1000 to 2000 USD.

FAQ

Should I use Locale.getDefault() or Locale.setDefault(...)?

Please don’t. This method changes the defaults of the IDE’s JVM process and has side effects for all other plugins.

The IDE doesn’t use it to choose the language of the user interface – that’s what language packs are for. It’s better to align with the IDE and use the language pack’s locale instead, e.g. by calling com.intellij.DynamicBundle.getLocale().

Why Can’t I Just Add My Own Language Pack?

Because you’d make a mess 😉 As explained above, only a single pack may be installed at a time. Adding your own would override the existing language pack and then only your plugin would be localized but nothing else.

Limitations of the SDK

This is a collection of shortcomings I found.

Ideally, the SDK’s DynamicBundle should first try to fetch the translated resources from the plugin’s classloader before delegating to the language pack classloader.
That should solve most issues, but I’m not entirely sure that this would work as I imagine.

List of limitations of 2021.3 for 3rd-party plugins:

  • Action texts and description are not translated (workaround above)
  • Action overrides are not translated (no known workaround)
  • Action synonyms are not translated (no known workaround)
  • Inspection display name and group display name are not translated (workaround above)
  • Inspection descriptions are not translated (workaround above)
  • Intention descriptions are not translated (no known workaround)
  • Intention before/action contents are not translated (no known workaround)
  • Configurable names are not translated (workaround above)
  • Notification group labels (no known workaround when using xml-based configuration)
  • Live template names and descriptions are not translated (no known workaround, details above)
  • File template descriptions are not translated (no known workaround)

There’s certainly more, but so far that’s all I had to research for my own work.

Sample Code

You can find an example of a localized plugin at github.com/jansorg/intellij-plugin-localization.

Here’s a neat trick in the build definition to automatically install the Chinese language pack when you execute ./gradlew runIde.