From 29cedcb751faf728995ccca1e8550e2f334adcf3 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 12 Oct 2026 12:22:51 +1100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20FastAPI=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=8D=A1=E7=89=87=E7=9A=84=E8=B0=83=E7=94=A8=E6=A0=91?= =?UTF-8?q?=E5=B8=A6=E4=B8=8A=E5=89=8D=E7=AB=AF=E8=B0=83=E7=94=A8=E5=AE=83?= =?UTF-8?q?=E7=9A=84=E5=9C=B0=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/esbuild.mjs | 4 +- vscode/src/codeReader/blockView.test.ts | 14 ++ vscode/src/codeReader/blockView.ts | 13 +- .../src/codeReader/callTree/endpoint.test.ts | 108 +++++++++++++++ vscode/src/codeReader/callTree/endpoint.ts | 97 +++++++++++++ vscode/src/codeReader/callTree/model.test.ts | 63 ++++++++- vscode/src/codeReader/callTree/model.ts | 64 +++++++-- .../src/codeReader/callTree/openapi.test.ts | 86 ++++++++++++ vscode/src/codeReader/callTree/openapi.ts | 123 +++++++++++++++++ vscode/src/codeReader/callTree/query.ts | 111 ++++++++++++--- .../src/codeReader/callTree/requests.test.ts | 95 +++++++++++++ vscode/src/codeReader/callTree/requests.ts | 130 ++++++++++++++++++ .../src/codeReader/callTree/session.test.ts | 75 +++++++++- vscode/src/codeReader/callTree/session.ts | 60 ++++---- vscode/src/codeReader/callTree/walk.test.ts | 25 ++++ vscode/src/codeReader/callTree/walk.ts | 8 +- vscode/src/codeReader/contract.ts | 15 ++ vscode/src/codeReader/editor/messages.ts | 10 +- vscode/src/codeReader/editor/previewEditor.ts | 28 +++- vscode/src/codeReader/index.ts | 9 +- vscode/src/codeReader/rules/fastapi.test.ts | 40 ++++++ vscode/src/codeReader/rules/fastapi.ts | 9 ++ vscode/src/codeReader/runtime.ts | 14 +- vscode/src/codeReader/testSupport.ts | 23 +++- vscode/webview-ui/src/codeReader/App.tsx | 23 +++- .../webview-ui/src/codeReader/BlockTree.tsx | 6 +- .../src/codeReader/CallTreeDialog.test.tsx | 77 +++++++++-- .../src/codeReader/CallTreeDialog.tsx | 62 ++++++--- .../webview-ui/src/codeReader/codeReader.css | 18 +++ vscode/webview-ui/src/codeReader/types.ts | 36 ++++- 30 files changed, 1316 insertions(+), 130 deletions(-) create mode 100644 vscode/src/codeReader/callTree/endpoint.test.ts create mode 100644 vscode/src/codeReader/callTree/endpoint.ts create mode 100644 vscode/src/codeReader/callTree/openapi.test.ts create mode 100644 vscode/src/codeReader/callTree/openapi.ts create mode 100644 vscode/src/codeReader/callTree/requests.test.ts create mode 100644 vscode/src/codeReader/callTree/requests.ts 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}

+ +
+ ) +} + +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 }