diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index d72b73b..b905ccb 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -30,6 +30,7 @@ | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | | 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | | 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 | +| 推导式(写在表达式里的遍历) | Python 的列表 / 集合 / 字典推导式、生成器表达式出现在译文任何地方(一行译文、属性表的值、默认值、字典项的值),都原地换成一个带循环图标和结果类型(生成器 / 列表 / 集合 / 字典)的小框,颜色同「循环」块,框里按读的顺序重排:「遍历 来源 的每个 变量,仅当 条件,取 值」。语言插件报结构,翻译插件定用词,webview 只认「框」不认语言 | ## 用户可见行为 @@ -98,6 +99,7 @@ 卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`)。输入参数的子行分名称 / 类型 / 默认值三列对齐,前两列宽 150px / 220px,编辑区窄时按 28% / 38% 收,默认值列占剩下的;字典 / 对象的子行分键 / 值两列,键列同名称列。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和参数、导入项的子行一样)。 + | 种类 | 行 | | --- | --- | | 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,`@param` 这类标签行照抄;得到的原文按 Markdown 渲染,见「注释的 Markdown 排版」;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(`默认 ` + 译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | @@ -123,6 +125,25 @@ - 空字面量不展开:值是一行 `空字典` / `空对象`(一行译文里也这样翻,同 `空数组`)。 - `as const`、`satisfies` 包着的对象(`{ … } as const`)不算字面量本身,照旧一行译文。 +### 推导式(写在表达式里的遍历) + +原型画板「遍历 · 推导式」。Python 的列表 / 集合 / 字典推导式、生成器表达式(含 `async for`、几节 `for`、`if`、套着的推导式)不管出现在哪(一行译文、属性表的值 / 初始值 / 默认值、字典项的值),都原地换成一个小框: + +- 框:嵌在前后文字里(`display: inline-block`),框头是循环图标(lucide `Repeat`,12px)和结果类型,后面是重排后的译文。边框、底色、框头字的颜色都取「循环」块的种类色(`--cr-kind-loop`:边框 45%、底色 14%,圆角 4px,左右留 5px);框头界面字体 11px、500 字重。上下边框算在 20px 行高里,框和前后文字基线对齐、不把一行撑高;框里套的框行高再减 2px。整个框放不下时挪到下一行,比一行还长时占满一行、在框里换行。 +- 结果类型:生成器 / 列表 / 集合 / 字典(`[…]` 列表、`{…}` 集合、`{k: v …}` 字典、`(…)` 生成器)。 +- 框里按读的顺序重排:每节 `for` 写「遍历 <来源> 的每个 <变量>」,紧挨着的几节 `for` 用顿号接着写(「遍历 xss 的每个 xs、xs 的每个 x」),`async for` 写「异步遍历」(同步、异步换了就另起一句「,异步遍历 …」),`if` 在它所在的位置写「,仅当 <条件>」(后面再有 `for` 就另起一句「,遍历 …」),最后「,取 <值>」,字典是「<键>: <值>」。来源、变量、条件、值照常直译,里面还有推导式就是框里的框。「遍历」「的每个」「仅当」「取」「异步遍历」按关键字着色,逗号、顿号、空格按普通文字。 +- 括号:框代替了推导式自己的方括号 / 花括号 / 圆括号,不再写出来;Python 调用唯一的参数是生成器时只写一对括号(`f(x for x in y)`),这对括号属于调用,照常写在框外,紧贴着框:`"".join(` 框 `)`。生成器是几个参数之一时(`f(a, (x for x in y))`)括号是它自己的,不写。 +- 框里的名字照样带源码下标:⌘ / Ctrl + 点击跳到定义、单击高亮各处出现都照旧;跳到定义的目标只显示在框里(推导式的变量)时,目标是框所在的块。 +- 样例(必须逐字一致;框写成 `⟦类型|框里的文字⟧`): + +| 源码 | 译文 | +| --- | --- | +| `return "".join(secrets.choice(ALPHABET) for _ in range(length))` | `返回 "".join(⟦生成器|遍历 range(length) 的每个 _,取 secrets.choice(ALPHABET)⟧)` | +| `return [u.id for u in users if u.is_active]` | `返回 ⟦列表|遍历 users 的每个 u,仅当 u.is_active,取 u.id⟧` | +| `return {u.id: u for u in users}` | `返回 ⟦字典|遍历 users 的每个 u,取 u.id: u⟧` | + +- TypeScript 没有这种写法,不涉及。几种读不成遍历的写法(语法树认、Python 3 不认的 `for x in a, b`,写到一半缺了变量 / 来源 / 条件 / 值的)照常逐词直译。 + ### 注释的 Markdown 排版 - 用在:注释块(`#`、`//`、`/* */`、不挂在函数、类上的 JSDoc、Python 模块开头的 docstring)的预览,函数、类属性表的「说明」。视图数据里它们是 `{ markdown }`(见「通用契约」的 `Markdown`),webview 按数据决定怎么渲染,不猜。 @@ -229,6 +250,23 @@ export type EntryFacts = | { kind: 'spread', value: Node } // **other、...other:value 是展开的表达式 | { kind: 'method', name: Node, node: Node, header: SourceSpan } // 对象里的方法、getter、setter:header 同 BlockShape.header,只到主体开始之前 +/** 写在表达式里的遍历(Python 推导式、生成器表达式)得到什么。 */ +export type IterationResult = 'generator' | 'list' | 'set' | 'dict' + +/** 遍历的一节,照源码顺序:从来源里逐个取出变量(async:异步地取),或只留下满足条件的。 */ +export type IterationClause = + | { kind: 'for', variables: Node, source: Node, async: boolean } + | { kind: 'if', condition: Node } + +/** 写在表达式里的遍历的结构:语言插件读出,翻译插件按自己的语序重排;各节、取的值里还能再套遍历。 */ +export interface IterationFacts { + result: IterationResult + clauses: IterationClause[] // 至少一节,第一节是 for + value: Node // 每一项取的值 + key?: Node // 结果是字典时每一项的键 + sharedParentheses?: { open: Node, close: Node } // 遍历的括号同时是外面调用的括号(Python f(x for x in y)):写在遍历外面 +} + export interface DeclaratorFacts { name: Node type?: Node @@ -291,6 +329,15 @@ export type TokenRole = */ export interface Segment { text: string, role: TokenRole, offset?: number } +/** 框的样式,决定框头图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块。 */ +export type GroupStyle = 'iteration' + +/** 框起来的一组词:预览画成嵌在前后文字里的小框,框头是图标和 label(翻译插件给);框里的词照样带下标,还能再套框。 */ +export interface SegmentGroup { style: GroupStyle, label: string, segments: Inline[] } + +/** 译文里的一段:着色的词,或框起来的一组词。 */ +export type Inline = Segment | SegmentGroup + /** * 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,不做别的转换。 * 预览按 Markdown 渲染成注释色的段落、列表、代码块(见「注释的 Markdown 排版」);第三方翻译插件给注释也填它。 @@ -298,7 +345,7 @@ export interface Segment { text: string, role: TokenRole, offset?: number } export interface Markdown { markdown: string } /** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明)。 */ -export type PreviewText = Segment[] | Markdown +export type PreviewText = Inline[] | Markdown /** * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts)在源码里的范围—— @@ -308,7 +355,7 @@ export type PreviewText = Segment[] | Markdown export interface Defines { defines?: SourceRange } /** 属性表里展开的一个子行:几列对齐的格子;items 是展开在它下面的下一层子行(嵌套的字典 / 对象),一层层可以一直往下套。 */ -export interface PropertyItem extends Defines { cells: Segment[][], items?: PropertyItem[] } +export interface PropertyItem extends Defines { cells: Inline[][], items?: PropertyItem[] } /** 属性表的一行:属性名、着色的值或 Markdown 说明(函数、类的「说明」)、展开在下面的子行(输入参数、导入项、字典 / 对象的各项)。 */ export interface PropertyRow extends Defines { label: string, value: PreviewText, items?: PropertyItem[] } @@ -351,7 +398,7 @@ 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`)。 +- 分工:语言插件只从语法树读出和语言无关的事实(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,不认语言。 ## 解析运行时 @@ -426,6 +473,8 @@ 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`(语言插件给的读结构的函数)认出来,引擎按「推导式」一节的规则排进框里;和节点模板一样,没整个落在要翻的这一段里就不重排、照常翻。 + TypeScript 关键字表(流程 = keyword,声明 = storage): | 流程 | 声明 / 字面量 | @@ -434,7 +483,7 @@ TypeScript 关键字表(流程 = keyword,声明 = storage): TypeScript 内置类型:string 字符串、number 数值、boolean 布尔、any 任意、unknown 未知、never 永不、void 无、object 对象、bigint 大整数、symbol 符号。`Promise` 等库类型不翻。 -Python 关键字:import 导入、from(导入)从 / (`raise … from`)源自 / (`yield from`)自、as(导入别名、`with … as`、`except … as`)作为、def 函数、async 异步、await 等待、class 类、return 返回、if 如果、elif 否则如果、else(`if` 后)否则 / (`for` / `while` 后,循环没被 break 才执行)未中断时 / (`try` 后,没出错才执行)未出错时、for 遍历、in(比较里)在 / (for 头部与推导式里)取自、while 当、try 尝试、except 捕获、finally 最终、raise 抛出、with 使用、yield 产出、lambda 匿名函数、pass 跳过、break 跳出、continue 继续、global 全局、nonlocal 非局部、del 删除、assert 断言、not 非、and 并且、or 或者、is 是、not in 不在、is not 不是、None 空、True 真、False 假、match 匹配、case 情况。 +Python 关键字:import 导入、from(导入)从 / (`raise … from`)源自 / (`yield from`)自、as(导入别名、`with … as`、`except … as`)作为、def 函数、async 异步、await 等待、class 类、return 返回、if 如果、elif 否则如果、else(`if` 后)否则 / (`for` / `while` 后,循环没被 break 才执行)未中断时 / (`try` 后,没出错才执行)未出错时、for 遍历、in(比较里)在 / (for 头部)取自(推导式里的 for / in 不逐词翻,整个重排,见「推导式」)、while 当、try 尝试、except 捕获、finally 最终、raise 抛出、with 使用、yield 产出、lambda 匿名函数、pass 跳过、break 跳出、continue 继续、global 全局、nonlocal 非局部、del 删除、assert 断言、not 非、and 并且、or 或者、is 是、not in 不在、is not 不是、None 空、True 真、False 假、match 匹配、case 情况。 Python 内置类型(仅注解里):str 字符串、int 整数、float 浮点数、bool 布尔、list 列表、dict 字典、set 集合、tuple 元组、bytes 字节串。 @@ -525,7 +574,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: P | hint | `--vscode-descriptionForeground` | 同左 | | plain | `--vscode-editor-foreground` | 同左 | -属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认、默认导出、全部、整个模块、匿名按 hint。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。 +属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认、默认导出、全部、整个模块、匿名按 hint。推导式框里的「遍历」「的每个」「仅当」「取」「异步遍历」按 keyword,框头按「循环」块的种类色。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。 种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框)。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 @@ -539,8 +588,8 @@ vscode/src/codeReader/ blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译) attributes.ts 块的筛选属性(从 facts 推出通用属性 + 语言自己报的)、文件里出现过的属性和块数 languages/typescript.ts - languages/python.ts - translators/zhTemplate.ts 模板直译引擎(按语言的表驱动) + languages/python.ts (pythonIteration:推导式的结构) + translators/zhTemplate.ts 模板直译引擎(按语言的表驱动;推导式排进框见 iterationGroup) translators/typescriptZh.ts TS 表 translators/pythonZh.ts Python 表 explorer/fileTree.ts 侧栏文件树(FileTreeDecorator:给行添信息的接口) @@ -556,7 +605,7 @@ vscode/src/codeReader/ editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数) editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights) index.ts registerCodeReader(context): CodeReaderApi -vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;属性表在 PropertyGrid.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;属性表在 PropertyGrid.tsx,着色的词和框在 SegmentText.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts) ``` ## 测试 @@ -566,6 +615,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 折叠:默认函数、类块折叠、其余展开;用户的选择盖过默认、重新加载后还在(嵌套子行的也是);各层子行的 id 互不相撞;旧版本存的状态不报错;跳到定义时默认折叠的函数、类也会展开。 - 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则;字典 / 对象字面量的各项:Python 字符串键、名字键、嵌套字典、`**` 展开、空字典、类属性,TypeScript 名字键、字符串键、计算属性名、简写、展开、方法和 getter(头部到主体之前)、嵌套对象、空对象,不是字面量的值没有 entries。 - 字典 / 对象的属性表:项数、键 | 值两列、嵌套一层层展开、「展开」、方法头部、空的一行;键和值里的名字、字符串带下标(嵌套的也是);每项标的定义是键,展开项不标;跳到定义落在对象属性名上只闪那一项、连外层各层一起展开,只是显示着的名字闪整块、展开到它那一层。 +- 推导式:Python 语言插件读出的结构(调用唯一参数的生成器带 sharedParentheses、几个参数之一的不带,列表带条件,字典分出键和值,集合的解构变量,几节 for / if 照源码顺序,async for,套着的各自认,字面量和 for 语句不算);中文直译「推导式」一节的样例表逐字一致,框的样式、框头、关键字着色,顿号连写、条件位置、另起一句、异步遍历、集合、框里的框、多个参数旁边的生成器、跨行带注释的生成器,属性表的默认值和字典项的值里也换;框里的名字带下标且指回源码原文,高亮各处出现、跳到定义都认框里的名字(只显示在框里的推导式变量 → 框所在的块);TypeScript 不认。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。 - 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。 diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 0227d26..11f0f94 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -92,6 +92,33 @@ export type EntryFacts | { kind: 'spread', value: Node } // **other、...other:value 是展开的那个表达式 | { kind: 'method', name: Node, node: Node, header: SourceSpan } // 对象字面量里的方法、getter、setter:node 是整个方法,header 同 BlockShape.header,只到主体开始之前 +/** 写在表达式里的遍历得到什么:逐个产出的生成器,或一次建好的列表、集合、字典。 */ +export type IterationResult = 'generator' | 'list' | 'set' | 'dict' + +/** 遍历的一节,照源码顺序:从来源里逐个取出变量(async:异步地取),或只留下满足条件的。 */ +export type IterationClause + = | { kind: 'for', variables: Node, source: Node, async: boolean } + | { kind: 'if', condition: Node } + +/** + * 写在表达式里的遍历(Python 的列表 / 集合 / 字典推导式、生成器表达式):语言插件从语法树读出结构,翻译插件按自己的语序重排。 + * 节点都指向源码;各节、每一项取的值里还能再套遍历。 + */ +export interface IterationFacts { + result: IterationResult + /** 至少一节,第一节是 for。 */ + clauses: IterationClause[] + /** 每一项取的值。 */ + value: Node + /** 结果是字典时每一项的键。 */ + key?: Node + /** + * 遍历自己的一对括号同时是外面调用的括号时(Python `f(x for x in y)` 只写一对括号)是这对括号: + * 它们属于外面的调用,照常写在遍历外面;别的写法里遍历自己的括号、方括号、花括号不写出来。 + */ + sharedParentheses?: { open: Node, close: Node } +} + export interface DeclaratorFacts { /** 变量名;解构声明是整个模式。 */ name: Node @@ -170,6 +197,23 @@ export type TokenRole */ export interface Segment { text: string, role: TokenRole, offset?: number } +/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块。 */ +export type GroupStyle = 'iteration' + +/** + * 框起来的一组词:译文里自成一体的一段(写在表达式里的遍历),预览把它画成嵌在前后文字里的一个小框,框头是图标和 label。 + * 框里的词照样带源码下标,还能再套框。 + */ +export interface SegmentGroup { + style: GroupStyle + /** 框头的文字(遍历的结果:生成器 / 列表 …),翻译插件给。 */ + label: string + segments: Inline[] +} + +/** 译文里的一段:着色的词,或框起来的一组词。 */ +export type Inline = Segment | SegmentGroup + /** * 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,不做别的转换 * (reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄)。预览按 Markdown 渲染成注释色的段落、列表、代码块,源码里的 HTML 当文字显示。 @@ -177,7 +221,7 @@ export interface Segment { text: string, role: TokenRole, offset?: number } export interface Markdown { markdown: string } /** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明)。 */ -export type PreviewText = Segment[] | Markdown +export type PreviewText = Inline[] | Markdown /** * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts,例如参数名、导入项的名称和别名、 @@ -187,7 +231,7 @@ export interface Defines { defines?: SourceRange } /** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值。 */ export interface PropertyItem extends Defines { - cells: Segment[][] + cells: Inline[][] /** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项);一层层可以一直往下套。 */ items?: PropertyItem[] } diff --git a/vscode/src/codeReader/languages/python.test.ts b/vscode/src/codeReader/languages/python.test.ts index fa00948..4b8c71e 100644 --- a/vscode/src/codeReader/languages/python.test.ts +++ b/vscode/src/codeReader/languages/python.test.ts @@ -1,7 +1,8 @@ +import type { IterationFacts, Node } from '../contract' import type { Outline } from '../testSupport' import { describe, expect, it } from 'vitest' -import { factsByLine, outline, readFixture, WASM_DIR, withBlocks } from '../testSupport' -import { createPythonAdapter } from './python' +import { factsByLine, nodesToText, outline, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport' +import { createPythonAdapter, pythonIteration } from './python' const adapter = createPythonAdapter(WASM_DIR) @@ -326,3 +327,90 @@ describe('python 类', () => { }) }) }) + +describe('python 写在表达式里的遍历(推导式)', () => { + /** 源码里每个认得出的遍历(外层在前),节点换成源码。 */ + async function iterations(source: string): Promise { + const tree = await runtime.parse(adapter, 'python', source) + try { + const found: IterationFacts[] = [] + const visit = (node: Node): void => { + const facts = pythonIteration(node) + if (facts) + found.push(facts) + for (const child of node.children) { + if (child) + visit(child) + } + } + visit(tree.rootNode) + return found.map(nodesToText) + } + finally { + tree.delete() + } + } + + it('调用唯一的参数是生成器:那对括号是调用的,记在 sharedParentheses;参数不止一个时生成器自己带括号,没有', async () => { + expect(await iterations('"".join(secrets.choice(ALPHABET) for _ in range(length))')).toEqual([{ + result: 'generator', + clauses: [{ kind: 'for', variables: '_', source: 'range(length)', async: false }], + value: 'secrets.choice(ALPHABET)', + sharedParentheses: { open: '(', close: ')' }, + }]) + const source = 'f(a, (x for x in y))' + expect(await iterations(source)).toEqual([{ + result: 'generator', + clauses: [{ kind: 'for', variables: 'x', source: 'y', async: false }], + value: 'x', + }]) + }) + + it('列表带条件、字典分出键和值、集合的变量是解构', async () => { + expect(await iterations('[u.id for u in users if u.is_active]')).toEqual([{ + result: 'list', + clauses: [{ kind: 'for', variables: 'u', source: 'users', async: false }, { kind: 'if', condition: 'u.is_active' }], + value: 'u.id', + }]) + expect(await iterations('{u.id: u for u in users}')).toEqual([{ + result: 'dict', + clauses: [{ kind: 'for', variables: 'u', source: 'users', async: false }], + key: 'u.id', + value: 'u', + }]) + expect(await iterations('{n for n, _ in pairs}')).toEqual([{ + result: 'set', + clauses: [{ kind: 'for', variables: 'n, _', source: 'pairs', async: false }], + value: 'n', + }]) + }) + + it('几节 for、if 照源码顺序;async for 标出异步', async () => { + expect(await iterations('[c for row in grid if row for c in row if c]')).toEqual([{ + result: 'list', + clauses: [ + { kind: 'for', variables: 'row', source: 'grid', async: false }, + { kind: 'if', condition: 'row' }, + { kind: 'for', variables: 'c', source: 'row', async: false }, + { kind: 'if', condition: 'c' }, + ], + value: 'c', + }]) + expect(await iterations('[v async for v in stream() if v]')).toEqual([{ + result: 'list', + clauses: [{ kind: 'for', variables: 'v', source: 'stream()', async: true }, { kind: 'if', condition: 'v' }], + value: 'v', + }]) + }) + + it('套着的遍历各自认,外层取的值就是里层整个遍历', async () => { + expect(await iterations('[[y for y in row] for row in grid]')).toEqual([ + { result: 'list', clauses: [{ kind: 'for', variables: 'row', source: 'grid', async: false }], value: '[y for y in row]' }, + { result: 'list', clauses: [{ kind: 'for', variables: 'y', source: 'row', async: false }], value: 'y' }, + ]) + }) + + it('列表、字典、元组字面量和 for 语句不是', async () => { + expect(await iterations('x = [1, 2]\ny = {"a": 1}\nz = (a, b)\nfor x in y:\n pass')).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index 84dfcfa..09dc411 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -1,4 +1,4 @@ -import type { Access, BlockShape, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' +import type { Access, BlockShape, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' import type { CommentRules } from './shared' import * as path from 'node:path' import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared' @@ -258,6 +258,54 @@ function dictionaryEntries(value: Node | null): EntryFacts[] | undefined { }) } +const ITERATION_RESULTS: Record = { + generator_expression: 'generator', + list_comprehension: 'list', + set_comprehension: 'set', + dictionary_comprehension: 'dict', +} + +/** + * 推导式、生成器表达式的结构,别的节点没有。几种读不成遍历的写法也没有,照常翻: + * `for x in a, b`(语法树认、Python 3 不认的多个来源),以及缺了变量、来源、条件或每一项的值(写到一半的代码)。 + */ +export function pythonIteration(node: Node): IterationFacts | undefined { + const result = ITERATION_RESULTS[node.type] + const body = node.childForFieldName('body') + if (!result || !body) + return undefined + const clauses: IterationClause[] = [] + for (const child of syntaxChildren(node)) { + if (child.type === 'for_in_clause') { + const variables = child.childForFieldName('left') + const sources = child.childrenForFieldName('right').filter(source => source !== null) + if (!variables || sources.length !== 1) + return undefined + clauses.push({ kind: 'for', variables, source: sources[0], async: child.children.some(token => token?.type === 'async') }) + } + else if (child.type === 'if_clause') { + const [condition] = syntaxChildren(child) + if (!condition) + return undefined + clauses.push({ kind: 'if', condition }) + } + } + if (clauses[0]?.kind !== 'for') + return undefined + const key = result === 'dict' ? body.childForFieldName('key') : undefined + const value = result === 'dict' ? body.childForFieldName('value') : body + if (!value || key === null) + return undefined + const facts: IterationFacts = { result, clauses, value, ...(key && { key }) } + // 调用唯一的参数是生成器表达式时只写一对括号(f(x for x in y)),语法树把这对括号算给生成器 + const call = node.parent + const open = node.child(0) + const close = node.child(node.childCount - 1) + if (result === 'generator' && call?.type === 'call' && sameNode(call.childForFieldName('arguments'), node) && open && close) + facts.sharedParentheses = { open, close } + return facts +} + /** elif / else 展平成紧跟 if 的兄弟块。 */ function ifBlocks(node: Node): CodeBlock[] { const blocks = [bodyBlock('if', node, bodyOf(node), 'other')] diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index d440bd1..89432c5 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -1,4 +1,4 @@ -import type { BlockKind, CodeBlock, LanguageAdapter, PreviewText, PropertyItem, PropertyRow, Segment } from './contract' +import type { BlockKind, CodeBlock, Inline, LanguageAdapter, PreviewText, PropertyItem, PropertyRow, Segment } from './contract' import { readFileSync } from 'node:fs' import * as path from 'node:path' import { Node } from '@vscode/tree-sitter-wasm' @@ -45,8 +45,14 @@ export function walk(blocks: CodeBlock[], visit: (block: CodeBlock) => void): vo } } -export function segmentText(segments: Segment[]): string { - return segments.map(segment => segment.text).join('') +/** 着色的词拼成文字;框写成 `⟦框头|框里的文字⟧`。 */ +export function segmentText(segments: Inline[]): string { + return segments.map(piece => 'role' in piece ? piece.text : `⟦${piece.label}|${segmentText(piece.segments)}⟧`).join('') +} + +/** 框拆开,框里的词按顺序接在前后的词中间(框头不算)。 */ +export function flatSegments(pieces: Inline[]): Segment[] { + return pieces.flatMap(piece => 'role' in piece ? [piece] : flatSegments(piece.segments)) } /** 预览文字写成文字:着色的词拼起来,Markdown 取原文。 */ @@ -64,7 +70,7 @@ export function propertyLines(rows: PropertyRow[]): string[] { } /** 语法树节点换成它的源码,没填的可选字段去掉。 */ -function nodesToText(value: unknown): unknown { +export function nodesToText(value: unknown): unknown { if (value instanceof Node) return value.text if (Array.isArray(value)) diff --git a/vscode/src/codeReader/translators/pythonZh.ts b/vscode/src/codeReader/translators/pythonZh.ts index 8a2f69b..77198a7 100644 --- a/vscode/src/codeReader/translators/pythonZh.ts +++ b/vscode/src/codeReader/translators/pythonZh.ts @@ -1,5 +1,5 @@ import type { Node, Translator } from '../contract' -import { insideAnnotation, pythonTokenRole } from '../languages/python' +import { insideAnnotation, pythonIteration, pythonTokenRole } from '../languages/python' import { createZhTemplateTranslator } from './zhTemplate' function stringContent(node: Node): string { @@ -39,7 +39,7 @@ export const pythonZhTranslator: Translator = createZhTemplateTranslator({ }, }, 'for': '遍历', - 'in': { zh: '在', byContext: { for_statement: '取自', for_in_clause: '取自' } }, + 'in': { zh: '在', byContext: { for_statement: '取自' } }, 'while': '当', 'try': '尝试', 'except': '捕获', @@ -87,4 +87,5 @@ export const pythonZhTranslator: Translator = createZhTemplateTranslator({ nodeTemplates: { conditional_expression: ['如果', { namedChild: 1 }, '则', { namedChild: 0 }, '否则', { namedChild: 2 }], }, + iteration: pythonIteration, }) diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index 5822df6..52a57ff 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -1,9 +1,9 @@ -import type { LanguageAdapter, PropertyItem, PropertyRow, Segment, Translator } from '../contract' +import type { Inline, LanguageAdapter, PropertyItem, 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 { propertyLines, readFixture, walk, WASM_DIR, withBlocks } from '../testSupport' +import { flatSegments, propertyLines, readFixture, segmentText, walk, WASM_DIR, withBlocks } from '../testSupport' import { pythonZhTranslator } from './pythonZh' import { typescriptZhTranslator } from './typescriptZh' import { createZhTemplateTranslator } from './zhTemplate' @@ -12,7 +12,7 @@ const ts = createTypeScriptAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR) /** 一块的译文:一行译文在 segments 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */ -interface Translated { kind: string, segments: Segment[], markdown?: string } +interface Translated { kind: string, segments: Inline[], markdown?: string } /** 一行译文块、注释块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 */ function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise> { @@ -48,15 +48,21 @@ function rowTexts(map: Map): Record { return Object.fromEntries([...map].map(([line, rows]) => [line, propertyLines(rows)])) } -function valueOf(rows: PropertyRow[] | undefined, label: string): Segment[] { +function valueOf(rows: PropertyRow[] | undefined, label: string): Inline[] { const value = rows?.find(row => row.label === label)?.value ?? [] if ('markdown' in value) throw new Error(`「${label}」是 Markdown`) return value } -function text(segments: Segment[] | undefined): string { - return (segments ?? []).map(segment => segment.text).join('') +/** 译文写成文字,框写成 `⟦框头|框里的文字⟧`。 */ +function text(segments: Inline[] | undefined): string { + return segmentText(segments ?? []) +} + +/** 带下标的段(框里的也算),按出现顺序写成 [文字, 下标]。 */ +function linked(segments: Inline[] | undefined): [string, number][] { + return flatSegments(segments ?? []).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]]) } function texts(map: Map): Record { @@ -72,14 +78,15 @@ const CJK = /\p{Script=Han}/u const CJK_ONLY = /^\p{Script=Han}+$/u const WORD_CHAR = /[\p{Script=Han}\w]/u -/** 被翻的词单独成段,而且不和前后的字母、数字、汉字粘在一起。 */ +/** 被翻的词单独成段,而且不和前后的字母、数字、汉字粘在一起;框里的词和框前后的词也一样。 */ function expectWordsApart(map: Map): void { for (const [line, { kind, segments }] of map) { if (kind === 'comment') continue - const full = text(segments) + const flat = flatSegments(segments) + const full = flat.map(segment => segment.text).join('') let at = 0 - for (const segment of segments) { + for (const segment of flat) { if (segment.role !== 'string' && CJK.test(segment.text)) { expect(segment.text, `L${line} ${full}`).toMatch(CJK_ONLY) for (const neighbor of [full[at - 1], full[at + segment.text.length]]) @@ -125,7 +132,7 @@ 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(colors(env.get(32)?.segments.filter(segment => segment.role !== 'plain'))).toEqual([ + expect(colors(flatSegments(env.get(32)?.segments ?? []).filter(segment => segment.role !== 'plain'))).toEqual([ { text: '公共', role: 'keyword' }, { text: '接口', role: 'storage' }, { text: 'EnvLockResult', role: 'type' }, @@ -213,7 +220,7 @@ describe('typeScript 中文直译', () => { 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([ + expect(flatSegments(map.get(1)?.segments ?? []).filter(segment => segment.role === 'keyword')).toEqual([ { text: '如果', role: 'keyword' }, { text: '则', role: 'keyword' }, { text: '否则', role: 'keyword' }, @@ -252,7 +259,7 @@ describe('python 中文直译', () => { expectWordsApart(map) }) - it('内置类型只在注解里翻,in 按位置区分取自 / 在', async () => { + it('内置类型只在注解里翻;比较里的 not in 是「不在」,推导式里也是', async () => { const source = [ 'def pick(items: dict[str, int], list: set) -> tuple:', ' return [str(k) for k in items if k not in list]', @@ -266,7 +273,7 @@ describe('python 中文直译', () => { ' list | 集合 | ', '输出参数: 元组', ]) - expect(texts(await translateAll(py, 'python', pythonZhTranslator, source))[2]).toBe('返回 [str(k) 遍历 k 取自 items 如果 k 不在 list]') + expect(texts(await translateAll(py, 'python', pythonZhTranslator, source))[2]).toBe('返回 ⟦列表|遍历 items 的每个 k,仅当 k 不在 list,取 str(k)⟧') }) it('逻辑与、或译成「并且」「或者」', async () => { @@ -324,9 +331,9 @@ describe('python 中文直译', () => { 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] 否则 空', + 4: '如果 非 done 并且 ok 则 ⟦列表|遍历 items 的每个 k,仅当 k 不在 seen,取 k⟧ 否则 空', }) - expect(valueOf(map.get(1), '初始值').filter(segment => segment.role === 'keyword' || segment.role === 'operator')).toEqual([ + expect(flatSegments(valueOf(map.get(1), '初始值')).filter(segment => segment.role === 'keyword' || segment.role === 'operator')).toEqual([ { text: '如果', role: 'keyword' }, { text: '等于', role: 'operator' }, { text: '则', role: 'keyword' }, @@ -335,6 +342,123 @@ describe('python 中文直译', () => { }) }) +describe('python 写在表达式里的遍历(推导式)', () => { + it('spec 样例逐字一致:原地换成一个框,框里按读的顺序重排;调用和生成器共用的括号写在框外', async () => { + const source = [ + 'def invite_code(length):', + ' return "".join(secrets.choice(ALPHABET) for _ in range(length))', + 'def active_ids(users):', + ' return [u.id for u in users if u.is_active]', + 'def by_id(users):', + ' return {u.id: u for u in users}', + ].join('\n') + const map = await translateAll(py, 'python', pythonZhTranslator, source) + expect(texts(map)).toEqual({ + 2: '返回 "".join(⟦生成器|遍历 range(length) 的每个 _,取 secrets.choice(ALPHABET)⟧)', + 4: '返回 ⟦列表|遍历 users 的每个 u,仅当 u.is_active,取 u.id⟧', + 6: '返回 ⟦字典|遍历 users 的每个 u,取 u.id: u⟧', + }) + expectWordsApart(map) + }) + + it('框是 iteration 样式、框头是结果类型;遍历 / 的每个 / 仅当 / 取 按关键字着色,框里的名字照常着色', async () => { + const map = await translateAll(py, 'python', pythonZhTranslator, 'return [u.id for u in users if u.is_active]') + expect(colors(map.get(1)?.segments)).toEqual([ + { text: '返回', role: 'keyword' }, + { text: ' ', role: 'plain' }, + { + style: 'iteration', + label: '列表', + segments: [ + { text: '遍历', role: 'keyword' }, + { text: ' ', role: 'plain' }, + { text: 'users', role: 'variable' }, + { text: ' ', role: 'plain' }, + { text: '的每个', role: 'keyword' }, + { text: ' ', role: 'plain' }, + { text: 'u', role: 'variable' }, + { text: ',', role: 'plain' }, + { text: '仅当', role: 'keyword' }, + { text: ' ', role: 'plain' }, + { text: 'u', role: 'variable' }, + { text: '.', role: 'plain' }, + { text: 'is_active', role: 'variable' }, + { text: ',', role: 'plain' }, + { text: '取', role: 'keyword' }, + { text: ' ', role: 'plain' }, + { text: 'u', role: 'variable' }, + { text: '.', role: 'plain' }, + { text: 'id', role: 'variable' }, + ], + }, + ]) + }) + + it('紧挨着的几节 for 用顿号接着写,条件在它的位置,异步的写「异步遍历」;集合、套着的框、别的参数旁边的生成器', async () => { + const source = [ + 'a = [c for xs in grid for c in xs]', + 'b = [c for row in grid if row for c in row if c]', + 'c = [v async for v in stream()]', + 'd = [(x, y) for x in xs async for y in ys]', + 'e = {n for n, _ in pairs}', + 'f = [[y for y in row] for row in grid]', + 'g = pick(a, (y for y in b))', + 'h = sum(', + ' n # 每一项', + ' for n in counts', + ')', + ].join('\n') + const map = await describeAll(py, 'python', pythonZhTranslator, source) + expect(Object.fromEntries([...map].map(([line, rows]) => [line, text(valueOf(rows, '初始值'))]))).toEqual({ + 1: '⟦列表|遍历 grid 的每个 xs、xs 的每个 c,取 c⟧', + 2: '⟦列表|遍历 grid 的每个 row,仅当 row,遍历 row 的每个 c,仅当 c,取 c⟧', + 3: '⟦列表|异步遍历 stream() 的每个 v,取 v⟧', + 4: '⟦列表|遍历 xs 的每个 x,异步遍历 ys 的每个 y,取 (x, y)⟧', + 5: '⟦集合|遍历 pairs 的每个 n, _,取 n⟧', + 6: '⟦列表|遍历 grid 的每个 row,取 ⟦列表|遍历 row 的每个 y,取 y⟧⟧', + 7: 'pick(a, ⟦生成器|遍历 b 的每个 y,取 y⟧)', + 8: 'sum(⟦生成器|遍历 counts 的每个 n,取 n⟧)', + }) + const async = valueOf(map.get(3), '初始值')[0] + expect('segments' in async && async.segments.filter(piece => 'role' in piece && piece.role === 'keyword')).toEqual([ + { text: '异步遍历', role: 'keyword' }, + { text: '的每个', role: 'keyword' }, + { text: '取', role: 'keyword' }, + ]) + }) + + it('属性表的值里也换:参数默认值、字典一项的值', async () => { + const source = [ + 'def f(ids=[u.id for u in users]): pass', + 'CONFIG = {"ids": {k: v for k, v in pairs}}', + ].join('\n') + const rows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, source)) + expect(rows[1]).toContain(' ids | 未标注 | 默认 ⟦列表|遍历 users 的每个 u,取 u.id⟧') + expect(rows[2]).toEqual(['名称: CONFIG', '访问级别: 公共', '值: 字典 1 项', ' "ids" | ⟦字典|遍历 pairs 的每个 k, v,取 k: v⟧']) + }) + + it('框里的名字带源码下标,和框外的一样指回源码原文', async () => { + const source = 'return "".join(secrets.choice(ALPHABET) for _ in range(length))' + const at = (word: string): number => source.indexOf(word) + const map = await translateAll(py, 'python', pythonZhTranslator, source) + expect(linked(map.get(1)?.segments)).toEqual([ + ['""', at('""')], + ['join', at('join')], + ['range', at('range')], + ['length', at('length')], + ['_', at('_')], + ['secrets', at('secrets')], + ['choice', at('choice')], + ['ALPHABET', at('ALPHABET')], + ]) + }) + + it('typeScript 没有这种写法,不认', async () => { + const map = await translateAll(ts, 'typescript', typescriptZhTranslator, 'return items.map(x => x.id)') + expect(text(map.get(1)?.segments)).toBe('返回 items.map(x => x.id)') + }) +}) + describe('属性表', () => { it('spec 样例逐字一致', async () => { const env = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))) @@ -703,18 +827,13 @@ describe('属性表', () => { }) describe('源码下标(跳到定义用)', () => { - /** 带下标的段,按出现顺序写成 [文字, 下标]。 */ - function linked(segments: Segment[] | undefined): [string, number][] { - return (segments ?? []).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]]) - } - - function itemCells(rows: PropertyRow[] | undefined, label: string): Segment[][] { + function itemCells(rows: PropertyRow[] | undefined, label: string): Inline[][] { return rows?.find(row => row.label === label)?.items?.flatMap(item => item.cells) ?? [] } function propertySegments(rows: PropertyRow[]): Segment[] { - const itemSegments = (items: PropertyItem[] | undefined): Segment[] => (items ?? []).flatMap(item => [...item.cells.flat(), ...itemSegments(item.items)]) - return rows.flatMap(row => [...('markdown' in row.value ? [] : row.value), ...itemSegments(row.items)]) + const itemSegments = (items: PropertyItem[] | undefined): Segment[] => (items ?? []).flatMap(item => [...flatSegments(item.cells.flat()), ...itemSegments(item.items)]) + return rows.flatMap(row => [...('markdown' in row.value ? [] : flatSegments(row.value)), ...itemSegments(row.items)]) } it('python:点分模块名每一节、导入项、别名各自带下标,点号不带;前面有中文注释也对得上', async () => { @@ -867,7 +986,7 @@ describe('源码下标(跳到定义用)', () => { ] for (const [adapter, languageId, translator, name] of samples) { const source = readFixture(name) - const translated = [...(await translateAll(adapter, languageId, translator, source)).values()].flatMap(block => block.segments) + const translated = [...(await translateAll(adapter, languageId, translator, source)).values()].flatMap(block => flatSegments(block.segments)) const described = [...(await describeAll(adapter, languageId, translator, source)).values()].flatMap(propertySegments) const segments = [...translated, ...described] expect(linked(segments).length, name).toBeGreaterThan(10) @@ -956,7 +1075,7 @@ describe('中文模板引擎', () => { 2: 'print(若 c 则 a 不然 若 d 则 b 不然 e)', 3: 'print((若 k 等于 1 则 a 不然 b))', }) - expect(map.get(1)?.segments.filter(segment => segment.role === 'keyword').map(segment => segment.text)) + expect(flatSegments(map.get(1)?.segments ?? []).filter(segment => segment.role === 'keyword').map(segment => segment.text)) .toEqual(['令', '加上', '若', '则', '不然']) }) diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 71c7e13..b7eb88f 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, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +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 { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -49,18 +49,26 @@ export interface ZhTable { * 槽位里的子节点照常递归翻译。模板引用的子节点缺了,或节点没整个落在要翻的这一段里,就不重排、照常翻。 */ nodeTemplates: Record + /** + * 写在表达式里的遍历(推导式、生成器表达式)的结构,语言插件给;没有这种写法的语言不填。 + * 认出来的节点整个换成一个框,框里按读的顺序重排,见 iterationGroup。 + */ + iteration?: (node: Node) => IterationFacts | undefined } interface Token { + /** 注释是空文字:不进译文,只在前后留一个空格。 */ text: string role: TokenRole translated: boolean - /** 源码里的原文,用来认出逗号、括号;模板插入的译词没有原文。 */ + /** 源码里的原文,用来认出逗号、括号;模板插入的译词、框没有原文。 */ raw: string /** 前面要空一格:源码里它前面有空白,或者模板在它前面插了别的项。 */ gap: boolean /** 照抄的名字、字符串在源码里的起始下标,见 Segment.offset。 */ offset?: number + /** 整个换成一个框的节点(写在表达式里的遍历):输出这个框,text 为空。 */ + group?: SegmentGroup } /** 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 */ @@ -90,10 +98,14 @@ function commentsWithin(node: Node, span: SourceSpan): Node[] { return node.children.flatMap(child => child ? commentsWithin(child, span) : []) } +function isComment(token: Token): boolean { + return token.text === '' && !token.group +} + /** 下一个要输出的词的原文(跳过注释)。 */ function nextRaw(tokens: Token[], i: number): string { for (let j = i + 1; j < tokens.length; j++) { - if (tokens[j].text !== '') + if (!isComment(tokens[j])) return tokens[j].raw } return '' @@ -142,6 +154,7 @@ const KIND_ZH: Record = { comment: '注释', } const ACCESS_ZH: Record = { public: '公共', private: '私有', protected: '受保护' } +const ITERATION_ZH: Record = { generator: '生成器', list: '列表', set: '集合', dict: '字典' } const YES_NO: Record<'true' | 'false', string> = { true: '是', false: '否' } /** 筛选属性和值的中文叫法;属性表里的访问级别、异步、抽象、仅类型这几行,属性名和值也用这一套。 */ @@ -225,6 +238,76 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { out.cursor = node.endIndex } + /** 语言插件认出的写在表达式里的遍历;没整个落在要翻的这一段里就不重排、照常翻。 */ + function iterationIn(node: Node, span: SourceSpan): IterationFacts | undefined { + if (!table.iteration || node.startIndex < span.startIndex || node.endIndex > span.endIndex) + return undefined + return table.iteration(node) + } + + /** + * 遍历按读的顺序重排进一个框:「遍历 <来源> 的每个 <变量>」,紧挨着的几节 for 用顿号接着写(异步的写「异步遍历」, + * 同步、异步换了就另起一句),条件在它所在的位置写「,仅当 <条件>」,最后「,取 <值>」,字典是「<键>: <值>」。 + * 来源、变量、条件、值照常翻,里面的名字带源码下标,套着的遍历是框里的框。 + */ + function iterationGroup(facts: IterationFacts, span: SourceSpan, source: string): 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))) + let previous: IterationClause | undefined + for (const clause of facts.clauses) { + if (clause.kind === 'if') { + plain(',') + word('仅当') + plain(' ') + say(clause.condition) + } + else { + if (previous?.kind === 'for' && previous.async === clause.async) { + plain('、') + } + else { + if (previous) + plain(',') + word(clause.async ? '异步遍历' : '遍历') + plain(' ') + } + say(clause.source) + plain(' ') + word('的每个') + plain(' ') + say(clause.variables) + } + previous = clause + } + plain(',') + word('取') + plain(' ') + if (facts.key) { + say(facts.key) + plain(': ') + } + say(facts.value) + return { style: 'iteration', label: ITERATION_ZH[facts.result], segments } + } + + /** 遍历整个换成一个框;和外面的调用共用的那对括号照常写在框外,紧贴着框。 */ + 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 }) + 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) }) + if (shared) { + out.cursor = shared.close.startIndex + emitSource(out, shared.close, bracket(shared.close)) + } + out.cursor = node.endIndex + } + function collect(node: Node, span: SourceSpan, out: Collector): void { if (node.endIndex <= span.startIndex || node.startIndex >= span.endIndex || node.startIndex === node.endIndex) return @@ -232,6 +315,11 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { emitSource(out, node, { text: '', role: 'comment', translated: false }) return } + const iteration = iterationIn(node, span) + if (iteration) { + emitIteration(out, node, iteration, span) + return + } const parts = templateParts(node, span) if (parts) { advance(out, node.startIndex) @@ -314,7 +402,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { function describe(block: FactBlock, source: string): PropertyRow[] { const { header } = block // 值里带语句块主体的回调(useMemo(() => { … }))只翻到主体开始之前,和块头部一致 - const within = (node: Node, part: SourceSpan): Segment[] => { + 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)) } @@ -353,30 +441,43 @@ function segment(text: string, role: TokenRole, offset: number | undefined): Seg return offset === undefined ? { text, role } : { text, role, offset } } -function pushSegment(segments: Segment[], text: string, role: TokenRole, offset?: number): void { +/** 相邻的两段不带下标的普通文字并成一段;框不和别的并。 */ +function pushSegment(segments: Inline[], text: string, role: TokenRole, offset?: number): void { if (!text) return const prev = segments.at(-1) - if (role === 'plain' && prev?.role === 'plain') + if (role === 'plain' && prev && 'role' in prev && prev.role === 'plain') prev.text += text else segments.push(segment(text, role, offset)) } +function appendInline(segments: Inline[], pieces: Inline[]): void { + for (const piece of pieces) { + if ('role' in piece) + pushSegment(segments, piece.text, piece.role, piece.offset) + else + segments.push(piece) + } +} + /** 词连成译文:源码里隔着空白的词之间空一格,注释不进译文,右括号前的尾逗号去掉。 */ -function joinTokens(tokens: Token[]): Segment[] { - const segments: Segment[] = [] +function joinTokens(tokens: Token[]): Inline[] { + const segments: Inline[] = [] 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) { + if (isComment(token) || trailingComma) { gap ||= token.role === 'comment' return } if (gap && segments.length > 0) pushSegment(segments, ' ', 'plain') - pushSegment(segments, token.text, token.role, token.offset) + if (token.group) + segments.push(token.group) + else + pushSegment(segments, token.text, token.role, token.offset) // 「非」是个词,后面补一个空格,不和操作数粘在一起 gap = token.translated && token.raw === '!' }) @@ -388,9 +489,9 @@ function joinTokens(tokens: Token[]): Segment[] { /** 属性表里的值怎么写。 */ interface Phrases { /** 按表翻译,只翻落在块头部里的那一段。 */ - translated: (node: Node) => Segment[] + translated: (node: Node) => Inline[] /** 同 translated,只翻 node 落在 span 里的那一段(对象字面量里方法的头部)。 */ - within: (node: Node, span: SourceSpan) => Segment[] + within: (node: Node, span: SourceSpan) => Inline[] /** 字典 / 对象字面量的项数:「字典 3 项」,叫法按字面量的节点类型查表。 */ entryCount: (literal: Node, size: number) => Segment[] /** 源码原样照抄、按源码着色(装饰器)。 */ @@ -416,7 +517,7 @@ function count(n: number): Segment[] { return [hint(n > 0 ? `${n} 个` : '无')] } -function joined(parts: Segment[][], separator: string): Segment[] { +function joined(parts: Inline[][], separator: string): Inline[] { return parts.flatMap((part, i) => i > 0 ? [{ text: separator, role: 'plain' as const }, ...part] : part) } @@ -443,7 +544,7 @@ function importNodes(item: ImportItem): (Node | undefined)[] { } } -function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segment[] { +function aliased(item: Inline[], alias: Node | undefined, say: Phrases): Inline[] { if (!alias) return item return [...item, { text: ' ', role: 'plain' }, { text: '作为', role: 'keyword' }, { text: ' ', role: 'plain' }, ...say.translated(alias)] @@ -478,7 +579,7 @@ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { * 说明及以后各行有才显示;类型参数、父类、实现的接口各是一行,多项用逗号隔开。类型参数那一行讲的是这几个泛型参数的定义。 */ function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] { - const listRow = (label: string, parts: Segment[][], defines: Defines = {}): PropertyRow[] => + const listRow = (label: string, parts: Inline[][], defines: Defines = {}): PropertyRow[] => parts.length > 0 ? [{ label, value: joined(parts, ', '), ...defines }] : [] return [ { label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] }, @@ -505,7 +606,7 @@ function decoratorRows(decorators: Node[], say: Phrases): PropertyRow[] { /** 导入:来源 / 仅类型(只有分这个的语言才有)/ 导入项(每项一行,有别名写成「名称 作为 别名」)。 */ function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] { - const itemText = (item: ImportItem): Segment[] => { + const itemText = (item: ImportItem): Inline[] => { switch (item.kind) { case 'named': return aliased(say.translated(item.name), item.alias, say) @@ -540,7 +641,7 @@ function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] { * 值:字典 / 对象字面量(语言插件给了 entries)写项数,各项展开成下一层子行; * 空字面量和别的值是一行译文(空的译成「空字典」「空对象」)。 */ -function literalValue(value: Node, entries: EntryFacts[] | undefined, say: Phrases): { value: Segment[], items?: PropertyItem[] } { +function literalValue(value: Node, entries: EntryFacts[] | undefined, say: Phrases): { value: Inline[], items?: PropertyItem[] } { if (!entries?.length) return { value: say.translated(value) } return { value: say.entryCount(value, entries.length), items: entries.map(entry => entryItem(entry, say)) } diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index 9b44693..d517986 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -1,7 +1,8 @@ import type { ReactElement } from 'react' -import type { PropertyItem, PropertyRow, Segment } from './types' +import type { Inline, PropertyItem, PropertyRow } from './types' import { ChevronDown, ChevronRight } from 'lucide-react' import { Fragment } from 'react' +import { inlineText } from './segments' import { SegmentText } from './SegmentText' import { TextPreview } from './TextPreview' import { propertyItemId, propertyRowId } from './viewState' @@ -20,15 +21,11 @@ interface Folding { onToggleFold: (id: string) => void } -function segmentsText(segments: Segment[]): string { - return segments.map(segment => segment.text).join('') -} - /** * 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换; * 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。 */ -function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Segment[], foldable: boolean, folding: Folding }): ReactElement { +function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Inline[], foldable: boolean, folding: Folding }): ReactElement { if (!foldable) { return (
@@ -45,7 +42,7 @@ function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Segment[]