From 70ea22543feb92e0573aa2f15dcd1147329f7400 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 30 Sep 2026 07:24:10 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E9=98=85=E8=AF=BB=E7=BC=96=E8=BE=91=E7=88=B6=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=97=B6=E6=98=BE=E7=A4=BA=E6=95=B4=E5=9D=97=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=EF=BC=88=E5=90=AB=E5=AD=90=E5=9D=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 块的编辑范围改成它在源码里的完整范围:头部连同各子孙块、收尾括号,保存时按整段写回;子卡片只有它自己连同子孙 - 范围由宿主统一算(blockSpans),各语言插件同一条路:else / elif / catch 这类展平的兄弟块按各块开头切开整条语句,最后一块到语句结尾(Shell 的 fi 归它) - 库规则卡片的编辑范围并进同一套算法,去掉 claimedShape - 块编辑时它的子孙都不画(代码都在编辑框里),点「编辑」时重叠的祖先、子孙、共用一行的兄弟块退出编辑 - 问 AI 的外层块开头改用新加的 headerEndLine,位置记号照旧按头部算,已有问答不受影响 - 着色改成整份源码算一次、各块按行取,编辑范围变大后排视图数据不变慢 --- docs/superpowers/specs/code-reader/spec.md | 28 ++-- vscode/src/codeReader/attributes.test.ts | 2 +- vscode/src/codeReader/blockView.test.ts | 106 ++++++++++--- vscode/src/codeReader/blockView.ts | 143 +++++++++++++----- vscode/src/codeReader/contract.ts | 4 +- vscode/src/codeReader/libraryRules.test.ts | 4 +- vscode/src/codeReader/libraryRules.ts | 23 +-- vscode/src/codeReader/qa/prompt.ts | 15 +- .../src/codeReader/rules/sqlalchemy.test.ts | 5 +- .../webview-ui/src/codeReader/BlockTree.tsx | 2 +- .../webview-ui/src/codeReader/editing.test.ts | 31 ++-- vscode/webview-ui/src/codeReader/editing.ts | 29 ++-- vscode/webview-ui/src/codeReader/types.ts | 2 +- 13 files changed, 251 insertions(+), 143 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 6a64e3d..6daf39a 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -148,10 +148,11 @@ - 种类图标和底色按种类区分(颜色见「颜色」);注释块虚线框。 - 内容两种状态,每块单独切: - 预览:函数、类、枚举、异常、导入、常量、变量、解释器、选项、解析指令、阶段、健康检查、启动块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文里有跨几行写的调用时,摊开实参的表排在译文下面(见「调用与构造」);块带说明(Python 字段的属性 docstring)时,说明按 Markdown 再排在下面。 - - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮换成眼睛「预览」;Esc 也回到预览。 -- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里;Python 常量、变量、字段的属性 docstring 算在它那一块里(中间隔着的空行也是)。 -- 库规则认领的块(数据表卡片等,见「库规则」):种类的图标、颜色和名字由规则给,内容是规则给的属性表;它的编辑范围是原块整条语句(类就是整个类),「编辑」时整段一起改。规则没并进卡片的子块(方法、别的成员)不管写在类主体哪儿(两个字段中间也算),都照源码顺序是卡片的子块,各自的「编辑」照旧只改自己那几行。 -- 同一行不会同时在两处能改(能同时编辑的几段互不重叠):一块在编辑时,编辑范围和它重叠的子孙块不画(卡片编辑时它下面的子块全收起来,一行写完的 `if x: return 1` 编辑时同一行的子块也不画),回到预览就照原样出来;点一块的「编辑」时,编辑范围和它重叠的别的块退出编辑。跳到定义的目标块正被盖住时闪盖住它的那块。 + - 编辑:这一块在源码里的完整代码(按语法树着色):头部连同各子孙块、收尾括号,可以直接改,改完按这整段写回;蓝色边框。按钮换成眼睛「预览」;Esc 也回到预览。 +- 复合语句(函数、条件、循环……)块的预览只讲它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里;Python 常量、变量、字段的属性 docstring 算在它那一块里(中间隔着的空行也是)。 +- 编辑范围是块的完整范围(`blockView.ts` 的 `blockSpans`,和语言无关):从头部开头,到头部、整条语句、各子孙块里最靠后的那个字符。else、elif、catch 这类展平成兄弟块的子句,整条 if / try 按各块的开头切开:每块到下一块开头之前,最后一块到语句结尾(Shell 的 `fi` 归它);`} else {` 写在一行时两块都含这一行。 +- 库规则认领的块(数据表卡片等,见「库规则」):种类的图标、颜色和名字由规则给,内容是规则给的属性表;编辑范围同别的块,是原块整条语句(类就是整个类),「编辑」时整段一起改。规则没并进卡片的子块(方法、别的成员)不管写在类主体哪儿(两个字段中间也算),都照源码顺序是卡片的子块,点它们的「编辑」只改它们自己连同子孙那几行。 +- 同一行不会同时在两处能改:一块在编辑时它的子孙都不画(代码都在它的编辑框里),回到预览就照原样出来;点一块的「编辑」时,编辑范围和它重叠的别的块(祖先、子孙、共用一行的兄弟块)退出编辑。跳到定义的目标块正被盖住时闪盖住它的那块。 - 折叠:函数、类、枚举、异常块和库规则的卡片打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 - 顶部筛选栏:见「筛选」;翻译下拉框(多于一种翻译时才显示)也在栏里。 - 解析失败:显示错误原因,并提示用「编辑」查看源码。 @@ -511,11 +512,11 @@ - 只读,靠三层:`--tools Read,Grep,Glob`(可用的内置工具只剩读、搜,编辑、写文件、跑命令的工具根本没有),`--allowedTools Read,Grep,Glob`(这几个不用再批),`--permission-mode dontAsk`(别的一律拒、不等人批,盖过 profile 里的 bypassPermissions),外加 `--strict-mcp-config` 且不给 `--mcp-config`(不加载任何 MCP 服务,免得 MCP 工具能改东西)。不带 `--dangerously-skip-permissions`。 - 问题从 stdin 发:`-p --input-format stream-json`,写一条用户消息(一行 JSON,同 `cc/spawnClaude.ts` 带图时的 `buildStreamJsonLine`:先是问题的文字,后面按贴的先后跟着各张图片,`{ type: 'image', source: { type: 'base64', media_type, data } }`)后关上 stdin。没贴图也走这一条路(只有文字),问题不上命令行。追问(`--resume`)和会话找不到时重新问都带上这一问的图片。本机 claude 2.1 试过:带图第一次问、接着会话再带一张图追问,都认得出图、记得上一张;约 6 MB 的 base64 图片照样能答。 - 输出 `--output-format stream-json --verbose --include-partial-messages`:init 消息给会话 id;逐字的 `text_delta` 拼成回答;工具调用换成「读取 X」「搜索 Y」;新的一条回复(读完文件后开口)一开口就换掉前面那句(「我先看看 X」);答完以 result 行的最终回答为准,`is_error` 当失败。超时 5 分钟;「停止」给进程发 SIGTERM,这次不存。 -- 第一次问的话:要求用中文、大白话、简洁,只读,提到文件写相对项目根目录的路径(带行号);带上文件的相对路径和语言、外面套着的各层块的开头(每层最多 6 行,带行号,例如类和它里面的函数)、这块代码(从块的第一行到它最后一个子孙的最后一行,带行号,超过 120 行只贴开头并说剩下的在哪几行)、问题。追问只发问题和「还是 路径 第 a–b 行那块代码,代码可能刚改过,需要时重新读文件」。这些都只用视图数据的行号、记号和树形,不认识任何语言。 +- 第一次问的话:要求用中文、大白话、简洁,只读,提到文件写相对项目根目录的路径(带行号);带上文件的相对路径和语言、外面套着的各层块的开头(每层最多 6 行,带行号,例如类和它里面的函数)、这块代码(块的完整范围,同「编辑」,带行号,超过 120 行只贴开头并说剩下的在哪几行)、问题。追问只发问题和「还是 路径 第 a–b 行那块代码,代码可能刚改过,需要时重新读文件」。这些都只用视图数据的行号、记号和树形,不认识任何语言。 块的位置记号(`BlockView.anchor`,`qa/anchor.ts`): -- 扩展端生成视图数据时按源码先后逐块给:有名字的块(`defines` 的来源:函数名、类名、枚举名、单个变量名,库规则卡片取规则给的定义范围)是外层各个有名字的块加自己的「种类 名字」,例如 `class ReferralRelation > function display`;没名字的块(条件、语句、返回、注释、导入、多变量声明……)是外层最近的有名字的块加「种类 @源码哈希」,哈希是这块编辑范围的源码去掉所有空白后的 SHA-1 前 8 位,例如 `function display > if @3fa1c2d0`。同一层里记号相同的(同名的两个函数、一模一样的两行语句)按源码先后在后面补 `[1]`、`[2]`。 +- 扩展端生成视图数据时按源码先后逐块给:有名字的块(`defines` 的来源:函数名、类名、枚举名、单个变量名,库规则卡片取规则给的定义范围)是外层各个有名字的块加自己的「种类 名字」,例如 `class ReferralRelation > function display`;没名字的块(条件、语句、返回、注释、导入、多变量声明……)是外层最近的有名字的块加「种类 @源码哈希」,哈希是这块头部那几行(库规则认领的块是整条语句)去掉所有空白后的 SHA-1 前 8 位,改它的子块不影响它的记号,例如 `function display > if @3fa1c2d0`。同一层里记号相同的(同名的两个函数、一模一样的两行语句)按源码先后在后面补 `[1]`、`[2]`。 - 所以:上面插几行、改缩进空白、改别的函数的名字,记号都不变;有名字的块改了主体、签名还认得;没名字的块自己的源码改了,记号就变了,旧问答成了「找不到原来位置的」(不显示)。种类用视图数据的种类(库规则的卡片是规则的种类 id)。 - webview 只拿记号比相等,不拆开看。 @@ -930,7 +931,7 @@ export interface RuleCard { rows: PropertyRow[] defines?: SourceRange // 块自己的名字(类名):跳到的定义是它时闪整块 attributes?: BlockAttributes // 规则自己的筛选属性,叠在宿主从原块推出的上面;kind 填了不算;名字由 LibraryRule.attributeNames 给 - absorbs: CodeBlock[] // 并进来的子块(原块 children 里的对象本身),不再单独成块;没并的照源码顺序留作子块,编辑范围是原块整条语句 + absorbs: CodeBlock[] // 并进来的子块(原块 children 里的对象本身),不再单独成块;没并的照源码顺序留作子块,编辑范围同别的块,是原块整条语句 } /** 把这块认成契约里现成的一种属性表块(enum 库的类 → 枚举块):宿主当语言插件报的这种块处理,翻译插件排属性表;头部是原块整条语句。 */ @@ -1055,7 +1056,7 @@ export interface CommandRule { - 启用:宿主收集这份源码里各层导入块的 `ImportFacts.sources`(去掉引号),有一个等于规则的某个库、或以「库.」「库/」开头(子模块),规则才对这份源码启用。规则不用自己读导入语句。 - 认领:宿主从顶层往下每块问一遍启用的规则,按注册顺序,第一个认领的算(先注册的优先);被认领的块留下的子块照样问。 - 出错:规则抛错、或替身不合约定(种类 id 没带规则 id 前缀、并进来的不是这块的子块、属性表里的引用标的不是自己 / 规则没给 `resolve` / 引用的段没带下标)时,宿主记一条警告日志(输出面板「Superpowers」),当它没认、接着问下一个规则;都没认这块照常显示。预览不会因为规则出错而失败。 -- 并进来的子块:不再单独成块,由规则挑(`absorbs`),宿主不替规则多并。没并进来的子块(方法、规则不讲的注释)不管写在哪(字段中间也算),都照源码顺序是替身的子块,预览里照常看得见。替身的编辑范围是原块整条语句(从头部开始,到原块连同各子块的最后一行,类就是整个类),盖住留下的子块的行:替身编辑时预览不画这些子块,同一行不会同时在两处能改(见「预览(代码块树)」)。内置规则并成员时连同紧挨在它前面的注释块一起并(讲的是这个成员),讲方法的注释、最后面的注释照常留下。 +- 并进来的子块:不再单独成块,由规则挑(`absorbs`),宿主不替规则多并。没并进来的子块(方法、规则不讲的注释)不管写在哪(字段中间也算),都照源码顺序是替身的子块,预览里照常看得见。替身的编辑范围同别的块,是原块整条语句(从头部开始,到原块连同各子块的最后一行,类就是整个类),盖住留下的子块的行:替身编辑时预览不画这些子块,同一行不会同时在两处能改(见「预览(代码块树)」)。内置规则并成员时连同紧挨在它前面的注释块一起并(讲的是这个成员),讲方法的注释、最后面的注释照常留下。 - 卡片在预览里:种类图标、颜色取 `badge`(`icon`;颜色暗色、亮色主题各取一个),悬停提示是 `badge.label`,内容是 `rows`;筛选的 `kind` 是替身的种类 id,其余属性从原块推出(原来的类是公共的,数据表也是公共的),叠上规则给的(规则自己的属性叫 `LibraryRule.attributeNames` 给的名字,规则起名时抛错记日志、照写 id);筛选栏里这个种类叫 `badge.label`;和类块一样默认收起子块。 ### 规则自己查定义 @@ -1616,8 +1617,9 @@ interface BlockViewBase { id: string // 在树里的位置,例如 '6.1.0' anchor: string // 块的位置记号(见「问 AI」):问答挂在它上面,webview 只比相等 kind: string // BlockKind(库规则认成的现成块也是,如 enum),或库规则卡片的种类 id(sqlalchemy.table) - startLine: number // 编辑范围的起止行,0 起:一般是 header,库规则认领的块是原块整条语句(盖住它留下的子块) + startLine: number // 编辑范围的起止行,0 起:块的完整范围,头部连同各子孙块、结尾(见 blockSpans);库规则认领的块是原块整条语句 endLine: number + headerEndLine: number // 块自己那部分(头部)的最后一行,库规则认领的块是整条语句;问 AI 时贴外层块的开头用,webview 不用 code: string // 编辑范围的源码,去掉共同缩进 codeTokens: Segment[] // 源码着色(拼起来等于 code) defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取(库规则的块取 RuleBlock.defines);跳到的定义是它时闪整块 @@ -1694,7 +1696,7 @@ vscode/src/codeReader/ runtime.ts web-tree-sitter 初始化、语法包加载、解析 blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译);嵌着的代码:解析那几段、分步、换那种语言的翻译插件转子块、着色叠一层(EmbeddedPart) attributes.ts 块的筛选属性(从 facts 推出通用属性 + 语言自己报的)、文件里出现过的属性和块数 - libraryRules.ts 库规则的宿主部分:按导入启用、按注册顺序认领、出错退回、编辑范围和留下的子块、查规则标的引用 + libraryRules.ts 库规则的宿主部分:按导入启用、按注册顺序认领、出错退回、查规则标的引用 workspaceIndex.ts 工作区索引(库规则的 WorkspaceIndex,和宿主自己的中文名索引)的宿主部分:建、缓存、跟着文件改动更新,查到的项带着认的时候给的东西(不碰 VS Code) nameResolution.ts 中文名落到预览里哪些名字上:本文件问各处出现、别的文件先查中文名索引再问定义,按文档版本查一次、没答上再查、跟着改动挪位置(不碰 VS Code) workspaceFiles.ts 工作区索引在 VS Code 里怎么找文件、读文件、盯改动(排除规则) @@ -1775,7 +1777,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit ## 测试 - 语言插件:`envLock.ts`、`issueLock.ts` 的代码块树(种类、header 行号、层级)与原型 `CODE_TREE` 一致,只是紧贴函数的 JSDoc 算进了函数头部;Python 样例覆盖 import / def / docstring / if-elif-else / for / try-except / with / 推导式;函数的说明:JSDoc 各种挂法(export、默认导出、方法隔着装饰器、函数值的变量、嵌套函数)和不挂的情况,docstring 连同它前面的注释进头部。类:`classes.ts`、`classes.py` 覆盖 export + 抽象 + 泛型 + extends / implements + 装饰器 + JSDoc、匿名默认导出、赋给变量 / 类字段的类表达式、函数里的类、类里的类、docstring、metaclass 关键字参数、下划线私有。 -- 块头部:各样例里块的头部行互不重叠;函数、类说明所在的行在函数、类块里,「编辑」写回原样不变。 +- 编辑范围:各样例里每块的编辑范围盖住它的子孙、兄弟块之间最多共用一行、首尾不是空行,「编辑」写回原样不变;父块的编辑框是整块代码(TSX 组件连 JSDoc、函数体、收尾括号),子块只有它自己连同子孙;展平的 else / elif / catch 各到下一块开头之前,Shell 的 `fi` 归最后一块;函数、类说明所在的行在函数、类块的头部里。 - 折叠:默认函数、类、枚举、异常块和库规则的卡片折叠、其余展开;用户的选择盖过默认、重新加载后还在(嵌套子行的也是);各层子行的 id 互不相撞;旧版本存的状态不报错;跳到定义时默认折叠的函数、类也会展开。 - 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则;字典 / 对象字面量的各项:Python 字符串键、名字键、嵌套字典、`**` 展开、空字典、类属性,TypeScript 名字键、字符串键、计算属性名、简写、展开、方法和 getter(头部到主体之前)、嵌套对象、空对象,不是字面量的值没有 entries。 - 字典 / 对象的属性表:项数、键 | 值两列、嵌套一层层展开、「展开」、方法头部、空的一行;键和值里的名字、字符串带下标(嵌套的也是);每项标的定义是键,展开项不标;跳到定义落在对象属性名上只闪那一项、连外层各层一起展开,只是显示着的名字闪整块、展开到它那一层。 @@ -1808,8 +1810,8 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 宿主(`editor/documentMessage.test.ts`):注册了 shell 和 Dockerfile 时 RUN 的各步是子块、按 shell 的种类,筛选栏有 Dockerfile 的种类名,名字连同 shell 里的一起交出。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中;库规则按语言过滤、保持注册顺序,注销后不再给。 - 库规则(宿主):导入了库(`from sqlalchemy.orm import …`、`import sqlalchemy as sa`、`import os, sqlalchemy`、函数里的导入)才启用,`sqlalchemy_utils`、相对导入 `.sqlalchemy` 不算;先注册的规则优先;规则抛错、替身不合约定时记下来、接着问下一个、都没认照常显示,document 消息不带 error;编辑范围是整个类、没并进来的子块(夹在中间的注释、两个字段中间的方法)照源码顺序留下,卡片「编辑」写回整个类,能编辑的范围只在块和它的子孙之间重叠;筛选的种类计数、名字取规则的标签;规则自己报的筛选属性名字取规则的 `attributeNames`(翻译插件给了的以它为准,`attributes.test.ts` 的合并、`documentMessage.test.ts` 的「请求方法」),规则起名时抛错记下来、照写 id;`rules/` 下的非测试文件只 import contract。 -- 同一行不在两处能改(webview `editing.test.ts`):卡片编辑时盖住它留下的子块(字段中间的方法连同主体),跳到被盖住的块改闪卡片;只编辑头部的块不盖住子块,一行写完的块盖住同一行的子块;点「编辑」时重叠的块退出编辑;任意点法下正在编辑、画出来的块两两不重叠。 -- SQLAlchemy 规则:luffy `referral/models.py` 的节选(`fixtures/sqlalchemyModels.py`)里 `ReferralRelation` 的属性表逐字同上面的样例,其余模型覆盖 `Mapped[bool]`、`int | None` 可空、`default=None`、`server_default`、别名的 `PgUUID`、`_enum` 辅助函数、关系、唯一约束、删除时置空 / 级联删除;枚举类、函数照常显示;卡片的编辑范围是整个类、每行只在一块里;两个字段中间写着的方法照常是子块、卡片「编辑」写回整个类、字段前面的注释并进卡片、讲方法的注释留下;类名、字段名的定义范围和带下标的词都指回源码原文;webview 里跳到类名闪整块、跳到字段名只闪字段表里那一行并先展开「字段」那一行,卡片默认折叠;`import sqlalchemy as sa` 的老写法 `Column`、类型全名、只写注解的字段、不认得的类型照写法、docstring 当说明、方法留作子块。 +- 同一行不在两处能改(webview `editing.test.ts`):块编辑时它的子孙都不画(卡片留下的方法连同主体、类的方法),跳到被盖住的块改闪它;子块编辑时只盖住自己的子孙;点「编辑」时重叠的块(祖先、子孙、`} else {` 共用一行的兄弟块)退出编辑;任意点法下正在编辑、画出来的块两两不重叠。 +- SQLAlchemy 规则:luffy `referral/models.py` 的节选(`fixtures/sqlalchemyModels.py`)里 `ReferralRelation` 的属性表逐字同上面的样例,其余模型覆盖 `Mapped[bool]`、`int | None` 可空、`default=None`、`server_default`、别名的 `PgUUID`、`_enum` 辅助函数、关系、唯一约束、删除时置空 / 级联删除;枚举类、函数照常显示;卡片的编辑范围是整个类、同一行只在块和它的子孙之间共用;两个字段中间写着的方法照常是子块、卡片「编辑」写回整个类、字段前面的注释并进卡片、讲方法的注释留下;类名、字段名的定义范围和带下标的词都指回源码原文;webview 里跳到类名闪整块、跳到字段名只闪字段表里那一行并先展开「字段」那一行,卡片默认折叠;`import sqlalchemy as sa` 的老写法 `Column`、类型全名、只写注解的字段、不认得的类型照写法、docstring 当说明、方法留作子块。 - 库规则的引用:引用标的不是自己、规则没给 `resolve`、段没带下标时当替身不合约定;查引用交给标它的规则,规则不在、没给 `resolve`、出错时记下来、当作没有定义;视图数据里的引用(下标 → 引用)只从规则认领的块的属性表里取,查定义时是引用的问规则、其余问语言扩展,只对记下它的那个文档版本有效(`editor/definitionSources.test.ts`)。 - 工作区索引(内存里的工作区,`MemoryIndexHost`):第一次查才建、同一个 id 只建一份;查到的是名字的行列(列按 UTF-16)、照文件和行排好;预筛不过的不解析;改了只重看那一份、新建加进来、删目录连里面的去掉;新建 / 删掉问过在不在的文件(`__init__.py`)时问过它的文件重看;建索引期间的改动建好后补上;读不了、规则出错的记下来、别的照常;dispose 后不再盯着。还要跳过的文件(`workspaceScope.test.ts`,内存里的文件夹):子目录里 `.git` 是文件的 git worktree、`.git` 是目录的子仓库整个跳过,工作区自己的 `.git` 不算;`.gitignore` 忽略的目录跳过,每层规则相对那一层、取反照 gitignore 写法,`.ignore` 也读;`search.useIgnoreFiles` 关着时不读忽略文件、嵌套的 checkout 照样跳过;同一个目录只查一次。 - Celery 规则:`fixtures/celeryApp.py` 的应用卡片和定时任务卡片逐字同上面的样例(列名、「12 条,另有展开 1 处」、行尾注释、条目里单独成行的注释、跨行的 `10⏎ * 60`、`每 get_settings().… 秒`、展开一行);任务名是引用、下标指回源码原文;应用卡片的位置参数按参数顺序认、别的参数各一行、展开、属性 docstring、别名;没导入 celery、不是 `Celery(...)` 的不认;频率换算(秒数算式、`timedelta`、`crontab` 各种写法、`schedule`、换算不了的「每 X 秒」和照写法);说明取哪些注释;条目名和任务名不同、没写任务名、任务名不是字符串、值不是字典、选项一列有才有;`conf.update(beat_schedule=…)`、不带 `beat_schedule` 的 `conf.update` 和 `autodiscover_tasks` 照常。任务名索引:显式 `name=`、默认「模块路径.函数名」(包、`src` 下的命名空间包、没有包)、`@shared_task` / `@shared_task()` / `@app.task` 不带括号 / 老写法 `@task`、`async def`、函数里的只认显式名字、`name=` 不是字符串的不认、同名几处都给、文件改了和新建 `__init__.py` 后跟着变、没有任务装饰器的文件不解析。 diff --git a/vscode/src/codeReader/attributes.test.ts b/vscode/src/codeReader/attributes.test.ts index 0f40bb6..0567ef6 100644 --- a/vscode/src/codeReader/attributes.test.ts +++ b/vscode/src/codeReader/attributes.test.ts @@ -108,7 +108,7 @@ describe('块的筛选属性:宿主从 kind 和 facts 推出通用属性', () }) function view(id: string, attributes: Record, children: BlockView[] = []): BlockView { - return { id, anchor: id, kind: 'statement', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes, children, translation: [] } + return { id, anchor: id, kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes, children, translation: [] } } describe('文件里出现过的属性', () => { diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index 616e356..ca08729 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -3,6 +3,7 @@ import type { LanguageAdapter, Translator } from './contract' import { describe, expect, it } from 'vitest' import { readBlockViews, readDefinitionKind, toBlockViews } from './blockView' import { replaceLines } from './editText' +import { createBashAdapter } from './languages/bash' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' import { previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport' @@ -11,6 +12,7 @@ import { typescriptZhTranslator } from './translators/typescriptZh' const ts = createTypeScriptAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR) +const bash = createBashAdapter(WASM_DIR) function views(adapter: LanguageAdapter, languageId: string, translator: Translator | undefined, source: string): Promise { return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator)) @@ -46,7 +48,7 @@ function translationText(view: BlockView): string { } describe('blockView', () => { - it.each(SAMPLES)('%#: codeTokens 拼起来等于 code,code 是 header 整行去共同缩进', async (adapter, languageId, translator, fixture) => { + it.each(SAMPLES)('%#: codeTokens 拼起来等于 code,code 是编辑范围整行去共同缩进', async (adapter, languageId, translator, fixture) => { const source = readFixture(fixture) const lines = source.split('\n') for (const view of flatten(await views(adapter, languageId, translator, source))) { @@ -68,20 +70,76 @@ describe('blockView', () => { } }) - it.each(SAMPLES)('%#: 各块头部的行互不重叠,每行源码最多归一块编辑', async (adapter, languageId, translator, fixture) => { - const owner = new Map() - for (const view of flatten(await views(adapter, languageId, translator, readFixture(fixture)))) { - for (let line = view.startLine; line <= view.endLine; line++) { - expect(owner.get(line), `第 ${line} 行又归 ${view.id}`).toBeUndefined() - owner.set(line, view.id) - } + it.each(SAMPLES)('%#: 编辑范围是块的完整范围:盖住各子孙块,兄弟块之间最多共用一行,首尾不是空行,原样写回不变;头部是开头那几行', async (adapter, languageId, translator, fixture) => { + const source = readFixture(fixture) + const lines = source.split('\n') + const visit = (list: BlockView[], parent: BlockView | undefined): void => { + list.forEach((view, i) => { + expect(lines[view.startLine].trim(), view.id).not.toBe('') + expect(lines[view.endLine].trim(), view.id).not.toBe('') + expect(view.headerEndLine, view.id).toBeGreaterThanOrEqual(view.startLine) + expect(view.headerEndLine, view.id).toBeLessThanOrEqual(view.endLine) + if (parent) { + expect(view.startLine, `${parent.id} 盖住 ${view.id}`).toBeGreaterThanOrEqual(parent.startLine) + expect(view.endLine, `${parent.id} 盖住 ${view.id}`).toBeLessThanOrEqual(parent.endLine) + } + const next = list[i + 1] + if (next) + expect(view.endLine, `${view.id} / ${next.id}`).toBeLessThanOrEqual(next.startLine) + expect(replaceLines(source, view.startLine, view.endLine, view.code), view.id).toBe(source) + visit(view.children, view) + }) } + visit(await views(adapter, languageId, translator, source), undefined) + }) + + it('编辑父块时编辑框里是整块代码:头部、各子块、结尾都在,改完整块写回;子块的编辑框只有它自己连同它的子块', async () => { + const source = [ + '/** 窗口顶部 40px.', + '侧栏开关只在侧栏顶部. */', + 'export const DesktopTitlebar = ({ platform }: { platform: DesktopShell["platform"] }) => {', + ' if (platform === "macos") {', + ' return null', + ' }', + ' return
', + '}', + '', + ].join('\n') + const list = await views(ts, 'typescriptreact', typescriptZhTranslator, source) + const bar = byId(list, '0') + expect(bar).toMatchObject({ kind: 'function', startLine: 0, headerEndLine: 2, endLine: 7, code: source.trimEnd() }) + expect(byId(list, '0.0')).toMatchObject({ kind: 'if', startLine: 3, headerEndLine: 3, endLine: 5, code: 'if (platform === "macos") {\n return null\n}' }) + expect(byId(list, '0.0.0')).toMatchObject({ kind: 'return', startLine: 4, endLine: 4, code: 'return null' }) + expect(replaceLines(source, bar.startLine, bar.endLine, bar.code.replace('return null', 'return '))).toBe(source.replace('return null', 'return ')) + }) + + it('展平成兄弟块的 else、elif、catch:各到下一块开头之前,最后一块到整条语句结尾(Shell 的 fi 归它);同一行写的 `} else {` 两块都有', async () => { + const code = async (adapter: LanguageAdapter, languageId: string, source: string): Promise<[string, number, number, string][]> => + (await views(adapter, languageId, undefined, source)).map(view => [view.kind, view.startLine, view.endLine, view.code]) + + expect(await code(ts, 'typescript', 'if (a) {\n x()\n} else if (b) {\n y()\n}\nelse {\n z()\n}\ntry {\n x()\n}\ncatch {\n y()\n}\n')).toEqual([ + ['if', 0, 2, 'if (a) {\n x()\n} else if (b) {'], + ['else', 2, 4, '} else if (b) {\n y()\n}'], + ['else', 5, 7, 'else {\n z()\n}'], + ['try', 8, 10, 'try {\n x()\n}'], + ['catch', 11, 13, 'catch {\n y()\n}'], + ]) + expect(await code(py, 'python', 'if a:\n x()\nelif b:\n y()\nelse:\n z()\n')).toEqual([ + ['if', 0, 1, 'if a:\n x()'], + ['else', 2, 3, 'elif b:\n y()'], + ['else', 4, 5, 'else:\n z()'], + ]) + expect(await code(bash, 'shellscript', 'if [ -n "$a" ]; then\n x\nelif [ -n "$b" ]; then\n y\nelse\n z\nfi\n')).toEqual([ + ['if', 0, 1, 'if [ -n "$a" ]; then\n x'], + ['else', 2, 3, 'elif [ -n "$b" ]; then\n y'], + ['else', 4, 6, 'else\n z\nfi'], + ]) }) it('函数的说明算在函数块的头部里:编辑时和签名一起显示、一起改', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) - expect(byId(env, '5')).toMatchObject({ kind: 'function', startLine: 17, endLine: 22 }) - expect(byId(env, '5').code.split('\n')).toEqual([ + expect(byId(env, '5')).toMatchObject({ kind: 'function', startLine: 17, headerEndLine: 22, endLine: 29 }) + expect(byId(env, '5').code.split('\n').slice(0, 6)).toEqual([ '/**', ' * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`', ' * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute', @@ -93,8 +151,8 @@ describe('blockView', () => { const routes = await views(py, 'python', pythonZhTranslator, readFixture('routes.py')) const getUser = flatten(routes).find(view => view.kind === 'function' && view.startLine === 28) - expect(getUser).toMatchObject({ startLine: 28, endLine: 37 }) - expect(getUser?.code.split('\n').slice(-5)).toEqual([ + expect(getUser).toMatchObject({ startLine: 28, headerEndLine: 37 }) + expect(getUser?.code.split('\n').slice(5, 10)).toEqual([ ') -> UserDetailResponse:', ' """获取用户详情。', '', @@ -106,8 +164,8 @@ describe('blockView', () => { it('类的说明同样算在类块的头部里:JSDoc 连着中间的装饰器,docstring 连着它前面的注释', async () => { const repo = byId(await views(ts, 'typescript', typescriptZhTranslator, readFixture('classes.ts.txt')), '1') - expect(repo).toMatchObject({ kind: 'class', startLine: 2, endLine: 8 }) - expect(repo.code.split('\n').slice(-3)).toEqual([ + expect(repo).toMatchObject({ kind: 'class', startLine: 2, headerEndLine: 8 }) + expect(repo.code.split('\n').slice(4, 7)).toEqual([ ' */', '@Injectable()', 'export abstract class Repository extends Base implements Loader, Disposable {', @@ -115,8 +173,8 @@ describe('blockView', () => { expect('properties' in repo && repo.properties[1]).toEqual({ label: '说明', value: { markdown: '仓库基类。\n\n子类实现 `load`。' } }) const notFound = byId(await views(py, 'python', pythonZhTranslator, readFixture('classes.py')), '3') - expect(notFound).toMatchObject({ kind: 'class', startLine: 17, endLine: 19 }) - expect(notFound.code.split('\n')).toEqual([ + expect(notFound).toMatchObject({ kind: 'class', startLine: 17, headerEndLine: 19 }) + expect(notFound.code.split('\n').slice(0, 3)).toEqual([ 'class NotFound(AppResponse): # 行尾', ' # 说明前面的注释', ' """找不到资源。"""', @@ -157,7 +215,7 @@ describe('blockView', () => { '值: "uq_case_library_deployment"', ], }, - { id: '1', kind: 'class', lines: [4, 4], rows: ['名称: CaseStatus', '访问级别: 公共'] }, + { id: '1', kind: 'class', lines: [4, 10], rows: ['名称: CaseStatus', '访问级别: 公共'] }, { id: '1.0', kind: 'variable', lines: [5, 7], rows: ['名称: ANALYZING', '说明: 正在分析源 deployment.', '访问级别: 公共', '初始值: "analyzing"'] }, { id: '1.1', kind: 'field', lines: [8, 10], translation: 'title: 字符串 = ""', doc: { markdown: '案例标题,\n用户可编辑.' } }, ]) @@ -179,8 +237,13 @@ describe('blockView', () => { it('id 是块在树里的位置,行号 0 起,多行块去掉共同缩进', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) expect(byId(env, '5.1.0')).toMatchObject({ kind: 'return', startLine: 25, endLine: 25, code: 'return []' }) - expect(byId(env, '5.1')).toMatchObject({ kind: 'if', startLine: 24, code: 'if (!folder)' }) - expect(byId(env, '7.2.1')).toMatchObject({ kind: 'catch', startLine: 45, code: 'catch (err) {' }) + expect(byId(env, '5.1')).toMatchObject({ kind: 'if', startLine: 24, endLine: 25, code: 'if (!folder)\n return []' }) + expect(byId(env, '7.2.1')).toMatchObject({ + kind: 'catch', + startLine: 45, + endLine: 47, + code: 'catch (err) {\n result.failed.push({ path: f, reason: err instanceof Error ? err.message : String(err) })\n}', + }) const issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) expect(byId(issue, '2.4')).toMatchObject({ @@ -223,7 +286,8 @@ describe('blockView', () => { const signature = byId(sample, '5').codeTokens expect(signature).toContainEqual({ text: 'def', role: 'storage' }) expect(signature).toContainEqual({ text: 'find_env_files', role: 'function' }) - expect(signature.filter(token => token.text === 'str')).toEqual([{ text: 'str', role: 'type' }, { text: 'str', role: 'type' }]) + // 签名里注解的 str 是类型,函数体里调用的 str(err) 是函数 + expect(signature.filter(token => token.text === 'str')).toEqual([{ text: 'str', role: 'type' }, { text: 'str', role: 'type' }, { text: 'str', role: 'function' }]) }) it('块自己的名字(defines):函数名、类名(赋给变量的类表达式取变量名)、只声明一个变量时的变量名(解构模式整个算);导入、一条语句几个变量、一行译文的块、匿名类没有', async () => { @@ -239,7 +303,7 @@ describe('blockView', () => { const source = 'function f() {\r\n\tif (a &&\r\n\t\tb) {\r\n\t\treturn 1\r\n\t}\r\n}\r\n' const list = await views(ts, 'typescript', undefined, source) const cond = byId(list, '0.0') - expect(cond).toMatchObject({ kind: 'if', startLine: 1, endLine: 2, code: 'if (a &&\n\tb) {', translation: [] }) + expect(cond).toMatchObject({ kind: 'if', startLine: 1, headerEndLine: 2, endLine: 4, code: 'if (a &&\n\tb) {\n\treturn 1\n}', translation: [] }) expect(cond.codeTokens.map(token => token.text).join('')).toBe(cond.code) expect(byId(list, '0')).toMatchObject({ kind: 'function', properties: [] }) }) diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index a671284..7c25190 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,9 +1,9 @@ import type { Tree } from '@vscode/tree-sitter-wasm' -import type { BlockAttributes, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, StepBlocks, TextBlock, TokenRole, Translator } from './contract' +import type { BlockAttributes, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import { blockAttributes } from './attributes' import { commonIndent, splitLines } from './editText' -import { activeRules, claimBlock, claimedShape, importsIn } from './libraryRules' +import { activeRules, claimBlock, importsIn } from './libraryRules' import { BlockAnchors } from './qa/anchor' interface BlockViewBase { @@ -14,11 +14,13 @@ interface BlockViewBase { /** BlockKind,或库规则卡片的种类 id(RuleCard.kind,带点,例如 sqlalchemy.table)。 */ kind: string /** - * 编辑范围的起止行,0 起:一般是 header;库规则认领的块是原块整条语句(见 claimedShape),盖住它留下的子块的行。 - * 同一时刻能编辑的几段不重叠:块编辑时,webview 不画编辑范围和它重叠的子孙块,点「编辑」时和它重叠的别的块退出编辑。 + * 编辑范围的起止行,0 起:块在源码里的完整范围,头部连同各子孙块、结尾(见 blockSpans);库规则认领的块是原块整条语句。 + * 块编辑时它的子孙都在编辑框里,webview 不画它们;点「编辑」时和它重叠的别的块退出编辑,同一行不会同时在两处能改。 */ startLine: number endLine: number + /** 块自己那部分(头部)的最后一行:复合语句到主体开始之前,库规则认领的块是整条语句。问 AI 时贴外层块的开头用。 */ + headerEndLine: number /** 编辑范围的整行源码,去掉共同缩进。 */ code: string /** 源码着色(拼起来等于 code)。 */ @@ -70,22 +72,29 @@ interface ColorLayer { tokenRole: (leaf: Node) => TokenRole } -/** 按语法树叶子给 [startRow, endRow] 这几行着色:每个有词性的叶子一段,空白、标点等 plain 的连成一段;后面的层盖过前面的。 */ -function colorLines(layers: readonly ColorLayer[], source: string, lines: Line[], startRow: number, endRow: number, indent: number): Segment[] { - const from = lines[startRow].start - const to = lines[endRow].end - const owner = new Int32Array(to - from).fill(-1) +/** 源码每个字符归哪个叶子(-1 是不归哪个叶子的空白),和各叶子的词性:一份源码算一次,各块按行取。 */ +interface Coloring { + owner: Int32Array + roles: TokenRole[] +} + +/** 各着色层依次铺上去,后面的层(嵌着的代码)盖住前面的。 */ +function coloring(layers: readonly ColorLayer[], source: string): Coloring { + const owner = new Int32Array(source.length).fill(-1) const roles: TokenRole[] = [] for (const { root, tokenRole } of layers) { const leaves: Node[] = [] - leavesIn(root.descendantForIndex(from, to) ?? root, from, to, leaves) + leavesIn(root, 0, source.length, leaves) for (const leaf of leaves) { roles.push(tokenRole(leaf)) - for (let at = Math.max(leaf.startIndex, from); at < Math.min(leaf.endIndex, to); at++) - owner[at - from] = roles.length - 1 + owner.fill(roles.length - 1, leaf.startIndex, Math.min(leaf.endIndex, source.length)) } } + return { owner, roles } +} +/** 按语法树叶子给 [startRow, endRow] 这几行着色:每个有词性的叶子一段,空白、标点等 plain 的连成一段。 */ +function colorLines({ owner, roles }: Coloring, source: string, lines: Line[], startRow: number, endRow: number, indent: number): Segment[] { const segments: Segment[] = [] let current = -2 const put = (char: string, who: number): void => { @@ -100,9 +109,9 @@ function colorLines(layers: readonly ColorLayer[], source: string, lines: Line[] for (let row = startRow; row <= endRow; row++) { const line = lines[row] if (row > startRow) - put('\n', owner[line.start - 1 - from]) + put('\n', owner[line.start - 1]) for (let at = line.start + Math.min(indent, line.end - line.start); at < line.end; at++) - put(source[at], owner[at - from]) + put(source[at], owner[at]) } return segments } @@ -170,8 +179,50 @@ export interface RuleRun { const NO_RULES: RuleRun = { rules: [], report: () => {} } +/** 源码里一段的结尾:到哪个字符为止(不含),那个字符在第几行。 */ +type SpanEnd = Pick + +function nodeEnd(node: Node): SpanEnd { + return { endIndex: node.endIndex, endRow: node.endPosition.row } +} + +function later(a: SpanEnd, b: SpanEnd): SpanEnd { + return b.endIndex > a.endIndex ? b : a +} + +/** 各子孙块的头部、节点里最靠后的结尾,和 end 比取靠后的:子孙一般写在块的节点里,Dockerfile 阶段的指令写在 FROM 后面。 */ +function descendantsEnd(blocks: readonly CodeBlock[], end: SpanEnd): SpanEnd { + return blocks.reduce((acc, child) => descendantsEnd(child.children, later(later(acc, child.header), nodeEnd(child.node))), end) +} + +/** index 之前最后一个非空白字符的结尾,不早于头部开头。 */ +function lastCharBefore(source: string, header: SourceSpan, index: number): SpanEnd { + let end = index + while (end > header.startIndex && /\s/.test(source[end - 1])) + end-- + return { endIndex: end, endRow: header.startRow + splitLines(source.slice(header.startIndex, end)).length - 1 } +} + /** - * 代码块树 → 视图数据:header 整行源码去共同缩进、按语法树着色、调用翻译插件。 + * 一串兄弟块各自在源码里的完整范围:从头部开头,到头部、所在语句、各子孙块里最靠后的那个字符。 + * 语言插件把 else、catch 这类子句展平成紧跟着的兄弟块,它们的节点都落在打头那块的节点(整条 if / try)里: + * 这条语句按各块的开头切开,每块到下一块开头之前最后一个非空白字符为止,最后一块到语句结尾(Shell 的 fi 归它)。 + */ +function blockSpans(list: readonly CodeBlock[], source: string): SourceSpan[] { + let statement: Node | undefined + return list.map((block, i) => { + if (!statement || block.node.startIndex < statement.startIndex || block.node.endIndex > statement.endIndex) + statement = block.node + const next = list[i + 1] + const nextStart = next && Math.min(next.header.startIndex, next.node.startIndex) + const until = nextStart !== undefined && nextStart < statement.endIndex ? lastCharBefore(source, block.header, nextStart) : nodeEnd(statement) + const { endIndex, endRow } = descendantsEnd(block.children, later(block.header, until)) + return { startIndex: block.header.startIndex, endIndex, startRow: block.header.startRow, endRow } + }) +} + +/** + * 代码块树 → 视图数据:块的完整范围整行源码去共同缩进、按语法树着色,调用翻译插件。 * 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock):规则的卡片,或认成的属性表块(交给翻译插件排,同语言插件报的这种块), * 并进去的子块不再单独成块。 */ @@ -186,12 +237,12 @@ export function toBlockViews( const lines = lineRanges(source) const texts = splitLines(source) const outer = blocks[0]?.node.tree.rootNode - const colors: ColorLayer[] = [ + const colors = coloring([ ...(outer ? [{ root: outer, tokenRole: document.language.tokenRole }] : []), ...[...embedded.values()].map(part => ({ root: part.root, tokenRole: part.document.language.tokenRole })), - ] - /** startRow..endRow 这几行整行源码去共同缩进,和它的着色(嵌着的代码按它那种语言着色)。 */ - const sourceRows = (startRow: number, endRow: number): Pick => { + ], source) + /** 一段占的整行源码去共同缩进,和它的着色(嵌着的代码按它那种语言着色)。 */ + const sourceRows = ({ startRow, endRow }: SourceSpan): Pick => { const rows = texts.slice(startRow, endRow + 1) const indent = commonIndent(rows).length return { @@ -201,11 +252,16 @@ export function toBlockViews( codeTokens: colorLines(colors, source, lines, startRow, endRow, indent), } } + /** 一段占的整行源码:没名字的块按它认(见 BlockAnchors.place)。 */ + const rowsText = ({ startRow, endRow }: SourceSpan): string => texts.slice(startRow, endRow + 1).join('\n') /** 按源码先后逐块给位置记号:先序遍历,块的记号在它的子块之前要 */ const anchors = new BlockAnchors() - /** 一种语言的块怎么转:用这种语言的源码、翻译插件和库规则(嵌着的代码没有库规则)。 */ - function layer(layerBlocks: readonly CodeBlock[], layerDocument: SourceDocument, layerTranslator: Translator | undefined, layerRules: RuleRun): (block: CodeBlock, id: string, scope: string) => BlockView { + /** + * 一种语言的块怎么转:用这种语言的源码、翻译插件和库规则(嵌着的代码没有库规则)。 + * 转的是一串兄弟块:各块的完整范围(编辑范围)按这一串算(见 blockSpans),id 是 prefix 接上它在这一串里的位置(从 from 数起)。 + */ + function layer(layerBlocks: readonly CodeBlock[], layerDocument: SourceDocument, layerTranslator: Translator | undefined, layerRules: RuleRun): (list: readonly CodeBlock[], prefix: string, scope: string, from?: number) => BlockView[] { const imports = importsIn(layerBlocks) const active = activeRules(layerRules.rules, imports) const context: RuleContext = { @@ -231,58 +287,63 @@ export function toBlockViews( return [] const steps = part.steps.blocks const [only] = steps - const kids = steps.length === 1 && only ? only.children : steps - const build = layer(steps, part.document, part.translator, NO_RULES) - return kids.map((child, i) => build(child, `${id}.${from + i}`, scope)) + return layer(steps, part.document, part.translator, NO_RULES)(steps.length === 1 && only ? only.children : steps, `${id}.`, scope, from) } - /** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):头部是编辑范围,子块照常交给各规则看。scope 见 BlockAnchors.place */ - function plain(block: CodeBlock, id: string, scope: string): BlockView { + /** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):编辑范围是 span,头部是块自己那部分,子块照常交给各规则看。scope 见 BlockAnchors.place */ + function plain(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView { const name = ownName(block) - const rows = sourceRows(block.header.startRow, block.header.endRow) - const { anchor, inner } = anchors.place(scope, block.kind, name?.text, rows.code) + const { anchor, inner } = anchors.place(scope, block.kind, name?.text, rowsText(block.header)) return { id, anchor, kind: block.kind, - ...rows, + ...sourceRows(span), + headerEndLine: block.header.endRow, ...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }), attributes: blockAttributes(block), children: [ - ...block.children.map((child, i) => build(child, `${id}.${i}`, inner)), + ...list(block.children, `${id}.`, inner), ...embeddedChildren(block, id, inner, block.children.length), ], ...('facts' in block ? { properties: layerTranslator ? layerTranslator.describe(block, layerDocument) : [] } : textView(block)), } } - function build(block: CodeBlock, id: string, scope: string): BlockView { + /** + * 块先问启用的库规则,没认领就照它自己的样子。认领了的:规则讲的是整条语句,头部就是整个范围; + * 规则没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序留作它的子块。 + */ + function build(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView { const claim = active.length > 0 ? claimBlock(block, active, context, layerRules.report) : undefined if (!claim) - return plain(block, id, scope) - const { span, kept } = claimedShape(block, claim.block.absorbs) + return plain(block, span, id, scope) + const kept = block.children.filter(child => !claim.block.absorbs.includes(child)) if ('as' in claim.block) { const { attributes } = block - return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, id, scope) + return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope) } const { kind, badge, rows, defines, attributes } = claim.block - const lines = sourceRows(span.startRow, span.endRow) - const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), lines.code) + const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(span)) return { id, anchor, kind, - ...lines, + ...sourceRows(span), + headerEndLine: span.endRow, ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), attributes: { ...blockAttributes(block), ...attributes, kind }, badge, - children: kept.map((child, i) => build(child, `${id}.${i}`, inner)), + children: list(kept, `${id}.`, inner), properties: rows, } } - return build + function list(siblings: readonly CodeBlock[], prefix: string, scope: string, from = 0): BlockView[] { + const spans = blockSpans(siblings, source) + return siblings.map((block, i) => build(block, spans[i], `${prefix}${from + i}`, scope)) + } + return list } - const build = layer(blocks, document, translator, rules) - return blocks.map((block, i) => build(block, String(i), '')) + return layer(blocks, document, translator, rules)(blocks, '', '') } /** diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 9bd03b1..8d6819c 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -459,7 +459,7 @@ export interface BlockShape { /** 这一块对应的语法树节点(整条语句)。 */ node: Node /** - * 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。 + * 块自己那一段(预览讲的就是它):复合语句只到主体开始之前,其余是整条语句。编辑时宿主给的是整块:头部连同各子块、结尾。 * 块自己的说明(facts.doc、TextBlock.doc)也算在里面:JSDoc 往上延到注释开头,docstring、属性 docstring 往下延到字符串结尾。 */ header: SourceSpan @@ -788,7 +788,7 @@ export interface RuleCard { /** * 并进这一块的子块(原块 children 里的对象本身):不再单独成块,内容由 rows 讲。 * 没并进来的子块(方法、别的成员、规则不讲的注释)不管写在哪,都照源码顺序照常是这一块的子块,也照常交给各规则看。 - * 这一块的编辑范围是原块整条语句(类就是整个类),盖住留下的子块:它编辑时预览不画这些子块,同一行不会同时在两处能改。 + * 这一块的编辑范围同别的块,是原块整条语句(类就是整个类),盖住留下的子块:它编辑时预览不画这些子块,同一行不会同时在两处能改。 */ absorbs: CodeBlock[] } diff --git a/vscode/src/codeReader/libraryRules.test.ts b/vscode/src/codeReader/libraryRules.test.ts index 87211c7..d56012d 100644 --- a/vscode/src/codeReader/libraryRules.test.ts +++ b/vscode/src/codeReader/libraryRules.test.ts @@ -131,7 +131,7 @@ describe('库规则:认领', () => { ? { kind: 'orm.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows: [], absorbs: block.children.filter(child => child.kind === 'variable') } : undefined } const [, card] = await views(source, [fields]) - expect(card.children.map(child => [child.kind, child.startLine, child.endLine])).toEqual([['function', 5, 5]]) + expect(card.children.map(child => [child.kind, child.startLine, child.endLine])).toEqual([['function', 5, 6]]) expect([card.startLine, card.endLine]).toEqual([2, 8]) expect(replaceLines(source, card.startLine, card.endLine, card.code)).toBe(source) const edited = replaceLines(source, card.startLine, card.endLine, card.code.replace('greet', 'hello').replace('String', 'Text')) @@ -159,7 +159,7 @@ describe('库规则:认领', () => { expect(reports[1]).toBe('库规则 throwing 看第 3 行的 class 块时出错,这块照常显示:坏了') const alone = await views(MODEL, [throwing]) - expect(alone[1]).toMatchObject({ kind: 'class', startLine: 2, endLine: 2 }) + expect(alone[1]).toMatchObject({ kind: 'class', startLine: 2, headerEndLine: 2, endLine: 8 }) expect(alone[1].children.map(child => child.kind)).toEqual(['variable', 'comment', 'variable', 'function']) }) diff --git a/vscode/src/codeReader/libraryRules.ts b/vscode/src/codeReader/libraryRules.ts index 24dfece..fdf5894 100644 --- a/vscode/src/codeReader/libraryRules.ts +++ b/vscode/src/codeReader/libraryRules.ts @@ -1,6 +1,6 @@ -import type { AttributeNames, CodeBlock, ImportFacts, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, RuleLocation, RuleReference, RuleWorkspace, Segment, SourceSpan } from './contract' +import type { AttributeNames, CodeBlock, ImportFacts, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, RuleLocation, RuleReference, RuleWorkspace, Segment } from './contract' -/** 宿主这边怎么用库规则:什么时候启用、问哪个规则、认领了之后编辑哪一段、留下哪些子块。规则本身只经 contract 拿信息。 */ +/** 宿主这边怎么用库规则:什么时候启用、问哪个规则、规则出错时怎么记。规则本身只经 contract 拿信息。 */ /** 这份源码里各处导入的 facts,照源码顺序(先父块、再子块)。 */ export function importsIn(blocks: readonly CodeBlock[]): ImportFacts[] { @@ -97,25 +97,6 @@ export function ruleAttributeNames(rules: readonly LibraryRule[], displayLanguag }) } -/** 源码里一个节点占的那段。 */ -function nodeSpan(node: Node): SourceSpan { - return { startIndex: node.startIndex, endIndex: node.endIndex, startRow: node.startPosition.row, endRow: node.endPosition.row } -} - -/** - * 认领的块的编辑范围和留下的子块:编辑范围是原块整条语句(从头部开始,到它连同各子块的最后一行,类就是整个类),「编辑」时整段一起改; - * 规则没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序留作它的子块。 - * 这些子块的行也在这段里:webview 在这块编辑时不画它们,同一行不会同时在两处能改。 - */ -export function claimedShape(block: CodeBlock, absorbs: readonly CodeBlock[]): { span: SourceSpan, kept: CodeBlock[] } { - const ends = [block.header, nodeSpan(block.node), ...block.children.flatMap(child => [child.header, nodeSpan(child.node)])] - const last = ends.reduce((end, span) => span.endIndex > end.endIndex ? span : end) - return { - span: { ...block.header, endIndex: last.endIndex, endRow: last.endRow }, - kept: block.children.filter(child => !absorbs.includes(child)), - } -} - /** 文字里(连框里)带引用的段。 */ function referencesInText(pieces: readonly Inline[]): Segment[] { return pieces.flatMap(piece => 'role' in piece ? (piece.reference ? [piece] : []) : referencesInText(piece.segments)) diff --git a/vscode/src/codeReader/qa/prompt.ts b/vscode/src/codeReader/qa/prompt.ts index db044bf..753d890 100644 --- a/vscode/src/codeReader/qa/prompt.ts +++ b/vscode/src/codeReader/qa/prompt.ts @@ -32,14 +32,9 @@ export function findBlock(blocks: BlockView[], anchor: string): { block: BlockVi return undefined } -/** 块连同子孙管到的最后一行(0 起):复合语句的头部之外还有主体。 */ -function lastLine(block: BlockView): number { - return Math.max(block.endLine, ...block.children.map(lastLine)) -} - -/** 这块代码占的行(1 起,给人看的写法)。 */ +/** 这块代码占的行(1 起,给人看的写法):块的完整范围,复合语句连同主体。 */ export function blockLines(block: BlockView): { from: number, to: number } { - return { from: block.startLine + 1, to: lastLine(block) + 1 } + return { from: block.startLine + 1, to: block.endLine + 1 } } function numbered(lines: string[], from: number, to: number, width: number): string[] { @@ -53,7 +48,7 @@ export function askPrompt(subject: AskSubject, question: string): string | undef return undefined const { block, outer } = found const lines = splitLines(subject.source) - const last = Math.min(lastLine(block), lines.length - 1) + const last = Math.min(block.endLine, lines.length - 1) const width = String(last + 1).length const shownLast = Math.min(last, block.startLine + BLOCK_LINES - 1) const { from, to } = blockLines(block) @@ -64,8 +59,8 @@ export function askPrompt(subject: AskSubject, question: string): string | undef ] if (outer.length > 0) { const heads = outer.flatMap((item) => { - const end = Math.min(item.endLine, item.startLine + OUTER_LINES - 1) - return [...numbered(lines, item.startLine, end, width), ...(end < item.endLine ? [`${' '.repeat(width)} | …`] : [])] + const end = Math.min(item.headerEndLine, item.startLine + OUTER_LINES - 1) + return [...numbered(lines, item.startLine, end, width), ...(end < item.headerEndLine ? [`${' '.repeat(width)} | …`] : [])] }) parts.push(`这块代码外面套着(各层外层块的开头):\n${heads.join('\n')}`) } diff --git a/vscode/src/codeReader/rules/sqlalchemy.test.ts b/vscode/src/codeReader/rules/sqlalchemy.test.ts index 44bbf35..f9e86ad 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.test.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.test.ts @@ -123,7 +123,7 @@ describe('sqlAlchemy 库规则', () => { expect(view.children, view.id).toEqual([]) }) - it('卡片的编辑范围是整个类,每行只在一块里能编辑', async () => { + it('卡片的编辑范围是整个类,同一行只在块和它的子孙之间共用', async () => { const lines = SNAPSHOT.split('\n') const table = await model(SNAPSHOT, 'ReferralRelation') expect(lines[table.startLine]).toBe('class ReferralRelation(IntPK, TimestampMixin, Base):') @@ -134,7 +134,8 @@ describe('sqlAlchemy 库规则', () => { for (let line = view.startLine; line <= view.endLine; line++) owners.set(line, [...owners.get(line) ?? [], view.id]) } - expect([...owners.values()].filter(ids => ids.length > 1)).toEqual([]) + const nested = (ids: string[]): boolean => ids.every((id, i) => i === 0 || id.startsWith(`${ids[i - 1]}.`)) + expect([...owners.values()].filter(ids => !nested(ids))).toEqual([]) expect(owners.get(lines.indexOf(' __tablename__ = "referral_relations"'))).toEqual([table.id]) }) diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index f54537e..f891c20 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -22,7 +22,7 @@ export interface BlockTreeState { hidden: ReadonlySet /** 本来被筛掉、跳到定义时临时显示的块 */ temporary: ReadonlySet - /** 被正在编辑的祖先盖住、不画的块(它们的行在祖先的编辑范围里),见 coveredByEditing */ + /** 被正在编辑的祖先盖住、不画的块(它们的代码在祖先的编辑框里),见 coveredByEditing */ covered: ReadonlyMap /** 种类叫什么(种类图标的悬停提示和 aria-label):翻译插件给种类起的名字(库规则的种类是规则给的 badge.label),没给就是 id */ kindName: (kind: string) => string diff --git a/vscode/webview-ui/src/codeReader/editing.test.ts b/vscode/webview-ui/src/codeReader/editing.test.ts index 9172991..2dbeb18 100644 --- a/vscode/webview-ui/src/codeReader/editing.test.ts +++ b/vscode/webview-ui/src/codeReader/editing.test.ts @@ -8,43 +8,46 @@ function block(id: string, kind: string, [startLine, endLine]: [number, number], } /** - * 库规则的卡片(1)编辑范围是整个类(2–12 行),两个字段中间写着的方法(1.0)和它的主体(1.0.0)、类末尾的方法(1.1)都在这段里; - * 普通的类(2)只编辑头部,方法(2.0)不在头部里;一行写完的 if(3)和它的返回(3.0)在同一行。 + * 编辑范围是块的完整源码:库规则的卡片(1)是整个类(2–12 行),两个字段中间写着的方法(1.0)和它的主体(1.0.0)、 + * 类末尾的方法(1.1)都在这段里;普通的类(2)同样盖住它的方法(2.0);`} else {` 写在一行时 if(3)和 else(4)共用第 20 行。 */ const BLOCKS = [ block('0', 'import', [0, 0]), block('1', 'sqlalchemy.table', [2, 12], [ - block('1.0', 'function', [5, 5], [block('1.0.0', 'return', [6, 6])]), + block('1.0', 'function', [5, 6], [block('1.0.0', 'return', [6, 6])]), block('1.1', 'function', [11, 12]), ]), - block('2', 'class', [14, 14], [block('2.0', 'function', [15, 16])]), - block('3', 'if', [18, 18], [block('3.0', 'return', [18, 18])]), + block('2', 'class', [14, 16], [block('2.0', 'function', [15, 16])]), + block('3', 'if', [18, 20], [block('3.0', 'return', [19, 19])]), + block('4', 'else', [20, 22], [block('4.0', 'return', [21, 21])]), ] describe('同一行不会同时在两处能改', () => { - it('卡片编辑时,它留下的子块(字段中间的方法连同主体)不画,跳到它们时改闪卡片', () => { + it('块编辑时它的子孙都不画(代码都在它的编辑框里),跳到它们时改闪它', () => { expect(coveredByEditing(BLOCKS, new Set(['1']))).toEqual(new Map([['1.0', '1'], ['1.0.0', '1'], ['1.1', '1']])) + expect(coveredByEditing(BLOCKS, new Set(['2']))).toEqual(new Map([['2.0', '2']])) }) - it('只编辑头部的块不盖住子块;一行写完的块编辑时盖住同一行的子块', () => { - expect(coveredByEditing(BLOCKS, new Set(['2', '2.0']))).toEqual(new Map()) - expect(coveredByEditing(BLOCKS, new Set(['3']))).toEqual(new Map([['3.0', '3']])) + it('子块编辑时只盖住它自己的子孙,父块、兄弟块照常画', () => { + expect(coveredByEditing(BLOCKS, new Set(['1.0']))).toEqual(new Map([['1.0.0', '1.0']])) + expect(coveredByEditing(BLOCKS, new Set(['1.1', '3']))).toEqual(new Map([['3.0', '3']])) }) - it('没在编辑的卡片不盖住子块:子块照常画、照常能单独编辑', () => { - expect(coveredByEditing(BLOCKS, new Set(['1.0']))).toEqual(new Map()) + it('祖先和子孙都记着在编辑时(文档刷新前的状态),算页面上画着的最外层那块', () => { + expect(coveredByEditing(BLOCKS, new Set(['1', '1.0']))).toEqual(new Map([['1.0', '1'], ['1.0.0', '1'], ['1.1', '1']])) }) - it('点「编辑」:编辑范围和它重叠的别的块退出编辑,不重叠的留着', () => { + it('点「编辑」:编辑范围和它重叠的别的块(祖先、子孙、共用一行的兄弟块)退出编辑,不重叠的留着', () => { expect(startEditing(BLOCKS, ['1.0', '1.1', '2'], '1')).toEqual(['2', '1']) expect(startEditing(BLOCKS, ['1', '2'], '1.0')).toEqual(['2', '1.0']) - expect(startEditing(BLOCKS, ['2'], '2.0')).toEqual(['2', '2.0']) + expect(startEditing(BLOCKS, ['2'], '2.0')).toEqual(['2.0']) + expect(startEditing(BLOCKS, ['0', '3'], '4')).toEqual(['0', '4']) }) it('任何时候正在编辑、画出来的几块,编辑范围两两不重叠', () => { const all = (list: BlockView[]): BlockView[] => list.flatMap(item => [item, ...all(item.children)]) let editing: string[] = [] - for (const id of ['1.0', '1.0.0', '2', '2.0', '1', '3.0', '3', '1.1']) + for (const id of ['1.0', '1.0.0', '2', '2.0', '1', '3.0', '3', '4', '1.1']) editing = startEditing(BLOCKS, editing, id) const covered = coveredByEditing(BLOCKS, new Set(editing)) const shown = all(BLOCKS).filter(item => editing.includes(item.id) && !covered.has(item.id)) diff --git a/vscode/webview-ui/src/codeReader/editing.ts b/vscode/webview-ui/src/codeReader/editing.ts index 16176c0..3058d30 100644 --- a/vscode/webview-ui/src/codeReader/editing.ts +++ b/vscode/webview-ui/src/codeReader/editing.ts @@ -2,9 +2,8 @@ import type { BlockView } from './types' /** * 同一时刻能编辑的几段源码不重叠,同一行不会同时在两处能改: - * ① 块编辑时,编辑范围和它重叠的子孙块不画(库规则的卡片编辑整个类,留下的方法也在这段里; - * 一行写完的 `if x: return 1`,子块和头部在同一行),它们下面的子孙也跟着不画; - * ② 点「编辑」时,编辑范围和这块重叠的别的块退出编辑。 + * ① 块的编辑范围是它的完整源码,子孙都在它的编辑框里:块编辑时它的子孙都不画; + * ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑。 */ type Lines = Pick @@ -13,22 +12,24 @@ function overlaps(a: Lines, b: Lines): boolean { return a.startLine <= b.endLine && b.startLine <= a.endLine } -/** 正在编辑的块盖住、不画的块 → 盖住它的那块(页面上画着的、正在编辑的祖先):跳到定义落在被盖住的块上时改闪它。 */ +/** 正在编辑的块的子孙不画 → 盖住它的那块(页面上画着的、正在编辑的祖先):跳到定义落在被盖住的块上时改闪它。 */ export function coveredByEditing(blocks: readonly BlockView[], editing: ReadonlySet): Map { const covered = new Map() - const visit = (list: readonly BlockView[], editors: readonly BlockView[], owner: string | undefined): void => { + const cover = (list: readonly BlockView[], by: string): void => { for (const block of list) { - const by = owner ?? editors.find(editor => overlaps(editor, block))?.id - if (by !== undefined) { - covered.set(block.id, by) - visit(block.children, editors, by) - } - else { - visit(block.children, editing.has(block.id) ? [...editors, block] : editors, undefined) - } + covered.set(block.id, by) + cover(block.children, by) } } - visit(blocks, [], undefined) + const visit = (list: readonly BlockView[]): void => { + for (const block of list) { + if (editing.has(block.id)) + cover(block.children, block.id) + else + visit(block.children) + } + } + visit(blocks) return covered } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 6ac4e25..1111942 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -102,7 +102,7 @@ interface BlockViewBase { anchor: string /** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table);webview 不认识后者,只按 badge 画 */ kind: string - /** 编辑范围的起止行,0 起:一般是 header,库规则认领的块是原块整条语句(盖住它留下的子块,见 coveredByEditing) */ + /** 编辑范围的起止行,0 起:块的完整源码,头部连同各子孙块、结尾(块编辑时子孙都不画,见 coveredByEditing) */ startLine: number endLine: number /** 编辑范围的源码,去掉共同缩进 */