Skip to content

Split View: 내 손으로 만드는 VS Code 확장과 Language Server — 2026 LSP 실전 가이드

|

내 손으로 만드는 VS Code 확장과 Language Server — 2026 LSP 실전 가이드

프롤로그 — 에디터가 글을 읽게 만든 사람들

내가 처음으로 "에디터가 내 코드를 이해한다"고 느낀 건 2016년의 VS Code였다. 그 전까지 자동완성이라는 건 식별자 사전과 정규식의 그럴싸한 합작이었고, "Go to Definition"은 ctags가 지킨 마지막 보루였다. 그런데 VS Code에서 TypeScript 파일을 열면 함수 위에 타입이 떠올랐고, F12 한 번에 정의가 열렸고, 잘못된 글자에는 빨간 줄이 그어졌다.

그 뒤에는 두 가지가 같이 있었다. Extension API가 에디터와 코드를 잇는 표면이었고, Language Server Protocol(LSP) 이 그 표면 너머에서 "코드를 이해하는 일"을 따로 떼어 표준화했다.

2026년 5월 기준, LSP 사양은 3.18이고 VS Code는 stable 채널에서 매달 새 마이너 버전을 찍는다. vscode-languageserver-node 9.x가 표준 TypeScript 구현이고, Rust 쪽에는 tower-lsp가 사실상의 기본이다. 같은 LSP 서버 하나가 Neovim, Helix, Emacs, Sublime, Zed, JetBrains에서도 동작한다. 이제는 "에디터에 언어 지원을 넣는다"라는 표현 자체가 어색하다 — 우리는 "언어 서버를 짠다"고 말하고, 에디터들이 알아서 붙는다.

이 글은 그 일을 처음부터 끝까지 한 번 해보는 가이드다. 단순한 TODO 하이라이터 확장으로 시작해, 가상의 설정 언어를 위한 진짜 LSP를 짠다. 그리고 둘을 붙여서 마켓플레이스에 올리는 데까지 간다. Cursor와 Continue 같은 포크에서 같은 코드가 동작하는 이유도, Tree-sitter가 왜 LSP 옆에서 같이 산책하는지도 같이 본다.


1장 · VS Code Extension API의 지형

먼저 가장 얇은 표면, 확장 자체부터 본다. LSP는 그 위에 얹는 한 층이다.

1.1 확장이란 무엇인가

VS Code 확장은 한마디로 "에디터의 호스트 프로세스에서 실행되는 Node.js 모듈"이다. 모든 확장은 별도의 Extension Host 프로세스에서 격리되어 동작한다. 메인 UI 스레드(렌더러)와는 IPC로 떨어져 있다 — 확장이 무한 루프를 돌아도 에디터는 살아남는다.

확장이 할 수 있는 일은 대략 다음과 같다.

  • 명령(Commands) 등록 — 커맨드 팔레트와 키 바인딩에 노출.
  • 언어 기능 프로바이더 — Completion, Hover, Definition, References, Diagnostics 등을 직접 구현하거나 LSP에 위임.
  • 데코레이션과 트리 뷰 — 편집기 본문에 색을 칠하고, 사이드바에 트리를 그린다.
  • 상태바·노티피케이션·웹뷰 — 사용자 인터페이스 표면.
  • 워크스페이스/파일 시스템 프로바이더 — 가상 파일 시스템, 원격 파일 시스템.
  • 디버그 어댑터(DAP) — 디버거 통합.
  • 태스크와 터미널 — 실행 환경.

이 글에서 우리는 위 두 개 — 명령과 언어 기능 — 를 집중해서 다룬다.

1.2 package.json 매니페스트

확장의 정체는 package.json이 전부 결정한다. 다음은 우리 TODO 하이라이터의 최소 매니페스트다.

{
  "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"
        }
      }
    }
  }
}

핵심 필드 세 개만 짚는다.

  • engines.vscode — 호환되는 최소 VS Code 버전. 너무 낮게 잡으면 새 API를 못 쓴다.
  • main — 진입점 파일. CommonJS여야 한다(2026년 기준 ESM 진입점은 실험 단계).
  • activationEvents언제 확장을 깨울지. 잘못 설정하면 사용자의 모든 VS Code 시작이 느려진다.

activationEvents는 가장 자주 실수하는 곳이다. onStartupFinished는 "에디터가 다 뜬 다음 한 번"이라 가장 무난하다. 옛날 *는 deprecated — 모든 시작에 확장을 깨우기 때문이다. 더 좋은 건 활성화 트리거를 좁히는 것이다.

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

위는 "tomlplus 언어 파일이 열렸을 때", "특정 명령이 호출됐을 때", "워크스페이스에 해당 파일이 있을 때"만 확장을 깨운다. VS Code 1.74부터 contributes.commands의 명령은 자동으로 activation 트리거가 되므로 onCommand:*는 보통 생략 가능하다.

1.3 Hello World 확장

extension.ts는 다음 형태가 표준이다.

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() {}

context.subscriptions에 푸시한 모든 Disposable은 확장 비활성화 시 자동으로 정리된다. 메모리 누수를 막는 가장 중요한 규칙 하나다.

1.4 데코레이션으로 TODO 색칠하기

진짜 TODO 하이라이터를 만들려면 데코레이션 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
);

여기까지는 LSP가 필요 없다. 정규식 한 줄과 데코레이션 API로 충분한 일은 굳이 서버를 띄울 이유가 없다. 하지만 자동완성·정의로 이동·심볼 인덱스·incremental 파싱이 필요해지면 — 그때부터는 서버다.

1.5 트리 뷰, 상태바, 웹뷰, 워크스루

확장이 다룰 수 있는 UI 표면들:

  • 트리 뷰(TreeDataProvider) — 사이드바에 트리. 빌드 산출물, 테스트 목록, TODO 모음 같은 것에 어울린다.
  • 상태바 아이템(StatusBarItem) — 좌/우측에 작은 텍스트. 진행 상황이나 빠른 액션.
  • 웹뷰(Webview) — 임의의 HTML/JS를 띄울 수 있는 iframe과 비슷한 표면. 풀스크린 패널이 필요할 때.
  • 워크스루(Walkthroughs) — 사용자에게 확장 사용법을 단계로 안내하는 온보딩 UI. package.json에 선언만 하면 된다.
  • Notebook 컨트롤러 — Jupyter처럼 셀 기반 인터페이스.
  • 인레이 힌트(Inlay Hints) — 타입이나 매개변수 이름을 코드 사이에 표시.

각각의 API는 같은 패턴이다 — 등록(register), 디스포저블 반환, 모델 변화 시 갱신.


2장 · 왜 LSP인가 — 한 서버, 많은 에디터

여기까지는 VS Code 안의 이야기였다. LSP는 한 단계 더 나아간다.

2.1 M×N 문제

LSP 이전, 에디터마다 같은 언어 기능을 따로 짰다. M개의 에디터가 N개의 언어를 지원하려면 M×N 개의 통합이 필요했다. Python 자동완성을 Emacs에 넣은 사람이 Vim용을 또 짜고, 그 둘이 VS Code 것과 다르게 동작한다.

Microsoft는 2016년 VS Code 출시 직전 LSP 사양을 공개했다. 아이디어는 단순하다 — 언어 기능을 서버로 분리하자. 에디터는 클라이언트가 되고, JSON-RPC로 메시지를 주고받는다. M+N 문제로 바뀐다.

2.2 사양의 위치

2026년 5월 기준:

  • LSP 3.18 — 최신 안정 사양. semantic tokens v2, inlay hints, type hierarchy 등이 포함.
  • DAP(Debug Adapter Protocol) — 디버거를 위한 자매 프로토콜.
  • Open VSX — VSCodium·Cursor·Gitpod 등 비-MS 빌드가 쓰는 마켓플레이스 대체.

LSP는 결국 메시지 사양이다. 서버 구현은 자유롭게 고를 수 있다. Rust로는 tower-lsp(또는 더 최신 tower-lsp-server), TypeScript로는 vscode-languageserver, Go로는 go.lsp.dev/protocol, Python으로는 pygls, Haskell까지 다 있다.

2.3 누가 LSP를 쓰는가

실전에서 잘 쓰이는 서버들. 한 줄로:

  • rust-analyzer — Rust 공식. tower-lsp 기반이 아니라 직접 구현.
  • gopls — Go 공식. Go 팀이 직접 관리.
  • typescript-language-server — Microsoft의 tsserver를 LSP로 래핑.
  • pyright / pylance — Python 타입 체커 겸 LSP.
  • ruff-lsp — Rust로 짠 초고속 Python 린터 + LSP.
  • clangd — C/C++ 공식.
  • Astro Language Server.astro 파일 지원.
  • svelte-language-server, vue-language-server — 각 프레임워크.
  • lsp-bridge, copilot-language-server — Copilot도 결국 LSP 메시지를 흉내낸다.

같은 서버가 Neovim에서 nvim-lspconfig, Emacs에서 lsp-mode, Helix에서 기본 내장, JetBrains에서 LSP4IJ 플러그인을 거쳐 동작한다.


3장 · LSP 프로토콜 해부

이제 메시지의 모양을 본다.

3.1 전송 — JSON-RPC over stdio (보통)

LSP의 표준 전송은 stdin/stdout에 JSON-RPC 2.0이다. HTTP가 아니라 단순 바이트 스트림. 메시지 하나는 헤더와 본문으로 나뉜다.

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

대안 전송: 소켓, IPC, WebSocket. VS Code 클라이언트는 셋 다 지원하지만 stdio가 가장 쓰기 쉽다.

3.2 라이프사이클

client ──▶ initialize ──▶ server
client ◀── initialize result ─ server
client ──▶ initialized ──▶ (notification)
... (정상 동작 구간) ...
client ──▶ shutdown ──▶ server
client ──▶ exit ──▶ server

initialize 요청에서 클라이언트는 자기가 지원하는 기능을 알리고, 서버는 자기가 지원하는 기능을 알린다. 이 합의가 ServerCapabilities다. 예: 서버가 hoverProvider: true를 안 알리면 클라이언트는 hover를 절대 요청하지 않는다.

3.3 메시지의 세 종류

  • Request — id가 있고, response가 반드시 와야 한다. 예: textDocument/hover.
  • Response — request에 대한 답. id로 매칭.
  • Notification — id가 없고, 응답이 없다. 예: textDocument/didChange, textDocument/publishDiagnostics.

방향도 양방향이다. 서버가 클라이언트에 window/showMessage를 보내거나, 클라이언트가 서버에 workspace/configuration을 묻는다.

3.4 텍스트 문서 동기화

서버가 파일 내용을 알아야 일을 한다. 동기화 방식은 셋 중 하나다.

  • None(0) — 동기화 안 함.
  • Full(1) — 변경마다 전체 내용을 보냄. 작은 파일에 단순.
  • Incremental(2) — 변경된 범위만. 큰 파일에 효율적.

textDocument/didOpen, didChange, didClose, didSave가 핵심 노티피케이션이다.

3.5 핵심 기능별 메서드

서버가 구현하는 주요 요청들:

기능요청 메서드
진단(빨간 줄)서버 → 클라 textDocument/publishDiagnostics
자동완성textDocument/completion
시그니처 도움말textDocument/signatureHelp
호버textDocument/hover
정의로 이동textDocument/definition
참조 찾기textDocument/references
심볼 검색textDocument/documentSymbol, workspace/symbol
이름 바꾸기textDocument/rename, prepareRename
코드 액션textDocument/codeAction
포맷팅textDocument/formatting, rangeFormatting
세만틱 토큰textDocument/semanticTokens/full
인레이 힌트textDocument/inlayHint
콜 계층textDocument/prepareCallHierarchy

4장 · 미니 언어 — tomlplus를 정의한다

코드를 짜기 전에 가상의 언어를 정한다. 이름은 tomlplus. TOML과 비슷하지만 두 가지 추가가 있다.

  • 참조link = $other_section.key 형태로 다른 섹션의 값을 가리킨다.
  • 타입 주석port: int = 8080처럼 키 옆에 타입을 적을 수 있다.

문법은 단순하다.

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

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

LSP가 해야 할 일:

  1. 파싱하고 잘못된 곳에 진단을 띄운다.
  2. 키와 값에 호버로 타입을 보여준다.
  3. $ref에서 F12로 원본 키로 점프한다.
  4. 자동완성으로 섹션·키·$ref 후보를 제안한다.
  5. 이름 바꾸기로 키와 모든 참조를 같이 바꾼다.

이걸 처음부터 짜본다.


5장 · TypeScript로 LSP 서버 짜기

표준 출발점은 vscode-languageserver-node다. VS Code 팀이 직접 관리하고, 클라이언트 라이브러리도 같은 모노레포에 있다.

5.1 프로젝트 구조

tomlplus-lsp/
  client/
    src/extension.ts        # VS Code 확장 (클라이언트)
    package.json
  server/
    src/server.ts            # LSP 서버 (Node로 실행)
    package.json
  package.json               # 워크스페이스 루트

서버는 별도의 Node 프로세스로 실행되고, 클라이언트가 그걸 자식 프로세스로 띄운다.

5.2 서버 부트스트랩

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();

TextDocuments 매니저가 didOpen·didChange 노티피케이션을 자동으로 처리해서 우리에게 항상 최신 텍스트를 준다. 우리는 그 위에 기능만 얹는다.

5.3 파서

진짜 LSP는 incremental 파서를 쓰지만, 시작은 단순한 라인 기반 파서로도 충분하다.

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 };
}

실전 LSP에서는 Tree-sitter나 chevrotain 같은 라이브러리로 진짜 incremental 파서를 쓴다. 다음 장에서 다룬다.

5.4 진단 — publishDiagnostics

문서가 바뀔 때마다 파싱하고 잘못된 곳을 빨간 줄로 보낸다.

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',
    });
  }

  // 타입 미스매치 — 간단한 예
  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 });
}

핵심 포인트 둘:

  • 항상 같은 URI의 전체 diagnostics를 보낸다. 부분 갱신 없음. 빈 배열을 보내면 "잘못된 곳 없음"이라는 뜻.
  • source 필드는 무엇이 이 진단을 냈는지 — UI에서 "tomlplus(eslint)" 같은 라벨로 뜬다.

5.5 자동완성

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();

  // $로 시작 → 다른 섹션의 키 제안
  if (linePrefix.endsWith('$')) {
    return keys.map((k) => ({
      label: `${k.section}.${k.key}`,
      kind: CompletionItemKind.Variable,
      detail: `${k.type ?? 'any'} = ${k.value}`,
    }));
  }
  // 라인 시작 → 키 이름 자동완성
  return [
    { label: 'host: string = ', kind: CompletionItemKind.Snippet },
    { label: 'port: int = ', kind: CompletionItemKind.Snippet },
    { label: 'enabled: bool = ', kind: CompletionItemKind.Snippet },
  ];
});

connection.onCompletionResolve((item) => {
  // 무거운 정보를 lazy하게 채울 자리. 지금은 그대로 반환.
  return item;
});

onCompletionResolve는 중요한 최적화 포인트다. 자동완성 목록에 100개 후보가 있을 때 모든 항목의 documentation을 미리 계산하면 비싸다. 대신 사용자가 한 항목을 호버했을 때만 resolve가 호출되어 그때 채워 넣는다.

5.6 호버

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 정의로 이동 + 참조 찾기

$server.host 같은 참조에서 F12를 누르면 원본 키로 점프해야 한다.

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); // e.g. {section:'server', key:'host'}
  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 }) => {
  // 모든 참조 라인을 찾아서 Location[]로 반환.
  // 구현 생략 — onDefinition의 거꾸로.
  return findAllReferences(textDocument.uri, position);
});

5.8 이름 바꾸기

가장 까다로운 기능 중 하나. prepareProvider: true를 켰으니 두 단계로 동작한다.

  1. prepareRename — "이 위치에서 이름을 바꿀 수 있는가?"를 묻는다. 가능하면 바꿀 범위를 돌려준다.
  2. rename — 새 이름을 받아서 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)),
    },
  };
});

여러 파일에 걸친 rename은 changes에 URI마다 배열을 채우면 된다. 클라이언트가 모든 편집을 한 번에 적용한다.

5.9 코드 액션

진단 옆에 떠 있는 전구 아이콘이 코드 액션이다. "이 경고를 무시", "타입을 추가", "이 키를 두 곳에서 쓰기 — 추출" 같은 것들.

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 세만틱 토큰

신택스 하이라이팅을 정규식 기반 TextMate 문법으로 하면 한계가 분명하다. 세만틱 토큰은 서버가 "이 단어는 변수, 저 단어는 키워드"라고 클라이언트에 직접 알려주는 메커니즘이다.

서버는 토큰을 압축 정수 배열로 보낸다 — [deltaLine, deltaStart, length, tokenType, tokenModifiers] 5개씩 묶어서. 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();
});

세만틱 토큰은 기본 TextMate 하이라이팅 위에 덧칠해진다 — 정의되지 않은 토큰은 기본을 그대로 둔다. 이게 우아한 점이다.

5.11 포맷팅

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
  )];
});

대형 포매터는 보통 별도의 외부 도구(black, prettier, gofmt)를 LSP 서버가 spawn해서 결과를 받는다.


6장 · 클라이언트 — 확장에서 서버 띄우기

이제 VS Code 확장 쪽이다. 서버를 자식 프로세스로 띄우고 클라이언트를 연결한다.

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는 stdio보다 빠른 Node 전용 IPC 채널이다. stdio도 가능하지만 IPC가 표준 권장.

package.json에 언어 정의를 추가한다.

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

tmLanguage.json은 정규식 기반 기본 하이라이팅이고, 그 위에 LSP의 세만틱 토큰이 덧칠한다.


7장 · Rust로 LSP 짜기 — tower-lsp

같은 LSP를 Rust로도 짜본다. 왜 Rust인가:

  • Tree-sitter와의 자연스러운 결합.
  • Tokio 기반 비동기 처리.
  • rust-analyzer가 입증한 패턴.

tower-lsp의 핵심은 trait LanguageServer다 — async trait 메서드로 LSP 요청을 받는다.

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"] }

VS Code 확장에서 Rust 서버를 띄우려면 ServerOptionscommand 형태를 쓴다.

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

플랫폼별 바이너리를 함께 패키징하거나(권장), npm postinstall에서 다운로드하거나, Cargo로 사용자 머신에서 빌드하게 한다.


8장 · Tree-sitter — 파서를 직접 만들 자유

위 라인 기반 파서는 장난감이다. 진짜 LSP는 incremental 파서가 필요하다 — 사용자가 한 글자 칠 때마다 파일 전체를 다시 파싱하면 큰 파일에서 끊긴다.

Tree-sitter는 GitHub가 Atom 시절에 만든 incremental 파서 제너레이터다. 핵심 특징:

  • incremental — 변경된 부분만 다시 파싱.
  • 에러 복구 — 깨진 코드에서도 트리를 만든다 (LSP에 결정적).
  • 다언어 — 200+ 언어 문법이 이미 존재.
  • C로 짠 런타임 — TS, Rust, Python, Go에서 다 쓸 수 있다.

8.1 문법 정의

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가 C 파서를 출력한다. 그걸 LSP에서 로드한다.

8.2 TypeScript에서 Tree-sitter 사용

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
}

oldTree.edit()로 어디가 바뀌었는지 알려주면, 다음 parse는 변경된 서브트리만 다시 짓는다. 1000줄 파일에서 한 글자를 쳐도 거의 0ms.

8.3 쿼리로 노드 찾기

Tree-sitter 쿼리는 S-expression 같은 패턴 언어다.

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

@name 캡처가 매칭된 노드를 잡는다. LSP에서 정의·참조·심볼 추출이 다 이 쿼리로 끝난다.

8.4 Tree-sitter와 LSP의 분업

Tree-sitter는 구문을, LSP는 의미와 사용자 인터랙션을 맡는다. 보통 다음 그림이 된다.

[사용자 키입력]
[VS Code 클라이언트]  ── didChange ──▶  [LSP 서버]
                                       [Tree-sitter incremental parse]
                                       [심볼 인덱스 갱신]
                                       publishDiagnostics, etc.

Helix와 Zed는 Tree-sitter를 에디터 자체에 내장한다 — 클라이언트가 직접 트리를 쥐고 있고 LSP는 의미 정보만 다룬다. VS Code도 비공식 트리시터 통합이 있지만, 보통은 LSP 서버 내부에서 쓴다.


9장 · Marketplace와 OpenVSX에 배포하기

코드가 동작하면 이제 배포다.

9.1 VSIX 패키징

VS Code 확장은 .vsix라는 zip 파일이다. vsce로 만든다.

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

테스트는 code --install-extension tomlplus-0.1.0.vsix로 로컬 설치.

9.2 마켓플레이스 게시

먼저 Azure DevOps에서 Publisher 계정을 만들고 Personal Access Token을 받는다.

vsce login yj
vsce publish
# 또는: vsce publish minor / patch / major

게시 정책 주의점:

  • LICENSE가 없으면 거부될 수 있다.
  • README.md의 이미지는 절대 URL이어야 한다.
  • 24시간 안에 검수 통과 — 자동화된 정적 검사 + 보안 스캔.
  • extensionDependencies로 다른 확장을 끌어다 쓰면 명시.

9.3 OpenVSX — 비-MS 빌드를 위한 마켓

VSCodium, Cursor, Continue, Theia, Gitpod, code-server는 MS 마켓플레이스에 접근할 수 없거나, 라이선스상 그래선 안 된다. 그들의 대안이 Open VSX Registry(Eclipse Foundation 운영, open-vsx.org)다.

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

같은 vsix를 두 곳에 올리는 게 표준이다. 자동화는 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·Continue 호환성

Cursor는 VS Code 포크다. Open VSX를 기본 마켓플레이스로 쓰고, 일부 MS 독점 확장(C/C++, Pylance, Live Share 등)은 라이선스 때문에 직접 설치할 수 없다. 우리가 만든 일반 확장은 그대로 동작한다 — 클라이언트는 같은 VS Code API를 노출한다.

Continue, Cody 같은 AI 확장도 같은 LSP 클라이언트 코드를 쓰는 경우가 많다. 우리 서버가 표준 LSP를 잘 따르면 자연스럽게 호환된다.

9.5 텔레메트리

VS Code의 vscode/extension-telemetry 패키지로 사용 통계를 보낼 수 있다. 단:

  • 사용자가 telemetry.telemetryLeveloff로 두면 자동으로 비활성.
  • PII(개인정보)는 절대 보내지 말 것. 익명화된 이벤트만.
  • 첫 실행에서 사용자에게 알려야 한다 (마켓플레이스 정책).
import TelemetryReporter from '@vscode/extension-telemetry';
const reporter = new TelemetryReporter('<connection-string>');
context.subscriptions.push(reporter);
reporter.sendTelemetryEvent('command.scanWorkspace');

10장 · 실전 프로덕션 LSP의 패턴

마지막 장은 실제 거대 프로젝트들이 어떻게 일하는지의 스케치다.

10.1 rust-analyzer — 그래픽적 LSP 설계의 교과서

  • Salsa(쿼리 시스템, demand-driven 계산)로 모든 분석을 incremental로.
  • VFS 추상화로 파일 시스템과 LSP 노티피케이션을 단일 모델로 통합.
  • 거대 워크스페이스(서브크레이트 100+)에서도 핫 패스가 빠르다.

10.2 gopls — Go 팀이 짠 정석

  • 자체 캐시 레이어를 두고 파일 변경 시 의존 그래프만 재계산.
  • "view"라는 추상으로 같은 코드의 여러 빌드 컨텍스트(GOOS/GOARCH 등)를 동시에 관리.

10.3 typescript-language-server — 어댑터 패턴

  • 실제 분석은 tsserver(TypeScript 컴파일러)가 한다.
  • 둘 사이를 LSP↔tsserver 프로토콜로 번역하는 얇은 레이어.
  • 이걸 통해 LSP를 모르는 TS 컴파일러를 모든 에디터에 노출.

10.4 ruff-lsp — Rust로 짠 초고속

  • Ruff 자체는 Python 린터지만, Rust로 짜서 ESLint의 100배 빠르다.
  • LSP는 그 위에 얇게 얹은 어댑터.
  • pyright/pylance 같은 타입 체크와 공존 — 둘 다 활성화돼도 충돌 없음.

10.5 Astro Language Server — 다중 언어 파일

  • .astro 파일 안에는 frontmatter(TS), 마크업(HTML), 인라인 스크립트(JS)가 섞인다.
  • "임베디드 언어" 패턴 — Astro LS가 영역마다 가상 문서를 만들어 TS LS, HTML LS에 위임.
  • 같은 패턴이 Vue Volar, Svelte LS에서도 쓰인다.

10.6 공통 패턴 정리

  • 워크스페이스 인덱스를 항상 메모리에 — 디스크 재읽기 회피.
  • 분석은 demand-driven, incremental — 사용자가 hover를 한 번 누른 뒤에만 계산하고 캐시.
  • 에러 복구가 사용자 경험을 좌우 — 깨진 파일에서 자동완성이 죽지 않아야 한다.
  • 취소 가능성(cancellation) — 사용자가 빠르게 타이핑하면 이전 요청을 즉시 취소.

에필로그 — 두 시간으로 시작하기, 두 달로 다듬기

LSP는 처음 30분이 가장 어렵다. JSON-RPC 메시지의 방향이 헷갈리고, capabilities 협상이 막연하고, didChange 동기화 모드가 셋 다 비슷해 보인다. 그 30분만 넘으면 — 기능 하나하나는 사실 같은 패턴의 변주다. 텍스트 위치를 받고, 우리 모델에서 의미를 뽑고, 응답을 돌려준다. 그게 전부다.

시작 체크리스트

  • vscode-languageserver-node의 샘플 LSP(server-and-client-sample)를 먼저 돌려보고 메시지 흐름을 눈으로 본다.
  • --inspect=6009로 서버에 디버거 붙이는 법부터 익힌다.
  • package.jsonactivationEvents를 최대한 좁혀라. 모든 사용자의 시작 시간을 책임진다.
  • TextDocumentSyncKind.Incremental 기본값. Full은 작은 파일 전용.
  • 진단은 source 필드를 채워라 — 사용자가 어디서 온 경고인지 알 수 있게.
  • onCompletionResolve로 무거운 정보를 지연 로드.
  • 큰 파일에 대비해 Tree-sitter 또는 직접 짠 incremental 파서.
  • 첫 배포는 OpenVSX와 마켓플레이스 둘 다.

안티 패턴

  • activationEvents: ["*"] — 모든 시작을 느리게 한다. 사용자 분노의 가장 큰 원인.
  • 매 키 입력에 풀 파싱 — 큰 파일에서 끊긴다. Tree-sitter나 debounce 필수.
  • 진단을 키 입력마다 즉시 — 300ms 정도 debounce. 사용자가 글자 하나마다 빨간 줄 깜빡이는 걸 본다.
  • 수동 텍스트 동기화TextDocuments 매니저를 안 쓰고 직접 didChange를 처리하면 incremental 동기화의 오프셋 계산을 직접 해야 한다. 거의 항상 버그가 생긴다.
  • 포그라운드 분석 — 무거운 작업을 동기로 하면 그동안 모든 요청이 막힌다. async 또는 워커 스레드로.
  • 메모리 누수context.subscriptions에 모든 디스포저블을 넣지 않으면 비활성화 후에도 핸들러가 남는다.
  • rename에서 prepareRename 생략 — 사용자가 잘못된 위치(주석, 문자열)에서 F2를 눌렀을 때 막아야 한다.
  • MS 마켓플레이스에만 게시 — Cursor·VSCodium 사용자가 못 받는다. OpenVSX도 같이.

다음 글 예고

  • incremental 컴파일러를 LSP로 노출하기 — Salsa, Adapton, demand-driven 분석.
  • DAP 깊은 다이브 — LSP의 디버그 자매. 같은 JSON-RPC, 다른 메서드.
  • Copilot이 LSP인 척하는 법completionProviderinlineCompletionItemProvider의 미묘한 경계.
  • 에디터를 직접 짤 때의 LSP 클라이언트 — Helix와 Zed가 어떻게 합쳤는가.

참고 / References

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

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