✨ feat(vscode): 代码阅读的解析与模板直译核心

This commit is contained in:
cruldra committed 2026-09-24 21:16:21 +08:00
1 parent af56985d9a
commit 4d34b8c715
24 files changed
+2294

No files matched your search

@@ -0,0 +1,77 @@
import type { Node, Translator } from '../contract'
import { insideAnnotation, pythonTokenRole } from '../languages/python'
import { createZhTemplateTranslator } from './zhTemplate'
function stringContent(node: Node): string {
if (node.type === 'string_content')
return node.text
return node.children.map(child => child ? stringContent(child) : '').join('')
}
/** `#` 注释去掉井号;docstring 只取引号里的内容。 */
function commentText(node: Node): string {
return node.type === 'comment' ? node.text.replace(/^#+\s?/, '') : stringContent(node)
}
export const pythonZhTranslator: Translator = createZhTemplateTranslator({
id: 'python.zh-template',
label: '中文直译',
languageIds: ['python'],
tokenRole: pythonTokenRole,
keywords: {
'import': '导入',
'from': '来自',
'as': '作为',
'def': '函数',
'async': '异步',
'await': '等待',
'class': '类',
'return': '返回',
'if': '如果',
'elif': '否则如果',
'else': '否则',
'for': '遍历',
'in': { zh: '在', byParent: { for_statement: '取自', for_in_clause: '取自' } },
'while': '当',
'try': '尝试',
'except': '捕获',
'finally': '最终',
'raise': '抛出',
'with': '使用',
'yield': '产出',
'lambda': '匿名函数',
'pass': '跳过',
'break': '跳出',
'continue': '继续',
'global': '全局',
'nonlocal': '非局部',
'del': '删除',
'assert': '断言',
'not': '非',
'and': '与',
'or': '或',
'is': '是',
'not in': '不在',
'is not': '不是',
'match': '匹配',
'case': '情况',
},
literals: { none: '空', true: '真', false: '假' },
builtinTypes: {
str: '字符串',
int: '整数',
float: '浮点数',
bool: '布尔',
list: '列表',
dict: '字典',
set: '集合',
tuple: '元组',
bytes: '字节串',
},
isTypePosition: leaf => leaf.type === 'identifier' && leaf.parent?.type !== 'attribute' && insideAnnotation(leaf),
operators: { '==': '等于', '!=': '不等于' },
operatorParents: ['comparison_operator'],
emptyLiterals: { list: '空列表', dictionary: '空字典' },
stringNodes: ['string'],
commentText,
})
@@ -0,0 +1,90 @@
import type { Node, Translator } from '../contract'
import { typescriptTokenRole } from '../languages/typescript'
import { createZhTemplateTranslator } from './zhTemplate'
function commentText(node: Node): string {
const text = node.text
if (text.startsWith('//'))
return text.replace(/^\/{2,}\s?/, '')
return text
.replace(/^\/\*+/, '')
.replace(/\*+\/$/, '')
.split('\n')
.map(line => line.replace(/^\s*\*(?!\/)\s?/, ''))
.join('\n')
}
export const typescriptZhTranslator: Translator = createZhTemplateTranslator({
id: 'typescript.zh-template',
label: '中文直译',
languageIds: ['typescript', 'typescriptreact', 'javascript', 'javascriptreact'],
tokenRole: typescriptTokenRole,
keywords: {
// 流程
import: '导入',
from: '来自',
as: '作为',
export: '公共',
default: '默认',
if: '如果',
else: '否则',
for: '遍历',
of: '取自',
in: '在',
while: '当',
do: '执行',
switch: '分支',
case: '情况',
break: '跳出',
continue: '继续',
return: '返回',
throw: '抛出',
try: '尝试',
catch: '捕获',
finally: '最终',
await: '等待',
yield: '产出',
// 声明
const: '常量',
let: '变量',
var: '变量',
function: '函数',
async: '异步',
class: '类',
extends: '继承',
implements: '实现',
interface: '接口',
type: '类型',
enum: '枚举',
new: '新建',
instanceof: '属于',
typeof: '类型为',
keyof: '键名',
readonly: '只读',
public: '公共',
private: '私有',
protected: '受保护',
static: '静态',
abstract: '抽象',
delete: '删除',
},
literals: { true: '真', false: '假', null: '空', undefined: '未定义' },
builtinTypes: {
string: '字符串',
number: '数值',
boolean: '布尔',
any: '任意',
unknown: '未知',
never: '永不',
void: '无',
object: '对象',
bigint: '大整数',
symbol: '符号',
},
isTypePosition: leaf => leaf.parent?.type === 'predefined_type',
operators: { '===': '全等', '!==': '不全等', '==': '等于', '!=': '不等于', '&&': '与', '||': '或', '!': '非' },
operatorParents: ['binary_expression', 'unary_expression'],
emptyLiterals: { array: '空数组' },
stringNodes: ['string', 'template_string', 'regex'],
commentText,
})
@@ -0,0 +1,179 @@
import type { LanguageAdapter, Segment, Translator } from '../contract'
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 { pythonZhTranslator } from './pythonZh'
import { typescriptZhTranslator } from './typescriptZh'
const ts = createTypeScriptAdapter(WASM_DIR)
const py = createPythonAdapter(WASM_DIR)
interface Translated { kind: string, segments: Segment[] }
/** 每块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。 */
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))
byLine.set(line, { kind: block.kind, segments: translator.translate(block, source) })
})
return byLine
})
}
function text(segments: Segment[] | undefined): string {
return (segments ?? []).map(segment => segment.text).join('')
}
function texts(map: Map<number, Translated>): Record<number, string> {
return Object.fromEntries([...map].map(([line, { segments }]) => [line, text(segments)]))
}
const CJK = /\p{Script=Han}/u
const CJK_ONLY = /^\p{Script=Han}+$/u
const WORD_CHAR = /[\p{Script=Han}\w]/u
/** 被翻的词单独成段,而且不和前后的字母、数字、汉字粘在一起。 */
function expectWordsApart(map: Map<number, Translated>): void {
for (const [line, { kind, segments }] of map) {
if (kind === 'comment')
continue
const full = text(segments)
let at = 0
for (const segment of segments) {
if (segment.role !== 'string' && CJK.test(segment.text)) {
expect(segment.text, `L${line} ${full}`).toMatch(CJK_ONLY)
for (const neighbor of [full[at - 1], full[at + segment.text.length]])
expect(neighbor ?? ' ', `L${line} "${full}" 里的「${segment.text}」`).not.toMatch(WORD_CHAR)
}
at += segment.text.length
}
}
}
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 全等 未定义)')
})
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([
{ text: '公共', role: 'keyword' },
{ text: '异步', role: 'storage' },
{ text: '函数', role: 'storage' },
{ text: 'findEnvFiles', role: 'function' },
])
expect(issue.get(14)?.segments).toContainEqual({ text: '全等', role: 'operator' })
expect(env.get(26)?.segments).toEqual([{ text: '返回', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: '空数组', role: 'literal' }])
expectWordsApart(env)
expectWordsApart(issue)
})
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',
// eslint-disable-next-line no-template-curly-in-string -- 被翻译的源码文本里本来就有模板字符串
'const s: string | void = 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')
// eslint-disable-next-line no-template-curly-in-string -- 模板字符串原样照抄进译文
expect(map[3]).toBe('常量 s: 字符串 | 无 = 类型为 v 全等 \'string\' ? `${v}` : \'number\'')
})
it('多行合成一行、去掉右括号前的尾逗号、行中注释不进译文', async () => {
const source = [
'call(',
' first, // 第一个',
' [],',
')',
].join('\n')
expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source))[1]).toBe('call( first, 空数组 )')
})
})
describe('python 中文直译', () => {
it('样例全文逐块一致', async () => {
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',
21: '如果 name 在 skipped',
22: '继续',
23: '尝试',
24: '使用 open(os.path.join(root, found[0])) 作为 handle',
25: 'handle.read()',
26: '捕获 OSError 作为 err',
27: '抛出 RuntimeError(str(err)) 来自 err',
28: '最终',
29: 'skipped.clear()',
30: '返回 found',
33: '类 EnvLockResult',
34: 'total: 整数',
35: 'ok: 列表[字符串] = 空列表',
36: 'failed: 字典[字符串, 字符串] = 空字典',
37: 'label = \'env\'',
39: '函数 summary(self, verbose: 布尔 = 假) -> 字符串',
40: '返回 f\'{self.total} files\' 如果 verbose 不是 空 否则 \'\'',
})
expectWordsApart(map)
})
it('内置类型只在注解里翻,in 按位置区分取自 / 在', async () => {
const source = [
'def pick(items: dict[str, int], list: set) -> tuple:',
' return [str(k) for k in items if k not in list]',
].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]')
})
})
@@ -0,0 +1,169 @@
import type { CodeBlock, Node, Segment, SourceSpan, TokenRole, Translator } from '../contract'
/** 按父节点类型区分译法的关键字,例如 Python 的 in:for 头部里「取自」,比较里「在」。 */
export interface ContextualWord { zh: string, byParent: Record<string, string> }
/** 一种语言的中文直译表。引擎只替换表里的三类东西,其余原样照抄。 */
export interface ZhTable {
id: string
label: string
languageIds: string[]
/** 未翻译的词按源码着色的词性上色;关键字译文的词性也取它。 */
tokenRole: (leaf: Node) => TokenRole
/** 关键字:按语法树里匿名节点的类型查(Python 的 `not in` 这类组合关键字本身也是一个匿名节点)。 */
keywords: Record<string, string | ContextualWord>
/** 字面量关键字:具名的叶子节点(true / None …),按节点类型查。 */
literals: Record<string, string>
/** 内置类型:只在 isTypePosition 成立的叶子上按原文查。 */
builtinTypes: Record<string, string>
isTypePosition: (leaf: Node) => boolean
/** 翻成文字的运算符:只认父节点在 operatorParents 里的运算符节点(TS 的非空断言 `x!` 不算)。 */
operators: Record<string, string>
operatorParents: string[]
/** 空字面量(没有具名子节点)整个换成一个词:空数组 / 空列表 / 空字典。 */
emptyLiterals: Record<string, string>
/** 整段原样照抄、按字符串着色的节点。 */
stringNodes: string[]
/** 注释(或 docstring)节点去掉注释符号后的原文,可以多行。 */
commentText: (node: Node) => string
}
interface Token {
start: number
end: number
text: string
role: TokenRole
translated: boolean
/** 源码里的原文,用来认出逗号、括号。 */
raw: string
}
const CLOSING = new Set([')', ']', '}'])
const HEADER_OPENERS = new Set(['{', ':'])
function firstLeaf(node: Node): Node {
const first = node.child(0)
return first ? firstLeaf(first) : node
}
function commentsWithin(node: Node, span: SourceSpan): Node[] {
if (node.endIndex <= span.startIndex || node.startIndex >= span.endIndex)
return []
if (node.type === 'comment')
return [node]
return node.children.flatMap(child => child ? commentsWithin(child, span) : [])
}
/** 下一个要输出的词的原文(跳过注释)。 */
function nextRaw(tokens: Token[], i: number): string {
for (let j = i + 1; j < tokens.length; j++) {
if (tokens[j].text !== '')
return tokens[j].raw
}
return ''
}
function oneLine(text: string): string {
return text.replace(/\s*\n\s*/g, ' ')
}
export function createZhTemplateTranslator(table: ZhTable): Translator {
const operatorParents = new Set(table.operatorParents)
const stringNodes = new Set(table.stringNodes)
/** 能整个换成中文的节点 → 译文和词性。 */
function translateNode(node: Node): { text: string, role: TokenRole } | undefined {
if (node.childCount === 0 && table.isTypePosition(node)) {
const type = table.builtinTypes[node.text]
if (type)
return { text: type, role: 'type' }
}
const parent = node.parent
if (!node.isNamed) {
if (parent && operatorParents.has(parent.type) && table.operators[node.type])
return { text: table.operators[node.type], role: 'operator' }
const word = table.keywords[node.type]
if (word !== undefined) {
const text = typeof word === 'string' ? word : (parent && word.byParent[parent.type]) || word.zh
return { text, role: table.tokenRole(firstLeaf(node)) }
}
return undefined
}
if (node.childCount === 0 && table.literals[node.type])
return { text: table.literals[node.type], role: table.tokenRole(node) }
if (table.emptyLiterals[node.type] && node.namedChildCount === 0)
return { text: table.emptyLiterals[node.type], role: 'literal' }
return undefined
}
function collect(node: Node, span: SourceSpan, out: Token[]): void {
if (node.endIndex <= span.startIndex || node.startIndex >= span.endIndex || node.startIndex === node.endIndex)
return
const base = { start: node.startIndex, end: node.endIndex }
if (node.type === 'comment') {
out.push({ ...base, raw: node.text, text: '', role: 'comment', translated: false })
return
}
const translated = translateNode(node)
if (translated) {
out.push({ ...base, raw: node.text, ...translated, translated: true })
return
}
if (node.childCount === 0 || stringNodes.has(node.type)) {
const role = stringNodes.has(node.type) ? 'string' : table.tokenRole(node)
out.push({ ...base, raw: node.text, text: oneLine(node.text), role, translated: false })
return
}
for (const child of node.children) {
if (child)
collect(child, span, out)
}
}
function translateComment(block: CodeBlock): 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]
const text = nodes.map(table.commentText).join('\n').split('\n').map(line => line.trim()).filter(Boolean).join(' ')
return text ? [{ text, role: 'comment' }] : []
}
function translate(block: CodeBlock, source: string): Segment[] {
if (block.kind === 'comment')
return translateComment(block)
const tokens: Token[] = []
collect(block.node, block.header, tokens)
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
let prevEnd = block.header.startIndex
tokens.forEach((token, i) => {
gap ||= /\s/.test(source.slice(prevEnd, token.start))
prevEnd = token.end
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 { id: table.id, label: table.label, languageIds: table.languageIds, translate }
}