✨ 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,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]')
})
})