Search code examples
visual-studio-codevscode-extensionstmlanguage

Can a language in Visual Studio Code be extended?


Scenario

I have JSON files that describe a series of tasks to be carried out, where each task can refer to other tasks and objects in the JSON file.

{
    "tasks": [
        { "id": "first", "action": "doSomething()", "result": {} },
        { "id": "second", "action": "doSomething(${id:first.result})", "result": {} },
    ]
}

I'd like to have both JSON schema validation and custom language text effects like keyword coloring and even "Go to definition" support within strings in the JSON.

What I can do

I can create an extension that specifies a JSON schema for a file extension "*.foo.json". This gives schema validation and code completion in the editor if vscode recognizes the file as a JSON file.

I can also create a new "foo" language in the extension for "*.foo.json" files that has custom keyword coloring within the JSON strings. I do this by creating a TextMate (*.tmLanguage.json) file copied from the JSON.tmLanguage.json, and then modifying the "stringcontent" definition.

Problem

The problem is that the schema validation and hints only work if I have "JSON" chosen in the status bar as the file type, and the custom text coloring only works if I have "foo" chosen in the status bar as the file type.

Is there any way to have both at once? Can I somehow extend the JSON language handling within vscode?


Solution

  • With some help from the vscode team, the code below got things working.

    Syntax highlighting within a JSON string literal

    package.json

      ...
      "activationEvents": [
          "onLanguage:json",
          "onLanguage:jsonc"
      ],
      "main": "./src/extension",
      "dependencies": {
          "jsonc": "^0.1.0",
          "jsonc-parser": "^1.0.0",
          "vscode-nls": "^3.2.1"
      },
      ...
    

    src/extension.js

    'use strict';
    
    const path = require( 'path' );
    const vscode = require( 'vscode' );
    const { getLocation, visit, parse, ParseError, ParseErrorCode } = require( 'jsonc-parser' );
    
    module.exports = {
        activate
    };
    
    let pendingFooJsonDecoration;
    
    const decoration = vscode.window.createTextEditorDecorationType( {
        color: '#04f1f9' // something like cyan
    } );
    
    // wire up *.foo.json decorations
    function activate ( context /* vscode.ExtensionContext */) {
    
        // decorate when changing the active editor editor
        context.subscriptions.push( vscode.window.onDidChangeActiveTextEditor( editor => updateFooJsonDecorations( editor ), null, context.subscriptions ) );
    
        // decorate when the document changes
        context.subscriptions.push( vscode.workspace.onDidChangeTextDocument( event => {
            if ( vscode.window.activeTextEditor && event.document === vscode.window.activeTextEditor.document ) {
                if ( pendingFooJsonDecoration ) {
                    clearTimeout( pendingFooJsonDecoration );
                }
                pendingFooJsonDecoration = setTimeout( () => updateFooJsonDecorations( vscode.window.activeTextEditor ), 1000);
            }
        }, null, context.subscriptions ) );
    
        // decorate the active editor now
        updateFooJsonDecorations( vscode.window.activeTextEditor );
    
        // decorate when then cursor moves
        context.subscriptions.push( new EditorEventHandler() );
    }
    
    const substitutionRegex = /\$\{[\w\:\.]+\}/g;
    function updateFooJsonDecorations ( editor /* vscode.TextEditor */ ) {
        if ( !editor || !path.basename( editor.document.fileName ).endsWith( '.foo.json' ) ) {
            return;
        }
    
        const ranges /* vscode.Range[] */ = [];
        visit( editor.document.getText(), {
            onLiteralValue: ( value, offset, length ) => {
                const matches = [];
                let match;
                while ( ( match = substitutionRegex.exec( value ) ) !== null) {
                    matches.push( match );
                    const start = offset + match.index + 1;
                    const end = match.index + 1 + offset + match[ 0 ].length;
    
                    ranges.push( new vscode.Range( editor.document.positionAt( start ), editor.document.positionAt( end ) ) );
                }
            }
        });
    
        editor.setDecorations( decoration, ranges );
    }
    
    class EditorEventHandler {
    
        constructor () {
            let subscriptions /*: Disposable[] */ = [];
            vscode.window.onDidChangeTextEditorSelection( ( e /* TextEditorSelectionChangeEvent */ ) => {
                if ( e.textEditor === vscode.window.activeTextEditor) {
                    updateFooJsonDecorations( e.textEditor );
                }
            }, this, subscriptions );
            this._disposable = vscode.Disposable.from( ...subscriptions );    
        }
    
        dispose () {
            this._disposable.dispose();
        }
    }