Skip to main content

TypescriptServerPlugin_VSCode Plugin Development Notes 3

Free2018-12-16#Tool#Go to Definition#VSCode语法分析#JS Go to Definition in VSCode#VSCode Webpack Resolve extension#Webpack Resolve VSCode extension

For JS/TS, VS Code also provides a more powerful extension method called TypescriptServerPlugin

1. Requirement Scenarios

VS Code can correctly support JS/TS features like go to definition, completion hints, etc., but only for module references that conform to Node Module Resolution and TypeScript Module Resolution rules, such as:

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

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

// TypeScript
// Allows omitting extension
import {fn1, fn2} from './myModule';
// Supports baseUrl
import { localize } from 'vs/nls';

If using other custom import rules, these features will be unavailable (cannot jump, no hints, no Lint validation...), for example:

  • Webpack Resolve: import Utility from 'Utilities/utility';

  • [React's Haste](/articles/react 背后的工具化体系/#articleHeader3): var ReactCurrentOwner = require('ReactCurrentOwner');

Special import rules causing these key features to be unavailable is a pain point for projects built on similar build tools, expecting to solve through plugins

2. Implementation Approach

Based on VS Code plugin mechanism, to support special import rules, there are 2 ways:

The former is a general Definition extension method, specific cases see Show Definitions of a Symbol; the latter only applies to JS/TS, but is more powerful (not limited to Definition)

3. registerDefinitionProvider

Implementing your own DefinitionProvider through registerDefinitionProvider is the most common Go to Definition extension method, but has 2 problems:

  • Lacks semantic support: Can only obtain the current Document and the row/column position where the jump action occurred, no code semantic-related information is provided. For example, whether it's an import statement or property access, whether it's a require function call or string literal, these key informations are not exposed

  • Prone to Definition conflicts: When Go to Definition action occurs, all plugins (VS Code built-in) registered related DefinitionProviders will be triggered to execute, and DefinitionProviders don't know about the existence of other DefinitionProviders, naturally conflicts will arise where multiple Providers provide the same or similar Definitions

Lack of Semantic Support

Lack of semantic support is a hard defect, for example often need to do things like this at the entry:

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

(Excerpted from WesleyLuk90/node-import-resolver-code)

The bigger problem is limited by inability to touch the syntax tree, many things "cannot be done", for example:

// Non-ES Module standard Webpack Resolve
import myModule from 'non-es-module-compatible-path-to/my-awesome-module';

// Try to jump to doSomething definition
myModule.doSomething();

To jump to definitions in dependency files, must achieve these 2 points:

  • "Understand" that myModule is a dependency module, and find the file myModule points to

  • "Understand" the semantics of the file content, find out the row/column position where doSomething definition is located

That is to say, must perform semantic analysis on current file and dependency file content, and VS Code plugin mechanism does not open this capability

Admittedly, plugins themselves (through Babel and other tools) implementing semantic analysis can handle this scenario, but will discover more problems:

  • Input myModule. lacks completion hints

  • Input myModule.doAnotherThing( lacks parameter hints

  • Input myModule.undefinedFunction() lacks Lint errors

  • ...

This whole set of originally existing functionality now needs to be re-implemented, investment is like a bottomless pit, we seem to have fallen into a misunderstanding: trying to fix lower-level problems from the upper layer, finally discovering need to pave the entire ground to solve (almost need to re-implement the entire lower layer)

Definition Conflicts

Same/similar Definition problems mainly manifest in conflicts between user plugins and built-in plugin functionality, since plugin API cannot obtain built-in Provider's Definition results, conflicts are inevitable

From an implementation perspective, Definition results provided by all DefinitionProviders will be merged together:

// 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 considered duplicate definition situations, internally does deduplication, but only for exactly the same definitions (i.e., uri and range(startLine, startColumn, endLine, endColumn) are exactly the same):

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

Corresponding source code:

// 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) {
        // No definition results, prompt definition not found
        if (this._configuration.showMessage) {
          const info = model.getWordAtPosition(pos);
          MessageController.get(editor).showMessage(this._getNoResultFoundMessage(info), pos);
        }
      } else if (result.length === 1 && idxOfCurrent !== -1) {
        // Only 1 result, jump directly
        let [current] = result;
        this._openReference(editor, editorService, current, false);

      } else {
        // Multiple results, deduplicate and display
        this._onResult(editorService, editor, new ReferencesModel(result));
      }
    });
  }
}

Deduplication logic:

// 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 = [];
    // Sort file paths by dictionary order, within same file sort by range start position
    references.sort(ReferencesModel._compareReferences);

    let current: FileReferences;
    for (let ref of references) {
      // Group by file
      if (!current || current.uri.toString() !== ref.uri.toString()) {
        // new group
        current = new FileReferences(this, ref.uri);
        this.groups.push(current);
      }

      // Deduplicate, filter out exactly same ranges
      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);
      }
    }
  }
}

Finally, there's an important display logic:

  private _openReference(): TPromise<ICodeEditor> {
    return editorService.openCodeEditor({
      resource: reference.uri,
      options: {
        // Select range start position (cursor moves to that position)
        selection: Range.collapseToStart(reference.range),
        revealIfOpened: true,
        revealInCenterIfOutsideViewport: true
      }
    }, editor, sideBySide);
  }

This triggers easily occurring "duplicate" definition problems, when displaying, these 3 ranges look exactly the same:

Range(0, 0, 28, 12)
Position(0, 0)
// Position(0, 0) will be converted to
Range(0, 0, 0, 0)

ranges with starting points at similar positions on the same line are also hard to distinguish (e.g., Range(0, 0, _, _) and Range(0, 1, _, _)), display looks like duplicate definitions

To solve similar "duplicates" (essentially conflicts between DefinitionProviders), there are two approaches:

  • Guess built-in Provider's behavior, actively avoid cases that built-in plugins can handle

  • VS Code provides feature support, such as registerDefinitionProvider providing option enabelWhenNoReferences (only use mine when others have no results)

The former is relatively fragile, relying on guesses is hard to cover all situations, and once built-in plugins update, these guesses may no longer apply. The latter currently (2018/12/16) doesn't provide support, may not provide in the future either, specifically see Duplicated definition references found when Go to Definition

4. TypescriptServerPlugins

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

In short, through plugins built-in specified TypeScript Language Service Plugin, thereby extending VS Code's ability to handle JS/TS

TypeScript Language Service Plugin

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

Can only enhance editing experience, cannot change TS core behavior (such as changing type checking behavior) or add new features (such as providing a new syntax or)

Specifically, editing experience-related things include:

  • Provide Lint errors

  • Process completion hint lists, filter out some things, like window.eval

  • Make Go to definition point to different reference locations

  • Provide errors and completion hints for string literal form custom template languages, such as Microsoft/typescript-lit-html-plugin

Things that cannot be done include:

  • Add a new custom syntax to TypeScript

  • Change compiler transpilation behavior outputting JavaScript

  • Customize type system, trying to change tsc command validation behavior

Therefore, if just want to enhance editing experience, TypeScript Language Service Plugin is a very good choice

Example

VS Code default behavior is to jump to .ts first for extensionless (regardless of whether source file is JS or TS), if want module references in .js files to all point to .js files, can implement through simple Service Plugin:

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

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

    // Tamper with resolveModuleNames to extend custom behavior
    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 => {
          // Only target extensionless relative path references
          const needsToResolve = /^\./.test(moduleName) && !/\.\w+$/.test(moduleName);
          if (needsToResolve) {
            const targetFile = path.resolve(dir, moduleName + '.js');
            if (ts.sys.fileExists(targetFile)) {
              // Add .js extension, require jump to .js file
              return moduleName + '.js';
            }
          }

          return moduleName;
        });

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

      return info.languageService;
    }
  }

  return { create };
}

Where moduleNames are the import module names collected after syntax analysis is completed, that is to say, TypeScript Language Service Plugin has semantic support

P.S. More similar examples, see:

contributes.typescriptServerPlugins

Service Plugin is written, next import it through VS Code plugin, enabling it to enhance VS Code's editing experience

Only need to do two things, first install Service Plugin as npm dependency:

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

Then import through contributes.typescriptServerPlugins extension point:

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

Finally, VS Code built-in typescript-language-features plugin will collect all plugins' typescriptServerPlugins and register them to TypeScriptServiceClient:

// Collect
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;
}

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

Therefore, contributes.typescriptServerPlugins extension point is the bridge connecting TypeScript Language Service Plugin and TypeScriptServiceClient in VS Code

5. Summary

For JS/TS, VS Code also provides a more powerful extension method called TypescriptServerPlugin

Compared to general registerDefinitionProvider, TypescriptServerPlugin can touch the syntax tree, this is a huge advantage, especially suitable for semantics-related scenarios like go to definition, Lint checking, completion hints, etc.

Of course, TypescriptServerPlugin is not perfect either, limitations as follows:

  • Only used to extend JS/TS, and JSX/TSX etc., doesn't support other languages

  • Only supports extending editing experience, cannot change language features

  • Semantic support is still limited by TypeScript Language Service API, places without Hooks cannot be accessed

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment