工具链:编辑器 / REPL / 格式化器(M38 · 主线 B2/B3/B4)
主线 B 的目标是可信赖的工具链(服务北极星的后半句「出问题能快速定位」)。 B1(调试器)见
docs/调试器.md;这里记 B2/B3/B4 三件事的现状、实测数据与 已知边界。
| 编号 | 内容 | 现状 |
|---|---|---|
| B1 | 调试器 | ✅ jishi 调试(命令行)+ jishi dap(Debug Adapter Protocol,M43)——见 docs/调试器.md |
| B2 | LSP 增量同步 | ✅ 增量文本同步 + 按行复用前缀的增量分词 |
| B3 | REPL 增强 | ✅ 历史(跨会话)/ Tab 补全 / 多行编辑更准 |
| B4 | 格式化器边界 | ✅ 修掉 4 个真问题 + 支持整个目录 |
| B5 | 字节码执行器的调试器 | ✅ M39 起三条执行器都能调(C VM 由 M40 收口,会话记录逐字相同) |
| B6 | 语言服务补能力(M42) | ✅ 格式化 / 重命名 / 快速修复 / 签名 / 工作区符号 / 语义高亮 |
| B7 | VS Code 语言客户端(M42) | ✅ 零依赖客户端,11 项能力逐条接上;verify-lsp.js 端到端自检 |
| B8 | 编辑器里调试(M43) | ✅ jishi dap + 扩展接调试适配器;断点/单步/变量/调用栈/求值;.vsix 可安装 |
B2:LSP 增量同步
为什么要做(先量了再说,别猜)
以前的实现是「每收到一次编辑就把整份文件 tokenize + parse 一遍」。实测每敲一个 字符的成本:
| 文件行数 | tokenize | parse | 合计 |
|---|---|---|---|
| 253 | 6.1 ms | 3.8 ms | 9.9 ms |
| 1253 | 25.6 ms | 18.6 ms | 44 ms |
| 5003 | 103.6 ms | 77.9 ms | 182 ms |
| 20003 | 430 ms | 328 ms | 759 ms |
也就是一千行以上就有可感的卡顿——路线图里那句「大文件有卡顿」实测坐实。
做了什么
- 协议层:
textDocumentSync.change = 2(增量)。客户端只发改动的那一段
(contentChanges[].range + text),服务端按区间应用;仍然兼容「不带 range 的全量替换」(客户端降级、或我们没声明增量时的兜底)。
- 版本乱序保护:收到的版本不比当前新时直接忽略。编辑器偶尔会重发旧版本,
照单全收的后果是诊断「改完又变回去」。
- 按行复用前缀的增量分词(
jishi.incremental):编辑只影响改动行之后的
内容——前面那些行的 token 和「进入该行时的词法状态」都没变,直接复用, 只把后缀重新分词。依据是 tokenizer.LineAnchor 记下的七个值(缩进栈、括号 深度、跨行三引号的累积内容与起点、是否在多行注释里)。
实测(每敲一个字符的分词成本):
| 文件行数 | 全量 | 增量(改末行) | 增量(改中间) |
|---|---|---|---|
| 451 | 8.15 ms | 0.18 ms | 4.22 ms |
| 1801 | 33.35 ms | 0.47 ms | 17.39 ms |
| 7201 | 141.27 ms | 3.17 ms | 75.36 ms |
| 18001 | 359.70 ms | 8.46 ms | 190.92 ms |
改末行(最常见:接着往下写)快 40 倍以上;改中间快约 1.9 倍——因为后缀 仍然要重算。正确性靠对拍:随机编辑下增量结果必须与全量分词逐项相同 (tests/test_m38_lsp_incremental.py,含三引号内部、括号续行内部、 多行注释内部、缩进错等形状)。
已知边界(不粉饰)
parse仍是全量的(5000 行约 78 ms)。增量解析要维护「每个语法节点的
子树缓存 + 编辑后的失效范围」,复杂度高一个量级,而当前收益只有几十毫秒, 路线图也没要求——先做成一半、并把另一半如实标出来,比做个半成品强。
- 位置单位按 UTF-16 码元(LSP 默认):中文一个字符算 1 个码元,
emoji 这类天文平面字符算 2。入向(应用编辑)与出向(诊断/悬停位置)都按 这个换算;写错了会「改在错位置」,所以 _utf16_len / _utf16_to_index 有专门用例钉住(含 emoji 前后两个边界)。
didChange不带 range 时按全量处理——多付一点带宽,但行为正确。
B3:REPL 增强
三件事
- 历史:
↑/↓翻;跨会话——落盘在~/.jishi/repl_history.txt
(JISHI_HISTORY 可改位置,测试就靠它避免写进用户家目录)。 相邻重复不重复记,上限 500 条。历史 命令能看最近 20 条。
- Tab 补全:关键字 / 内建 / 标准库模块 / 本次会话已定义过的变量;
模块. 之后给该模块的函数,对象. 之后给方法名。唯一命中直接替换, 多个命中补到共同前缀并列出候选(与 shell 手感一致)。 候选数据全部来自 ai.build_lang_spec()(经 jishi.langdata 提炼层)—— REPL 不另存一份关键字/内建表,语言那边加了内建这里自动就有。
- 多行编辑更准:除了「冒号结尾 / 括号未闭合 / 块未闭合」,**引号(含三引号、
反引号插值)没写完也要续行——判定交给真词法器**(拿缓冲去 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 项断言,全过才算接好
已知边界
- 就没有作用域分析:重命名只在模块级符号上可靠(见上表)。
- 工作区索引是同步扫的:工作区特别大时首次搜索会有一次可感的延迟
(有封顶兜底)。真要做增量索引得上后台任务。
- 语义高亮的 token 位置按 UTF-16 码元换算(LSP 默认单位),而词法器给的行列
按 Python 字符数——换算集中在 _utf16_col 一处。
B4:格式化器边界
B4:格式化器边界
修掉的四个真问题
- 字符串里的
#被当成注释:令 甲 = "含 # 号"会被从字符串中间劈开,
格式化直接报「字符串没有正常结束」(插值字符串同理)。 于是注释识别改成状态机扫描(_scan_code),并把「括号计数」也并进同一个 扫描器——以前这两件事各写了一份字符串处理,两边都得单独记得「跳过字符串」。
- 括号续行里的注释被吃掉:多行调用的
1, # 注释那一行注释直接消失,
与「保留注释」的设计承诺相悖。
- 带 BOM 的文件直接崩:Windows 记事本存「UTF-8 带 BOM」时,词法器把
U+FEFF 判成「无法识别的字符」——连跑都跑不起来。现在词法器只剥 开头那一个 BOM(文件中间的仍报错,别把不可见字符的问题放过去), 格式化器则保留它(否则 --check 会把每个带 BOM 的文件判成没格式化)。
jishi 格式化 <目录>甩英文 traceback:README 里写的用法是
jishi 格式化 --check examples/,而这条路一直抛 PermissionError。 现在目录走递归处理,并且把「没格式化」与「处理不了(词法错/读不了)」 分开报——两者修法完全不同(前者 --write,后者先改代码)。
另外顺手修了一个潜伏很久的词法器 bug:content_start 在「括号内续行」 那一支没有被赋值(用的是上一轮的残留值),顶格的续行会被整行跳过。 它一直藏着(正常写法续行都有缩进,被跳过的刚好是空白),是 B2 从 bracket_depth > 0 的状态续跑时以 UnboundLocalError 的形式暴露的。
验收方式
- 边界语料 42 条(条件表达式、切片与字典冒号、三引号、插值、块注释、
一元正负号、解构星号、模式匹配守卫、枚举、用 … 为、续行与注释、 tab 缩进、CRLF、BOM、空文件、只有注释……)逐条过「幂等 + token 流等价 + 解析结果不变」;
- 仓库自己的
examples/与tests/cases/必须本来就合规
(jishi 格式化 --check 全绿)——这条是「格式化器的风格 = 项目自己的风格」 的验收,两边打起来说明规则定得与实际写法不一致。
已知边界
- 格式化器只依赖词法层,不做完整语法分析(有意为之):
如果 真(漏冒号)
这种半成品照样能排版——用户可能正写到一半,不该因为「还没写完」拒绝服务。 所以「处理不了」只对应词法错误。
- 不拆行、不重排:只做「缩进归一 + 词间间距归一」,行结构不变。