✨ feat(vscode): 代码阅读函数、导入、常量、变量块预览改成属性表
This commit is contained in:
1 parent
0c4c9b0fba
commit
dd9b39ac5d
19 files changed
+1458
-213
No files matched your search
@@ -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<BlockKind, FactBlock['kind']>
|
||||
/** 预览成一行译文的块。 */
|
||||
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<string[]> {` | `公共 异步 函数 findEnvFiles(workspaceRoot: 字符串): Promise<字符串[]>` |
|
||||
| `async function chmodAll(workspaceRoot: string, mode: number): Promise<EnvLockResult> {` | `异步 函数 chmodAll(workspaceRoot: 字符串, mode: 数值): Promise<EnvLockResult>` |
|
||||
| `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<string[]> {` | 名称 findEnvFiles / 访问级别 公共 / 异步 是 / 输入参数 1 个:`workspaceRoot \| 字符串 \|` / 输出参数 `Promise<字符串[]>` |
|
||||
| `async function chmodAll(workspaceRoot: string, mode: number): Promise<EnvLockResult> {` | 名称 chmodAll / 访问级别 私有 / 异步 是 / 输入参数 2 个:`workspaceRoot \| 字符串`、`mode \| 数值` / 输出参数 `Promise<EnvLockResult>` |
|
||||
| `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` 选中。
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)))
|
||||
|
||||
@@ -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<BlockKind, FactBlock['kind']>
|
||||
|
||||
/** 预览成一行译文的块。 */
|
||||
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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
@@ -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<Outline[]> {
|
||||
return withBlocks(adapter, 'python', source, outline)
|
||||
}
|
||||
|
||||
function facts(source: string): Promise<Record<number, unknown>> {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, BlockKind> = {
|
||||
const TRY_CLAUSES: Record<string, TextKind> = {
|
||||
except_clause: 'catch',
|
||||
except_group_clause: 'catch',
|
||||
else_clause: 'else',
|
||||
|
||||
@@ -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<B extends CodeBlock>(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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Outline[]> {
|
||||
return withBlocks(adapter, languageId, source, outline)
|
||||
}
|
||||
|
||||
function facts(source: string, languageId = 'typescript'): Promise<Record<number, unknown>> {
|
||||
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<string[]>',
|
||||
})
|
||||
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<void> {}',
|
||||
'const g = async (x: number = 2, { y }: O): Promise<string> => {',
|
||||
' 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<void>',
|
||||
})
|
||||
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<string>',
|
||||
})
|
||||
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<void> {}',
|
||||
' #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<void>' })
|
||||
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' }] },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, string> = {
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 源码着色 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<number, unknown> {
|
||||
const byLine: Record<number, unknown> = {}
|
||||
walk(blocks, (block) => {
|
||||
if ('facts' in block)
|
||||
byLine[block.header.startRow + 1] ??= nodesToText(block.facts)
|
||||
})
|
||||
return byLine
|
||||
}
|
||||
@@ -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<Map<number, Translated>> {
|
||||
return withBlocks(adapter, languageId, source, (blocks) => {
|
||||
const byLine = new Map<number, Translated>()
|
||||
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<Map<number, PropertyRow[]>> {
|
||||
return withBlocks(adapter, languageId, source, (blocks) => {
|
||||
const byLine = new Map<number, PropertyRow[]>()
|
||||
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<number, PropertyRow[]>): Record<number, string[]> {
|
||||
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<EnvLockResult>')
|
||||
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 extends X>(t: T) {}',
|
||||
'type F<T extends X> = (t: T) => void',
|
||||
'type R<T> = 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 限定为 X>(t: T)')
|
||||
expect(map[9]).toBe('类型 F<T 限定为 X> = (t: T) => 无')
|
||||
expect(map[10]).toBe('类型 R<T> = 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<EnvLockResult>',
|
||||
])
|
||||
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: '丁',
|
||||
|
||||
@@ -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<Access, string> = { 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('无')] },
|
||||
])
|
||||
}
|
||||
@@ -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<BlockKind, string> = {
|
||||
@@ -60,7 +61,18 @@ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?:
|
||||
)
|
||||
}
|
||||
|
||||
/** 一块:折叠箭头、种类标签、译文或源码、右边的「编辑 / 预览」切换。 */
|
||||
/** 预览状态的内容:函数、导入、常量、变量块是属性表,其余是一行译文。 */
|
||||
function BlockPreview({ block }: { block: BlockView }): ReactElement {
|
||||
if ('properties' in block)
|
||||
return <PropertyGrid rows={block.properties} />
|
||||
return (
|
||||
<p className="cr-code">
|
||||
<SegmentText segments={block.translation} />
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一块:折叠箭头、种类标签、预览内容或源码、右边的「编辑 / 预览」切换。 */
|
||||
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)}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<p className="cr-code">
|
||||
<SegmentText segments={block.translation} />
|
||||
</p>
|
||||
)}
|
||||
: <BlockPreview block={block} />}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { PropertyItem, PropertyRow } from './types'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import { SegmentText } from './SegmentText'
|
||||
|
||||
/** 子行里除最后一列以外各列的宽度(参数的名称、类型),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
|
||||
const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)']
|
||||
|
||||
function columns(count: number): string {
|
||||
const fixed = Array.from({ length: count - 1 }, (_, i) => CELL_WIDTHS[i] ?? CELL_WIDTHS[CELL_WIDTHS.length - 1])
|
||||
return [...fixed, 'minmax(0, 1fr)'].join(' ')
|
||||
}
|
||||
|
||||
/** 展开在一行下面的子行:左边一条竖线,每个子行几列对齐。 */
|
||||
function PropertyItems({ items }: { items: PropertyItem[] }): ReactElement {
|
||||
return (
|
||||
<div className="flex gap-2.5 pl-[5px]">
|
||||
<div className="cr-guide w-px shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="grid gap-x-3" style={{ gridTemplateColumns: columns(item.cells.length) }}>
|
||||
{item.cells.map((cell, i) => (
|
||||
<p key={i} className="cr-code min-w-0">
|
||||
<SegmentText segments={cell} />
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 函数、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项下面展开子行。 */
|
||||
export function PropertyGrid({ rows }: { rows: PropertyRow[] }): ReactElement {
|
||||
return (
|
||||
<div className="flex flex-col gap-[3px]">
|
||||
{rows.map((row, index) => {
|
||||
const items = row.items ?? []
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<div className="flex gap-3">
|
||||
<span className="cr-prop-label flex h-5 w-[76px] shrink-0 items-center gap-1">
|
||||
{items.length > 0 && <ChevronDown size={12} strokeWidth={1.8} className="shrink-0" />}
|
||||
{row.label}
|
||||
</span>
|
||||
<p className="cr-code min-w-0 flex-1">
|
||||
<SegmentText segments={row.value} />
|
||||
</p>
|
||||
</div>
|
||||
{items.length > 0 && <PropertyItems items={items} />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -132,6 +132,15 @@ body.vscode-high-contrast-light {
|
||||
.cr-role-number,
|
||||
.cr-role-literal { color: var(--cr-number); }
|
||||
.cr-role-comment { color: var(--cr-comment); }
|
||||
.cr-role-hint { color: var(--vscode-descriptionForeground); }
|
||||
|
||||
/* 属性表的属性名:界面字体,和值一样的行高,两边逐行对齐 */
|
||||
.cr-prop-label {
|
||||
font-size: 12.5px;
|
||||
line-height: 20px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 编辑:透明输入框垫在底下接收输入和选区,着色层盖在上面只负责显示,高度由着色层撑开 */
|
||||
.cr-editor {
|
||||
|
||||
@@ -10,12 +10,23 @@ export type BlockKind
|
||||
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' // 被翻成文字的运算符:全等 与 或 非
|
||||
| 'operator' // 被翻成文字的运算符:全等 并且 或者 非
|
||||
| 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 …
|
||||
| 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain'
|
||||
|
||||
export interface Segment { text: string, role: TokenRole }
|
||||
|
||||
export interface BlockView {
|
||||
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值 */
|
||||
export interface PropertyItem { cells: Segment[][] }
|
||||
|
||||
/** 属性表的一行:左列属性名,右列着色的值;items 是展开在这一行下面的子行(输入参数、导入项) */
|
||||
export interface PropertyRow {
|
||||
label: string
|
||||
value: Segment[]
|
||||
items?: PropertyItem[]
|
||||
}
|
||||
|
||||
interface BlockViewBase {
|
||||
/** 在树里的位置,例如 '6.1.0' */
|
||||
id: string
|
||||
kind: BlockKind
|
||||
@@ -26,10 +37,12 @@ export interface BlockView {
|
||||
code: string
|
||||
/** 源码着色(拼起来等于 code) */
|
||||
codeTokens: Segment[]
|
||||
translation: Segment[]
|
||||
children: BlockView[]
|
||||
}
|
||||
|
||||
/** 函数、导入、常量、变量块预览时是属性表,其余是一行译文 */
|
||||
export type BlockView = BlockViewBase & ({ translation: Segment[] } | { properties: PropertyRow[] })
|
||||
|
||||
export interface TranslatorOption { id: string, label: string }
|
||||
|
||||
export interface DocumentMessage {
|
||||
|
||||
Reference in new issue
Block a user