IntelliJ Language Plugin

IntelliJ Platform Plugin SDK is the name of the Java API that we can use to write plugin for IntelliJ-based IDEs.

Language Plugins are plugin adding support for a language, such as R, Rust, or OCaml. The best way to learn is to see how others implemented the API "extension points": intellij-platform-explorer.

📚 A plugin using the Language Server Protocol (LSP) maybe a more viable alternative than implementing your own logic.


Get Started

You can use the Template or directly inside the IntelliJ, you can create IDE Plugin project. I used the latter which is a bit different.

Open the Manifest file at: src/main/resources/META-INF/plugin.xml and define the basic information about the plugin:

<idea-plugin>
    <id>com.my.plugin.id</id>
    <name>Plugin Name</name>
    <vendor email="support@yourcompany.com" url="https://www.yourcompany.com">YourCompany</vendor>
    <description><![CDATA[
        Enter short description for your plugin here.<br>
        <em>most HTML tags may be used</em>
    ]]></description>
</idea-plugin>

You can also add your plugin icon(s):

  • src/main/resources/META-INF/pluginIcon.svg
  • src/main/resources/META-INF/pluginIcon_dark.svg

Run the Gradle task Run Plugin and see if your plugin is in the list of the new IDE that opened, and each information is properly set.

A few gradle settings:

intellij {
  version.set("0.4.0-EAP-241")
  type.set("IU")

  plugins.set(listOf(
    "com.jetbrains.hackathon.indices.viewer:$indicesVersion",
    "PsiViewer:$psiViewerPluginVersion",
    "java" // to enable "com.intellij.modules.java"
  ))
  

  sandboxDir.set("${layout.buildDirectory.get()}/idea-sandbox-241-IU")
  
  // You can add sourceSets as you need them
  sourceSets["main"].java.srcDirs("src/main/gen")
  sourceSets["main"].kotlin.srcDirs("src/anotherSource/kotlin")
  sourceSets["test"].kotlin.srcDirs("src/test/anotherTestSource/")
  
  idea {
    module {
      generatedSourceDirs.add(file("src/main/gen"))
    }
  }
}

Basic Utilities

Icons

Create a class to centralize access to icons.

Useful links: Working with Icons and Images, IntelliJ Platform UI Guidelines, IntelliJ Platform Icons, intellij-icon-generator.

/**
 * @see com.intellij.icons.AllIcons
 * @see com.intellij.icons.AllIcons.FileTypes
 * @see com.intellij.icons.AllIcons.General
 * @see com.intellij.icons.AllIcons.Gutter
 * @see com.intellij.icons.AllIcons.Nodes
 */
 object OCamlIcons {
    private fun loadIcon(path: String): Icon {
        return IconLoader.getIcon(path, OCamlIcons::class.java)
    }

    object FileTypes {
        val OCAML_SOURCE = loadIcon("/icons/mlFile.svg")
    }
}

Bundle

IntelliJ recommends avoiding hard-coded translatable texts. We store translatable strings in a file, and access them from the code:

import com.intellij.DynamicBundle
import org.jetbrains.annotations.PropertyKey

private const val BUNDLE = "messages.OCamlBundle"

// ex: OCamlBundle.message("filetype.ml.description")
object OCamlBundle : DynamicBundle(BUNDLE) {
    fun message(@PropertyKey(resourceBundle = BUNDLE) key: String,
                vararg params: Any): String {
        return getMessage(key, *params)
    }
}

With src/main/resources/messages/OCamlBundle.properties:

# Filetype
filetype.ml.description=OCaml file

Defining File Types

Add a fileType with all information that is also defined in the class com.ocaml.ide.files.OCamlFileType.

    <extensions defaultExtensionNs="com.intellij">
        <!-- FILES -->
        <fileType extensions="ml" hashBangs="ml" name="OCaml File" language="OCaml"
                  fieldName="INSTANCE" implementationClass="com.ocaml.ide.files.OCamlFileType"/>
    </extensions>

You may have to define the language of the file:

import com.intellij.lang.Language

object OCamlLanguage : Language("OCaml") {
    private fun readResolve(): Any = OCamlLanguage
    override fun getDisplayName(): String = "OCaml"
    override fun isCaseSensitive(): Boolean = true
}

🍰 You should see an icon in files having the selected extension.

👉 You can use Language(OCamlLanguage, "OCamli") to declare an language inheriting from OCamlLanguage (same highlighter, etc.).

Using variables for the extension is not needed, but it's useful if we need this information in other classes.

import com.intellij.openapi.fileTypes.LanguageFileType
import com.ocaml.OCamlBundle
import com.ocaml.icons.OCamlIcons
import javax.swing.Icon

object OCamlFileType : LanguageFileType(OCamlLanguage) {
    // Extension-Related Constants
    private const val DEFAULT_EXTENSION = "ml"
    private const val DOT_DEFAULT_EXTENSION = ".$DEFAULT_EXTENSION"

    // LanguageFileType implementation
    override fun getName(): String  = "OCaml File"
    override fun getDescription(): String = OCamlBundle.message("filetype.ml.description")
    override fun getDefaultExtension(): String = DEFAULT_EXTENSION
    override fun getIcon(): Icon = OCamlIcons.FileTypes.OCAML_SOURCE
    override fun getDisplayName(): String  = description
}

⚠️ There is no INSTANCE in the Kotlin Class as it's a static object.


Language Parsing

JetBrains uses the Backus–Naur form (BNF) language to define the parsing rules of a language. We create a .bnf syntax file, such as OCaml.bnf, and use JetBrains Grammar-Kit to generate a parser from it.

This section is the hardest ⚠️. The OCaml Manual along the extended page describe language elements and explanations in a BNF-like format, which you may use as a reference.

// Left: an element of the language
// Right: how the element is structured
file ::= expressions*

// Braces can be used to group expressions
// "?" means the expression is optional
// "*" means "0+" times the expression
// "+" means "1+" times the expression
// "|" is used for each alternative structure
expression ::= { A ";" B } * // Must quote characters
| (A | B)*                   // Alternative to braces
| another_expression         // Reference another element

// Brackets can be used to group an optional sequence
another_expression ::= [A B]
| (A B)?            // Alternative to brackets

⚠️ An element can reference itself as long as there is no "left recursive" call, e.g. the first element is not itself.

⚠️ The parser is applied on the Lexed tokens!

At the start of the file, we may configure the parser generation. Refer to the Generated Files Section to learn more about these.

{
    // Parser class
    parserClass="com.ocaml.language.parser.OCamlParser"
    // Generated class with all elements+tokens instances
    elementTypeHolderClass="com.ocaml.language.psi.OCamlTypes"

    // Generated files prefix/suffix
    psiClassPrefix="OCaml"
    psiImplClassSuffix="Impl"
    // Where to store generated files
    psiPackage="com.ocaml.language.psi"
    psiImplPackage="com.ocaml.language.psi.impl"

    // Base classes for elements/tokens instances
    // That we can access from the "elementTypeHolderClass"
    elementTypeClass="com.ocaml.language.psi.OCamlElementType"
    tokenTypeClass="com.ocaml.language.psi.OCamlTokenType"
    
    tokens=[
        AND = "and"
        NUMBER='regexp:\d+'
        [...]
    ]
    
    // ...
}

Inside the tokens, you can define the language tokens. For instance, SEMI=";" means you can use SEMI instead of ";". See also: Lexer.


Generated Parser Files

Right-click on the BNF syntax file to generate the parser code.

Psi Type Holder

The tool will also generate a elementTypeHolderClass with every an element type for each token and element of the language, allowing us to reference an element or a token in the code.

IElementType COMMENT = new OCamlTokenType("COMMENT");

The type of the tokens/elements in the type folder class can be customized using elementTypeClass and tokenTypeClass.

Example of Type Classes
// Elements are Composite Elements
// elementTypeClass="com.ocaml.language.psi.OCamlElementType"
class OCamlElementType(debugName: String) : IElementType(debugName, OCamlLanguage), ICompositeElementType {
    override fun createCompositeNode(): ASTNode {
        return OCamlTypes.Factory.createElement(this)
    }
}
// Tokens are IElementType tokens 
// tokenTypeClass="com.ocaml.language.psi.OCamlTokenType"
class OCamlTokenType(debugName: String) : IElementType(debugName, OCamlLanguage)

👉 See also: generateTokens=true (default).

Psi Files And Interfaces

The tool will generate an interface (stored in psiPackage) and its implementation (stored in psiImplPackage) for every element.

You can exclude some by marking them as private:

private abc ::= /* some definition */

Each interface will define getters allowing us to fetch the children elements. Methods/types are computed from the whole rule.

All interfaces extend PsiElement, but you can use your own interface:

// Add: implements='com.ocaml.language.psi.api.OCamlElement'
interface OCamlElement : PsiElement, UserDataHolderEx

All implementations extend ASTWrapperPsiElement, but you can use your own class:

// Add: extends='com.ocaml.language.psi.api.OCamlElementImpl'
abstract class OCamlElementImpl(type: IElementType) : CompositePsiElement(type)

👉 See also: generateTokenAccessors=false (default).


Lexical Analysis

Before parsing a file, we convert the stream of characters to a stream of tokens using a Lexer. Right-click on the BNF syntax file to generate the base lexer file. For instance, if we have /* xxx */ in the file, the lexer could return that this text is a COMMENT token.

  • Integers (5, 0x5, 0X5, 0xF, etc.)
  • Floats (5., 5.0, 0x9.5, etc.)
  • Chars (escape characters, etc.)
  • Strings (multilines string, '' and "", escape characters, etc.)
  • Comments (Multiline comments, documentation comments, etc.)
  • Operators (";", "(", ")", etc.)
  • Keywords ("and", "if", "fun", etc.)
  • ...

Right-click on the lexer file to generate a Lexer. We don't use the lexer directly in the code, so create an adapter. It should have the same package path as generated lexer class.

import com.intellij.lexer.FlexAdapter // generated

class OCamlLexerAdapter : FlexAdapter(_OCamlLexer(null))

An overview of a lexing file:

[...]          // class definition (omitted)
DIGIT=[0-9]    // variables
%state INITIAL // parsing states
%state IN_XXX
%%
<INITIAL> { // Link to the Type Holder Variables
   "and"       { return AND; } // OCamlTypes.AND
   "A" "B"     { return AB; }  // Same as "AB"
   // You can use variables, etc.
   {DIGIT}* ("i"|"I")? { return INTEGER_VALUE; }
   
   // you can move to custom state for complex tokens
   "/*" { yybegin(XXX); /* ... */ }
}

<IN_XXX> {
    "*/" { /*...*/; return BLOCK_COMMENT; }
    . { }
    <<EOF>> { /*...*/ }
}

// Every state can have a default case
[^] { return BAD_CHARACTER; }

Parser Definition

The parser definition connects all classes defined above:

internal class OCamlParserDefinition : ParserDefinition {
    override fun createLexer(project: Project?): Lexer = OCamlLexerAdapter()
    override fun getCommentTokens(): TokenSet = ParserDefinitionUtils.COMMENTS
    override fun getStringLiteralElements(): TokenSet = ParserDefinitionUtils.STRINGS
    override fun createParser(project: Project?): PsiParser = OCamlParser()
    override fun getFileNodeType(): IFileElementType = ParserDefinitionUtils.FILE
    override fun createFile(viewProvider: FileViewProvider): PsiFile = OCamlFile(viewProvider)
    override fun createElement(node: ASTNode?): PsiElement = OCamlTypes.Factory.createElement(node)

    object ParserDefinitionUtils {
        val FILE = IFileElementType(OCamlLanguage)
        val COMMENTS = TokenSet.create(OCamlTypes.COMMENT)
        val STRINGS = TokenSet.create(OCamlTypes.STRING_VALUE)
    }
}

📚 Add to getCommentTokens() any token that must be ignored by the parser, e.g., treated as a comment.

⚠️ Use OCamlTypes.Factory.createElement(node.elementType) when using CompositePsiElement.

The OCamlFile class:

import com.intellij.extapi.psi.PsiFileBase
import com.intellij.openapi.fileTypes.FileType
import com.intellij.psi.FileViewProvider
import com.ocaml.ide.files.OCamlFileType
import com.ocaml.ide.files.OCamlLanguage

class OCamlFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, OCamlLanguage) {
    override fun getFileType(): FileType = OCamlFileType
    override fun toString(): String = "OCaml File"
}

Add to the Manifest:

    <extensions defaultExtensionNs="com.intellij">
        <!-- PARSER -->
        <lang.parserDefinition language="OCaml" implementationClass="com.ocaml.language.parser.OCamlParserDefinition"/>
    </extensions>

Syntax Highlighter

The syntax highlighter is coloring the tokens that were lexed by the Lexer. For parsed elements, we need to use an annotator.

First, define colors:

import com.intellij.openapi.editor.colors.TextAttributesKey
import com.intellij.openapi.options.OptionsBundle
import com.intellij.openapi.options.colors.AttributesDescriptor
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors as Default

// Token(text_shown_in_settings, color_used)
// Use "//" in translations to group colors in folders
enum class OCamlColor(humanText: String, attr: TextAttributesKey? = null) {
    VARIABLE(OCamlBundle.message("settings.ocaml.color.variables.default"), Default.IDENTIFIER),
    
    // See also: OptionsBundle for existing texts
    BRACES(OptionsBundle.message("options.language.defaults.braces"), Default.BRACES),
    ;

    // For The Highlighter
    val textAttributesKey = TextAttributesKey.createTextAttributesKey("com.ocaml.$name", attr)
    // For Highlight Color Settings
    val attributesDescriptor = AttributesDescriptor(humanText, textAttributesKey)
}

Then we can write an highlighter:

import com.intellij.openapi.fileTypes.SyntaxHighlighter
import com.intellij.openapi.fileTypes.SyntaxHighlighterBase.pack
import com.intellij.psi.tree.IElementType

class OCamlSyntaxHighlighter : SyntaxHighlighter {
    override fun getHighlightingLexer(): Lexer = OCamlLexerAdapter()

    override fun getTokenHighlights(tokenType: IElementType): Array<TextAttributesKey> =
        pack(map(tokenType)?.textAttributesKey)

    companion object {
        fun map(tokenType: IElementType): OCamlColor? = when (tokenType) {
            CHAR_VALUE -> OCamlColor.CHAR
            // ...
            LPAREN, RPAREN -> OCamlColor.PARENTHESES
            // ...
            in OCAML_KEYWORDS -> OCamlColor.KEYWORD
            // ...
            else -> null
        }

        private val OCAML_KEYWORDS = setOf<IElementType>(
            AS, CLASS, ELSE, FOR, IF, // ...
        )
    }
}

You need to create a factory and add it to the Manifest:

// <lang.syntaxHighlighterFactory language="..." implementationClass="com.xxx.highlight.OCamlSyntaxHighlighterFactory"/>
class OCamlSyntaxHighlighterFactory : SyntaxHighlighterFactory() {
    override fun getSyntaxHighlighter(project: Project?, virtualFile: VirtualFile?): SyntaxHighlighter {
        return OCamlSyntaxHighlighter()
    }
}

To add the highlight color settings page in Editor > Color Scheme:

// <colorSettingsPage implementation="com.xxx.OCamlColorSettingsPage"/>
import com.intellij.openapi.editor.colors.TextAttributesKey
import com.intellij.openapi.fileTypes.SyntaxHighlighter
import com.intellij.openapi.options.colors.AttributesDescriptor
import com.intellij.openapi.options.colors.ColorDescriptor
import com.intellij.openapi.options.colors.ColorSettingsPage
import javax.swing.Icon

class OCamlColorSettingsPage : ColorSettingsPage {
    override fun getDisplayName(): String = "OCaml"
    override fun getIcon(): Icon = OCamlIcons.FileTypes.OCAML_SOURCE
    override fun getAttributeDescriptors() = Constants.ATTRS
    override fun getColorDescriptors(): Array<ColorDescriptor> = ColorDescriptor.EMPTY_ARRAY
    override fun getHighlighter(): SyntaxHighlighter = OCamlSyntaxHighlighter()
    override fun getAdditionalHighlightingTagToDescriptorMap() = Constants.ANNOTATOR_TAGS
    override fun getDemoText() = Constants.DEMO_TEXT

    internal object Constants {
        val ATTRS: Array<AttributesDescriptor> = OCamlColor.values().map{ it.attributesDescriptor }.toTypedArray()
        val ANNOTATOR_TAGS: Map<String, TextAttributesKey> = OCamlColor.values().associateBy({ it.name }, { it.textAttributesKey })
        val DEMO_TEXT: String by lazy {
            """
            (* write some code *)
            """
        }
    }

}

Customizing Parser Generated Classes

At the top of the file, you can define the extends/implements restrictions on elements and their interface.

{
    // We use a regex to select elements
    // implements: selected element will implement this interface
    implements("A|B")="com.intellij.psi.PsiNamedElement"
    // extends: selected elements implementation will inherit from this class
    extends(".*expr")=expr
    // ⚠️ You can have multiple "extends" but an element
    // is only associated to the first extends that matches it
}

You can alternatively define properties locally, e.g., after an element.

element ::= /* some definition */
{
    name = "my_element" // Generate class: {Prefix}MyElement
    methods = [getName setName]
    mixin="com.xxx.yyy.OCamlValDeclMixin"
    extends="com.xxx.yyy.XXX"
    implements = "com.intellij.psi.PsiNamedElement"
    //or: implements = ["com.intellij.psi.PsiNamedElement"]
}

Methods

Using methods, you can inject a method in the generated class. First, in the top-level block, add a psi implementation util class: psiImplUtilClass="com.xxx.OCamlImplUtils".

The first argument is the interface of the associated element. You can add as many argument as you need.

internal object OCamlImplUtils {
    @JvmStatic // my_element ::= and prefix = "XXX"
    fun getName(myElement: XXXMyElement): String? {
        return myElement.text
    }
    // ...
}

Mixins

Instead of using method injection, we can mix the generated class with the contents of another "mixin" class.

  • The generated class will inherit from the mixin class
  • The generated class will explicitly copy every constructor
  • The mixin class must extend an element implementation class, such as the OCamlElementImpl in the examples above.
// OCamlLetBinding is a generated interface
// Allowing us to use the implementation methods in the mixin
abstract class LetBindingMixin : OCamlElementImpl, OCamlLetBinding {

    // example of declaring multiple constructors
    constructor(type: IElementType) : super(type) {}
    constructor(node: ASTNode) : super(node.elementType) {}

    // declare methods
    override fun getNameIdentifier(): PsiElement? {
        TODO("Not yet implemented")
    }

    override fun getName(): String? = nameIdentifier?.text

    override fun setName(name: String): PsiElement {
        TODO("Not yet implemented")
    }
}

Interfaces

  • PsiNameIdentifierOwner: elements that contain a name identifier, e.g., an element that has a name
  • PsiNamedElement: elements that have a name
  • NavigatablePsiElement: elements that we can navigate to

Ensure the relevant elements implement the relevant interfaces.


Structure View

The structure view is a tab listing all variables, types/classes, functions, etc. of a file. Add to the Manifest:

<lang.psiStructureViewFactory language="..." implementationClass="com.xxx.OCamlStructureViewFactory"/>

And create the entrypoint factory:

import com.intellij.ide.structureView.StructureViewBuilder
import com.intellij.ide.structureView.StructureViewModel
import com.intellij.ide.structureView.TreeBasedStructureViewBuilder
import com.intellij.lang.PsiStructureViewFactory
import com.intellij.openapi.editor.Editor
import com.intellij.psi.PsiFile

internal class OCamlStructureViewFactory : PsiStructureViewFactory {
    override fun getStructureViewBuilder(psiFile: PsiFile): StructureViewBuilder {
        return object : TreeBasedStructureViewBuilder() {
            override fun createStructureViewModel(editor: Editor?): StructureViewModel {
                return OCamlStructureViewModel(editor, psiFile)
            }
        }
    }
}

And the model:

import com.intellij.ide.structureView.StructureViewModel
import com.intellij.ide.structureView.StructureViewModelBase
import com.intellij.ide.structureView.StructureViewTreeElement
import com.intellij.ide.util.treeView.smartTree.Filter
import com.intellij.ide.util.treeView.smartTree.Sorter
import com.intellij.openapi.editor.Editor
import com.intellij.psi.PsiFile

class OCamlStructureViewModel(editor: Editor?, psiFile: PsiFile) :
    StructureViewModelBase(psiFile, editor, OCamlStructureViewElement(psiFile)),
    StructureViewModel.ElementInfoProvider {

    override fun getSorters(): Array<Sorter> = arrayOf(Sorter.ALPHA_SORTER)
    override fun getFilters(): Array<Filter> = super.getFilters()
    override fun isAlwaysShowsPlus(element: StructureViewTreeElement): Boolean = element.value is OCamlFileBase // your Psi File Class
    override fun isAlwaysLeaf(element: StructureViewTreeElement): Boolean = when (element.value) {
        // list of generated interfaces of "always leaf" elements
        is OCamlLetBinding -> true
        else -> false
    }
}

The structure view element requires quite a lot of language-specific code. The base "template" would be something like this:

class OCamlStructureViewElement(element: PsiElement) : StructureViewTreeElement {
    private val psiAnchor = TreeAnchorizer.getService().createAnchor(element)
    private val myElement: PsiElement? get() = TreeAnchorizer.getService().retrieveElement(psiAnchor) as? PsiElement
    private val childElements: List<PsiElement>
        get() {
            return when (val psi = myElement) {
                else -> emptyList()
            }
        }

    override fun getValue(): PsiElement? = myElement
    override fun navigate(requestFocus: Boolean) { (myElement as? Navigatable)?.navigate(requestFocus) }
    override fun canNavigate(): Boolean = (myElement as? Navigatable)?.canNavigate() == true
    override fun canNavigateToSource(): Boolean = (myElement as? Navigatable)?.canNavigateToSource() == true
    override fun getPresentation(): ItemPresentation {
        return  PresentationData("unknown", "", null, null)
    }
    override fun getChildren(): Array<out TreeElement> =
        childElements.map2Array { OCamlStructureViewElement(it) }
}

You must then define how, starting from the PsiFile, we can get the list of children nodes in childElements.

// for instance, in OCaml (let_bindings="let x = 5 and y = 7"
is OCamlFile -> {
    psi.childrenOfType<OCamlLetBindings>() // all variables
}
// letBindingList=["x=5", "y=7"]
is OCamlLetBindings -> psi.letBindingList

And, you need to define how each element is rendered in the view:

override fun getPresentation(): ItemPresentation {
    return myElement?.let(::getPresentationForStructure)
        ?: PresentationData("unknown", null, null, null)
}
private fun getPresentationForStructure(psi: PsiElement): ItemPresentation {
    val presentation =  when(psi) { // text shown for each element
        is OCamlNamedElement -> psi.name
        else -> null
    }
    val icon = when(psi) { // icon shown | implement it in each element
        else -> psi.getIcon(Iconable.ICON_FLAG_VISIBILITY)
    }
    return PresentationData(presentation, null, icon, null)
}

➡️ See also related classes: SortableTreeElement, Queryable.


Project Wizard

The new project wizard is one of the possible implementation for the new project menu. It is used for Java, Kotlin, and other languages.

package com.ocaml.ide.module.wizard

import com.intellij.ide.wizard.AbstractNewProjectWizardMultiStep
import com.intellij.ide.wizard.NewProjectWizardStep
import com.intellij.ide.wizard.language.LanguageGeneratorNewProjectWizard
import com.ocaml.OCamlBundle.message
import com.ocaml.icons.OCamlIcons
import com.ocaml.ide.module.wizard.buildSystem.BuildSystemOCamlNewProjectWizard
import javax.swing.Icon

class OCamlNewProjectWizard : LanguageGeneratorNewProjectWizard {
    override val icon: Icon get() = OCamlIcons.Nodes.OCAML_MODULE
    override val name: String = message("language.name")
    override val ordinal: Int = 200

    override fun createStep(parent: NewProjectWizardStep): NewProjectWizardStep = OCamlNewProjectWizardStep(parent)

    class OCamlNewProjectWizardStep(parent: NewProjectWizardStep) :
        AbstractNewProjectWizardMultiStep<OCamlNewProjectWizardStep, BuildSystemOCamlNewProjectWizard>(parent, BuildSystemOCamlNewProjectWizard.EP_NAME)
    {
        override val label: String  get() = message("project.wizard.build.system")
        override val self: OCamlNewProjectWizardStep get() = this
    }
}

The code is based on "build systems" such as the buttons "Gradle", "Maven", and "IntelliJ" for the Java Wizard. We need to declare a new extension point and create at least one implementation (such as Dune).

<extensionPoints>
    <extensionPoint qualifiedName="com.intellij.newProjectWizard.ocaml.buildSystem" interface="com.ocaml.ide.module.wizard.buildSystem.BuildSystemOCamlNewProjectWizard" dynamic="true"/>
</extensionPoints>

<extensions defaultExtensionNs="com.intellij">
<newProjectWizard.languageGenerator implementation="com.ocaml.ide.module.wizard.OCamlNewProjectWizard"/>
<newProjectWizard.ocaml.buildSystem implementation="com.ocaml.ide.module.wizard.buildSystem.OCamlDefaultBuildSystemWizard" />
<!-- ... -->
</extensions>
import com.intellij.ide.util.projectWizard.WizardContext
import com.intellij.ide.wizard.NewProjectWizardMultiStepFactory
import com.intellij.openapi.extensions.ExtensionPointName
import com.ocaml.ide.module.wizard.OCamlNewProjectWizard

interface BuildSystemOCamlNewProjectWizard : NewProjectWizardMultiStepFactory<OCamlNewProjectWizard.OCamlNewProjectWizardStep> {
    override fun isEnabled(context: WizardContext): Boolean = true

    companion object {
        val EP_NAME = ExtensionPointName<BuildSystemOCamlNewProjectWizard>("com.intellij.newProjectWizard.ocaml.buildSystem")
    }
}

We will add logic specific to the build system in each wizard.

class OCamlDuneBuildSystemWizard : OCamlDefaultBuildSystemWizard() {
    override val name: String = message("project.wizard.build.system.dune")
    override fun createStep(parent: OCamlNewProjectWizard.OCamlNewProjectWizardStep): NewProjectWizardStep = Step(parent)

    private class Step(parent: OCamlNewProjectWizard.OCamlNewProjectWizardStep) : OCamlNewProjectWizardBaseStep(parent) {
        override fun setupUI(builder: Panel) {
            super.setupUI(builder)
            // Specific to Dune
        }
    }
}

open class OCamlNewProjectWizardBaseStep(parent: OCamlNewProjectWizard.OCamlNewProjectWizardStep) : AbstractNewProjectWizardStep(parent) {
    override fun setupUI(builder: Panel) {
        super.setupUI(builder)
        // For every subclass
    }
}

Inside IntelliJ, e.g., with the JAVA plugin, we can use classes such as IntelliJNewProjectWizardStep to have a JAVA-like menu.

When the client is done, setupProject is invoked. Refer to the aforementioned class for a possible implementation. It loads the selected SDK and configure the builder with the parameters.

    override fun setupProject(project: Project) {
        super.setupProject(project)
        setupProject(project, OCamlModuleBuilder())
    }

Don't forget to register the module builder and the module type:

<moduleBuilder builderClass="com.ocaml.ide.module.OCamlModuleBuilder" order="first"/>
<moduleType id="OCAML_MODULE" implementationClass="com.ocaml.ide.module.OCamlIdeaModuleType"/>

Refer to OCamlNewProjectWizardAssetStep to understand how we can create IntelliJ files and load a template. To be fair, I am adding hardcoded files without any variable to inject, but if I didn't, I would use "internal templates" and refer to AssetsJavaNewProjectWizardStep.

moduleType is deprecated but there is no clear alternative...


Editor and Actions

CreateNewFile

To create a new file, refer to OCamlCreateFileAction. The content of the new files is based on files in fileTemplates/internal.

    <extensions defaultExtensionNs="com.intellij">
        <internalFileTemplate name="OCaml File"/>
        <internalFileTemplate name="OCaml Interface"/>
    </extensions>

    <actions>
        <!-- Create file -->
        <action id="NewOCamlFile" class="com.ocaml.ide.files.actions.OCamlCreateFileAction">
            <add-to-group group-id="NewGroup" anchor="before" relative-to-action="NewFile"/>
        </action>
    </actions>

SDK and Libraries

Setting an SDK is essentially the same as adding a library to the project. The files added can be browsed and referenced/navigated to once we implement the said features.

<sdkType implementation="com.ocaml.sdk.OCamlSdkType"/>
<sdkDownload implementation="com.ocaml.sdk.OCamlSdkType"/>

When we navigate to "Project Structure > SDKs", we can see the files loaded by IntelliJ. This is the result of:

    override fun isRootTypeApplicable(type: OrderRootType): Boolean = type === OrderRootType.CLASSES
    override fun setupSdkPaths(sdk: Sdk) {
        val homePath = checkNotNull(sdk.homePath) { sdk }
        val sdkModificator = sdk.sdkModificator
        sdkModificator.removeRoots(OrderRootType.CLASSES)
        addSources(File(homePath), sdkModificator)
        // 0.0.6 - added by default
        sdkModificator.addRoot(getDefaultDocumentationUrl(sdk), OrderRootType.DOCUMENTATION)
        sdkModificator.addRoot(OCamlSdkWebsiteUtils.getApiURL(sdk.versionString!!), OrderRootType.DOCUMENTATION)
        sdkModificator.commitChanges()
    }

We can add additional tabs such as Documentation tabs, but we cannot edit the SDK to match the change in settings from the UI (JB changed something, I can't do it anymore, getting some error about Bridges and Elements when trying to edit the SDK by following their short documentation).

    override fun createAdditionalDataConfigurable(sdkModel: SdkModel, sdkModificator: SdkModificator) = OCamlSdkAdditionalDataConfigurable()

    override fun loadAdditionalData(currentSdk: Sdk, additional: Element): SdkAdditionalData {
        // ...
    }

    override fun saveAdditionalData(additionalData: SdkAdditionalData, additional: Element) {
        // ...
    }

We often want to notify the user when the SDK is not set, as it often limit what the plugin can do. We can show a message at the top of a source file using ProjectSdkSetupValidator.

<projectSdkSetupValidator implementation="com.ocaml.sdk.validator.OCamlSDKValidator"/>
class OCamlSDKValidator : ProjectSdkSetupValidator {
    override fun isApplicableFor(project: Project, file: VirtualFile): Boolean {
        // checks if the file is a source file (+in the project)
    }

    override fun getErrorMessage(project: Project, file: VirtualFile): String? {
        // Null == no error, otherwise, the error message shown
    }

    override fun getFixHandler(project: Project, file: VirtualFile): EditorNotificationPanel.ActionHandler {
        // filter which SDK is suggested
    }
}

While it's purely visual, you can change the SDK library root icons or hide non-source files. Refer to OCamlLibraryRootsNodeDecorator and OCamlLibraryRootsTreeStructureProvider.

Additional notes:

val moduleSdk : Sdk? = ModuleRootManager.getInstance(module).sdk;
val xxx = ProjectJdkTable.getInstance()

WSL-related notes:

val isWSLPath = WslPath.isWslUncPath(path)

Build and Run

RunLineMarkerContributor

You can show the "Run icon" next to the "main class/function" or any PsiElement by implementing RunLineMarkerContributor. Refer to DuneTargetRunLineMarkerContributor and DuneRunTargetAction.

RunConfigurationProducer

This interface must be implemented when we want to generate a runConfiguration from a PSI element. Refer to DuneRunConfigurationProducer. We are essentially filling the runConfiguration with data in setupConfigurationFromContext.

SettingsEditor

SettingsEditor and SettingsEditorGroup are both classes that you may use when building the UI for your run configuration.

Use the applyEditorTo to save into the configuration attributes the values from the UI. Use resetEditorFrom to load into the UI values from the configuration.

Refer to DuneRunConfigurationEditor.

RunConfiguration

A runConfiguration is a configuration to run a program while it often includes a "build" step preceding the run step.

Possible parent classes:

  • ModuleBasedConfiguration<RunConfigurationModule, Element>(...): provide access to configurationModule attribute which is used to store a module. It's handy when you want to use the SDK of a module. Also, it includes a "build" step before "run".

  • LocatableConfigurationBase<RunProfileState>(...): a simpler version of the class above.

Interesting methods:

  • checkConfiguration: raise an exception if the config is incorrect
  • {read/write}External: load/save the configuration attributes
  • getState: returns the command to execute
  • getConfigurationEditor: returns the main panel of the settings configuration editor. See also: SettingsEditorGroup.

Refer to DuneRunConfigurationor OCamlRunConfiguration.


Random Features

Spell Checker

You can define which elements should be analyzed by the spell checker using: <spellchecker.support language="..." implementationClass="com.xxx.OCamlSpellcheckingStrategy"/>

import com.intellij.psi.PsiElement
import com.intellij.spellchecker.tokenizer.SpellcheckingStrategy
import com.intellij.spellchecker.tokenizer.Tokenizer
import com.ocaml.ide.files.OCamlLanguage
import com.ocaml.language.psi.OCamlTypes

class OCamlSpellcheckingStrategy : SpellcheckingStrategy() {
    override fun isMyContext(element: PsiElement) = OCamlLanguage.isKindOf(element.language)

    override fun getTokenizer(element: PsiElement?): Tokenizer<*> = when {
        element?.node?.elementType == OCamlTypes.STRING_LITERAL -> TEXT_TOKENIZER
        // add variables, etc.
        else -> super.getTokenizer(element)
    }
}

The default tokenizer can handle comments, but it doesn't handle elements of the language such as STRINGS or variable names etc.

For instance, assuming that OCamlNameIdentifierOwner is an element that own an element that has a name, we would add element is OCamlNameIdentifierOwner -> OCamlNameIdentifierOwnerTokenizer.

We would then develop a custom tokenizer:

import com.intellij.spellchecker.inspections.IdentifierSplitter
import com.intellij.spellchecker.tokenizer.TokenConsumer
import com.intellij.spellchecker.tokenizer.Tokenizer

object OCamlNameIdentifierOwnerTokenizer : Tokenizer<OCamlNameIdentifierOwner>() {
    override fun tokenize(element: OCamlNameIdentifierOwner, consumer: TokenConsumer) {
        val identifier = element.nameIdentifier ?: return
        consumer.consumeToken(identifier, IdentifierSplitter.getInstance())
        // you can add a more complex logic here
    }
}

Commenter

We can use <lang.commenter language="..." implementationClass="com.xxx.OCamlCommenter"/> to support the comment line (CTRL+/) and the comment block (CTRL+SHIFT+/) features.

import com.intellij.lang.Commenter

class OCamlCommenter : Commenter {
    override fun getLineCommentPrefix(): String? = "//"
    override fun getBlockCommentPrefix(): String  = "/*"
    override fun getBlockCommentSuffix(): String  = "*/"
    override fun getCommentedBlockCommentPrefix(): String  = "/*"
    override fun getCommentedBlockCommentSuffix(): String  = "*/"
}

It automatically supports uncommenting. Related classes: CommenterDataHolder, CustomUncommenter, SelfManagingCommenter<T>.

Braces Matching

You can add support to automatically detect the matching brace/token using <lang.braceMatcher language="..." implementationClass="com.xxx.OCamlBraceMatcher"/>:

package com.ocaml.ide.typing

import com.intellij.lang.BracePair
import com.intellij.lang.PairedBraceMatcher
import com.intellij.psi.PsiFile
import com.intellij.psi.tree.IElementType
import com.ocaml.language.psi.OCamlTypes

class OCamlBraceMatcher : PairedBraceMatcher {
    override fun getPairs() = Constants.PAIRS

    override fun isPairedBracesAllowedBeforeType(lbraceType: IElementType, next: IElementType?): Boolean = 
        true
    override fun getCodeConstructStart(file: PsiFile?, openingBraceOffset: Int): Int = 
        openingBraceOffset

    internal object Constants {
        val PAIRS: Array<BracePair> = arrayOf(
            BracePair(OCamlTypes.LBRACE, OCamlTypes.RBRACE, false),
            BracePair(OCamlTypes.BEGIN, OCamlTypes.END, false),
            // ...
        )
    }
}

Utility classes

PathMacroManager

Handle variables in paths.

val macroManager = PathMacroManager.getInstance(context.project)
// replace path with variables
val newPath = macroManager.collapsePath(path)
// replace variables with their values
val path = macroManager.expandPath(newPath)

ModuleUtilCore

Manipulate modules.

val module = ModuleUtilCore.findModuleForFile(virtualFile, project)

👻 To-do 👻

Stuff that I found, but never read/used yet.

MyBundle.message("applicationService")
MyBundle.message("projectService", project.name)

System.getenv("CI")

project.service<MyProjectService>()

val instance: OCamlSdkType?
    get() = EP_NAME.findExtension(OCamlSdkType::class.java)
    
// Parse input with macros
PathMacroManager.getInstance(project).expandPath(input)

// when you cannot run something on EDT
ApplicationManager.getApplication().executeOnPooledThread {}
Split the XML into sub-files
-<idea-plugin>
+<idea-plugin xmlns:xi="http://www.w3.org/2001/XInclude">

+<xi:include href="/META-INF/other.xml" xpointer="xpointer(/idea-plugin/*)"/>

Stubs

  • IStubElementType
  • stubClass=""
  • elementTypeFactory="""

BNF Grammar File

elementTypeFactory(".*") = "com.xxx.yyy.AAA"
consumeTokenMethod(".*") = "consumeTokenFast"

Random

TreeAnchorizer.getService().createAnchor(element)
TreeAnchorizer.getService().retrieveElement(psiAnchor)