Equana

All articles
July 15, 2026·Max Schuriglsptoolinglanguage-design

Writing a language server for your own language

How Equana gets completions, hover, signature help, and go-to-definition in both VS Code and its in-browser editor — from a single semantic core wrapped by two different frontends.

When you invent a language, you sign up for a second, quieter project: the editor experience. A parser and an evaluator make the language run. Completions, hover tooltips, red squiggles under type errors, jump-to-definition — those make it usable. Skip them and every user meets your language as a plain text box that punishes typos at runtime.

Equana is a scientific-computing notebook that runs entirely in the browser, on top of a language of the same name. From early on I wanted the same editing intelligence in two very different places: inside the notebook's in-browser cells, and inside VS Code for people editing .eq files on disk. The obvious way to build that is twice — once for each editor. I built it once. This post is about the architecture that makes one semantic core serve both a CodeMirror editor and a full Language Server Protocol server, and what I learned wiring up LSP for a language nobody else has ever heard of.

The problem LSP was invented to solve

Before the protocol, editor tooling was an N×M disaster. Every editor (VS Code, Vim, Emacs, Sublime…) needed a bespoke integration for every language (Python, Rust, Go…). M languages times N editors is a lot of plugins, most of them half-finished.

The Language Server Protocol, introduced by Microsoft in 2016, collapses that to N+M. You write one language server that speaks a standard wire protocol, and it works in any editor that speaks the same protocol. The server is a separate process. The editor (the client) talks to it over JSON-RPC — a tiny request/response format — sending messages like "the user typed here, what completions apply?" and "the cursor is on this token, what's its type?"

The conversation opens with capability negotiation. The client asks initialize; the server answers with a list of what it can do. Equana's server advertises exactly this:

capabilities: { textDocumentSync: TextDocumentSyncKind.Full, completionProvider: { triggerCharacters: ['.', '"'] }, signatureHelpProvider: { triggerCharacters: ['(', ','] }, hoverProvider: true, definitionProvider: true, referencesProvider: true, renameProvider: { prepareProvider: true }, documentFormattingProvider: true, }

Each true (or each provider object) is a promise: ask me for hovers, I'll answer. The editor then only sends requests the server said it can handle. That negotiation is the whole trick that lets a generic editor light up for a language it was never built for.

The core: a language service that knows nothing about editors

The center of gravity is a class called LanguageService, and its most important property is what it doesn't import. It has no dependency on VS Code, on the LSP libraries, on CodeMirror, or even on a Web Worker. It's plain TypeScript that turns source text into editor-shaped answers.

Its surface is a set of methods that map one-to-one onto the questions an editor asks:

class LanguageService { update(cellId: string, source: string): void getDiagnostics(cellId: string): readonly EqError[] getCompletions(cellId: string, offset: number, packageNames?): readonly Completion[] getHover(cellId: string, offset: number): HoverInfo | null getSignatureHelp(cellId: string, offset: number): SignatureHelp | null getDefinition(cellId: string, offset: number): Location | null getReferences(cellId: string, offset: number): readonly Location[] getRenameLocations(cellId: string, offset: number): readonly Location[] | null }

Every query is addressed by a cellId and a character offset. That's it — no line/column, no editor coordinates, no URIs. Those are frontend concerns, and keeping them out of the core is what lets the same class serve two frontends that disagree about almost everything else.

Under the hood the service composes three pieces I'd already built for the language itself: the Checker (the type checker), a CompletionEngine, and a SymbolIndex. The reuse here is the real story. The type checker that produces error diagnostics is the same checker that resolves the type shown in a hover and the same one that picks the overload shown in signature help. I didn't write an "editor type system" separate from the "real" one. There is one type system, and the editor features are just different questions asked of it.

Take hover. When you point at a name, the service asks the SymbolIndex which symbol occupies that offset, looks up its definition and type, and — for functions — asks the checker whether it already resolved a specific overload at that call site:

const sigs = methodTable.getMethods(name) if (sigs.length > 0) { const resolved = this.checker.getCallResolution(span.start.offset) typeStr = resolved ? `${name}${formatSignature(resolved)}` // the overload chosen here : sigs.map((s) => `${name}${formatSignature(s)}`).join('\n') // all overloads }

Because Equana has multiple dispatch, add isn't one function — it's a whole method table. Hovering over a specific add call and seeing which method the checker picked is only possible because the hover feature borrows the checker's actual dispatch resolution rather than re-deriving it.

The SymbolIndex: a tiny spatial database

Go-to-definition, find-references, hover, and rename all reduce to one primitive question: what symbol is at this character offset, and where else does it appear? The SymbolIndex answers it. As the checker walks the AST it records every definition and every reference into a per-cell list of spans, keyed by name. Point queries are a linear scan for the entry whose [start, end) contains the offset:

symbolAt(cellId: string, offset: number): string | null { for (const entry of this.spanIndex.get(cellId) ?? []) { if (offset >= entry.start && offset < entry.end) return entry.name } return null }

Rename is then almost free: collect the definition spans and reference spans for a name, and hand back every location that has to change. The editor turns that list into edits. The language service never touches a file.

One choice that surprised me: recheck everything, every keystroke

update() re-lexes, re-parses, and then re-checks all cells from scratch — not an incremental diff. That sounds reckless. It works because the AST-walking checker is fast enough that a full recheck of a notebook is microseconds of work, and because "throw it all away and rebuild" is dramatically simpler to keep correct than incremental invalidation. Incremental recomputation is one of the classic sources of subtle staleness bugs in language servers. For a language whose unit of code is a notebook cell, brute force is the right default until a profiler says otherwise.

Frontend one: the LSP server

The server is a thin translation layer. It creates a connection, creates one LanguageService, and forwards protocol requests to it:

const ls = new LanguageService() connection.onHover((params) => { const offset = lspPositionToOffset(doc.getText(), params.position) const info = ls.getHover(params.textDocument.uri, offset) // …wrap info in the LSP Hover shape and return it })

Notice what the frontend actually does: convert the LSP { line, character } position into a flat offset, use the document URI as the cellId, and translate the service's answer back into the LSP wire types. Diagnostics get mapped from Equana's internal EqError (with its severity and error code) into LSP Diagnostic objects; completion kinds are mapped from the service's vocabulary onto the protocol's CompletionItemKind enum. The interesting features already happened inside the core — the server is glue.

There's one capability the server adds on its own: inside a wacall("…") string (Equana's call into a raw WASM binary) it offers completions for binary names pulled from the package manifests it watches on disk. That's genuinely editor-side knowledge — the filesystem — so it lives in the frontend. The rule I ended up with: semantic answers come from the core; environmental answers (files, workspace layout) come from the frontend.

Wrapping that server as a VS Code extension is almost anticlimactic. The extension launches the server as a subprocess and connects a LanguageClient to it:

const serverOptions = { run: { module: serverModule, transport: TransportKind.ipc } } const clientOptions = { documentSelector: [{ scheme: 'file', language: 'equana' }] } client = new LanguageClient('equana', 'Equana Language Server', serverOptions, clientOptions) client.start()

Twelve meaningful lines. Everything else — the actual intelligence — is the server, and the server is mostly the core.

Frontend two: the in-browser editor

The notebook doesn't use LSP at all. There's no separate process to talk to; the editor and the language service run in the same JavaScript context. So the second frontend skips the protocol entirely and calls the core directly.

A small LanguageBridge owns one LanguageService and keeps it in sync with the notebook's cells. CodeMirror extensions then call straight into it. The completion source is representative:

export function equanaCompletionSource(bridge: LanguageBridge, cellId: string) { return (context) => { const word = context.matchBefore(/[a-zA-Z_]\w*/) if (!word) return null const completions = bridge.ls.getCompletions(cellId, word.from) return { from: word.from, options: completions.map(toCodeMirrorOption) } } }

Same for hover (a CodeMirror hoverTooltip that renders the service's HoverInfo into a DOM node) and for diagnostics (a CodeMirror linter that forwards getDiagnostics results as squiggles). Different editor API, different rendering, identical semantics — because both frontends are asking the exact same getCompletions, getHover, getDiagnostics of the exact same class.

This is the payoff, stated plainly: a completion behaves the same in VS Code and in the browser notebook, a type error underlines the same token in both, and an overload resolves to the same method in both — not because two implementations were carefully kept in agreement, but because there is only one implementation. When I add a stdlib function or fix a dispatch rule in the checker, both editors improve in the same commit. There is no drift to manage because there is nothing to keep in sync.

What I'd tell anyone building a DSL

Build the language service before you build any editor integration, and make it depend on nothing editor-specific. Address everything by offset, not by editor coordinates. Return plain data structures, not protocol types. Then treat each editor as a translation shim: coordinates in, wire types out.

The temptation is to reach for LSP first, because it's the famous acronym. But LSP is a transport — a way to ship your answers across a process boundary to editors you don't control. The answers themselves live below the protocol, and if you factor them out cleanly, you get the in-process integrations (a browser editor, a REPL, a doc generator) for free, and the LSP server becomes the thin part rather than the whole thing.

You can try the in-browser editor at equana.dev — completions, hover, and live diagnostics run in the tab as you type —, the same package the VS Code server imports. If you want the surrounding systems, I've written up why the language reads like English and how the debugger works when you own the AST — both lean on the same idea as this post: own the semantic core, and the tooling falls out of it.

Next: What executing every code sample taught us about our own marketing