diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 69cc663..63a01b1 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -19,7 +19,8 @@ | 多种翻译怎么选 | 预览顶部下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 | | 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 | | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | -| 折叠 | 有子块的块、属性表里带子行的行能折叠,默认全部展开 | +| 折叠 | 有子块的块、属性表里带子行的行能折叠;函数块(含方法、嵌套函数)默认收起主体、属性表照常显示,其余块和属性行默认展开;用户点过的按他点的记住 | +| 函数的说明 | TypeScript 紧贴在函数前面的 JSDoc、Python 函数主体开头的 docstring 是函数自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数块的头部里,「编辑」时和签名一起改 | | 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠;不做新建 / 重命名 / 删除 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | @@ -50,7 +51,8 @@ - 内容两种状态,每块单独切: - 预览:函数、导入、常量、变量块是属性表(见下节),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。 -- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。 +- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数的说明(JSDoc / docstring)也算在函数块的头部里。 +- 折叠:函数块打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 - 顶部工具栏:翻译下拉框(多于一种翻译时才显示)。 - 解析失败:显示错误原因,并提示用「编辑」查看源码。 @@ -60,7 +62,7 @@ | 种类 | 行 | | --- | --- | -| 函数 | 名称(函数色;匿名函数写「匿名」)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(`默认 ` + 译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | +| 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,保留换行和 `@param` 这类标签行;按 comment 着色,多行照原样换行、长了折行;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(`默认 ` + 译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | | 导入 | 来源(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`)就重复几组 | @@ -111,6 +113,7 @@ export interface ParameterFacts { export interface FunctionFacts { name?: Node // 匿名函数没有 + doc?: Node // 函数的说明:紧贴在前面的 JSDoc 注释节点(TS)、主体开头的 docstring 字符串节点(Python) access?: Access // 嵌套在语句块里的函数没有 async: boolean decorators: Node[] @@ -140,7 +143,7 @@ export interface VariableFacts { export interface BlockShape { /** 这一块对应的语法树节点(整条语句)。 */ node: Node - /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。 */ + /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句;函数的说明(doc)也算在里面。 */ header: SourceSpan children: CodeBlock[] } @@ -222,6 +225,7 @@ export interface CodeReaderApi { ### TypeScript / JavaScript - 顶层与语句块内的每条语句一块;连续的行注释合成一块注释。 +- JSDoc(以 `/**` 开头的块注释,`/**/` 不算)和下一条语句之间只有空白(空行也算,中间隔着装饰器也算)、那条语句是函数块时,它是这个函数的说明:进 `facts.doc`,头部往上延到注释开头,不单独成块。其余情况(后面不是函数块、中间隔着别的注释、在上一条语句的行尾)照旧是注释块。`//` 行注释和普通 `/* */` 块注释不变。 - `import_statement` → import。 - `export_statement` 包着声明 → 按声明定种类,节点取整个 `export_statement`(头部包含 `export`)。 - `function_declaration` / `generator_function_declaration` / `method_definition` → function,子块来自主体语句。 @@ -241,7 +245,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)和 `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。 @@ -251,10 +256,10 @@ export interface CodeReaderApi { ### 属性表信息(facts)从哪来 -- TypeScript 函数:`function_declaration` / `generator_function_declaration` / `function_signature`、类里的 `method_definition` / 方法签名、值是带语句块主体函数的变量和类字段、`export default` 的函数值。名称取声明名 / 变量名 / 字段名;参数来自 `formal_parameters`(`required_parameter` / `optional_parameter` 的 pattern、type、value,JavaScript 的 `assignment_pattern`),不带括号的单参数箭头函数取 `parameter`;返回类型取 `return_type` 里的类型;异步看有没有 `async`;类成员的装饰器是类主体里排在它前面的 `decorator` 兄弟节点。 +- 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 导入:`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` 子节点;装饰器是 `decorated_definition` 里的 `decorator`;参数 `identifier`、`typed_parameter`(名字是第一个具名子节点)、`default_parameter`、`typed_default_parameter`、`*args` / `**kwargs`,`*` 和 `/` 分隔符不算参数。 +- 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 导入:`import_statement` 每个 name 一个来源、导入项是整个模块;`import_from_statement` 的 module_name 和各 name(`aliased_import` 拆出 alias),`wildcard_import` 是全部;`future_import_statement` 的来源是 `__future__`。 - Python 变量:`assignment` 的 left / type / right。 @@ -306,12 +311,12 @@ Python 内置类型(仅注解里):str 字符串、int 整数、float 浮 | 源码 | 属性表 | | --- | --- | -| `export async function findEnvFiles(workspaceRoot: string): Promise {` | 名称 findEnvFiles / 访问级别 公共 / 异步 是 / 输入参数 1 个:`workspaceRoot \| 字符串 \|` / 输出参数 `Promise<字符串[]>` | +| `export async function findEnvFiles(workspaceRoot: string): Promise {`(envLock.ts 18–23 行,连同前面的 JSDoc) | 名称 findEnvFiles / 说明 `Scan the workspace folder rooted at …`(三行,照原样换行)/ 访问级别 公共 / 异步 是 / 输入参数 1 个:`workspaceRoot \| 字符串 \|` / 输出参数 `Promise<字符串[]>` | | `async function chmodAll(workspaceRoot: string, mode: number): Promise {` | 名称 chmodAll / 访问级别 私有 / 异步 是 / 输入参数 2 个:`workspaceRoot \| 字符串`、`mode \| 数值` / 输出参数 `Promise` | | `import { promises as fsp } from 'node:fs'` | 来源 `'node:fs'` / 仅类型 否 / 导入项 1 个:`promises 作为 fsp` | | `const ENV_PATTERN = '**/.env*'` | 名称 ENV_PATTERN / 访问级别 私有 / 值 `'**/.env*'` | | `const issue = issues.find(i => i.number === issueNumber)` | 名称 issue / 初始值 `issues.find(i => i.number 全等 issueNumber)` | -| `def find_env_files(root: str) -> list[str]:` | 名称 find_env_files / 访问级别 公共 / 异步 否 / 输入参数 1 个:`root \| 字符串` / 输出参数 `列表[字符串]` | +| `def find_env_files(root: str) -> list[str]:`(sample.py,连同下一行的 docstring) | 名称 find_env_files / 说明 `Return every .env file under root.` / 访问级别 公共 / 异步 否 / 输入参数 1 个:`root \| 字符串` / 输出参数 `列表[字符串]` | | `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` | @@ -345,7 +350,7 @@ type BlockView = BlockViewBase & ({ translation: Segment[] } | { properties: Pro - `{ type: 'ready' }` - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 -- 块的编辑 / 折叠状态在 webview 里按块 id 记(`vscode.setState`),属性行的折叠状态记在同一份折叠集合里,id 是 `<块 id>/p<行序号>`(例如 `6.1.0/p3`);文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 +- 块的编辑状态和用户的折叠选择在 webview 里按块 id 记(`vscode.setState`):`{ editing: string[], folds: Record, scroll? }`。`folds` 只记用户点过的(id → 折叠着没有),属性行也记在里面,id 是 `<块 id>/p<行序号>`(例如 `6.1.0/p3`);没记的按默认:函数块折叠,其余块和属性行展开。眼下折叠着哪些由「默认 + folds」算出来,界面、跳到定义的展开都只看这一份。跳到定义展开祖先块、属性行时也记进 `folds`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 ## 颜色 @@ -364,7 +369,7 @@ type BlockView = BlockViewBase & ({ translation: Segment[] } | { properties: Pro | hint | `--vscode-descriptionForeground` | 同左 | | plain | `--vscode-editor-foreground` | 同左 | -属性表里:访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,未标注、无、N 个、默认、默认导出、全部、整个模块、匿名按 hint。 +属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,未标注、无、N 个、默认、默认导出、全部、整个模块、匿名按 hint。 种类(标签字色 + 标签底 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`。 @@ -392,7 +397,9 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit ## 测试 -- 语言插件:`envLock.ts`、`issueLock.ts` 的代码块树(种类、header 行号、层级)与原型 `CODE_TREE` 一致;Python 样例覆盖 import / def / docstring / if-elif-else / for / try-except / with / 推导式。 +- 语言插件:`envLock.ts`、`issueLock.ts` 的代码块树(种类、header 行号、层级)与原型 `CODE_TREE` 一致,只是紧贴函数的 JSDoc 算进了函数头部;Python 样例覆盖 import / def / docstring / if-elif-else / for / try-except / with / 推导式;函数的说明:JSDoc 各种挂法(export、默认导出、方法隔着装饰器、函数值的变量、嵌套函数)和不挂的情况,docstring 连同它前面的注释进头部。 +- 块头部:各样例里块的头部行互不重叠;函数说明所在的行在函数块里,「编辑」写回原样不变。 +- 折叠:默认函数块折叠、其余展开;用户的选择盖过默认、重新加载后还在;旧版本存的状态不报错;跳到定义时默认折叠的函数也会展开。 - 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、导入、常量、变量块只带属性表,其余块只带译文。 diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index 919bce5..034bcfa 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -64,14 +64,50 @@ describe('blockView', () => { } }) + it.each(SAMPLES)('%#: 各块头部的行互不重叠,每行源码最多归一块编辑', async (adapter, languageId, translator, fixture) => { + const owner = new Map() + for (const view of flatten(await views(adapter, languageId, translator, readFixture(fixture)))) { + for (let line = view.startLine; line <= view.endLine; line++) { + expect(owner.get(line), `第 ${line} 行又归 ${view.id}`).toBeUndefined() + owner.set(line, view.id) + } + } + }) + + it('函数的说明算在函数块的头部里:编辑时和签名一起显示、一起改', async () => { + const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) + expect(byId(env, '5')).toMatchObject({ kind: 'function', startLine: 17, endLine: 22 }) + expect(byId(env, '5').code.split('\n')).toEqual([ + '/**', + ' * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`', + ' * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute', + ' * fs paths. Empty array if the workspace folder isn\'t currently open.', + ' */', + 'export async function findEnvFiles(workspaceRoot: string): Promise {', + ]) + expect(byId(env, '4')).toMatchObject({ kind: 'const', startLine: 15, endLine: 15 }) + + const routes = await views(py, 'python', pythonZhTranslator, readFixture('routes.py')) + const getUser = flatten(routes).find(view => view.kind === 'function' && view.startLine === 28) + expect(getUser).toMatchObject({ startLine: 28, endLine: 37 }) + expect(getUser?.code.split('\n').slice(-5)).toEqual([ + ') -> UserDetailResponse:', + ' """获取用户详情。', + '', + ' 管理员可查看任意用户,普通用户只能查看自己。', + ' """', + ]) + expect(getUser?.children[0]).toMatchObject({ kind: 'comment', startLine: 38, endLine: 38 }) + }) + it('id 是块在树里的位置,行号 0 起,多行块去掉共同缩进', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) - expect(byId(env, '6.1.0')).toMatchObject({ kind: 'return', startLine: 25, endLine: 25, code: 'return []' }) - expect(byId(env, '6.1')).toMatchObject({ kind: 'if', startLine: 24, code: 'if (!folder)' }) - expect(byId(env, '8.2.1')).toMatchObject({ kind: 'catch', startLine: 45, code: 'catch (err) {' }) + expect(byId(env, '5.1.0')).toMatchObject({ kind: 'return', startLine: 25, endLine: 25, code: 'return []' }) + expect(byId(env, '5.1')).toMatchObject({ kind: 'if', startLine: 24, code: 'if (!folder)' }) + expect(byId(env, '7.2.1')).toMatchObject({ kind: 'catch', startLine: 45, code: 'catch (err) {' }) const issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) - expect(byId(issue, '3.4')).toMatchObject({ + expect(byId(issue, '2.4')).toMatchObject({ kind: 'return', startLine: 19, endLine: 23, @@ -81,8 +117,8 @@ describe('blockView', () => { it('源码着色来自语法树:每个叶子一段,空白和标点归 plain', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) - const tokens = byId(env, '6.3').codeTokens - expect(byId(env, '6.3').code).toBe('const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)') + const tokens = byId(env, '5.3').codeTokens + expect(byId(env, '5.3').code).toBe('const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)') expect(tokens.slice(0, 7)).toEqual([ { text: 'const', role: 'storage' }, { text: ' ', role: 'plain' }, @@ -97,14 +133,15 @@ describe('blockView', () => { // 中文注释之后的行:语法树下标按字符串下标算,不按 UTF-8 字节 const issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) - expect(byId(issue, '3').codeTokens.slice(0, 5)).toEqual([ + expect(byId(issue, '2').codeTokens.slice(0, 7)).toEqual([ + { text: '/**\n * 纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行,\n * 与 webview 的 `isIssueLocked` 保持同一语义。\n */', role: 'comment' }, + { text: '\n', role: 'plain' }, { text: 'export', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: 'function', role: 'storage' }, { text: ' ', role: 'plain' }, { text: 'evaluateLockedReason', role: 'function' }, ]) - expect(byId(issue, '2').codeTokens).toEqual([{ text: byId(issue, '2').code, role: 'comment' }]) const sample = await views(py, 'python', pythonZhTranslator, readFixture('sample.py')) const signature = byId(sample, '5').codeTokens diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 42ae3cc..0b6cd55 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -26,6 +26,11 @@ export interface ParameterFacts { export interface FunctionFacts { /** 匿名函数(`export default function () {}`)没有。 */ name?: Node + /** + * 函数自己的说明:TypeScript 是紧贴在声明前面的 JSDoc 注释(以 `/**` 开头),Python 是主体开头的 docstring(字符串节点)。 + * 它算在块的头部里,不再单独成一块注释。 + */ + doc?: Node /** 嵌套在函数体、条件等语句块里的函数没有。 */ access?: Access async: boolean @@ -67,7 +72,10 @@ export interface VariableFacts { export interface BlockShape { /** 这一块对应的语法树节点(整条语句)。 */ node: Node - /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。 */ + /** + * 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。 + * 函数的说明(FunctionFacts.doc)也算在里面:JSDoc 往上延到注释开头,docstring 往下延到字符串结尾。 + */ header: SourceSpan children: CodeBlock[] } diff --git a/vscode/src/codeReader/editText.test.ts b/vscode/src/codeReader/editText.test.ts index ef9f006..9f79987 100644 --- a/vscode/src/codeReader/editText.test.ts +++ b/vscode/src/codeReader/editText.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { toBlockViews } from './blockView' import { blockReplacement, commonIndent, reindentBlock, replaceLines } from './editText' +import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' import { readFixture, WASM_DIR, withBlocks } from './testSupport' @@ -33,10 +34,14 @@ describe('行替换', () => { expect(reindentBlock([' x'], 'a\n b')).toBe(' a\n b') }) - it('把预览里的 code 原样写回,文件不变(去缩进与补缩进互逆)', async () => { - const adapter = createTypeScriptAdapter(WASM_DIR) - const source = readFixture('envLock.ts.txt') - const views = await withBlocks(adapter, 'typescript', source, blocks => toBlockViews(blocks, source, adapter, undefined)) + it.each([ + [createTypeScriptAdapter(WASM_DIR), 'typescript', 'envLock.ts.txt'], + [createTypeScriptAdapter(WASM_DIR), 'typescript', 'issueLock.ts.txt'], + [createPythonAdapter(WASM_DIR), 'python', 'sample.py'], + [createPythonAdapter(WASM_DIR), 'python', 'routes.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 { return [view, ...view.children.flatMap(flat)] }) diff --git a/vscode/src/codeReader/languages/python.test.ts b/vscode/src/codeReader/languages/python.test.ts index 8c119de..d52e3d5 100644 --- a/vscode/src/codeReader/languages/python.test.ts +++ b/vscode/src/codeReader/languages/python.test.ts @@ -20,8 +20,7 @@ const SAMPLE: Outline[] = [ { at: 4, kind: 'import' }, { at: 6, kind: 'const' }, { at: 7, kind: 'variable' }, - { at: 10, kind: 'function', kids: [ - { at: 11, kind: 'comment' }, + { at: [10, 11], kind: 'function', kids: [ { at: 12, kind: 'comment' }, { at: 13, kind: 'variable' }, { at: 14, kind: 'if', kids: [{ at: 15, kind: 'return' }] }, @@ -91,6 +90,42 @@ describe('python 代码块树', () => { }) }) +describe('python 函数的 docstring', () => { + const source = [ + 'class K:', + ' """类说明"""', + ' def run(self): # 行尾', + ' # 先说一句', + ' """跑一遍。', + '', + ' 细节。', + ' """ # 行尾', + ' # 真正的注释', + ' return 1', + 'def g(): "单行"', + ].join('\n') + + it('算进函数头部、不单独成块,它前面的注释也在头部里;后面的注释照旧成块;类的 docstring 照旧是注释块', async () => { + expect(await tree(source)).toEqual([ + { at: 1, kind: 'class', kids: [ + { at: 2, kind: 'comment' }, + { at: [3, 8], kind: 'function', kids: [ + { at: 9, kind: 'comment' }, + { at: 10, kind: 'return' }, + ] }, + ] }, + { at: 11, kind: 'function' }, + ]) + }) + + it('进 facts.doc:是那个字符串节点;没有 docstring 的函数没有', async () => { + const found = await facts(source) + expect(found[3]).toMatchObject({ name: 'run', doc: '"""跑一遍。\n\n 细节。\n """' }) + expect(found[11]).toMatchObject({ name: 'g', doc: '"单行"' }) + expect(await facts('def f():\n x = 1\n """not a docstring"""')).not.toHaveProperty('1.doc') + }) +}) + describe('python 属性表信息(facts)', () => { it('sample.py:import / from import、模块和类里的赋值、函数和方法', async () => { const sample = await facts(readFixture('sample.py')) @@ -99,6 +134,7 @@ describe('python 属性表信息(facts)', () => { expect(sample[6]).toEqual({ access: 'public', declarators: [{ name: 'MAX_DEPTH', value: '8' }] }) expect(sample[10]).toEqual({ name: 'find_env_files', + doc: '"""Return every .env file under root."""', access: 'public', async: false, decorators: [], @@ -128,6 +164,7 @@ describe('python 属性表信息(facts)', () => { }) expect(routes[29]).toEqual({ name: 'get_user', + doc: '"""获取用户详情。\n\n 管理员可查看任意用户,普通用户只能查看自己。\n """', access: 'public', async: true, decorators: ['@router.get("/{user_id}", response_model=UserDetailResponse)'], diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index 2844ecb..492eb8a 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -1,4 +1,5 @@ import type { Access, BlockShape, 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' @@ -8,9 +9,11 @@ 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,不是写在前面的注释 +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), () => true) + return listBlocks(items, afterRow, (item, first) => statementBlocks(item, scope, docstring && first), COMMENTS) } /** 冒号后面的主体:body / consequence 字段,except / finally 子句里是不带字段名的 block。 */ @@ -69,15 +72,37 @@ 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, definition.childForFieldName('body'), 'class', true) } + return { kind: 'class', ...bodyShape(outer, body, 'class', true) } + const doc = docstringOf(body) return { kind: 'function', - ...bodyShape(outer, definition.childForFieldName('body'), 'other', true), - facts: functionFacts(definition, outer, scope), + ...(body && doc ? documentedShape(outer, body, doc) : bodyShape(outer, body, 'other')), + facts: functionFacts(definition, outer, scope, doc), } } +/** 主体开头的 docstring:第一条语句(注释不算)是单独一个字符串表达式时,返回那个字符串节点。 */ +function docstringOf(body: Node | null): Node | undefined { + const first = named(body).find(child => child.type !== 'comment') + return first && docstringIn(first) +} + +/** 语句是单独一个字符串表达式(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 +} + +/** 函数的 docstring 是它自己的说明:算进头部(连同它前面的注释),子块从它后面开始。 */ +function documentedShape(outer: Node, body: Node, doc: Node): BlockShape { + const rest = named(body).filter(item => item.startIndex >= doc.endIndex) + return compoundShape(outer, doc.endIndex, rest, (list, afterRow) => statements(list, 'other', afterRow, false)) +} + /** 单下划线开头的名字是私有(`_x`、`__x`),前后双下划线的特殊方法(`__init__`)是公共。 */ function nameAccess(name: string): Access { const dunder = name.length > 4 && name.startsWith('__') && name.endsWith('__') @@ -108,10 +133,11 @@ function scopeAccess(target: Node, scope: Scope): Access | undefined { return names.every(name => nameAccess(name.text) === 'private') ? 'private' : 'public' } -function functionFacts(definition: Node, outer: Node, scope: Scope): FunctionFacts { +function functionFacts(definition: Node, outer: Node, scope: Scope, doc: Node | undefined): FunctionFacts { const name = definition.childForFieldName('name') return { name: name ?? undefined, + doc, access: name ? scopeAccess(name, scope) : undefined, async: children(definition).some(child => child.type === 'async'), decorators: named(outer).filter(child => child.type === 'decorator'), @@ -177,9 +203,9 @@ function isConstantTarget(target: Node | null): boolean { /** 赋值 → 字段 / 常量 / 变量;主体开头的字符串是 docstring;其余表达式是语句。 */ function expressionBlock(node: Node, scope: Scope, docstring: boolean): CodeBlock { - const [expression, ...rest] = named(node) - if (docstring && rest.length === 0 && (expression?.type === 'string' || expression?.type === 'concatenated_string')) + if (docstring && docstringIn(node)) return leafBlock('comment', node) + const expression = named(node)[0] const left = expression?.type === 'assignment' ? expression.childForFieldName('left') : null if (!expression || !left) return leafBlock('statement', node) diff --git a/vscode/src/codeReader/languages/shared.ts b/vscode/src/codeReader/languages/shared.ts index 215d70b..70e8a83 100644 --- a/vscode/src/codeReader/languages/shared.ts +++ b/vscode/src/codeReader/languages/shared.ts @@ -1,4 +1,4 @@ -import type { BlockShape, CodeBlock, Node, SourceSpan, TextKind } from '../contract' +import type { BlockShape, CodeBlock, FunctionBlock, Node, SourceSpan, TextKind } from '../contract' /** 语言插件共用的建树工具:取子节点、算头部、把一串语句切成块。 */ @@ -85,9 +85,24 @@ export function compoundShape(outer: Node, bodyStart: number, items: Node[], lis return { node: outer, header, children: list([...between, ...items], header.endRow) } } +/** 语言怎么认注释。 */ +export interface CommentRules { + /** 行注释:相邻几行合成一块。 */ + isLine: (comment: Node) => boolean + /** 文档注释(JSDoc):紧贴在函数块前面时是这个函数的说明,见 FunctionFacts.doc。 */ + isDoc: (comment: Node) => boolean +} + +/** 函数块带上说明:说明进 facts.doc,头部往上延到它开头。 */ +function documented(block: FunctionBlock, doc: Node): FunctionBlock { + return { ...extendHeader(block, doc), facts: { ...block.facts, doc } } +} + /** * 一串语句 → 块。注释单独成块,相邻行的行注释合成一块; * 和前一条语句(或头部)同一行的注释是行尾注释,跟着那一行显示,不单独成块。 + * 文档注释和下一条语句之间只有空白、下一条语句是函数块时,它是这个函数的说明,不单独成块; + * 中间隔着的装饰器(不出块的语句)不算隔开。 * afterRow 是这串语句之前的最后一行(头部结尾行),模块顶层传 -1; * toBlocks 的 first 表示它是这串里第一条非注释语句(Python 认 docstring 用)。 */ @@ -95,20 +110,32 @@ export function listBlocks( items: Node[], afterRow: number, toBlocks: (item: Node, first: boolean) => CodeBlock[], - isLineComment: (comment: Node) => boolean, + comments: CommentRules, ): CodeBlock[] { const out: CodeBlock[] = [] let lastRow = afterRow let lineRun: CodeBlock | undefined + let doc: Node | undefined let first = true - for (const item of items) { + const flushDoc = (): void => { + if (doc) + out.push(leafBlock('comment', doc)) + doc = undefined + } + items.forEach((item, i) => { if (item.type === 'comment') { if (item.startPosition.row === lastRow) - continue - const line = isLineComment(item) + return + flushDoc() + const line = comments.isLine(item) + const next = items[i + 1] if (line && lineRun && item.startPosition.row === lineRun.header.endRow + 1) { lineRun.header = { ...lineRun.header, endIndex: item.endIndex, endRow: item.endPosition.row } } + else if (comments.isDoc(item) && next && next.type !== 'comment' && sameNode(item.nextSibling, next)) { + doc = item + lineRun = undefined + } else { const block = leafBlock('comment', item) out.push(block) @@ -116,12 +143,22 @@ export function listBlocks( } } else { - out.push(...toBlocks(item, first)) + const blocks = toBlocks(item, first) + const [head] = blocks + if (head?.kind === 'function' && doc) { + blocks[0] = documented(head, doc) + doc = undefined + } + else if (head) { + flushDoc() + } + out.push(...blocks) first = false lineRun = undefined } lastRow = item.endPosition.row - } + }) + flushDoc() return out } diff --git a/vscode/src/codeReader/languages/typescript.test.ts b/vscode/src/codeReader/languages/typescript.test.ts index a842202..bf14313 100644 --- a/vscode/src/codeReader/languages/typescript.test.ts +++ b/vscode/src/codeReader/languages/typescript.test.ts @@ -13,15 +13,14 @@ function facts(source: string, languageId = 'typescript'): Promise { }) }) +describe('typeScript 函数的 JSDoc', () => { + const source = [ + '/** 普通 */', + 'function a() {}', + '/** 隔一个空行也算紧贴 */', + '', + 'export function b() {}', + '/** 默认导出 */', + 'export default function () {}', + '/** 函数值的变量 */', + 'const c = () => {', + ' /** 嵌套 */', + ' function d() {}', + '}', + 'class K {', + ' /** 方法,隔着装饰器 */', + ' @memo()', + ' m() {}', + ' /** 字段 */', + ' x = 1', + '}', + '/** 常量 */', + 'const y = 1', + '/* 普通块注释 */', + 'function e() {}', + '// 行注释', + 'function f() {}', + '/** 中间隔着行注释 */', + '// 行注释', + 'function g() {}', + 'foo() /** 行尾 */', + 'function h() {}', + '/**/', + 'function i() {}', + ].join('\n') + + it('紧贴在函数块前面的算进函数头部、不单独成块;前面不是函数块、没紧贴、不是 JSDoc 的照旧是注释块', async () => { + expect(await tree(source)).toEqual([ + { at: [1, 2], kind: 'function' }, + { at: [3, 5], kind: 'function' }, + { at: [6, 7], kind: 'function' }, + { at: [8, 9], kind: 'function', kids: [{ at: [10, 11], kind: 'function' }] }, + { at: 13, kind: 'class', kids: [ + { at: [14, 16], kind: 'function' }, + { at: 17, kind: 'comment' }, + { at: 18, kind: 'field' }, + ] }, + { at: 20, kind: 'comment' }, + { at: 21, kind: 'const' }, + { at: 22, kind: 'comment' }, + { at: 23, kind: 'function' }, + { at: 24, kind: 'comment' }, + { at: 25, kind: 'function' }, + { at: 26, kind: 'comment' }, + { at: 27, kind: 'comment' }, + { at: 28, kind: 'function' }, + { at: 29, kind: 'statement' }, + { at: 30, kind: 'function' }, + { at: 31, kind: 'comment' }, + { at: 32, kind: 'function' }, + ]) + }) + + it('进 facts.doc:是那个注释节点;没挂上说明的函数没有', async () => { + const found = await facts(source) + expect(found[1]).toMatchObject({ name: 'a', doc: '/** 普通 */' }) + expect(found[3]).toMatchObject({ name: 'b', doc: '/** 隔一个空行也算紧贴 */' }) + expect(found[6]).toMatchObject({ doc: '/** 默认导出 */' }) + expect(found[8]).toMatchObject({ name: 'c', doc: '/** 函数值的变量 */' }) + expect(found[10]).toMatchObject({ name: 'd', doc: '/** 嵌套 */' }) + expect(found[14]).toMatchObject({ name: 'm', doc: '/** 方法,隔着装饰器 */', decorators: ['@memo()'] }) + for (const line of [23, 25, 28, 30, 32]) + expect(found[line], `L${line}`).not.toHaveProperty('doc') + }) +}) + describe('typeScript 属性表信息(facts)', () => { it('envLock.ts:导入、常量、函数、局部变量', async () => { const env = await facts(readFixture('envLock.ts.txt')) expect(env[12]).toEqual({ sources: ['\'node:fs\''], typeOnly: false, items: [{ kind: 'named', name: 'promises', alias: 'fsp' }] }) expect(env[13]).toEqual({ sources: ['\'vscode\''], typeOnly: false, items: [{ kind: 'named', name: 'RelativePattern' }, { kind: 'named', name: 'workspace' }] }) expect(env[15]).toEqual({ access: 'private', declarators: [{ name: 'ENV_PATTERN', value: '\'**/.env*\'' }] }) - expect(env[23]).toEqual({ + expect(env[18]).toEqual({ name: 'findEnvFiles', + doc: [ + '/**', + ' * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`', + ' * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute', + ' * fs paths. Empty array if the workspace folder isn\'t currently open.', + ' */', + ].join('\n'), access: 'public', async: true, decorators: [], diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index 993f4cb..9463091 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -1,4 +1,5 @@ import type { Access, BlockShape, 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' @@ -13,8 +14,10 @@ const FUNCTION_VALUES = new Set(['arrow_function', 'function_expression', 'funct const LOOPS = new Set(['for_statement', 'for_in_statement', 'while_statement', 'do_statement']) const CLASSES = new Set(['class_declaration', 'abstract_class_declaration', 'class']) -function isLineComment(comment: Node): boolean { - return comment.text.startsWith('//') +const COMMENTS: CommentRules = { + isLine: comment => comment.text.startsWith('//'), + // `/**/` 是空的块注释,不是 JSDoc + isDoc: comment => comment.text.startsWith('/**') && !comment.text.startsWith('/**/'), } /** 带语句块主体的函数值(箭头函数 / 函数表达式)的主体。 */ @@ -39,7 +42,7 @@ function callbackBody(expression: Node | null | undefined): Node | null { } function statements(items: Node[], top: boolean, afterRow: number): CodeBlock[] { - return listBlocks(items, afterRow, item => statementBlocks(item, top), isLineComment) + return listBlocks(items, afterRow, item => statementBlocks(item, top), COMMENTS) } /** 主体是语句块时子块来自它的语句,否则主体本身就是唯一的子语句(`if (x) return`)。 */ @@ -291,7 +294,7 @@ function classBlock(declaration: Node, outer: Node): CodeBlock { const [from] = decorators decorators = [] return from ? blocks.map((block, i) => i === 0 ? extendHeader(block, from) : block) : blocks - }, isLineComment) + }, COMMENTS) }), } } @@ -321,7 +324,7 @@ function membersBlock(declaration: Node, outer: Node): CodeBlock { return { kind: 'type', ...compoundShape(outer, body.startIndex, named(body), (list, afterRow) => - listBlocks(list, afterRow, member => [leafBlock('field', member)], isLineComment)), + listBlocks(list, afterRow, member => [leafBlock('field', member)], COMMENTS)), } } diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index b3105d7..eac32f0 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -90,7 +90,6 @@ describe('typeScript 中文直译', () => { expect(text(env.get(26)?.segments)).toBe('返回 空数组') expect(text(env.get(41)?.segments)).toBe('遍历 (常量 f 取自 files)') expect(text(env.get(35)?.segments)).toBe('failed: { path: 字符串, reason: 字符串 }[]') - expect(text(env.get(53)?.segments)).toBe('Set every `.env*` file in the workspace to 0o444 (read-only).') expect(text(issue.get(20)?.segments)).toBe('返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }') }) @@ -103,7 +102,6 @@ describe('typeScript 中文直译', () => { expect(env[47]).toBe('result.failed.push({ path: f, reason: 如果 err 属于 Error 则 err.message 否则 String(err) })') expect(env[1]).toBe('.env file lock / unlock helpers. Recursively scans the main workspace folder for `.env*` files (skipping `node_modules`, `.git`, `.claude`) and chmods them to 0o444 (locked) or 0o644 (unlocked) in bulk. Backs the toolbar Lock toggle in `PanelHeader`. No persistent watcher — re-scan happens on every toggle, so newly created `.env` files are only picked up the next time the user clicks the button.') expect(issue[5]).toBe('prerequisiteNumber?: 数值') - expect(issue[9]).toBe('纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行, 与 webview 的 `isIssueLocked` 保持同一语义。') expect(issue[15]).toBe('如果 (非 issue 或者 issue.prerequisite 全等 未定义)') }) @@ -212,7 +210,6 @@ describe('python 中文直译', () => { const map = await translateAll(py, 'python', pythonZhTranslator, readFixture('sample.py')) expect(texts(map)).toEqual({ 1: 'Scan a workspace for .env files.', - 11: 'Return every .env file under root.', 12: '跳过备份文件', 14: '如果 非 found', 15: '返回 空列表', @@ -327,8 +324,9 @@ describe('属性表', () => { const env = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))) const issue = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))) const routes = rowTexts(await describeAll(py, 'python', pythonZhTranslator, readFixture('routes.py'))) - expect(env[23]).toEqual([ + expect(env[18]).toEqual([ '名称: findEnvFiles', + '说明: Scan the workspace folder rooted at `workspaceRoot` for all `.env*`\nfiles, skipping `node_modules`, `.git`, and `.claude`. Returns absolute\nfs paths. Empty array if the workspace folder isn\'t currently open.', '访问级别: 公共', '异步: 是', '输入参数: 1 个', @@ -360,6 +358,7 @@ describe('属性表', () => { ]) expect(routes[29]).toEqual([ '名称: get_user', + '说明: 获取用户详情。\n\n管理员可查看任意用户,普通用户只能查看自己。', '访问级别: 公共', '异步: 是', '装饰器: @router.get("/{user_id}", response_model=UserDetailResponse)', @@ -373,21 +372,22 @@ describe('属性表', () => { expect(routes[118]).toEqual(['名称: payload', '初始值: 等待 file.read()']) const sample = rowTexts(await describeAll(py, 'python', pythonZhTranslator, readFixture('sample.py'))) - expect(sample[10]).toEqual(['名称: find_env_files', '访问级别: 公共', '异步: 否', '输入参数: 1 个', ' root | 字符串 | ', '输出参数: 列表[字符串]']) + expect(sample[10]).toEqual(['名称: find_env_files', '说明: Return every .env file under root.', '访问级别: 公共', '异步: 否', '输入参数: 1 个', ' root | 字符串 | ', '输出参数: 列表[字符串]']) 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']) }) it('属性名是纯文字;值按词性着色,说明词(未标注、N 个、默认)用 hint', async () => { const env = await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) - const rows = colors(env.get(23) ?? []) - expect(rows.map(row => row.label)).toEqual(['名称', '访问级别', '异步', '输入参数', '输出参数']) + const rows = colors(env.get(18) ?? []) + expect(rows.map(row => row.label)).toEqual(['名称', '说明', '访问级别', '异步', '输入参数', '输出参数']) expect(rows[0].value).toEqual([{ text: 'findEnvFiles', role: 'function' }]) - expect(rows[1].value).toEqual([{ text: '公共', role: 'keyword' }]) - expect(rows[2].value).toEqual([{ text: '是', role: 'storage' }]) - expect(rows[3].value).toEqual([{ text: '1 个', role: 'hint' }]) - expect(rows[3].items).toEqual([{ cells: [[{ text: 'workspaceRoot', role: 'variable' }], [{ text: '字符串', role: 'type' }], []] }]) - expect(rows[4].value).toEqual([ + expect(rows[1].value).toEqual([{ text: valueOf(env.get(18), '说明')[0].text, role: 'comment' }]) + expect(rows[2].value).toEqual([{ text: '公共', role: 'keyword' }]) + expect(rows[3].value).toEqual([{ text: '是', role: 'storage' }]) + expect(rows[4].value).toEqual([{ text: '1 个', role: 'hint' }]) + expect(rows[4].items).toEqual([{ cells: [[{ text: 'workspaceRoot', role: 'variable' }], [{ text: '字符串', role: 'type' }], []] }]) + expect(rows[5].value).toEqual([ { text: 'Promise', role: 'type' }, { text: '<', role: 'plain' }, { text: '字符串', role: 'type' }, @@ -410,6 +410,43 @@ describe('属性表', () => { ]) }) + it('说明:紧跟名称,去掉注释符号、引号、共同缩进和首尾空行,保留换行和 @ 标签行;没有文字不显示', async () => { + const tsSource = [ + 'class K {', + ' /**', + ' * 读配置。', + ' *', + ' * 例子:', + ' * load(\'a\')', + ' * @param path 文件路径', + ' * @returns 配置', + ' */', + ' load(path: string) {}', + ' /** */', + ' empty() {}', + '}', + ].join('\n') + const tsRows = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource)) + expect(tsRows[2].slice(0, 3)).toEqual(['名称: load', '说明: 读配置。\n\n例子:\n load(\'a\')\n@param path 文件路径\n@returns 配置', '访问级别: 公共']) + expect(tsRows[11].slice(0, 2)).toEqual(['名称: empty', '访问级别: 公共']) + + const pySource = [ + 'def f():', + ' \'\'\'', + ' 第一段', + ' 缩进的一行', + '', + ' 第二段', + ' \'\'\'', + 'def g():', + ' """"""', + ' return 1', + ].join('\n') + const pyRows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, pySource)) + expect(pyRows[1].slice(0, 3)).toEqual(['名称: f', '说明: 第一段\n 缩进的一行\n\n第二段', '访问级别: 公共']) + expect(pyRows[8].slice(0, 2)).toEqual(['名称: g', '访问级别: 公共']) + }) + it('导入:默认导出、全部、整个模块、副作用导入;Python 没有「仅类型」', async () => { const tsSource = [ 'import d, { b as c } from \'y\'', diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 7941946..1756117 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,5 @@ import type { Access, FactBlock, FunctionFacts, ImportFacts, ImportItem, Node, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ export interface ContextualWord { @@ -100,6 +101,16 @@ function oneLine(text: string): string { return text.replace(/\s*\n\s*/g, ' ') } +/** + * 说明文字排版:去掉行尾空白、首尾空行和共同缩进,保留换行(段落之间的空行也留着)。 + * 第一行紧跟在引号 / 注释符号后面,本来就没有缩进,不参与算共同缩进(同 PEP 257 的做法)。 + */ +function docText(raw: string): string { + const [first = '', ...rest] = splitLines(raw).map(line => line.trimEnd()) + const indent = commonIndent(rest).length + return [first.trim(), ...rest.map(line => line.slice(indent))].join('\n').replace(/^\n+|\n+$/g, '') +} + /** 模板槽位引用的子节点;注释是语法之外的节点,不占具名子节点序号。 */ function slotChild(node: Node, slot: Exclude): Node | null | undefined { if ('field' in slot) @@ -287,6 +298,10 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { // 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样 module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node))] : verbatim(node, source, 'type'), name: (node, role) => [segment(node.text, role, offsetOf(node))], + doc: (node) => { + const text = docText(table.commentText(node)) + return text ? [{ text, role: 'comment' }] : [] + }, } switch (block.kind) { case 'function': @@ -347,6 +362,8 @@ interface Phrases { module: (node: Node) => Segment[] /** 源码里的一个名字原样照抄、按 role 着色(函数名称)。 */ name: (node: Node, role: TokenRole) => Segment[] + /** 函数的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进,保留换行,按注释着色;没有文字时是空的。 */ + doc: (node: Node) => Segment[] } const ACCESS_ZH: Record = { public: '公共', private: '私有', protected: '受保护' } @@ -380,8 +397,10 @@ function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segmen /** 函数:名称 / 访问级别 / 异步 / 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */ 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.length > 0 ? [{ label: '说明', value: doc }] : []), ...accessRows(facts.access), { label: '异步', value: yesNo(facts.async) }, ...(decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators, '\n') }] : []), diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 7caf257..8e4f564 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -9,17 +9,18 @@ import { BlockLevel } from './BlockTree' import { useDefinitionLinks } from './useDefinitionLinks' import { useReveal } from './useReveal' import { useScrollPosition } from './useScrollPosition' +import { collapsedIds } from './viewState' import { onMessage, postMessage, viewState } from './vscode' -type BlockModes = Pick +type BlockModes = Pick function toggled(ids: string[], id: string): string[] { return ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id] } function savedBlockModes(): BlockModes { - const { editing, collapsed } = viewState.get() - return { editing, collapsed } + const { editing, folds } = viewState.get() + return { editing, folds } } export function App(): ReactElement | null { @@ -28,10 +29,16 @@ export function App(): ReactElement | null { const [focusId, setFocusId] = useState(null) /** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */ const [reveal, setReveal] = useState(null) - const expand = useCallback((ids: string[]) => setView(prev => ({ ...prev, collapsed: prev.collapsed.filter(id => !ids.includes(id)) })), []) + const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds]) + /** 记下这几块、属性行折叠还是展开,从此不再按默认 */ + const setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({ + ...prev, + folds: { ...prev.folds, ...Object.fromEntries(ids.map(id => [id, folded])) }, + })), []) + const expand = useCallback((ids: string[]) => setFolded(ids, false), [setFolded]) const revealed = useCallback(() => setReveal(null), []) useScrollPosition(doc) - useReveal(doc, reveal, view.collapsed, expand, revealed) + useReveal(doc, reveal, collapsed, expand, revealed) useDefinitionLinks(doc?.version) useEffect(() => { @@ -58,15 +65,15 @@ export function App(): ReactElement | null { const tree = useMemo((): BlockTreeState | null => doc && { version: doc.version, editing: new Set(view.editing), - collapsed: new Set(view.collapsed), + collapsed, focusId, onFocused, onToggleEdit: (id) => { setFocusId(view.editing.includes(id) ? null : id) setView(prev => ({ ...prev, editing: toggled(prev.editing, id) })) }, - onToggleFold: id => setView(prev => ({ ...prev, collapsed: toggled(prev.collapsed, id) })), - }, [doc, view, focusId, onFocused]) + onToggleFold: id => setFolded([id], !collapsed.has(id)), + }, [doc, view, collapsed, focusId, onFocused, setFolded]) if (!doc || !tree) return null diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 0450134..b800655 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -29,7 +29,7 @@ const KIND_LABEL: Record = { export interface BlockTreeState { version: number editing: ReadonlySet - /** 折叠的块 id 和属性行 id(propertyRowId),默认全部展开 */ + /** 眼下折叠着的块 id 和属性行 id(propertyRowId):用户的选择叠在默认上,见 collapsedIds */ collapsed: ReadonlySet /** 刚点了「编辑」、要把光标放进去的块 */ focusId: string | null diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index 5509036..187f82c 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -1,6 +1,7 @@ import type { BlockKind, BlockView, PropertyRow, Segment } from './types' import { describe, expect, it } from 'vitest' import { revealPlan, scrollTopToShow } from './reveal' +import { collapsedIds } from './viewState' function name(text: string, offset: number): Segment { return { text, role: 'variable', offset } @@ -63,6 +64,14 @@ describe('跳到定义:滚到哪块、展开哪些', () => { expect(revealPlan(BLOCKS, 20, 999)).toEqual({ id: '2', expand: [] }) }) + it('函数块默认折叠:目标在函数体里时,要展开的块里有这个函数;展开过一次就记住,不再挡着', () => { + const plan = revealPlan(BLOCKS, 4, 102) + const hidden = (folds: Record): string[] => plan?.expand.filter(id => collapsedIds(BLOCKS, folds).has(id)) ?? [] + expect(hidden({})).toEqual(['1']) + expect(hidden({ 1: false })).toEqual([]) + expect(hidden({ '1': false, '1.0': true })).toEqual(['1.0']) + }) + it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => { const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])] expect(revealPlan(sameLine, 1, 999)).toEqual({ id: '0', expand: [] }) diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index 7245072..02d919c 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -30,7 +30,7 @@ function flash(el: HTMLElement): void { export function useReveal( doc: DocumentMessage | null, reveal: Reveal | null, - collapsed: readonly string[], + collapsed: ReadonlySet, expand: (ids: string[]) => void, done: () => void, ): void { @@ -45,7 +45,7 @@ export function useReveal( return } const plan = revealPlan(doc.blocks, reveal.line, reveal.definition) - const hidden = plan?.expand.filter(id => collapsed.includes(id)) ?? [] + const hidden = plan?.expand.filter(id => collapsed.has(id)) ?? [] if (hidden.length > 0) { expand(hidden) return diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index 74e634f..e138ec9 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -1,38 +1,65 @@ -import type { ViewState, ViewStateStorage } from './viewState' +import type { BlockKind, BlockView } from './types' +import type { ViewStateStorage } from './viewState' import { describe, expect, it } from 'vitest' -import { createViewStateStore, propertyRowId } from './viewState' +import { collapsedIds, createViewStateStore, propertyRowId } from './viewState' -/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回 */ -function memoryStorage(): ViewStateStorage { - let saved: string | undefined +/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回;也能塞进旧版本存的东西 */ +function memoryStorage(initial?: unknown): ViewStateStorage { + let saved = initial === undefined ? undefined : JSON.stringify(initial) return { - getState: () => saved === undefined ? undefined : JSON.parse(saved) as ViewState, + getState: () => saved === undefined ? undefined : JSON.parse(saved) as unknown, setState: (state) => { saved = JSON.stringify(state) }, } } +function block(id: string, kind: BlockKind, children: BlockView[] = []): BlockView { + return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], children, translation: [] } +} + +/** + * 0 import + * 1 function + * 1.0 if + * 1.0.0 function(嵌套函数) + * 2 class + * 2.0 function(方法) + */ +const BLOCKS = [ + block('0', 'import'), + block('1', 'function', [block('1.0', 'if', [block('1.0.0', 'function', [block('1.0.0.0', 'return')])])]), + block('2', 'class', [block('2.0', 'function', [block('2.0.0', 'return')])]), +] + describe('webview 状态', () => { - it('第一次打开:没有编辑中、折叠的块,也没有滚动位置', () => { - expect(createViewStateStore(memoryStorage()).get()).toEqual({ editing: [], collapsed: [] }) + it('第一次打开:没有编辑中的块、没点过折叠,也没有滚动位置', () => { + expect(createViewStateStore(memoryStorage()).get()).toEqual({ editing: [], folds: {} }) }) it('块状态和滚动位置各自更新,互不覆盖', () => { const storage = memoryStorage() const store = createViewStateStore(storage) - store.update({ editing: ['2'], collapsed: ['6.1'] }) + store.update({ editing: ['2'], folds: { 6.1: true } }) store.update({ scroll: { line: 12, offset: 30, scrollTop: 480 } }) - store.update({ editing: [], collapsed: ['6.1'] }) - expect(storage.getState()).toEqual({ editing: [], collapsed: ['6.1'], scroll: { line: 12, offset: 30, scrollTop: 480 } }) + store.update({ editing: [], folds: { 6.1: true } }) + expect(storage.getState()).toEqual({ editing: [], folds: { 6.1: true }, scroll: { line: 12, offset: 30, scrollTop: 480 } }) }) it('页面重新加载后取回上次存的全部状态', () => { const storage = memoryStorage() const store = createViewStateStore(storage) - store.update({ collapsed: ['3'] }) + store.update({ folds: { 3: true, 4: false } }) store.update({ scroll: { line: 40, offset: -6, scrollTop: 1210 } }) - expect(createViewStateStore(storage).get()).toEqual({ editing: [], collapsed: ['3'], scroll: { line: 40, offset: -6, scrollTop: 1210 } }) + expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: { 3: true, 4: false }, scroll: { line: 40, offset: -6, scrollTop: 1210 } }) + }) + + it('旧版本存的、形状不对的项当没存过,认得的项照旧取回', () => { + expect(createViewStateStore(memoryStorage({ editing: ['1'], collapsed: ['2'] })).get()).toEqual({ editing: ['1'], folds: {} }) + expect(createViewStateStore(memoryStorage({ editing: 'x', folds: ['2'], scroll: { line: 'a' } })).get()).toEqual({ editing: [], folds: {} }) + expect(createViewStateStore(memoryStorage({ editing: [1, '2'], folds: { 1: true, 2: 'yes' } })).get()).toEqual({ editing: ['2'], folds: { 1: true } }) + expect(createViewStateStore(memoryStorage(42)).get()).toEqual({ editing: [], folds: {} }) + expect(createViewStateStore(memoryStorage(null)).get()).toEqual({ editing: [], folds: {} }) }) it('属性行的折叠 id 是块 id 加行序号,不会和块 id 撞上', () => { @@ -41,9 +68,20 @@ describe('webview 状态', () => { expect(propertyRowId('6.1', 0)).not.toBe(propertyRowId('6', 10)) }) - it('属性行和块的折叠状态存在同一份 collapsed 里,页面重新加载后都还在', () => { + it('属性行和块的折叠选择存在同一份 folds 里,页面重新加载后都还在', () => { const storage = memoryStorage() - createViewStateStore(storage).update({ collapsed: ['6.1', propertyRowId('6.1.0', 3)] }) - expect(createViewStateStore(storage).get().collapsed).toEqual(['6.1', '6.1.0/p3']) + createViewStateStore(storage).update({ folds: { 6.1: false, [propertyRowId('6.1.0', 3)]: true } }) + expect(createViewStateStore(storage).get().folds).toEqual({ '6.1': false, '6.1.0/p3': true }) + }) +}) + +describe('眼下折叠着哪些', () => { + it('没点过:函数块(含方法、嵌套函数)折叠,其余块和属性行展开', () => { + expect([...collapsedIds(BLOCKS, {})].sort()).toEqual(['1', '1.0.0', '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']) }) }) diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index ff2c725..d500fb6 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -1,17 +1,20 @@ import type { ScrollAnchor } from './scrollPosition' +import type { BlockKind, BlockView } from './types' /** * 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、 - * 哪些块和属性行折叠了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行 id 见 propertyRowId),以及滚到了哪。 + * 用户把哪些块和属性行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行 id 见 propertyRowId),以及滚到了哪。 */ export interface ViewState { editing: string[] - collapsed: string[] + /** 用户自己点过折叠箭头的块和属性行:id → 折叠着没有。没点过的按默认,见 collapsedIds */ + folds: Record scroll?: ScrollAnchor } export interface ViewStateStorage { - getState: () => ViewState | undefined + /** 存下来的东西可能是旧版本的形状,读的时候逐项核对 */ + getState: () => unknown setState: (state: ViewState) => void } @@ -23,7 +26,7 @@ export interface ViewStateStore { /** 一个 webview 只有一份状态:块的状态和滚动位置由不同地方各自更新,存的时候合在一起。 */ export function createViewStateStore(storage: ViewStateStorage): ViewStateStore { - let current: ViewState = storage.getState() ?? { editing: [], collapsed: [] } + let current = readViewState(storage.getState()) return { get: () => current, update: (patch) => { @@ -33,8 +36,45 @@ export function createViewStateStore(storage: ViewStateStorage): ViewStateStore } } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isScrollAnchor(value: unknown): value is ScrollAnchor { + return isRecord(value) && [value.line, value.offset, value.scrollTop].every(field => typeof field === 'number') +} + +/** 存下来的状态逐项核对,形状不对的项(包括旧版本存的)当没存过。 */ +function readViewState(saved: unknown): ViewState { + const state = isRecord(saved) ? saved : {} + const editing = Array.isArray(state.editing) ? state.editing.filter((id): id is string => typeof id === 'string') : [] + const folds = isRecord(state.folds) + ? Object.fromEntries(Object.entries(state.folds).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean')) + : {} + return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds } +} + +/** 块默认折叠吗:函数块收起主体(属性表照常显示),其余块展开。属性行默认都展开。 */ +export function foldedByDefault(kind: BlockKind): boolean { + return kind === 'function' +} + +/** 眼下折叠着的块和属性行:用户点过的按他点的,没点过的按 foldedByDefault。 */ +export function collapsedIds(blocks: readonly BlockView[], folds: Readonly>): Set { + const collapsed = new Set(Object.keys(folds).filter(id => folds[id])) + const visit = (list: readonly BlockView[]): void => { + for (const block of list) { + if (folds[block.id] === undefined && foldedByDefault(block.kind)) + collapsed.add(block.id) + visit(block.children) + } + } + visit(blocks) + return collapsed +} + /** - * 属性表里带子行的那一行(输入参数、导入项)的折叠 id,和块 id 放在同一个 collapsed 里: + * 属性表里带子行的那一行(输入参数、导入项)的折叠 id,和块 id 放在同一份 folds 里: * `<块 id>/p<行序号>`,例如 '6.1.0/p3'。块 id 只由数字和点组成,带 '/' 的不会和任何块 id 撞上。 */ export function propertyRowId(blockId: string, rowIndex: number): string {