✨ feat(vscode): 代码阅读中文直译里 Python 的 not x、TypeScript 的 !x 读成「x 不成立」(如果 root_dir.is_dir() 不成立),Python 走节点模板,TypeScript 用节点读法只换 !,去掉「非」的译法和它补空格的特例

This commit is contained in:
cruldra committed 2026-10-14 15:16:37 +11:00
1 parent e3c8c1b118
commit e1a5f4f012
9 files changed
+99 -35

No files matched your search

+1 -1
View File
@@ -349,7 +349,7 @@ describe('blockView', () => {
' x | 整数 | ',
'输出参数: 布尔',
])
expect(translationText(list[0].children[0])).toBe('返回 非 x')
expect(translationText(list[0].children[0])).toBe('返回 x 不成立')
})
it('跨几行写的调用:一行译文下面带摊开实参的表;calleeKind 交给翻译插件分新建还是调用', async () => {
+1 -1
View File
@@ -600,7 +600,7 @@ export interface CommentBlock extends TextBlock { kind: 'comment' }
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 个 默认导出 …
| 'constant' // 按习惯全大写、当常量用的名字(shell 的 PY_VERSION、HOME)
| 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain'
+7 -7
View File
@@ -305,7 +305,7 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis
' 依赖项: collapsed(useCallback)',
' variable 48',
' 名称: next',
' 初始值: 非 collapsed',
' 初始值: collapsed 不成立',
' statement 49: setCollapsed(next);',
' try 50: 尝试',
' statement 51: localStorage.setItem(PLAN_CHECKLIST_COLLAPSED_KEY, 如果 next 则 "1" 否则 "0");',
@@ -323,7 +323,7 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis
expect(render.badge).toEqual({ label: '渲染输出', color: { dark: '#89d185', light: '#388a34' }, icon: 'layout-template' })
expect(lines(render).slice(0, 4)).toEqual([
'JSX 结构大纲: ',
' Collapsible ⟦样式 1 个类 · 取决于 CARD_SURFACE|⟧ open=非 collapsed onOpenChange=toggle',
' Collapsible ⟦样式 1 个类 · 取决于 CARD_SURFACE|⟧ open=collapsed 不成立 onOpenChange=toggle',
' CollapsibleTrigger ⟦样式 3 个类 · 取决于 FLUSH_ROW_INTERACTIVE|⟧ data-testid="plan-checklist-toggle"',
' div ⟦样式 4 个类|⟧ data-testid="plan-progress"',
])
@@ -351,7 +351,7 @@ describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」
' variable 176',
' 名称: el',
' 初始值: textareaRef.current',
' if 177: 如果 (非 el)',
' if 177: 如果 (el 不成立)',
' return 177: 返回;',
' statement 178: el.focus();',
' statement 179: el.setSelectionRange(el.value.length, el.value.length);',
@@ -381,7 +381,7 @@ describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」
' textarea ⟦样式 6 个类 · 取决于 FIELD_TEXT|⟧ ref=textareaRef value=draft onChange=(e) => setDraft(e.target.value) aria-label="排队消息内容"',
' div ⟦样式 3 个类|⟧',
' Button variant="ghost" size="sm" onClick=onCancel "取消"',
' Button size="sm" disabled=非 canSave onClick=save "保存"',
' Button size="sm" disabled=canSave 不成立 onClick=save "保存"',
])
})
@@ -600,10 +600,10 @@ describe('react 库规则:渲染大纲', () => {
it('luffy 的 DesktopSettingsDialog:先 return null 是如果卡片,最后返回的 JSX 是渲染输出;AppVaultSummary 的片段写「片段,不产生元素」', async () => {
const dialog = await card(SETTINGS, 'DesktopSettingsDialog')
expect(viewLines([childAt(dialog, 180)])).toEqual(['if 180: 如果 (非 section)', ' return 180: 返回 空;'])
expect(viewLines([childAt(dialog, 180)])).toEqual(['if 180: 如果 (section 不成立)', ' return 180: 返回 空;'])
expect(lines(childAt(dialog, 181)).slice(0, 2)).toEqual([
'JSX 结构大纲: ',
' Dialog open onOpenChange=(next) => 非 next 并且 closeSettings()',
' Dialog open onOpenChange=(next) => next 不成立 并且 closeSettings()',
])
expect(lines(await render(readFixture('appVaultSummary.tsx.txt'), 'AppVaultSummary')).slice(0, 7)).toEqual([
'JSX 结构大纲: ',
@@ -649,7 +649,7 @@ describe('react 库规则:渲染大纲', () => {
it('luffy 的 DeleteAppDialog 的删除按钮:文字的三元写成是 / 否两支', async () => {
const text = lines(await render(readFixture('deleteAppDialog.tsx.txt'), 'DeleteAppDialog'))
const button = text.indexOf(' Button variant="destructive" onClick=() => remove.mutate() disabled=非 ready 或者 pending')
const button = text.indexOf(' Button variant="destructive" onClick=() => remove.mutate() disabled=ready 不成立 或者 pending')
expect(text.slice(button + 1, button + 4)).toEqual([
' ⟦|如果 pending⟧',
' ⟦|是⟧ "删除中…"',
@@ -64,7 +64,6 @@ export const pythonZhTranslator: Translator = createZhTemplateTranslator({
'nonlocal': '非局部',
'del': '删除',
'assert': '断言',
'not': '非',
'and': '并且',
'or': '或者',
'is': '是',
@@ -95,5 +94,7 @@ export const pythonZhTranslator: Translator = createZhTemplateTranslator({
// `a if c else b` 的三个具名子节点依次是 a、c、b,语法没给它们起字段名
nodeTemplates: {
conditional_expression: ['如果', { namedChild: 1 }, '则', { namedChild: 0 }, '否则', { namedChild: 2 }],
// `not x` 读成「x 不成立」,照中文语序把条件放在前面;`not in`、`is not` 是单独的词,不走这里
not_operator: [{ field: 'argument' }, '不成立'],
},
})
@@ -1,6 +1,18 @@
import type { Node, Translator } from '../contract'
import type { NodeReader } from './zhTemplate'
import { createZhTemplateTranslator } from './zhTemplate'
/**
* `!x` 读成「x 不成立」,照中文语序把条件放在前面;别的一元运算(`-x`、`typeof x`)照常翻。
* 和节点模板一样,节点没整个落在要翻的这一段里也照常翻。
*/
const unaryExpression: NodeReader = (node, context) => {
const argument = node.childForFieldName('argument')
if (node.childForFieldName('operator')?.type !== '!' || !argument || !context.whole(node))
return undefined
return [...context.say(argument), { text: ' ', role: 'plain' }, { text: '不成立', role: 'keyword' }]
}
function commentText(_node: Node, text: string): string {
if (text.startsWith('//'))
return text.replace(/^\/{2,}\s?/, '')
@@ -80,8 +92,8 @@ export const typescriptZhTranslator: Translator = createZhTemplateTranslator({
symbol: '符号',
},
isTypePosition: leaf => leaf.parent?.type === 'predefined_type',
operators: { '===': '全等', '!==': '不全等', '==': '等于', '!=': '不等于', '&&': '并且', '||': '或者', '!': '非' },
operatorParents: ['binary_expression', 'unary_expression'],
operators: { '===': '全等', '!==': '不全等', '==': '等于', '!=': '不等于', '&&': '并且', '||': '或者' },
operatorParents: ['binary_expression'],
emptyLiterals: { array: '空数组', object: '空对象' },
collections: { object: '对象', array: '数组' },
stringNodes: ['string', 'template_string', 'regex'],
@@ -89,4 +101,5 @@ export const typescriptZhTranslator: Translator = createZhTemplateTranslator({
nodeTemplates: {
ternary_expression: ['如果', { field: 'condition' }, '则', { field: 'consequence' }, '否则', { field: 'alternative' }],
},
readers: { unary_expression: unaryExpression },
})
@@ -112,7 +112,7 @@ 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(25)?.segments)).toBe('如果 (非 folder)')
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: 字符串 }[]')
@@ -138,7 +138,7 @@ describe('typeScript 中文直译', () => {
'`.env` files are only picked up the next time the user clicks the button.',
].join('\n'))
expect(issue[5]).toBe('prerequisiteNumber?: 数值')
expect(issue[15]).toBe('如果 (非 issue 或者 issue.prerequisite 全等 未定义)')
expect(issue[15]).toBe('如果 (issue 不成立 或者 issue.prerequisite 全等 未定义)')
})
it('每个被翻的词单独成段,着色取语法树词性', async () => {
@@ -165,7 +165,7 @@ describe('typeScript 中文直译', () => {
].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[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[4]).toBe('s = 如果 类型为 v 全等 \'string\' 则 `${v}` 否则 \'number\'')
@@ -228,7 +228,7 @@ describe('typeScript 中文直译', () => {
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',
4: 'v = 如果 a 不全等 b 并且 c 不成立 则 类型为 d 否则 等待 e',
})
expect(flatSegments(map.get(1)?.segments ?? []).filter(segment => segment.role === 'keyword')).toEqual([
{ text: '如果', role: 'keyword' },
@@ -237,6 +237,28 @@ describe('typeScript 中文直译', () => {
])
expectWordsApart(map)
})
it('取反 `!x` 读成「x 不成立」;别的一元运算、非空断言、不等号照旧', async () => {
const source = [
'if (!a) {}',
'if (!(a && b)) {}',
'x = -a + typeof b',
'y = c!.d !== e',
].join('\n')
const map = await translateAll(ts, 'typescript', typescriptZhTranslator, source)
expect(texts(map)).toEqual({
1: '如果 (a 不成立)',
2: '如果 ((a 并且 b) 不成立)',
3: 'x = -a + 类型为 b',
4: 'y = c!.d 不全等 e',
})
expect(flatSegments(map.get(1)?.segments ?? []).filter(segment => segment.role !== 'plain')).toEqual([
{ text: '如果', role: 'keyword' },
{ text: 'a', role: 'variable', offset: source.indexOf('a') },
{ text: '不成立', role: 'keyword' },
])
expectWordsApart(map)
})
})
describe('python 中文直译', () => {
@@ -245,7 +267,7 @@ describe('python 中文直译', () => {
expect(texts(map)).toEqual({
1: 'Scan a workspace for .env files.',
12: '跳过备份文件',
14: '如果 非 found',
14: '如果 found 不成立',
15: '返回 空列表',
16: '否则如果 len(found) > MAX_DEPTH',
18: '否则',
@@ -341,7 +363,7 @@ describe('python 中文直译', () => {
1: '如果 _role 等于 "beat" 则 5680 否则 5679',
2: '如果 x 则 a 否则 如果 y 则 b 否则 c',
3: '如果 y 则 (如果 x 则 a 否则 b) 否则 c',
4: '如果 非 done 并且 ok 则 ⟦列表|遍历 items 的每个 k,仅当 k 不在 seen,取 k⟧ 否则 空',
4: '如果 done 不成立 并且 ok 则 ⟦列表|遍历 items 的每个 k,仅当 k 不在 seen,取 k⟧ 否则 空',
})
expect(flatSegments(valueOf(map.get(1), '初始值')).filter(segment => segment.role === 'keyword' || segment.role === 'operator')).toEqual([
{ text: '如果', role: 'keyword' },
@@ -350,6 +372,34 @@ describe('python 中文直译', () => {
{ text: '否则', role: 'keyword' },
])
})
it('`not x` 读成「x 不成立」:if 的条件、和 and 连着、括号里的、条件表达式里的;`not in`、`is not` 照旧', async () => {
const source = [
'def f(a, b, c, root_dir):',
' if not root_dir.is_dir():',
' return 1 if not b else 2',
' if not a and b:',
' pass',
' if not (a or b):',
' pass',
' if a not in b or a is not c:',
' pass',
].join('\n')
const map = await translateAll(py, 'python', pythonZhTranslator, source)
expect([2, 3, 4, 6, 8].map(line => text(map.get(line)?.segments))).toEqual([
'如果 root_dir.is_dir() 不成立',
'返回 如果 b 不成立 则 1 否则 2',
'如果 a 不成立 并且 b',
'如果 (a 或者 b) 不成立',
'如果 a 不在 b 或者 a 不是 c',
])
expect(flatSegments(inlineOf(map.get(4)?.segments ?? [])).filter(segment => segment.role === 'keyword' || segment.role === 'operator')).toEqual([
{ text: '如果', role: 'keyword' },
{ text: '不成立', role: 'keyword' },
{ text: '并且', role: 'operator' },
])
expect(linked(map.get(2)?.segments)).toEqual([['root_dir', source.indexOf('root_dir.')], ['is_dir', source.indexOf('is_dir')]])
})
})
describe('python 写在表达式里的遍历(推导式)', () => {
@@ -835,7 +885,7 @@ describe('属性表', () => {
'值: 对象 7 项',
' total | files.length',
' \'x-y\' | 等待 load()',
' [key] | 非 ok',
' [key] | ok 不成立',
' files | files',
' 展开 | rest',
' load | 异步 load(id: 字符串): Promise<无>',
@@ -897,7 +947,7 @@ describe('源码下标(跳到定义用)', () => {
const at = (word: string, from = 0): number => source.indexOf(word, from)
const map = await translateAll(py, 'python', pythonZhTranslator, source)
const ifLine = at('if')
expect(text(map.get(2)?.segments)).toBe('如果 非 user.active 并且 user 不是 空')
expect(text(map.get(2)?.segments)).toBe('如果 user.active 不成立 并且 user 不是 空')
expect(linked(map.get(2)?.segments)).toEqual([['user', at('user', ifLine)], ['active', at('active')], ['user', at('user is')]])
expect(linked(map.get(3)?.segments)).toEqual([['HTTPException', at('HTTPException')], ['status_code', at('status_code')]])
})
@@ -38,7 +38,7 @@ export interface ZhTable {
/** 内置类型:只在 isTypePosition 成立的叶子上按原文查。 */
builtinTypes: Record<string, string>
isTypePosition: (leaf: Node) => boolean
/** 翻成文字的运算符:只认父节点在 operatorParents 里的运算符节点(TS 的非空断言 `x!` 不算)。 */
/** 翻成文字的运算符:只认父节点在 `operatorParents` 里的运算符节点,同一个符号写在别处的照抄。 */
operators: Record<string, string>
operatorParents: string[]
/** 空字面量(没有具名子节点)整个换成一个词:空数组 / 空列表 / 空字典 / 空对象。 */
@@ -60,7 +60,7 @@ export interface ZhTable {
*/
nodeTemplates: Record<string, TemplatePart[]>
/**
* 节点读法:节点类型 → 怎么读成一行译文,给模板排不了的写法(shell 的 `${x:-d}`、`[ -f x ]`、`case`)。
* 节点读法:节点类型 → 怎么读成一行译文,给模板排不了的写法(shell 的 `${x:-d}`、`[ -f x ]`、`case`,TypeScript 一元运算里只有 `!x` 换语序)。
* 命中的节点只输出读法给的,不再照常翻它的子节点;读法返回 undefined 就照常翻。读法里要翻子节点用 ReaderContext.say。
*/
readers?: Record<string, NodeReader>
@@ -903,8 +903,7 @@ function joinTokens(tokens: Token[]): Inline[] {
segments.push(token.group)
else
pushSegment(segments, token.text, token.role, token.offset)
// 「非」是个词,后面补一个空格,不和操作数粘在一起
gap = token.translated && token.raw === '!'
gap = false
})
return segments
}
+1 -1
View File
@@ -13,7 +13,7 @@ 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 个 默认导出 …
| 'constant' // 按习惯全大写、当常量用的名字
| 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain'