工具链:编辑器 / REPL / 格式化器(M38 · 主线 B2/B3/B4)

主线 B 的目标是可信赖的工具链(服务北极星的后半句「出问题能快速定位」)。 B1(调试器)见 docs/调试器.md;这里记 B2/B3/B4 三件事的现状、实测数据与 已知边界。

编号内容现状
B1调试器✅ jishi 调试(命令行)+ jishi dap(Debug Adapter Protocol,M43)——见 docs/调试器.md
B2LSP 增量同步✅ 增量文本同步 + 按行复用前缀的增量分词
B3REPL 增强✅ 历史(跨会话)/ Tab 补全 / 多行编辑更准
B4格式化器边界✅ 修掉 4 个真问题 + 支持整个目录
B5字节码执行器的调试器✅ M39 起三条执行器都能调(C VM 由 M40 收口,会话记录逐字相同)
B6语言服务补能力(M42)✅ 格式化 / 重命名 / 快速修复 / 签名 / 工作区符号 / 语义高亮
B7VS Code 语言客户端(M42)✅ 零依赖客户端,11 项能力逐条接上;verify-lsp.js 端到端自检
B8编辑器里调试(M43)✅ jishi dap + 扩展接调试适配器;断点/单步/变量/调用栈/求值;.vsix 可安装

B2:LSP 增量同步

为什么要做(先量了再说,别猜)

以前的实现是「每收到一次编辑就把整份文件 tokenize + parse 一遍」。实测每敲一个 字符的成本:

文件行数tokenizeparse合计
2536.1 ms3.8 ms9.9 ms
125325.6 ms18.6 ms44 ms
5003103.6 ms77.9 ms182 ms
20003430 ms328 ms759 ms

也就是一千行以上就有可感的卡顿——路线图里那句「大文件有卡顿」实测坐实。

做了什么

  1. 协议层:textDocumentSync.change = 2(增量)。客户端只发改动的那一段

(contentChanges[].range + text),服务端按区间应用;仍然兼容「不带 range 的全量替换」(客户端降级、或我们没声明增量时的兜底)。

  1. 版本乱序保护:收到的版本不比当前新时直接忽略。编辑器偶尔会重发旧版本,

照单全收的后果是诊断「改完又变回去」。

  1. 按行复用前缀的增量分词(jishi.incremental):编辑只影响改动行之后的

内容——前面那些行的 token 和「进入该行时的词法状态」都没变,直接复用, 只把后缀重新分词。依据是 tokenizer.LineAnchor 记下的七个值(缩进栈、括号 深度、跨行三引号的累积内容与起点、是否在多行注释里)。

实测(每敲一个字符的分词成本):

文件行数全量增量(改末行)增量(改中间)
4518.15 ms0.18 ms4.22 ms
180133.35 ms0.47 ms17.39 ms
7201141.27 ms3.17 ms75.36 ms
18001359.70 ms8.46 ms190.92 ms

改末行(最常见:接着往下写)快 40 倍以上;改中间快约 1.9 倍——因为后缀 仍然要重算。正确性靠对拍:随机编辑下增量结果必须与全量分词逐项相同 (tests/test_m38_lsp_incremental.py,含三引号内部、括号续行内部、 多行注释内部、缩进错等形状)。

已知边界(不粉饰)

子树缓存 + 编辑后的失效范围」,复杂度高一个量级,而当前收益只有几十毫秒, 路线图也没要求——先做成一半、并把另一半如实标出来,比做个半成品强。

emoji 这类天文平面字符算 2。入向(应用编辑)与出向(诊断/悬停位置)都按 这个换算;写错了会「改在错位置」,所以 _utf16_len / _utf16_to_index 有专门用例钉住(含 emoji 前后两个边界)。


B3:REPL 增强

三件事

  1. 历史:↑/↓ 翻;跨会话——落盘在 ~/.jishi/repl_history.txt

(JISHI_HISTORY 可改位置,测试就靠它避免写进用户家目录)。 相邻重复不重复记,上限 500 条。历史 命令能看最近 20 条。

  1. Tab 补全:关键字 / 内建 / 标准库模块 / 本次会话已定义过的变量;

模块. 之后给该模块的函数,对象. 之后给方法名。唯一命中直接替换, 多个命中补到共同前缀并列出候选(与 shell 手感一致)。 候选数据全部来自 ai.build_lang_spec()(经 jishi.langdata 提炼层)—— REPL 不另存一份关键字/内建表,语言那边加了内建这里自动就有。

  1. 多行编辑更准:除了「冒号结尾 / 括号未闭合 / 块未闭合」,**引号(含三引号、

反引号插值)没写完也要续行——判定交给真词法器**(拿缓冲去 tokenize, 报「字符串没有正常结束」就继续读),而不是自己数引号。手写引号扫描器迟早 与词法器漂移(中文引号、三引号、转义、插值表达式各有各的坑)。

平台差异(都收在最外层 lineedit.read_line)

场景行为
Windows 真终端自己实现逐键编辑(msvcrt.getwch + 整行重绘):光标移动、删字符、历史、Tab 补全、Ctrl-C 放弃当前输入
POSIX 真终端交给 readline(自带行编辑与历史),只把我们的历史与补全回调接上去
不是终端(管道 / Cmd 重定向 / CI)退回 input(),行为与以前完全一样——脚本与测试不受行编辑打扰

行编辑逻辑(LineEditor / History / Completer)与平台键解码分开写, 按键序列可注入,所以「上键取历史、Tab 补全、退格删汉字、Ctrl-C 放弃」 都能在没有终端的环境里断言;Windows 那条真路径也有一组用例直接喂假 msvcrt。


B6/B7:语言服务补能力 + VS Code 语言客户端(M42)

补了哪六项

textDocument/formatting / rename(+ prepareRename)/ codeAction / signatureHelp / workspace/symbol(+ 跨文件跳转)/ semanticTokens/full。

格式化直接把已有实现(jishi formatter)接上来——一份实现两处用, 不会出现「编辑器里格式化结果和命令行不一样」。语法有错时什么都不做: 编辑器里正在打字,半截代码是常态,这时候「帮你格式化」只会把用户正在写的东西搅乱。

三条有意的边界(都在扩展 README 里写明)

边界为什么
重命名只做模块级符号AST 不记形参位置;且没有作用域分析就改名会误伤「另一个函数里的同名局部变量」。做不到就返回 null(编辑器显示「无法重命名」),不给错的编辑
跨文件跳转只在唯一命中时生效同名符号出现在多个文件就放弃——跳错地方比跳不过去更费时间
工作区符号索引有封顶(400 文件 / 512KB / 跳过 .jishi 与隐藏目录)做全项目数据库得上后台任务,不该卡住编辑器

签名提示为什么要「token 兜底」

签名提示最常用的时刻恰恰是「正在写这个调用、括号还没配上」——那会儿整篇文档 解析不过,AST 根本不存在。但词法层是好的,函数 甲(参数1, 参数2): 的 token 序列照样完整可读,所以另写了一条从 token 流扫形参的路径 (LspServer._params_from_tokens)。

客户端:为什么手写

editors/vscode-jishi 之前连 main 都没有(纯语法高亮)。M42 自己写了 lsp-transport.js(约 150 行:分帧 / 请求应答 / 通知分发),没引 vscode-languageclient——理由见 D39。好处是服务端每项能力在客户端逐条可见, 而且能写 verify-lsp.js 用同一份代码脱离编辑器做端到端自检。

bashcd editors/vscode-jishi
node verify-lsp.js /path/to/jishi     # 25 项断言,全过才算接好

已知边界

(有封顶兜底)。真要做增量索引得上后台任务。

按 Python 字符数——换算集中在 _utf16_col 一处。


B4:格式化器边界

B4:格式化器边界

修掉的四个真问题

  1. 字符串里的 # 被当成注释:令 甲 = "含 # 号" 会被从字符串中间劈开,

格式化直接报「字符串没有正常结束」(插值字符串同理)。 于是注释识别改成状态机扫描(_scan_code),并把「括号计数」也并进同一个 扫描器——以前这两件事各写了一份字符串处理,两边都得单独记得「跳过字符串」。

  1. 括号续行里的注释被吃掉:多行调用的 1, # 注释 那一行注释直接消失,

与「保留注释」的设计承诺相悖。

  1. 带 BOM 的文件直接崩:Windows 记事本存「UTF-8 带 BOM」时,词法器把

U+FEFF 判成「无法识别的字符」——连跑都跑不起来。现在词法器只剥 开头那一个 BOM(文件中间的仍报错,别把不可见字符的问题放过去), 格式化器则保留它(否则 --check 会把每个带 BOM 的文件判成没格式化)。

  1. jishi 格式化 <目录> 甩英文 traceback:README 里写的用法是

jishi 格式化 --check examples/,而这条路一直抛 PermissionError。 现在目录走递归处理,并且把「没格式化」与「处理不了(词法错/读不了)」 分开报——两者修法完全不同(前者 --write,后者先改代码)。

另外顺手修了一个潜伏很久的词法器 bug:content_start 在「括号内续行」 那一支没有被赋值(用的是上一轮的残留值),顶格的续行会被整行跳过。 它一直藏着(正常写法续行都有缩进,被跳过的刚好是空白),是 B2 从 bracket_depth > 0 的状态续跑时以 UnboundLocalError 的形式暴露的。

验收方式

一元正负号、解构星号、模式匹配守卫、枚举、用 … 为、续行与注释、 tab 缩进、CRLF、BOM、空文件、只有注释……)逐条过「幂等 + token 流等价 + 解析结果不变」;

(jishi 格式化 --check 全绿)——这条是「格式化器的风格 = 项目自己的风格」 的验收,两边打起来说明规则定得与实际写法不一致。

已知边界

这种半成品照样能排版——用户可能正写到一半,不该因为「还没写完」拒绝服务。 所以「处理不了」只对应词法错误。