强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

LSP 开发指南 / 第 10 章:实现示例

10.1 TypeScript 实现

10.1.1 项目初始化

mkdir my-ts-lsp && cd my-ts-lsp
npm init -y
npm install vscode-languageserver vscode-languageserver-textdocument
npm install -D typescript @types/node
npx tsc --init

10.1.2 Server 实现

// server.ts
import {
  createConnection,
  TextDocuments,
  ProposedFeatures,
  InitializeParams,
  InitializeResult,
  TextDocumentSyncKind,
  CompletionItem,
  CompletionItemKind,
  Diagnostic,
  DiagnosticSeverity,
  TextDocumentPositionParams,
  Position,
  TextEdit,
} 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: {
        resolveProvider: true,
        triggerCharacters: [".", "(", "'", '"'],
      },
      hoverProvider: true,
      definitionProvider: true,
      referencesProvider: true,
      documentFormattingProvider: true,
      documentSymbolProvider: true,
    },
    serverInfo: {
      name: "my-ts-lsp",
      version: "1.0.0",
    },
  };
});

// ===== 文档同步 =====
documents.onDidChangeContent((change) => {
  validateDocument(change.document);
});

// ===== 诊断 =====
async function validateDocument(doc: TextDocument): Promise<void> {
  const diagnostics: Diagnostic[] = [];
  const text = doc.getText();
  const lines = text.split("\n");

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];

    // 检测 TODO
    const todoIdx = line.indexOf("TODO");
    if (todoIdx !== -1) {
      diagnostics.push({
        range: {
          start: Position.create(i, todoIdx),
          end: Position.create(i, todoIdx + 4),
        },
        severity: DiagnosticSeverity.Information,
        source: "my-ts-lsp",
        message: "TODO comment found",
        code: "todo-comment",
      });
    }

    // 检测过长行
    if (line.length > 100) {
      diagnostics.push({
        range: {
          start: Position.create(i, 100),
          end: Position.create(i, line.length),
        },
        severity: DiagnosticSeverity.Warning,
        source: "my-ts-lsp",
        message: `Line too long (${line.length} > 100)`,
        code: "line-too-long",
      });
    }
  }

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

// ===== 代码补全 =====
const keywords = [
  "if", "else", "for", "while", "return", "function",
  "class", "const", "let", "var", "import", "export",
  "async", "await", "try", "catch", "throw", "new",
];

const builtins: CompletionItem[] = keywords.map((kw) => ({
  label: kw,
  kind: CompletionItemKind.Keyword,
  detail: "keyword",
}));

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

  const text = doc.getText();
  const offset = doc.offsetAt(params.position);
  const prefix = text.substring(Math.max(0, offset - 50), offset);
  const wordMatch = prefix.match(/(\w+)$/);
  const word = wordMatch ? wordMatch[1] : "";

  if (!word) return builtins;

  return builtins.filter((item) =>
    item.label.toLowerCase().startsWith(word.toLowerCase())
  );
});

connection.onCompletionResolve((item) => {
  return item;
});

// ===== 悬停 =====
connection.onHover((params) => {
  const doc = documents.get(params.textDocument.uri);
  if (!doc) return null;

  const text = doc.getText();
  const offset = doc.offsetAt(params.position);
  const prefix = text.substring(Math.max(0, offset - 50), offset);
  const wordMatch = prefix.match(/(\w+)$/);
  const word = wordMatch ? wordMatch[1] : "";

  if (keywords.includes(word)) {
    return {
      contents: {
        kind: "markdown",
        value: `\`${word}\` — JavaScript/TypeScript keyword`,
      },
    };
  }

  return null;
});

// ===== 格式化 =====
connection.onDocumentFormatting((params) => {
  const doc = documents.get(params.textDocument.uri);
  if (!doc) return [];

  const edits: TextEdit[] = [];
  const text = doc.getText();
  const lines = text.split("\n");

  for (let i = 0; i < lines.length; i++) {
    // 去除行尾空格
    const trimmed = lines[i].trimEnd();
    if (trimmed !== lines[i]) {
      edits.push({
        range: {
          start: Position.create(i, trimmed.length),
          end: Position.create(i, lines[i].length),
        },
        newText: "",
      });
    }
  }

  return edits;
});

// 启动
documents.listen(connection);
connection.listen();

10.1.3 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}

10.1.4 运行与测试

npx tsc
node dist/server.js
# 或者在 VS Code launch.json 中配置启动

10.2 Python 实现

10.2.1 使用 pygls 库

pip install pygls

10.2.2 Server 实现

# server.py
import re
from typing import Optional
from lsprotocol.types import (
    TEXT_DOCUMENT_COMPLETION,
    TEXT_DOCUMENT_DID_OPEN,
    TEXT_DOCUMENT_DID_CHANGE,
    TEXT_DOCUMENT_DID_SAVE,
    TEXT_DOCUMENT_HOVER,
    TEXT_DOCUMENT_FORMATTING,
    CompletionItem,
    CompletionItemKind,
    CompletionList,
    CompletionParams,
    Diagnostic,
    DiagnosticSeverity,
    DidOpenTextDocumentParams,
    DidChangeTextDocumentParams,
    DidSaveTextDocumentParams,
    DocumentFormattingParams,
    Hover,
    MarkupContent,
    MarkupKind,
    Position,
    Range,
    TextEdit,
    InitializeParams,
    InitializeResult,
    ServerCapabilities,
    TextDocumentSyncKind,
)
from pygls.server import LanguageServer

server = LanguageServer("my-py-lsp", "1.0.0")

# 文档存储
documents: dict[str, str] = {}


@server.feature("initialize")
def on_initialize(params: InitializeParams) -> InitializeResult:
    return InitializeResult(
        capabilities=ServerCapabilities(
            text_document_sync=TextDocumentSyncKind.Incremental,
            completion_provider={"trigger_characters": [".", "'", '"', "("]},
            hover_provider=True,
            document_formatting_provider=True,
        ),
        server_info={"name": "my-py-lsp", "version": "1.0.0"},
    )


@server.feature(TEXT_DOCUMENT_DID_OPEN)
def did_open(params: DidOpenTextDocumentParams):
    uri = params.text_document.uri
    text = params.text_document.text
    documents[uri] = text
    _validate(uri, text)


@server.feature(TEXT_DOCUMENT_DID_CHANGE)
def did_change(params: DidChangeTextDocumentParams):
    uri = params.text_document.uri
    for change in params.content_changes:
        if hasattr(change, "range"):
            old_text = documents.get(uri, "")
            documents[uri] = _apply_incremental(old_text, change)
        else:
            documents[uri] = change.text
    _validate(uri, documents.get(uri, ""))


@server.feature(TEXT_DOCUMENT_DID_SAVE)
def did_save(params: DidSaveTextDocumentParams):
    uri = params.text_document.uri
    text = documents.get(uri, "")
    _validate(uri, text)


def _validate(uri: str, text: str):
    """发送诊断信息"""
    diagnostics = []
    lines = text.split("\n")

    for i, line in enumerate(lines):
        # TODO 检测
        if "TODO" in line:
            idx = line.index("TODO")
            diagnostics.append(
                Diagnostic(
                    range=Range(
                        start=Position(line=i, character=idx),
                        end=Position(line=i, character=idx + 4),
                    ),
                    message="TODO comment found",
                    severity=DiagnosticSeverity.Information,
                    source="my-py-lsp",
                )
            )

        # 未定义变量检测(简单示例)
        match = re.search(r"\bprint\s*\(\s*(\w+)", line)
        if match:
            var_name = match.group(1)
            # 检查变量是否在前文定义
            defined = any(
                re.search(rf"\b{var_name}\s*=", lines[j])
                for j in range(i)
            )
            if not defined and var_name not in ("True", "False", "None"):
                diagnostics.append(
                    Diagnostic(
                        range=Range(
                            start=Position(line=i, character=match.start(1)),
                            end=Position(line=i, character=match.end(1)),
                        ),
                        message=f"Possibly undefined variable '{var_name}'",
                        severity=DiagnosticSeverity.Warning,
                        source="my-py-lsp",
                    )
                )

    server.publish_diagnostics(uri, diagnostics)


KEYWORDS = [
    "def", "class", "if", "elif", "else", "for", "while", "return",
    "import", "from", "as", "try", "except", "finally", "with",
    "async", "await", "yield", "raise", "pass", "break", "continue",
    "and", "or", "not", "in", "is", "lambda", "global", "nonlocal",
]

BUILTINS = [
    "print", "len", "range", "int", "str", "float", "list",
    "dict", "set", "tuple", "bool", "type", "object", "super",
    "enumerate", "zip", "map", "filter", "sorted", "reversed",
    "isinstance", "issubclass", "hasattr", "getattr", "setattr",
]


@server.feature(TEXT_DOCUMENT_COMPLETION)
def completion(params: CompletionParams) -> CompletionList:
    uri = params.text_document.uri
    text = documents.get(uri, "")
    lines = text.split("\n")
    line = lines[params.position.line] if params.position.line < len(lines) else ""
    prefix = line[: params.position.character]
    word_match = re.search(r"(\w+)$", prefix)
    word = word_match.group(1) if word_match else ""

    items = []
    for kw in KEYWORDS + BUILTINS:
        if not word or kw.startswith(word):
            items.append(
                CompletionItem(
                    label=kw,
                    kind=CompletionItemKind.Keyword if kw in KEYWORDS else CompletionItemKind.Function,
                    detail="keyword" if kw in KEYWORDS else "builtin",
                )
            )

    return CompletionList(is_incomplete=False, items=items)


@server.feature(TEXT_DOCUMENT_HOVER)
def hover(params):
    uri = params.text_document.uri
    text = documents.get(uri, "")
    lines = text.split("\n")
    line = lines[params.position.line] if params.position.line < len(lines) else ""
    prefix = line[: params.position.character]
    word_match = re.search(r"(\w+)$", prefix)
    word = word_match.group(1) if word_match else ""

    if word in BUILTINS:
        return Hover(
            contents=MarkupContent(
                kind=MarkupKind.Markdown,
                value=f"```python\n{word}()\n```\n\nPython built-in function",
            )
        )
    return None


@server.feature(TEXT_DOCUMENT_FORMATTING)
def formatting(params: DocumentFormattingParams):
    uri = params.text_document.uri
    text = documents.get(uri, "")
    lines = text.split("\n")
    edits = []

    for i, line in enumerate(lines):
        trimmed = line.rstrip()
        if trimmed != line:
            edits.append(
                TextEdit(
                    range=Range(
                        start=Position(line=i, character=len(trimmed)),
                        end=Position(line=i, character=len(line)),
                    ),
                    new_text="",
                )
            )

    return edits


def _apply_incremental(old_text: str, change) -> str:
    """应用增量编辑"""
    if not hasattr(change, "range"):
        return change.text

    lines = old_text.split("\n")
    start = change.range.start
    end = change.range.end

    before = "\n".join(lines[: start.line]) + (
        ("\n" if start.line > 0 else "") + lines[start.line][: start.character]
        if start.line < len(lines)
        else ""
    )
    after = (
        (lines[end.line][end.character :] if end.line < len(lines) else "")
        + "\n"
        + "\n".join(lines[end.line + 1 :])
    )
    return before + change.text + after


if __name__ == "__main__":
    server.start_io()

10.2.3 运行

python server.py
# 或作为 LSP Client 的配置使用

10.3 Go 实现

10.3.1 项目初始化

mkdir my-go-lsp && cd my-go-lsp
go mod init my-go-lsp
go get github.com/tliron/glsp

10.3.2 Server 实现

// main.go
package main

import (
	"fmt"
	"os"
	"strings"

	"github.com/tliron/commonlog"
	_ "github.com/tliron/commonlog/simple"
	"github.com/tliron/glsp"
	protocol "github.com/tliron/glsp/protocol_3_16"
	"github.com/tliron/glsp/server"
)

const (
	lsName = "my-go-lsp"
	lsVer  = "0.1.0"
)

var (
	handler protocol.Handler
)

func main() {
	commonlog.Configure(1, nil)

	handler = protocol.Handler{
		Initialize:  onInitialize,
		Initialized: onInitialized,
		Shutdown:    onShutdown,
		SetTrace:    onSetTrace,

		TextDocumentDidOpen:        onDidOpen,
		TextDocumentDidChange:      onDidChange,
		TextDocumentDidSave:        onDidSave,
		TextDocumentDidClose:       onDidClose,
		TextDocumentCompletion:     onCompletion,
		TextDocumentHover:          onHover,
		TextDocumentFormatting:     onFormatting,
		TextDocumentDocumentSymbol: onDocumentSymbol,
	}

	server := server.NewServer(&handler, lsName, false)
	server.RunStdio()
}

// ===== 初始化 =====

func onInitialize(ctx *glsp.Context, params *protocol.InitializeParams) (any, error) {
	capabilities := handler.CreateServerCapabilities()

	// 声明能力
	textSyncKind := protocol.TextDocumentSyncKindIncremental
	capabilities.TextDocumentSync = &textSyncKind
	capabilities.CompletionProvider = &protocol.CompletionOptions{
		TriggerCharacters: []string{".", "(", "'", "\""},
		ResolveProvider:   true,
	}
	hoverProvider := true
	capabilities.HoverProvider = &hoverProvider
	formatProvider := true
	capabilities.DocumentFormattingProvider = &formatProvider
	symbolProvider := true
	capabilities.DocumentSymbolProvider = &symbolProvider

	return protocol.InitializeResult{
		Capabilities: capabilities,
		ServerInfo: &protocol.InitializeResultServerInfo{
			Name:    lsName,
			Version: &lsVer,
		},
	}, nil
}

func onInitialized(ctx *glsp.Context, params *protocol.InitializedParams) error {
	return nil
}

func onShutdown(ctx *glsp.Context) error {
	protocol.SetTraceValue(protocol.TraceValueOff)
	return nil
}

func onSetTrace(ctx *glsp.Context, params *protocol.SetTraceParams) error {
	protocol.SetTraceValue(params.Value)
	return nil
}

// ===== 文档管理 =====

var documents = map[string]string{}

func onDidOpen(ctx *glsp.Context, params *protocol.DidOpenTextDocumentParams) error {
	uri := params.TextDocument.URI
	documents[uri] = params.TextDocument.Text
	validateDocument(ctx, uri, params.TextDocument.Text)
	return nil
}

func onDidChange(ctx *glsp.Context, params *protocol.DidChangeTextDocumentParams) error {
	uri := params.TextDocument.URI
	for _, change := range params.ContentChanges {
		if c, ok := change.(protocol.TextDocumentContentChangeEvent); ok {
			documents[uri] = c.Text
		}
	}
	validateDocument(ctx, uri, documents[uri])
	return nil
}

func onDidSave(ctx *glsp.Context, params *protocol.DidSaveTextDocumentParams) error {
	uri := params.TextDocument.URI
	text, ok := documents[uri]
	if ok {
		validateDocument(ctx, uri, text)
	}
	return nil
}

func onDidClose(ctx *glsp.Context, params *protocol.DidCloseTextDocumentParams) error {
	delete(documents, params.TextDocument.URI)
	return nil
}

// ===== 诊断 =====

func validateDocument(ctx *glsp.Context, uri string, text string) {
	lines := strings.Split(text, "\n")
	diagnostics := []protocol.Diagnostic{}

	for i, line := range lines {
		// TODO 检测
		idx := strings.Index(line, "TODO")
		if idx >= 0 {
			d := protocol.Diagnostic{
				Range: protocol.Range{
					Start: protocol.Position{Line: uint32(i), Character: uint32(idx)},
					End:   protocol.Position{Line: uint32(i), Character: uint32(idx + 4)},
				},
				Severity: &[]protocol.DiagnosticSeverity{protocol.DiagnosticSeverityInformation}[0],
				Source:   &lsName,
				Message:  "TODO comment found",
			}
			diagnostics = append(diagnostics, d)
		}

		// 行长度检测
		if len(line) > 120 {
			d := protocol.Diagnostic{
				Range: protocol.Range{
					Start: protocol.Position{Line: uint32(i), Character: 120},
					End:   protocol.Position{Line: uint32(i), Character: uint32(len(line))},
				},
				Severity: &[]protocol.DiagnosticSeverity{protocol.DiagnosticSeverityWarning}[0],
				Source:   &lsName,
				Message:  fmt.Sprintf("Line too long (%d > 120)", len(line)),
			}
			diagnostics = append(diagnostics, d)
		}
	}

	ctx.Notify("textDocument/publishDiagnostics", protocol.PublishDiagnosticsParams{
		URI:         uri,
		Diagnostics: diagnostics,
	})
}

// ===== 代码补全 =====

var keywords = []string{
	"func", "var", "const", "type", "struct", "interface",
	"if", "else", "for", "range", "switch", "case",
	"return", "break", "continue", "go", "defer",
	"map", "chan", "select", "package", "import",
}

func onCompletion(ctx *glsp.Context, params *protocol.CompletionParams) (any, error) {
	uri := params.TextDocument.URI
	text, ok := documents[uri]
	if !ok {
		return []protocol.CompletionItem{}, nil
	}

	lines := strings.Split(text, "\n")
	line := ""
	if int(params.Position.Line) < len(lines) {
		line = lines[params.Position.Line][:params.Position.Character]
	}

	parts := strings.Fields(line)
	word := ""
	if len(parts) > 0 {
		word = parts[len(parts)-1]
	}

	items := []protocol.CompletionItem{}
	for _, kw := range keywords {
		if word == "" || strings.HasPrefix(kw, word) {
			kind := protocol.CompletionItemKindKeyword
			item := protocol.CompletionItem{
				Label: kw,
				Kind:  &kind,
			}
			items = append(items, item)
		}
	}

	return items, nil
}

// ===== 悬停 =====

func onHover(ctx *glsp.Context, params *protocol.TextDocumentPositionParams) (any, error) {
	uri := params.TextDocument.URI
	text, ok := documents[uri]
	if !ok {
		return nil, nil
	}

	lines := strings.Split(text, "\n")
	line := ""
	if int(params.Position.Line) < len(lines) {
		line = lines[params.Position.Line]
	}

	prefix := line[:min(int(params.Position.Character), len(line))]
	parts := strings.Fields(prefix)
	word := ""
	if len(parts) > 0 {
		word = parts[len(parts)-1]
	}

	goDocMap := map[string]string{
		"func":     "Declares a function",
		"var":      "Declares a variable",
		"const":    "Declares a constant",
		"type":     "Declares a type",
		"struct":   "Declares a struct type",
		"interface": "Declares an interface type",
	}

	if doc, exists := goDocMap[word]; exists {
		kind := protocol.MarkupKindMarkdown
		return protocol.Hover{
			Contents: protocol.MarkupContent{
				Kind:  kind,
				Value: fmt.Sprintf("```go\n%s\n```\n\n%s", word, doc),
			},
		}, nil
	}

	return nil, nil
}

// ===== 格式化 =====

func onFormatting(ctx *glsp.Context, params *protocol.DocumentFormattingParams) (any, error) {
	uri := params.TextDocument.URI
	text, ok := documents[uri]
	if !ok {
		return []protocol.TextEdit{}, nil
	}

	lines := strings.Split(text, "\n")
	edits := []protocol.TextEdit{}

	for i, line := range lines {
		trimmed := strings.TrimRight(line, " \t")
		if trimmed != line {
			edit := protocol.TextEdit{
				Range: protocol.Range{
					Start: protocol.Position{Line: uint32(i), Character: uint32(len(trimmed))},
					End:   protocol.Position{Line: uint32(i), Character: uint32(len(line))},
				},
				NewText: "",
			}
			edits = append(edits, edit)
		}
	}

	return edits, nil
}

// ===== 文档符号 =====

func onDocumentSymbol(ctx *glsp.Context, params *protocol.DocumentSymbolParams) (any, error) {
	uri := params.TextDocument.URI
	text, ok := documents[uri]
	if !ok {
		return []protocol.DocumentSymbol{}, nil
	}

	lines := strings.Split(text, "\n")
	symbols := []protocol.DocumentSymbol{}

	for i, line := range lines {
		trimmed := strings.TrimSpace(line)

		if strings.HasPrefix(trimmed, "func ") {
			name := strings.TrimPrefix(trimmed, "func ")
			if idx := strings.Index(name, "("); idx > 0 {
				name = name[:idx]
			}
			kind := protocol.SymbolKindFunction
			symbols = append(symbols, protocol.DocumentSymbol{
				Name: name,
				Kind: kind,
				Range: protocol.Range{
					Start: protocol.Position{Line: uint32(i), Character: 0},
					End:   protocol.Position{Line: uint32(i), Character: uint32(len(line))},
				},
				SelectionRange: protocol.Range{
					Start: protocol.Position{Line: uint32(i), Character: 5},
					End:   protocol.Position{Line: uint32(i), Character: uint32(5 + len(name))},
				},
			})
		}
	}

	return symbols, nil
}

func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}

10.3.3 运行与测试

go build -o my-go-lsp .
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":1,"rootUri":"file:///tmp","capabilities":{}}}' | \
  Content-Length=$(echo -n '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":1,"rootUri":"file:///tmp","capabilities":{}}}' | wc -c) | \
  ./my-go-lsp

10.4 三种实现对比

特性TypeScriptPythonGo
框架vscode-languageserverpyglsglsp
类型系统原生强类型Type hints原生强类型
生态成熟度⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
启动速度中等极快
内存占用中等较高极低
适用场景通用首选快速原型/数据科学高性能工具
调试便利性VS Code 原生pdbdelve

⚠️ 注意事项

问题建议
TypeScript 版本冲突锁定 vscode-languageserver 版本
Python 异步问题pygls 默认异步,避免阻塞调用
Go 接口断言内容变更事件需要类型断言
跨平台兼容测试 Windows/Linux/macOS 三种平台

🔗 扩展阅读


下一章第 11 章:编辑器集成 — VS Code、Neovim、Emacs 客户端配置。