From cfde0fedf5985c42cea0ee1cd3fed1e41484d14c Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 14 Oct 2026 19:57:20 +1100 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20perf(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E9=A2=84=E8=A7=88=E6=B3=A8=E9=87=8A=E9=87=8C?= =?UTF-8?q?=E7=9A=84=E5=BC=95=E7=94=A8=E8=A7=A3=E6=9E=90=E8=BF=87=E5=B0=B1?= =?UTF-8?q?=E8=AE=B0=E7=9D=80=EF=BC=9A=E6=A0=87=E5=9D=8F=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E3=80=81=E6=82=AC=E5=81=9C=E3=80=81=E7=82=B9=E5=87=BB=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E5=BC=95=E7=94=A8=E5=8F=AA=E9=97=AE=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=20Pylance=EF=BC=8C=E5=90=8C=E6=97=B6=E9=97=AE?= =?UTF-8?q?=E7=9A=84=E7=AD=89=E5=90=8C=E4=B8=80=E4=B8=AA=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=EF=BC=8C=E6=96=87=E6=A1=A3=E7=89=88=E6=9C=AC=E6=88=96=E5=9D=97?= =?UTF-8?q?=E6=A0=91=E3=80=81=E5=AF=BC=E5=85=A5=E3=80=81=E7=94=A8=E5=88=B0?= =?UTF-8?q?=E7=9A=84=E5=90=8D=E5=AD=97=E6=8D=A2=E4=BA=86=E6=89=8D=E4=BD=9C?= =?UTF-8?q?=E5=BA=9F=EF=BC=8C=E6=8C=87=E5=88=B0=E7=9A=84=E5=88=AB=E7=9A=84?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=94=B9=E8=BF=87=E5=86=8D=E9=97=AE=EF=BC=8C?= =?UTF-8?q?=E6=89=BE=E4=B8=8D=E5=88=B0=E5=92=8C=E8=AF=AD=E8=A8=80=E8=87=AA?= =?UTF-8?q?=E5=B8=A6=E7=9A=84=E4=B8=8D=E7=95=99=EF=BC=9B=E6=82=AC=E5=81=9C?= =?UTF-8?q?=E7=9C=8B=E5=88=AB=E7=9A=84=E6=96=87=E4=BB=B6=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=97=B6=E8=AF=BB=E6=88=90=E7=9A=84=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=90=84=E9=A2=84=E8=A7=88=E5=85=B1=E7=94=A8=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E7=89=88=E4=B8=8D=E5=86=8D=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=EF=BC=8C=E7=95=99=E6=9C=80=E8=BF=91=208=20?= =?UTF-8?q?=E4=BB=BD=EF=BC=8C=E6=B3=A8=E5=86=8C=E7=9A=84=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=8F=98=E4=BA=86=E5=85=A8=E9=83=A8=E9=87=8D=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/definitionKinds.ts | 39 ++--- .../codeReader/editor/documentReads.test.ts | 100 ++++++++++++ vscode/src/codeReader/editor/documentReads.ts | 43 +++++ .../codeReader/editor/previewEditor.test.ts | 63 +++++++- vscode/src/codeReader/editor/previewEditor.ts | 76 ++++----- .../editor/referenceTargets.test.ts | 149 ++++++++++++++++++ .../src/codeReader/editor/referenceTargets.ts | 101 ++++++++++++ vscode/src/codeReader/recentPromises.test.ts | 45 ++++++ vscode/src/codeReader/recentPromises.ts | 49 ++++++ 9 files changed, 595 insertions(+), 70 deletions(-) create mode 100644 vscode/src/codeReader/editor/documentReads.test.ts create mode 100644 vscode/src/codeReader/editor/documentReads.ts create mode 100644 vscode/src/codeReader/editor/referenceTargets.test.ts create mode 100644 vscode/src/codeReader/editor/referenceTargets.ts create mode 100644 vscode/src/codeReader/recentPromises.test.ts create mode 100644 vscode/src/codeReader/recentPromises.ts diff --git a/vscode/src/codeReader/definitionKinds.ts b/vscode/src/codeReader/definitionKinds.ts index 97f4bbe..4f18101 100644 --- a/vscode/src/codeReader/definitionKinds.ts +++ b/vscode/src/codeReader/definitionKinds.ts @@ -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 }>() + private readonly files: RecentPromises constructor( private readonly languageFor: (languageId: string) => LanguageAdapter | undefined, private readonly runtime: Pick, - 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 { - 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): 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())) } } diff --git a/vscode/src/codeReader/editor/documentReads.test.ts b/vscode/src/codeReader/editor/documentReads.test.ts new file mode 100644 index 0000000..965e443 --- /dev/null +++ b/vscode/src/codeReader/editor/documentReads.test.ts @@ -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, parses: () => number } { + let parses = 0 + return { + runtime: { + parse: (...args) => { + parses++ + return runtime.parse(...args) + }, + }, + parses: () => parses, + } +} + +function registryWithPython(): CodeReaderRegistry { + const store: Record = {} + const registry = new CodeReaderRegistry({ + get: (key: string): T | undefined => store[key] as T | undefined, + update: async (key: string, value: unknown) => { + store[key] = value + }, + } as Pick) + 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) + }) +}) diff --git a/vscode/src/codeReader/editor/documentReads.ts b/vscode/src/codeReader/editor/documentReads.ts new file mode 100644 index 0000000..221e6a4 --- /dev/null +++ b/vscode/src/codeReader/editor/documentReads.ts @@ -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 + +/** + * 悬停看别的文件里的定义时,那份文件读成的文档(见 [documentMessage]),各预览共用,免得每次悬停都把整份文件重新解析一遍。 + * + * 同一份文件的同一版、同样的翻译插件和入口名字再看时用记着的,几处同时看也只解析一次;注册的语言插件、翻译插件、规则变了全部重读。 + * 只留最近看过的 `limit` 份文件,大文件不一直占着内存。这里不碰 VS Code。 + */ +export class DocumentReads { + private readonly files: RecentPromises + + constructor( + private readonly registry: Pick, + private readonly runtime: Pick, + limit = LIMIT, + ) { + this.files = new RecentPromises(limit) + registry.onDidChange(() => this.files.clear()) + } + + /** `uri` 这份文件按 `document` 读成的文档。 */ + read(uri: string, document: DocumentSnapshot): Promise { + 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 { + // 那个文件里规则出的错由它自己的预览记 + const { message, displayNames } = await documentMessage(document, this.registry, this.runtime, () => {}) + return { message, displayNames } + } +} diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 4d945c1..9c69e13 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -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(), + languageDefinitions: vi.fn(async () => []), +})) /** 仓库在 /workspace,里面只有这几个文件 */ vi.mock('./repoPath', async importOriginal => ({ ...await importOriginal(), @@ -74,7 +85,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise 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('点了带几行的仓库链接', () => { diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 348839c..2d8f4c8 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -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(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, ) { 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 { @@ -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 { const root = await repoRootOf(this.document.uri.fsPath) - const asked = new Map>() - const symbolsOf = (uri: string): Promise => { - 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 = documentSymbolNodes): Promise { - 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 { + 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 } } diff --git a/vscode/src/codeReader/editor/referenceTargets.test.ts b/vscode/src/codeReader/editor/referenceTargets.test.ts new file mode 100644 index 0000000..8f46205 --- /dev/null +++ b/vscode/src/codeReader/editor/referenceTargets.test.ts @@ -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 => uri === OWN ? [symbol('load', 0, 80)] : [symbol('RuntimeError', 100, 200)]) + const definition = vi.fn(async (offset: number): Promise => 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([['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) + }) +}) diff --git a/vscode/src/codeReader/editor/referenceTargets.ts b/vscode/src/codeReader/editor/referenceTargets.ts new file mode 100644 index 0000000..794355e --- /dev/null +++ b/vscode/src/codeReader/editor/referenceTargets.ts @@ -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 +} + +/** 解析引用要问的(见 [ReferenceSources],代码里用到的名字由 [ReferenceInputs] 给),和眼下打开着的一个文件是第几版(没打开时没有)。 */ +export interface TargetSources extends Omit { + versionOf: (uri: string) => number | undefined +} + +/** 记着的一份:问的 Promise,问完留下来的再记着结果落在哪个文件、那个文件当时第几版。 */ +interface Kept { + value: Promise + file?: { uri: string, version: number } +} + +/** 按同一份 [ReferenceInputs] 记着的一轮:各引用指到哪,各文件的文档符号。 */ +interface Round { + inputs: ReferenceInputs + targets: Map> + symbols: Map> +} + +/** 引用的键:同一个名字写在不同块的注释里分开记,指到的可以不一样(拼法同[页面那边的键](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 { + const round = this.roundOf(inputs) + const { uri, definition, builtin } = this.sources + const symbols = (file: string): Promise => + 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(kept: Map>, key: string, ask: () => Promise, fileOf: (value: T) => string | undefined): Promise { + 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 = { 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 + } +} diff --git a/vscode/src/codeReader/recentPromises.test.ts b/vscode/src/codeReader/recentPromises.test.ts new file mode 100644 index 0000000..8d341e1 --- /dev/null +++ b/vscode/src/codeReader/recentPromises.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { RecentPromises } from './recentPromises' + +describe('最近用过的几份异步结果(RecentPromises)', () => { + it('同一个键按同样的 stamp 再要给记着的那份,stamp 变了没有;换上新的那份后旧的不再有', async () => { + const kept = new RecentPromises(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(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((_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(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() + }) +}) diff --git a/vscode/src/codeReader/recentPromises.ts b/vscode/src/codeReader/recentPromises.ts new file mode 100644 index 0000000..30f1bc4 --- /dev/null +++ b/vscode/src/codeReader/recentPromises.ts @@ -0,0 +1,49 @@ +/** + * 按键记着最近用过的几份异步结果,同一个键按同样的东西(`stamp`,比如文件的版本)再要时直接给记着的那份,正在算的也给同一个 Promise, + * 免得同一份东西反复算;`stamp` 变了就换成新算的,算失败的不留、下次重算,多了丢最久没用的。这里不碰 VS Code。 + */ +export class RecentPromises { + private readonly entries = new Map }>() + + constructor(private readonly limit: number) {} + + /** `key` 记着的、按 `stamp` 算的那份,拿到的挪到最近用过的那头;没记过或记的不是按 `stamp` 算的时没有。 */ + get(key: string, stamp: string | number): Promise | 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): Promise { + 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 }): 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) + } + } +}