From 0afd012c943e2a112c8c4805beb22bccf359f437 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 27 Sep 2026 10:42:10 +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=E5=BC=82=E5=B8=B8=E5=8D=A1=E7=89=87?= =?UTF-8?q?=EF=BC=8CPython=E3=80=81TypeScript=20=E7=9A=84=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E7=B1=BB=E7=94=BB=E6=88=90=E5=90=8C=E4=B8=80=E5=BC=A0?= =?UTF-8?q?=E8=A1=A8=EF=BC=88=E5=B1=9E=E6=80=A7=E3=80=81=E6=9E=84=E9=80=A0?= =?UTF-8?q?=E5=8F=82=E6=95=B0=EF=BC=89=EF=BC=8C=E6=96=B9=E6=B3=95=E7=85=A7?= =?UTF-8?q?=E5=B8=B8=E6=98=AF=E5=AD=90=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 106 +++++- vscode/src/codeReader/attributes.ts | 1 + vscode/src/codeReader/blockView.test.ts | 2 +- vscode/src/codeReader/blockView.ts | 3 +- vscode/src/codeReader/contract.ts | 39 +- .../src/codeReader/fixtures/appExceptions.py | 145 ++++++++ vscode/src/codeReader/fixtures/classes.py | 8 +- .../codeReader/fixtures/commerceExceptions.py | 75 ++++ .../src/codeReader/fixtures/exceptions.ts.txt | 30 ++ .../codeReader/languages/exceptions.test.ts | 339 ++++++++++++++++++ .../src/codeReader/languages/python.test.ts | 8 +- vscode/src/codeReader/languages/python.ts | 122 ++++++- vscode/src/codeReader/languages/shared.ts | 127 ++++++- vscode/src/codeReader/languages/typescript.ts | 40 ++- .../codeReader/translators/zhTemplate.test.ts | 10 +- .../src/codeReader/translators/zhTemplate.ts | 90 ++++- .../webview-ui/src/codeReader/codeReader.css | 3 + .../webview-ui/src/codeReader/reveal.test.ts | 26 +- vscode/webview-ui/src/codeReader/types.ts | 2 +- .../src/codeReader/viewState.test.ts | 4 + vscode/webview-ui/src/codeReader/viewState.ts | 4 +- 21 files changed, 1118 insertions(+), 66 deletions(-) create mode 100644 vscode/src/codeReader/fixtures/appExceptions.py create mode 100644 vscode/src/codeReader/fixtures/commerceExceptions.py create mode 100644 vscode/src/codeReader/fixtures/exceptions.ts.txt create mode 100644 vscode/src/codeReader/languages/exceptions.test.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 4c3dbc9..04ce2c6 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 写法);段内单个换行是软换行,空行分段 | @@ -35,6 +35,7 @@ | 输入参数 | 排成带表头和边框的表格:名称 \| 类型 \| 默认值,表头写了「默认值」,格子里不再写「默认 」 | | 库规则 | 第四种插件:按库认出一种写法,把那块整个换成专门的卡片(内置 SQLAlchemy 规则:模型类 → 「数据表」卡片;内置 Celery 规则:`Celery(...)` → 「Celery 应用」卡片、`beat_schedule` → 「定时任务」卡片),或把那块认成契约里现成的一种属性表块(内置 enum 规则:Python 自带 enum 库的类 → 枚举块)。和语言插件、翻译插件一样写代码注册、只经契约拿信息;文件导入了规则关心的库才启用,由宿主按语言插件给的导入判断;卡片的用词归规则自己,认成的现成块照常由翻译插件排,webview 只画结构。见「库规则」 | | 枚举 | 枚举是一种块(种类「枚举」,紫色 #b180d7 / 亮色 #652d90),属性表:名称 / 说明 / 访问级别 / 装饰器 / 继承(有才显示)/ 成员 N 个,成员排成表格「成员 \| 值 \| 说明」。TypeScript 的 `enum` 是语言自己的写法,由语言插件报;Python 没有枚举语法,自带 enum 库的写法由库规则认成同一种块。两边同一种块、同一张表、同一个筛选种类,webview 只有一种画法 | +| 异常 | 异常类是一种块(种类「异常」,红色 #f14c4c / 亮色 #cd3131,和「捕获」同一色系),属性表:名称 / 说明 / 访问级别 / 装饰器 / 继承 / 属性 N 个(表格「属性 \| 类型 \| 值 \| 说明」)/ 构造参数 N 个(表格同函数的输入参数),有才显示。异常是语言自己的概念,Python、TypeScript 都由语言插件认:父类是语言自带的异常类、同一文件里认出来的异常类,或名字按命名习惯以 Error / Exception(Python 还有 Warning)结尾。方法(含构造函数)照常是子块、默认收起。见「异常」 | | 规则自己查定义 | 规则可以把自己给的名字标成「引用」(Celery 任务表里的任务名),⌘ / Ctrl 悬停、点击这样的名字时由规则查定义(Celery 在整个工作区里找任务函数),其余名字照旧问语言扩展;webview 只有一条链接 / 跳转的路,样子、闪、挑选框、在预览里打开都一样。规则要在全工作区找东西时只描述要找什么(`WorkspaceIndex`),找文件、解析、缓存、跟着改动更新归宿主。见「规则自己查定义」 | | 中文名 | 代码里给定义起的中文名(`Annotated[T, Comment("当前用户")]`、`@Comment("…")`、行尾 `# @Comment 当前用户`,没有时取参数在 docstring / JSDoc 里的说明):预览里这个定义和用到它的各处都显示中文名,悬停看原名,定义处本身在中文名后面淡淡写原名。语言插件认写法,用到它的各处由语言扩展按语义认(同一文件问 document highlights,别的文件定义的先查工作区索引、再问定义提供者),webview 只按源码下标换字。见「中文名」 | | 推导式(写在表达式里的遍历) | Python 的列表 / 集合 / 字典推导式、生成器表达式出现在译文任何地方(一行译文、属性表的值、默认值、字典项的值),都原地换成一个带循环图标和结果类型(生成器 / 列表 / 集合 / 字典)的小框,颜色同「循环」块,框里按读的顺序重排:「遍历 来源 的每个 变量,仅当 条件,取 值」。语言插件报结构,翻译插件定用词,webview 只认「框」不认语言 | @@ -84,18 +85,18 @@ ### 预览(代码块树) -- 每一句是一块:导入、函数、类、枚举、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。 +- 每一句是一块:导入、函数、类、枚举、异常、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。 - 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。 - 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签(字取翻译插件给 `kind` 值起的名字,见「筛选」)、内容、右上角一排图标按钮。 - 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示(`title`)和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`)。先是「问 AI」(sparkles 图标,点开 / 收起块下面的问答区,展开时带按下的底色、`aria-expanded`;文件不在工作区文件夹里时没有,见「问 AI」),再是「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类标签同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。 - 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 - 内容两种状态,每块单独切: - - 预览:函数、类、枚举、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。 + - 预览:函数、类、枚举、异常、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮换成眼睛「预览」;Esc 也回到预览。 - 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里;Python 常量、变量、字段的属性 docstring 算在它那一块里(中间隔着的空行也是)。 - 库规则认领的块(数据表卡片等,见「库规则」):种类标签的字和颜色由规则给,内容是规则给的属性表;它的编辑范围是原块整条语句(类就是整个类),「编辑」时整段一起改。规则没并进卡片的子块(方法、别的成员)不管写在类主体哪儿(两个字段中间也算),都照源码顺序是卡片的子块,各自的「编辑」照旧只改自己那几行。 - 同一行不会同时在两处能改(能同时编辑的几段互不重叠):一块在编辑时,编辑范围和它重叠的子孙块不画(卡片编辑时它下面的子块全收起来,一行写完的 `if x: return 1` 编辑时同一行的子块也不画),回到预览就照原样出来;点一块的「编辑」时,编辑范围和它重叠的别的块退出编辑。跳到定义的目标块正被盖住时闪盖住它的那块。 -- 折叠:函数、类、枚举块和库规则的卡片打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 +- 折叠:函数、类、枚举、异常块和库规则的卡片打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 - 顶部筛选栏:见「筛选」;翻译下拉框(多于一种翻译时才显示)也在栏里。 - 解析失败:显示错误原因,并提示用「编辑」查看源码。 @@ -104,7 +105,7 @@ 预览顶部钉住一条筛选栏,按块的属性藏掉一部分块(原型画板「筛选 · 默认 / 按访问级别 / 按种类」)。 - 三层分工,webview 不认识任何语言: - 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` 总是块的种类。 + 1. 语言插件给每块报和语言无关的属性「属性 = 值」,都是稳定的英文 id,不放显示文字。通用词表由契约定(`COMMON_ATTRIBUTES`):`kind`(块的种类)、`access`(`public` / `private` / `protected`,语言可以加自己的值)、`async`、`abstract`、`typeOnly`(`'true'` / `'false'`)。通用属性由宿主从块的 kind 和 facts 推出来(`FunctionFacts.access` / `async`、`ClassFacts.access` / `abstract`、`EnumFacts.access`、`ExceptionFacts.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」)。 @@ -118,7 +119,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-textLink-foreground`,间距 12px),下面 1px 分隔线(`--vscode-menu-separatorBackground`),列表太长时只滚列表、这一行不动;每项勾选框(勾着是 `--vscode-focusBorder` 底)、值、右边块数(`--vscode-editorLineNumber-foreground`);悬停、键盘高亮 `--vscode-list-hoverBackground`。 - 键盘和读屏:下拉框按钮 `aria-haspopup="listbox"`、`aria-expanded`;菜单 `role="listbox"`、`aria-multiselectable`,每项 `role="option"`、`aria-selected` 表示勾着。按钮上 ↓ / 回车 / 空格打开(↑ 打开并停在最后一项),焦点落在列表上;列表里 ↑ ↓ Home End 移动,空格 / 回车勾选;Shift+Tab 回到「取消全选」「全选」(普通按钮,回车 / 空格触发),Tab 再回到列表;Esc 关掉并回到按钮;焦点离开下拉框(Tab 出去、点外面)就关。鼠标点选项、全选 / 取消全选时焦点留在列表上,方向键接着能用。 -### 属性表(函数、类、枚举、导入、常量、变量块) +### 属性表(函数、类、枚举、异常、导入、常量、变量块) 卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`);输入参数例外,是一张表格(见下)。字典 / 对象的子行分键 / 值两列,键列宽 150px(编辑区窄时按 28% 收),值列占剩下的。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和导入项的子行一样)。 @@ -129,6 +130,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`) | +| 异常 | 名称(类型色;匿名类写「匿名」)/ 说明(有说明才显示,规则同函数)/ 访问级别(同类)/ 装饰器(有才显示,同函数)/ 继承(有才显示:照写的父类,同类的「父类」,如 `Exception`、`errors.HttpException<字符串>`)/ 属性(有类属性才显示:`N 个`,展开成表格,列名 属性 \| 类型 \| 值 \| 说明,每个属性一行:属性名(variable 色)\| 类型(写了标注是它的译文,一个属性都没标注时没有这一列)\| 值(写了值是值的译文,没写留空)\| 说明(同枚举成员的说明,一个属性都没有说明时没有这一列);讲的定义是属性名)/ 构造参数(写了构造函数才显示:表格同函数的「输入参数」,Python 去掉 self;讲的定义是参数名)。见「异常」 | | 导入 | 来源(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`)就重复几组 | @@ -150,6 +152,49 @@ - 空字面量不展开:值是一行 `空字典` / `空对象`(一行译文里也这样翻,同 `空数组`)。 - `as const`、`satisfies` 包着的对象(`{ … } as const`)不算字面量本身,照旧一行译文。 +### 异常 + +原型没画,照枚举卡片的画法。异常是语言自己的概念,不是某个库的写法,所以和 TypeScript 的 enum 一样由语言插件认,报成契约里的异常块(`ExceptionBlock`,种类 `exception`,facts 是 `ExceptionFacts`);Python、TypeScript 同一种块、同一张属性表、同一个筛选种类,用词归翻译插件,webview 只按种类上色、默认收起。 + +- 认哪些类(`languages/shared.ts` 的 `withExceptions`,各语言给一份 `ExceptionRules`):父类里有一项是异常类就是异常类。 + - Python:父类名(`X`、`pkg.X` 取 X,`Base[T]` 取 Base;关键字参数、`*bases`、调用不看)是自带的异常类(builtins 里的全部:`BaseException`、`Exception`、`ValueError`、`KeyboardInterrupt`、`StopIteration`……,含各种 `Warning`),或以 `Error`、`Exception`、`Warning` 结尾。 + - TypeScript / JavaScript:`extends` 后面的类名(`X`、`ns.X` 取 X,泛型实参不看;`Mixin(Base)` 这类调用不看)以 `Error` 或 `Exception` 结尾。自带的 `Error`、`TypeError`、`RangeError`、`AggregateError`……都以 Error 结尾,`DOMException` 以 Exception 结尾,不用另列。 + - 两种语言都算:父类是同一文件里认出来的异常类,按名字认,不论写在前面还是后面、嵌套在哪,一层层往下传(`class Deeper(Late)`、`class Late(Base)`、`class Base(Exception)` 三个都是)。 +- 按命名习惯认的部分和它的限制:父类从别的文件导入时语言插件看不到那个文件,只能按名字认(PEP 8 要求异常类名以 Error 结尾,`AppException` 这种以 Exception 结尾的也常见)。所以父类名不合习惯(从别处导入的 `NotFound`、`Forbidden` 当父类)的认不出来,照常是类块;名字合习惯却不是异常的父类(普通类起名 `RenderError`)会被当成异常。同一文件里按名字认,不看作用域(同名的两个类当成一个)。 +- 类属性(进「属性」表):Python 是类主体里给一个名字赋值的语句(`x = 1`、`x: int = 1`、只写注解的 `x: int`),连着赋值(`a = b = 1`)、解构赋值不算;TypeScript / JavaScript 是类字段(`public_field_definition` / `field_definition`,含 static;值是带语句块主体的函数、类表达式的照常是方法、类块)。说明同枚举成员:紧挨在上面的注释块、这一行行尾的注释、Python 紧跟在后面的属性 docstring,原文用空格连起来。类 docstring 里谷歌写法的 `Attributes:` 一节照原文留在「说明」里,不拆到各属性的说明格子。 +- 构造参数:Python 是 `__init__` 的参数去掉第一个(self;第一个是 `*args` 这类时不去),TypeScript / JavaScript 是 `constructor(...)` 的参数(参数属性 `public code?: string` 也是一个参数);写了几个取最后一个(重载的实现写在最后)。`*`、`/` 分隔符同函数的输入参数,不算参数。类里没写构造函数(继承父类的)时没有这一行。 +- 并进属性表的子块:各个类属性连同紧挨在它前面的注释块;构造函数、方法、别的语句、讲方法的注释、最后面的注释照常是子块(不管写在哪),默认收起。异常块的头部(编辑范围)是整个类:从说明、装饰器开始,到类连同各子块的最后一行,「编辑」时整段一起改,盖住留下的子块(同库规则的卡片)。 +- 语言插件报的已经是异常块,库规则只认类块,所以异常类不交给库规则:`@dataclass` 装饰的异常类照样是异常卡片,`@dataclass` 写在「装饰器」一行,注解的字段在「属性」表里。 +- 跳到定义:类名是块自己的名字(闪整块),属性名只闪「属性」表里那一行,构造函数的参数只闪「构造参数」表里那一行(构造函数自己的「输入参数」也标了同一个参数,页面上先找到的是卡片里这一行);中文名(`Annotated[…, Comment("…")]`、行尾 `# @Comment …`)照常换属性格子里的名字,行尾记号不进说明。 + +样例(`fixtures/appExceptions.py` 是 luffy `core/exceptions/schemas.py` 的快照,测试里逐字核对;`str | None` 译成「字符串 | 空」): + +``` +名称: AppException +说明: 应用异常基类. …(docstring 原文,含 Attributes: 一节) +访问级别: 公共 +继承: Exception +属性: 3 个 + status_code | 整数 | 500 + error_code | 字符串 | "INTERNAL_ERROR" + message | 字符串 | "服务器内部错误" +构造参数: 3 个 + message | 字符串 | 空 | 空 + detail | Any | 空 + error_code | 字符串 | 空 | 空 + +名称: NotFound +说明: 资源不存在 (404). +访问级别: 公共 +继承: AppException +属性: 3 个 + status_code | 404 + error_code | "NOT_FOUND" + message | "资源不存在" +``` + +`AppException` 的 `__init__`、`to_dict` 是卡片的子块。`fixtures/commerceExceptions.py`(luffy `commerce/exceptions.py` 的快照)里 16 个类都是异常,只有说明的是名称 / 说明 / 访问级别 / 继承四行,写了 `__init__` 的多一行构造参数。 + ### 推导式(写在表达式里的遍历) 原型画板「遍历 · 推导式」。Python 的列表 / 集合 / 字典推导式、生成器表达式(含 `async for`、几节 `for`、`if`、套着的推导式)不管出现在哪(一行译文、属性表的值 / 初始值 / 默认值、字典项的值),都原地换成一个小框: @@ -298,7 +343,7 @@ ```ts export type BlockKind = - | 'import' | 'function' | 'class' | 'enum' | 'type' | 'field' | 'const' | 'variable' + | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' /** 源码里一段的下标(源码字符串下标),end 为开区间。 */ @@ -360,6 +405,25 @@ export interface EnumFacts { members: EnumMemberFacts[] } +/** 异常类的一个类属性:Python 类主体里给一个名字赋值的语句,TS 的类字段(含 static)。 */ +export interface FieldFacts { + name: Node + type?: Node + value?: Node + docs: Node[] // 同 EnumMemberFacts.docs +} + +/** 异常类:Python、TS 都由语言插件报(见「异常」)。 */ +export interface ExceptionFacts { + name?: Node // 同 ClassFacts.name + doc?: Node // 同 ClassFacts.doc + access?: Access // 同 ClassFacts.access + decorators: Node[] + bases: BaseFacts[] // 同 ClassFacts.bases + fields: FieldFacts[] // 类属性,照源码顺序 + parameters?: ParameterFacts[] // 构造函数的参数(Python 的 __init__ 去掉 self,TS 的 constructor);没写构造函数时没有 +} + 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' @@ -432,11 +496,12 @@ export interface BlockShape { 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 ExceptionBlock extends BlockShape { kind: 'exception', facts: ExceptionFacts } // 头部是整个类,留下的方法(含构造函数)等是子块 export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts } export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts } -export type FactBlock = FunctionBlock | ClassBlock | EnumBlock | ImportBlock | VariableBlock +export type FactBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock /** 带说明(facts.doc)的块。 */ -export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | VariableBlock +export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | VariableBlock /** 属性表块的种类和 facts,不带块的形状:库规则把一块认成现成的一种属性表块时给它。 */ export type BlockFacts = FactBlock extends infer B ? B extends FactBlock ? Pick : never : never @@ -546,7 +611,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 // 筛选属性和值叫什么,和属性表的叫法一致;筛选栏、种类标签用它 @@ -633,9 +698,9 @@ export interface CodeReaderApi { - `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 - 内置的 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`(有写在表达式里的遍历的语言)、`displayNames`(这门语言里给定义起中文名的写法)。没给的就少那一样:没有 entries 的值不展开,没有 `iteration` 的遍历照源码结构逐词翻,没有 `displayNames` 就没有中文名。 +- 第三方语言插件要给的:`id`、`languageIds`、`grammarPath`、`toBlocks`(块的种类、头部、子块;函数、类、枚举、异常、导入、常量、变量块带 facts)、`tokenRole`。可以给的:facts 里语言有才填的项(async、abstract、typeOnly、access、doc、`DeclaratorFacts.entries`)、`BlockShape.attributes`(通用词表以外的筛选属性)、`iteration`(有写在表达式里的遍历的语言)、`displayNames`(这门语言里给定义起中文名的写法)。没给的就少那一样:没有 entries 的值不展开,没有 `iteration` 的遍历照源码结构逐词翻,没有 `displayNames` 就没有中文名。 - 中文名的分工:语言插件只认写法(`DisplayNameReader.find`:定义处的名字、中文名、注释里的记号);宿主解析一次源码时顺带问它,记号放进 `SourceDocument.markers` 交给翻译插件(翻注释、说明时去掉,中文模板直译在 `ZhTable.commentText` 之前去掉),定义交给预览找用到它的各处(`nameResolution.ts`);翻译插件不认识 `@Comment`,webview 不认识任何语言。 - 翻译插件拿到的:代码块(kind、node、header、children,属性表块还有 facts)和 `SourceDocument`。按语法节点类型写的叫法(关键字表、按语境换的译法、节点模板、注解位置这类只关乎措辞的判断)属于翻译插件自己,写在它的表里。 @@ -914,6 +979,7 @@ export interface CodeReaderApi { - 变量声明的唯一声明值是带语句块主体的箭头函数 / 函数表达式 → 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。 +- 类(含类表达式)的父类是异常类时(见「异常」)→ exception:头部是整个类,字段连同前面的注释并进属性表,方法(含 constructor)、别的成员照常是子块。整棵树建好后按整份文件认(同一文件里的异常类写在后面也算)。 - `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。 @@ -927,6 +993,7 @@ export interface CodeReaderApi { - `import_statement` / `import_from_statement` / `future_import_statement` → import。 - `function_definition` → function;`decorated_definition` 按内部定义定种类,头部包含装饰器。 - `class_definition` → class;类体里的带注解赋值 → field。 +- 父类是异常类的类(见「异常」)→ exception:头部是整个类,类属性连同前面的注释并进属性表,方法(含 `__init__`)、别的语句照常是子块。整棵树建好后按整份文件认(同一文件里的异常类写在后面也算)。 - 字符串语句:单独一个普通字符串字面量的表达式语句(`string`,或隐式拼接的 `concatenated_string`,每段的前缀只有 r / u 或没有;f-string、t-string、字节串不算,照旧是 statement)。 - 函数、类主体的第一条语句(注释不算)是字符串语句时,它是这个函数、类的 docstring:进 `facts.doc`,头部往下延到字符串结尾(它和冒号之间的注释也在头部里),子块从它后面开始,不单独成块。 - 属性 docstring:模块、类主体里(条件、函数等语句块里的不算)紧跟在一条赋值后面的字符串语句是这个常量、变量、字段的说明:常量、变量进 `facts.doc`,字段进块的 `doc`,头部往下延到字符串结尾,不单独成块。「紧跟」同 JSDoc:中间可以隔空行和赋值的行尾注释,隔着别的注释、语句就不算;只挂一个,后面再来的字符串照下一条规则。 @@ -946,6 +1013,7 @@ export interface CodeReaderApi { - 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 没有枚举语法:enum 库的类由库规则认成枚举块(见「内置:enum(Python)」)。 +- 异常(TypeScript、Python 一样):名称、说明、访问级别、装饰器、父类取自原来的类(同上两条),TypeScript 的抽象、泛型参数、实现的接口不带;类属性、构造参数见「异常」。 - 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` 里的表达式。 @@ -1016,7 +1084,7 @@ Python 内置类型(仅注解里):str 字符串、int 整数、float 浮 | `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` | -| `class NotFound(AppException):`(classes.py,连同 docstring) | 名称 NotFound / 说明 `找不到资源。` / 访问级别 公共 / 父类 `AppException` | +| `class NotFound(AppResponse):`(classes.py,连同 docstring) | 名称 NotFound / 说明 `找不到资源。` / 访问级别 公共 / 父类 `AppResponse` | ## 预览数据与消息 @@ -1069,7 +1137,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | - `{ type: 'stopAsk', askId }`:叫停,结束 claude 进程,这次不存。 - `{ type: 'deleteQa', anchor, entryId }`:删掉一次问答。 - `{ type: 'selectAskProfile', id }`:「问 AI」换 profile;扩展端记进 `globalState` 后发 `askProfiles` 给所有预览。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`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 ## 颜色 @@ -1090,7 +1158,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;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,数据类 #ef7fb0 / #b3306c。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 +种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;enum 枚举 #b180d7(亮色 #652d90);exception 异常 #f14c4c(亮色 #cd3131,和尝试、捕获、最终同一色系,更红一些);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,数据类 #ef7fb0 / #b3306c。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 ## 代码位置 @@ -1113,8 +1181,9 @@ vscode/src/codeReader/ rules/pythonTree.ts Python 库规则共用的读语法树的办法(名字最后一节、字符串内容、调用的参数、别名、注解里的 X[...]、库里的名字) rules/segments.ts 库规则共用的拼属性表文字的办法 rules/children.ts 库规则共用的挑子块的办法(成员连同紧挨在它前面的注释块、成员的说明节点) - languages/typescript.ts - languages/python.ts (iteration:推导式的结构) + languages/shared.ts 语言插件共用的建树工具;异常类(withExceptions:按整份文件认异常类,类块换成异常块,并类属性、取构造参数) + languages/typescript.ts (TYPESCRIPT_EXCEPTIONS:认异常类的父类名、类字段、constructor) + languages/python.ts (iteration:推导式的结构;PYTHON_EXCEPTIONS:自带的异常类、类属性、__init__) languages/displayNames.ts 中文名的共用部分:第一小句、行尾记号、按优先级汇总 languages/pythonNames.ts Python 的中文名写法(Annotated、@Comment、行尾记号、docstring) languages/typescriptNames.ts TypeScript / JavaScript 的中文名写法(@Comment 装饰器、行尾记号、JSDoc) @@ -1157,7 +1226,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 不认。 @@ -1185,6 +1254,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 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 枚举块默认折叠。 - 数据类(`rules/dataclass.test.ts`):`fixtures/dataclassModels.py` 的 `SandboxAccess` 逐字同上面的样例,`AdminUserRow`、`RedirectPayResult`(`field(init=False, default=…)`)、`SmsSent`(`default_factory=dict`)的行;种类 `dataclasses.dataclass`、叫「数据类」、颜色、访问级别同原来的类;编辑范围是整个类、写回原样,`__post_init__` 是子块;类名、字段名的定义范围和带下标的词指回源码原文;装饰器的写法(别名、`dataclasses.dataclass`、`dc.dataclass`)认,pydantic 的、`other.dataclass` 不认,没导入 dataclasses 不认;每个选项的译法、默认值不讲、别的照写、`**x`、别的装饰器一行;字段规则(`ClassVar`、`typing.ClassVar` 留作子块,`InitVar` 的类型和「仅初始化时使用」,`KW_ONLY` 记号并进来、后面的字段「只能按名字传参」、单独写 `kw_only=False` 的「可按位置传参」),`field(...)` 的默认值、`default_factory`(名字、lambda)、各参数、`metadata` 照写、`doc=`,说明取上面的注释块、行尾注释、属性 docstring;没注解的类属性、字段中间的方法照源码顺序是子块,「编辑」写回整个类。 +- 异常(`languages/exceptions.test.ts`,真实解析 + 中文直译 + 视图数据):`fixtures/appExceptions.py` 的 `AppException`、`NotFound` 逐字同「异常」的样例(列名随有没有类型、说明变),`GatewayTimeout` 的多段说明,方法是子块;`fixtures/commerceExceptions.py` 16 个类都是异常、只有说明的四行、写了 `__init__` 的构造参数;认哪些类(自带的异常类、`Warning`、不合后缀的自带类 `StopIteration` / `KeyboardInterrupt`、`pkg.errors.AppException`、同一文件里写在后面的父类一层层往下传、泛型和别的父类混写,关键字参数、`Mapping[...]`、`*bases` 不算,函数里的类也认);类属性(带不带注解、只写注解、说明取上面的注释块 / 行尾注释 / 属性 docstring,连着赋值、解构赋值、方法、讲方法的注释、最后面的注释留作子块),构造参数去掉 self、`*args` 开头的不去;`@dataclass` 的异常类照样是异常卡片;编辑范围是整个类、改卡片写回整个类,类名、属性名、参数名的定义范围和带下标的词指回源码原文;TypeScript `fixtures/exceptions.ts.txt`(`extends Error`、类字段含 static、参数属性、JSDoc、写在后面的父类、类表达式、`ns.X`,父类名不合习惯的照常是类)、JavaScript 的类字段;TypeScript 和 Python 同样的异常得到一样的行和列名;筛选的种类叫「异常」、没有 badge;属性的中文名和去掉行尾记号。webview:异常块默认折叠(`viewState.test.ts`),跳到类名闪整块、属性名 / 构造参数名只闪表里那一行(`reveal.test.ts`)。 - 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;全选 / 取消全选只动这份文件里的值、别的文件的去掉状态和别的属性不动、取消全选藏掉有这个属性的块(整棵子树)、再全选全回来;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 - 侧栏行上的菜单(`explorer/rowMenus.test.ts`,读 `package.json`):行尾按钮先「复制」子菜单再注释,有没有注释换「添加 / 编辑注释」,工作区根都没有;「复制」子菜单带 `$(copy)`,注释图标 `$(note)` / `$(edit)`;子菜单和右键菜单里文件四项、目录三项(有没有注释一样)、菜单上的字;复制命令都登记了、和代码里的命令表一致、命令面板里藏起来。 diff --git a/vscode/src/codeReader/attributes.ts b/vscode/src/codeReader/attributes.ts index 0905e7f..d76d2ff 100644 --- a/vscode/src/codeReader/attributes.ts +++ b/vscode/src/codeReader/attributes.ts @@ -27,6 +27,7 @@ function factAttributes(block: CodeBlock): Partial { const notFound = byId(await views(py, 'python', pythonZhTranslator, readFixture('classes.py')), '3') expect(notFound).toMatchObject({ kind: 'class', startLine: 17, endLine: 19 }) expect(notFound.code.split('\n')).toEqual([ - 'class NotFound(AppException): # 行尾', + 'class NotFound(AppResponse): # 行尾', ' # 说明前面的注释', ' """找不到资源。"""', ]) diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 558b868..43dbe3e 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -32,7 +32,7 @@ interface BlockViewBase { } /** - * 发给 webview 的一块(可序列化):函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 + * 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 * 一行译文的块带说明(TextBlock.doc)时 doc 是翻译插件给的 Markdown 原文,预览排在译文下面。 */ export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] }) @@ -107,6 +107,7 @@ function ownName(block: CodeBlock): Node | undefined { case 'function': case 'class': case 'enum': + case 'exception': return block.facts.name case 'const': case 'variable': diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index eea637b..66df2c0 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' | 'enum' | 'type' | 'field' | 'const' | 'variable' + = | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' /** 源码里一段的下标:源码字符串下标(web-tree-sitter 的 startIndex / endIndex),end 为开区间。 */ @@ -101,6 +101,35 @@ export interface EnumFacts { members: EnumMemberFacts[] } +/** 异常类的一个类属性:Python 类主体里给一个名字赋值的语句(带不带注解、有没有值),TypeScript 的类字段(含 static);节点都指向源码。 */ +export interface FieldFacts { + name: Node + type?: Node + value?: Node + /** 属性的说明文字节点,同 EnumMemberFacts.docs:紧挨在上面单独成行的注释 / JSDoc、这一行行尾的注释、Python 紧跟在后面的属性 docstring。 */ + docs: Node[] +} + +/** + * 异常类:父类是语言自带的异常类、同一文件里认出来的异常类,或名字按命名习惯以 Error / Exception(Python 还有 Warning)结尾。 + * Python、TypeScript 都由语言插件报,同一种块、同一张属性表。块的头部是整个类(类属性在属性表里讲),留下的方法等是子块。 + */ +export interface ExceptionFacts { + /** 同 ClassFacts.name。 */ + name?: Node + /** 同 ClassFacts.doc。 */ + doc?: Node + /** 同 ClassFacts.access。 */ + access?: Access + decorators: Node[] + /** 照源码写的父类,同 ClassFacts.bases。 */ + bases: BaseFacts[] + /** 类属性,照源码顺序。 */ + fields: FieldFacts[] + /** 构造函数的参数(Python 的 `__init__` 去掉 self,TypeScript 的 `constructor(...)`);类里没写构造函数时没有。 */ + parameters?: ParameterFacts[] +} + /** 导入的一项。 */ export type ImportItem = | { kind: 'named', name: Node, alias?: Node } // import { a as b }、from m import a as b @@ -176,7 +205,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、EnumFacts.access、ImportFacts.typeOnly、VariableFacts.access), + * 宿主从块的 kind 和 facts 推出它们(FunctionFacts.access / async、ClassFacts.access / abstract、EnumFacts.access、ExceptionFacts.access、ImportFacts.typeOnly、VariableFacts.access), * 语言插件不用另报;facts 里没填的(语言没有这个概念、块没有这一项),块就没有这个属性,也不受这个属性的筛选影响。 */ export const COMMON_ATTRIBUTES = ['kind', 'access', 'async', 'abstract', 'typeOnly'] as const @@ -207,12 +236,14 @@ export interface FunctionBlock extends BlockShape { kind: 'function', facts: Fun export interface ClassBlock extends BlockShape { kind: 'class', facts: ClassFacts } /** 枚举块的头部是整个枚举(成员都在属性表里讲),没有子块;库规则认出来的枚举(Python)是整个类,留下的方法等是子块。 */ export interface EnumBlock extends BlockShape { kind: 'enum', facts: EnumFacts } +/** 异常块的头部是整个类(类属性都在属性表里讲),留下的方法(含构造函数)等是子块。 */ +export interface ExceptionBlock extends BlockShape { kind: 'exception', facts: ExceptionFacts } export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts } export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts } -export type FactBlock = FunctionBlock | ClassBlock | EnumBlock | ImportBlock | VariableBlock +export type FactBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock /** 带说明(facts.doc)的块。 */ -export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | VariableBlock +export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | VariableBlock /** 属性表块的种类和 facts,不带块的形状(节点、头部、子块):库规则把一块认成现成的一种属性表块时给它(RuleFacts)。 */ export type BlockFacts = FactBlock extends infer B ? B extends FactBlock ? Pick : never : never diff --git a/vscode/src/codeReader/fixtures/appExceptions.py b/vscode/src/codeReader/fixtures/appExceptions.py new file mode 100644 index 0000000..0daac66 --- /dev/null +++ b/vscode/src/codeReader/fixtures/appExceptions.py @@ -0,0 +1,145 @@ +"""自定义异常体系. + +所有业务异常继承自 AppException,携带 HTTP 状态码和结构化错误信息。 +在 main.py 中注册全局异常处理器统一返回格式。 +""" + +from __future__ import annotations + +from typing import Any + + +class AppException(Exception): + """应用异常基类. + + Attributes: + status_code: HTTP 状态码。 + error_code: 业务错误码(机器可读)。 + message: 错误描述(人类可读)。 + detail: 额外错误详情。 + """ + + status_code: int = 500 + error_code: str = "INTERNAL_ERROR" + message: str = "服务器内部错误" + + def __init__( + self, + message: str | None = None, + *, + detail: Any = None, + error_code: str | None = None, + ) -> None: + if message is not None: + self.message = message + if error_code is not None: + self.error_code = error_code + self.detail = detail + super().__init__(self.message) + + def to_dict(self) -> dict[str, Any]: + """序列化为 JSON 响应体.""" + body: dict[str, Any] = { + "error_code": self.error_code, + "message": self.message, + } + if self.detail is not None: + body["detail"] = self.detail + return body + + +class NotFound(AppException): + """资源不存在 (404).""" + + status_code = 404 + error_code = "NOT_FOUND" + message = "资源不存在" + + +class Unauthorized(AppException): + """未认证 (401).""" + + status_code = 401 + error_code = "UNAUTHORIZED" + message = "未认证或凭据无效" + + +class Forbidden(AppException): + """无权限 (403).""" + + status_code = 403 + error_code = "FORBIDDEN" + message = "无操作权限" + + +class ValidationError(AppException): + """数据验证失败 (422).""" + + status_code = 422 + error_code = "VALIDATION_ERROR" + message = "数据验证失败" + + +class ExternalServiceError(AppException): + """外部服务调用失败 (502).""" + + status_code = 502 + error_code = "EXTERNAL_SERVICE_ERROR" + message = "外部服务调用失败" + + +class BusinessError(AppException): + """业务错误 (400).""" + + status_code = 400 + error_code = "BUSINESS_ERROR" + message = "业务处理失败" + + +class Conflict(AppException): + """资源冲突 (409).""" + + status_code = 409 + error_code = "CONFLICT" + message = "资源已存在" + + +class GatewayTimeout(AppException): + """上游服务响应超时 (504). + + 用于表达 "请求已发往上游但在时限内未拿到响应":区别于 502 + (上游明确报错) 和 503 (上游不可达/未就绪)。 + """ + + status_code = 504 + error_code = "UPSTREAM_TIMEOUT" + message = "上游服务响应超时" + + +class ServiceUnavailable(AppException): + """服务暂不可用 (503). + + 用于表达 "功能依赖的配置/外部资源未就绪" 这类状态: + 资源本身合法存在但当前不可达 / 未配置;区别于 404 (资源不存在) + 和 502 (上游报错)。 + """ + + status_code = 503 + error_code = "SERVICE_UNAVAILABLE" + message = "服务暂不可用" + + +class PayloadTooLarge(AppException): + """请求体超限 (413).""" + + status_code = 413 + error_code = "PAYLOAD_TOO_LARGE" + message = "压缩包超过 20MB 上限" + + +class UploadReadTimeout(AppException): + """上传收流超时 (408).""" + + status_code = 408 + error_code = "UPLOAD_READ_TIMEOUT" + message = "上传超时, 请检查网络后重试" diff --git a/vscode/src/codeReader/fixtures/classes.py b/vscode/src/codeReader/fixtures/classes.py index bd702c0..391950f 100644 --- a/vscode/src/codeReader/fixtures/classes.py +++ b/vscode/src/codeReader/fixtures/classes.py @@ -1,10 +1,10 @@ -"""领域异常。""" +"""接口响应。""" from abc import ABCMeta -class AppException(Exception): - """应用异常的基类。 +class AppResponse(BaseModel): + """应用响应的基类。 带上 HTTP 状态码。 """ @@ -15,7 +15,7 @@ class AppException(Exception): self.message = message -class NotFound(AppException): # 行尾 +class NotFound(AppResponse): # 行尾 # 说明前面的注释 """找不到资源。""" status_code = 404 diff --git a/vscode/src/codeReader/fixtures/commerceExceptions.py b/vscode/src/codeReader/fixtures/commerceExceptions.py new file mode 100644 index 0000000..b6d39fc --- /dev/null +++ b/vscode/src/codeReader/fixtures/commerceExceptions.py @@ -0,0 +1,75 @@ +"""commerce 域异常体系.""" + +from __future__ import annotations + + +class CommerceError(Exception): + """commerce 域基础异常.""" + + +class ProductNotFoundError(CommerceError): + """商品不存在或已下架.""" + + +class OrderNotFoundError(CommerceError): + """订单不存在.""" + + +class InvalidOrderStateError(CommerceError): + """订单状态非法转换.""" + + +class PaymentVerifyError(CommerceError): + """支付通道验签失败.""" + + +class AmountMismatchError(CommerceError): + """支付金额与订单金额不匹配.""" + + +class FulfillmentError(CommerceError): + """fulfillment 调 billing 失败.""" + + +class RefundError(CommerceError): + """退款失败.""" + + +class UnsupportedPaymentMethodError(CommerceError): + """未配置当前 payment_method 对应的 driver. 通常意味着环境变量缺失.""" + + def __init__(self, method: str) -> None: + super().__init__(f"unsupported payment method: {method}") + self.method = method + + +class OutOfStockError(CommerceError): + """商品库存不足, 无法下单。""" + + +class PurchaseLimitExceededError(CommerceError): + """超出商品 purchase_limit_per_user 限购次数。""" + + +class PendingOrderExistsError(CommerceError): + """同一用户已有相同商品的待支付订单。""" + + +class ProductPlanMissingError(CommerceError): + """商品未绑定任何 billing.Plan, 无法下单/履约。""" + + +class InstanceTypeNotFoundError(CommerceError): + """instance_type_id 指向的档位不存在; admin 应回 400 而不是撞 FK 500。""" + + def __init__(self, instance_type_id: int) -> None: + super().__init__(f"instance_type {instance_type_id} not found") + self.instance_type_id = instance_type_id + + +class ProductPlanMismatchError(CommerceError): + """商品展示档位与 Billing Plan 实际发放档位不一致.""" + + +class WechatPayUnknownError(CommerceError): + """微信预下单结果未知: 网络中断/超时, 不能判定远端是否已受理.""" diff --git a/vscode/src/codeReader/fixtures/exceptions.ts.txt b/vscode/src/codeReader/fixtures/exceptions.ts.txt new file mode 100644 index 0000000..b609837 --- /dev/null +++ b/vscode/src/codeReader/fixtures/exceptions.ts.txt @@ -0,0 +1,30 @@ +/** 调接口出错。 */ +export class ApiError extends Error { + status = 500 + // 机器可读的错误码 + static readonly kind: string = 'API_ERROR' + + constructor(message: string, public code?: string) { + super(message) + } + + toJSON() { + return { status: this.status, code: this.code } + } +} + +export class NotFoundError extends ApiError { + status = 404 // 找不到 +} + +class Slow extends Timeout {} + +class Timeout extends NotFoundError {} + +const Cancelled = class extends RangeError {} + +export class Forbidden extends errors.HttpException {} + +export class Repository extends Base { + status = 1 +} diff --git a/vscode/src/codeReader/languages/exceptions.test.ts b/vscode/src/codeReader/languages/exceptions.test.ts new file mode 100644 index 0000000..fe1e4ad --- /dev/null +++ b/vscode/src/codeReader/languages/exceptions.test.ts @@ -0,0 +1,339 @@ +import type { BlockView } from '../blockView' +import type { Inline, LanguageAdapter, PropertyRow, Segment, Translator } from '../contract' +import { describe, expect, it } from 'vitest' +import { fileAttributes } from '../attributes' +import { readBlockViews, toBlockViews } from '../blockView' +import { replaceLines } from '../editText' +import { celeryRule } from '../rules/celery' +import { dataclassRule } from '../rules/dataclass' +import { enumRule } from '../rules/enum' +import { sqlalchemyRule } from '../rules/sqlalchemy' +import { flatSegments, outline, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport' +import { pythonZhTranslator } from '../translators/pythonZh' +import { typescriptZhTranslator } from '../translators/typescriptZh' +import { createPythonAdapter } from './python' +import { createTypeScriptAdapter } from './typescript' + +const py = createPythonAdapter(WASM_DIR) +const ts = createTypeScriptAdapter(WASM_DIR) +const APP = readFixture('appExceptions.py') +const COMMERCE = readFixture('commerceExceptions.py') +const TS = readFixture('exceptions.ts.txt') +/** 和扩展里注册的顺序一样。 */ +const RULES = [sqlalchemyRule, celeryRule, enumRule, dataclassRule] + +function views(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise { + return withBlocks(adapter, languageId, source, blocks => + toBlockViews(blocks, { source, language: adapter, markers: [] }, translator, { rules: RULES, report: () => {} })) +} + +const pyViews = (source: string): Promise => views(py, 'python', pythonZhTranslator, source) +const tsViews = (source: string): Promise => views(ts, 'typescript', typescriptZhTranslator, source) + +function flatten(list: readonly BlockView[]): BlockView[] { + return list.flatMap(view => [view, ...flatten(view.children)]) +} + +function properties(view: BlockView | undefined): PropertyRow[] { + if (!view || !('properties' in view)) + throw new Error(`块 ${view?.id} 不是属性表`) + return view.properties +} + +function named(list: readonly BlockView[], name: string): BlockView { + const found = flatten(list).find(view => 'properties' in view && propertyLines(view.properties)[0] === `名称: ${name}`) + if (!found) + throw new Error(`没有 ${name}`) + return found +} + +function lines(list: readonly BlockView[], name: string): string[] { + return propertyLines(properties(named(list, name))) +} + +/** 属性表里所有带下标的词(连表格里的格子)。 */ +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 PY_CASES = [ + 'from dataclasses import dataclass', + 'import typing', + '', + '', + 'class Late(Base):', + ' """写在父类前面,照样认得出。"""', + '', + '', + 'class Base(Exception):', + ' # 错误码', + ' # 机器可读', + ' code = "E"', + ' retry: bool = False # 能不能重试', + ' detail: str', + ' """额外的说明。"""', + '', + ' # 讲方法的注释', + ' def describe(self) -> str:', + ' return self.code', + '', + ' level = 1', + ' a = b = 2', + ' x, y = 1, 2', + '', + ' def __init__(self, message: str, *, level: int = 1, **extra) -> None:', + ' super().__init__(message)', + '', + ' # 收尾', + '', + '', + 'class Deeper(Late):', + ' pass', + '', + '', + 'class Stop(StopIteration): ...', + 'class Warned(UserWarning): ...', + 'class Remote(pkg.errors.AppException): ...', + 'class Typed(typing.Generic[T], KeyboardInterrupt): ...', + 'class Plain(Model, metaclass=ErrorMeta): ...', + 'class Other(Mapping[str, int]): ...', + 'class Starred(*bases): ...', + '', + '', + 'class Loose(Exception):', + ' def __init__(*args):', + ' pass', + '', + '', + '@dataclass', + 'class Payload(ValueError):', + ' field: int = 0', + '', + '', + 'def factory():', + ' class Local(RuntimeError):', + ' pass', + ' return Local', + '', +].join('\n') + +describe('异常卡片(Python)', () => { + it('luffy 的 core/exceptions:基类的类属性、构造参数,子类只改类属性;方法留作子块', async () => { + const list = await pyViews(APP) + expect(list.map(view => view.kind)).toEqual(['comment', 'import', 'import', ...Array.from({ length: 12 }, () => 'exception')]) + expect(lines(list, 'AppException')).toEqual([ + '名称: AppException', + '说明: 应用异常基类.\n\nAttributes:\n status_code: HTTP 状态码。\n error_code: 业务错误码(机器可读)。\n message: 错误描述(人类可读)。\n detail: 额外错误详情。', + '访问级别: 公共', + '继承: Exception', + '属性: 3 个', + ' status_code | 整数 | 500', + ' error_code | 字符串 | "INTERNAL_ERROR"', + ' message | 字符串 | "服务器内部错误"', + '构造参数: 3 个', + ' message | 字符串 | 空 | 空', + ' detail | Any | 空', + ' error_code | 字符串 | 空 | 空', + ]) + const app = properties(named(list, 'AppException')) + expect(app.map(row => row.columns)).toEqual([undefined, undefined, undefined, undefined, ['属性', '类型', '值'], ['名称', '类型', '默认值']]) + expect(named(list, 'AppException').children.map(child => [child.kind, child.startLine])).toEqual([['function', 25], ['function', 39]]) + expect(lines(list, 'NotFound')).toEqual([ + '名称: NotFound', + '说明: 资源不存在 (404).', + '访问级别: 公共', + '继承: AppException', + '属性: 3 个', + ' status_code | 404', + ' error_code | "NOT_FOUND"', + ' message | "资源不存在"', + ]) + expect(properties(named(list, 'NotFound')).at(-1)?.columns).toEqual(['属性', '值']) + expect(named(list, 'NotFound').children).toEqual([]) + expect(lines(list, 'GatewayTimeout').slice(0, 2)).toEqual([ + '名称: GatewayTimeout', + '说明: 上游服务响应超时 (504).\n\n用于表达 "请求已发往上游但在时限内未拿到响应":区别于 502\n(上游明确报错) 和 503 (上游不可达/未就绪)。', + ]) + }) + + it('luffy 的 commerce/exceptions:一串子类都是异常;只有说明的一张卡片四行,写了 __init__ 的多一行构造参数', async () => { + const list = await pyViews(COMMERCE) + expect(list.map(view => view.kind)).toEqual(['comment', 'import', ...Array.from({ length: 16 }, () => 'exception')]) + expect(lines(list, 'CommerceError')).toEqual(['名称: CommerceError', '说明: commerce 域基础异常.', '访问级别: 公共', '继承: Exception']) + expect(lines(list, 'ProductNotFoundError')).toEqual(['名称: ProductNotFoundError', '说明: 商品不存在或已下架.', '访问级别: 公共', '继承: CommerceError']) + expect(lines(list, 'UnsupportedPaymentMethodError')).toEqual([ + '名称: UnsupportedPaymentMethodError', + '说明: 未配置当前 payment_method 对应的 driver. 通常意味着环境变量缺失.', + '访问级别: 公共', + '继承: CommerceError', + '构造参数: 1 个', + ' method | 字符串 | ', + ]) + expect(named(list, 'UnsupportedPaymentMethodError').children.map(child => child.kind)).toEqual(['function']) + }) + + it('认哪些类:父类是自带的异常类(含 Warning)、同一文件里的异常类(写在前面后面都行,一层层往下传)、名字以 Error / Exception / Warning 结尾;关键字参数、泛型、展开不算', async () => { + const list = await pyViews(PY_CASES) + const kinds = Object.fromEntries(flatten(list).filter(view => view.kind === 'exception' || view.kind === 'class') + .map(view => [propertyLines(properties(view))[0].replace('名称: ', ''), view.kind])) + expect(kinds).toEqual({ + Late: 'exception', + Base: 'exception', + Deeper: 'exception', + Stop: 'exception', + Warned: 'exception', + Remote: 'exception', + Typed: 'exception', + Plain: 'class', + Other: 'class', + Starred: 'class', + Loose: 'exception', + Payload: 'exception', + Local: 'exception', + }) + }) + + it('类属性:给一个名字赋值(带不带注解、有没有值),说明取上面的注释块、行尾注释、属性 docstring;连着赋值、解构赋值、方法照常是子块;构造参数去掉 self', async () => { + const list = await pyViews(PY_CASES) + expect(lines(list, 'Base')).toEqual([ + '名称: Base', + '访问级别: 公共', + '继承: Exception', + '属性: 4 个', + ' code | | "E" | 错误码 机器可读', + ' retry | 布尔 | 假 | 能不能重试', + ' detail | 字符串 | | 额外的说明。', + ' level | | 1 | ', + '构造参数: 3 个', + ' message | 字符串 | ', + ' level | 整数 | 1', + ' **extra | 未标注 | ', + ]) + const source = PY_CASES.split('\n') + const base = named(list, 'Base') + expect(base.children.map(child => source[child.startLine].trim())).toEqual([ + '# 讲方法的注释', + 'def describe(self) -> str:', + 'a = b = 2', + 'x, y = 1, 2', + 'def __init__(self, message: str, *, level: int = 1, **extra) -> None:', + '# 收尾', + ]) + expect(lines(list, 'Loose').slice(-2)).toEqual(['构造参数: 1 个', ' *args | 未标注 | ']) + expect(lines(list, 'Payload')).toEqual(['名称: Payload', '访问级别: 公共', '装饰器: @dataclass', '继承: ValueError', '属性: 1 个', ' field | 整数 | 0']) + expect(named(list, 'Local').attributes).toEqual({ kind: 'exception' }) + }) + + it('编辑范围是整个类(并进来的属性、留下的方法都在里面),改卡片写回整个类;块自己是类名的定义,属性行、参数行各是它那个名字的定义', async () => { + const source = PY_CASES.split('\n') + const base = named(await pyViews(PY_CASES), 'Base') + expect([source[base.startLine], source[base.endLine]]).toEqual(['class Base(Exception):', ' # 收尾']) + expect(replaceLines(PY_CASES, base.startLine, base.endLine, base.code.replace('code = "E"', 'code = "F"'))) + .toBe(PY_CASES.replace('code = "E"', 'code = "F"')) + expect(PY_CASES.slice(base.defines?.startIndex, base.defines?.endIndex)).toBe('Base') + const rows = properties(base) + const defined = (label: string): string[] => (rows.find(row => row.label === label)?.items ?? []).map(item => PY_CASES.slice(item.defines?.startIndex, item.defines?.endIndex)) + expect(defined('属性')).toEqual(['code', 'retry', 'detail', 'level']) + expect(defined('构造参数')).toEqual(['message', 'level', '**extra']) + const [fieldName] = rows.find(row => row.label === '属性')?.items?.[0].cells[0] as Segment[] + expect(fieldName).toEqual({ text: 'code', role: 'variable', offset: PY_CASES.indexOf('code = "E"') }) + for (const segment of linkedSegments(rows)) + expect(PY_CASES.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text) + }) +}) + +describe('异常卡片(TypeScript)', () => { + it('extends Error 的类:JSDoc 当说明,类字段(含 static)进属性表,constructor 的参数(含参数属性)进构造参数,方法留作子块', async () => { + const list = await tsViews(TS) + expect(lines(list, 'ApiError')).toEqual([ + '名称: ApiError', + '说明: 调接口出错。', + '访问级别: 公共', + '继承: Error', + '属性: 2 个', + ' status | | 500 | ', + ' kind | 字符串 | \'API_ERROR\' | 机器可读的错误码', + '构造参数: 2 个', + ' message | 字符串 | ', + ' code? | 字符串 | ', + ]) + const api = named(list, 'ApiError') + expect(api.children.map(child => [child.kind, child.startLine])).toEqual([['function', 6], ['function', 10]]) + expect([api.startLine, api.endLine]).toEqual([0, 13]) + expect(lines(list, 'NotFoundError')).toEqual(['名称: NotFoundError', '访问级别: 公共', '继承: ApiError', '属性: 1 个', ' status | 404 | 找不到']) + }) + + it('认哪些类:同一文件里的异常类(写在后面也算)、赋给变量的类表达式、`ns.X`;父类名不合习惯、不在这份文件里认出来的照常是类', async () => { + const list = await tsViews(TS) + expect(lines(list, 'Slow')).toEqual(['名称: Slow', '访问级别: 私有', '继承: Timeout']) + expect(named(list, 'Timeout').kind).toBe('exception') + expect(lines(list, 'Cancelled')).toEqual(['名称: Cancelled', '访问级别: 私有', '继承: RangeError']) + expect(lines(list, 'Forbidden')).toEqual(['名称: Forbidden', '访问级别: 公共', '继承: errors.HttpException<字符串>']) + const repository = named(list, 'Repository') + expect(repository.kind).toBe('class') + expect(repository.children.map(child => child.kind)).toEqual(['field']) + }) + + it('javaScript 的类字段同样认', async () => { + const source = 'class HttpError extends Error {\n status = 500\n static code\n}\n' + const list = await views(ts, 'javascript', typescriptZhTranslator, source) + expect(lines(list, 'HttpError')).toEqual(['名称: HttpError', '访问级别: 私有', '继承: Error', '属性: 2 个', ' status | 500', ' code | ']) + }) + + it('typeScript 和 Python 同样的异常得到一样的种类、属性表行、列名', async () => { + const pyList = await pyViews('class Failed(Exception):\n status = 500 # 状态码\n\n def __init__(self, message: str) -> None:\n pass\n') + const tsList = await tsViews('export class Failed extends Error {\n status = 500 // 状态码\n\n constructor(message: string) {\n super(message)\n }\n}\n') + const shape = (list: BlockView[]): unknown => properties(named(list, 'Failed')).map(row => [row.label, row.columns, row.items?.map(item => item.cells.length)]) + expect(named(pyList, 'Failed').kind).toBe('exception') + expect(named(tsList, 'Failed').kind).toBe('exception') + expect(shape(pyList)).toEqual(shape(tsList)) + expect(lines(pyList, 'Failed').slice(-4)).toEqual(lines(tsList, 'Failed').slice(-4)) + }) +}) + +describe('异常卡片(宿主、筛选、中文名)', () => { + it('筛选:种类是 exception、叫「异常」,访问级别同原来的类;没有种类标签(badge),库规则不认', async () => { + const list = await pyViews(PY_CASES) + expect(named(list, 'Base').attributes).toEqual({ kind: 'exception', access: 'public' }) + expect(named(list, 'Base').badge).toBeUndefined() + expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values) + .toContainEqual({ id: 'exception', name: '异常', count: 10 }) + }) + + it('各块的源码着色拼起来等于编辑范围的整行源码', async () => { + for (const [list, source] of [[await pyViews(APP), APP], [await pyViews(COMMERCE), COMMERCE], [await tsViews(TS), TS]] as const) { + const rows = source.split('\n') + for (const view of flatten(list)) { + expect(view.codeTokens.map(token => token.text).join(''), view.id).toBe(view.code) + const indent = Math.min(...rows.slice(view.startLine, view.endLine + 1).filter(row => row.trim()).map(row => row.length - row.trimStart().length)) + expect(view.code, view.id).toBe(rows.slice(view.startLine, view.endLine + 1).map(row => row.slice(indent)).join('\n')) + } + } + }) + + it('代码块树:异常块的头部是整个类,留下的方法是子块', async () => { + expect(await withBlocks(py, 'python', 'class E(Exception):\n code = 1\n\n def run(self):\n pass\n', outline)).toEqual([ + { at: [1, 5], kind: 'exception', kids: [{ at: 4, kind: 'function', kids: [{ at: 5, kind: 'statement' }] }] }, + ]) + }) + + it('属性的中文名:属性格子按下标换字,行尾记号不进说明', async () => { + const source = [ + 'from typing import Annotated', + 'class Failed(Exception):', + ' code: Annotated[str, Comment("错误码")] = "E"', + ' status = 500 # 状态 @Comment 状态码', + ].join('\n') + const { blocks, displayNames } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source) + expect(propertyLines(properties(named(blocks, 'Failed'))).slice(-3)).toEqual([ + '属性: 2 个', + ' code | Annotated[字符串, Comment("错误码")] | "E" | ', + ' status | | 500 | 状态', + ]) + expect(displayNames.map(({ name, text }) => `${source.slice(name.startIndex, name.endIndex)} → ${text}`)).toEqual(['code → 错误码', 'status → 状态码']) + }) +}) diff --git a/vscode/src/codeReader/languages/python.test.ts b/vscode/src/codeReader/languages/python.test.ts index 74d7855..d4af3a3 100644 --- a/vscode/src/codeReader/languages/python.test.ts +++ b/vscode/src/codeReader/languages/python.test.ts @@ -367,15 +367,15 @@ describe('python 类', () => { it('classes.py:名称、说明、访问级别(同函数的下划线规则,类里的类也按它,函数里的没有)、装饰器、父类(参数列表照写的顺序,关键字参数也算)', async () => { const found = await facts(readFixture('classes.py')) expect(found[6]).toEqual({ - name: 'AppException', - doc: '"""应用异常的基类。\n\n 带上 HTTP 状态码。\n """', + name: 'AppResponse', + doc: '"""应用响应的基类。\n\n 带上 HTTP 状态码。\n """', access: 'public', decorators: [], typeParameters: [], - bases: [{ value: 'Exception' }], + bases: [{ value: 'BaseModel' }], implements: [], }) - expect(found[18]).toMatchObject({ name: 'NotFound', doc: '"""找不到资源。"""', bases: [{ value: 'AppException' }] }) + expect(found[18]).toMatchObject({ name: 'NotFound', doc: '"""找不到资源。"""', bases: [{ value: 'AppResponse' }] }) expect(found[24]).toEqual({ name: 'Meta', access: 'public', diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index b5c786b..e9a0371 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -1,8 +1,8 @@ import type { Access, BlockShape, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' -import type { CommentRules } from './shared' +import type { CommentRules, ExceptionRules } from './shared' import * as path from 'node:path' import { pythonNameReader } from './pythonNames' -import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared' +import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren, withExceptions } from './shared' /** 常量、类字段只认直接写在模块 / 类主体里的赋值;这两处的声明也才有访问级别。 */ type Scope = 'module' | 'class' | 'other' @@ -398,6 +398,122 @@ function tryBlocks(node: Node): CodeBlock[] { return blocks } +// ─── 异常类 ─────────────────────────────────────────────────────────────────── + +/** Python 自带的异常类(builtins 里的,含各种 Warning)。 */ +const BUILTIN_EXCEPTIONS = new Set([ + 'BaseException', + 'BaseExceptionGroup', + 'GeneratorExit', + 'KeyboardInterrupt', + 'SystemExit', + 'Exception', + 'ArithmeticError', + 'FloatingPointError', + 'OverflowError', + 'ZeroDivisionError', + 'AssertionError', + 'AttributeError', + 'BufferError', + 'EOFError', + 'ExceptionGroup', + 'ImportError', + 'ModuleNotFoundError', + 'LookupError', + 'IndexError', + 'KeyError', + 'MemoryError', + 'NameError', + 'UnboundLocalError', + 'OSError', + 'EnvironmentError', + 'IOError', + 'WindowsError', + 'BlockingIOError', + 'ChildProcessError', + 'ConnectionError', + 'BrokenPipeError', + 'ConnectionAbortedError', + 'ConnectionRefusedError', + 'ConnectionResetError', + 'FileExistsError', + 'FileNotFoundError', + 'InterruptedError', + 'IsADirectoryError', + 'NotADirectoryError', + 'PermissionError', + 'ProcessLookupError', + 'TimeoutError', + 'ReferenceError', + 'RuntimeError', + 'NotImplementedError', + 'PythonFinalizationError', + 'RecursionError', + 'StopAsyncIteration', + 'StopIteration', + 'SyntaxError', + 'IndentationError', + 'TabError', + 'SystemError', + 'TypeError', + 'ValueError', + 'UnicodeError', + 'UnicodeDecodeError', + 'UnicodeEncodeError', + 'UnicodeTranslateError', + 'Warning', + 'BytesWarning', + 'DeprecationWarning', + 'EncodingWarning', + 'FutureWarning', + 'ImportWarning', + 'PendingDeprecationWarning', + 'ResourceWarning', + 'RuntimeWarning', + 'SyntaxWarning', + 'UnicodeWarning', + 'UserWarning', +]) +/** PEP 8 的命名习惯:异常类名以 Error 结尾(别的文件里的 `AppException`、`*Warning` 也常见)。 */ +const EXCEPTION_NAME = /(?:Error|Exception|Warning)$/ + +/** 父类写的类名:`X`、`pkg.X` 取 X,泛型 `Base[T]` 取 Base 的;关键字参数、`*bases`、调用没有。 */ +function className(node: Node | null): string | undefined { + switch (node?.type) { + case 'identifier': + return node.text + case 'attribute': + return node.childForFieldName('attribute')?.text + case 'subscript': + return className(node.childForFieldName('value')) + default: + return undefined + } +} + +/** + * 类属性:类主体里给一个名字赋值的语句(`x = 1`、`x: int = 1`、只写注解的 `x: int`);连着赋值(`a = b = 1`)、解构赋值不算。 + * 构造函数是 `__init__`,参数去掉第一个(self;`*args` 这类不是 self 的不去)。 + */ +const PYTHON_EXCEPTIONS: ExceptionRules = { + baseName: base => className(base.value), + isExceptionName: name => BUILTIN_EXCEPTIONS.has(name) || EXCEPTION_NAME.test(name), + field: (block) => { + const assignment = block.kind === 'field' || block.kind === 'variable' ? assignmentIn(block.node) : undefined + const name = assignment?.childForFieldName('left') + const value = assignment?.childForFieldName('right') + if (!assignment || name?.type !== 'identifier' || value?.type === 'assignment') + return undefined + return { name, type: assignment.childForFieldName('type') ?? undefined, value: value ?? undefined } + }, + constructorParameters: (block) => { + if (block.kind !== 'function' || block.facts.name?.text !== '__init__') + return undefined + const [receiver, ...rest] = block.facts.parameters + return receiver?.name.type === 'identifier' ? rest : block.facts.parameters + }, +} + // ─── 源码着色 ───────────────────────────────────────────────────────────────── const STORAGE_KEYWORDS = new Set(['def', 'class', 'async', 'lambda', 'global', 'nonlocal', 'del']) @@ -484,7 +600,7 @@ export function createPythonAdapter(wasmDir: string): LanguageAdapter { id: 'python', languageIds: ['python'], grammarPath: () => path.join(wasmDir, 'tree-sitter-python.wasm'), - toBlocks: root => statements(named(root), 'module', -1), + toBlocks: root => withExceptions(statements(named(root), 'module', -1), PYTHON_EXCEPTIONS), tokenRole: pythonTokenRole, iteration: pythonIteration, displayNames: pythonNameReader, diff --git a/vscode/src/codeReader/languages/shared.ts b/vscode/src/codeReader/languages/shared.ts index 17d9d8d..33de599 100644 --- a/vscode/src/codeReader/languages/shared.ts +++ b/vscode/src/codeReader/languages/shared.ts @@ -1,4 +1,4 @@ -import type { BlockShape, CodeBlock, DocumentedBlock, Node, SourceSpan, TextKind } from '../contract' +import type { BaseFacts, BlockShape, ClassBlock, ClassFacts, CodeBlock, DocumentedBlock, ExceptionBlock, FieldFacts, Node, ParameterFacts, SourceSpan, TextKind } from '../contract' /** 语言插件共用的建树工具:取子节点、算头部、把一串语句切成块。 */ @@ -164,3 +164,128 @@ export function listBlocks( export function sameNode(a: Node | null | undefined, b: Node): boolean { return !!a && a.startIndex === b.startIndex && a.endIndex === b.endIndex && a.type === b.type } + +// ─── 异常类 ─────────────────────────────────────────────────────────────────── + +/** 语言怎么认异常类、类主体里哪些子块是类属性、哪个是构造函数。 */ +export interface ExceptionRules { + /** 父类一项写的类名(点分写法取最后一节);关键字参数、展开、调用这类不是类名的项没有。 */ + baseName: (base: BaseFacts) => string | undefined + /** 光看名字就认得是异常类:语言自带的异常类,或按命名习惯以 Error 这类后缀结尾。 */ + isExceptionName: (name: string) => boolean + /** 类主体里的子块是类属性时它的名字、类型、值;不是时没有。 */ + field: (block: CodeBlock) => Omit | undefined + /** 子块是构造函数时它的参数(Python 去掉 self);不是时没有。 */ + constructorParameters: (block: CodeBlock) => ParameterFacts[] | undefined +} + +function classBlocks(blocks: readonly CodeBlock[]): ClassBlock[] { + return blocks.flatMap(block => [...(block.kind === 'class' ? [block] : []), ...classBlocks(block.children)]) +} + +/** 紧挨在 child 前面的注释块(连着几块都算,中间没隔别的子块),照源码顺序:讲的是它下面的这个 child。 */ +function leadingComments(children: readonly CodeBlock[], child: CodeBlock): CodeBlock[] { + const at = children.indexOf(child) + let from = at + while (from > 0 && children[from - 1].kind === 'comment') + from-- + return children.slice(from, at) +} + +/** 注释块里的各条注释(几条行注释连成的一块是好几个节点);Python 当注释用的字符串是那个字符串。 */ +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 +} + +/** 块自己的说明节点:属性表块的 facts.doc,一行译文块的 doc(Python 字段的属性 docstring)。 */ +function ownDoc(block: CodeBlock): Node | undefined { + if (!('facts' in block)) + return block.doc + return block.kind === 'import' ? undefined : block.facts.doc +} + +/** 类属性的说明,照源码顺序:紧挨在它上面的注释块、它这一行行尾的注释、Python 紧跟在后面的属性 docstring。 */ +function fieldDocs(children: readonly CodeBlock[], block: CodeBlock): Node[] { + const trailing = block.node.nextNamedSibling + const doc = ownDoc(block) + return [ + ...leadingComments(children, block).flatMap(commentNodes), + ...(trailing?.type === 'comment' && trailing.startPosition.row === block.node.endPosition.row ? [trailing] : []), + ...(doc ? [doc] : []), + ] +} + +/** + * 类块换成异常块:类属性连同紧挨在它前面的注释块并进属性表,构造函数(写了几个取最后一个,重载的实现写在最后)的参数进属性表, + * 留下的子块(方法、构造函数本身、别的语句)不管写在哪都照源码顺序留作子块;头部是整个类(到它连同各子块的最后一行), + * 「编辑」时整段一起改,webview 在它编辑时不画留下的子块。 + */ +function exceptionBlock(block: ClassBlock, rules: ExceptionRules): ExceptionBlock { + const { children, facts } = block + const fields: FieldFacts[] = [] + const members: CodeBlock[] = [] + let parameters: ParameterFacts[] | undefined + for (const child of children) { + const field = rules.field(child) + if (field) { + fields.push({ ...field, docs: fieldDocs(children, child) }) + members.push(child) + } + parameters = rules.constructorParameters(child) ?? parameters + } + const absorbed = new Set(members.flatMap(member => [...leadingComments(children, member), member])) + const ends = [block.header, nodeSpan(block.node), ...children.flatMap(child => [child.header, nodeSpan(child.node)])] + const last = ends.reduce((end, span) => span.endIndex > end.endIndex ? span : end) + return { + kind: 'exception', + node: block.node, + header: { ...block.header, endIndex: last.endIndex, endRow: last.endRow }, + children: children.filter(child => !absorbed.has(child)), + ...(block.attributes && { attributes: block.attributes }), + facts: { + name: facts.name, + doc: facts.doc, + access: facts.access, + decorators: facts.decorators, + bases: facts.bases, + fields, + ...(parameters && { parameters }), + }, + } +} + +/** + * 认出异常类,把它们的类块换成异常块(见 exceptionBlock)。父类里有一项是异常类就是异常类:光看名字认得出(isExceptionName), + * 或是同一文件里(不论写在前面还是后面、嵌套在哪)认出来的异常类,一层层往下传。父类从别的文件导入、名字又不合命名习惯的认不出来。 + */ +export function withExceptions(blocks: CodeBlock[], rules: ExceptionRules): CodeBlock[] { + const classes = classBlocks(blocks) + const local = new Set() + const isException = (facts: ClassFacts): boolean => facts.bases.some((base) => { + const name = rules.baseName(base) + return name !== undefined && (rules.isExceptionName(name) || local.has(name)) + }) + let grew = true + while (grew) { + grew = false + for (const { facts } of classes) { + const name = facts.name?.text + if (name !== undefined && !local.has(name) && isException(facts)) { + local.add(name) + grew = true + } + } + } + const convert = (list: CodeBlock[]): CodeBlock[] => list.map((block) => { + const children = convert(block.children) + const same = children.every((child, i) => child === block.children[i]) + const next: CodeBlock = same ? block : { ...block, children } + return next.kind === 'class' && isException(next.facts) ? exceptionBlock(next, rules) : next + }) + return convert(blocks) +} diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index a0c0cad..6ad4b07 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, EntryFacts, EnumMemberFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' -import type { CommentRules } from './shared' +import type { CommentRules, ExceptionRules } from './shared' import * as path from 'node:path' -import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren } from './shared' +import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren, withExceptions } from './shared' import { typescriptNameReader } from './typescriptNames' const GRAMMARS: Record = { @@ -476,6 +476,40 @@ function blockReader(typed: boolean): (root: Node) => CodeBlock[] { return root => statements(named(root), true, -1) } +// ─── 异常类 ─────────────────────────────────────────────────────────────────── + +/** 自带的错误类(Error、TypeError、RangeError、AggregateError……)名字都以 Error 结尾,DOMException 以 Exception 结尾;自己写的照这个习惯起名。 */ +const EXCEPTION_NAME = /(?:Error|Exception)$/ +/** 类字段:TypeScript 的 public_field_definition(名字在 name),JavaScript 的 field_definition(名字在 property)。 */ +const FIELDS = new Set(['public_field_definition', 'field_definition']) + +/** extends 后面写的类名:`X`、`ns.X` 取 X;调用(`Mixin(Base)`)这类没有。 */ +function className(node: Node): string | undefined { + switch (node.type) { + case 'identifier': + case 'type_identifier': + return node.text + case 'member_expression': + return node.childForFieldName('property')?.text + default: + return undefined + } +} + +/** 类属性是类字段(含 static,值不是带语句块主体的函数、类表达式的:那些照常是方法、类块);构造函数是 `constructor`。 */ +const TYPESCRIPT_EXCEPTIONS: ExceptionRules = { + baseName: base => className(base.value), + isExceptionName: name => EXCEPTION_NAME.test(name), + field: (block) => { + const member = block.node + const name = block.kind === 'field' && FIELDS.has(member.type) ? member.childForFieldName('name') ?? member.childForFieldName('property') : null + if (!name) + return undefined + return { name, type: annotatedType(member.childForFieldName('type')), value: member.childForFieldName('value') ?? undefined } + }, + constructorParameters: block => block.kind === 'function' && block.facts.name?.text === 'constructor' ? block.facts.parameters : undefined, +} + // ─── 源码着色 ───────────────────────────────────────────────────────────────── /** 流程关键字;其余字母关键字都算声明关键字(storage)。 */ @@ -584,7 +618,7 @@ export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter { throw new Error(`TypeScript 语言插件不支持 ${languageId}`) return path.join(wasmDir, file) }, - toBlocks: (root, _source, languageId) => blockReader(TYPED_LANGUAGES.has(languageId))(root), + toBlocks: (root, _source, languageId) => withExceptions(blockReader(TYPED_LANGUAGES.has(languageId))(root), TYPESCRIPT_EXCEPTIONS), tokenRole: typescriptTokenRole, displayNames: typescriptNameReader, } diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index 1de1f22..292c1aa 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -725,8 +725,8 @@ describe('属性表', () => { expect(tsRows[32]).toEqual(['名称: Plain', '访问级别: 私有', '抽象: 否', '装饰器: @sealed']) const pyRows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, readFixture('classes.py'))) - expect(pyRows[6]).toEqual(['名称: AppException', '说明: 应用异常的基类。\n\n带上 HTTP 状态码。', '访问级别: 公共', '父类: Exception']) - expect(pyRows[18]).toEqual(['名称: NotFound', '说明: 找不到资源。', '访问级别: 公共', '父类: AppException']) + expect(pyRows[6]).toEqual(['名称: AppResponse', '说明: 应用响应的基类。\n\n带上 HTTP 状态码。', '访问级别: 公共', '父类: BaseModel']) + expect(pyRows[18]).toEqual(['名称: NotFound', '说明: 找不到资源。', '访问级别: 公共', '父类: AppResponse']) expect(pyRows[24]).toEqual(['名称: Meta', '访问级别: 公共', '装饰器: @dataclass(frozen=True)', '父类: Base, metaclass=ABCMeta']) expect(pyRows[29]).toEqual(['名称: _Hidden', '访问级别: 私有']) expect(pyRows[38]).toEqual(['名称: Local', '说明: 局部类。']) @@ -987,11 +987,11 @@ describe('源码下标(跳到定义用)', () => { expect(linked(valueOf(found, '实现的接口'))).toEqual([['Loader', at('Loader')], ['K', at('K>,')], ['Disposable', at('Disposable')]]) expect(found.filter(row => row.defines).map(row => [row.label, source.slice(row.defines?.startIndex, row.defines?.endIndex)])).toEqual([['类型参数', 'K, T extends Entity']]) - const pySource = 'class NotFound(errors.AppException, metaclass=ABCMeta):\n pass' + const pySource = 'class NotFound(models.AppResponse, metaclass=ABCMeta):\n pass' const pyRows = await describeAll(py, 'python', pythonZhTranslator, pySource) expect(linked(valueOf(pyRows.get(1), '父类'))).toEqual([ - ['errors', pySource.indexOf('errors')], - ['AppException', pySource.indexOf('AppException')], + ['models', pySource.indexOf('models')], + ['AppResponse', pySource.indexOf('AppResponse')], ['metaclass', pySource.indexOf('metaclass')], ['ABCMeta', pySource.indexOf('ABCMeta')], ]) diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index f2873e0..035ade6 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, AttributeNames, BaseFacts, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, AttributeNames, BaseFacts, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, ParameterFacts, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -148,6 +148,7 @@ const KIND_ZH: Record = { function: '函数', class: '类', enum: '枚举', + exception: '异常', type: '类型', field: '字段', const: '常量', @@ -451,6 +452,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return classRows(block.facts, phrases) case 'enum': return enumRows(block.facts, phrases) + case 'exception': + return exceptionRows(block.facts, phrases) case 'import': return importRows(block.facts, phrases) default: @@ -599,23 +602,28 @@ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { ...accessRows(facts.access), ...flagRows('async', facts.async), ...decoratorRows(facts.decorators, say), - { - label: '输入参数', - value: count(facts.parameters.length), - columns: ['名称', '类型', '默认值'], - items: facts.parameters.map(param => ({ - cells: [ - param.optional ? [...say.translated(param.name), { text: '?', role: 'plain' }] : say.translated(param.name), - param.type ? say.translated(param.type) : [hint('未标注')], - param.default ? say.translated(param.default) : [], - ], - ...defining(param.name), - })), - }, + parameterRow('输入参数', facts.parameters, say), { label: '输出参数', value: facts.returnType ? say.translated(facts.returnType) : [hint('未标注')] }, ] } +/** 参数表:列名是名称 / 类型 / 默认值,每个参数一行,讲的定义是参数名;没有默认值的格子留空。 */ +function parameterRow(label: string, parameters: ParameterFacts[], say: Phrases): PropertyRow { + return { + label, + value: count(parameters.length), + columns: ['名称', '类型', '默认值'], + items: parameters.map(param => ({ + cells: [ + param.optional ? [...say.translated(param.name), { text: '?', role: 'plain' }] : say.translated(param.name), + param.type ? say.translated(param.type) : [hint('未标注')], + param.default ? say.translated(param.default) : [], + ], + ...defining(param.name), + })), + } +} + /** * 类:名称 / 说明 / 访问级别 / 抽象(只有分这个的语言才有)/ 装饰器 / 类型参数 / 父类 / 实现的接口, * 说明及以后各行有才显示;类型参数、父类、实现的接口各是一行,多项用逗号隔开。类型参数那一行讲的是这几个泛型参数的定义。 @@ -646,14 +654,14 @@ function baseText(base: BaseFacts, say: Phrases): Inline[] { * 说明是成员的注释、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 docs = facts.members.map(member => docsText(member.docs, say)) 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)), ', ') }] : []), + ...baseRows(facts.bases, say), { label: '成员', value: count(facts.members.length), @@ -662,7 +670,7 @@ function enumRows(facts: EnumFacts, say: Phrases): PropertyRow[] { 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)] : []] : []), + ...(described ? [docCell(docs[i])] : []), ], ...defining(member.name), })), @@ -670,6 +678,54 @@ function enumRows(facts: EnumFacts, say: Phrases): PropertyRow[] { ] } +/** + * 异常(Python、TypeScript 同一张表):名称 / 说明 / 访问级别 / 装饰器 / 继承(照写的父类),说明及以后有才显示; + * 类属性收成一张表:属性 | 类型 | 值 | 说明,一个属性都没写类型时没有「类型」列、都没有说明时没有「说明」列,没写的格子留空; + * 构造参数同函数的输入参数表。没有类属性、没写构造函数时没有这两行。 + */ +function exceptionRows(facts: ExceptionFacts, say: Phrases): PropertyRow[] { + const docs = facts.fields.map(field => docsText(field.docs, say)) + const described = docs.some(Boolean) + const typed = facts.fields.some(field => field.type) + const fields: PropertyRow = { + label: '属性', + value: count(facts.fields.length), + columns: ['属性', ...(typed ? ['类型'] : []), '值', ...(described ? ['说明'] : [])], + items: facts.fields.map((field, i) => ({ + cells: [ + say.name(field.name, 'variable'), + ...(typed ? [field.type ? say.translated(field.type) : []] : []), + field.value ? say.translated(field.value) : [], + ...(described ? [docCell(docs[i])] : []), + ], + ...defining(field.name), + })), + } + return [ + { label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] }, + ...docRows(facts.doc, say), + ...accessRows(facts.access), + ...decoratorRows(facts.decorators, say), + ...baseRows(facts.bases, say), + ...(facts.fields.length > 0 ? [fields] : []), + ...(facts.parameters ? [parameterRow('构造参数', facts.parameters, say)] : []), + ] +} + +/** 继承:照写的父类,逗号隔开;没有父类时没有这一行。 */ +function baseRows(bases: BaseFacts[], say: Phrases): PropertyRow[] { + return bases.length > 0 ? [{ label: '继承', value: joined(bases.map(base => baseText(base, say)), ', ') }] : [] +} + +/** 表格里一格说明:几段说明文字的原文用空格连起来。 */ +function docsText(nodes: Node[], say: Phrases): string { + return nodes.map(node => say.doc(node)?.markdown).filter(Boolean).join(' ') +} + +function docCell(text: string): Inline[] { + return text ? [segment(text, 'comment', undefined)] : [] +} + /** 函数、类、常量、变量的说明:有文字才有这一行。 */ function docRows(doc: Node | undefined, say: Phrases): PropertyRow[] { const markdown = doc && say.doc(doc) diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index e066beb..266a978 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -15,6 +15,7 @@ body { --cr-kind-function: #dcdcaa; --cr-kind-type: #4ec9b0; --cr-kind-enum: #b180d7; + --cr-kind-exception: #f14c4c; --cr-kind-data: #9cdcfe; --cr-kind-branch: #ce9178; --cr-kind-loop: #8f9cff; @@ -45,6 +46,7 @@ body.vscode-high-contrast-light { --cr-kind-function: #795e26; --cr-kind-type: #267f99; --cr-kind-enum: #652d90; + --cr-kind-exception: #cd3131; --cr-kind-data: #001080; --cr-kind-branch: #a31515; --cr-kind-loop: #4f56d6; @@ -59,6 +61,7 @@ body.vscode-high-contrast-light { [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); } +[data-kind="exception"] { --kind: var(--cr-kind-exception); } :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/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index e460163..9c25839 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -326,12 +326,34 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { const views = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator)) expect([...collapsedIds(views, {})].sort()).toEqual(['0', '0.0']) - const pySource = ['class NotFound(AppException):', ' """找不到。"""', ' def run(self):', ' pass'].join('\n') + const pySource = ['class NotFound(AppResponse):', ' """找不到。"""', ' def run(self):', ' pass'].join('\n') const atPy = (needle: string): ReturnType => jump(py, 'python', pythonZhTranslator, pySource, needle) - expect(await atPy('NotFound')).toEqual({ plan: { id: '0', expand: [] }, flashed: 'class NotFound(AppException):' }) + expect(await atPy('NotFound')).toEqual({ plan: { id: '0', expand: [] }, flashed: 'class NotFound(AppResponse):' }) expect(await atPy('run')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'def run(self):' }) }) + it('异常块:类名闪整块,属性名只闪「属性」表里它那一行,构造函数的参数只闪「构造参数」表里它那一行(都先展开那一行);留下的构造函数照常是子块,异常块默认折叠', async () => { + const source = [ + 'class Failed(Exception):', + ' status = 500', + '', + ' def __init__(self, message: str) -> None:', + ' self.message = message', + ].join('\n') + const at = (needle: string): ReturnType => jump(py, 'python', pythonZhTranslator, source, needle) + expect(await at('Failed')).toEqual({ plan: { id: '0', expand: [] }, flashed: 'class Failed(Exception):' }) + expect(await at('status')).toEqual({ plan: { id: '0', row: '0/p3/0', expand: ['0/p3'] }, flashed: 'status | 500' }) + expect(await at('message: str')).toEqual({ plan: { id: '0', row: '0/p4/0', expand: ['0/p4'] }, flashed: 'message | 字符串 | ' }) + expect(await at('__init__')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'def __init__(self, message: str) -> None:' }) + + const tsSource = 'export class ApiError extends Error {\n status = 500\n constructor(public code: string) {\n super(code)\n }\n}' + const atTs = (needle: string): ReturnType => jump(ts, 'typescript', typescriptZhTranslator, tsSource, needle) + expect(await atTs('status')).toEqual({ plan: { id: '0', row: '0/p3/0', expand: ['0/p3'] }, flashed: 'status | 500' }) + expect(await atTs('code:')).toEqual({ plan: { id: '0', row: '0/p4/0', expand: ['0/p4'] }, flashed: 'code | 字符串 | ' }) + const views = await withBlocks(ts, 'typescript', tsSource, blocks => toBlockViews(blocks, { source: tsSource, language: ts, markers: [] }, typescriptZhTranslator)) + expect([...collapsedIds(views, {})].sort()).toEqual(['0', '0.0']) + }) + it('库规则认领的块:类名闪整块,字段名只闪字段表里它那一行(先展开「字段」那一行);留下的方法照常,卡片默认折叠', async () => { const source = [ 'from sqlalchemy.orm import Mapped, mapped_column', diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 2d4f97b..4b4c73c 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' | 'enum' | 'type' | 'field' | 'const' | 'variable' + = | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' /** 着色用的词性,源码着色和译文着色共用。 */ diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index c7340eb..b798427 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -115,6 +115,10 @@ describe('眼下折叠着哪些', () => { expect([...collapsedIds([block('0', 'enum', [block('0.0', 'function')]), block('1', 'if')], {})]).toEqual(['0', '0.0']) }) + it('没点过:异常块(下面留着构造函数、方法)和类块一样折叠', () => { + expect([...collapsedIds([block('0', 'exception', [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 02a2b36..b83a17d 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' || block.kind === 'enum' + return block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind) } /** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */