From 43323659e1de6abf712cd455f288cf487b91b56e Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 25 Sep 2026 14:46:33 +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=E7=B1=BB=E5=9D=97=E6=94=B9=E6=88=90=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E8=A1=A8=E5=B9=B6=E9=BB=98=E8=AE=A4=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=EF=BC=8C=E7=B1=BB=E7=9A=84=E8=AF=B4=E6=98=8E=E8=BF=9B=E3=80=8C?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E3=80=8D=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 96 ++++++++++++------- vscode/src/codeReader/blockView.test.ts | 36 +++++-- vscode/src/codeReader/blockView.ts | 5 +- vscode/src/codeReader/contract.ts | 41 +++++++- vscode/src/codeReader/editText.test.ts | 4 +- vscode/src/codeReader/fixtures/classes.py | 40 ++++++++ vscode/src/codeReader/fixtures/classes.ts.txt | 32 +++++++ .../src/codeReader/languages/python.test.ts | 71 +++++++++++++- vscode/src/codeReader/languages/python.ts | 47 ++++++--- vscode/src/codeReader/languages/shared.ts | 12 +-- .../codeReader/languages/typescript.test.ts | 58 +++++++++++ vscode/src/codeReader/languages/typescript.ts | 86 +++++++++++++---- .../codeReader/translators/zhTemplate.test.ts | 77 ++++++++++++++- .../src/codeReader/translators/zhTemplate.ts | 46 +++++++-- .../webview-ui/src/codeReader/BlockTree.tsx | 2 +- .../src/codeReader/PropertyGrid.tsx | 2 +- vscode/webview-ui/src/codeReader/markdown.ts | 2 +- .../webview-ui/src/codeReader/reveal.test.ts | 22 +++++ vscode/webview-ui/src/codeReader/reveal.ts | 2 +- vscode/webview-ui/src/codeReader/types.ts | 6 +- .../src/codeReader/viewState.test.ts | 10 +- vscode/webview-ui/src/codeReader/viewState.ts | 4 +- 22 files changed, 580 insertions(+), 121 deletions(-) create mode 100644 vscode/src/codeReader/fixtures/classes.py create mode 100644 vscode/src/codeReader/fixtures/classes.ts.txt diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 7485362..131fe3b 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -19,13 +19,13 @@ | 多种翻译怎么选 | 预览顶部下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 | | 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 | | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | -| 折叠 | 有子块的块、属性表里带子行的行能折叠;函数块(含方法、嵌套函数)默认收起主体、属性表照常显示,其余块和属性行默认展开;用户点过的按他点的记住 | -| 函数的说明 | TypeScript 紧贴在函数前面的 JSDoc、Python 函数主体开头的 docstring 是函数自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数块的头部里,「编辑」时和签名一起改 | -| 注释按 Markdown 显示 | 注释块和函数的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | +| 折叠 | 有子块的块、属性表里带子行的行能折叠;函数块(含方法、嵌套函数)和类块默认收起主体、属性表照常显示,其余块和属性行默认展开;用户点过的按他点的记住 | +| 函数、类的说明 | TypeScript 紧贴在函数、类前面的 JSDoc,Python 函数、类主体开头的 docstring 是它自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数、类块的头部里,「编辑」时和签名一起改 | +| 注释按 Markdown 显示 | 注释块和函数、类的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | | 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠;不做新建 / 重命名 / 删除 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | -| 函数、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | +| 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | ## 用户可见行为 @@ -50,34 +50,35 @@ - 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签、内容、右侧切换按钮。 - 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 - 内容两种状态,每块单独切: - - 预览:函数、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。 + - 预览:函数、类、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。 -- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数的说明(JSDoc / docstring)也算在函数块的头部里。 -- 折叠:函数块打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 +- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里。 +- 折叠:函数、类块打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 - 顶部工具栏:翻译下拉框(多于一种翻译时才显示)。 - 解析失败:显示错误原因,并提示用「编辑」查看源码。 -### 属性表(函数、导入、常量、变量块) +### 属性表(函数、类、导入、常量、变量块) 卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`)。输入参数的子行分名称 / 类型 / 默认值三列对齐,前两列宽 150px / 220px,编辑区窄时按 28% / 38% 收,默认值列占剩下的。 | 种类 | 行 | | --- | --- | | 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,`@param` 这类标签行照抄;得到的原文按 Markdown 渲染,见「注释的 Markdown 排版」;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(`默认 ` + 译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | +| 类 | 名称(类型色;匿名类 `export default class {}` 写「匿名」;赋给变量、类字段的类表达式写变量名、字段名)/ 说明(有说明才显示,规则同函数)/ 访问级别 / 抽象(是 / 否,只有 TypeScript 有,看 `abstract class`,TypeScript 的类都有这一行)/ 装饰器(同函数)/ 类型参数(有才显示:TypeScript `` 每个参数的译文,逗号隔开,如 `K, T 限定为 X = Y`;Python 3.12 的 `class G[T]` 同样)/ 父类(有才显示:TypeScript `extends` 后面的表达式连着泛型实参的译文,如 `Base<字符串>`;Python 类名后面括号里的参数列表照写的顺序逐项翻,关键字参数、`*bases` 也在这一行,如 `Base, metaclass=ABCMeta`;逗号隔开)/ 实现的接口(有才显示:TypeScript `implements` 后面每个类型的译文,逗号隔开) | | 导入 | 来源(TypeScript 是字符串字面量原样,如 `'node:fs'`,字符串色;Python 是点分模块名,如 `fastapi`、`.models`,类型色)/ 仅类型(是 / 否,只有 TypeScript 有,看 `import type`)/ 导入项(`N 个` 或「无」,每项一行:`name`、`name 作为 alias`;默认导入写「默认导出 作为 x」,`* as x` 写「全部 作为 x」,Python `from m import *` 写「全部」,Python `import a.b as c` 写「整个模块 作为 c」,一条 `import a, b` 导入多个模块时写模块名) | | 常量 / 变量 | 名称(解构声明写整个模式,如 `{ p, q }`)/ 访问级别 / 类型(写了标注才有)/ 值(常量)或初始值(变量):初始化表达式的译文,没有写「无」。一条语句声明几个变量(`let a = 1, b = 2`)就重复几组 | 访问级别(公共 / 私有 / 受保护): -- TypeScript 模块里的声明:`export` 了是公共,顶层没 `export` 是私有;嵌套在函数体、条件等语句块里的没有这一行。 -- TypeScript 类成员:写了 `public` / `private` / `protected` 按写的,`#name` 是私有,都没写是公共。 -- Python:只有模块顶层和类主体里的声明有;名字单下划线开头(`_x`、`__x`)是私有,前后双下划线(`__init__`)是公共,其余公共;解构赋值里的名字全是私有才算私有;赋值目标是属性、下标(`self.x = …`)时没有这一行。 +- TypeScript 模块里的声明(函数、类、变量):`export` 了是公共(含 `export default`),顶层没 `export` 是私有;嵌套在函数体、条件等语句块里的没有这一行。 +- TypeScript 类成员(方法、函数值字段、类表达式字段):写了 `public` / `private` / `protected` 按写的,`#name` 是私有,都没写是公共。 +- Python:只有模块顶层和类主体里的声明(函数、类、变量)有;名字单下划线开头(`_x`、`__x`)是私有,前后双下划线(`__init__`)是公共,其余公共;解构赋值里的名字全是私有才算私有;赋值目标是属性、下标(`self.x = …`)时没有这一行。 值里的表达式只翻落在块头部里的那一段:`const v = useMemo(() => { … })` 这种值带语句块主体回调的块,回调主体是子块,值只翻到 `useMemo(() =>`,和块头部一致。 ### 注释的 Markdown 排版 -- 用在:注释块(`#`、`//`、`/* */`、不挂在函数上的 JSDoc、Python 模块 / 类主体开头的 docstring)的预览,函数属性表的「说明」。视图数据里它们是 `{ markdown }`(见「通用契约」的 `Markdown`),webview 按数据决定怎么渲染,不猜。 +- 用在:注释块(`#`、`//`、`/* */`、不挂在函数、类上的 JSDoc、Python 模块开头的 docstring)的预览,函数、类属性表的「说明」。视图数据里它们是 `{ markdown }`(见「通用契约」的 `Markdown`),webview 按数据决定怎么渲染,不猜。 - 原文:翻译插件去掉注释符号(`#`、`//`、`/*` `*/` 和每行开头的 `*`)和引号,再去掉行尾空白、首尾空行和共同缩进,保留换行,不做别的转换。一个注释节点(块注释、docstring)的第一行紧跟在注释符号 / 引号后面,不参与算共同缩进(同 PEP 257);几条行注释连成的一块每行地位相同,第一行也算,缩进四格的代码块才留得住。 - 渲染:webview 用 markdown-it(`html: false`、`linkify: true`),CommonMark 加 GFM 的表格、删除线:段落、强调、行内代码(含双反引号)、代码块(围栏、缩进四格)、列表、链接、标题、引用、表格。段内单个换行是软换行,空行分段。同一段文字只渲染一次(缓存最近 500 段)。 - 安全:源码里的 HTML 当文字显示;链接只认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、相对路径)照原文显示。点链接由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。图片不渲染(webview 的 CSP 不放行外部图片),`![alt](url)` 显示成 `!` 加链接。 @@ -86,7 +87,7 @@ ### 跳到定义 -- 能点的:预览里(属性表的名称、类型、来源、导入项、默认值、值,以及一行译文)照抄自源码的名字——标识符、属性名、类型名、点分模块名的每一节(`luffy_agent.core.db` 三节各自能点,和 VS Code 一样)、字符串(TypeScript 的模块路径 `'./x'` 就是字符串)。翻出来的关键字、运算符、说明词不能点。 +- 能点的:预览里(属性表的名称、类型、来源、导入项、默认值、值、类型参数、父类、实现的接口,以及一行译文)照抄自源码的名字——标识符、属性名、类型名、点分模块名的每一节(`luffy_agent.core.db` 三节各自能点,和 VS Code 一样)、字符串(TypeScript 的模块路径 `'./x'` 就是字符串)。翻出来的关键字、运算符、说明词不能点。 - 链接和编辑器里一样:只有按住修饰键(macOS ⌘,其余平台 Ctrl)、鼠标停在**查得到定义**的名字上时,名字加下划线、换成链接色(`--vscode-editorLink-activeForeground`)、光标变手形;松开修饰键或移开就恢复。有没有定义问扩展端(`vscode.executeDefinitionProvider`,文档 uri + 名字在源码里的位置);和编辑器一样,每回指到一个名字上(或指着名字按下修饰键、换了文档版本)都现问,回答不留到下次用——Pylance、tsserver 刚启动那几秒会答「没有」;只认最近一次询问的回答,迟到的丢掉。 - ⌘ / Ctrl + 点击: - 一处定义,在当前文件:原地滚过去; @@ -96,8 +97,8 @@ - 没有定义:不动。 - 和普通编辑器里 ⌘ + 点击一样:目标是否按「预览标签」打开听 `workbench.editor.enablePreview`;`workbench.editor.enablePreviewFromCodeNavigation` 为假(默认)时先把当前预览标签固定住,免得被目标替掉。 - 闪哪一处:页面上**就是这处定义**的最具体的那一处,只按源码位置判断(定义提供者给的定义处下标,对上块和属性行带的源码范围),不看属性名文字: - 1. 定义是块自己的名字(函数名;只声明一个变量时的变量名,解构模式整个算)→ 整块。块的这段范围由扩展端从 facts 取(`BlockView.defines`),和翻译插件无关。 - 2. 定义是属性表里某一行 / 子行讲的东西(参数、导入项、一条语句声明几个变量时的某一个)→ 只闪这一行 / 子行。范围是翻译插件从 facts 取的节点(`PropertyRow.defines`、`PropertyItem.defines`),定义处落在范围里就算(`*args` 的定义在 `args` 上、解构参数里的名字都对得上)。 + 1. 定义是块自己的名字(函数名、类名;只声明一个变量时的变量名,解构模式整个算)→ 整块。块的这段范围由扩展端从 facts 取(`BlockView.defines`),和翻译插件无关。 + 2. 定义是属性表里某一行 / 子行讲的东西(参数、导入项、一条语句声明几个变量时的某一个、类的泛型参数)→ 只闪这一行 / 子行。范围是翻译插件从 facts 取的节点(`PropertyRow.defines`、`PropertyItem.defines`),定义处落在范围里就算(`*args` 的定义在 `args` 上、解构参数里的名字都对得上)。 3. 其余(定义在一行译文里,例如循环变量;翻译插件没填 `defines`)→ 显示着定义处那个名字的块,名字在属性行的子行里时展开那一行;页面上找不到这个名字就取包含定义那一行的块。 - 同一文件原地跳、跳进另一个文件的预览,规则一样。 - 滚过去:目标所在块的祖先块折叠着就展开,目标在折叠的属性行子行里(参数、导入项)就展开那一行。目标已在视口里不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一处再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。目标是属性行 / 子行但块正在编辑(页面上没有属性表)时闪整块。 @@ -139,6 +140,20 @@ export interface FunctionFacts { returnType?: Node } +/** 父类的一项:TS `extends Base` 的 Base(表达式)和它后面的泛型实参 ;Python 类参数列表的一项(含 metaclass=…、*bases)。 */ +export interface BaseFacts { value: Node, typeArguments?: Node } + +export interface ClassFacts { + name?: Node // 匿名类没有;赋给变量、类字段的类表达式取变量名、字段名 + doc?: Node // 同 FunctionFacts.doc + access?: Access // 嵌套在语句块里的类没有 + abstract?: boolean // 只有 TypeScript 填 + decorators: Node[] + typeParameters: Node[] // 每个泛型参数一项 + bases: BaseFacts[] + implements: Node[] // TypeScript 的 implements,每个接口一项 +} + 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' @@ -161,16 +176,19 @@ export interface VariableFacts { export interface BlockShape { /** 这一块对应的语法树节点(整条语句)。 */ node: Node - /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句;函数的说明(doc)也算在里面。 */ + /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句;函数、类的说明(doc)也算在里面。 */ header: SourceSpan children: CodeBlock[] } /** 预览成属性表的块:种类和 facts 绑在一起,语言插件漏给 facts 编译不过。 */ export interface FunctionBlock extends BlockShape { kind: 'function', facts: FunctionFacts } +export interface ClassBlock extends BlockShape { kind: 'class', facts: ClassFacts } export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts } export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts } -export type FactBlock = FunctionBlock | ImportBlock | VariableBlock +export type FactBlock = FunctionBlock | ClassBlock | ImportBlock | VariableBlock +/** 带说明(facts.doc)的块。 */ +export type DocumentedBlock = FunctionBlock | ClassBlock export type TextKind = Exclude /** 预览成一行译文的块(注释块是 Markdown 说明)。 */ @@ -199,12 +217,12 @@ export interface Segment { text: string, role: TokenRole, offset?: number } */ export interface Markdown { markdown: string } -/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数的说明)。 */ +/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明)。 */ export type PreviewText = Segment[] | Markdown /** * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts)在源码里的范围—— - * 参数名(连 `*` 前缀、整个解构模式)、导入项(名称到别名)、声明的变量名。跳到的定义落在里面就只闪这一行; + * 参数名(连 `*` 前缀、整个解构模式)、导入项(名称到别名)、声明的变量名、类的泛型参数(整串)。跳到的定义落在里面就只闪这一行; * 第三方翻译插件可以不填,不填就闪整块。 */ export interface Defines { defines?: SourceRange } @@ -212,7 +230,7 @@ export interface Defines { defines?: SourceRange } /** 属性表里展开的一个子行:几列对齐的格子。 */ export interface PropertyItem extends Defines { cells: Segment[][] } -/** 属性表的一行:属性名、着色的值或 Markdown 说明(函数的「说明」)、展开在下面的子行(输入参数、导入项)。 */ +/** 属性表的一行:属性名、着色的值或 Markdown 说明(函数、类的「说明」)、展开在下面的子行(输入参数、导入项)。 */ export interface PropertyRow extends Defines { label: string, value: PreviewText, items?: PropertyItem[] } /** 语言插件:语法树 → 代码块树。 */ @@ -233,7 +251,7 @@ export interface Translator { label: string // '中文直译' languageIds: string[] translate: (block: TextBlock, source: string) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文 - describe: (block: FactBlock, source: string) => PropertyRow[] // 函数、导入、常量、变量块 → 属性表 + describe: (block: FactBlock, source: string) => PropertyRow[] // 函数、类、导入、常量、变量块 → 属性表 } export interface CodeReaderApi { @@ -245,7 +263,7 @@ export interface CodeReaderApi { - `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 - 内置的 TypeScript、Python 语言插件和两个中文直译翻译插件在 `activate()` 里通过同一个 API 注册,没有旁路。 - 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。 -- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译。第三方语言插件给函数、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表。 +- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译。第三方语言插件给函数、类、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表。 ## 解析运行时 @@ -259,13 +277,13 @@ 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 组件、回调常见写法),子块来自主体。 +- 变量声明的唯一声明值是带语句块主体的箭头函数 / 函数表达式 → function(React 组件、回调常见写法),子块来自主体;是类表达式(`const A = class {}`)→ class。 - `lexical_declaration` / `variable_declaration`:模块顶层的 `const` → const,其余 → variable。 -- `class_declaration` → class,子块:方法 → function,字段 → field,注释 → comment。 +- `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。 - `if_statement` → if,子块来自 consequence;`else` / `else if` 展平成紧跟其后的兄弟块 else(`else if` 的头部是 `else if (...)`,子块来自它的 consequence)。 - `for` / `for…in` / `for…of` / `while` / `do` → loop。 @@ -279,8 +297,8 @@ export interface CodeReaderApi { - `import_statement` / `import_from_statement` / `future_import_statement` → import。 - `function_definition` → function;`decorated_definition` 按内部定义定种类,头部包含装饰器。 - `class_definition` → class;类体里的带注解赋值 → field。 -- 模块 / 类主体开头的字符串表达式(docstring)和 `comment` → comment。 -- 函数主体的第一条语句(注释不算)是单独一个字符串表达式时,它是函数的 docstring:进 `facts.doc`,头部往下延到字符串结尾(它和冒号之间的注释也在头部里),子块从它后面开始,不单独成块。 +- 模块开头的字符串表达式(docstring)和 `comment` → comment。 +- 函数、类主体的第一条语句(注释不算)是单独一个字符串表达式时,它是这个函数、类的 docstring:进 `facts.doc`,头部往下延到字符串结尾(它和冒号之间的注释也在头部里),子块从它后面开始,不单独成块。 - 赋值:模块顶层且变量名全大写 → const,其余 → variable。 - `if_statement` → if;`elif_clause` / `else_clause` → 兄弟块 else;`for` / `while` → loop(循环的 `else` → 兄弟块 else)。 - `try_statement` → try;`except_clause` → catch;`finally_clause` → finally。 @@ -291,9 +309,11 @@ export interface CodeReaderApi { ### 属性表信息(facts)从哪来 - TypeScript 函数:`function_declaration` / `generator_function_declaration` / `function_signature`、类里的 `method_definition` / 方法签名、值是带语句块主体函数的变量和类字段、`export default` 的函数值。名称取声明名 / 变量名 / 字段名;说明取紧贴在前面的 JSDoc 注释节点(见上面代码块树规则);参数来自 `formal_parameters`(`required_parameter` / `optional_parameter` 的 pattern、type、value,JavaScript 的 `assignment_pattern`),不带括号的单参数箭头函数取 `parameter`;返回类型取 `return_type` 里的类型;异步看有没有 `async`;类成员的装饰器是类主体里排在它前面的 `decorator` 兄弟节点。 +- TypeScript 类:`class_declaration` / `abstract_class_declaration`、类表达式 `class`(`export default class {}`、变量的值、类字段的值)。名称取类名 / 变量名 / 字段名;说明同函数;访问级别同函数(类字段按类成员);抽象看节点类型是不是 `abstract_class_declaration`;装饰器是 `export_statement` 和类声明自己的 `decorator` 子节点(`@d export class`、`export @d class`、`@d class` 都算),类字段的值是类表达式时还有类主体里排在它前面的 `decorator`;泛型参数是 `type_parameters` 里每个 `type_parameter`;父类、实现的接口在 `class_heritage` 里:`extends_clause` 的每个 value 一项,跟在它后面的 `type_arguments` 归它,`implements_clause` 里每个类型一项;JavaScript 的 `class_heritage` 直接是 `extends` 后面的表达式。 - TypeScript 导入:`import_statement` 的 `source`(`import x = require()` 取 `import_require_clause` 里的);`import_clause` 里的标识符是默认导入,`namespace_import` 是 `* as`,`named_imports` 里每个 `import_specifier` 的 name / alias;语句本身带 `type` 是仅类型。 - TypeScript 变量:每个 `variable_declarator` 的 name / type / value。 - Python 函数:`function_definition` 的 name / parameters / return_type,`async` 子节点;说明是主体开头 docstring 的字符串节点(`string` / `concatenated_string`);装饰器是 `decorated_definition` 里的 `decorator`;参数 `identifier`、`typed_parameter`(名字是第一个具名子节点)、`default_parameter`、`typed_default_parameter`、`*args` / `**kwargs`,`*` 和 `/` 分隔符不算参数。 +- Python 类:`class_definition` 的 name;说明是主体开头 docstring 的字符串节点;装饰器同函数;泛型参数是 Python 3.12 `type_parameters` 里的每一项;父类是 `superclasses`(`argument_list`)里的每个具名子节点,照写的顺序,关键字参数(`metaclass=ABCMeta`)、`*bases`、`**kw` 也算;没有抽象、实现的接口。 - Python 导入:`import_statement` 每个 name 一个来源、导入项是整个模块;`import_from_statement` 的 module_name 和各 name(`aliased_import` 拆出 alias),`wildcard_import` 是全部;`future_import_statement` 的来源是 `__future__`。 - Python 变量:`assignment` 的 left / type / right。 @@ -354,6 +374,10 @@ Python 内置类型(仅注解里):str 字符串、int 整数、float 浮 | `from .schemas import (⏎ UserAvatarUploadResponse, … )`(routes.py 19–23 行) | 来源 `.schemas` / 导入项 3 个:UserAvatarUploadResponse、UserDetailResponse、UserUpdateRequest | | `payload = await file.read()`(routes.py,函数体里) | 名称 payload / 初始值 `等待 file.read()` | | `_default_port = 5680 if _role == "beat" else 5679` | 名称 _default_port / 访问级别 私有 / 初始值 `如果 _role 等于 "beat" 则 5680 否则 5679` | +| `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` | ## 预览数据与消息 @@ -367,7 +391,7 @@ interface BlockViewBase { endLine: number code: string // header 源码,去掉共同缩进 codeTokens: Segment[] // 源码着色(拼起来等于 code) - defines?: SourceRange // 块自己的名字(函数名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取;跳到的定义是它时闪整块 + defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取;跳到的定义是它时闪整块 children: BlockView[] } @@ -385,7 +409,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: P - `{ type: 'ready' }` - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 -- 块的编辑状态和用户的折叠选择在 webview 里按块 id 记(`vscode.setState`):`{ editing: string[], folds: Record, scroll? }`。`folds` 只记用户点过的(id → 折叠着没有),属性行也记在里面,id 是 `<块 id>/p<行序号>`(例如 `6.1.0/p3`);没记的按默认:函数块折叠,其余块和属性行展开。眼下折叠着哪些由「默认 + folds」算出来,界面、跳到定义的展开都只看这一份。跳到定义展开祖先块、属性行时也记进 `folds`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 +- 块的编辑状态和用户的折叠选择在 webview 里按块 id 记(`vscode.setState`):`{ editing: string[], folds: Record, scroll? }`。`folds` 只记用户点过的(id → 折叠着没有),属性行也记在里面,id 是 `<块 id>/p<行序号>`(例如 `6.1.0/p3`);没记的按默认:函数、类块折叠,其余块和属性行展开。眼下折叠着哪些由「默认 + folds」算出来,界面、跳到定义的展开都只看这一份。跳到定义展开祖先块、属性行时也记进 `folds`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 ## 颜色 @@ -404,7 +428,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: P | hint | `--vscode-descriptionForeground` | 同左 | | plain | `--vscode-editor-foreground` | 同左 | -属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,未标注、无、N 个、默认、默认导出、全部、整个模块、匿名按 hint。 +属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、默认、默认导出、全部、整个模块、匿名按 hint。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。 种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框)。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 @@ -432,15 +456,15 @@ 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 连同它前面的注释进头部。 -- 块头部:各样例里块的头部行互不重叠;函数说明所在的行在函数块里,「编辑」写回原样不变。 -- 折叠:默认函数块折叠、其余展开;用户的选择盖过默认、重新加载后还在;旧版本存的状态不报错;跳到定义时默认折叠的函数也会展开。 +- 语言插件:`envLock.ts`、`issueLock.ts` 的代码块树(种类、header 行号、层级)与原型 `CODE_TREE` 一致,只是紧贴函数的 JSDoc 算进了函数头部;Python 样例覆盖 import / def / docstring / if-elif-else / for / try-except / with / 推导式;函数的说明:JSDoc 各种挂法(export、默认导出、方法隔着装饰器、函数值的变量、嵌套函数)和不挂的情况,docstring 连同它前面的注释进头部。类:`classes.ts`、`classes.py` 覆盖 export + 抽象 + 泛型 + extends / implements + 装饰器 + JSDoc、匿名默认导出、赋给变量 / 类字段的类表达式、函数里的类、类里的类、docstring、metaclass 关键字参数、下划线私有。 +- 块头部:各样例里块的头部行互不重叠;函数、类说明所在的行在函数、类块里,「编辑」写回原样不变。 +- 折叠:默认函数、类块折叠、其余展开;用户的选择盖过默认、重新加载后还在;旧版本存的状态不报错;跳到定义时默认折叠的函数、类也会展开。 - 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 -- `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、导入、常量、变量块只带属性表,其余块只带译文。 -- 注释的 Markdown:注释块、函数「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。 +- `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。 +- 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。 - 行替换:多行块替换后缩进正确。 -- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,函数名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量标的范围指回源码原文;块自己的名字只有函数和单变量声明有。 +- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 ## 收尾 diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index 60dd31c..38cddbb 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -29,11 +29,13 @@ function byId(list: BlockView[], id: string): BlockView { const SAMPLES: [LanguageAdapter, string, Translator, string][] = [ [ts, 'typescript', typescriptZhTranslator, 'envLock.ts.txt'], [ts, 'typescript', typescriptZhTranslator, 'issueLock.ts.txt'], + [ts, 'typescript', typescriptZhTranslator, 'classes.ts.txt'], [py, 'python', pythonZhTranslator, 'sample.py'], [py, 'python', pythonZhTranslator, 'routes.py'], + [py, 'python', pythonZhTranslator, 'classes.py'], ] -const PROPERTY_KINDS = new Set(['function', 'import', 'const', 'variable']) +const PROPERTY_KINDS = new Set(['function', 'class', 'import', 'const', 'variable']) function translationText(view: BlockView): string { if (!('translation' in view)) @@ -57,7 +59,7 @@ describe('blockView', () => { } }) - it.each(SAMPLES)('%#: 函数、导入、常量、变量块只带属性表,其余块只带一行译文', async (adapter, languageId, translator, fixture) => { + it.each(SAMPLES)('%#: 函数、类、导入、常量、变量块只带属性表,其余块只带一行译文', async (adapter, languageId, translator, fixture) => { for (const view of flatten(await views(adapter, languageId, translator, readFixture(fixture)))) { expect('properties' in view, view.id).toBe(PROPERTY_KINDS.has(view.kind)) expect('translation' in view, view.id).toBe(!PROPERTY_KINDS.has(view.kind)) @@ -100,6 +102,26 @@ describe('blockView', () => { expect(getUser?.children[0]).toMatchObject({ kind: 'comment', startLine: 38, endLine: 38 }) }) + it('类的说明同样算在类块的头部里:JSDoc 连着中间的装饰器,docstring 连着它前面的注释', async () => { + const repo = byId(await views(ts, 'typescript', typescriptZhTranslator, readFixture('classes.ts.txt')), '1') + expect(repo).toMatchObject({ kind: 'class', startLine: 2, endLine: 8 }) + expect(repo.code.split('\n').slice(-3)).toEqual([ + ' */', + '@Injectable()', + 'export abstract class Repository extends Base implements Loader, Disposable {', + ]) + expect('properties' in repo && repo.properties[1]).toEqual({ label: '说明', value: { markdown: '仓库基类。\n\n子类实现 `load`。' } }) + + const notFound = byId(await views(py, 'python', pythonZhTranslator, readFixture('classes.py')), '3') + expect(notFound).toMatchObject({ kind: 'class', startLine: 17, endLine: 19 }) + expect(notFound.code.split('\n')).toEqual([ + 'class NotFound(AppException): # 行尾', + ' # 说明前面的注释', + ' """找不到资源。"""', + ]) + expect(notFound.children[0]).toMatchObject({ kind: 'variable', startLine: 20, endLine: 20 }) + }) + it('注释块的译文、函数的「说明」是 Markdown 原文,webview 按它渲染', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) expect(byId(env, '0')).toMatchObject({ kind: 'comment', translation: { markdown: expect.stringMatching(/^\.env file lock \/ unlock helpers\.\n\nRecursively/) } }) @@ -157,13 +179,13 @@ describe('blockView', () => { expect(signature.filter(token => token.text === 'str')).toEqual([{ text: 'str', role: 'type' }, { text: 'str', role: 'type' }]) }) - it('块自己的名字(defines):函数名、只声明一个变量时的变量名(解构模式整个算);导入、一条语句几个变量、一行译文的块没有', async () => { + it('块自己的名字(defines):函数名、类名(赋给变量的类表达式取变量名)、只声明一个变量时的变量名(解构模式整个算);导入、一条语句几个变量、一行译文的块、匿名类没有', async () => { const own = (source: string, list: BlockView[]): Record => Object.fromEntries(list.map(view => [view.id, view.defines && source.slice(view.defines.startIndex, view.defines.endIndex)])) - const pySource = ['import os', 'def f(x): pass', 'a, b = 1, 2', 'for i in x: pass'].join('\n') - expect(own(pySource, await views(py, 'python', pythonZhTranslator, pySource))).toEqual({ 0: undefined, 1: 'f', 2: 'a, b', 3: undefined }) - const tsSource = ['let i = 0, j', 'const { u, v } = o', 'export const h = () => 1'].join('\n') - expect(own(tsSource, await views(ts, 'typescript', typescriptZhTranslator, tsSource))).toEqual({ 0: undefined, 1: '{ u, v }', 2: 'h' }) + const pySource = ['import os', 'def f(x): pass', 'a, b = 1, 2', 'for i in x: pass', 'class K: pass'].join('\n') + expect(own(pySource, await views(py, 'python', pythonZhTranslator, pySource))).toEqual({ 0: undefined, 1: 'f', 2: 'a, b', 3: undefined, 4: 'K' }) + const tsSource = ['let i = 0, j', 'const { u, v } = o', 'export const h = () => 1', 'class A {}', 'const B = class C {}', 'export default class {}'].join('\n') + expect(own(tsSource, await views(ts, 'typescript', typescriptZhTranslator, tsSource))).toEqual({ 0: undefined, 1: '{ u, v }', 2: 'h', 3: 'A', 4: 'B', 5: undefined }) }) it('\\r\\n 和制表符缩进:code 用 \\n 连接,去掉共同的制表符', async () => { diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index e72107c..fb1fdd0 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -18,7 +18,7 @@ interface BlockViewBase { children: BlockView[] } -/** 发给 webview 的一块(可序列化):函数、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 */ +/** 发给 webview 的一块(可序列化):函数、类、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 */ export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] }) interface Line { start: number, end: number } @@ -83,12 +83,13 @@ function colorLines(root: Node, source: string, lines: Line[], startRow: number, } /** - * 这块本身就是哪个名字的定义:函数名,只声明一个变量(或一个解构模式)时的变量名。 + * 这块本身就是哪个名字的定义:函数名、类名,只声明一个变量(或一个解构模式)时的变量名。 * 导入、一条语句声明几个变量时没有,它们的名字各是属性表里的一行(PropertyRow.defines)。 */ function ownName(block: CodeBlock): Node | undefined { switch (block.kind) { case 'function': + case 'class': return block.facts.name case 'const': case 'variable': diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 8b1c412..1b983a6 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -42,6 +42,33 @@ export interface FunctionFacts { returnType?: Node } +/** + * 父类的一项:TypeScript `extends Base` 里的 Base(一个表达式)和跟在它后面的泛型实参 ``(语法里是两个节点); + * Python 类参数列表里的一项,`metaclass=ABCMeta` 这类关键字参数、`*bases` 也算。 + */ +export interface BaseFacts { + value: Node + typeArguments?: Node +} + +export interface ClassFacts { + /** 匿名类(`export default class {}`)没有;赋给变量、类字段的类表达式取变量名、字段名。 */ + name?: Node + /** 类自己的说明,同 FunctionFacts.doc:TypeScript 是紧贴在前面的 JSDoc,Python 是主体开头的 docstring。 */ + doc?: Node + /** 同函数:嵌套在函数体、条件等语句块里的类没有。 */ + access?: Access + /** 抽象类(TypeScript 的 `abstract class`);没有这个概念的语言不填。 */ + abstract?: boolean + decorators: Node[] + /** 泛型参数,每个参数一项(TypeScript `` 里的 `T extends X = Y`)。 */ + typeParameters: Node[] + /** 按源码顺序。 */ + bases: BaseFacts[] + /** 实现的接口(TypeScript 的 `implements A, B`),每个接口一项。 */ + implements: Node[] +} + /** 导入的一项。 */ export type ImportItem = | { kind: 'named', name: Node, alias?: Node } // import { a as b }、from m import a as b @@ -77,7 +104,7 @@ export interface BlockShape { node: Node /** * 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。 - * 函数的说明(FunctionFacts.doc)也算在里面:JSDoc 往上延到注释开头,docstring 往下延到字符串结尾。 + * 函数、类的说明(facts.doc)也算在里面:JSDoc 往上延到注释开头,docstring 往下延到字符串结尾。 */ header: SourceSpan children: CodeBlock[] @@ -85,9 +112,13 @@ export interface BlockShape { /** 预览成属性表的块:语言插件从语法树读出结构化信息(facts),翻译插件把它排成属性表。 */ export interface FunctionBlock extends BlockShape { kind: 'function', facts: FunctionFacts } +export interface ClassBlock extends BlockShape { kind: 'class', facts: ClassFacts } export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts } export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts } -export type FactBlock = FunctionBlock | ImportBlock | VariableBlock +export type FactBlock = FunctionBlock | ClassBlock | ImportBlock | VariableBlock + +/** 带说明(facts.doc)的块。 */ +export type DocumentedBlock = FunctionBlock | ClassBlock export type TextKind = Exclude @@ -116,12 +147,12 @@ export interface Segment { text: string, role: TokenRole, offset?: number } */ export interface Markdown { markdown: string } -/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数的说明)。 */ +/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明)。 */ export type PreviewText = Segment[] | Markdown /** * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts,例如参数名、导入项的名称和别名、 - * 声明的变量名)在源码里的范围。跳到定义时,定义处落在这个范围里就只闪这一行;不填(第三方翻译插件可以不填)就闪整块。 + * 声明的变量名、类的泛型参数)在源码里的范围。跳到定义时,定义处落在这个范围里就只闪这一行;不填(第三方翻译插件可以不填)就闪整块。 */ export interface Defines { defines?: SourceRange } @@ -154,7 +185,7 @@ export interface Translator { languageIds: string[] /** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */ translate: (block: TextBlock, source: string) => PreviewText - /** 函数、导入、常量、变量块 → 属性表。 */ + /** 函数、类、导入、常量、变量块 → 属性表。 */ describe: (block: FactBlock, source: string) => PropertyRow[] } diff --git a/vscode/src/codeReader/editText.test.ts b/vscode/src/codeReader/editText.test.ts index 9f79987..51d06ef 100644 --- a/vscode/src/codeReader/editText.test.ts +++ b/vscode/src/codeReader/editText.test.ts @@ -37,9 +37,11 @@ describe('行替换', () => { it.each([ [createTypeScriptAdapter(WASM_DIR), 'typescript', 'envLock.ts.txt'], [createTypeScriptAdapter(WASM_DIR), 'typescript', 'issueLock.ts.txt'], + [createTypeScriptAdapter(WASM_DIR), 'typescript', 'classes.ts.txt'], [createPythonAdapter(WASM_DIR), 'python', 'sample.py'], [createPythonAdapter(WASM_DIR), 'python', 'routes.py'], - ] as const)('%#: 把预览里的 code 原样写回,文件不变(去缩进与补缩进互逆,函数头部连着说明也一样)', async (adapter, languageId, fixture) => { + [createPythonAdapter(WASM_DIR), 'python', 'classes.py'], + ] as const)('%#: 把预览里的 code 原样写回,文件不变(去缩进与补缩进互逆,函数、类头部连着说明也一样)', async (adapter, languageId, fixture) => { const source = readFixture(fixture) const views = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, undefined)) const all = views.flatMap(function flat(view): typeof views { diff --git a/vscode/src/codeReader/fixtures/classes.py b/vscode/src/codeReader/fixtures/classes.py new file mode 100644 index 0000000..bd702c0 --- /dev/null +++ b/vscode/src/codeReader/fixtures/classes.py @@ -0,0 +1,40 @@ +"""领域异常。""" + +from abc import ABCMeta + + +class AppException(Exception): + """应用异常的基类。 + + 带上 HTTP 状态码。 + """ + + status_code: int = 500 + + def __init__(self, message: str) -> None: + self.message = message + + +class NotFound(AppException): # 行尾 + # 说明前面的注释 + """找不到资源。""" + status_code = 404 + + +@dataclass(frozen=True) +class Meta(Base, metaclass=ABCMeta): + class Config: + extra = "forbid" + + class _Hidden: + pass + + +class _Private: + pass + + +def factory(): + class Local: + """局部类。""" + return Local diff --git a/vscode/src/codeReader/fixtures/classes.ts.txt b/vscode/src/codeReader/fixtures/classes.ts.txt new file mode 100644 index 0000000..6b36c1e --- /dev/null +++ b/vscode/src/codeReader/fixtures/classes.ts.txt @@ -0,0 +1,32 @@ +import { Injectable } from './di' + +/** + * 仓库基类。 + * + * 子类实现 `load`。 + */ +@Injectable() +export abstract class Repository extends Base implements Loader, Disposable { + /** 缓存 */ + private readonly cache = new Map() + + abstract load(id: K): Promise + + static Query = class extends QueryBase { + run() {} + } +} + +export default class { + x = 1 +} + +/** 类表达式 */ +export const Mixed = class Inner extends mixin(A, B) {} + +function make() { + class Local {} + return Local +} + +@sealed class Plain {} diff --git a/vscode/src/codeReader/languages/python.test.ts b/vscode/src/codeReader/languages/python.test.ts index d52e3d5..32b2863 100644 --- a/vscode/src/codeReader/languages/python.test.ts +++ b/vscode/src/codeReader/languages/python.test.ts @@ -90,7 +90,7 @@ describe('python 代码块树', () => { }) }) -describe('python 函数的 docstring', () => { +describe('python 函数、类的 docstring', () => { const source = [ 'class K:', ' """类说明"""', @@ -105,10 +105,9 @@ describe('python 函数的 docstring', () => { 'def g(): "单行"', ].join('\n') - it('算进函数头部、不单独成块,它前面的注释也在头部里;后面的注释照旧成块;类的 docstring 照旧是注释块', async () => { + it('算进函数、类头部、不单独成块,它前面的注释也在头部里;后面的注释照旧成块', async () => { expect(await tree(source)).toEqual([ - { at: 1, kind: 'class', kids: [ - { at: 2, kind: 'comment' }, + { at: [1, 2], kind: 'class', kids: [ { at: [3, 8], kind: 'function', kids: [ { at: 9, kind: 'comment' }, { at: 10, kind: 'return' }, @@ -118,9 +117,11 @@ describe('python 函数的 docstring', () => { ]) }) - it('进 facts.doc:是那个字符串节点;没有 docstring 的函数没有', async () => { + it('进 facts.doc:是那个字符串节点;没有 docstring 的函数没有;模块开头的 docstring 照旧是注释块', async () => { const found = await facts(source) + expect(found[1]).toMatchObject({ name: 'K', doc: '"""类说明"""' }) expect(found[3]).toMatchObject({ name: 'run', doc: '"""跑一遍。\n\n 细节。\n """' }) + expect(await tree('"""模块说明"""\nclass K:\n pass')).toEqual([{ at: 1, kind: 'comment' }, { at: 2, kind: 'class', kids: [{ at: 3, kind: 'statement' }] }]) expect(found[11]).toMatchObject({ name: 'g', doc: '"单行"' }) expect(await facts('def f():\n x = 1\n """not a docstring"""')).not.toHaveProperty('1.doc') }) @@ -236,3 +237,63 @@ describe('python 属性表信息(facts)', () => { expect(found[10]).not.toHaveProperty('access') }) }) + +describe('python 类', () => { + it('classes.py:docstring(连同它前面的注释)算进类头部,类里的类、函数里的类照常成块', async () => { + expect(await tree(readFixture('classes.py'))).toEqual([ + { at: 1, kind: 'comment' }, + { at: 3, kind: 'import' }, + { at: [6, 10], kind: 'class', kids: [ + { at: 12, kind: 'field' }, + { at: 14, kind: 'function', kids: [{ at: 15, kind: 'variable' }] }, + ] }, + { at: [18, 20], kind: 'class', kids: [{ at: 21, kind: 'variable' }] }, + { at: [24, 25], kind: 'class', kids: [ + { at: 26, kind: 'class', kids: [{ at: 27, kind: 'variable' }] }, + { at: 29, kind: 'class', kids: [{ at: 30, kind: 'statement' }] }, + ] }, + { at: 33, kind: 'class', kids: [{ at: 34, kind: 'statement' }] }, + { at: 37, kind: 'function', kids: [ + { at: [38, 39], kind: 'class' }, + { at: 40, kind: 'return' }, + ] }, + ]) + }) + + it('classes.py:名称、说明、访问级别(同函数的下划线规则,类里的类也按它,函数里的没有)、装饰器、父类(参数列表照写的顺序,关键字参数也算)', async () => { + const found = await facts(readFixture('classes.py')) + expect(found[6]).toEqual({ + name: 'AppException', + doc: '"""应用异常的基类。\n\n 带上 HTTP 状态码。\n """', + access: 'public', + decorators: [], + typeParameters: [], + bases: [{ value: 'Exception' }], + implements: [], + }) + expect(found[18]).toMatchObject({ name: 'NotFound', doc: '"""找不到资源。"""', bases: [{ value: 'AppException' }] }) + expect(found[24]).toEqual({ + name: 'Meta', + access: 'public', + decorators: ['@dataclass(frozen=True)'], + typeParameters: [], + bases: [{ value: 'Base' }, { value: 'metaclass=ABCMeta' }], + implements: [], + }) + expect(found[26]).toMatchObject({ name: 'Config', access: 'public', bases: [] }) + expect(found[29]).toMatchObject({ name: '_Hidden', access: 'private' }) + expect(found[33]).toMatchObject({ name: '_Private', access: 'private' }) + expect(found[38]).toEqual({ name: 'Local', doc: '"""局部类。"""', decorators: [], typeParameters: [], bases: [], implements: [] }) + }) + + it('没有「抽象」这个概念;Python 3.12 的泛型参数进 typeParameters;*bases、**kw 也是父类里的一项', async () => { + expect((await facts('class G[T](Generic[T], *mixins, **kw):\n pass'))[1]).toEqual({ + name: 'G', + access: 'public', + decorators: [], + typeParameters: ['T'], + bases: [{ value: 'Generic[T]' }, { value: '*mixins' }, { value: '**kw' }], + implements: [], + }) + }) +}) diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index 492eb8a..26bcacc 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -1,4 +1,4 @@ -import type { Access, BlockShape, CodeBlock, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' +import type { Access, BlockShape, ClassFacts, CodeBlock, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' import type { CommentRules } from './shared' import * as path from 'node:path' import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared' @@ -9,7 +9,7 @@ type Scope = 'module' | 'class' | 'other' const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/ const PARAMETER_SEPARATORS = new Set(['keyword_separator', 'positional_separator']) const PATTERNS = new Set(['pattern_list', 'tuple_pattern', 'list_pattern']) -// 只有 # 行注释;函数的说明是主体开头的 docstring,不是写在前面的注释 +// 只有 # 行注释;函数、类的说明是主体开头的 docstring,不是写在前面的注释 const COMMENTS: CommentRules = { isLine: () => true, isDoc: () => false } function statements(items: Node[], scope: Scope, afterRow: number, docstring: boolean): CodeBlock[] { @@ -73,14 +73,12 @@ function statementBlocks(node: Node, scope: Scope, docstring: boolean): CodeBloc /** function_definition / class_definition;outer 是带装饰器的整体时头部从装饰器开始。 */ function definitionBlock(definition: Node, outer: Node, scope: Scope): CodeBlock { const body = definition.childForFieldName('body') - if (definition.type === 'class_definition') - return { kind: 'class', ...bodyShape(outer, body, 'class', true) } const doc = docstringOf(body) - return { - kind: 'function', - ...(body && doc ? documentedShape(outer, body, doc) : bodyShape(outer, body, 'other')), - facts: functionFacts(definition, outer, scope, doc), - } + const inner: Scope = definition.type === 'class_definition' ? 'class' : 'other' + const shape = body && doc ? documentedShape(outer, body, doc, inner) : bodyShape(outer, body, inner) + if (definition.type === 'class_definition') + return { kind: 'class', ...shape, facts: classFacts(definition, outer, scope, doc) } + return { kind: 'function', ...shape, facts: functionFacts(definition, outer, scope, doc) } } /** 主体开头的 docstring:第一条语句(注释不算)是单独一个字符串表达式时,返回那个字符串节点。 */ @@ -97,10 +95,10 @@ function docstringIn(statement: Node): Node | undefined { return rest.length === 0 && (expression?.type === 'string' || expression?.type === 'concatenated_string') ? expression : undefined } -/** 函数的 docstring 是它自己的说明:算进头部(连同它前面的注释),子块从它后面开始。 */ -function documentedShape(outer: Node, body: Node, doc: Node): BlockShape { +/** 函数、类的 docstring 是它自己的说明:算进头部(连同它前面的注释),子块从它后面开始。 */ +function documentedShape(outer: Node, body: Node, doc: Node, scope: Scope): BlockShape { const rest = named(body).filter(item => item.startIndex >= doc.endIndex) - return compoundShape(outer, doc.endIndex, rest, (list, afterRow) => statements(list, 'other', afterRow, false)) + return compoundShape(outer, doc.endIndex, rest, (list, afterRow) => statements(list, scope, afterRow, false)) } /** 单下划线开头的名字是私有(`_x`、`__x`),前后双下划线的特殊方法(`__init__`)是公共。 */ @@ -140,7 +138,7 @@ function functionFacts(definition: Node, outer: Node, scope: Scope, doc: Node | doc, access: name ? scopeAccess(name, scope) : undefined, async: children(definition).some(child => child.type === 'async'), - decorators: named(outer).filter(child => child.type === 'decorator'), + decorators: decoratorsOf(outer), parameters: syntaxChildren(definition.childForFieldName('parameters')) .filter(param => !PARAMETER_SEPARATORS.has(param.type)) .map(parameterFacts), @@ -148,6 +146,26 @@ function functionFacts(definition: Node, outer: Node, scope: Scope, doc: Node | } } +/** 父类是类名后面括号里的参数列表,照写的顺序一项一项取,`metaclass=…` 这类关键字参数也在里面。 */ +function classFacts(definition: Node, outer: Node, scope: Scope, doc: Node | undefined): ClassFacts { + const name = definition.childForFieldName('name') + return { + name: name ?? undefined, + doc, + access: name ? scopeAccess(name, scope) : undefined, + decorators: decoratorsOf(outer), + // Python 3.12 的 `class G[T]:` + typeParameters: syntaxChildren(definition.childForFieldName('type_parameters')), + bases: syntaxChildren(definition.childForFieldName('superclasses')).map(value => ({ value })), + implements: [], + } +} + +/** 装饰器在 decorated_definition 里,和被装饰的定义是兄弟节点。 */ +function decoratorsOf(outer: Node): Node[] { + return named(outer).filter(child => child.type === 'decorator') +} + /** 带注解的参数(typed_parameter)语法没给名字起字段名,名字是第一个具名子节点(可能是 `*args`)。 */ function parameterFacts(param: Node): ParameterFacts { switch (param.type) { @@ -301,6 +319,9 @@ function identifierRole(leaf: Node): TokenRole { return 'function' if (parent.type === 'class_definition' && sameNode(parent.childForFieldName('name'), leaf)) return 'type' + // 父类:类名后面括号里直接写的名字 + if (parent.type === 'argument_list' && parent.parent?.type === 'class_definition') + return 'type' if (parent.type === 'decorator' || isCallee(leaf)) return 'function' if (parent.type === 'attribute' && sameNode(parent.childForFieldName('attribute'), leaf) && isCallee(parent)) diff --git a/vscode/src/codeReader/languages/shared.ts b/vscode/src/codeReader/languages/shared.ts index 70e8a83..458dd33 100644 --- a/vscode/src/codeReader/languages/shared.ts +++ b/vscode/src/codeReader/languages/shared.ts @@ -1,4 +1,4 @@ -import type { BlockShape, CodeBlock, FunctionBlock, Node, SourceSpan, TextKind } from '../contract' +import type { BlockShape, CodeBlock, DocumentedBlock, Node, SourceSpan, TextKind } from '../contract' /** 语言插件共用的建树工具:取子节点、算头部、把一串语句切成块。 */ @@ -89,19 +89,19 @@ export function compoundShape(outer: Node, bodyStart: number, items: Node[], lis export interface CommentRules { /** 行注释:相邻几行合成一块。 */ isLine: (comment: Node) => boolean - /** 文档注释(JSDoc):紧贴在函数块前面时是这个函数的说明,见 FunctionFacts.doc。 */ + /** 文档注释(JSDoc):紧贴在函数、类块前面时是它的说明,见 FunctionFacts.doc、ClassFacts.doc。 */ isDoc: (comment: Node) => boolean } -/** 函数块带上说明:说明进 facts.doc,头部往上延到它开头。 */ -function documented(block: FunctionBlock, doc: Node): FunctionBlock { +/** 函数、类块带上说明:说明进 facts.doc,头部往上延到它开头。 */ +function documented(block: B, doc: Node): B { return { ...extendHeader(block, doc), facts: { ...block.facts, doc } } } /** * 一串语句 → 块。注释单独成块,相邻行的行注释合成一块; * 和前一条语句(或头部)同一行的注释是行尾注释,跟着那一行显示,不单独成块。 - * 文档注释和下一条语句之间只有空白、下一条语句是函数块时,它是这个函数的说明,不单独成块; + * 文档注释和下一条语句之间只有空白、下一条语句是函数或类块时,它是这个函数、类的说明,不单独成块; * 中间隔着的装饰器(不出块的语句)不算隔开。 * afterRow 是这串语句之前的最后一行(头部结尾行),模块顶层传 -1; * toBlocks 的 first 表示它是这串里第一条非注释语句(Python 认 docstring 用)。 @@ -145,7 +145,7 @@ export function listBlocks( else { const blocks = toBlocks(item, first) const [head] = blocks - if (head?.kind === 'function' && doc) { + if ((head?.kind === 'function' || head?.kind === 'class') && 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 bf14313..08e1bae 100644 --- a/vscode/src/codeReader/languages/typescript.test.ts +++ b/vscode/src/codeReader/languages/typescript.test.ts @@ -424,3 +424,61 @@ describe('typeScript 属性表信息(facts)', () => { }) }) }) + +describe('typeScript 类', () => { + it('classes.ts:紧贴的 JSDoc(隔着装饰器也算)算进类头部;类表达式赋给变量、类字段也是类块;函数里的类照常成块', async () => { + expect(await tree(readFixture('classes.ts.txt'))).toEqual([ + { at: 1, kind: 'import' }, + { at: [3, 9], kind: 'class', kids: [ + { at: 10, kind: 'comment' }, + { at: 11, kind: 'field' }, + { at: 13, kind: 'function' }, + { at: 15, kind: 'class', kids: [{ at: 16, kind: 'function' }] }, + ] }, + { at: 20, kind: 'class', kids: [{ at: 21, kind: 'field' }] }, + { at: [24, 25], kind: 'class' }, + { at: 27, kind: 'function', kids: [ + { at: 28, kind: 'class' }, + { at: 29, kind: 'return' }, + ] }, + { at: 32, kind: 'class' }, + ]) + }) + + it('classes.ts:名称、说明、访问级别、抽象、装饰器、泛型参数、父类(泛型实参归它)、实现的接口', async () => { + const found = await facts(readFixture('classes.ts.txt')) + expect(found[3]).toEqual({ + name: 'Repository', + doc: '/**\n * 仓库基类。\n *\n * 子类实现 `load`。\n */', + access: 'public', + abstract: true, + decorators: ['@Injectable()'], + typeParameters: ['K', 'T extends Entity = Entity'], + bases: [{ value: 'Base', typeArguments: '' }], + implements: ['Loader', 'Disposable'], + }) + // 类字段的值是类表达式:名称取字段名,访问级别同类成员 + expect(found[15]).toEqual({ name: 'Query', access: 'public', abstract: false, decorators: [], typeParameters: [], bases: [{ value: 'QueryBase' }], implements: [] }) + expect(found[20]).toEqual({ access: 'public', abstract: false, decorators: [], typeParameters: [], bases: [], implements: [] }) + // 赋给变量的类表达式:名称取变量名 + expect(found[24]).toMatchObject({ name: 'Mixed', doc: '/** 类表达式 */', access: 'public', bases: [{ value: 'mixin(A, B)' }] }) + expect(found[28]).toEqual({ name: 'Local', abstract: false, decorators: [], typeParameters: [], bases: [], implements: [] }) + expect(found[32]).toMatchObject({ name: 'Plain', access: 'private', decorators: ['@sealed'] }) + }) + + it('装饰器写在 export 后面也算;export default class Foo 是公共;JavaScript 的父类直接跟在 extends 后面', async () => { + const source = [ + 'export @dec class A {}', + 'export default class B extends C {}', + 'const D = class {}', + ].join('\n') + expect(await facts(source)).toEqual({ + 1: { name: 'A', access: 'public', abstract: false, decorators: ['@dec'], typeParameters: [], bases: [], implements: [] }, + 2: { name: 'B', access: 'public', abstract: false, decorators: [], typeParameters: [], bases: [{ value: 'C' }], implements: [] }, + 3: { name: 'D', access: 'private', abstract: false, decorators: [], typeParameters: [], bases: [], implements: [] }, + }) + const js = await facts('class A extends mixin(B) {}\nexport default class extends C {}', 'javascript') + expect(js[1]).toMatchObject({ name: 'A', bases: [{ value: 'mixin(B)' }], implements: [] }) + expect(js[2]).toMatchObject({ access: 'public', bases: [{ value: 'C' }] }) + }) +}) diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index 9463091..8db633f 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -1,4 +1,4 @@ -import type { Access, BlockShape, CodeBlock, DeclaratorFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' +import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' import type { CommentRules } from './shared' import * as path from 'node:path' import { children, compoundShape, extendHeader, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared' @@ -98,7 +98,7 @@ function exportBlocks(node: Node, top: boolean): CodeBlock[] { return declarationBlocks(declaration, node, top) const value = node.childForFieldName('value') if (value && CLASSES.has(value.type)) - return [classBlock(value, node)] + return [classBlock(node, value, value.childForFieldName('name'), 'public')] if (value && functionBody(value)) return [functionBlock(node, value, value.childForFieldName('name'), 'public')] return [leafBlock('statement', node)] @@ -120,18 +120,20 @@ function declarationBlocks(declaration: Node, outer: Node, top: boolean): CodeBl return [leafBlock('type', outer)] default: { if (CLASSES.has(declaration.type)) - return [classBlock(declaration, outer)] + return [classBlock(outer, declaration, declaration.childForFieldName('name'), declarationAccess(outer, top))] const body = declaration.childForFieldName('body') return [body?.type === 'statement_block' ? bodyBlock('statement', outer, body) : leafBlock('statement', outer)] } } } -/** 唯一声明值是带语句块主体的函数 → function;否则模块顶层的 const → const,其余 → variable。 */ +/** 唯一声明值是带语句块主体的函数 → function,是类表达式 → class;否则模块顶层的 const → const,其余 → variable。 */ function variableBlock(declaration: Node, outer: Node, top: boolean): CodeBlock { const declarators = named(declaration).filter(child => child.type === 'variable_declarator') const access = declarationAccess(outer, top) const value = declarators.length === 1 ? declarators[0].childForFieldName('value') : null + if (value && CLASSES.has(value.type)) + return classBlock(outer, value, declarators[0].childForFieldName('name'), access) if (value && functionBody(value)) return functionBlock(outer, value, declarators[0].childForFieldName('name'), access) const isConst = declaration.type === 'lexical_declaration' && declaration.childForFieldName('kind')?.type === 'const' @@ -276,29 +278,68 @@ function caseBlock(node: Node): CodeBlock { return { kind: 'if', ...compoundShape(node, bodyStart, items, (list, afterRow) => statements(list, false, afterRow)) } } -/** 类:成员前面的装饰器是类主体里的兄弟节点,算进成员头部,也交给成员当装饰器。 */ -function classBlock(declaration: Node, outer: Node): CodeBlock { +/** + * 类块:declaration 是类声明或类表达式,name 是它在源码里的名字(类名;赋给变量、类字段的类表达式取变量名、字段名), + * decorators 是类主体里排在它前面的装饰器(类字段的值是类表达式时)。 + */ +function classBlock(outer: Node, declaration: Node, name: Node | null, access: Access | undefined, decorators: Node[] = []): CodeBlock { const body = declaration.childForFieldName('body') + const facts = classFacts(outer, declaration, name, access, decorators) if (!body) - return leafBlock('class', outer) + return { kind: 'class', ...leafShape(outer), facts } + return { kind: 'class', ...compoundShape(outer, body.startIndex, named(body), classMembers), facts } +} + +/** 类成员:成员前面的装饰器是类主体里的兄弟节点,算进成员头部,也交给成员当装饰器。 */ +function classMembers(list: Node[], afterRow: number): CodeBlock[] { + let decorators: Node[] = [] + return listBlocks(list, afterRow, (member) => { + if (member.type === 'decorator') { + decorators.push(member) + return [] + } + const blocks = memberBlocks(member, decorators) + const [from] = decorators + decorators = [] + return from ? blocks.map((block, i) => i === 0 ? extendHeader(block, from) : block) : blocks + }, COMMENTS) +} + +/** + * 类的装饰器写在 export 前面时挂在 export 语句上,写在 class 前面时挂在类声明上; + * 父类、实现的接口在 class_heritage 里:TypeScript 分 extends_clause / implements_clause,JavaScript 直接是 extends 后面的表达式。 + */ +function classFacts(outer: Node, declaration: Node, name: Node | null, access: Access | undefined, decorators: Node[]): ClassFacts { + const holders = sameNode(outer, declaration) ? [declaration] : [outer, declaration] + const heritage = syntaxChildren(named(declaration).find(child => child.type === 'class_heritage')) return { - kind: 'class', - ...compoundShape(outer, body.startIndex, named(body), (list, afterRow) => { - let decorators: Node[] = [] - return listBlocks(list, afterRow, (member) => { - if (member.type === 'decorator') { - decorators.push(member) - return [] - } - const blocks = memberBlocks(member, decorators) - const [from] = decorators - decorators = [] - return from ? blocks.map((block, i) => i === 0 ? extendHeader(block, from) : block) : blocks - }, COMMENTS) + name: name ?? undefined, + access, + abstract: declaration.type === 'abstract_class_declaration', + decorators: [...decorators, ...holders.flatMap(holder => named(holder).filter(child => child.type === 'decorator'))], + typeParameters: syntaxChildren(declaration.childForFieldName('type_parameters')), + bases: heritage.flatMap((part) => { + if (part.type === 'extends_clause') + return extendsBases(part) + return part.type === 'implements_clause' ? [] : [{ value: part }] }), + implements: heritage.filter(part => part.type === 'implements_clause').flatMap(syntaxChildren), } } +/** extends 后面的一串:每个表达式是一项,跟在它后面的泛型实参 `` 归它。 */ +function extendsBases(clause: Node): BaseFacts[] { + const bases: BaseFacts[] = [] + for (const part of syntaxChildren(clause)) { + const last = bases.at(-1) + if (part.type === 'type_arguments' && last) + last.typeArguments = part + else + bases.push({ value: part }) + } + return bases +} + function memberBlocks(member: Node, decorators: Node[]): CodeBlock[] { switch (member.type) { case 'method_definition': @@ -309,6 +350,8 @@ function memberBlocks(member: Node, decorators: Node[]): CodeBlock[] { return [bodyBlock('statement', member, member.childForFieldName('body'))] default: { const value = member.childForFieldName('value') + if (value && CLASSES.has(value.type)) + return [classBlock(member, value, member.childForFieldName('name'), memberAccess(member), decorators)] if (value && functionBody(value)) return [functionBlock(member, value, member.childForFieldName('name'), memberAccess(member), decorators)] return [leafBlock('field', member)] @@ -385,7 +428,8 @@ function nameRole(leaf: Node): TokenRole { return 'function' if (parent.type === 'member_expression' && sameNode(parent.childForFieldName('property'), leaf) && isCallee(parent)) return 'function' - if (parent.type === 'new_expression') + // 父类 `extends Base`:TypeScript 在 extends_clause 里,JavaScript 直接在 class_heritage 里 + if (parent.type === 'new_expression' || parent.type === 'extends_clause' || parent.type === 'class_heritage') return 'type' if (parent.type === 'variable_declarator' && sameNode(parent.childForFieldName('name'), leaf) && functionBody(parent.childForFieldName('value'))) return 'function' diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index 16e6f13..f87fc02 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -174,7 +174,7 @@ describe('typeScript 中文直译', () => { 'k = [\'a\'] as const', 'for (const key in obj) {}', 'has = \'k\' in obj', - 'class A extends B implements C {}', + 'use(class A extends B implements C {})', 'interface I extends J, K {}', 'type F = (t: T) => void', 'type R = T extends string ? 1 : 2', @@ -191,7 +191,7 @@ describe('typeScript 中文直译', () => { expect(map[4]).toBe('k = [\'a\'] 断言为 常量') expect(map[5]).toBe('遍历 (常量 key 取自 obj)') expect(map[6]).toBe('has = \'k\' 在 obj') - expect(map[7]).toBe('类 A 继承 B 实现 C') + expect(map[7]).toBe('use(类 A 继承 B 实现 C {})') expect(map[8]).toBe('接口 I 继承 J, K') expect(map[9]).toBe('类型 F = (t: T) => 无') expect(map[10]).toBe('类型 R = T 符合 字符串 ? 1 : 2') @@ -244,7 +244,6 @@ describe('python 中文直译', () => { 28: '最终', 29: 'skipped.clear()', 30: '返回 found', - 33: '类 EnvLockResult', 34: 'total: 整数', 35: 'ok: 列表[字符串] = 空列表', 36: 'failed: 字典[字符串, 字符串] = 空字典', @@ -390,6 +389,7 @@ describe('属性表', () => { const sample = rowTexts(await describeAll(py, 'python', pythonZhTranslator, readFixture('sample.py'))) expect(sample[10]).toEqual(['名称: find_env_files', '说明: Return every .env file under root.', '访问级别: 公共', '异步: 否', '输入参数: 1 个', ' root | 字符串 | ', '输出参数: 列表[字符串]']) + expect(sample[33]).toEqual(['名称: EnvLockResult', '访问级别: 公共']) const port = rowTexts(await describeAll(py, 'python', pythonZhTranslator, '_default_port = 5680 if _role == "beat" else 5679')) expect(port[1]).toEqual(['名称: _default_port', '访问级别: 私有', '初始值: 如果 _role 等于 "beat" 则 5680 否则 5679']) }) @@ -555,10 +555,58 @@ describe('属性表', () => { 1: ['名称: 匿名', '访问级别: 公共', '异步: 是', '输入参数: 无', '输出参数: 未标注'], 2: ['名称: f', '访问级别: 私有', '异步: 否', '输入参数: 2 个', ' b? | 数值 | ', ' c | 未标注 | 默认 1', '输出参数: 无'], 3: ['名称: inner', '异步: 否', '输入参数: 无', '输出参数: 未标注'], + 5: ['名称: K', '访问级别: 私有', '抽象: 否'], 6: ['名称: load', '访问级别: 受保护', '异步: 否', '装饰器: @memo()', '输入参数: 无', '输出参数: x is Foo'], }) }) + it('类:说明、抽象(只有 TypeScript 有)、装饰器、类型参数、父类、实现的接口有才显示;父类的泛型实参、类型照常翻', async () => { + const tsRows = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('classes.ts.txt'))) + expect(tsRows[3]).toEqual([ + '名称: Repository', + '说明: 仓库基类。\n\n子类实现 `load`。', + '访问级别: 公共', + '抽象: 是', + '装饰器: @Injectable()', + '类型参数: K, T 限定为 Entity = Entity', + '父类: Base<字符串>', + '实现的接口: Loader, Disposable', + ]) + expect(tsRows[15]).toEqual(['名称: Query', '访问级别: 公共', '抽象: 否', '父类: QueryBase']) + expect(tsRows[20]).toEqual(['名称: 匿名', '访问级别: 公共', '抽象: 否']) + expect(tsRows[24]).toEqual(['名称: Mixed', '说明: 类表达式', '访问级别: 公共', '抽象: 否', '父类: mixin(A, B)']) + expect(tsRows[28]).toEqual(['名称: Local', '抽象: 否']) + 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[24]).toEqual(['名称: Meta', '访问级别: 公共', '装饰器: @dataclass(frozen=True)', '父类: Base, metaclass=ABCMeta']) + expect(pyRows[29]).toEqual(['名称: _Hidden', '访问级别: 私有']) + expect(pyRows[38]).toEqual(['名称: Local', '说明: 局部类。']) + }) + + it('类的着色:名称、父类名按类型,说明是 Markdown,抽象按 storage', async () => { + const tsRows = colors((await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('classes.ts.txt'))).get(3) ?? []) + expect(tsRows[0].value).toEqual([{ text: 'Repository', role: 'type' }]) + expect(tsRows[1].value).toEqual({ markdown: '仓库基类。\n\n子类实现 `load`。' }) + expect(tsRows[3].value).toEqual([{ text: '是', role: 'storage' }]) + expect(valueOf(tsRows, '父类')).toEqual([ + { text: 'Base', role: 'type' }, + { text: '<', role: 'plain' }, + { text: '字符串', role: 'type' }, + { text: '>', role: 'plain' }, + ]) + const pyRows = colors((await describeAll(py, 'python', pythonZhTranslator, readFixture('classes.py'))).get(24) ?? []) + expect(valueOf(pyRows, '父类')).toEqual([ + { text: 'Base', role: 'type' }, + { text: ', ', role: 'plain' }, + { text: 'metaclass', role: 'variable' }, + { text: '=', role: 'plain' }, + { text: 'ABCMeta', role: 'variable' }, + ]) + }) + it('变量:一条语句多个变量各占一组,没初始值写无,带语句块主体的回调只翻到主体开始之前', async () => { const source = [ 'let i = 0, n: number', @@ -675,12 +723,35 @@ describe('源码下标(跳到定义用)', () => { expect(marked(tsSource, tsRows.get(3))).toEqual(['名称: i', '名称: j']) }) + it('类:名称、类型参数、父类、实现的接口里的名字带下标;只有「类型参数」一行标出它讲的定义(这几个泛型参数),名称是块自己的名字、不标', async () => { + const source = 'export class Repo extends Base implements Loader, Disposable {}' + const at = (word: string): number => source.indexOf(word) + const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source) + const found = rows.get(1) ?? [] + expect(linked(valueOf(found, '名称'))).toEqual([['Repo', at('Repo')]]) + expect(linked(valueOf(found, '类型参数'))).toEqual([['K', at('K,')], ['T', at('T extends')], ['Entity', at('Entity')]]) + expect(linked(valueOf(found, '父类'))).toEqual([['Base', at('Base')], ['T', at('T> implements')]]) + 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 pyRows = await describeAll(py, 'python', pythonZhTranslator, pySource) + expect(linked(valueOf(pyRows.get(1), '父类'))).toEqual([ + ['errors', pySource.indexOf('errors')], + ['AppException', pySource.indexOf('AppException')], + ['metaclass', pySource.indexOf('metaclass')], + ['ABCMeta', pySource.indexOf('ABCMeta')], + ]) + }) + it('样例全文:带下标的只有照抄的名字和字符串,下标处的源码就是这段;变量名、函数名都带,译词、说明词都不带', async () => { const samples: [LanguageAdapter, string, Translator, string][] = [ [ts, 'typescript', typescriptZhTranslator, 'envLock.ts.txt'], [ts, 'typescript', typescriptZhTranslator, 'issueLock.ts.txt'], + [ts, 'typescript', typescriptZhTranslator, 'classes.ts.txt'], [py, 'python', pythonZhTranslator, 'sample.py'], [py, 'python', pythonZhTranslator, 'routes.py'], + [py, 'python', pythonZhTranslator, 'classes.py'], ] for (const [adapter, languageId, translator, name] of samples) { const source = readFixture(name) diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 07272e1..b20326c 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, Defines, FactBlock, FunctionFacts, ImportFacts, ImportItem, Markdown, Node, PreviewText, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, ClassFacts, Defines, FactBlock, FunctionFacts, ImportFacts, ImportItem, Markdown, Node, PreviewText, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -307,6 +307,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { switch (block.kind) { case 'function': return functionRows(block.facts, phrases) + case 'class': + return classRows(block.facts, phrases) case 'import': return importRows(block.facts, phrases) default: @@ -361,9 +363,9 @@ interface Phrases { verbatim: (node: Node) => Segment[] /** 导入来源:字符串字面量按字符串着色,模块名按类型着色。 */ module: (node: Node) => Segment[] - /** 源码里的一个名字原样照抄、按 role 着色(函数名称)。 */ + /** 源码里的一个名字原样照抄、按 role 着色(函数、类的名称)。 */ name: (node: Node, role: TokenRole) => Segment[] - /** 函数的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进的 Markdown 原文;没有文字时没有。 */ + /** 函数、类的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进的 Markdown 原文;没有文字时没有。 */ doc: (node: Node) => Markdown | undefined } @@ -414,16 +416,14 @@ function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segmen return [...item, { text: ' ', role: 'plain' }, { text: '作为', role: 'keyword' }, { text: ' ', role: 'plain' }, ...say.translated(alias)] } -/** 函数:名称 / 访问级别 / 异步 / 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */ +/** 函数:名称 / 说明 / 访问级别 / 异步 / 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { - const decorators = facts.decorators.map(say.verbatim) - const doc = facts.doc && say.doc(facts.doc) return [ { label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] }, - ...(doc ? [{ label: '说明', value: doc }] : []), + ...docRows(facts.doc, say), ...accessRows(facts.access), { label: '异步', value: yesNo(facts.async) }, - ...(decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators, '\n') }] : []), + ...decoratorRows(facts.decorators, say), { label: '输入参数', value: count(facts.parameters.length), @@ -440,6 +440,36 @@ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { ] } +/** + * 类:名称 / 说明 / 访问级别 / 抽象(只有分这个的语言才有)/ 装饰器 / 类型参数 / 父类 / 实现的接口, + * 说明及以后各行有才显示;类型参数、父类、实现的接口各是一行,多项用逗号隔开。类型参数那一行讲的是这几个泛型参数的定义。 + */ +function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] { + const listRow = (label: string, parts: Segment[][], defines: Defines = {}): PropertyRow[] => + parts.length > 0 ? [{ label, value: joined(parts, ', '), ...defines }] : [] + return [ + { label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] }, + ...docRows(facts.doc, say), + ...accessRows(facts.access), + ...(facts.abstract === undefined ? [] : [{ label: '抽象', value: yesNo(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.implements.map(say.translated)), + ] +} + +/** 函数、类的说明:有文字才有这一行。 */ +function docRows(doc: Node | undefined, say: Phrases): PropertyRow[] { + const markdown = doc && say.doc(doc) + return markdown ? [{ label: '说明', value: markdown }] : [] +} + +/** 装饰器:源码原样照抄,多个换行。 */ +function decoratorRows(decorators: Node[], say: Phrases): PropertyRow[] { + return decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators.map(say.verbatim), '\n') }] : [] +} + /** 导入:来源 / 仅类型(只有分这个的语言才有)/ 导入项(每项一行,有别名写成「名称 作为 别名」)。 */ function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] { const itemText = (item: ImportItem): Segment[] => { diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index e7d80ad..6cb0fe3 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -62,7 +62,7 @@ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?: ) } -/** 预览状态的内容:函数、导入、常量、变量块是属性表,其余是一行译文。 */ +/** 预览状态的内容:函数、类、导入、常量、变量块是属性表,其余是一行译文。 */ 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 1a9cfe6..2081af1 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -39,7 +39,7 @@ function PropertyItems({ rowId, items }: { rowId: string, items: PropertyItem[] ) } -/** 函数、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项下面展开子行。 */ +/** 函数、类、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项下面展开子行。 */ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: { blockId: string rows: PropertyRow[] diff --git a/vscode/webview-ui/src/codeReader/markdown.ts b/vscode/webview-ui/src/codeReader/markdown.ts index 8bda6d7..4ebb672 100644 --- a/vscode/webview-ui/src/codeReader/markdown.ts +++ b/vscode/webview-ui/src/codeReader/markdown.ts @@ -1,7 +1,7 @@ import MarkdownIt from 'markdown-it' /** - * 注释、函数说明的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。 + * 注释、函数和类说明的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。 * 源码里的 HTML 当文字显示;链接只认 http / https / mailto,别的写法照原文显示—— * webview 会拦下 的点击交给 VS Code 在外部打开。图片不渲染:webview 的 CSP 不放行外部图片。 */ diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index d15e2e0..c80285f 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -245,6 +245,28 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { expect(await at('line of')).toEqual({ plan: { id: '1.0', expand: ['1'] }, flashed: 'for (const line of lines) {' }) }) + it('类:类名是块自己的名字闪整块,泛型参数只闪「类型参数」一行;类默认折叠,跳到类里的方法、参数要先展开它', async () => { + const source = [ + 'export class Repo extends Base {', + ' load(id: T) {', + ' return id', + ' }', + '}', + ].join('\n') + const at = (needle: string): ReturnType => jump(ts, 'typescript', typescriptZhTranslator, source, needle) + expect(await at('Repo')).toEqual({ plan: { id: '0', expand: [] }, flashed: 'export class Repo extends Base {' }) + expect(await at('T>')).toEqual({ plan: { id: '0', row: '0/p3', expand: [] }, flashed: '类型参数: T' }) + expect(await at('load')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'load(id: T) {' }) + expect(await at('id: T')).toEqual({ plan: { id: '0.0', row: '0.0/p3/0', expand: ['0', '0.0/p3'] }, flashed: 'id | T | ' }) + const views = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, source, ts, typescriptZhTranslator)) + expect([...collapsedIds(views, {})].sort()).toEqual(['0', '0.0']) + + const pySource = ['class NotFound(AppException):', ' """找不到。"""', ' 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('run')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'def run(self):' }) + }) + it('typeScript:翻译插件没标 defines 时闪整块', async () => { const at = (needle: string): ReturnType => jump(ts, 'typescript', withoutDefines(typescriptZhTranslator), TS, needle) expect(await at('path: string')).toEqual({ plan: { id: '1', expand: ['1/p3'] }, flashed: 'export function load(path: string, { encoding }: Options): string {' }) diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index b36a43e..8467a0b 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -37,7 +37,7 @@ function shows(text: PreviewText, offset: number): boolean { /** * 块里哪儿是 offset 处的定义,从具体到笼统: - * ① 块自己的名字(函数名、只声明一个变量时的变量名)→ 整块; + * ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块; * ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个)→ 只闪它,子行连它所在的行一起展开; * ③ 块里只是显示着这个名字(一行译文里的循环变量、没填 defines 的翻译插件)→ 整块,名字在子行里时展开那一行。 * 不在这块里是 undefined。 diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 7363c19..4140834 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -20,7 +20,7 @@ export interface Segment { text: string, role: TokenRole, offset?: number } /** 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,按 Markdown 渲染成注释色的段落、列表、代码块 */ export interface Markdown { markdown: string } -/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数的说明) */ +/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明) */ export type PreviewText = Segment[] | Markdown /** 源码里一段的下标,end 为开区间 */ @@ -53,12 +53,12 @@ interface BlockViewBase { code: string /** 源码着色(拼起来等于 code) */ codeTokens: Segment[] - /** 块自己的名字(函数名、只声明一个变量时的变量名)在源码里的范围:跳到的定义就是它时闪整块 */ + /** 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围:跳到的定义就是它时闪整块 */ defines?: SourceRange children: BlockView[] } -/** 函数、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文 */ +/** 函数、类、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文 */ export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] }) export interface TranslatorOption { id: string, label: string } diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index e138ec9..ff52622 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -76,12 +76,12 @@ describe('webview 状态', () => { }) describe('眼下折叠着哪些', () => { - it('没点过:函数块(含方法、嵌套函数)折叠,其余块和属性行展开', () => { - expect([...collapsedIds(BLOCKS, {})].sort()).toEqual(['1', '1.0.0', '2.0']) + it('没点过:函数块(含方法、嵌套函数)、类块折叠,其余块和属性行展开', () => { + expect([...collapsedIds(BLOCKS, {})].sort()).toEqual(['1', '1.0.0', '2', '2.0']) }) - it('点过的按用户的:展开的函数保持展开,折叠的其他块、属性行保持折叠', () => { - const folds = { '1': false, '2': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false } - expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '2', '2.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 206eff4..0d02b5b 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(kind: BlockKind): boolean { - return kind === 'function' + return kind === 'function' || kind === 'class' } /** 眼下折叠着的块和属性行:用户点过的按他点的,没点过的按 foldedByDefault。 */