✨ feat(vscode): 代码阅读注释里不带路径的引用 [Name] 能点,本文件定义的优先、再找导入的

This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent 0958506681
commit 300d8a07b9
16 files changed
+600 -124

No files matched your search

+8 -3
View File
@@ -1,12 +1,14 @@
import type { Tree } from '@vscode/tree-sitter-wasm'
import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleCard, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract'
import type { TreeSitterRuntime } from './runtime'
import type { ImportedName } from './symbolLinks'
import { blockAttributes } from './attributes'
import { KIND_ICONS } from './contract'
import { commonIndent, splitLines } from './editText'
import { commentSpan } from './languages/shared'
import { activeRules, claimBlock, importsIn } from './libraryRules'
import { BlockAnchors } from './qa/anchor'
import { importedNames } from './symbolLinks'
interface BlockViewBase {
/** 在树里的位置,例如 '6.1.0'。 */
@@ -415,12 +417,14 @@ export function toBlockViews(
/**
* 一份源码的视图数据,和它里面起了中文名的定义(预览找用到它们的各处要用,见 displayNames.ts)、
* 语言插件自己认得出的名字(LanguageAdapter.symbols,语言扩展查不到定义、各处出现时用)。
* 语言插件自己认得出的名字(LanguageAdapter.symbols,语言扩展查不到定义、各处出现时用)、
* 各条导入引进来的名字(注释里不带路径的引用按它找导入的定义,见 symbolLinks.ts)。
*/
export interface DocumentViews {
blocks: BlockView[]
displayNames: DisplayName[]
symbols: LocalSymbol[]
imports: ImportedName[]
}
/**
@@ -487,12 +491,13 @@ export async function readBlockViews(
...(commandRule && { commandRule }),
...(parts.size > 0 && { embedded }),
}
const blocks = toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), document, translator, extras.rules, parts)
const codeBlocks = adapter.toBlocks(tree.rootNode, source, languageId)
const blocks = toBlockViews(codeBlocks, document, translator, extras.rules, parts)
const symbols = [
...adapter.symbols?.(tree.rootNode, source, languageId) ?? [],
...[...parts.values()].flatMap(part => part.document.language.symbols?.(part.root, source, part.code.languageId) ?? []),
]
return { blocks, displayNames: names, symbols }
return { blocks, displayNames: names, symbols, imports: importedNames(importsIn(codeBlocks)) }
}
finally {
for (const parsed of trees)
+2 -2
View File
@@ -11,8 +11,8 @@ const TAG_LINE = /^\s*@(\w+)(?:\s(.*))?$/
/** 围栏代码块的开头(三个以上的反引号或波浪线,前面最多三个空格):里面的行照原样留在正文里。 */
const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})/
/** 一个名字或点分的几个名字(`ValueError`、`errors.NotFound`):写成不带路径的引用。 */
const NAME_PATH = /^[\p{L}_$][\p{L}\p{N}_$]*(?:\.[\p{L}_$][\p{L}\p{N}_$]*)*$/u
/** 一个标识符或点分的几个(`ValueError`、`errors.NotFound`),同预览认引用的写法:写成不带路径的引用。 */
const NAME_PATH = /^[A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*)*$/i
interface Tag {
kind: TagKind
@@ -1,6 +1,7 @@
import type { DisplayName, LocalSymbol, RuleContext, SourceDocument } from '../contract'
import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime'
import type { ImportedName } from '../symbolLinks'
import type { DocumentMessage } from './messages'
import { fileAttributes, mergeAttributeNames } from '../attributes'
import { readBlockViews } from '../blockView'
@@ -19,11 +20,15 @@ export interface DocumentSnapshot {
entryNames?: readonly string[]
}
/** 发给预览的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)、语言插件自己认得出的名字(LocalSymbol)。 */
/**
* 发给预览的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)、语言插件自己认得出的名字(LocalSymbol)、
* 各条导入引进来的名字(注释里不带路径的引用按它找导入的定义)。
*/
export interface DocumentRead {
message: DocumentMessage
displayNames: DisplayName[]
symbols: LocalSymbol[]
imports: ImportedName[]
}
/**
@@ -55,10 +60,10 @@ export async function documentMessage(
}
const adapter = registry.languageFor(languageId)
if (!adapter)
return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [], symbols: [] }
return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [], symbols: [], imports: [] }
try {
const rules = registry.rulesFor(languageId)
const { blocks, displayNames, symbols } = await readBlockViews(runtime, adapter, translator, languageId, source, {
const { blocks, displayNames, symbols, imports } = await readBlockViews(runtime, adapter, translator, languageId, source, {
...(document.fileName && { fileName: document.fileName }),
rules: { rules, report, lookup },
calleeKind,
@@ -73,9 +78,9 @@ export async function documentMessage(
},
})
const names = mergeAttributeNames([translator?.attributeNames, ...ruleAttributeNames(rules, translator?.displayLanguage, report)])
return { message: { ...message, blocks: markEntryBlocks(blocks, source, document.entryNames ?? []), attributes: fileAttributes(blocks, names) }, displayNames, symbols }
return { message: { ...message, blocks: markEntryBlocks(blocks, source, document.entryNames ?? []), attributes: fileAttributes(blocks, names) }, displayNames, symbols, imports }
}
catch (err) {
return { message: { ...message, error: err instanceof Error ? err.message : String(err) }, displayNames: [], symbols: [] }
return { message: { ...message, error: err instanceof Error ? err.message : String(err) }, displayNames: [], symbols: [], imports: [] }
}
}
+11 -10
View File
@@ -101,9 +101,9 @@ export interface QaMessage {
* displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发,
* 换了筛选、基准挪了(提交后 HEAD 动了)再发;null 是不比了(回到全部文件)
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
*/
export type ExtensionToWebview
@@ -121,9 +121,9 @@ export type ExtensionToWebview
| { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string }
| { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string }
| { type: 'diff', diff: DocumentDiff | null }
| { type: 'repoLinksChecked', id: number, missing: string[] }
| { type: 'repoLinkBroken', path: string, symbol?: string }
| { type: 'repoLinkOpened', path: string, symbol?: string }
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
| { type: 'repoLinkBroken', path?: string, symbol?: string }
| { type: 'repoLinkOpened', path?: string, symbol?: string }
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
| { type: 'focusPathAsk' }
@@ -145,8 +145,9 @@ export type ExtensionToWebview
* selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发;
* 扩展端按它设 when 子句用的上下文键
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录
* checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录;
* 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),按这份文件找 symbol(本文件定义的,再是导入的,见 symbolLinks.ts)
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
*/
export type WebviewToExtension
@@ -166,6 +167,6 @@ export type WebviewToExtension
| { type: 'deleteQa', anchor: string, entryId: string }
| { type: 'selectAskProfile', id: string }
| { type: 'occurrenceNavigation', available: boolean }
| { type: 'openRepoLink', path: string, symbol?: string }
| { type: 'checkRepoLinks', id: number, paths: string[] }
| { type: 'openRepoLink', path?: string, symbol?: string }
| { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] }
| { type: 'checkRepoImages', id: number, paths: string[] }
+105 -32
View File
@@ -1,6 +1,6 @@
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, WorkspaceIndex } from '../contract'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange, WorkspaceIndex } from '../contract'
import type { DiffBase } from '../diff/diffBase'
import type { OldDocument, SentDocument } from '../diff/previewDiff'
import type { WorkspaceEntries } from '../entries/workspaceEntries'
@@ -9,6 +9,7 @@ import type { CodeQa } from '../qa/codeQa'
import type { CodeReaderRegistry } from '../registry'
import type { LookupSources } from '../ruleLookups'
import type { TreeSitterRuntime } from '../runtime'
import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks'
import type { IndexedEntry } from '../workspaceIndex'
import type { BlockFilter } from './blockFilter'
import type { Definition, FoundLocation } from './definitions'
@@ -29,6 +30,7 @@ import { askClaude, askFailure } from '../qa/claudeAsk'
import { PATH_ASK_ANCHOR } from '../qa/pathQa'
import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups'
import { resolveReference } from '../symbolLinks'
import { definitionAction } from './definitionAction'
import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { DefinitionSources } from './definitionSources'
@@ -188,6 +190,8 @@ class Preview {
private sent: SentDocument | undefined
/** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */
private symbols: { version: number, list: LocalSymbol[] } | undefined
/** 最近一次发出去的文档里各条导入引进来的名字:注释里不带路径的引用按它找导入的定义 */
private imports: ImportedName[] = []
/** 这份文档各版本显示成中文名的名字 */
private readonly names: DocumentNames
/** 库规则问的名字的定义查到了什么:查到的变了就重发文档 */
@@ -330,7 +334,7 @@ class Preview {
const source = this.document.getText()
const session = this.lookups.session()
const callees = this.callees.session()
const { message, displayNames, symbols } = await documentMessage({ languageId, version, getText: () => source, fileName: this.document.fileName, entryNames: this.owner.entries.namesOf(this.document.uri) }, this.registry, this.runtime, (text) => {
const { message, displayNames, symbols, imports } = await documentMessage({ languageId, version, getText: () => source, fileName: this.document.fileName, entryNames: this.owner.entries.namesOf(this.document.uri) }, this.registry, this.runtime, (text) => {
logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` })
}, session.lookup, callees.calleeKind)
if (request !== this.latest || !this.ready)
@@ -339,6 +343,7 @@ class Preview {
// 解析失败的文档没有块:问 AI 找不到块,也不拿它比差异
this.sent = message.error === undefined ? { version: message.version, blocks: message.blocks, source } : undefined
this.symbols = { version, list: symbols }
this.imports = imports
const reveal = this.pendingReveal
this.pendingReveal = undefined
this.post({ ...message, names: this.names.known(version), ...(reveal && { reveal }) })
@@ -579,7 +584,7 @@ class Preview {
await this.openRepoLink(msg.path, msg.symbol)
return
case 'checkRepoLinks':
await this.checkRepoLinks(msg.id, msg.paths)
await this.checkRepoLinks(msg.id, msg.paths, msg.symbols)
return
case 'checkRepoImages':
await this.checkRepoImages(msg.id, msg.paths)
@@ -654,16 +659,25 @@ class Preview {
await this.owner.openAt(uri, reveal, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options))
}
/** 预览里这批仓库路径,哪些文件或目录不在。 */
private async checkRepoLinks(id: number, paths: string[]): Promise<void> {
/** 预览里这批仓库路径,哪些文件或目录不在;这批不带路径的引用,哪些找不到。 */
private async checkRepoLinks(id: number, paths: string[], symbols: string[]): Promise<void> {
const root = await repoRootOf(this.document.uri.fsPath)
const missing = (await Promise.all(paths.map(async (rel) => {
if (root === undefined || await resolveRepoEntry(root, rel) === undefined)
return rel
return undefined
}))).filter(rel => rel !== undefined)
const asked = new Map<string, Promise<SymbolNode[]>>()
const symbolsOf = (uri: string): Promise<SymbolNode[]> => {
const known = asked.get(uri) ?? this.symbolNodes(uri)
asked.set(uri, known)
return known
}
const [missing, unresolved] = await Promise.all([
Promise.all(paths.map(async (rel) => {
if (root === undefined || await resolveRepoEntry(root, rel) === undefined)
return rel
return undefined
})),
Promise.all(symbols.map(async symbol => await this.resolveSymbolLink(symbol, symbolsOf) ? undefined : symbol)),
])
if (this.ready)
this.post({ type: 'repoLinksChecked', id, missing })
this.post({ type: 'repoLinksChecked', id, missing: missing.filter(rel => rel !== undefined), unresolved: unresolved.filter(symbol => symbol !== undefined) })
}
/** 仓库根放进这个预览能读的本地目录,图片地址才能加载。真实位置和 git 给的路径不一样时两个都放。 */
@@ -707,18 +721,29 @@ class Preview {
/**
* 打开注释里的仓库链接:目录在代码阅读侧栏的树上定位,文件用编辑器打开。
* 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。
* 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。没有路径的是不带路径的引用,按这份文件找(见 resolveSymbolLink)。
*/
private async openRepoLink(rel: string, symbol?: string): Promise<void> {
const root = await repoRootOf(this.document.uri.fsPath)
const target = root === undefined ? undefined : await resolveRepoEntry(root, rel)
if (!target) {
this.markRepoLinkBroken(rel, symbol)
return
}
const uri = Uri.file(target.fsPath)
private async openRepoLink(rel: string | undefined, symbol?: string): Promise<void> {
const link = rel ?? `[${symbol}]`
try {
if (target.isDirectory) {
if (rel === undefined) {
const target = symbol === undefined ? undefined : await this.resolveSymbolLink(symbol)
if (!target) {
this.markRepoLinkBroken(undefined, symbol)
return
}
await this.showLinkTarget(target)
this.markRepoLinkOpened(undefined, symbol)
return
}
const root = await repoRootOf(this.document.uri.fsPath)
const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel)
if (!entry) {
this.markRepoLinkBroken(rel, symbol)
return
}
const uri = Uri.file(entry.fsPath)
if (entry.isDirectory) {
if (symbol !== undefined) {
this.markRepoLinkBroken(rel, symbol)
return
@@ -738,21 +763,69 @@ class Preview {
this.markRepoLinkBroken(rel, symbol)
return
}
await this.showRepoFile(uri, found.selectionRange)
await this.showLinkTarget(found)
this.markRepoLinkOpened(rel, symbol)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'warn', source: 'codeReader', message: `没能打开仓库链接 ${rel}:${message}` })
void window.showErrorMessage(`代码阅读:没能打开 ${rel}`)
logger.add({ level: 'warn', source: 'codeReader', message: `没能打开仓库链接 ${link}:${message}` })
void window.showErrorMessage(`代码阅读:没能打开 ${link}`)
}
}
/** 用文档符号按名字找。先把文件读进来(不显示),没有这个名字就没有,不打开到文件顶上。 */
private async locateRepoSymbol(uri: Uri, symbol: string): Promise<DocumentSymbol | undefined> {
const doc = await workspace.openTextDocument(uri)
/** 用文档符号按名字找(`Agent`、`Agent.owner_id`);没有这个名字就没有,不打开到文件顶上。 */
private async locateRepoSymbol(uri: Uri, symbol: string): Promise<LinkTarget | undefined> {
const found = findRepoSymbol(await this.symbolNodes(uri.toString()), symbol)
return found && { uri: uri.toString(), selection: found.selection }
}
/** 一个文件的文档符号(vscode.executeDocumentSymbolProvider):先把文件读进来(不显示),范围换成那个文件里的下标。 */
private async symbolNodes(uri: string): Promise<SymbolNode[]> {
const doc = await workspace.openTextDocument(Uri.parse(uri))
const symbols = await commands.executeCommand<DocumentSymbol[] | undefined>('vscode.executeDocumentSymbolProvider', doc.uri)
return Array.isArray(symbols) ? findRepoSymbol(symbols, symbol) : undefined
const span = (range: Range): SourceRange => ({ startIndex: doc.offsetAt(range.start), endIndex: doc.offsetAt(range.end) })
const node = (symbol: DocumentSymbol): SymbolNode => ({
name: symbol.name,
range: span(symbol.range),
selection: span(symbol.selectionRange),
children: (symbol.children ?? []).map(node),
})
return Array.isArray(symbols) ? symbols.map(node) : []
}
/**
* 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪:本文件定义的优先,再是导入进来的(见 symbolLinks.ts)。
* 文档符号同仓库链接找符号那一套(symbols,一批引用一起问时共用一份),导入的名字问语言扩展它的定义(同跳到定义),定义几处取第一处。
*/
private async resolveSymbolLink(symbol: string, symbols: (uri: string) => Promise<SymbolNode[]> = uri => this.symbolNodes(uri)): Promise<LinkTarget | undefined> {
const { document } = this
try {
return await resolveReference(symbol, 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) } }
},
})
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `没能找注释里的引用 [${symbol}]:${err instanceof Error ? err.message : String(err)}` })
return undefined
}
}
/** 跳到链接指的符号:在这份文件里就在本预览里滚过去、闪一下;别的文件在预览所在的编辑器组打开、选中符号的名字。 */
private async showLinkTarget({ uri, selection }: LinkTarget): Promise<void> {
if (uri === this.document.uri.toString()) {
this.reveal({ line: this.document.positionAt(selection.startIndex).line, definition: selection.startIndex })
return
}
const doc = await workspace.openTextDocument(Uri.parse(uri))
await this.showRepoFile(doc.uri, new Range(doc.positionAt(selection.startIndex), doc.positionAt(selection.endIndex)))
}
/** 在预览所在的编辑器组打开文件,并把预览标签固定住,免得被它替掉。图片用默认打开方式,才看得到图。 */
@@ -766,14 +839,14 @@ class Preview {
await window.showTextDocument(uri, { viewColumn: this.panel.viewColumn, ...(selection && { selection }) })
}
private markRepoLinkBroken(path: string, symbol?: string): void {
private markRepoLinkBroken(path: string | undefined, symbol?: string): void {
if (this.ready)
this.post({ type: 'repoLinkBroken', path, ...(symbol !== undefined && { symbol }) })
this.post({ type: 'repoLinkBroken', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }) })
}
private markRepoLinkOpened(path: string, symbol?: string): void {
private markRepoLinkOpened(path: string | undefined, symbol?: string): void {
if (this.ready)
this.post({ type: 'repoLinkOpened', path, ...(symbol !== undefined && { symbol }) })
this.post({ type: 'repoLinkOpened', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }) })
}
/** 用写注释的技能重写这一段,只把正文套回原来的定界符,再走块编辑那条写回。 */
+100
View File
@@ -0,0 +1,100 @@
import type { ReferenceSources, SymbolNode } from './symbolLinks'
import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
import { importsIn } from './libraryRules'
import { importedNames, resolveReference, splitReference } from './symbolLinks'
import { readFixture, WASM_DIR, withBlocks } from './testSupport'
const py = createPythonAdapter(WASM_DIR)
const ts = createTypeScriptAdapter(WASM_DIR)
function symbol(name: string, start: number, end: number, children: SymbolNode[] = []): SymbolNode {
return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children }
}
/** 假的语言扩展:几个文件的文档符号,本文件几处名字的定义;记下问过哪些。 */
function sources(files: Record<string, SymbolNode[]>, definitions: Record<number, { uri: string, at: number, length: number }> = {}): ReferenceSources & { asked: number[] } {
const asked: number[] = []
return {
uri: 'file:///here.py',
asked,
symbols: async uri => files[uri] ?? [],
definition: async (offset) => {
asked.push(offset)
const found = definitions[offset]
return found && { uri: found.uri, selection: { startIndex: found.at, endIndex: found.at + found.length } }
},
}
}
describe('不带路径的引用:拆名字', () => {
it('拆开 Class.member:名字和成员,单个名字没有成员', () => {
expect(splitReference('Agent.owner_id')).toEqual({ head: 'Agent', members: ['owner_id'] })
expect(splitReference('USER')).toEqual({ head: 'USER', members: [] })
})
})
describe('不带路径的引用:指到哪', () => {
const imports = [{ name: 'Agent', offset: 20, range: { startIndex: 20, endIndex: 25 } }]
it('本文件定义的优先于导入的;不问语言扩展', async () => {
const found = sources({ 'file:///here.py': [symbol('Agent', 100, 200)] }, { 20: { uri: 'file:///models.py', at: 5, length: 5 } })
expect(await resolveReference('Agent', imports, found)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 100, endIndex: 105 } })
expect(found.asked).toEqual([])
})
it('本文件的 Class.member 在类底下找成员;类有、成员没有就是找不到,不再去导入里找', async () => {
const here = sources({ 'file:///here.py': [symbol('Agent', 100, 200, [symbol('owner_id', 120, 140)])] }, { 20: { uri: 'file:///models.py', at: 5, length: 5 } })
expect(await resolveReference('Agent.owner_id', imports, here)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 120, endIndex: 128 } })
expect(await resolveReference('Agent.missing', imports, here)).toBeUndefined()
expect(here.asked).toEqual([])
})
it('导入语句里的名字不算本文件定义的:问那个名字的定义,Class.member 在定义所在文件的那个类底下找', async () => {
const files = {
'file:///here.py': [symbol('Agent', 20, 25)],
'file:///models.py': [symbol('Other', 0, 4, [symbol('owner_id', 1, 3)]), symbol('Agent', 10, 90, [symbol('owner_id', 30, 50)])],
}
const found = sources(files, { 20: { uri: 'file:///models.py', at: 10, length: 5 } })
expect(await resolveReference('Agent', imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 10, endIndex: 15 } })
expect(await resolveReference('Agent.owner_id', imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 30, endIndex: 38 } })
expect(found.asked).toEqual([20, 20])
})
it('导入的是整个模块(定义落在文件开头)时成员从顶层找', async () => {
const files = { 'file:///errors.py': [symbol('NotFound', 40, 80)] }
const found = sources(files, { 7: { uri: 'file:///errors.py', at: 0, length: 0 } })
expect(await resolveReference('errors.NotFound', [{ name: 'errors', offset: 7, range: { startIndex: 7, endIndex: 13 } }], found))
.toEqual({ uri: 'file:///errors.py', selection: { startIndex: 40, endIndex: 48 } })
})
it('本文件没定义、也没导入,或语言扩展答不上:找不到', async () => {
expect(await resolveReference('Nowhere', imports, sources({}))).toBeUndefined()
expect(await resolveReference('Agent', imports, sources({}))).toBeUndefined()
})
})
describe('导入引进来的名字(取自预览已经读出的导入 facts)', () => {
it('python:from 导入问原名的位置、别名按别名认,整个模块按第一节认、起了别名问最后一节,import * 没有', async () => {
const source = 'from a.b import C, D as E\nimport os.path\nimport x.y as z\nfrom m import *\n'
const names = await withBlocks(py, 'python', source, blocks => importedNames(importsIn(blocks)))
expect(names.map(item => [item.name, source.slice(item.offset, item.offset + 1), source.slice(item.range.startIndex, item.range.endIndex)])).toEqual([
['C', 'C', 'C'],
['E', 'D', 'D as E'],
['os', 'o', 'os.path'],
['z', 'y', 'x.y as z'],
])
})
it('typeScript:具名、默认、命名空间导入', async () => {
const source = 'import d, { b as c } from \'y\'\nimport * as ns from \'z\'\n'
const names = await withBlocks(ts, 'typescript', source, blocks => importedNames(importsIn(blocks)))
expect(names.map(item => [item.name, source.slice(item.offset, item.offset + 1)])).toEqual([['d', 'd'], ['c', 'b'], ['ns', 'n']])
})
it('gate.py 导入的 require_desktop_client、AppException 都在,本文件定义的 LocalRuntimeDisabled 不在', async () => {
const names = await withBlocks(py, 'python', readFixture('localRuntimeGate.py'), blocks => importedNames(importsIn(blocks)))
expect(names.map(item => item.name)).toEqual(['annotations', 'Request', 'get_local_runtime_enabled', 'AppException', 'require_desktop_client'])
})
})
+118
View File
@@ -0,0 +1,118 @@
import type { ImportFacts, ImportItem, Node, SourceRange } from './contract'
/**
* 注释里不带路径的引用(KDoc 的 `[Name]`、`[Class.member]`)指到哪:先找本文件定义的,再找本文件导入进来的,都没有就找不到。
* 符号、定义问语言扩展,交给调用方(预览)去问;这里只管按什么顺序找、怎么拆名字(不碰 VS Code)。
*/
/** 本文件某条导入引进来的一个名字:名字、问定义时问的位置(导入语句里那个名字的下标)、这一项在源码里的范围。 */
export interface ImportedName {
name: string
offset: number
range: SourceRange
}
/** 一个文件里的一个文档符号:名字、整个定义的范围(看定义落在哪个符号里)、名字的范围、子符号。下标都是那个文件里的。 */
export interface SymbolNode {
name: string
range: SourceRange
selection: SourceRange
children: readonly SymbolNode[]
}
/** 引用指到的地方:文件(uri 字符串)和定义处那个名字的范围。 */
export interface LinkTarget {
uri: string
selection: SourceRange
}
/** 解析一个引用要问的:本文件是哪个、它的文档符号,本文件某处名字的定义(几处取第一处),别的文件的文档符号。 */
export interface ReferenceSources {
uri: string
symbols: (uri: string) => Promise<readonly SymbolNode[]>
definition: (offset: number) => Promise<LinkTarget | undefined>
}
function spanOf(nodes: readonly (Node | undefined)[]): SourceRange {
const present = nodes.filter((node): node is Node => node !== undefined)
return { startIndex: Math.min(...present.map(node => node.startIndex)), endIndex: Math.max(...present.map(node => node.endIndex)) }
}
/** 一项导入引进来的名字:起了别名的是别名;问定义时问原来那个名字(模块问最后一节,那才是这个模块)。整模块导入(`from m import *`)没有。 */
function importedName(item: ImportItem): ImportedName | undefined {
switch (item.kind) {
case 'named':
return { name: (item.alias ?? item.name).text, offset: item.name.startIndex, range: spanOf([item.name, item.alias]) }
case 'default':
return { name: item.alias.text, offset: item.alias.startIndex, range: spanOf([item.alias]) }
case 'namespace':
return item.alias && { name: item.alias.text, offset: item.alias.startIndex, range: spanOf([item.alias]) }
case 'module': {
const last = item.module.namedChildren.at(-1) ?? item.module
const name = item.alias?.text ?? item.module.text.split('.')[0]
return { name, offset: item.alias ? last.startIndex : item.module.startIndex, range: spanOf([item.module, item.alias]) }
}
}
}
/** 本文件各条导入(预览已经读出的 ImportFacts)引进来的名字,照源码顺序。 */
export function importedNames(imports: readonly ImportFacts[]): ImportedName[] {
return imports.flatMap(facts => facts.items.flatMap(item => importedName(item) ?? []))
}
/** 引用拆成名字和成员:`Agent.owner_id` 是 Agent 和 [owner_id]。 */
export function splitReference(reference: string): { head: string, members: string[] } {
const [head = '', ...members] = reference.split('.')
return { head, members }
}
function covers(range: SourceRange, offset: number): boolean {
return range.startIndex <= offset && offset < range.endIndex
}
/** 符号底下按名字一层层找成员:先找直接的子符号,没有再往深处找。 */
function memberOf(symbols: readonly SymbolNode[], members: readonly string[]): SymbolNode | undefined {
const [first, ...rest] = members
const found = symbols.find(symbol => symbol.name === first) ?? deepFind(symbols, first)
return found && (rest.length > 0 ? memberOf(found.children, rest) : found)
}
function deepFind(symbols: readonly SymbolNode[], name: string | undefined): SymbolNode | undefined {
for (const symbol of symbols) {
const found = symbol.children.find(child => child.name === name) ?? deepFind(symbol.children, name)
if (found)
return found
}
return undefined
}
/** 盖住 offset 的最里层的符号;定义落在文件开头(导入的是整个模块)时没有。 */
function innermost(symbols: readonly SymbolNode[], offset: number): SymbolNode | undefined {
const outer = symbols.find(symbol => covers(symbol.range, offset))
return outer && (innermost(outer.children, offset) ?? outer)
}
/**
* 引用指到哪,按 KDoc 的规矩:
* 1. 本文件定义的:顶层的符号(类、函数、常量、变量)名字对上,`Class.member` 再在它底下找成员;导入语句里的名字不算本文件定义的。
* 2. 本文件导入进来的:名字(`Class.member` 的第一段)是某条导入引进来的,问导入语句里那个名字的定义;`Class.member` 再在定义所在文件的
* 符号里找:定义落在哪个符号里就在它底下找,落在文件开头(导入的是模块)就从顶层找。
* 3. 都没有:找不到。本文件定义了这个名字、却没有这个成员时也是找不到,不再去导入里找。
*/
export async function resolveReference(reference: string, imports: readonly ImportedName[], sources: ReferenceSources): Promise<LinkTarget | undefined> {
const { head, members } = splitReference(reference)
const local = await sources.symbols(sources.uri)
const own = local.find(symbol => symbol.name === head && !imports.some(item => covers(item.range, symbol.selection.startIndex)))
if (own) {
const found = members.length > 0 ? memberOf(own.children, members) : own
return found && { uri: sources.uri, selection: found.selection }
}
const imported = imports.find(item => item.name === head)
const target = imported && await sources.definition(imported.offset)
if (!target || members.length === 0)
return target
const symbols = await sources.symbols(target.uri)
const holder = innermost(symbols, target.selection.startIndex)
const found = memberOf(holder ? holder.children : symbols, members)
return found && { uri: target.uri, selection: found.selection }
}