# gpt5-learning-lsp **Repository Path**: luzhihaoTestingLab/gpt5-learning-lsp ## Basic Information - **Project Name**: gpt5-learning-lsp - **Description**: 使用gpt5生成的学习LSP(Language Server Protocol,语言服务器协议)课程 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2025-10-21 - **Last Updated**: 2025-10-21 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # LSP 学习课程与示例代码 本仓库包含循序渐进学习 LSP(Language Server Protocol)的课程内容,以及第一课的可运行示例代码(最小语言服务器 + 最小客户端)。 ## TypeScript 版本说明 - 已将项目迁移到 TypeScript:源码位于 `server/*.ts` 与 `client/*.ts`,编译输出到 `dist/`。 - 运行方式: - 构建:`npm run build` - 启动示例(客户端会自动拉起服务端):`npm run start:client` - 单独启动服务端(用于调试传输层等):`npm run start:server` - tsconfig 关键设置:`module: commonjs`、`target: ES2019`、`outDir: dist`、`strict: true`。 ## 课程路线 - 认识 LSP:解决什么问题、客户端/服务端角色、消息流 - 最小语言服务器:文档同步与诊断(错误/警告提示) - 自动补全与悬停:`completion` 与 `hover` 能力 - 跳转与符号:定义跳转、引用查找、工作区符号索引 - 快速修复与代码操作:`codeAction` 提供一键修复 - 格式化与重写:`documentFormatting` 集成格式化器 - 语义令牌与高亮:更丰富的语法高亮(如变量、方法) - 浏览器集成:Monaco Editor + WebSocket LSP - 调试与性能:日志、trace、测试与基准 --- ## 第一课:认识 LSP 与跑通握手(带可运行示例) ### 目标 - 搞清楚 LSP 的角色分工(Client/Server) - 了解关键消息流程(`initialize` → `initialized`) - 跑通一个最小的 LSP 服务并看到诊断消息 ### 前置准备 - Node.js 18+(Windows 下用 `node -v` 检查) - 在目录 `c:\Users\luzhihao\Desktop\workplace\gpt5-learning-lsp` 下已初始化项目(本仓库已帮你完成) ### 概念速览 - 客户端(Client):编辑器或前端(VSCode、Monaco)发请求、展示结果。 - 语言服务器(Server):实现 LSP 接口,返回诊断、补全、悬停等。 - 传输与协议:基于 JSON-RPC,常用 `stdio`、TCP、WebSocket。 - 关键消息流:`initialize` 请求 → 服务器返回能力集 → 客户端发 `initialized` 通知 → 文档打开/修改 → 服务器发布 `publishDiagnostics`。 ### 目录结构 ``` . ├─ client/ │ └─ client.js ├─ server/ │ └─ server.js ├─ package.json └─ README.md ``` ### 代码:语言服务器(`server/server.js`) 功能:识别文档中所有 “TODO” 片段,发布为 Warning 诊断。 ```js const { createConnection, ProposedFeatures, TextDocuments, DiagnosticSeverity, TextDocumentSyncKind, } = require('vscode-languageserver/node'); const { TextDocument } = require('vscode-languageserver-textdocument'); const connection = createConnection(ProposedFeatures.all); const documents = new TextDocuments(TextDocument); connection.onInitialize(() => { return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, }, }; }); documents.onDidOpen((e) => { validateTextDocument(e.document); }); documents.onDidChangeContent(change => { validateTextDocument(change.document); }); documents.onDidClose((e) => { connection.sendDiagnostics({ uri: e.document.uri, diagnostics: [] }); }); async function validateTextDocument(textDocument) { const text = textDocument.getText(); const diagnostics = []; const regex = /TODO/g; let match; while ((match = regex.exec(text))) { const start = textDocument.positionAt(match.index); const end = textDocument.positionAt(match.index + match[0].length); diagnostics.push({ range: { start, end }, severity: DiagnosticSeverity.Warning, message: 'Found TODO', source: 'learn-lsp', }); } connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); } documents.listen(connection); connection.listen(); ``` ### 代码:最小客户端(`client/client.js`) 功能:启动服务端进程,通过 JSON-RPC 发送 `initialize`/`didOpen`/`didChange`,监听并打印服务端的 `publishDiagnostics`。 ```js const cp = require('child_process'); const path = require('path'); const { createMessageConnection, StreamMessageReader, StreamMessageWriter, } = require('vscode-jsonrpc'); const projectRoot = path.join(__dirname, '..'); const serverPath = path.join('server', 'server.js'); const serverProcess = cp.spawn(process.execPath, [serverPath], { cwd: projectRoot }); serverProcess.on('exit', (code) => { console.log('Server exited with code:', code); }); serverProcess.stderr.on('data', (data) => { console.error('[server stderr]', data.toString()); }); const connection = createMessageConnection( new StreamMessageReader(serverProcess.stdout), new StreamMessageWriter(serverProcess.stdin) ); connection.onNotification('textDocument/publishDiagnostics', (params) => { console.log('Diagnostics for', params.uri); console.log(JSON.stringify(params.diagnostics, null, 2)); }); connection.listen(); const uri = 'file:///demo.txt'; async function delay(ms) { return new Promise((res) => setTimeout(res, ms)); } (async function main() { const initializeParams = { processId: process.pid, rootUri: null, capabilities: {}, }; const result = await connection.sendRequest('initialize', initializeParams); console.log('Server capabilities:', JSON.stringify(result.capabilities, null, 2)); connection.sendNotification('initialized', {}); connection.sendNotification('textDocument/didOpen', { textDocument: { uri, languageId: 'plaintext', version: 1, text: 'Here is a TODO and another TODO.', }, }); await delay(150); connection.sendNotification('textDocument/didChange', { textDocument: { uri, version: 2 }, contentChanges: [ { text: 'No todos here.' } ], }); await delay(150); await connection.sendRequest('shutdown'); connection.sendNotification('exit'); serverProcess.kill(); })(); ``` ### 运行步骤 - 在项目根目录执行:`npm run start:client` - 预期效果: - 首次 `didOpen` 后打印 2 条 `TODO` 诊断 - `didChange` 后打印空诊断(警告被清除) - 打印服务器能力集(`textDocumentSync`) ### 练习题 - 修改规则:同时识别 `FIXME`,并将其标为 Error(`DiagnosticSeverity.Error`) - 行长检查:如果某一行字符数 > 80,给该整行一个 Warning 诊断,`message` 标注实际长度 - 清除逻辑:确保当移除 `TODO` 或缩短行长后再次发布空诊断(诊断列表随文本变化实时更新) ### 思考与拓展 - 为什么服务器用 `TextDocument.positionAt` 转换偏移到 `Range`? - 为什么诊断是“通知”(没有响应)而不是“请求”? - 传输层除了 `stdio` 还可以用什么?浏览器里如何改成 WebSocket?(后续课程会做) --- ## 第二课:自动补全与悬停(Completion & Hover) 这一课我们在最小语言服务器上新增两个常用能力: - `textDocument/completion`:在输入关键字时给出候选项(如 TODO/FIXME/NOTE)。 - `textDocument/hover`:光标悬停时显示文档说明(Markdown 文本)。 ### 服务器端改动(`server/server.ts`) - 在 `onInitialize` 返回的 `capabilities` 中声明: - `completionProvider: { resolveProvider: true, triggerCharacters: ["T", "F", "N"] }` - `hoverProvider: true` - 新增处理器: - `connection.onCompletion`:返回候选词(按光标处单词前缀过滤),并提供 `onCompletionResolve` 追加说明。 - `connection.onHover`:对 `TODO`/`FIXME` 返回 Markdown 文本说明,附带精确 `range`。 - 辅助函数:`getWordRange(doc, position)` 获取光标所在单词与范围。 ### 客户端改动(`client/client.ts`) - 在 `didOpen` 之后,主动发起: - `textDocument/completion` 请求,打印返回的候选项。 - `textDocument/hover` 请求,打印返回的 Markdown 内容与范围。 ### 运行与预期输出 - 运行:`npm run start:client` - 预期在控制台看到: - 服务器能力集中包含 `completionProvider` 与 `hoverProvider`。 - 两次诊断(打开时、内容初次变更时),随后内容变更为空诊断。 - `Completion items` 里至少包含 `TODO`(当光标位于 `TODO` 上)。 - `Hover` 返回 Markdown 文本(例如 “Marks a task to be done.”)。 ### 练习题(第二课) - 扩展补全:在 `onCompletion` 中加入更多关键字,如 `NOTE`、`BUG`,并为其在 `onCompletionResolve` 中提供更详细说明。 - 改进触发:调整 `triggerCharacters`,比如只在输入大写首字母时触发,或改为空触发由客户端按需请求。 - 富文本 Hover:使用更复杂的 Markdown,包含链接或代码片段。 --- 完成第二课后,你可以尝试将这些能力集成到 Monaco Editor 或 VSCode 扩展里,体验更真实的编辑器交互。 ## 第三课:跳转与符号(Definition / References / Workspace Symbols) 这一课我们在最小语言服务器上加入三个导航能力: - `textDocument/definition`:跳转到符号的“定义”(此示例中用该词的首次出现位置作为定义)。 - `textDocument/references`:列出该符号在文档中的全部引用位置。 - `workspace/symbol`:工作区符号检索(本示例遍历已打开文档并返回匹配词的所有位置)。 ### 服务器端改动(`server/server.ts`) - 能力声明:在 `onInitialize` 返回的 `capabilities` 中增加 - `definitionProvider: true` - `referencesProvider: true` - `workspaceSymbolProvider: true` - 处理器实现: - `onDefinition`:取光标所在单词,返回该词在文档中的首次出现位置作为 `Location`。 - `onReferences`:返回该词全部出现位置列表 `Location[]`。 - `onWorkspaceSymbol`:若 `query` 非空,匹配该词;否则返回常见关键字(`TODO`/`FIXME`/`NOTE`)的全部位置。 - 工具函数: - `getWordRange(doc, position)`:获取光标所在单词与范围。 - `findWordOccurrences(doc, word)`:用正则查找所有匹配位置并返回 `Range[]`。 ### 客户端改动(`client/client.ts`) - 在 `didOpen` 后,依次请求: - `textDocument/definition`(光标位于 `TODO` 上) - `textDocument/references`(包含定义本身) - `workspace/symbol`(查询 `TODO` 与空查询)并打印结果。 ### 运行与预期输出 - 运行:`npm run start:client` - 预期在控制台看到: - `Definition` 指向 `TODO` 的首次出现位置。 - `References` 列出两处 `TODO`。 - `Workspace symbols (TODO)` 与 `Workspace symbols (all)` 列出对应位置。 ### 练习题(第三课) - 定义更合理:将“定义”改为文档首行第一个出现位置或规则化声明区。 - 引用过滤:`onReferences` 根据 `includeDeclaration` 控制是否包含定义位置。 - 工作区扩展:维护一个简单索引,支持多文档查询与模糊匹配(如前缀)。 ## 第四课:快速修复与代码操作(Code Action) 这一课我们为最小语言服务器加入“快速修复”能力,示例针对 `TODO` 提供两个修复: - 将 `TODO` 替换为 `DONE` - 移除 `TODO` ### 服务器端改动(`server/server.ts`) - 能力声明:在 `onInitialize` 的 `capabilities` 中加入 `codeActionProvider: true`。 - 处理器实现: - `connection.onCodeAction(params)`: - 若 `context.only` 包含 `quickfix` 或为空,则生成 QuickFix。 - 基于 `context.diagnostics` 中的诊断范围创建 `CodeAction`,并附带 `edit.changes[uri]` 的 `TextEdit`。 - 若没有诊断,尝试根据当前范围起点的单词(`getWordRange`)匹配 `TODO` 并生成修复。 ### 客户端改动(`client/client.ts`) - 记录诊断:在 `textDocument/publishDiagnostics` 里缓存到 `diagnosticsByUri`。 - 请求并应用修复: - 构造 `range` 覆盖第一个 `TODO`,并传入缓存的诊断: - `context: { diagnostics: diagnosticsByUri[uri] || [], only: ['quickfix'] }` - 打印返回的 `CodeAction[]`。 - 若第一个动作包含 `edit.changes[uri]`,在客户端模拟应用文本更改,并发送 `didChange` 将新文本同步给服务器。 ### 运行与预期输出 - 运行:`npm run start:client` - 预期在控制台看到: - `Code actions` 列出两条:`Mark TODO as DONE` 与 `Remove TODO`。 - 客户端应用第一条后打印新文本,例如:`Here is a DONE and another TODO.` - 随后 `Diagnostics` 只剩下一个 `TODO` 的警告(第二处),验证修复已生效。 ### 练习题(第四课) - 扩展修复:对 `FIXME` 提供修复(替换为 `TODO`/`DONE` 或附加说明)。 - 批量修复:在一个 `CodeAction` 中返回多处 `TextEdit`,一次性修复所有 `TODO`。 - 命令式修复:通过 `workspace/executeCommand` 触发服务端发送 `workspace/applyEdit`,并在客户端实现 handler 将编辑结果同步回 `didChange`。 - 解析流程:将 `codeActionProvider` 改为 `{ resolveProvider: true }`,在 `onCodeAction` 仅返回骨架,在 `onCodeActionResolve` 中补齐 `edit`。 ## 第五课:格式化与重写(Document Formatting) 这一课我们为最小语言服务器加入“文档格式化”能力,示例通过返回整文档的 `TextEdit` 来修正简单的空白问题:去除每行末尾空白并确保文件以换行结尾。 ### 服务器端改动(`server/server.ts`) - 能力声明:在 `onInitialize` 的 `capabilities` 中加入 `documentFormattingProvider: true`。 - 处理器实现: - `connection.onDocumentFormatting(params)`: - 读取当前文档文本,逐行去除行尾空白; - 如果末尾没有换行,则追加一个 `\n`; - 若格式化后文本与原文本不同,返回覆盖整文档范围的单个 `TextEdit`(`range: [0, len)`)。 ### 客户端改动(`client/client.ts`) - 在应用一次 Quick Fix 后,故意在文本末尾追加几个空格以制造格式问题; - 请求 `textDocument/formatting`: - 参数示例:`options: { tabSize: 2, insertSpaces: true }`; - 打印返回的 `TextEdit[]`,并在客户端模拟应用这些编辑; - 通过 `textDocument/didChange` 同步新文本到服务器。 ### 运行与预期输出 - 运行:`npm run start:client` - 预期在控制台看到: - `Formatting edits` 返回一个覆盖整个文档的编辑,`newText` 末尾包含 `\n`; - `Applied formatting; new text` 打印格式化后的文本; - 随后 `Diagnostics` 仍显示剩余的一个 `TODO`(来自第二处),验证格式化并未更改语义,仅处理空白。 ### 练习题(第五课) - 选择性格式化:实现 `textDocument/rangeFormatting`,仅格式化选定范围。 - 按键触发:实现 `textDocument/onTypeFormatting`,在输入 `\n` 或 `.` 时自动整理行尾空白。 - 选项生效:根据 `FormattingOptions` 的 `tabSize`/`insertSpaces` 决定缩进风格;将行首制表符转换为指定空格数。 - 更丰富规则:统一多个连续空格为一个空格、移除多余空行、确保文件末尾只有一个换行。 - 多文档批处理:为工作区所有已打开文档提供格式化(在客户端逐个请求并应用)。 ## 第六课:语义令牌与高亮(Semantic Tokens) 这一课我们为最小语言服务器加入“语义令牌”能力,提供更丰富的语法高亮信息。示例中我们用关键字与标注词来演示:将 `TODO`/`FIXME`/`DONE` 标注为 `keyword` 并附带不同修饰符,将 `NOTE` 标注为 `comment` 并附带 `documentation` 修饰符。 ### 服务器端改动(`server/server.ts`) - 能力声明:在 `onInitialize` 的 `capabilities` 中加入 `semanticTokensProvider`: - `legend.tokenTypes`: `[keyword, comment]` - `legend.tokenModifiers`: `[deprecated, modification, documentation, readonly]` - 支持 `full: true` 与 `range: true` - 构建函数:实现 `buildSemanticTokensForDoc(doc, range?)`: - 使用 `SemanticTokensBuilder` 构造令牌数据; - 为了满足 `builder.push(line, char, length, tokenTypeIndex, tokenModifiersBits)` 的参数类型要求,建立数值映射: - `TOKEN_TYPE_INDEX`: 将 `keyword`/`comment` 映射到数字索引(按 legend 顺序)。 - `MOD_INDEX`: 将修饰符名映射到位索引;`toModifierBits(mods)` 将字符串数组转换为位图。 - 通过正则在文档中查找单词边界匹配: - `TODO` → `keyword` + `modification` - `FIXME` → `keyword` + `deprecated` - `DONE` → `keyword` + `readonly` - `NOTE` → `comment` + `documentation` - 若传入 `range`,仅在该范围内推送令牌。 - 处理器注册: - `connection.languages.semanticTokens.on`:返回整文档的语义令牌; - `connection.languages.semanticTokens.onRange`:返回指定范围内的语义令牌。 ### 客户端改动(`client/client.ts`) - 语义令牌解码:实现 `decodeSemanticTokens(legend, data)`,将服务端返回的 `data` 解码为可读对象: - 逐条累加 `lineDelta` 与 `charDelta`,计算出绝对 `line`/`character`; - 将 `tokenTypeIndex` 映射到 `legend.tokenTypes` 的字符串; - 将 `tokenModifiersBits` 按位与 `legend.tokenModifiers` 对应解码为修饰符字符串数组; - 请求与打印:在格式化之后,依次请求: - `textDocument/semanticTokens/full`:打印 raw 与 decoded; - `textDocument/semanticTokens/range`:选取 `[0,0]` 到该行末尾的范围,打印 raw 与 decoded; - 便于观察,控制台展示两类请求的原始数组和解码结果。 ### 运行与预期输出 - 运行:`npm run start:client` - 预期在控制台看到(示例): - `Semantic tokens (raw full)` 与 `Semantic tokens (decoded full)`: - 如:`TODO` 在第 0 行第 27 列,类型 `keyword`,修饰符 `[modification]`; - 如:`DONE` 在第 0 行第 10 列,类型 `keyword`,修饰符 `[readonly]`。 - `Semantic tokens (raw range)` 与 `Semantic tokens (decoded range)`:与 `full` 一致但仅覆盖所选范围。 ### 练习题(第六课) - 扩展类型:在 `legend.tokenTypes` 中增加更多类型,如 `string`、`number`、`variable`,并在 `buildSemanticTokensForDoc` 中识别对应模式(引号中的文本、纯数字、驼峰变量等)。 - 多修饰符组合:为同一令牌同时添加多个修饰符,如 `[deprecated, modification]`,验证位图累加是否正确。 - 范围验证:构造不同的 `range` 请求,验证返回的令牌仅在范围内(注意边界条件)。 - 与诊断/格式化协同:在应用 Quick Fix 与格式化后再次请求语义令牌,观察令牌是否随文本变化而更新。 - 性能与增量:尝试实现 `fullDelta`(可选进阶),按 LSP 规范返回增量更新结果以减少传输数据量。 --- ## 第七课:浏览器集成(WebSocket LSP) 这一课我们把已有的最小语言服务器通过 WebSocket 桥接到浏览器,演示纯网页环境下的 LSP 交互:初始化、打开文档、格式化、语义令牌。 ### 改动概览 - 新增 `gateway/ws-gateway.ts`: - 提供静态文件 HTTP 服务(默认端口 `3000`,目录 `web/`)。 - 提供 WebSocket 服务(端口 `3001`),为每个浏览器连接启动一个 LSP 子进程,完成 JSON-RPC ↔ LSP Content-Length 帧的双向桥接。 - 新增前端 `web/index.html` 与 `web/ws-client.js`: - 简易页面含“连接/断开”“格式化”“语义令牌”按钮与文本输入区与日志区; - 浏览器端发送 `initialize/initialized/didOpen`,触发 `formatting` 与 `semanticTokens`,并解码打印令牌。 - 更新 `package.json`: - 依赖:`ws`;脚本:`start:ws`(`npm run build && node dist/gateway/ws-gateway.js`)。 - 更新 `tsconfig.json`: - `include` 中加入 `gateway/**/*`,保证编译输出 `dist/gateway/ws-gateway.js`。 ### 运行步骤 - 安装依赖:`npm install` - 启动网关:`npm run start:ws` - 打开浏览器访问:`http://localhost:3000/` - 点击“连接”后,日志应显示服务端 `capabilities`; - 点击“格式化”,看到返回的 `TextEdit[]`,并在文本框应用;随后 `didChange` 同步; - 点击“语义令牌”,打印 raw 与 decoded 两种输出,并包含类型与修饰符。 ### 预期输出示例 - `Server capabilities`:包含此前课程提供的 completion/hover/definition/references/codeAction/formatting/semanticTokens 等能力; - `Diagnostics`:对示例文本中的 `TODO` 等进行诊断发布; - `Formatting edits`:返回单个覆盖全文的编辑,应用后文本尾部包含换行; - `Semantic tokens (raw/decoded)`: - 例如:`TODO` → `keyword + modification`;`DONE` → `keyword + readonly`;`NOTE` → `comment + documentation`。 ### 练习题(第七课) - 替换文本框为 Monaco Editor: - 将 `ws-client.js` 中的文本输入改为 Monaco,并使用 `semanticTokens` 接口在编辑器中高亮(可参考 Monaco 的 Tokenization 支持)。 - 增量同步: - 在浏览器端监听输入事件,按增量构造 `didChange`(记录 range/offset),而不是全量覆盖文本。 - 扩展令牌: - 在服务端增加更多类型与修饰符映射,如为 `FIXME/NOTE` 添加不同修饰符组合;测试 `range` 请求只返回选区内令牌。 - 增加更多按钮: - 在页面增加 `Hover/Completion/Definition/References` 按钮,分别请求并将结果打印到日志区。 - 使用真实文件路径: - 将 URI 改为真实 `file://` 路径,在网关中选项支持从磁盘加载该文件作为初始文本。 ### 停止服务 - 关闭页面或点击“断开”,浏览器端会发送 `shutdown`/`exit` 并断开 WebSocket; - 在终端中按 `Ctrl+C` 停止 HTTP/WS 网关进程。 ## 第八课:Monaco 集成与前端 LSP 这一课我们在浏览器端集成 Monaco Editor,并通过 WebSocket 将编辑器内容与 LSP 服务端桥接,体验更真实的编辑器交互与高亮。 ### 改动概览 - 替换页面文本框为 Monaco Editor(CDN 引入),设置 `plaintext` 语言与初始内容。 - 重写前端脚本 `web/ws-client.js`: - 使用编辑器模型内容作为 `didOpen`/`didChange` 的文本源(简化为全量同步)。 - 实现 `semanticTokens` 解码并映射为 Monaco `decorations` 高亮。 - 注册 `hover` 与 `completion` provider,转发到 LSP:`textDocument/hover`、`textDocument/completion`。 - 样式:新增令牌装饰的 CSS 类(示例:`tok-keyword`、`tok-keyword-mod`、`tok-keyword-readonly`、`tok-comment`、`tok-comment-doc`)。 - 工具栏按钮保持不变:连接/断开、格式化、语义令牌。 ### 运行步骤 - 启动网关:`npm run start:ws` - 打开:`http://localhost:3000/` - 点击“连接”后,页面将发送 `initialize/initialized/didOpen`,内容取自 Monaco。 - 点击“格式化”:应用返回的全文 `TextEdit` 到编辑器,并同步 `didChange`。 - 点击“语义令牌”:请求 `full` 令牌,解码并以 `decorations` 高亮。 - 将鼠标悬停或输入关键字,触发 `hover`/`completion`(若服务端已实现第二课的对应处理器)。 ### 预期效果 - 页面日志显示服务端能力集与各请求的返回信息。 - 编辑器内对 `TODO/FIXME/DONE/NOTE` 的高亮: - `TODO` → `keyword + modification` → 类 `tok-keyword-mod` - `FIXME` → `keyword + deprecated`(示例样式可扩展) - `DONE` → `keyword + readonly` → 类 `tok-keyword-readonly` - `NOTE` → `comment + documentation` → 类 `tok-comment-doc` - 悬停与补全: - 若服务端已实现 `hover/completion`,编辑器悬停显示 Markdown;输入大写首字母触发关键字补全。 ### 练习题(第八课) - 更细致的高亮:将 `decorations` 改为 Monaco Tokens Provider(`monaco.languages.setTokensProvider`)以获得更自然的语法着色。 - 增量同步:在 `onDidChangeModelContent` 中收集增量 `range`/`text`,按 LSP 的增量 `didChange` 发送。 - Range 令牌:增加按钮请求 `semanticTokens/range` 并只对选区进行高亮。 - 功能扩展:在页面增加 Definition/References/CodeAction 按钮,调用对应 LSP 能力并在编辑器中可视化。 - 文件路径:将 `uri` 替换为真实 `file://` 路径,初次打开从磁盘加载文本。 ### 注意 - 若服务端暂未实现 `hover/completion`,对应提供器会返回空结果;可对照第二课在服务端补齐。 - 高亮用到的 CSS 类可在 `web/index.html` 中调整颜色与样式以适配你的主题。