第 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 库
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 三种实现对比
| 特性 | TypeScript | Python | Go |
|---|
| 框架 | vscode-languageserver | pygls | glsp |
| 类型系统 | 原生强类型 | Type hints | 原生强类型 |
| 生态成熟度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 启动速度 | 快 | 中等 | 极快 |
| 内存占用 | 中等 | 较高 | 极低 |
| 适用场景 | 通用首选 | 快速原型/数据科学 | 高性能工具 |
| 调试便利性 | VS Code 原生 | pdb | delve |
⚠️ 注意事项
| 问题 | 建议 |
|---|
| TypeScript 版本冲突 | 锁定 vscode-languageserver 版本 |
| Python 异步问题 | pygls 默认异步,避免阻塞调用 |
| Go 接口断言 | 内容变更事件需要类型断言 |
| 跨平台兼容 | 测试 Windows/Linux/macOS 三种平台 |
🔗 扩展阅读
下一章:第 11 章:编辑器集成 — VS Code、Neovim、Emacs 客户端配置。