✨ feat(vscode): FastAPI 接口卡片的调用树带上前端调用它的地方
This commit is contained in:
1 parent
d4459e93b3
commit
29cedcb751
30 files changed
+1316
-130
No files matched your search
+2
-2
@@ -37,8 +37,8 @@ const TREE_SITTER_WASM = [
|
||||
'tree-sitter-rust.wasm',
|
||||
]
|
||||
|
||||
// @vscode/tree-sitter-wasm 没有的语法包,从 tree-sitter-wasm(预编译、签过名的各家语法包)拿:语言名 → 它的 wasm
|
||||
const EXTRA_GRAMMARS = ['dockerfile']
|
||||
// @vscode/tree-sitter-wasm 没有的语法包,从 tree-sitter-wasm(预编译、签过名的各家语法包)拿:语言名 → 它的 wasm;json 只给工作区索引读接口契约用
|
||||
const EXTRA_GRAMMARS = ['dockerfile', 'json']
|
||||
|
||||
function copyTreeSitterWasm() {
|
||||
const from = path.dirname(createRequire(import.meta.url).resolve('@vscode/tree-sitter-wasm'))
|
||||
|
||||
@@ -51,6 +51,20 @@ function translationText(view: BlockView): string {
|
||||
}
|
||||
|
||||
describe('blockView', () => {
|
||||
it('有名字的函数、类(含嵌在里面的方法)标上能看调用树,别的块不标', async () => {
|
||||
const source = 'import os\n\nLIMIT = 1\n\n\nclass Order:\n def pay(self):\n print(os.name)\n\n\ndef main():\n Order().pay()\n'
|
||||
const found = flatten(await views(py, 'python', pythonZhTranslator, source)).map(view => [view.kind, view.callTree])
|
||||
expect(found).toEqual([
|
||||
['import', undefined],
|
||||
['const', undefined],
|
||||
['class', {}],
|
||||
['function', {}],
|
||||
['statement', undefined],
|
||||
['function', {}],
|
||||
['statement', undefined],
|
||||
])
|
||||
})
|
||||
|
||||
it.each(SAMPLES)('%#: codeTokens 拼起来等于 code,code 是编辑范围整行去共同缩进', async (adapter, languageId, translator, fixture) => {
|
||||
const source = readFixture(fixture)
|
||||
const lines = source.split('\n')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, HttpEndpoint, 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'
|
||||
@@ -44,9 +44,16 @@ interface BlockViewBase {
|
||||
badge?: KindBadge
|
||||
/** 库规则挂在父卡片子行上的子块才有(见 RuleSlot):展开时画在父卡片属性表第 row 行下面,不排在父卡片的子块里。 */
|
||||
slot?: BlockSlot
|
||||
/** 能看调用树的块才有(见 CallTreeTarget):页面按它摆调用树按钮,宿主按它数棵数、查调用者。 */
|
||||
callTree?: CallTreeTarget
|
||||
children: BlockView[]
|
||||
}
|
||||
|
||||
/** 一块能看调用树:有名字的函数、类是 `{}`;讲 HTTP 接口的库规则卡片带上接口(RuleCard.endpoint),调用者里另外并上前端发这个请求的地方。 */
|
||||
export interface CallTreeTarget {
|
||||
endpoint?: HttpEndpoint
|
||||
}
|
||||
|
||||
/** 子块挂在父卡片的哪儿:属性表第 row 行的第 item 个子行,点那个子行的第一格才在这一行下面展开(见 RuleSlot)。 */
|
||||
export interface BlockSlot {
|
||||
row: number
|
||||
@@ -434,6 +441,7 @@ export function toBlockViews(
|
||||
...sourceRows(span),
|
||||
headerEndLine: block.header.endRow,
|
||||
...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }),
|
||||
...(name && (block.kind === 'function' || block.kind === 'class') && { callTree: {} }),
|
||||
attributes: blockAttributes(block),
|
||||
concepts: blockConcepts(block),
|
||||
...commentTarget(block),
|
||||
@@ -460,7 +468,7 @@ export function toBlockViews(
|
||||
const { attributes, comments } = block
|
||||
return plain({ ...replacement.as, node: block.node, header: statement, children: kept, ...(attributes && { attributes }), ...(comments && { comments }) }, span, id, scope)
|
||||
}
|
||||
const { kind, badge, rows, defines, attributes } = replacement
|
||||
const { kind, badge, rows, defines, attributes, endpoint } = replacement
|
||||
const slots = new Map((replacement.slots ?? []).map(({ block: child, row, item }): [CodeBlock, BlockSlot] => [child, { row, item }]))
|
||||
const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(statement))
|
||||
return {
|
||||
@@ -470,6 +478,7 @@ export function toBlockViews(
|
||||
...sourceRows(span),
|
||||
headerEndLine: statement.endRow,
|
||||
...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }),
|
||||
...(defines && endpoint && { callTree: { endpoint } }),
|
||||
attributes: { ...blockAttributes(block), ...attributes, kind },
|
||||
concepts: blockConcepts(block, replacement),
|
||||
...commentTarget(block),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { OperationEntry } from './openapi'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { INDEX_GRAMMARS, MemoryIndexHost } from '../testSupport'
|
||||
import { WorkspaceIndexes } from '../workspaceIndex'
|
||||
import { endpointCalls, requestOperation, routeCandidates } from './endpoint'
|
||||
|
||||
function op(method: string, path: string, operationId?: string): OperationEntry {
|
||||
return { key: method, name: { startIndex: 0, endIndex: 0 }, path, ...(operationId && { operationId }) }
|
||||
}
|
||||
|
||||
const GETS = [
|
||||
op('GET', '/api/v1/agents', 'list_agents_api_v1_agents_get'),
|
||||
op('GET', '/api/v1/agents/default', 'get_default_agent_api_v1_agents_default_get'),
|
||||
op('GET', '/api/v1/agents/{agent_id}', 'get_agent_api_v1_agents__agent_id__get'),
|
||||
op('GET', '/api/v1/admin/agents', 'admin_list_agents_api_v1_admin_agents_get'),
|
||||
// 两份契约文件内容一样时同一条出现两次
|
||||
op('GET', '/api/v1/agents', 'list_agents_api_v1_agents_get'),
|
||||
]
|
||||
|
||||
describe('后端路由对到契约里的那一条', () => {
|
||||
it('完整路径以本文件的路径结尾,参数段对参数段', () => {
|
||||
expect(routeCandidates(GETS, { method: 'GET', path: '/agents/default', operationIdPrefix: 'get_default_agent_' })).toEqual(['/api/v1/agents/default'])
|
||||
expect(routeCandidates(GETS, { method: 'GET', path: '/agents/{agent_id}' })).toEqual(['/api/v1/agents/{agent_id}'])
|
||||
expect(routeCandidates(GETS, { method: 'GET', path: '/agents/{id:path}' })).toEqual(['/api/v1/agents/{agent_id}'])
|
||||
expect(routeCandidates(GETS, { method: 'POST', path: '/agents/default' })).toEqual([])
|
||||
})
|
||||
|
||||
it('对上几条时按 operationId 以「函数名_」开头挑;挑剩几条是分不出,一条不剩是没有', () => {
|
||||
expect(routeCandidates(GETS, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_' })).toEqual(['/api/v1/agents'])
|
||||
expect(routeCandidates(GETS, { method: 'GET', path: '/agents', operationIdPrefix: 'other_' })).toEqual([])
|
||||
expect(routeCandidates(GETS, { method: 'GET', path: '/agents' })).toEqual(['/api/v1/agents', '/api/v1/admin/agents'])
|
||||
expect(routeCandidates([...GETS, op('GET', '/api/v1/console/agents', 'list_agents_api_v1_console_agents_get')], { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_' }))
|
||||
.toEqual(['/api/v1/agents', '/api/v1/console/agents'])
|
||||
expect(routeCandidates(GETS, { method: 'GET', path: '/missing' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('前端请求对到契约里最具体的那一条', () => {
|
||||
const paths = ['/api/v1/agents', '/api/v1/agents/default', '/api/v1/agents/{agent_id}']
|
||||
|
||||
it(`字面量段优先配字面量,配不上再配参数段;模板里的 \${...} 只配参数段`, () => {
|
||||
expect(requestOperation(paths, '/api/v1/agents/default')).toBe('/api/v1/agents/default')
|
||||
expect(requestOperation(paths, '/api/v1/agents/abc')).toBe('/api/v1/agents/{agent_id}')
|
||||
expect(requestOperation(paths, `/api/v1/agents/\${id}`)).toBe('/api/v1/agents/{agent_id}')
|
||||
expect(requestOperation(paths, '/api/v1/agents/')).toBe('/api/v1/agents')
|
||||
expect(requestOperation(paths, 'https://example.com/api/v1/agents/default')).toBe('/api/v1/agents/default')
|
||||
expect(requestOperation(paths, '/api/v1/agents/a/b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('最具体的不止一条时当没对上', () => {
|
||||
expect(requestOperation(['/a/{x}', '/a/{y}'], '/a/b')).toBeUndefined()
|
||||
expect(requestOperation(['/a/{x}/c', '/a/b/{y}'], '/a/b/c')).toBe('/a/b/{y}')
|
||||
})
|
||||
})
|
||||
|
||||
const SCHEMA = [
|
||||
'export interface paths {',
|
||||
' "/api/v1/agents": {',
|
||||
' get: operations["list_agents_api_v1_agents_get"];',
|
||||
' post: operations["create_agent_api_v1_agents_post"];',
|
||||
' };',
|
||||
' "/api/v1/agents/default": {',
|
||||
' get: operations["get_default_agent_api_v1_agents_default_get"];',
|
||||
' };',
|
||||
' "/api/v1/agents/{agent_id}": {',
|
||||
' get: operations["get_agent_api_v1_agents__agent_id__get"];',
|
||||
' };',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const FRONTEND = {
|
||||
'web/src/app/(main)/my-agents/page.tsx': 'const MyAgentsPage = async () => {\n const { data } = await api.GET("/api/v1/agents", {})\n return <div />\n}\n',
|
||||
'web/src/lib/agents.ts': `export async function fetchDefaultAgent() {\n return fetch("/api/v1/agents/default", { credentials: "include" })\n}\nexport function loadAgent(id: string) {\n return api.GET(\`/api/v1/agents/\${id}\`)\n}\n`,
|
||||
}
|
||||
|
||||
async function calls(files: Record<string, string>, endpoint: Parameters<typeof endpointCalls>[1]): Promise<unknown> {
|
||||
const indexes = new WorkspaceIndexes(new MemoryIndexHost(files, INDEX_GRAMMARS))
|
||||
const found = await endpointCalls((index, key) => indexes.find(index, key), endpoint)
|
||||
if ('contracts' in found)
|
||||
return { ...found, contracts: found.contracts.map(uri => uri.replace(`${MemoryIndexHost.FOLDER}/`, '')) }
|
||||
return found.requests.map(({ location, entry }) => `${location.uri.replace(`${MemoryIndexHost.FOLDER}/`, '')} ${entry.caller} ${entry.path}`)
|
||||
}
|
||||
|
||||
describe('接口卡片找前端调用它的地方', () => {
|
||||
const files = { 'web/src/lib/api/schema.d.ts': SCHEMA, ...FRONTEND }
|
||||
|
||||
it('先在契约里对出完整网址,再挑出最具体地配上它的前端请求', async () => {
|
||||
expect(await calls(files, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_' })).toEqual(['web/src/app/(main)/my-agents/page.tsx MyAgentsPage /api/v1/agents'])
|
||||
expect(await calls(files, { method: 'GET', path: '/agents/default', operationIdPrefix: 'get_default_agent_' })).toEqual(['web/src/lib/agents.ts fetchDefaultAgent /api/v1/agents/default'])
|
||||
expect(await calls(files, { method: 'GET', path: '/agents/{agent_id}', operationIdPrefix: 'get_agent_' })).toEqual([`web/src/lib/agents.ts loadAgent /api/v1/agents/\${id}`])
|
||||
})
|
||||
|
||||
it('契约里有、前端没人调是空的一串,不是「契约里没有」', async () => {
|
||||
expect(await calls(files, { method: 'POST', path: '/agents', operationIdPrefix: 'create_agent_' })).toEqual([])
|
||||
})
|
||||
|
||||
it('契约里没有这个接口时交回找到的契约文件;一份契约文件都没有时是空的', async () => {
|
||||
expect(await calls(files, { method: 'DELETE', path: '/agents/{agent_id}', operationIdPrefix: 'delete_agent_' })).toEqual({ contracts: ['web/src/lib/api/schema.d.ts'] })
|
||||
expect(await calls(FRONTEND, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_' })).toEqual({ contracts: [] })
|
||||
})
|
||||
|
||||
it('契约里几条都对得上、按 operationId 也分不出时不猜,连同那几条交回', async () => {
|
||||
const admin = SCHEMA.replace('export interface paths {', 'export interface paths {\n "/api/v1/admin/agents": {\n get: operations["list_agents_api_v1_admin_agents_get"];\n };')
|
||||
expect(await calls({ ...files, 'web/src/lib/api/schema.d.ts': admin }, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_' }))
|
||||
.toEqual({ contracts: ['web/src/lib/api/schema.d.ts'], ambiguous: ['/api/v1/admin/agents', '/api/v1/agents'] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 接口卡片的前端调用者:先到接口契约文件(OpenAPI)里对出这个接口的完整网址,再挑出最具体地配上它的前端请求。
|
||||
* 契约里对不上就交回找到了哪几份契约文件,不猜、不退回去只比网址结尾。
|
||||
*/
|
||||
|
||||
import type { HttpEndpoint, IndexEntry } from '../contract'
|
||||
import type { IndexedEntry, WorkspaceIndexOf } from '../workspaceIndex'
|
||||
import type { OperationEntry } from './openapi'
|
||||
import type { RequestEntry } from './requests'
|
||||
import { CONTRACT_INDEXES, CONTRACT_METHODS } from './openapi'
|
||||
import { REQUESTS_INDEX } from './requests'
|
||||
|
||||
/** 查工作区索引里键是 key 的各项(宿主是 WorkspaceIndexes.find)。 */
|
||||
export type IndexFind = <E extends IndexEntry>(index: WorkspaceIndexOf<E>, key: string) => Promise<IndexedEntry<E>[]>
|
||||
|
||||
/** 接口在契约文件里没对上。 */
|
||||
export interface ContractMiss {
|
||||
/** 找到的契约文件(从工作区文件夹算起的路径),一份都没有时是空的 */
|
||||
files: string[]
|
||||
/** 契约里结尾对得上、按 operationId 也分不出是哪一条的几条完整路径;一条都对不上时没有 */
|
||||
ambiguous?: string[]
|
||||
}
|
||||
|
||||
/** 路径拆成段:去掉协议和主机,空段不算。 */
|
||||
function segmentsOf(path: string): string[] {
|
||||
return path.replace(/^[a-z][\w+.-]*:\/\/[^/]*/i, '').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
/** 参数段:契约里的 `{agent_id}`,前端模板里的 `${id}`。 */
|
||||
function isParam(segment: string): boolean {
|
||||
return segment.includes('{')
|
||||
}
|
||||
|
||||
/** 契约路径 full 以后端写的 tail 结尾:字面量段对字面量段要相等,参数段对参数段。 */
|
||||
function endsWith(full: readonly string[], tail: readonly string[]): boolean {
|
||||
const offset = full.length - tail.length
|
||||
return offset >= 0 && tail.every((segment, i) => isParam(segment) ? isParam(full[offset + i]) : full[offset + i] === segment)
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端路由在契约里可能是哪几条(契约里的完整路径):方法相同、完整路径以本文件的路径结尾;
|
||||
* 结尾对上几条时只留 operationId 以 operationIdPrefix 开头的。正好一条才算对上,剩几条是分不出,一条不剩是没有。
|
||||
*/
|
||||
export function routeCandidates(operations: readonly OperationEntry[], endpoint: HttpEndpoint): string[] {
|
||||
const tail = segmentsOf(endpoint.path)
|
||||
const matched = operations.filter(operation => operation.key === endpoint.method && endsWith(segmentsOf(operation.path), tail))
|
||||
const prefix = endpoint.operationIdPrefix
|
||||
const candidates = new Set(matched.map(operation => operation.path))
|
||||
if (candidates.size <= 1 || prefix === undefined)
|
||||
return [...candidates]
|
||||
return [...new Set(matched.filter(operation => operation.operationId?.startsWith(prefix)).map(operation => operation.path))]
|
||||
}
|
||||
|
||||
/** 契约路径的各段是不是字面量;从左往右先在字面量上配上的更具体。 */
|
||||
function moreSpecific(a: readonly boolean[], b: readonly boolean[]): number {
|
||||
const at = a.findIndex((literal, i) => literal !== b[i])
|
||||
return at < 0 ? 0 : a[at] ? 1 : -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端请求最具体地配上契约里的哪条路径:段数相同,契约的字面量段要和前端的字面量段相等,契约的参数段配什么都行,
|
||||
* 前端的参数段只配参数段;最具体的不止一条时没有。
|
||||
*/
|
||||
export function requestOperation(paths: readonly string[], requestPath: string): string | undefined {
|
||||
const request = segmentsOf(requestPath)
|
||||
let best: { path: string, shape: boolean[] }[] = []
|
||||
for (const path of new Set(paths)) {
|
||||
const segments = segmentsOf(path)
|
||||
if (segments.length !== request.length || !segments.every((segment, i) => isParam(segment) || (!isParam(request[i]) && segment === request[i])))
|
||||
continue
|
||||
const shape = segments.map(segment => !isParam(segment))
|
||||
const order = best.length === 0 ? 1 : moreSpecific(shape, best[0].shape)
|
||||
if (order > 0)
|
||||
best = [{ path, shape }]
|
||||
else if (order === 0)
|
||||
best.push({ path, shape })
|
||||
}
|
||||
return best.length === 1 ? best[0].path : undefined
|
||||
}
|
||||
|
||||
/** 前端发这个请求的各处;接口在契约里没对上时是找到的契约文件(uri),分不出是哪一条时连同那几条完整路径。 */
|
||||
export type EndpointCalls = { requests: IndexedEntry<RequestEntry>[] } | { contracts: string[], ambiguous?: string[] }
|
||||
|
||||
/** 接口卡片的前端调用者:契约里对出完整路径后,同一方法的前端请求里最具体地配上这条路径的那些。 */
|
||||
export async function endpointCalls(find: IndexFind, endpoint: HttpEndpoint): Promise<EndpointCalls> {
|
||||
const operations = (await Promise.all(CONTRACT_INDEXES.map(index => find(index, endpoint.method)))).flat().map(({ entry }) => entry)
|
||||
const candidates = routeCandidates(operations, endpoint)
|
||||
if (candidates.length !== 1) {
|
||||
const all = await Promise.all(CONTRACT_INDEXES.flatMap(index => CONTRACT_METHODS.map(method => find(index, method))))
|
||||
const contracts = [...new Set(all.flat().map(({ location }) => location.uri))].sort()
|
||||
return { contracts, ...(candidates.length > 1 && { ambiguous: candidates.sort() }) }
|
||||
}
|
||||
const [path] = candidates
|
||||
const paths = operations.map(operation => operation.path)
|
||||
const requests = await find(REQUESTS_INDEX, endpoint.method)
|
||||
return { requests: requests.filter(({ entry }) => requestOperation(paths, entry.path) === path) }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CallEdge, CallRange, CallSymbol, CallTreeNode } from './model'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildCallTrees } from './model'
|
||||
import { buildCallTrees, nextPageUrl } from './model'
|
||||
|
||||
function symbol(name: string, line = 0, extra: Partial<CallSymbol> = {}): CallSymbol {
|
||||
return {
|
||||
@@ -159,15 +159,76 @@ describe('调用边收成调用树', () => {
|
||||
expect(made.name).toBe('新建 OrderService')
|
||||
expect(made.detail).toBe('进入这个类')
|
||||
expect(made.where).toBe('billing/order.py:40')
|
||||
expect(made.jump).toEqual({ uri: 'file:///create_order', line: 39, character: 0 })
|
||||
expect(made.current).toBe(false)
|
||||
expect(made.kind).toBe('class')
|
||||
expect(made.children).toEqual([{
|
||||
name: 'OrderService',
|
||||
kind: 'class',
|
||||
where: 'billing/order.py:14',
|
||||
jump: { uri: 'file:///billing/order.py', line: 13, character: 0 },
|
||||
current: true,
|
||||
children: [],
|
||||
}])
|
||||
expect(JSON.stringify(trees)).not.toContain('方法')
|
||||
})
|
||||
})
|
||||
|
||||
describe('接口的调用树', () => {
|
||||
const route = symbol('get_default_agent', 78, { uri: 'file:///api/agent/routes.py', path: 'api/agent/routes.py' })
|
||||
|
||||
it('前端发请求的那一行单独成「请求」节点,点了跳到那一行;Next.js 页面入口第二行写页面网址', () => {
|
||||
const fetchDefault = symbol('fetchDefaultAgent', 9, { uri: 'file:///web/src/lib/agents.ts', path: 'web/src/lib/agents.ts' })
|
||||
const page = symbol('MyAgentsPage', 4, { uri: 'file:///web/src/app/(main)/my-agents/page.tsx', path: 'web/src/app/(main)/my-agents/page.tsx' })
|
||||
const sidebar = symbol('useSidebar', 3, { uri: 'file:///web/src/components/sidebar/useSidebar.ts', path: 'web/src/components/sidebar/useSidebar.ts' })
|
||||
const trees = buildCallTrees([
|
||||
{ ...edge(fetchDefault, route, 10, 20), request: 'GET /api/v1/agents/default' },
|
||||
edge(page, fetchDefault, 6),
|
||||
edge(sidebar, fetchDefault, 8),
|
||||
], route)
|
||||
expect(trees.map(tree => [tree.name, tree.root.detail])).toEqual([['MyAgentsPage', '页面 /my-agents'], ['useSidebar', '入口']])
|
||||
const request = trees[0].root.children[0].children[0]
|
||||
expect(request).toEqual({
|
||||
name: '请求 GET /api/v1/agents/default',
|
||||
kind: 'request',
|
||||
where: 'web/src/lib/agents.ts:11',
|
||||
jump: { uri: 'file:///web/src/lib/agents.ts', line: 10, character: 20 },
|
||||
current: false,
|
||||
children: [{
|
||||
name: 'get_default_agent',
|
||||
kind: 'function',
|
||||
where: 'api/agent/routes.py:79',
|
||||
jump: { uri: 'file:///api/agent/routes.py', line: 78, character: 0 },
|
||||
current: true,
|
||||
children: [],
|
||||
}],
|
||||
})
|
||||
expect(trees[0].hits).toBe(1)
|
||||
expect(trees[0].root.jump).toEqual({ uri: 'file:///web/src/app/(main)/my-agents/page.tsx', line: 4, character: 0 })
|
||||
})
|
||||
|
||||
it('在 Python 里直接调用路由函数的不加「请求」节点', () => {
|
||||
const job = symbol('nightly_job', 2, { path: 'jobs/nightly.py' })
|
||||
const trees = buildCallTrees([edge(job, route, 5)], route)
|
||||
expect(trees[0].root.children.map(node => node.name)).toEqual(['get_default_agent'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('按 Next.js 的约定算页面网址', () => {
|
||||
it('按 app 目录下的目录算:去掉路由分组和并行路由,动态段原样留', () => {
|
||||
expect(nextPageUrl('web/src/app/(main)/my-agents/page.tsx')).toBe('/my-agents')
|
||||
expect(nextPageUrl('app/page.tsx')).toBe('/')
|
||||
expect(nextPageUrl('client/src/app/(main)/billing/orders/[orderId]/page.tsx')).toBe('/billing/orders/[orderId]')
|
||||
expect(nextPageUrl('app/docs/[[...section]]/page.js')).toBe('/docs/[[...section]]')
|
||||
expect(nextPageUrl('app/dashboard/@team/settings/page.jsx')).toBe('/dashboard/settings')
|
||||
expect(nextPageUrl('app/(marketing)/page.ts')).toBe('/')
|
||||
})
|
||||
|
||||
it('不是 app 目录下的 page 文件就不是页面', () => {
|
||||
expect(nextPageUrl('app/layout.tsx')).toBeUndefined()
|
||||
expect(nextPageUrl('pages/index.tsx')).toBeUndefined()
|
||||
expect(nextPageUrl('src/apps/page.tsx')).toBeUndefined()
|
||||
expect(nextPageUrl('app/page.mdx')).toBeUndefined()
|
||||
expect(nextPageUrl('page.tsx')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -33,16 +33,28 @@ export interface CallEdge {
|
||||
caller: CallSymbol
|
||||
callee: CallSymbol
|
||||
at: CallRange
|
||||
/** 前端发请求调到接口的才有:方法和前端写的路径(`GET /api/v1/agents`),树上在调用方和接口之间多一个「请求」节点 */
|
||||
request?: string
|
||||
}
|
||||
|
||||
/** 点节点跳到哪:文件和位置,行和列从 0 起。 */
|
||||
export interface CallJump {
|
||||
uri: string
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
|
||||
/** 树上的一个节点。当前块可以在同一棵里出现多次,每次一个节点。 */
|
||||
export interface CallTreeNode {
|
||||
name: string
|
||||
/** 块种类;「请求」节点是 request */
|
||||
kind: string
|
||||
/** 第二行。根是「入口」,新建类是「进入这个类」,其余是符号自己的说明(没有就不写) */
|
||||
/** 第二行。根是「入口」(Next.js 页面是「页面 网址」),新建类是「进入这个类」,其余是符号自己的说明(没有就不写) */
|
||||
detail?: string
|
||||
/** 文件:行,行从 1 起 */
|
||||
where: string
|
||||
/** 符号是它的名字起点;「新建」「请求」节点是发生调用的那一处 */
|
||||
jump: CallJump
|
||||
current: boolean
|
||||
children: CallTreeNode[]
|
||||
/** 不在通向当前块的路径上的调用次数;没有就不画那一行 */
|
||||
@@ -76,10 +88,30 @@ function whereOf(symbol: CallSymbol): string {
|
||||
return `${symbol.path}:${symbol.line + 1}`
|
||||
}
|
||||
|
||||
/** 调用方里发生这次调用的那一处:「新建」「请求」节点写它、点了跳到它。 */
|
||||
function callSite(caller: CallSymbol, at: CallRange): Pick<CallTreeNode, 'where' | 'jump'> {
|
||||
return { where: `${caller.path}:${at.line + 1}`, jump: { uri: caller.uri, line: at.line, character: at.character } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Next.js 页面的网址:文件在某个 `app/` 目录下、名字是 `page.(tsx|ts|jsx|js)` 时按目录算,路由分组 `(xxx)`、并行路由 `@slot` 去掉,
|
||||
* 动态段 `[id]`、`[[...section]]` 原样留;`app/page.tsx` 是 `/`。不是页面文件时没有。
|
||||
* @param path `/` 分隔的路径,取第一层叫 app 的目录
|
||||
*/
|
||||
export function nextPageUrl(path: string): string | undefined {
|
||||
const segments = path.split('/')
|
||||
const app = segments.indexOf('app')
|
||||
if (app < 0 || !/^page\.[jt]sx?$/.test(segments.at(-1)!))
|
||||
return undefined
|
||||
const route = segments.slice(app + 1, -1).filter(segment => !/^\(.*\)$/.test(segment) && !segment.startsWith('@'))
|
||||
return `/${route.join('/')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 从目标往上收到根,再顺着走回目标。
|
||||
* 不通向目标的调用不展开,记在父节点的 otherCalls 上。
|
||||
* 选中的是类时,直接调用它的那一层写成「新建 类名」。
|
||||
* 选中的是类时,直接调用它的那一层写成「新建 类名」;前端发请求调到接口的那一下写成「请求 方法 路径」。
|
||||
* 根的第二行是「入口」,Next.js 页面写成「页面 网址」。
|
||||
*/
|
||||
export function buildCallTrees(edges: readonly CallEdge[], target: CallSymbol, options?: { classTarget?: boolean }): CallTree[] {
|
||||
const symbols = new Map<string, CallSymbol>()
|
||||
@@ -96,7 +128,7 @@ export function buildCallTrees(edges: readonly CallEdge[], target: CallSymbol, o
|
||||
const unique: CallEdge[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const edge of edges) {
|
||||
const normalized: CallEdge = { caller: remember(edge.caller), callee: remember(edge.callee), at: edge.at }
|
||||
const normalized: CallEdge = { ...edge, caller: remember(edge.caller), callee: remember(edge.callee) }
|
||||
const key = edgeKey(normalized)
|
||||
if (seen.has(key))
|
||||
continue
|
||||
@@ -171,17 +203,7 @@ export function buildCallTrees(edges: readonly CallEdge[], target: CallSymbol, o
|
||||
}
|
||||
if (calleeDepth >= nodeDepth)
|
||||
continue
|
||||
const child = classTarget && calleeKey === targetKey
|
||||
? {
|
||||
name: `新建 ${targetSymbol.name}`,
|
||||
kind: 'class',
|
||||
detail: '进入这个类',
|
||||
where: `${node.path}:${edge.at.line + 1}`,
|
||||
current: false,
|
||||
children: [render(edge.callee, nextPath)],
|
||||
}
|
||||
: render(edge.callee, nextPath)
|
||||
children.push(child)
|
||||
children.push(calleeKey === targetKey ? targetChild(node, edge, nextPath) : render(edge.callee, nextPath))
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -189,15 +211,27 @@ export function buildCallTrees(edges: readonly CallEdge[], target: CallSymbol, o
|
||||
kind: node.kind,
|
||||
...(node.detail && { detail: node.detail }),
|
||||
where: whereOf(node),
|
||||
jump: { uri: node.uri, line: node.line, character: node.character },
|
||||
current,
|
||||
children,
|
||||
...(otherCalls > 0 && { otherCalls }),
|
||||
}
|
||||
}
|
||||
|
||||
/** 直接调到目标的那一下:前端发的请求、新建类,各在中间多一个节点写调用的那一处;其余直接是目标。 */
|
||||
function targetChild(caller: CallSymbol, edge: CallEdge, path: ReadonlySet<string>): CallTreeNode {
|
||||
const target = render(edge.callee, path)
|
||||
if (edge.request)
|
||||
return { name: `请求 ${edge.request}`, kind: 'request', ...callSite(caller, edge.at), current: false, children: [target] }
|
||||
if (classTarget)
|
||||
return { name: `新建 ${targetSymbol.name}`, kind: 'class', detail: '进入这个类', ...callSite(caller, edge.at), current: false, children: [target] }
|
||||
return target
|
||||
}
|
||||
|
||||
return roots.map((root) => {
|
||||
const rendered = render(root, new Set())
|
||||
rendered.detail = '入口'
|
||||
const page = nextPageUrl(root.path)
|
||||
rendered.detail = page === undefined ? '入口' : `页面 ${page}`
|
||||
return { id: symbolKey(root), name: root.name, hits: countCurrent(rendered), root: rendered }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { WorkspaceIndexOf } from '../workspaceIndex'
|
||||
import type { OperationEntry } from './openapi'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { INDEX_GRAMMARS, MemoryIndexHost } from '../testSupport'
|
||||
import { WorkspaceIndexes } from '../workspaceIndex'
|
||||
import { OPENAPI_JSON_INDEX, OPENAPI_TYPES_INDEX } from './openapi'
|
||||
|
||||
/** openapi-typescript 生成的样子:有 operationId 的引用 operations,没有的直接写参数。 */
|
||||
const SCHEMA = [
|
||||
'/**',
|
||||
' * This file was auto-generated by openapi-typescript.',
|
||||
' */',
|
||||
'',
|
||||
'export interface paths {',
|
||||
' "/api/v1/agents": {',
|
||||
' parameters: {',
|
||||
' query?: never;',
|
||||
' path?: never;',
|
||||
' };',
|
||||
' /** List Agents */',
|
||||
' get: operations["list_agents_api_v1_agents_get"];',
|
||||
' put?: never;',
|
||||
' post: operations["create_agent_api_v1_agents_post"];',
|
||||
' delete?: never;',
|
||||
' };',
|
||||
' "/api/v1/agents/default": {',
|
||||
' get: operations["get_default_agent_api_v1_agents_default_get"];',
|
||||
' };',
|
||||
' "/api/v1/agents/{agent_id}": {',
|
||||
' get: {',
|
||||
' parameters: { path: { agent_id: string } };',
|
||||
' };',
|
||||
' };',
|
||||
'}',
|
||||
'export interface operations {',
|
||||
' list_agents_api_v1_agents_get: {};',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const OPENAPI = JSON.stringify({
|
||||
openapi: '3.1.0',
|
||||
paths: {
|
||||
'/api/v1/commerce/orders/{order_id}': {
|
||||
parameters: [],
|
||||
get: { operationId: 'get_order_api_v1_commerce_orders__order_id__get' },
|
||||
delete: { summary: '没有 operationId' },
|
||||
},
|
||||
},
|
||||
}, null, 2)
|
||||
|
||||
function rows(found: { location: { uri: string, start: { line: number } }, entry: OperationEntry }[]): string[] {
|
||||
return found.map(({ location, entry }) => `${location.uri.replace(`${MemoryIndexHost.FOLDER}/`, '')}:${location.start.line} ${entry.key} ${entry.path} ${entry.operationId ?? '-'}`)
|
||||
}
|
||||
|
||||
async function lookup(files: Record<string, string>, index: WorkspaceIndexOf<OperationEntry>, method: string): Promise<string[]> {
|
||||
return rows(await new WorkspaceIndexes(new MemoryIndexHost(files, INDEX_GRAMMARS)).find(index, method))
|
||||
}
|
||||
|
||||
describe('接口契约文件', () => {
|
||||
it('openapi-typescript 生成的 .d.ts:paths 里每条路径的每个方法一项,写成 never 的不算,operationId 取 operations 引用里的', async () => {
|
||||
const files = { 'web/src/lib/api/schema.d.ts': SCHEMA, 'web/src/types.d.ts': 'export interface Agent { id: string }\n' }
|
||||
expect(await lookup(files, OPENAPI_TYPES_INDEX, 'GET')).toEqual([
|
||||
'web/src/lib/api/schema.d.ts:11 GET /api/v1/agents list_agents_api_v1_agents_get',
|
||||
'web/src/lib/api/schema.d.ts:17 GET /api/v1/agents/default get_default_agent_api_v1_agents_default_get',
|
||||
'web/src/lib/api/schema.d.ts:20 GET /api/v1/agents/{agent_id} -',
|
||||
])
|
||||
expect(await lookup(files, OPENAPI_TYPES_INDEX, 'POST')).toEqual(['web/src/lib/api/schema.d.ts:13 POST /api/v1/agents create_agent_api_v1_agents_post'])
|
||||
expect(await lookup(files, OPENAPI_TYPES_INDEX, 'PUT')).toEqual([])
|
||||
})
|
||||
|
||||
it('openapi*.json:paths → 方法 → operationId,路径上的 parameters 不是方法', async () => {
|
||||
const files = { 'docs/openapi.json': OPENAPI, 'package.json': '{"paths": {"/x": {"get": {}}}}' }
|
||||
expect(await lookup(files, OPENAPI_JSON_INDEX, 'GET')).toEqual(['docs/openapi.json:5 GET /api/v1/commerce/orders/{order_id} get_order_api_v1_commerce_orders__order_id__get'])
|
||||
expect(await lookup(files, OPENAPI_JSON_INDEX, 'DELETE')).toEqual(['docs/openapi.json:8 DELETE /api/v1/commerce/orders/{order_id} -'])
|
||||
})
|
||||
|
||||
it('契约文件重新生成后重读', async () => {
|
||||
const host = new MemoryIndexHost({ 'web/schema.d.ts': SCHEMA }, INDEX_GRAMMARS)
|
||||
const indexes = new WorkspaceIndexes(host)
|
||||
expect(rows(await indexes.find(OPENAPI_TYPES_INDEX, 'PATCH'))).toEqual([])
|
||||
host.emit('change', 'web/schema.d.ts', 'export interface paths {\n "/api/v1/agents/{agent_id}": {\n patch: operations["update_agent_api_v1_agents__agent_id__patch"];\n };\n}\n')
|
||||
expect(rows(await indexes.find(OPENAPI_TYPES_INDEX, 'PATCH'))).toEqual(['web/schema.d.ts:2 PATCH /api/v1/agents/{agent_id} update_agent_api_v1_agents__agent_id__patch'])
|
||||
expect(rows(await indexes.find(OPENAPI_TYPES_INDEX, 'GET'))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 接口契约文件(OpenAPI)的工作区索引:openapi-typescript 生成的 `.d.ts`(`export interface paths`)和 `openapi*.json`。
|
||||
* 每个接口一项,键是大写的方法;几份文件合在一起查,文件重新生成后索引跟着重看那一份。
|
||||
*/
|
||||
|
||||
import type { IndexEntry, Node } from '../contract'
|
||||
import type { WorkspaceIndexOf } from '../workspaceIndex'
|
||||
import { named, stringContent } from '../rules/reactTree'
|
||||
|
||||
/** 契约里的 HTTP 方法(小写,同 OpenAPI 的写法)。 */
|
||||
const METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'])
|
||||
|
||||
/** 契约里的一个接口:键是大写的方法,名字是写方法的那个键(跳过去就是这一条)。 */
|
||||
export interface OperationEntry extends IndexEntry {
|
||||
/** 完整路径,`{参数}` 照写 */
|
||||
path: string
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
/** method 是契约里写的小写方法,name 是写它的那个键。 */
|
||||
function operation(method: string, name: Node, path: string, operationId: string | undefined): OperationEntry {
|
||||
return {
|
||||
key: method.toUpperCase(),
|
||||
name: { startIndex: name.startIndex, endIndex: name.endIndex },
|
||||
path,
|
||||
...(operationId && { operationId }),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── openapi-typescript 生成的 .d.ts ─────────────────────────────────────────
|
||||
|
||||
/** 接口体、对象类型里的各个属性:名字和冒号后面的类型。 */
|
||||
function members(body: Node | null | undefined): { name: Node, type?: Node }[] {
|
||||
return named(body).flatMap((member) => {
|
||||
const name = member.type === 'property_signature' ? member.childForFieldName('name') : null
|
||||
return name ? [{ name, type: named(member.childForFieldName('type'))[0] }] : []
|
||||
})
|
||||
}
|
||||
|
||||
/** `operations["list_agents_api_v1_agents_get"]` 里的 operationId;直接写出参数的接口没有。 */
|
||||
function operationIdOf(type: Node): string | undefined {
|
||||
const [object, index] = type.type === 'lookup_type' ? named(type) : []
|
||||
if (object?.text !== 'operations' || index?.type !== 'literal_type')
|
||||
return undefined
|
||||
return stringContent(named(index)[0])
|
||||
}
|
||||
|
||||
/** `paths` 里每条路径的每个方法;写成 `never` 的是没有这个方法。 */
|
||||
function typeOperations(root: Node): OperationEntry[] {
|
||||
const interfaces = named(root).flatMap((statement) => {
|
||||
const declaration = statement.type === 'export_statement' ? statement.childForFieldName('declaration') : statement
|
||||
return declaration?.type === 'interface_declaration' && declaration.childForFieldName('name')?.text === 'paths' ? [declaration] : []
|
||||
})
|
||||
return interfaces.flatMap(paths => members(paths.childForFieldName('body')).flatMap(({ name, type }) => {
|
||||
const path = stringContent(name)
|
||||
if (path === undefined || type?.type !== 'object_type')
|
||||
return []
|
||||
return members(type).flatMap(({ name: method, type: value }) => {
|
||||
if (method.type !== 'property_identifier' || !METHODS.has(method.text) || !value || value.text === 'never')
|
||||
return []
|
||||
return [operation(method.text, method, path, operationIdOf(value))]
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/** openapi-typescript 生成的 `.d.ts`。 */
|
||||
export const OPENAPI_TYPES_INDEX: WorkspaceIndexOf<OperationEntry> = {
|
||||
id: 'callTree.openapiTypes',
|
||||
languageId: 'typescript',
|
||||
include: '**/*.d.ts',
|
||||
mentions: source => /\binterface\s+paths\b/.test(source),
|
||||
entries: root => typeOperations(root),
|
||||
}
|
||||
|
||||
// ─── openapi*.json ───────────────────────────────────────────────────────────
|
||||
|
||||
function jsonString(node: Node | null | undefined): string | undefined {
|
||||
if (node?.type !== 'string')
|
||||
return undefined
|
||||
try {
|
||||
const value: unknown = JSON.parse(node.text)
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON 对象的各个键值。 */
|
||||
function pairs(object: Node | null | undefined): { key: string, keyNode: Node, value?: Node }[] {
|
||||
if (object?.type !== 'object')
|
||||
return []
|
||||
return named(object).flatMap((pair) => {
|
||||
const keyNode = pair.type === 'pair' ? pair.childForFieldName('key') : null
|
||||
const key = jsonString(keyNode)
|
||||
return keyNode && key !== undefined ? [{ key, keyNode, value: pair.childForFieldName('value') ?? undefined }] : []
|
||||
})
|
||||
}
|
||||
|
||||
/** paths → 路径 → 方法 → operationId;路径上和方法并列的 parameters、summary 这些不是方法。 */
|
||||
function jsonOperations(root: Node): OperationEntry[] {
|
||||
const paths = pairs(named(root).find(node => node.type === 'object')).find(({ key }) => key === 'paths')?.value
|
||||
return pairs(paths).flatMap(({ key: path, value: item }) => pairs(item).flatMap(({ key: method, keyNode, value }) => {
|
||||
if (!METHODS.has(method) || value?.type !== 'object')
|
||||
return []
|
||||
return [operation(method, keyNode, path, jsonString(pairs(value).find(({ key }) => key === 'operationId')?.value))]
|
||||
}))
|
||||
}
|
||||
|
||||
/** 导出的 `openapi*.json`(JSON 用索引专用的语法包解析,见 runtime.ts 的 indexGrammar)。 */
|
||||
export const OPENAPI_JSON_INDEX: WorkspaceIndexOf<OperationEntry> = {
|
||||
id: 'callTree.openapiJson',
|
||||
languageId: 'json',
|
||||
include: '**/openapi*.json',
|
||||
mentions: source => source.includes('"paths"'),
|
||||
entries: root => jsonOperations(root),
|
||||
}
|
||||
|
||||
/** 两种契约文件的索引,查的时候合在一起。 */
|
||||
export const CONTRACT_INDEXES = [OPENAPI_TYPES_INDEX, OPENAPI_JSON_INDEX]
|
||||
|
||||
/** 契约里的全部方法(大写),列出找到了哪几份契约文件时挨个查。 */
|
||||
export const CONTRACT_METHODS = [...METHODS].map(method => method.toUpperCase())
|
||||
@@ -1,37 +1,60 @@
|
||||
/**
|
||||
* 问编辑器的调用关系命令,收成调用边。
|
||||
* 问编辑器的调用关系命令,收成调用边;接口卡片另外并上前端发这个请求的地方。
|
||||
* 语言扩展不提供、或者这一轮已经作废,就当没有。
|
||||
*/
|
||||
|
||||
import type { CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, TextDocument } from 'vscode'
|
||||
import type { RuleLocation } from '../contract'
|
||||
import type { IndexedEntry } from '../workspaceIndex'
|
||||
import type { IndexFind } from './endpoint'
|
||||
import type { CallEdge, CallRange, CallSymbol } from './model'
|
||||
import { commands, SymbolKind, workspace } from 'vscode'
|
||||
import type { RequestEntry } from './requests'
|
||||
import type { CallTreeBlock, LoadedCalls } from './session'
|
||||
import type { IncomingHit } from './walk'
|
||||
import * as path from 'node:path'
|
||||
import { commands, Position, SymbolKind, Uri, workspace } from 'vscode'
|
||||
import { endpointCalls } from './endpoint'
|
||||
import { symbolKey } from './model'
|
||||
import { collectCallEdges } from './walk'
|
||||
|
||||
/**
|
||||
* 问一块的调用关系。接口卡片(callTree.endpoint)先到契约里找前端发这个请求的地方(见 endpoint.ts),并进起点的调用者;
|
||||
* 契约里对不上时带上 contractMiss,Python 里的调用者照常问。
|
||||
*/
|
||||
export async function loadBlockCalls(document: TextDocument, block: CallTreeBlock, find: IndexFind, cancelled: () => boolean): Promise<LoadedCalls | undefined> {
|
||||
const offset = block.defines?.startIndex
|
||||
const endpoint = block.callTree?.endpoint
|
||||
if (offset === undefined)
|
||||
return undefined
|
||||
if (!endpoint)
|
||||
return loadCallEdges(document, offset, cancelled)
|
||||
const found = await endpointCalls(find, endpoint)
|
||||
if (cancelled())
|
||||
return undefined
|
||||
if ('requests' in found)
|
||||
return loadCallEdges(document, offset, cancelled, found.requests)
|
||||
const loaded = await loadCallEdges(document, offset, cancelled, [])
|
||||
const files = found.contracts.map(uri => workspace.asRelativePath(Uri.parse(uri)))
|
||||
return loaded && { ...loaded, contractMiss: { files, ...(found.ambiguous && { ambiguous: found.ambiguous }) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 offset 这个名字起点上问调用关系。
|
||||
* 先准备符号,再往上问谁调用了它;路径上的调用者再问它还调用了谁,用来收「另外 N 处」。
|
||||
* 给了 requests(接口卡片,可以是空的)时,它们并进起点的调用者,从它们往上照常问编辑器;
|
||||
* 这时编辑器认不出起点也照样收前端这一路,起点按名字起点自己写一个。
|
||||
*/
|
||||
export async function loadCallEdges(
|
||||
document: TextDocument,
|
||||
offset: number,
|
||||
cancelled: () => boolean,
|
||||
requests?: readonly IndexedEntry<RequestEntry>[],
|
||||
): Promise<{ symbol: CallSymbol, edges: CallEdge[] } | undefined> {
|
||||
if (cancelled())
|
||||
return undefined
|
||||
const position = document.positionAt(offset)
|
||||
let prepared: CallHierarchyItem | CallHierarchyItem[] | null | undefined
|
||||
try {
|
||||
prepared = await commands.executeCommand('vscode.prepareCallHierarchy', document.uri, position)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
if (cancelled() || prepared == null)
|
||||
return undefined
|
||||
const items = (Array.isArray(prepared) ? prepared : [prepared]).filter(item => item)
|
||||
if (items.length === 0)
|
||||
const items = await prepare(document.uri, position)
|
||||
if (cancelled() || (items.length === 0 && !requests))
|
||||
return undefined
|
||||
|
||||
const vscodeItems = new Map<string, CallHierarchyItem>()
|
||||
@@ -50,15 +73,27 @@ export async function loadCallEdges(
|
||||
seen.add(key)
|
||||
starts.push(symbol)
|
||||
}
|
||||
const symbol = starts.find(item => item.line === position.line && item.character === position.character) ?? starts[0]!
|
||||
const edges: CallEdge[] = []
|
||||
for (const start of starts) {
|
||||
const symbol = starts.find(item => item.line === position.line && item.character === position.character) ?? starts[0] ?? symbolAt(document, position)
|
||||
const sent: IncomingHit[] = []
|
||||
for (const { location, entry } of requests ?? []) {
|
||||
if (cancelled())
|
||||
return undefined
|
||||
edges.push(...await collectCallEdges(start, incoming, outgoing, cancelled))
|
||||
sent.push({ from: await requestCaller(location, entry), ranges: [entry.at], request: `${entry.key} ${entry.path}` })
|
||||
}
|
||||
const edges: CallEdge[] = []
|
||||
for (const start of starts.length > 0 ? starts : [symbol]) {
|
||||
if (cancelled())
|
||||
return undefined
|
||||
edges.push(...await collectCallEdges(start, withRequests, outgoing, cancelled))
|
||||
}
|
||||
return cancelled() ? undefined : { symbol, edges }
|
||||
|
||||
/** 起点的调用者里并上前端发请求的地方;别的节点照常问编辑器。 */
|
||||
async function withRequests(node: CallSymbol): Promise<IncomingHit[]> {
|
||||
const calls = await incoming(node)
|
||||
return symbolKey(node) === symbolKey(symbol) ? [...calls, ...sent] : calls
|
||||
}
|
||||
|
||||
async function incoming(node: CallSymbol) {
|
||||
return hits(node, 'vscode.provideIncomingCalls', (call: CallHierarchyIncomingCall) => ({
|
||||
from: remember(call.from),
|
||||
@@ -73,6 +108,24 @@ export async function loadCallEdges(
|
||||
}))
|
||||
}
|
||||
|
||||
/** 发请求的地方所在的函数:编辑器认得它就用编辑器的(往上还能接着问),不在有名字的函数里、编辑器不认得时照索引写一个。 */
|
||||
async function requestCaller(location: RuleLocation, entry: RequestEntry): Promise<CallSymbol> {
|
||||
const uri = Uri.parse(location.uri)
|
||||
const at = new Position(location.start.line, location.start.character)
|
||||
const found = entry.caller ? await prepare(uri, at) : []
|
||||
const item = found.find(candidate => candidate.selectionRange.start.isEqual(at)) ?? found[0]
|
||||
if (item)
|
||||
return remember(item)
|
||||
return {
|
||||
uri: location.uri,
|
||||
line: at.line,
|
||||
character: at.character,
|
||||
name: entry.caller ?? path.posix.basename(uri.path),
|
||||
kind: entry.caller ? 'function' : 'module',
|
||||
path: workspace.asRelativePath(uri),
|
||||
}
|
||||
}
|
||||
|
||||
async function hits<T, R>(node: CallSymbol, command: string, map: (call: T) => R): Promise<R[]> {
|
||||
const item = vscodeItems.get(symbolKey(node))
|
||||
if (!item || cancelled())
|
||||
@@ -87,6 +140,30 @@ export async function loadCallEdges(
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑器在这个位置准备出的调用关系符号;语言扩展不提供时是空的。 */
|
||||
async function prepare(uri: Uri, position: Position): Promise<CallHierarchyItem[]> {
|
||||
try {
|
||||
const prepared = await commands.executeCommand<CallHierarchyItem | CallHierarchyItem[] | null | undefined>('vscode.prepareCallHierarchy', uri, position)
|
||||
return prepared == null ? [] : (Array.isArray(prepared) ? prepared : [prepared]).filter(item => item)
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑器认不出起点时按名字起点自己写的符号。 */
|
||||
function symbolAt(document: TextDocument, position: Position): CallSymbol {
|
||||
const word = document.getWordRangeAtPosition(position)
|
||||
return {
|
||||
uri: document.uri.toString(),
|
||||
line: position.line,
|
||||
character: position.character,
|
||||
name: word ? document.getText(word) : '',
|
||||
kind: 'function',
|
||||
path: workspace.asRelativePath(document.uri),
|
||||
}
|
||||
}
|
||||
|
||||
function toSymbol(item: CallHierarchyItem): CallSymbol {
|
||||
const start = item.selectionRange.start
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { INDEX_GRAMMARS, MemoryIndexHost } from '../testSupport'
|
||||
import { WorkspaceIndexes } from '../workspaceIndex'
|
||||
import { REQUESTS_INDEX } from './requests'
|
||||
|
||||
const STORE = [
|
||||
'export function useAgentStore() {',
|
||||
' let later = () => fetch("/api/v1/agents/later", { method: "PATCH" })',
|
||||
' return later',
|
||||
'}',
|
||||
'',
|
||||
'class AgentApi {',
|
||||
` rename = async (id: string) => { await this.http.PATCH(\`/api/v1/agents/\${id}\`) }`,
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const PAGE = [
|
||||
'import { api } from "@/lib/api"',
|
||||
'',
|
||||
'const MyAgentsPage = async () => {',
|
||||
' const { data } = await api.GET("/api/v1/agents", { params: { query: {} } })',
|
||||
' return <div>{data?.length}</div>',
|
||||
'}',
|
||||
'',
|
||||
'export default MyAgentsPage',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const AGENTS = [
|
||||
'export async function fetchDefaultAgent() {',
|
||||
' const res = await fetch("/api/v1/agents/default", { credentials: "include" })',
|
||||
' return res.json()',
|
||||
'}',
|
||||
'',
|
||||
'export const createAgent = async (body: unknown) => {',
|
||||
` await fetch(\`/api/v1/agents?force=\${1}\`, { method: "POST", body: JSON.stringify(body) })`,
|
||||
'}',
|
||||
'',
|
||||
'export function useRemoveAgent(id: string) {',
|
||||
` return useMutation({ mutationFn: () => api.DELETE(\`/api/v1/agents/\${id}\`) })`,
|
||||
'}',
|
||||
'',
|
||||
'export async function guessed(method: string, init: RequestInit) {',
|
||||
' await fetch("/api/v1/agents", { method })',
|
||||
' await fetch("/api/v1/agents", init)',
|
||||
' await fetch("/api/v1/agents", { ...init })',
|
||||
' await fetch(url)',
|
||||
' await axios.get("/api/v1/agents")',
|
||||
'}',
|
||||
'',
|
||||
'class Client {',
|
||||
` load(orderId: string) { /* 中文 */ return this.http.GET(\`/api/v1/commerce/orders/\${orderId}\` as never) }`,
|
||||
'}',
|
||||
'',
|
||||
'const pending = fetch(\'/api/v1/health#top\')',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
async function requests(files: Record<string, string>, method: string): Promise<string[]> {
|
||||
const found = await new WorkspaceIndexes(new MemoryIndexHost(files, INDEX_GRAMMARS)).find(REQUESTS_INDEX, method)
|
||||
return found.map(({ location, entry }) => {
|
||||
const file = location.uri.replace(`${MemoryIndexHost.FOLDER}/`, '')
|
||||
const call = `${entry.at.line}:${entry.at.character}`
|
||||
return `${file} ${entry.caller ?? '(顶层)'}@${location.start.line}:${location.start.character} ${entry.key} ${entry.path} ${call}`
|
||||
})
|
||||
}
|
||||
|
||||
describe('前端发请求的地方', () => {
|
||||
const files = { 'web/src/app/(main)/my-agents/page.tsx': PAGE, 'web/src/lib/agents.ts': AGENTS }
|
||||
|
||||
it('认 X.GET 这类成员名是大写 HTTP 方法的调用,名字归最内层有名字的函数(箭头函数取赋值的名字)', async () => {
|
||||
expect(await requests(files, 'GET')).toEqual([
|
||||
'web/src/app/(main)/my-agents/page.tsx MyAgentsPage@2:6 GET /api/v1/agents 3:25',
|
||||
'web/src/lib/agents.ts fetchDefaultAgent@0:22 GET /api/v1/agents/default 1:20',
|
||||
`web/src/lib/agents.ts load@22:2 GET /api/v1/commerce/orders/\${orderId} 22:42`,
|
||||
'web/src/lib/agents.ts (顶层)@25:16 GET /api/v1/health 25:16',
|
||||
])
|
||||
})
|
||||
|
||||
it('fetch 的方法看 method 字面量,没写是 GET;method 不是字面量、第二个参数不是对象字面量、带展开的都不认;查询串去掉', async () => {
|
||||
expect(await requests(files, 'POST')).toEqual(['web/src/lib/agents.ts createAgent@5:13 POST /api/v1/agents 6:8'])
|
||||
})
|
||||
|
||||
it('赋给 let 的箭头函数不算有名字,归到外层;赋给类字段的取字段名', async () => {
|
||||
expect(await requests({ 'web/src/lib/store.ts': STORE }, 'PATCH')).toEqual([
|
||||
'web/src/lib/store.ts useAgentStore@0:16 PATCH /api/v1/agents/later 1:20',
|
||||
`web/src/lib/store.ts rename@6:2 PATCH /api/v1/agents/\${id} 6:41`,
|
||||
])
|
||||
})
|
||||
|
||||
it('匿名回调里发的请求归到外层有名字的函数', async () => {
|
||||
expect(await requests(files, 'DELETE')).toEqual([`web/src/lib/agents.ts useRemoveAgent@9:16 DELETE /api/v1/agents/\${id} 10:41`])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 前端发 HTTP 请求的地方的工作区索引:openapi-fetch 的 `X.GET("/path", ...)` 这类(成员名是大写的 HTTP 方法),
|
||||
* 和 `fetch("/path", { method: "POST" })`。路径要写成字符串字面量或模板字符串;接口的调用树拿它找前端调用了哪个接口(见 endpoint.ts)。
|
||||
*/
|
||||
|
||||
import type { IndexEntry, Node } from '../contract'
|
||||
import type { WorkspaceIndexOf } from '../workspaceIndex'
|
||||
import type { CallRange } from './model'
|
||||
import { argumentsOf, isFunction, named, sameNode, stringContent, unwrap } from '../rules/reactTree'
|
||||
|
||||
/** openapi-fetch 客户端上发请求的方法名,也是 fetch 的 method 认得的值。 */
|
||||
const METHODS = new Set(['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE'])
|
||||
|
||||
/**
|
||||
* 前端发的一个请求:键是大写的方法。名字是发请求的地方所在的最内层有名字的函数的名字(编辑器从它往上问调用者);
|
||||
* 不在有名字的函数里时,名字是被调用的 fetch / GET 本身。
|
||||
*/
|
||||
export interface RequestEntry extends IndexEntry {
|
||||
/** 写的路径,模板里的 `${...}` 照写,`?`、`#` 后面的去掉 */
|
||||
path: string
|
||||
/** 发请求那个调用的范围,行和列从 0 起 */
|
||||
at: CallRange
|
||||
/** 所在的有名字的函数叫什么;不在有名字的函数里时没有 */
|
||||
caller?: string
|
||||
}
|
||||
|
||||
/** 第一个参数写的路径:字符串字面量或模板字符串(`${...}` 照写),去掉 `?`、`#` 后面的;别的写法没有。 */
|
||||
function pathOf(node: Node | undefined): string | undefined {
|
||||
if (node?.type === 'string')
|
||||
return stringContent(node)?.split(/[?#]/)[0]
|
||||
if (node?.type !== 'template_string')
|
||||
return undefined
|
||||
let path = ''
|
||||
for (const part of named(node)) {
|
||||
const end = part.type === 'template_substitution' ? -1 : part.text.search(/[?#]/)
|
||||
if (end >= 0)
|
||||
return path + part.text.slice(0, end)
|
||||
path += part.text
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/** 对象字面量的键名:`method`、`"method"`。 */
|
||||
function keyName(key: Node | null): string | undefined {
|
||||
return key?.type === 'property_identifier' ? key.text : stringContent(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch 的方法:没有第二个参数、对象里没写 method 是 GET,method 是字面量就照它;
|
||||
* method 不是字面量、第二个参数不是对象字面量、对象里带展开(可能藏着 method)都不认。
|
||||
*/
|
||||
function fetchMethod(init: Node | undefined): string | undefined {
|
||||
if (!init)
|
||||
return 'GET'
|
||||
if (init.type !== 'object')
|
||||
return undefined
|
||||
const members = named(init)
|
||||
const written = members.find(member => member.type === 'pair' ? keyName(member.childForFieldName('key')) === 'method' : member.type === 'shorthand_property_identifier' && member.text === 'method')
|
||||
if (!written)
|
||||
return members.some(member => member.type === 'spread_element') ? undefined : 'GET'
|
||||
const method = written.type === 'pair' ? stringContent(unwrap(written.childForFieldName('value')))?.toUpperCase() : undefined
|
||||
return method && METHODS.has(method) ? method : undefined
|
||||
}
|
||||
|
||||
/** 一个调用是不是发请求:`X.GET(路径)` 这类看成员名,`fetch(路径, 选项)` 看选项里的 method。 */
|
||||
function requestOf(call: Node): { method: string, path: string, callee: Node } | undefined {
|
||||
const callee = unwrap(call.childForFieldName('function'))
|
||||
const [first, init] = argumentsOf(call)
|
||||
const path = pathOf(unwrap(first))
|
||||
if (!callee || path === undefined)
|
||||
return undefined
|
||||
if (callee.type === 'member_expression') {
|
||||
const property = callee.childForFieldName('property')
|
||||
return property && METHODS.has(property.text) ? { method: property.text, path, callee: property } : undefined
|
||||
}
|
||||
const method = callee.type === 'identifier' && callee.text === 'fetch' ? fetchMethod(unwrap(init)) : undefined
|
||||
return method ? { method, path, callee } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 函数自己的名字,和编辑器的调用关系认的一样:函数声明、方法写的;箭头函数、函数表达式取赋给的 const 变量名、类字段名。
|
||||
* 匿名的、赋给 let / var 的没有。
|
||||
*/
|
||||
function functionName(fn: Node): Node | undefined {
|
||||
if (fn.type === 'function_declaration' || fn.type === 'generator_function_declaration' || fn.type === 'method_definition')
|
||||
return fn.childForFieldName('name') ?? undefined
|
||||
const holder = fn.parent
|
||||
if (!holder || !sameNode(holder.childForFieldName('value'), fn))
|
||||
return undefined
|
||||
const name = holder.childForFieldName('name')
|
||||
if (holder.type === 'variable_declarator' && holder.parent?.childForFieldName('kind')?.text === 'const')
|
||||
return name?.type === 'identifier' ? name : undefined
|
||||
return holder.type === 'public_field_definition' && name?.type === 'property_identifier' ? name : undefined
|
||||
}
|
||||
|
||||
/** node 所在的最内层有名字的函数的名字;匿名回调往外找,不在有名字的函数里时没有。 */
|
||||
function callerName(node: Node): Node | undefined {
|
||||
for (let current = node.parent; current; current = current.parent) {
|
||||
const name = isFunction(current) ? functionName(current) : undefined
|
||||
if (name)
|
||||
return name
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function requestsIn(root: Node): RequestEntry[] {
|
||||
return root.descendantsOfType('call_expression').flatMap((call) => {
|
||||
const request = call && requestOf(call)
|
||||
if (!request)
|
||||
return []
|
||||
const caller = callerName(call)
|
||||
const name = caller ?? request.callee
|
||||
return [{
|
||||
key: request.method,
|
||||
name: { startIndex: name.startIndex, endIndex: name.endIndex },
|
||||
path: request.path,
|
||||
at: { line: call.startPosition.row, character: call.startPosition.column, endLine: call.endPosition.row, endCharacter: call.endPosition.column },
|
||||
...(caller && { caller: caller.text }),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
/** 前端源码里发请求的地方(TSX 的语法包也读得了 .ts、.js)。 */
|
||||
export const REQUESTS_INDEX: WorkspaceIndexOf<RequestEntry> = {
|
||||
id: 'callTree.requests',
|
||||
languageId: 'typescriptreact',
|
||||
include: '**/*.{ts,tsx,js,jsx}',
|
||||
mentions: source => /\bfetch\s*\(|\.(?:GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH|TRACE)\s*\(/.test(source),
|
||||
entries: root => requestsIn(root),
|
||||
}
|
||||
@@ -7,12 +7,19 @@ function symbol(name: string): CallSymbol {
|
||||
return { uri: `file:///${name}`, line: 0, character: 0, name, kind: 'function', path: `${name}.py` }
|
||||
}
|
||||
|
||||
/** 函数、类块:宿主标了能看调用树(callTree),别的种类没标。 */
|
||||
function block(anchor: string, kind: string, defines?: number, children: CallTreeBlock[] = []): CallTreeBlock {
|
||||
return { anchor, kind, ...(defines !== undefined && { defines: { startIndex: defines } }), children }
|
||||
return {
|
||||
anchor,
|
||||
kind,
|
||||
...(defines !== undefined && { defines: { startIndex: defines } }),
|
||||
...((kind === 'function' || kind === 'class') && { callTree: {} }),
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
describe('调用树棵数在文档发出去之后算', () => {
|
||||
it('只给有名字起点的函数和类计数,没有调用者的不出现,嵌在里面的也算', async () => {
|
||||
it('只给标了能看调用树、有名字起点的块计数,没有调用者的不出现,嵌在里面的也算', async () => {
|
||||
const mask = symbol('mask_phone')
|
||||
const caller = symbol('get_profile')
|
||||
const edges: CallEdge[] = [{
|
||||
@@ -28,11 +35,12 @@ describe('调用树棵数在文档发出去之后算', () => {
|
||||
block('stmt', 'statement', 4),
|
||||
block('cls', 'class', 20, [block('inner', 'function', 30)]),
|
||||
block('alone', 'function', 40),
|
||||
{ anchor: 'bare', kind: 'function', defines: { startIndex: 10 } },
|
||||
],
|
||||
load: async (offset) => {
|
||||
if (offset === 10)
|
||||
load: async ({ defines }) => {
|
||||
if (defines?.startIndex === 10)
|
||||
return { symbol: mask, edges }
|
||||
if (offset === 30)
|
||||
if (defines?.startIndex === 30)
|
||||
return { symbol: symbol('inner'), edges: [{ caller, callee: symbol('inner'), at: edges[0].at }] }
|
||||
return { symbol: symbol('nobody'), edges: [] }
|
||||
},
|
||||
@@ -188,6 +196,63 @@ describe('调用树棵数在文档发出去之后算', () => {
|
||||
expect(posted).toEqual([])
|
||||
session.dispose()
|
||||
})
|
||||
it('接口卡片(宿主标了 endpoint)照样数棵数,问的时候拿得到 endpoint', async () => {
|
||||
const route = symbol('list_agents')
|
||||
const page = symbol('MyAgentsPage')
|
||||
const endpoint = { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_' }
|
||||
const asked: unknown[] = []
|
||||
const source: CallTreeSource = {
|
||||
version: 9,
|
||||
blocks: [{ anchor: 'route', kind: 'fastapi.route', defines: { startIndex: 5 }, callTree: { endpoint } }],
|
||||
load: async (target) => {
|
||||
asked.push(target.callTree?.endpoint)
|
||||
return { symbol: route, edges: [{ caller: page, callee: route, at: { line: 3, character: 2, endLine: 3, endCharacter: 9 }, request: 'GET /api/v1/agents' }] }
|
||||
},
|
||||
}
|
||||
const posted: unknown[] = []
|
||||
const session = new CallTreeSession(message => posted.push(message), () => source)
|
||||
session.refresh()
|
||||
await viFlush()
|
||||
expect(asked).toEqual([endpoint])
|
||||
expect(posted).toEqual([{ type: 'callTreeCounts', version: 9, counts: { route: 1 } }])
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('接口在契约文件里没对上:点开时发出去的树带着 contractMiss,和「没人调用」分得开', async () => {
|
||||
const route = symbol('list_agents')
|
||||
const caller = symbol('nightly_job')
|
||||
let callers: CallEdge[] = []
|
||||
const source: CallTreeSource = {
|
||||
version: 10,
|
||||
blocks: [{ anchor: 'route', kind: 'fastapi.route', defines: { startIndex: 5 }, callTree: { endpoint: { method: 'GET', path: '/agents' } } }],
|
||||
load: async () => ({ symbol: route, edges: callers, contractMiss: { files: ['web/src/lib/api/schema.d.ts'] } }),
|
||||
}
|
||||
const posted: unknown[] = []
|
||||
const session = new CallTreeSession(message => posted.push(message), () => source)
|
||||
session.refresh()
|
||||
await viFlush()
|
||||
expect(posted).toEqual([{ type: 'callTreeCounts', version: 10, counts: {} }])
|
||||
posted.length = 0
|
||||
session.open(10, 'route')
|
||||
await viFlush()
|
||||
expect(posted).toEqual([{
|
||||
type: 'callTree',
|
||||
version: 10,
|
||||
anchor: 'route',
|
||||
name: 'list_agents',
|
||||
where: 'list_agents.py:1',
|
||||
trees: [],
|
||||
contractMiss: { files: ['web/src/lib/api/schema.d.ts'] },
|
||||
}])
|
||||
|
||||
callers = [{ caller, callee: route, at: { line: 1, character: 0, endLine: 1, endCharacter: 4 } }]
|
||||
session.refresh()
|
||||
await viFlush()
|
||||
posted.length = 0
|
||||
session.open(10, 'route')
|
||||
expect(posted).toEqual([expect.objectContaining({ trees: [expect.objectContaining({ name: 'nightly_job' })], contractMiss: { files: ['web/src/lib/api/schema.d.ts'] } })])
|
||||
session.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
async function viFlush(): Promise<void> {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* 文档发出去之后,在后台数每个函数、类落在几棵调用树里,有调用者的收起来,数完把棵数发给页面。
|
||||
* 页面点开某一块时,收着就把树发回去;没收着就当场问这一块,问到了补发棵数再发树,没问到发一棵空的。
|
||||
* 文档发出去之后,在后台数每个能看调用树的块(函数、类、接口卡片)落在几棵调用树里,有调用者的收起来,数完把棵数发给页面。
|
||||
* 页面点开某一块时,收着就把树发回去;没收着就当场问这一块,问到了补发棵数再发树,没问到发一棵空的;
|
||||
* 接口在契约文件里没对上时,发出去的树带着 contractMiss,页面照它说明,不当成没人调用。
|
||||
* 问的时候文档版本变了、或者停掉了,结果都丢掉。
|
||||
*/
|
||||
|
||||
import type { CallTreeTarget } from '../blockView'
|
||||
import type { ExtensionToWebview } from '../editor/messages'
|
||||
import type { ContractMiss } from './endpoint'
|
||||
import type { CallEdge, CallSymbol, CallTree } from './model'
|
||||
import { buildCallTrees } from './model'
|
||||
|
||||
@@ -13,19 +16,25 @@ export interface CallTreeBlock {
|
||||
anchor: string
|
||||
kind: string
|
||||
defines?: { startIndex: number }
|
||||
callTree?: CallTreeTarget
|
||||
children?: readonly CallTreeBlock[]
|
||||
}
|
||||
|
||||
/** 眼下这份文档,以及按名字起点去问调用关系。问的时候可以取消。 */
|
||||
/** 问到的一块的调用关系:起点、调用边;接口在契约文件里没对上时带 contractMiss。 */
|
||||
export interface LoadedCalls {
|
||||
symbol: CallSymbol
|
||||
edges: CallEdge[]
|
||||
contractMiss?: ContractMiss
|
||||
}
|
||||
|
||||
/** 眼下这份文档,以及去问某一块(按它的名字起点,接口卡片另带接口)的调用关系。问的时候可以取消。 */
|
||||
export interface CallTreeSource {
|
||||
version: number
|
||||
blocks: readonly CallTreeBlock[]
|
||||
load: (offset: number, cancelled: () => boolean) => Promise<{ symbol: CallSymbol, edges: CallEdge[] } | undefined>
|
||||
load: (block: CallTreeBlock, cancelled: () => boolean) => Promise<LoadedCalls | undefined>
|
||||
}
|
||||
|
||||
interface Cached {
|
||||
symbol: CallSymbol
|
||||
edges: CallEdge[]
|
||||
interface Cached extends LoadedCalls {
|
||||
classTarget: boolean
|
||||
}
|
||||
|
||||
@@ -87,35 +96,35 @@ export class CallTreeSession {
|
||||
this.postCounts(version)
|
||||
}
|
||||
|
||||
/** 当场问页面点开的那一块:问到了先补发棵数再发树,没问到(找不到这块、没人调用)发一棵空的。 */
|
||||
/** 当场问页面点开的那一块:问到了先补发棵数再发树,没问到(找不到这块、没人调用)发一棵空的;接口在契约里没对上时发带 contractMiss 的树。 */
|
||||
private async askNow(generation: number, src: CallTreeSource, anchor: string): Promise<void> {
|
||||
const version = src.version
|
||||
const block = callBlocks(src.blocks).find(item => item.anchor === anchor)
|
||||
const cached = block && await this.collect(generation, src, block)
|
||||
const found = block && await this.collect(generation, src, block)
|
||||
if (this.stale(generation, version))
|
||||
return
|
||||
if (!cached) {
|
||||
if (!found || (found.count === 0 && !found.cached.contractMiss)) {
|
||||
this.postEmpty(version, anchor)
|
||||
return
|
||||
}
|
||||
this.postCounts(version)
|
||||
this.postTree(version, anchor, cached)
|
||||
if (found.count > 0)
|
||||
this.postCounts(version)
|
||||
this.postTree(version, anchor, found.cached)
|
||||
}
|
||||
|
||||
/** 问一块有谁调用。有调用者就收起来、记上棵数再交回来;没人调用、语言不提供、问到一半作废了,都交回空。 */
|
||||
private async collect(generation: number, src: CallTreeSource, block: CallTreeBlock): Promise<Cached | undefined> {
|
||||
/** 问一块有谁调用,连同棵数交回来;有调用者的收起来、记上棵数。语言不提供、问到一半作废了交回空。 */
|
||||
private async collect(generation: number, src: CallTreeSource, block: CallTreeBlock): Promise<{ cached: Cached, count: number } | undefined> {
|
||||
const version = src.version
|
||||
const loaded = await src.load(block.defines!.startIndex, () => generation !== this.generation)
|
||||
const loaded = await src.load(block, () => generation !== this.generation)
|
||||
if (!loaded || this.stale(generation, version))
|
||||
return undefined
|
||||
const classTarget = block.kind === 'class'
|
||||
const count = buildCallTrees(loaded.edges, loaded.symbol, { classTarget }).length
|
||||
if (count === 0)
|
||||
return undefined
|
||||
const cached: Cached = { symbol: loaded.symbol, edges: loaded.edges, classTarget }
|
||||
this.cache.set(block.anchor, cached)
|
||||
this.counts[block.anchor] = count
|
||||
return cached
|
||||
const cached: Cached = { ...loaded, classTarget: block.kind === 'class' }
|
||||
const count = buildCallTrees(cached.edges, cached.symbol, { classTarget: cached.classTarget }).length
|
||||
if (count > 0) {
|
||||
this.cache.set(block.anchor, cached)
|
||||
this.counts[block.anchor] = count
|
||||
}
|
||||
return { cached, count }
|
||||
}
|
||||
|
||||
/** 这一轮停掉了,或者文档版本变了:手上的结果不能再用。 */
|
||||
@@ -136,6 +145,7 @@ export class CallTreeSession {
|
||||
name: cached.symbol.name,
|
||||
where: `${cached.symbol.path}:${cached.symbol.line + 1}`,
|
||||
trees,
|
||||
...(cached.contractMiss && { contractMiss: cached.contractMiss }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,12 +154,12 @@ export class CallTreeSession {
|
||||
}
|
||||
}
|
||||
|
||||
/** 有名字起点的函数和类,含嵌在别的块里面的。 */
|
||||
/** 宿主标了能看调用树(callTree)、有名字起点的块,含嵌在别的块里面的。 */
|
||||
function callBlocks(blocks: readonly CallTreeBlock[]): CallTreeBlock[] {
|
||||
const found: CallTreeBlock[] = []
|
||||
const walk = (list: readonly CallTreeBlock[]) => {
|
||||
for (const block of list) {
|
||||
if ((block.kind === 'function' || block.kind === 'class') && block.defines)
|
||||
if (block.callTree && block.defines)
|
||||
found.push(block)
|
||||
if (block.children?.length)
|
||||
walk(block.children)
|
||||
|
||||
@@ -169,4 +169,29 @@ describe('顺着调用者往上收集调用边', () => {
|
||||
expect(asked).toEqual(['mask_phone'])
|
||||
expect(edges).toEqual([])
|
||||
})
|
||||
it('前端发请求的调用者带着请求写法进边,前端节点往上照常问;前端测试文件里发的请求不算', async () => {
|
||||
const route = symbolIn('api/agent/routes.py', 'get_default_agent')
|
||||
const fetchDefault = symbolIn('web/src/lib/agents.ts', 'fetchDefaultAgent')
|
||||
const inTest = symbolIn('web/src/lib/__tests__/agents.ts', 'mocked')
|
||||
const page = symbolIn('web/src/app/(main)/my-agents/page.tsx', 'MyAgentsPage')
|
||||
const asked: string[] = []
|
||||
const edges = await collectCallEdges(route, async (node) => {
|
||||
asked.push(node.name)
|
||||
if (node.name === 'get_default_agent') {
|
||||
return [
|
||||
{ from: fetchDefault, ranges: [range(10, 20)], request: 'GET /api/v1/agents/default' },
|
||||
{ from: inTest, ranges: [range(3)], request: 'GET /api/v1/agents/default' },
|
||||
{ from: symbolIn('web/src/lib/agents.test.ts', 'spec'), ranges: [range(4)], request: 'GET /api/v1/agents/default' },
|
||||
]
|
||||
}
|
||||
if (node.name === 'fetchDefaultAgent')
|
||||
return [{ from: page, ranges: [range(6)] }]
|
||||
return []
|
||||
}, async () => [])
|
||||
expect(asked).toEqual(['get_default_agent', 'fetchDefaultAgent', 'MyAgentsPage'])
|
||||
expect(edges).toEqual([
|
||||
{ caller: fetchDefault, callee: route, at: range(10, 20), request: 'GET /api/v1/agents/default' },
|
||||
{ caller: page, callee: fetchDefault, at: range(6) },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,8 @@ import { CALL_TREE_MAX_DEPTH, symbolKey } from './model'
|
||||
export interface IncomingHit {
|
||||
from: CallSymbol
|
||||
ranges: readonly CallRange[]
|
||||
/** 前端发请求调到接口的才有,原样进调用边(见 CallEdge.request) */
|
||||
request?: string
|
||||
}
|
||||
|
||||
/** 一个被调用的符号,以及调用它的每一处范围。 */
|
||||
@@ -42,8 +44,8 @@ export async function collectCallEdges(
|
||||
const readingTests = isTestFile(start.path)
|
||||
const counted = (symbol: CallSymbol) => readingTests || !isTestFile(symbol.path)
|
||||
|
||||
const add = (caller: CallSymbol, callee: CallSymbol, at: CallRange) => {
|
||||
const edge: CallEdge = { caller, callee, at }
|
||||
const add = (caller: CallSymbol, callee: CallSymbol, at: CallRange, request?: string) => {
|
||||
const edge: CallEdge = { caller, callee, at, ...(request && { request }) }
|
||||
const key = `${symbolKey(caller)}>${symbolKey(callee)}@${at.line}:${at.character}-${at.endLine}:${at.endCharacter}`
|
||||
if (seen.has(key))
|
||||
return
|
||||
@@ -65,7 +67,7 @@ export async function collectCallEdges(
|
||||
if (here.has(key) || !counted(call.from))
|
||||
continue
|
||||
for (const at of uniqueRanges(call.ranges))
|
||||
add(call.from, node, at)
|
||||
add(call.from, node, at, call.request)
|
||||
if (key !== startKey && !ancestors.has(key))
|
||||
ancestors.set(key, call.from)
|
||||
if (!next.has(key))
|
||||
|
||||
@@ -991,6 +991,21 @@ export interface RuleCard {
|
||||
absorbs: CodeBlock[]
|
||||
/** 挂在卡片某一行的子行上、点那个子行才展开的子块(见 RuleSlot);一块只能放一处。 */
|
||||
slots?: RuleSlot[]
|
||||
/**
|
||||
* 这张卡片讲的是一个 HTTP 接口(要有 defines):卡片能看调用树,宿主拿它到工作区的接口契约文件(OpenAPI)里对出完整网址,
|
||||
* 把前端发这个请求的地方并进调用者。
|
||||
*/
|
||||
endpoint?: HttpEndpoint
|
||||
}
|
||||
|
||||
/** 卡片讲的 HTTP 接口。 */
|
||||
export interface HttpEndpoint {
|
||||
/** 大写的 HTTP 方法 */
|
||||
method: string
|
||||
/** 这份文件里写出来的路径,`{参数}` 照写;挂到应用上时别处再叠的前缀不算,宿主按结尾去对完整网址 */
|
||||
path: string
|
||||
/** 契约里结尾对上几条时,operationId 以它开头的那条才是(FastAPI 默认的 operationId 以「函数名_」开头) */
|
||||
operationIdPrefix?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { FileAttribute } from '../attributes'
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { ContractMiss } from '../callTree/endpoint'
|
||||
import type { CallTree } from '../callTree/model'
|
||||
import type { DocumentDiff } from '../diff/previewDiff'
|
||||
import type { ShownName } from '../nameResolution'
|
||||
@@ -130,8 +131,9 @@ export interface StickyScrollSettings {
|
||||
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
|
||||
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
|
||||
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
|
||||
* callTreeCounts:version 这份文档里,每个函数、类落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理
|
||||
* callTree:anchor 那块的调用树。trees 空着是没有调用它的地方。不是页面上这个版本的不理
|
||||
* callTreeCounts:version 这份文档里,每个能看调用树的块(BlockView.callTree)落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理
|
||||
* callTree:anchor 那块的调用树。trees 空着、又没有 contractMiss 是没有调用它的地方;接口卡片在契约文件里没对上时带 contractMiss。
|
||||
* 不是页面上这个版本的不理
|
||||
*/
|
||||
export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
@@ -157,7 +159,7 @@ export type ExtensionToWebview
|
||||
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
|
||||
| { type: 'focusPathAsk' }
|
||||
| { type: 'callTreeCounts', version: number, counts: Record<string, number> }
|
||||
| { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[] }
|
||||
| { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss }
|
||||
|
||||
/**
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
|
||||
@@ -184,6 +186,7 @@ export type ExtensionToWebview
|
||||
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问
|
||||
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
|
||||
* openCallTree:打开 anchor 那块的调用树。version 是这块所在的文档版本,对不上就不查
|
||||
* goToCallNode:点了调用树上的一个节点,打开 uri 那个文件,跳到 line 行 character 列(都从 0 起,见 CallTreeNode.jump)
|
||||
*/
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
@@ -208,3 +211,4 @@ export type WebviewToExtension
|
||||
| { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] }
|
||||
| { type: 'checkRepoImages', id: number, paths: string[] }
|
||||
| { type: 'openCallTree', version: number, anchor: string }
|
||||
| { type: 'goToCallNode', uri: string, line: number, character: number }
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
|
||||
import type { ClaudeProfile } from '../../cc/profiles'
|
||||
import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { IndexFind } from '../callTree/endpoint'
|
||||
import type { CommentEdit, RewriteOutcome, WriteResult } from '../commentRewrite'
|
||||
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange, WorkspaceIndex } from '../contract'
|
||||
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange } from '../contract'
|
||||
import type { DiffBase } from '../diff/diffBase'
|
||||
import type { OldDocument, SentDocument } from '../diff/previewDiff'
|
||||
import type { WorkspaceEntries } from '../entries/workspaceEntries'
|
||||
@@ -12,7 +13,6 @@ 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 { FoundLocation } from './definitions'
|
||||
import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
|
||||
@@ -23,7 +23,7 @@ import { commands, EndOfLine, Position, Range, Uri, window, workspace, Workspace
|
||||
import { logger } from '../../logging/logger'
|
||||
import { webviewHtml, webviewUiRoot } from '../../webview/html'
|
||||
import { DocumentCallees } from '../calleeKinds'
|
||||
import { loadCallEdges } from '../callTree/query'
|
||||
import { loadBlockCalls } from '../callTree/query'
|
||||
import { CallTreeSession } from '../callTree/session'
|
||||
import { findBlock, rewriteBlockComment } from '../commentRewrite'
|
||||
import { PreviewDiff } from '../diff/previewDiff'
|
||||
@@ -82,8 +82,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
readonly qa: CodeQa,
|
||||
/** 工作区里别的文件定义的、原名叫 name 的中文名(languageId 是查的这份文档的语言) */
|
||||
readonly indexedNames: (languageId: string, name: string) => Promise<IndexedName[]>,
|
||||
/** 库规则的工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts) */
|
||||
readonly indexed: (index: WorkspaceIndex, key: string) => Promise<IndexedEntry[]>,
|
||||
/** 工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts;接口的调用树查契约和前端请求时用) */
|
||||
readonly indexed: IndexFind,
|
||||
/** 预览标差异时对比什么 */
|
||||
readonly diffBases: DiffBases,
|
||||
/** 哪些文件是项目写明的入口、预览里哪些名字的块标成入口 */
|
||||
@@ -189,7 +189,7 @@ class Preview {
|
||||
private readonly rewriteStops = new Map<string, AbortController>()
|
||||
/** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */
|
||||
private sent: SentDocument | undefined
|
||||
/** 函数、类落在几棵调用树里。文档发出去之后再数,不挡住第一次预览 */
|
||||
/** 能看调用树的块(函数、类、接口卡片)落在几棵调用树里。文档发出去之后再数,不挡住第一次预览 */
|
||||
private readonly callTrees = new CallTreeSession(
|
||||
message => this.post(message),
|
||||
() => {
|
||||
@@ -198,7 +198,7 @@ class Preview {
|
||||
return {
|
||||
version: this.sent.version,
|
||||
blocks: this.sent.blocks,
|
||||
load: (offset, cancelled) => loadCallEdges(this.document, offset, cancelled),
|
||||
load: (block, cancelled) => loadBlockCalls(this.document, block, this.owner.indexed, cancelled),
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -630,6 +630,9 @@ class Preview {
|
||||
case 'openCallTree':
|
||||
this.callTrees.open(msg.version, msg.anchor)
|
||||
return
|
||||
case 'goToCallNode':
|
||||
await this.goToCallNode(Uri.parse(msg.uri), new Position(msg.line, msg.character))
|
||||
return
|
||||
case 'occurrenceNavigation':
|
||||
this.owner.navigation.update(this, msg.available)
|
||||
}
|
||||
@@ -777,6 +780,17 @@ class Preview {
|
||||
await open()
|
||||
}
|
||||
|
||||
/** 点了调用树上的一个节点:打开它在的文件,滚到这一处(同 ⌘ + 点击跳到别的文件)。 */
|
||||
private async goToCallNode(uri: Uri, position: Position): Promise<void> {
|
||||
try {
|
||||
const target = await workspace.openTextDocument(uri)
|
||||
await this.openElsewhere(uri, { range: new Range(position, position), offset: target.offsetAt(position) })
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `调用树里的 ${uri.toString()} 打不开:${err instanceof Error ? err.message : String(err)}` })
|
||||
}
|
||||
}
|
||||
|
||||
/** 预览里这批仓库路径,哪些文件或目录不在;这批不带路径的引用,哪些找不到。 */
|
||||
private async checkRepoLinks(id: number, paths: string[], symbols: string[]): Promise<void> {
|
||||
const root = await repoRootOf(this.document.uri.fsPath)
|
||||
|
||||
@@ -46,7 +46,7 @@ import { sqlalchemyRule } from './rules/sqlalchemy'
|
||||
import { sqlxRule } from './rules/sqlx'
|
||||
import { tauriRule } from './rules/tauri'
|
||||
import { thiserrorRule } from './rules/thiserror'
|
||||
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
|
||||
import { bundledWasmDir, indexGrammar, TreeSitterRuntime } from './runtime'
|
||||
import { bashZhTranslator } from './translators/bashZh'
|
||||
import { dockerfileZhTranslator } from './translators/dockerfileZh'
|
||||
import { pythonZhTranslator } from './translators/pythonZh'
|
||||
@@ -79,11 +79,12 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const registry = new CodeReaderRegistry(context.globalState)
|
||||
const runtime = new TreeSitterRuntime(bundledWasmDir())
|
||||
const report = (message: string): void => logger.add({ level: 'warn', source: 'codeReader', message })
|
||||
// 有语言插件的按它的语法包解析,没有的(接口契约 openapi.json)用索引专用的语法包
|
||||
const indexes = new WorkspaceIndexes(workspaceIndexHost(async (languageId, source) => {
|
||||
const adapter = registry.languageFor(languageId)
|
||||
if (!adapter)
|
||||
const grammar = registry.languageFor(languageId) ?? indexGrammar(bundledWasmDir(), languageId)
|
||||
if (!grammar)
|
||||
throw new Error(`没有「${languageId}」的语言插件`)
|
||||
return runtime.parse(adapter, languageId, source)
|
||||
return runtime.parse(grammar, languageId, source)
|
||||
}, report))
|
||||
const resolve = (reference: RuleReference): Promise<RuleLocation[]> => resolveReference(reference, registry.libraryRules(), indexes, report)
|
||||
const qa = new CodeQa(context, context.workspaceState)
|
||||
|
||||
@@ -485,3 +485,43 @@ def show(item_id: Int4Path, color: Color, owner: Int4Query, plain: Plain):
|
||||
indexes.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fastapi 库规则:接口卡片能看调用树', () => {
|
||||
const source = `from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["智能体"], prefix="/agents")
|
||||
bare = APIRouter()
|
||||
PREFIX = "/x"
|
||||
dynamic = APIRouter(prefix=PREFIX)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_agents():
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/default")
|
||||
async def get_default_agent():
|
||||
return None
|
||||
|
||||
|
||||
@bare.delete("/{agent_id}")
|
||||
def remove(agent_id: str):
|
||||
return None
|
||||
|
||||
|
||||
@dynamic.get("/y")
|
||||
def dynamic_route():
|
||||
return None
|
||||
`
|
||||
|
||||
it('卡片带上 HTTP 接口:方法、本文件的前缀接装饰器里的路径、operationId 的开头(函数名_)', async () => {
|
||||
expect((await route(source, 'list_agents')).callTree).toEqual({ endpoint: { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_' } })
|
||||
expect((await route(source, 'get_default_agent')).callTree).toEqual({ endpoint: { method: 'GET', path: '/agents/default', operationIdPrefix: 'get_default_agent_' } })
|
||||
expect((await route(source, 'remove')).callTree).toEqual({ endpoint: { method: 'DELETE', path: '/{agent_id}', operationIdPrefix: 'remove_' } })
|
||||
})
|
||||
|
||||
it('前缀或路径不是字符串字面量时算不出路径,卡片不带 HTTP 接口,也就没有调用树', async () => {
|
||||
expect((await route(source, 'dynamic_route')).callTree).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -731,9 +731,18 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
|
||||
...(facts.name && { defines: range(facts.name) }),
|
||||
attributes: { [METHOD_ATTRIBUTE]: route.method },
|
||||
absorbs: [],
|
||||
...(facts.name && endpointOf(route, facts.name.text)),
|
||||
}
|
||||
}
|
||||
|
||||
/** 卡片讲的 HTTP 接口:前缀和装饰器里的路径都是字符串字面量(或不写)才算得出路径;FastAPI 默认的 operationId 是函数名接完整路径和方法。 */
|
||||
function endpointOf(route: Route, fn: string): Pick<RuleCard, 'endpoint'> {
|
||||
const parts = [route.router.prefix, route.path].map(part => part ? stringContent(part)?.text : '')
|
||||
if (parts.includes(undefined))
|
||||
return {}
|
||||
return { endpoint: { method: route.method, path: parts.join(''), operationIdPrefix: `${fn}_` } }
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由器卡片:名称 / 说明 / 前缀 / 标签 / 依赖 / 默认响应(有才显示),接口表列这份文件里挂在它上面的全部接口:
|
||||
* 方法 | 路径(前缀 + 装饰器里的路径,`{参数}` 讲那个函数的参数)| 函数(⌘ + 点击跳到那张接口卡片)| 说明(docstring 第一行)。
|
||||
|
||||
@@ -8,6 +8,18 @@ export function bundledWasmDir(): string {
|
||||
return path.join(__dirname, 'tree-sitter')
|
||||
}
|
||||
|
||||
/** 没有语言插件、只给工作区索引解析的语言(接口契约 openapi.json):languageId → 语法包的文件名。 */
|
||||
const INDEX_GRAMMARS: Record<string, string> = { json: 'tree-sitter-json.wasm' }
|
||||
|
||||
/**
|
||||
* 工作区索引按 languageId 解析时,没有语言插件的语言用哪个语法包;不是这几种语言时没有。
|
||||
* @param wasmDir 放语法包的目录
|
||||
*/
|
||||
export function indexGrammar(wasmDir: string, languageId: string): Pick<LanguageAdapter, 'grammarPath'> | undefined {
|
||||
const file = INDEX_GRAMMARS[languageId]
|
||||
return file === undefined ? undefined : { grammarPath: () => path.join(wasmDir, file) }
|
||||
}
|
||||
|
||||
/** 源码下标的行列(行从 0 起,列按 UTF-16,同 web-tree-sitter 解析 JS 字符串时的算法)。 */
|
||||
function pointAt(source: string, index: number): Point {
|
||||
const lineStart = source.lastIndexOf('\n', index - 1) + 1
|
||||
@@ -44,7 +56,7 @@ export class TreeSitterRuntime {
|
||||
* 返回的语法树占着 wasm 内存,用完必须 `tree.delete()`。给了 ranges 就只解析源码里的这几段(嵌着的代码,见 EmbeddedCode),
|
||||
* 节点的下标、行列照旧是整份源码的。
|
||||
*/
|
||||
async parse(adapter: LanguageAdapter, languageId: string, source: string, ranges?: readonly SourceRange[]): Promise<Tree> {
|
||||
async parse(adapter: Pick<LanguageAdapter, 'grammarPath'>, languageId: string, source: string, ranges?: readonly SourceRange[]): Promise<Tree> {
|
||||
const parser = await this.parserFor(adapter.grammarPath(languageId))
|
||||
const tree = parser.parse(source, null, ranges ? { includedRanges: ranges.map(range => includedRange(source, range)) } : undefined)
|
||||
if (!tree)
|
||||
|
||||
@@ -5,7 +5,10 @@ import type { FileEvent, IndexHost, WorkspaceFile } from './workspaceIndex'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { Node } from '@vscode/tree-sitter-wasm'
|
||||
import { TreeSitterRuntime } from './runtime'
|
||||
import { minimatch } from 'minimatch'
|
||||
import { createPythonAdapter } from './languages/python'
|
||||
import { createTypeScriptAdapter } from './languages/typescript'
|
||||
import { indexGrammar, TreeSitterRuntime } from './runtime'
|
||||
|
||||
/** 单元测试直接用依赖包里的 wasm,不走打包产物。 */
|
||||
export const WASM_DIR = path.resolve(__dirname, '../../node_modules/@vscode/tree-sitter-wasm/wasm')
|
||||
@@ -13,6 +16,18 @@ export const WASM_DIR = path.resolve(__dirname, '../../node_modules/@vscode/tree
|
||||
/** Dockerfile 的语法包在 tree-sitter-wasm 里(打包时 esbuild 把它和上面的一起复制进 dist/tree-sitter)。 */
|
||||
export const DOCKERFILE_WASM_DIR = path.resolve(__dirname, '../../node_modules/tree-sitter-wasm/out/dockerfile')
|
||||
|
||||
/** JSON 的语法包也在 tree-sitter-wasm 里(只给工作区索引解析 openapi.json 用,见 runtime.ts 的 indexGrammar)。 */
|
||||
export const JSON_WASM_DIR = path.resolve(__dirname, '../../node_modules/tree-sitter-wasm/out/json')
|
||||
|
||||
/** 前后端混着的工作区建索引时用的语法包:JSON 用索引专用的,Python 用 Python 语言插件的,其余用 TypeScript 语言插件的。 */
|
||||
export const INDEX_GRAMMARS: Pick<LanguageAdapter, 'grammarPath'> = {
|
||||
grammarPath: (languageId) => {
|
||||
if (languageId === 'json')
|
||||
return indexGrammar(JSON_WASM_DIR, languageId)!.grammarPath(languageId)
|
||||
return (languageId === 'python' ? createPythonAdapter(WASM_DIR) : createTypeScriptAdapter(WASM_DIR)).grammarPath(languageId)
|
||||
},
|
||||
}
|
||||
|
||||
export const runtime = new TreeSitterRuntime(WASM_DIR)
|
||||
|
||||
export function readFixture(name: string): string {
|
||||
@@ -179,7 +194,8 @@ export class MemoryIndexHost implements IndexHost {
|
||||
lists = 0
|
||||
private readonly listeners = new Set<(event: FileEvent) => void>()
|
||||
|
||||
constructor(files: Record<string, string>, private readonly adapter: LanguageAdapter) {
|
||||
/** adapter 解析时按 languageId 给语法包;几种语言混着的工作区给一个按 languageId 分派的 grammarPath(见 INDEX_GRAMMARS)。 */
|
||||
constructor(files: Record<string, string>, private readonly adapter: Pick<LanguageAdapter, 'grammarPath'>) {
|
||||
this.files = new Map(Object.entries(files))
|
||||
}
|
||||
|
||||
@@ -189,8 +205,7 @@ export class MemoryIndexHost implements IndexHost {
|
||||
|
||||
async list(include: string): Promise<WorkspaceFile[]> {
|
||||
this.lists++
|
||||
const extension = include.slice(include.lastIndexOf('.'))
|
||||
return [...this.files.keys()].filter(relative => relative.endsWith(extension)).map(relative => this.file(relative))
|
||||
return [...this.files.keys()].filter(relative => minimatch(relative, include, { dot: true })).map(relative => this.file(relative))
|
||||
}
|
||||
|
||||
async read(file: WorkspaceFile): Promise<string> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { BlockTreeState } from './BlockTree'
|
||||
import type { NameMap, VersionNames } from './displayNames'
|
||||
import type { BlockView, CallTree, DocumentDiff, DocumentMessage, Excluded, Reveal } from './types'
|
||||
import type { BlockView, CallTree, ContractMiss, DocumentDiff, DocumentMessage, Excluded, KindBadge, Reveal } from './types'
|
||||
import type { OccurrencePage } from './useOccurrences'
|
||||
import type { ViewState } from './viewState'
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
@@ -43,15 +43,15 @@ function savedBlockModes(): BlockModes {
|
||||
return { editing, folds }
|
||||
}
|
||||
|
||||
function blockKind(blocks: readonly BlockView[], anchor: string): string {
|
||||
function blockAt(blocks: readonly BlockView[], anchor: string): BlockView | undefined {
|
||||
for (const block of blocks) {
|
||||
if (block.anchor === anchor)
|
||||
return block.kind
|
||||
const inner = blockKind(block.children, anchor)
|
||||
return block
|
||||
const inner = blockAt(block.children, anchor)
|
||||
if (inner)
|
||||
return inner
|
||||
}
|
||||
return ''
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function App(): ReactElement | null {
|
||||
@@ -86,7 +86,9 @@ export function App(): ReactElement | null {
|
||||
name: string
|
||||
where: string
|
||||
kind: string
|
||||
badge?: KindBadge
|
||||
trees: CallTree[]
|
||||
contractMiss?: ContractMiss
|
||||
} | null>(null)
|
||||
/** 块树所在的页面内容:钉住卡片时在这里面量 */
|
||||
const content = useRef<HTMLDivElement>(null)
|
||||
@@ -157,7 +159,7 @@ export function App(): ReactElement | null {
|
||||
setOpenCall((prev) => {
|
||||
if (!prev || prev.anchor !== msg.anchor || prev.version !== msg.version)
|
||||
return prev
|
||||
return { ...prev, status: 'ready', name: msg.name, where: msg.where, trees: msg.trees }
|
||||
return { ...prev, status: 'ready', name: msg.name, where: msg.where, trees: msg.trees, contractMiss: msg.contractMiss }
|
||||
})
|
||||
return
|
||||
case 'reveal':
|
||||
@@ -192,7 +194,8 @@ export function App(): ReactElement | null {
|
||||
const onOpenCall = useCallback((anchor: string) => {
|
||||
if (!doc)
|
||||
return
|
||||
setOpenCall({ anchor, version: doc.version, status: 'loading', name: '', where: '', kind: blockKind(doc.blocks, anchor) || 'function', trees: [] })
|
||||
const block = blockAt(doc.blocks, anchor)
|
||||
setOpenCall({ anchor, version: doc.version, status: 'loading', name: '', where: '', kind: block?.kind ?? 'function', ...(block?.badge && { badge: block.badge }), trees: [] })
|
||||
postMessage({ type: 'openCallTree', version: doc.version, anchor })
|
||||
}, [doc])
|
||||
const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc])
|
||||
@@ -303,11 +306,17 @@ export function App(): ReactElement | null {
|
||||
{openCall && (
|
||||
<CallTreeDialog
|
||||
kind={openCall.kind}
|
||||
badge={openCall.badge}
|
||||
name={openCall.name}
|
||||
where={openCall.where}
|
||||
status={openCall.status === 'loading' ? 'loading' : openCall.trees.length === 0 ? 'empty' : 'ready'}
|
||||
trees={openCall.trees}
|
||||
contractMiss={openCall.contractMiss}
|
||||
onClose={() => setOpenCall(null)}
|
||||
onJump={(jump) => {
|
||||
setOpenCall(null)
|
||||
postMessage({ type: 'goToCallNode', ...jump })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DisplayNameContext>
|
||||
|
||||
@@ -128,7 +128,7 @@ function shown(block: BlockView, tree: BlockTreeState): boolean {
|
||||
* 块的种类色写在哪:内置种类按 data-kind 取 CSS 里的配色;库规则认领的块(带 badge)按规则给的颜色,
|
||||
* 暗色、亮色两个都写在 style 上,CSS 按主题挑一个(见 [data-badge])。
|
||||
*/
|
||||
function kindProps(block: BlockView | undefined): { 'data-kind'?: string, 'data-badge'?: '', 'style'?: CSSProperties } {
|
||||
export function kindProps(block: Pick<BlockView, 'kind' | 'badge'> | undefined): { 'data-kind'?: string, 'data-badge'?: '', 'style'?: CSSProperties } {
|
||||
if (!block)
|
||||
return {}
|
||||
if (!block.badge)
|
||||
@@ -235,10 +235,10 @@ function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTre
|
||||
)
|
||||
}
|
||||
|
||||
/** 这块的「重写注释」按钮,排在右上角那排按钮里:能写注释(commentTarget)、页面上能改代码时才有。 */
|
||||
/** 这块的调用树按钮:扩展端标了能看调用树(BlockView.callTree)的块才有。 */
|
||||
function callTreeTool(block: BlockView, tree: BlockTreeState) {
|
||||
const callTree = tree.callTree
|
||||
if (!callTree || (block.kind !== 'function' && block.kind !== 'class'))
|
||||
if (!callTree || !block.callTree)
|
||||
return undefined
|
||||
return {
|
||||
anchor: block.anchor,
|
||||
|
||||
@@ -36,11 +36,12 @@ async function render(element: ReactElement): Promise<void> {
|
||||
await act(async () => root.render(element))
|
||||
}
|
||||
|
||||
function block(kind: string, id = kind): BlockView {
|
||||
function block(kind: string, id = kind, callTree?: BlockView['callTree']): BlockView {
|
||||
return {
|
||||
id,
|
||||
anchor: id,
|
||||
kind,
|
||||
...(callTree && { callTree }),
|
||||
startLine: 0,
|
||||
endLine: 1,
|
||||
code: '',
|
||||
@@ -79,7 +80,7 @@ function tree(callTree?: BlockTreeState['callTree'], edit = true): BlockTreeStat
|
||||
}
|
||||
|
||||
function node(name: string, extra: Partial<CallTree['root']> = {}): CallTree['root'] {
|
||||
return { name, kind: 'function', where: `${name}.py:1`, current: false, children: [], ...extra }
|
||||
return { name, kind: 'function', where: `${name}.py:1`, jump: { uri: `file:///${name}.py`, line: 0, character: 0 }, current: false, children: [], ...extra }
|
||||
}
|
||||
|
||||
const ONE: CallTree = {
|
||||
@@ -104,21 +105,29 @@ const MANY: CallTree[] = [
|
||||
]
|
||||
|
||||
describe('调用树弹框', () => {
|
||||
it('函数和类都有按钮,棵数为 0 只有图标、大于 0 带数字,语句没有;只读的树一排按钮都没有', async () => {
|
||||
const counts = new Map([['function', 0], ['class', 2], ['statement', 4]])
|
||||
await render(<BlockLevel blocks={[block('function'), block('class'), block('statement')]} tree={tree({ counts, onOpen: () => {} })} />)
|
||||
it('宿主标了能看调用树的块才有按钮(接口卡片也是),棵数为 0 只有图标、大于 0 带数字;只读的树一排按钮都没有', async () => {
|
||||
const counts = new Map([['function', 0], ['class', 2], ['statement', 4], ['route', 1], ['bare', 5]])
|
||||
const endpoint = { method: 'GET', path: '/agents' }
|
||||
await render(
|
||||
<BlockLevel
|
||||
blocks={[block('function', 'function', {}), block('class', 'class', {}), block('statement'), block('fastapi.route', 'route', { endpoint }), block('function', 'bare')]}
|
||||
tree={tree({ counts, onOpen: () => {} })}
|
||||
/>,
|
||||
)
|
||||
expect(container.querySelector('[data-call-tree="function"]')!.textContent).toBe('')
|
||||
expect(container.querySelector('[data-call-tree="statement"]')).toBeNull()
|
||||
expect(container.querySelector('[data-call-tree="class"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-call-tree="class"]')!.textContent).toBe('2')
|
||||
expect(container.querySelector('[data-call-tree="route"]')!.textContent).toBe('1')
|
||||
expect(container.querySelector('[data-call-tree="bare"]')).toBeNull()
|
||||
|
||||
await render(<BlockLevel blocks={[block('function')]} tree={tree({ counts: new Map([['function', 3]]), onOpen: () => {} }, false)} />)
|
||||
await render(<BlockLevel blocks={[block('function', 'function', {})]} tree={tree({ counts: new Map([['function', 3]]), onOpen: () => {} }, false)} />)
|
||||
expect(container.querySelector('[aria-label="块操作"]')).toBeNull()
|
||||
expect(container.querySelector('[data-call-tree="function"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('弹框打开时原来的卡片还在,关掉之后弹框没了,焦点回到那个按钮', async () => {
|
||||
const card = block('function', 'mask')
|
||||
const card = block('function', 'mask', {})
|
||||
function Page(): ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
@@ -135,6 +144,7 @@ describe('调用树弹框', () => {
|
||||
status="ready"
|
||||
trees={[ONE]}
|
||||
onClose={() => setOpen(false)}
|
||||
onJump={() => {}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -165,7 +175,7 @@ describe('调用树弹框', () => {
|
||||
? (
|
||||
<>
|
||||
<div data-id="mask">卡片</div>
|
||||
<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={[ONE]} onClose={() => setOpen(false)} />
|
||||
<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={[ONE]} onClose={() => setOpen(false)} onJump={() => {}} />
|
||||
</>
|
||||
)
|
||||
: <div data-id="mask">卡片</div>
|
||||
@@ -184,7 +194,7 @@ describe('调用树弹框', () => {
|
||||
})
|
||||
|
||||
it('多于一棵树才有入口切换,点一项换成那棵;只有一棵没有这排', async () => {
|
||||
await render(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} onClose={() => {}} />)
|
||||
await render(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} onClose={() => {}} onJump={() => {}} />)
|
||||
const tabs = [...document.body.querySelectorAll('[role="tab"]')]
|
||||
expect(tabs.map(tab => tab.textContent?.replace(/\s+/g, ''))).toEqual(['读取用户资料2处', '夜间对账1处'])
|
||||
const body = () => document.body.querySelector('.cr-call-body')!.textContent ?? ''
|
||||
@@ -196,9 +206,9 @@ describe('调用树弹框', () => {
|
||||
})
|
||||
|
||||
it('正在查、没有调用者,以及类的「新建」节点', async () => {
|
||||
await render(<CallTreeDialog kind="class" name="" where="" status="loading" trees={[]} onClose={() => {}} />)
|
||||
await render(<CallTreeDialog kind="class" name="" where="" status="loading" trees={[]} onClose={() => {}} onJump={() => {}} />)
|
||||
expect(document.body.textContent).toContain('正在查调用关系')
|
||||
await render(<CallTreeDialog kind="class" name="OrderService" where="billing/order.py:14" status="empty" trees={[]} onClose={() => {}} />)
|
||||
await render(<CallTreeDialog kind="class" name="OrderService" where="billing/order.py:14" status="empty" trees={[]} onClose={() => {}} onJump={() => {}} />)
|
||||
expect(document.body.textContent).toContain('没有调用它的地方')
|
||||
const made = node('新建 OrderService', {
|
||||
kind: 'class',
|
||||
@@ -214,6 +224,7 @@ describe('调用树弹框', () => {
|
||||
status="ready"
|
||||
trees={[{ id: 'post', name: 'POST /orders', hits: 1, root: node('POST /orders', { detail: '入口', kind: 'function', children: [made] }) }]}
|
||||
onClose={() => {}}
|
||||
onJump={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(document.body.textContent).toContain('新建 OrderService')
|
||||
@@ -222,4 +233,48 @@ describe('调用树弹框', () => {
|
||||
expect(document.body.textContent).not.toContain('方法')
|
||||
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
|
||||
})
|
||||
it('接口在契约文件里没对上:写明重新生成、列出找到的契约文件;一份都没有时写没找到;都不说「没有调用它的地方」', async () => {
|
||||
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: ['web/src/lib/api/schema.d.ts', 'admin/src/lib/api/schema.d.ts'] }} onClose={() => {}} onJump={() => {}} />)
|
||||
const body = () => document.body.querySelector('.cr-call-body')!.textContent ?? ''
|
||||
expect(body()).toContain('接口契约文件里没有这个接口,重新生成后再看')
|
||||
expect(body()).toContain('web/src/lib/api/schema.d.ts')
|
||||
expect(body()).toContain('admin/src/lib/api/schema.d.ts')
|
||||
expect(body()).not.toContain('没有调用它的地方')
|
||||
|
||||
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: [] }} onClose={() => {}} onJump={() => {}} />)
|
||||
expect(body()).toContain('工作区里没找到接口契约文件(openapi-typescript 生成的 .d.ts 或 openapi.json)')
|
||||
expect(body()).not.toContain('没有调用它的地方')
|
||||
|
||||
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="ready" trees={[ONE]} contractMiss={{ files: [] }} onClose={() => {}} onJump={() => {}} />)
|
||||
expect(body()).toContain('工作区里没找到接口契约文件')
|
||||
expect(body()).toContain('夜间对账')
|
||||
})
|
||||
|
||||
it('契约里几条都对得上、分不出是哪一条时列出那几条,不叫人重新生成', async () => {
|
||||
const miss = { files: ['web/src/lib/api/schema.d.ts'], ambiguous: ['/api/v1/admin/agents', '/api/v1/agents'] }
|
||||
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={miss} onClose={() => {}} onJump={() => {}} />)
|
||||
const body = document.body.querySelector('.cr-call-body')!.textContent ?? ''
|
||||
expect(body).toContain('接口契约文件里有几条都对得上这个接口,按 operationId 也分不出是哪一条')
|
||||
expect(body).toContain('/api/v1/admin/agents')
|
||||
expect(body).toContain('/api/v1/agents')
|
||||
expect(body).not.toContain('重新生成')
|
||||
expect(body).not.toContain('没有调用它的地方')
|
||||
})
|
||||
|
||||
it('点一行跳到它在的文件和行,「请求」节点跳到发请求的那一行', async () => {
|
||||
const jumps: unknown[] = []
|
||||
const request = node('请求 GET /api/v1/agents/default', {
|
||||
kind: 'request',
|
||||
where: 'web/src/lib/agents.ts:11',
|
||||
jump: { uri: 'file:///web/src/lib/agents.ts', line: 10, character: 20 },
|
||||
children: [node('get_default_agent', { current: true })],
|
||||
})
|
||||
const page: CallTree = { id: 'page', name: 'MyAgentsPage', hits: 1, root: node('MyAgentsPage', { detail: '页面 /my-agents', children: [request] }) }
|
||||
await render(<CallTreeDialog kind="fastapi.route" name="get_default_agent" where="api/routes.py:79" status="ready" trees={[page]} onClose={() => {}} onJump={jump => jumps.push(jump)} />)
|
||||
expect(document.body.textContent).toContain('页面 /my-agents')
|
||||
const rows = [...document.body.querySelectorAll<HTMLButtonElement>('button.cr-call-card')]
|
||||
expect(rows.map(row => row.querySelector('.cr-call-name')!.textContent)).toEqual(['MyAgentsPage', '请求 GET /api/v1/agents/default', 'get_default_agent'])
|
||||
await act(async () => rows[1].click())
|
||||
expect(jumps).toEqual([{ uri: 'file:///web/src/lib/agents.ts', line: 10, character: 20 }])
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,28 @@
|
||||
/**
|
||||
* 调用树弹框:盖在编辑区上,预览的卡片留在后面。点遮罩、点叉、按 Esc 关掉,焦点回到打开它的按钮。
|
||||
* 点树上的一行跳到它在的文件和行。接口在契约文件里没对上时写明原因,不当成没人调用。
|
||||
*/
|
||||
|
||||
import type { ReactElement } from 'react'
|
||||
import type { CallTree, CallTreeNode } from './types'
|
||||
import { Box, Braces, X } from 'lucide-react'
|
||||
import type { CallJump, CallTree, CallTreeNode, ContractMiss, KindBadge } from './types'
|
||||
import { Box, Braces, Send, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { kindProps } from './BlockTree'
|
||||
|
||||
export function CallTreeDialog({ kind, name, where, status, trees, onClose }: {
|
||||
/** 当前块的种类,整条路径用这个种类色 */
|
||||
export function CallTreeDialog({ kind, badge, name, where, status, trees, contractMiss, onClose, onJump }: {
|
||||
/** 当前块的种类,整条路径用这个种类色;库规则的卡片按 badge 的颜色 */
|
||||
kind: string
|
||||
badge?: KindBadge
|
||||
name: string
|
||||
where: string
|
||||
status: 'loading' | 'empty' | 'ready'
|
||||
trees: readonly CallTree[]
|
||||
/** 接口在契约文件里没对上时有 */
|
||||
contractMiss?: ContractMiss
|
||||
onClose: () => void
|
||||
/** 点了树上的一行 */
|
||||
onJump: (jump: CallJump) => void
|
||||
}): ReactElement {
|
||||
const [selected, setSelected] = useState(0)
|
||||
const tree = trees[Math.min(selected, Math.max(trees.length - 1, 0))]
|
||||
@@ -49,7 +56,7 @@ export function CallTreeDialog({ kind, name, where, status, trees, onClose }: {
|
||||
aria-modal="true"
|
||||
aria-label={name ? `调用树 ${name}` : '调用树'}
|
||||
className="cr-call-dialog"
|
||||
data-kind={kind}
|
||||
{...kindProps({ kind, badge })}
|
||||
>
|
||||
<div className="cr-call-title">
|
||||
<span className="cr-call-heading">调用树</span>
|
||||
@@ -79,8 +86,9 @@ export function CallTreeDialog({ kind, name, where, status, trees, onClose }: {
|
||||
)}
|
||||
<div className="cr-call-body">
|
||||
{status === 'loading' && <p className="cr-call-note">正在查调用关系</p>}
|
||||
{status === 'empty' && <p className="cr-call-note">没有调用它的地方</p>}
|
||||
{status === 'ready' && tree && <CallNodes node={tree.root} depth={0} path={tree.id} />}
|
||||
{status !== 'loading' && contractMiss && <ContractNote miss={contractMiss} />}
|
||||
{status === 'empty' && !contractMiss && <p className="cr-call-note">没有调用它的地方</p>}
|
||||
{status === 'ready' && tree && <CallNodes node={tree.root} depth={0} path={tree.id} onJump={onJump} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -88,26 +96,48 @@ export function CallTreeDialog({ kind, name, where, status, trees, onClose }: {
|
||||
)
|
||||
}
|
||||
|
||||
function CallNodes({ node, depth, path }: { node: CallTreeNode, depth: number, path: string }): ReactElement {
|
||||
const Icon = node.kind === 'class' ? Box : Braces
|
||||
/**
|
||||
* 接口在契约文件里没对上:分不出是哪一条时列出那几条;没有这一条时让人重新生成、列出找到的契约文件;
|
||||
* 一份契约文件都没有时说没找到。
|
||||
*/
|
||||
function ContractNote({ miss }: { miss: ContractMiss }): ReactElement {
|
||||
if (miss.files.length === 0)
|
||||
return <p className="cr-call-note">工作区里没找到接口契约文件(openapi-typescript 生成的 .d.ts 或 openapi.json)</p>
|
||||
const [text, items] = miss.ambiguous
|
||||
? ['接口契约文件里有几条都对得上这个接口,按 operationId 也分不出是哪一条', miss.ambiguous]
|
||||
: ['接口契约文件里没有这个接口,重新生成后再看', miss.files]
|
||||
return (
|
||||
<div className="cr-call-note">
|
||||
<p>{text}</p>
|
||||
<ul className="cr-call-files">
|
||||
{items.map(item => <li key={item} className="cr-call-loc">{item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NODE_ICONS: Record<string, typeof Braces> = { class: Box, request: Send }
|
||||
|
||||
function CallNodes({ node, depth, path, onJump }: { node: CallTreeNode, depth: number, path: string, onJump: (jump: CallJump) => void }): ReactElement {
|
||||
const Icon = NODE_ICONS[node.kind] ?? Braces
|
||||
return (
|
||||
<>
|
||||
<div className="cr-call-row" style={{ paddingLeft: depth * 22 }}>
|
||||
<span className="cr-call-guide" />
|
||||
<div className="cr-call-card" data-current={node.current ? '' : undefined}>
|
||||
<div className="cr-call-line">
|
||||
<button type="button" className="cr-call-card" data-current={node.current ? '' : undefined} onClick={() => onJump(node.jump)}>
|
||||
<span className="cr-call-line">
|
||||
<Icon size={14} strokeWidth={1.8} aria-hidden />
|
||||
<span className="cr-call-name">{node.name}</span>
|
||||
{node.current && <span className="cr-call-pill">当前</span>}
|
||||
</div>
|
||||
<div className="cr-call-sub">
|
||||
</span>
|
||||
<span className="cr-call-sub">
|
||||
{node.detail && <span>{node.detail}</span>}
|
||||
<span className="cr-call-loc">{node.where}</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{node.children.map((child, index) => (
|
||||
<CallNodes key={`${path}/${index}`} node={child} depth={depth + 1} path={`${path}/${index}`} />
|
||||
<CallNodes key={`${path}/${index}`} node={child} depth={depth + 1} path={`${path}/${index}`} onJump={onJump} />
|
||||
))}
|
||||
{node.otherCalls !== undefined && node.otherCalls > 0 && (
|
||||
<div className="cr-call-row" style={{ paddingLeft: (depth + 1) * 22 }}>
|
||||
|
||||
@@ -1394,6 +1394,13 @@ del.cr-diff-word {
|
||||
margin: 8px 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
.cr-call-note p {
|
||||
margin: 0;
|
||||
}
|
||||
.cr-call-files {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.cr-call-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
@@ -1420,6 +1427,17 @@ del.cr-diff-word {
|
||||
border: 1px solid color-mix(in srgb, var(--kind) 35%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--kind) 7%, transparent);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cr-call-card:hover {
|
||||
background: color-mix(in srgb, var(--kind) 12%, transparent);
|
||||
}
|
||||
.cr-call-card:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.cr-call-card[data-current] {
|
||||
border-color: var(--kind);
|
||||
|
||||
@@ -149,9 +149,16 @@ interface BlockViewBase {
|
||||
badge?: KindBadge
|
||||
/** 库规则挂在父卡片子行上的子块才有:展开时画在父卡片属性表第 row 行下面,不排在父卡片的子块里;见 BlockSlot */
|
||||
slot?: BlockSlot
|
||||
/** 能看调用树的块才有(有名字的函数、类,讲 HTTP 接口的卡片):有它才摆调用树按钮 */
|
||||
callTree?: CallTreeTarget
|
||||
children: BlockView[]
|
||||
}
|
||||
|
||||
/** 一块能看调用树:接口卡片带上它讲的接口(扩展端查前端调用者用,这边只看有没有 callTree)。 */
|
||||
export interface CallTreeTarget {
|
||||
endpoint?: { method: string, path: string, operationIdPrefix?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* 子块挂在父卡片的哪儿:属性表第 row 行的第 item 个子行。它默认收着(它的块 id 记成折叠着),点那个子行的第一格才在这一行下面展开、
|
||||
* 同一行一次只展开一个,折叠这一行时跟着收起(Rust 实现块的方法表点方法名)
|
||||
@@ -445,22 +452,41 @@ export interface StickyScrollSettings {
|
||||
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
|
||||
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
|
||||
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
|
||||
* callTreeCounts:version 这份文档里,每个函数、类落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理
|
||||
* callTree:anchor 那块的调用树。trees 空着是没有调用它的地方。不是页面上这个版本的不理
|
||||
* callTreeCounts:version 这份文档里,每个能看调用树的块落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理
|
||||
* callTree:anchor 那块的调用树。trees 空着、又没有 contractMiss 是没有调用它的地方;接口卡片在契约文件里没对上时带 contractMiss。
|
||||
* 不是页面上这个版本的不理
|
||||
*/
|
||||
/** 点节点跳到哪:文件和位置,行和列从 0 起。 */
|
||||
export interface CallJump {
|
||||
uri: string
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
|
||||
export interface CallTreeNode {
|
||||
name: string
|
||||
/** 块种类;「请求」节点是 request */
|
||||
kind: string
|
||||
/** 第二行。根是「入口」,新建类是「进入这个类」 */
|
||||
/** 第二行。根是「入口」(Next.js 页面是「页面 网址」),新建类是「进入这个类」 */
|
||||
detail?: string
|
||||
/** 文件:行,行从 1 起 */
|
||||
where: string
|
||||
/** 符号是它的名字起点;「新建」「请求」节点是发生调用的那一处 */
|
||||
jump: CallJump
|
||||
current: boolean
|
||||
children: CallTreeNode[]
|
||||
/** 不在通向当前块的路径上的调用次数 */
|
||||
otherCalls?: number
|
||||
}
|
||||
|
||||
/** 接口在契约文件里没对上。 */
|
||||
export interface ContractMiss {
|
||||
/** 找到的契约文件(从工作区文件夹算起的路径),一份都没有时是空的 */
|
||||
files: string[]
|
||||
/** 契约里结尾对得上、按 operationId 也分不出是哪一条的几条完整路径;一条都对不上时没有 */
|
||||
ambiguous?: string[]
|
||||
}
|
||||
|
||||
/** 一棵调用树。hits 是这一棵里当前块出现几次。 */
|
||||
export interface CallTree {
|
||||
id: string
|
||||
@@ -493,7 +519,7 @@ export type ExtensionToWebview
|
||||
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
|
||||
| { type: 'focusPathAsk' }
|
||||
| { type: 'callTreeCounts', version: number, counts: Record<string, number> }
|
||||
| { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[] }
|
||||
| { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss }
|
||||
|
||||
/**
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
|
||||
@@ -519,6 +545,7 @@ export type ExtensionToWebview
|
||||
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问
|
||||
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
|
||||
* openCallTree:打开 anchor 那块的调用树。version 是这块所在的文档版本
|
||||
* goToCallNode:点了调用树上的一个节点,打开 uri 那个文件,跳到 line 行 character 列(见 CallTreeNode.jump)
|
||||
*/
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
@@ -543,3 +570,4 @@ export type WebviewToExtension
|
||||
| { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] }
|
||||
| { type: 'checkRepoImages', id: number, paths: string[] }
|
||||
| { type: 'openCallTree', version: number, anchor: string }
|
||||
| { type: 'goToCallNode', uri: string, line: number, character: number }
|
||||
Reference in new issue
Block a user