diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index fd3adfa..dc840de 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -22,6 +22,7 @@ | 折叠 | 有子块的块能折叠,默认全部展开 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠;不做新建 / 重命名 / 删除 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | +| 函数、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | ## 用户可见行为 @@ -46,12 +47,30 @@ - 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签、内容、右侧切换按钮。 - 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 - 内容两种状态,每块单独切: - - 预览:译文,按词着色,等宽字体(中文部分用系统中文字体回退)。 + - 预览:函数、导入、常量、变量块是属性表(见下节),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。 - 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。 - 顶部工具栏:翻译下拉框(多于一种翻译时才显示)。 - 解析失败:显示错误原因,并提示用「编辑」查看源码。 +### 属性表(函数、导入、常量、变量块) + +卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」下面展开子行,属性名前带一个向下的箭头(固定展开,不单独折叠);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`)。输入参数的子行分名称 / 类型 / 默认值三列对齐,前两列宽 150px / 220px,编辑区窄时按 28% / 38% 收,默认值列占剩下的。 + +| 种类 | 行 | +| --- | --- | +| 函数 | 名称(函数色;匿名函数写「匿名」)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`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`)就重复几组 | + +访问级别(公共 / 私有 / 受保护): + +- TypeScript 模块里的声明:`export` 了是公共,顶层没 `export` 是私有;嵌套在函数体、条件等语句块里的没有这一行。 +- TypeScript 类成员:写了 `public` / `private` / `protected` 按写的,`#name` 是私有,都没写是公共。 +- Python:只有模块顶层和类主体里的声明有;名字单下划线开头(`_x`、`__x`)是私有,前后双下划线(`__init__`)是公共,其余公共;解构赋值里的名字全是私有才算私有;赋值目标是属性、下标(`self.x = …`)时没有这一行。 + +值里的表达式只翻落在块头部里的那一段:`const v = useMemo(() => { … })` 这种值带语句块主体回调的块,回调主体是子块,值只翻到 `useMemo(() =>`,和块头部一致。 + ## 通用契约 放在 `vscode/src/codeReader/contract.ts`,语法树类型用 `@vscode/tree-sitter-wasm` 自带的 web-tree-sitter 类型。 @@ -64,8 +83,46 @@ export type BlockKind = /** 源码里的一段,行号从 0 开始,end 为开区间的字节偏移。 */ export interface SourceSpan { startIndex: number, endIndex: number, startRow: number, endRow: number } -export interface CodeBlock { - kind: BlockKind +// ── 属性表块的结构化信息(facts):语言插件从语法树读出,节点都指向源码 ── + +export type Access = 'public' | 'private' | 'protected' + +export interface ParameterFacts { + name: Node // 解构参数是整个模式,...rest / *args / **kwargs 带前缀 + optional: boolean // TypeScript 的 x? + type?: Node + default?: Node +} + +export interface FunctionFacts { + name?: Node // 匿名函数没有 + access?: Access // 嵌套在语句块里的函数没有 + async: boolean + decorators: Node[] + parameters: ParameterFacts[] + returnType?: Node +} + +export type ImportItem = + | { kind: 'named', name: Node, alias?: Node } // import { a as b }、from m import a as b + | { kind: 'default', alias: Node } // import x from 'm' + | { kind: 'namespace', alias?: Node } // import * as x、from m import * + | { kind: 'module', module: Node, alias?: Node } // import a.b as c、import x = require('m') + +export interface ImportFacts { + sources: Node[] // 来源模块的写法;Python 的 import a, b 有多个 + typeOnly?: boolean // 只有 TypeScript 填 + items: ImportItem[] +} + +export interface DeclaratorFacts { name: Node, type?: Node, value?: Node } + +export interface VariableFacts { + access?: Access + declarators: DeclaratorFacts[] +} + +export interface BlockShape { /** 这一块对应的语法树节点(整条语句)。 */ node: Node /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。 */ @@ -73,15 +130,34 @@ export interface CodeBlock { children: CodeBlock[] } +/** 预览成属性表的块:种类和 facts 绑在一起,语言插件漏给 facts 编译不过。 */ +export interface FunctionBlock extends BlockShape { kind: 'function', facts: FunctionFacts } +export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts } +export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts } +export type FactBlock = FunctionBlock | ImportBlock | VariableBlock + +export type TextKind = Exclude +/** 预览成一行译文的块。 */ +export interface TextBlock extends BlockShape { kind: TextKind } + +export type CodeBlock = TextBlock | FactBlock + /** 着色用的词性,源码着色和译文着色共用。 */ export type TokenRole = | 'keyword' // 流程关键字:import from export if else for return try catch await … | 'storage' // 声明关键字与字面量关键字:const function async class interface new true false null … | 'operator' // 被翻成文字的运算符:全等 并且 或者 非 + | 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 … | 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain' export interface Segment { text: string, role: TokenRole } +/** 属性表里展开的一个子行:几列对齐的格子。 */ +export interface PropertyItem { cells: Segment[][] } + +/** 属性表的一行:属性名、着色的值、展开在下面的子行(输入参数、导入项)。 */ +export interface PropertyRow { label: string, value: Segment[], items?: PropertyItem[] } + /** 语言插件:语法树 → 代码块树。 */ export interface LanguageAdapter { id: string @@ -94,12 +170,13 @@ export interface LanguageAdapter { tokenRole: (leaf: Node) => TokenRole } -/** 翻译插件:代码块 → 译文。 */ +/** 翻译插件:代码块 → 一行译文或属性表。 */ export interface Translator { id: string // 'typescript.zh-template' label: string // '中文直译' languageIds: string[] - translate: (block: CodeBlock, source: string) => Segment[] + translate: (block: TextBlock, source: string) => Segment[] // 只翻 header 那一段 + describe: (block: FactBlock, source: string) => PropertyRow[] // 函数、导入、常量、变量块 → 属性表 } export interface CodeReaderApi { @@ -111,6 +188,7 @@ export interface CodeReaderApi { - `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 - 内置的 TypeScript、Python 语言插件和两个中文直译翻译插件在 `activate()` 里通过同一个 API 注册,没有旁路。 - 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。 +- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译。第三方语言插件给函数、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表。 ## 解析运行时 @@ -151,6 +229,15 @@ export interface CodeReaderApi { - `return_statement` → return;其余 → statement。 - 复合语句头部到主体开始为止(包含行尾冒号,翻译时去掉)。 +### 属性表信息(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 导入:`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 导入:`import_statement` 每个 name 一个来源、导入项是整个模块;`import_from_statement` 的 module_name 和各 name(`aliased_import` 拆出 alias),`wildcard_import` 是全部;`future_import_statement` 的来源是 `__future__`。 +- Python 变量:`assignment` 的 left / type / right。 + ## 中文模板直译 结构照抄源码(按节点模板重排的语法结构除外,见下文),只替换三类东西,其余(标识符、属性名、字面量、标点)原样保留并按词性着色: @@ -159,6 +246,8 @@ export interface CodeReaderApi { 2. 内置类型:只在类型位置翻(TS 的 `predefined_type`、Python 注解里的内置类型名)。 3. 运算符:`===` 全等、`!==` 不全等、`==` 等于、`!=` 不等于、`&&` 并且、`||` 或者、`!` 非(Python 的 `not` / `and` / `or` 本身是关键字)。 +同一套表和节点模板也用来翻属性表里的值:引擎能翻任意一个节点(只取它落在块头部里的那一段),参数类型、默认值、返回类型、变量类型和初始值都走这条路。 + 其他规则:空数组字面量 → 空数组(Python 空列表 → 空列表、空字典 → 空字典);复合语句头部去掉结尾的 `{` 或 `:`;多行合成一行(连续空白压成一个空格,右括号前的尾逗号去掉);注释去掉注释符号,原文照搬。关键字与前后内容之间保留源码里的空格(`!` 翻成「非」后补一个空格)。 关键字按所在语法节点选择译法:同一个关键字在不同语法结构里意思不同时,表里写一个默认译法,再加若干「语境 → 译法」。语境是从关键字节点往上的节点类型链,外层在前:`raise_statement` 指父节点是 raise_statement,`for_statement > else_clause` 指父节点是 else_clause、祖父节点是 for_statement(关键字包在一层子句节点里时要看到祖父节点)。几条语境同时匹配时取最长的,都不匹配用默认译法。节点类型以 tree-sitter 语法为准。下面两张表里写成「(语境)译法 / (语境)译法」的关键字就是按这条规则区分的,排第一个的是默认译法。 @@ -182,38 +271,48 @@ Python 关键字:import 导入、from(导入)从 / (`raise … from`) Python 内置类型(仅注解里):str 字符串、int 整数、float 浮点数、bool 布尔、list 列表、dict 字典、set 集合、tuple 元组、bytes 字节串。 -样例(必须逐字一致;条件表达式两行以外来自原型 `test.js`): +一行译文样例(必须逐字一致): | 源码 | 译文 | | --- | --- | -| `export async function findEnvFiles(workspaceRoot: string): Promise {` | `公共 异步 函数 findEnvFiles(workspaceRoot: 字符串): Promise<字符串[]>` | -| `async function chmodAll(workspaceRoot: string, mode: number): Promise {` | `异步 函数 chmodAll(workspaceRoot: 字符串, mode: 数值): Promise` | -| `import { promises as fsp } from 'node:fs'` | `导入 { promises 作为 fsp } 来自 'node:fs'` | | `if (!folder)` | `如果 (非 folder)` | | `return []` | `返回 空数组` | | `for (const f of files) {` | `遍历 (常量 f 取自 files)` | | `failed: { path: string, reason: string }[]` | `failed: { path: 字符串, reason: 字符串 }[]` | -| `const issue = issues.find(i => i.number === issueNumber)` | `常量 issue = issues.find(i => i.number 全等 issueNumber)` | | `return {⏎ locked: true,⏎ … }`(issueLock.ts 20–24 行) | `返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }` | -| `def find_env_files(root: str) -> list[str]:` | `函数 find_env_files(root: 字符串) -> 列表[字符串]` | | `err instanceof Error ? err.message : String(err)` | `如果 err 属于 Error 则 err.message 否则 String(err)` | -| `_default_port = 5680 if _role == "beat" else 5679` | `_default_port = 如果 _role 等于 "beat" 则 5680 否则 5679` | + +属性表样例(必须逐字一致;子行各列用 ` | ` 隔开): + +| 源码 | 属性表 | +| --- | --- | +| `export async function findEnvFiles(workspaceRoot: string): Promise {` | 名称 findEnvFiles / 访问级别 公共 / 异步 是 / 输入参数 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 \| 字符串` / 输出参数 `列表[字符串]` | +| `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` | ## 预览数据与消息 扩展端把代码块树转成可序列化的视图数据发给 webview: ```ts -interface BlockView { +interface BlockViewBase { id: string // 在树里的位置,例如 '6.1.0' kind: BlockKind startLine: number // header 的起止行,0 起 endLine: number code: string // header 源码,去掉共同缩进 codeTokens: Segment[] // 源码着色(拼起来等于 code) - translation: Segment[] children: BlockView[] } + +/** 属性表块(有 facts)只带 properties,其余块只带 translation;没有翻译插件时是空数组。 */ +type BlockView = BlockViewBase & ({ translation: Segment[] } | { properties: PropertyRow[] }) ``` - 扩展 → webview:`{ type: 'document', version, languageId, blocks: BlockView[], translators: { id, label }[], translatorId, error?: string }`,文档每次变化(防抖 150ms)重发。 @@ -237,8 +336,11 @@ interface BlockView { | string | #ce9178 | #a31515 | | number / literal | #b5cea8 | #098658 | | comment | #6a9955 | #008000 | +| hint | `--vscode-descriptionForeground` | 同左 | | plain | `--vscode-editor-foreground` | 同左 | +属性表里:访问级别(公共 / 私有 / 受保护)和「作为」按 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`。 ## 代码位置 @@ -258,14 +360,15 @@ vscode/src/codeReader/ editor/previewEditor.ts CustomTextEditorProvider editor/messages.ts 消息类型 index.ts registerCodeReader(context): CodeReaderApi -vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;属性表在 PropertyGrid.tsx) ``` ## 测试 - 语言插件:`envLock.ts`、`issueLock.ts` 的代码块树(种类、header 行号、层级)与原型 `CODE_TREE` 一致;Python 样例覆盖 import / def / docstring / if-elif-else / for / try-except / with / 推导式。 -- 翻译:上面样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄。 -- `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确。 +- 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则。 +- 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 +- `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、导入、常量、变量块只带属性表,其余块只带译文。 - 行替换:多行块替换后缩进正确。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index 12f5527..919bce5 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest' import { readBlockViews, toBlockViews } from './blockView' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' -import { readFixture, runtime, WASM_DIR, withBlocks } from './testSupport' +import { propertyLines, readFixture, runtime, segmentText, WASM_DIR, withBlocks } from './testSupport' import { pythonZhTranslator } from './translators/pythonZh' import { typescriptZhTranslator } from './translators/typescriptZh' @@ -30,8 +30,17 @@ const SAMPLES: [LanguageAdapter, string, Translator, string][] = [ [ts, 'typescript', typescriptZhTranslator, 'envLock.ts.txt'], [ts, 'typescript', typescriptZhTranslator, 'issueLock.ts.txt'], [py, 'python', pythonZhTranslator, 'sample.py'], + [py, 'python', pythonZhTranslator, 'routes.py'], ] +const PROPERTY_KINDS = new Set(['function', 'import', 'const', 'variable']) + +function translationText(view: BlockView): string { + if (!('translation' in view)) + throw new Error(`块 ${view.id} 是属性表`) + return segmentText(view.translation) +} + describe('blockView', () => { it.each(SAMPLES)('%#: codeTokens 拼起来等于 code,code 是 header 整行去共同缩进', async (adapter, languageId, translator, fixture) => { const source = readFixture(fixture) @@ -41,7 +50,17 @@ describe('blockView', () => { const rows = lines.slice(view.startLine, view.endLine + 1) const indent = Math.min(...rows.filter(row => row.trim()).map(row => row.length - row.trimStart().length)) expect(view.code, view.id).toBe(rows.map(row => row.slice(indent)).join('\n')) - expect(view.translation.length, view.id).toBeGreaterThan(0) + if ('properties' in view) + expect(view.properties.length, view.id).toBeGreaterThan(0) + else + expect(view.translation.length, view.id).toBeGreaterThan(0) + } + }) + + it.each(SAMPLES)('%#: 函数、导入、常量、变量块只带属性表,其余块只带一行译文', async (adapter, languageId, translator, fixture) => { + for (const view of flatten(await views(adapter, languageId, translator, readFixture(fixture)))) { + expect('properties' in view, view.id).toBe(PROPERTY_KINDS.has(view.kind)) + expect('translation' in view, view.id).toBe(!PROPERTY_KINDS.has(view.kind)) } }) @@ -100,12 +119,20 @@ describe('blockView', () => { const cond = byId(list, '0.0') expect(cond).toMatchObject({ kind: 'if', startLine: 1, endLine: 2, code: 'if (a &&\n\tb) {', translation: [] }) expect(cond.codeTokens.map(token => token.text).join('')).toBe(cond.code) + expect(byId(list, '0')).toMatchObject({ kind: 'function', properties: [] }) }) it('readBlockViews:解析、转视图、释放语法树', async () => { const list = await readBlockViews(runtime, py, pythonZhTranslator, 'python', 'def f(x: int) -> bool:\n return not x\n') expect(list).toHaveLength(1) - expect(list[0].translation.map(segment => segment.text).join('')).toBe('函数 f(x: 整数) -> 布尔') - expect(list[0].children[0].translation.map(segment => segment.text).join('')).toBe('返回 非 x') + expect('properties' in list[0] && propertyLines(list[0].properties)).toEqual([ + '名称: f', + '访问级别: 公共', + '异步: 否', + '输入参数: 1 个', + ' x | 整数 | ', + '输出参数: 布尔', + ]) + expect(translationText(list[0].children[0])).toBe('返回 非 x') }) }) diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index cf447db..0e8093c 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,9 +1,8 @@ -import type { BlockKind, CodeBlock, LanguageAdapter, Node, Segment, TokenRole, Translator } from './contract' +import type { BlockKind, CodeBlock, LanguageAdapter, Node, PropertyRow, Segment, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import { commonIndent, splitLines } from './editText' -/** 发给 webview 的一块(可序列化)。 */ -export interface BlockView { +interface BlockViewBase { /** 在树里的位置,例如 '6.1.0'。 */ id: string kind: BlockKind @@ -14,10 +13,12 @@ export interface BlockView { code: string /** 源码着色(拼起来等于 code)。 */ codeTokens: Segment[] - translation: Segment[] children: BlockView[] } +/** 发给 webview 的一块(可序列化):函数、导入、常量、变量块预览时是属性表,其余是一行译文。 */ +export type BlockView = BlockViewBase & ({ translation: Segment[] } | { properties: PropertyRow[] }) + interface Line { start: number, end: number } /** 每行在源码里的起止下标(end 不含换行符)。 */ @@ -94,8 +95,10 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu endLine: endRow, code: rows.map(row => row.slice(Math.min(indent, row.length))).join('\n'), codeTokens: colorLines(block.node.tree.rootNode, source, lines, startRow, endRow, indent, adapter.tokenRole), - translation: translator ? translator.translate(block, source) : [], children: block.children.map((child, i) => build(child, `${id}.${i}`)), + ...('facts' in block + ? { properties: translator ? translator.describe(block, source) : [] } + : { translation: translator ? translator.translate(block, source) : [] }), } } return blocks.map((block, i) => build(block, String(i))) diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 9ee97af..2daaa76 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -10,8 +10,61 @@ export type BlockKind /** 源码里的一段,行号从 0 开始;下标是源码字符串下标(web-tree-sitter 的 startIndex / endIndex),end 为开区间。 */ export interface SourceSpan { startIndex: number, endIndex: number, startRow: number, endRow: number } -export interface CodeBlock { - kind: BlockKind +/** 访问级别。 */ +export type Access = 'public' | 'private' | 'protected' + +/** 函数的一个参数;节点都指向源码。 */ +export interface ParameterFacts { + /** 参数名;解构参数是整个模式,`...rest`、`*args`、`**kwargs` 带着前缀。 */ + name: Node + /** TypeScript 的可省略参数 `x?`。 */ + optional: boolean + type?: Node + default?: Node +} + +export interface FunctionFacts { + /** 匿名函数(`export default function () {}`)没有。 */ + name?: Node + /** 嵌套在函数体、条件等语句块里的函数没有。 */ + access?: Access + async: boolean + decorators: Node[] + parameters: ParameterFacts[] + returnType?: Node +} + +/** 导入的一项。 */ +export type ImportItem + = | { kind: 'named', name: Node, alias?: Node } // import { a as b }、from m import a as b + | { kind: 'default', alias: Node } // import x from 'm' + | { kind: 'namespace', alias?: Node } // import * as x、from m import * + | { kind: 'module', module: Node, alias?: Node } // import a.b as c、import x = require('m') + +export interface ImportFacts { + /** 来源模块,节点就是源码里的写法:TypeScript 是字符串字面量,Python 是点分模块名;Python 的 `import a, b` 有多个。 */ + sources: Node[] + /** 只导入类型(TypeScript 的 `import type`);没有这个概念的语言不填。 */ + typeOnly?: boolean + /** 副作用导入(`import './x'`)没有。 */ + items: ImportItem[] +} + +export interface DeclaratorFacts { + /** 变量名;解构声明是整个模式。 */ + name: Node + type?: Node + value?: Node +} + +export interface VariableFacts { + /** 只有模块顶层(Python 还有类主体)的声明有。 */ + access?: Access + /** 一条语句声明几个变量(`let a = 1, b = 2`)就有几项。 */ + declarators: DeclaratorFacts[] +} + +export interface BlockShape { /** 这一块对应的语法树节点(整条语句)。 */ node: Node /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。 */ @@ -19,15 +72,39 @@ export interface CodeBlock { children: CodeBlock[] } +/** 预览成属性表的块:语言插件从语法树读出结构化信息(facts),翻译插件把它排成属性表。 */ +export interface FunctionBlock extends BlockShape { kind: 'function', facts: FunctionFacts } +export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts } +export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts } +export type FactBlock = FunctionBlock | ImportBlock | VariableBlock + +export type TextKind = Exclude + +/** 预览成一行译文的块。 */ +export interface TextBlock extends BlockShape { kind: TextKind } + +export type CodeBlock = TextBlock | FactBlock + /** 着色用的词性,源码着色和译文着色共用。 */ export type TokenRole = | 'keyword' // 流程关键字:import from export if else for return try catch await … | 'storage' // 声明关键字与字面量关键字:const function async class interface new true false null … | 'operator' // 被翻成文字的运算符:全等 并且 或者 非 + | 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 … | 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain' export interface Segment { text: string, role: TokenRole } +/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值。 */ +export interface PropertyItem { cells: Segment[][] } + +/** 属性表的一行:左列属性名,右列着色的值;items 是展开在这一行下面的子行(输入参数、导入项)。 */ +export interface PropertyRow { + label: string + value: Segment[] + items?: PropertyItem[] +} + /** 语言插件:语法树 → 代码块树。 */ export interface LanguageAdapter { id: string @@ -40,12 +117,15 @@ export interface LanguageAdapter { tokenRole: (leaf: Node) => TokenRole } -/** 翻译插件:代码块 → 译文。 */ +/** 翻译插件:代码块 → 一行译文或属性表。 */ export interface Translator { id: string // 'typescript.zh-template' label: string // '中文直译' languageIds: string[] - translate: (block: CodeBlock, source: string) => Segment[] + /** 只翻 header 那一段。 */ + translate: (block: TextBlock, source: string) => Segment[] + /** 函数、导入、常量、变量块 → 属性表。 */ + describe: (block: FactBlock, source: string) => PropertyRow[] } export interface CodeReaderApi { diff --git a/vscode/src/codeReader/editor/documentMessage.test.ts b/vscode/src/codeReader/editor/documentMessage.test.ts index ba770aa..4fd430d 100644 --- a/vscode/src/codeReader/editor/documentMessage.test.ts +++ b/vscode/src/codeReader/editor/documentMessage.test.ts @@ -35,7 +35,7 @@ describe('预览的 document 消息', () => { }) expect(message.error).toBeUndefined() expect(message.blocks.map(b => b.kind)).toEqual(['if']) - expect(message.blocks[0].children.map(b => b.translation.map(s => s.text).join(''))).toEqual(['返回 空数组']) + expect(message.blocks[0].children).toMatchObject([{ kind: 'return', translation: [{ text: '返回' }, { text: ' ' }, { text: '空数组' }] }]) }) it('没有语言插件的语言:带 error,不解析', async () => { diff --git a/vscode/src/codeReader/fixtures/routes.py b/vscode/src/codeReader/fixtures/routes.py new file mode 100644 index 0000000..14e5b75 --- /dev/null +++ b/vscode/src/codeReader/fixtures/routes.py @@ -0,0 +1,126 @@ +"""用户管理路由。 + +提供用户自助查看 / 更新资料与上传头像接口。 +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, File, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession + +from luffy_agent.core.db import get_db +from luffy_agent.core.exceptions import Forbidden +from luffy_agent.core.params import Int4Path +from luffy_agent.user.auth.deps import get_current_user + +from .models import User, UserRole +from .schemas import ( + UserAvatarUploadResponse, + UserDetailResponse, + UserUpdateRequest, +) +from .services import user_service + +router = APIRouter(tags=["用户管理"], prefix="/users") + + +@router.get("/{user_id}", response_model=UserDetailResponse) +async def get_user( + user_id: Int4Path, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> UserDetailResponse: + """获取用户详情。 + + 管理员可查看任意用户,普通用户只能查看自己。 + """ + # 非管理员只能查看自己 + if current_user.role != UserRole.ADMIN and current_user.id != user_id: + raise Forbidden("无权查看此用户信息") + + user = await user_service.get_user(db, user_id=user_id) + + return UserDetailResponse( + id=user.id, + username=user.username, + display_name=user.display_name, + role=user.role.value, + email=user.email, + phone=user.phone, + bio=user.bio, + avatar_url=user.avatar_url, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.put("/{user_id}", response_model=UserDetailResponse) +async def update_user( + user_id: Int4Path, + body: UserUpdateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> UserDetailResponse: + """更新用户信息。 + + 管理员可更新任意用户,普通用户只能更新自己的基础资料。 + """ + # 非管理员只能更新自己 + if current_user.role != UserRole.ADMIN and current_user.id != user_id: + raise Forbidden("无权更新此用户信息") + + if current_user.role != UserRole.ADMIN: + if body.role is not None: + raise Forbidden("无权修改角色") + if body.is_active is not None: + raise Forbidden("无权修改启用状态") + if body.password is not None: + raise Forbidden("无权修改密码") + + user = await user_service.update_user( + db, + user_id=user_id, + display_name=body.display_name, + email=body.email, + phone=body.phone, + bio=body.bio, + avatar_url=body.avatar_url, + role=body.role, + is_active=body.is_active, + password=body.password, + ) + + return UserDetailResponse( + id=user.id, + username=user.username, + display_name=user.display_name, + role=user.role.value, + email=user.email, + phone=user.phone, + bio=user.bio, + avatar_url=user.avatar_url, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.post("/me/avatar", response_model=UserAvatarUploadResponse) +async def upload_current_user_avatar( + file: Annotated[UploadFile, File(..., description="头像文件")], + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> UserAvatarUploadResponse: + """上传当前登录用户头像。""" + payload = await file.read() + avatar_url = await user_service.upload_avatar( + db, + user_id=current_user.id, + file_name=file.filename or "avatar.bin", + content_type=file.content_type, + payload=payload, + ) + return UserAvatarUploadResponse(avatar_url=avatar_url) diff --git a/vscode/src/codeReader/languages/python.test.ts b/vscode/src/codeReader/languages/python.test.ts index f735db8..8c119de 100644 --- a/vscode/src/codeReader/languages/python.test.ts +++ b/vscode/src/codeReader/languages/python.test.ts @@ -1,6 +1,6 @@ import type { Outline } from '../testSupport' import { describe, expect, it } from 'vitest' -import { outline, readFixture, WASM_DIR, withBlocks } from '../testSupport' +import { factsByLine, outline, readFixture, WASM_DIR, withBlocks } from '../testSupport' import { createPythonAdapter } from './python' const adapter = createPythonAdapter(WASM_DIR) @@ -9,6 +9,10 @@ function tree(source: string): Promise { return withBlocks(adapter, 'python', source, outline) } +function facts(source: string): Promise> { + return withBlocks(adapter, 'python', source, factsByLine) +} + // fixtures/sample.py:行号 1 起 const SAMPLE: Outline[] = [ { at: 1, kind: 'comment' }, @@ -86,3 +90,112 @@ describe('python 代码块树', () => { ]) }) }) + +describe('python 属性表信息(facts)', () => { + it('sample.py:import / from import、模块和类里的赋值、函数和方法', async () => { + const sample = await facts(readFixture('sample.py')) + expect(sample[3]).toEqual({ sources: ['os'], items: [{ kind: 'module', module: 'os' }] }) + expect(sample[4]).toEqual({ sources: ['pathlib'], items: [{ kind: 'named', name: 'Path', alias: 'P' }] }) + expect(sample[6]).toEqual({ access: 'public', declarators: [{ name: 'MAX_DEPTH', value: '8' }] }) + expect(sample[10]).toEqual({ + name: 'find_env_files', + access: 'public', + async: false, + decorators: [], + parameters: [{ name: 'root', optional: false, type: 'str' }], + returnType: 'list[str]', + }) + expect(sample[17]).toEqual({ declarators: [{ name: 'found', value: 'found[:MAX_DEPTH]' }] }) + expect(sample[37]).toEqual({ access: 'public', declarators: [{ name: 'label', value: '\'env\'' }] }) + expect(sample[39]).toMatchObject({ + name: 'summary', + access: 'public', + parameters: [{ name: 'self', optional: false }, { name: 'verbose', optional: false, type: 'bool', default: 'False' }], + returnType: 'str', + }) + }) + + it('routes.py:future 导入、多行括号导入、带装饰器的 async def、await 赋值', async () => { + const routes = await facts(readFixture('routes.py')) + expect(routes[6]).toEqual({ sources: ['__future__'], items: [{ kind: 'named', name: 'annotations' }] }) + expect(routes[19]).toEqual({ + sources: ['.schemas'], + items: [ + { kind: 'named', name: 'UserAvatarUploadResponse' }, + { kind: 'named', name: 'UserDetailResponse' }, + { kind: 'named', name: 'UserUpdateRequest' }, + ], + }) + expect(routes[29]).toEqual({ + name: 'get_user', + access: 'public', + async: true, + decorators: ['@router.get("/{user_id}", response_model=UserDetailResponse)'], + parameters: [ + { name: 'user_id', optional: false, type: 'Int4Path' }, + { name: 'db', optional: false, type: 'AsyncSession', default: 'Depends(get_db)' }, + { name: 'current_user', optional: false, type: 'User', default: 'Depends(get_current_user)' }, + ], + returnType: 'UserDetailResponse', + }) + expect(routes[118]).toEqual({ declarators: [{ name: 'payload', value: 'await file.read()' }] }) + }) + + it('导入:import a.b as c, d 每个模块一个来源;from . import、from m import *', async () => { + const source = [ + 'import os.path as osp, sys', + 'from . import x', + 'from m import *', + ].join('\n') + expect(await facts(source)).toEqual({ + 1: { sources: ['os.path', 'sys'], items: [{ kind: 'module', module: 'os.path', alias: 'osp' }, { kind: 'module', module: 'sys' }] }, + 2: { sources: ['.'], items: [{ kind: 'named', name: 'x' }] }, + 3: { sources: ['m'], items: [{ kind: 'namespace' }] }, + }) + }) + + it('形参:*args / **kwargs 带前缀,* 和 / 分隔符不算参数', async () => { + const found = await facts('async def f(a, /, b: int, *args: int, c=1, d: str = \'x\', **kw) -> None:\n pass') + expect(found[1]).toEqual({ + name: 'f', + access: 'public', + async: true, + decorators: [], + parameters: [ + { name: 'a', optional: false }, + { name: 'b', optional: false, type: 'int' }, + { name: '*args', optional: false, type: 'int' }, + { name: 'c', optional: false, default: '1' }, + { name: 'd', optional: false, type: 'str', default: '\'x\'' }, + { name: '**kw', optional: false }, + ], + returnType: 'None', + }) + }) + + it('访问级别:单下划线开头私有、__dunder__ 公共;解构全是私有名才私有;函数体里的没有', async () => { + const source = [ + '_cache = {}', + '__all__ = [\'f\']', + 'a, _b = 1, 2', + '_c, _d = 1, 2', + 'total: int', + 'class K:', + ' def __init__(self):', + ' self.x = 1', + ' def _helper(self):', + ' def inner():', + ' pass', + ].join('\n') + const found = await facts(source) + expect(found[1]).toMatchObject({ access: 'private' }) + expect(found[2]).toMatchObject({ access: 'public' }) + expect(found[3]).toEqual({ access: 'public', declarators: [{ name: 'a, _b', value: '1, 2' }] }) + expect(found[4]).toMatchObject({ access: 'private' }) + expect(found[5]).toEqual({ access: 'public', declarators: [{ name: 'total', type: 'int' }] }) + expect(found[7]).toMatchObject({ name: '__init__', access: 'public' }) + expect(found[8]).toEqual({ declarators: [{ name: 'self.x', value: '1' }] }) + expect(found[9]).toMatchObject({ name: '_helper', access: 'private' }) + expect(found[10]).not.toHaveProperty('access') + }) +}) diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index 8e4bce4..2844ecb 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -1,11 +1,13 @@ -import type { BlockKind, CodeBlock, LanguageAdapter, Node, TokenRole } from '../contract' +import type { Access, BlockShape, CodeBlock, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' import * as path from 'node:path' -import { compoundBlock, leafBlock, listBlocks, named, sameNode } from './shared' +import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared' -/** 常量、类字段只认直接写在模块 / 类主体里的赋值。 */ +/** 常量、类字段只认直接写在模块 / 类主体里的赋值;这两处的声明也才有访问级别。 */ 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']) function statements(items: Node[], scope: Scope, afterRow: number, docstring: boolean): CodeBlock[] { return listBlocks(items, afterRow, (item, first) => statementBlocks(item, scope, docstring && first), () => true) @@ -20,10 +22,14 @@ function bodyOf(node: Node): Node | null { return blocks.at(-1) ?? null } -function bodyBlock(kind: BlockKind, outer: Node, body: Node | null, scope: Scope, docstring = false): CodeBlock { +function bodyShape(outer: Node, body: Node | null, scope: Scope, docstring = false): BlockShape { if (!body) - return leafBlock(kind, outer) - return compoundBlock(kind, outer, body.startIndex, named(body), (list, afterRow) => statements(list, scope, afterRow, docstring)) + return leafShape(outer) + return compoundShape(outer, body.startIndex, named(body), (list, afterRow) => statements(list, scope, afterRow, docstring)) +} + +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[] { @@ -31,13 +37,13 @@ function statementBlocks(node: Node, scope: Scope, docstring: boolean): CodeBloc case 'import_statement': case 'import_from_statement': case 'future_import_statement': - return [leafBlock('import', node)] + return [{ kind: 'import', ...leafShape(node), facts: importFacts(node) }] case 'function_definition': case 'class_definition': - return [definitionBlock(node, node)] + return [definitionBlock(node, node, scope)] case 'decorated_definition': { const definition = node.childForFieldName('definition') - return [definition ? definitionBlock(definition, node) : leafBlock('statement', node)] + return [definition ? definitionBlock(definition, node, scope) : leafBlock('statement', node)] } case 'if_statement': return ifBlocks(node) @@ -55,16 +61,106 @@ function statementBlocks(node: Node, scope: Scope, docstring: boolean): CodeBloc case 'return_statement': return [leafBlock('return', node)] case 'expression_statement': - return [leafBlock(expressionKind(node, scope, docstring), node)] + return [expressionBlock(node, scope, docstring)] default: return [leafBlock('statement', node)] } } /** function_definition / class_definition;outer 是带装饰器的整体时头部从装饰器开始。 */ -function definitionBlock(definition: Node, outer: Node): CodeBlock { - const isClass = definition.type === 'class_definition' - return bodyBlock(isClass ? 'class' : 'function', outer, definition.childForFieldName('body'), isClass ? 'class' : 'other', true) +function definitionBlock(definition: Node, outer: Node, scope: Scope): CodeBlock { + if (definition.type === 'class_definition') + return { kind: 'class', ...bodyShape(outer, definition.childForFieldName('body'), 'class', true) } + return { + kind: 'function', + ...bodyShape(outer, definition.childForFieldName('body'), 'other', true), + facts: functionFacts(definition, outer, scope), + } +} + +/** 单下划线开头的名字是私有(`_x`、`__x`),前后双下划线的特殊方法(`__init__`)是公共。 */ +function nameAccess(name: string): Access { + const dunder = name.length > 4 && name.startsWith('__') && name.endsWith('__') + return name.startsWith('_') && !dunder ? 'private' : 'public' +} + +/** 赋值目标里的名字;目标是属性、下标(`self.x = …`、`d[k] = …`)时没有。 */ +function targetNames(target: Node): Node[] | undefined { + if (target.type === 'identifier') + return [target] + if (!PATTERNS.has(target.type) && target.type !== 'list_splat_pattern') + return undefined + const names: Node[] = [] + for (const part of syntaxChildren(target)) { + const found = targetNames(part) + if (!found) + return undefined + names.push(...found) + } + return names +} + +/** 模块、类主体里的声明才有访问级别;解构赋值里的名字全是私有才算私有。 */ +function scopeAccess(target: Node, scope: Scope): Access | undefined { + const names = scope === 'other' ? undefined : targetNames(target) + if (!names?.length) + return undefined + return names.every(name => nameAccess(name.text) === 'private') ? 'private' : 'public' +} + +function functionFacts(definition: Node, outer: Node, scope: Scope): FunctionFacts { + const name = definition.childForFieldName('name') + return { + name: name ?? undefined, + access: name ? scopeAccess(name, scope) : undefined, + async: children(definition).some(child => child.type === 'async'), + decorators: named(outer).filter(child => child.type === 'decorator'), + parameters: syntaxChildren(definition.childForFieldName('parameters')) + .filter(param => !PARAMETER_SEPARATORS.has(param.type)) + .map(parameterFacts), + returnType: definition.childForFieldName('return_type') ?? undefined, + } +} + +/** 带注解的参数(typed_parameter)语法没给名字起字段名,名字是第一个具名子节点(可能是 `*args`)。 */ +function parameterFacts(param: Node): ParameterFacts { + switch (param.type) { + case 'typed_parameter': + return { name: syntaxChildren(param)[0] ?? param, optional: false, type: param.childForFieldName('type') ?? undefined } + case 'default_parameter': + case 'typed_default_parameter': + return { + name: param.childForFieldName('name') ?? param, + optional: false, + type: param.childForFieldName('type') ?? undefined, + default: param.childForFieldName('value') ?? undefined, + } + default: + return { name: param, optional: false } + } +} + +/** + * import a.b as c, d:每个模块一个来源,导入项是整个模块; + * from m import a as b / from m import *:来源是 m(可以是相对的 `.models`),future 导入的来源是 `__future__` 这个词本身。 + */ +function importFacts(node: Node): ImportFacts { + const names = node.childrenForFieldName('name').filter((name): name is Node => name !== null) + const alias = (name: Node): Node | undefined => name.type === 'aliased_import' ? name.childForFieldName('alias') ?? undefined : undefined + const original = (name: Node): Node => name.type === 'aliased_import' ? name.childForFieldName('name') ?? name : name + if (node.type === 'import_statement') { + return { + sources: names.map(original), + items: names.map(name => ({ kind: 'module', module: original(name), alias: alias(name) })), + } + } + const source = node.type === 'future_import_statement' + ? children(node).find(child => child.type === '__future__') + : node.childForFieldName('module_name') + const items: ImportItem[] = names.map(name => ({ kind: 'named', name: original(name), alias: alias(name) })) + if (named(node).some(child => child.type === 'wildcard_import')) + items.push({ kind: 'namespace' }) + return { sources: source ? [source] : [], items } } function isConstantTarget(target: Node | null): boolean { @@ -79,17 +175,25 @@ function isConstantTarget(target: Node | null): boolean { return false } -function expressionKind(node: Node, scope: Scope, docstring: boolean): BlockKind { +/** 赋值 → 字段 / 常量 / 变量;主体开头的字符串是 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')) - return 'comment' - if (expression?.type !== 'assignment') - return 'statement' - if (scope === 'class' && expression.childForFieldName('type')) - return 'field' - if (scope === 'module' && isConstantTarget(expression.childForFieldName('left'))) - return 'const' - return 'variable' + return leafBlock('comment', node) + const left = expression?.type === 'assignment' ? expression.childForFieldName('left') : null + if (!expression || !left) + return leafBlock('statement', node) + const type = expression.childForFieldName('type') + if (scope === 'class' && type) + return leafBlock('field', node) + return { + kind: scope === 'module' && isConstantTarget(left) ? 'const' : 'variable', + ...leafShape(node), + facts: { + access: scopeAccess(left, scope), + declarators: [{ name: left, type: type ?? undefined, value: expression.childForFieldName('right') ?? undefined }], + }, + } } /** elif / else 展平成紧跟 if 的兄弟块。 */ @@ -111,7 +215,7 @@ function loopBlocks(node: Node): CodeBlock[] { return blocks } -const TRY_CLAUSES: Record = { +const TRY_CLAUSES: Record = { except_clause: 'catch', except_group_clause: 'catch', else_clause: 'else', diff --git a/vscode/src/codeReader/languages/shared.ts b/vscode/src/codeReader/languages/shared.ts index e84be62..215d70b 100644 --- a/vscode/src/codeReader/languages/shared.ts +++ b/vscode/src/codeReader/languages/shared.ts @@ -1,4 +1,4 @@ -import type { BlockKind, CodeBlock, Node, SourceSpan } from '../contract' +import type { BlockShape, CodeBlock, Node, SourceSpan, TextKind } from '../contract' /** 语言插件共用的建树工具:取子节点、算头部、把一串语句切成块。 */ @@ -10,6 +10,11 @@ export function children(node: Node): Node[] { return node.children.filter((child): child is Node => child !== null) } +/** 具名子节点,去掉注释这类语法之外的节点。 */ +export function syntaxChildren(node: Node | null | undefined): Node[] { + return named(node).filter(child => !child.isExtra) +} + export function nodeSpan(node: Node): SourceSpan { return { startIndex: node.startIndex, @@ -19,12 +24,16 @@ export function nodeSpan(node: Node): SourceSpan { } } -export function leafBlock(kind: BlockKind, node: Node): CodeBlock { - return { kind, node, header: nodeSpan(node), children: [] } +export function leafShape(node: Node): BlockShape { + return { node, header: nodeSpan(node), children: [] } +} + +export function leafBlock(kind: TextKind, node: Node): CodeBlock { + return { kind, ...leafShape(node) } } /** 头部往前扩到 from(装饰器)。 */ -export function extendHeader(block: CodeBlock, from: Node): CodeBlock { +export function extendHeader(block: B, from: Node): B { return { ...block, header: { ...block.header, startIndex: from.startIndex, startRow: from.startPosition.row } } } @@ -67,13 +76,13 @@ function commentsIn(node: Node, from: number, to: number): Node[] { export type ListBlocks = (items: Node[], afterRow: number) => CodeBlock[] /** - * 复合语句块:头部到 bodyStart 之前,子块来自 items。 + * 复合语句块(不含种类):头部到 bodyStart 之前,子块来自 items。 * 头部和主体之间的注释(例如 Python 冒号后、主体前的注释)也归子块。 */ -export function compoundBlock(kind: BlockKind, outer: Node, bodyStart: number, items: Node[], list: ListBlocks): CodeBlock { +export function compoundShape(outer: Node, bodyStart: number, items: Node[], list: ListBlocks): BlockShape { const header = headerSpan(outer, bodyStart) const between = commentsIn(outer, header.endIndex, bodyStart) - return { kind, node: outer, header, children: list([...between, ...items], header.endRow) } + return { node: outer, header, children: list([...between, ...items], header.endRow) } } /** diff --git a/vscode/src/codeReader/languages/typescript.test.ts b/vscode/src/codeReader/languages/typescript.test.ts index f502c4a..a842202 100644 --- a/vscode/src/codeReader/languages/typescript.test.ts +++ b/vscode/src/codeReader/languages/typescript.test.ts @@ -1,6 +1,6 @@ import type { Outline } from '../testSupport' import { describe, expect, it } from 'vitest' -import { outline, readFixture, WASM_DIR, withBlocks } from '../testSupport' +import { factsByLine, outline, readFixture, WASM_DIR, withBlocks } from '../testSupport' import { createTypeScriptAdapter } from './typescript' const adapter = createTypeScriptAdapter(WASM_DIR) @@ -9,6 +9,10 @@ function tree(source: string, languageId = 'typescript'): Promise { return withBlocks(adapter, languageId, source, outline) } +function facts(source: string, languageId = 'typescript'): Promise> { + return withBlocks(adapter, languageId, source, factsByLine) +} + // 与原型 docs/prototype/code-reader/figma-plugin/code.js 的 CODE_TREE 一致 const ENV_LOCK: Outline[] = [ { at: [1, 10], kind: 'comment' }, @@ -225,3 +229,119 @@ describe('typeScript 代码块树', () => { ]) }) }) + +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({ + name: 'findEnvFiles', + access: 'public', + async: true, + decorators: [], + parameters: [{ name: 'workspaceRoot', optional: false, type: 'string' }], + returnType: 'Promise', + }) + expect(env[24]).toEqual({ declarators: [{ name: 'folder', value: 'workspace.workspaceFolders?.find(f => f.uri.fsPath === workspaceRoot)' }] }) + expect(env[38]).toMatchObject({ name: 'chmodAll', access: 'private', async: true }) + expect(env[40]).toEqual({ declarators: [{ name: 'result', type: 'EnvLockResult', value: '{ total: files.length, ok: [], failed: [] }' }] }) + }) + + it('导入:默认导入、* as、仅类型、副作用导入、require', async () => { + const source = [ + 'import type { A } from \'x\'', + 'import d, { b as c, type E } from \'y\'', + 'import * as ns from \'z\'', + 'import \'./side\'', + 'import fs = require(\'fs\')', + ].join('\n') + expect(await facts(source)).toEqual({ + 1: { sources: ['\'x\''], typeOnly: true, items: [{ kind: 'named', name: 'A' }] }, + 2: { sources: ['\'y\''], typeOnly: false, items: [ + { kind: 'default', alias: 'd' }, + { kind: 'named', name: 'b', alias: 'c' }, + { kind: 'named', name: 'E' }, + ] }, + 3: { sources: ['\'z\''], typeOnly: false, items: [{ kind: 'namespace', alias: 'ns' }] }, + 4: { sources: ['\'./side\''], typeOnly: false, items: [] }, + 5: { sources: ['\'fs\''], typeOnly: false, items: [{ kind: 'module', module: '\'fs\'', alias: 'fs' }] }, + }) + }) + + it('函数:各种形参、箭头函数、匿名默认导出;嵌套函数没有访问级别', async () => { + const source = [ + 'export default async function () {}', + 'function f(this: Foo, a: string, b?: number, c = 1, ...rest: string[]): Promise {}', + 'const g = async (x: number = 2, { y }: O): Promise => {', + ' const h = x => {', + ' return x', + ' }', + '}', + ].join('\n') + const found = await facts(source) + expect(found[1]).toEqual({ access: 'public', async: true, decorators: [], parameters: [] }) + expect(found[2]).toEqual({ + name: 'f', + access: 'private', + async: false, + decorators: [], + parameters: [ + { name: 'this', optional: false, type: 'Foo' }, + { name: 'a', optional: false, type: 'string' }, + { name: 'b', optional: true, type: 'number' }, + { name: 'c', optional: false, default: '1' }, + { name: '...rest', optional: false, type: 'string[]' }, + ], + returnType: 'Promise', + }) + expect(found[3]).toMatchObject({ + name: 'g', + access: 'private', + async: true, + parameters: [{ name: 'x', optional: false, type: 'number', default: '2' }, { name: '{ y }', optional: false, type: 'O' }], + returnType: 'Promise', + }) + expect(found[4]).toEqual({ name: 'h', async: false, decorators: [], parameters: [{ name: 'x', optional: false }] }) + }) + + it('javaScript 的默认值参数是 assignment_pattern', async () => { + const found = await facts('function f(a, b = 1, ...c) {}', 'javascript') + expect(found[1]).toMatchObject({ + parameters: [{ name: 'a', optional: false }, { name: 'b', optional: false, default: '1' }, { name: '...c', optional: false }], + }) + }) + + it('类成员:访问修饰符、#私有名、装饰器、箭头函数字段', async () => { + const source = [ + 'class K {', + ' @dec() @other', + ' private async m(a: T): Promise {}', + ' #priv() {}', + ' protected handler = async (e: E) => {}', + ' static s(): x is Foo {}', + '}', + ].join('\n') + const found = await facts(source) + expect(found[2]).toMatchObject({ name: 'm', access: 'private', async: true, decorators: ['@dec()', '@other'], returnType: 'Promise' }) + expect(found[4]).toMatchObject({ name: '#priv', access: 'private', async: false }) + expect(found[5]).toMatchObject({ name: 'handler', access: 'protected', async: true, parameters: [{ name: 'e', optional: false, type: 'E' }] }) + expect(found[6]).toMatchObject({ name: 's', access: 'public', returnType: 'x is Foo' }) + }) + + it('变量:解构写整个模式,一条语句多个变量,export 的是公共、顶层其余私有、嵌套的没有', async () => { + const source = [ + 'export const { p, q } = obj', + 'let i = 0, n: number', + 'if (ready) {', + ' const local = 1', + '}', + ].join('\n') + expect(await facts(source)).toEqual({ + 1: { access: 'public', declarators: [{ name: '{ p, q }', value: 'obj' }] }, + 2: { access: 'private', declarators: [{ name: 'i', value: '0' }, { name: 'n', type: 'number' }] }, + 4: { declarators: [{ name: 'local', value: '1' }] }, + }) + }) +}) diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index ed5caf0..993f4cb 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -1,6 +1,6 @@ -import type { BlockKind, CodeBlock, LanguageAdapter, Node, TokenRole } from '../contract' +import type { Access, BlockShape, CodeBlock, DeclaratorFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' import * as path from 'node:path' -import { compoundBlock, extendHeader, leafBlock, listBlocks, named, sameNode } from './shared' +import { children, compoundShape, extendHeader, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared' const GRAMMARS: Record = { typescript: 'tree-sitter-typescript.wasm', @@ -43,17 +43,21 @@ function statements(items: Node[], top: boolean, afterRow: number): CodeBlock[] } /** 主体是语句块时子块来自它的语句,否则主体本身就是唯一的子语句(`if (x) return`)。 */ -function bodyBlock(kind: BlockKind, outer: Node, body: Node | null, top = false): CodeBlock { +function bodyShape(outer: Node, body: Node | null): BlockShape { if (!body) - return leafBlock(kind, outer) + return leafShape(outer) const items = body.type === 'statement_block' ? named(body) : [body] - return compoundBlock(kind, outer, body.startIndex, items, (list, afterRow) => statements(list, top, afterRow)) + return compoundShape(outer, body.startIndex, items, (list, afterRow) => statements(list, false, afterRow)) +} + +function bodyBlock(kind: TextKind, outer: Node, body: Node | null): CodeBlock { + return { kind, ...bodyShape(outer, body) } } function statementBlocks(node: Node, top: boolean): CodeBlock[] { switch (node.type) { case 'import_statement': - return [leafBlock('import', node)] + return [{ kind: 'import', ...leafShape(node), facts: importFacts(node) }] case 'export_statement': return exportBlocks(node, top) case 'if_statement': @@ -92,17 +96,17 @@ function exportBlocks(node: Node, top: boolean): CodeBlock[] { const value = node.childForFieldName('value') if (value && CLASSES.has(value.type)) return [classBlock(value, node)] - const body = functionBody(value) - return [body ? bodyBlock('function', node, body) : leafBlock('statement', node)] + if (value && functionBody(value)) + return [functionBlock(node, value, value.childForFieldName('name'), 'public')] + return [leafBlock('statement', node)] } function declarationBlocks(declaration: Node, outer: Node, top: boolean): CodeBlock[] { switch (declaration.type) { case 'function_declaration': case 'generator_function_declaration': - return [bodyBlock('function', outer, declaration.childForFieldName('body'))] case 'function_signature': - return [leafBlock('function', outer)] + return [functionBlock(outer, declaration, declaration.childForFieldName('name'), declarationAccess(outer, top))] case 'lexical_declaration': case 'variable_declaration': return [variableBlock(declaration, outer, top)] @@ -123,12 +127,110 @@ function declarationBlocks(declaration: Node, outer: Node, top: boolean): CodeBl /** 唯一声明值是带语句块主体的函数 → function;否则模块顶层的 const → const,其余 → variable。 */ function variableBlock(declaration: Node, outer: Node, top: boolean): CodeBlock { const declarators = named(declaration).filter(child => child.type === 'variable_declarator') + const access = declarationAccess(outer, top) const value = declarators.length === 1 ? declarators[0].childForFieldName('value') : null - const body = functionBody(value) - if (body) - return bodyBlock('function', outer, body) + if (value && functionBody(value)) + return functionBlock(outer, value, declarators[0].childForFieldName('name'), access) const isConst = declaration.type === 'lexical_declaration' && declaration.childForFieldName('kind')?.type === 'const' - return bodyBlock(top && isConst ? 'const' : 'variable', outer, callbackBody(value)) + return { + kind: top && isConst ? 'const' : 'variable', + ...bodyShape(outer, callbackBody(value)), + facts: { access, declarators: declarators.map(declaratorFacts) }, + } +} + +/** 函数块:fn 是声明、方法或函数值,带语句块主体时子块来自主体;name 是它在源码里的名字(变量名、方法名)。 */ +function functionBlock(outer: Node, fn: Node, name: Node | null, access: Access | undefined, decorators: Node[] = []): CodeBlock { + return { + kind: 'function', + ...bodyShape(outer, fn.childForFieldName('body')), + facts: functionFacts(fn, name, access, decorators), + } +} + +/** 模块里的声明:export 了是公共,顶层没 export 是私有,嵌套在语句块里的没有访问级别。 */ +function declarationAccess(outer: Node, top: boolean): Access | undefined { + if (outer.type === 'export_statement') + return 'public' + return top ? 'private' : undefined +} + +/** 类成员:写了 public / private / protected 按写的,#name 是私有,默认公共。 */ +function memberAccess(member: Node): Access { + const modifier = named(member).find(child => child.type === 'accessibility_modifier') + if (modifier) + return modifier.text as Access + return member.childForFieldName('name')?.type === 'private_property_identifier' ? 'private' : 'public' +} + +/** 类型标注 `: T` 里的 T(也可能是 `x is T` 这类谓词)。 */ +function annotatedType(annotation: Node | null): Node | undefined { + return syntaxChildren(annotation)[0] +} + +function functionFacts(fn: Node, name: Node | null, access: Access | undefined, decorators: Node[]): FunctionFacts { + // 不带括号的单参数箭头函数 `x => …` + const single = fn.childForFieldName('parameter') + return { + name: name ?? undefined, + access, + async: children(fn).some(child => child.type === 'async'), + decorators, + parameters: single ? [{ name: single, optional: false }] : syntaxChildren(fn.childForFieldName('parameters')).map(parameterFacts), + returnType: annotatedType(fn.childForFieldName('return_type')), + } +} + +/** TypeScript 的形参带类型和默认值;JavaScript 的默认值是 assignment_pattern,其余形参本身就是参数名。 */ +function parameterFacts(param: Node): ParameterFacts { + switch (param.type) { + case 'required_parameter': + case 'optional_parameter': + return { + name: param.childForFieldName('pattern') ?? param, + optional: param.type === 'optional_parameter', + type: annotatedType(param.childForFieldName('type')), + default: param.childForFieldName('value') ?? undefined, + } + case 'assignment_pattern': + return { name: param.childForFieldName('left') ?? param, optional: false, default: param.childForFieldName('right') ?? undefined } + default: + return { name: param, optional: false } + } +} + +function declaratorFacts(declarator: Node): DeclaratorFacts { + return { + name: declarator.childForFieldName('name') ?? declarator, + type: annotatedType(declarator.childForFieldName('type')), + value: declarator.childForFieldName('value') ?? undefined, + } +} + +/** import 语句:默认导入、`* as x`、`{ a as b }`、`import x = require('m')`;`import type` 只导入类型。 */ +function importFacts(node: Node): ImportFacts { + const require = named(node).find(child => child.type === 'import_require_clause') + const source = (require ?? node).childForFieldName('source') + const items: ImportItem[] = [] + const requireAlias = named(require).find(child => child.type === 'identifier') + if (source && requireAlias) + items.push({ kind: 'module', module: source, alias: requireAlias }) + for (const part of named(named(node).find(child => child.type === 'import_clause'))) { + if (part.type === 'identifier') { + items.push({ kind: 'default', alias: part }) + } + else if (part.type === 'namespace_import') { + items.push({ kind: 'namespace', alias: named(part).find(child => child.type === 'identifier') }) + } + else if (part.type === 'named_imports') { + for (const specifier of named(part)) { + const name = specifier.type === 'import_specifier' ? specifier.childForFieldName('name') : null + if (name) + items.push({ kind: 'named', name, alias: specifier.childForFieldName('alias') ?? undefined }) + } + } + } + return { sources: source ? [source] : [], typeOnly: children(node).some(child => child.type === 'type'), items } } /** if 的 else / else if 展平成紧跟其后的兄弟块。 */ @@ -160,7 +262,7 @@ function switchBlock(node: Node): CodeBlock { const body = node.childForFieldName('body') if (!body) return leafBlock('if', node) - return compoundBlock('if', node, body.startIndex, named(body), (list, afterRow) => statements(list, false, afterRow)) + return { kind: 'if', ...compoundShape(node, body.startIndex, named(body), (list, afterRow) => statements(list, false, afterRow)) } } /** case / default:头部到冒号为止,子块来自它下面的语句。 */ @@ -168,40 +270,45 @@ function caseBlock(node: Node): CodeBlock { const [first] = node.childrenForFieldName('body') const bodyStart = first?.startIndex ?? node.endIndex const items = named(node).filter(child => child.startIndex >= bodyStart) - return compoundBlock('if', node, bodyStart, items, (list, afterRow) => statements(list, false, afterRow)) + return { kind: 'if', ...compoundShape(node, bodyStart, items, (list, afterRow) => statements(list, false, afterRow)) } } +/** 类:成员前面的装饰器是类主体里的兄弟节点,算进成员头部,也交给成员当装饰器。 */ function classBlock(declaration: Node, outer: Node): CodeBlock { const body = declaration.childForFieldName('body') if (!body) return leafBlock('class', outer) - return compoundBlock('class', outer, body.startIndex, named(body), (list, afterRow) => { - let decorators: Node | undefined - return listBlocks(list, afterRow, (member) => { - if (member.type === 'decorator') { - decorators ??= member - return [] - } - const blocks = memberBlocks(member) - const from = decorators - decorators = undefined - return from ? blocks.map((block, i) => i === 0 ? extendHeader(block, from) : block) : blocks - }, isLineComment) - }) + return { + kind: 'class', + ...compoundShape(outer, body.startIndex, named(body), (list, afterRow) => { + let decorators: Node[] = [] + return listBlocks(list, afterRow, (member) => { + if (member.type === 'decorator') { + decorators.push(member) + return [] + } + const blocks = memberBlocks(member, decorators) + const [from] = decorators + decorators = [] + return from ? blocks.map((block, i) => i === 0 ? extendHeader(block, from) : block) : blocks + }, isLineComment) + }), + } } -function memberBlocks(member: Node): CodeBlock[] { +function memberBlocks(member: Node, decorators: Node[]): CodeBlock[] { switch (member.type) { case 'method_definition': - return [bodyBlock('function', member, member.childForFieldName('body'))] case 'method_signature': case 'abstract_method_signature': - return [leafBlock('function', member)] + return [functionBlock(member, member, member.childForFieldName('name'), memberAccess(member), decorators)] case 'class_static_block': return [bodyBlock('statement', member, member.childForFieldName('body'))] default: { - const body = functionBody(member.childForFieldName('value')) - return [body ? bodyBlock('function', member, body) : leafBlock('field', member)] + const value = member.childForFieldName('value') + if (value && functionBody(value)) + return [functionBlock(member, value, member.childForFieldName('name'), memberAccess(member), decorators)] + return [leafBlock('field', member)] } } } @@ -211,8 +318,11 @@ function membersBlock(declaration: Node, outer: Node): CodeBlock { const body = declaration.childForFieldName('body') if (!body) return leafBlock('type', outer) - return compoundBlock('type', outer, body.startIndex, named(body), (list, afterRow) => - listBlocks(list, afterRow, member => [leafBlock('field', member)], isLineComment)) + return { + kind: 'type', + ...compoundShape(outer, body.startIndex, named(body), (list, afterRow) => + listBlocks(list, afterRow, member => [leafBlock('field', member)], isLineComment)), + } } // ─── 源码着色 ───────────────────────────────────────────────────────────────── diff --git a/vscode/src/codeReader/registry.test.ts b/vscode/src/codeReader/registry.test.ts index c720710..a3e489f 100644 --- a/vscode/src/codeReader/registry.test.ts +++ b/vscode/src/codeReader/registry.test.ts @@ -17,7 +17,7 @@ function adapter(id: string, languageIds: string[]): LanguageAdapter { } function translator(id: string, languageIds: string[]): Translator { - return { id, label: id, languageIds, translate: () => [] } + return { id, label: id, languageIds, translate: () => [], describe: () => [] } } describe('codeReaderRegistry', () => { diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index 4c2cc14..746e672 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -1,6 +1,7 @@ -import type { BlockKind, CodeBlock, LanguageAdapter } from './contract' +import type { BlockKind, CodeBlock, LanguageAdapter, PropertyRow, Segment } from './contract' import { readFileSync } from 'node:fs' import * as path from 'node:path' +import { Node } from '@vscode/tree-sitter-wasm' import { TreeSitterRuntime } from './runtime' /** 单元测试直接用依赖包里的 wasm,不走打包产物。 */ @@ -43,3 +44,36 @@ export function walk(blocks: CodeBlock[], visit: (block: CodeBlock) => void): vo walk(block.children, visit) } } + +export function segmentText(segments: Segment[]): string { + return segments.map(segment => segment.text).join('') +} + +/** 属性表写成文字:每行 `名称: 值`,展开的子行缩进两格、各列用 ` | ` 隔开。 */ +export function propertyLines(rows: PropertyRow[]): string[] { + return rows.flatMap(row => [ + `${row.label}: ${segmentText(row.value)}`, + ...(row.items ?? []).map(item => ` ${item.cells.map(segmentText).join(' | ')}`), + ]) +} + +/** 语法树节点换成它的源码,没填的可选字段去掉。 */ +function nodesToText(value: unknown): unknown { + if (value instanceof Node) + return value.text + if (Array.isArray(value)) + return value.map(nodesToText) + if (value && typeof value === 'object') + return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined).map(([key, field]) => [key, nodesToText(field)])) + return value +} + +/** 每个属性表块的 facts(节点换成源码),按 header 起始行(1 起)索引。 */ +export function factsByLine(blocks: CodeBlock[]): Record { + const byLine: Record = {} + walk(blocks, (block) => { + if ('facts' in block) + byLine[block.header.startRow + 1] ??= nodesToText(block.facts) + }) + return byLine +} diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index dc7d5d3..44d02e2 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -1,9 +1,9 @@ -import type { LanguageAdapter, Segment, Translator } from '../contract' +import type { LanguageAdapter, PropertyRow, Segment, Translator } from '../contract' import type { ZhTable } from './zhTemplate' import { describe, expect, it } from 'vitest' import { createPythonAdapter } from '../languages/python' import { createTypeScriptAdapter } from '../languages/typescript' -import { readFixture, walk, WASM_DIR, withBlocks } from '../testSupport' +import { propertyLines, readFixture, walk, WASM_DIR, withBlocks } from '../testSupport' import { pythonZhTranslator } from './pythonZh' import { typescriptZhTranslator } from './typescriptZh' import { createZhTemplateTranslator } from './zhTemplate' @@ -13,19 +13,40 @@ const py = createPythonAdapter(WASM_DIR) interface Translated { kind: string, segments: Segment[] } -/** 每块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。 */ +/** 一行译文块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 */ function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise> { return withBlocks(adapter, languageId, source, (blocks) => { const byLine = new Map() walk(blocks, (block) => { const line = block.header.startRow + 1 - if (!byLine.has(line)) + if (!byLine.has(line) && !('facts' in block)) byLine.set(line, { kind: block.kind, segments: translator.translate(block, source) }) }) return byLine }) } +/** 属性表块(函数、导入、常量、变量)的属性表,按 header 起始行(1 起)索引。 */ +function describeAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise> { + return withBlocks(adapter, languageId, source, (blocks) => { + const byLine = new Map() + walk(blocks, (block) => { + const line = block.header.startRow + 1 + if (!byLine.has(line) && 'facts' in block) + byLine.set(line, translator.describe(block, source)) + }) + return byLine + }) +} + +function rowTexts(map: Map): Record { + return Object.fromEntries([...map].map(([line, rows]) => [line, propertyLines(rows)])) +} + +function valueOf(rows: PropertyRow[] | undefined, label: string): Segment[] { + return rows?.find(row => row.label === label)?.value ?? [] +} + function text(segments: Segment[] | undefined): string { return (segments ?? []).map(segment => segment.text).join('') } @@ -60,29 +81,22 @@ describe('typeScript 中文直译', () => { it('spec 样例逐字一致', async () => { const env = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) const issue = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) - expect(text(env.get(23)?.segments)).toBe('公共 异步 函数 findEnvFiles(workspaceRoot: 字符串): Promise<字符串[]>') - expect(text(env.get(38)?.segments)).toBe('异步 函数 chmodAll(workspaceRoot: 字符串, mode: 数值): Promise') - expect(text(env.get(12)?.segments)).toBe('导入 { promises 作为 fsp } 来自 \'node:fs\'') expect(text(env.get(25)?.segments)).toBe('如果 (非 folder)') 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(14)?.segments)).toBe('常量 issue = issues.find(i => i.number 全等 issueNumber)') expect(text(issue.get(20)?.segments)).toBe('返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }') }) it('其余块:结构照抄,复合语句去掉结尾的 {,注释去掉注释符号', async () => { const env = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))) const issue = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))) - expect(env[24]).toBe('常量 folder = workspace.workspaceFolders?.find(f => f.uri.fsPath 全等 workspaceRoot)') - expect(env[28]).toBe('常量 uris = 等待 workspace.findFiles(include, EXCLUDE_PATTERN)') expect(env[32]).toBe('公共 接口 EnvLockResult') expect(env[42]).toBe('尝试') expect(env[46]).toBe('捕获 (err)') 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[1]).toBe('导入 类型 { Issue } 来自 \'../gitea/types\'') expect(issue[5]).toBe('prerequisiteNumber?: 数值') expect(issue[9]).toBe('纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行, 与 webview 的 `isIssueLocked` 保持同一语义。') expect(issue[15]).toBe('如果 (非 issue 或者 issue.prerequisite 全等 未定义)') @@ -91,13 +105,12 @@ describe('typeScript 中文直译', () => { it('每个被翻的词单独成段,着色取语法树词性', async () => { const env = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) const issue = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) - expect(env.get(23)?.segments.filter(segment => segment.role !== 'plain').slice(0, 4)).toEqual([ + expect(env.get(32)?.segments.filter(segment => segment.role !== 'plain')).toEqual([ { text: '公共', role: 'keyword' }, - { text: '异步', role: 'storage' }, - { text: '函数', role: 'storage' }, - { text: 'findEnvFiles', role: 'function' }, + { text: '接口', role: 'storage' }, + { text: 'EnvLockResult', role: 'type' }, ]) - expect(issue.get(14)?.segments).toContainEqual({ text: '全等', role: 'operator' }) + expect(issue.get(15)?.segments).toContainEqual({ text: '全等', role: 'operator' }) expect(env.get(26)?.segments).toEqual([{ text: '返回', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: '空数组', role: 'literal' }]) expectWordsApart(env) expectWordsApart(issue) @@ -105,16 +118,18 @@ describe('typeScript 中文直译', () => { it('属性名、对象键、非空断言不被误翻', async () => { const source = [ - 'const o = { if: 1, return: i.number, string: x!.type, new: a.default }', - 'let n = a !== b && !c || d == e != f', + 'o = { if: 1, return: i.number, string: x!.type, new: a.default }', + 'n = a !== b && !c || d == e != f', + 'type S = string | void', // eslint-disable-next-line no-template-curly-in-string -- 被翻译的源码文本里本来就有模板字符串 - 'const s: string | void = typeof v === \'string\' ? `${v}` : \'number\'', + 's = typeof v === \'string\' ? `${v}` : \'number\'', ].join('\n') const map = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source)) - expect(map[1]).toBe('常量 o = { if: 1, return: i.number, string: x!.type, new: a.default }') - expect(map[2]).toBe('变量 n = a 不全等 b 并且 非 c 或者 d 等于 e 不等于 f') + expect(map[1]).toBe('o = { if: 1, return: i.number, string: x!.type, new: a.default }') + expect(map[2]).toBe('n = a 不全等 b 并且 非 c 或者 d 等于 e 不等于 f') + expect(map[3]).toBe('类型 S = 字符串 | 无') // eslint-disable-next-line no-template-curly-in-string -- 模板字符串原样照抄进译文 - expect(map[3]).toBe('常量 s: 字符串 | 无 = 如果 类型为 v 全等 \'string\' 则 `${v}` 否则 \'number\'') + expect(map[4]).toBe('s = 如果 类型为 v 全等 \'string\' 则 `${v}` 否则 \'number\'') }) it('逻辑与、或译成「并且」「或者」', async () => { @@ -133,34 +148,34 @@ describe('typeScript 中文直译', () => { it('as / in / extends / default 按所在语法节点换译法', async () => { const source = [ - 'import * as path from \'node:path\'', + 'export * as path from \'node:path\'', 'export { run as start } from \'./run\'', - 'const v = x as string', - 'const k = [\'a\'] as const', + 'v = x as string', + 'k = [\'a\'] as const', 'for (const key in obj) {}', - 'const has = \'k\' in obj', + 'has = \'k\' in obj', 'class A extends B implements C {}', 'interface I extends J, K {}', - 'function f(t: T) {}', + 'type F = (t: T) => void', 'type R = T extends string ? 1 : 2', - 'export default function g() {}', + 'export default g', 'switch (x) {', ' default:', ' break', '}', ].join('\n') const map = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source)) - expect(map[1]).toBe('导入 * 作为 path 来自 \'node:path\'') + expect(map[1]).toBe('公共 * 作为 path 来自 \'node:path\'') expect(map[2]).toBe('公共 { run 作为 start } 来自 \'./run\'') - expect(map[3]).toBe('常量 v = x 断言为 字符串') - expect(map[4]).toBe('常量 k = [\'a\'] 断言为 常量') + expect(map[3]).toBe('v = x 断言为 字符串') + expect(map[4]).toBe('k = [\'a\'] 断言为 常量') expect(map[5]).toBe('遍历 (常量 key 取自 obj)') - expect(map[6]).toBe('常量 has = \'k\' 在 obj') + expect(map[6]).toBe('has = \'k\' 在 obj') expect(map[7]).toBe('类 A 继承 B 实现 C') expect(map[8]).toBe('接口 I 继承 J, K') - expect(map[9]).toBe('函数 f(t: T)') + expect(map[9]).toBe('类型 F = (t: T) => 无') expect(map[10]).toBe('类型 R = T 符合 字符串 ? 1 : 2') - expect(map[11]).toBe('公共 默认 函数 g()') + expect(map[11]).toBe('公共 默认 g') expect(map[13]).toBe('默认情况') }) @@ -168,15 +183,15 @@ describe('typeScript 中文直译', () => { const source = [ 'err instanceof Error ? err.message : String(err)', 'z = x ? (y ? 1 : 2) : 3', - 'const w = a ? b : c ? d : e', - 'const v = a !== b && !c ? typeof d : await e', + 'w = a ? b : c ? d : e', + 'v = a !== b && !c ? typeof d : await e', ].join('\n') const map = await translateAll(ts, 'typescript', typescriptZhTranslator, source) expect(texts(map)).toEqual({ 1: '如果 err 属于 Error 则 err.message 否则 String(err)', 2: 'z = 如果 x 则 (如果 y 则 1 否则 2) 否则 3', - 3: '常量 w = 如果 a 则 b 否则 如果 c 则 d 否则 e', - 4: '常量 v = 如果 a 不全等 b 并且 非 c 则 类型为 d 否则 等待 e', + 3: 'w = 如果 a 则 b 否则 如果 c 则 d 否则 e', + 4: 'v = 如果 a 不全等 b 并且 非 c 则 类型为 d 否则 等待 e', }) expect(map.get(1)?.segments.filter(segment => segment.role === 'keyword')).toEqual([ { text: '如果', role: 'keyword' }, @@ -192,18 +207,11 @@ describe('python 中文直译', () => { const map = await translateAll(py, 'python', pythonZhTranslator, readFixture('sample.py')) expect(texts(map)).toEqual({ 1: 'Scan a workspace for .env files.', - 3: '导入 os', - 4: '从 pathlib 导入 Path 作为 P', - 6: 'MAX_DEPTH = 8', - 7: 'skipped = 空列表', - 10: '函数 find_env_files(root: 字符串) -> 列表[字符串]', 11: 'Return every .env file under root.', 12: '跳过备份文件', - 13: 'found = [p 遍历 p 取自 os.listdir(root) 如果 p.startswith(\'.env\') 并且 非 p.endswith(\'.bak\')]', 14: '如果 非 found', 15: '返回 空列表', 16: '否则如果 len(found) > MAX_DEPTH', - 17: 'found = found[:MAX_DEPTH]', 18: '否则', 19: '跳过', 20: '遍历 name 取自 found', @@ -221,8 +229,6 @@ describe('python 中文直译', () => { 34: 'total: 整数', 35: 'ok: 列表[字符串] = 空列表', 36: 'failed: 字典[字符串, 字符串] = 空字典', - 37: 'label = \'env\'', - 39: '函数 summary(self, verbose: 布尔 = 假) -> 字符串', 40: '返回 如果 verbose 不是 空 则 f\'{self.total} files\' 否则 \'\'', }) expectWordsApart(map) @@ -233,16 +239,23 @@ describe('python 中文直译', () => { 'def pick(items: dict[str, int], list: set) -> tuple:', ' return [str(k) for k in items if k not in list]', ].join('\n') - const map = texts(await translateAll(py, 'python', pythonZhTranslator, source)) - expect(map[1]).toBe('函数 pick(items: 字典[字符串, 整数], list: 集合) -> 元组') - expect(map[2]).toBe('返回 [str(k) 遍历 k 取自 items 如果 k 不在 list]') + expect(rowTexts(await describeAll(py, 'python', pythonZhTranslator, source))[1]).toEqual([ + '名称: pick', + '访问级别: 公共', + '异步: 否', + '输入参数: 2 个', + ' items | 字典[字符串, 整数] | ', + ' list | 集合 | ', + '输出参数: 元组', + ]) + expect(texts(await translateAll(py, 'python', pythonZhTranslator, source))[2]).toBe('返回 [str(k) 遍历 k 取自 items 如果 k 不在 list]') }) it('逻辑与、或译成「并且」「或者」', async () => { expect(texts(await translateAll(py, 'python', pythonZhTranslator, 'a and b or c'))).toEqual({ 1: 'a 并且 b 或者 c' }) }) - it('from:导入里「从」,raise … from 里「源自」,yield from 里「自」', async () => { + it('from:raise … from 里「源自」,yield from 里「自」', async () => { const source = [ 'from typing import Annotated', 'def walk(nodes):', @@ -250,7 +263,6 @@ describe('python 中文直译', () => { ' raise RuntimeError(str(err)) from err', ].join('\n') const map = texts(await translateAll(py, 'python', pythonZhTranslator, source)) - expect(map[1]).toBe('从 typing 导入 Annotated') expect(map[3]).toBe('产出 自 nodes') expect(map[4]).toBe('抛出 RuntimeError(str(err)) 源自 err') }) @@ -289,20 +301,166 @@ describe('python 中文直译', () => { 'z = (a if x else b) if y else c', 'v = [k for k in items if k not in seen] if not done and ok else None', ].join('\n') - const map = await translateAll(py, 'python', pythonZhTranslator, source) - expect(texts(map)).toEqual({ - 1: '_default_port = 如果 _role 等于 "beat" 则 5680 否则 5679', - 2: 'y = 如果 x 则 a 否则 如果 y 则 b 否则 c', - 3: 'z = 如果 y 则 (如果 x 则 a 否则 b) 否则 c', - 4: 'v = 如果 非 done 并且 ok 则 [k 遍历 k 取自 items 如果 k 不在 seen] 否则 空', + const map = await describeAll(py, 'python', pythonZhTranslator, source) + expect(Object.fromEntries([...map].map(([line, rows]) => [line, text(valueOf(rows, '初始值'))]))).toEqual({ + 1: '如果 _role 等于 "beat" 则 5680 否则 5679', + 2: '如果 x 则 a 否则 如果 y 则 b 否则 c', + 3: '如果 y 则 (如果 x 则 a 否则 b) 否则 c', + 4: '如果 非 done 并且 ok 则 [k 遍历 k 取自 items 如果 k 不在 seen] 否则 空', }) - expect(map.get(1)?.segments.filter(segment => segment.role === 'keyword' || segment.role === 'operator')).toEqual([ + expect(valueOf(map.get(1), '初始值').filter(segment => segment.role === 'keyword' || segment.role === 'operator')).toEqual([ { text: '如果', role: 'keyword' }, { text: '等于', role: 'operator' }, { text: '则', role: 'keyword' }, { text: '否则', role: 'keyword' }, ]) - expectWordsApart(map) + }) +}) + +describe('属性表', () => { + it('spec 样例逐字一致', async () => { + 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([ + '名称: findEnvFiles', + '访问级别: 公共', + '异步: 是', + '输入参数: 1 个', + ' workspaceRoot | 字符串 | ', + '输出参数: Promise<字符串[]>', + ]) + expect(env[38]).toEqual([ + '名称: chmodAll', + '访问级别: 私有', + '异步: 是', + '输入参数: 2 个', + ' workspaceRoot | 字符串 | ', + ' mode | 数值 | ', + '输出参数: Promise', + ]) + expect(env[12]).toEqual(['来源: \'node:fs\'', '仅类型: 否', '导入项: 1 个', ' promises 作为 fsp']) + expect(env[15]).toEqual(['名称: ENV_PATTERN', '访问级别: 私有', '值: \'**/.env*\'']) + expect(env[27]).toEqual(['名称: include', '初始值: 新建 RelativePattern(folder, ENV_PATTERN)']) + expect(env[28]).toEqual(['名称: uris', '初始值: 等待 workspace.findFiles(include, EXCLUDE_PATTERN)']) + expect(env[40]).toEqual(['名称: result', '类型: EnvLockResult', '初始值: { total: files.length, ok: 空数组, failed: 空数组 }']) + expect(issue[1]).toEqual(['来源: \'../gitea/types\'', '仅类型: 是', '导入项: 1 个', ' Issue']) + expect(issue[14]).toEqual(['名称: issue', '初始值: issues.find(i => i.number 全等 issueNumber)']) + expect(routes[19]).toEqual([ + '来源: .schemas', + '导入项: 3 个', + ' UserAvatarUploadResponse', + ' UserDetailResponse', + ' UserUpdateRequest', + ]) + expect(routes[29]).toEqual([ + '名称: get_user', + '访问级别: 公共', + '异步: 是', + '装饰器: @router.get("/{user_id}", response_model=UserDetailResponse)', + '输入参数: 3 个', + ' user_id | Int4Path | ', + ' db | AsyncSession | 默认 Depends(get_db)', + ' current_user | User | 默认 Depends(get_current_user)', + '输出参数: UserDetailResponse', + ]) + expect(routes[111]).toContain(' file | Annotated[UploadFile, File(..., description="头像文件")] | ') + 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 | 字符串 | ', '输出参数: 列表[字符串]']) + 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 = env.get(23) ?? [] + 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([ + { text: 'Promise', role: 'type' }, + { text: '<', role: 'plain' }, + { text: '字符串', role: 'type' }, + { text: '[]>', role: 'plain' }, + ]) + expect(valueOf(env.get(12), '来源')).toEqual([{ text: '\'node:fs\'', role: 'string' }]) + expect(env.get(12)?.at(-1)?.items?.[0].cells[0]).toEqual([ + { text: 'promises', role: 'variable' }, + { text: ' ', role: 'plain' }, + { text: '作为', role: 'keyword' }, + { text: ' ', role: 'plain' }, + { text: 'fsp', role: 'variable' }, + ]) + + const sample = await describeAll(py, 'python', pythonZhTranslator, readFixture('sample.py')) + expect(valueOf(sample.get(3), '来源')).toEqual([{ text: 'os', role: 'type' }]) + expect(sample.get(39)?.find(row => row.label === '输入参数')?.items).toEqual([ + { cells: [[{ text: 'self', role: 'variable' }], [{ text: '未标注', role: 'hint' }], []] }, + { cells: [[{ text: 'verbose', role: 'variable' }], [{ text: '布尔', role: 'type' }], [{ text: '默认 ', role: 'hint' }, { text: '假', role: 'storage' }]] }, + ]) + }) + + it('导入:默认导出、全部、整个模块、副作用导入;Python 没有「仅类型」', async () => { + const tsSource = [ + 'import d, { b as c } from \'y\'', + 'import * as ns from \'z\'', + 'import \'./side\'', + 'import fs = require(\'fs\')', + ].join('\n') + expect(rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource))).toEqual({ + 1: ['来源: \'y\'', '仅类型: 否', '导入项: 2 个', ' 默认导出 作为 d', ' b 作为 c'], + 2: ['来源: \'z\'', '仅类型: 否', '导入项: 1 个', ' 全部 作为 ns'], + 3: ['来源: \'./side\'', '仅类型: 否', '导入项: 无'], + 4: ['来源: \'fs\'', '仅类型: 否', '导入项: 1 个', ' 整个模块 作为 fs'], + }) + const pySource = [ + 'import numpy.linalg as la', + 'import os, sys as system', + 'from m import *', + ].join('\n') + expect(rowTexts(await describeAll(py, 'python', pythonZhTranslator, pySource))).toEqual({ + 1: ['来源: numpy.linalg', '导入项: 1 个', ' 整个模块 作为 la'], + 2: ['来源: os, sys', '导入项: 2 个', ' os', ' sys 作为 system'], + 3: ['来源: m', '导入项: 1 个', ' 全部'], + }) + }) + + it('函数:匿名、可省略参数、嵌套函数没有访问级别、类成员受保护、装饰器照抄源码', async () => { + const source = [ + 'export default async function () {}', + 'function f(b?: number, c = 1): void {', + ' function inner() {}', + '}', + 'class K {', + ' @memo()', + ' protected load(): x is Foo {}', + '}', + ].join('\n') + expect(rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, source))).toEqual({ + 1: ['名称: 匿名', '访问级别: 公共', '异步: 是', '输入参数: 无', '输出参数: 未标注'], + 2: ['名称: f', '访问级别: 私有', '异步: 否', '输入参数: 2 个', ' b? | 数值 | ', ' c | 未标注 | 默认 1', '输出参数: 无'], + 3: ['名称: inner', '异步: 否', '输入参数: 无', '输出参数: 未标注'], + 6: ['名称: load', '访问级别: 受保护', '异步: 否', '装饰器: @memo()', '输入参数: 无', '输出参数: x is Foo'], + }) + }) + + it('变量:一条语句多个变量各占一组,没初始值写无,带语句块主体的回调只翻到主体开始之前', async () => { + const source = [ + 'let i = 0, n: number', + 'function f() {', + ' const v = useMemo(() => {', + ' return 1', + ' }, [])', + '}', + ].join('\n') + const rows = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, source)) + expect(rows[1]).toEqual(['名称: i', '访问级别: 私有', '初始值: 0', '名称: n', '访问级别: 私有', '类型: 数值', '初始值: 无']) + expect(rows[3]).toEqual(['名称: v', '初始值: useMemo(() =>']) }) }) @@ -339,7 +497,7 @@ describe('中文模板引擎', () => { ' if k in seen:', ' pass', ' else:', - ' x = a if c else b', + ' print(a if c else b)', 'else:', ' pass', ].join('\n') @@ -347,7 +505,7 @@ describe('中文模板引擎', () => { expect(map[1]).toBe('for k 乙 items') expect(map[2]).toBe('if k 甲 seen') expect(map[4]).toBe('丁') - expect(map[5]).toBe('x = a if c 丙 b') + expect(map[5]).toBe('print(a if c 丙 b)') expect(map[6]).toBe('戊') }) @@ -359,41 +517,53 @@ describe('中文模板引擎', () => { operatorParents: ['comparison_operator'], nodeTemplates: { conditional_expression: ['若', { namedChild: 1 }, '则', { namedChild: 0 }, '不然', { namedChild: 2 }], - assignment: ['令', { field: 'left' }, '类型', { field: 'type' }, '为', { field: 'right' }], + augmented_assignment: ['令', { field: 'left' }, '加上', { field: 'right' }], }, }) const source = [ - 'n: int = a if k in seen else b', + 'n += a if k in seen else b', 'print(a if c else b if d else e)', - 'w = (a # 注释不占序号', - ' if k == 1 else b)', + 'print((a # 注释不占序号', + ' if k == 1 else b))', ].join('\n') const map = await translateAll(py, 'python', translator, source) expect(texts(map)).toEqual({ - 1: '令 n 类型 int 为 若 k 在 seen 则 a 不然 b', + 1: '令 n 加上 若 k 在 seen 则 a 不然 b', 2: 'print(若 c 则 a 不然 若 d 则 b 不然 e)', - 3: 'w = (若 k 等于 1 则 a 不然 b)', + 3: 'print((若 k 等于 1 则 a 不然 b))', }) expect(map.get(1)?.segments.filter(segment => segment.role === 'keyword').map(segment => segment.text)) - .toEqual(['令', '类型', '为', '若', '则', '不然']) + .toEqual(['令', '加上', '若', '则', '不然']) + }) + + it('属性表里的类型、值用同一张表和同一套模板翻', async () => { + const translator = testTranslator({ + tokenRole: () => 'variable', + keywords: { in: '在' }, + builtinTypes: { int: '整型' }, + isTypePosition: leaf => leaf.parent?.type === 'type', + nodeTemplates: { conditional_expression: ['若', { namedChild: 1 }, '则', { namedChild: 0 }, '不然', { namedChild: 2 }] }, + }) + const rows = await describeAll(py, 'python', translator, 'n: int = a if k in seen else b') + expect(rowTexts(rows)[1]).toEqual(['名称: n', '访问级别: 公共', '类型: 整型', '初始值: 若 k 在 seen 则 a 不然 b']) }) it('没命中模板的节点照抄源码结构;模板引用的子节点不存在时也不重排', async () => { const translator = testTranslator({ keywords: { in: '在', else: { zh: '丙', byContext: { else_clause: '丁' } } }, nodeTemplates: { - assignment: ['令', { field: 'left' }, '类型', { field: 'type' }, '为', { field: 'right' }], + augmented_assignment: ['令', { field: 'left' }, '类型', { field: 'type' }, '为', { field: 'right' }], }, }) const source = [ - 'x = a if k in seen else b', + 'x += a if k in seen else b', 'if k in seen:', ' pass', 'else:', ' pass', ].join('\n') expect(texts(await translateAll(py, 'python', translator, source))).toEqual({ - 1: 'x = a if k 在 seen 丙 b', + 1: 'x += a if k 在 seen 丙 b', 2: 'if k 在 seen', 3: 'pass', 4: '丁', diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index d4c969f..4dfe786 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { CodeBlock, Node, Segment, SourceSpan, TokenRole, Translator } from '../contract' +import type { Access, FactBlock, FunctionFacts, ImportFacts, ImportItem, Node, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ export interface ContextualWord { @@ -210,7 +210,37 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } } - function translateComment(block: CodeBlock): Segment[] { + /** 按译文顺序收集 node 落在 span 里的词。 */ + function tokensIn(node: Node, span: SourceSpan, source: string): Token[] { + const out: Collector = { source, tokens: [], cursor: span.startIndex, gap: false } + collect(node, span, out) + return out.tokens + } + + /** node 的源码原样照抄:叶子按源码着色,叶子之间的空白、标点照抄。 */ + function verbatim(node: Node, source: string): Segment[] { + const segments: Segment[] = [] + let cursor = node.startIndex + const visit = (current: Node): void => { + if (current.startIndex === current.endIndex) + return + if (current.childCount > 0) { + for (const child of current.children) { + if (child) + visit(child) + } + return + } + pushSegment(segments, source.slice(cursor, current.startIndex), 'plain') + pushSegment(segments, current.text, table.tokenRole(current)) + cursor = current.endIndex + } + visit(node) + pushSegment(segments, source.slice(cursor, node.endIndex), 'plain') + return segments + } + + function translateComment(block: TextBlock): Segment[] { const { startIndex, endIndex } = block.header const root = block.node.tree.rootNode const nodes = block.node.type === 'comment' ? commentsWithin(root.descendantForIndex(startIndex, endIndex) ?? root, block.header) : [block.node] @@ -218,41 +248,169 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return text ? [{ text, role: 'comment' }] : [] } - function translate(block: CodeBlock, source: string): Segment[] { + function translate(block: TextBlock, source: string): Segment[] { if (block.kind === 'comment') return translateComment(block) - const out: Collector = { source, tokens: [], cursor: block.header.startIndex, gap: false } - collect(block.node, block.header, out) - const { tokens } = out + const tokens = tokensIn(block.node, block.header, source) const compound = block.children.length > 0 || block.header.endIndex < block.node.endIndex const last = tokens.at(-1) if (compound && last && !last.translated && HEADER_OPENERS.has(last.raw)) tokens.pop() - - const segments: Segment[] = [] - const push = (text: string, role: TokenRole): void => { - const prev = segments.at(-1) - if (role === 'plain' && prev?.role === 'plain') - prev.text += text - else - segments.push({ text, role }) - } - let gap = false - tokens.forEach((token, i) => { - gap ||= token.gap - const trailingComma = token.raw === ',' && !token.translated && CLOSING.has(nextRaw(tokens, i)) - if (token.text === '' || trailingComma) { - gap ||= token.role === 'comment' - return - } - if (gap && segments.length > 0) - push(' ', 'plain') - push(token.text, token.role) - // 「非」是个词,后面补一个空格,不和操作数粘在一起 - gap = token.translated && token.raw === '!' - }) - return segments + return joinTokens(tokens) } - return { id: table.id, label: table.label, languageIds: table.languageIds, translate } + function describe(block: FactBlock, source: string): PropertyRow[] { + const { header } = block + const phrases: Phrases = { + // 值里带语句块主体的回调(useMemo(() => { … }))只翻到主体开始之前,和块头部一致 + translated: (node) => { + const clipped = node.endIndex > header.endIndex + const span = { + startIndex: node.startIndex, + endIndex: clipped ? header.endIndex : node.endIndex, + startRow: node.startPosition.row, + endRow: clipped ? header.endRow : node.endPosition.row, + } + return joinTokens(tokensIn(node, span, source)) + }, + verbatim: node => verbatim(node, source), + module: node => [{ text: node.text, role: stringNodes.has(node.type) ? 'string' : 'type' }], + } + switch (block.kind) { + case 'function': + return functionRows(block.facts, phrases) + case 'import': + return importRows(block.facts, phrases) + default: + return variableRows(block, phrases) + } + } + + return { id: table.id, label: table.label, languageIds: table.languageIds, translate, describe } +} + +function pushSegment(segments: Segment[], text: string, role: TokenRole): void { + if (!text) + return + const prev = segments.at(-1) + if (role === 'plain' && prev?.role === 'plain') + prev.text += text + else + segments.push({ text, role }) +} + +/** 词连成译文:源码里隔着空白的词之间空一格,注释不进译文,右括号前的尾逗号去掉。 */ +function joinTokens(tokens: Token[]): Segment[] { + const segments: Segment[] = [] + let gap = false + tokens.forEach((token, i) => { + gap ||= token.gap + const trailingComma = token.raw === ',' && !token.translated && CLOSING.has(nextRaw(tokens, i)) + if (token.text === '' || trailingComma) { + gap ||= token.role === 'comment' + return + } + if (gap && segments.length > 0) + pushSegment(segments, ' ', 'plain') + pushSegment(segments, token.text, token.role) + // 「非」是个词,后面补一个空格,不和操作数粘在一起 + gap = token.translated && token.raw === '!' + }) + return segments +} + +// ─── 属性表 ─────────────────────────────────────────────────────────────────── + +/** 属性表里值的三种来源。 */ +interface Phrases { + /** 按表翻译,只翻落在块头部里的那一段。 */ + translated: (node: Node) => Segment[] + /** 源码原样照抄、按源码着色(装饰器)。 */ + verbatim: (node: Node) => Segment[] + /** 导入来源:字符串字面量按字符串着色,模块名按类型着色。 */ + module: (node: Node) => Segment[] +} + +const ACCESS_ZH: Record = { public: '公共', private: '私有', protected: '受保护' } + +function hint(text: string): Segment { + return { text, role: 'hint' } +} + +function yesNo(value: boolean): Segment[] { + return [{ text: value ? '是' : '否', role: 'storage' }] +} + +function count(n: number): Segment[] { + return [hint(n > 0 ? `${n} 个` : '无')] +} + +function joined(parts: Segment[][], separator: string): Segment[] { + return parts.flatMap((part, i) => i > 0 ? [{ text: separator, role: 'plain' as const }, ...part] : part) +} + +function accessRows(access: Access | undefined): PropertyRow[] { + return access ? [{ label: '访问级别', value: [{ text: ACCESS_ZH[access], role: 'keyword' }] }] : [] +} + +function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segment[] { + if (!alias) + return item + return [...item, { text: ' ', role: 'plain' }, { text: '作为', role: 'keyword' }, { text: ' ', role: 'plain' }, ...say.translated(alias)] +} + +/** 函数:名称 / 访问级别 / 异步 / 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */ +function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { + const decorators = facts.decorators.map(say.verbatim) + return [ + { label: '名称', value: facts.name ? [{ text: facts.name.text, role: 'function' }] : [hint('匿名')] }, + ...accessRows(facts.access), + { label: '异步', value: yesNo(facts.async) }, + ...(decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators, '\n') }] : []), + { + label: '输入参数', + value: count(facts.parameters.length), + items: facts.parameters.map(param => ({ + cells: [ + param.optional ? [...say.translated(param.name), { text: '?', role: 'plain' }] : say.translated(param.name), + param.type ? say.translated(param.type) : [hint('未标注')], + param.default ? [hint('默认 '), ...say.translated(param.default)] : [], + ], + })), + }, + { label: '输出参数', value: facts.returnType ? say.translated(facts.returnType) : [hint('未标注')] }, + ] +} + +/** 导入:来源 / 仅类型(只有分这个的语言才有)/ 导入项(每项一行,有别名写成「名称 作为 别名」)。 */ +function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] { + const itemText = (item: ImportItem): Segment[] => { + switch (item.kind) { + case 'named': + return aliased(say.translated(item.name), item.alias, say) + case 'default': + return aliased([hint('默认导出')], item.alias, say) + case 'namespace': + return aliased([hint('全部')], item.alias, say) + case 'module': + // 一条语句导入多个模块时(Python 的 import a, b)要写出是哪个模块 + return aliased(facts.sources.length > 1 ? say.module(item.module) : [hint('整个模块')], item.alias, say) + } + } + return [ + { label: '来源', value: facts.sources.length > 0 ? joined(facts.sources.map(say.module), ', ') : [hint('无')] }, + ...(facts.typeOnly === undefined ? [] : [{ label: '仅类型', value: yesNo(facts.typeOnly) }]), + { label: '导入项', value: count(facts.items.length), items: facts.items.map(item => ({ cells: [itemText(item)] })) }, + ] +} + +/** 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值)。 */ +function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] { + const valueLabel = block.kind === 'const' ? '值' : '初始值' + return block.facts.declarators.flatMap(declarator => [ + { label: '名称', value: say.translated(declarator.name) }, + ...accessRows(block.facts.access), + ...(declarator.type ? [{ label: '类型', value: say.translated(declarator.type) }] : []), + { label: valueLabel, value: declarator.value ? say.translated(declarator.value) : [hint('无')] }, + ]) } diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index eeb1879..28f7c9d 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -4,6 +4,7 @@ import { ChevronDown, ChevronRight, Eye, Pencil } from 'lucide-react' import { Fragment } from 'react' import { cn } from '@/lib/utils' import { CodeEditor } from './CodeEditor' +import { PropertyGrid } from './PropertyGrid' import { SegmentText } from './SegmentText' const KIND_LABEL: Record = { @@ -60,7 +61,18 @@ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?: ) } -/** 一块:折叠箭头、种类标签、译文或源码、右边的「编辑 / 预览」切换。 */ +/** 预览状态的内容:函数、导入、常量、变量块是属性表,其余是一行译文。 */ +function BlockPreview({ block }: { block: BlockView }): ReactElement { + if ('properties' in block) + return + return ( +

+ +

+ ) +} + +/** 一块:折叠箭头、种类标签、预览内容或源码、右边的「编辑 / 预览」切换。 */ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { const editing = tree.editing.has(block.id) const collapsed = tree.collapsed.has(block.id) @@ -100,11 +112,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): onExit={() => tree.onToggleEdit(block.id)} /> ) - : ( -

- -

- )} + : }