TypescriptServerPlugin_VSCode插件开发笔记3

一.需求场景

VS Code能够正确支持JS/TS跳转到定义、补全提示等功能,但仅限于符合Node Module Resolution以及TypeScript Module Resolution规则的模块引用,如:

// 标准import
import {fn1, fn2} from './myModule.js';

// node modules引用
var x = require("./moduleB");

// TypeScript
// 允许省略后缀名
import {fn1, fn2} from './myModule';
// 支持baseUrl
import { localize } from 'vs/nls';

如果是其它自定义import规则,这些功能都将不可用(无法跳转、没有提示、没有Lint校验……),例如:

import规则特殊导致这些关键功能不可用,对于建立在类似构建工具上的项目而言是个痛点,期望通过插件来解决

二.实现思路

立足VS Code插件机制,想要支持特殊import规则的话,有2种方式:

前者是通用的Definition扩展方式,具体案例见Show Definitions of a Symbol;后者仅适用于JS/TS,但功能更强大(不限于Definition)

三.registerDefinitionProvider

通过registerDefinitionProvider实现自己的DefinitionProvider是最常见的Go to Definition扩展方式,但存在2个问题

  • 缺少语义支持:仅能获得当前Document以及跳转动作发生的行列位置,没有提供任何代码语义相关的信息。比如是个import语句还是属性访问,是个require函数调用还是字符串字面量,这些关键信息都没有暴露出来

  • 易出现Definition冲突:发生Go to Definition动作时,所有插件(VS Code内置)注册的相关DefinitionProvider都会触发执行,而DefinitionProvider之间并不知道其它DefinitionProvider的存在,自然会出现多个Provider提供了相同或相似Definition的冲突情况

语义支持缺失

缺少语义支持是个硬缺陷,例如经常需要在入口处做类似这样的事情:

getToken() {
    const position = Editor.getEditorCursorStart();
    const token = this.scanReverse(position) + this.scanForward(position);
    if (this.isEmpty(token)) {
        return null;
    }
    return token;
}

(摘自WesleyLuk90/node-import-resolver-code

更大的问题是受限于触摸不到语法树,很多事情“做不了”,比如:

// 非ES Module标准的Webpack Resolve
import myModule from 'non-es-module-compatible-path-to/my-awesome-module';

// 试图跳转到doSomething定义
myModule.doSomething();

想要跳转到依赖文件中的定义,必须要做到这2点:

  • “理解”myModule是个依赖模块,并找到myModule指向的文件

  • “理解”该文件内容的语义,找出doSomething定义所在的行列位置

也就是说,必须对当前文件以及依赖文件内容进行语义分析,而VS Code插件机制并没有开放这种能力

诚然,插件自己(通过Babel等工具)实现语义分析可以应对这种场景,但会发现更多的问题:

  • 输入myModule.缺少补全提示

  • 输入myModule.doAnotherThing(缺少参数提示

  • 输入myModule.undefinedFunction()缺少Lint报错

  • ……

这一整套原本存在的功能现在都要重新实现一遍,投入就像无底洞,我们似乎陷入了一个误区:试图从上层修复下层问题,最后发现要铺满整块地面才能解决(几乎要重新实现整个下层)

Definition冲突

相同/相似Definition的问题主要表现在用户插件与内置插件功能冲突上,由于通过插件API无法获知内置Provider的Definition结果,冲突在所难免

从实现上来看,所有DefinitionProvider提供的Definition结果会被merge到一起:

// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/goToDefinition/goToDefinition.ts
function getDefinitions<T>(): Promise<DefinitionLink[]> {
  const provider = registry.ordered(model);

  // get results
  const promises = provider.map((provider): Promise<DefinitionLink | DefinitionLink[] | null | undefined> => {
    return Promise.resolve(provide(provider, model, position)).then(undefined, err => {
      onUnexpectedExternalError(err);
      return null;
    });
  });
  return Promise.all(promises)
    .then(flatten)
    .then(coalesce);
}

VS Code考虑到了重复定义的情况,内部做了去重,但只针对完全相同的定义(即urirange(startLine, startColumn, endLine, endColumn)都完全相同):

the design is to be cooperative and we only de-dupe items that are exactly the same.

对应源码:

// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/goToDefinition/goToDefinitionCommands.ts
export class DefinitionAction extends EditorAction {
  public run(): TPromise<void> {
    const definitionPromise = this._getDeclarationsAtPosition().then(references => {
      const result: DefinitionLink[] = [];
      for (let i = 0; i < references.length; i++) {
        let reference = references[i];
        let { uri, range } = reference;
        result.push({ uri, range });
      }

      if (result.length === 0) {
        // 无definition结果,提示没找到定义
        if (this._configuration.showMessage) {
          const info = model.getWordAtPosition(pos);
          MessageController.get(editor).showMessage(this._getNoResultFoundMessage(info), pos);
        }
      } else if (result.length === 1 && idxOfCurrent !== -1) {
        // 只有1条结果,直接跳转
        let [current] = result;
        this._openReference(editor, editorService, current, false);

      } else {
        // 多条结果,去重并显示
        this._onResult(editorService, editor, new ReferencesModel(result));
      }
    });
  }
}

去重逻辑:

// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/referenceSearch/referencesModel.ts
export class ReferencesModel implements IDisposable {
  constructor(references: Location[]) {
    this._disposables = [];
    // 按字典序对文件路径排序,同一文件内按range起始位置排序
    references.sort(ReferencesModel._compareReferences);

    let current: FileReferences;
    for (let ref of references) {
      // 按文件分组
      if (!current || current.uri.toString() !== ref.uri.toString()) {
        // new group
        current = new FileReferences(this, ref.uri);
        this.groups.push(current);
      }

      // 去重,滤掉完全相同的range
      if (current.children.length === 0
        || !Range.equalsRange(ref.range, current.children[current.children.length - 1].range)) {

        let oneRef = new OneReference(current, ref.range);
        this._disposables.push(oneRef.onRefChanged((e) => this._onDidChangeReferenceRange.fire(e)));
        this.references.push(oneRef);
        current.children.push(oneRef);
      }
    }
  }
}

最后,还有一个重要的展示逻辑:

  private _openReference(): TPromise<ICodeEditor> {
    return editorService.openCodeEditor({
      resource: reference.uri,
      options: {
        // 选中range起始位置(光标移动到该位置)
        selection: Range.collapseToStart(reference.range),
        revealIfOpened: true,
        revealInCenterIfOutsideViewport: true
      }
    }, editor, sideBySide);
  }

这就引发了很容易出现的“重复”定义问题,在显示时,这3个range看起来完全一样

Range(0, 0, 28, 12)
Position(0, 0)
// Position(0, 0)会被转换成
Range(0, 0, 0, 0)

起点位于同一行相近位置的range也难以分辨(如Range(0, 0, _, _)Range(0, 1, _, _)),展示上看起来都是重复定义

要解决类似的“重复”(本质上是DefinitionProvider间的冲突),有两种思路:

  • 猜测内置Provider的行为,主动避开内置插件能够处理的case

  • 由VS Code提供特性支持,比如registerDefinitionProvider提供选项enabelWhenNoReferences(仅在别人都没结果时才走我的)

前者比较脆弱,依靠猜测很难覆盖所有情况,并且一旦内置插件更新了,这些猜测可能就不适用了。而后者目前(2018/12/16)没有提供支持,将来可能也不会提供,具体见Duplicated definition references found when Go to Definition

四.TypescriptServerPlugins

TypeScript server plugins are loaded for all JavaScript and TypeScript files when the user is using VS Code’s version of TypeScript.

简言之,就是通过插件内置指定的TypeScript Language Service Plugin,从而扩展VS Code处理JS/TS的能力

TypeScript Language Service Plugin

TypeScript Language Service Plugins (“plugins”) are for changing the editing experience only.

仅能增强编辑体验,无法改变TS核心行为(比如改变类型检查行为)或增加新特性(比如提供一种新语法或者)

具体的,编辑体验相关的事情包括:

  • 提供Lint报错

  • 处理补全提示列表,滤掉一些东西,比如window.eval

  • 让Go to definition指向不同的引用位置

  • 给字符串字面量形式的自定义模板语言提供报错及补全提示,例如Microsoft/typescript-lit-html-plugin

做不到的事情包括:

  • 给TypeScript添一种新的自定义语法

  • 改变编译器转译出JavaScript的行为

  • 定制类型系统,试图改变tsc命令的校验行为

因此,如果只是想增强编辑体验,TypeScript Language Service Plugin是很不错的选择

示例

VS Code默认行为是无后缀名的优先跳.ts(无论源文件是JS还是TS),如果想要.js文件里的模块引用都指向.js文件的话,可以通过简单的Service Plugin来实现:

function init(modules: { typescript: typeof ts_module }) {
  const ts = modules.typescript;

  function create(info: ts.server.PluginCreateInfo) {
    const resolveModuleNames = info.languageServiceHost.resolveModuleNames;

    // 篡改resolveModuleNames,以扩展自定义行为
    info.languageServiceHost.resolveModuleNames = function(moduleNames: string[], containingFile: string) {
      const isJsFile = containingFile.endsWith('.js');
      let resolvedNames = moduleNames;
      if (isJsFile) {
        const dir = path.dirname(containingFile);
        resolvedNames = moduleNames.map(moduleName => {
          // 仅针对无后缀名的相对路径引用
          const needsToResolve = /^\./.test(moduleName) && !/\.\w+$/.test(moduleName);
          if (needsToResolve) {
            const targetFile = path.resolve(dir, moduleName + '.js');
            if (ts.sys.fileExists(targetFile)) {
              // 添上.js后缀名,要求跳转到.js文件
              return moduleName + '.js';
            }
          }

          return moduleName;
        });

        return resolveModuleNames.call(info.languageServiceHost, resolvedNames, containingFile);
      }

      return info.languageService;
    }
  }

  return { create };
}

其中,moduleNames就是在语法分析完成之后收集到的import模块名,也就是说,TypeScript Language Service Plugin有语义支持

P.S.更多类似示例,见:

contributes.typescriptServerPlugins

Service Plugin写好了,接下来通过VS Code插件把它引进来,使之能够增强VS Code的编辑体验

只需要做两件事情,先把Service Plugin作为npm依赖装上:

{
    "dependencies": {
        "my-typescript-server-plugin": "*"
    }
}

再通过contributes.typescriptServerPlugins扩展点引入:

"contributes": {
  "typescriptServerPlugins": [
      {
        "name": "my-typescript-server-plugin"
      }
    ]
}

最后,VS Code内置的typescript-language-features插件会把所有插件的typescriptServerPlugins都收集起来并注册到TypeScriptServiceClient

// 收集
export function getContributedTypeScriptServerPlugins(): TypeScriptServerPlugin[] {
  const plugins: TypeScriptServerPlugin[] = [];
  for (const extension of vscode.extensions.all) {
    const pack = extension.packageJSON;
    if (pack.contributes && pack.contributes.typescriptServerPlugins && Array.isArray(pack.contributes.typescriptServerPlugins)) {
      for (const plugin of pack.contributes.typescriptServerPlugins) {
        plugins.push({
          name: plugin.name,
          path: extension.extensionPath,
          languages: Array.isArray(plugin.languages) ? plugin.languages : [],
        });
      }
    }
  }
  return plugins;
}

// 注册
this.client = this._register(new TypeScriptServiceClient(
  workspaceState,
  version => this.versionStatus.onDidChangeTypeScriptVersion(version),
  plugins,
  logDirectoryProvider,
  allModeIds)
);

因此,contributes.typescriptServerPlugins扩展点是用来连接TypeScript Language Service Plugin和VS Code里的TypeScriptServiceClient的桥梁

五.总结

对于JS/TS,VS Code还提供了一种更强大的扩展方式,叫TypescriptServerPlugin

与通用的registerDefinitionProvider相比,TypescriptServerPlugin能够触摸到语法树,这是极大的优势,在跳转到定义、Lint检查、补全提示等语义相关的场景尤为适用

当然,TypescriptServerPlugin也并非完美,限制如下:

  • 仅用于扩展JS/TS,以及JSX/TSX等,不支持其它语言

  • 仅支持扩展编辑体验,无法改变语言特性

  • 语义支持仍然受限于TypeScript Language Service API,没留Hook的地方就没法涉足

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code