LSP 格式化与脏文档跟踪
ttsc format 在编辑器里的对应物是 textDocument/formatting。它有个微妙要求:格式化活的(可能未保存的)编辑器缓冲区,而不是磁盘文件——否则 formatOnSave 会丢掉未写盘的编辑。本页讲文档文本缓存、UTF-16 偏移映射、以及格式化请求处理。源码 packages/ttsc/internal/lspserver/lsp_proxy.go。
为什么要缓存文档文本
LSP 的 textDocument/formatting 只给一个 uri。如果 ttsc 把这个 uri 交给 lint 旁车去读磁盘文件,那读到的是保存前的内容——formatOnSave 场景下用户还没保存,磁盘是旧的。所以 ttsc 自己缓存每个文档的活缓冲区文本(documentText map,diagnosticsMu 保护),把它通过 stdin 喂给旁车格式化。
文本缓存的维护
didOpen/ full-syncdidChange:用全文 seed 缓存。- incremental(ranged)
didChange:把每个编辑 splice 进缓存文本,让它跟随活缓冲区。 didClose:逐出条目。- ranged change 但没有缓存基底、或位置无法映射:丢弃条目,让格式化处理器回退到读盘。
UTF-16 偏移映射(一个真实坑)
LSP 的 Position.character 是 UTF-16 code unit 偏移,不是字节偏移也不是 rune 偏移:一个星界平面字符(≥ U+10000)算两个 UTF-16 code unit。把 ranged edit splice 进 Go 字符串(字节索引)时必须正确换算。
lspPositionToByteOffset(lsp_proxy.go:1162)做这件事:
- 逐行前进到目标行(处理
\n与\r\n); - 在目标行内前进
character个 UTF-16 code unit,用utf16.RuneLen(r)算每个 rune 占几个 code unit; - 返回对应字节索引。
越界处理是刻意的(lsp_proxy.go:1149 注释):行/列越过文本末尾时返回 (len, false) 而非 clamp——调用方把 !ok 当作缓存/编辑器分歧的信号,丢掉缓存让格式化读盘。落在行末(行尾光标那一列)算在范围内,映射到行结束或文本末尾的字节索引。
格式化请求处理
handleFormattingRequest(lsp_proxy.go:543):
关键设计:
- 仅当 ttsc 拥有
ttsc.format.document命令时拦截,否则转发上游(ownsCommand(formatDocumentCommand))。 - 它刻意不走 executeCommand 路径的脏文档守卫——formatting 就是要格式化活缓冲区,包括脏的。
hasContent区分"代理有缓冲区可格式化"与"无缓冲区,让旁车读盘"。空缓冲区是合法文档状态(用户清空了文件),所以空字符串不能当成无缓冲区哨兵:缓存命中永远hasContent=true,即使内容是""(lsp_proxy.go:560注释)。- 格式化失败绝不破坏保存:任何错误都回空
TextEdit[],让失败的格式化器不阻断编辑器保存。
contentExecutor 能力
contentExecutor(lsp_proxy.go:527)是 PluginSource 可选实现的接口:ExecuteCommandWithContent(command, args, content, hasContent)。NativePluginSource 实现它,把缓冲区通过 --content-stdin 管给旁车,让旁车格式化管进来的文本而非磁盘文件。不实现它的 source 退回普通 ExecuteCommand(读盘)。
executeFormatCommand(lsp_proxy.go:595):单个命令参数是 uri(与旁车已实现的 executeCommand 路径一致),--content-stdin 让旁车格式化管入文本。
documentFormattingProvider 的强制声明
回顾 设计:augmentInitializeResult 在 ttsc 拥有格式化器时强制打开 documentFormattingProvider,即使 upstream tsgo 已声明一个。因为 tsgo 的格式化器会格式化磁盘文件、丢失未保存编辑,而 ttsc 的代理拦截这个方法、格式化活缓冲区。
不变量
- 缓存命中永远
hasContent=true,空字符串是合法内容不是哨兵。 - UTF-16 越界返回
(len, false),触发缓存丢弃 + 读盘回退。 - 格式化失败回空 TextEdit[],绝不让保存失败。
- formatting 路径不走脏文档守卫(要格式化活缓冲区)。