Language Server Protocol 把补全、跳转、诊断等语言能力定义为编辑器与语言服务之间的协议。它减少不同编辑器分别适配每种语言的重复工作,但不要求语言服务必须采用某种编译器、线程模型或数据库。
语言服务器可以复用编译器前端,也可以维护专门面向交互的分析引擎。它面对的是持续变化、经常不完整的源码,需要在响应速度、结果完整度和版本正确性之间组织工作。IDE 的语言功能也可以通过编辑器内部实现,LSP 并不是唯一来源。
协议先约定能力,再交换请求与通知
LSP 使用 JSON-RPC 消息模型。请求有 ID,并对应结果或错误;通知没有对应响应。初始化阶段双方交换能力,再按协商结果使用功能。例如 textDocument/publishDiagnostics 是服务器发出的通知,不能把它列为编辑器发起的普通查询。
文档同步通常包括 didOpen、didChange、didClose 等通知,具体使用整份文本还是增量编辑由能力约定。对于已打开文档,服务器要分析编辑器同步的未保存内容,而不能每次重新读取磁盘上的旧文件。LSP 3.17 规范
从一次字段补全理解分析依赖
用户输入 account.,此时表达式还不完整。分析引擎可以保留接收者 account、点号和缺失成员节点,继续解析 account 的绑定与类型,再枚举候选成员。候选还可能受可见性、泛型替换、trait、扩展方法和预期类型约束。
因此补全不只是“把符号表所有名字列出来”。语法位置决定期望哪类候选,名字解析确定具体声明,类型分析决定哪些成员可用,排序与自动导入则改善交互体验。
编译器同样可以进行错误恢复,而不是遇到第一个不完整表达式就必须停止。区别主要在目标:交互系统更频繁地要求带错源码仍能提供局部有用结果。
常见能力速查
| 消息或请求 | 核心工作 | 不能忽略的边界 |
| completion | 上下文、可见符号、成员与排序 | 不完整语法、自动导入和替换范围 |
| definition | 从使用位置解析具体定义 | 重载、宏展开与生成代码位置 |
| references | 以符号身份查询引用 | 同名不同符号、跨项目与索引完整度 |
| hover | 类型、文档与相关信息 | 类型实例化、错误恢复状态 |
| signatureHelp | 当前调用的候选签名与参数位置 | 嵌套调用和重载 |
| rename | 生成关联位置的 WorkspaceEdit | 名字捕获、冲突、文件版本和可写性 |
| publishDiagnostics | 服务器推送诊断 | 旧版本结果的淘汰;另有拉取诊断接口 |
| semanticTokens | 返回分类后的文本范围 | 位置编码、能力与 legend |
Rename 不是全局字符串替换。两个作用域里的同名变量不是同一符号;宏展开中的名字也未必有可直接修改的源位置。服务器生成编辑集合后,客户端仍需按协议与版本状态应用。
增量计算:缓存的是查询依赖,不只是文件
设查询链为“文件文本→语法树→声明签名→函数体类型→诊断”。修改函数体内一个局部表达式,可能使该函数的类型结果失效,却不影响导出签名;修改公共类型则可能影响多个依赖文件。
增量引擎记录依赖关系与输入版本,只重算受影响查询,并可在结果未变化时阻止失效继续传播。“只重算被改的文件”过窄,“一改就重算全项目”又过宽。项目配置、编译选项、依赖版本和生成文件也都是输入。
rust-analyzer 的架构将语法、语义和增量查询等职责分开,体现了这种面向交互分析的组织方式;这不意味着所有结果都必须持久化成磁盘索引。rust-analyzer 架构
项目索引:用符号身份连接声明与使用
项目级索引帮助查找未打开文件里的符号、引用和关系。它可以结合当前打开文件的动态结果、后台建立的索引,以及可选的外部索引。缓存或磁盘持久化是性能手段,不是 LSP 的强制要求。
clangd 将不同来源的索引组合起来,兼顾当前编辑状态与整个项目的信息。clangd 索引设计
索引必须按实际编译上下文解释源码。对 C/C++,同一文件在不同宏定义或 include 路径下可能代表不同程序;仅以文件修改时间作为缓存键可能复用错误结果。索引尚未完成时,也应明确哪些查询可以返回局部结果,哪些操作需要等待更多信息。
位置信息:字符不总等于字节
LSP 位置通常由零起始的行号与行内偏移表示。3.17 支持协商位置编码,未协商其他编码时保留 UTF-16 的兼容默认。UTF-8 字节、Unicode 码点与 UTF-16 code unit 数量可能不同。
例如一个非 BMP 字符在 UTF-16 中占两个 code unit,在 UTF-8 中可能占四字节。若服务器直接把字节偏移当作 LSP character,中文与 emoji 附近的补全替换或诊断范围会偏移。编辑应用顺序、换行表示以及 range 端点也应遵守协议。具体数据结构见 LSP 元模型。
连续编辑:第二个 range 属于第一次编辑后的文本
设第 7 版只有一行 a😀b,位置编码为 UTF-16。a 占 1 个单元,😀 占 2 个,b 的起点是 3;如果内部字符串按 Unicode 码点索引,b 的下标却是 2。先在偏移 1 插入 X,文本成为 aX😀b,b 的 UTF-16 起点变为 4。接着用范围 [4,5) 把 b 替换成 B,才得到 aX😀B。
这两个 change 若放在同一条 didChange 中,也须按数组顺序应用:第二个范围基于中间文本,而不是最初第 7 版。消息里的版本号标记全部变化应用后的版本。文档同步定义
下面是只处理单行、合法 Unicode 文本的转换模型。超过行长的偏移按协议夹到行尾;落在 emoji 代理对中间的偏移在本模型中拒绝,以避免切开字符。这项拒绝策略不是声称协议给所有服务器规定了同一种修复方式。
def index16(text, offset):
if offset < 0:
raise ValueError("negative position")
units = 0
for index, char in enumerate(text):
if units == offset:
return index
units += len(char.encode("utf-16-le")) // 2
if units > offset:
raise ValueError("inside surrogate pair")
return len(text)
def apply(text, changes):
for start, end, replacement in changes:
if start > end:
raise ValueError("reversed range")
lo, hi = index16(text, start), index16(text, end)
text = text[:lo] + replacement + text[hi:]
return text
assert index16("a😀b", 3) == 2
assert index16("a😀b", 99) == 3
assert apply("a😀b", [(1, 1, "X"), (4, 5, "B")]) == "aX😀B"
assert apply("a😀b", [(1, 3, "")]) == "ab"
for changes in [[(2, 3, "")], [(3, 1, "")], [(-1, 0, "")]]:
try:
apply("a😀b", changes)
except ValueError:
pass
else:
raise AssertionError("invalid edit accepted")
完整实现还需要行索引、CRLF、跨行替换以及整篇替换事件,不能把这里的单行函数直接用于真实文档。每次编辑后应同步更新文本与行索引;缓存旧行起点后只改文本,会让后续所有位置转换建立在错误基础上。Position 定义
同一个光标,为什么会有三个不同偏移?
三个计数器指向的是同一个字符边界,不是三个不同光标。移动边界时,emoji 让 UTF-8 增加四、UTF-16 增加二、码点增加一。随后比较连续编辑的中间文本:插入 X 已经移动 b,下一次替换必须使用新的 UTF-16 范围。下面的按钮选择完整编辑快照,边界滑杆只负责观察,不会暗中修改文本。
正在呈现知识画面
同一个光标,为什么会有三个不同偏移?UTF-8字节、UTF-16单元与码点不是同一单位。连续 change 按顺序基于中间文本应用;第二次仍用旧范围会指向错误位置或字符内部。
{"id":"lsp-position-units","title":"同一个光标,为什么会有三个不同偏移?","summary":"UTF-8字节、UTF-16单元与码点不是同一单位。连续 change 按顺序基于中间文本应用;第二次仍用旧范围会指向错误位置或字符内部。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"stage\"><button data-mode=\"0\" data-i18n=\"original\"></button><button data-mode=\"1\" data-i18n=\"inserted\"></button><button data-mode=\"2\" data-i18n=\"replaced\"></button></div><div class=\"control\"><label for=\"cursor\" data-i18n=\"cursor\"></label><output for=\"cursor\" id=\"cursor-value\"></output><input id=\"cursor\" type=\"range\" min=\"0\" max=\"3\" value=\"2\" step=\"1\"></div><div id=\"characters\" class=\"track\"></div><div id=\"units\" class=\"table\"></div><div class=\"metrics\"><div><span data-i18n=\"utf8\"></span><strong id=\"utf8\"></strong></div><div><span data-i18n=\"utf16\"></span><strong id=\"utf16\"></strong></div><div><span data-i18n=\"points\"></span><strong id=\"points\"></strong></div></div><p id=\"edit\" class=\"formula\"></p><p class=\"hint\" data-i18n=\"hint\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.metrics strong{font-size:20px}#units .tr{grid-template-columns:.5fr 1fr 1fr}#units .tr>*{min-height:3.2em}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}let stage=0;function draw(){const text=['a😀b','aX😀b','aX😀B'][stage],chars=[...text];$('#cursor').max=chars.length;const n=Math.min(+$('#cursor').value,chars.length),prefix=chars.slice(0,n).join('');set('cursor-value',n);cells('characters',Array.from({length:4},(_,i)=>({text:chars[i]||'·',cls:i<n?'on':i<chars.length?'gold':'empty'})));table('units',[['char','UTF-8','UTF-16'],...Array.from({length:4},(_,i)=>{const c=chars[i];return c?[c,new TextEncoder().encode(c).length,c.length]:['·','·','·']})]);set('utf8',new TextEncoder().encode(prefix).length);set('utf16',prefix.length);set('points',n);set('edit',stage===0?'v7: a😀b; b starts at UTF-16 3':stage===1?'[1,1) ← X → aX😀b; b starts at 4':'[4,5) ← B → aX😀B');$('#characters').dataset.text=text;pressed(String(stage));}document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>{stage=+b.dataset.mode;draw()});$('#cursor').oninput=draw;draw();","audio":false,"strings":{"cursor":"字符边界","heading":"同一个光标,为什么会有三个不同偏移?","hint":"插入使用 [1,1);下一条替换使用中间文本的 [4,5)。边界按钮不允许停在半个 emoji 上。","inserted":"插入 X 后","intro":"固定文本 a😀b,选择字符边界。再应用插入 X 与替换 B 两个连续编辑,观察第二个 range 应属于哪份文本。","original":"原文 v7","points":"Unicode / code point","replaced":"再替换 b 后","reset":"重置","scope":"单行合法Unicode,LSP位置编码固定UTF-16。模型拒绝切开代理对;不涵盖CRLF、跨行编辑或协议的全部端点处理。","scopeLabel":"模型范围与约定","stage":"文本快照","utf16":"UTF-16 / unit","utf8":"UTF-8 / byte"}}
语义高亮与语法高亮
正则规则可以识别局部词法模式;Tree-sitter 的 highlights.scm 主要查询语法树节点,不是把整个文件当作纯正则匹配。它能利用语法结构,但通常不承担完整的跨文件类型解析。Tree-sitter 高亮文档
LSP semantic tokens 允许服务器返回更丰富的分类与修饰符,例如某个标识符是类型、参数还是只读变量。它不是规定“必须为源码每个 token 标注”,也不禁止编辑器同时保留语法高亮作为快速基础层。
调度:快速响应与正确版本同样重要
补全、悬停等交互查询通常应优先于全项目索引。后台分析可以合并相近编辑、复用快照,并在输入变化后取消过时任务;但不能让旧诊断晚到后覆盖新结果。
$/cancelRequest 表达对某个协议请求的取消,不是任意内部后台任务的统一停止机制。取消可能与完成竞争,服务端仍要妥善结束请求;内部分析也需要自己的协作式取消与资源管理。
去抖等待和查询延迟有取舍。某些快速局部分析可以每次编辑都执行;昂贵后台诊断才适合合并。不存在“每次按键解析一定卡顿”或“统一去抖 50ms 就能保证所有请求低于 100ms”的结论。
为什么取消后仍要做结果检查
假设请求 81 分析 v7,随后文档变成 v8,请求 82 先返回。此时即使客户端发出了取消 81 的通知,81 也可能已经完成计算或正在发送响应。取消不是“这个 ID 从此不会再收到任何消息”的承诺;请求仍须按协议有结束响应。取消机制
可以给内部任务保存 (URI, 文档版本, 项目配置代次)。下面演示一种保守策略:只有完整快照键仍相同才接受结果。LSP 补全请求并不直接携带这里的全部字段,它们由客户端/服务端自己的调度层关联;项目配置变化也可能让文本未变的结果失效。
current = ("file:///demo.lang", 8, 3)
pending = {81: ("file:///demo.lang", 7, 3), 82: current}
shown = []
def finish(request_id, result):
snapshot = pending.pop(request_id) if snapshot == current:
shown.append(result)
finish(82, "new completion")
finish(81, "old completion")
assert shown == ["new completion"] and not pending
pending[83] = current
current = ("file:///demo.lang", 8, 4) finish(83, "stale configuration")
assert shown == ["new completion"]
这不是要求任何编辑都丢弃所有工作。更精细的系统可以证明查询依赖未变,或把旧位置映射到新版本后复用结果;但仅凭“请求 ID 较大”不能证明结果有效,因为 ID 用于对应请求与响应,并不是文档版本排序字段。
| 结果类型 | 过期造成的实际问题 | 应关注的状态 |
| 补全 | 替换范围指向旧文本,覆盖新输入 | 请求上下文、光标与文本版本 |
| 诊断 | 已修复的错误重新出现 | 诊断所属版本;无版本时的调度策略 |
| 重命名编辑 | 对变更后的文件修改错误位置 | 每个文档的版本、冲突与编辑能力 |
| 后台索引 | 同名符号连到另一套条件编译定义 | 项目配置、依赖与索引代次 |
对于重命名,不能把“服务器已算完所有引用”当作文件仍然保持原样。支持时可用带版本的 TextDocumentEdit 表达编辑依据,客户端按版本和能力处理;跨文件编辑还要考虑失败处理能力,不能默认所有客户端都提供完全相同的原子提交语义。
请求号更大,结果就一定更新吗?
这里故意让第二个任务的请求 ID 更小,以免把数值顺序误当作版本顺序。任务保存启动时的文本版本和配置代次,交付时与当前快照比较;过期结果不能覆盖已经接受的新结果。取消只表达意图,图中的迟到交付模拟响应已完成的竞争。修改配置也会使结果失效,即使文本版本未变。
正在呈现知识画面
请求号更大,结果就一定更新吗?请求 ID 只配对请求与响应。文本版本和项目配置共同决定结果是否适用;取消可能与完成竞争,迟到结果仍必须检查依据。
{"id":"analysis-snapshot-validity","title":"请求号更大,结果就一定更新吗?","summary":"请求 ID 只配对请求与响应。文本版本和项目配置共同决定结果是否适用;取消可能与完成竞争,迟到结果仍必须检查依据。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"actions\"><button id=\"start\" data-i18n=\"start\"></button><button id=\"edit\" data-i18n=\"edit\"></button><button id=\"config\" data-i18n=\"config\"></button><button id=\"deliverA\" data-i18n=\"deliverA\"></button><button id=\"deliverB\" data-i18n=\"deliverB\"></button><button id=\"cancel\" data-i18n=\"cancel\"></button><button id=\"reset\" data-i18n=\"reset\"></button></div><div class=\"metrics\"><div><span data-i18n=\"current\"></span><strong id=\"current\"></strong></div><div><span data-i18n=\"accepted\"></span><strong id=\"accepted\"></strong></div></div><div id=\"tasks\" class=\"table\"></div><p id=\"history\" class=\"log\" role=\"status\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.metrics strong{font-size:17px;min-height:3.2em}#tasks .tr{grid-template-columns:.6fr 1fr 1fr}#tasks .tr>*{min-height:4.8em}#history{min-height:5.4em}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}let version=7,config=1,tasks=[],accepted=null,history='∅';function draw(){set('current','v'+version+' / c'+config);set('accepted',accepted?'#'+accepted.id+' v'+accepted.v+' c'+accepted.c:'∅');table('tasks',['A','B'].map((n,i)=>{const task=tasks[i];return[n,task?'#'+task.id+' v'+task.v+' c'+task.c:'∅',task?(task.done?'delivered':task.cancelled?'cancel requested':'pending'):'—']}));set('history',history);$('#start').disabled=tasks.length===2;$('#deliverA').disabled=!tasks[0]||tasks[0].done;$('#deliverB').disabled=!tasks[1]||tasks[1].done;$('#cancel').disabled=!tasks[0]||tasks[0].done||tasks[0].cancelled;$('#accepted').dataset.id=accepted?.id||'';}$('#start').onclick=()=>{tasks.push({id:tasks.length?12:81,v:version,c:config,done:false,cancelled:false});draw()};function invalidate(){accepted=null;draw()}$('#edit').onclick=()=>{version++;invalidate()};$('#config').onclick=()=>{config++;invalidate()};for(const [i,id]of ['deliverA','deliverB'].entries())$('#'+id).onclick=()=>{const task=tasks[i];task.done=true;const valid=task.v===version&&task.c===config;if(valid)accepted=task;history='#'+task.id+': (v'+task.v+',c'+task.c+') '+(valid?'=':'≠')+' (v'+version+',c'+config+')\\n'+t(valid?'valid':'stale');draw()};$('#cancel').onclick=()=>{tasks[0].cancelled=true;draw()};$('#reset').onclick=()=>{version=7;config=1;tasks=[];accepted=null;history='∅';draw()};draw();","audio":false,"strings":{"accepted":"当前采用的结果","cancel":"请求取消任务 A","config":"修改项目配置","current":"当前快照键","deliverA":"交付任务 A","deliverB":"交付任务 B","edit":"修改文本版本","heading":"请求号更大,结果就一定更新吗?","history":"最近一次交付检查","intro":"启动两个分析任务,独立修改文本或项目配置,再按任意顺序交付结果。取消只记录请求,最终仍按快照键检查。","reset":"重置","scope":"固定一个 URI,最多两个未交付任务。采用完整 (version,config) 相等的保守策略,不模拟协议全部消息或精细依赖复用。取消后的到达是已完成响应的竞争情形。","scopeLabel":"模型范围与约定","stale":"快照已改变,丢弃迟到结果。","start":"启动分析任务","valid":"快照仍匹配,采用结果。"}}
最小实现的学习顺序
先实现初始化、文档同步与位置转换,确保服务器持有正确文本;再提供局部语法诊断和简单 hover;接着引入名字解析、项目上下文与增量查询;最后实现跨文件引用、重命名和更复杂的补全。每一步都应检查错误源码与连续编辑,而不只检查一次性完整文件。
相关基础:AST 与错误恢复、符号表与作用域。
参考
Keywords: LSP, language server, JSON-RPC, incremental analysis, document version, UTF-16, semantic tokens, cancellation