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-sync didChange:用全文 seed 缓存。
  • incremental(ranged)didChange:把每个编辑 splice 进缓存文本,让它跟随活缓冲区。
  • didClose:逐出条目。
  • ranged change 但没有缓存基底、或位置无法映射:丢弃条目,让格式化处理器回退到读盘。

UTF-16 偏移映射(一个真实坑)

LSP 的 Position.characterUTF-16 code unit 偏移,不是字节偏移也不是 rune 偏移:一个星界平面字符(≥ U+10000)算两个 UTF-16 code unit。把 ranged edit splice 进 Go 字符串(字节索引)时必须正确换算。

lspPositionToByteOffsetlsp_proxy.go:1162)做这件事:

  1. 逐行前进到目标行(处理 \n\r\n);
  2. 在目标行内前进 character 个 UTF-16 code unit,用 utf16.RuneLen(r) 算每个 rune 占几个 code unit;
  3. 返回对应字节索引。

越界处理是刻意的(lsp_proxy.go:1149 注释):行/列越过文本末尾时返回 (len, false) 而非 clamp——调用方把 !ok 当作缓存/编辑器分歧的信号,丢掉缓存让格式化读盘。落在行末(行尾光标那一列)算在范围内,映射到行结束或文本末尾的字节索引。

格式化请求处理

handleFormattingRequestlsp_proxy.go:543):

关键设计:

  • 仅当 ttsc 拥有 ttsc.format.document 命令时拦截,否则转发上游(ownsCommand(formatDocumentCommand))。
  • 刻意不走 executeCommand 路径的脏文档守卫——formatting 就是要格式化活缓冲区,包括脏的。
  • hasContent 区分"代理有缓冲区可格式化"与"无缓冲区,让旁车读盘"。空缓冲区是合法文档状态(用户清空了文件),所以空字符串不能当成无缓冲区哨兵:缓存命中永远 hasContent=true,即使内容是 ""lsp_proxy.go:560 注释)。
  • 格式化失败绝不破坏保存:任何错误都回空 TextEdit[],让失败的格式化器不阻断编辑器保存。

contentExecutor 能力

contentExecutorlsp_proxy.go:527)是 PluginSource 可选实现的接口:ExecuteCommandWithContent(command, args, content, hasContent)NativePluginSource 实现它,把缓冲区通过 --content-stdin 管给旁车,让旁车格式化管进来的文本而非磁盘文件。不实现它的 source 退回普通 ExecuteCommand(读盘)。

executeFormatCommandlsp_proxy.go:595):单个命令参数是 uri(与旁车已实现的 executeCommand 路径一致),--content-stdin 让旁车格式化管入文本。

documentFormattingProvider 的强制声明

回顾 设计augmentInitializeResult 在 ttsc 拥有格式化器时强制打开 documentFormattingProvider,即使 upstream tsgo 已声明一个。因为 tsgo 的格式化器会格式化磁盘文件、丢失未保存编辑,而 ttsc 的代理拦截这个方法、格式化活缓冲区。

不变量

  • 缓存命中永远 hasContent=true,空字符串是合法内容不是哨兵。
  • UTF-16 越界返回 (len, false),触发缓存丢弃 + 读盘回退。
  • 格式化失败回空 TextEdit[],绝不让保存失败。
  • formatting 路径不走脏文档守卫(要格式化活缓冲区)。

失败模式

失败行为
旁车格式化失败回空 TextEdit[],保存正常
ranged change 位置不可映射丢缓存,下次读盘
缓存未命中读盘回退(os.ReadFile
uri 不是 file://filePathFromURI 返回 false,无法读盘

接下来