diff --git a/vscode/esbuild.mjs b/vscode/esbuild.mjs
index 9118c55..b473633 100644
--- a/vscode/esbuild.mjs
+++ b/vscode/esbuild.mjs
@@ -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'))
diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts
index eec979b..a605cf1 100644
--- a/vscode/src/codeReader/blockView.test.ts
+++ b/vscode/src/codeReader/blockView.test.ts
@@ -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')
diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts
index c1219dd..a4dfd88 100644
--- a/vscode/src/codeReader/blockView.ts
+++ b/vscode/src/codeReader/blockView.ts
@@ -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),
diff --git a/vscode/src/codeReader/callTree/endpoint.test.ts b/vscode/src/codeReader/callTree/endpoint.test.ts
new file mode 100644
index 0000000..78e4c6d
--- /dev/null
+++ b/vscode/src/codeReader/callTree/endpoint.test.ts
@@ -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
\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, endpoint: Parameters[1]): Promise {
+ 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'] })
+ })
+})
diff --git a/vscode/src/codeReader/callTree/endpoint.ts b/vscode/src/codeReader/callTree/endpoint.ts
new file mode 100644
index 0000000..0859870
--- /dev/null
+++ b/vscode/src/codeReader/callTree/endpoint.ts
@@ -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 = (index: WorkspaceIndexOf, key: string) => Promise[]>
+
+/** 接口在契约文件里没对上。 */
+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[] } | { contracts: string[], ambiguous?: string[] }
+
+/** 接口卡片的前端调用者:契约里对出完整路径后,同一方法的前端请求里最具体地配上这条路径的那些。 */
+export async function endpointCalls(find: IndexFind, endpoint: HttpEndpoint): Promise {
+ 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) }
+}
diff --git a/vscode/src/codeReader/callTree/model.test.ts b/vscode/src/codeReader/callTree/model.test.ts
index 0821482..8cdec6b 100644
--- a/vscode/src/codeReader/callTree/model.test.ts
+++ b/vscode/src/codeReader/callTree/model.test.ts
@@ -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 {
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()
+ })
+})
diff --git a/vscode/src/codeReader/callTree/model.ts b/vscode/src/codeReader/callTree/model.ts
index cac96e0..9148955 100644
--- a/vscode/src/codeReader/callTree/model.ts
+++ b/vscode/src/codeReader/callTree/model.ts
@@ -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 {
+ 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()
@@ -96,7 +128,7 @@ export function buildCallTrees(edges: readonly CallEdge[], target: CallSymbol, o
const unique: CallEdge[] = []
const seen = new Set()
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): 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 }
})
}
diff --git a/vscode/src/codeReader/callTree/openapi.test.ts b/vscode/src/codeReader/callTree/openapi.test.ts
new file mode 100644
index 0000000..5d9f25c
--- /dev/null
+++ b/vscode/src/codeReader/callTree/openapi.test.ts
@@ -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, index: WorkspaceIndexOf, method: string): Promise {
+ 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([])
+ })
+})
diff --git a/vscode/src/codeReader/callTree/openapi.ts b/vscode/src/codeReader/callTree/openapi.ts
new file mode 100644
index 0000000..85ab6d6
--- /dev/null
+++ b/vscode/src/codeReader/callTree/openapi.ts
@@ -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 = {
+ 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 = {
+ 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())
diff --git a/vscode/src/codeReader/callTree/query.ts b/vscode/src/codeReader/callTree/query.ts
index 7529656..5b82b20 100644
--- a/vscode/src/codeReader/callTree/query.ts
+++ b/vscode/src/codeReader/callTree/query.ts
@@ -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 {
+ 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[],
): 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()
@@ -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 {
+ 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 {
+ 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(node: CallSymbol, command: string, map: (call: T) => R): Promise {
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 {
+ try {
+ const prepared = await commands.executeCommand('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 {
diff --git a/vscode/src/codeReader/callTree/requests.test.ts b/vscode/src/codeReader/callTree/requests.test.ts
new file mode 100644
index 0000000..378cc1a
--- /dev/null
+++ b/vscode/src/codeReader/callTree/requests.test.ts
@@ -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 {data?.length}
',
+ '}',
+ '',
+ '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, method: string): Promise {
+ 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`])
+ })
+})
diff --git a/vscode/src/codeReader/callTree/requests.ts b/vscode/src/codeReader/callTree/requests.ts
new file mode 100644
index 0000000..8052d57
--- /dev/null
+++ b/vscode/src/codeReader/callTree/requests.ts
@@ -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 = {
+ 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),
+}
diff --git a/vscode/src/codeReader/callTree/session.test.ts b/vscode/src/codeReader/callTree/session.test.ts
index c188c36..b62e000 100644
--- a/vscode/src/codeReader/callTree/session.test.ts
+++ b/vscode/src/codeReader/callTree/session.test.ts
@@ -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 {
diff --git a/vscode/src/codeReader/callTree/session.ts b/vscode/src/codeReader/callTree/session.ts
index 6dde917..30bdef9 100644
--- a/vscode/src/codeReader/callTree/session.ts
+++ b/vscode/src/codeReader/callTree/session.ts
@@ -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
}
-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 {
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 {
+ /** 问一块有谁调用,连同棵数交回来;有调用者的收起来、记上棵数。语言不提供、问到一半作废了交回空。 */
+ 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)
diff --git a/vscode/src/codeReader/callTree/walk.test.ts b/vscode/src/codeReader/callTree/walk.test.ts
index cca01b3..53a5533 100644
--- a/vscode/src/codeReader/callTree/walk.test.ts
+++ b/vscode/src/codeReader/callTree/walk.test.ts
@@ -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) },
+ ])
+ })
})
diff --git a/vscode/src/codeReader/callTree/walk.ts b/vscode/src/codeReader/callTree/walk.ts
index d09bd0c..5a8a15f 100644
--- a/vscode/src/codeReader/callTree/walk.ts
+++ b/vscode/src/codeReader/callTree/walk.ts
@@ -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))
diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts
index 4b39024..921a927 100644
--- a/vscode/src/codeReader/contract.ts
+++ b/vscode/src/codeReader/contract.ts
@@ -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
}
/**
diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts
index 48a2afb..87496b5 100644
--- a/vscode/src/codeReader/editor/messages.ts
+++ b/vscode/src/codeReader/editor/messages.ts
@@ -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 }
- | { 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 }
diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts
index bb0624a..05726fd 100644
--- a/vscode/src/codeReader/editor/previewEditor.ts
+++ b/vscode/src/codeReader/editor/previewEditor.ts
@@ -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,
- /** 库规则的工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts) */
- readonly indexed: (index: WorkspaceIndex, key: string) => Promise,
+ /** 工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts;接口的调用树查契约和前端请求时用) */
+ readonly indexed: IndexFind,
/** 预览标差异时对比什么 */
readonly diffBases: DiffBases,
/** 哪些文件是项目写明的入口、预览里哪些名字的块标成入口 */
@@ -189,7 +189,7 @@ class Preview {
private readonly rewriteStops = new Map()
/** 最近一次发出去的文档(版本、块树、源码):问 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 {
+ 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 {
const root = await repoRootOf(this.document.uri.fsPath)
diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts
index 9267c5b..54463a3 100644
--- a/vscode/src/codeReader/index.ts
+++ b/vscode/src/codeReader/index.ts
@@ -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 => resolveReference(reference, registry.libraryRules(), indexes, report)
const qa = new CodeQa(context, context.workspaceState)
diff --git a/vscode/src/codeReader/rules/fastapi.test.ts b/vscode/src/codeReader/rules/fastapi.test.ts
index 64c33a0..3b88714 100644
--- a/vscode/src/codeReader/rules/fastapi.test.ts
+++ b/vscode/src/codeReader/rules/fastapi.test.ts
@@ -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()
+ })
+})
diff --git a/vscode/src/codeReader/rules/fastapi.ts b/vscode/src/codeReader/rules/fastapi.ts
index ef0c95d..0540eb1 100644
--- a/vscode/src/codeReader/rules/fastapi.ts
+++ b/vscode/src/codeReader/rules/fastapi.ts
@@ -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 {
+ 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 第一行)。
diff --git a/vscode/src/codeReader/runtime.ts b/vscode/src/codeReader/runtime.ts
index 2c3bce1..07f9ebe 100644
--- a/vscode/src/codeReader/runtime.ts
+++ b/vscode/src/codeReader/runtime.ts
@@ -8,6 +8,18 @@ export function bundledWasmDir(): string {
return path.join(__dirname, 'tree-sitter')
}
+/** 没有语言插件、只给工作区索引解析的语言(接口契约 openapi.json):languageId → 语法包的文件名。 */
+const INDEX_GRAMMARS: Record = { json: 'tree-sitter-json.wasm' }
+
+/**
+ * 工作区索引按 languageId 解析时,没有语言插件的语言用哪个语法包;不是这几种语言时没有。
+ * @param wasmDir 放语法包的目录
+ */
+export function indexGrammar(wasmDir: string, languageId: string): Pick | 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 {
+ async parse(adapter: Pick, languageId: string, source: string, ranges?: readonly SourceRange[]): Promise {
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)
diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts
index dd5f1c3..38ad7e9 100644
--- a/vscode/src/codeReader/testSupport.ts
+++ b/vscode/src/codeReader/testSupport.ts
@@ -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 = {
+ 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, private readonly adapter: LanguageAdapter) {
+ /** adapter 解析时按 languageId 给语法包;几种语言混着的工作区给一个按 languageId 分派的 grammarPath(见 INDEX_GRAMMARS)。 */
+ constructor(files: Record, private readonly adapter: Pick) {
this.files = new Map(Object.entries(files))
}
@@ -189,8 +205,7 @@ export class MemoryIndexHost implements IndexHost {
async list(include: string): Promise {
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 {
diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx
index 94be0f4..3fe70d8 100644
--- a/vscode/webview-ui/src/codeReader/App.tsx
+++ b/vscode/webview-ui/src/codeReader/App.tsx
@@ -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(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 && (
setOpenCall(null)}
+ onJump={(jump) => {
+ setOpenCall(null)
+ postMessage({ type: 'goToCallNode', ...jump })
+ }}
/>
)}
diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx
index 1a4a66b..2f827ad 100644
--- a/vscode/webview-ui/src/codeReader/BlockTree.tsx
+++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx
@@ -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 | 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,
diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx
index cfca9ea..2672b0a 100644
--- a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx
+++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx
@@ -36,11 +36,12 @@ async function render(element: ReactElement): Promise {
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'] {
- 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( {} })} />)
+ 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(
+ {} })}
+ />,
+ )
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( {} }, false)} />)
+ await render( {} }, 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('调用树弹框', () => {
? (
<>
卡片
- setOpen(false)} />
+ setOpen(false)} onJump={() => {}} />
>
)
: 卡片
@@ -184,7 +194,7 @@ describe('调用树弹框', () => {
})
it('多于一棵树才有入口切换,点一项换成那棵;只有一棵没有这排', async () => {
- await render( {}} />)
+ await render( {}} 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( {}} />)
+ await render( {}} onJump={() => {}} />)
expect(document.body.textContent).toContain('正在查调用关系')
- await render( {}} />)
+ await render( {}} 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( {}} 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( {}} onJump={() => {}} />)
+ expect(body()).toContain('工作区里没找到接口契约文件(openapi-typescript 生成的 .d.ts 或 openapi.json)')
+ expect(body()).not.toContain('没有调用它的地方')
+
+ await render( {}} 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( {}} 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( {}} onJump={jump => jumps.push(jump)} />)
+ expect(document.body.textContent).toContain('页面 /my-agents')
+ const rows = [...document.body.querySelectorAll('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 }])
+ })
})
diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx
index 0f97e77..d07e764 100644
--- a/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx
+++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.tsx
@@ -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 })}
>
调用树
@@ -79,8 +86,9 @@ export function CallTreeDialog({ kind, name, where, status, trees, onClose }: {
)}
{status === 'loading' &&
正在查调用关系
}
- {status === 'empty' &&
没有调用它的地方
}
- {status === 'ready' && tree &&
}
+ {status !== 'loading' && contractMiss &&
}
+ {status === 'empty' && !contractMiss &&
没有调用它的地方
}
+ {status === 'ready' && tree &&
}
,
@@ -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 工作区里没找到接口契约文件(openapi-typescript 生成的 .d.ts 或 openapi.json)
+ const [text, items] = miss.ambiguous
+ ? ['接口契约文件里有几条都对得上这个接口,按 operationId 也分不出是哪一条', miss.ambiguous]
+ : ['接口契约文件里没有这个接口,重新生成后再看', miss.files]
+ return (
+
+
{text}
+
+ {items.map(item => - {item}
)}
+
+
+ )
+}
+
+const NODE_ICONS: Record = { 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 (
<>
-
-
+
-
+
+
{node.detail && {node.detail}}
{node.where}
-
-
+
+
{node.children.map((child, index) => (
-
+
))}
{node.otherCalls !== undefined && node.otherCalls > 0 && (
diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css
index a6a2bc4..6f36140 100644
--- a/vscode/webview-ui/src/codeReader/codeReader.css
+++ b/vscode/webview-ui/src/codeReader/codeReader.css
@@ -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);
diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts
index d2fae7e..cdb745e 100644
--- a/vscode/webview-ui/src/codeReader/types.ts
+++ b/vscode/webview-ui/src/codeReader/types.ts
@@ -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 }
- | { 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 }