From 202fa954bac67698a9491865ffc4da2599517841 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 16:24:36 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(vscode):=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=98=85=E8=AF=BB=E7=9A=84=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=8F=AA=E7=BB=8F=E5=A5=91=E7=BA=A6=E6=8B=BF?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=8F=92=E4=BB=B6=E7=9A=84=E8=AF=8D=E6=80=A7?= =?UTF-8?q?=E5=92=8C=E6=8E=A8=E5=AF=BC=E5=BC=8F=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 27 ++++--- vscode/eslint.config.mjs | 10 +++ vscode/src/codeReader/blockView.ts | 7 +- vscode/src/codeReader/contract.ts | 23 ++++-- .../src/codeReader/languages/python.test.ts | 4 +- vscode/src/codeReader/languages/python.ts | 7 +- vscode/src/codeReader/languages/typescript.ts | 2 +- .../translators/contractOnly.test.ts | 70 ++++++++++++++++++ vscode/src/codeReader/translators/pythonZh.ts | 14 +++- .../codeReader/translators/typescriptZh.ts | 2 - .../codeReader/translators/zhTemplate.test.ts | 22 +++--- .../src/codeReader/translators/zhTemplate.ts | 73 +++++++++---------- 12 files changed, 185 insertions(+), 76 deletions(-) create mode 100644 vscode/src/codeReader/translators/contractOnly.test.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index c498179..e3cfe6f 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -365,7 +365,7 @@ export interface PropertyItem extends Defines { cells: Inline[][], items?: Prope */ export interface PropertyRow extends Defines { label: string, value: PreviewText, items?: PropertyItem[], columns?: string[] } -/** 语言插件:语法树 → 代码块树。 */ +/** 语言插件:语法树 → 代码块树,外加只有它懂、翻译插件翻表达式时要用的信息(叶子的词性、写在表达式里的遍历的结构)。 */ export interface LanguageAdapter { id: string /** 覆盖的 VS Code languageId,例如 ['typescript', 'typescriptreact', 'javascript', 'javascriptreact']。 */ @@ -374,10 +374,15 @@ export interface LanguageAdapter { grammarPath: (languageId: string) => string /** languageId:一个插件覆盖几种方言时按它区分(JavaScript 没有 TypeScript 的抽象类、仅类型导入)。 */ toBlocks: (root: Node, source: string, languageId: string) => CodeBlock[] - /** 源码着色:给语法树叶子节点定词性。 */ + /** 给叶子节点定词性:源码着色用它,译文里照抄的词也按它着色、按它认出能查定义的名字。 */ tokenRole: (leaf: Node) => TokenRole + /** node 是写在表达式里的遍历(推导式、生成器表达式)时给出它的结构,别的节点没有;没有这种写法的语言不填。 */ + iteration?: (node: Node) => IterationFacts | undefined } +/** 翻译插件翻一块时拿到的这份源码:源码字符串,和解析它的语言插件。 */ +export interface SourceDocument { source: string, language: LanguageAdapter } + /** 筛选属性和值的显示名;没给名字的显示 id。 */ export interface AttributeNames { attributes: Record // access → 访问级别 @@ -389,8 +394,8 @@ export interface Translator { id: string // 'typescript.zh-template' label: string // '中文直译' languageIds: string[] - translate: (block: TextBlock, source: string) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文 - describe: (block: FactBlock, source: string) => PropertyRow[] // 函数、类、导入、常量、变量块 → 属性表 + translate: (block: TextBlock, document: SourceDocument) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文 + describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] // 函数、类、导入、常量、变量块 → 属性表 attributeNames?: AttributeNames // 筛选属性和值叫什么,和属性表的叫法一致;筛选栏、种类标签用它 } @@ -403,7 +408,10 @@ export interface CodeReaderApi { - `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 - 内置的 TypeScript、Python 语言插件和两个中文直译翻译插件在 `activate()` 里通过同一个 API 注册,没有旁路。 - 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。 -- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译,也给筛选属性和值起名(`attributeNames`)。筛选属性的通用部分由宿主从 facts 推出(`attributes.ts` 的 `blockAttributes`),语言插件只在 `BlockShape.attributes` 里补自己语言特有的。第三方语言插件给函数、类、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。写在表达式里的遍历(推导式这类)由语言插件给一个读结构的函数(节点 → `IterationFacts`,Python 是 `languages/python.ts` 的 `pythonIteration`),中文模板直译通过 `ZhTable.iteration` 接上,排成一个 `SegmentGroup`(样式 `iteration`,框头是结果类型的译名);webview 只认框的样式 id,不认语言。 +- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译,也给筛选属性和值起名(`attributeNames`)。筛选属性的通用部分由宿主从 facts 推出(`attributes.ts` 的 `blockAttributes`),语言插件只在 `BlockShape.attributes` 里补自己语言特有的。第三方语言插件给函数、类、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。写在表达式里的遍历(推导式这类)由语言插件的 `iteration` 给结构(节点 → `IterationFacts`),翻译插件排成一个 `SegmentGroup`(中文直译是样式 `iteration`、框头是结果类型的译名);webview 只认框的样式 id,不认语言。 +- 语言插件的信息只经契约到翻译插件:宿主翻每一块时把 `{ source, language }`(`SourceDocument`,language 是解析这份源码的语言插件)交给 `translate` / `describe`,翻译插件要词性、遍历结构都从 `language` 取,不 import 任何语言插件的模块(lint 规则挡住 `translators/` 下的非测试文件 import `languages/`)。所以第三方翻译插件(英文、AI 翻译)拿得到和内置翻译插件一样的信息,第三方语言插件也能配上任何翻译插件。 +- 第三方语言插件要给的:`id`、`languageIds`、`grammarPath`、`toBlocks`(块的种类、头部、子块;函数、类、导入、常量、变量块带 facts)、`tokenRole`。可以给的:facts 里语言有才填的项(async、abstract、typeOnly、access、doc、`DeclaratorFacts.entries`)、`BlockShape.attributes`(通用词表以外的筛选属性)、`iteration`(有写在表达式里的遍历的语言)。没给的就少那一样:没有 entries 的值不展开,没有 `iteration` 的遍历照源码结构逐词翻。 +- 翻译插件拿到的:代码块(kind、node、header、children,属性表块还有 facts)和 `SourceDocument`。按语法节点类型写的叫法(关键字表、按语境换的译法、节点模板、注解位置这类只关乎措辞的判断)属于翻译插件自己,写在它的表里。 ## 解析运行时 @@ -478,7 +486,7 @@ export interface CodeReaderApi { | TypeScript | `ternary_expression`(`c ? a : b`) | 如果 〈condition〉 则 〈consequence〉 否则 〈alternative〉 | | Python | `conditional_expression`(`a if c else b`,没有字段名,具名子节点依次是 a、c、b) | 如果 〈1 号〉 则 〈0 号〉 否则 〈2 号〉 | -写在表达式里的遍历(推导式)不走节点模板:它的节数不定、要换成一个框,由表里的 `iteration`(语言插件给的读结构的函数)认出来,引擎按「推导式」一节的规则排进框里;和节点模板一样,没整个落在要翻的这一段里就不重排、照常翻。 +写在表达式里的遍历(推导式)不走节点模板:它的节数不定、要换成一个框,由语言插件的 `iteration`(翻译时从 `SourceDocument.language` 拿到)认出来,引擎按「推导式」一节的规则排进框里;和节点模板一样,没整个落在要翻的这一段里就不重排、照常翻。 TypeScript 关键字表(流程 = keyword,声明 = storage): @@ -593,10 +601,10 @@ vscode/src/codeReader/ blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译) attributes.ts 块的筛选属性(从 facts 推出通用属性 + 语言自己报的)、文件里出现过的属性和块数 languages/typescript.ts - languages/python.ts (pythonIteration:推导式的结构) - translators/zhTemplate.ts 模板直译引擎(按语言的表驱动;推导式排进框见 iterationGroup) + languages/python.ts (iteration:推导式的结构) + translators/zhTemplate.ts 模板直译引擎(按语言的表驱动,词性、遍历结构从 SourceDocument.language 取;推导式排进框见 iterationGroup) translators/typescriptZh.ts TS 表 - translators/pythonZh.ts Python 表 + translators/pythonZh.ts Python 表(含注解位置的判断) explorer/fileTree.ts 侧栏文件树(FileTreeDecorator:给行添信息的接口) notes/noteMap.ts 注释的键、保存 / 改名 / 删除时怎么改(纯函数) notes/store.ts .spx/code-notes.json 读写 @@ -621,6 +629,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则;字典 / 对象字面量的各项:Python 字符串键、名字键、嵌套字典、`**` 展开、空字典、类属性,TypeScript 名字键、字符串键、计算属性名、简写、展开、方法和 getter(头部到主体之前)、嵌套对象、空对象,不是字面量的值没有 entries。 - 字典 / 对象的属性表:项数、键 | 值两列、嵌套一层层展开、「展开」、方法头部、空的一行;键和值里的名字、字符串带下标(嵌套的也是);每项标的定义是键,展开项不标;跳到定义落在对象属性名上只闪那一项、连外层各层一起展开,只是显示着的名字闪整块、展开到它那一层。 - 推导式:Python 语言插件读出的结构(调用唯一参数的生成器带 sharedParentheses、几个参数之一的不带,列表带条件,字典分出键和值,集合的解构变量,几节 for / if 照源码顺序,async for,套着的各自认,字面量和 for 语句不算);中文直译「推导式」一节的样例表逐字一致,框的样式、框头、关键字着色,顿号连写、条件位置、另起一句、异步遍历、集合、框里的框、多个参数旁边的生成器、跨行带注释的生成器,属性表的默认值和字典项的值里也换;框里的名字带下标且指回源码原文,高亮各处出现、跳到定义都认框里的名字(只显示在框里的推导式变量 → 框所在的块);TypeScript 不认。 +- 只经契约:只照 contract 写的假语言插件(`iteration` 把一个普通调用认成遍历)配中文模板,那个调用换成遍历框;同一个翻译插件配上不给 `iteration` 的语言插件照常翻。测试文件不 import 任何语言插件模块。 - 输入参数表格:函数的「输入参数」行带列名 名称 / 类型 / 默认值(TS、Python 一样),默认值格子只有译文、没有就是空格子,别的行没有列名;webview 按有没有列名选表格还是列表,表格的格子按列数取。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。 diff --git a/vscode/eslint.config.mjs b/vscode/eslint.config.mjs index 64149c8..ee5c5c0 100644 --- a/vscode/eslint.config.mjs +++ b/vscode/eslint.config.mjs @@ -12,4 +12,14 @@ export default antfu( // overrides }, }, + { + // 翻译插件只通过 contract 拿语言插件给的信息(翻译时传进来的 SourceDocument.language),第三方翻译插件也是这样拿 + files: ['src/codeReader/translators/**/*.ts'], + ignores: ['**/*.test.ts'], + rules: { + 'no-restricted-imports': ['error', { + patterns: [{ group: ['**/languages/*'], message: '翻译插件不直接依赖语言插件:词性、遍历结构等从 SourceDocument.language 取' }], + }], + }, + }, ) diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 1602ca0..7d0255a 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,4 +1,4 @@ -import type { BlockAttributes, BlockKind, CodeBlock, LanguageAdapter, Node, PreviewText, PropertyRow, Segment, SourceRange, TokenRole, Translator } from './contract' +import type { BlockAttributes, BlockKind, CodeBlock, LanguageAdapter, Node, PreviewText, PropertyRow, Segment, SourceDocument, SourceRange, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import { blockAttributes } from './attributes' import { commonIndent, splitLines } from './editText' @@ -106,6 +106,7 @@ function ownName(block: CodeBlock): Node | undefined { export function toBlockViews(blocks: CodeBlock[], source: string, adapter: LanguageAdapter, translator: Translator | undefined): BlockView[] { const lines = lineRanges(source) const texts = splitLines(source) + const document: SourceDocument = { source, language: adapter } const build = (block: CodeBlock, id: string): BlockView => { const { startRow, endRow } = block.header const rows = texts.slice(startRow, endRow + 1) @@ -122,8 +123,8 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu attributes: blockAttributes(block), 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) : [] }), + ? { properties: translator ? translator.describe(block, document) : [] } + : { translation: translator ? translator.translate(block, document) : [] }), } } return blocks.map((block, i) => build(block, String(i))) diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 173e190..2a45e33 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -101,8 +101,8 @@ export type IterationClause | { kind: 'if', condition: Node } /** - * 写在表达式里的遍历(Python 的列表 / 集合 / 字典推导式、生成器表达式):语言插件从语法树读出结构,翻译插件按自己的语序重排。 - * 节点都指向源码;各节、每一项取的值里还能再套遍历。 + * 写在表达式里的遍历(Python 的列表 / 集合 / 字典推导式、生成器表达式):语言插件从语法树读出结构(LanguageAdapter.iteration), + * 翻译插件按自己的语序重排。节点都指向源码;各节、每一项取的值里还能再套遍历。 */ export interface IterationFacts { result: IterationResult @@ -247,7 +247,10 @@ export interface PropertyRow extends Defines { columns?: string[] } -/** 语言插件:语法树 → 代码块树。 */ +/** + * 语言插件:语法树 → 代码块树,外加只有它懂、翻译插件翻表达式时要用的信息(叶子的词性、写在表达式里的遍历的结构)。 + * 翻译插件翻一块时从 SourceDocument.language 拿到解析这份源码的语言插件,不直接依赖某个语言插件。 + */ export interface LanguageAdapter { id: string /** 覆盖的 VS Code languageId,例如 ['typescript', 'typescriptreact', 'javascript', 'javascriptreact']。 */ @@ -256,8 +259,16 @@ export interface LanguageAdapter { grammarPath: (languageId: string) => string /** languageId 是这份源码的 languageId:一个插件覆盖几种方言时按它区分(JavaScript 没有 TypeScript 的抽象类、仅类型导入)。 */ toBlocks: (root: Node, source: string, languageId: string) => CodeBlock[] - /** 源码着色:给语法树叶子节点定词性。 */ + /** 给语法树叶子节点定词性:源码着色用它,译文里照抄的词也按它着色、按它认出能查定义的名字。 */ tokenRole: (leaf: Node) => TokenRole + /** node 是写在表达式里的遍历(推导式、生成器表达式)时给出它的结构,别的节点没有;没有这种写法的语言不填。 */ + iteration?: (node: Node) => IterationFacts | undefined +} + +/** 翻译插件翻一块时拿到的这份源码:源码字符串,和解析它的语言插件。 */ +export interface SourceDocument { + source: string + language: LanguageAdapter } /** 筛选属性和值的显示名,筛选栏、种类标签用它;没给名字的显示 id。 */ @@ -274,9 +285,9 @@ export interface Translator { label: string // '中文直译' languageIds: string[] /** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */ - translate: (block: TextBlock, source: string) => PreviewText + translate: (block: TextBlock, document: SourceDocument) => PreviewText /** 函数、类、导入、常量、变量块 → 属性表。 */ - describe: (block: FactBlock, source: string) => PropertyRow[] + describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] /** 筛选属性和值叫什么,和属性表里的属性名、值用同一套叫法;不给就都显示 id。 */ attributeNames?: AttributeNames } diff --git a/vscode/src/codeReader/languages/python.test.ts b/vscode/src/codeReader/languages/python.test.ts index 4b8c71e..2b33d3c 100644 --- a/vscode/src/codeReader/languages/python.test.ts +++ b/vscode/src/codeReader/languages/python.test.ts @@ -2,7 +2,7 @@ import type { IterationFacts, Node } from '../contract' import type { Outline } from '../testSupport' import { describe, expect, it } from 'vitest' import { factsByLine, nodesToText, outline, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport' -import { createPythonAdapter, pythonIteration } from './python' +import { createPythonAdapter } from './python' const adapter = createPythonAdapter(WASM_DIR) @@ -335,7 +335,7 @@ describe('python 写在表达式里的遍历(推导式)', () => { try { const found: IterationFacts[] = [] const visit = (node: Node): void => { - const facts = pythonIteration(node) + const facts = adapter.iteration?.(node) if (facts) found.push(facts) for (const child of node.children) { diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index 09dc411..1274fd0 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -269,7 +269,7 @@ const ITERATION_RESULTS: Record = { * 推导式、生成器表达式的结构,别的节点没有。几种读不成遍历的写法也没有,照常翻: * `for x in a, b`(语法树认、Python 3 不认的多个来源),以及缺了变量、来源、条件或每一项的值(写到一半的代码)。 */ -export function pythonIteration(node: Node): IterationFacts | undefined { +function pythonIteration(node: Node): IterationFacts | undefined { const result = ITERATION_RESULTS[node.type] const body = node.childForFieldName('body') if (!result || !body) @@ -360,7 +360,7 @@ function insideString(leaf: Node): boolean { } /** 注解里的名字:typed_parameter / 返回值 / 带注解赋值的 type 节点之内。 */ -export function insideAnnotation(leaf: Node): boolean { +function insideAnnotation(leaf: Node): boolean { for (let node = leaf.parent; node; node = node.parent) { if (node.type === 'type') return true @@ -395,7 +395,7 @@ function identifierRole(leaf: Node): TokenRole { return 'variable' } -export function pythonTokenRole(leaf: Node): TokenRole { +function pythonTokenRole(leaf: Node): TokenRole { if (leaf.type === 'comment') return 'comment' if (insideString(leaf)) @@ -430,5 +430,6 @@ export function createPythonAdapter(wasmDir: string): LanguageAdapter { grammarPath: () => path.join(wasmDir, 'tree-sitter-python.wasm'), toBlocks: root => statements(named(root), 'module', -1, true), tokenRole: pythonTokenRole, + iteration: pythonIteration, } } diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index eca103c..124defd 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -479,7 +479,7 @@ function nameRole(leaf: Node): TokenRole { return 'variable' } -export function typescriptTokenRole(leaf: Node): TokenRole { +function typescriptTokenRole(leaf: Node): TokenRole { if (leaf.type === 'comment' || leaf.type === 'hash_bang_line') return 'comment' if (insideString(leaf)) diff --git a/vscode/src/codeReader/translators/contractOnly.test.ts b/vscode/src/codeReader/translators/contractOnly.test.ts new file mode 100644 index 0000000..dafcbec --- /dev/null +++ b/vscode/src/codeReader/translators/contractOnly.test.ts @@ -0,0 +1,70 @@ +import type { IterationFacts, LanguageAdapter, Node, TextBlock } from '../contract' +import * as path from 'node:path' +import { describe, expect, it } from 'vitest' +import { segmentText, WASM_DIR, withBlocks } from '../testSupport' +import { createZhTemplateTranslator } from './zhTemplate' + +/** 每条顶层语句一块一行译文。 */ +function statement(node: Node): TextBlock { + return { + kind: 'statement', + node, + header: { startIndex: node.startIndex, endIndex: node.endIndex, startRow: node.startPosition.row, endRow: node.endPosition.row }, + children: [], + } +} + +/** 假装 `keep(变量, 来源, 条件)` 是这门语言写在表达式里的遍历:结构只由语言插件给,翻译插件不认识这个写法。 */ +function keep(node: Node): IterationFacts | undefined { + const [variables, source, condition] = node.childForFieldName('arguments')?.namedChildren ?? [] + if (node.type !== 'call' || node.childForFieldName('function')?.text !== 'keep' || !variables || !source || !condition) + return undefined + return { result: 'list', clauses: [{ kind: 'for', variables, source, async: false }, { kind: 'if', condition }], value: variables } +} + +/** 只照 contract 写的第三方语言插件:借 Python 的语法包解析,词性、遍历结构都自己给。 */ +function fakeLanguage(iteration?: LanguageAdapter['iteration']): LanguageAdapter { + return { + id: 'fake', + languageIds: ['fake'], + grammarPath: () => path.join(WASM_DIR, 'tree-sitter-python.wasm'), + toBlocks: root => root.namedChildren.flatMap(node => node ? [statement(node)] : []), + tokenRole: leaf => leaf.type === 'identifier' ? 'variable' : 'plain', + ...(iteration && { iteration }), + } +} + +const translator = createZhTemplateTranslator({ + id: 'fake.zh-template', + label: '中文直译', + languageIds: ['fake'], + keywords: {}, + literals: {}, + builtinTypes: {}, + isTypePosition: () => false, + operators: {}, + operatorParents: [], + emptyLiterals: {}, + mapLiterals: {}, + stringNodes: [], + commentText: node => node.text, + nodeTemplates: {}, +}) + +function translateFirst(language: LanguageAdapter, source: string): Promise { + return withBlocks(language, 'fake', source, ([block]) => { + const translation = translator.translate(block as TextBlock, { source, language }) + return 'markdown' in translation ? translation.markdown : segmentText(translation) + }) +} + +describe('只照 contract 写的语言插件 + 中文模板', () => { + it('语言插件的 iteration 认出的节点整个换成遍历框,框里按读的顺序重排', async () => { + expect(await translateFirst(fakeLanguage(keep), 'print(keep(v, vs, v > 0))')) + .toBe('print(⟦列表|遍历 vs 的每个 v,仅当 v > 0,取 v⟧)') + }) + + it('同一个翻译插件配上不给 iteration 的语言插件就照常翻', async () => { + expect(await translateFirst(fakeLanguage(), 'print(keep(v, vs, v > 0))')).toBe('print(keep(v, vs, v > 0))') + }) +}) diff --git a/vscode/src/codeReader/translators/pythonZh.ts b/vscode/src/codeReader/translators/pythonZh.ts index 77198a7..3873ac9 100644 --- a/vscode/src/codeReader/translators/pythonZh.ts +++ b/vscode/src/codeReader/translators/pythonZh.ts @@ -1,7 +1,17 @@ import type { Node, Translator } from '../contract' -import { insideAnnotation, pythonIteration, pythonTokenRole } from '../languages/python' import { createZhTemplateTranslator } from './zhTemplate' +/** 写在类型注解里(参数、返回值、带注解赋值的 type 节点之内):内置类型名只在这里翻成中文。 */ +function insideAnnotation(leaf: Node): boolean { + for (let node = leaf.parent; node; node = node.parent) { + if (node.type === 'type') + return true + if (node.type === 'block' || node.type === 'module') + return false + } + return false +} + function stringContent(node: Node): string { if (node.type === 'string_content') return node.text @@ -17,7 +27,6 @@ export const pythonZhTranslator: Translator = createZhTemplateTranslator({ id: 'python.zh-template', label: '中文直译', languageIds: ['python'], - tokenRole: pythonTokenRole, keywords: { 'import': '导入', 'from': { zh: '从', byContext: { raise_statement: '源自', yield: '自' } }, @@ -87,5 +96,4 @@ export const pythonZhTranslator: Translator = createZhTemplateTranslator({ nodeTemplates: { conditional_expression: ['如果', { namedChild: 1 }, '则', { namedChild: 0 }, '否则', { namedChild: 2 }], }, - iteration: pythonIteration, }) diff --git a/vscode/src/codeReader/translators/typescriptZh.ts b/vscode/src/codeReader/translators/typescriptZh.ts index 6c25520..807f46c 100644 --- a/vscode/src/codeReader/translators/typescriptZh.ts +++ b/vscode/src/codeReader/translators/typescriptZh.ts @@ -1,5 +1,4 @@ import type { Node, Translator } from '../contract' -import { typescriptTokenRole } from '../languages/typescript' import { createZhTemplateTranslator } from './zhTemplate' function commentText(node: Node): string { @@ -18,7 +17,6 @@ export const typescriptZhTranslator: Translator = createZhTemplateTranslator({ id: 'typescript.zh-template', label: '中文直译', languageIds: ['typescript', 'typescriptreact', 'javascript', 'javascriptreact'], - tokenRole: typescriptTokenRole, keywords: { // 流程 import: '导入', diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index aa87a74..75a9ed3 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -1,4 +1,4 @@ -import type { Inline, LanguageAdapter, PropertyItem, PropertyRow, Segment, Translator } from '../contract' +import type { Inline, LanguageAdapter, PropertyItem, PropertyRow, Segment, TokenRole, Translator } from '../contract' import type { ZhTable } from './zhTemplate' import { describe, expect, it } from 'vitest' import { createPythonAdapter } from '../languages/python' @@ -22,7 +22,7 @@ function translateAll(adapter: LanguageAdapter, languageId: string, translator: const line = block.header.startRow + 1 if (byLine.has(line) || 'facts' in block) return - const translation = translator.translate(block, source) + const translation = translator.translate(block, { source, language: adapter }) byLine.set(line, 'markdown' in translation ? { kind: block.kind, segments: [], markdown: translation.markdown } : { kind: block.kind, segments: translation }) @@ -38,7 +38,7 @@ function describeAll(adapter: LanguageAdapter, languageId: string, translator: T walk(blocks, (block) => { const line = block.header.startRow + 1 if (!byLine.has(line) && 'facts' in block) - byLine.set(line, translator.describe(block, source)) + byLine.set(line, translator.describe(block, { source, language: adapter })) }) return byLine }) @@ -1034,7 +1034,6 @@ describe('中文模板引擎', () => { id: 'test.zh-template', label: '测试', languageIds: ['python'], - tokenRole: () => 'keyword', keywords: {}, literals: {}, builtinTypes: {}, @@ -1050,6 +1049,11 @@ describe('中文模板引擎', () => { }) } + /** 按 Python 分块、词性都是 role 的语言插件:引擎着色只看翻译时传进来的语言插件。 */ + function pythonColored(role: TokenRole): LanguageAdapter { + return { ...py, tokenRole: () => role } + } + it('关键字按父节点类型选译法,更长的节点类型链优先,都不匹配用默认', async () => { const translator = testTranslator({ keywords: { @@ -1066,7 +1070,7 @@ describe('中文模板引擎', () => { 'else:', ' pass', ].join('\n') - const map = texts(await translateAll(py, 'python', translator, source)) + const map = texts(await translateAll(pythonColored('keyword'), 'python', translator, source)) expect(map[1]).toBe('for k 乙 items') expect(map[2]).toBe('if k 甲 seen') expect(map[4]).toBe('丁') @@ -1076,7 +1080,6 @@ describe('中文模板引擎', () => { it('节点模板按节点类型重排:槽位按字段名或具名子节点序号取子节点并递归翻译,译词按关键字着色', async () => { const translator = testTranslator({ - tokenRole: () => 'variable', keywords: { in: '在', else: { zh: '丙', byContext: { else_clause: '丁' } } }, operators: { '==': '等于' }, operatorParents: ['comparison_operator'], @@ -1091,7 +1094,7 @@ describe('中文模板引擎', () => { 'print((a # 注释不占序号', ' if k == 1 else b))', ].join('\n') - const map = await translateAll(py, 'python', translator, source) + const map = await translateAll(pythonColored('variable'), 'python', translator, source) expect(texts(map)).toEqual({ 1: '令 n 加上 若 k 在 seen 则 a 不然 b', 2: 'print(若 c 则 a 不然 若 d 则 b 不然 e)', @@ -1103,13 +1106,12 @@ describe('中文模板引擎', () => { 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') + const rows = await describeAll(pythonColored('variable'), 'python', translator, 'n: int = a if k in seen else b') expect(rowTexts(rows)[1]).toEqual(['名称: n', '访问级别: 公共', '类型: 整型', '初始值: 若 k 在 seen 则 a 不然 b']) }) @@ -1127,7 +1129,7 @@ describe('中文模板引擎', () => { 'else:', ' pass', ].join('\n') - expect(texts(await translateAll(py, 'python', translator, source))).toEqual({ + expect(texts(await translateAll(pythonColored('keyword'), 'python', translator, source))).toEqual({ 1: 'x += a if k 在 seen 丙 b', 2: 'if k 在 seen', 3: 'pass', diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 48cc6e7..58a7dbd 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, AttributeNames, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, AttributeNames, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -20,12 +20,14 @@ export interface ContextualWord { export type TemplatePart = string | { field: string } | { namedChild: number } /** 一种语言的中文直译表。引擎替换表里的关键字、内置类型、运算符,按节点模板重排少数语法结构,其余原样照抄。 */ +/** + * 一种语言的中文词表:只有叫法和语法节点类型,语言知道的结构(词性、遍历)翻译时从 SourceDocument.language 取—— + * 未翻译的词、关键字译文按它给的词性着色,它认出的遍历整个换成一个框,框里按读的顺序重排(见 iterationGroup)。 + */ export interface ZhTable { id: string label: string languageIds: string[] - /** 未翻译的词按源码着色的词性上色;关键字译文的词性也取它。 */ - tokenRole: (leaf: Node) => TokenRole /** 关键字:按语法树里匿名节点的类型查(Python 的 `not in` 这类组合关键字本身也是一个匿名节点);随所在语法节点换译法的写成 ContextualWord。 */ keywords: Record /** 字面量关键字:具名的叶子节点(true / None …),按节点类型查。 */ @@ -49,11 +51,6 @@ export interface ZhTable { * 槽位里的子节点照常递归翻译。模板引用的子节点缺了,或节点没整个落在要翻的这一段里,就不重排、照常翻。 */ nodeTemplates: Record - /** - * 写在表达式里的遍历(推导式、生成器表达式)的结构,语言插件给;没有这种写法的语言不填。 - * 认出来的节点整个换成一个框,框里按读的顺序重排,见 iterationGroup。 - */ - iteration?: (node: Node) => IterationFacts | undefined } interface Token { @@ -73,7 +70,7 @@ interface Token { /** 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 */ interface Collector { - source: string + document: SourceDocument tokens: Token[] cursor: number gap: boolean @@ -184,13 +181,13 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } /** 照抄这个节点时带不带源码下标:名字(按名字着色的具名叶子)和整段字符串带,标点、数字、关键字不带。 */ - function offsetOf(node: Node): number | undefined { - const linkable = stringNodes.has(node.type) || (node.childCount === 0 && node.isNamed && NAME_ROLES.has(table.tokenRole(node))) + function offsetOf(node: Node, language: LanguageAdapter): number | undefined { + const linkable = stringNodes.has(node.type) || (node.childCount === 0 && node.isNamed && NAME_ROLES.has(language.tokenRole(node))) return linkable ? node.startIndex : undefined } /** 能整个换成中文的节点 → 译文和词性。 */ - function translateNode(node: Node): { text: string, role: TokenRole } | undefined { + function translateNode(node: Node, language: LanguageAdapter): { text: string, role: TokenRole } | undefined { if (node.childCount === 0 && table.isTypePosition(node)) { const type = table.builtinTypes[node.text] if (type) @@ -202,11 +199,11 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return { text: table.operators[node.type], role: 'operator' } const word = table.keywords[node.type] if (word !== undefined) - return { text: keywordText(word, node), role: table.tokenRole(firstLeaf(node)) } + return { text: keywordText(word, node), role: language.tokenRole(firstLeaf(node)) } return undefined } if (node.childCount === 0 && table.literals[node.type]) - return { text: table.literals[node.type], role: table.tokenRole(node) } + return { text: table.literals[node.type], role: language.tokenRole(node) } if (table.emptyLiterals[node.type] && node.namedChildCount === 0) return { text: table.emptyLiterals[node.type], role: 'literal' } return undefined @@ -223,7 +220,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { /** 源码里上一个词到 index 之间有空白,下一个词前面就空一格。 */ function advance(out: Collector, index: number): void { - out.gap ||= /\s/.test(out.source.slice(out.cursor, index)) + out.gap ||= /\s/.test(out.document.source.slice(out.cursor, index)) out.cursor = index } @@ -239,10 +236,10 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } /** 语言插件认出的写在表达式里的遍历;没整个落在要翻的这一段里就不重排、照常翻。 */ - function iterationIn(node: Node, span: SourceSpan): IterationFacts | undefined { - if (!table.iteration || node.startIndex < span.startIndex || node.endIndex > span.endIndex) + function iterationIn(node: Node, span: SourceSpan, language: LanguageAdapter): IterationFacts | undefined { + if (!language.iteration || node.startIndex < span.startIndex || node.endIndex > span.endIndex) return undefined - return table.iteration(node) + return language.iteration(node) } /** @@ -250,11 +247,11 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { * 同步、异步换了就另起一句),条件在它所在的位置写「,仅当 <条件>」,最后「,取 <值>」,字典是「<键>: <值>」。 * 来源、变量、条件、值照常翻,里面的名字带源码下标,套着的遍历是框里的框。 */ - function iterationGroup(facts: IterationFacts, span: SourceSpan, source: string): SegmentGroup { + function iterationGroup(facts: IterationFacts, span: SourceSpan, document: SourceDocument): SegmentGroup { const segments: Inline[] = [] const plain = (text: string): void => pushSegment(segments, text, 'plain') const word = (text: string): void => pushSegment(segments, text, 'keyword') - const say = (node: Node): void => appendInline(segments, joinTokens(tokensIn(node, span, source))) + const say = (node: Node): void => appendInline(segments, joinTokens(tokensIn(node, span, document))) let previous: IterationClause | undefined for (const clause of facts.clauses) { if (clause.kind === 'if') { @@ -295,12 +292,12 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { /** 遍历整个换成一个框;和外面的调用共用的那对括号照常写在框外,紧贴着框。 */ function emitIteration(out: Collector, node: Node, facts: IterationFacts, span: SourceSpan): void { const shared = facts.sharedParentheses - const bracket = (paren: Node): Omit => ({ text: paren.text, role: table.tokenRole(paren), translated: false }) + const bracket = (paren: Node): Omit => ({ text: paren.text, role: out.document.language.tokenRole(paren), translated: false }) if (shared) emitSource(out, shared.open, bracket(shared.open)) else advance(out, node.startIndex) - emit(out, { text: '', role: 'plain', translated: true, raw: '', group: iterationGroup(facts, span, out.source) }) + emit(out, { text: '', role: 'plain', translated: true, raw: '', group: iterationGroup(facts, span, out.document) }) if (shared) { out.cursor = shared.close.startIndex emitSource(out, shared.close, bracket(shared.close)) @@ -315,7 +312,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { emitSource(out, node, { text: '', role: 'comment', translated: false }) return } - const iteration = iterationIn(node, span) + const { language } = out.document + const iteration = iterationIn(node, span, language) if (iteration) { emitIteration(out, node, iteration, span) return @@ -335,14 +333,14 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { out.cursor = node.endIndex return } - const translated = translateNode(node) + const translated = translateNode(node, language) if (translated) { emitSource(out, node, { ...translated, translated: true }) return } if (node.childCount === 0 || stringNodes.has(node.type)) { - const role = stringNodes.has(node.type) ? 'string' : table.tokenRole(node) - emitSource(out, node, { text: oneLine(node.text), role, translated: false, offset: offsetOf(node) }) + const role = stringNodes.has(node.type) ? 'string' : language.tokenRole(node) + emitSource(out, node, { text: oneLine(node.text), role, translated: false, offset: offsetOf(node, language) }) return } for (const child of node.children) { @@ -352,14 +350,14 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } /** 按译文顺序收集 node 落在 span 里的词。 */ - function tokensIn(node: Node, span: SourceSpan, source: string): Token[] { - const out: Collector = { source, tokens: [], cursor: span.startIndex, gap: false } + function tokensIn(node: Node, span: SourceSpan, document: SourceDocument): Token[] { + const out: Collector = { document, tokens: [], cursor: span.startIndex, gap: false } collect(node, span, out) return out.tokens } /** node 的源码原样照抄:叶子按源码着色(给了 role 就都按它),叶子之间的空白、标点照抄。 */ - function verbatim(node: Node, source: string, role?: TokenRole): Segment[] { + function verbatim(node: Node, { source, language }: SourceDocument, role?: TokenRole): Segment[] { const segments: Segment[] = [] let cursor = node.startIndex const visit = (current: Node): void => { @@ -373,7 +371,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return } pushSegment(segments, source.slice(cursor, current.startIndex), 'plain') - pushSegment(segments, current.text, role ?? table.tokenRole(current), offsetOf(current)) + pushSegment(segments, current.text, role ?? language.tokenRole(current), offsetOf(current, language)) cursor = current.endIndex } visit(node) @@ -388,10 +386,10 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return { markdown: docText(nodes.map(table.commentText)) } } - function translate(block: TextBlock, source: string): PreviewText { + function translate(block: TextBlock, document: SourceDocument): PreviewText { if (block.kind === 'comment') return translateComment(block) - const tokens = tokensIn(block.node, block.header, source) + const tokens = tokensIn(block.node, block.header, document) 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)) @@ -399,12 +397,13 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return joinTokens(tokens) } - function describe(block: FactBlock, source: string): PropertyRow[] { + function describe(block: FactBlock, document: SourceDocument): PropertyRow[] { const { header } = block + const { language } = document // 值里带语句块主体的回调(useMemo(() => { … }))只翻到主体开始之前,和块头部一致 const within = (node: Node, part: SourceSpan): Inline[] => { const span = part.endIndex > header.endIndex ? { ...part, endIndex: header.endIndex, endRow: header.endRow } : part - return joinTokens(tokensIn(node, span, source)) + return joinTokens(tokensIn(node, span, document)) } const phrases: Phrases = { translated: node => within(node, { startIndex: node.startIndex, endIndex: node.endIndex, startRow: node.startPosition.row, endRow: node.endPosition.row }), @@ -413,10 +412,10 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { const name = table.mapLiterals[literal.type] return [hint(name ? `${name} ${size} 项` : `${size} 项`)] }, - verbatim: node => verbatim(node, source), + verbatim: node => verbatim(node, document), // 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样 - module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node))] : verbatim(node, source, 'type'), - name: (node, role) => [segment(node.text, role, offsetOf(node))], + module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node, language))] : verbatim(node, document, 'type'), + name: (node, role) => [segment(node.text, role, offsetOf(node, language))], doc: (node) => { const markdown = docText([table.commentText(node)]) return markdown ? { markdown } : undefined