LabHub

Blog

Build Your Own VS Code Extension and Language Server — A 2026 LSP Hands-On Guide

한국어English日本語

Prologue — The People Who Taught Editors to Read

The first time I felt that "the editor understands my code" was VS Code in 2016. Before that, autocomplete was a fancy collaboration between an identifier dictionary and a regex, and "Go to Definition" was the last frontier ctags defended. Then I opened a TypeScript file in VS Code and types floated over functions, F12 jumped to the definition, and bad characters got a red squiggle.

Two things lived behind that. The Extension API was the surface that connected editor to code, and beyond that surface, the Language Server Protocol (LSP) had separated and standardized the act of "understanding code."

As of May 2026, the LSP specification is at 3.18, and VS Code ships a new minor version on the stable channel every month. vscode-languageserver-node 9.x is the canonical TypeScript implementation, and on the Rust side, tower-lsp is the de facto default. The same LSP server runs in Neovim, Helix, Emacs, Sublime, Zed, and JetBrains. The phrase "adding language support to an editor" has become awkward — we say "write a language server" and the editors plug in by themselves.

This article does that full loop, once. We start with a tiny TODO highlighter extension, then build a real LSP for a fictional config language. We connect the two, publish them, and look at why Cursor and Continue run the same code, and why Tree-sitter walks alongside LSP rather than against it.


1. The Terrain of the VS Code Extension API

Start with the thinnest surface — the extension itself. LSP is one layer stacked on top.

1.1 What an extension actually is

A VS Code extension is, in one sentence, "a Node.js module that runs inside the editor's host process." Every extension runs isolated in a separate Extension Host process, talking to the main UI thread (the renderer) only over IPC. An extension can infinite-loop and the editor will survive.

What an extension can do, in broad strokes:

We focus on the first two: commands and language features.

1.2 The package.json manifest

An extension's identity is entirely defined by package.json. Here's the minimal manifest for our TODO highlighter.

{
  "name": "todo-highlighter",
  "displayName": "TODO Highlighter",
  "version": "0.1.0",
  "publisher": "yj",
  "engines": { "vscode": "^1.95.0" },
  "main": "./out/extension.js",
  "activationEvents": ["onStartupFinished"],
  "categories": ["Other"],
  "contributes": {
    "commands": [
      {
        "command": "todoHighlighter.scanWorkspace",
        "title": "TODO: Scan Workspace"
      }
    ],
    "configuration": {
      "title": "TODO Highlighter",
      "properties": {
        "todoHighlighter.keywords": {
          "type": "array",
          "default": ["TODO", "FIXME", "HACK"],
          "description": "Keywords to highlight"
        }
      }
    }
  }
}

Three fields deserve attention.

activationEvents is the most common foot-gun. onStartupFinished means "once after the editor has finished loading" and is the safest default. The old * is deprecated — it wakes the extension on every startup. Better: narrow the activation trigger.

"activationEvents": [
  "onLanguage:tomlplus",
  "onCommand:tomlplus.format",
  "workspaceContains:**/.tomlplus"
]

This wakes the extension only when a tomlplus file is opened, a specific command is invoked, or such a file exists in the workspace. Since VS Code 1.74, commands declared in contributes.commands automatically become activation triggers, so onCommand:* is usually omittable.

1.3 The Hello World extension

extension.ts has a canonical shape.

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
  console.log('TODO Highlighter activated');

  const disposable = vscode.commands.registerCommand(
    'todoHighlighter.scanWorkspace',
    async () => {
      const files = await vscode.workspace.findFiles('**/*.{ts,js,md}', '**/node_modules/**');
      vscode.window.showInformationMessage(`Scanned ${files.length} files`);
    }
  );

  context.subscriptions.push(disposable);
}

export function deactivate() {}

Every Disposable pushed into context.subscriptions is cleaned up automatically when the extension deactivates. That's the single most important rule for avoiding memory leaks.

1.4 Decorating TODOs

For a real TODO highlighter, use the decoration API.

const todoDecoration = vscode.window.createTextEditorDecorationType({
  backgroundColor: 'rgba(255, 200, 0, 0.2)',
  border: '1px dashed orange',
  overviewRulerColor: 'orange',
  overviewRulerLane: vscode.OverviewRulerLane.Right,
});

function updateDecorations(editor: vscode.TextEditor) {
  const text = editor.document.getText();
  const regex = /\b(TODO|FIXME|HACK)\b/g;
  const ranges: vscode.Range[] = [];

  let match: RegExpExecArray | null;
  while ((match = regex.exec(text)) !== null) {
    const start = editor.document.positionAt(match.index);
    const end = editor.document.positionAt(match.index + match[0].length);
    ranges.push(new vscode.Range(start, end));
  }
  editor.setDecorations(todoDecoration, ranges);
}

vscode.window.onDidChangeActiveTextEditor(
  (editor) => editor && updateDecorations(editor),
  null,
  context.subscriptions
);

vscode.workspace.onDidChangeTextDocument(
  (event) => {
    const editor = vscode.window.activeTextEditor;
    if (editor && event.document === editor.document) updateDecorations(editor);
  },
  null,
  context.subscriptions
);

No LSP needed here. A regex and the decoration API do everything. But the moment you need completion, go-to-definition, a symbol index, or incremental parsing — that's when a server earns its keep.

1.5 Tree views, status bar, webviews, walkthroughs

UI surfaces an extension can use:

Every API follows the same pattern: register, return a disposable, refresh on model change.


2. Why LSP — One Server, Many Editors

So far we lived inside VS Code. LSP takes one step further out.

2.1 The M-times-N problem

Before LSP, each editor implemented every language feature separately. For M editors to support N languages, you needed M times N integrations. Someone wrote Python autocomplete for Emacs, then wrote it again for Vim, and both behaved differently from VS Code's.

In 2016, just before VS Code's launch, Microsoft published the LSP specification. The idea is simple: split language features into a server. The editor becomes a client and they exchange messages over JSON-RPC. The problem collapses from M-times-N to M-plus-N.

2.2 Where the spec stands

As of May 2026:

LSP is fundamentally a message contract. You can pick any server implementation. In Rust it's tower-lsp (or the newer tower-lsp-server), in TypeScript it's vscode-languageserver, in Go go.lsp.dev/protocol, in Python pygls, and Haskell, OCaml, and others all exist.

2.3 Who uses LSP

Servers that earn their pay in production, one line each:

The same server drives Neovim via nvim-lspconfig, Emacs via lsp-mode, Helix natively, and JetBrains via the LSP4IJ plugin.


3. Anatomy of the LSP Protocol

Now the message shapes.

3.1 Transport — JSON-RPC over stdio (usually)

The canonical LSP transport is JSON-RPC 2.0 over stdin/stdout. Not HTTP, just a byte stream. Each message has a header and a body.

Content-Length: 152\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":12345,...}}

Alternative transports: sockets, IPC, WebSocket. The VS Code client supports all three but stdio is the easiest to write.

3.2 Lifecycle

client ──▶ initialize ──▶ server
client ◀── initialize result ─ server
client ──▶ initialized ──▶ (notification)
... (normal operation) ...
client ──▶ shutdown ──▶ server
client ──▶ exit ──▶ server

In the initialize request the client announces what it supports, and the server announces what it provides. That handshake is ServerCapabilities. Example: if the server doesn't set hoverProvider: true, the client never sends a hover request.

3.3 Three kinds of messages

Direction is bidirectional. The server can send window/showMessage to the client, and the client can query the server with workspace/configuration.

3.4 Text document synchronization

A server needs the file contents. The sync mode is one of three:

textDocument/didOpen, didChange, didClose, didSave are the core notifications.

3.5 Methods by capability

Major requests the server implements:

FeatureRequest method
Diagnostics (red squiggles)server-to-client textDocument/publishDiagnostics
AutocompletetextDocument/completion
Signature helptextDocument/signatureHelp
HovertextDocument/hover
Go to definitiontextDocument/definition
Find referencestextDocument/references
Symbol searchtextDocument/documentSymbol, workspace/symbol
RenametextDocument/rename, prepareRename
Code actiontextDocument/codeAction
FormattingtextDocument/formatting, rangeFormatting
Semantic tokenstextDocument/semanticTokens/full
Inlay hintstextDocument/inlayHint
Call hierarchytextDocument/prepareCallHierarchy

4. The Mini Language — Defining tomlplus

Before code, fix the toy language. Call it tomlplus. It looks like TOML with two additions.

The grammar is small.

[server]
host: string = "localhost"
port: int = 8080

[client]
target = $server.host
timeout: int = 30

The LSP needs to:

  1. Parse and emit diagnostics on bad syntax.
  2. Show types on hover for keys and values.
  3. Jump from a $ref to the original key on F12.
  4. Suggest sections, keys, and $ref targets in completion.
  5. Rename a key across all references.

We build that, end to end.


5. Writing an LSP in TypeScript

The standard starting point is vscode-languageserver-node. The VS Code team maintains it directly, and the client library lives in the same monorepo.

5.1 Project layout

tomlplus-lsp/
  client/
    src/extension.ts        # VS Code extension (client)
    package.json
  server/
    src/server.ts            # LSP server (runs as Node)
    package.json
  package.json               # workspace root

The server runs as a separate Node process; the client spawns it as a child.

5.2 Server bootstrap

import {
  createConnection,
  TextDocuments,
  ProposedFeatures,
  InitializeParams,
  TextDocumentSyncKind,
  InitializeResult,
} from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';

const connection = createConnection(ProposedFeatures.all);
const documents = new TextDocuments(TextDocument);

connection.onInitialize((params: InitializeParams): InitializeResult => {
  return {
    capabilities: {
      textDocumentSync: TextDocumentSyncKind.Incremental,
      completionProvider: { triggerCharacters: ['.', '$'] },
      hoverProvider: true,
      definitionProvider: true,
      referencesProvider: true,
      renameProvider: { prepareProvider: true },
      codeActionProvider: true,
      documentFormattingProvider: true,
      semanticTokensProvider: {
        legend: {
          tokenTypes: ['keyword', 'string', 'number', 'variable', 'property'],
          tokenModifiers: ['declaration', 'readonly'],
        },
        range: false,
        full: true,
      },
    },
  };
});

documents.listen(connection);
connection.listen();

The TextDocuments manager handles didOpen and didChange notifications automatically, so we always have the latest text. We just layer features on top.

5.3 The parser

A real LSP wants an incremental parser, but a simple line-based parser is enough to start.

interface TomlPlusKey {
  section: string;
  key: string;
  type: string | null;
  value: string;
  line: number;
  range: { start: number; end: number };
}

function parse(text: string): { keys: TomlPlusKey[]; errors: ParseError[] } {
  const lines = text.split('\n');
  const keys: TomlPlusKey[] = [];
  const errors: ParseError[] = [];
  let section = '';

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i].replace(/#.*$/, '').trim();
    if (!line) continue;

    const sectionMatch = /^\[([a-zA-Z_][\w]*)\]$/.exec(line);
    if (sectionMatch) { section = sectionMatch[1]; continue; }

    const kvMatch = /^([a-zA-Z_][\w]*)\s*(?::\s*(int|string|bool))?\s*=\s*(.+)$/.exec(line);
    if (!kvMatch) {
      errors.push({ line: i, message: `Invalid syntax: "${line}"` });
      continue;
    }
    keys.push({
      section,
      key: kvMatch[1],
      type: kvMatch[2] ?? null,
      value: kvMatch[3],
      line: i,
      range: { start: 0, end: lines[i].length },
    });
  }
  return { keys, errors };
}

Production LSPs use Tree-sitter or libraries like chevrotain for true incremental parsing. Covered in the next chapter.

5.4 Diagnostics via publishDiagnostics

On every change, parse and publish the red squiggles.

import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver/node';

documents.onDidChangeContent((change) => {
  validateDocument(change.document);
});

function validateDocument(doc: TextDocument) {
  const text = doc.getText();
  const { keys, errors } = parse(text);
  const diagnostics: Diagnostic[] = [];

  for (const err of errors) {
    diagnostics.push({
      severity: DiagnosticSeverity.Error,
      range: {
        start: { line: err.line, character: 0 },
        end: { line: err.line, character: 100 },
      },
      message: err.message,
      source: 'tomlplus',
    });
  }

  // Type mismatch — simple example.
  for (const k of keys) {
    if (k.type === 'int' && !/^\d+$/.test(k.value.trim())) {
      diagnostics.push({
        severity: DiagnosticSeverity.Warning,
        range: {
          start: { line: k.line, character: 0 },
          end: { line: k.line, character: k.range.end },
        },
        message: `Expected int, got "${k.value}"`,
        source: 'tomlplus',
      });
    }
  }

  connection.sendDiagnostics({ uri: doc.uri, diagnostics });
}

Two key points:

5.5 Completion

import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node';

connection.onCompletion(({ textDocument, position }): CompletionItem[] => {
  const doc = documents.get(textDocument.uri);
  if (!doc) return [];

  const text = doc.getText();
  const { keys } = parse(text);
  const linePrefix = doc
    .getText({ start: { line: position.line, character: 0 }, end: position })
    .trim();

  // After $ — suggest cross-section keys.
  if (linePrefix.endsWith('$')) {
    return keys.map((k) => ({
      label: `${k.section}.${k.key}`,
      kind: CompletionItemKind.Variable,
      detail: `${k.type ?? 'any'} = ${k.value}`,
    }));
  }
  // Start of line — suggest key skeletons.
  return [
    { label: 'host: string = ', kind: CompletionItemKind.Snippet },
    { label: 'port: int = ', kind: CompletionItemKind.Snippet },
    { label: 'enabled: bool = ', kind: CompletionItemKind.Snippet },
  ];
});

connection.onCompletionResolve((item) => {
  // Lazy slot for heavy info. Identity for now.
  return item;
});

onCompletionResolve matters as an optimization. If a completion list has 100 candidates, computing every documentation up front is wasteful. Instead, when the user hovers a single item, resolve runs and fills it in.

5.6 Hover

import { Hover } from 'vscode-languageserver/node';

connection.onHover(({ textDocument, position }): Hover | null => {
  const doc = documents.get(textDocument.uri);
  if (!doc) return null;

  const { keys } = parse(doc.getText());
  const lineText = doc.getText({
    start: { line: position.line, character: 0 },
    end: { line: position.line, character: 1000 },
  });
  const word = wordAt(lineText, position.character);
  if (!word) return null;

  const found = keys.find((k) => k.key === word);
  if (!found) return null;

  return {
    contents: {
      kind: 'markdown',
      value: `**${found.section}.${found.key}**\n\nType: \`${found.type ?? 'any'}\`\nValue: \`${found.value}\``,
    },
  };
});

5.7 Definition and References

When the user hits F12 on $server.host, jump to the original key.

connection.onDefinition(({ textDocument, position }) => {
  const doc = documents.get(textDocument.uri);
  if (!doc) return null;

  const { keys } = parse(doc.getText());
  const lineText = doc.getText({
    start: { line: position.line, character: 0 },
    end: { line: position.line, character: 1000 },
  });
  const ref = parseRef(lineText, position.character); // {section:'server', key:'host'} for example
  if (!ref) return null;

  const target = keys.find((k) => k.section === ref.section && k.key === ref.key);
  if (!target) return null;

  return {
    uri: doc.uri,
    range: {
      start: { line: target.line, character: 0 },
      end: { line: target.line, character: target.range.end },
    },
  };
});

connection.onReferences(({ textDocument, position }) => {
  // Find every referencing line, return Location[]. Inverse of onDefinition.
  return findAllReferences(textDocument.uri, position);
});

5.8 Rename

One of the trickier features. With prepareProvider: true, rename happens in two steps.

  1. prepareRename — "Is renaming valid here?" If yes, return the range.
  2. rename — receive the new name, return a WorkspaceEdit.
import { WorkspaceEdit, TextEdit } from 'vscode-languageserver/node';

connection.onPrepareRename(({ textDocument, position }) => {
  const doc = documents.get(textDocument.uri);
  if (!doc) return null;
  const range = findIdentifierRange(doc, position);
  return range ?? null;
});

connection.onRenameRequest(({ textDocument, position, newName }): WorkspaceEdit => {
  const doc = documents.get(textDocument.uri)!;
  const locations = findAllOccurrences(doc, position);

  return {
    changes: {
      [doc.uri]: locations.map((loc) => TextEdit.replace(loc.range, newName)),
    },
  };
});

A multi-file rename fills changes with one array per URI. The client applies them atomically.

5.9 Code actions

The light bulb next to a diagnostic is a code action. "Ignore this warning", "Add a type annotation", "Extract this key" — that family.

import { CodeAction, CodeActionKind } from 'vscode-languageserver/node';

connection.onCodeAction((params): CodeAction[] => {
  const doc = documents.get(params.textDocument.uri);
  if (!doc) return [];
  const actions: CodeAction[] = [];

  for (const diag of params.context.diagnostics) {
    if (diag.message.startsWith('Expected int')) {
      actions.push({
        title: 'Change type to "string"',
        kind: CodeActionKind.QuickFix,
        diagnostics: [diag],
        edit: {
          changes: {
            [doc.uri]: [TextEdit.replace(diag.range, fixTypeToString(doc, diag.range))],
          },
        },
      });
    }
  }
  return actions;
});

5.10 Semantic tokens

Regex-based TextMate grammars hit obvious limits for syntax highlighting. Semantic tokens let the server tell the client directly: "this identifier is a variable, that one is a keyword."

The server sends tokens as a packed integer array — groups of five: [deltaLine, deltaStart, length, tokenType, tokenModifiers]. Indexes refer to the legend.

import { SemanticTokensBuilder } from 'vscode-languageserver/node';

connection.languages.semanticTokens.on(({ textDocument }) => {
  const doc = documents.get(textDocument.uri)!;
  const builder = new SemanticTokensBuilder();
  const { keys } = parse(doc.getText());

  for (const k of keys) {
    // [line, startChar, length, tokenType, tokenModifiers]
    builder.push(k.line, 0, k.key.length, /*property*/ 4, /*declaration*/ 1);
  }
  return builder.build();
});

Semantic tokens overlay the base TextMate highlighting — undefined tokens fall through to the default. That's the elegance of it.

5.11 Formatting

connection.onDocumentFormatting(({ textDocument, options }) => {
  const doc = documents.get(textDocument.uri)!;
  const formatted = formatTomlPlus(doc.getText(), options);
  return [TextEdit.replace(
    {
      start: { line: 0, character: 0 },
      end: doc.positionAt(doc.getText().length),
    },
    formatted
  )];
});

Large formatters usually spawn an external tool (black, prettier, gofmt) and pass the result through.


6. The Client — Spawning the Server from the Extension

Now the VS Code extension side. We spawn the server as a child process and wire up the client.

import * as path from 'path';
import * as vscode from 'vscode';
import {
  LanguageClient,
  LanguageClientOptions,
  ServerOptions,
  TransportKind,
} from 'vscode-languageclient/node';

let client: LanguageClient;

export function activate(context: vscode.ExtensionContext) {
  const serverModule = context.asAbsolutePath(path.join('server', 'out', 'server.js'));

  const serverOptions: ServerOptions = {
    run: { module: serverModule, transport: TransportKind.ipc },
    debug: {
      module: serverModule,
      transport: TransportKind.ipc,
      options: { execArgv: ['--nolazy', '--inspect=6009'] },
    },
  };

  const clientOptions: LanguageClientOptions = {
    documentSelector: [{ scheme: 'file', language: 'tomlplus' }],
    synchronize: {
      fileEvents: vscode.workspace.createFileSystemWatcher('**/*.tomlplus'),
    },
  };

  client = new LanguageClient('tomlplus', 'TOML+ Language Server', serverOptions, clientOptions);
  client.start();
}

export function deactivate(): Thenable\<void\> | undefined {
  return client?.stop();
}

TransportKind.ipc is a Node-only IPC channel that's faster than stdio. Stdio also works but IPC is the standard recommendation.

Add the language definition to package.json.

"contributes": {
  "languages": [{
    "id": "tomlplus",
    "extensions": [".tomlplus", ".tpl"],
    "configuration": "./language-configuration.json"
  }],
  "grammars": [{
    "language": "tomlplus",
    "scopeName": "source.tomlplus",
    "path": "./syntaxes/tomlplus.tmLanguage.json"
  }]
}

The tmLanguage.json provides regex-based baseline highlighting; semantic tokens from the LSP overlay it.


7. Writing an LSP in Rust — tower-lsp

We rebuild the same LSP in Rust. Why Rust:

The core of tower-lsp is the LanguageServer trait — async methods receive LSP requests.

use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LanguageServer, LspService, Server};

#[derive(Debug)]
struct Backend {
    client: Client,
}

#[tower_lsp::async_trait]
impl LanguageServer for Backend {
    async fn initialize(&self, _: InitializeParams) -> Result\<InitializeResult\> {
        Ok(InitializeResult {
            capabilities: ServerCapabilities {
                text_document_sync: Some(TextDocumentSyncCapability::Kind(
                    TextDocumentSyncKind::INCREMENTAL,
                )),
                completion_provider: Some(CompletionOptions {
                    trigger_characters: Some(vec![".".into(), "$".into()]),
                    ..Default::default()
                }),
                hover_provider: Some(HoverProviderCapability::Simple(true)),
                definition_provider: Some(OneOf::Left(true)),
                ..Default::default()
            },
            server_info: Some(ServerInfo {
                name: "tomlplus-lsp".into(),
                version: Some("0.1.0".into()),
            }),
        })
    }

    async fn initialized(&self, _: InitializedParams) {
        self.client.log_message(MessageType::INFO, "server ready").await;
    }

    async fn did_change(&self, params: DidChangeTextDocumentParams) {
        let uri = params.text_document.uri.clone();
        let diags = validate(&params.content_changes);
        self.client.publish_diagnostics(uri, diags, None).await;
    }

    async fn completion(&self, _: CompletionParams) -> Result\<Option\<CompletionResponse\>\> {
        Ok(Some(CompletionResponse::Array(vec![
            CompletionItem::new_simple("host".into(), "hostname".into()),
            CompletionItem::new_simple("port".into(), "TCP port".into()),
        ])))
    }

    async fn shutdown(&self) -> Result\<()\> { Ok(()) }
}

#[tokio::main]
async fn main() {
    let stdin = tokio::io::stdin();
    let stdout = tokio::io::stdout();
    let (service, socket) = LspService::new(|client| Backend { client });
    Server::new(stdin, stdout, socket).serve(service).await;
}

Cargo.toml:

[dependencies]
tower-lsp = "0.20"
tokio = { version = "1", features = ["full"] }

To spawn the Rust server from a VS Code extension, use the command variant of ServerOptions.

const serverOptions: ServerOptions = {
  command: context.asAbsolutePath('bin/tomlplus-lsp'),
  args: [],
  transport: TransportKind.stdio,
};

Ship per-platform binaries with the extension (preferred), download them in a npm postinstall, or build on the user's machine via Cargo.


8. Tree-sitter — Freedom to Build Your Own Parser

The line-based parser above is a toy. Real LSPs need incremental parsers — re-parsing the whole file on every keystroke breaks down on large files.

Tree-sitter is an incremental parser generator GitHub built during the Atom era. The defining traits:

8.1 Defining a grammar

Grammars live in grammar.js.

module.exports = grammar({
  name: 'tomlplus',
  rules: {
    source_file: ($) => repeat(choice($.section, $.assignment)),
    section: ($) => seq('[', $.identifier, ']'),
    assignment: ($) =>
      seq($.identifier, optional($.type_annotation), '=', $.value),
    type_annotation: ($) => seq(':', choice('int', 'string', 'bool')),
    value: ($) => choice($.string, $.number, $.reference),
    reference: ($) => seq('$', $.identifier, '.', $.identifier),
    identifier: ($) => /[a-zA-Z_][\w]*/,
    string: ($) => /"[^"]*"/,
    number: ($) => /\d+/,
  },
});

tree-sitter generate emits a C parser. Load it from the LSP.

8.2 Tree-sitter in TypeScript

import Parser from 'tree-sitter';
import TomlPlus from 'tree-sitter-tomlplus';

const parser = new Parser();
parser.setLanguage(TomlPlus);

function parseDoc(text: string) {
  return parser.parse(text);
}

function reparse(oldTree: Parser.Tree, edit: Parser.Edit, newText: string) {
  oldTree.edit(edit);
  return parser.parse(newText, oldTree); // incremental
}

Tell Tree-sitter what changed via oldTree.edit(), and the next parse rebuilds only the affected subtree. One keystroke in a 1000-line file is essentially free.

8.3 Finding nodes with queries

Tree-sitter queries are an S-expression-style pattern language.

(section (identifier) @section.name)
(assignment
  (identifier) @key.name
  (type_annotation (_) @key.type)?
  (value) @key.value)
(reference
  (identifier) @ref.section
  (identifier) @ref.key)

@name captures matched nodes. In an LSP, definition, reference, and symbol extraction all reduce to such queries.

8.4 Division of labor: Tree-sitter and LSP

Tree-sitter handles syntax; LSP handles semantics and user interaction. The typical picture:

[user types]
[VS Code client]  ── didChange ──▶  [LSP server]
                                    [Tree-sitter incremental parse]
                                    [symbol index update]
                                    publishDiagnostics, etc.

Helix and Zed embed Tree-sitter directly in the editor — the client holds the tree and the LSP only contributes semantic information. VS Code has unofficial Tree-sitter integrations, but it's most often used inside the LSP server.


9. Publishing to Marketplace and OpenVSX

Once the code works, ship it.

9.1 Packaging a VSIX

A VS Code extension is a zip file with .vsix extension. Build it with vsce.

npm install -g @vscode/vsce
cd tomlplus
vsce package
# → tomlplus-0.1.0.vsix

Local install for testing: code --install-extension tomlplus-0.1.0.vsix.

9.2 Publishing to the Marketplace

Create a Publisher in Azure DevOps and obtain a Personal Access Token first.

vsce login yj
vsce publish
# or: vsce publish minor / patch / major

Publishing policy reminders:

9.3 OpenVSX — the marketplace for non-Microsoft builds

VSCodium, Cursor, Continue, Theia, Gitpod, and code-server can't (or by license shouldn't) reach the MS Marketplace. Their alternative is the Open VSX Registry (operated by the Eclipse Foundation at open-vsx.org).

npm install -g ovsx
ovsx create-namespace yj -p <ovsx-token>
ovsx publish tomlplus-0.1.0.vsix -p <ovsx-token>

Publishing the same VSIX to both registries is standard practice. Automate via GitHub Actions:

- name: Publish to VS Marketplace
  run: vsce publish -p $\{\{ secrets.VSCE_PAT \}\}
- name: Publish to OpenVSX
  run: ovsx publish *.vsix -p $\{\{ secrets.OVSX_TOKEN \}\}

9.4 Cursor and Continue compatibility

Cursor is a VS Code fork. It uses Open VSX as its default marketplace, and certain MS-proprietary extensions (C/C++, Pylance, Live Share) can't be installed directly because of licensing. Generic extensions like ours just work — Cursor exposes the same VS Code API surface.

AI extensions like Continue and Cody often reuse the same LSP client code. If our server speaks standard LSP, compatibility falls out naturally.

9.5 Telemetry

VS Code provides @vscode/extension-telemetry for usage statistics. Caveats:

import TelemetryReporter from '@vscode/extension-telemetry';
const reporter = new TelemetryReporter('<connection-string>');
context.subscriptions.push(reporter);
reporter.sendTelemetryEvent('command.scanWorkspace');

10. Patterns from Production LSPs

The final chapter sketches how the giants actually work.

10.1 rust-analyzer — the textbook for principled LSP design

10.2 gopls — the Go team's canonical implementation

10.3 typescript-language-server — the adapter pattern

10.4 ruff-lsp — Rust-fast Python tooling

10.5 Astro Language Server — multi-language files

10.6 Common patterns across the field


Epilogue — Two Hours to Start, Two Months to Polish

The first 30 minutes with LSP are the hardest. JSON-RPC directions are confusing, capabilities negotiation feels abstract, all three didChange sync modes look similar. After that 30 minutes — every feature turns out to be a variation on the same shape. Receive a text position, derive meaning from your model, return a response. That's the whole game.

Starter checklist

Anti-patterns

Coming up next


References

Comments

No comments yet.

Sign in to leave a comment