diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 16530d5..ff668af 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -37,7 +37,7 @@ | 问 AI | 块右上角「问 AI」,在块下面问 Claude Code(`claude -p`,只读,用输入框旁边选的 profile,记住上次选的,没选过用插件默认的)这块代码,回答逐字出来按 Markdown 显示、能追问(接着这块最近一次的会话,收起再展开、重启 VS Code 也接得上)、能停;输入框里能粘贴 / 拖进图片随问题发(存在 `.spx/code-qa/`,跟着问答删);问答存进各工作区文件夹的 `.spx/code-qa.json`,灰字挂在块下面免得重复问,理解了就删。问答按块的位置记号挂,记号只由和语言无关的块数据(种类、facts 里的名字、源码哈希)算出,文件别处改了照样认得;认不回来的不显示,留在文件里,代码改回来又挂回去。见「问 AI」 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | | 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | -| 字典 / 对象、列表字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行;跨几行写的列表(元组、集合、数组)同样写「列表 N 项」,各项展开成「第 N 项 \| 值」,整项是调用的排成「新建 / 调用 …」带自己的实参表;项的值又是字典、跨几行的列表时一层层往下展开,实参的值是跨几行的列表时也拆;参数默认值照旧一行 | +| 字典 / 对象、列表字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行;跨几行写的列表(元组、集合、数组)同样写「列表 N 项」,各项展开成「第 N 项 \| 值」,整项是调用的排成「新建 / 调用 …」带自己的实参表;项的值又是字典、跨几行的列表时一层层往下展开。实参的值、一行译文的块(`return [...]`、表达式语句……)里跨几行写的字典、列表同样拆,一行写「列表 N 项」,各项排在下面;参数默认值照旧一行 | | 输入参数 | 排成带表头和边框的表格:名称 \| 类型 \| 默认值,表头写了「默认值」,格子里不再写「默认 」 | | 库规则 | 第四种插件:按库认出一种写法,把那块整个换成专门的卡片(内置 SQLAlchemy 规则:模型类 → 「数据表」卡片;内置 Celery 规则:`Celery(...)` → 「Celery 应用」卡片、`beat_schedule` → 「定时任务」卡片;内置 dataclasses 规则:数据类 → 「数据类」卡片;内置 FastAPI 规则:挂在路由对象上的函数 → 「接口」卡片,请求、响应各一张表),或把那块认成契约里现成的一种属性表块(内置 enum 规则:Python 自带 enum 库的类 → 枚举块)。和语言插件、翻译插件一样写代码注册、只经契约拿信息;文件导入了规则关心的库才启用,由宿主按语言插件给的导入判断;卡片的用词归规则自己,认成的现成块照常由翻译插件排,webview 只画结构。见「库规则」 | | 枚举 | 枚举是一种块(种类「枚举」,紫色 #b180d7 / 亮色 #652d90),属性表:名称 / 说明 / 访问级别 / 装饰器 / 继承(有才显示)/ 成员 N 个,成员排成表格「成员 \| 值 \| 说明」。TypeScript 的 `enum` 是语言自己的写法,由语言插件报;Python 没有枚举语法,自带 enum 库的写法由库规则认成同一种块。两边同一种块、同一张表、同一个筛选种类,webview 只有一种画法 | @@ -189,7 +189,7 @@ - 种类图标和底色按种类区分(颜色见「颜色」);文件开头的说明(种类「注释」)虚线框。 - 「注释」一行:讲这块的普通注释不单独成块,是这张卡片上的一行(见「注释挂到它讲的块上」)。属性表卡片(语言的、翻译插件排的、库规则的卡片都一样,宿主统一插,规则、翻译插件不用管)插在第一行(名称这类讲它是谁的一行)后面,有说明(值是 Markdown 的那一行)时插在说明后面,所以是「名称、说明、注释、别的」;一行译文的卡片(条件、循环、语句、调用、返回……)排在译文、摊开实参的表、说明的下面,同属性表的一行(左边「注释」、右边 Markdown)。名字是翻译插件给种类 comment 起的名字(中文直译「注释」)。几段注释(中间隔着空行、或前后两处)各是一段 Markdown,照源码顺序,空行隔开。按「注释的 Markdown 排版」显示(仓库路径链接、不带路径的引用、仓库图片都照常能点),标差异时同属性表的行逐词标。这一行上不放按钮,「按注释规范重写」排在卡片右上角那排按钮里(见「重写注释」)。 - 内容两种状态,每块单独切: - - 预览:函数、类、枚举、异常、导入、常量、变量、解释器、选项、解析指令、阶段、健康检查、启动块是属性表(见下节),文件开头的说明是 Markdown(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文里有跨几行写的调用时,摊开实参的表排在译文下面(见「调用与构造」);块带说明(Python 字段的属性 docstring)时,说明按 Markdown 再排在下面;块挂着注释时「注释」一行排在最下面。 + - 预览:函数、类、枚举、异常、导入、常量、变量、解释器、选项、解析指令、阶段、健康检查、启动块是属性表(见下节),文件开头的说明是 Markdown(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文里有跨几行写的字典 / 列表时,拆开的各项排在译文下面(见「字典 / 对象字面量、跨几行写的列表」),有跨几行写的调用时,摊开实参的表排在译文下面(见「调用与构造」);块带说明(Python 字段的属性 docstring)时,说明按 Markdown 再排在下面;块挂着注释时「注释」一行排在最下面。 - 编辑:这一块在源码里的完整代码(按语法树着色):挂在它上面的注释、头部连同各子孙块、收尾括号,可以直接改,改完按这整段写回;蓝色边框。按钮换成眼睛「预览」;Esc 也回到预览。 - 复合语句(函数、条件、循环……)块的预览只讲它自己的头部那几行(主体交给子块);收尾括号、空行不成块。挂在块上的注释算在块的完整范围里(写在块上面的从注释开头算起,挂在这一层最后一块上的、子块交上来的到注释结尾):差异、跳过去、钉住卡片、总览条、「只看改动」、按行找块都按它,注释那几行改了标的是它挂着的那块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里;Python 常量、变量、字段的属性 docstring 算在它那一块里(中间隔着的空行也是)。 - 编辑范围是块的完整范围(`blockView.ts` 的 `blockSpans`,和语言无关):从头部和挂在它上面的注释里最靠前的开头,到头部、整条语句、挂在它上面的注释、各子孙块(连同它们的注释)里最靠后的那个字符。else、elif、catch 这类展平成兄弟块的子句,整条 if / try 按各块的开头(连同挂在它上面的注释)切开:每块到下一块开头之前,最后一块到语句结尾(Shell 的 `fi` 归它);`} else {` 写在一行时两块都含这一行。 @@ -273,7 +273,7 @@ 值里的表达式只翻落在块头部里的那一段:`const v = useMemo(() => { … })` 这种值带语句块主体回调的块,回调主体是子块,值只翻到 `useMemo(() =>`,和块头部一致。 -字典 / 对象字面量、跨几行写的列表(常量、变量的值,含 Python 类主体里不带注解的类属性;参数默认值照旧是一行译文): +字典 / 对象字面量、跨几行写的列表(常量、变量的值,含 Python 类主体里不带注解的类属性;实参的值、一行译文的块里跨几行写的也拆,见本节最后;参数默认值照旧是一行译文): - 值写项数:Python `字典 N 项`、TypeScript `对象 N 项`(按 hint 着色),各项展开成子行,照源码顺序,注释不算一项。 - 每项两列:键 | 值。键照源码写法:字符串键带引号、按字符串着色,名字键按名字着色,计算属性名 `[k]` 照常翻;值同常量、变量的值:字典 / 对象、跨几行的列表接着拆,跨几行写的调用排成「新建 / 调用 …」、实参表排在这一项下面,别的照常直译(`等待 …`、`空数组`……)。 @@ -281,10 +281,12 @@ - 展开:Python `**other`、TypeScript `...other` 是一项,键写「展开」(hint),值是展开的表达式。 - TypeScript 简写 `{ a }`:键 `a`,值 `a`。对象里的方法、getter、setter:键是方法名(函数色),值是方法头部(到主体开始之前)的译文,如 `异步 load(id: 字符串): Promise<无>`、`get size()`。 - 空字面量不展开:值是一行 `空字典` / `空对象`(一行译文里也这样翻,同 `空数组`)。 -- `as const`、`satisfies` 包着的对象(`{ … } as const`)不算字面量本身,照旧一行译文。 +- `as const`、`satisfies` 包着的对象(`{ … } as const`)不算字面量本身:写在一行里的照旧一行译文,跨几行写的是埋在式子里的字面量(见下面「一段排一个」),写成「(对象 N 项) 断言为 常量」、各项照常拆。 - 列表(Python 的列表 / 元组 / 集合、TypeScript 的数组、Rust 的数组 / 元组)跨几行写时同样拆:值写 `列表 N 项`(`元组`、`集合`、`数组` 各用自己的叫法,展开的也算一项),每项两列:`第 N 项`(hint,只数不是展开的项)| 值;`*other`、`...other` 左边写「展开」,值是展开的表达式。列表的项不讲哪个定义(不标 defines)。写在一行里的列表照旧一行译文(`[a, f(b)]`),空的照旧 `空列表`。`[值; 个数]` 不是一项项写出来的,不拆。 - 列表的一项整个是一次调用时,不管写成一行还是几行都排成「新建 / 调用 被调用的名字 · N 个参数」(见「调用与构造」),它的实参表排在这一项下面、往右缩一层,同一个列表里样子一致;别处写在一行里的调用照旧。项的值是字典 / 对象照字典拆,是跨几行的列表接着拆,一层层往下套。 -- 实参的值是跨几行写的列表时也这样拆,各项排在实参表值那一格里、文字下面(见「调用与构造」);实参里的字典 / 对象照旧一行。 +- 实参的值是跨几行写的列表、字典 / 对象时也这样拆,各项排在实参表值那一格里、文字下面(见「调用与构造」),同一张实参表里跨行的列表、字典样子一致;实参里写在一行里的字典 / 对象照旧一行。 +- 一行译文的块(`return [...]`、`yield {...}`、表达式语句、`+=` 赋值、字段……)里跨几行写的列表、字典 / 对象同样拆:译文里字面量的位置写「列表 N 项」「对象 N 项」(措辞同常量、变量的值),各项排在这一行下面(`TextTranslation.items` → 视图数据的 `items`),排法同变量初始值下面的子行(往右缩一层、左边一条竖线,项下面的下一层能折叠,项带的实参表排在这一项下面);这一行下面不再另挂实参表,各项各管各的。 +- 一段排一个:和「调用与构造」的跨几行调用是同一条规则——一行译文的块、常量 / 变量的值、实参表里每个值各是一段,各自排自己里面先遇到的(先外后里)那一个跨几行写的调用或字面量,同一段里后面再有的照常一行。所以 `return f([⏎ a,⏎ b⏎ ]) + 1` 排的是外面的 f(「返回 (调用 f · 1 个参数) + 1」,列表拆在它实参表那一格里);`return [⏎ make(…)⏎ ] + [⏎ b⏎ ]` 排的是先遇到的列表,后面那个照旧一行;字面量后面还紧跟着同一个式子的别的部分时整个括起来,同调用:「返回 (列表 1 项) + [ b ]」,常量 / 变量的值也一样(`LIMITS = [⏎ 1,⏎ ] + DEFAULTS` → 「值 (列表 1 项) + DEFAULTS」)。值整个是字典 / 对象字面量的写在一行里也拆,只在常量、变量的值和它们一层层的项里(实参的值、一行译文里写在一行里的照旧一行)。 - 用户那段代码读出来是: ``` @@ -301,6 +303,8 @@ value 空 ``` + 同一段写成 `return [...]` 时一行译文是「返回 列表 2 项」,下面「第 1 项 / 第 2 项」同上、各带自己的实参表。 + - 语言插件给各项(`LanguageAdapter.collection`:节点 → `CollectionFacts`),翻译插件按字面量的节点类型查叫法(`ZhTable.collections`)、定怎么拆;webview 只画子行和表,不认语言。 ### 异常 @@ -370,17 +374,17 @@ 原型画板「调用 · 构造」(`code.js` 的「调用与构造」一节,嵌套见 `nestedArgRow`、`NESTED_CALL_SAMPLE`)。实参多的调用一般跨几行写,挤成一行译文读不下去;把实参摊成一张表,实参的值又是这样的调用时在格子里接着摊。 - 哪些调用:实参列表(括号连同里面,`CallFacts.argumentList`)跨了几行的调用,而且整个落在要翻的这一段里(块头部;TypeScript `describe('x', () => { … })` 这种主体是子块的调用不算)。一行写完的调用照旧一行译文,不问定义。链式调用跨了几行、每段的括号都写在一行里的(`items\n .filter(…)\n .map(…)`)也不算;式子跨了几行、里面的调用各自写在一行里的(`bool(x)\n and Path(x).is_file()`)是式子,也不算。拆开的列表里整项是调用的例外,写成一行也排(见「字典 / 对象字面量、跨几行写的列表」)。 -- 一段排一个:一行译文的块、常量 / 变量的值、实参表里每个值各是一段,各自排自己里面遇到的第一个(先外后里)这样的调用;同一段里后面再有的照常一行译文(挤成一行时圆括号贴着里面的内容:`f(\n x\n)` 写成 `f(x)`,方括号、花括号照旧空一格)。被调用的表达式里的调用(`get_driver(\n m\n).query(\n …\n)` 排的是外面的 query)照常一行。 +- 一段排一个:一行译文的块、常量 / 变量的值、实参表里每个值各是一段,各自排自己里面遇到的第一个(先外后里)这样的调用或跨几行写的字典 / 列表(见「字典 / 对象字面量、跨几行写的列表」);同一段里后面再有的照常一行译文(挤成一行时圆括号贴着里面的内容:`f(\n x\n)` 写成 `f(x)`,方括号、花括号照旧空一格)。被调用的表达式里的调用(`get_driver(\n m\n).query(\n …\n)` 排的是外面的 query)照常一行。 - 一行:`[等待 ]新建 / 调用 被调用的名字 · N 个参数`。「等待」就是 `await` 照常的译法(在调用前面),「新建」「调用」按声明关键字着色;被调用的名字照常翻、带源码下标(⌘ / Ctrl + 点击、单击高亮、中文名都照旧),前面几节(对象、模块)按变量着色,最后那个名字新建时按类型着色、调用时按函数着色;「· N 个参数」按说明词着色,N 数的是表里的行。 - 埋在式子里:调用后面还紧跟着同一个式子的别的部分(`.model_dump()`、`[0]`、`or x`;后面是右括号、逗号、分号、冒号或什么都没有的不算),这一行整个括起来:「(新建 X · 3 个参数).model_dump()」「(调用 make · 1 个参数) 或者 fallback」,表照样排在这一段下面。前面有东西的(`fallback or make(\n…\n)`)不括。 - 新建还是调用:写法说明了的看写法(`CallFacts.construct`:TypeScript 的 `new X(…)` 是新建,不带 new 的是调用,都不问定义);Python 新建对象和调用函数写法一样,翻译插件问 `SourceDocument.calleeKind`:名字定义成类(含异常、枚举)是「新建」,函数、别的、查不到、还没查到都是「调用」。两种语言都用「新建」这个词。 - 查定义(宿主,`calleeKinds.ts` + `editor/definitions.ts` 的 `definitionKind`):排视图数据是同步的,先按已经查到的排(第一次都按调用画),记下问了哪些调用;发出预览后问语言扩展被调用的名字(`CallFacts.name`)定义在哪(和 ⌘ + 点击同一套),打开定义所在的文件、用它那种语言的语言插件分块(`readDefinitionKind`:自己的名字盖住定义处的最里层那块),几处定义取第一处认得出的。查到的和已知的不一样就重排、重发。按被调用的表达式原文记(`user_service.update_user`),同一份文档里写法一样的按一处查;文档改了先用上一版查到的,每个版本查一次,语言扩展还没起来(没答)的照 RETRY_DELAYS 隔一会儿再查(和库规则查名字的定义同一套 `DeferredAnswers`)。被调用的不是名字(`handlers[k](…)`)的不查,按调用算。 - 表:两列「参数 | 值」,每个实参一行、照源码顺序。按名字传的(Python `name=value`)写名字,按变量着色、带源码下标;按位置传的写「第 N 个」(说明词,只数按位置传的);`*args`、`...args` 写「展开」,`**kwargs` 写「展开关键字」(说明词)。值照常一行译文(名字带下标);唯一的实参是生成器时(`sum(\n x for x in xs\n)`),共用的那对括号属于调用,格子里只有框。表的样子同输入参数表(`PropertyTable`:表头、圆角外框、格子之间分隔线,第一列按内容定宽)。 -- 嵌套:值(一段)里有跨几行写的调用时,值那一格写它的一行,它的实参表排在同一格里、文字下面(`PropertyItem.table`),一层层往下套,多深都一样;套着的表不能折叠,总是展开。值是跨几行写的列表时值那一格写「列表 N 项」,各项排在同一格里、文字下面(`PropertyItem.items`),项里整项是调用的接着带自己的实参表;实参里的字典 / 对象照旧一行。每一层都和最外层一样:先按「调用」画、查到定义再刷新成「新建」,名字带源码下标,跳到定义、高亮各处出现、中文名照旧。排版:每套一层往右缩 5px(`PropertyTable` 外面那层的左边距),第一列按内容定宽、窄了能收,长的值在格子里换行,不出横向滚动条。 +- 嵌套:值(一段)里有跨几行写的调用时,值那一格写它的一行,它的实参表排在同一格里、文字下面(`PropertyItem.table`),一层层往下套,多深都一样;套着的表不能折叠,总是展开。值是跨几行写的列表、字典 / 对象时值那一格写「列表 N 项」「字典 N 项」,各项排在同一格里、文字下面(`PropertyItem.items`),项里整项是调用的接着带自己的实参表;实参里写在一行里的字典 / 对象照旧一行。每一层都和最外层一样:先按「调用」画、查到定义再刷新成「新建」,名字带源码下标,跳到定义、高亮各处出现、中文名照旧。排版:每套一层往右缩 5px(`PropertyTable` 外面那层的左边距),第一列按内容定宽、窄了能收,长的值在格子里换行,不出横向滚动条。 - 放在哪: - 变量、常量块:初始值 / 值那一行写上面那一行字,表是这一行展开的子行(`PropertyRow.columns = ['参数', '值']`),带折叠箭头、默认展开。值是字典 / 对象字面量、跨几行写的列表时按项展开(见「字典 / 对象字面量、跨几行写的列表」),里面的调用跟着各自那一项。 - - 一行译文的块(返回、语句、字段……):译文里调用的位置换成那一行字(「返回 新建 UserDetailResponse · 11 个参数」),表排在译文下面(`TextTranslation.table` → 视图数据的 `table`),总是展开;块带说明时说明排在表下面。 -- 分工:语言插件报结构(`LanguageAdapter.call`:被调用的表达式、最后那个名字、写法是不是新建、实参列表、各个实参),翻译插件定用词、挑哪个调用、怎么排;webview 只画表(`PreviewTable`),不认语言;跳到定义、高亮各处出现、中文名在表里照旧(`renderedNames`、`reveal.ts` 都算上这张表和一层层套着的表)。 + - 一行译文的块(返回、语句、字段……):译文里调用的位置换成那一行字(「返回 新建 UserDetailResponse · 11 个参数」),表排在译文下面(`TextTranslation.table` → 视图数据的 `table`),总是展开;块带说明时说明排在表下面。这一段先遇到的是跨几行写的字典 / 列表时排的是它拆开的各项(`TextTranslation.items`),里面的调用跟着各自那一项。 +- 分工:语言插件报结构(`LanguageAdapter.call`:被调用的表达式、最后那个名字、写法是不是新建、实参列表、各个实参),翻译插件定用词、挑哪个调用、怎么排;webview 只画表(`PreviewTable`),不认语言;跳到定义、高亮各处出现、中文名在表里照旧(`renderedNames`、`reveal.ts` 都算上这张表、一行译文下面拆开的各项和一层层套着的表)。 - 样例(`fixtures/routes.py`、`paymentConsoleRoutes.py`、`chainedCalls.py` 是 luffy 的代码,`calls.ts.txt`、`nestedCalls.ts.txt` 是本仓库的代码): | 源码 | 预览 | @@ -529,7 +533,7 @@ - 打开别的文件只有一条路(预览的 `openElsewhere`,用哪种编辑器由 `editor/openTarget.ts` 的 `openingFor` 按目标文件的语言定,侧栏点文件也按它):跳到定义、注释里的仓库链接、不带路径的引用都走它。 - 闪哪一处:页面上**就是这处定义**的最具体的那一处,只按源码位置判断(定义提供者给的定义处下标,对上块和属性行带的源码范围),不看属性名文字: 1. 定义是块自己的名字(函数名、类名;只声明一个变量时的变量名,解构模式整个算)→ 整块。块的这段范围由扩展端从 facts 取(`BlockView.defines`),和翻译插件无关。 - 2. 定义是属性表里某一行 / 子行讲的东西(参数、导入项、一条语句声明几个变量时的某一个、类的泛型参数、对象字面量一项的键——TypeScript 对象的属性名就是定义,嵌套几层都算)→ 只闪这一行 / 子行。范围是翻译插件从 facts 取的节点(`PropertyRow.defines`、`PropertyItem.defines`;字典 / 对象的项标的是键,展开项、列表的项不标),定义处落在范围里就算(`*args` 的定义在 `args` 上、解构参数里的名字都对得上)。 + 2. 定义是属性表里某一行 / 子行讲的东西(参数、导入项、一条语句声明几个变量时的某一个、类的泛型参数、对象字面量一项的键——TypeScript 对象的属性名就是定义,嵌套几层都算;一行译文下面拆开的各项、实参表里的子行也算)→ 只闪这一行 / 子行。范围是翻译插件从 facts 取的节点(`PropertyRow.defines`、`PropertyItem.defines`;字典 / 对象的项标的是键,展开项、列表的项不标),定义处落在范围里就算(`*args` 的定义在 `args` 上、解构参数里的名字都对得上)。 3. 其余(定义在一行译文里,例如循环变量;翻译插件没填 `defines`)→ 显示着定义处那个名字的块,名字在属性行的子行里时展开那一行(在嵌套的子行里时连它上面各层一起展开);页面上找不到这个名字就取包含定义那一行的块。 - 同一文件原地跳、跳进另一个文件的预览,规则一样。 - 滚过去:目标所在块的祖先块折叠着就展开,目标在折叠的属性行子行里(参数、导入项、字典 / 对象的项)就展开那一行,在嵌套的子行里就连它上面折叠着的各层子行一起展开。目标已在视口里(筛选栏和钉住的卡片下面)不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一处再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。目标是属性行 / 子行但块正在编辑(页面上没有属性表)时闪整块。 @@ -1015,8 +1019,8 @@ export interface PropertyRow extends Defines { label: string, value: PreviewText /** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行。 */ export interface PreviewTable { columns: string[], items: PropertyItem[] } -/** 一行译文的块翻出来的:一行译文(注释块——文件开头的说明、挂在块上的注释——是 Markdown 原文);跨几行写的调用还有摊开各个实参的表,预览排在这一行下面。 */ -export interface TextTranslation { text: PreviewText, table?: PreviewTable } +/** 一行译文的块翻出来的:一行译文(注释块——文件开头的说明、挂在块上的注释——是 Markdown 原文);预览排在这一行下面的,跨几行写的字面量是拆开的各项(items),跨几行写的调用是摊开各个实参的表(table)。 */ +export interface TextTranslation { text: PreviewText, items?: PropertyItem[], table?: PreviewTable } /** 语言插件:语法树 → 代码块树,外加只有它懂、翻译插件翻表达式时要用的信息(叶子的词性、写在表达式里的遍历的结构)。 */ export interface LanguageAdapter { @@ -1100,7 +1104,7 @@ export interface Translator { label: string // '中文直译' languageIds: string[] displayLanguage: string // 译文用的语言('zh'):库规则按它挑自己的词表 - translate: (block: TextBlock, document: SourceDocument) => TextTranslation // 只翻 header 那一段;注释块(文件开头的说明、挂在块上的注释)给 Markdown 原文;跨几行写的调用带实参表 + translate: (block: TextBlock, document: SourceDocument) => TextTranslation // 只翻 header 那一段;注释块(文件开头的说明、挂在块上的注释)给 Markdown 原文;跨几行写的字面量带拆开的各项、调用带实参表 describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] // 函数、类、枚举、异常、导入、常量、变量块 → 属性表 expression: (node: Node, document: SourceDocument) => Inline[] // 一个表达式 / 类型节点整个翻成一行译文,译法同属性表的值;库规则借它 doc: (node: Node, document: SourceDocument) => Doc | undefined // 说明文字(注释、docstring、当说明用的字符串)→ 正文和块标签(Doc);正文同属性表的「说明」;连着几行的行文档注释(Rust 的 `///`)node 是第一条,翻译插件自己往下连 @@ -2037,7 +2041,7 @@ Python 内置类型(仅注解里):str 字符串、int 整数、float 浮 | `return []` | `返回 空数组` | | `for (const f of files) {` | `遍历 (常量 f 取自 files)` | | `failed: { path: string, reason: string }[]` | `failed: { path: 字符串, reason: 字符串 }[]` | -| `return {⏎ locked: true,⏎ … }`(issueLock.ts 20–24 行) | `返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }` | +| `return {⏎ locked: true,⏎ … }`(issueLock.ts 20–24 行) | `返回 对象 3 项`,下面各项 `locked \| 真`、`prerequisiteNumber \| prereq.number`、`prerequisiteColumn \| prereq.column` | | `err instanceof Error ? err.message : String(err)` | `如果 err 属于 Error 则 err.message 否则 String(err)` | 属性表样例(必须逐字一致;子行各列用 ` | ` 隔开): @@ -2090,10 +2094,10 @@ type CommentTarget = { kind: 'replace', startLine: number, endLine: number } | { /** * 属性表块(有 facts)和库规则认领的块只带 properties(「注释」一行由宿主插在里面),其余块只带 translation(文件开头的说明是 `{ markdown }`);没有翻译插件时是空数组。 - * 一行译文里有跨几行写的调用时 table 是摊开实参的表(TextTranslation.table),块带说明(TextBlock.doc)时 doc 是翻译插件读出的说明正文(Translator.doc 的 body), + * 一行译文里有跨几行写的字面量时 items 是拆开的各项(TextTranslation.items),有跨几行写的调用时 table 是摊开实参的表(TextTranslation.table),块带说明(TextBlock.doc)时 doc 是翻译插件读出的说明正文(Translator.doc 的 body), * 挂着注释时 rows 是「注释」一行,webview 依次排在译文下面。差异的标记里一行译文的卡片的 rows 同属性表逐行标。 */ -type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] }) +type BlockView = BlockViewBase & ({ translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] }) ``` - 扩展 → webview: diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index a605cf1..5ab180c 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -351,6 +351,18 @@ describe('blockView', () => { }) }) + it('跨几行写的列表:一行译文下面带拆开的各项,项里的调用带着自己的实参表', async () => { + const source = 'def f(user):\n return [\n Detail(\n id=user.id,\n ),\n user,\n ]\n' + const { blocks: list } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source, { calleeKind: call => call.callee.text === 'Detail' ? 'class' : undefined }) + const returned = list[0].children[0] + expect(translationText(returned)).toBe('返回 列表 2 项') + expect(returned).not.toHaveProperty('table') + expect('items' in returned && returned.items).toMatchObject([ + { cells: [[{ text: '第 1 项' }], [{ text: '新建' }, { text: ' ' }, { text: 'Detail', offset: source.indexOf('Detail') }, { text: ' ' }, { text: '· 1 个参数' }]], table: { columns: ['参数', '值'] } }, + { cells: [[{ text: '第 2 项' }], [{ text: 'user', offset: source.indexOf('user,') }]] }, + ]) + }) + it('概念列表:语言的种类在前,语言另认的接着,规则的种类和另认的在后;同一个 id 只留第一个', async () => { const language: LanguageAdapter = { ...py, diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index c80478b..49b0a42 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,5 +1,5 @@ import type { Tree } from '@vscode/tree-sitter-wasm' -import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, HttpEndpoint, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleCard, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract' +import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, HttpEndpoint, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import type { ImportedName } from './symbolLinks' import { blockAttributes } from './attributes' @@ -72,10 +72,10 @@ export type CommentTarget /** * 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文。 - * 一行译文下面依次是:跨几行写的调用摊开各个实参的表(TextTranslation.table),块的说明(TextBlock.doc,翻译插件给的 Markdown 原文), - * 几行属性(rows:「注释」一行)。 + * 一行译文下面依次是:跨几行写的字面量拆开的各项(TextTranslation.items),跨几行写的调用摊开各个实参的表(TextTranslation.table), + * 块的说明(TextBlock.doc,翻译插件给的 Markdown 原文),几行属性(rows:「注释」一行)。 */ -export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] }) +export type BlockView = BlockViewBase & ({ translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] }) interface Line { start: number, end: number } @@ -342,13 +342,13 @@ export function toBlockViews( doc: node => layerTranslator?.doc(node, layerDocument), lookup: layerRules.lookup ?? (() => undefined), } - /** 一行译文,连着摊开实参的表、块的说明(有才有)。 */ - const textView = (block: TextBlock): { translation: PreviewText, table?: PreviewTable, doc?: Markdown } => { + /** 一行译文,连着拆开的各项、摊开实参的表、块的说明(有才有)。 */ + const textView = (block: TextBlock): { translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown } => { if (!layerTranslator) return { translation: [] } - const { text, table } = layerTranslator.translate(block, layerDocument) + const { text, items, table } = layerTranslator.translate(block, layerDocument) const doc = block.doc && layerTranslator.doc(block.doc, layerDocument)?.body - return { translation: text, ...(table && { table }), ...(doc && { doc }) } + return { translation: text, ...(items && { items }), ...(table && { table }), ...(doc && { doc }) } } /** 块执行的嵌着的代码分成的几步:只有一步时是它的子块,几步时是各步,交给那种语言转(见 TextBlock.embedded)。 */ const embeddedChildren = (block: CodeBlock, id: string, scope: string, from: number): BlockView[] => { diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 06d060c..72711a0 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -715,9 +715,13 @@ export interface PreviewTable { items: PropertyItem[] } -/** 一行译文的块翻出来的:一行译文(注释块是 Markdown 说明);跨几行写的调用还有摊开各个实参的表,预览排在这一行下面。 */ +/** + * 一行译文的块翻出来的:一行译文(注释块是 Markdown 说明);预览排在这一行下面的,跨几行写的字面量是拆开的各项(items,同 PropertyItem.items), + * 跨几行写的调用是摊开各个实参的表(table)。 + */ export interface TextTranslation { text: PreviewText + items?: PropertyItem[] table?: PreviewTable } diff --git a/vscode/src/codeReader/diff/viewDiff.test.ts b/vscode/src/codeReader/diff/viewDiff.test.ts index aa977a0..5d9742b 100644 --- a/vscode/src/codeReader/diff/viewDiff.test.ts +++ b/vscode/src/codeReader/diff/viewDiff.test.ts @@ -160,6 +160,14 @@ describe('blockMarks', () => { expect(marks?.doc).toEqual({ markdown: [{ text: '旧说明', change: 'del' }] }) }) + it('一行译文下面拆开的各项逐行比:按第一列配,多出来的算加行,没了的算删行', () => { + const before = textView([seg('返回 列表 2 项')], { items: [item('第 1 项'), item('第 2 项')] } as Partial) + const after = textView([seg('返回 列表 1 项')], { items: [item('第 1 项')] } as Partial) + const marks = blockMarks(before, after) + expect(marks?.items?.removed).toEqual([{ after: 0, value: item('第 2 项') }]) + expect(blockMarks(after, before)?.items?.changed).toEqual({ 1: { added: true } }) + }) + it('一块是属性表、一块是一行译文时没法逐行比', () => { expect(blockMarks(textView([seg('a')]), propertyView([row('名称', [seg('a')])]))).toBeUndefined() }) @@ -178,6 +186,14 @@ describe('removedView / withoutSource', () => { expect(JSON.stringify(removed)).not.toMatch(/"offset"|"refers"/) }) + it('一行译文下面拆开的各项、摊开实参的表照样带着,词不带源码下标', () => { + const named = { cells: [[seg('第 1 项')], [seg('x', 'variable', 9)]] } + const removed = removedView(textView([seg('返回 列表 1 项')], { items: [named], table: { columns: ['参数', '值'], items: [named] } } as Partial)) + expect('items' in removed && removed.items).toEqual([{ cells: [[seg('第 1 项')], [seg('x', 'variable')]] }]) + expect('table' in removed && removed.table?.items).toHaveLength(1) + expect(JSON.stringify(removed)).not.toMatch(/"offset"/) + }) + it('框里的词也去掉下标,颜色留着', () => { const color = { dark: '#fff', light: '#000' } expect(withoutSource([{ style: 'iteration', label: '列表', segments: [{ ...seg('x', 'variable', 3), color }] }])).toEqual([ diff --git a/vscode/src/codeReader/diff/viewDiff.ts b/vscode/src/codeReader/diff/viewDiff.ts index 8a1d5e8..3653bae 100644 --- a/vscode/src/codeReader/diff/viewDiff.ts +++ b/vscode/src/codeReader/diff/viewDiff.ts @@ -54,10 +54,14 @@ export interface RowDiff { items?: ItemsDiff } -/** 一块自己的内容(不算子块)改了哪些:属性表(一行译文的卡片是下面那几行 rows,「注释」一行)逐行,一行译文逐截,摊开实参的表逐行,说明逐词。 */ +/** + * 一块自己的内容(不算子块)改了哪些:属性表(一行译文的卡片是下面那几行 rows,「注释」一行)逐行,一行译文逐截, + * 译文下面拆开的各项、摊开实参的表逐行,说明逐词。 + */ export interface BlockMarks { rows?: RowsDiff translation?: TextDiff + items?: ItemsDiff table?: ItemsDiff doc?: TextDiff } @@ -112,6 +116,7 @@ export function removedView(view: BlockView): BlockView { return { ...base, translation: textWithoutSource(view.translation), + ...(view.items && { items: view.items.map(itemWithoutSource) }), ...(view.table && { table: tableWithoutSource(view.table) }), ...(view.doc && { doc: view.doc }), ...(view.rows && { rows: view.rows.map(rowWithoutSource) }), @@ -305,7 +310,7 @@ export function rowsDiff(before: readonly PropertyRow[], after: readonly Propert } /** - * 配上的两块自己的内容(不算子块)比:属性表逐行,一行译文逐截,摊开实参的表逐行,说明逐词。 + * 配上的两块自己的内容(不算子块)比:属性表逐行,一行译文逐截,译文下面拆开的各项、摊开实参的表逐行,说明逐词。 * 一样(或一块是属性表、一块是一行译文,没法逐行比)时是 undefined。 */ export function blockMarks(before: BlockView, after: BlockView): BlockMarks | undefined { @@ -314,9 +319,10 @@ export function blockMarks(before: BlockView, after: BlockView): BlockMarks | un return rows && { rows } } const translation = textDiff(before.translation, after.translation) + const items = itemsDiff(before.items, after.items) const table = itemsDiff(before.table?.items, after.table?.items) const doc = textDiff(before.doc ?? { markdown: '' }, after.doc ?? { markdown: '' }) const rows = rowsDiff(before.rows ?? [], after.rows ?? []) - const marks: BlockMarks = { ...(translation && { translation }), ...(table && { table }), ...(doc && { doc }), ...(rows && { rows }) } + const marks: BlockMarks = { ...(translation && { translation }), ...(items && { items }), ...(table && { table }), ...(doc && { doc }), ...(rows && { rows }) } return Object.keys(marks).length > 0 ? marks : undefined } diff --git a/vscode/src/codeReader/nameResolution.test.ts b/vscode/src/codeReader/nameResolution.test.ts index c02553e..a14f1b5 100644 --- a/vscode/src/codeReader/nameResolution.test.ts +++ b/vscode/src/codeReader/nameResolution.test.ts @@ -106,6 +106,15 @@ describe('中文名落到哪些名字上', () => { expect(rendered.get(at(source, 'user.name'))).toBe('user') }) + it('renderedNames:一行译文下面拆开的各项(跨几行写的列表、字典)、项下面的实参表里带下标的名字也算', async () => { + const source = 'def f(user):\n return [\n Detail(\n id=user.id,\n ),\n {"owner": owner},\n ]\n' + const rendered = renderedNames((await read(source)).blocks) + expect(rendered.get(at(source, 'Detail'))).toBe('Detail') + expect(rendered.get(at(source, 'id='))).toBe('id') + expect(rendered.get(at(source, 'user.id'))).toBe('user') + expect(rendered.get(at(source, 'owner}'))).toBe('owner') + }) + it('本文件定义的:定义处问各处出现,落在画出来的名字上的都换,定义处本身标 definition;长度对不上、没画出来的不换', async () => { const { blocks, displayNames } = await read(ROUTES) const definition = at(ROUTES, 'current_user') diff --git a/vscode/src/codeReader/nameResolution.ts b/vscode/src/codeReader/nameResolution.ts index 9be4c21..952787a 100644 --- a/vscode/src/codeReader/nameResolution.ts +++ b/vscode/src/codeReader/nameResolution.ts @@ -15,7 +15,7 @@ export interface ShownName { definition?: true } -/** 视图数据里照抄自源码、带下标的名字(一行译文和它下面的表、属性表的值、各层子行的格子、框里):下标 → 原文。 */ +/** 视图数据里照抄自源码、带下标的名字(一行译文和它下面拆开的各项、表,属性表的值、各层子行的格子、框里):下标 → 原文。 */ export function renderedNames(blocks: readonly BlockView[]): Map { const found = new Map() const inline = (pieces: readonly Inline[]): void => { @@ -47,6 +47,7 @@ export function renderedNames(blocks: readonly BlockView[]): Map } else { text(block.translation) + items(block.items) items(block.table?.items) } visit(block.children) diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index 38ad7e9..c451c60 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -109,9 +109,14 @@ export function tableLines(table: PreviewTable | undefined): string[] { return itemLines(table?.items, '') } +/** 一行译文下面拆开的各项写成文字,同 tableLines。 */ +export function listLines(items: PropertyItem[] | undefined): string[] { + return itemLines(items, '') +} + /** * 视图数据写成文字(整份源码读出来的样子):一行译文的块是「种类 行号: 译文」(下面的「注释」一行写成「注释: 第一行」),属性表块是「种类 行号」下面每行「属性: 值」, - * 子块比上一层多缩进两格;Markdown 只取第一行。表(属性行展开的、译文下面的、格子里套的)每行比上一层多缩进, + * 子块比上一层多缩进两格;Markdown 只取第一行。子行和表(属性行展开的、译文下面拆开的各项和表、格子里套的)每行比上一层多缩进, * 格子用 | 隔开,格子里的换行写成 ⏎。 */ export function viewLines(blocks: readonly BlockView[], indent = ''): string[] { @@ -126,6 +131,7 @@ export function viewLines(blocks: readonly BlockView[], indent = ''): string[] { ? [`${indent}${block.kind} ${block.startLine + 1}`, ...block.properties.flatMap(row => [`${indent} ${row.label}: ${value(row.value)}`, ...items(row.items, `${indent} `)])] : [ `${indent}${block.kind} ${block.startLine + 1}: ${value(block.translation)}`, + ...items(block.items, `${indent} `), ...items(block.table?.items, `${indent} `), ...(block.rows ?? []).map(row => `${indent} ${row.label}: ${value(row.value)}`), ] diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index 0a24866..a9082ca 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -3,7 +3,7 @@ import type { ZhTable } from './zhTemplate' import { describe, expect, it } from 'vitest' import { createPythonAdapter } from '../languages/python' import { createTypeScriptAdapter } from '../languages/typescript' -import { flatSegments, inlineOf, previewText, propertyLines, readFixture, tableLines, walk, WASM_DIR, withBlocks } from '../testSupport' +import { flatSegments, inlineOf, listLines, previewText, propertyLines, readFixture, tableLines, walk, WASM_DIR, withBlocks } from '../testSupport' import { pythonZhTranslator } from './pythonZh' import { typescriptZhTranslator } from './typescriptZh' import { createZhTemplateTranslator } from './zhTemplate' @@ -11,8 +11,11 @@ import { createZhTemplateTranslator } from './zhTemplate' const ts = createTypeScriptAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR) -/** 一块的译文:一行译文在 segments 里,下面摊开实参的表在 table 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */ -interface Translated { kind: string, segments: Inline[], markdown?: string, table?: PreviewTable } +/** + * 一块的译文:一行译文在 segments 里,下面拆开的各项在 items 里、摊开实参的表在 table 里; + * 注释块是 Markdown,原文在 markdown 里,segments 为空。 + */ +interface Translated { kind: string, segments: Inline[], markdown?: string, items?: PropertyItem[], table?: PreviewTable } /** * 一行译文块、注释块(单独成块的、挂在块上的)的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 @@ -26,10 +29,10 @@ function translateAll(adapter: LanguageAdapter, languageId: string, translator: const line = item.header.startRow + 1 if (byLine.has(line) || 'facts' in item) return - const { text: translation, table } = translator.translate(item, { source, language: adapter, markers: [], ...(calleeKind && { calleeKind }) }) + const { text: translation, items, table } = translator.translate(item, { source, language: adapter, markers: [], ...(calleeKind && { calleeKind }) }) byLine.set(line, 'markdown' in translation ? { kind: item.kind, segments: [], markdown: translation.markdown } - : { kind: item.kind, segments: translation, ...(table && { table }) }) + : { kind: item.kind, segments: translation, ...(items && { items }), ...(table && { table }) }) })) return byLine }) @@ -82,7 +85,10 @@ const CJK = /\p{Script=Han}/u const CJK_ONLY = /^\p{Script=Han}+$/u const WORD_CHAR = /[\p{Script=Han}\w]/u -/** 被翻的词单独成段,而且不和前后的字母、数字、汉字粘在一起;框里的词和框前后的词也一样。 */ +/** + * 被翻的词单独成段,而且不和前后的字母、数字、汉字粘在一起;框里的词和框前后的词也一样。 + * 说明词(「对象 3 项」「· 2 个参数」)本来就带数字,只看它不和前后粘在一起。 + */ function expectWordsApart(map: Map): void { for (const [line, { kind, segments }] of map) { if (kind === 'comment') @@ -92,7 +98,8 @@ function expectWordsApart(map: Map): void { let at = 0 for (const segment of flat) { if (segment.role !== 'string' && CJK.test(segment.text)) { - expect(segment.text, `L${line} ${full}`).toMatch(CJK_ONLY) + if (segment.role !== 'hint') + expect(segment.text, `L${line} ${full}`).toMatch(CJK_ONLY) for (const neighbor of [full[at - 1], full[at + segment.text.length]]) expect(neighbor ?? ' ', `L${line} "${full}" 里的「${segment.text}」`).not.toMatch(WORD_CHAR) } @@ -109,7 +116,8 @@ describe('typeScript 中文直译', () => { expect(text(env.get(26)?.segments)).toBe('返回 空数组') expect(text(env.get(41)?.segments)).toBe('遍历 (常量 f 取自 files)') expect(text(env.get(35)?.segments)).toBe('failed: { path: 字符串, reason: 字符串 }[]') - expect(text(issue.get(20)?.segments)).toBe('返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }') + expect(text(issue.get(20)?.segments)).toBe('返回 对象 3 项') + expect(listLines(issue.get(20)?.items)).toEqual(['locked | 真', 'prerequisiteNumber | prereq.number', 'prerequisiteColumn | prereq.column']) }) it('其余块:结构照抄,复合语句去掉结尾的 {,注释去掉注释符号、保留换行', async () => { @@ -169,12 +177,10 @@ describe('typeScript 中文直译', () => { it('多行合成一行、去掉右括号前的尾逗号、行中注释不进译文', async () => { const source = [ - 'list = [', - ' first, // 第一个', - ' [],', - ']', + 'list = first // 第一个', + ' || [first, [],]', ].join('\n') - expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source))[1]).toBe('list = [ first, 空数组 ]') + expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source))[1]).toBe('list = first 或者 [first, 空数组]') }) it('as / in / extends / default 按所在语法节点换译法', async () => { @@ -1346,8 +1352,11 @@ describe('跨几行写的调用', () => { const { asked, calleeKind } = kinds({}) const texts = await translateAll(ts, 'typescript', typescriptZhTranslator, calls, calleeKind) expect(text(texts.get(18)?.segments)).toBe('this.definitions = 新建 DefinitionSources · 1 个参数') + // 实参是跨几行写的对象:按项拆在那一格里 expect(tableLines(texts.get(18)?.table)).toEqual([ - '第 1 个 | { rule: 异步 reference => ruleDefinitions(等待 owner.resolveReference(reference)), language: offset => languageDefinitions(document.uri, document.positionAt(offset)) }', + '第 1 个 | 对象 2 项', + ' rule | 异步 reference => ruleDefinitions(等待 owner.resolveReference(reference))', + ' language | offset => languageDefinitions(document.uri, document.positionAt(offset))', ]) expect(text(texts.get(33)?.segments)).toBe('返回 调用 concatAll · 2 个参数') expect(tableLines(texts.get(33)?.table)).toEqual(['第 1 个 | base', '展开 | extra']) @@ -1507,7 +1516,7 @@ describe('跨几行写的列表', () => { ]) }) - it('实参的值是跨几行写的列表时也按项拆,嵌在那一格下面;写在一行里的列表、实参里的字典照旧一行', async () => { + it('实参的值是跨几行写的列表时也按项拆,嵌在那一格下面;写在一行里的列表、字典照旧一行', async () => { const source = [ 'session.query(', ' filters=[', @@ -1574,6 +1583,182 @@ describe('跨几行写的列表', () => { expect(text(line?.segments)).toBe('调用 register · 2 个参数') expect(tableLines(line?.table)).toEqual(['第 1 个 | \'x\'', '第 2 个 | 数组 2 项', ' 第 1 项 | a', ' 第 2 项 | b']) }) + + it('实参的值是跨几行写的字典时也按项拆,嵌在那一格下面,和跨几行的列表一样;项的值照字典拆,键标出它讲的定义', async () => { + const source = [ + 'session.query(', + ' filters=[', + ' Filter(field="a"),', + ' ],', + ' meta={', + ' "a": 1,', + ' "b": {"c": 2},', + ' **extra,', + ' },', + ' ids={"x": 1},', + ')', + ].join('\n') + const line = (await translateAll(py, 'python', pythonZhTranslator, source, calleeKind)).get(1) + expect(text(line?.segments)).toBe('调用 session.query · 3 个参数') + expect(tableLines(line?.table)).toEqual([ + 'filters | 列表 1 项', + ' 第 1 项 | 新建 Filter · 1 个参数', + ' field | "a"', + 'meta | 字典 3 项', + ' "a" | 1', + ' "b" | 字典 1 项', + ' "c" | 2', + ' 展开 | extra', + 'ids | {"x": 1}', + ]) + const meta = line?.table?.items[1] + expect(meta?.table).toBeUndefined() + const key = source.indexOf('"a": 1') + expect(meta?.items?.[0].defines).toEqual({ startIndex: key, endIndex: key + 3 }) + expect(linked(meta?.items?.[2].cells[1])).toEqual([['extra', source.indexOf('extra')]]) + }) + + it('返回跨几行写的列表:这一行写「返回 列表 N 项」,各项排在这一行下面、各带自己的实参表,不再有挂在整行下面的表', async () => { + const source = [ + 'def list_filters(archived: bool, current_user: User):', + ...FILTERS.split('\n').slice(1).map(line => line.replace('filters = ', 'return ')), + ].join('\n') + const line = (await translateAll(py, 'python', pythonZhTranslator, source, calleeKind)).get(2) + expect(text(line?.segments)).toBe('返回 列表 2 项') + expect(colors(line?.segments.at(-1))).toEqual({ text: '列表 2 项', role: 'hint' }) + expect(line?.table).toBeUndefined() + expect(listLines(line?.items)).toEqual([ + '第 1 项 | 新建 Filter · 3 个参数', + ' field | "user_id"', + ' operator | FilterOperator.EQ', + ' value | current_user.id', + '第 2 项 | 新建 Filter · 3 个参数', + ' field | "archived_at"', + ' operator | 如果 archived 则 FilterOperator.NE 否则 FilterOperator.EQ', + ' value | 空', + ]) + const [first, second] = line?.items ?? [] + expect(first?.table?.columns).toEqual(['参数', '值']) + // 名字照样带源码下标:被调用的名字、按名字传的名字、值里的名字 + const multiline = source.indexOf('Filter(\n') + expect(inlineOf(second?.cells[1] ?? []).find(piece => 'role' in piece && piece.text === 'Filter')).toEqual({ text: 'Filter', role: 'type', offset: multiline }) + expect(second?.table?.items[0].cells[0]).toEqual([{ text: 'field', role: 'variable', offset: source.indexOf('field="archived_at"') }]) + const user = source.indexOf('current_user.id') + expect(linked(first?.table?.items[2].cells[1])).toEqual([['current_user', user], ['id', user + 13]]) + }) + + it('别的语句里跨几行写的字典、列表同样拆;一段只排先遇到的那一个(先外后里),埋在式子里的整个括起来,常量 / 变量的值也一样', async () => { + const source = [ + 'def save(self, rows):', + ' yield {', + ' "rows": [', + ' 1,', + ' 2,', + ' ],', + ' "count": len(rows),', + ' }', + ' publish({', + ' "a": 1,', + ' })', + ' rows += [', + ' make(', + ' a,', + ' ),', + ' ] + [', + ' b,', + ' ]', + 'def total(a, b):', + ' return f([', + ' a,', + ' b,', + ' ]) + 1', + 'LIMITS = [', + ' 1,', + '] + DEFAULTS', + ].join('\n') + const lines = await translateAll(py, 'python', pythonZhTranslator, source) + expect(text(lines.get(2)?.segments)).toBe('产出 字典 2 项') + expect(listLines(lines.get(2)?.items)).toEqual(['"rows" | 列表 2 项', ' 第 1 项 | 1', ' 第 2 项 | 2', '"count" | len(rows)']) + // 调用在外面:排调用,字典是它的实参,拆在实参表那一格里 + expect(text(lines.get(9)?.segments)).toBe('调用 publish · 1 个参数') + expect(lines.get(9)?.items).toBeUndefined() + expect(tableLines(lines.get(9)?.table)).toEqual(['第 1 个 | 字典 1 项', ' "a" | 1']) + // 列表埋在式子里:整个括起来;同一段里后面再有的照旧一行 + expect(text(lines.get(12)?.segments)).toBe('rows += (列表 1 项) + [ b ]') + expect(listLines(lines.get(12)?.items)).toEqual(['第 1 项 | 调用 make · 1 个参数', ' 第 1 个 | a']) + expect(text(lines.get(20)?.segments)).toBe('返回 (调用 f · 1 个参数) + 1') + expect(lines.get(20)?.items).toBeUndefined() + expect(tableLines(lines.get(20)?.table)).toEqual(['第 1 个 | 列表 2 项', ' 第 1 项 | a', ' 第 2 项 | b']) + const rows = await describeAll(py, 'python', pythonZhTranslator, source) + expect(propertyLines(rows.get(24) ?? []).slice(2)).toEqual(['值: (列表 1 项) + DEFAULTS', ' 第 1 项 | 1']) + }) + + it('typeScript:实参的值是跨几行写的对象时也按项拆,嵌在那一格下面;写在一行里的对象照旧一行', async () => { + const source = [ + 'register(', + ' \'x\',', + ' {', + ' a: 1,', + ' b: { c: 2 },', + ' ...rest,', + ' },', + ' { d: 1 },', + ')', + ].join('\n') + const line = (await translateAll(ts, 'typescript', typescriptZhTranslator, source)).get(1) + expect(text(line?.segments)).toBe('调用 register · 3 个参数') + expect(tableLines(line?.table)).toEqual([ + '第 1 个 | \'x\'', + '第 2 个 | 对象 3 项', + ' a | 1', + ' b | 对象 1 项', + ' c | 2', + ' 展开 | rest', + '第 3 个 | { d: 1 }', + ]) + const key = source.indexOf('a: 1') + expect(line?.table?.items[1].items?.[0].defines).toEqual({ startIndex: key, endIndex: key + 1 }) + }) + + it('typeScript:返回、赋值里跨几行写的数组、对象按项拆,各项排在这一行下面;as const 包着的对象埋在式子里,整个括起来', async () => { + const source = [ + 'function filters(archived: boolean) {', + ' return [', + ' new Filter({ field: \'user_id\' }),', + ' makeFilter(', + ' \'archived_at\',', + ' archived,', + ' ),', + ' ]', + '}', + 'module.exports = {', + ' a: 1,', + ' b: [', + ' 1,', + ' 2,', + ' ],', + '}', + 'export const ICONS = {', + ' a: \'x\',', + '} as const', + ].join('\n') + const lines = await translateAll(ts, 'typescript', typescriptZhTranslator, source) + expect(text(lines.get(2)?.segments)).toBe('返回 数组 2 项') + expect(lines.get(2)?.table).toBeUndefined() + expect(listLines(lines.get(2)?.items)).toEqual([ + '第 1 项 | 新建 Filter · 1 个参数', + ' 第 1 个 | { field: \'user_id\' }', + '第 2 项 | 调用 makeFilter · 2 个参数', + ' 第 1 个 | \'archived_at\'', + ' 第 2 个 | archived', + ]) + const make = source.indexOf('makeFilter') + expect(inlineOf(lines.get(2)?.items?.[1].cells[1] ?? []).find(piece => 'role' in piece && piece.text === 'makeFilter')).toEqual({ text: 'makeFilter', role: 'function', offset: make }) + expect(text(lines.get(10)?.segments)).toBe('module.exports = 对象 2 项') + expect(listLines(lines.get(10)?.items)).toEqual(['a | 1', 'b | 数组 2 项', ' 第 1 项 | 1', ' 第 2 项 | 2']) + const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source) + expect(propertyLines(rows.get(17) ?? []).slice(-2)).toEqual(['值: (对象 1 项) 断言为 常量', ' a | \'x\'']) + }) }) describe('中文模板引擎', () => { diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index cbf36d1..b86fd22 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CommandContext, CommandFacts, CommandForm, CommandReading, CommandRule, CommandWord, CommonAttribute, DeclaratorFacts, Defines, DirectiveFacts, Doc, ElementFacts, EmbeddedReading, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, HealthFacts, ImportFacts, ImportItem, Inline, InstructionFlag, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, StageFacts, StartFacts, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CollectionFacts, CommandContext, CommandFacts, CommandForm, CommandReading, CommandRule, CommandWord, CommonAttribute, DeclaratorFacts, Defines, DirectiveFacts, Doc, ElementFacts, EmbeddedReading, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, HealthFacts, ImportFacts, ImportItem, Inline, InstructionFlag, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, StageFacts, StartFacts, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract' import type { ParamDocs } from '../rules/docRows' import { readDoc } from '../docTags' import { commonIndent, splitLines } from '../editText' @@ -107,16 +107,17 @@ interface Token { } /** - * 排在这一段译文下面的那张表:跨几行写的调用的实参表(见 emitCall),或读法给的表(分情况的「情况 / 做什么」)。 - * 一段只排一张,排了的记在 table 里。 + * 排在这一段译文下面的那一样:跨几行写的调用的实参表(见 emitCall)、读法给的表(分情况的「情况 / 做什么」), + * 或跨几行写的字面量拆开的各项(见 emitCollection)。一段只排一样,排了的记在这里。 */ -interface TableSlot { +interface Slot { + items?: PropertyItem[] table?: PreviewTable } /** * 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 - * slot 是这一段下面能不能排一张表(跨几行写的调用、读法给的表);没有就照常翻。 + * slot 是这一段下面能不能排一样东西(跨几行写的调用、字面量,读法给的表);没有就照常翻。 */ interface Collector { document: SourceDocument @@ -125,7 +126,7 @@ interface Collector { tokens: Token[] cursor: number gap: boolean - slot?: TableSlot + slot?: Slot } const CONTEXT_SEPARATOR = ' > ' @@ -171,6 +172,11 @@ function continued(node: Node, root: Node): boolean { return false } +/** 这一段下面已经排了一样东西(见 Slot)。 */ +function taken(slot: Slot): boolean { + return slot.items !== undefined || slot.table !== undefined +} + function isComment(token: Token): boolean { return token.text === '' && !token.group } @@ -412,47 +418,58 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { out.cursor = node.endIndex } - /** 要排成一行、实参摊成表的调用:实参列表跨了几行,整个落在要翻的这一段里,这一段还没排过别的调用。 */ - function expandedCall(node: Node, span: SourceSpan, slot: TableSlot, { language: { call } }: SourceDocument): CallFacts | undefined { - if (slot.table || !call || !inside(node, span)) + /** 要排成一行、实参摊成表的调用:实参列表跨了几行,整个落在要翻的这一段里,这一段还没排过别的。 */ + function expandedCall(node: Node, span: SourceSpan, slot: Slot, { language: { call } }: SourceDocument): CallFacts | undefined { + if (taken(slot) || !call || !inside(node, span)) return undefined const facts = call(node) return facts && facts.argumentList.startPosition.row !== facts.argumentList.endPosition.row ? facts : undefined } + /** 要按项拆开的字面量:语言插件给了各项(LanguageAdapter.collection)、不是空的,跨了几行,整个落在要翻的这一段里,这一段还没排过别的。 */ + function expandedCollection(node: Node, span: SourceSpan, slot: Slot, { language: { collection } }: SourceDocument): CollectionFacts | undefined { + if (taken(slot) || !collection || !inside(node, span)) + return undefined + const facts = collection(node) + if (!facts || (facts.kind === 'map' ? facts.entries : facts.elements).length === 0) + return undefined + return node.startPosition.row !== node.endPosition.row ? facts : undefined + } + /** - * node 落在 span 里的那一段翻成一行译文,里面跨几行写的第一个调用排成一行「新建 / 调用 …」,实参摊成表(见 emitCall); - * 给了 call(node 整个就是这次调用)时不管写成几行都这样排。一行译文的块、属性表里每一格的值各翻各的,各自排自己里面的那个调用。 + * node 落在 span 里的那一段翻成一行译文,里面先遇到的(先外后里)跨几行写的调用排成一行「新建 / 调用 …」、实参摊成表(见 emitCall), + * 或跨几行写的字面量排成一行「列表 2 项」、各项拆成子行(见 emitCollection);同一段里后面再有的照常一行。 + * 给了 call(node 整个就是这次调用)时不管写成几行都这样排。一行译文的块、属性表里每一格的值各是一段,各自排自己里面的那一个。 */ function expandedText(node: Node, span: SourceSpan, document: SourceDocument, call?: CallFacts): CellValue { - const slot: TableSlot = {} + const slot: Slot = {} const out = collector(node, span, document, slot) if (call) emitCall(out, slot, node, call, span) else collect(node, span, out) - return { text: joinTokens(out.tokens), ...(slot.table && { table: slot.table }) } + return { text: joinTokens(out.tokens), ...slot } } /** - * 属性表里一格的值(常量 / 变量的值、字典一项的值、列表的一项、实参的值),只翻落在 span 里的部分: - * 语言插件给了各项的字面量(LanguageAdapter.collection)写叫法和项数,各项拆成下一层子行(见 entryItem、elementItems)—— - * 字典 / 对象一直拆(maps 为假时照旧一行:实参的值),列表 / 元组 / 集合 / 数组跨几行写才拆; - * 空字面量和别的值是一行译文,里面跨几行写的调用排成「新建 / 调用 …」,实参表跟着这一格(见 expandedText)。 + * 属性表里一格的值(常量 / 变量的值、字典一项的值、列表的一项、实参的值),只翻落在 span 里的部分,见 expandedText; + * maps 为真时(实参的值以外)值整个是字典 / 对象字面量的写在一行里也拆,空字面量照旧一行(「空字典」)。 */ function cellValue(node: Node, span: SourceSpan, document: SourceDocument, maps = true): CellValue { - const collection = document.language.collection?.(node) - if (collection?.kind === 'map' && maps && collection.entries.length > 0) - return { text: itemCount(node, collection.entries.length), items: collection.entries.map(entry => entryItem(entry, span, document)) } - if (collection?.kind === 'sequence' && collection.elements.length > 0 && node.startPosition.row !== node.endPosition.row) - return { text: itemCount(node, collection.elements.length), items: elementItems(collection.elements, span, document) } + const collection = maps ? document.language.collection?.(node) : undefined + if (collection?.kind === 'map' && collection.entries.length > 0) + return collectionValue(node, collection, span, document) return expandedText(node, span, document) } - /** 拆开的字面量写叫法和项数(展开的也算一项):「字典 3 项」,叫法按字面量的节点类型查表(ZhTable.collections)。 */ - function itemCount(literal: Node, size: number): Segment[] { + /** + * 拆开的字面量:写叫法和项数(展开的也算一项),叫法按字面量的节点类型查表(ZhTable.collections):「字典 3 项」; + * 各项是下一层子行,字典 / 对象见 entryItem,列表 / 元组 / 集合 / 数组见 elementItems。 + */ + function collectionValue(literal: Node, facts: CollectionFacts, span: SourceSpan, document: SourceDocument): { text: Segment[], items: PropertyItem[] } { + const items = facts.kind === 'map' ? facts.entries.map(entry => entryItem(entry, span, document)) : elementItems(facts.elements, span, document) const name = table.collections[literal.type] - return [hint(name ? `${name} ${size} 项` : `${size} 项`)] + return { text: [hint(name ? `${name} ${items.length} 项` : `${items.length} 项`)], items } } /** @@ -492,8 +509,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { /** * 实参表:参数 | 值,每个实参一行,照源码顺序。按名字传的写名字,按位置传的写「第 N 个」(只数按位置传的), - * 展开的写「展开」(*args、...args)、「展开关键字」(**kwargs)。值见 cellValue(字典 / 对象照旧一行):值里有跨几行写的调用时 - * 同样排成一行,它的实参表排在值那一格里(PropertyItem.table);值是跨几行写的列表时各项拆在那一格里(PropertyItem.items)。一层层往下套。 + * 展开的写「展开」(*args、...args)、「展开关键字」(**kwargs)。值见 cellValue(写在一行里的字典 / 对象照旧一行):值里有跨几行写的调用时 + * 同样排成一行,它的实参表排在值那一格里(PropertyItem.table);值里有跨几行写的字典 / 列表时各项拆在那一格里(PropertyItem.items)。一层层往下套。 */ function argumentTable(facts: CallFacts, span: SourceSpan, document: SourceDocument): PreviewTable { let position = 0 @@ -523,7 +540,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { * 写法看不出来的问名字的定义:是类(含异常、枚举)就是新建,还没查到、查不到按调用算。 * 调用后面还紧跟着同一个式子的别的部分(见 continued)时整行括起来:「(新建 X · 3 个参数).model_dump()」。 */ - function emitCall(out: Collector, slot: TableSlot, node: Node, facts: CallFacts, span: SourceSpan): void { + function emitCall(out: Collector, slot: Slot, node: Node, facts: CallFacts, span: SourceSpan): void { slot.table = argumentTable(facts, span, out.document) const construct = facts.construct ?? CONSTRUCTED.has(out.document.calleeKind?.(facts)) const wrapped = continued(node, out.root) @@ -546,6 +563,24 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { out.cursor = node.endIndex } + /** + * 跨几行写的字面量排成一行「列表 2 项」「字典 3 项」,各项拆成子行记在 slot 里(见 collectionValue)。 + * 后面还紧跟着同一个式子的别的部分(见 continued)时整个括起来:「(列表 2 项) + rest」。 + */ + function emitCollection(out: Collector, slot: Slot, node: Node, facts: CollectionFacts, span: SourceSpan): void { + const { text, items } = collectionValue(node, facts, span, out.document) + slot.items = items + const wrapped = continued(node, out.root) + advance(out, node.startIndex) + if (wrapped) + emit(out, { text: '(', role: 'plain', translated: true, raw: '' }) + for (const piece of text) + emit(out, { text: piece.text, role: piece.role, translated: true, raw: '' }) + if (wrapped) + emit(out, { text: ')', role: 'plain', translated: true, raw: '' }) + out.cursor = node.endIndex + } + /** 读法用的:照常翻、看节点在不在这一段里、照抄名字、读命令。 */ function readerContext(out: Collector, span: SourceSpan): ReaderContext { const { document } = out @@ -561,10 +596,10 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } } - /** 读出来的几段接着前面的词输出;读法给了表、这一段还没排表时表排在这一段下面。 */ + /** 读出来的几段接着前面的词输出;读法给了表、这一段下面还空着时表排在这一段下面。 */ function emitReading(out: Collector, node: Node, reading: Inline[] | { text: Inline[], table?: PreviewTable }): void { const { text, table: below } = Array.isArray(reading) ? { text: reading, table: undefined } : reading - if (below && out.slot && !out.slot.table) + if (below && out.slot && !taken(out.slot)) out.slot.table = below emitPieces(out, node, text) } @@ -624,6 +659,11 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { emitCall(out, slot, node, call, span) return } + const collection = slot && expandedCollection(node, span, slot, out.document) + if (slot && collection) { + emitCollection(out, slot, node, collection, span) + return + } const reading = table.readers?.[node.type]?.(node, readerContext(out, span)) if (reading) { emitReading(out, node, reading) @@ -665,13 +705,13 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } } - /** 翻 node 落在 span 里的那一段用的收集器;给了 slot,跨几行写的调用排成一行、实参摊成表(见 emitCall)。 */ - function collector(node: Node, span: SourceSpan, document: SourceDocument, slot?: TableSlot): Collector { + /** 翻 node 落在 span 里的那一段用的收集器;给了 slot,先遇到的跨几行写的调用、字面量排开(见 expandedText)。 */ + function collector(node: Node, span: SourceSpan, document: SourceDocument, slot?: Slot): Collector { return { document, root: node, tokens: [], cursor: span.startIndex, gap: false, ...(slot && { slot }) } } /** 按译文顺序收集 node 落在 span 里的词;slot 见 collector。 */ - function tokensIn(node: Node, span: SourceSpan, document: SourceDocument, slot?: TableSlot): Token[] { + function tokensIn(node: Node, span: SourceSpan, document: SourceDocument, slot?: Slot): Token[] { const out = collector(node, span, document, slot) collect(node, span, out) return out.tokens @@ -719,13 +759,13 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { const group = block.group && table.groups?.[block.node.type] if (block.group && group) return group(block.group, readerContext(collector(block.node, block.header, document), block.header)) - const slot: TableSlot = {} + const slot: Slot = {} const tokens = tokensIn(block.node, block.header, document, slot) const compound = block.children.length > 0 || block.header.endIndex < block.node.endIndex const last = tokens.at(-1) if (compound && last && !last.translated && HEADER_OPENERS.has(last.raw)) tokens.pop() - return { text: joinTokens(tokens), ...(slot.table && { table: slot.table }) } + return { text: joinTokens(tokens), ...slot } } function expression(node: Node, document: SourceDocument): Inline[] { diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index 1cefd12..1991a25 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -1,3 +1,5 @@ +// @vitest-environment happy-dom + import type { BlockTreeState, RewriteTree } from './BlockTree' import type { Ask, ChatState } from './qa' import type { BlockView, KindBadge } from './types' @@ -240,3 +242,85 @@ describe('「注释」一行和「重写注释」按钮', () => { expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范') }) }) + +describe('一行译文下面拆开的各项', () => { + /** 一段 Python 里函数体的第一块(return)经过宿主转成的视图数据;Filter 的定义是类。 */ + async function returned(source: string): Promise { + const language = createPythonAdapter(WASM_DIR) + const views = await withBlocks(language, 'python', source, blocks => toBlockViews(blocks, { source, language, markers: [], calleeKind: call => call.callee.text === 'Filter' ? 'class' : undefined }, pythonZhTranslator)) + return views[0].children[0] + } + + const SOURCE = [ + 'def list_filters(archived, current_user):', + ' return [', + ' Filter(field="user_id", value=current_user.id),', + ' Filter(', + ' field="archived_at",', + ' value=None,', + ' ),', + ' {"k": v},', + ' ]', + ].join('\n') + + /** 画成页面(只画这一块),collapsed 是折叠着的 id */ + function page(block: BlockView, collapsed: ReadonlySet = new Set(), diff?: BlockTreeState['diff']): HTMLElement { + const root = document.createElement('div') + root.innerHTML = renderToStaticMarkup() + return root + } + + function byId(root: HTMLElement, id: string): HTMLElement { + const found = root.querySelector(`[data-id="${id}"]`) + if (!found) + throw new Error(`页面上没有 ${id}`) + return found + } + + /** a 在页面上排在 b 前面 */ + function before(a: Element, b: Element): boolean { + return (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 + } + + it('各项排在译文下面、往右缩一层、左边一条竖线,和变量初始值的各项一样;项下面挂着它的实参表,排在下一项上面', async () => { + const block = await returned(SOURCE) + const root = page(block) + const line = root.querySelector('.cr-code') + expect(line?.textContent).toBe('返回 列表 3 项') + const first = byId(root, `${block.id}/i/0`) + const second = byId(root, `${block.id}/i/1`) + expect(first.textContent).toBe('第 1 项新建 Filter · 2 个参数') + expect(second.textContent).toBe('第 2 项新建 Filter · 2 个参数') + const cell = byId(root, `${block.id}/i/1/t/0`) + expect(cell.textContent).toBe('field"archived_at"') + expect(before(line!, first) && before(second, cell) && before(cell, byId(root, `${block.id}/i/2`))).toBe(true) + expect(first.closest('.cr-table')).toBeNull() + let level = first.parentElement + while (level && !level.firstElementChild?.classList.contains('cr-guide')) + level = level.parentElement + expect(level?.contains(second)).toBe(true) + expect(level?.contains(line)).toBe(false) + }) + + it('项下面的下一层(字典)能折叠,折着时只收起那一层;实参表总是展开的', async () => { + const block = await returned(SOURCE) + const map = `${block.id}/i/2` + const open = page(block) + expect(byId(open, map).querySelector('button')?.getAttribute('aria-expanded')).toBe('true') + expect(byId(open, `${map}/0`).textContent).toBe('"k"v') + const folded = page(block, new Set([map])) + expect(folded.querySelector(`[data-id="${map}/0"]`)).toBeNull() + expect(folded.querySelector(`[data-id="${block.id}/i/0/t/0"]`)).not.toBeNull() + }) + + it('标差异:新加的项淡绿底,删掉的项放回原来的位置、划掉', async () => { + const block = await returned(SOURCE) + const gone = { cells: [[{ text: '第 3 项', role: 'hint' as const }], [{ text: 'old', role: 'variable' as const }]] } + const changes = new Map([[block.id, { change: 'mod' as const, own: true as const, marks: { items: { changed: { 2: { added: true as const } }, removed: [{ after: 1, value: gone }] } } }]]) + const root = page(block, new Set(), { changes, removed: new Map(), current: true }) + expect(byId(root, `${block.id}/i/2`).dataset.diff).toBe('add') + const removed = root.querySelector('[data-diff="del"]') + expect(removed?.textContent).toBe('第 3 项old') + expect(before(byId(root, `${block.id}/i/1`), removed!) && before(removed!, byId(root, `${block.id}/i/2`))).toBe(true) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 4b4ba10..dffc090 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -13,10 +13,10 @@ import { CodeEditor } from './CodeEditor' import { DiffText } from './DiffText' import { removedTitle } from './diffView' import { KindIcon } from './kindIcons' -import { PropertyGrid, PropertyTable } from './PropertyGrid' +import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid' import { asksOf, historyOf, unfinished } from './qa' import { TextPreview } from './TextPreview' -import { tableId } from './viewState' +import { listId, tableId } from './viewState' /** * 块树里改代码的那部分:哪些块在编辑、点「编辑」。页面上的块树才有;定义卡片浮窗里的块树没有, @@ -234,17 +234,24 @@ function BlockRewrite({ anchor, rewrite }: { anchor: string, rewrite: RewriteTre /** * 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文; - * 译文下面依次接着摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。 + * 译文下面依次接着拆开的各项(跨几行写的字面量,排法同变量初始值下面的)、摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。 + * 标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。 */ function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTreeState, marks?: BlockMarks }): ReactElement { if ('properties' in block) return + const folding = { collapsed: tree.collapsed, onToggleFold: tree.onToggleFold } return ( <> {marks?.translation ? : } + {block.items && ( +
+ +
+ )} {block.table && (
- +
)} {marks?.doc ? : block.doc && } diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index 8dbe00c..9a06a45 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -133,12 +133,12 @@ function Nested({ children }: { children: ReactNode }): ReactElement { } /** - * 展开在一行或一个子行(parentId)下面的一层子行:左边一条竖线,每个子行几列对齐。 + * 展开在一行或一个子行(parentId)下面的一层子行:左边一条竖线,每个子行几列对齐。一行译文下面拆开的各项也用它。 * 带下一层的子行(字典 / 对象、跨几行写的列表的各项),下一层展开在它下面、再往右缩进一层,默认展开,能折叠; * 带实参表的子行(列表里整项是调用),表排在它下面、同样往右缩进一层,总是展开。 * 标差异时(marks)新加的子行淡绿底、左边 +,删掉的放回原来的位置、淡红底划掉、左边 −,改了的格子逐截标。 */ -function PropertyItems({ parentId, items, folding, marks }: { parentId: string, items: PropertyItem[], folding: Folding, marks?: ItemsDiff }): ReactElement { +export function PropertyItems({ parentId, items, folding, marks }: { parentId: string, items: PropertyItem[], folding: Folding, marks?: ItemsDiff }): ReactElement { const nested = items.some(item => (item.items?.length ?? 0) > 0) /** 一个子行:删掉的(change 是 del)不带 id、下面的子行总是展开 */ const itemRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined): ReactElement => { diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index f7c36f4..781bd99 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -134,6 +134,23 @@ describe('跳到定义:滚到哪块、展开哪些', () => { expect(occurrencePlan(blocks, { start: 140, end: 147, kind: 'read' }, () => false)).toEqual({ id: '1', expand: [] }) }) + it('名字在一行译文下面拆开的各项里:各项总是展开的,讲这个定义的项(字典的键)只闪它;项下面的下一层要展开那一项,实参表总是展开的', () => { + const blocks: BlockView[] = [ + { ...textBlock('0', 'return', 0, [word('返回'), word('列表 2 项')]), items: [ + { cells: [[word('第 1 项')], [word('新建'), name('Filter', 10)]], table: { columns: ['参数', '值'], items: [{ cells: [[name('field', 20)], [name('user_id', 30)]] }] } }, + { cells: [[word('第 2 项')], [word('字典 1 项')]], items: [{ cells: [[name('key', 40)], [name('value', 45)]], defines: { startIndex: 40, endIndex: 43 } }] }, + ] }, + { ...textBlock('1', 'return', 5, [word('返回'), word('对象 1 项')]), items: [{ cells: [[name('locked', 60)], [name('flag', 70)]], defines: { startIndex: 60, endIndex: 66 } }] }, + ] + expect(revealPlan(blocks, 0, 10)).toEqual({ id: '0', expand: [] }) + expect(revealPlan(blocks, 0, 30)).toEqual({ id: '0', expand: [] }) + expect(revealPlan(blocks, 0, 40)).toEqual({ id: '0', row: '0/i/1/0', expand: ['0/i/1'] }) + expect(revealPlan(blocks, 0, 45)).toEqual({ id: '0', expand: ['0/i/1'] }) + expect(revealPlan(blocks, 5, 60)).toEqual({ id: '1', row: '1/i/0', expand: [] }) + expect(occurrencePlan(blocks, { start: 45, end: 50, kind: 'read' }, () => false)).toEqual({ id: '0', expand: ['0/i/1'] }) + expect(occurrencePlan(blocks, { start: 30, end: 37, kind: 'read' }, () => false)).toEqual({ id: '0', expand: [] }) + }) + it('名字在框里(套了几层都算)也是显示着它', () => { const inner = { style: 'iteration' as const, label: '列表', segments: [word('遍历'), name('row', 30), word('取'), name('y', 40)] } const blocks: BlockView[] = [ diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index 390777b..afc5b3b 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -1,7 +1,7 @@ import type { BlockView, Inline, Occurrence, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from './types' import { blockPath } from './filter' import { showsOccurrence, sourceOf } from './occurrences' -import { propertyItemId, propertyRowId, tableId, unfoldToShow } from './viewState' +import { listId, propertyItemId, propertyRowId, tableId, unfoldToShow } from './viewState' /** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */ export interface RevealPlan { @@ -87,34 +87,46 @@ function rowItems(row: PropertyRow, id: string): Generator { return nestedItems(row.items, id, [id], !row.columns) } -/** 一行译文下面那张实参表(总是展开的)里一层层的子行。 */ -function tableItems(blockId: string, table: PreviewTable | undefined): Generator { - return nestedItems(table?.items, tableId(blockId), [], false) +/** 一行译文下面一层层的子行:拆开的各项(这一层总是展开的,项下面的下一层折叠在项上),摊开实参的表(总是展开的)。 */ +function* textItems(blockId: string, items: PropertyItem[] | undefined, table: PreviewTable | undefined): Generator { + yield* nestedItems(items, listId(blockId), [], true) + yield* nestedItems(table?.items, tableId(blockId), [], false) +} + +/** 一层层的子行里,讲 offset 处这个定义的那一个(defines 盖住 offset):只闪它,展开它上面的各层。 */ +function definedItem(items: Iterable, offset: number): Spot | undefined { + for (const { item, id, expand } of items) { + if (item.defines && covers(item.defines, offset)) + return { row: id, expand, span: width(item.defines) } + } + return undefined } /** * 块里哪儿是 offset 处的定义,从具体到笼统: * ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块; - * ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它, + * ② 属性表、一行译文下面的子行里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它, * 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开; - * ③ 块里只是显示着这个名字(一段源码范围盖住 offset:一行译文和它下面的表里的名字、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。 + * ③ 块里只是显示着这个名字(一段源码范围盖住 offset:一行译文和它下面拆开的各项、表里的名字,没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。 * span 是盖住 offset 的那段有多长。不在这块里是 undefined。 */ function locate(block: BlockView, offset: number): Spot | undefined { if (block.defines && covers(block.defines, offset)) return { expand: [], span: width(block.defines) } if ('translation' in block) { + const defined = definedItem(textItems(block.id, block.items, block.table), offset) + if (defined) + return defined const span = textSpan(block.translation, offset) - return span === undefined ? shownItem(tableItems(block.id, block.table), offset) : { expand: [], span } + return span === undefined ? shownItem(textItems(block.id, block.items, block.table), offset) : { expand: [], span } } const rows = block.properties.map((row, index) => ({ row, id: propertyRowId(block.id, index) })) for (const { row, id } of rows) { if (row.defines && covers(row.defines, offset)) return { row: id, expand: [], span: width(row.defines) } - for (const { item, id: itemId, expand } of rowItems(row, id)) { - if (item.defines && covers(item.defines, offset)) - return { row: itemId, expand, span: width(item.defines) } - } + const defined = definedItem(rowItems(row, id), offset) + if (defined) + return defined } for (const { row, id } of rows) { const value = textSpan(row.value, offset) @@ -186,10 +198,10 @@ export function uncoveredPlan(plan: RevealPlan, covered: ReadonlyMap boolean): string[] | undefined { if ('translation' in block) - return test(block.translation) ? [] : showingItem(tableItems(block.id, block.table), test) + return test(block.translation) ? [] : showingItem(textItems(block.id, block.items, block.table), test) for (const [index, row] of block.properties.entries()) { if (test(row.value)) return [] diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 34bee55..40197ef 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -181,9 +181,10 @@ export type CommentTarget /** * 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文; - * 一行译文下面依次是:跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文),几行属性(rows:「注释」一行) + * 一行译文下面依次是:跨几行写的字面量拆开的各项(items),跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文), + * 几行属性(rows:「注释」一行) */ -export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] }) +export type BlockView = BlockViewBase & ({ translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] }) export interface TranslatorOption { id: string, label: string } @@ -407,10 +408,11 @@ export interface RowDiff { items?: ItemsDiff } -/** 一块自己的内容(不算子块)改了哪些:属性表(一行译文的卡片是下面那几行 rows)逐行,一行译文逐截,摊开实参的表逐行,说明逐词 */ +/** 一块自己的内容(不算子块)改了哪些:属性表(一行译文的卡片是下面那几行 rows)逐行,一行译文逐截,译文下面拆开的各项、摊开实参的表逐行,说明逐词 */ export interface BlockMarks { rows?: RowsDiff translation?: TextDiff + items?: ItemsDiff table?: ItemsDiff doc?: TextDiff } diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index 70eedbf..159c1ff 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -124,6 +124,14 @@ export function tableId(ownerId: string): string { return `${ownerId}/t` } +/** + * 一行译文下面拆开的各项(跨几行写的列表、字典)的上一层 id:`<块 id>/i`,各项的 id 是 propertyItemId(它, 项号)。 + * 这一层总是展开的,项下面的下一层照常折叠在项的 id 上。 + */ +export function listId(blockId: string): string { + return `${blockId}/i` +} + /** * 父块看得见时,要让它的一个子块也看得见得展开的(块 id、属性行 id):一般的子块是父块自己(收起子块时不画); * 挂在父卡片子行上的子块(BlockSlot)是它所在的那一行和它自己。