VSCode не позволяет установить несколько языков одновременно для одного документа.

Авторы расширений могут открыть текстовый документ для определенного языка:

await vscode.workspace.openTextDocument({ language: 'mongodb', content });

Или установите язык для существующего текстового документа:

await vscode.languages.setTextDocumentLanguage(document, 'javascript');

Но есть много случаев использования, когда документ должен содержать несколько языков одновременно, таких как HTML и CSS, или расширять существующий язык дополнительным синтаксисом, как в случае с TypeScript и JavaScript.

Если вы создаете функциональность поверх существующего языка, вы можете захотеть написать только пользовательские функции и повторно использовать те, которые уже доступны.

VSCode обеспечивает первоклассную поддержку функций для распространенных языков, таких как JavaScript: подсветка синтаксиса, автозаполнение переменных, функций и выражений, рефакторинг кода, линтинг и форматирование кода.

Если вы хотите расширить такой язык, как JavaScript, у вас есть два варианта:

  • Разработайте расширение для работы с языком JavaScript, приняв все его языковые функции и расширив их там, где это применимо.
  • Или создайте собственный язык с полным контролем над его функциями, но если вам понадобится что-то из набора функций JavaScript, вы добавите это самостоятельно.

В первом сценарии вы можете положиться на существующие функции JS и избежать проблем с их повторной реализацией. Однако такой подход может ограничить ваши возможности настройки и даже привести к непредвиденному поведению, например к тому, что правила подсветки синтаксиса не будут работать должным образом.

Подсветка синтаксиса

VSCode реализует подсветку синтаксиса через грамматики TextMate. Вы можете написать грамматику с нуля или внедрить дополнительный синтаксис в один из существующих.

Грамматики вставки добавляются через package.json, где injectTo указывает целевой язык.

"grammars": [{
  "path": "./syntaxes/mongodb.tmLanguage.json",
  "scopeName": "mongodb.injection",
  "injectTo": ["source.js"]
}]

Используя инспектор области, вы можете найти селектор внедрения, чтобы указать, в какой области должна применяться введенная грамматика.

{
  "scopeName": "mongodb.injection",
  "injectionSelector": "L:meta.objectliteral.js",
  "patterns": [{
    "include": "#object-member"
  }],
  "repository": {
    "object-member": {
      "patterns": [{
        "name": "meta.object.member.mongodb",
        "match": "\\$match\\b",
        "captures": { "0": { "name": "keyword.other.match.mongodb" } }
      }]
    }
  }
}

L: в селекторе внедрения означает, что внедрение добавляется слева от существующих правил грамматики, т. е. введенные правила грамматики будут применяться перед любыми существующими правилами грамматики.

Введя новый шаблон $match, я стремился расширить возможности языка JavaScript и назначить особый цвет ключевому слову MongoDB, чтобы отделить его от других свойств объекта. Однако моим усилиям помешали ограничения семантической подсветки JavaScript, которая автоматически переопределяет любые настройки, сделанные для исходных токенов.

Токены TM могут быть только приближением анализируемого языка/AST и не могут извлечь выгоду из семантического анализа. Вот почему «мы приняли дизайнерское решение, согласно которому семантические токены всегда перезаписывают токены ТМ. Перезапись работает как маска, поэтому, если диапазон не покрывается семантическим токеном, вместо него используется токен TM».

Если вы попробуете этот пример, вы заметите, что ключевое слово $match окрашивается в течение нескольких секунд, а затем семантическая подсветка меняет цвет синтаксиса.

В настоящее время нет другого способа решить эту проблему, кроме как отключить семантическую подсветку для вашей текущей рабочей области или для всех тем, которые вы используете.

"editor.semanticHighlighting.enabled": false

Если вас не устраивает это решение, использование только JavaScript может оказаться недостаточным для вашего расширения, и вам следует подумать о добавлении поддержки пользовательского языка.

Предоставление пользовательского языка

Точка вклада languages в package.json позволяет определить новый идентификатор языка и связать его с пользовательской грамматикой.

"contributes": {
  "languages": [{
    "id": "mongodb",
    "aliases": ["MongoDB", "mongodb"],
    "extensions": [".mongodb"]
  }],
  "grammars": [{
    "language": "mongodb",
    "scopeName": "source.mongodb",
    "path": "./syntaxes/mongodb.tmLanguage.json"
  }]
}

Грамматика MongoDB-TmLanguage является производной от TypeScript-TmLanguage путем расширения ее синтаксиса ключевыми словами MongoDB для обеспечения пользовательской подсветки синтаксиса.

Если вы переключите документ на язык JavaScript, вы потеряете выделение ключевых слов $match, $group, $sum и т. д.

Технически вы можете применить грамматику MongoDB-TmLanguage к идентификатору языка JavaScript, но вы, вероятно, не захотите перезаписывать нативную подсветку синтаксиса JavaScript для всех файлов JavaScript, открытых в VSCode.

Грамматики отвечают только за подсветку синтаксиса.

Однако разработчики часто ожидают большего от языковой поддержки, т.е. автозаполнение переменных, функций и выражений, рефакторинг кода, линтинг и форматирование кода. Можно реализовать функции языка программирования с помощью languages.* API или воспользоваться инструментами, предоставляемыми Language Server.

В этой статье мы создадим простое Language Server Extension, которое обеспечивает завершение как MongoDB, так и JavaScript для документа, открытого с помощью языка MongoDB. IntelliSense — неотъемлемая часть любой IDE, которая играет важную роль в обеспечении бесперебойной и продуктивной разработки для пользователей.

Один документ на двух языках

Исходный код доступен по адресу alenakhineika/vscode-js-languageservice-sample. В оставшейся части статьи предполагается, что вы знакомы с VSCode Extension API.

Когда языковой сервер инициализируется, он регистрирует метод onCompletion, который вызывается клиентом каждый раз, когда пользователь вводит триггерный символ для запроса предложений завершения.

// server/src/server.ts
import LanguageService from './tsLanguageService';

// The TypeScript language service.
const tsLanguageService = new LanguageService();

connection.onInitialize((params: InitializeParams) => {
  const capabilities = params.capabilities;
  return {
    capabilities: {
      textDocumentSync: TextDocumentSyncKind.Incremental,
      // Tell the client that the server supports code completion.
      completionProvider: {
        resolveProvider: true,
        triggerCharacters: ['.'],
      },
    },
  };
});

// Provide completion items.
connection.onCompletion(async (params: TextDocumentPositionParams) => {
  const document = documents.get(params.textDocument.uri);

  if (!document) {
    return [];
  }

  return tsLanguageService.doComplete(document, params.position);
});

С tsLanguageService мы используем логику завершения из расширения TypeScript, встроенного в VSCode.

// server/src/tsLanguageService.ts
import * as ts from 'typescript';
import { CompletionItem } from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';

import { loadLibrary } from './loadLibrary';
import { convertKind } from './convertKind';

type JavascriptServiceHost = {
  getLanguageService(jsDocument: TextDocument): ts.LanguageService;
  getCompilationSettings(): ts.CompilerOptions;
  dispose(): void;
};

export default class LanguageService {
  _host: JavascriptServiceHost;

  constructor() {
    this._host = this._getJavaScriptServiceHost();
  }

  _getJavaScriptServiceHost() {
    const compilerOptions = {
      allowNonTsExtensions: true,
      allowJs: true,
      target: ts.ScriptTarget.Latest,
      moduleResolution: ts.ModuleResolutionKind.Classic,
      experimentalDecorators: false,
    };
    let currentTextDocument = TextDocument.create('init', 'javascript', 1, '');

    const host: ts.LanguageServiceHost = {
      getCompilationSettings: () => compilerOptions,
      getScriptFileNames: () => [
        currentTextDocument.uri,
        'global.d.ts',
      ],
      getScriptKind: () => ts.ScriptKind.JS,
      getScriptVersion: (fileName: string) => {
        if (fileName === currentTextDocument.uri) {
          return String(currentTextDocument.version);
        }
        return '1';
      },
      getScriptSnapshot: (fileName: string) => {
        let text = '';
        if (fileName === currentTextDocument.uri) {
          text = currentTextDocument.getText();
        } else {
          text = loadLibrary(fileName);
        }
        return {
          getText: (start, end) => text.substring(start, end),
          getLength: () => text.length,
          getChangeRange: () => undefined,
        };
      },
      getCurrentDirectory: () => '',
      getDefaultLibFileName: () => 'lib.es2022.full.d.ts',
      readFile: (): string | undefined => undefined,
      fileExists: (): boolean => false,
      directoryExists: (): boolean => false,
    };

    const languageService = ts.createLanguageService(host);

    return {
      // Return a language service instance for a document.
      getLanguageService(jsDocument: TextDocument): ts.LanguageService {
        currentTextDocument = jsDocument;
        return languageService;
      },
      getCompilationSettings() {
        return compilerOptions;
      },
      dispose() {
        languageService.dispose();
      },
    };
  }

  async doComplete(
    document: TextDocument,
    position: { line: number; character: number },
  ) {
    const jsDocument = TextDocument.create(
      document.uri,
      'javascript',
      document.version,
      document.getText()
    );
    const languageService = await this._host.getLanguageService(jsDocument);
    const offset = jsDocument.offsetAt(position);
    const jsCompletion = languageService.getCompletionsAtPosition(
      jsDocument.uri,
      offset,
      {
        includeExternalModuleExports: false,
        includeInsertTextCompletions: false,
      }
    );

    return jsCompletion?.entries.map((entry) => {
      const data = {
        languageId: 'javascript',
        uri: document.uri,
        offset: offset
      };
      return {
        uri: document.uri,
        position: position,
        label: entry.name,
        sortText: entry.sortText,
        kind: convertKind(entry.kind),
        data
      };
    }) || [];
  }
}

Метод getCompilationSettings определяет параметры, которые будут использоваться в процессе компиляции. Эти параметры включают такие вещи, как версия на целевом языке, формат модуля и другие параметры.

Метод getScriptKind может анализировать текущий файл, чтобы определить, является ли он TypeScript, JavaScript, JSON и т. д. Но мы возвращаем ts.ScriptKind.JS, прямо указывающее, что текущий скрипт всегда является файлом JavaScript.

Метод getDefaultLibFileName указывает, где можно найти определения TypeScript по умолчанию.

getScriptFileNames - самый интересный метод. Мы можем использовать его, чтобы указать дополнительные источники определений типов, которые будут обрабатываться службой TypeScript для разрешения элементов завершения, сигнатур справки и других функций языка.

// global.d.ts declares the custom methods.
declare global {
  let mongodbMethod: (dbName: string) => void;
}
export {};

Теперь, если вы активируете IntelliSense, вы увидите не только соответствующие завершения JavaScript, но и наш дополнительный метод MongoDB 🎉

Возможность использовать сервис TypeScript таким образом значительно упрощает настраиваемое автозаполнение. В противном случае вы, вероятно, использовали бы @babel/parser или любой другой синтаксический анализатор для анализа и разметки исходного кода и создания списка элементов завершения, подходящих для текущего контекста, на основе позиции триггерного символа.

import type * as babel from '@babel/core';
import * as parser from '@babel/parser';
import traverse from '@babel/traverse';

let isGlobalSymbol;

const visitExpressionStatement = (path: babel.NodePath) => {
  if (
    path.node.type === 'ExpressionStatement' &&
    path.node.expression.type === 'Identifier' &&
    path.node.expression.name.includes('TRIGGER_CHARACTER') &&
  ) {
    isGlobalSymbol = true;
  }
}

const parseAST = ({ textFromEditor, selection }) => {
  let ast;
  try {
    ast = parser.parse(textFromEditor, {
      // Parse in strict mode and allow module declarations.
      sourceType: 'module',
    });
  } catch (error) { /** Handle error */ }
  
  traverse(ast, {
    enter: (path: babel.NodePath) => {
      visitExpressionStatement(path);
    },
  });
}

К счастью, нам не нужно делать это вручную, и мы можем делегировать работу сервису TypeScript.

Вы можете поэкспериментировать с ним и добавить в расширение дополнительные языковые функции. Например, вы можете использовать существующую конфигурацию языковой службы, чтобы предоставить сигнатуры справки для методов, объявленных в файле global.d.ts.

// server/src/tsLanguageService.ts
doSignatureHelp(
  document: TextDocument,
  position: Position
): Promise<SignatureHelp | null> {
  const jsDocument = TextDocument.create(
    document.uri,
    'javascript',
    document.version,
    document.getText()
  );
  const languageService = this._host.getLanguageService(jsDocument);
  const signHelp = languageService.getSignatureHelpItems(
    jsDocument.uri,
    jsDocument.offsetAt(position),
   undefined
  );

  if (signHelp) {
    const ret: SignatureHelp = {
      activeSignature: signHelp.selectedItemIndex,
      activeParameter: signHelp.argumentIndex,
      signatures: [],
    };
    signHelp.items.forEach((item) => {
      const signature: SignatureInformation = {
        label: '',
        documentation: undefined,
        parameters: [],
      };

      signature.label += ts.displayPartsToString(item.prefixDisplayParts);
      item.parameters.forEach((p, i, a) => {
        const label = ts.displayPartsToString(p.displayParts);
        const parameter: ParameterInformation = {
          label: label,
          documentation: ts.displayPartsToString(p.documentation),
      };
      signature.label += label;
      signature.parameters?.push(parameter);
      if (i < a.length - 1) {
        signature.label += ts.displayPartsToString(
          item.separatorDisplayParts
        );
       }
      });
      signature.label += ts.displayPartsToString(item.suffixDisplayParts);
      ret.signatures.push(signature);
    });
    return Promise.resolve(ret);
  }
  return Promise.resolve(null);
}

Вы также должны сообщить языковому клиенту VSCode, что сервер поддерживает подписи справки.

connection.onInitialize((params: InitializeParams) => {
  const capabilities = params.capabilities;
  return {
    capabilities: {
      textDocumentSync: TextDocumentSyncKind.Incremental,
      // Tell the client that the server supports code completion.
      completionProvider: {
        resolveProvider: true,
        triggerCharacters: ['.'],
      },
      signatureHelpProvider: {
        resolveProvider: true,
        triggerCharacters: [',', '('],
      },
    },
  };
});

Теперь, когда вы откроете скобку после метода mongodbMethod, VSCode покажет вам документацию по методу.

VSCode Embedded Languages ​​API может сделать языковой сервер еще умнее и разбить документ на языковые регионы, а также использовать соответствующую языковую службу для обработки запросов языкового сервера. Например, vscode-css-languageservice обеспечивает поддержку CSS в файлах HTML. Для областей, начинающихся с <|, служба обеспечивает завершение HTML, а внутри блоков <style>.foo { | }</style> завершает CSS.

Я считаю полезным взглянуть на исходный код VSCode и TypeScript, чтобы понять, как устроен API и как его можно использовать. Вы не всегда можете делать то, к чему у расширений VSCode есть доступ. В любом случае, это отличный учебный ресурс, который эффективно проясняет многие непонятные области.