How to write a Diagnostics TypeScript Language Service Plugin?

Do you recognize the fact that those beautiful TODO or FIXME comments you add to your code are forgotten and left as is until eternity? At our companies innovation day, we wanted to write a TypeScript compiler plugin that makes your build fail when a condition you provide in your TODO comments evaluates to true. Unfortunately, we came to the conclusion that TypeScript does not allow us to write a compiler plugin. It does however allow us to write a Language Service Plugin.

A TypeScript Language Service Plugin can be used to change the editing experience for TypeScript users. It doesn't interfere with the compiler. This means it can only guide or help, not enforce. This article will describe how to create a diagnostics plugin using our Todo Or Die use case as an example.

Just here for the code? Check out this repository.

Setup

First, you need a simple TypeScript project with a factory function and a decorator that we should return to our factory function.

You'll end up with the following code:

function init(modules: { typescript: typeof import("typescript/lib/tsserverlibrary") }) {
  const ts = modules.typescript;

  function create(info: ts.server.PluginCreateInfo) {
    const proxy: ts.LanguageService = Object.create(null);

    for (let k of Object.keys(info.languageService) as Array<keyof ts.LanguageService>) {
      const x = info.languageService[k]!;
      // @ts-expect-error - JS runtime trickery which is tricky to type tersely
      proxy[k] = (...args: Array<{}>) => x.apply(info.languageService, args);
    }

    return proxy;
  }

  return { create };
}

export = init;

This is now just a pass-through plugin, but it enables us to start adding custom behavior for our comments.

Custom behavior

After setting up this decorator we are now able to overwrite TypeScript's Language Service functions. To make a change in the diagnostics service TypeScript defines three types of diagnostics.

  • Syntactic
  • Semantic
  • Suggestion

Each of them has a corresponding function that we can overwrite.

Deciding what function to overwrite

In the type files TypeScript gives us a detailed explanation of when to use these functions. All three will pass the current file name as an argument and return and require you to return an array of diagnostics. You need to shape the diagnostics depending on the type. Choose the one you want to overwrite.

Syntactic

getSyntacticDiagnostics(fileName: string): DiagnosticWithLocation[]

"Gets errors indicating invalid syntax in a file.

In English, "this cdeo have, erorrs" is syntactically invalid because it has typos, grammatical errors, and misplaced punctuation. Likewise, examples of syntax errors in TypeScript are missing parentheses in an if statement, mismatched curly braces, and using a reserved keyword as a variable name.

These diagnostics are inexpensive to compute and don't require knowledge of other files. Note that a non-empty result increases the likelihood of false positives from getSemanticDiagnostics.

While these represent the majority of syntax-related diagnostics, there are some that require the type system, which will be present in getSemanticDiagnostics."

Semantic

getSemanticDiagnostics(fileName: string): Diagnostic[]

Gets warnings or errors indicating type system issues in a given file.
Requesting semantic diagnostics may start up the type system and run deferred work, so the first call may take longer than subsequent calls.

Unlike the other get*Diagnostics functions, these diagnostics can potentially not include a reference to a source file. Specifically, the first time this is called, it will return global diagnostics with no associated location.

To contrast the differences between semantic and syntactic diagnostics, consider the sentence: "The sun is green." is syntactically correct; those are real English words with correct sentence structure. However, it is semantically invalid, because it is not true."

Suggestion

getSuggestionDiagnostics(fileName: string): DiagnosticWithLocation[]

"Gets suggestion diagnostics for a specific file. These diagnostics tend to proactively suggest refactors, as opposed to diagnostics that indicate potentially incorrect runtime behavior."

Overwrite the function

We can now use the proxy we set up to overwrite one of the above functions. I'm going to overwrite getSemanticDiagnostics, but make sure to choose the function that matches best what you want to achieve.

getSemanticDiagnostics gives us the name of the current file our user works in as an argument and it expects us to return a list of ts.Diagnostic.

This looks something like this:

proxy.getSemanticDiagnostics = (filename) => {
  return [];
}

Now the first thing we want to do is make sure to return other diagnostics that are already logged by TypeScript itself or another Language Service plugin. We can use info.languageService.getSemanticDiagnostics to do this.

proxy.getSemanticDiagnostics = (filename) => {
  const prior = languageService.getSemanticDiagnostics(filename);

  return [...prior];
}

Finally, we can add our own logic to return diagnostics. First, we need to get the contents of the file based on the filename argument. For this, we can use info.languageService.getProgram()?.getSourceFile(filename). Since the result of this function can be undefined we make sure to catch that case and return prior instead.

proxy.getSemanticDiagnostics = (filename) => {
  const prior = info.languageService.getSemanticDiagnostics(filename);
  const doc = info.languageService.getProgram()?.getSourceFile(filename);

  if (!doc) {
    return prior;
  }

  return [...prior];
}

After that, we can analyze the file and generate diagnostics based on it. In our case, we want to check every line of the file for to-do or die statements. To make it as simple as possible for this example, we'll just look for any lines that start with // TODO: and create a diagnostic for each of them.

The type of ts.Diagnostic is:

enum DiagnosticCategory {
  Warning = 0,
  Error = 1,
  Suggestion = 2,
  Message = 3
}

interface DiagnosticMessageChain {
  messageText: string;
  category: DiagnosticCategory;
  code: number;
  next?: DiagnosticMessageChain[];
}

interface Diagnostic {
  category: DiagnosticCategory;
  code: number;
  file: SourceFile | undefined;
  start: number | undefined; // Index of `doc` to start error from
  length: number | undefined;
  messageText: string | DiagnosticMessageChain;
}

Use this information to gather all necessary data to be able to put together a diagnostic. In our case, we want to keep track of the line number and the line itself including the TODO comment.

// Context
import { DiagnosticCategory } from "typescript";
// Context

proxy.getSemanticDiagnostics = (filename) => {
  const prior = info.languageService.getSemanticDiagnostics(filename);
  const doc = info.languageService.getProgram()?.getSourceFile(filename);

  if (!doc) {
    return prior;
  }

  return [
    ...prior,
    ...doc.text
      .split("\n")
      .reduce<[string, number]][]>((acc, line, index) => {
        if (line.trim().startsWith("// TODO:")) {
          return [...acc, [line, index]];
        }

        return acc;
      }, [])
      .map(([line, lineNumber]) => ({
        file: doc,
        start: doc.getPositionOfLineAndCharacter(lineNumber, 0),
        length: line.length,
        messageText: "This TODO comment should be fixed!",
        category: DiagnosticCategory.Error,
        source: "Your plugin name",
        code: 666
      }))
  ];
}

This code will mark full lines that start with // TODO: and shows the "This TODO commend should be fixed!" message in the details of the error.

End result

Now combine the setup code snippet with the mutating proxy behavior and you've got yourself a working diagnostics plugin!

import { DiagnosticCategory } from "typescript";

function init(modules: { typescript: typeof import("typescript/lib/tsserverlibrary") }) {
  const ts = modules.typescript;

  function create(info: ts.server.PluginCreateInfo) {
    const proxy: ts.LanguageService = Object.create(null);

    for (let k of Object.keys(info.languageService) as Array<keyof ts.LanguageService>) {
      const x = info.languageService[k]!;
      // @ts-expect-error - JS runtime trickery which is tricky to type tersely
      proxy[k] = (...args: Array<{}>) => x.apply(info.languageService, args);
    }

    proxy.getSemanticDiagnostics = (filename) => {
      const prior = info.languageService.getSemanticDiagnostics(filename);
      const doc = info.languageService.getProgram()?.getSourceFile(filename);

      if (!doc) {
        return prior;
      }

      return [
        ...prior,
        ...doc.text
          .split("\n")
          .reduce<[string, number][]>((acc, line, index) => {
            if (line.trim().startsWith("// TODO:")) {
              return [...acc, [line, index]];
            }

            return acc;
          }, [])
          .map(([line, lineNumber]) => ({
            file: doc,
            start: doc.getPositionOfLineAndCharacter(lineNumber, 0),
            length: line.length,
            messageText: "This TODO comment should be fixed!",
            category: DiagnosticCategory.Error,
            source: "Your plugin name",
            code: 666
          }))
      ];
    }

    return proxy;
  }

  return { create };
}

export = init;

Testing locally

  1. Add at least the following to your package.json.
{
  "name": "your-plugin-name",
  "version": "1.0.0",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc -p .",
  },
  "dependencies": {
    "typescript": "^4.5.4"
  }
}
  1. Install dependencies.
npm install
  1. Build plugin.
npm run build
  1. Setup link.
npm link
  1. Link plugin in another repository.
cd ../path-to-repository
npm link "your-plugin-name"
  1. Add plugin to tsconfig in a TypeScript project.
{
  "plugins": [
    { "name": "your-plugin-name" }
  ]
}
  1. Restart your editor.
  2. Check any TODO comments in your repository.

Note: If you're using Visual Studio Code, you'll have to run the "TypeScript: Select TypeScript Version" command and choose "Use Workspace Version", or click the version number next to "TypeScript" in the lower-right corner. Otherwise, VS Code will not be able to find your plugin.

Release the plugin

To release the plugin you need to refer to the file containing the above code in the package.json file using the main key. E.g. { "main": "dist/index.js" }. Now publish your plugin to npm and install it in another project!

Conclusion

Thanks for reading this article! Check out a boilerplate plugin in this repository. If you're interested in how we applied it in our to-do or die plugin you can find it at https://github.com/ngnijland/typescript-todo-or-die-plugin.

22