diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 1ee6097..e1ab86f 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -19,7 +19,7 @@ | 多种翻译怎么选 | 预览顶部下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 | | 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 | | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | -| 折叠 | 有子块的块、属性表里带子行的行能折叠;函数块(含方法、嵌套函数)和类块默认收起主体、属性表照常显示,其余块和属性行默认展开;用户点过的按他点的记住 | +| 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)和类块默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 | | 函数、类的说明 | TypeScript 紧贴在函数、类前面的 JSDoc,Python 函数、类主体开头的 docstring 是它自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数、类块的头部里,「编辑」时和签名一起改 | | 注释按 Markdown 显示 | 注释块和函数、类的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | | 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | @@ -27,6 +27,7 @@ | 文件注释 | 读代码时给目录 / 文件随手记一句:存在各工作区文件夹的 `.spx/code-notes.json`,跟着仓库走;在 VS Code 里改名、挪位置、删除时注释跟着变 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | | 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | +| 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 | ## 用户可见行为 @@ -75,14 +76,14 @@ ### 属性表(函数、类、导入、常量、变量块) -卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`)。输入参数的子行分名称 / 类型 / 默认值三列对齐,前两列宽 150px / 220px,编辑区窄时按 28% / 38% 收,默认值列占剩下的。 +卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`)。输入参数的子行分名称 / 类型 / 默认值三列对齐,前两列宽 150px / 220px,编辑区窄时按 28% / 38% 收,默认值列占剩下的;字典 / 对象的子行分键 / 值两列,键列同名称列。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和参数、导入项的子行一样)。 | 种类 | 行 | | --- | --- | | 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,`@param` 这类标签行照抄;得到的原文按 Markdown 渲染,见「注释的 Markdown 排版」;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(`默认 ` + 译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | | 类 | 名称(类型色;匿名类 `export default class {}` 写「匿名」;赋给变量、类字段的类表达式写变量名、字段名)/ 说明(有说明才显示,规则同函数)/ 访问级别 / 抽象(是 / 否,只有 TypeScript 有,看 `abstract class`,TypeScript 的类都有这一行)/ 装饰器(同函数)/ 类型参数(有才显示:TypeScript `` 每个参数的译文,逗号隔开,如 `K, T 限定为 X = Y`;Python 3.12 的 `class G[T]` 同样)/ 父类(有才显示:TypeScript `extends` 后面的表达式连着泛型实参的译文,如 `Base<字符串>`;Python 类名后面括号里的参数列表照写的顺序逐项翻,关键字参数、`*bases` 也在这一行,如 `Base, metaclass=ABCMeta`;逗号隔开)/ 实现的接口(有才显示:TypeScript `implements` 后面每个类型的译文,逗号隔开) | | 导入 | 来源(TypeScript 是字符串字面量原样,如 `'node:fs'`,字符串色;Python 是点分模块名,如 `fastapi`、`.models`,类型色)/ 仅类型(是 / 否,只有 TypeScript 有,看 `import type`)/ 导入项(`N 个` 或「无」,每项一行:`name`、`name 作为 alias`;默认导入写「默认导出 作为 x」,`* as x` 写「全部 作为 x」,Python `from m import *` 写「全部」,Python `import a.b as c` 写「整个模块 作为 c」,一条 `import a, b` 导入多个模块时写模块名) | -| 常量 / 变量 | 名称(解构声明写整个模式,如 `{ p, q }`)/ 访问级别 / 类型(写了标注才有)/ 值(常量)或初始值(变量):初始化表达式的译文,没有写「无」。一条语句声明几个变量(`let a = 1, b = 2`)就重复几组 | +| 常量 / 变量 | 名称(解构声明写整个模式,如 `{ p, q }`)/ 访问级别 / 类型(写了标注才有)/ 值(常量)或初始值(变量):初始化表达式的译文,没有写「无」;是字典 / 对象字面量时见下面「字典 / 对象字面量」。一条语句声明几个变量(`let a = 1, b = 2`)就重复几组 | 访问级别(公共 / 私有 / 受保护): @@ -92,6 +93,16 @@ 值里的表达式只翻落在块头部里的那一段:`const v = useMemo(() => { … })` 这种值带语句块主体回调的块,回调主体是子块,值只翻到 `useMemo(() =>`,和块头部一致。 +字典 / 对象字面量(常量、变量的值,含 Python 类主体里不带注解的类属性;参数默认值照旧是一行译文): + +- 值写项数:Python `字典 N 项`、TypeScript `对象 N 项`(按 hint 着色),各项展开成子行,照源码顺序,注释不算一项。 +- 每项两列:键 | 值。键照源码写法:字符串键带引号、按字符串着色,名字键按名字着色,计算属性名 `[k]` 照常翻;值照常直译(`等待 …`、`空数组`……)。 +- 值又是字典 / 对象字面量时,它这一项的值也写项数,它的各项展开成下一层子行,一层层往下套。 +- 展开:Python `**other`、TypeScript `...other` 是一项,键写「展开」(hint),值是展开的表达式。 +- TypeScript 简写 `{ a }`:键 `a`,值 `a`。对象里的方法、getter、setter:键是方法名(函数色),值是方法头部(到主体开始之前)的译文,如 `异步 load(id: 字符串): Promise<无>`、`get size()`。 +- 空字面量不展开:值是一行 `空字典` / `空对象`(一行译文里也这样翻,同 `空数组`)。 +- `as const`、`satisfies` 包着的对象(`{ … } as const`)不算字面量本身,照旧一行译文。 + ### 注释的 Markdown 排版 - 用在:注释块(`#`、`//`、`/* */`、不挂在函数、类上的 JSDoc、Python 模块开头的 docstring)的预览,函数、类属性表的「说明」。视图数据里它们是 `{ markdown }`(见「通用契约」的 `Markdown`),webview 按数据决定怎么渲染,不猜。 @@ -103,7 +114,7 @@ ### 跳到定义 -- 能点的:预览里(属性表的名称、类型、来源、导入项、默认值、值、类型参数、父类、实现的接口,以及一行译文)照抄自源码的名字——标识符、属性名、类型名、点分模块名的每一节(`luffy_agent.core.db` 三节各自能点,和 VS Code 一样)、字符串(TypeScript 的模块路径 `'./x'` 就是字符串)。翻出来的关键字、运算符、说明词不能点。 +- 能点的:预览里(属性表的名称、类型、来源、导入项、默认值、值、字典 / 对象各层子行的键和值、类型参数、父类、实现的接口,以及一行译文)照抄自源码的名字——标识符、属性名、类型名、点分模块名的每一节(`luffy_agent.core.db` 三节各自能点,和 VS Code 一样)、字符串(TypeScript 的模块路径 `'./x'` 就是字符串)。翻出来的关键字、运算符、说明词不能点。 - 链接和编辑器里一样:只有按住修饰键(macOS ⌘,其余平台 Ctrl)、鼠标停在**查得到定义**的名字上时,名字加下划线、换成链接色(`--vscode-editorLink-activeForeground`)、光标变手形;松开修饰键或移开就恢复。有没有定义问扩展端(`vscode.executeDefinitionProvider`,文档 uri + 名字在源码里的位置);和编辑器一样,每回指到一个名字上(或指着名字按下修饰键、换了文档版本)都现问,回答不留到下次用——Pylance、tsserver 刚启动那几秒会答「没有」;只认最近一次询问的回答,迟到的丢掉。 - ⌘ / Ctrl + 点击: - 一处定义,在当前文件:原地滚过去; @@ -114,10 +125,10 @@ - 和普通编辑器里 ⌘ + 点击一样:目标是否按「预览标签」打开听 `workbench.editor.enablePreview`;`workbench.editor.enablePreviewFromCodeNavigation` 为假(默认)时先把当前预览标签固定住,免得被目标替掉。 - 闪哪一处:页面上**就是这处定义**的最具体的那一处,只按源码位置判断(定义提供者给的定义处下标,对上块和属性行带的源码范围),不看属性名文字: 1. 定义是块自己的名字(函数名、类名;只声明一个变量时的变量名,解构模式整个算)→ 整块。块的这段范围由扩展端从 facts 取(`BlockView.defines`),和翻译插件无关。 - 2. 定义是属性表里某一行 / 子行讲的东西(参数、导入项、一条语句声明几个变量时的某一个、类的泛型参数)→ 只闪这一行 / 子行。范围是翻译插件从 facts 取的节点(`PropertyRow.defines`、`PropertyItem.defines`),定义处落在范围里就算(`*args` 的定义在 `args` 上、解构参数里的名字都对得上)。 - 3. 其余(定义在一行译文里,例如循环变量;翻译插件没填 `defines`)→ 显示着定义处那个名字的块,名字在属性行的子行里时展开那一行;页面上找不到这个名字就取包含定义那一行的块。 + 2. 定义是属性表里某一行 / 子行讲的东西(参数、导入项、一条语句声明几个变量时的某一个、类的泛型参数、对象字面量一项的键——TypeScript 对象的属性名就是定义,嵌套几层都算)→ 只闪这一行 / 子行。范围是翻译插件从 facts 取的节点(`PropertyRow.defines`、`PropertyItem.defines`;字典 / 对象的项标的是键,展开项不标),定义处落在范围里就算(`*args` 的定义在 `args` 上、解构参数里的名字都对得上)。 + 3. 其余(定义在一行译文里,例如循环变量;翻译插件没填 `defines`)→ 显示着定义处那个名字的块,名字在属性行的子行里时展开那一行(在嵌套的子行里时连它上面各层一起展开);页面上找不到这个名字就取包含定义那一行的块。 - 同一文件原地跳、跳进另一个文件的预览,规则一样。 -- 滚过去:目标所在块的祖先块折叠着就展开,目标在折叠的属性行子行里(参数、导入项)就展开那一行。目标已在视口里不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一处再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。目标是属性行 / 子行但块正在编辑(页面上没有属性表)时闪整块。 +- 滚过去:目标所在块的祖先块折叠着就展开,目标在折叠的属性行子行里(参数、导入项、字典 / 对象的项)就展开那一行,在嵌套的子行里就连它上面折叠着的各层子行一起展开。目标已在视口里不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一处再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。目标是属性行 / 子行但块正在编辑(页面上没有属性表)时闪整块。 - 不按修饰键的点击照旧;⌘ / Ctrl + 点击名字不会触发块的编辑切换、折叠。 ## 通用契约 @@ -182,7 +193,18 @@ export interface ImportFacts { items: ImportItem[] } -export interface DeclaratorFacts { name: Node, type?: Node, value?: Node } +/** 字典 / 对象字面量里的一项,照源码顺序;注释不算。 */ +export type EntryFacts = + | { kind: 'pair', key: Node, value: Node, entries?: EntryFacts[] } // 'a': 1、a: 1、[k]: v;TS 简写 { a } 键和值是同一个节点;entries 同 DeclaratorFacts.entries + | { kind: 'spread', value: Node } // **other、...other:value 是展开的表达式 + | { kind: 'method', name: Node, node: Node, header: SourceSpan } // 对象里的方法、getter、setter:header 同 BlockShape.header,只到主体开始之前 + +export interface DeclaratorFacts { + name: Node + type?: Node + value?: Node + entries?: EntryFacts[] // 值是字典 / 对象字面量(Python dictionary、TS object)时是它的各项,空字面量是空数组;别的值没有 +} export interface VariableFacts { access?: Access @@ -238,15 +260,15 @@ export type PreviewText = Segment[] | Markdown /** * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts)在源码里的范围—— - * 参数名(连 `*` 前缀、整个解构模式)、导入项(名称到别名)、声明的变量名、类的泛型参数(整串)。跳到的定义落在里面就只闪这一行; + * 参数名(连 `*` 前缀、整个解构模式)、导入项(名称到别名)、声明的变量名、类的泛型参数(整串)、字典 / 对象一项的键。跳到的定义落在里面就只闪这一行; * 第三方翻译插件可以不填,不填就闪整块。 */ export interface Defines { defines?: SourceRange } -/** 属性表里展开的一个子行:几列对齐的格子。 */ -export interface PropertyItem extends Defines { cells: Segment[][] } +/** 属性表里展开的一个子行:几列对齐的格子;items 是展开在它下面的下一层子行(嵌套的字典 / 对象),一层层可以一直往下套。 */ +export interface PropertyItem extends Defines { cells: Segment[][], items?: PropertyItem[] } -/** 属性表的一行:属性名、着色的值或 Markdown 说明(函数、类的「说明」)、展开在下面的子行(输入参数、导入项)。 */ +/** 属性表的一行:属性名、着色的值或 Markdown 说明(函数、类的「说明」)、展开在下面的子行(输入参数、导入项、字典 / 对象的各项)。 */ export interface PropertyRow extends Defines { label: string, value: PreviewText, items?: PropertyItem[] } /** 语言插件:语法树 → 代码块树。 */ @@ -279,7 +301,7 @@ export interface CodeReaderApi { - `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 - 内置的 TypeScript、Python 语言插件和两个中文直译翻译插件在 `activate()` 里通过同一个 API 注册,没有旁路。 - 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。 -- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译。第三方语言插件给函数、类、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表。 +- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译。第三方语言插件给函数、类、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。 ## 解析运行时 @@ -327,11 +349,11 @@ export interface CodeReaderApi { - TypeScript 函数:`function_declaration` / `generator_function_declaration` / `function_signature`、类里的 `method_definition` / 方法签名、值是带语句块主体函数的变量和类字段、`export default` 的函数值。名称取声明名 / 变量名 / 字段名;说明取紧贴在前面的 JSDoc 注释节点(见上面代码块树规则);参数来自 `formal_parameters`(`required_parameter` / `optional_parameter` 的 pattern、type、value,JavaScript 的 `assignment_pattern`),不带括号的单参数箭头函数取 `parameter`;返回类型取 `return_type` 里的类型;异步看有没有 `async`;类成员的装饰器是类主体里排在它前面的 `decorator` 兄弟节点。 - TypeScript 类:`class_declaration` / `abstract_class_declaration`、类表达式 `class`(`export default class {}`、变量的值、类字段的值)。名称取类名 / 变量名 / 字段名;说明同函数;访问级别同函数(类字段按类成员);抽象看节点类型是不是 `abstract_class_declaration`;装饰器是 `export_statement` 和类声明自己的 `decorator` 子节点(`@d export class`、`export @d class`、`@d class` 都算),类字段的值是类表达式时还有类主体里排在它前面的 `decorator`;泛型参数是 `type_parameters` 里每个 `type_parameter`;父类、实现的接口在 `class_heritage` 里:`extends_clause` 的每个 value 一项,跟在它后面的 `type_arguments` 归它,`implements_clause` 里每个类型一项;JavaScript 的 `class_heritage` 直接是 `extends` 后面的表达式。 - TypeScript 导入:`import_statement` 的 `source`(`import x = require()` 取 `import_require_clause` 里的);`import_clause` 里的标识符是默认导入,`namespace_import` 是 `* as`,`named_imports` 里每个 `import_specifier` 的 name / alias;语句本身带 `type` 是仅类型。 -- TypeScript 变量:每个 `variable_declarator` 的 name / type / value。 +- TypeScript 变量:每个 `variable_declarator` 的 name / type / value;value 是 `object` 时 entries 是它的具名子节点(注释除外):`pair` 的 key / value(value 又是 `object` 时连它的 entries)、`shorthand_property_identifier`(键和值都是它)、`spread_element` 里的表达式、`method_definition` 的 name 和头部(到 body 之前)。 - Python 函数:`function_definition` 的 name / parameters / return_type,`async` 子节点;说明是主体开头 docstring 的字符串节点(`string` / `concatenated_string`);装饰器是 `decorated_definition` 里的 `decorator`;参数 `identifier`、`typed_parameter`(名字是第一个具名子节点)、`default_parameter`、`typed_default_parameter`、`*args` / `**kwargs`,`*` 和 `/` 分隔符不算参数。 - Python 类:`class_definition` 的 name;说明是主体开头 docstring 的字符串节点;装饰器同函数;泛型参数是 Python 3.12 `type_parameters` 里的每一项;父类是 `superclasses`(`argument_list`)里的每个具名子节点,照写的顺序,关键字参数(`metaclass=ABCMeta`)、`*bases`、`**kw` 也算;没有抽象、实现的接口。 - Python 导入:`import_statement` 每个 name 一个来源、导入项是整个模块;`import_from_statement` 的 module_name 和各 name(`aliased_import` 拆出 alias),`wildcard_import` 是全部;`future_import_statement` 的来源是 `__future__`。 -- Python 变量:`assignment` 的 left / type / right。 +- Python 变量:`assignment` 的 left / type / right;right 是 `dictionary` 时 entries 是它的具名子节点(注释除外):`pair` 的 key / value(value 又是 `dictionary` 时连它的 entries)、`dictionary_splat` 里的表达式。 ## 中文模板直译 @@ -341,9 +363,9 @@ export interface CodeReaderApi { 2. 内置类型:只在类型位置翻(TS 的 `predefined_type`、Python 注解里的内置类型名)。 3. 运算符:`===` 全等、`!==` 不全等、`==` 等于、`!=` 不等于、`&&` 并且、`||` 或者、`!` 非(Python 的 `not` / `and` / `or` 本身是关键字)。 -同一套表和节点模板也用来翻属性表里的值:引擎能翻任意一个节点(只取它落在块头部里的那一段),参数类型、默认值、返回类型、变量类型和初始值都走这条路。 +同一套表和节点模板也用来翻属性表里的值:引擎能翻任意一个节点(只取它落在块头部里的那一段),参数类型、默认值、返回类型、变量类型和初始值、字典 / 对象各项的键和值都走这条路。 -其他规则:空数组字面量 → 空数组(Python 空列表 → 空列表、空字典 → 空字典);复合语句头部去掉结尾的 `{` 或 `:`;多行合成一行(连续空白压成一个空格,右括号前的尾逗号去掉);注释块给去掉注释符号的 Markdown 原文(见「注释的 Markdown 排版」)。关键字与前后内容之间保留源码里的空格(`!` 翻成「非」后补一个空格)。 +其他规则:空数组字面量 → 空数组、空对象字面量 → 空对象(Python 空列表 → 空列表、空字典 → 空字典);复合语句头部去掉结尾的 `{` 或 `:`;多行合成一行(连续空白压成一个空格,右括号前的尾逗号去掉);注释块给去掉注释符号的 Markdown 原文(见「注释的 Markdown 排版」)。关键字与前后内容之间保留源码里的空格(`!` 翻成「非」后补一个空格)。 关键字按所在语法节点选择译法:同一个关键字在不同语法结构里意思不同时,表里写一个默认译法,再加若干「语境 → 译法」。语境是从关键字节点往上的节点类型链,外层在前:`raise_statement` 指父节点是 raise_statement,`for_statement > else_clause` 指父节点是 else_clause、祖父节点是 for_statement(关键字包在一层子句节点里时要看到祖父节点)。几条语境同时匹配时取最长的,都不匹配用默认译法。节点类型以 tree-sitter 语法为准。下面两张表里写成「(语境)译法 / (语境)译法」的关键字就是按这条规则区分的,排第一个的是默认译法。 @@ -390,6 +412,10 @@ Python 内置类型(仅注解里):str 字符串、int 整数、float 浮 | `from .schemas import (⏎ UserAvatarUploadResponse, … )`(routes.py 19–23 行) | 来源 `.schemas` / 导入项 3 个:UserAvatarUploadResponse、UserDetailResponse、UserUpdateRequest | | `payload = await file.read()`(routes.py,函数体里) | 名称 payload / 初始值 `等待 file.read()` | | `_default_port = 5680 if _role == "beat" else 5679` | 名称 _default_port / 访问级别 私有 / 初始值 `如果 _role 等于 "beat" 则 5680 否则 5679` | +| `_CONTEST_STATUS_KEYS = {⏎ "published": "contests:publish", "ended": "contests:end", "draft": "contests:update" }` | 名称 _CONTEST_STATUS_KEYS / 访问级别 私有 / 初始值 字典 3 项:`"published" \| "contests:publish"`、`"ended" \| "contests:end"`、`"draft" \| "contests:update"` | +| `CONFIG = {"db": {"host": host, "opts": {}}, **defaults, 1: await load()}` | 名称 CONFIG / 访问级别 公共 / 值 字典 3 项:`"db" \| 字典 2 项`(下一层 `"host" \| host`、`"opts" \| 空字典`)、`展开 \| defaults`、`1 \| 等待 load()` | +| `const result: EnvLockResult = { total: files.length, ok: [], failed: [] }`(envLock.ts 40 行) | 名称 result / 类型 EnvLockResult / 初始值 对象 3 项:`total \| files.length`、`ok \| 空数组`、`failed \| 空数组` | +| `export const handlers = { total: files.length, 'x-y': await load(), [key]: !ok, files, ...rest, async load(id: string): Promise { … }, nested: { a: [], b: { c: 1 } } }` | 名称 handlers / 访问级别 公共 / 值 对象 7 项:`total \| files.length`、`'x-y' \| 等待 load()`、`[key] \| 非 ok`、`files \| files`、`展开 \| rest`、`load \| 异步 load(id: 字符串): Promise<无>`、`nested \| 对象 2 项`(下一层 `a \| 空数组`、`b \| 对象 1 项`,再下一层 `c \| 1`) | | `export abstract class Repository extends Base implements Loader, Disposable {`(classes.ts 3–9 行,连同前面的 JSDoc 和 `@Injectable()`) | 名称 Repository / 说明 `仓库基类。…` / 访问级别 公共 / 抽象 是 / 装饰器 `@Injectable()` / 类型参数 `K, T 限定为 Entity = Entity` / 父类 `Base<字符串>` / 实现的接口 `Loader, Disposable` | | `export default class {` | 名称 匿名 / 访问级别 公共 / 抽象 否 | | `class Meta(Base, metaclass=ABCMeta):`(classes.py,带 `@dataclass(frozen=True)`) | 名称 Meta / 访问级别 公共 / 装饰器 `@dataclass(frozen=True)` / 父类 `Base, metaclass=ABCMeta` | @@ -425,7 +451,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: P - `{ type: 'ready' }` - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 -- 块的编辑状态和用户的折叠选择在 webview 里按块 id 记(`vscode.setState`):`{ editing: string[], folds: Record, scroll? }`。`folds` 只记用户点过的(id → 折叠着没有),属性行也记在里面,id 是 `<块 id>/p<行序号>`(例如 `6.1.0/p3`);没记的按默认:函数、类块折叠,其余块和属性行展开。眼下折叠着哪些由「默认 + folds」算出来,界面、跳到定义的展开都只看这一份。跳到定义展开祖先块、属性行时也记进 `folds`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 +- 块的编辑状态和用户的折叠选择在 webview 里按块 id 记(`vscode.setState`):`{ editing: string[], folds: Record, scroll? }`。`folds` 只记用户点过的(id → 折叠着没有),属性行和带下一层的子行也记在里面:属性行 id 是 `<块 id>/p<行序号>`(例如 `6.1.0/p3`),子行 id 是上一层(行或子行)的 id 加 `/<子行序号>`,一层层往后接(例如 `6.1.0/p3/1` 是第 3 行的第 1 个子行,`6.1.0/p3/1/0` 是它下面的第 0 个);块 id 只有数字和点,只有行那一节带 `p`、其余各节是纯数字,所以块、行、各层子行的 id 互不相撞。子行 id 也是跳到定义时在页面上找要闪的那一项用的。没记的按默认:函数、类块折叠,其余块、属性行和子行展开。眼下折叠着哪些由「默认 + folds」算出来,界面、跳到定义的展开都只看这一份。跳到定义展开祖先块、属性行时也记进 `folds`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 ## 颜色 @@ -444,7 +470,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: P | hint | `--vscode-descriptionForeground` | 同左 | | plain | `--vscode-editor-foreground` | 同左 | -属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、默认、默认导出、全部、整个模块、匿名按 hint。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。 +属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认、默认导出、全部、整个模块、匿名按 hint。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。 种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框)。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 @@ -479,8 +505,9 @@ 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 关键字参数、下划线私有。 - 块头部:各样例里块的头部行互不重叠;函数、类说明所在的行在函数、类块里,「编辑」写回原样不变。 -- 折叠:默认函数、类块折叠、其余展开;用户的选择盖过默认、重新加载后还在;旧版本存的状态不报错;跳到定义时默认折叠的函数、类也会展开。 -- 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则。 +- 折叠:默认函数、类块折叠、其余展开;用户的选择盖过默认、重新加载后还在(嵌套子行的也是);各层子行的 id 互不相撞;旧版本存的状态不报错;跳到定义时默认折叠的函数、类也会展开。 +- 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则;字典 / 对象字面量的各项:Python 字符串键、名字键、嵌套字典、`**` 展开、空字典、类属性,TypeScript 名字键、字符串键、计算属性名、简写、展开、方法和 getter(头部到主体之前)、嵌套对象、空对象,不是字面量的值没有 entries。 +- 字典 / 对象的属性表:项数、键 | 值两列、嵌套一层层展开、「展开」、方法头部、空的一行;键和值里的名字、字符串带下标(嵌套的也是);每项标的定义是键,展开项不标;跳到定义落在对象属性名上只闪那一项、连外层各层一起展开,只是显示着的名字闪整块、展开到它那一层。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。 - 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。 diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 1b983a6..a5a397a 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -85,11 +85,19 @@ export interface ImportFacts { items: ImportItem[] } +/** 字典 / 对象字面量里的一项,照源码顺序;注释不算。 */ +export type EntryFacts + = | { kind: 'pair', key: Node, value: Node, entries?: EntryFacts[] } // 'a': 1、a: 1、[k]: v;TypeScript 的简写 { a } 键和值是同一个节点;entries 同 DeclaratorFacts.entries + | { kind: 'spread', value: Node } // **other、...other:value 是展开的那个表达式 + | { kind: 'method', name: Node, node: Node, header: SourceSpan } // 对象字面量里的方法、getter、setter:node 是整个方法,header 同 BlockShape.header,只到主体开始之前 + export interface DeclaratorFacts { /** 变量名;解构声明是整个模式。 */ name: Node type?: Node value?: Node + /** 值是字典 / 对象字面量(Python 的 dictionary、TypeScript 的 object)时是它的各项,空字面量是空数组;别的值没有。 */ + entries?: EntryFacts[] } export interface VariableFacts { @@ -152,14 +160,18 @@ export type PreviewText = Segment[] | Markdown /** * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts,例如参数名、导入项的名称和别名、 - * 声明的变量名、类的泛型参数)在源码里的范围。跳到定义时,定义处落在这个范围里就只闪这一行;不填(第三方翻译插件可以不填)就闪整块。 + * 声明的变量名、类的泛型参数、字典 / 对象字面量一项的键)在源码里的范围。跳到定义时,定义处落在这个范围里就只闪这一行;不填(第三方翻译插件可以不填)就闪整块。 */ export interface Defines { defines?: SourceRange } -/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值。 */ -export interface PropertyItem extends Defines { cells: Segment[][] } +/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值。 */ +export interface PropertyItem extends Defines { + cells: Segment[][] + /** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项);一层层可以一直往下套。 */ + items?: PropertyItem[] +} -/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项)。 */ +/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项、字典 / 对象的各项)。 */ export interface PropertyRow extends Defines { label: string value: PreviewText diff --git a/vscode/src/codeReader/languages/python.test.ts b/vscode/src/codeReader/languages/python.test.ts index 32b2863..fa00948 100644 --- a/vscode/src/codeReader/languages/python.test.ts +++ b/vscode/src/codeReader/languages/python.test.ts @@ -236,6 +236,35 @@ describe('python 属性表信息(facts)', () => { expect(found[9]).toMatchObject({ name: '_helper', access: 'private' }) expect(found[10]).not.toHaveProperty('access') }) + + it('字典字面量的各项:字符串键、名字键、嵌套字典连它的各项、**展开;空字典是空数组,别的值没有;类属性也有', async () => { + const source = [ + '_KEYS = {', + ' "published": "contests:publish",', + ' key: 1,', + ' # 注释不算一项', + ' "nested": {"a": {}, "b": [1]},', + ' **other,', + '}', + 'EMPTY = {}', + 'ITEMS = [1]', + 'class K:', + ' labels = {"x": 1}', + ].join('\n') + const found = await facts(source) + expect(found[1]).toMatchObject({ access: 'private', declarators: [{ name: '_KEYS', entries: [ + { kind: 'pair', key: '"published"', value: '"contests:publish"' }, + { kind: 'pair', key: 'key', value: '1' }, + { kind: 'pair', key: '"nested"', value: '{"a": {}, "b": [1]}', entries: [ + { kind: 'pair', key: '"a"', value: '{}', entries: [] }, + { kind: 'pair', key: '"b"', value: '[1]' }, + ] }, + { kind: 'spread', value: 'other' }, + ] }] }) + expect(found[8]).toEqual({ access: 'public', declarators: [{ name: 'EMPTY', value: '{}', entries: [] }] }) + expect(found[9]).toEqual({ access: 'public', declarators: [{ name: 'ITEMS', value: '[1]' }] }) + expect(found[11]).toEqual({ access: 'public', declarators: [{ name: 'labels', value: '{"x": 1}', entries: [{ kind: 'pair', key: '"x"', value: '1' }] }] }) + }) }) describe('python 类', () => { diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index 26bcacc..84dfcfa 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -1,4 +1,4 @@ -import type { Access, BlockShape, ClassFacts, CodeBlock, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' +import type { Access, BlockShape, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' import type { CommentRules } from './shared' import * as path from 'node:path' import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared' @@ -230,16 +230,34 @@ function expressionBlock(node: Node, scope: Scope, docstring: boolean): CodeBloc const type = expression.childForFieldName('type') if (scope === 'class' && type) return leafBlock('field', node) + const value = expression.childForFieldName('right') return { kind: scope === 'module' && isConstantTarget(left) ? 'const' : 'variable', ...leafShape(node), facts: { access: scopeAccess(left, scope), - declarators: [{ name: left, type: type ?? undefined, value: expression.childForFieldName('right') ?? undefined }], + declarators: [{ name: left, type: type ?? undefined, value: value ?? undefined, entries: dictionaryEntries(value) }], }, } } +/** 字典字面量的各项:`键: 值`(值又是字典时连它的各项)、`**other`;值不是字典字面量时没有。 */ +function dictionaryEntries(value: Node | null): EntryFacts[] | undefined { + if (value?.type !== 'dictionary') + return undefined + return syntaxChildren(value).flatMap((item): EntryFacts[] => { + if (item.type === 'dictionary_splat') { + const [spread] = syntaxChildren(item) + return spread ? [{ kind: 'spread', value: spread }] : [] + } + const key = item.childForFieldName('key') + const itemValue = item.childForFieldName('value') + if (item.type !== 'pair' || !key || !itemValue) + return [] + return [{ kind: 'pair', key, value: itemValue, entries: dictionaryEntries(itemValue) }] + }) +} + /** elif / else 展平成紧跟 if 的兄弟块。 */ function ifBlocks(node: Node): CodeBlock[] { const blocks = [bodyBlock('if', node, bodyOf(node), 'other')] diff --git a/vscode/src/codeReader/languages/typescript.test.ts b/vscode/src/codeReader/languages/typescript.test.ts index 08e1bae..496e040 100644 --- a/vscode/src/codeReader/languages/typescript.test.ts +++ b/vscode/src/codeReader/languages/typescript.test.ts @@ -325,7 +325,58 @@ describe('typeScript 属性表信息(facts)', () => { }) expect(env[24]).toEqual({ declarators: [{ name: 'folder', value: 'workspace.workspaceFolders?.find(f => f.uri.fsPath === workspaceRoot)' }] }) expect(env[38]).toMatchObject({ name: 'chmodAll', access: 'private', async: true }) - expect(env[40]).toEqual({ declarators: [{ name: 'result', type: 'EnvLockResult', value: '{ total: files.length, ok: [], failed: [] }' }] }) + expect(env[40]).toEqual({ declarators: [{ + name: 'result', + type: 'EnvLockResult', + value: '{ total: files.length, ok: [], failed: [] }', + entries: [ + { kind: 'pair', key: 'total', value: 'files.length' }, + { kind: 'pair', key: 'ok', value: '[]' }, + { kind: 'pair', key: 'failed', value: '[]' }, + ], + }] }) + }) + + it('对象字面量的各项:各种键、简写(键和值是同一个节点)、展开、方法(头部到主体之前)、嵌套对象连它的各项;空对象是空数组,别的值没有', async () => { + const source = [ + 'const o = {', + ' a: 1,', + ' \'b-c\': x,', + ' [k]: 2,', + ' s,', + ' ...rest,', + ' // 注释不算一项', + ' async load(id: string): Promise {', + ' await run(id)', + ' },', + ' get size() { return 1 },', + ' n: { d: 4, e: {} },', + '}', + 'const empty = {}', + 'const list = [1]', + ].join('\n') + const at = (text: string): number => source.indexOf(text) + const found = await facts(source) + expect(found[1]).toMatchObject({ declarators: [{ entries: [ + { kind: 'pair', key: 'a', value: '1' }, + { kind: 'pair', key: '\'b-c\'', value: 'x' }, + { kind: 'pair', key: '[k]', value: '2' }, + { kind: 'pair', key: 's', value: 's' }, + { kind: 'spread', value: 'rest' }, + { + kind: 'method', + name: 'load', + node: 'async load(id: string): Promise {\n await run(id)\n }', + header: { startIndex: at('async load'), endIndex: at(' {\n await'), startRow: 7, endRow: 7 }, + }, + { kind: 'method', name: 'size', header: { startIndex: at('get size'), endIndex: at(' { return 1'), startRow: 10, endRow: 10 } }, + { kind: 'pair', key: 'n', value: '{ d: 4, e: {} }', entries: [ + { kind: 'pair', key: 'd', value: '4' }, + { kind: 'pair', key: 'e', value: '{}', entries: [] }, + ] }, + ] }] }) + expect(found[14]).toEqual({ access: 'private', declarators: [{ name: 'empty', value: '{}', entries: [] }] }) + expect(found[15]).toEqual({ access: 'private', declarators: [{ name: 'list', value: '[1]' }] }) }) it('导入:默认导入、* as、仅类型、副作用导入、require', async () => { diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index 8db633f..db81dcb 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -1,7 +1,7 @@ -import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' +import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' import type { CommentRules } from './shared' import * as path from 'node:path' -import { children, compoundShape, extendHeader, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared' +import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren } from './shared' const GRAMMARS: Record = { typescript: 'tree-sitter-typescript.wasm', @@ -205,13 +205,43 @@ function parameterFacts(param: Node): ParameterFacts { } function declaratorFacts(declarator: Node): DeclaratorFacts { + const value = declarator.childForFieldName('value') return { name: declarator.childForFieldName('name') ?? declarator, type: annotatedType(declarator.childForFieldName('type')), - value: declarator.childForFieldName('value') ?? undefined, + value: value ?? undefined, + entries: objectEntries(value), } } +/** 对象字面量的各项:`键: 值`(值又是对象时连它的各项)、简写 `{ a }`、`...other`、方法;值不是对象字面量时没有。 */ +function objectEntries(value: Node | null): EntryFacts[] | undefined { + if (value?.type !== 'object') + return undefined + return syntaxChildren(value).flatMap((member): EntryFacts[] => { + switch (member.type) { + case 'pair': { + const key = member.childForFieldName('key') + const memberValue = member.childForFieldName('value') + return key && memberValue ? [{ kind: 'pair', key, value: memberValue, entries: objectEntries(memberValue) }] : [] + } + case 'shorthand_property_identifier': + return [{ kind: 'pair', key: member, value: member }] + case 'spread_element': { + const [spread] = syntaxChildren(member) + return spread ? [{ kind: 'spread', value: spread }] : [] + } + case 'method_definition': { + const name = member.childForFieldName('name') + const body = member.childForFieldName('body') + return name ? [{ kind: 'method', name, node: member, header: body ? headerSpan(member, body.startIndex) : nodeSpan(member) }] : [] + } + default: + return [] + } + }) +} + /** import 语句:默认导入、`* as x`、`{ a as b }`、`import x = require('m')`;`import type` 只导入类型。 */ function importFacts(node: Node): ImportFacts { const require = named(node).find(child => child.type === 'import_require_clause') diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index 1fa315a..e5a2ed0 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -1,4 +1,4 @@ -import type { BlockKind, CodeBlock, LanguageAdapter, PreviewText, PropertyRow, Segment } from './contract' +import type { BlockKind, CodeBlock, LanguageAdapter, PreviewText, PropertyItem, PropertyRow, Segment } from './contract' import { readFileSync } from 'node:fs' import * as path from 'node:path' import { Node } from '@vscode/tree-sitter-wasm' @@ -54,12 +54,13 @@ export function previewText(text: PreviewText): string { return 'markdown' in text ? text.markdown : segmentText(text) } -/** 属性表写成文字:每行 `名称: 值`,展开的子行缩进两格、各列用 ` | ` 隔开。 */ +/** 属性表写成文字:每行 `名称: 值`,展开的子行比上一层多缩进两格、各列用 ` | ` 隔开。 */ export function propertyLines(rows: PropertyRow[]): string[] { - return rows.flatMap(row => [ - `${row.label}: ${previewText(row.value)}`, - ...(row.items ?? []).map(item => ` ${item.cells.map(segmentText).join(' | ')}`), + const itemLines = (items: PropertyItem[] | undefined, indent: string): string[] => (items ?? []).flatMap(item => [ + `${indent}${item.cells.map(segmentText).join(' | ')}`, + ...itemLines(item.items, `${indent} `), ]) + return rows.flatMap(row => [`${row.label}: ${previewText(row.value)}`, ...itemLines(row.items, ' ')]) } /** 语法树节点换成它的源码,没填的可选字段去掉。 */ diff --git a/vscode/src/codeReader/translators/pythonZh.ts b/vscode/src/codeReader/translators/pythonZh.ts index b4b3ed9..8a2f69b 100644 --- a/vscode/src/codeReader/translators/pythonZh.ts +++ b/vscode/src/codeReader/translators/pythonZh.ts @@ -80,6 +80,7 @@ export const pythonZhTranslator: Translator = createZhTemplateTranslator({ operators: { '==': '等于', '!=': '不等于' }, operatorParents: ['comparison_operator'], emptyLiterals: { list: '空列表', dictionary: '空字典' }, + mapLiterals: { dictionary: '字典' }, stringNodes: ['string'], commentText, // `a if c else b` 的三个具名子节点依次是 a、c、b,语法没给它们起字段名 diff --git a/vscode/src/codeReader/translators/typescriptZh.ts b/vscode/src/codeReader/translators/typescriptZh.ts index 9bc1948..6c25520 100644 --- a/vscode/src/codeReader/translators/typescriptZh.ts +++ b/vscode/src/codeReader/translators/typescriptZh.ts @@ -85,7 +85,8 @@ export const typescriptZhTranslator: Translator = createZhTemplateTranslator({ isTypePosition: leaf => leaf.parent?.type === 'predefined_type', operators: { '===': '全等', '!==': '不全等', '==': '等于', '!=': '不等于', '&&': '并且', '||': '或者', '!': '非' }, operatorParents: ['binary_expression', 'unary_expression'], - emptyLiterals: { array: '空数组' }, + emptyLiterals: { array: '空数组', object: '空对象' }, + mapLiterals: { object: '对象' }, stringNodes: ['string', 'template_string', 'regex'], commentText, nodeTemplates: { diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index f87fc02..5822df6 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -1,4 +1,4 @@ -import type { LanguageAdapter, PropertyRow, Segment, Translator } from '../contract' +import type { LanguageAdapter, PropertyItem, PropertyRow, Segment, Translator } from '../contract' import type { ZhTable } from './zhTemplate' import { describe, expect, it } from 'vitest' import { createPythonAdapter } from '../languages/python' @@ -362,7 +362,7 @@ describe('属性表', () => { expect(env[15]).toEqual(['名称: ENV_PATTERN', '访问级别: 私有', '值: \'**/.env*\'']) expect(env[27]).toEqual(['名称: include', '初始值: 新建 RelativePattern(folder, ENV_PATTERN)']) expect(env[28]).toEqual(['名称: uris', '初始值: 等待 workspace.findFiles(include, EXCLUDE_PATTERN)']) - expect(env[40]).toEqual(['名称: result', '类型: EnvLockResult', '初始值: { total: files.length, ok: 空数组, failed: 空数组 }']) + expect(env[40]).toEqual(['名称: result', '类型: EnvLockResult', '初始值: 对象 3 项', ' total | files.length', ' ok | 空数组', ' failed | 空数组']) expect(issue[1]).toEqual(['来源: \'../gitea/types\'', '仅类型: 是', '导入项: 1 个', ' Issue']) expect(issue[14]).toEqual(['名称: issue', '初始值: issues.find(i => i.number 全等 issueNumber)']) expect(routes[19]).toEqual([ @@ -620,6 +620,86 @@ describe('属性表', () => { expect(rows[1]).toEqual(['名称: i', '访问级别: 私有', '初始值: 0', '名称: n', '访问级别: 私有', '类型: 数值', '初始值: 无']) expect(rows[3]).toEqual(['名称: v', '初始值: useMemo(() =>']) }) + + it('字典:值写「字典 N 项」,每项一个子行「键 | 值」,嵌套的字典一层层往下展开;**展开的键写「展开」,空字典照旧一行', async () => { + const source = [ + '_CONTEST_STATUS_KEYS = {', + ' "published": "contests:publish",', + ' "ended": "contests:end",', + ' "draft": "contests:update",', + '}', + 'CONFIG = {"db": {"host": host, "opts": {}}, **defaults, 1: await load()}', + 'EMPTY = {}', + ].join('\n') + const rows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, source)) + expect(rows[1]).toEqual([ + '名称: _CONTEST_STATUS_KEYS', + '访问级别: 私有', + '初始值: 字典 3 项', + ' "published" | "contests:publish"', + ' "ended" | "contests:end"', + ' "draft" | "contests:update"', + ]) + expect(rows[6]).toEqual([ + '名称: CONFIG', + '访问级别: 公共', + '值: 字典 3 项', + ' "db" | 字典 2 项', + ' "host" | host', + ' "opts" | 空字典', + ' 展开 | defaults', + ' 1 | 等待 load()', + ]) + expect(rows[7]).toEqual(['名称: EMPTY', '访问级别: 公共', '值: 空字典']) + }) + + it('对象:值写「对象 N 项」,键照源码写法(计算属性名照常翻),简写的值就是它自己,方法的值是它的头部,嵌套的一层层往下展开;空对象一行「空对象」', async () => { + const source = [ + 'export const handlers = {', + ' total: files.length,', + ' \'x-y\': await load(),', + ' [key]: !ok,', + ' files,', + ' ...rest,', + ' async load(id: string): Promise {', + ' await run(id)', + ' },', + ' nested: { a: [], b: { c: 1 } },', + '}', + 'let none = {}', + ].join('\n') + const described = await describeAll(ts, 'typescript', typescriptZhTranslator, source) + const rows = rowTexts(described) + expect(rows[1]).toEqual([ + '名称: handlers', + '访问级别: 公共', + '值: 对象 7 项', + ' total | files.length', + ' \'x-y\' | 等待 load()', + ' [key] | 非 ok', + ' files | files', + ' 展开 | rest', + ' load | 异步 load(id: 字符串): Promise<无>', + ' nested | 对象 2 项', + ' a | 空数组', + ' b | 对象 1 项', + ' c | 1', + ]) + expect(rows[12]).toEqual(['名称: none', '访问级别: 私有', '初始值: 空对象']) + + const value = colors(described.get(1) ?? []).find(row => row.label === '值') + expect(value?.value).toEqual([{ text: '对象 7 项', role: 'hint' }]) + expect(value?.items?.map(item => item.cells[0])).toEqual([ + [{ text: 'total', role: 'variable' }], + [{ text: '\'x-y\'', role: 'string' }], + [{ text: '[', role: 'plain' }, { text: 'key', role: 'variable' }, { text: ']', role: 'plain' }], + [{ text: 'files', role: 'variable' }], + [{ text: '展开', role: 'hint' }], + [{ text: 'load', role: 'function' }], + [{ text: 'nested', role: 'variable' }], + ]) + expect(value?.items?.[6].cells[1]).toEqual([{ text: '对象 2 项', role: 'hint' }]) + }) }) describe('源码下标(跳到定义用)', () => { @@ -633,7 +713,8 @@ describe('源码下标(跳到定义用)', () => { } function propertySegments(rows: PropertyRow[]): Segment[] { - return rows.flatMap(row => [...('markdown' in row.value ? [] : row.value), ...(row.items ?? []).flatMap(item => item.cells.flat())]) + const itemSegments = (items: PropertyItem[] | undefined): Segment[] => (items ?? []).flatMap(item => [...item.cells.flat(), ...itemSegments(item.items)]) + return rows.flatMap(row => [...('markdown' in row.value ? [] : row.value), ...itemSegments(row.items)]) } it('python:点分模块名每一节、导入项、别名各自带下标,点号不带;前面有中文注释也对得上', async () => { @@ -723,6 +804,37 @@ describe('源码下标(跳到定义用)', () => { expect(marked(tsSource, tsRows.get(3))).toEqual(['名称: i', '名称: j']) }) + it('字典 / 对象字面量:键和值里的名字、字符串带下标,嵌套的也是;每项标出它讲的定义是键,展开没有', async () => { + const source = [ + 'const o = {', + ' total: files.length,', + ' "k": v,', + ' inner: { deep: x },', + ' ...rest,', + ' load() {},', + '}', + ].join('\n') + const at = (word: string): number => source.indexOf(word) + const items = (await describeAll(ts, 'typescript', typescriptZhTranslator, source)).get(1)?.find(row => row.label === '值')?.items ?? [] + expect(items.map(item => item.cells.map(linked))).toEqual([ + [[['total', at('total')]], [['files', at('files')], ['length', at('length')]]], + [[['"k"', at('"k"')]], [['v', at('v,')]]], + [[['inner', at('inner')]], []], + [[], [['rest', at('rest')]]], + [[['load', at('load')]], [['load', at('load')]]], + ]) + expect(items[2].items?.map(item => item.cells.map(linked))).toEqual([[[['deep', at('deep')]], [['x', at('x }')]]]]) + expect(items.map(item => item.defines && source.slice(item.defines.startIndex, item.defines.endIndex))).toEqual(['total', '"k"', 'inner', undefined, 'load']) + expect(items[2].items?.[0].defines).toEqual({ startIndex: at('deep'), endIndex: at('deep') + 'deep'.length }) + + const pySource = 'X = {"a": b, **c}' + const pyItems = (await describeAll(py, 'python', pythonZhTranslator, pySource)).get(1)?.find(row => row.label === '值')?.items ?? [] + expect(pyItems.map(item => item.cells.map(linked))).toEqual([ + [[['"a"', pySource.indexOf('"a"')]], [['b', pySource.indexOf('b')]]], + [[], [['c', pySource.indexOf('c')]]], + ]) + }) + it('类:名称、类型参数、父类、实现的接口里的名字带下标;只有「类型参数」一行标出它讲的定义(这几个泛型参数),名称是块自己的名字、不标', async () => { const source = 'export class Repo extends Base implements Loader, Disposable {}' const at = (word: string): number => source.indexOf(word) @@ -789,6 +901,7 @@ describe('中文模板引擎', () => { operators: {}, operatorParents: [], emptyLiterals: {}, + mapLiterals: {}, stringNodes: [], commentText: node => node.text, nodeTemplates: {}, diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index b20326c..f827c33 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, ClassFacts, Defines, FactBlock, FunctionFacts, ImportFacts, ImportItem, Markdown, Node, PreviewText, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, ClassFacts, Defines, EntryFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -36,8 +36,10 @@ export interface ZhTable { /** 翻成文字的运算符:只认父节点在 operatorParents 里的运算符节点(TS 的非空断言 `x!` 不算)。 */ operators: Record operatorParents: string[] - /** 空字面量(没有具名子节点)整个换成一个词:空数组 / 空列表 / 空字典。 */ + /** 空字面量(没有具名子节点)整个换成一个词:空数组 / 空列表 / 空字典 / 空对象。 */ emptyLiterals: Record + /** 字典 / 对象字面量(语言插件给了 entries 的值)的叫法,按节点类型查:属性表里的值写成「字典 3 项」,各项展开成子行。 */ + mapLiterals: Record /** 整段原样照抄、按字符串着色的节点。 */ stringNodes: string[] /** 注释(或 docstring)节点去掉注释符号后的原文,可以多行。 */ @@ -283,17 +285,17 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { function describe(block: FactBlock, source: string): PropertyRow[] { const { header } = block + // 值里带语句块主体的回调(useMemo(() => { … }))只翻到主体开始之前,和块头部一致 + const within = (node: Node, part: SourceSpan): Segment[] => { + const span = part.endIndex > header.endIndex ? { ...part, endIndex: header.endIndex, endRow: header.endRow } : part + return joinTokens(tokensIn(node, span, source)) + } const phrases: Phrases = { - // 值里带语句块主体的回调(useMemo(() => { … }))只翻到主体开始之前,和块头部一致 - translated: (node) => { - const clipped = node.endIndex > header.endIndex - const span = { - startIndex: node.startIndex, - endIndex: clipped ? header.endIndex : node.endIndex, - startRow: node.startPosition.row, - endRow: clipped ? header.endRow : node.endPosition.row, - } - return joinTokens(tokensIn(node, span, source)) + translated: node => within(node, { startIndex: node.startIndex, endIndex: node.endIndex, startRow: node.startPosition.row, endRow: node.endPosition.row }), + within, + entryCount: (literal, size) => { + const name = table.mapLiterals[literal.type] + return [hint(name ? `${name} ${size} 项` : `${size} 项`)] }, verbatim: node => verbatim(node, source), // 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样 @@ -355,10 +357,14 @@ function joinTokens(tokens: Token[]): Segment[] { // ─── 属性表 ─────────────────────────────────────────────────────────────────── -/** 属性表里值的三种来源。 */ +/** 属性表里的值怎么写。 */ interface Phrases { /** 按表翻译,只翻落在块头部里的那一段。 */ translated: (node: Node) => Segment[] + /** 同 translated,只翻 node 落在 span 里的那一段(对象字面量里方法的头部)。 */ + within: (node: Node, span: SourceSpan) => Segment[] + /** 字典 / 对象字面量的项数:「字典 3 项」,叫法按字面量的节点类型查表。 */ + entryCount: (literal: Node, size: number) => Segment[] /** 源码原样照抄、按源码着色(装饰器)。 */ verbatim: (node: Node) => Segment[] /** 导入来源:字符串字面量按字符串着色,模块名按类型着色。 */ @@ -492,13 +498,40 @@ function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] { ] } -/** 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值)。 */ +/** 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值,字典 / 对象字面量见 literalValue)。 */ function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] { const valueLabel = block.kind === 'const' ? '值' : '初始值' return block.facts.declarators.flatMap(declarator => [ { label: '名称', value: say.translated(declarator.name), ...defining(declarator.name) }, ...accessRows(block.facts.access), ...(declarator.type ? [{ label: '类型', value: say.translated(declarator.type) }] : []), - { label: valueLabel, value: declarator.value ? say.translated(declarator.value) : [hint('无')] }, + { label: valueLabel, ...(declarator.value ? literalValue(declarator.value, declarator.entries, say) : { value: [hint('无')] }) }, ]) } + +/** + * 值:字典 / 对象字面量(语言插件给了 entries)写项数,各项展开成下一层子行; + * 空字面量和别的值是一行译文(空的译成「空字典」「空对象」)。 + */ +function literalValue(value: Node, entries: EntryFacts[] | undefined, say: Phrases): { value: Segment[], items?: PropertyItem[] } { + if (!entries?.length) + return { value: say.translated(value) } + return { value: say.entryCount(value, entries.length), items: entries.map(entry => entryItem(entry, say)) } +} + +/** + * 字典 / 对象字面量的一项是一个子行,两列:键 | 值。键照源码写法(字符串带引号,计算属性名 `[k]` 照常翻), + * `**x`、`...x` 的键写「展开」、值是展开的表达式,方法的值是它的头部。讲的定义是键(对象的属性名)。 + */ +function entryItem(entry: EntryFacts, say: Phrases): PropertyItem { + switch (entry.kind) { + case 'pair': { + const { value, ...nested } = literalValue(entry.value, entry.entries, say) + return { cells: [say.translated(entry.key), value], ...nested, ...defining(entry.key) } + } + case 'spread': + return { cells: [[hint('展开')], say.translated(entry.value)] } + case 'method': + return { cells: [say.translated(entry.name), say.within(entry.node, entry.header)], ...defining(entry.name) } + } +} diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 8e4f564..dc1f931 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -30,7 +30,7 @@ export function App(): ReactElement | null { /** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */ const [reveal, setReveal] = useState(null) const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds]) - /** 记下这几块、属性行折叠还是展开,从此不再按默认 */ + /** 记下这几块、属性行、子行折叠还是展开,从此不再按默认 */ const setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({ ...prev, folds: { ...prev.folds, ...Object.fromEntries(ids.map(id => [id, folded])) }, diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 6cb0fe3..f0e8135 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -29,7 +29,7 @@ const KIND_LABEL: Record = { export interface BlockTreeState { version: number editing: ReadonlySet - /** 眼下折叠着的块 id 和属性行 id(propertyRowId):用户的选择叠在默认上,见 collapsedIds */ + /** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */ collapsed: ReadonlySet /** 刚点了「编辑」、要把光标放进去的块 */ focusId: string | null diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index 2081af1..9b44693 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -1,12 +1,12 @@ import type { ReactElement } from 'react' -import type { PropertyItem, PropertyRow } from './types' +import type { PropertyItem, PropertyRow, Segment } from './types' import { ChevronDown, ChevronRight } from 'lucide-react' import { Fragment } from 'react' import { SegmentText } from './SegmentText' import { TextPreview } from './TextPreview' import { propertyItemId, propertyRowId } from './viewState' -/** 子行里除最后一列以外各列的宽度(参数的名称、类型),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */ +/** 子行里除最后一列以外各列的宽度(参数的名称、类型,字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */ const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)'] function columns(count: number): string { @@ -14,32 +14,89 @@ function columns(count: number): string { return [...fixed, 'minmax(0, 1fr)'].join(' ') } -/** 展开在一行下面的子行:左边一条竖线,每个子行几列对齐。 */ -function PropertyItems({ rowId, items }: { rowId: string, items: PropertyItem[] }): ReactElement { +/** 眼下折叠着哪些属性行、子行,点折叠箭头时切换哪个。 */ +interface Folding { + collapsed: ReadonlySet + onToggleFold: (id: string) => void +} + +function segmentsText(segments: Segment[]): string { + return segments.map(segment => segment.text).join('') +} + +/** + * 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换; + * 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。 + */ +function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Segment[], foldable: boolean, folding: Folding }): ReactElement { + if (!foldable) { + return ( +
+ +

+ +

+
+ ) + } + const folded = folding.collapsed.has(id) + const Chevron = folded ? ChevronRight : ChevronDown + return ( + + ) +} + +/** + * 展开在一行或一个子行(parentId)下面的一层子行:左边一条竖线,每个子行几列对齐。 + * 带下一层的子行(字典 / 对象的值又是字典 / 对象),下一层展开在它下面、再往右缩进一层,默认展开。 + */ +function PropertyItems({ parentId, items, folding }: { parentId: string, items: PropertyItem[], folding: Folding }): ReactElement { + const nested = items.some(item => (item.items?.length ?? 0) > 0) return (
- {items.map((item, index) => ( -
- {item.cells.map((cell, i) => ( -

- -

- ))} -
- ))} + {items.map((item, index) => { + const id = propertyItemId(parentId, index) + const children = item.items ?? [] + return ( + +
+ {item.cells.map((cell, i) => i === 0 && nested + ? 0} folding={folding} /> + : ( +

+ +

+ ))} +
+ {children.length > 0 && !folding.collapsed.has(id) && } +
+ ) + })}
) } -/** 函数、类、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项下面展开子行。 */ +/** 函数、类、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行。 */ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: { blockId: string rows: PropertyRow[] @@ -72,7 +129,7 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: { : {row.label}}
- {items.length > 0 && !folded && } + {items.length > 0 && !folded && } ) })} diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index c80285f..f202a3e 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -1,6 +1,6 @@ import type { LanguageAdapter, Translator } from '../../../src/codeReader/contract' import type { RevealPlan } from './reveal' -import type { BlockKind, BlockView, PropertyRow, Segment } from './types' +import type { BlockKind, BlockView, PropertyItem, PropertyRow, Segment } from './types' import { describe, expect, it } from 'vitest' import { toBlockViews } from '../../../src/codeReader/blockView' import { createPythonAdapter } from '../../../src/codeReader/languages/python' @@ -138,13 +138,25 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { /** 不填 defines 的第三方翻译插件:属性表照旧,行和子行都不标讲的是哪个定义。 */ function withoutDefines(translator: Translator): Translator { + const strip = (items: PropertyItem[]): PropertyItem[] => items.map(({ cells, items: nested }) => nested ? { cells, items: strip(nested) } : { cells }) return { ...translator, describe: (block, source) => translator.describe(block, source).map(({ label, value, items }) => - items ? { label, value, items: items.map(({ cells }) => ({ cells })) } : { label, value }), + items ? { label, value, items: strip(items) } : { label, value }), } } + /** parentId 下面一层层的子行里 id 是 target 的那个。 */ + function findItem(items: PropertyItem[] | undefined, parentId: string, target: string): PropertyItem | undefined { + for (const [index, item] of (items ?? []).entries()) { + const id = propertyItemId(parentId, index) + const found = id === target ? item : findItem(item.items, id, target) + if (found) + return found + } + return undefined + } + function flatten(list: BlockView[]): BlockView[] { return list.flatMap(view => [view, ...flatten(view.children)]) } @@ -163,7 +175,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { const rowId = propertyRowId(block.id, index) if (plan.row === rowId) return `${row.label}: ${text(row.value)}` - const item = row.items?.find((_, i) => plan.row === propertyItemId(rowId, i)) + const item = findItem(row.items, rowId, plan.row) if (item) return item.cells.map(text).join(' | ') } @@ -267,6 +279,27 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { expect(await atPy('run')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'def run(self):' }) }) + it('字典 / 对象:定义落在对象的属性名上只闪那一项,嵌套的连外层各层一起展开;值里只是显示着的名字闪整块,展开到它那一层', async () => { + const source = [ + 'export const routes = {', + ' home: { path: \'/\', page: Home },', + ' about,', + '}', + 'use(routes.home.path)', + ].join('\n') + const at = (needle: string): ReturnType => jump(ts, 'typescript', typescriptZhTranslator, source, needle) + expect(await at('home:')).toEqual({ plan: { id: '0', row: '0/p2/0', expand: ['0/p2'] }, flashed: 'home | 对象 2 项' }) + expect(await at('path:')).toEqual({ plan: { id: '0', row: '0/p2/0/0', expand: ['0/p2', '0/p2/0'] }, flashed: 'path | \'/\'' }) + expect(await at('about,')).toEqual({ plan: { id: '0', row: '0/p2/1', expand: ['0/p2'] }, flashed: 'about | about' }) + expect(await at('Home')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'export const routes = {' }) + const plain = (needle: string): ReturnType => jump(ts, 'typescript', withoutDefines(typescriptZhTranslator), source, needle) + expect(await plain('path:')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'export const routes = {' }) + + const pySource = 'CONFIG = {"db": {"host": host}}' + const atPy = (needle: string): ReturnType => jump(py, 'python', pythonZhTranslator, pySource, needle) + expect(await atPy('host}')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'CONFIG = {"db": {"host": host}}' }) + }) + it('typeScript:翻译插件没标 defines 时闪整块', async () => { const at = (needle: string): ReturnType => jump(ts, 'typescript', withoutDefines(typescriptZhTranslator), TS, needle) expect(await at('path: string')).toEqual({ plan: { id: '1', expand: ['1/p3'] }, flashed: 'export function load(path: string, { encoding }: Options): string {' }) diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index 8467a0b..ba28a3e 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -1,7 +1,7 @@ -import type { BlockView, PreviewText, SourceRange } from './types' +import type { BlockView, PreviewText, PropertyItem, SourceRange } from './types' import { propertyItemId, propertyRowId } from './viewState' -/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块和属性行:它们折叠着,目标就不在页面上。 */ +/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */ export interface RevealPlan { id: string /** 属性行 id(propertyRowId)或子行 id(propertyItemId);没有时闪整块 */ @@ -38,8 +38,9 @@ function shows(text: PreviewText, offset: number): boolean { /** * 块里哪儿是 offset 处的定义,从具体到笼统: * ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块; - * ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个)→ 只闪它,子行连它所在的行一起展开; - * ③ 块里只是显示着这个名字(一行译文里的循环变量、没填 defines 的翻译插件)→ 整块,名字在子行里时展开那一行。 + * ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它, + * 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开; + * ③ 块里只是显示着这个名字(一行译文里的循环变量、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。 * 不在这块里是 undefined。 */ function locate(block: BlockView, offset: number): Spot | undefined { @@ -51,15 +52,41 @@ function locate(block: BlockView, offset: number): Spot | undefined { for (const { row, id } of rows) { if (covers(row.defines, offset)) return { row: id, expand: [] } - const item = row.items?.findIndex(entry => covers(entry.defines, offset)) ?? -1 - if (item >= 0) - return { row: propertyItemId(id, item), expand: [id] } + const item = definingItem(row.items, id, offset) + if (item) + return item } for (const { row, id } of rows) { if (shows(row.value, offset)) return { expand: [] } - if (row.items?.some(item => item.cells.some(cell => shows(cell, offset)))) - return { expand: [id] } + const expand = showingItem(row.items, id, offset) + if (expand) + return { expand } + } + return undefined +} + +/** parentId 下面一层层的子行里,defines 盖住 offset 的那个:闪它,展开它上面的各层(parentId 和它所在的那串上层子行)。 */ +function definingItem(items: PropertyItem[] | undefined, parentId: string, offset: number): Spot | undefined { + for (const [index, item] of (items ?? []).entries()) { + const id = propertyItemId(parentId, index) + if (covers(item.defines, offset)) + return { row: id, expand: [parentId] } + const inner = definingItem(item.items, id, offset) + if (inner) + return { ...inner, expand: [parentId, ...inner.expand] } + } + return undefined +} + +/** parentId 下面一层层的子行里,哪个显示着 offset 处的名字:要展开的各层(parentId 到那个子行所在的那一层)。 */ +function showingItem(items: PropertyItem[] | undefined, parentId: string, offset: number): string[] | undefined { + for (const [index, item] of (items ?? []).entries()) { + if (item.cells.some(cell => shows(cell, offset))) + return [parentId] + const inner = showingItem(item.items, propertyItemId(parentId, index), offset) + if (inner) + return [parentId, ...inner] } return undefined } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 4140834..b01011b 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -27,15 +27,19 @@ export type PreviewText = Segment[] | Markdown export interface SourceRange { startIndex: number, endIndex: number } /** - * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字(参数名、导入项、声明的变量名)在源码里的范围。 + * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字(参数名、导入项、声明的变量名、字典 / 对象一项的键)在源码里的范围。 * 跳到的定义落在里面就只闪这一行;翻译插件没填就闪整块 */ export interface Defines { defines?: SourceRange } -/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值 */ -export interface PropertyItem extends Defines { cells: Segment[][] } +/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值 */ +export interface PropertyItem extends Defines { + cells: Segment[][] + /** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项),一层层可以一直往下套 */ + items?: PropertyItem[] +} -/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项) */ +/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项、字典 / 对象的各项) */ export interface PropertyRow extends Defines { label: string value: PreviewText diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index c756da4..84d105c 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -25,7 +25,7 @@ function flash(el: HTMLElement): void { /** * 滚到 reveal 指的位置,处理完调 done: * ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部,折叠着的不展开; - * ② 跳到定义:先展开把目标藏起来的块和属性行(expand 之后重新渲染,再进来一次),再把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。 + * ② 跳到定义:先展开把目标藏起来的块、属性行和子行(expand 之后重新渲染,再进来一次),再把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。 */ export function useReveal( doc: DocumentMessage | null, diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index ff52622..712130a 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -1,7 +1,7 @@ import type { BlockKind, BlockView } from './types' import type { ViewStateStorage } from './viewState' import { describe, expect, it } from 'vitest' -import { collapsedIds, createViewStateStore, propertyRowId } from './viewState' +import { collapsedIds, createViewStateStore, propertyItemId, propertyRowId } from './viewState' /** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回;也能塞进旧版本存的东西 */ function memoryStorage(initial?: unknown): ViewStateStorage { @@ -73,6 +73,32 @@ describe('webview 状态', () => { createViewStateStore(storage).update({ folds: { 6.1: false, [propertyRowId('6.1.0', 3)]: true } }) expect(createViewStateStore(storage).get().folds).toEqual({ '6.1': false, '6.1.0/p3': true }) }) + + it('子行 id 是上一层的 id 加子行序号,一层层往后接;不同位置的不会撞上,也不会和块 id、属性行 id 撞上', () => { + const row = propertyRowId('6.1.0', 3) + expect(propertyItemId(row, 1)).toBe('6.1.0/p3/1') + expect(propertyItemId(propertyItemId(row, 1), 0)).toBe('6.1.0/p3/1/0') + const ids = [ + '6', + '6.1', + propertyRowId('6', 1), + propertyRowId('6', 11), + propertyItemId(propertyRowId('6', 1), 1), + propertyItemId(propertyRowId('6', 11), 1), + propertyItemId(propertyItemId(propertyRowId('6', 1), 1), 1), + propertyItemId(propertyItemId(propertyRowId('6', 1), 11), 1), + propertyItemId(propertyItemId(propertyRowId('6', 1), 1), 11), + propertyItemId(propertyRowId('6.1', 1), 1), + ] + expect(new Set(ids).size).toBe(ids.length) + }) + + it('嵌套子行的折叠选择也存在 folds 里,页面重新加载后还在', () => { + const storage = memoryStorage() + const nested = propertyItemId(propertyItemId(propertyRowId('2', 2), 0), 1) + createViewStateStore(storage).update({ folds: { [nested]: true, [propertyItemId(propertyRowId('2', 2), 0)]: false } }) + expect(createViewStateStore(storage).get().folds).toEqual({ '2/p2/0/1': true, '2/p2/0': false }) + }) }) describe('眼下折叠着哪些', () => { @@ -84,4 +110,11 @@ describe('眼下折叠着哪些', () => { const folds = { '1': false, '2': false, '1.0': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false } expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '1.0', '2.0']) }) + + it('带下一层的子行默认展开,用户折叠过的按他的', () => { + const outer = propertyItemId(propertyRowId('0', 2), 0) + const inner = propertyItemId(outer, 3) + expect(collapsedIds(BLOCKS, {}).has(outer)).toBe(false) + expect([...collapsedIds(BLOCKS, { [outer]: false, [inner]: true })].sort()).toEqual(['0/p2/0/3', '1', '1.0.0', '2', '2.0']) + }) }) diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index 0d02b5b..9ac6adb 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -3,11 +3,11 @@ import type { BlockKind, BlockView } from './types' /** * 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、 - * 用户把哪些块和属性行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行 id 见 propertyRowId),以及滚到了哪。 + * 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 propertyRowId、propertyItemId),以及滚到了哪。 */ export interface ViewState { editing: string[] - /** 用户自己点过折叠箭头的块和属性行:id → 折叠着没有。没点过的按默认,见 collapsedIds */ + /** 用户自己点过折叠箭头的块、属性行和子行:id → 折叠着没有。没点过的按默认,见 collapsedIds */ folds: Record scroll?: ScrollAnchor } @@ -54,12 +54,12 @@ function readViewState(saved: unknown): ViewState { return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds } } -/** 块默认折叠吗:函数、类块收起主体(属性表照常显示),其余块展开。属性行默认都展开。 */ +/** 块默认折叠吗:函数、类块收起主体(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */ export function foldedByDefault(kind: BlockKind): boolean { return kind === 'function' || kind === 'class' } -/** 眼下折叠着的块和属性行:用户点过的按他点的,没点过的按 foldedByDefault。 */ +/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */ export function collapsedIds(blocks: readonly BlockView[], folds: Readonly>): Set { const collapsed = new Set(Object.keys(folds).filter(id => folds[id])) const visit = (list: readonly BlockView[]): void => { @@ -74,14 +74,19 @@ export function collapsedIds(blocks: readonly BlockView[], folds: Readonly/p<行序号>`,例如 '6.1.0/p3'。块 id 只由数字和点组成,带 '/' 的不会和任何块 id 撞上。 */ export function propertyRowId(blockId: string, rowIndex: number): string { return `${blockId}/p${rowIndex}` } -/** 属性行展开的第几个子行,例如 '6.1.0/p3/1'。只用来在页面上找到它(跳到定义时闪它),子行不折叠、不进 folds。 */ -export function propertyItemId(rowId: string, itemIndex: number): string { - return `${rowId}/${itemIndex}` +/** + * 一行或一个子行(parentId)下面展开的第几个子行:`<上一层的 id>/<子行序号>`,一层套一层往后接, + * 例如 '6.1.0/p3/1' 是第 3 行的第 1 个子行,'6.1.0/p3/1/0' 是它下面的第 0 个。跳到定义时拿它在页面上找到要闪的子行; + * 带下一层子行的子行(字典 / 对象的值又是字典 / 对象)也拿它当折叠 id,和属性行一样记进 folds、默认展开。 + * 只有行那一节带 'p',后面各节都是纯数字,不同位置的 id 不会撞上,也不会和块 id、属性行 id 撞上。 + */ +export function propertyItemId(parentId: string, itemIndex: number): string { + return `${parentId}/${itemIndex}` }