How to write a Diagnostics TypeScript Language Service Plugin?
21 Dec 2021Do 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.
Follow the steps Setup and Initialization and Decorator Creation in the Writing a Language Service Plugin article on the TypeScript wiki.
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
- 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"
}
}
- Install dependencies.
npm install
- Build plugin.
npm run build
- Setup link.
npm link
- Link plugin in another repository.
cd ../path-to-repository
npm link "your-plugin-name"
- Add plugin to
tsconfig
in a TypeScript project.
{
"plugins": [
{ "name": "your-plugin-name" }
]
}
- Restart your editor.
- 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.