⚡ perf(vscode): 代码阅读预览注释里的引用解析过就记着:标坏链接、悬停、点击同一个引用只问一次 Pylance,同时问的等同一个结果,文档版本或块树、导入、用到的名字换了才作废,指到的别的文件改过再问,找不到和语言自带的不留;悬停看别的文件里的定义时读成的文档各预览共用,同一版不再重新解析,留最近 8 份,注册的插件变了全部重读

This commit is contained in:
cruldra committed 2026-10-14 19:57:20 +11:00
1 parent 3983032356
commit cfde0fedf5
9 files changed
+595 -70

No files matched your search

+11 -28
View File
@@ -2,6 +2,7 @@ import type { NamedKind } from './blockView'
import type { BlockKind, LanguageAdapter } from './contract'
import type { TreeSitterRuntime } from './runtime'
import { kindAt, readNamedKinds } from './blockView'
import { RecentPromises } from './recentPromises'
/** 定义所在的那份文件(TextDocument 的子集):uri、版本、语言、内容。 */
export interface KindSource {
@@ -20,17 +21,19 @@ const LIMIT = 64
* 最近用过的留 limit 份。这里不碰 VS Code。
*/
export class DefinitionKinds {
private readonly files = new Map<string, { version: number, kinds: Promise<NamedKind[] | undefined> }>()
private readonly files: RecentPromises<NamedKind[] | undefined>
constructor(
private readonly languageFor: (languageId: string) => LanguageAdapter | undefined,
private readonly runtime: Pick<TreeSitterRuntime, 'parse'>,
private readonly limit = LIMIT,
) {}
limit = LIMIT,
) {
this.files = new RecentPromises(limit)
}
/** uri 这份文件 version 这一版各块的名字和种类(预览发文档时读出的)。 */
remember(uri: string, version: number, kinds: NamedKind[]): void {
this.keep(uri, version, Promise.resolve(kinds))
this.files.set(uri, version, Promise.resolve(kinds))
}
/** file 里 offset 处的名字定义的是哪种块;没有语言插件、不在哪块的名字上时没有。 */
@@ -40,32 +43,12 @@ export class DefinitionKinds {
}
private kindsOf(file: KindSource): Promise<NamedKind[] | undefined> {
const known = this.files.get(file.uri)
if (known?.version === file.version) {
this.keep(file.uri, known.version, known.kinds)
return known.kinds
}
const known = this.files.get(file.uri, file.version)
if (known)
return known
const adapter = this.languageFor(file.languageId)
if (!adapter)
return Promise.resolve(undefined)
const kinds = readNamedKinds(this.runtime, adapter, file.languageId, file.getText())
this.keep(file.uri, file.version, kinds)
// 解析失败的不记,下次重来
kinds.catch(() => {
if (this.files.get(file.uri)?.kinds === kinds)
this.files.delete(file.uri)
})
return kinds
}
/** 记下(或用到了)一份:挪到最近用过的那头,多了丢最久没用的。 */
private keep(uri: string, version: number, kinds: Promise<NamedKind[] | undefined>): void {
this.files.delete(uri)
this.files.set(uri, { version, kinds })
for (const oldest of this.files.keys()) {
if (this.files.size <= this.limit)
break
this.files.delete(oldest)
}
return this.files.set(file.uri, file.version, readNamedKinds(this.runtime, adapter, file.languageId, file.getText()))
}
}
@@ -0,0 +1,100 @@
import type { Memento } from 'vscode'
import type { TreeSitterRuntime } from '../runtime'
import type { DocumentSnapshot } from './documentMessage'
import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from '../languages/python'
import { CodeReaderRegistry } from '../registry'
import { fastapiRule } from '../rules/fastapi'
import { runtime, WASM_DIR } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { DocumentReads } from './documentReads'
const SOURCE = 'class UserService:\n def update_user(self):\n pass\n'
/** 数着解析了几次的解析运行时。 */
function counting(): { runtime: Pick<TreeSitterRuntime, 'parse'>, parses: () => number } {
let parses = 0
return {
runtime: {
parse: (...args) => {
parses++
return runtime.parse(...args)
},
},
parses: () => parses,
}
}
function registryWithPython(): CodeReaderRegistry {
const store: Record<string, unknown> = {}
const registry = new CodeReaderRegistry({
get: <T>(key: string): T | undefined => store[key] as T | undefined,
update: async (key: string, value: unknown) => {
store[key] = value
},
} as Pick<Memento, 'get' | 'update'>)
registry.registerLanguage(createPythonAdapter(WASM_DIR))
registry.registerTranslator(pythonZhTranslator)
return registry
}
function file(version: number, entryNames: string[] = []): DocumentSnapshot {
return { languageId: 'python', version, getText: () => SOURCE, fileName: '/app/service.py', entryNames }
}
describe('悬停看别的文件里的定义时读成的文档(DocumentReads)', () => {
it('同一份文件同一版再看不再解析,几处同时看也只解析一次;文件改了(版本变了)、入口名字变了再解析', async () => {
const { runtime: parser, parses } = counting()
const reads = new DocumentReads(registryWithPython(), parser)
const [first, second] = [reads.read('file:///app/service.py', file(1)), reads.read('file:///app/service.py', file(1))]
expect(second).toBe(first)
const { message } = await first
expect(message.blocks.map(block => block.anchor)).toContain('class UserService')
const once = parses()
expect(once).toBeGreaterThan(0)
expect(await reads.read('file:///app/service.py', file(1))).toBe(await first)
expect(parses()).toBe(once)
await reads.read('file:///app/service.py', file(2))
expect(parses()).toBe(once * 2)
await reads.read('file:///app/service.py', file(2, ['UserService']))
expect(parses()).toBe(once * 3)
})
it('注册的插件、规则变了,或换了翻译插件:重新解析', async () => {
const { runtime: parser, parses } = counting()
const registry = registryWithPython()
const reads = new DocumentReads(registry, parser)
await reads.read('file:///app/service.py', file(1))
const once = parses()
registry.registerLibraryRule(fastapiRule)
await reads.read('file:///app/service.py', file(1))
expect(parses()).toBe(once * 2)
await reads.read('file:///app/service.py', file(1))
expect(parses()).toBe(once * 2)
registry.registerTranslator({ ...pythonZhTranslator, id: 'python-other' })
await reads.read('file:///app/service.py', file(1))
expect(parses()).toBe(once * 3)
await registry.selectTranslator('python', 'python-other')
await reads.read('file:///app/service.py', file(1))
expect(parses()).toBe(once * 4)
})
it('最多记 limit 份文件,最久没看过的先丢', async () => {
const { runtime: parser, parses } = counting()
const reads = new DocumentReads(registryWithPython(), parser, 2)
await reads.read('file:///a.py', file(1))
const once = parses()
await reads.read('file:///b.py', file(1))
await reads.read('file:///a.py', file(1))
await reads.read('file:///c.py', file(1))
expect(parses()).toBe(once * 3)
await reads.read('file:///a.py', file(1))
expect(parses()).toBe(once * 3)
await reads.read('file:///b.py', file(1))
expect(parses()).toBe(once * 4)
})
})
@@ -0,0 +1,43 @@
import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime'
import type { DocumentRead, DocumentSnapshot } from './documentMessage'
import { RecentPromises } from '../recentPromises'
import { documentMessage } from './documentMessage'
/** 默认记几份文件 */
const LIMIT = 8
/** 悬停看定义用到的那部分:读成的文档,和里面起了中文名的定义。 */
export type PeekRead = Pick<DocumentRead, 'message' | 'displayNames'>
/**
* 悬停看别的文件里的定义时,那份文件读成的文档(见 [documentMessage]),各预览共用,免得每次悬停都把整份文件重新解析一遍。
*
* 同一份文件的同一版、同样的翻译插件和入口名字再看时用记着的,几处同时看也只解析一次;注册的语言插件、翻译插件、规则变了全部重读。
* 只留最近看过的 `limit` 份文件,大文件不一直占着内存。这里不碰 VS Code。
*/
export class DocumentReads {
private readonly files: RecentPromises<PeekRead>
constructor(
private readonly registry: Pick<CodeReaderRegistry, 'onDidChange' | 'languageFor' | 'translatorsFor' | 'selectedTranslator' | 'rulesFor' | 'commandRulesFor'>,
private readonly runtime: Pick<TreeSitterRuntime, 'parse'>,
limit = LIMIT,
) {
this.files = new RecentPromises(limit)
registry.onDidChange(() => this.files.clear())
}
/** `uri` 这份文件按 `document` 读成的文档。 */
read(uri: string, document: DocumentSnapshot): Promise<PeekRead> {
const { languageId, version, fileName, entryNames } = document
const stamp = JSON.stringify([version, languageId, fileName, this.registry.selectedTranslator(languageId)?.id, entryNames ?? []])
return this.files.get(uri, stamp) ?? this.files.set(uri, stamp, this.parse(document))
}
private async parse(document: DocumentSnapshot): Promise<PeekRead> {
// 那个文件里规则出的错由它自己的预览记
const { message, displayNames } = await documentMessage(document, this.registry, this.runtime, () => {})
return { message, displayNames }
}
}
@@ -6,7 +6,8 @@ import type { SymbolNode } from '../symbolLinks'
import type { CodeColors } from '../theme/codeColors'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { describe, expect, it, vi } from 'vitest'
import { commands, window, workspace } from 'vscode'
import { commands, Range, Uri, window, workspace } from 'vscode'
import { languageDefinitions } from './definitions'
import { documentMessage } from './documentMessage'
import { documentSymbolNodes } from './documentSymbols'
import { FIND_CONTEXT } from './findCommands'
@@ -22,6 +23,8 @@ vi.mock('vscode', () => ({
getConfiguration: () => ({ get: (_key: string, fallback: unknown) => fallback }),
openTextDocument: vi.fn(),
applyEdit: vi.fn(async () => true),
textDocuments: [],
asRelativePath: (uri: { fsPath: string }) => uri.fsPath,
},
/** 记下换了哪儿、换成什么 */
WorkspaceEdit: class {
@@ -33,7 +36,10 @@ vi.mock('vscode', () => ({
get start() { return { line: this.startLine, character: this.startCharacter } }
get end() { return { line: this.endLine, character: this.endCharacter } }
},
Uri: { file: (fsPath: string) => ({ fsPath, scheme: 'file', toString: () => `file://${fsPath}` }) },
Uri: {
file: (fsPath: string) => ({ fsPath, scheme: 'file', toString: () => `file://${fsPath}` }),
parse: (value: string) => ({ fsPath: value.replace('file://', ''), scheme: 'file', toString: () => value }),
},
EndOfLine: { LF: 1, CRLF: 2 },
}))
vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } }))
@@ -55,6 +61,11 @@ vi.mock('./documentMessage', async importOriginal => ({
}))
/** 文档符号由各个测试给 */
vi.mock('./documentSymbols', () => ({ documentSymbolNodes: vi.fn(async () => []) }))
/** 语言扩展答的定义由各个测试给,默认没答 */
vi.mock('./definitions', async importOriginal => ({
...await importOriginal<typeof import('./definitions')>(),
languageDefinitions: vi.fn(async () => []),
}))
/** 仓库在 /workspace,里面只有这几个文件 */
vi.mock('./repoPath', async importOriginal => ({
...await importOriginal<typeof import('./repoPath')>(),
@@ -74,7 +85,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise<str
const qa = { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { ask: { subscribe }, rewrite: { subscribe } }, rewrite: vi.fn(rewrite) }
const provider = new PreviewEditorProvider(...[
{},
{},
{ onDidChange: subscribe },
{},
{ subscribe },
{ subscribe },
@@ -211,7 +222,7 @@ function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }, co
}
const provider = new PreviewEditorProvider(...[
{},
{ selectedTranslator: () => undefined, languageFor },
{ selectedTranslator: () => undefined, languageFor, onDidChange: subscribe },
{},
{ subscribe, current: { excluded: {}, onlyChanges: false } },
colors,
@@ -587,6 +598,50 @@ describe('注释里不带路径的引用按它写在哪块的注释里找', () =
expect(page.posted()).toContainEqual({ type: 'repoLinkBuiltin', symbol: 'Error', block: HELPER })
expect(page.posted()).not.toContainEqual(expect.objectContaining({ type: 'repoLinkBroken' }))
})
it('检查过的引用再悬停、再点:文档符号、定义都只问一次;悬停两次,定义所在的别的文件也只读一次', async () => {
const BUILTINS = '/typeshed/builtins.pyi'
const builtins = { uri: Uri.file(BUILTINS), version: 1, languageId: 'python', fileName: BUILTINS, getText: () => 'class RuntimeError: ...', positionAt: () => ({ line: 0, character: 6 }), offsetAt: () => 6 }
vi.mocked(documentMessage).mockImplementationOnce(async input => ({
message: { type: 'document', version: input.version, languageId: input.languageId, blocks: [declared(HELPER, 950)], source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] },
displayNames: [],
symbols: [],
imports: [{ name: 'RuntimeError', offset: 7, range: { startIndex: 0, endIndex: 20 } }],
kinds: [],
bindings: [],
}) as never)
vi.mocked(documentSymbolNodes).mockReset().mockResolvedValue([symbol('helper', 950, 990)])
vi.mocked(languageDefinitions).mockClear().mockResolvedValue([{ uri: builtins.uri, range: new Range(0, 6, 0, 18) }] as never)
const { provider, document } = opening()
Object.assign(document, { positionAt: (offset: number) => ({ line: 0, character: offset }) })
Object.assign(workspace, { textDocuments: [document, builtins] })
vi.mocked(workspace.openTextDocument).mockImplementation((async (uri: { toString: () => string }) => uri.toString() === builtins.uri.toString() ? builtins : document) as never)
try {
const page = tab()
provider.resolveCustomTextEditor(document as never, page.panel)
await page.send({ type: 'ready' })
await vi.waitFor(() => expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document' })))
vi.mocked(documentMessage).mockClear()
const reference = { symbol: 'RuntimeError', block: HELPER }
await page.send({ type: 'checkRepoLinks', id: 1, paths: [], references: [reference] })
expect(page.posted()).toContainEqual({ type: 'repoLinksChecked', id: 1, missing: [], unresolved: [], builtin: [] })
await page.send({ type: 'peekLink', id: 2, ...reference })
await page.send({ type: 'peekLink', id: 3, ...reference })
await page.send({ type: 'openRepoLink', ...reference })
expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'repoLinkOpened', ...reference }))
expect(commands.executeCommand).toHaveBeenCalledWith('vscode.open', expect.objectContaining({ fsPath: BUILTINS }), expect.anything())
expect(documentSymbolNodes).toHaveBeenCalledTimes(1)
expect(languageDefinitions).toHaveBeenCalledTimes(1)
expect(documentMessage).toHaveBeenCalledTimes(1)
expect(documentMessage).toHaveBeenCalledWith(expect.objectContaining({ fileName: BUILTINS }), expect.anything(), expect.anything(), expect.any(Function))
}
finally {
Object.assign(workspace, { textDocuments: [] })
vi.mocked(workspace.openTextDocument).mockReset()
}
})
})
describe('点了带几行的仓库链接', () => {
+38 -38
View File
@@ -10,7 +10,7 @@ import type { CodeQa, TopRequest } from '../qa/codeQa'
import type { CodeReaderRegistry } from '../registry'
import type { LookupSources } from '../ruleLookups'
import type { TreeSitterRuntime } from '../runtime'
import type { ImportedName, LinkTarget, ReferenceTarget, SymbolNode } from '../symbolLinks'
import type { ImportedName, LinkTarget, ReferenceTarget } from '../symbolLinks'
import type { ThemeColors } from '../theme/codeColors'
import type { IndexFind } from '../workspaceIndex'
import type { BlockFilter } from './blockFilter'
@@ -37,18 +37,20 @@ import { NOT_REWRITABLE } from '../qa/codeQa'
import { PATH_ASK_ANCHOR } from '../qa/pathQa'
import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups'
import { outsideUses, referenceScope, resolveReference } from '../symbolLinks'
import { outsideUses } from '../symbolLinks'
import { definitionAction } from './definitionAction'
import { definitionSiteNames, namesOnCard, pagePeekBlock, peekBlock } from './definitionPeek'
import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { DefinitionSources } from './definitionSources'
import { documentMessage, pageDocument } from './documentMessage'
import { DocumentReads } from './documentReads'
import { documentSymbolNodes } from './documentSymbols'
import { FIND_CONTEXT } from './findCommands'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { occurrencesOf } from './occurrences'
import { languageOf, linesSpot, openingFor, revealOf } from './openTarget'
import { PATH_PREVIEW_SCHEME, topSubjectOf } from './pathPreview'
import { ReferenceTargets } from './referenceTargets'
import { findRepoSymbol, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath'
export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
@@ -85,6 +87,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
readonly finding = new NavigationContext<Preview>(value => void commands.executeCommand('setContext', FIND_CONTEXT, value))
/** 调用的名字的定义是哪种块:各预览共用,定义所在的文件同一版只解析一次 */
readonly definitionKinds: DefinitionKinds
/** 悬停看别的文件里的定义时那份文件读成的文档:各预览共用,同一版不再重新解析 */
readonly documentReads: DocumentReads
constructor(
private readonly extensionUri: Uri,
@@ -111,6 +115,7 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
readonly askFeature: (subject: Uri, request: TopRequest) => Promise<string | undefined>,
) {
this.definitionKinds = new DefinitionKinds(languageId => registry.languageFor(languageId), runtime)
this.documentReads = new DocumentReads(registry, runtime)
}
/**
@@ -241,6 +246,11 @@ class Preview {
offset => this.requests.distance(this.document.positionAt(offset).line),
)
/**
* 注释里不带路径的引用解析过的指到哪(见 [ReferenceTargets])。文档符号问这种语言的扩展(同仓库链接找符号),导入的、代码里用到的名字问语言扩展它的定义
* (同跳到定义,几处取第一处),哪些名字是语言自带的问语言插件([LanguageAdapter.builtins](vscode/src/codeReader/contract.ts#LanguageAdapter.builtins))
*/
private readonly references: ReferenceTargets
/** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */
private symbols: { version: number, list: LocalSymbol[] } | undefined
/** 最近一次发出去的文档里各条导入引进来的名字:注释里不带路径的引用按它找导入的定义 */
@@ -283,6 +293,19 @@ class Preview {
return found.length > 0 ? found : this.localDefinitions(offset)
},
})
this.references = new ReferenceTargets({
uri: document.uri.toString(),
symbols: documentSymbolNodes,
definition: async (offset) => {
const [found] = await languageDefinitions(document.uri, document.positionAt(offset))
if (!found)
return undefined
const doc = await workspace.openTextDocument(found.uri)
return { uri: found.uri.toString(), selection: { startIndex: doc.offsetAt(found.range.start), endIndex: doc.offsetAt(found.range.end) } }
},
builtin: name => registry.languageFor(document.languageId)?.builtins?.has(name) ?? false,
versionOf: uri => workspace.textDocuments.find(doc => doc.uri.toString() === uri)?.version,
})
this.names = new DocumentNames((version, names) => {
if (version === document.version)
this.post({ type: 'displayNames', version, names })
@@ -870,8 +893,8 @@ class Preview {
}
/**
* uri 里 offset 处的定义的卡片:盖住它的那块(peekBlock)。同一份文件用最近发出去的那份文档的块和中文名;
* 别的文件打开它、按它的语言排视图数据(同它自己的预览:翻译插件、库规则、项目入口;规则查名字的定义、调用的种类不等),
* `uri` 里 `offset` 处的定义的卡片:盖住它的那块([peekBlock])。同一份文件用最近发出去的那份文档的块和中文名;
* 别的文件按它的语言读成文档(翻译插件、库规则、项目入口同它自己的预览,规则查名字的定义、调用的种类不等),读过的各预览共用(见 [DocumentReads]),
* 中文名只认起了中文名的定义本身。语言没有代码阅读、解析失败、没有块盖住定义处时没有。
*/
private async peekAt(uri: Uri, offset: number): Promise<DefinitionPeek | undefined> {
@@ -886,12 +909,9 @@ class Preview {
const block = peekBlock(sent.blocks, offset, line)
return block && { path, line, block: pagePeekBlock(block, sent.source), names: namesOnCard(block, this.names.known(target.version)) }
}
const { message, displayNames } = await documentMessage(
const { message, displayNames } = await this.owner.documentReads.read(
uri.toString(),
{ languageId: target.languageId, version: target.version, getText: () => target.getText(), fileName: target.fileName, entryNames: this.owner.entries.namesOf(uri) },
this.registry,
this.runtime,
// 那个文件里规则出的错由它自己的预览记
() => {},
)
const block = message.error === undefined ? peekBlock(message.blocks, offset, line) : undefined
return block && { path, line, block: pagePeekBlock(block, message.source), names: namesOnCard(block, definitionSiteNames(displayNames)) }
@@ -933,19 +953,13 @@ class Preview {
/** 预览里这批仓库路径,哪些文件或目录不在;这批不带路径的引用,各按写在哪块找,哪些找不到、哪些是语言自带的名字(页面当普通文字)。 */
private async checkRepoLinks(id: number, paths: string[], references: SymbolReference[]): Promise<void> {
const root = await repoRootOf(this.document.uri.fsPath)
const asked = new Map<string, Promise<SymbolNode[]>>()
const symbolsOf = (uri: string): Promise<SymbolNode[]> => {
const known = asked.get(uri) ?? documentSymbolNodes(uri)
asked.set(uri, known)
return known
}
const [missing, resolved] = await Promise.all([
Promise.all(paths.map(async (rel) => {
if (root === undefined || await resolveRepoEntry(root, rel) === undefined)
return rel
return undefined
})),
Promise.all(references.map(async reference => ({ reference, target: await this.resolveSymbolLink(reference, symbolsOf) }))),
Promise.all(references.map(async reference => ({ reference, target: await this.resolveSymbolLink(reference) }))),
])
this.post({
type: 'repoLinksChecked',
@@ -1062,32 +1076,18 @@ class Preview {
}
/**
* 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪:从它写在的那块(`block`,在眼下这份块树里找)的声明起一层层往外找本文件定义的,
* 再是导入进来的、代码里用到的语言自带的名字,语言自带又问不到定义的是 `'builtin'`(见 [resolveReference])。检查坏链接、点击、悬停看定义卡片都走这里。
* 文档符号同仓库链接找符号那一套(`symbols`,一批引用一起问时共用一份),导入的、代码里用到的名字问语言扩展它的定义(同跳到定义),定义几处取第一处;
* 哪些名字是语言自带的问这门语言的语言插件([LanguageAdapter.builtins](vscode/src/codeReader/contract.ts#LanguageAdapter.builtins))。
* 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪,检查坏链接、点击、悬停看定义卡片都走这里;解析过的记在 [ReferenceTargets] 里,不再问语言扩展。
*
* 从它写在的那块(在最近发出去的块树里找)的声明起一层层往外找本文件定义的,再找导入的、代码里用到的语言自带的名字,
* 语言自带又问不到定义的是 `'builtin'`(见 [解析引用](vscode/src/codeReader/symbolLinks.ts#resolveReference))。出错时记一笔,当找不到。
*/
private async resolveSymbolLink({ symbol, block }: SymbolReference, symbols: (uri: string) => Promise<SymbolNode[]> = documentSymbolNodes): Promise<ReferenceTarget> {
const { document, sent, uses } = this
const at = block === undefined || !sent ? undefined : referenceScope(sent.blocks, block)
const builtins = this.registry.languageFor(document.languageId)?.builtins
private async resolveSymbolLink(reference: SymbolReference): Promise<ReferenceTarget> {
const { document, sent, imports, uses } = this
try {
return await resolveReference(symbol, at, this.imports, {
uri: document.uri.toString(),
symbols,
definition: async (offset) => {
const [found] = await languageDefinitions(document.uri, document.positionAt(offset))
if (!found)
return undefined
const doc = await workspace.openTextDocument(found.uri)
return { uri: found.uri.toString(), selection: { startIndex: doc.offsetAt(found.range.start), endIndex: doc.offsetAt(found.range.end) } }
},
uses: name => uses.get(name) ?? [],
builtin: name => builtins?.has(name) ?? false,
})
return await this.references.resolve({ version: document.version, sent, imports, uses }, reference)
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `没能找注释里的引用 [${symbol}]:${err instanceof Error ? err.message : String(err)}` })
logger.add({ level: 'warn', source: 'codeReader', message: `没能找注释里的引用 [${reference.symbol}]:${err instanceof Error ? err.message : String(err)}` })
return undefined
}
}
@@ -0,0 +1,149 @@
import type { SourceRange } from '../contract'
import type { LinkTarget, SymbolNode } from '../symbolLinks'
import type { ReferenceInputs, TargetSources } from './referenceTargets'
import { describe, expect, it, vi } from 'vitest'
import { ReferenceTargets } from './referenceTargets'
const OWN = 'file:///app/gate.py'
const BUILTINS = 'file:///typeshed/builtins.pyi'
const RUNTIME_ERROR: LinkTarget = { uri: BUILTINS, selection: { startIndex: 100, endIndex: 112 } }
function symbol(name: string, start: number, end: number): SymbolNode {
return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children: [] }
}
/** 本文件定义了 `load`,代码里在 30 处用到 `RuntimeError`、在 60 处用到 `str`,问 30 处的定义落到 builtins.pyi;各文件眼下都是第 1 版 */
function sources() {
const versions = new Map([[OWN, 1], [BUILTINS, 1]])
const symbols = vi.fn(async (uri: string): Promise<SymbolNode[]> => uri === OWN ? [symbol('load', 0, 80)] : [symbol('RuntimeError', 100, 200)])
const definition = vi.fn(async (offset: number): Promise<LinkTarget | undefined> => offset === 30 ? RUNTIME_ERROR : undefined)
const given: TargetSources = {
uri: OWN,
symbols,
definition,
builtin: name => name === 'str',
versionOf: uri => versions.get(uri),
}
return { given, symbols, definition, versions }
}
function inputs(version = 1, uses = new Map<string, SourceRange[]>([['RuntimeError', [{ startIndex: 30, endIndex: 42 }]], ['str', [{ startIndex: 60, endIndex: 63 }]]])): ReferenceInputs {
return { version, sent: undefined, imports: [], uses }
}
describe('注释里引用的解析结果(ReferenceTargets)', () => {
it('同一份文档里同一个引用检查过,再悬停、再点:文档符号、定义都只问一次', async () => {
const { given, symbols, definition } = sources()
const targets = new ReferenceTargets(given)
const now = inputs()
const checked = await Promise.all([targets.resolve(now, { symbol: 'RuntimeError', block: 'function load' }), targets.resolve(now, { symbol: 'load' })])
expect(checked).toEqual([RUNTIME_ERROR, { uri: OWN, selection: { startIndex: 0, endIndex: 4 } }])
expect(await targets.resolve(now, { symbol: 'RuntimeError', block: 'function load' })).toEqual(RUNTIME_ERROR)
expect(await targets.resolve(now, { symbol: 'RuntimeError', block: 'function load' })).toEqual(RUNTIME_ERROR)
expect(symbols).toHaveBeenCalledTimes(1)
expect(definition).toHaveBeenCalledTimes(1)
})
it('同时问同一个引用:等同一个 Promise,只问一次', async () => {
const { given, definition } = sources()
const targets = new ReferenceTargets(given)
const now = inputs()
const first = targets.resolve(now, { symbol: 'RuntimeError' })
const second = targets.resolve(now, { symbol: 'RuntimeError' })
expect(second).toBe(first)
await first
expect(definition).toHaveBeenCalledTimes(1)
})
it('同一个名字写在不同块的注释里分开记', async () => {
const { given, definition } = sources()
const targets = new ReferenceTargets(given)
const now = inputs()
await targets.resolve(now, { symbol: 'RuntimeError', block: 'function load' })
await targets.resolve(now, { symbol: 'RuntimeError' })
expect(definition).toHaveBeenCalledTimes(2)
})
it('文档换了版本,或块树、导入、用到的名字换了一份:重新问', async () => {
const { given, symbols, definition } = sources()
const targets = new ReferenceTargets(given)
const reference = { symbol: 'RuntimeError' }
const first = inputs(1)
await targets.resolve(first, reference)
await targets.resolve(inputs(2), reference)
expect(definition).toHaveBeenCalledTimes(2)
expect(symbols).toHaveBeenCalledTimes(2)
const second = inputs(2)
await targets.resolve(second, reference)
await targets.resolve({ ...second, imports: [] }, reference)
await targets.resolve({ ...second, sent: { blocks: [] } }, reference)
expect(definition).toHaveBeenCalledTimes(5)
})
it('找不到的、语言自带的不留,下次再问;语言扩展先答空、后来答上了的用后来的', async () => {
const { given, definition } = sources()
const targets = new ReferenceTargets(given)
const now = inputs()
expect(await targets.resolve(now, { symbol: 'str' })).toBe('builtin')
expect(await targets.resolve(now, { symbol: 'str' })).toBe('builtin')
expect(definition.mock.calls.filter(([offset]) => offset === 60)).toHaveLength(2)
definition.mockResolvedValueOnce(undefined)
expect(await targets.resolve(now, { symbol: 'RuntimeError' })).toBeUndefined()
expect(await targets.resolve(now, { symbol: 'RuntimeError' })).toEqual(RUNTIME_ERROR)
})
it('问出错的不留:下次再问', async () => {
const { given, definition } = sources()
const targets = new ReferenceTargets(given)
const now = inputs()
definition.mockRejectedValueOnce(new Error('Pylance 还没起来'))
await expect(targets.resolve(now, { symbol: 'RuntimeError' })).rejects.toThrow('Pylance 还没起来')
expect(await targets.resolve(now, { symbol: 'RuntimeError' })).toEqual(RUNTIME_ERROR)
})
it('本文件的文档符号答空的不留:下次再问', async () => {
const { given, symbols } = sources()
const targets = new ReferenceTargets(given)
const now = inputs()
symbols.mockResolvedValueOnce([])
expect(await targets.resolve(now, { symbol: 'load' })).toBeUndefined()
expect(await targets.resolve(now, { symbol: 'load' })).toEqual({ uri: OWN, selection: { startIndex: 0, endIndex: 4 } })
expect(symbols).toHaveBeenCalledTimes(2)
})
it('指到的别的文件改过(换了版本)、关掉了:重新问', async () => {
const { given, definition, versions } = sources()
const targets = new ReferenceTargets(given)
const now = inputs()
const reference = { symbol: 'RuntimeError' }
await targets.resolve(now, reference)
versions.set(BUILTINS, 2)
await targets.resolve(now, reference)
expect(definition).toHaveBeenCalledTimes(2)
await targets.resolve(now, reference)
expect(definition).toHaveBeenCalledTimes(2)
versions.delete(BUILTINS)
await targets.resolve(now, reference)
expect(definition).toHaveBeenCalledTimes(3)
})
it('成员落在别的文件的文档符号里:那个文件改过后连文档符号一起重新问', async () => {
const { given, symbols, versions } = sources()
const targets = new ReferenceTargets(given)
symbols.mockImplementation(async uri => uri === OWN
? [symbol('load', 0, 80)]
: [{ ...symbol('RuntimeError', 100, 200), children: [symbol('args', 150, 160)] }])
const now = inputs()
const reference = { symbol: 'RuntimeError.args' }
expect(await targets.resolve(now, reference)).toEqual({ uri: BUILTINS, selection: { startIndex: 150, endIndex: 154 } })
expect(await targets.resolve(now, reference)).toEqual({ uri: BUILTINS, selection: { startIndex: 150, endIndex: 154 } })
expect(symbols.mock.calls.filter(([uri]) => uri === BUILTINS)).toHaveLength(1)
versions.set(BUILTINS, 2)
await targets.resolve(now, reference)
expect(symbols.mock.calls.filter(([uri]) => uri === BUILTINS)).toHaveLength(2)
})
})
@@ -0,0 +1,101 @@
import type { BlockView } from '../blockView'
import type { SourceRange } from '../contract'
import type { ImportedName, ReferenceSources, ReferenceTarget, SymbolNode } from '../symbolLinks'
import type { SymbolReference } from './messages'
import { referenceScope, resolveReference } from '../symbolLinks'
/**
* 解析引用时本文件的样子:文档版本、最近一次发出去的块树(没有时只从文件顶层找)、各条导入引进来的名字、代码里用到又没定义的名字各在哪几处。
* 哪样换了,引用从哪个声明起找、导入和用到的名字在哪都可能跟着变。
*/
export interface ReferenceInputs {
version: number
sent: { blocks: BlockView[] } | undefined
imports: readonly ImportedName[]
uses: ReadonlyMap<string, readonly SourceRange[]>
}
/** 解析引用要问的(见 [ReferenceSources],代码里用到的名字由 [ReferenceInputs] 给),和眼下打开着的一个文件是第几版(没打开时没有)。 */
export interface TargetSources extends Omit<ReferenceSources, 'uses'> {
versionOf: (uri: string) => number | undefined
}
/** 记着的一份:问的 Promise,问完留下来的再记着结果落在哪个文件、那个文件当时第几版。 */
interface Kept<T> {
value: Promise<T>
file?: { uri: string, version: number }
}
/** 按同一份 [ReferenceInputs] 记着的一轮:各引用指到哪,各文件的文档符号。 */
interface Round {
inputs: ReferenceInputs
targets: Map<string, Kept<ReferenceTarget>>
symbols: Map<string, Kept<readonly SymbolNode[]>>
}
/** 引用的键:同一个名字写在不同块的注释里分开记,指到的可以不一样(拼法同[页面那边的键](vscode/webview-ui/src/codeReader/repoLink.ts#referenceKey))。 */
function referenceKey({ symbol, block }: SymbolReference): string {
return `${symbol}\u0000${block ?? ''}`
}
function sameInputs(a: ReferenceInputs, b: ReferenceInputs): boolean {
return a.version === b.version && a.sent === b.sent && a.imports === b.imports && a.uses === b.uses
}
/**
* 一个预览里注释中不带路径的引用指到哪(见 [resolveReference]),解析过的记着:标链接、悬停、点击同一个引用只问一次语言扩展,同时问的等同一个 Promise。
*
* 本文件的样子([ReferenceInputs])换了,记着的全部作废。指到的文件改过、关掉了的再问一遍,免得范围指错地方。
* 找不到的、语言自带的、出错的不留,因为语言扩展还没准备好时会暂时答空,下次要再问。各文件的文档符号也这样记着,一批引用共用一份。
*/
export class ReferenceTargets {
private round: Round | undefined
constructor(private readonly sources: TargetSources) {}
/** 本文件是 `inputs` 这个样子时,`reference` 指到哪。 */
resolve(inputs: ReferenceInputs, reference: SymbolReference): Promise<ReferenceTarget> {
const round = this.roundOf(inputs)
const { uri, definition, builtin } = this.sources
const symbols = (file: string): Promise<readonly SymbolNode[]> =>
this.share(round.symbols, file, () => this.sources.symbols(file), nodes => nodes.length > 0 ? file : undefined)
return this.share(round.targets, referenceKey(reference), () => {
const { symbol, block } = reference
const at = block === undefined || !inputs.sent ? undefined : referenceScope(inputs.sent.blocks, block)
return resolveReference(symbol, at, inputs.imports, { uri, symbols, definition, builtin, uses: name => inputs.uses.get(name) ?? [] })
}, target => target && target !== 'builtin' ? target.uri : undefined)
}
/** 按 `inputs` 记的这一轮:本文件的样子换了就开新的一轮,旧一轮里还在问的问完也不记。 */
private roundOf(inputs: ReferenceInputs): Round {
if (!this.round || !sameInputs(this.round.inputs, inputs))
this.round = { inputs, targets: new Map(), symbols: new Map() }
return this.round
}
/**
* `kept` 里 `key` 那份:记着、指到的文件也没改过就给它,没有就 `ask` 一份记上,问完 `fileOf` 说结果落在哪个文件。
* 不说(不留的结果)、那个文件眼下没打开、问失败了的拿掉,留下的记着那个文件此刻第几版。
*/
private share<T>(kept: Map<string, Kept<T>>, key: string, ask: () => Promise<T>, fileOf: (value: T) => string | undefined): Promise<T> {
const { versionOf } = this.sources
const known = kept.get(key)
if (known && (!known.file || versionOf(known.file.uri) === known.file.version))
return known.value
const entry: Kept<T> = { value: ask() }
kept.set(key, entry)
const drop = (): void => {
if (kept.get(key) === entry)
kept.delete(key)
}
entry.value.then((value) => {
const uri = fileOf(value)
const version = uri === undefined ? undefined : versionOf(uri)
if (uri === undefined || version === undefined)
drop()
else
entry.file = { uri, version }
}, drop)
return entry.value
}
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { RecentPromises } from './recentPromises'
describe('最近用过的几份异步结果(RecentPromises)', () => {
it('同一个键按同样的 stamp 再要给记着的那份,stamp 变了没有;换上新的那份后旧的不再有', async () => {
const kept = new RecentPromises<string>(4)
const first = kept.set('a', 1, Promise.resolve('一'))
expect(kept.get('a', 1)).toBe(first)
expect(kept.get('a', 2)).toBeUndefined()
const second = kept.set('a', 2, Promise.resolve('二'))
expect(kept.get('a', 2)).toBe(second)
expect(kept.get('a', 1)).toBeUndefined()
})
it('算失败的拿掉,下次重算;失败前已经换上的新的那份不受影响', async () => {
const kept = new RecentPromises<string>(4)
const failed = kept.set('a', 1, Promise.reject(new Error('坏了')))
await expect(failed).rejects.toThrow('坏了')
expect(kept.get('a', 1)).toBeUndefined()
let fail: (err: Error) => void = () => {}
const slow = kept.set('b', 1, new Promise<string>((_resolve, reject) => {
fail = reject
}))
const fresh = kept.set('b', 2, Promise.resolve('新'))
fail(new Error('旧的坏了'))
await expect(slow).rejects.toThrow('旧的坏了')
expect(kept.get('b', 2)).toBe(fresh)
})
it('最多留 limit 份,拿过的算用过,最久没用的先丢;clear 全部丢掉', () => {
const kept = new RecentPromises<string>(2)
kept.set('a', 1, Promise.resolve('a'))
kept.set('b', 1, Promise.resolve('b'))
kept.get('a', 1)
kept.set('c', 1, Promise.resolve('c'))
expect(kept.get('b', 1)).toBeUndefined()
expect(kept.get('a', 1)).toBeDefined()
expect(kept.get('c', 1)).toBeDefined()
kept.clear()
expect(kept.get('a', 1)).toBeUndefined()
expect(kept.get('c', 1)).toBeUndefined()
})
})
+49
View File
@@ -0,0 +1,49 @@
/**
* 按键记着最近用过的几份异步结果,同一个键按同样的东西(`stamp`,比如文件的版本)再要时直接给记着的那份,正在算的也给同一个 Promise,
* 免得同一份东西反复算;`stamp` 变了就换成新算的,算失败的不留、下次重算,多了丢最久没用的。这里不碰 VS Code。
*/
export class RecentPromises<T> {
private readonly entries = new Map<string, { stamp: string | number, value: Promise<T> }>()
constructor(private readonly limit: number) {}
/** `key` 记着的、按 `stamp` 算的那份,拿到的挪到最近用过的那头;没记过或记的不是按 `stamp` 算的时没有。 */
get(key: string, stamp: string | number): Promise<T> | undefined {
const known = this.entries.get(key)
if (known?.stamp !== stamp)
return undefined
this.keep(key, known)
return known.value
}
/**
* 记下 `key` 按 `stamp` 算的 `value`,换掉这个键原来那份;`value` 失败时拿掉,下次重算。
*
* @return `value` 本身,好接着用
*/
set(key: string, stamp: string | number, value: Promise<T>): Promise<T> {
const entry = { stamp, value }
this.keep(key, entry)
value.catch(() => {
if (this.entries.get(key) === entry)
this.entries.delete(key)
})
return value
}
/** 全部丢掉:算它们用的东西整个变了时。 */
clear(): void {
this.entries.clear()
}
/** 挪到最近用过的那头,多了丢最久没用的。 */
private keep(key: string, entry: { stamp: string | number, value: Promise<T> }): void {
this.entries.delete(key)
this.entries.set(key, entry)
for (const oldest of this.entries.keys()) {
if (this.entries.size <= this.limit)
break
this.entries.delete(oldest)
}
}
}