Skip to content

lint, hover, goto, autocomplete, and twoslash extensions for CodeMirror + TypeScript

License

Notifications You must be signed in to change notification settings

val-town/codemirror-ts

Repository files navigation

codemirror-ts

npm

Made by val.town, a social website to write and deploy backend services.

On npm as @valtown/codemirror-ts

TypeScript extensions for CodeMirror. This aims to support as much of the basic interactions with TypeScript code as possible in CodeMirror.

Currently provides

  • Hover hints for types
  • Autocomplete
  • Diagnostics (lints, in CodeMirror's terminology)
  • Go-to definition
  • Twoslash support

Peer dependencies

This module does not depend on anything: your project should have direct dependencies to:

  • @codemirror/view
  • @codemirror/lint
  • @codemirror/autocomplete

Setup

Using a Worker, you can run TypeScript separately from the rest of your JavaScript, which can make both faster and more reliable. Depending on how you're building applications, you'll need to consult their documentation on setting up web workers:

With that out of the way:

  1. Create your worker

In a file like worker.ts, you'll need something like this:

import {
  createDefaultMapFromCDN,
  createSystem,
  createVirtualTypeScriptEnvironment,
} from "@typescript/vfs";
import ts from "typescript";
import * as Comlink from "comlink";
import { createWorker } from "@valtown/codemirror-ts/worker";

Comlink.expose(
  createWorker(async function () {
    const fsMap = await createDefaultMapFromCDN(
      { target: ts.ScriptTarget.ES2022 },
      "5.7.3",
      false,
      ts,
    );
    const system = createSystem(fsMap);
    const compilerOpts = {};
    return createVirtualTypeScriptEnvironment(system, [], ts, compilerOpts);
  }),
);

This code should look familiar if you read the section about setting this up with the main thread: it's the same setting-up of the TypeScript environment, but this time wrapping it in Comlink.expose, and, importantly, setting the third parameter of createDefaultMapFromCDN to false.

The third option in createDefaultMapFromCDN is whether to cache files: it uses localStorage to power that cache, and Web Workers don't support localStorage. You can implement your own storer instead.

There is an optional fifth option in createDefaultMapFromCDN to pass in lzstring to compress files that as well. You can follow this example and add a lzstring.min.js file to your codebase if you want.

  1. Initialize the worker

Now, on the application side (in the code in which you're initializing CodeMirror), you'll need to import and initialize the worker:

import { type WorkerShape } from "@valtown/codemirror-ts/worker";
import * as Comlink from "comlink";

const innerWorker = new Worker(new URL("./worker.ts", import.meta.url), {
  type: "module",
});
const worker = Comlink.wrap<WorkerShape>(innerWorker);
await worker.initialize();
  1. Add extensions

In short, there are *Worker versions of each of the extension that accept the worker instead of env as an argument.

[
  tsFacet.of({ worker, path }),
  tsSync(),
  tsLinter(),
  autocompletion({
    override: [tsAutocomplete()],
  }),
  tsHover(),
  tsGoto(),
  tsTwoslash(),
];

Using ATA

The example above will give you a working TypeScript setup, but if you import a module from NPM, that module will not have types. Usually TypeScript expects you to be installing modules with npm and expects that they'll be stored in a node_modules directory. codemirror-ts is on the internet in a WebWorker, so obviously it is not running NPM.

You can emulate what you'd get in a local editor by using ATA, or 'automatic type acquisition'. The setup looks like this example, and is a change to your WebWorker setup. We use the onFileUpdated callback passed to createWorker, trigger ATA to get new types, and then create those files on path.

// … import createWorker etc.
// Import setupTypeAcquisition from @typescript/ata

const worker = createWorker({
  env: (async () => {
    const fsMap = await createDefaultMapFromCDN(
      { target: ts.ScriptTarget.ES2022 },
      ts.version,
      false,
      ts,
    );
    const system = createSystem(fsMap);
    return createVirtualTypeScriptEnvironment(system, [], ts, {
      lib: ["ES2022"],
    });
  })(),
  onFileUpdated(_env, _path, code) {
    ata(code);
  },
});

const ata = setupTypeAcquisition({
  projectName: "My ATA Project",
  typescript: ts,
  logger: console,
  delegate: {
    receivedFile: (code: string, path: string) => {
      worker.getEnv().createFile(path, code);
    },
  },
});

Comlink.expose(worker);

Note

If you are targeting a non-Node environment, like Deno or a web browser, ATA will not help you with your HTTP imports or prefixed imports. It narrowly targets the Node & NPM way of doing imports.

Conceptual notes: persisted code

There are a few different approaches to building CodeMirror + TypeScript integrations. Each of the things that this does - linting, hovering, autocompleting - they all require TypeScript to know about your source code. It's tempting to send it over all the time: you get your whole source code and call something like

lint(sourceCode);

However, this has an overhead, and it combines poorly: if you're linting, and hovering, and autocompleting, it's inefficient to send the whole code over for each of those. Hence how these extensions start with the sync method, which updates TypeScript's version of the code contents.

This has some drawbacks: maybe the version gets out of sync, especially when the TypeScript environment is in a worker. But we think it's worth it, and it yields some other benefits.

Conceptual notes: file names

These extensions expect your client-side CodeMirror instance to be attached to a filename, like index.ts. By sharing a TypeScript environment, this lets you have two CodeMirror instances, say, editing a.ts and b.ts, and for one to import values from the other and get the correct types - because TypeScript automatically include both.

Note, however: these extensions currently only support creating and updating files - if you support removing or deleting files, they won't be possible to do that. It would be really nice to support those other parts of the lifecycle - PRs gladly accepted!

Conceptual notes: Comlink

This uses Comlink as an abstraction for the WebWorker. There are certainly other ways to build it - we could write our own similar abstraction. But, Comlink is a pretty nifty way to use Web Workers - it allows you to call functions in a web worker from the top page.

Like any other communication across postMessage, there are limits on the kinds of values you can pass. So we can't pass the raw CompletionContext object across the boundary. Right now this module works around that limitation by just passing the properties we need. There may be other solutions in the future.

Comlink is lightweight (4.7kb gzipped).

Conceptual notes: LSP

This module uses TypeScript’s public APIs to power its functionality: it doesn't use the Language Server Protocol, which is a specification developed by Microsoft and intended for functionality like this. TypeScript itself does not have a first-party LSP implementation and LSP is usually used across a network. Most good TypeScript language tooling, like VS Code’s autocompletion, does not use the LSP specification. Unfortunately, most TypeScript language tooling in other editors is based directly off of the VS Code implementation.

❤️ Other great CodeMirror plugins