diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 86ef58e..e345685 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -22,7 +22,8 @@ | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | | 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)、类块和库规则认领的块默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 | | 函数、类的说明 | TypeScript 紧贴在函数、类前面的 JSDoc,Python 函数、类主体开头的 docstring 是它自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数、类块的头部里,「编辑」时和签名一起改 | -| 注释按 Markdown 显示 | 注释块和函数、类的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | +| 变量、字段的说明 | Python 模块、类主体里紧跟在一条赋值后面的字符串(属性 docstring,PEP 257 / Sphinx 的写法)是这个变量的说明:常量、变量块进属性表「说明」一行(紧跟名称),字段块排在一行译文下面;不再单独成块,它的源码行算在这一块里,「编辑」时和赋值一起改 | +| 注释按 Markdown 显示 | 注释块和函数、类、变量、字段的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | | 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | | 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色;看不见的(折叠、筛掉的)不自动展开,筛选栏上写「名字 · N 处」 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释;不做新建 / 重命名 / 删除 | @@ -72,9 +73,9 @@ - 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签(字取翻译插件给 `kind` 值起的名字,见「筛选」)、内容、右侧切换按钮。 - 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 - 内容两种状态,每块单独切: - - 预览:函数、类、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。 + - 预览:函数、类、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。 -- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里。 +- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里;Python 常量、变量、字段的属性 docstring 算在它那一块里(中间隔着的空行也是)。 - 库规则认领的块(数据表卡片等,见「库规则」):种类标签的字和颜色由规则给,内容是规则给的属性表;它的编辑范围是原来的头部加上并进来的成员,「编辑」时一起改。 - 折叠:函数、类块和库规则认领的块打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 - 顶部筛选栏:见「筛选」;翻译下拉框(多于一种翻译时才显示)也在栏里。 @@ -109,7 +110,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` 后面每个类型的译文,逗号隔开) | | 导入 | 来源(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 }`)/ 访问级别 / 类型(写了标注才有)/ 值(常量)或初始值(变量):初始化表达式的译文,没有写「无」;是字典 / 对象字面量时见下面「字典 / 对象字面量」。一条语句声明几个变量(`let a = 1, b = 2`)就重复几组 | +| 常量 / 变量 | 名称(解构声明写整个模式,如 `{ p, q }`)/ 说明(有说明才显示:Python 的属性 docstring,规则同函数;一条语句一个,排在第一个名称后面)/ 访问级别 / 类型(写了标注才有)/ 值(常量)或初始值(变量):初始化表达式的译文,没有写「无」;是字典 / 对象字面量时见下面「字典 / 对象字面量」。一条语句声明几个变量(`let a = 1, b = 2`)就重复几组 | 访问级别(公共 / 私有 / 受保护): @@ -150,7 +151,7 @@ ### 注释的 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)` 显示成 `!` 加链接。 @@ -280,6 +281,7 @@ export interface DeclaratorFacts { export interface VariableFacts { access?: Access + doc?: Node // 同 FunctionFacts.doc:Python 模块、类主体里紧跟在这条赋值后面的字符串节点(属性 docstring) declarators: DeclaratorFacts[] } @@ -296,7 +298,7 @@ export type BlockAttributes = Record export interface BlockShape { /** 这一块对应的语法树节点(整条语句)。 */ node: Node - /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句;函数、类的说明(doc)也算在里面。 */ + /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句;块自己的说明(facts.doc、TextBlock.doc)也算在里面。 */ header: SourceSpan children: CodeBlock[] /** 语言自己报的筛选属性:通用词表以外的(Rust unsafe: 'true'),或 facts 表达不了的通用属性值(Kotlin access: 'internal',盖过推出来的);kind 填了不算。 */ @@ -310,11 +312,14 @@ export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportF export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts } export type FactBlock = FunctionBlock | ClassBlock | ImportBlock | VariableBlock /** 带说明(facts.doc)的块。 */ -export type DocumentedBlock = FunctionBlock | ClassBlock +export type DocumentedBlock = FunctionBlock | ClassBlock | VariableBlock export type TextKind = Exclude /** 预览成一行译文的块(注释块是 Markdown 说明)。 */ -export interface TextBlock extends BlockShape { kind: TextKind } +export interface TextBlock extends BlockShape { + kind: TextKind + doc?: Node // 同 VariableFacts.doc:Python 字段后面紧跟的属性 docstring;预览在译文下面按 Markdown 显示(Translator.doc) +} export type CodeBlock = TextBlock | FactBlock @@ -527,7 +532,7 @@ export interface CodeReaderApi { | `server_default=表达式` | 数据库默认 表达式 | | `ForeignKey("t.c", ondelete=…, onupdate=…)` | 外键 t.c(删除时禁止);ondelete:RESTRICT 删除时禁止、CASCADE 级联删除、SET NULL 删除时置空、SET DEFAULT 删除时置默认值、NO ACTION 删除时不处理,onupdate 同理「更新时…」「级联更新」,别的写「删除时 X」;两个都有用「,」隔开 | -说明:`comment=` 的字符串(comment 色)。关系的目标取第一个位置参数(字符串去掉引号),没写就用 `Mapped[...]` 里的类型;说明写「对方字段 X」(`back_populates` / `backref`)、「单个对象」(`uselist=False`)、「中间表 X」(`secondary`),用 ` · ` 隔开。 +说明:`comment=` 的字符串(comment 色);没写 `comment=` 时用字段的属性 docstring(`context.doc` 给的原文,comment 色;它随字段并进卡片)。关系的目标取第一个位置参数(字符串去掉引号),没写就用 `Mapped[...]` 里的类型;说明写「对方字段 X」(`back_populates` / `backref`)、「单个对象」(`uselist=False`)、「中间表 X」(`secondary`),用 ` · ` 隔开。 并进卡片的子块:`__tablename__`、`__table_args__`、每个字段和关系的赋值;`__mapper_args__`、普通属性、方法照常是子块。 @@ -575,7 +580,7 @@ export interface CodeReaderApi { - `for` / `for…in` / `for…of` / `while` / `do` → loop。 - `try_statement` → try;`catch_clause` → 兄弟块 catch;`finally_clause` → 兄弟块 finally。 - `switch_statement` → if,每个 `case` / `default` → 子块 if。 -- `return_statement` → return;其余语句(含 `throw`)→ statement。 +- `return_statement` → return;其余语句(含 `throw`、单独一个字符串的语句:`'use strict'` 这类指令,TypeScript / JavaScript 没有拿字符串当说明的写法)→ statement。 - 表达式语句的参数里带语句块主体的函数(`useEffect(() => { … })`、`describe('x', () => { … })`)→ statement,子块来自那个函数主体,头部到主体开始为止。 ### Python @@ -583,8 +588,10 @@ 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`,头部往下延到字符串结尾(它和冒号之间的注释也在头部里),子块从它后面开始,不单独成块。 +- 字符串语句:单独一个普通字符串字面量的表达式语句(`string`,或隐式拼接的 `concatenated_string`,每段的前缀只有 r / u 或没有;f-string、t-string、字节串不算,照旧是 statement)。 +- 函数、类主体的第一条语句(注释不算)是字符串语句时,它是这个函数、类的 docstring:进 `facts.doc`,头部往下延到字符串结尾(它和冒号之间的注释也在头部里),子块从它后面开始,不单独成块。 +- 属性 docstring:模块、类主体里(条件、函数等语句块里的不算)紧跟在一条赋值后面的字符串语句是这个常量、变量、字段的说明:常量、变量进 `facts.doc`,字段进块的 `doc`,头部往下延到字符串结尾,不单独成块。「紧跟」同 JSDoc:中间可以隔空行和赋值的行尾注释,隔着别的注释、语句就不算;只挂一个,后面再来的字符串照下一条规则。 +- 其余字符串语句(模块开头的 docstring、写在语句中间当注释用的字符串)和 `comment` → comment。 - 赋值:模块顶层且变量名全大写 → const,其余 → variable。 - `if_statement` → if;`elif_clause` / `else_clause` → 兄弟块 else;`for` / `while` → loop(循环的 `else` → 兄弟块 else)。 - `try_statement` → try;`except_clause` → catch;`finally_clause` → finally。 @@ -601,7 +608,7 @@ export interface CodeReaderApi { - 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;right 是 `dictionary` 时 entries 是它的具名子节点(注释除外):`pair` 的 key / value(value 又是 `dictionary` 时连它的 entries)、`dictionary_splat` 里的表达式。 +- Python 变量:`assignment` 的 left / type / right,说明是紧跟在后面的属性 docstring 的字符串节点(见上面代码块树规则);right 是 `dictionary` 时 entries 是它的具名子节点(注释除外):`pair` 的 key / value(value 又是 `dictionary` 时连它的 entries)、`dictionary_splat` 里的表达式。 ## 中文模板直译 @@ -689,8 +696,11 @@ interface BlockViewBase { children: BlockView[] } -/** 属性表块(有 facts)和库规则认领的块只带 properties,其余块只带 translation(注释块是 `{ markdown }`);没有翻译插件时是空数组。 */ -type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] }) +/** + * 属性表块(有 facts)和库规则认领的块只带 properties,其余块只带 translation(注释块是 `{ markdown }`);没有翻译插件时是空数组。 + * 一行译文的块带说明(TextBlock.doc)时 doc 是翻译插件给的 Markdown 原文(Translator.doc),webview 排在译文下面。 + */ +type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] }) ``` - 扩展 → webview: diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index 38cddbb..85e001e 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -2,6 +2,7 @@ import type { BlockView } from './blockView' import type { LanguageAdapter, Translator } from './contract' import { describe, expect, it } from 'vitest' import { readBlockViews, toBlockViews } from './blockView' +import { replaceLines } from './editText' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' import { previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport' @@ -33,6 +34,7 @@ const SAMPLES: [LanguageAdapter, string, Translator, string][] = [ [py, 'python', pythonZhTranslator, 'sample.py'], [py, 'python', pythonZhTranslator, 'routes.py'], [py, 'python', pythonZhTranslator, 'classes.py'], + [py, 'python', pythonZhTranslator, 'attributeDocs.py'], ] const PROPERTY_KINDS = new Set(['function', 'class', 'import', 'const', 'variable']) @@ -122,6 +124,51 @@ describe('blockView', () => { expect(notFound.children[0]).toMatchObject({ kind: 'variable', startLine: 20, endLine: 20 }) }) + it('属性 docstring 算在常量、变量、字段块里:常量、变量的「说明」紧跟名称,字段的说明排在译文下面;编辑范围连着它', async () => { + const source = [ + 'CASE_DEPLOYMENT_UNIQUE_CONSTRAINT = "uq_case_library_deployment"', + '"""一 deployment 一案例的唯一约束名; 入库侧按它认"重复入库", 故与建表同源。"""', + '', + '', + 'class CaseStatus:', + ' ANALYZING = "analyzing"', + '', + ' """正在分析源 deployment."""', + ' title: str = ""', + ' """案例标题,', + ' 用户可编辑."""', + ].join('\n') + const list = await views(py, 'python', pythonZhTranslator, source) + const snapshot = flatten(list).map(view => ({ + id: view.id, + kind: view.kind, + lines: [view.startLine, view.endLine], + ...('properties' in view ? { rows: propertyLines(view.properties) } : { translation: previewText(view.translation), doc: view.doc }), + })) + expect(snapshot).toEqual([ + { + id: '0', + kind: 'const', + lines: [0, 1], + rows: [ + '名称: CASE_DEPLOYMENT_UNIQUE_CONSTRAINT', + '说明: 一 deployment 一案例的唯一约束名; 入库侧按它认"重复入库", 故与建表同源。', + '访问级别: 公共', + '值: "uq_case_library_deployment"', + ], + }, + { id: '1', kind: 'class', lines: [4, 4], rows: ['名称: CaseStatus', '访问级别: 公共'] }, + { id: '1.0', kind: 'variable', lines: [5, 7], rows: ['名称: ANALYZING', '说明: 正在分析源 deployment.', '访问级别: 公共', '初始值: "analyzing"'] }, + { id: '1.1', kind: 'field', lines: [8, 10], translation: 'title: 字符串 = ""', doc: { markdown: '案例标题,\n用户可编辑.' } }, + ]) + expect(byId(list, '0').code).toBe(source.split('\n').slice(0, 2).join('\n')) + expect(byId(list, '1.0').code).toBe('ANALYZING = "analyzing"\n\n"""正在分析源 deployment."""') + expect(byId(list, '1.1').code).toBe('title: str = ""\n"""案例标题,\n用户可编辑."""') + for (const view of flatten(list)) + expect(replaceLines(source, view.startLine, view.endLine, view.code), view.id).toBe(source) + expect(replaceLines(source, 8, 10, 'title: str = "x"\n"""新说明"""').split('\n').slice(8)).toEqual([' title: str = "x"', ' """新说明"""']) + }) + 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/) } }) diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 2d05d3d..5b1b847 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,4 +1,4 @@ -import type { BlockAttributes, CodeBlock, KindBadge, LanguageAdapter, LibraryRule, Node, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TokenRole, Translator } from './contract' +import type { BlockAttributes, CodeBlock, KindBadge, LanguageAdapter, LibraryRule, Markdown, Node, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TextBlock, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import { blockAttributes } from './attributes' import { commonIndent, splitLines } from './editText' @@ -27,8 +27,9 @@ interface BlockViewBase { /** * 发给 webview 的一块(可序列化):函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 + * 一行译文的块带说明(TextBlock.doc)时 doc 是翻译插件给的 Markdown 原文,预览排在译文下面。 */ -export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] }) +export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] }) interface Line { start: number, end: number } @@ -144,6 +145,11 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu codeTokens: colorLines(root, source, lines, startRow, endRow, indent, adapter.tokenRole), } } + /** 一行译文,带说明的块连着说明。 */ + const textView = (block: TextBlock): { translation: PreviewText, doc?: Markdown } => { + const doc = block.doc && translator?.doc(block.doc, document) + return { translation: translator ? translator.translate(block, document) : [], ...(doc && { doc }) } + } const build = (block: CodeBlock, id: string): BlockView => { const root = block.node.tree.rootNode const claim = active.length > 0 ? claimBlock(block, active, context, rules.report) : undefined @@ -169,9 +175,7 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu ...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }), attributes: blockAttributes(block), children: block.children.map((child, i) => build(child, `${id}.${i}`)), - ...('facts' in block - ? { properties: translator ? translator.describe(block, document) : [] } - : { translation: translator ? translator.translate(block, document) : [] }), + ...('facts' in block ? { properties: translator ? translator.describe(block, document) : [] } : textView(block)), } } return blocks.map((block, i) => build(block, String(i))) diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 7670046..9ba08fb 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -131,6 +131,11 @@ export interface DeclaratorFacts { export interface VariableFacts { /** 只有模块顶层(Python 还有类主体)的声明有。 */ access?: Access + /** + * 变量自己的说明,同 FunctionFacts.doc:Python 是模块、类主体里紧跟在这条赋值后面的字符串(属性 docstring,字符串节点; + * 中间可以隔空行和赋值行尾的注释)。它算在块的头部里,不再单独成一块。 + */ + doc?: Node /** 一条语句声明几个变量(`let a = 1, b = 2`)就有几项。 */ declarators: DeclaratorFacts[] } @@ -155,7 +160,7 @@ export interface BlockShape { node: Node /** * 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。 - * 函数、类的说明(facts.doc)也算在里面:JSDoc 往上延到注释开头,docstring 往下延到字符串结尾。 + * 块自己的说明(facts.doc、TextBlock.doc)也算在里面:JSDoc 往上延到注释开头,docstring、属性 docstring 往下延到字符串结尾。 */ header: SourceSpan children: CodeBlock[] @@ -174,12 +179,19 @@ export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', export type FactBlock = FunctionBlock | ClassBlock | ImportBlock | VariableBlock /** 带说明(facts.doc)的块。 */ -export type DocumentedBlock = FunctionBlock | ClassBlock +export type DocumentedBlock = FunctionBlock | ClassBlock | VariableBlock export type TextKind = Exclude /** 预览成一行译文的块。 */ -export interface TextBlock extends BlockShape { kind: TextKind } +export interface TextBlock extends BlockShape { + kind: TextKind + /** + * 块自己的说明,同 VariableFacts.doc:Python 字段(类主体里带注解的赋值)后面紧跟的属性 docstring(字符串节点)。 + * 算在块的头部里;预览在一行译文下面按 Markdown 显示(Translator.doc)。 + */ + doc?: Node +} export type CodeBlock = TextBlock | FactBlock diff --git a/vscode/src/codeReader/fixtures/attributeDocs.py b/vscode/src/codeReader/fixtures/attributeDocs.py new file mode 100644 index 0000000..9deda02 --- /dev/null +++ b/vscode/src/codeReader/fixtures/attributeDocs.py @@ -0,0 +1,33 @@ +"""属性 docstring 的样例:前两条常量节选自 luffy-agent-platform 的 case_library/models.py。""" + +CASE_DEPLOYMENT_UNIQUE_CONSTRAINT = "uq_case_library_deployment" +"""一 deployment 一案例的唯一约束名; 入库侧按它认"重复入库", 故与建表同源。""" + +_TIMEOUT = 30 # 秒 + +"""隔着空行、赋值行尾有注释也算。""" + + +class CaseStatus: + """案例状态.""" + + ANALYZING = "analyzing" + """正在分析源 deployment.""" + + title: str = "" + """案例标题, + 用户可编辑.""" # 行尾 + + # 隔着注释就不算 + """不是 title 的说明。""" + + +def run(items): + total = 0 + """先把总数清零。 + + - 再逐个加 + """ + f"{total} 不是注释" + b"bytes" + return total diff --git a/vscode/src/codeReader/languages/python.test.ts b/vscode/src/codeReader/languages/python.test.ts index 2b33d3c..74d7855 100644 --- a/vscode/src/codeReader/languages/python.test.ts +++ b/vscode/src/codeReader/languages/python.test.ts @@ -1,7 +1,7 @@ import type { IterationFacts, Node } from '../contract' import type { Outline } from '../testSupport' import { describe, expect, it } from 'vitest' -import { factsByLine, nodesToText, outline, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport' +import { factsByLine, nodesToText, outline, readFixture, runtime, walk, WASM_DIR, withBlocks } from '../testSupport' import { createPythonAdapter } from './python' const adapter = createPythonAdapter(WASM_DIR) @@ -79,15 +79,89 @@ describe('python 代码块树', () => { ]) }) - it('docstring 只认主体开头;不是开头的字符串表达式是语句', async () => { + it('docstring 只认主体开头;不是开头、也没挂到赋值上的字符串是注释块,f-string、字节串照旧是语句', async () => { const source = [ 'def f():', ' x = 1', ' """not a docstring"""', + ' "a" \'b\'', + ' f"{x}"', + ' b"x"', + ' "a" f"b"', ].join('\n') expect(await tree(source)).toEqual([ - { at: 1, kind: 'function', kids: [{ at: 2, kind: 'variable' }, { at: 3, kind: 'statement' }] }, + { at: 1, kind: 'function', kids: [ + { at: 2, kind: 'variable' }, + { at: 3, kind: 'comment' }, + { at: 4, kind: 'comment' }, + { at: 5, kind: 'statement' }, + { at: 6, kind: 'statement' }, + { at: 7, kind: 'statement' }, + ] }, ]) + expect(await tree('def f(): f"not a docstring"')).toEqual([{ at: 1, kind: 'function', kids: [{ at: 1, kind: 'statement' }] }]) + }) +}) + +describe('python 属性 docstring', () => { + /** 字段(一行译文的块)的说明,按 header 起始行(1 起)索引。 */ + function fieldDocs(source: string): Promise> { + return withBlocks(adapter, 'python', source, (blocks) => { + const byLine: Record = {} + walk(blocks, (block) => { + if (block.kind === 'field' && block.doc) + byLine[block.header.startRow + 1] = nodesToText(block.doc) + }) + return byLine + }) + } + + it('attributeDocs.py:模块、类主体里紧跟在赋值后面的字符串并进那块,头部延到字符串结尾;隔着注释、不跟在赋值后面的是注释块', async () => { + expect(await tree(readFixture('attributeDocs.py'))).toEqual([ + { at: 1, kind: 'comment' }, + { at: [3, 4], kind: 'const' }, + { at: [6, 8], kind: 'variable' }, + { at: [11, 12], kind: 'class', kids: [ + { at: [14, 15], kind: 'variable' }, + { at: [17, 19], kind: 'field' }, + { at: 21, kind: 'comment' }, + { at: 22, kind: 'comment' }, + ] }, + { at: 25, kind: 'function', kids: [ + { at: 26, kind: 'variable' }, + { at: [27, 30], kind: 'comment' }, + { at: 31, kind: 'statement' }, + { at: 32, kind: 'statement' }, + { at: 33, kind: 'return' }, + ] }, + ]) + }) + + it('进常量、变量的 facts.doc 和字段的 doc,是那个字符串节点;函数体里的赋值没有', async () => { + const source = readFixture('attributeDocs.py') + const found = await facts(source) + expect(found[3]).toMatchObject({ doc: '"""一 deployment 一案例的唯一约束名; 入库侧按它认"重复入库", 故与建表同源。"""' }) + expect(found[6]).toMatchObject({ doc: '"""隔着空行、赋值行尾有注释也算。"""' }) + expect(found[14]).toMatchObject({ doc: '"""正在分析源 deployment."""' }) + expect(found[26]).not.toHaveProperty('doc') + expect(await fieldDocs(source)).toEqual({ 17: '"""案例标题,\n 用户可编辑."""' }) + }) + + it('只挂到紧跟着的下一条语句:两个字符串只有第一个是说明;没有值的注解、拼接的字符串也算;条件分支里的赋值不算', async () => { + const source = [ + 'class K:', + ' a: int', + ' "A" "B"', + ' "又一个"', + 'if DEBUG:', + ' X = 1', + ' """不算"""', + ].join('\n') + expect(await tree(source)).toEqual([ + { at: 1, kind: 'class', kids: [{ at: [2, 3], kind: 'field' }, { at: 4, kind: 'comment' }] }, + { at: 5, kind: 'if', kids: [{ at: 6, kind: 'variable' }, { at: 7, kind: 'comment' }] }, + ]) + expect(await fieldDocs(source)).toEqual({ 2: '"A" "B"' }) }) }) diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index 1274fd0..d3684ac 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -9,11 +9,41 @@ 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']) +/** 字符串开头(string_start):普通字符串的前缀只有 r / u 或没有;f、t、b 开头的不是。 */ +const STRING_PREFIX = /^[ru]?["']/i // 只有 # 行注释;函数、类的说明是主体开头的 docstring,不是写在前面的注释 const COMMENTS: CommentRules = { isLine: () => true, isDoc: () => false } -function statements(items: Node[], scope: Scope, afterRow: number, docstring: boolean): CodeBlock[] { - return listBlocks(items, afterRow, (item, first) => statementBlocks(item, scope, docstring && first), COMMENTS) +function statements(items: Node[], scope: Scope, afterRow: number): CodeBlock[] { + const docs = attributeDocs(items, scope) + const attached = new Set([...docs.values()].map(statement => statement.startIndex)) + return listBlocks(items, afterRow, (item) => { + if (attached.has(item.startIndex)) + return [] + const docStatement = docs.get(item.startIndex) + return statementBlocks(item, scope, docStatement && docstringIn(docStatement)) + }, COMMENTS) +} + +/** + * 属性 docstring(PEP 257):模块、类主体里紧跟在一条赋值后面的字符串是这个变量的说明。返回赋值语句的起始下标 → 那条字符串语句。 + * 同 JSDoc 挂到下一条语句:中间可以隔空行和赋值的行尾注释,隔着别的注释、语句就不算。 + */ +function attributeDocs(items: Node[], scope: Scope): Map { + const docs = new Map() + if (scope === 'other') + return docs + items.forEach((item, i) => { + if (!assignmentIn(item)) + return + let next = i + 1 + while (items[next]?.type === 'comment' && items[next].startPosition.row === item.endPosition.row) + next++ + const statement = items[next] + if (statement && docstringIn(statement)) + docs.set(item.startIndex, statement) + }) + return docs } /** 冒号后面的主体:body / consequence 字段,except / finally 子句里是不带字段名的 block。 */ @@ -25,17 +55,18 @@ function bodyOf(node: Node): Node | null { return blocks.at(-1) ?? null } -function bodyShape(outer: Node, body: Node | null, scope: Scope, docstring = false): BlockShape { +function bodyShape(outer: Node, body: Node | null, scope: Scope): BlockShape { if (!body) return leafShape(outer) - return compoundShape(outer, body.startIndex, named(body), (list, afterRow) => statements(list, scope, afterRow, docstring)) + return compoundShape(outer, body.startIndex, named(body), (list, afterRow) => statements(list, scope, afterRow)) } function bodyBlock(kind: TextKind, outer: Node, body: Node | null, scope: Scope): CodeBlock { return { kind, ...bodyShape(outer, body, scope) } } -function statementBlocks(node: Node, scope: Scope, docstring: boolean): CodeBlock[] { +/** doc 是紧跟在这条赋值后面的属性 docstring(见 attributeDocs)。 */ +function statementBlocks(node: Node, scope: Scope, doc: Node | undefined): CodeBlock[] { switch (node.type) { case 'import_statement': case 'import_from_statement': @@ -64,7 +95,7 @@ function statementBlocks(node: Node, scope: Scope, docstring: boolean): CodeBloc case 'return_statement': return [leafBlock('return', node)] case 'expression_statement': - return [expressionBlock(node, scope, docstring)] + return [expressionBlock(node, scope, doc)] default: return [leafBlock('statement', node)] } @@ -87,18 +118,30 @@ function docstringOf(body: Node | null): Node | undefined { return first && docstringIn(first) } -/** 语句是单独一个字符串表达式(docstring 的写法)时返回那个字符串节点。 */ +/** + * 语句是单独一个字符串字面量(docstring 的写法,隐式拼接的几段也算)时返回那个字符串节点。 + * f-string、t-string、字节串不算:Python 不把它们当 docstring。 + */ function docstringIn(statement: Node): Node | undefined { if (statement.type !== 'expression_statement') return undefined const [expression, ...rest] = named(statement) - return rest.length === 0 && (expression?.type === 'string' || expression?.type === 'concatenated_string') ? expression : undefined + if (!expression || rest.length > 0) + return undefined + const parts = expression.type === 'concatenated_string' ? named(expression) : [expression] + return parts.every(isTextString) ? expression : undefined +} + +/** 普通字符串字面量:前缀只有 r / u(不分大小写)或没有。 */ +function isTextString(node: Node): boolean { + const start = node.type === 'string' ? node.firstChild : null + return start?.type === 'string_start' && STRING_PREFIX.test(start.text) } /** 函数、类的 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, scope, afterRow, false)) + return compoundShape(outer, doc.endIndex, rest, (list, afterRow) => statements(list, scope, afterRow)) } /** 单下划线开头的名字是私有(`_x`、`__x`),前后双下划线的特殊方法(`__init__`)是公共。 */ @@ -219,28 +262,40 @@ function isConstantTarget(target: Node | null): boolean { return false } -/** 赋值 → 字段 / 常量 / 变量;主体开头的字符串是 docstring;其余表达式是语句。 */ -function expressionBlock(node: Node, scope: Scope, docstring: boolean): CodeBlock { - if (docstring && docstringIn(node)) +/** + * 赋值 → 字段 / 常量 / 变量,doc 是它的属性 docstring:进字段的 doc、常量和变量的 facts.doc,头部往下延到字符串结尾; + * 单独一个字符串(模块开头的 docstring、写在语句中间当注释用的字符串)是注释;其余表达式是语句。 + */ +function expressionBlock(node: Node, scope: Scope, doc: Node | undefined): CodeBlock { + if (docstringIn(node)) return leafBlock('comment', node) - const expression = named(node)[0] - const left = expression?.type === 'assignment' ? expression.childForFieldName('left') : null + const expression = assignmentIn(node) + const left = expression?.childForFieldName('left') if (!expression || !left) return leafBlock('statement', node) + const leaf = leafShape(node) + const shape = doc ? { ...leaf, header: { ...leaf.header, endIndex: doc.endIndex, endRow: doc.endPosition.row } } : leaf const type = expression.childForFieldName('type') if (scope === 'class' && type) - return leafBlock('field', node) + return { kind: 'field', ...shape, doc } const value = expression.childForFieldName('right') return { kind: scope === 'module' && isConstantTarget(left) ? 'const' : 'variable', - ...leafShape(node), + ...shape, facts: { access: scopeAccess(left, scope), + doc, declarators: [{ name: left, type: type ?? undefined, value: value ?? undefined, entries: dictionaryEntries(value) }], }, } } +/** 语句是一条赋值(带不带注解、有没有值)时返回那个 assignment 节点。 */ +function assignmentIn(statement: Node): Node | undefined { + const expression = statement.type === 'expression_statement' ? named(statement)[0] : undefined + return expression?.type === 'assignment' ? expression : undefined +} + /** 字典字面量的各项:`键: 值`(值又是字典时连它的各项)、`**other`;值不是字典字面量时没有。 */ function dictionaryEntries(value: Node | null): EntryFacts[] | undefined { if (value?.type !== 'dictionary') @@ -428,7 +483,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, true), + toBlocks: root => statements(named(root), 'module', -1), tokenRole: pythonTokenRole, iteration: pythonIteration, } diff --git a/vscode/src/codeReader/languages/shared.ts b/vscode/src/codeReader/languages/shared.ts index 458dd33..f8daf67 100644 --- a/vscode/src/codeReader/languages/shared.ts +++ b/vscode/src/codeReader/languages/shared.ts @@ -104,19 +104,18 @@ function documented(block: B, doc: Node): B { * 文档注释和下一条语句之间只有空白、下一条语句是函数或类块时,它是这个函数、类的说明,不单独成块; * 中间隔着的装饰器(不出块的语句)不算隔开。 * afterRow 是这串语句之前的最后一行(头部结尾行),模块顶层传 -1; - * toBlocks 的 first 表示它是这串里第一条非注释语句(Python 认 docstring 用)。 + * toBlocks 可以返回空数组:这条语句不出块(装饰器、并进上一块的属性 docstring),和它同一行的注释照样算行尾注释。 */ export function listBlocks( items: Node[], afterRow: number, - toBlocks: (item: Node, first: boolean) => CodeBlock[], + toBlocks: (item: Node) => CodeBlock[], comments: CommentRules, ): CodeBlock[] { const out: CodeBlock[] = [] let lastRow = afterRow let lineRun: CodeBlock | undefined let doc: Node | undefined - let first = true const flushDoc = (): void => { if (doc) out.push(leafBlock('comment', doc)) @@ -143,7 +142,7 @@ export function listBlocks( } } else { - const blocks = toBlocks(item, first) + const blocks = toBlocks(item) const [head] = blocks if ((head?.kind === 'function' || head?.kind === 'class') && doc) { blocks[0] = documented(head, doc) @@ -153,7 +152,6 @@ export function listBlocks( flushDoc() } out.push(...blocks) - first = false lineRun = undefined } lastRow = item.endPosition.row diff --git a/vscode/src/codeReader/languages/typescript.test.ts b/vscode/src/codeReader/languages/typescript.test.ts index 00c274d..dba29e7 100644 --- a/vscode/src/codeReader/languages/typescript.test.ts +++ b/vscode/src/codeReader/languages/typescript.test.ts @@ -224,6 +224,23 @@ describe('typeScript 代码块树', () => { { at: 6, kind: 'statement' }, ]) }) + + it('单独一个字符串的语句(\'use strict\' 这类指令,赋值后面的字符串)照旧是语句,不挂到赋值上', async () => { + const source = [ + '\'use strict\'', + 'const x = 1', + '\'not a doc\'', + 'function f() {', + ' \'use client\'', + '}', + ].join('\n') + expect(await tree(source, 'javascript')).toEqual([ + { at: 1, kind: 'statement' }, + { at: 2, kind: 'const' }, + { at: 3, kind: 'statement' }, + { at: 4, kind: 'function', kids: [{ at: 5, kind: 'statement' }] }, + ]) + }) }) describe('typeScript 函数的 JSDoc', () => { diff --git a/vscode/src/codeReader/rules/sqlalchemy.test.ts b/vscode/src/codeReader/rules/sqlalchemy.test.ts index 86753d2..7ae2dae 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.test.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.test.ts @@ -209,6 +209,35 @@ describe('sqlAlchemy 库规则', () => { expect(user.children.map(child => [child.kind, child.startLine])).toEqual([['function', 20]]) }) + it('字段的属性 docstring:没写 comment= 时当说明,写了以 comment= 为准;它并进卡片,编辑范围到最后一个字段的说明为止', async () => { + const source = [ + 'from sqlalchemy.orm import Mapped, mapped_column', + '', + '', + 'class Case(Base):', + ' __tablename__ = "case_library"', + '', + ' title: Mapped[str] = mapped_column(String(120))', + ' """案例标题, 用户可编辑"""', + ' summary: Mapped[str] = mapped_column(Text, comment="案例描述")', + ' """不显示"""', + ' status = mapped_column(String(16))', + ' """案例状态"""', + '', + ' def label(self) -> str:', + ' return self.title', + ].join('\n') + const [, table] = await views(source) + expect(propertyLines(properties(table)).slice(-4)).toEqual([ + '字段: 3 个', + ' title | 字符串(120) | | 案例标题, 用户可编辑', + ' summary | 长文本 | | 案例描述', + ' status | 字符串(16) | 可空 | 案例状态', + ]) + expect([table.startLine, table.endLine]).toEqual([3, 11]) + expect(table.children.map(child => [child.kind, child.startLine])).toEqual([['function', 13]]) + }) + it('没导入 SQLAlchemy 的文件不认', async () => { const list = await views('class User(Base):\n __tablename__ = "users"\n') expect(list.map(view => view.kind)).toEqual(['class']) diff --git a/vscode/src/codeReader/rules/sqlalchemy.ts b/vscode/src/codeReader/rules/sqlalchemy.ts index f5fee74..c6c5fda 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.ts @@ -123,12 +123,13 @@ interface Call { keywords: Map } -/** 类主体里一条给名字赋值(或只写注解)的语句。 */ +/** 类主体里一条给名字赋值(或只写注解)的语句;doc 是它的属性 docstring(字段的 doc、变量的 facts.doc)。 */ interface Member { block: CodeBlock name: Node annotation?: Node value?: Node + doc?: Node } /** 一个字段:只写 `Mapped[...]` 注解的没有 call。 */ @@ -225,7 +226,13 @@ function memberOf(block: CodeBlock): Member | undefined { const name = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null if (!assignment || name?.type !== 'identifier') return undefined - return { block, name, annotation: assignment.childForFieldName('type') ?? undefined, value: assignment.childForFieldName('right') ?? undefined } + return { + block, + name, + annotation: assignment.childForFieldName('type') ?? undefined, + value: assignment.childForFieldName('right') ?? undefined, + doc: 'facts' in block ? block.facts.doc : block.doc, + } } /** 注解里 `type` 节点只包着一个类型时取里面那个。 */ @@ -424,7 +431,7 @@ function isNullable(call: Call | undefined, optional: boolean | undefined): bool return call?.name === 'Column' || optional === undefined ? true : optional } -/** 字段表的一行:字段 | 类型 | 约束 | 说明。讲的定义是字段名。 */ +/** 字段表的一行:字段 | 类型 | 约束 | 说明(`comment=`,没写时用字段的属性 docstring 原文)。讲的定义是字段名。 */ function columnItem({ member, call }: Column, env: Env): PropertyItem { const { words, context } = env const mapped = mappedType(member.annotation) @@ -452,12 +459,13 @@ function columnItem({ member, call }: Column, env: Env): PropertyItem { for (const foreign of calls.filter((found): found is Call => found?.name === 'ForeignKey')) constraints.push(foreignKey(foreign, env)) const comment = keywords.get('comment') + const doc = member.doc && context.doc(member.doc) return { cells: [ [name(member.name, 'variable')], typeArg ? sqlType(typeArg, env) : annotated ? context.translate(annotated.type) : [hint(words.unannotated)], joined(constraints, hint(' · ')), - comment ? textOr(comment, 'comment', env) : [], + comment ? textOr(comment, 'comment', env) : doc ? [{ text: doc.markdown, role: 'comment' }] : [], ], defines: range(member.name), } diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 6c6888a..25ae65e 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -616,7 +616,7 @@ function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] { ] } -/** 函数、类的说明:有文字才有这一行。 */ +/** 函数、类、常量、变量的说明:有文字才有这一行。 */ function docRows(doc: Node | undefined, say: Phrases): PropertyRow[] { const markdown = doc && say.doc(doc) return markdown ? [{ label: '说明', value: markdown }] : [] @@ -649,11 +649,15 @@ function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] { ] } -/** 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值,字典 / 对象字面量见 literalValue)。 */ +/** + * 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值,字典 / 对象字面量见 literalValue); + * 说明(有才显示)是整条语句的,排在第一个名称后面。 + */ function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] { const valueLabel = block.kind === 'const' ? '值' : '初始值' - return block.facts.declarators.flatMap(declarator => [ + return block.facts.declarators.flatMap((declarator, i) => [ { label: '名称', value: say.translated(declarator.name), ...defining(declarator.name) }, + ...(i === 0 ? docRows(block.facts.doc, say) : []), ...accessRows(block.facts.access), ...(declarator.type ? [{ label: '类型', value: say.translated(declarator.type) }] : []), { label: valueLabel, ...(declarator.value ? literalValue(declarator.value, declarator.entries, say) : { value: [hint('无')] }) }, diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 50879b5..fe10036 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -67,11 +67,16 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner ) } -/** 预览状态的内容:函数、类、导入、常量、变量块和库规则认领的块是属性表,其余是一行译文。 */ +/** 预览状态的内容:函数、类、导入、常量、变量块和库规则认领的块是属性表,其余是一行译文,带说明的译文下面接着说明。 */ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { if ('properties' in block) return - return + return ( + <> + + {block.doc && } + + ) } /** 一块:折叠箭头、种类标签、预览内容或源码、右边的「编辑 / 预览」切换。 */ diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 8d4065f..2939479 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -90,8 +90,11 @@ interface BlockViewBase { children: BlockView[] } -/** 函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文 */ -export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] }) +/** + * 函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文; + * 一行译文的块带说明时 doc 是说明的 Markdown 原文,排在译文下面 + */ +export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] }) export interface TranslatorOption { id: string, label: string }