✨ feat(vscode): 代码阅读预览按住 ⌘ / Ctrl 点名字跳到定义

This commit is contained in:
cruldra committed 2026-09-25 12:54:45 +08:00
1 parent e5db1a2e65
commit 4e146eb48f
20 files changed
+870 -60

No files matched your search

@@ -55,6 +55,11 @@ function texts(map: Map<number, Translated>): Record<number, string> {
return Object.fromEntries([...map].map(([line, { segments }]) => [line, text(segments)]))
}
/** 只看文字和着色:去掉各段的源码下标(下标另有用例)。 */
function colors<T>(value: T): T {
return JSON.parse(JSON.stringify(value, (key, field: unknown) => key === 'offset' ? undefined : field)) as T
}
const CJK = /\p{Script=Han}/u
const CJK_ONLY = /^\p{Script=Han}+$/u
const WORD_CHAR = /[\p{Script=Han}\w]/u
@@ -105,7 +110,7 @@ describe('typeScript 中文直译', () => {
it('每个被翻的词单独成段,着色取语法树词性', async () => {
const env = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
const issue = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))
expect(env.get(32)?.segments.filter(segment => segment.role !== 'plain')).toEqual([
expect(colors(env.get(32)?.segments.filter(segment => segment.role !== 'plain'))).toEqual([
{ text: '公共', role: 'keyword' },
{ text: '接口', role: 'storage' },
{ text: 'EnvLockResult', role: 'type' },
@@ -375,7 +380,7 @@ describe('属性表', () => {
it('属性名是纯文字;值按词性着色,说明词(未标注、N 个、默认)用 hint', async () => {
const env = await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
const rows = env.get(23) ?? []
const rows = colors(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' }])
@@ -388,8 +393,8 @@ describe('属性表', () => {
{ 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([
expect(colors(valueOf(env.get(12), '来源'))).toEqual([{ text: '\'node:fs\'', role: 'string' }])
expect(colors(env.get(12)?.at(-1)?.items?.[0].cells[0])).toEqual([
{ text: 'promises', role: 'variable' },
{ text: ' ', role: 'plain' },
{ text: '作为', role: 'keyword' },
@@ -398,8 +403,8 @@ describe('属性表', () => {
])
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([
expect(colors(valueOf(sample.get(3), '来源'))).toEqual([{ text: 'os', role: 'type' }])
expect(colors(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' }]] },
])
@@ -464,6 +469,107 @@ describe('属性表', () => {
})
})
describe('源码下标(跳到定义用)', () => {
/** 带下标的段,按出现顺序写成 [文字, 下标]。 */
function linked(segments: Segment[] | undefined): [string, number][] {
return (segments ?? []).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]])
}
function itemCells(rows: PropertyRow[] | undefined, label: string): Segment[][] {
return rows?.find(row => row.label === label)?.items?.flatMap(item => item.cells) ?? []
}
function propertySegments(rows: PropertyRow[]): Segment[] {
return rows.flatMap(row => [...row.value, ...(row.items ?? []).flatMap(item => item.cells.flat())])
}
it('python:点分模块名每一节、导入项、别名各自带下标,点号不带;前面有中文注释也对得上', async () => {
const source = [
'# 数据库连接',
'from luffy_agent.core.db import get_db as conn',
'import os.path',
'from . import x',
].join('\n')
const at = (word: string): number => source.indexOf(word)
const rows = await describeAll(py, 'python', pythonZhTranslator, source)
expect(text(valueOf(rows.get(2), '来源'))).toBe('luffy_agent.core.db')
expect(linked(valueOf(rows.get(2), '来源'))).toEqual([['luffy_agent', at('luffy_agent')], ['core', at('core')], ['db', at('db')]])
expect(itemCells(rows.get(2), '导入项').map(linked)).toEqual([[['get_db', at('get_db')], ['conn', at('conn')]]])
expect(linked(valueOf(rows.get(3), '来源'))).toEqual([['os', at('os')], ['path', at('path')]])
expect(linked(valueOf(rows.get(4), '来源'))).toEqual([])
expect(itemCells(rows.get(4), '导入项').map(linked)).toEqual([[['x', at('x')]]])
})
it('python:一行译文里的名字带下标,关键字、运算符、字面量译词不带', async () => {
const source = [
'def f(user):',
' if not user.active and user is not None:',
' raise HTTPException(status_code=404)',
].join('\n')
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(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')]])
})
it('typeScript:模块字符串、导入项、函数名称、参数名和类型、返回类型、译文里的名字带下标', async () => {
const source = [
'// 读取配置',
'import { readFile as rf } from \'./fs\'',
'export async function load(path: string, opts?: Options): Promise<Result> {',
' if (opts === undefined) return rf(path)',
'}',
].join('\n')
const at = (word: string): number => source.indexOf(word)
const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source)
expect(linked(valueOf(rows.get(2), '来源'))).toEqual([['\'./fs\'', at('\'./fs\'')]])
expect(itemCells(rows.get(2), '导入项').map(linked)).toEqual([[['readFile', at('readFile')], ['rf', at('rf }')]]])
expect(linked(valueOf(rows.get(3), '名称'))).toEqual([['load', at('load')]])
expect(itemCells(rows.get(3), '输入参数').map(linked)).toEqual([
[['path', at('path:')]],
[],
[],
[['opts', at('opts?')]],
[['Options', at('Options')]],
[],
])
expect(linked(valueOf(rows.get(3), '输出参数'))).toEqual([['Promise', at('Promise')], ['Result', at('Result')]])
const map = await translateAll(ts, 'typescript', typescriptZhTranslator, source)
expect(text(map.get(4)?.segments)).toBe('如果 (opts 全等 未定义)')
expect(linked(map.get(4)?.segments)).toEqual([['opts', at('opts ===')]])
})
it('样例全文:带下标的只有照抄的名字和字符串,下标处的源码就是这段;变量名、函数名都带,译词、说明词都不带', async () => {
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'],
]
for (const [adapter, languageId, translator, name] of samples) {
const source = readFixture(name)
const translated = [...(await translateAll(adapter, languageId, translator, source)).values()].flatMap(block => block.segments)
const described = [...(await describeAll(adapter, languageId, translator, source)).values()].flatMap(propertySegments)
const segments = [...translated, ...described]
expect(linked(segments).length, name).toBeGreaterThan(10)
for (const segment of segments) {
const where = `${name} 「${segment.text}」`
if (segment.role === 'variable' || segment.role === 'function')
expect(segment.offset, where).toBeTypeOf('number')
if (segment.offset === undefined)
continue
expect(['variable', 'function', 'type', 'string'], where).toContain(segment.role)
if (segment.role === 'string')
expect(source[segment.offset], where).toBe(segment.text[0])
else
expect(source.startsWith(segment.text, segment.offset), where).toBe(true)
}
}
})
})
describe('中文模板引擎', () => {
function testTranslator(table: Partial<ZhTable>): Translator {
return createZhTemplateTranslator({
@@ -56,6 +56,8 @@ interface Token {
raw: string
/** 前面要空一格:源码里它前面有空白,或者模板在它前面插了别的项。 */
gap: boolean
/** 照抄的名字、字符串在源码里的起始下标,见 Segment.offset。 */
offset?: number
}
/** 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 */
@@ -69,6 +71,8 @@ interface Collector {
const CONTEXT_SEPARATOR = ' > '
const CLOSING = new Set([')', ']', '}'])
const HEADER_OPENERS = new Set(['{', ':'])
/** 源码着色里代表名字的词性:着这几种色的具名叶子是标识符、属性名、类型名,能查定义。 */
const NAME_ROLES = new Set<TokenRole>(['variable', 'function', 'type'])
function firstLeaf(node: Node): Node {
const first = node.child(0)
@@ -123,6 +127,12 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return text
}
/** 照抄这个节点时带不带源码下标:名字(按名字着色的具名叶子)和整段字符串带,标点、数字、关键字不带。 */
function offsetOf(node: Node): number | undefined {
const linkable = stringNodes.has(node.type) || (node.childCount === 0 && node.isNamed && NAME_ROLES.has(table.tokenRole(node)))
return linkable ? node.startIndex : undefined
}
/** 能整个换成中文的节点 → 译文和词性。 */
function translateNode(node: Node): { text: string, role: TokenRole } | undefined {
if (node.childCount === 0 && table.isTypePosition(node)) {
@@ -201,7 +211,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
}
if (node.childCount === 0 || stringNodes.has(node.type)) {
const role = stringNodes.has(node.type) ? 'string' : table.tokenRole(node)
emitSource(out, node, { text: oneLine(node.text), role, translated: false })
emitSource(out, node, { text: oneLine(node.text), role, translated: false, offset: offsetOf(node) })
return
}
for (const child of node.children) {
@@ -217,8 +227,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return out.tokens
}
/** node 的源码原样照抄:叶子按源码着色,叶子之间的空白、标点照抄。 */
function verbatim(node: Node, source: string): Segment[] {
/** node 的源码原样照抄:叶子按源码着色(给了 role 就都按它),叶子之间的空白、标点照抄。 */
function verbatim(node: Node, source: string, role?: TokenRole): Segment[] {
const segments: Segment[] = []
let cursor = node.startIndex
const visit = (current: Node): void => {
@@ -232,7 +242,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return
}
pushSegment(segments, source.slice(cursor, current.startIndex), 'plain')
pushSegment(segments, current.text, table.tokenRole(current))
pushSegment(segments, current.text, role ?? table.tokenRole(current), offsetOf(current))
cursor = current.endIndex
}
visit(node)
@@ -274,7 +284,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return joinTokens(tokensIn(node, span, source))
},
verbatim: node => verbatim(node, source),
module: node => [{ text: node.text, role: stringNodes.has(node.type) ? 'string' : 'type' }],
// 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样
module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node))] : verbatim(node, source, 'type'),
name: (node, role) => [segment(node.text, role, offsetOf(node))],
}
switch (block.kind) {
case 'function':
@@ -289,14 +301,18 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return { id: table.id, label: table.label, languageIds: table.languageIds, translate, describe }
}
function pushSegment(segments: Segment[], text: string, role: TokenRole): void {
function segment(text: string, role: TokenRole, offset: number | undefined): Segment {
return offset === undefined ? { text, role } : { text, role, offset }
}
function pushSegment(segments: Segment[], text: string, role: TokenRole, offset?: number): void {
if (!text)
return
const prev = segments.at(-1)
if (role === 'plain' && prev?.role === 'plain')
prev.text += text
else
segments.push({ text, role })
segments.push(segment(text, role, offset))
}
/** 词连成译文:源码里隔着空白的词之间空一格,注释不进译文,右括号前的尾逗号去掉。 */
@@ -312,7 +328,7 @@ function joinTokens(tokens: Token[]): Segment[] {
}
if (gap && segments.length > 0)
pushSegment(segments, ' ', 'plain')
pushSegment(segments, token.text, token.role)
pushSegment(segments, token.text, token.role, token.offset)
// 「非」是个词,后面补一个空格,不和操作数粘在一起
gap = token.translated && token.raw === '!'
})
@@ -329,6 +345,8 @@ interface Phrases {
verbatim: (node: Node) => Segment[]
/** 导入来源:字符串字面量按字符串着色,模块名按类型着色。 */
module: (node: Node) => Segment[]
/** 源码里的一个名字原样照抄、按 role 着色(函数名称)。 */
name: (node: Node, role: TokenRole) => Segment[]
}
const ACCESS_ZH: Record<Access, string> = { public: '公共', private: '私有', protected: '受保护' }
@@ -363,7 +381,7 @@ function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segmen
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('匿名')] },
{ label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] },
...accessRows(facts.access),
{ label: '异步', value: yesNo(facts.async) },
...(decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators, '\n') }] : []),