Skip to content

✍️ 필사 모드: Build Your Own VS Code Extension and Language Server — A 2026 LSP Hands-On Guide

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

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:

  • Register commands that show up in the palette and key bindings.
  • Provide language features — Completion, Hover, Definition, References, Diagnostics — directly, or delegate them to an LSP.
  • Decorate the editor and draw tree views in the sidebar.
  • Status bar items, notifications, webviews — the UI surface.
  • Workspace and file system providers for virtual or remote filesystems.
  • Debug adapters via DAP.
  • Tasks and terminals.

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.

  • engines.vscode — minimum compatible VS Code version. Set it too low and you lose access to new APIs.
  • main — entry file. CommonJS (ESM entry is still experimental as of 2026).
  • activationEventswhen to wake the extension up. Misconfigure this and every user's VS Code start becomes slower.

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:

  • Tree view (TreeDataProvider) — a tree in the sidebar. Good fit for build outputs, test lists, TODO collections.
  • Status bar item — small text on the left or right edge. Progress or quick actions.
  • Webview — iframe-like surface that can host arbitrary HTML and JS. For full-screen panels.
  • Walkthroughs — onboarding UI that guides users through extension features. Pure declaration in package.json.
  • Notebook controllers — cell-based interfaces like Jupyter.
  • Inlay hints — types or parameter names rendered inline between tokens.

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 3.18 — the current stable spec. Semantic tokens v2, inlay hints, type hierarchy are included.
  • DAP (Debug Adapter Protocol) — the sibling protocol for debuggers.
  • Open VSX — the non-Microsoft marketplace used by VSCodium, Cursor, Gitpod.

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:

  • rust-analyzer — official Rust. Custom implementation, not built on tower-lsp.
  • gopls — official Go. Managed by the Go team directly.
  • typescript-language-server — wraps Microsoft's tsserver behind LSP.
  • pyright / pylance — Python type-checker doubling as an LSP.
  • ruff-lsp — extremely fast Python linter LSP written in Rust.
  • clangd — official C/C++.
  • Astro Language Server — for .astro files.
  • svelte-language-server, vue-language-server — framework-specific.
  • lsp-bridge, copilot-language-server — Copilot itself ends up mimicking LSP messages.

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

  • Request — has an id, must get a response. Example: textDocument/hover.
  • Response — reply to a request, matched by id.
  • Notification — no id, no response. Examples: textDocument/didChange, textDocument/publishDiagnostics.

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:

  • None (0) — no sync.
  • Full (1) — every change sends the whole file. Simple for tiny files.
  • Incremental (2) — only the changed range. Efficient on large files.

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.

  • Referenceslink = $other_section.key points at another section's value.
  • Type annotationsport: int = 8080 puts a type next to the key.

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:

  • Always send the full diagnostic set for a URI. No partial updates. An empty array means "no problems."
  • The source field tells the UI which tool published this diagnostic — labels like "tomlplus(eslint)" in the Problems panel.

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:

  • Natural pairing with Tree-sitter.
  • Tokio-based async runtime.
  • The pattern is proven by rust-analyzer.

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:

  • Incremental — only the changed region is re-parsed.
  • Error recovery — produces a tree even on broken code (decisive for LSP).
  • Multilingual — 200+ language grammars already exist.
  • C runtime — usable from TS, Rust, Python, Go.

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:

  • Missing LICENSE may cause rejection.
  • Images in README.md must use absolute URLs.
  • Review completes in roughly 24 hours — static checks plus a security scan.
  • Declare any cross-extension dependencies via extensionDependencies.

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:

  • If the user sets telemetry.telemetryLevel to off, events are automatically suppressed.
  • Never send PII. Anonymized events only.
  • Notify the user on first run (Marketplace policy).
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

  • Salsa (a query system with demand-driven, incremental computation) underpins every analysis.
  • A VFS abstraction unifies the filesystem and LSP notifications under a single model.
  • Hot paths stay fast even on workspaces with 100+ sub-crates.

10.2 gopls — the Go team's canonical implementation

  • A bespoke cache layer recomputes only the affected dependency graph on file changes.
  • A "view" abstraction lets it manage multiple build contexts (GOOS, GOARCH) for the same code simultaneously.

10.3 typescript-language-server — the adapter pattern

  • The actual analysis is done by tsserver (the TypeScript compiler).
  • A thin layer translates between LSP and tsserver's own protocol.
  • That single shim exposes a TS compiler that knew nothing about LSP to every editor.

10.4 ruff-lsp — Rust-fast Python tooling

  • Ruff itself is a Python linter, but written in Rust at roughly 100x the speed of ESLint.
  • The LSP is a thin adapter on top.
  • Coexists with pyright and pylance — both can run simultaneously without conflicts.

10.5 Astro Language Server — multi-language files

  • An .astro file mixes frontmatter (TS), markup (HTML), and inline scripts (JS).
  • "Embedded language" pattern — Astro LS creates virtual documents per region and delegates to TS LS and HTML LS.
  • The same pattern powers Vue Volar and Svelte LS.

10.6 Common patterns across the field

  • Keep the workspace index in memory — avoid re-reading from disk.
  • Demand-driven, incremental analysis — compute on the first hover and cache.
  • Error recovery decides UX — completion must not die on broken files.
  • Cancellation — the moment the user types again, cancel the previous request.

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

  • Run the vscode-languageserver-node sample LSP (server-and-client-sample) once to see the message traffic in action.
  • Learn how to attach a debugger with --inspect=6009 early.
  • Narrow activationEvents as much as possible. You own every user's startup time.
  • Default to TextDocumentSyncKind.Incremental. Full is for tiny files only.
  • Fill the diagnostic source field — users want to know which tool produced the warning.
  • Lazy-load heavy completion info via onCompletionResolve.
  • Reach for Tree-sitter or an incremental parser as soon as files exceed a few hundred lines.
  • Publish to both OpenVSX and the Marketplace on day one.

Anti-patterns

  • activationEvents: ["*"] — slow startup for every user. The single largest source of complaints.
  • Full re-parse on every keystroke — stutters on large files. Tree-sitter or a debounce, mandatory.
  • Diagnostics fired instantly per keystroke — debounce around 300 ms or users see red squiggles flicker per character.
  • Hand-rolled text sync — bypassing the TextDocuments manager means computing incremental offsets yourself. Always bug-prone.
  • Foreground analysis — heavy synchronous work blocks every other request. Async or workers.
  • Memory leaks — every disposable must go into context.subscriptions, or handlers persist past deactivation.
  • Skipping prepareRename — F2 inside a comment or string should be blocked, not allowed to ruin a project.
  • Publishing only to the MS Marketplace — Cursor and VSCodium users can't install it. OpenVSX too.

Coming up next

  • Exposing an incremental compiler as an LSP — Salsa, Adapton, demand-driven analysis.
  • Deep-dive into DAP — LSP's debugging sibling. Same JSON-RPC, different methods.
  • How Copilot pretends to be an LSP — the subtle line between completionProvider and inlineCompletionItemProvider.
  • Embedding an LSP client when you build your own editor — what Helix and Zed got right.

References

현재 단락 (1/694)

The first time I felt that "the editor understands my code" was VS Code in 2016. Before that, autoco...

작성 글자: 0원문 글자: 31,872작성 단락: 0/694