✨ feat(vscode): 代码阅读的调用与构造:跨多行写的调用排成「新建 / 调用 名字 · N 个参数」一行,实参摊成参数 / 值表;Python 查被调用名字的定义分新建和调用,查到后刷新

This commit is contained in:
cruldra committed 2026-09-27 22:08:16 +08:00
1 parent ebf228cdc7
commit ecc415d763
30 files changed
+1033 -157

No files matched your search

+22 -1
View File
@@ -1,7 +1,7 @@
import type { BlockView } from './blockView'
import type { LanguageAdapter, Translator } from './contract'
import { describe, expect, it } from 'vitest'
import { readBlockViews, toBlockViews } from './blockView'
import { readBlockViews, readDefinitionKind, toBlockViews } from './blockView'
import { replaceLines } from './editText'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
@@ -257,4 +257,25 @@ describe('blockView', () => {
])
expect(translationText(list[0].children[0])).toBe('返回 非 x')
})
it('跨几行写的调用:一行译文下面带摊开实参的表;calleeKind 交给翻译插件分新建还是调用', async () => {
const source = 'def f(user):\n return Detail(\n id=user.id,\n )\n'
const { blocks: list } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source, undefined, call => call.callee.text === 'Detail' ? 'class' : undefined)
const returned = list[0].children[0]
expect(translationText(returned)).toBe('返回 新建 Detail · 1 个参数')
expect('table' in returned && returned.table).toMatchObject({
columns: ['参数', '值'],
items: [{ cells: [[{ text: 'id', offset: source.indexOf('id=') }], [{ text: 'user' }, { text: '.' }, { text: 'id' }]] }],
})
})
it('readDefinitionKind:offset 落在哪块自己的名字上就是那块的种类,里层的块优先;不在名字上没有', async () => {
const source = 'class UserService:\n def update_user(self):\n pass\n\nclass NotFound(Exception):\n pass\n\nlimit = 3\n'
const kind = (text: string): Promise<string | undefined> => readDefinitionKind(runtime, py, 'python', source, source.indexOf(text))
expect(await kind('UserService')).toBe('class')
expect(await kind('update_user')).toBe('function')
expect(await kind('NotFound')).toBe('exception')
expect(await kind('limit')).toBe('variable')
expect(await kind('pass')).toBeUndefined()
})
})
+47 -9
View File
@@ -1,4 +1,4 @@
import type { BlockAttributes, CodeBlock, DisplayName, KindBadge, LanguageAdapter, LibraryRule, Markdown, Node, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TextBlock, TokenRole, Translator } from './contract'
import type { BlockAttributes, BlockKind, CodeBlock, DisplayName, KindBadge, LanguageAdapter, LibraryRule, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TextBlock, TokenRole, Translator } from './contract'
import type { TreeSitterRuntime } from './runtime'
import { blockAttributes } from './attributes'
import { commonIndent, splitLines } from './editText'
@@ -33,9 +33,9 @@ interface BlockViewBase {
/**
* 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文。
* 一行译文的块带说明(TextBlock.doc)时 doc 是翻译插件给的 Markdown 原文,预览排在译文下面。
* 一行译文下面依次是:跨几行写的调用摊开各个实参的表(TextTranslation.table),块的说明(TextBlock.doc,翻译插件给的 Markdown 原文)。
*/
export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] })
export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown } | { properties: PropertyRow[] })
interface Line { start: number, end: number }
@@ -159,10 +159,13 @@ export function toBlockViews(blocks: CodeBlock[], document: SourceDocument, tran
codeTokens: colorLines(root, source, lines, startRow, endRow, indent, adapter.tokenRole),
}
}
/** 一行译文,带说明的块连着说明。 */
const textView = (block: TextBlock): { translation: PreviewText, doc?: Markdown } => {
const doc = block.doc && translator?.doc(block.doc, document)
return { translation: translator ? translator.translate(block, document) : [], ...(doc && { doc }) }
/** 一行译文,连着摊开实参的表、块的说明(有才有)。 */
const textView = (block: TextBlock): { translation: PreviewText, table?: PreviewTable, doc?: Markdown } => {
if (!translator)
return { translation: [] }
const { text, table } = translator.translate(block, document)
const doc = block.doc && translator.doc(block.doc, document)
return { translation: text, ...(table && { table }), ...(doc && { doc }) }
}
/** 按源码先后逐块给位置记号:先序遍历,块的记号在它的子块之前要 */
const anchors = new BlockAnchors()
@@ -215,7 +218,10 @@ export interface DocumentViews {
displayNames: DisplayName[]
}
/** 解析一份源码并转成视图数据(中文名记号交给翻译插件去掉);语法树用完即释放。 */
/**
* 解析一份源码并转成视图数据(中文名记号交给翻译插件去掉);语法树用完即释放。
* calleeKind 交出调用的名字的定义眼下查到是哪种块(SourceDocument.calleeKind)。
*/
export async function readBlockViews(
runtime: Pick<TreeSitterRuntime, 'parse'>,
adapter: LanguageAdapter,
@@ -223,14 +229,46 @@ export async function readBlockViews(
languageId: string,
source: string,
rules: RuleRun = NO_RULES,
calleeKind?: SourceDocument['calleeKind'],
): Promise<DocumentViews> {
const tree = await runtime.parse(adapter, languageId, source)
try {
const { names, markers } = adapter.displayNames?.find(tree.rootNode, source, languageId) ?? { names: [], markers: [] }
const blocks = toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), { source, language: adapter, markers }, translator, rules)
const document: SourceDocument = { source, language: adapter, markers, ...(calleeKind && { calleeKind }) }
const blocks = toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), document, translator, rules)
return { blocks, displayNames: names }
}
finally {
tree.delete()
}
}
/** 块树里自己的名字(见 ownName)盖住 offset 的最里层那块的种类;offset 不在哪块的名字上时没有。 */
export function blockKindAt(blocks: readonly CodeBlock[], offset: number): BlockKind | undefined {
for (const block of blocks) {
const inner = blockKindAt(block.children, offset)
if (inner)
return inner
const name = ownName(block)
if (name && name.startIndex <= offset && offset < name.endIndex)
return block.kind
}
return undefined
}
/** 解析一份源码,offset 处的名字定义的是哪种块(见 blockKindAt);语法树用完即释放。 */
export async function readDefinitionKind(
runtime: Pick<TreeSitterRuntime, 'parse'>,
adapter: LanguageAdapter,
languageId: string,
source: string,
offset: number,
): Promise<BlockKind | undefined> {
const tree = await runtime.parse(adapter, languageId, source)
try {
return blockKindAt(adapter.toBlocks(tree.rootNode, source, languageId), offset)
}
finally {
tree.delete()
}
}
+84
View File
@@ -0,0 +1,84 @@
import type { BlockKind, CallFacts, Node } from './contract'
import { describe, expect, it, vi } from 'vitest'
import { DocumentCallees } from './calleeKinds'
/** 一次调用:只用到被调用的表达式原文,和最后那个名字的下标。 */
function call(callee: string, nameAt?: number): CallFacts {
const facts = { callee: { text: callee } as Node, argumentList: {} as Node, arguments: [] }
return nameAt === undefined ? facts : { ...facts, name: { startIndex: nameAt } as Node }
}
/** 各下标处名字的定义是哪种块(语言扩展答的;没有的是没答);记下问了哪些下标。 */
function source(kinds: Record<number, BlockKind | null>): { asked: number[], kindOf: (offset: number) => Promise<BlockKind | null | undefined> } {
const asked: number[] = []
return {
asked,
kindOf: async (offset) => {
asked.push(offset)
return kinds[offset]
},
}
}
describe('调用的名字定义成哪种块(DocumentCallees)', () => {
it('第一遍当没查到,按被调用的表达式原文记下问了什么(写法一样的按一处问);查到后叫宿主重排,再排时交出来', async () => {
let changed = 0
const callees = new DocumentCallees(() => changed++, [])
const first = callees.session()
expect(first.calleeKind(call('UserDetailResponse', 40))).toBeUndefined()
expect(first.calleeKind(call('UserDetailResponse', 90))).toBeUndefined()
expect(first.calleeKind(call('user_service.update_user', 120))).toBeUndefined()
expect(first.requests()).toEqual([{ callee: 'UserDetailResponse', offset: 40 }, { callee: 'user_service.update_user', offset: 120 }])
const from = source({ 40: 'class', 120: 'function' })
callees.resolve(1, first.requests(), from.kindOf)
await vi.waitFor(() => expect(changed).toBe(1))
const second = callees.session()
expect(second.calleeKind(call('UserDetailResponse', 12))).toBe('class')
expect(second.calleeKind(call('user_service.update_user', 7))).toBe('function')
// 同一版本再发一遍:问过的不再查
callees.resolve(1, first.requests(), from.kindOf)
await Promise.resolve()
expect(from.asked).toEqual([40, 120])
})
it('被调用的不是名字(没有 name)不问;查了没有(null)不算变了', async () => {
let changed = 0
const callees = new DocumentCallees(() => changed++, [])
const session = callees.session()
expect(session.calleeKind(call('handlers[k]'))).toBeUndefined()
session.calleeKind(call('build', 5))
expect(session.requests()).toEqual([{ callee: 'build', offset: 5 }])
const from = source({ 5: null })
callees.resolve(1, session.requests(), from.kindOf)
await vi.waitFor(() => expect(from.asked).toEqual([5]))
await Promise.resolve()
expect(changed).toBe(0)
})
it('语言扩展还没起来(没答):隔一会儿只再查没答的,查到了再叫重排', async () => {
let changed = 0
const pending: (() => void)[] = []
const scheduled: number[] = []
const callees = new DocumentCallees(() => changed++, [5, 10], (task, ms) => {
scheduled.push(ms)
pending.push(task)
return setTimeout(() => {}, 0)
})
const session = callees.session()
session.calleeKind(call('Detail', 40))
session.calleeKind(call('run', 60))
const kinds: Record<number, BlockKind | null> = { 60: 'function' }
const from = source(kinds)
callees.resolve(1, session.requests(), from.kindOf)
await vi.waitFor(() => expect(scheduled).toEqual([5]))
expect(changed).toBe(1)
kinds[40] = 'class'
pending.shift()?.()
await vi.waitFor(() => expect(changed).toBe(2))
expect(from.asked).toEqual([40, 60, 40])
expect(callees.session().calleeKind(call('Detail', 40))).toBe('class')
})
})
+46
View File
@@ -0,0 +1,46 @@
import type { BlockKind, CallFacts } from './contract'
import { DeferredAnswers } from './deferredAnswers'
/**
* 调用的名字的定义是哪种块(SourceDocument.calleeKind):翻译插件靠它分新建对象还是调用函数。
* 按被调用的表达式原文记(`user_service.update_user`),同一份文档里写法一样的调用按一处查;问语言扩展时问它最后那个名字。这里不碰 VS Code。
*/
/** 问的一次调用:被调用的表达式原文,和它最后那个名字在源码里的下标。 */
export interface CalleeRequest { callee: string, offset: number }
/** 这份文档里 offset 处名字的定义是哪种块:查了没有是 null,语言扩展没答(还没起来)是 undefined。 */
export type CalleeSource = (offset: number) => Promise<BlockKind | null | undefined>
/** 排一遍视图数据用的:交给翻译插件的 calleeKind,和这一遍问了哪些调用。 */
export interface CalleeSession {
calleeKind: (call: CallFacts) => BlockKind | undefined
requests: () => CalleeRequest[]
}
/** 一份打开着的文档里调用的名字查到定义成哪种块;什么时候查、查到了怎么叫宿主重排见 DeferredAnswers。 */
export class DocumentCallees {
private readonly answers: DeferredAnswers<CalleeRequest, BlockKind>
constructor(changed: () => void, delays?: readonly number[], schedule?: (run: () => void, ms: number) => ReturnType<typeof setTimeout>) {
this.answers = new DeferredAnswers(({ callee }) => callee, (a, b) => (a ?? null) === (b ?? null), changed, delays, schedule)
}
/** 被调用的不是名字(没有 CallFacts.name)的不问,按调用算。 */
session(): CalleeSession {
const session = this.answers.session()
return {
calleeKind: call => call.name ? session.ask({ callee: call.callee.text, offset: call.name.startIndex }) : undefined,
requests: session.questions,
}
}
/** 查 version 这一版问的调用:这个版本查过的不再查。 */
resolve(version: number, requests: readonly CalleeRequest[], source: CalleeSource): void {
this.answers.resolve(version, requests, ({ offset }) => source(offset))
}
dispose(): void {
this.answers.dispose()
}
}
+47 -2
View File
@@ -179,6 +179,32 @@ export interface IterationFacts {
sharedParentheses?: { open: Node, close: Node }
}
/** 调用里的一个实参,照源码顺序;注释不算。 */
export type CallArgument
= | { kind: 'positional', value: Node } // 按位置传的
| { kind: 'keyword', name: Node, value: Node } // 按名字传的:Python 的 name=value
| { kind: 'spread', value: Node } // 展开成按位置传的几个:*args、...args;value 是展开的那个表达式
| { kind: 'keywordSpread', value: Node } // 展开成按名字传的几个:Python 的 **kwargs
/**
* 一次调用(含新建对象):语言插件从语法树读出结构(LanguageAdapter.call),翻译插件把跨几行写的调用排成
* 「新建 / 调用 被调用的名字 · N 个参数」一行,实参摊成一张表。节点都指向源码。
*/
export interface CallFacts {
/** 被调用的表达式:`user_service.update_user`、`UserDetailResponse`;TypeScript 的 new 不算在里面。 */
callee: Node
/** callee 里最后那个名字(点分名字的最后一节):着色、查定义用它;callee 不是名字(`f()()`、`handlers[k]()`)时没有。 */
name?: Node
/**
* 写法本身说明了是不是新建对象时给:TypeScript 的 `new X()` 是,不带 new 的调用不是。
* Python 新建对象和调用函数写法一样,不给,翻译插件问 name 的定义是哪种块(SourceDocument.calleeKind)。
*/
construct?: boolean
/** 括号连同里面的实参:它跨了几行,这次调用就是跨几行写的。 */
argumentList: Node
arguments: CallArgument[]
}
export interface DeclaratorFacts {
/** 变量名;解构声明是整个模式。 */
name: Node
@@ -331,6 +357,18 @@ export interface PropertyRow extends Defines {
columns?: string[]
}
/** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行。 */
export interface PreviewTable {
columns: string[]
items: PropertyItem[]
}
/** 一行译文的块翻出来的:一行译文(注释块是 Markdown 说明);跨几行写的调用还有摊开各个实参的表,预览排在这一行下面。 */
export interface TextTranslation {
text: PreviewText
table?: PreviewTable
}
/**
* 语言插件:语法树 → 代码块树,外加只有它懂、翻译插件翻表达式时要用的信息(叶子的词性、写在表达式里的遍历的结构)。
* 翻译插件翻一块时从 SourceDocument.language 拿到解析这份源码的语言插件,不直接依赖某个语言插件。
@@ -347,6 +385,8 @@ export interface LanguageAdapter {
tokenRole: (leaf: Node) => TokenRole
/** node 是写在表达式里的遍历(推导式、生成器表达式)时给出它的结构,别的节点没有;没有这种写法的语言不填。 */
iteration?: (node: Node) => IterationFacts | undefined
/** node 是一次调用(含新建对象)时给出它的结构,别的节点没有;没给的语言不展开调用,照常一行译文。 */
call?: (node: Node) => CallFacts | undefined
/** 认代码里给定义起的中文名(见「中文名」);不填就没有中文名。 */
displayNames?: DisplayNameReader
}
@@ -384,6 +424,11 @@ export interface SourceDocument {
language: LanguageAdapter
/** 写在注释里给代码阅读看的记号(中文名记号,见 DisplayNameFacts.markers):不是说明文字,翻注释、说明时去掉 */
markers: SourceRange[]
/**
* 调用的名字(CallFacts.name)的定义是哪种块:宿主问语言扩展定义在哪,用那个文件的语言插件分块认出来。
* 排视图数据是同步的,这里只交出已经查到的;还没查到、查不到、宿主不查(没给)都是 undefined,查到了宿主重排、重发。
*/
calleeKind?: (call: CallFacts) => BlockKind | undefined
}
/** 筛选属性和值的显示名,筛选栏、块的种类(图标的悬停提示)用它;没给名字的显示 id。 */
@@ -401,8 +446,8 @@ export interface Translator {
languageIds: string[]
/** 译文用的语言(BCP 47 的语言部分,例如 'zh'):库规则按它挑自己的词表。 */
displayLanguage: string
/** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */
translate: (block: TextBlock, document: SourceDocument) => PreviewText
/** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文,跨几行写的调用连着摊开实参的表。 */
translate: (block: TextBlock, document: SourceDocument) => TextTranslation
/** 函数、类、枚举、导入、常量、变量块 → 属性表。 */
describe: (block: FactBlock, document: SourceDocument) => PropertyRow[]
/** 一个表达式或类型节点整个翻成一行译文,译法同属性表里的值:库规则翻默认值、类型时借它。 */
+97
View File
@@ -0,0 +1,97 @@
import { RETRY_DELAYS } from './nameResolution'
/** 排一遍视图数据用的:同步地问(交出已经查到的),和这一遍问了哪些问题。 */
export interface AnswerSession<Q, A> {
ask: (question: Q) => A | undefined
questions: () => Q[]
}
/**
* 查一个问题:查到了是答案,查了没有是 null;语言扩展没答(还没起来)是 undefined,已知的留着不动,隔一会儿再查。
*/
export type Answer<Q, A> = (question: Q) => Promise<A | null | undefined>
/**
* 一份打开着的文档里,排视图数据时要问、却要异步才查得到的问题(库规则问的名字的定义、调用的名字定义成哪种块)的答案,
* 按问题的 id 记,id 一样的问题按一个查。这里不碰 VS Code。
* 排视图数据是同步的:先交出已经查到的(文档改了也照旧用上一版查到的),记下这一遍问了什么;发出预览后再查,
* 每个版本同一个问题查一次(语言扩展没答的照 RETRY_DELAYS 隔一会儿再查),查到的和已知的不一样就 changed,宿主按新结果重排、重发。
*/
export class DeferredAnswers<Q, A> {
/** 查到的:查了没有是 null */
private readonly known = new Map<string, A | null>()
private version: number | undefined
/** 眼下这个版本查过(或正在查)的问题 */
private readonly checked = new Set<string>()
private timer: ReturnType<typeof setTimeout> | undefined
constructor(
private readonly idOf: (question: Q) => string,
/** 两个答案对问的人来说一样不一样 */
private readonly same: (a: A | null | undefined, b: A | null | undefined) => boolean,
private readonly changed: () => void,
private readonly delays: readonly number[] = RETRY_DELAYS,
private readonly schedule: (run: () => void, ms: number) => ReturnType<typeof setTimeout> = setTimeout,
) {}
session(): AnswerSession<Q, A> {
const asked = new Map<string, Q>()
return {
ask: (question) => {
const id = this.idOf(question)
if (!asked.has(id))
asked.set(id, question)
return this.known.get(id) ?? undefined
},
questions: () => [...asked.values()],
}
}
/** 查 version 这一版问的问题:这个版本查过的不再查。 */
resolve(version: number, questions: readonly Q[], answer: Answer<Q, A>): void {
if (this.version !== version) {
clearTimeout(this.timer)
this.version = version
this.checked.clear()
}
const fresh = questions.filter(question => !this.checked.has(this.idOf(question)))
for (const question of fresh)
this.checked.add(this.idOf(question))
if (fresh.length === 0)
return
const attempt = (pending: readonly Q[], tries: number): void => {
void this.check(pending, answer).then(({ unanswered, changed }) => {
if (this.version !== version)
return
if (changed)
this.changed()
if (unanswered.length > 0 && tries < this.delays.length)
this.timer = this.schedule(() => attempt(unanswered, tries + 1), this.delays[tries])
})
}
attempt(fresh, 0)
}
dispose(): void {
clearTimeout(this.timer)
this.version = undefined
}
/** 查一遍:没答的问题留着已知的不动,交回去等下一次再查。 */
private async check(questions: readonly Q[], answer: Answer<Q, A>): Promise<{ unanswered: Q[], changed: boolean }> {
const unanswered: Q[] = []
let changed = false
await Promise.all(questions.map(async (question) => {
const found = await answer(question)
if (found === undefined) {
unanswered.push(question)
return
}
const id = this.idOf(question)
if (!this.same(this.known.get(id), found))
changed = true
this.known.set(id, found)
}))
return { unanswered, changed }
}
}
+20 -1
View File
@@ -1,8 +1,10 @@
import type { Location, LocationLink, Position } from 'vscode'
import type { RuleLocation } from '../contract'
import type { BlockKind, LanguageAdapter, RuleLocation } from '../contract'
import type { TreeSitterRuntime } from '../runtime'
import type { DefinitionTarget } from './definitionAction'
import { commands, Range, Uri, window, workspace } from 'vscode'
import { logger } from '../../logging/logger'
import { readDefinitionKind } from '../blockView'
/** 查到的一处定义:文件和定义处那个名字的范围。 */
export interface FoundLocation { uri: Uri, range: Range }
@@ -36,6 +38,23 @@ export function ruleDefinitions(locations: readonly RuleLocation[]): FoundLocati
return locations.map(({ uri, start, end }) => ({ uri: Uri.parse(uri), range: new Range(start.line, start.character, end.line, end.character) }))
}
/** 定义处那个名字定义的是哪种块:打开目标文件,用它那种语言的语言插件分块认;目标打不开、语言不支持、不在哪块的名字上时没有。 */
export async function definitionKind(
location: FoundLocation,
languageFor: (languageId: string) => LanguageAdapter | undefined,
runtime: Pick<TreeSitterRuntime, 'parse'>,
): Promise<BlockKind | undefined> {
try {
const document = await workspace.openTextDocument(location.uri)
const adapter = languageFor(document.languageId)
return adapter && await readDefinitionKind(runtime, adapter, document.languageId, document.getText(), document.offsetAt(location.range.start))
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `认不出 ${location.uri.toString()} 里定义的是哪种块:${err instanceof Error ? err.message : String(err)}` })
return undefined
}
}
/** 查到的各处定义打开目标文件补上行文字、下标;目标打不开成文本的(例如 Python 命名空间包指向的目录)略过。 */
export async function findDefinitions(locations: readonly FoundLocation[], supports: (languageId: string) => boolean): Promise<Definition[]> {
const found = await Promise.all(locations.map(async (location): Promise<Definition[]> => {
@@ -118,6 +118,21 @@ describe('预览的 document 消息', () => {
expect(reports).toEqual(['库规则 fastapi 给筛选属性起名时出错,筛选栏照写 id:词表坏了'])
})
it('调用的名字定义成哪种块交给 calleeKind 问:跨几行新建对象的一行译文下面带实参表', async () => {
const source = 'def f(user):\n return Detail(\n id=user.id,\n name=user.name,\n )\n'
const asked: string[] = []
const { message } = await documentMessage(doc('python', source), registryWithPython(), runtime, ignore, undefined, (call) => {
asked.push(call.callee.text)
return 'class'
})
expect(asked).toEqual(['Detail'])
expect(message.blocks[0].children[0]).toMatchObject({
kind: 'return',
translation: [{ text: '返回' }, { text: ' ' }, { text: '新建' }, { text: ' ' }, { text: 'Detail', role: 'type' }, { text: ' ' }, { text: '· 2 个参数', role: 'hint' }],
table: { columns: ['参数', '值'], items: [{ cells: [[{ text: 'id' }], expect.anything()] }, { cells: [[{ text: 'name' }], expect.anything()] }] },
})
})
it('库规则抛错:不算解析失败,交给 report 记下,这块照常显示', async () => {
const broken: LibraryRule = {
id: 'broken',
@@ -1,4 +1,4 @@
import type { DisplayName, RuleContext } from '../contract'
import type { DisplayName, RuleContext, SourceDocument } from '../contract'
import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime'
import type { DocumentMessage } from './messages'
@@ -21,7 +21,8 @@ export interface DocumentRead {
/**
* 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下;
* 规则查名字的定义时问 lookup(RuleContext.lookup)。筛选属性和值的名字先取翻译插件的,没给的再取库规则的(LibraryRule.attributeNames)。
* 规则查名字的定义时问 lookup(RuleContext.lookup),翻译插件问调用的名字定义成哪种块时问 calleeKind(SourceDocument.calleeKind)。
* 筛选属性和值的名字先取翻译插件的,没给的再取库规则的(LibraryRule.attributeNames)。
*/
export async function documentMessage(
document: DocumentSnapshot,
@@ -29,6 +30,7 @@ export async function documentMessage(
runtime: Pick<TreeSitterRuntime, 'parse'>,
report: (message: string) => void,
lookup?: RuleContext['lookup'],
calleeKind?: SourceDocument['calleeKind'],
): Promise<DocumentRead> {
const { languageId, version } = document
const source = document.getText()
@@ -48,7 +50,7 @@ export async function documentMessage(
return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [] }
try {
const rules = registry.rulesFor(languageId)
const { blocks, displayNames } = await readBlockViews(runtime, adapter, translator, languageId, source, { rules, report, lookup })
const { blocks, displayNames } = await readBlockViews(runtime, adapter, translator, languageId, source, { rules, report, lookup }, calleeKind)
const names = mergeAttributeNames([translator?.attributeNames, ...ruleAttributeNames(rules, translator?.displayLanguage, report)])
return { message: { ...message, blocks, attributes: fileAttributes(blocks, names) }, displayNames }
}
+24 -3
View File
@@ -1,7 +1,7 @@
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { BlockView } from '../blockView'
import type { RuleLocation, RuleReference, WorkspaceIndex } from '../contract'
import type { BlockKind, RuleLocation, RuleReference, WorkspaceIndex } from '../contract'
import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution'
import type { CodeQa } from '../qa/codeQa'
import type { CodeReaderRegistry } from '../registry'
@@ -14,13 +14,14 @@ import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages'
import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
import { webviewHtml, webviewUiRoot } from '../../webview/html'
import { DocumentCallees } from '../calleeKinds'
import { blockReplacement } from '../editText'
import { locate } from '../folderStore'
import { DocumentNames, resolveNames } from '../nameResolution'
import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups'
import { definitionAction } from './definitionAction'
import { findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { DefinitionSources } from './definitionSources'
import { documentMessage } from './documentMessage'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
@@ -131,6 +132,8 @@ class Preview {
private readonly names: DocumentNames
/** 库规则问的名字的定义查到了什么:查到的变了就重发文档 */
private readonly lookups: DocumentLookups
/** 调用的名字定义成哪种块(分新建还是调用):查到的变了就重发文档 */
private readonly callees: DocumentCallees
constructor(
private readonly document: TextDocument,
@@ -154,6 +157,7 @@ class Preview {
this.post({ type: 'displayNames', version, names })
})
this.lookups = new DocumentLookups(() => this.schedulePush())
this.callees = new DocumentCallees(() => this.schedulePush())
this.disposables = [
panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)),
panel.onDidChangeViewState(() => {
@@ -223,9 +227,10 @@ class Preview {
const { languageId, version } = this.document
const source = this.document.getText()
const session = this.lookups.session()
const callees = this.callees.session()
const { message, displayNames } = await documentMessage({ languageId, version, getText: () => source }, this.registry, this.runtime, (text) => {
logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` })
}, session.lookup)
}, session.lookup, callees.calleeKind)
if (request !== this.latest || !this.ready)
return
this.definitions.remember(message.version, message.blocks)
@@ -237,9 +242,24 @@ class Preview {
if (version === this.document.version) {
this.names.resolve(version, report => resolveNames(displayNames, message.blocks, this.nameSources(), report))
this.lookups.resolve(version, session.requests(), this.lookupSources())
this.callees.resolve(version, callees.requests(), offset => this.calleeKind(offset))
}
}
/** 这份文档里 offset 处名字的定义是哪种块:语言扩展查到的几处定义里第一处认得出的;语言扩展没答是 undefined。 */
private async calleeKind(offset: number): Promise<BlockKind | null | undefined> {
const { document } = this
const found = await languageDefinitions(document.uri, document.positionAt(offset))
if (found.length === 0)
return undefined
for (const location of found) {
const kind = await definitionKind(location, languageId => this.registry.languageFor(languageId), this.runtime)
if (kind)
return kind
}
return null
}
/** 这份文档里 offset 处名字的定义(语言扩展查的)落在哪儿。 */
private async definitionStarts(offset: number): Promise<DefinitionStart[]> {
const { document } = this
@@ -284,6 +304,7 @@ class Preview {
clearTimeout(this.timer)
this.names.dispose()
this.lookups.dispose()
this.callees.dispose()
for (const disposable of this.disposables)
disposable.dispose()
}
@@ -0,0 +1,37 @@
import { window } from 'vscode'
import { DefinitionSources } from './definitionSources'
export async function removeWorktree(panel: Panel, relPath: string, issueNumber: number): Promise<void> {
const state = await panel.readIssueState(issueNumber)
const branch = typeof state.branch === 'string' ? state.branch : ''
const choice = await window.showWarningMessage(
branch ? `确认删除 worktree ${relPath} 及其分支 ${branch}?` : `确认删除 worktree ${relPath}?`,
{ modal: true },
'删除',
)
if (choice !== '删除')
return
}
class Preview {
constructor(document: TextDocument, owner: Owner) {
this.definitions = new DefinitionSources({
rule: async reference => ruleDefinitions(await owner.resolveReference(reference)),
language: offset => languageDefinitions(document.uri, document.positionAt(offset)),
})
}
}
export function commentsUrl(opts: Options): URL {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`,
)
return url
}
export function merged(base: string[], extra: string[]): string[] {
return concatAll(
base,
...extra,
)
}
+26 -1
View File
@@ -1,7 +1,7 @@
import type { IterationFacts, Node } from '../contract'
import type { Outline } from '../testSupport'
import { describe, expect, it } from 'vitest'
import { factsByLine, nodesToText, outline, readFixture, runtime, walk, WASM_DIR, withBlocks } from '../testSupport'
import { factsByLine, nodeFacts, nodesToText, outline, readFixture, runtime, walk, WASM_DIR, withBlocks } from '../testSupport'
import { createPythonAdapter } from './python'
const adapter = createPythonAdapter(WASM_DIR)
@@ -488,3 +488,28 @@ describe('python 写在表达式里的遍历(推导式)', () => {
expect(await iterations('x = [1, 2]\ny = {"a": 1}\nz = (a, b)\nfor x in y:\n pass')).toEqual([])
})
})
describe('python 调用', () => {
const calls = (source: string): Promise<unknown[]> => nodeFacts(adapter, 'python', source, node => adapter.call?.(node))
it('实参照源码顺序:按位置、按名字、*x、**x;新建对象和调用写法一样,不给 construct;点分名字取最后一节', async () => {
const source = 'user_service.update_user(\n db, # 会话\n user_id=user_id,\n *rest,\n **options,\n)'
expect(await calls(source)).toEqual([{
callee: 'user_service.update_user',
name: 'update_user',
argumentList: '(\n db, # 会话\n user_id=user_id,\n *rest,\n **options,\n)',
arguments: [
{ kind: 'positional', value: 'db' },
{ kind: 'keyword', name: 'user_id', value: 'user_id' },
{ kind: 'spread', value: 'rest' },
{ kind: 'keywordSpread', value: 'options' },
],
}])
})
it('唯一的实参是生成器表达式时就是它;被调用的不是名字时没有 name;装饰器、父类列表不是调用', async () => {
expect(await calls('sum(x for x in xs)')).toEqual([{ callee: 'sum', name: 'sum', argumentList: '(x for x in xs)', arguments: [{ kind: 'positional', value: '(x for x in xs)' }] }])
expect(await calls('handlers[k]()')).toEqual([{ callee: 'handlers[k]', argumentList: '()', arguments: [] }])
expect(await calls('@cache\nclass A(Base):\n pass')).toEqual([])
})
})
+38 -1
View File
@@ -1,4 +1,4 @@
import type { Access, BlockShape, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { Access, BlockShape, CallArgument, CallFacts, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { CommentRules, ExceptionRules } from './shared'
import * as path from 'node:path'
import { pythonNameReader } from './pythonNames'
@@ -363,6 +363,42 @@ function pythonIteration(node: Node): IterationFacts | undefined {
}
/** elif / else 展平成紧跟 if 的兄弟块。 */
/** 调用的实参:name=value 按名字传,*x、**x 展开,别的按位置传;唯一的实参是生成器表达式(f(x for x in y))时就是它。 */
function callArgument(arg: Node): CallArgument | undefined {
switch (arg.type) {
case 'keyword_argument': {
const name = arg.childForFieldName('name')
const value = arg.childForFieldName('value')
return name && value ? { kind: 'keyword', name, value } : undefined
}
case 'list_splat':
case 'dictionary_splat': {
const [value] = syntaxChildren(arg)
if (!value)
return undefined
return { kind: arg.type === 'list_splat' ? 'spread' : 'keywordSpread', value }
}
default:
return { kind: 'positional', value: arg }
}
}
/** 调用的结构:新建对象和调用函数写法一样,不给 construct。被调用的是名字或点分名字时 name 是最后那个名字。 */
function pythonCall(node: Node): CallFacts | undefined {
const callee = node.type === 'call' ? node.childForFieldName('function') : null
const list = node.childForFieldName('arguments')
if (!callee || !list)
return undefined
const name = callee.type === 'identifier' ? callee : callee.type === 'attribute' ? callee.childForFieldName('attribute') : null
const args = list.type === 'generator_expression' ? [list] : syntaxChildren(list)
return {
callee,
...(name && { name }),
argumentList: list,
arguments: args.map(callArgument).filter(arg => arg !== undefined),
}
}
function ifBlocks(node: Node): CodeBlock[] {
const blocks = [bodyBlock('if', node, bodyOf(node), 'other')]
for (const alternative of node.childrenForFieldName('alternative')) {
@@ -603,6 +639,7 @@ export function createPythonAdapter(wasmDir: string): LanguageAdapter {
toBlocks: root => withExceptions(statements(named(root), 'module', -1), PYTHON_EXCEPTIONS),
tokenRole: pythonTokenRole,
iteration: pythonIteration,
call: pythonCall,
displayNames: pythonNameReader,
}
}
@@ -1,6 +1,6 @@
import type { Outline } from '../testSupport'
import { describe, expect, it } from 'vitest'
import { factsByLine, outline, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { factsByLine, nodeFacts, outline, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { createTypeScriptAdapter } from './typescript'
const adapter = createTypeScriptAdapter(WASM_DIR)
@@ -612,3 +612,23 @@ describe('typeScript 枚举', () => {
expect(byLine[1]).toMatchObject({ members: [{ name: 'A', docs: [] }, { name: 'B', docs: ['/** 第二 */'] }, { name: 'C', docs: ['// 第三'] }] })
})
})
describe('typeScript 调用', () => {
const calls = (source: string): Promise<unknown[]> => nodeFacts(adapter, 'typescript', source, node => adapter.call?.(node))
it('new X(…) 是新建,被调用的不含 new;别的调用不是新建;...x 是展开;点分名字取最后一节', async () => {
expect(await calls('new vscode.Uri(a, ...rest)')).toEqual([{
callee: 'vscode.Uri',
name: 'Uri',
construct: true,
argumentList: '(a, ...rest)',
arguments: [{ kind: 'positional', value: 'a' }, { kind: 'spread', value: 'rest' }],
}])
expect(await calls('run<T>({ a: 1 })')).toEqual([{ callee: 'run', name: 'run', construct: false, argumentList: '({ a: 1 })', arguments: [{ kind: 'positional', value: '{ a: 1 }' }] }])
})
it('带标签的模板字符串、不带括号的 new X 不是调用', async () => {
expect(await calls('sql`select 1`')).toEqual([])
expect(await calls('new Date')).toEqual([])
})
})
+22 -1
View File
@@ -1,4 +1,4 @@
import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, EnumMemberFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { Access, BaseFacts, BlockShape, CallArgument, CallFacts, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, EnumMemberFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { CommentRules, ExceptionRules } from './shared'
import * as path from 'node:path'
import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren, withExceptions } from './shared'
@@ -542,6 +542,26 @@ const LITERAL_KEYWORDS = new Set(['true', 'false', 'null', 'undefined', 'this',
const STRING_NODES = new Set(['string', 'template_string', 'regex'])
const FUNCTION_NAMED = new Set(['function_declaration', 'generator_function_declaration', 'function_expression', 'function', 'generator_function', 'method_definition', 'method_signature', 'abstract_method_signature', 'function_signature'])
/** 调用的结构:`new X(…)` 是新建对象,别的调用不是;带标签的模板字符串(sql`…`)、不带括号的 `new X` 不算。 */
function typescriptCall(node: Node): CallFacts | undefined {
const construct = node.type === 'new_expression'
const callee = construct ? node.childForFieldName('constructor') : node.type === 'call_expression' ? node.childForFieldName('function') : null
const list = node.childForFieldName('arguments')
if (!callee || list?.type !== 'arguments')
return undefined
const name = callee.type === 'identifier' ? callee : callee.type === 'member_expression' ? callee.childForFieldName('property') : null
return {
callee,
...(name && { name }),
construct,
argumentList: list,
arguments: syntaxChildren(list).map((arg): CallArgument => {
const spread = arg.type === 'spread_element' ? syntaxChildren(arg)[0] : undefined
return spread ? { kind: 'spread', value: spread } : { kind: 'positional', value: arg }
}),
}
}
function insideString(leaf: Node): boolean {
for (let node = leaf.parent; node; node = node.parent) {
if (node.type === 'template_substitution')
@@ -620,6 +640,7 @@ export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter {
},
toBlocks: (root, _source, languageId) => withExceptions(blockReader(TYPED_LANGUAGES.has(languageId))(root), TYPESCRIPT_EXCEPTIONS),
tokenRole: typescriptTokenRole,
call: typescriptCall,
displayNames: typescriptNameReader,
}
}
@@ -96,6 +96,13 @@ describe('中文名落到哪些名字上', () => {
expect(rendered.get(at(ROUTES, 'delete_item'))).toBe('delete_item')
})
it('renderedNames:一行译文下面那张表(跨几行写的调用的实参)里带下标的名字也算', async () => {
const source = 'def f(user):\n return Detail(\n id=user.id,\n )\n'
const rendered = renderedNames((await read(source)).blocks)
expect(rendered.get(at(source, 'id='))).toBe('id')
expect(rendered.get(at(source, 'user.id'))).toBe('user')
})
it('本文件定义的:定义处问各处出现,落在画出来的名字上的都换,定义处本身标 definition;长度对不上、没画出来的不换', async () => {
const { blocks, displayNames } = await read(ROUTES)
const definition = at(ROUTES, 'current_user')
+2 -1
View File
@@ -15,7 +15,7 @@ export interface ShownName {
definition?: true
}
/** 视图数据里照抄自源码、带下标的名字(一行译文、属性表的值、各层子行的格子、框里):下标 → 原文。 */
/** 视图数据里照抄自源码、带下标的名字(一行译文和它下面的表、属性表的值、各层子行的格子、框里):下标 → 原文。 */
export function renderedNames(blocks: readonly BlockView[]): Map<number, string> {
const found = new Map<number, string>()
const inline = (pieces: readonly Inline[]): void => {
@@ -46,6 +46,7 @@ export function renderedNames(blocks: readonly BlockView[]): Map<number, string>
}
else {
text(block.translation)
items(block.table?.items)
}
visit(block.children)
}
+1 -1
View File
@@ -17,7 +17,7 @@ function adapter(id: string, languageIds: string[]): LanguageAdapter {
}
function translator(id: string, languageIds: string[]): Translator {
return { id, label: id, languageIds, displayLanguage: 'zh', translate: () => [], describe: () => [], expression: () => [], doc: () => undefined }
return { id, label: id, languageIds, displayLanguage: 'zh', translate: () => ({ text: [] }), describe: () => [], expression: () => [], doc: () => undefined }
}
function rule(id: string, languageIds: string[]): LibraryRule {
+26 -73
View File
@@ -1,7 +1,7 @@
import type { IndexEntry, RuleContext, WorkspaceIndex } from './contract'
import type { DefinitionStart } from './nameResolution'
import type { IndexedEntry } from './workspaceIndex'
import { RETRY_DELAYS } from './nameResolution'
import { DeferredAnswers } from './deferredAnswers'
/**
* 库规则认领时查的名字(RuleContext.lookup):名字的定义在规则的工作区索引里是哪一项。
@@ -33,91 +33,44 @@ function same(a: IndexEntry | null | undefined, b: IndexEntry | null | undefined
}
/**
* 一份打开着的文档里库规则问的名字查到了什么:按「索引 id + 名字原文」记,同一份文档里同名的名字按一处查。
* 排视图数据是同步的:先交出已经查到的(文档改了也照旧用上一版查到的),记下这一遍问了什么;发出预览后再查,
* 每个版本同一个名字查一次(索引里有同名的项、语言扩展却没答时照 RETRY_DELAYS 隔一会儿再查),查到的和已知的不一样就 changed,
* 宿主按新结果重排、重发。
* 一份打开着的文档里库规则问的名字查到了什么:按「索引 id + 名字原文」记,同一份文档里同名的名字按一处查;
* 什么时候查、查到了怎么叫宿主重排见 DeferredAnswers。
*/
export class DocumentLookups {
/** 查到的:没有这个定义是 null */
private readonly known = new Map<string, IndexEntry | null>()
private version: number | undefined
/** 眼下这个版本查过(或正在查)的名字 */
private readonly checked = new Set<string>()
private timer: ReturnType<typeof setTimeout> | undefined
private readonly answers: DeferredAnswers<LookupRequest, IndexEntry>
constructor(
private readonly changed: () => void,
private readonly delays: readonly number[] = RETRY_DELAYS,
private readonly schedule: (run: () => void, ms: number) => ReturnType<typeof setTimeout> = setTimeout,
) {}
constructor(changed: () => void, delays?: readonly number[], schedule?: (run: () => void, ms: number) => ReturnType<typeof setTimeout>) {
this.answers = new DeferredAnswers(({ index, key }) => idOf(index, key), same, changed, delays, schedule)
}
session(): LookupSession {
const requests = new Map<string, LookupRequest>()
const session = this.answers.session()
return {
lookup: (index, name) => {
const id = idOf(index, name.text)
if (!requests.has(id))
requests.set(id, { index, key: name.text, offset: name.startIndex })
return this.known.get(id) ?? undefined
},
requests: () => [...requests.values()],
lookup: (index, name) => session.ask({ index, key: name.text, offset: name.startIndex }),
requests: session.questions,
}
}
/** 查 version 这一版规则问的名字:这个版本查过的不再查。 */
resolve(version: number, requests: readonly LookupRequest[], sources: LookupSources): void {
if (this.version !== version) {
clearTimeout(this.timer)
this.version = version
this.checked.clear()
}
const fresh = requests.filter(request => !this.checked.has(idOf(request.index, request.key)))
for (const request of fresh)
this.checked.add(idOf(request.index, request.key))
if (fresh.length === 0)
return
const attempt = (tries: number): void => {
void this.check(fresh, sources).then(({ settled, changed }) => {
if (this.version !== version)
return
if (changed)
this.changed()
if (!settled && tries < this.delays.length)
this.timer = this.schedule(() => attempt(tries + 1), this.delays[tries])
})
}
attempt(0)
this.answers.resolve(version, requests, request => find(request, sources))
}
dispose(): void {
clearTimeout(this.timer)
this.version = undefined
}
/**
* 查一遍:索引里没有同名的项就是没有这个定义;有就问语言扩展,定义处正是其中一项才算。
* 语言扩展没答(还没起来)的名字留着已知的不动,算没查完(settled 为 false)。
*/
private async check(requests: readonly LookupRequest[], sources: LookupSources): Promise<{ settled: boolean, changed: boolean }> {
let settled = true
let changed = false
await Promise.all(requests.map(async ({ index, key, offset }) => {
const id = idOf(index, key)
const candidates = await sources.find(index, key)
let found: IndexEntry | null = null
if (candidates.length > 0) {
const definitions = await sources.definitions(offset)
if (definitions.length === 0) {
settled = false
return
}
found = candidates.find(({ location }) => definitions.some(at => at.uri === location.uri && at.line === location.start.line && at.character === location.start.character))?.entry ?? null
}
if (!same(this.known.get(id), found))
changed = true
this.known.set(id, found)
}))
return { settled, changed }
this.answers.dispose()
}
}
/**
* 查规则问的一个名字:索引里没有同名的项就是没有这个定义;有就问语言扩展,定义处正是其中一项才算。
* 语言扩展没答(还没起来)是 undefined。
*/
async function find({ index, key, offset }: LookupRequest, sources: LookupSources): Promise<IndexEntry | null | undefined> {
const candidates = await sources.find(index, key)
if (candidates.length === 0)
return null
const definitions = await sources.definitions(offset)
if (definitions.length === 0)
return undefined
return candidates.find(({ location }) => definitions.some(at => at.uri === location.uri && at.line === location.start.line && at.character === location.start.character))?.entry ?? null
}
+22
View File
@@ -82,6 +82,28 @@ export function nodesToText(value: unknown): unknown {
return value
}
/** 源码里 read 认得出的每个节点(先序,外层在前)读出的结构,节点换成源码。 */
export async function nodeFacts(adapter: LanguageAdapter, languageId: string, source: string, read: (node: Node) => unknown): Promise<unknown[]> {
const tree = await runtime.parse(adapter, languageId, source)
try {
const found: unknown[] = []
const visit = (node: Node): void => {
const facts = read(node)
if (facts)
found.push(nodesToText(facts))
for (const child of node.children) {
if (child)
visit(child)
}
}
visit(tree.rootNode)
return found
}
finally {
tree.delete()
}
}
/** 每个属性表块的 facts(节点换成源码),按 header 起始行(1 起)索引。 */
export function factsByLine(blocks: CodeBlock[]): Record<number, unknown> {
const byLine: Record<number, unknown> = {}
@@ -53,8 +53,8 @@ const translator = createZhTemplateTranslator({
function translateFirst(language: LanguageAdapter, source: string): Promise<string> {
return withBlocks(language, 'fake', source, ([block]) => {
const translation = translator.translate(block as TextBlock, { source, language, markers: [] })
return 'markdown' in translation ? translation.markdown : segmentText(translation)
const { text } = translator.translate(block as TextBlock, { source, language, markers: [] })
return 'markdown' in text ? text.markdown : segmentText(text)
})
}
@@ -1,4 +1,4 @@
import type { Inline, LanguageAdapter, PropertyItem, PropertyRow, Segment, TokenRole, Translator } from '../contract'
import type { BlockKind, CallFacts, Inline, LanguageAdapter, PreviewTable, PropertyItem, PropertyRow, Segment, SourceDocument, TokenRole, Translator } from '../contract'
import type { ZhTable } from './zhTemplate'
import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from '../languages/python'
@@ -11,34 +11,37 @@ import { createZhTemplateTranslator } from './zhTemplate'
const ts = createTypeScriptAdapter(WASM_DIR)
const py = createPythonAdapter(WASM_DIR)
/** 一块的译文:一行译文在 segments 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */
interface Translated { kind: string, segments: Inline[], markdown?: string }
/** 一块的译文:一行译文在 segments 里,下面摊开实参的表在 table 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */
interface Translated { kind: string, segments: Inline[], markdown?: string, table?: PreviewTable }
/** 一行译文块、注释块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 */
function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise<Map<number, Translated>> {
/**
* 一行译文块、注释块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。
* calleeKind 同 SourceDocument.calleeKind(调用的名字定义成哪种块),不给就是宿主不查。
*/
function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, calleeKind?: SourceDocument['calleeKind']): 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) || 'facts' in block)
return
const translation = translator.translate(block, { source, language: adapter, markers: [] })
const { text: translation, table } = translator.translate(block, { source, language: adapter, markers: [], ...(calleeKind && { calleeKind }) })
byLine.set(line, 'markdown' in translation
? { kind: block.kind, segments: [], markdown: translation.markdown }
: { kind: block.kind, segments: translation })
: { kind: block.kind, segments: translation, ...(table && { table }) })
})
return byLine
})
}
/** 属性表块(函数、导入、常量、变量)的属性表,按 header 起始行(1 起)索引。 */
function describeAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise<Map<number, PropertyRow[]>> {
/** 属性表块(函数、导入、常量、变量)的属性表,按 header 起始行(1 起)索引;calleeKind 同 translateAll。 */
function describeAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, calleeKind?: SourceDocument['calleeKind']): 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, language: adapter, markers: [] }))
byLine.set(line, translator.describe(block, { source, language: adapter, markers: [], ...(calleeKind && { calleeKind }) }))
})
return byLine
})
@@ -165,12 +168,12 @@ describe('typeScript 中文直译', () => {
it('多行合成一行、去掉右括号前的尾逗号、行中注释不进译文', async () => {
const source = [
'call(',
'list = [',
' first, // 第一个',
' [],',
')',
']',
].join('\n')
expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source))[1]).toBe('call( first, 空数组 )')
expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source))[1]).toBe('list = [ first, 空数组 ]')
})
it('as / in / extends / default 按所在语法节点换译法', async () => {
@@ -417,8 +420,10 @@ describe('python 写在表达式里的遍历(推导式)', () => {
5: '⟦集合|遍历 pairs 的每个 n, _,取 n⟧',
6: '⟦列表|遍历 grid 的每个 row,取 ⟦列表|遍历 row 的每个 y,取 y⟧⟧',
7: 'pick(a, ⟦生成器|遍历 b 的每个 y,取 y⟧)',
8: 'sum(⟦生成器|遍历 counts 的每个 n,取 n⟧)',
8: '调用 sum · 1 个参数',
})
// 跨几行写的调用唯一的实参是生成器:共用的那对括号属于调用,实参表里只有框
expect(propertyLines(map.get(8) ?? []).at(-1)).toBe(' 第 1 个 | ⟦生成器|遍历 counts 的每个 n,取 n⟧')
const async = valueOf(map.get(3), '初始值')[0]
expect('segments' in async && async.segments.filter(piece => 'role' in piece && piece.role === 'keyword')).toEqual([
{ text: '异步遍历', role: 'keyword' },
@@ -1028,6 +1033,173 @@ describe('源码下标(跳到定义用)', () => {
})
})
describe('跨几行写的调用', () => {
const routes = readFixture('routes.py')
const calls = readFixture('calls.ts.txt')
/** 调用的名字定义成哪种块(按被调用的表达式原文查,没有的是还没查到),并记下问了哪些。 */
function kinds(known: Record<string, BlockKind>): { asked: string[], calleeKind: (call: CallFacts) => BlockKind | undefined } {
const asked: string[] = []
return {
asked,
calleeKind: (call) => {
asked.push(call.callee.text)
return known[call.callee.text]
},
}
}
function tableLines(table: PreviewTable | undefined): string[] {
return (table?.items ?? []).map(item => item.cells.map(segmentText).join(' | '))
}
const USER_DETAIL = [
'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',
]
it('返回里新建对象(名字定义成类):一行「返回 新建 UserDetailResponse · 11 个参数」,实参摊成表(参数 | 值),按名字传的写名字', async () => {
const { asked, calleeKind } = kinds({ UserDetailResponse: 'class' })
const line = (await translateAll(py, 'python', pythonZhTranslator, routes, calleeKind)).get(45)
expect(colors(line?.segments)).toEqual([
{ text: '返回', role: 'keyword' },
{ text: ' ', role: 'plain' },
{ text: '新建', role: 'storage' },
{ text: ' ', role: 'plain' },
{ text: 'UserDetailResponse', role: 'type' },
{ text: ' ', role: 'plain' },
{ text: '· 11 个参数', role: 'hint' },
])
expect(line?.table?.columns).toEqual(['参数', '值'])
expect(tableLines(line?.table)).toEqual(USER_DETAIL)
expect(asked).toContain('UserDetailResponse')
// 被调用的名字、按名字传的名字、值里的名字都带源码下标:能跳到定义、显示中文名
const call = routes.indexOf('UserDetailResponse(')
expect(line?.segments.find(piece => 'role' in piece && piece.role === 'type')).toMatchObject({ offset: call })
expect(line?.table?.items[0].cells).toEqual([
[{ text: 'id', role: 'variable', offset: routes.indexOf('id=user.id', call) }],
[
{ text: 'user', role: 'variable', offset: routes.indexOf('user.id', call) },
{ text: '.', role: 'plain' },
{ text: 'id', role: 'variable', offset: routes.indexOf('user.id', call) + 5 },
],
])
})
it('名字的定义还没查到、查不到、是函数都按调用算:被调用的名字按函数着色', async () => {
for (const calleeKind of [undefined, kinds({}).calleeKind, kinds({ UserDetailResponse: 'function' }).calleeKind]) {
const line = (await translateAll(py, 'python', pythonZhTranslator, routes, calleeKind)).get(45)
expect(text(line?.segments)).toBe('返回 调用 UserDetailResponse · 11 个参数')
expect(line?.segments.find(piece => 'role' in piece && piece.text === 'UserDetailResponse')).toMatchObject({ role: 'function' })
}
const exception = (await translateAll(py, 'python', pythonZhTranslator, routes, kinds({ UserDetailResponse: 'exception' }).calleeKind)).get(96)
expect(text(exception?.segments)).toBe('返回 新建 UserDetailResponse · 11 个参数')
})
it('变量的初始值里跨几行调用:初始值一行「等待 调用 user_service.update_user · 10 个参数」,实参表展开在这一行下面,按位置传的写「第 N 个」', async () => {
const rows = (await describeAll(py, 'python', pythonZhTranslator, routes, kinds({}).calleeKind)).get(83) ?? []
expect(propertyLines(rows)).toEqual([
'名称: user',
'初始值: 等待 调用 user_service.update_user · 10 个参数',
' 第 1 个 | 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',
])
const value = rows.find(row => row.label === '初始值')
expect(value?.columns).toEqual(['参数', '值'])
expect(colors(value?.value)).toEqual([
{ text: '等待', role: 'keyword' },
{ text: ' ', role: 'plain' },
{ text: '调用', role: 'storage' },
{ text: ' ', role: 'plain' },
{ text: 'user_service', role: 'variable' },
{ text: '.', role: 'plain' },
{ text: 'update_user', role: 'function' },
{ text: ' ', role: 'plain' },
{ text: '· 10 个参数', role: 'hint' },
])
expect(value?.items?.[0].cells[0]).toEqual([{ text: '第 1 个', role: 'hint' }])
})
it('一行写完的调用照旧一行译文,不问定义', async () => {
const { asked, calleeKind } = kinds({})
const map = await describeAll(py, 'python', pythonZhTranslator, routes, calleeKind)
expect(propertyLines(map.get(43) ?? [])).toEqual(['名称: user', '初始值: 等待 user_service.get_user(db, user_id=user_id)'])
expect(propertyLines(map.get(118) ?? [])).toEqual(['名称: payload', '初始值: 等待 file.read()'])
expect(asked).not.toContain('user_service.get_user')
expect(asked).not.toContain('file.read')
})
it('*x 写「展开」、**x 写「展开关键字」,「第 N 个」只数按位置传的;实参里的调用不再排成表', async () => {
const source = [
'run(',
' first,',
' *rest,',
' second,',
' key=Inner(',
' x=1,',
' ),',
' **options,',
')',
].join('\n')
const line = (await translateAll(py, 'python', pythonZhTranslator, source)).get(1)
expect(text(line?.segments)).toBe('调用 run · 5 个参数')
expect(tableLines(line?.table)).toEqual(['第 1 个 | first', '展开 | rest', '第 2 个 | second', 'key | Inner( x=1 )', '展开关键字 | options'])
})
it('typeScript:new 写法就是新建,不带 new 的是调用,都不问定义;...x 写「展开」', async () => {
const { asked, calleeKind } = kinds({})
const texts = await translateAll(ts, 'typescript', typescriptZhTranslator, calls, calleeKind)
expect(text(texts.get(18)?.segments)).toBe('this.definitions = 新建 DefinitionSources · 1 个参数')
expect(tableLines(texts.get(18)?.table)).toEqual([
'第 1 个 | { rule: 异步 reference => ruleDefinitions(等待 owner.resolveReference(reference)), language: offset => languageDefinitions(document.uri, document.positionAt(offset)) }',
])
expect(text(texts.get(33)?.segments)).toBe('返回 调用 concatAll · 2 个参数')
expect(tableLines(texts.get(33)?.table)).toEqual(['第 1 个 | base', '展开 | extra'])
const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, calls, calleeKind)
// 实参是模板字符串:照抄
expect(propertyLines(rows.get(7) ?? []).slice(1)).toEqual([
'初始值: 等待 调用 window.showWarningMessage · 3 个参数',
expect.stringMatching(/^ {2}第 1 个 \| 如果 branch 则 `确认删除 worktree [^`]+` 否则 `确认删除 worktree [^`]+`$/),
' 第 2 个 | { modal: 真 }',
' 第 3 个 | \'删除\'',
])
expect(propertyLines(rows.get(26) ?? []).slice(1)).toEqual(['初始值: 新建 URL · 1 个参数', expect.stringMatching(/^ {2}第 1 个 \| `[^`]+\/comments`$/)])
expect(asked).toEqual([])
})
it('链式调用跨了几行、实参列表各自写在一行里的不排成表;回调主体不在块头部里的调用也不排', async () => {
const source = [
'const ids = items',
' .filter(a => a.ok)',
' .map(a => a.id)',
'describe(\'x\', () => {',
' run()',
'})',
].join('\n')
const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source)
expect(propertyLines(rows.get(1) ?? []).at(-1)).toBe('值: items .filter(a => a.ok) .map(a => a.id)')
const texts = await translateAll(ts, 'typescript', typescriptZhTranslator, source)
expect(texts.get(4)).toEqual(expect.not.objectContaining({ table: expect.anything() }))
})
})
describe('中文模板引擎', () => {
function testTranslator(table: Partial<ZhTable>): Translator {
return createZhTemplateTranslator({
@@ -1091,14 +1263,14 @@ describe('中文模板引擎', () => {
const source = [
'n += a if k in seen else b',
'print(a if c else b if d else e)',
'print((a # 注释不占序号',
' if k == 1 else b))',
'return (a # 注释不占序号',
' if k == 1 else b)',
].join('\n')
const map = await translateAll(pythonColored('variable'), 'python', translator, source)
expect(texts(map)).toEqual({
1: '令 n 加上 若 k 在 seen 则 a 不然 b',
2: 'print(若 c 则 a 不然 若 d 则 b 不然 e)',
3: 'print((若 k 等于 1 则 a 不然 b))',
3: 'return (若 k 等于 1 则 a 不然 b)',
})
expect(flatSegments(map.get(1)?.segments ?? []).filter(segment => segment.role === 'keyword').map(segment => segment.text))
.toEqual(['令', '加上', '若', '则', '不然'])
+112 -17
View File
@@ -1,4 +1,4 @@
import type { Access, AttributeNames, BaseFacts, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, ParameterFacts, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, ParameterFacts, PreviewTable, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract'
import { commonIndent, splitLines } from '../editText'
/** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */
@@ -68,12 +68,23 @@ interface Token {
group?: SegmentGroup
}
/** 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 */
/** 跨几行写的调用排成一行、实参摊成表(见 emitCall):只排遇到的第一个,它的表记在 table 里。 */
interface CallSlot {
table?: PreviewTable
}
/**
* 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。
* calls 是这一段里跨几行写的调用要不要排成一行、实参摊成表;没有就照常翻。
*/
interface Collector {
document: SourceDocument
/** 要翻的那个节点 */
root: Node
tokens: Token[]
cursor: number
gap: boolean
calls?: CallSlot
}
const CONTEXT_SEPARATOR = ' > '
@@ -165,6 +176,9 @@ const KIND_ZH: Record<BlockKind, string> = {
}
const ACCESS_ZH: Record<Access, string> = { public: '公共', private: '私有', protected: '受保护' }
const ITERATION_ZH: Record<IterationResult, string> = { generator: '生成器', list: '列表', set: '集合', dict: '字典' }
/** 被调用的名字定义成这几种块时,调用就是新建对象。 */
const CONSTRUCTED = new Set<BlockKind | undefined>(['class', 'exception', 'enum'])
const ARGUMENT_COLUMNS = ['参数', '值']
const YES_NO: Record<'true' | 'false', string> = { true: '是', false: '否' }
/** 筛选属性和值的中文叫法;属性表里的访问级别、异步、抽象、仅类型这几行,属性名和值也用这一套。 */
@@ -302,9 +316,13 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return { style: 'iteration', label: ITERATION_ZH[facts.result], segments }
}
/** 遍历整个换成一个框;和外面的调用共用的那对括号照常写在框外,紧贴着框。 */
/**
* 遍历整个换成一个框;和外面的调用共用的那对括号照常写在框外,紧贴着框。
* 只翻这个遍历(它是调用的实参,排在实参表里)时外面的调用不在译文里,这对括号也不写。
*/
function emitIteration(out: Collector, node: Node, facts: IterationFacts, span: SourceSpan): void {
const shared = facts.sharedParentheses
const alone = node.startIndex === out.root.startIndex && node.endIndex === out.root.endIndex
const shared = alone ? undefined : facts.sharedParentheses
const bracket = (paren: Node): Omit<Token, 'gap' | 'raw'> => ({ text: paren.text, role: out.document.language.tokenRole(paren), translated: false })
if (shared)
emitSource(out, shared.open, bracket(shared.open))
@@ -318,6 +336,60 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
out.cursor = node.endIndex
}
/** 要排成一行、实参摊成表的调用:实参列表跨了几行,整个落在要翻的这一段里,这一段还没排过别的调用。 */
function expandedCall(node: Node, span: SourceSpan, calls: CallSlot, { language: { call } }: SourceDocument): CallFacts | undefined {
if (calls.table || !call || node.startIndex < span.startIndex || node.endIndex > span.endIndex)
return undefined
const facts = call(node)
return facts && facts.argumentList.startPosition.row !== facts.argumentList.endPosition.row ? facts : undefined
}
/**
* 实参表:参数 | 值,每个实参一行,照源码顺序。按名字传的写名字,按位置传的写「第 N 个」(只数按位置传的),
* 展开的写「展开」(*args、...args)、「展开关键字」(**kwargs);值照常翻,里面的调用不再排成表。
*/
function argumentTable(facts: CallFacts, span: SourceSpan, document: SourceDocument): PreviewTable {
const value = (node: Node): Inline[] => joinTokens(tokensIn(node, span, document))
let position = 0
const label = (arg: CallArgument): Inline[] => {
switch (arg.kind) {
case 'keyword':
return [segment(arg.name.text, 'variable', offsetOf(arg.name, document.language))]
case 'positional':
position++
return [hint(`第 ${position} 个`)]
case 'spread':
return [hint('展开')]
case 'keywordSpread':
return [hint('展开关键字')]
}
}
return { columns: ARGUMENT_COLUMNS, items: facts.arguments.map(arg => ({ cells: [label(arg), value(arg.value)] })) }
}
/**
* 调用排成一行「新建 / 调用 被调用的名字 · N 个参数」,实参摊成表记在 calls 里。被调用的名字照常翻、带源码下标,
* 最后那个名字新建时按类型、调用时按函数着色,前面几节按变量着色。新建还是调用看写法(CallFacts.construct),
* 写法看不出来的问名字的定义:是类(含异常、枚举)就是新建,还没查到、查不到按调用算。
*/
function emitCall(out: Collector, calls: CallSlot, node: Node, facts: CallFacts, span: SourceSpan): void {
calls.table = argumentTable(facts, span, out.document)
const construct = facts.construct ?? CONSTRUCTED.has(out.document.calleeKind?.(facts))
advance(out, node.startIndex)
emit(out, { text: construct ? '新建' : '调用', role: 'storage', translated: true, raw: '' })
out.gap = true
out.cursor = facts.callee.startIndex
const from = out.tokens.length
collect(facts.callee, span, out)
for (const token of out.tokens.slice(from)) {
if (token.offset !== undefined && NAME_ROLES.has(token.role))
token.role = token.offset !== facts.name?.startIndex ? 'variable' : construct ? 'type' : 'function'
}
out.gap = true
emit(out, { text: `· ${facts.arguments.length} 个参数`, role: 'hint', translated: true, raw: '' })
out.cursor = node.endIndex
}
function collect(node: Node, span: SourceSpan, out: Collector): void {
if (node.endIndex <= span.startIndex || node.startIndex >= span.endIndex || node.startIndex === node.endIndex)
return
@@ -331,6 +403,12 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
emitIteration(out, node, iteration, span)
return
}
const { calls } = out
const call = calls && expandedCall(node, span, calls, out.document)
if (calls && call) {
emitCall(out, calls, node, call, span)
return
}
const parts = templateParts(node, span)
if (parts) {
advance(out, node.startIndex)
@@ -362,9 +440,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
}
}
/** 按译文顺序收集 node 落在 span 里的词。 */
function tokensIn(node: Node, span: SourceSpan, document: SourceDocument): Token[] {
const out: Collector = { document, tokens: [], cursor: span.startIndex, gap: false }
/** 按译文顺序收集 node 落在 span 里的词;给了 calls,跨几行写的调用排成一行、实参摊成表(见 emitCall)。 */
function tokensIn(node: Node, span: SourceSpan, document: SourceDocument, calls?: CallSlot): Token[] {
const out: Collector = { document, root: node, tokens: [], cursor: span.startIndex, gap: false, ...(calls && { calls }) }
collect(node, span, out)
return out.tokens
}
@@ -404,15 +482,16 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return { markdown: docText(nodes.map(node => commentOf(node, document))) }
}
function translate(block: TextBlock, document: SourceDocument): PreviewText {
function translate(block: TextBlock, document: SourceDocument): TextTranslation {
if (block.kind === 'comment')
return translateComment(block, document)
const tokens = tokensIn(block.node, block.header, document)
return { text: translateComment(block, document) }
const calls: CallSlot = {}
const tokens = tokensIn(block.node, block.header, document, calls)
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()
return joinTokens(tokens)
return { text: joinTokens(tokens), ...(calls.table && { table: calls.table }) }
}
function expression(node: Node, document: SourceDocument): Inline[] {
@@ -428,13 +507,16 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
const { header } = block
const { language } = document
// 值里带语句块主体的回调(useMemo(() => { … }))只翻到主体开始之前,和块头部一致
const within = (node: Node, part: SourceSpan): Inline[] => {
const span = part.endIndex > header.endIndex ? { ...part, endIndex: header.endIndex, endRow: header.endRow } : part
return joinTokens(tokensIn(node, span, document))
}
const clipped = (part: SourceSpan): SourceSpan => part.endIndex > header.endIndex ? { ...part, endIndex: header.endIndex, endRow: header.endRow } : part
const within = (node: Node, part: SourceSpan): Inline[] => joinTokens(tokensIn(node, clipped(part), document))
const phrases: Phrases = {
translated: node => within(node, nodeSpan(node)),
within,
expanded: (node) => {
const calls: CallSlot = {}
const text = joinTokens(tokensIn(node, clipped(nodeSpan(node)), document, calls))
return { text, ...(calls.table && { table: calls.table }) }
},
entryCount: (literal, size) => {
const name = table.mapLiterals[literal.type]
return [hint(name ? `${name} ${size} 项` : `${size} 项`)]
@@ -533,6 +615,8 @@ interface Phrases {
translated: (node: Node) => Inline[]
/** 同 translated,只翻 node 落在 span 里的那一段(对象字面量里方法的头部)。 */
within: (node: Node, span: SourceSpan) => Inline[]
/** 同 translated,里面跨几行写的第一个调用排成一行「新建 / 调用 …」,实参摊成表(见 emitCall)。 */
expanded: (node: Node) => { text: Inline[], table?: PreviewTable }
/** 字典 / 对象字面量的项数:「字典 3 项」,叫法按字面量的节点类型查表。 */
entryCount: (literal: Node, size: number) => Segment[]
/** 源码原样照抄、按源码着色(装饰器)。 */
@@ -760,7 +844,7 @@ function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] {
}
/**
* 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值,字典 / 对象字面量见 literalValue);
* 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值,见 declaredValue);
* 说明(有才显示)是整条语句的,排在第一个名称后面。
*/
function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] {
@@ -770,10 +854,21 @@ function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] {
...(i === 0 ? docRows(block.facts.doc, say) : []),
...accessRows(block.facts.access),
...(declarator.type ? [{ label: '类型', value: say.translated(declarator.type) }] : []),
{ label: valueLabel, ...(declarator.value ? literalValue(declarator.value, declarator.entries, say) : { value: [hint('无')] }) },
{ label: valueLabel, ...(declarator.value ? declaredValue(declarator.value, declarator.entries, say) : { value: [hint('无')] }) },
])
}
/**
* 声明的值:字典 / 对象字面量见 literalValue;别的值是一行译文,里面跨几行写的调用排成「新建 / 调用 …」,
* 实参摊成表(参数 | 值)展开在这一行下面。
*/
function declaredValue(value: Node, entries: EntryFacts[] | undefined, say: Phrases): Pick<PropertyRow, 'value' | 'items' | 'columns'> {
if (entries?.length)
return literalValue(value, entries, say)
const { text, table } = say.expanded(value)
return table ? { value: text, ...table } : { value: text }
}
/**
* 值:字典 / 对象字面量(语言插件给了 entries)写项数,各项展开成下一层子行;
* 空字面量和别的值是一行译文(空的译成「空字典」「空对象」)。
+11 -2
View File
@@ -8,9 +8,10 @@ import { AskThread } from './AskThread'
import { BlockTools } from './BlockTools'
import { CodeEditor } from './CodeEditor'
import { KindIcon } from './kindIcons'
import { PropertyGrid } from './PropertyGrid'
import { PropertyGrid, PropertyTable } from './PropertyGrid'
import { asksOf, historyOf } from './qa'
import { TextPreview } from './TextPreview'
import { translationTableId } from './viewState'
export interface BlockTreeState {
version: number
@@ -112,13 +113,21 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
)
}
/** 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文,带说明的译文下面接着说明。 */
/**
* 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文;
* 译文下面依次接着摊开实参的表(跨几行写的调用)、说明。
*/
function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
if ('properties' in block)
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} />
return (
<>
<TextPreview text={block.translation} />
{block.table && (
<div className="mt-[3px]">
<PropertyTable parentId={translationTableId(block.id)} columns={block.table.columns} items={block.table.items} />
</div>
)}
{block.doc && <TextPreview text={block.doc} className="mt-0.5" />}
</>
)
@@ -96,9 +96,9 @@ function PropertyItems({ parentId, items, folding }: { parentId: string, items:
/**
* 给了列名的子行(输入参数、数据表的字段)排成表格:第一行是表头(列名),下面每个子行一行;外框圆角,格子之间、行之间一条分隔线。
* 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。
* 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。一行译文下面摊开实参的表也用它。
*/
function PropertyTable({ parentId, columns, items }: { parentId: string, columns: string[], items: PropertyItem[] }): ReactElement {
export function PropertyTable({ parentId, columns, items }: { parentId: string, columns: string[], items: PropertyItem[] }): ReactElement {
return (
<div className="pl-[5px]">
<div className="cr-table" role="table" style={{ gridTemplateColumns: tableTemplate(columns.length) }}>
@@ -95,6 +95,16 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
})
it('名字在一行译文下面那张表里(跨几行写的调用的实参):表总是展开的,目标是那块,不用展开什么', () => {
const call: BlockView = {
...textBlock('0.0', 'return', 1, [word('返回'), word('新建'), name('UserDetailResponse', 10)]),
table: { columns: ['参数', '值'], items: [{ cells: [[name('id', 40)], [name('user', 43), { text: '.', role: 'plain' }, name('id', 48)]] }] },
}
const blocks = [textBlock('0', 'if', 0, [word('如果'), name('user', 3)], [call])]
expect(revealPlan(blocks, 9, 48)).toEqual({ id: '0.0', expand: ['0'] })
expect(occurrencePlan(blocks, { start: 40, end: 42, kind: 'read' }, () => false)).toEqual({ id: '0.0', expand: ['0'] })
})
it('名字在框里(套了几层都算)也是显示着它', () => {
const inner = { style: 'iteration' as const, label: '列表', segments: [word('遍历'), name('row', 30), word('取'), name('y', 40)] }
const blocks: BlockView[] = [
+10 -5
View File
@@ -1,4 +1,4 @@
import type { BlockView, Occurrence, PreviewText, PropertyItem, SourceRange } from './types'
import type { BlockView, Occurrence, PreviewTable, PreviewText, PropertyItem, SourceRange } from './types'
import { blockPath } from './filter'
import { showsOccurrence } from './occurrences'
import { showsOffset } from './segments'
@@ -38,19 +38,24 @@ function shows(text: PreviewText, offset: number): boolean {
return !('markdown' in text) && showsOffset(text, offset)
}
/** 一行译文下面那张表(总是展开的)里有格子满足 test。 */
function tableShows(table: PreviewTable | undefined, test: (cell: PreviewText) => boolean): boolean {
return table?.items.some(item => item.cells.some(test)) ?? false
}
/**
* 块里哪儿是 offset 处的定义,从具体到笼统:
* ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块;
* ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它,
* 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开;
* ③ 块里只是显示着这个名字(一行译文里的循环变量、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。
* ③ 块里只是显示着这个名字(一行译文和它下面的表里的名字、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。
* 不在这块里是 undefined。
*/
function locate(block: BlockView, offset: number): Spot | undefined {
if (covers(block.defines, offset))
return { expand: [] }
if ('translation' in block)
return shows(block.translation, offset) ? { expand: [] } : undefined
return shows(block.translation, offset) || tableShows(block.table, cell => shows(cell, offset)) ? { expand: [] } : undefined
const rows = block.properties.map((row, index) => ({ row, id: propertyRowId(block.id, index) }))
for (const { row, id } of rows) {
if (covers(row.defines, offset))
@@ -120,10 +125,10 @@ export function uncoveredPlan(plan: RevealPlan, covered: ReadonlyMap<string, str
return owner === undefined ? plan : { id: owner, expand: blockPath(owner).slice(0, -1) }
}
/** 块里哪儿显示着满足 test 的文字(译文、属性行的值、各层子行的格子):要展开的属性行和子行;不在这块里是 undefined。 */
/** 块里哪儿显示着满足 test 的文字(译文和它下面的表、属性行的值、各层子行的格子):要展开的属性行和子行;不在这块里是 undefined。 */
function showingSpot(block: BlockView, test: (text: PreviewText) => boolean): string[] | undefined {
if ('translation' in block)
return test(block.translation) ? [] : undefined
return test(block.translation) || tableShows(block.table, test) ? [] : undefined
for (const [index, row] of block.properties.entries()) {
if (test(row.value))
return []
+8 -2
View File
@@ -71,6 +71,12 @@ export interface PropertyRow extends Defines {
columns?: string[]
}
/** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行 */
export interface PreviewTable {
columns: string[]
items: PropertyItem[]
}
/** 一种颜色在暗色、亮色主题下各用哪个(CSS 颜色值);高对比主题跟着暗 / 亮走 */
export interface ThemeColor { dark: string, light: string }
@@ -108,9 +114,9 @@ interface BlockViewBase {
/**
* 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文;
* 一行译文的块带说明时 doc 是说明的 Markdown 原文,排在译文下面
* 一行译文下面依次是:跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文)
*/
export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] })
export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown } | { properties: PropertyRow[] })
export interface TranslatorOption { id: string, label: string }
@@ -90,3 +90,8 @@ export function propertyRowId(blockId: string, rowIndex: number): string {
export function propertyItemId(parentId: string, itemIndex: number): string {
return `${parentId}/${itemIndex}`
}
/** 一行译文下面那张表(跨几行写的调用的实参)的 id:表里各行的 id 是 propertyItemId(它, 行号)。这张表总是展开的。 */
export function translationTableId(blockId: string): string {
return `${blockId}/t`
}