From de355661f015a223961adbd2fd965d0f0ba494cb Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 20:10:36 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E5=8A=A0=E6=9E=9A=E4=B8=BE=E5=8D=A1=E7=89=87?= =?UTF-8?q?=EF=BC=8CPython=20enum=20=E5=BA=93=E8=A7=84=E5=88=99=E5=92=8C?= =?UTF-8?q?=20TypeScript=20enum=20=E7=94=BB=E6=88=90=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E5=BC=A0=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 117 ++++++-- vscode/src/codeReader/attributes.ts | 5 +- vscode/src/codeReader/blockView.ts | 52 ++-- vscode/src/codeReader/contract.ts | 69 ++++- vscode/src/codeReader/fixtures/enumModels.py | 38 +++ vscode/src/codeReader/fixtures/enums.ts.txt | 20 ++ vscode/src/codeReader/index.ts | 2 + vscode/src/codeReader/languages/shared.ts | 2 +- .../codeReader/languages/typescript.test.ts | 55 +++- vscode/src/codeReader/languages/typescript.ts | 67 ++++- vscode/src/codeReader/libraryRules.test.ts | 23 +- vscode/src/codeReader/libraryRules.ts | 6 +- vscode/src/codeReader/rules/celery.ts | 6 +- .../src/codeReader/rules/contractOnly.test.ts | 2 +- vscode/src/codeReader/rules/enum.test.ts | 260 ++++++++++++++++++ vscode/src/codeReader/rules/enum.ts | 152 ++++++++++ vscode/src/codeReader/rules/sqlalchemy.ts | 4 +- .../src/codeReader/translators/zhTemplate.ts | 42 ++- .../webview-ui/src/codeReader/BlockTree.tsx | 2 +- .../src/codeReader/PropertyGrid.tsx | 2 +- .../webview-ui/src/codeReader/codeReader.css | 3 + vscode/webview-ui/src/codeReader/types.ts | 4 +- .../src/codeReader/viewState.test.ts | 4 + vscode/webview-ui/src/codeReader/viewState.ts | 4 +- 24 files changed, 858 insertions(+), 83 deletions(-) create mode 100644 vscode/src/codeReader/fixtures/enumModels.py create mode 100644 vscode/src/codeReader/fixtures/enums.ts.txt create mode 100644 vscode/src/codeReader/rules/enum.test.ts create mode 100644 vscode/src/codeReader/rules/enum.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 97ed019..8e41506 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -20,7 +20,7 @@ | 块筛选 | 预览顶部钉住一条筛选栏,按块的属性(种类、访问级别、异步……)筛;属性由语言插件按和语言无关的 id 报(通用的由宿主从 facts 推),名字由翻译插件起,webview 不认识任何语言;筛选整个工作区共用、记住 | | 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 | | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | -| 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)、类块和库规则认领的块默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 | +| 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)、类块、枚举块和库规则的卡片默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 | | 函数、类的说明 | TypeScript 紧贴在函数、类前面的 JSDoc,Python 函数、类主体开头的 docstring 是它自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数、类块的头部里,「编辑」时和签名一起改 | | 变量、字段的说明 | Python 模块、类主体里紧跟在一条赋值后面的字符串(属性 docstring,PEP 257 / Sphinx 的写法)是这个变量的说明:常量、变量块进属性表「说明」一行(紧跟名称),字段块排在一行译文下面;不再单独成块,它的源码行算在这一块里,「编辑」时和赋值一起改 | | 注释按 Markdown 显示 | 注释块和函数、类、变量、字段的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | @@ -32,7 +32,8 @@ | 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | | 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 | | 输入参数 | 排成带表头和边框的表格:名称 \| 类型 \| 默认值,表头写了「默认值」,格子里不再写「默认 」 | -| 库规则 | 第四种插件:按库认出一种写法,把那块整个换成专门的卡片(内置 SQLAlchemy 规则:模型类 → 「数据表」卡片;内置 Celery 规则:`Celery(...)` → 「Celery 应用」卡片、`beat_schedule` → 「定时任务」卡片)。和语言插件、翻译插件一样写代码注册、只经契约拿信息;文件导入了规则关心的库才启用,由宿主按语言插件给的导入判断;用词归规则自己,webview 只画结构。见「库规则」 | +| 库规则 | 第四种插件:按库认出一种写法,把那块整个换成专门的卡片(内置 SQLAlchemy 规则:模型类 → 「数据表」卡片;内置 Celery 规则:`Celery(...)` → 「Celery 应用」卡片、`beat_schedule` → 「定时任务」卡片),或把那块认成契约里现成的一种属性表块(内置 enum 规则:Python 自带 enum 库的类 → 枚举块)。和语言插件、翻译插件一样写代码注册、只经契约拿信息;文件导入了规则关心的库才启用,由宿主按语言插件给的导入判断;卡片的用词归规则自己,认成的现成块照常由翻译插件排,webview 只画结构。见「库规则」 | +| 枚举 | 枚举是一种块(种类「枚举」,紫色 #b180d7 / 亮色 #652d90),属性表:名称 / 说明 / 访问级别 / 装饰器 / 继承(有才显示)/ 成员 N 个,成员排成表格「成员 \| 值 \| 说明」。TypeScript 的 `enum` 是语言自己的写法,由语言插件报;Python 没有枚举语法,自带 enum 库的写法由库规则认成同一种块。两边同一种块、同一张表、同一个筛选种类,webview 只有一种画法 | | 规则自己查定义 | 规则可以把自己给的名字标成「引用」(Celery 任务表里的任务名),⌘ / Ctrl 悬停、点击这样的名字时由规则查定义(Celery 在整个工作区里找任务函数),其余名字照旧问语言扩展;webview 只有一条链接 / 跳转的路,样子、闪、挑选框、在预览里打开都一样。规则要在全工作区找东西时只描述要找什么(`WorkspaceIndex`),找文件、解析、缓存、跟着改动更新归宿主。见「规则自己查定义」 | | 推导式(写在表达式里的遍历) | Python 的列表 / 集合 / 字典推导式、生成器表达式出现在译文任何地方(一行译文、属性表的值、默认值、字典项的值),都原地换成一个带循环图标和结果类型(生成器 / 列表 / 集合 / 字典)的小框,颜色同「循环」块,框里按读的顺序重排:「遍历 来源 的每个 变量,仅当 条件,取 值」。语言插件报结构,翻译插件定用词,webview 只认「框」不认语言 | @@ -69,17 +70,17 @@ ### 预览(代码块树) -- 每一句是一块:导入、函数、类、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。 +- 每一句是一块:导入、函数、类、枚举、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。 - 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。 - 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签(字取翻译插件给 `kind` 值起的名字,见「筛选」)、内容、右侧切换按钮。 - 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 - 内容两种状态,每块单独切: - - 预览:函数、类、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。 + - 预览:函数、类、枚举、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。 - 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里;Python 常量、变量、字段的属性 docstring 算在它那一块里(中间隔着的空行也是)。 - 库规则认领的块(数据表卡片等,见「库规则」):种类标签的字和颜色由规则给,内容是规则给的属性表;它的编辑范围是原块整条语句(类就是整个类),「编辑」时整段一起改。规则没并进卡片的子块(方法、别的成员)不管写在类主体哪儿(两个字段中间也算),都照源码顺序是卡片的子块,各自的「编辑」照旧只改自己那几行。 - 同一行不会同时在两处能改(能同时编辑的几段互不重叠):一块在编辑时,编辑范围和它重叠的子孙块不画(卡片编辑时它下面的子块全收起来,一行写完的 `if x: return 1` 编辑时同一行的子块也不画),回到预览就照原样出来;点一块的「编辑」时,编辑范围和它重叠的别的块退出编辑。跳到定义的目标块正被盖住时闪盖住它的那块。 -- 折叠:函数、类块和库规则认领的块打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 +- 折叠:函数、类、枚举块和库规则的卡片打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 - 顶部筛选栏:见「筛选」;翻译下拉框(多于一种翻译时才显示)也在栏里。 - 解析失败:显示错误原因,并提示用「编辑」查看源码。 @@ -88,7 +89,7 @@ 预览顶部钉住一条筛选栏,按块的属性藏掉一部分块(原型画板「筛选 · 默认 / 按访问级别 / 按种类」)。 - 三层分工,webview 不认识任何语言: - 1. 语言插件给每块报和语言无关的属性「属性 = 值」,都是稳定的英文 id,不放显示文字。通用词表由契约定(`COMMON_ATTRIBUTES`):`kind`(块的种类)、`access`(`public` / `private` / `protected`,语言可以加自己的值)、`async`、`abstract`、`typeOnly`(`'true'` / `'false'`)。通用属性由宿主从块的 kind 和 facts 推出来(`FunctionFacts.access` / `async`、`ClassFacts.access` / `abstract`、`ImportFacts.typeOnly`、`VariableFacts.access`),只有这一条路,语言插件不用另报;facts 里没填的(语言没有这个概念,例如 JavaScript 没有抽象类、仅类型导入)块就没有这个属性,不是填「否」。语言自己的属性(Rust 的 `unsafe = 'true'`)、facts 表达不了的通用属性值(Kotlin 的 `access = 'internal'`)填在块的 `attributes` 里,和推出来的合在一起,同一属性以它为准;`kind` 总是块的种类。 + 1. 语言插件给每块报和语言无关的属性「属性 = 值」,都是稳定的英文 id,不放显示文字。通用词表由契约定(`COMMON_ATTRIBUTES`):`kind`(块的种类)、`access`(`public` / `private` / `protected`,语言可以加自己的值)、`async`、`abstract`、`typeOnly`(`'true'` / `'false'`)。通用属性由宿主从块的 kind 和 facts 推出来(`FunctionFacts.access` / `async`、`ClassFacts.access` / `abstract`、`EnumFacts.access`、`ImportFacts.typeOnly`、`VariableFacts.access`),只有这一条路,语言插件不用另报;facts 里没填的(语言没有这个概念,例如 JavaScript 没有抽象类、仅类型导入)块就没有这个属性,不是填「否」。语言自己的属性(Rust 的 `unsafe = 'true'`)、facts 表达不了的通用属性值(Kotlin 的 `access = 'internal'`)填在块的 `attributes` 里,和推出来的合在一起,同一属性以它为准;`kind` 总是块的种类。 2. 翻译插件给属性和值起名(`Translator.attributeNames`:`access` → 访问级别、`public` → 公共、`kind` 的 `function` → 函数、布尔值 → 是 / 否),和属性表里的属性名、值是同一套叫法(中文直译里属性表的「访问级别」「异步」「抽象」「仅类型」行和筛选共用一份表);种类标签上的字也取它。没起名的显示 id。 3. webview 只按属性 id / 值 id 筛,显示翻译插件给的名字。 - 下拉框:这份文件里有两种以上值的属性各一个(只有一种值的不给——筛它等于按种类筛);只有一种值、但这个值正被去掉的也给,不然藏起来的块在栏上找不到原因。排列:通用属性按契约的顺序(种类、访问级别、异步、抽象、仅类型),语言自己的属性按在文件里第一次出现的先后排在后面。下拉菜单是勾选列表,列出这份文件里出现的值(按第一次出现的先后)和各有几块(所有层的块都算,例如「私有 3 / 公共 3」)。 @@ -101,7 +102,7 @@ - 样子:筛选栏钉在预览顶部(`position: sticky`,块从它下面滚过去),横贯编辑区、下边一条 `--vscode-panel-border`、底色 `--vscode-editor-background`,里面的内容和代码块同宽对齐:「≡ 筛选」(宽 52px)、每个属性一个下拉框(宽 150px、高 24px,`--vscode-dropdown-background` / `--vscode-dropdown-border` / `--vscode-dropdown-foreground`)、右边汇总(`descriptionForeground`,「清除」`--vscode-textLink-foreground`)、再右边翻译下拉框;放不下就换行。下拉菜单盖在代码块上面:宽 200px,`--vscode-editorWidget-background`、`--vscode-editorWidget-border`、`--vscode-widget-shadow` 阴影;每项勾选框(勾着是 `--vscode-focusBorder` 底)、值、右边块数(`--vscode-editorLineNumber-foreground`);悬停、键盘高亮 `--vscode-list-hoverBackground`。 - 键盘和读屏:下拉框按钮 `aria-haspopup="listbox"`、`aria-expanded`;菜单 `role="listbox"`、`aria-multiselectable`,每项 `role="option"`、`aria-selected` 表示勾着。按钮上 ↓ / 回车 / 空格打开(↑ 打开并停在最后一项),列表里 ↑ ↓ Home End 移动,空格 / 回车勾选,Esc 关掉并回到按钮,Tab、点外面关掉。 -### 属性表(函数、类、导入、常量、变量块) +### 属性表(函数、类、枚举、导入、常量、变量块) 卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`);输入参数例外,是一张表格(见下)。字典 / 对象的子行分键 / 值两列,键列宽 150px(编辑区窄时按 28% 收),值列占剩下的。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和导入项的子行一样)。 @@ -111,6 +112,7 @@ | --- | --- | | 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,`@param` 这类标签行照抄;得到的原文按 Markdown 渲染,见「注释的 Markdown 排版」;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,展开成表格,列名 名称 \| 类型 \| 默认值,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | | 类 | 名称(类型色;匿名类 `export default class {}` 写「匿名」;赋给变量、类字段的类表达式写变量名、字段名)/ 说明(有说明才显示,规则同函数)/ 访问级别 / 抽象(是 / 否,只有 TypeScript / TSX 有,看 `abstract class`,TypeScript 的类都有这一行;JavaScript / JSX 没有)/ 装饰器(同函数)/ 类型参数(有才显示:TypeScript `` 每个参数的译文,逗号隔开,如 `K, T 限定为 X = Y`;Python 3.12 的 `class G[T]` 同样)/ 父类(有才显示:TypeScript `extends` 后面的表达式连着泛型实参的译文,如 `Base<字符串>`;Python 类名后面括号里的参数列表照写的顺序逐项翻,关键字参数、`*bases` 也在这一行,如 `Base, metaclass=ABCMeta`;逗号隔开)/ 实现的接口(有才显示:TypeScript `implements` 后面每个类型的译文,逗号隔开) | +| 枚举 | 名称(类型色)/ 说明(有说明才显示,规则同函数)/ 访问级别(同类)/ 装饰器(有才显示,同函数)/ 继承(有才显示:Python 类名后面括号里照写的父类,如 `str, enum.Enum`;TypeScript 没有)/ 成员(`N 个` 或「无」,展开成表格,列名 成员 \| 值 \| 说明,每个成员一行:成员名(variable 色)\| 值(写了值是值的译文;没写值时是语言算出来的值,TypeScript 数值枚举自增的 0、1、2……,算不出来和 Python 的 `auto()` 写「自动」,这两种按 hint 着色)\| 说明(成员的注释、JSDoc、属性 docstring 的原文用空格连起来,comment 色;一个成员都没有说明时没有这一列);讲的定义是成员名,跳到成员名只闪那一行)。原型「枚举」一节(`ENUM_SAMPLE`、`enumCard`) | | 导入 | 来源(TypeScript 是字符串字面量原样,如 `'node:fs'`,字符串色;Python 是点分模块名,如 `fastapi`、`.models`,类型色)/ 仅类型(是 / 否,只有 TypeScript / TSX 有,看 `import type`;JavaScript / JSX 没有)/ 导入项(`N 个` 或「无」,每项一行:`name`、`name 作为 alias`;默认导入写「默认导出 作为 x」,`* as x` 写「全部 作为 x」,Python `from m import *` 写「全部」,Python `import a.b as c` 写「整个模块 作为 c」,一条 `import a, b` 导入多个模块时写模块名) | | 常量 / 变量 | 名称(解构声明写整个模式,如 `{ p, q }`)/ 说明(有说明才显示:Python 的属性 docstring,规则同函数;一条语句一个,排在第一个名称后面)/ 访问级别 / 类型(写了标注才有)/ 值(常量)或初始值(变量):初始化表达式的译文,没有写「无」;是字典 / 对象字面量时见下面「字典 / 对象字面量」。一条语句声明几个变量(`let a = 1, b = 2`)就重复几组 | @@ -195,7 +197,7 @@ ```ts export type BlockKind = - | 'import' | 'function' | 'class' | 'type' | 'field' | 'const' | 'variable' + | 'import' | 'function' | 'class' | 'enum' | 'type' | 'field' | 'const' | 'variable' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' /** 源码里一段的下标(源码字符串下标),end 为开区间。 */ @@ -239,6 +241,24 @@ export interface ClassFacts { implements: Node[] // TypeScript 的 implements,每个接口一项 } +/** 枚举的一个成员。 */ +export interface EnumMemberFacts { + name: Node + value?: Node // 源码里写的值;没写(TS 自增)、交给库自动取(Python auto())时没有 + computed?: number // 没写值、语言算得出来时的值(TS 数值枚举自增 0、1、2…) + docs: Node[] // 说明节点,照源码顺序:紧挨在上面单独成行的注释 / JSDoc、这一行行尾的注释、Python 的属性 docstring +} + +/** 枚举:TS 的 enum 由语言插件报;Python 自带 enum 库的类由库规则认成这种块(RuleFacts)。 */ +export interface EnumFacts { + name: Node + doc?: Node // 同 ClassFacts.doc + access?: Access // 同 ClassFacts.access + decorators: Node[] + bases: BaseFacts[] // 照写的父类(Python 的 str, enum.Enum);TS 没有 + members: EnumMemberFacts[] +} + export type ImportItem = | { kind: 'named', name: Node, alias?: Node } // import { a as b }、from m import a as b | { kind: 'default', alias: Node } // import x from 'm' @@ -310,11 +330,14 @@ export interface BlockShape { /** 预览成属性表的块:种类和 facts 绑在一起,语言插件漏给 facts 编译不过。 */ export interface FunctionBlock extends BlockShape { kind: 'function', facts: FunctionFacts } export interface ClassBlock extends BlockShape { kind: 'class', facts: ClassFacts } +export interface EnumBlock extends BlockShape { kind: 'enum', facts: EnumFacts } // TS:头部是整条声明,没有子块;库规则认出来的:整个类,留下的方法等是子块 export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts } export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts } -export type FactBlock = FunctionBlock | ClassBlock | ImportBlock | VariableBlock +export type FactBlock = FunctionBlock | ClassBlock | EnumBlock | ImportBlock | VariableBlock /** 带说明(facts.doc)的块。 */ -export type DocumentedBlock = FunctionBlock | ClassBlock | VariableBlock +export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | VariableBlock +/** 属性表块的种类和 facts,不带块的形状:库规则把一块认成现成的一种属性表块时给它。 */ +export type BlockFacts = FactBlock extends infer B ? B extends FactBlock ? Pick : never : never export type TextKind = Exclude /** 预览成一行译文的块(注释块是 Markdown 说明)。 */ @@ -406,7 +429,7 @@ export interface Translator { languageIds: string[] displayLanguage: string // 译文用的语言('zh'):库规则按它挑自己的词表 translate: (block: TextBlock, document: SourceDocument) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文 - describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] // 函数、类、导入、常量、变量块 → 属性表 + describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] // 函数、类、枚举、导入、常量、变量块 → 属性表 expression: (node: Node, document: SourceDocument) => Inline[] // 一个表达式 / 类型节点整个翻成一行译文,译法同属性表的值;库规则借它 doc: (node: Node, document: SourceDocument) => Markdown | undefined // 说明文字(注释、docstring、当说明用的字符串)→ Markdown 原文,同属性表的「说明」 attributeNames?: AttributeNames // 筛选属性和值叫什么,和属性表的叫法一致;筛选栏、种类标签用它 @@ -420,8 +443,11 @@ export interface ThemeColor { dark: string, light: string } /** 种类标签:文字和颜色。 */ export interface KindBadge { label: string, color: ThemeColor } -/** 库规则认领一块后给出的替身:一张属性表,下面挂着原块留下的子块。 */ -export interface RuleBlock { +/** 库规则认领一块后给出的替身,下面挂着原块留下的子块:规则自己的卡片,或认成的现成属性表块。 */ +export type RuleBlock = RuleCard | RuleFacts + +/** 规则自己的卡片:一张属性表,种类标签、每一行都由规则给。 */ +export interface RuleCard { kind: string // 种类 id,「规则 id.名字」(sqlalchemy.table):筛选的 kind 值,要稳定 badge: KindBadge // 种类标签;筛选栏里这个种类也叫这个名字 rows: PropertyRow[] @@ -430,6 +456,12 @@ export interface RuleBlock { absorbs: CodeBlock[] // 并进来的子块(原块 children 里的对象本身),不再单独成块;没并的照源码顺序留作子块,编辑范围是原块整条语句 } +/** 把这块认成契约里现成的一种属性表块(enum 库的类 → 枚举块):宿主当语言插件报的这种块处理,翻译插件排属性表;头部是原块整条语句。 */ +export interface RuleFacts { + as: BlockFacts + absorbs: CodeBlock[] // 同 RuleCard.absorbs +} + /** 规则看一块时宿主给的。 */ export interface RuleContext { document: SourceDocument @@ -482,22 +514,25 @@ export interface CodeReaderApi { ``` - `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 -- 内置的 TypeScript、Python 语言插件、两个中文直译翻译插件和 SQLAlchemy、Celery 库规则在 `activate()` 里通过同一个 API 注册,没有旁路。 +- 内置的 TypeScript、Python 语言插件、两个中文直译翻译插件和 SQLAlchemy、Celery、enum 库规则在 `activate()` 里通过同一个 API 注册(规则按这个顺序),没有旁路。 - 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。 -- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译,也给筛选属性和值起名(`attributeNames`)。筛选属性的通用部分由宿主从 facts 推出(`attributes.ts` 的 `blockAttributes`),语言插件只在 `BlockShape.attributes` 里补自己语言特有的。第三方语言插件给函数、类、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。写在表达式里的遍历(推导式这类)由语言插件的 `iteration` 给结构(节点 → `IterationFacts`),翻译插件排成一个 `SegmentGroup`(中文直译是样式 `iteration`、框头是结果类型的译名);webview 只认框的样式 id,不认语言。 +- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译,也给筛选属性和值起名(`attributeNames`)。筛选属性的通用部分由宿主从 facts 推出(`attributes.ts` 的 `blockAttributes`),语言插件只在 `BlockShape.attributes` 里补自己语言特有的。第三方语言插件给函数、类、枚举、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。写在表达式里的遍历(推导式这类)由语言插件的 `iteration` 给结构(节点 → `IterationFacts`),翻译插件排成一个 `SegmentGroup`(中文直译是样式 `iteration`、框头是结果类型的译名);webview 只认框的样式 id,不认语言。 - 语言插件的信息只经契约到翻译插件:宿主翻每一块时把 `{ source, language }`(`SourceDocument`,language 是解析这份源码的语言插件)交给 `translate` / `describe`,翻译插件要词性、遍历结构都从 `language` 取,不 import 任何语言插件的模块(lint 规则挡住 `translators/` 下的非测试文件 import `languages/`)。所以第三方翻译插件(英文、AI 翻译)拿得到和内置翻译插件一样的信息,第三方语言插件也能配上任何翻译插件。 -- 第三方语言插件要给的:`id`、`languageIds`、`grammarPath`、`toBlocks`(块的种类、头部、子块;函数、类、导入、常量、变量块带 facts)、`tokenRole`。可以给的:facts 里语言有才填的项(async、abstract、typeOnly、access、doc、`DeclaratorFacts.entries`)、`BlockShape.attributes`(通用词表以外的筛选属性)、`iteration`(有写在表达式里的遍历的语言)。没给的就少那一样:没有 entries 的值不展开,没有 `iteration` 的遍历照源码结构逐词翻。 +- 第三方语言插件要给的:`id`、`languageIds`、`grammarPath`、`toBlocks`(块的种类、头部、子块;函数、类、枚举、导入、常量、变量块带 facts)、`tokenRole`。可以给的:facts 里语言有才填的项(async、abstract、typeOnly、access、doc、`DeclaratorFacts.entries`)、`BlockShape.attributes`(通用词表以外的筛选属性)、`iteration`(有写在表达式里的遍历的语言)。没给的就少那一样:没有 entries 的值不展开,没有 `iteration` 的遍历照源码结构逐词翻。 - 翻译插件拿到的:代码块(kind、node、header、children,属性表块还有 facts)和 `SourceDocument`。按语法节点类型写的叫法(关键字表、按语境换的译法、节点模板、注解位置这类只关乎措辞的判断)属于翻译插件自己,写在它的表里。 ## 库规则 -语言插件讲语法结构,翻译插件定用词,它们都不认识某个库。库规则是第四种插件:认出某个库的一种写法(SQLAlchemy 的模型类),把那块整个换成一张专门的卡片。原型:`docs/prototype/code-reader/figma-plugin/code.js` 的「库规则:SQLAlchemy 数据表」一节(`SQLA_MODEL`、`tableCard`),画板「库规则 · SQLAlchemy 数据表」。 +语言插件讲语法结构,翻译插件定用词,它们都不认识某个库。库规则是第四种插件:认出某个库的一种写法(SQLAlchemy 的模型类),把那块整个换成一张专门的卡片;库的写法对应契约里现成的一种块时(Python 自带 enum 库的类就是枚举,TypeScript 的 enum 由语言插件报),也可以把那块认成这种块,交给翻译插件照常排。原型:`docs/prototype/code-reader/figma-plugin/code.js` 的「库规则:SQLAlchemy 数据表」一节(`SQLA_MODEL`、`tableCard`),画板「库规则 · SQLAlchemy 数据表」。 ### 规则要给什么、可以给什么 - 注册:`CodeReaderApi.registerLibraryRule(rule)`,返回 `Disposable`;内置规则走同一个 API。 - 必须给:`id`、`label`、`languageIds`、`libraries`(关心的库)、`claim`(看一块,认领就返回替身 `RuleBlock`,不认返回 `undefined`)。 -- 替身必须给:`kind`(写成「规则 id.名字」,宿主核对前缀;筛选记住的就是它,要稳定)、`badge`(种类标签的字和暗 / 亮两种颜色)、`rows`(用现成的 `PropertyRow`:属性名 + 着色的值或 Markdown,表格就是带 `columns` 的子行)、`absorbs`(并进来的子块)。可以给:`defines`(块自己的名字,跳到定义是它时闪整块)、`attributes`(自己的筛选属性);表格子行填 `PropertyItem.defines`(字段名),跳到这个定义时只闪那一行。 +- 替身(`RuleBlock`)两种,按有没有 `as` 分: + - 认成现成的属性表块(`RuleFacts`):`as` 是种类和 facts(`BlockFacts`,例如 `{ kind: 'enum', facts: EnumFacts }`),`absorbs` 同卡片。宿主把原块换成这种块(节点不变,头部是原块整条语句,子块是留下的),此后和语言插件报的这种块完全一样:翻译插件的 `describe` 排属性表、种类名取翻译插件的、颜色取这个种类的、筛选属性从 facts 推、块自己的名字从 facts 取。规则不给用词,也不给标签和颜色。只要检查并进来的是不是这块的子块。 + - 规则自己的卡片(`RuleCard`),下面几条都说的是它。 +- 卡片必须给:`kind`(写成「规则 id.名字」,宿主核对前缀;筛选记住的就是它,要稳定)、`badge`(种类标签的字和暗 / 亮两种颜色)、`rows`(用现成的 `PropertyRow`:属性名 + 着色的值或 Markdown,表格就是带 `columns` 的子行)、`absorbs`(并进来的子块)。可以给:`defines`(块自己的名字,跳到定义是它时闪整块)、`attributes`(自己的筛选属性);表格子行填 `PropertyItem.defines`(字段名),跳到这个定义时只闪那一行。 - 只经契约:规则拿到的是块(kind、node、header、children、facts)和 `RuleContext`;源码里的名字照抄时自己带下标(`Segment.offset`),表达式、类型交给 `context.translate`(当前翻译插件的 `expression`,名字带下标,⌘ + 点击、高亮各处出现照常),说明文字交给 `context.doc`(当前翻译插件的 `doc`,和别的「说明」一样按 Markdown 显示)。规则可以读自己那门语言的语法树(规则声明了 `languageIds`),但不 import 语言插件、翻译插件和宿主模块(`rules/contractOnly.test.ts` 检查 `rules/` 下非测试文件只 import `../contract` 和同目录的文件)。 - 用词归规则:属性名、表头、种类名、类型译名都在规则自己的词表里,按 `context.displayLanguage`(翻译插件的 `displayLanguage`)挑,没有这种语言的词表就用规则默认的那份;加一种显示语言就是给规则加一份词表。webview 只画结构,不认识「数据表」这些字。 @@ -507,7 +542,7 @@ export interface CodeReaderApi { - 认领:宿主从顶层往下每块问一遍启用的规则,按注册顺序,第一个认领的算(先注册的优先);被认领的块留下的子块照样问。 - 出错:规则抛错、或替身不合约定(种类 id 没带规则 id 前缀、并进来的不是这块的子块、属性表里的引用标的不是自己 / 规则没给 `resolve` / 引用的段没带下标)时,宿主记一条警告日志(输出面板「Superpowers」),当它没认、接着问下一个规则;都没认这块照常显示。预览不会因为规则出错而失败。 - 并进来的子块:不再单独成块,由规则挑(`absorbs`),宿主不替规则多并。没并进来的子块(方法、规则不讲的注释)不管写在哪(字段中间也算),都照源码顺序是替身的子块,预览里照常看得见。替身的编辑范围是原块整条语句(从头部开始,到原块连同各子块的最后一行,类就是整个类),盖住留下的子块的行:替身编辑时预览不画这些子块,同一行不会同时在两处能改(见「预览(代码块树)」)。内置规则并成员时连同紧挨在它前面的注释块一起并(讲的是这个成员),讲方法的注释、最后面的注释照常留下。 -- 替身在预览里:种类标签的字、颜色取 `badge`(暗色、亮色主题各取一个),内容是 `rows`;筛选的 `kind` 是替身的种类 id,其余属性从原块推出(原来的类是公共的,数据表也是公共的),叠上规则给的;筛选栏里这个种类叫 `badge.label`;和类块一样默认收起子块。 +- 卡片在预览里:种类标签的字、颜色取 `badge`(暗色、亮色主题各取一个),内容是 `rows`;筛选的 `kind` 是替身的种类 id,其余属性从原块推出(原来的类是公共的,数据表也是公共的),叠上规则给的;筛选栏里这个种类叫 `badge.label`;和类块一样默认收起子块。 ### 规则自己查定义 @@ -674,6 +709,31 @@ export interface CodeReaderApi { 配色:Celery 应用的名称 variable、别的值按翻译插件;任务名 string,「(条目名 …)」「N 条…」「展开」hint,频率的换算词 keyword,选项名 storage、` · ` hint,说明 comment。 +### 内置:enum(Python) + +`vscode/src/codeReader/rules/enum.ts`,`libraries: ['enum']`(Python 自带的库)。原型:`code.js` 的「枚举」一节(`ENUM_SAMPLE`、`enumCard`),画板「Prototype / 枚举」;样例是 luffy 的 `referral/models.py`、`vault/models.py` 里的枚举(节选在 `fixtures/enumModels.py`)。不出卡片,把类块认成枚举块(`RuleFacts`,`as: { kind: 'enum', facts: EnumFacts }`),属性表、种类名、颜色和 TypeScript 的 enum 一样,见「属性表」的「枚举」一行。 + +- 认哪些类:父类里有 enum 的枚举类型(`Enum`、`IntEnum`、`StrEnum`、`Flag`、`IntFlag`、`ReprEnum`),写成 `enum.X`(`import enum`、`import enum as e` 的 `e.X`)或从 enum 导入的名字(`from enum import Enum as E` 的 `E`,别名按 `context.imports` 换回),可以和别的父类混着写(`class Status(str, enum.Enum)`)。同名的别处的东西不算(`from sqlalchemy import Enum` 的 `Enum`);父类是同一文件里别的枚举类(`class Sub(Base)`)的认不出来(规则只看这一块)。 +- facts:名称、说明(docstring)、访问级别、装饰器(`@enum.unique`)、父类照原来的类;成员照源码顺序。 +- 成员照 Python 的规则认:类主体里给一个名字赋值的语句(`A = 1`、`A: int = 1`),名字不是 `__x__`、`_x_`(enum 保留的,如 `_ignore_`、`_order_`)、`__x`(私有),不在 `_ignore_` 列的名字里(空格隔开的字符串,或字符串的列表 / 元组),值不是 `lambda`、`property(...)` / `classmethod(...)` / `staticmethod(...)`、`nonmember(...)`、连着赋值(`A = B = 1`);解构赋值(`A, B = 1, 2`)不认。只写注解的(`_value_: int`)不是成员。 +- 值:`auto()`(`enum.auto()` 也算)交给库自动取,没有 `value`(表里写「自动」);`member(x)` 的值是 `x`;别的是写的值。 +- 说明(`docs`):从上一个子块之后起紧挨在它上面的注释块(几条行注释连成的一块是几条)、它这一行行尾的注释、紧跟在它后面的属性 docstring,照源码顺序(同 Celery 定时任务的「说明」取法)。 +- 并进来的子块:各个成员连同紧挨在它前面的注释块;方法、嵌套类、不是成员的属性、讲方法的注释、最后面的注释照常是子块(不管写在哪),枚举块默认收起它们。 + +样例(`fixtures/enumModels.py` 的 `CommissionStatus`,测试里逐字核对): + +``` +名称: CommissionStatus +访问级别: 公共 +继承: str, enum.Enum +成员: 5 个 + PENDING | "pending" | 待结算 + AVAILABLE | "available" | 可提现 + WITHDRAWING | "withdrawing" | 提现中 + WITHDRAWN | "withdrawn" | 已提现 + INVALID | "invalid" | 已失效 +``` + ## 解析运行时 - 依赖 `@vscode/tree-sitter-wasm`(VS Code 团队维护,包含 web-tree-sitter 运行时和 TypeScript / TSX / JavaScript / Python 语法包,版本配套)。 @@ -686,14 +746,14 @@ export interface CodeReaderApi { ### TypeScript / JavaScript - 顶层与语句块内的每条语句一块;连续的行注释合成一块注释。 -- JSDoc(以 `/**` 开头的块注释,`/**/` 不算)和下一条语句之间只有空白(空行也算,中间隔着装饰器也算)、那条语句是函数或类块时,它是这个函数、类的说明:进 `facts.doc`,头部往上延到注释开头,不单独成块。其余情况(后面不是函数、类块,中间隔着别的注释,在上一条语句的行尾)照旧是注释块。`//` 行注释和普通 `/* */` 块注释不变。 +- JSDoc(以 `/**` 开头的块注释,`/**/` 不算)和下一条语句之间只有空白(空行也算,中间隔着装饰器也算)、那条语句是函数、类或枚举块时,它是这个函数、类、枚举的说明:进 `facts.doc`,头部往上延到注释开头,不单独成块。其余情况(后面不是函数、类、枚举块,中间隔着别的注释,在上一条语句的行尾)照旧是注释块。`//` 行注释和普通 `/* */` 块注释不变。 - `import_statement` → import。 - `export_statement` 包着声明 → 按声明定种类,节点取整个 `export_statement`(头部包含 `export`)。 - `function_declaration` / `generator_function_declaration` / `method_definition` → function,子块来自主体语句。 - 变量声明的唯一声明值是带语句块主体的箭头函数 / 函数表达式 → function(React 组件、回调常见写法),子块来自主体;是类表达式(`const A = class {}`)→ class。 - `lexical_declaration` / `variable_declaration`:模块顶层的 `const` → const,其余 → variable。 - `class_declaration` / `abstract_class_declaration`、`export default class {}` → class,子块:方法 → function,字段 → field(值是带语句块主体的函数 → function,值是类表达式 → class),注释 → comment。 -- `interface_declaration` → type,子块:属性 / 方法签名 → field;`type_alias_declaration` → type;`enum_declaration` → type,成员 → field。 +- `interface_declaration` → type,子块:属性 / 方法签名 → field;`type_alias_declaration` → type;`enum_declaration`(含 `const enum`、`declare enum` 的 `ambient_declaration`、`export` 包着的)→ enum,头部是整条声明,成员在属性表里讲、不成子块。 - `if_statement` → if,子块来自 consequence;`else` / `else if` 展平成紧跟其后的兄弟块 else(`else if` 的头部是 `else if (...)`,子块来自它的 consequence)。 - `for` / `for…in` / `for…of` / `while` / `do` → loop。 - `try_statement` → try;`catch_clause` → 兄弟块 catch;`finally_clause` → 兄弟块 finally。 @@ -724,7 +784,8 @@ export interface CodeReaderApi { - TypeScript 导入:`import_statement` 的 `source`(`import x = require()` 取 `import_require_clause` 里的);`import_clause` 里的标识符是默认导入,`namespace_import` 是 `* as`,`named_imports` 里每个 `import_specifier` 的 name / alias;语句本身带 `type` 是仅类型(同抽象,只有 TypeScript 填)。 - 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 类:`class_definition` 的 name;说明是主体开头 docstring 的字符串节点;装饰器同函数;泛型参数是 Python 3.12 `type_parameters` 里的每一项;父类是 `superclasses`(`argument_list`)里的每个具名子节点,照写的顺序,关键字参数(`metaclass=ABCMeta`)、`*bases`、`**kw` 也算;没有抽象、实现的接口。Python 没有枚举语法:enum 库的类由库规则认成枚举块(见「内置:enum(Python)」)。 +- TypeScript 枚举:`enum_declaration` 的 name、body;说明、访问级别同类(`export` 了公共、顶层没 `export` 私有、嵌套的没有);没有装饰器、父类。成员是 body 里的 `property_identifier`(没写值)和 `enum_assignment`(name、value),照源码顺序;没写值的接着上一个数往上加(第一个 0),上一个的值是数字字面量(可带正负号,`0x10` 这类也算)或算出来的数才算得出,否则没有 `computed`。说明:从上一个成员之后起单独成行的注释(`//` 和 JSDoc),和写在它这一行行尾的注释;同一行上一个成员后面的 JSDoc 讲的是下一个成员;最后一个成员后面的注释不算。 - Python 导入:`import_statement` 每个 name 一个来源、导入项是整个模块;`import_from_statement` 的 module_name 和各 name(`aliased_import` 拆出 alias),`wildcard_import` 是全部;`future_import_statement` 的来源是 `__future__`。 - Python 变量:`assignment` 的 left / type / right,说明是紧跟在后面的属性 docstring 的字符串节点(见上面代码块树规则);right 是 `dictionary` 时 entries 是它的具名子节点(注释除外):`pair` 的 key / value(value 又是 `dictionary` 时连它的 entries)、`dictionary_splat` 里的表达式。 @@ -803,14 +864,14 @@ Python 内置类型(仅注解里):str 字符串、int 整数、float 浮 ```ts interface BlockViewBase { id: string // 在树里的位置,例如 '6.1.0' - kind: string // BlockKind,或库规则认领的块的种类 id(sqlalchemy.table) + kind: string // BlockKind(库规则认成的现成块也是,如 enum),或库规则卡片的种类 id(sqlalchemy.table) startLine: number // 编辑范围的起止行,0 起:一般是 header,库规则认领的块是原块整条语句(盖住它留下的子块) endLine: number code: string // 编辑范围的源码,去掉共同缩进 codeTokens: Segment[] // 源码着色(拼起来等于 code) defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取(库规则的块取 RuleBlock.defines);跳到的定义是它时闪整块 attributes: Record // 筛选属性(含 kind),见「筛选」 - badge?: KindBadge // 库规则认领的块才有:种类标签的字和颜色;webview 按它上色、默认折叠 + badge?: KindBadge // 库规则的卡片才有:种类标签的字和颜色;webview 按它上色、默认折叠 children: BlockView[] } @@ -835,7 +896,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 - `{ type: 'setFilter', excluded }`:改块筛选(整份替换);扩展端逐项核对后记进 `workspaceState`,再发 `filter` 给所有预览。webview 不在本地先改,只按扩展端发来的显示。 -- 块的编辑状态和用户的折叠选择在 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`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 +- 块的编辑状态和用户的折叠选择在 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`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 ## 颜色 @@ -856,7 +917,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | 属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认导出、全部、整个模块、匿名按 hint;输入参数表格的列名是界面文字(`descriptionForeground`),不是着色的词。推导式框里的「遍历」「的每个」「仅当」「取」「异步遍历」按 keyword,框头按「循环」块的种类色。父类名(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(虚线框);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 +种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;enum 枚举 #b180d7(亮色 #652d90);field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 ## 代码位置 @@ -872,6 +933,7 @@ vscode/src/codeReader/ workspaceFiles.ts 工作区索引在 VS Code 里怎么找文件、读文件、盯改动(排除规则) rules/sqlalchemy.ts 内置 SQLAlchemy 库规则(只 import contract 和同目录的共用代码) rules/celery.ts 内置 Celery 库规则:两张卡片、频率换算、任务名索引 + rules/enum.ts 内置 enum 库规则(Python):枚举类认成枚举块,成员照 Python 的规则认 rules/pythonTree.ts Python 库规则共用的读语法树的办法(名字最后一节、字符串内容、调用的参数、别名) rules/segments.ts 库规则共用的拼属性表文字的办法 rules/children.ts 库规则共用的挑子块的办法(成员连同紧挨在它前面的注释块) @@ -901,7 +963,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 语言插件:`envLock.ts`、`issueLock.ts` 的代码块树(种类、header 行号、层级)与原型 `CODE_TREE` 一致,只是紧贴函数的 JSDoc 算进了函数头部;Python 样例覆盖 import / def / docstring / if-elif-else / for / try-except / with / 推导式;函数的说明:JSDoc 各种挂法(export、默认导出、方法隔着装饰器、函数值的变量、嵌套函数)和不挂的情况,docstring 连同它前面的注释进头部。类:`classes.ts`、`classes.py` 覆盖 export + 抽象 + 泛型 + extends / implements + 装饰器 + JSDoc、匿名默认导出、赋给变量 / 类字段的类表达式、函数里的类、类里的类、docstring、metaclass 关键字参数、下划线私有。 - 块头部:各样例里块的头部行互不重叠;函数、类说明所在的行在函数、类块里,「编辑」写回原样不变。 -- 折叠:默认函数、类块折叠、其余展开;用户的选择盖过默认、重新加载后还在(嵌套子行的也是);各层子行的 id 互不相撞;旧版本存的状态不报错;跳到定义时默认折叠的函数、类也会展开。 +- 折叠:默认函数、类、枚举块和库规则的卡片折叠、其余展开;用户的选择盖过默认、重新加载后还在(嵌套子行的也是);各层子行的 id 互不相撞;旧版本存的状态不报错;跳到定义时默认折叠的函数、类也会展开。 - 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则;字典 / 对象字面量的各项:Python 字符串键、名字键、嵌套字典、`**` 展开、空字典、类属性,TypeScript 名字键、字符串键、计算属性名、简写、展开、方法和 getter(头部到主体之前)、嵌套对象、空对象,不是字面量的值没有 entries。 - 字典 / 对象的属性表:项数、键 | 值两列、嵌套一层层展开、「展开」、方法头部、空的一行;键和值里的名字、字符串带下标(嵌套的也是);每项标的定义是键,展开项不标;跳到定义落在对象属性名上只闪那一项、连外层各层一起展开,只是显示着的名字闪整块、展开到它那一层。 - 推导式:Python 语言插件读出的结构(调用唯一参数的生成器带 sharedParentheses、几个参数之一的不带,列表带条件,字典分出键和值,集合的解构变量,几节 for / if 照源码顺序,async for,套着的各自认,字面量和 for 语句不算);中文直译「推导式」一节的样例表逐字一致,框的样式、框头、关键字着色,顿号连写、条件位置、另起一句、异步遍历、集合、框里的框、多个参数旁边的生成器、跨行带注释的生成器,属性表的默认值和字典项的值里也换;框里的名字带下标且指回源码原文,高亮各处出现、跳到定义都认框里的名字(只显示在框里的推导式变量 → 框所在的块);TypeScript 不认。 @@ -920,6 +982,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 库规则的引用:引用标的不是自己、规则没给 `resolve`、段没带下标时当替身不合约定;查引用交给标它的规则,规则不在、没给 `resolve`、出错时记下来、当作没有定义;视图数据里的引用(下标 → 引用)只从规则认领的块的属性表里取,查定义时是引用的问规则、其余问语言扩展,只对记下它的那个文档版本有效(`editor/definitionSources.test.ts`)。 - 工作区索引(内存里的工作区,`MemoryIndexHost`):第一次查才建、同一个 id 只建一份;查到的是名字的行列(列按 UTF-16)、照文件和行排好;预筛不过的不解析;改了只重看那一份、新建加进来、删目录连里面的去掉;新建 / 删掉问过在不在的文件(`__init__.py`)时问过它的文件重看;建索引期间的改动建好后补上;读不了、规则出错的记下来、别的照常;dispose 后不再盯着。 - Celery 规则:`fixtures/celeryApp.py` 的应用卡片和定时任务卡片逐字同上面的样例(列名、「12 条,另有展开 1 处」、行尾注释、条目里单独成行的注释、跨行的 `10⏎ * 60`、`每 get_settings().… 秒`、展开一行);任务名是引用、下标指回源码原文;应用卡片的位置参数按参数顺序认、别的参数各一行、展开、属性 docstring、别名;没导入 celery、不是 `Celery(...)` 的不认;频率换算(秒数算式、`timedelta`、`crontab` 各种写法、`schedule`、换算不了的「每 X 秒」和照写法);说明取哪些注释;条目名和任务名不同、没写任务名、任务名不是字符串、值不是字典、选项一列有才有;`conf.update(beat_schedule=…)`、不带 `beat_schedule` 的 `conf.update` 和 `autodiscover_tasks` 照常。任务名索引:显式 `name=`、默认「模块路径.函数名」(包、`src` 下的命名空间包、没有包)、`@shared_task` / `@shared_task()` / `@app.task` 不带括号 / 老写法 `@task`、`async def`、函数里的只认显式名字、`name=` 不是字符串的不认、同名几处都给、文件改了和新建 `__init__.py` 后跟着变、没有任务装饰器的文件不解析。 +- 枚举(`rules/enum.test.ts`、`languages/typescript.test.ts`):`fixtures/enumModels.py` 的 `CommissionStatus` 逐字同上面的样例,`SecretScope` 的 docstring 当说明、没有说明列;种类 enum、叫「枚举」、访问级别同原来的类、没有标签(badge),编辑范围是整个类;Python 成员规则(dunder、sunder、私有名、`_ignore_`、lambda、`property`、`nonmember`、只写注解的不是成员,`auto()` 写「自动」,`member(x)` 取 x,说明取上面的注释块、行尾注释、属性 docstring,装饰器);方法、嵌套类、不是成员的属性照源码顺序是子块,成员前面的注释并进来、最后面的留下,「编辑」写回整个类;类名、成员名的定义范围和带下标的词指回源码原文;没导入 enum、SQLAlchemy 的 `Enum`、不是枚举类型的父类不认,`import enum as e` 的 `e.IntEnum` 认。TypeScript:`enums.ts` 里 export / const / declare / 函数里的 enum 都是枚举块、JSDoc 进头部,写的值、自增算出的值(接不上数字就没有)、说明(上面的注释和 JSDoc、行尾注释,同一行的 JSDoc 讲下一个),访问级别;自增的值和「自动」按 hint。TypeScript 和 Python 同样的成员得到一样的种类、属性表行、列名和格子着色(除了 Python 多的「继承」)。宿主:规则认成现成属性表块时照语言插件报的这种块显示、并进来的不是子块时照常显示;webview 枚举块默认折叠。 - 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 diff --git a/vscode/src/codeReader/attributes.ts b/vscode/src/codeReader/attributes.ts index 0ab3dca..0905e7f 100644 --- a/vscode/src/codeReader/attributes.ts +++ b/vscode/src/codeReader/attributes.ts @@ -26,11 +26,12 @@ function factAttributes(block: CodeBlock): Partial {} } /** * 代码块树 → 视图数据:header 整行源码去共同缩进、按语法树着色、调用翻译插件。 - * 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock),并进去的子块不再单独成块。 + * 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock):规则的卡片,或认成的属性表块(交给翻译插件排,同语言插件报的这种块), + * 并进去的子块不再单独成块。 */ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: LanguageAdapter, translator: Translator | undefined, rules: RuleRun = NO_RULES): BlockView[] { const lines = lineRanges(source) @@ -153,34 +155,40 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu const doc = block.doc && translator?.doc(block.doc, document) return { translation: translator ? translator.translate(block, document) : [], ...(doc && { doc }) } } - const build = (block: CodeBlock, id: string): BlockView => { - const root = block.node.tree.rootNode - const claim = active.length > 0 ? claimBlock(block, active, context, rules.report) : undefined - if (claim) { - const { kind, badge, rows, defines, attributes } = claim.block - const { span, kept } = claimedShape(block, claim.block.absorbs) - return { - id, - kind, - ...sourceRows(root, span.startRow, span.endRow), - ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), - attributes: { ...blockAttributes(block), ...attributes, kind }, - badge, - children: kept.map((child, i) => build(child, `${id}.${i}`)), - properties: rows, - } - } + /** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):头部是编辑范围,子块照常交给各规则看。 */ + function plain(block: CodeBlock, id: string): BlockView { const name = ownName(block) return { id, kind: block.kind, - ...sourceRows(root, block.header.startRow, block.header.endRow), + ...sourceRows(block.node.tree.rootNode, block.header.startRow, block.header.endRow), ...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }), attributes: blockAttributes(block), children: block.children.map((child, i) => build(child, `${id}.${i}`)), ...('facts' in block ? { properties: translator ? translator.describe(block, document) : [] } : textView(block)), } } + function build(block: CodeBlock, id: string): BlockView { + const claim = active.length > 0 ? claimBlock(block, active, context, rules.report) : undefined + if (!claim) + return plain(block, id) + const { span, kept } = claimedShape(block, claim.block.absorbs) + if ('as' in claim.block) { + const { attributes } = block + return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, id) + } + const { kind, badge, rows, defines, attributes } = claim.block + return { + id, + kind, + ...sourceRows(block.node.tree.rootNode, span.startRow, span.endRow), + ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), + attributes: { ...blockAttributes(block), ...attributes, kind }, + badge, + children: kept.map((child, i) => build(child, `${id}.${i}`)), + properties: rows, + } + } return blocks.map((block, i) => build(block, String(i))) } diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 4d3367b..98cb54d 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -4,7 +4,7 @@ import type { Disposable } from 'vscode' export type { Node } export type BlockKind - = | 'import' | 'function' | 'class' | 'type' | 'field' | 'const' | 'variable' + = | 'import' | 'function' | 'class' | 'enum' | 'type' | 'field' | 'const' | 'variable' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' /** 源码里一段的下标:源码字符串下标(web-tree-sitter 的 startIndex / endIndex),end 为开区间。 */ @@ -70,6 +70,37 @@ export interface ClassFacts { implements: Node[] } +/** 枚举的一个成员;节点都指向源码。 */ +export interface EnumMemberFacts { + name: Node + /** 源码里写的值;没写(TypeScript 的自增成员)、或值交给库自动取(Python 的 `auto()`)时没有。 */ + value?: Node + /** 没写值、语言算得出来时的值(TypeScript 数值枚举自增:0、1、2……);算不出来时没有。 */ + computed?: number + /** + * 成员的说明文字节点(注释、JSDoc、属性 docstring),照源码顺序:紧挨在它上面(从上一个成员之后起)单独成行的、 + * 写在它这一行行尾的、Python 紧跟在它后面的属性 docstring。翻译插件逐个取原文连起来。 + */ + docs: Node[] +} + +/** + * 枚举:TypeScript 的 `enum` 由语言插件报;Python 没有枚举语法,自带 enum 库的写法(`class X(enum.Enum)`)由库规则认成这种块。 + * 两边同一种块、同一张属性表。 + */ +export interface EnumFacts { + name: Node + /** 枚举自己的说明,同 ClassFacts.doc。 */ + doc?: Node + /** 同 ClassFacts.access。 */ + access?: Access + decorators: Node[] + /** 照源码写的父类(Python 的 `str, enum.Enum`);TypeScript 没有。 */ + bases: BaseFacts[] + /** 照源码顺序。 */ + members: EnumMemberFacts[] +} + /** 导入的一项。 */ export type ImportItem = | { kind: 'named', name: Node, alias?: Node } // import { a as b }、from m import a as b @@ -145,7 +176,7 @@ export interface VariableFacts { * - kind:块的种类(BlockKind); * - access:访问级别,'public' | 'private' | 'protected',语言可以加自己的值(Kotlin 的 'internal',见 BlockShape.attributes); * - async、abstract、typeOnly:异步、抽象、仅类型,值是 'true' | 'false'。 - * 宿主从块的 kind 和 facts 推出它们(FunctionFacts.access / async、ClassFacts.access / abstract、ImportFacts.typeOnly、VariableFacts.access), + * 宿主从块的 kind 和 facts 推出它们(FunctionFacts.access / async、ClassFacts.access / abstract、EnumFacts.access、ImportFacts.typeOnly、VariableFacts.access), * 语言插件不用另报;facts 里没填的(语言没有这个概念、块没有这一项),块就没有这个属性,也不受这个属性的筛选影响。 */ export const COMMON_ATTRIBUTES = ['kind', 'access', 'async', 'abstract', 'typeOnly'] as const @@ -174,12 +205,17 @@ export interface BlockShape { /** 预览成属性表的块:语言插件从语法树读出结构化信息(facts),翻译插件把它排成属性表。 */ export interface FunctionBlock extends BlockShape { kind: 'function', facts: FunctionFacts } export interface ClassBlock extends BlockShape { kind: 'class', facts: ClassFacts } +/** 枚举块的头部是整个枚举(成员都在属性表里讲),没有子块;库规则认出来的枚举(Python)是整个类,留下的方法等是子块。 */ +export interface EnumBlock extends BlockShape { kind: 'enum', facts: EnumFacts } export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts } export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts } -export type FactBlock = FunctionBlock | ClassBlock | ImportBlock | VariableBlock +export type FactBlock = FunctionBlock | ClassBlock | EnumBlock | ImportBlock | VariableBlock /** 带说明(facts.doc)的块。 */ -export type DocumentedBlock = FunctionBlock | ClassBlock | VariableBlock +export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | VariableBlock + +/** 属性表块的种类和 facts,不带块的形状(节点、头部、子块):库规则把一块认成现成的一种属性表块时给它(RuleFacts)。 */ +export type BlockFacts = FactBlock extends infer B ? B extends FactBlock ? Pick : never : never export type TextKind = Exclude @@ -301,7 +337,7 @@ export interface Translator { displayLanguage: string /** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */ translate: (block: TextBlock, document: SourceDocument) => PreviewText - /** 函数、类、导入、常量、变量块 → 属性表。 */ + /** 函数、类、枚举、导入、常量、变量块 → 属性表。 */ describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] /** 一个表达式或类型节点整个翻成一行译文,译法同属性表里的值:库规则翻默认值、类型时借它。 */ expression: (node: Node, document: SourceDocument) => Inline[] @@ -318,9 +354,13 @@ export interface ThemeColor { dark: string, light: string } export interface KindBadge { label: string, color: ThemeColor } /** - * 库规则认领一块后给出的替身:预览里它代替原来那块,是一张属性表(种类标签、每一行都由规则给),下面挂着原块留下的子块。 + * 库规则认领一块后给出的替身,预览里它代替原来那块,下面挂着原块留下的子块: + * 规则自己的卡片(RuleCard),或认成契约里现成的一种属性表块(RuleFacts)。 */ -export interface RuleBlock { +export type RuleBlock = RuleCard | RuleFacts + +/** 规则自己的卡片:一张属性表,种类标签、每一行都由规则给(用词归规则)。 */ +export interface RuleCard { /** 种类 id,写成「规则 id.名字」(sqlalchemy.table):筛选的 kind 属性值就是它,筛选记住的也是它,要稳定。 */ kind: string /** 种类标签的文字和颜色:筛选栏里这个种类也叫这个名字。 */ @@ -338,6 +378,16 @@ export interface RuleBlock { absorbs: CodeBlock[] } +/** + * 把这块认成契约里现成的一种属性表块(Python 自带 enum 库的类 → 枚举块):宿主当语言插件报的这种块处理, + * 种类、种类名、颜色、属性表、筛选属性都和语言自带的这种块一样,由翻译插件排;规则只给 facts(库的知识),不给用词。 + * 块的头部是原块整条语句(facts 里的节点都在里面),absorbs 同 RuleCard.absorbs。 + */ +export interface RuleFacts { + as: BlockFacts + absorbs: CodeBlock[] +} + /** 库规则看一块时宿主给的:这份源码、它的导入,以及借当前翻译插件翻表达式、取说明的办法。 */ export interface RuleContext { document: SourceDocument @@ -396,8 +446,9 @@ export interface RuleWorkspace { } /** - * 库规则:认出某个库的一种写法(SQLAlchemy 的模型类),把那块整个换成一张专门的卡片;文件导入了它关心的库才启用。 - * 规则只经契约拿信息:块(kind、node、header、children、facts)和 RuleContext;用词(属性名、种类名、类型的译名)属于规则自己。 + * 库规则:认出某个库的一种写法(SQLAlchemy 的模型类),把那块整个换成一张专门的卡片,或认成现成的一种属性表块 + * (enum 库的类 → 枚举块);文件导入了它关心的库才启用。 + * 规则只经契约拿信息:块(kind、node、header、children、facts)和 RuleContext;卡片的用词(属性名、种类名、类型的译名)属于规则自己。 */ export interface LibraryRule { id: string // 'sqlalchemy' diff --git a/vscode/src/codeReader/fixtures/enumModels.py b/vscode/src/codeReader/fixtures/enumModels.py new file mode 100644 index 0000000..2953ca5 --- /dev/null +++ b/vscode/src/codeReader/fixtures/enumModels.py @@ -0,0 +1,38 @@ +# luffy-agent-platform 的 backend/src/luffy_agent/referral/models.py、vault/models.py 节选:enum 库规则的样例 +from __future__ import annotations + +import enum + +from sqlalchemy import Enum as SQLEnum + + +class ReferralStatus(str, enum.Enum): + NORMAL = "normal" + FROZEN = "frozen" + + +class RelationStatus(str, enum.Enum): + ACTIVE = "active" + INVALID = "invalid" + + +class CommissionStatus(str, enum.Enum): + PENDING = "pending" # 待结算 + AVAILABLE = "available" # 可提现 + WITHDRAWING = "withdrawing" # 提现中 + WITHDRAWN = "withdrawn" # 已提现 + INVALID = "invalid" # 已失效 + + +class SecretScope(enum.StrEnum): + """条目作用域: user 全局 / app 级 (同名覆盖 user).""" + + USER = "user" + APP = "app" + + +class SecretKind(enum.StrEnum): + """条目类型: secret 只写不读; variable 可读回明文.""" + + SECRET = "secret" + VARIABLE = "variable" diff --git a/vscode/src/codeReader/fixtures/enums.ts.txt b/vscode/src/codeReader/fixtures/enums.ts.txt new file mode 100644 index 0000000..8a251f3 --- /dev/null +++ b/vscode/src/codeReader/fixtures/enums.ts.txt @@ -0,0 +1,20 @@ +/** 订单状态。 */ +export enum OrderStatus { + /** 待支付 */ + Pending = 'pending', + Paid = 'paid', // 已支付 + // 已取消 + Cancelled = 'cancelled', +} + +enum Direction { Up, Down = 5, Left, Right = -1, Next, Mask = 1 << 2, After } + +export const enum Flag { A = 0x10, B } + +declare enum Ambient { X } + +export declare const enum Both { Y = 1 } + +function local(): void { + enum Inner { Z } +} diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index e30a782..aea259f 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -13,6 +13,7 @@ import { registerNoteCommands } from './notes/commands' import { FileNotes } from './notes/fileNotes' import { CodeReaderRegistry } from './registry' import { celeryRule } from './rules/celery' +import { enumRule } from './rules/enum' import { sqlalchemyRule } from './rules/sqlalchemy' import { bundledWasmDir, TreeSitterRuntime } from './runtime' import { pythonZhTranslator } from './translators/pythonZh' @@ -91,6 +92,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { api.registerTranslator(pythonZhTranslator), api.registerLibraryRule(sqlalchemyRule), api.registerLibraryRule(celeryRule), + api.registerLibraryRule(enumRule), ) return api } diff --git a/vscode/src/codeReader/languages/shared.ts b/vscode/src/codeReader/languages/shared.ts index f8daf67..17d9d8d 100644 --- a/vscode/src/codeReader/languages/shared.ts +++ b/vscode/src/codeReader/languages/shared.ts @@ -144,7 +144,7 @@ export function listBlocks( else { const blocks = toBlocks(item) const [head] = blocks - if ((head?.kind === 'function' || head?.kind === 'class') && doc) { + if ((head?.kind === 'function' || head?.kind === 'class' || head?.kind === 'enum') && doc) { blocks[0] = documented(head, doc) doc = undefined } diff --git a/vscode/src/codeReader/languages/typescript.test.ts b/vscode/src/codeReader/languages/typescript.test.ts index dba29e7..1844724 100644 --- a/vscode/src/codeReader/languages/typescript.test.ts +++ b/vscode/src/codeReader/languages/typescript.test.ts @@ -155,7 +155,7 @@ describe('typeScript 代码块树', () => { ]) }) - it('enum 成员 → field;type 别名 → type;多个循环 → loop', async () => { + it('enum → 枚举块(整条声明是头部,成员不成子块);type 别名 → type;多个循环 → loop', async () => { const source = [ 'enum Color {', ' Red = 1,', @@ -170,7 +170,7 @@ describe('typeScript 代码块树', () => { '}', ].join('\n') expect(await tree(source)).toEqual([ - { at: 1, kind: 'type', kids: [{ at: 2, kind: 'field' }, { at: 3, kind: 'field' }] }, + { at: [1, 4], kind: 'enum' }, { at: 5, kind: 'type' }, { at: 6, kind: 'loop', kids: [ { at: 7, kind: 'loop', kids: [{ at: 7, kind: 'statement' }] }, @@ -561,3 +561,54 @@ describe('typeScript 类', () => { expect(tsx[2]).toMatchObject({ abstract: false }) }) }) + +describe('typeScript 枚举', () => { + it('enums.ts:export / const / declare / 函数里的 enum 都是枚举块,紧贴的 JSDoc 算进头部', async () => { + expect(await tree(readFixture('enums.ts.txt'))).toEqual([ + { at: [1, 8], kind: 'enum' }, + { at: 10, kind: 'enum' }, + { at: 12, kind: 'enum' }, + { at: 14, kind: 'enum' }, + { at: 16, kind: 'enum' }, + { at: 18, kind: 'function', kids: [{ at: 19, kind: 'enum' }] }, + ]) + }) + + it('成员:写的值、自增算出来的值(接不上数字就算不出来)、说明(前面单独成行的注释和 JSDoc、行尾注释);export 的公共、顶层其余私有、嵌套的没有', async () => { + const byLine = await facts(readFixture('enums.ts.txt')) + expect(byLine[1]).toEqual({ + name: 'OrderStatus', + doc: '/** 订单状态。 */', + access: 'public', + decorators: [], + bases: [], + members: [ + { name: 'Pending', value: '\'pending\'', docs: ['/** 待支付 */'] }, + { name: 'Paid', value: '\'paid\'', docs: ['// 已支付'] }, + { name: 'Cancelled', value: '\'cancelled\'', docs: ['// 已取消'] }, + ], + }) + expect(byLine[10]).toMatchObject({ + access: 'private', + members: [ + { name: 'Up', computed: 0, docs: [] }, + { name: 'Down', value: '5', docs: [] }, + { name: 'Left', computed: 6, docs: [] }, + { name: 'Right', value: '-1', docs: [] }, + { name: 'Next', computed: 0, docs: [] }, + { name: 'Mask', value: '1 << 2', docs: [] }, + { name: 'After', docs: [] }, + ], + }) + expect((byLine[10] as { members: object[] }).members[6]).toEqual({ name: 'After', docs: [] }) + expect(byLine[12]).toMatchObject({ name: 'Flag', access: 'public', members: [{ name: 'A', value: '0x10' }, { name: 'B', computed: 17 }] }) + expect(byLine[14]).toMatchObject({ name: 'Ambient', access: 'private', members: [{ name: 'X', computed: 0 }] }) + expect(byLine[16]).toMatchObject({ name: 'Both', access: 'public', members: [{ name: 'Y', value: '1' }] }) + expect(byLine[19]).toEqual({ name: 'Inner', decorators: [], bases: [], members: [{ name: 'Z', computed: 0, docs: [] }] }) + }) + + it('jSDoc 挂在同一行上一个成员后面时讲的是下一个成员;最后一个成员后面的注释不算', async () => { + const byLine = await facts('enum E { A, /** 第二 */ B, C // 第三\n // 收尾\n}') + expect(byLine[1]).toMatchObject({ members: [{ name: 'A', docs: [] }, { name: 'B', docs: ['/** 第二 */'] }, { name: 'C', docs: ['// 第三'] }] }) + }) +}) diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index 124defd..b0ec8d8 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -1,4 +1,4 @@ -import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' +import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, EnumMemberFacts, 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, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren } from './shared' @@ -51,6 +51,49 @@ function declarationAccess(outer: Node, top: boolean): Access | undefined { return top ? 'private' : undefined } +/** 数字字面量(可带正负号)的值;别的写法算不出来。 */ +function numberValue(node: Node): number | undefined { + const sign = node.type === 'unary_expression' ? node.child(0)?.type : undefined + const literal = sign ? node.childForFieldName('argument') : node + if (literal?.type !== 'number' || (sign && sign !== '-' && sign !== '+')) + return undefined + const value = Number(literal.text.replace(/_/g, '')) + return Number.isFinite(value) ? (sign === '-' ? -value : value) : undefined +} + +/** + * enum 的成员,照源码顺序:没写值的成员接着上一个数字往上加(第一个是 0),上一个的值不是数字字面量、也不是算出来的数就算不出来。 + * 说明:从上一个成员之后起单独成行的注释、写在它这一行行尾的注释(JSDoc 总是讲下一个成员)。 + */ +function enumMembers(body: Node | null): EnumMemberFacts[] { + const members: EnumMemberFacts[] = [] + let pending: Node[] = [] + let lastRow = -1 + let next: number | undefined = 0 + for (const child of named(body)) { + if (child.type === 'comment') { + const last = members.at(-1) + if (last && pending.length === 0 && child.startPosition.row === lastRow && !COMMENTS.isDoc(child)) + last.docs.push(child) + else + pending.push(child) + continue + } + const assigned = child.type === 'enum_assignment' + const name = assigned ? child.childForFieldName('name') ?? child.firstNamedChild : child + const value = assigned ? child.childForFieldName('value') ?? undefined : undefined + if (!name) + continue + const computed: number | undefined = value ? undefined : next + const base: number | undefined = value ? numberValue(value) : computed + next = base === undefined ? undefined : base + 1 + members.push({ name, ...(value && { value }), ...(computed !== undefined && { computed }), docs: pending }) + pending = [] + lastRow = child.endPosition.row + } + return members +} + /** 类成员:写了 public / private / protected 按写的,#name 是私有,默认公共。 */ function memberAccess(member: Node): Access { const modifier = named(member).find(child => child.type === 'accessibility_modifier') @@ -274,8 +317,14 @@ function blockReader(typed: boolean): (root: Node) => CodeBlock[] { case 'variable_declaration': return [variableBlock(declaration, outer, top)] case 'interface_declaration': - case 'enum_declaration': return [membersBlock(declaration, outer)] + case 'enum_declaration': + return [enumBlock(declaration, outer, top)] + case 'ambient_declaration': { + // declare enum X {}:别的 declare 写法照旧是一条语句 + const inner = named(declaration).find(child => child.type === 'enum_declaration') + return [inner ? enumBlock(inner, outer, top) : leafBlock('statement', outer)] + } case 'type_alias_declaration': return [leafBlock('type', outer)] default: { @@ -399,7 +448,19 @@ function blockReader(typed: boolean): (root: Node) => CodeBlock[] { } } - /** interface / enum:成员(属性、方法签名、枚举项)→ field。 */ + /** enum(含 const enum、declare enum):整条声明是头部,成员都在属性表里讲,没有子块。 */ + function enumBlock(declaration: Node, outer: Node, top: boolean): CodeBlock { + const name = declaration.childForFieldName('name') + if (!name) + return leafBlock('type', outer) + return { + kind: 'enum', + ...leafShape(outer), + facts: { name, access: declarationAccess(outer, top), decorators: [], bases: [], members: enumMembers(declaration.childForFieldName('body')) }, + } + } + + /** interface:成员(属性、方法签名)→ field。 */ function membersBlock(declaration: Node, outer: Node): CodeBlock { const body = declaration.childForFieldName('body') if (!body) diff --git a/vscode/src/codeReader/libraryRules.test.ts b/vscode/src/codeReader/libraryRules.test.ts index 4e275f8..595600b 100644 --- a/vscode/src/codeReader/libraryRules.test.ts +++ b/vscode/src/codeReader/libraryRules.test.ts @@ -6,7 +6,7 @@ import { toBlockViews } from './blockView' import { replaceLines } from './editText' import { createPythonAdapter } from './languages/python' import { activeRules, fromLibrary, importsIn, resolveReference } from './libraryRules' -import { WASM_DIR, withBlocks } from './testSupport' +import { propertyLines, WASM_DIR, withBlocks } from './testSupport' import { pythonZhTranslator } from './translators/pythonZh' const py = createPythonAdapter(WASM_DIR) @@ -177,6 +177,27 @@ describe('库规则:认领', () => { ]) }) + it('认成契约里现成的属性表块:种类、属性表、筛选属性照语言插件报的这种块,编辑范围是整个类;并进来的不是子块时照常显示', async () => { + const asEnum = (absorbs: (block: CodeBlock) => CodeBlock[]): LibraryRule => ({ + ...cardRule('lib'), + claim: block => block.kind === 'class' && block.facts.name + ? { as: { kind: 'enum', facts: { name: block.facts.name, access: block.facts.access, decorators: [], bases: [], members: [] } }, absorbs: absorbs(block) } + : undefined, + }) + const [, recognized] = await views(MODEL, [asEnum(block => block.children.slice(0, 1))]) + expect(recognized).toMatchObject({ kind: 'enum', startLine: 2, endLine: 8, attributes: { kind: 'enum', access: 'public' } }) + expect(recognized.badge).toBeUndefined() + expect(propertyLines('properties' in recognized ? recognized.properties : [])).toEqual(['名称: User', '访问级别: 公共', '成员: 无']) + expect(recognized.children.map(child => child.kind)).toEqual(['comment', 'variable', 'function']) + + const reports: string[] = [] + const stranger: CodeBlock[] = [] + await withBlocks(py, 'python', 'x = 1', blocks => stranger.push(...blocks)) + const [, shown] = await views(MODEL, [asEnum(() => stranger)], reports) + expect(shown.kind).toBe('class') + expect(reports).toEqual(['库规则 lib 给第 3 行的 class 块的替身不合约定,这块照常显示:要并进来的块不是这块的子块']) + }) + it('筛选:规则的种类和别的种类一样计数,名字取规则给的标签', async () => { const list = await views(MODEL, [cardRule('orm')]) expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values).toEqual([ diff --git a/vscode/src/codeReader/libraryRules.ts b/vscode/src/codeReader/libraryRules.ts index 04f5243..0e416c9 100644 --- a/vscode/src/codeReader/libraryRules.ts +++ b/vscode/src/codeReader/libraryRules.ts @@ -40,10 +40,12 @@ export interface Claim { /** 替身不合 RuleBlock 的约定时说哪儿不对。 */ function problemOf(rule: LibraryRule, block: CodeBlock, claimed: RuleBlock): string | undefined { - if (!claimed.kind.startsWith(`${rule.id}.`)) - return `种类 id「${claimed.kind}」没以「${rule.id}.」开头` if (claimed.absorbs.some(child => !block.children.includes(child))) return '要并进来的块不是这块的子块' + if ('as' in claimed) + return undefined + if (!claimed.kind.startsWith(`${rule.id}.`)) + return `种类 id「${claimed.kind}」没以「${rule.id}.」开头` for (const { reference, offset } of referencedSegments(claimed.rows)) { if (reference?.rule !== rule.id) return `引用「${reference?.key}」标的规则是「${reference?.rule}」,不是自己` diff --git a/vscode/src/codeReader/rules/celery.ts b/vscode/src/codeReader/rules/celery.ts index 76fe31c..0e44970 100644 --- a/vscode/src/codeReader/rules/celery.ts +++ b/vscode/src/codeReader/rules/celery.ts @@ -1,4 +1,4 @@ -import type { IndexedFile, IndexEntry, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, Segment, ThemeColor, WorkspaceIndex } from '../contract' +import type { IndexedFile, IndexEntry, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, WorkspaceIndex } from '../contract' import type { Call } from './pythonTree' import { aliasesOf, callOf, lastName, stringContent, stringValue, syntaxChildren } from './pythonTree' import { hint, joined, name, plain, range, word } from './segments' @@ -482,7 +482,7 @@ function appOfConf(node: Node | null | undefined, attribute: string): Node | und } /** 定时任务卡片:应用 / 说明 / (conf.update 里别的配置各一行)/ 任务表。 */ -function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: Node | undefined, env: Env): RuleBlock { +function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: Node | undefined, env: Env): RuleCard { const { words, context } = env const { labels, heads } = words const entries = entriesOf(schedule) @@ -506,7 +506,7 @@ function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: N // ─── Celery 应用 ────────────────────────────────────────────────────────────── /** Celery 应用卡片:名称 / 说明 / 应用名 / 消息队列 / 结果存储,别的参数照写的顺序各一行,行名是参数名。 */ -function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): RuleBlock { +function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): RuleCard { const { words, context } = env const { labels } = words const parameters = new Map() diff --git a/vscode/src/codeReader/rules/contractOnly.test.ts b/vscode/src/codeReader/rules/contractOnly.test.ts index b9953da..18c1f2a 100644 --- a/vscode/src/codeReader/rules/contractOnly.test.ts +++ b/vscode/src/codeReader/rules/contractOnly.test.ts @@ -12,7 +12,7 @@ describe('库规则只经 contract 拿信息', () => { const files = readdirSync(__dirname).filter(file => file.endsWith('.ts') && !file.endsWith('.test.ts')) it('目录里有规则', () => { - expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts'])) + expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts', 'enum.ts'])) }) it.each(files)('%s 不 import 语言插件、翻译插件和宿主', (file) => { diff --git a/vscode/src/codeReader/rules/enum.test.ts b/vscode/src/codeReader/rules/enum.test.ts new file mode 100644 index 0000000..d6e248b --- /dev/null +++ b/vscode/src/codeReader/rules/enum.test.ts @@ -0,0 +1,260 @@ +import type { BlockView } from '../blockView' +import type { Inline, PropertyRow, Segment } from '../contract' +import { describe, expect, it } from 'vitest' +import { fileAttributes } from '../attributes' +import { toBlockViews } from '../blockView' +import { replaceLines } from '../editText' +import { createPythonAdapter } from '../languages/python' +import { createTypeScriptAdapter } from '../languages/typescript' +import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport' +import { pythonZhTranslator } from '../translators/pythonZh' +import { typescriptZhTranslator } from '../translators/typescriptZh' +import { celeryRule } from './celery' +import { enumRule } from './enum' +import { sqlalchemyRule } from './sqlalchemy' + +const py = createPythonAdapter(WASM_DIR) +const ts = createTypeScriptAdapter(WASM_DIR) +const SNAPSHOT = readFixture('enumModels.py') +/** 和扩展里注册的顺序一样。 */ +const RULES = [sqlalchemyRule, celeryRule, enumRule] + +async function views(source: string, reports: string[] = []): Promise { + return withBlocks(py, 'python', source, blocks => + toBlockViews(blocks, source, py, pythonZhTranslator, { rules: RULES, report: message => reports.push(message) })) +} + +function tsViews(source: string): Promise { + return withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, source, ts, typescriptZhTranslator, { rules: RULES, report: () => {} })) +} + +function properties(view: BlockView | undefined): PropertyRow[] { + if (!view || !('properties' in view)) + throw new Error(`块 ${view?.id} 不是属性表`) + return view.properties +} + +function named(list: BlockView[], name: string): BlockView { + const found = list.find(view => 'properties' in view && propertyLines(view.properties)[0] === `名称: ${name}`) + if (!found) + throw new Error(`没有 ${name}`) + return found +} + +/** 属性表里所有带下标的词(连表格里的格子)。 */ +function linkedSegments(rows: PropertyRow[]): Segment[] { + const pieces: Inline[] = rows.flatMap(row => [ + ...('markdown' in row.value ? [] : row.value), + ...(row.items ?? []).flatMap(item => item.cells.flat()), + ]) + return flatSegments(pieces).filter(segment => segment.offset !== undefined) +} + +const RULE_CASES = [ + 'from enum import Enum as E, IntFlag, auto, member, nonmember', + 'import enum', + '', + '', + 'class Color(E):', + ' """颜色。"""', + '', + ' _ignore_ = ["TEMP"]', + ' __slots__ = ()', + ' _order_ = "RED GREEN"', + ' # 红色', + ' # 最常用', + ' RED = auto()', + ' GREEN = 2 # 绿', + ' """绿色。"""', + ' TEMP = 9', + ' __secret = 1', + ' helper = lambda self: 1', + ' label = property(lambda self: self.name)', + ' table = nonmember({})', + '', + ' def describe(self) -> str:', + ' return self.name', + '', + ' BLUE = member(3)', + ' _private = 5', + ' ALIAS: int = 4', + ' _value_: int', + '', + ' class Meta:', + ' pass', + '', + ' # 收尾', + '', + '', + '@enum.unique', + 'class Perm(IntFlag):', + ' R = 4', + ' W = 2', + '', +].join('\n') + +describe('enum 库规则(Python)', () => { + it('luffy 的枚举:和原型一样的枚举卡片,说明取行尾注释,类的 docstring 当说明', async () => { + const list = await views(SNAPSHOT) + expect(list.map(view => view.kind)).toEqual(['comment', 'import', 'import', 'import', 'enum', 'enum', 'enum', 'enum', 'enum']) + const commission = named(list, 'CommissionStatus') + expect(commission.badge).toBeUndefined() + expect(propertyLines(properties(commission))).toEqual([ + '名称: CommissionStatus', + '访问级别: 公共', + '继承: str, enum.Enum', + '成员: 5 个', + ' PENDING | "pending" | 待结算', + ' AVAILABLE | "available" | 可提现', + ' WITHDRAWING | "withdrawing" | 提现中', + ' WITHDRAWN | "withdrawn" | 已提现', + ' INVALID | "invalid" | 已失效', + ]) + expect(properties(commission).at(-1)?.columns).toEqual(['成员', '值', '说明']) + expect(propertyLines(properties(named(list, 'SecretScope')))).toEqual([ + '名称: SecretScope', + '说明: 条目作用域: user 全局 / app 级 (同名覆盖 user).', + '访问级别: 公共', + '继承: enum.StrEnum', + '成员: 2 个', + ' USER | "user"', + ' APP | "app"', + ]) + expect(properties(named(list, 'SecretScope')).at(-1)?.columns).toEqual(['成员', '值']) + }) + + it('筛选:种类是 enum、叫「枚举」,访问级别同原来的类;卡片默认的编辑范围是整个类', async () => { + const list = await views(SNAPSHOT) + const commission = named(list, 'CommissionStatus') + expect(commission.attributes).toEqual({ kind: 'enum', access: 'public' }) + expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values) + .toContainEqual({ id: 'enum', name: '枚举', count: 5 }) + const lines = SNAPSHOT.split('\n') + expect(lines[commission.startLine]).toBe('class CommissionStatus(str, enum.Enum):') + expect(lines[commission.endLine]).toBe(' INVALID = "invalid" # 已失效') + }) + + it('成员照 Python 的规则认:dunder、sunder、私有名、_ignore_ 里的、lambda、property、nonmember、只写注解的不是成员;auto() 写「自动」,member(x) 的值是 x', async () => { + const list = await views(RULE_CASES) + const color = named(list, 'Color') + expect(propertyLines(properties(color))).toEqual([ + '名称: Color', + '说明: 颜色。', + '访问级别: 公共', + '继承: E', + '成员: 5 个', + ' RED | 自动 | 红色 最常用', + ' GREEN | 2 | 绿 绿色。', + ' BLUE | 3 | ', + ' _private | 5 | ', + ' ALIAS | 4 | ', + ]) + const auto = properties(color).at(-1)?.items?.[0].cells[1] + expect(auto).toEqual([{ text: '自动', role: 'hint' }]) + expect(propertyLines(properties(named(list, 'Perm')))).toEqual([ + '名称: Perm', + '访问级别: 公共', + '装饰器: @enum.unique', + '继承: IntFlag', + '成员: 2 个', + ' R | 4', + ' W | 2', + ]) + }) + + it('方法、嵌套类、不是成员的属性不管写在哪都照常是子块;成员前面的注释并进卡片,最后面的注释留下;卡片「编辑」写回整个类', async () => { + const list = await views(RULE_CASES) + const color = named(list, 'Color') + const lines = RULE_CASES.split('\n') + expect(color.children.map(child => lines[child.startLine].trim())).toEqual([ + '_ignore_ = ["TEMP"]', + '__slots__ = ()', + '_order_ = "RED GREEN"', + 'TEMP = 9', + '__secret = 1', + 'helper = lambda self: 1', + 'label = property(lambda self: self.name)', + 'table = nonmember({})', + 'def describe(self) -> str:', + '_value_: int', + 'class Meta:', + '# 收尾', + ]) + expect([lines[color.startLine], lines[color.endLine]]).toEqual(['class Color(E):', ' # 收尾']) + expect(replaceLines(RULE_CASES, color.startLine, color.endLine, color.code)).toBe(RULE_CASES) + const edited = replaceLines(RULE_CASES, color.startLine, color.endLine, color.code.replace('GREEN = 2', 'GREEN = 20')) + expect(edited).toBe(RULE_CASES.replace('GREEN = 2', 'GREEN = 20')) + }) + + it('跳到定义、高亮出现处:卡片是类名的定义,成员行是成员名的定义,带下标的词都指着源码里的这个名字', async () => { + const list = await views(SNAPSHOT) + const commission = named(list, 'CommissionStatus') + expect(SNAPSHOT.slice(commission.defines?.startIndex, commission.defines?.endIndex)).toBe('CommissionStatus') + const members = properties(commission).at(-1)?.items ?? [] + expect(members.map(item => SNAPSHOT.slice(item.defines?.startIndex, item.defines?.endIndex))).toEqual(['PENDING', 'AVAILABLE', 'WITHDRAWING', 'WITHDRAWN', 'INVALID']) + const linked = linkedSegments(properties(commission)) + expect(linked.map(segment => segment.text)).toEqual(expect.arrayContaining(['CommissionStatus', 'str', 'enum', 'Enum', 'PENDING', '"pending"'])) + for (const segment of linked) + expect(SNAPSHOT.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text) + }) + + it('不是 enum 的枚举类型不认:没导入 enum、父类是 SQLAlchemy 的 Enum、父类不是枚举类型', async () => { + const plain = await views('class Color(Enum):\n RED = 1\n') + expect(plain.map(view => view.kind)).toEqual(['class']) + const other = await views('import enum\nfrom sqlalchemy import Enum\n\n\nclass A(Enum):\n X = 1\n\n\nclass B(enum.auto):\n Y = 1\n\n\nclass C(str):\n Z = 1\n') + expect(other.map(view => view.kind)).toEqual(['import', 'import', 'class', 'class', 'class']) + const aliased = await views('import enum as e\n\n\nclass A(e.IntEnum):\n X = 1\n') + expect(aliased.map(view => view.kind)).toEqual(['import', 'enum']) + }) +}) + +describe('枚举:TypeScript 和 Python 同一张卡片', () => { + it('同样的成员:种类、种类名、属性表的行、列名、格子都一样(TypeScript 没有「继承」)', async () => { + const tsSource = 'export enum Status {\n PENDING = "pending", // 待结算\n /** 可提现 */\n AVAILABLE = "available",\n}\n' + const [tsEnum] = await tsViews(tsSource) + const [pending] = properties(tsEnum).at(-1)?.items ?? [] + expect(pending.cells[0]).toEqual([{ text: 'PENDING', role: 'variable', offset: tsSource.indexOf('PENDING') }]) + expect(pending.defines).toEqual({ startIndex: tsSource.indexOf('PENDING'), endIndex: tsSource.indexOf('PENDING') + 'PENDING'.length }) + const [, pyEnum] = await views('import enum\n\n\nclass Status(enum.Enum):\n PENDING = "pending" # 待结算\n # 可提现\n AVAILABLE = "available"\n') + const shape = (view: BlockView): unknown => { + const rows = properties(view).filter(row => row.label !== '继承') + return { + kind: view.kind, + badge: view.badge, + attributes: view.attributes, + lines: propertyLines(rows), + columns: rows.map(row => row.columns), + roles: rows.at(-1)?.items?.map(item => item.cells.map(cell => flatSegments(cell).map(segment => segment.role))), + } + } + expect(shape(tsEnum)).toEqual(shape(pyEnum)) + expect(propertyLines(properties(tsEnum))).toEqual([ + '名称: Status', + '访问级别: 公共', + '成员: 2 个', + ' PENDING | "pending" | 待结算', + ' AVAILABLE | "available" | 可提现', + ]) + }) + + it('typeScript 没写值的成员写算出来的数,算不出来写「自动」,都是说明词', async () => { + const [direction] = await tsViews('enum Direction { Up, Down = 5, Left, Mask = 1 << 2, After }') + expect(propertyLines(properties(direction))).toEqual([ + '名称: Direction', + '访问级别: 私有', + '成员: 5 个', + ' Up | 0', + ' Down | 5', + ' Left | 6', + ' Mask | 1 << 2', + ' After | 自动', + ]) + expect(properties(direction).at(-1)?.items?.map(item => item.cells[1])).toEqual([ + [{ text: '0', role: 'hint' }], + [{ text: '5', role: 'number' }], + [{ text: '6', role: 'hint' }], + expect.any(Array), + [{ text: '自动', role: 'hint' }], + ]) + }) +}) diff --git a/vscode/src/codeReader/rules/enum.ts b/vscode/src/codeReader/rules/enum.ts new file mode 100644 index 0000000..f139ea1 --- /dev/null +++ b/vscode/src/codeReader/rules/enum.ts @@ -0,0 +1,152 @@ +import type { ClassBlock, CodeBlock, EnumMemberFacts, ImportFacts, LibraryRule, Node, RuleFacts } from '../contract' +import { leadingComments, withLeadingComments } from './children' +import { stringValue, syntaxChildren } from './pythonTree' + +/** + * Python 自带 enum 库的规则:父类里有 enum 的枚举类型(Enum、IntEnum、StrEnum、Flag、IntFlag、ReprEnum;写成 `enum.X`、 + * 从 enum 导入的名字或它的别名都算,可以和别的父类混着写:`class Status(str, enum.Enum)`)的类,认成契约里的枚举块(EnumFacts): + * 和 TypeScript 的 enum 是同一种块、同一张属性表,用词归翻译插件,规则只给 enum 库的知识——哪些类是枚举、哪些赋值是成员。 + * 成员照 Python 的规则认(见 memberOf);成员连同讲它的注释并进来,方法、嵌套类、别的属性照常是子块。 + * 父类是同一文件里别的枚举类(`class Sub(Base)`)的认不出来:规则只看这一块。 + */ + +const LIBRARY = 'enum' +const ENUM_TYPES = new Set(['Enum', 'IntEnum', 'StrEnum', 'Flag', 'IntFlag', 'ReprEnum']) +/** 值是这几种内置描述符的类属性不是成员。 */ +const DESCRIPTORS = new Set(['property', 'classmethod', 'staticmethod']) + +/** 这份源码里怎么叫 enum 库的东西:库自己(`import enum`、`import enum as e`),和从它导入的名字(本地名 → 原名)。 */ +interface Names { + modules: Set + imported: Map +} + +function namesOf(imports: readonly ImportFacts[]): Names { + const names: Names = { modules: new Set(), imported: new Map() } + for (const facts of imports) { + const fromEnum = facts.sources.some(source => source.text === LIBRARY) + for (const item of facts.items) { + if (item.kind === 'module' && item.module.text === LIBRARY) + names.modules.add(item.alias?.text ?? LIBRARY) + else if (item.kind === 'named' && fromEnum) + names.imported.set(item.alias?.text ?? item.name.text, item.name.text) + } + } + return names +} + +/** 节点指的是 enum 库里的哪个名字:`enum.Enum`、从 enum 导入的 `Enum`(别名换回原名);别的是 undefined。 */ +function enumName(node: Node | null | undefined, names: Names): string | undefined { + if (node?.type === 'identifier') + return names.imported.get(node.text) + const object = node?.type === 'attribute' ? node.childForFieldName('object') : null + return object?.type === 'identifier' && names.modules.has(object.text) ? node?.childForFieldName('attribute')?.text : undefined +} + +/** 调用的是 enum 库里的哪个函数(`auto()`、`enum.nonmember(...)`)。 */ +function enumCall(node: Node | null | undefined, names: Names): string | undefined { + return node?.type === 'call' ? enumName(node.childForFieldName('function'), names) : undefined +} + +/** 类主体里给一个名字赋值的子块(`A = 1`、`A: int = 1`、只写注解的 `A: int`):名字和值。 */ +function assignmentOf(block: CodeBlock): { name: Node, value?: Node } | undefined { + if (block.kind !== 'variable' && block.kind !== 'field') + return undefined + const assignment = block.node.firstNamedChild + const name = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null + if (name?.type !== 'identifier') + return undefined + return { name, value: assignment?.childForFieldName('right') ?? undefined } +} + +/** `_ignore_` 里列的名字:空格隔开的字符串,或字符串的列表、元组。 */ +function ignoredNames(children: readonly CodeBlock[]): Set { + const value = children.map(assignmentOf).find(assignment => assignment?.name.text === '_ignore_')?.value + const text = stringValue(value) + if (text !== undefined) + return new Set(text.split(/\s+/).filter(Boolean)) + const items = value?.type === 'list' || value?.type === 'tuple' ? syntaxChildren(value).map(stringValue) : [] + return new Set(items.filter((item): item is string => item !== undefined)) +} + +/** 名字能当成员:不是 `__x__`(dunder)、`_x_`(enum 保留的 sunder)、`__x`(私有,会被改名)。 */ +function memberName(name: string): boolean { + const dunder = name.length > 4 && name.startsWith('__') && name.endsWith('__') + const sunder = name.length > 2 && name.startsWith('_') && name.endsWith('_') && name[1] !== '_' && name.at(-2) !== '_' + const privateName = name.startsWith('__') && !name.endsWith('__') + return !dunder && !sunder && !privateName +} + +/** + * 一个子块是不是成员,是的话它的值:名字能当成员、不在 `_ignore_` 里,赋了值,值不是函数(lambda)、内置描述符 + * (property、classmethod、staticmethod)、`nonmember(...)`、连着赋值(`A = B = 1`)。 + * `auto()` 的值交给库自动取(没有 value),`member(x)` 的值是 x。 + */ +function memberOf(block: CodeBlock, ignored: ReadonlySet, names: Names): { name: Node, value?: Node } | undefined { + const assignment = assignmentOf(block) + const value = assignment?.value + if (!assignment || !value || !memberName(assignment.name.text) || ignored.has(assignment.name.text)) + return undefined + const callee = value.type === 'call' ? value.childForFieldName('function') : null + if (value.type === 'lambda' || value.type === 'assignment' || (callee?.type === 'identifier' && DESCRIPTORS.has(callee.text))) + return undefined + switch (enumCall(value, names)) { + case 'nonmember': + return undefined + case 'auto': + return { name: assignment.name } + case 'member': + return { name: assignment.name, value: syntaxChildren(value.childForFieldName('arguments'))[0] } + default: + return { name: assignment.name, value } + } +} + +/** 注释块里的各条注释(几条行注释连成的一块是好几个节点);当注释用的字符串是那个字符串。 */ +function commentNodes(block: CodeBlock): Node[] { + if (block.node.type !== 'comment') + return syntaxChildren(block.node).slice(0, 1) + const nodes: Node[] = [] + for (let node: Node | null = block.node; node?.type === 'comment' && node.endIndex <= block.header.endIndex; node = node.nextSibling) + nodes.push(node) + return nodes +} + +/** 成员的说明,照源码顺序:紧挨在它上面的注释块、它这一行行尾的注释、紧跟在它后面的属性 docstring。 */ +function memberDocs(children: readonly CodeBlock[], block: CodeBlock): Node[] { + const trailing = block.node.nextSibling + const doc = block.kind === 'variable' ? block.facts.doc : block.kind === 'field' ? block.doc : undefined + return [ + ...leadingComments(children, block).flatMap(commentNodes), + ...(trailing?.type === 'comment' && trailing.startPosition.row === block.node.endPosition.row ? [trailing] : []), + ...(doc ? [doc] : []), + ] +} + +function enumBlock(block: ClassBlock, names: Names): RuleFacts | undefined { + const { facts, children } = block + if (!facts.name || !facts.bases.some(base => ENUM_TYPES.has(enumName(base.value, names) ?? ''))) + return undefined + const ignored = ignoredNames(children) + const members: EnumMemberFacts[] = [] + const blocks: CodeBlock[] = [] + for (const child of children) { + const member = memberOf(child, ignored, names) + if (!member) + continue + members.push({ ...member, docs: memberDocs(children, child) }) + blocks.push(child) + } + return { + as: { kind: 'enum', facts: { name: facts.name, doc: facts.doc, access: facts.access, decorators: facts.decorators, bases: facts.bases, members } }, + absorbs: withLeadingComments(children, blocks), + } +} + +export const enumRule: LibraryRule = { + id: 'enum', + label: 'Python 枚举(enum)', + languageIds: ['python'], + libraries: [LIBRARY], + claim: (block, context) => block.kind === 'class' ? enumBlock(block, namesOf(context.imports)) : undefined, +} diff --git a/vscode/src/codeReader/rules/sqlalchemy.ts b/vscode/src/codeReader/rules/sqlalchemy.ts index 0f5025c..3e290d1 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.ts @@ -1,4 +1,4 @@ -import type { ClassBlock, CodeBlock, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, ThemeColor, TokenRole } from '../contract' +import type { ClassBlock, CodeBlock, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor, TokenRole } from '../contract' import type { Call } from './pythonTree' import { withLeadingComments } from './children' import { aliasesOf, callOf, canonicalName, lastName, stringValue, syntaxChildren } from './pythonTree' @@ -414,7 +414,7 @@ function tableRow(label: string, heads: string[], items: PropertyItem[], env: En return items.length > 0 ? [{ label, value: [hint(env.words.count(items.length))], columns: heads, items }] : [] } -function tableBlock(block: ClassBlock, model: Model, env: Env): RuleBlock { +function tableBlock(block: ClassBlock, model: Model, env: Env): RuleCard { const { words, context } = env const { facts } = block const { labels } = words diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 25ae65e..9b84e1f 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, AttributeNames, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, AttributeNames, BaseFacts, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -136,6 +136,7 @@ const KIND_ZH: Record = { import: '导入', function: '函数', class: '类', + enum: '枚举', type: '类型', field: '字段', const: '常量', @@ -432,6 +433,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return functionRows(block.facts, phrases) case 'class': return classRows(block.facts, phrases) + case 'enum': + return enumRows(block.facts, phrases) case 'import': return importRows(block.facts, phrases) default: @@ -611,11 +614,46 @@ function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] { ...flagRows('abstract', facts.abstract), ...decoratorRows(facts.decorators, say), ...listRow('类型参数', facts.typeParameters.map(say.translated), defining(...facts.typeParameters)), - ...listRow('父类', facts.bases.map(base => base.typeArguments ? [...say.translated(base.value), ...say.translated(base.typeArguments)] : say.translated(base.value))), + ...listRow('父类', facts.bases.map(base => baseText(base, say))), ...listRow('实现的接口', facts.implements.map(say.translated)), ] } +/** 父类的一项:TypeScript 的泛型实参紧跟在后面。 */ +function baseText(base: BaseFacts, say: Phrases): Inline[] { + return base.typeArguments ? [...say.translated(base.value), ...say.translated(base.typeArguments)] : say.translated(base.value) +} + +/** + * 枚举(TypeScript 的 enum、Python enum 库的类,同一张表):名称 / 说明 / 访问级别 / 装饰器 / 继承(照写的父类),有才显示; + * 成员收成一张表:成员 | 值 | 说明。没写值的成员写语言算出的值(自增的 0、1、2……),算不出来写「自动」,都按说明词着色; + * 说明是成员的注释、JSDoc、属性 docstring 的原文用空格连起来,一个成员都没有说明时不给这一列。 + */ +function enumRows(facts: EnumFacts, say: Phrases): PropertyRow[] { + const docs = facts.members.map(member => member.docs.map(node => say.doc(node)?.markdown).filter(Boolean).join(' ')) + const described = docs.some(Boolean) + return [ + { label: '名称', value: say.name(facts.name, 'type') }, + ...docRows(facts.doc, say), + ...accessRows(facts.access), + ...decoratorRows(facts.decorators, say), + ...(facts.bases.length > 0 ? [{ label: '继承', value: joined(facts.bases.map(base => baseText(base, say)), ', ') }] : []), + { + label: '成员', + value: count(facts.members.length), + columns: described ? ['成员', '值', '说明'] : ['成员', '值'], + items: facts.members.map((member, i) => ({ + cells: [ + say.name(member.name, 'variable'), + member.value ? say.translated(member.value) : [hint(member.computed === undefined ? '自动' : String(member.computed))], + ...(described ? [docs[i] ? [segment(docs[i], 'comment', undefined)] : []] : []), + ], + ...defining(member.name), + })), + }, + ] +} + /** 函数、类、常量、变量的说明:有文字才有这一行。 */ function docRows(doc: Node | undefined, say: Phrases): PropertyRow[] { const markdown = doc && say.doc(doc) diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 8206d12..ccd9b3a 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -74,7 +74,7 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner ) } -/** 预览状态的内容:函数、类、导入、常量、变量块和库规则认领的块是属性表,其余是一行译文,带说明的译文下面接着说明。 */ +/** 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文,带说明的译文下面接着说明。 */ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { if ('properties' in block) return diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index 0b801e4..eaa9ff4 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -120,7 +120,7 @@ function PropertyTable({ parentId, columns, items }: { parentId: string, columns } /** - * 函数、类、导入、常量、变量块和库规则认领的块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行, + * 函数、类、枚举、导入、常量、变量块和库规则的卡片的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行, * 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。 */ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: { diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index d2d9ed4..3c41d76 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -14,6 +14,7 @@ body { --cr-kind-import: #c586c0; --cr-kind-function: #dcdcaa; --cr-kind-type: #4ec9b0; + --cr-kind-enum: #b180d7; --cr-kind-data: #9cdcfe; --cr-kind-branch: #ce9178; --cr-kind-loop: #8f9cff; @@ -43,6 +44,7 @@ body.vscode-high-contrast-light { --cr-kind-import: #af00db; --cr-kind-function: #795e26; --cr-kind-type: #267f99; + --cr-kind-enum: #652d90; --cr-kind-data: #001080; --cr-kind-branch: #a31515; --cr-kind-loop: #4f56d6; @@ -56,6 +58,7 @@ body.vscode-high-contrast-light { [data-kind="import"] { --kind: var(--cr-kind-import); } [data-kind="function"] { --kind: var(--cr-kind-function); } :is([data-kind="class"], [data-kind="type"]) { --kind: var(--cr-kind-type); } +[data-kind="enum"] { --kind: var(--cr-kind-enum); } :is([data-kind="field"], [data-kind="const"], [data-kind="variable"]) { --kind: var(--cr-kind-data); } :is([data-kind="if"], [data-kind="else"]) { --kind: var(--cr-kind-branch); } [data-kind="loop"] { --kind: var(--cr-kind-loop); } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index df8b57f..23a0aa8 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -3,7 +3,7 @@ */ export type BlockKind - = | 'import' | 'function' | 'class' | 'type' | 'field' | 'const' | 'variable' + = | 'import' | 'function' | 'class' | 'enum' | 'type' | 'field' | 'const' | 'variable' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' /** 着色用的词性,源码着色和译文着色共用。 */ @@ -97,7 +97,7 @@ interface BlockViewBase { } /** - * 函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文; + * 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文; * 一行译文的块带说明时 doc 是说明的 Markdown 原文,排在译文下面 */ export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] }) diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index 131b455..c7340eb 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -111,6 +111,10 @@ describe('眼下折叠着哪些', () => { expect([...collapsedIds([table, block('1', 'if')], {})]).toEqual(['0']) }) + it('没点过:枚举块(Python 枚举下面留着方法)和类块一样折叠', () => { + expect([...collapsedIds([block('0', 'enum', [block('0.0', 'function')]), block('1', 'if')], {})]).toEqual(['0', '0.0']) + }) + it('点过的按用户的:展开的函数、类保持展开,折叠的其他块、属性行保持折叠', () => { 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']) diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index 1d871a9..02a2b36 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -54,9 +54,9 @@ function readViewState(saved: unknown): ViewState { return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds } } -/** 块默认折叠吗:函数、类块和库规则认领的块收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */ +/** 块默认折叠吗:函数、类、枚举块和库规则的卡片收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */ export function foldedByDefault(block: Pick): boolean { - return block.badge !== undefined || block.kind === 'function' || block.kind === 'class' + return block.badge !== undefined || block.kind === 'function' || block.kind === 'class' || block.kind === 'enum' } /** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */