diff --git a/vscode/src/codeReader/callTree/endpoint.test.ts b/vscode/src/codeReader/callTree/endpoint.test.ts index 0d947fd..f7e6d1b 100644 --- a/vscode/src/codeReader/callTree/endpoint.test.ts +++ b/vscode/src/codeReader/callTree/endpoint.test.ts @@ -158,4 +158,16 @@ describe('接口卡片找前端调用它的地方', () => { expect(await calls({ ...files, 'web/src/lib/api/schema.d.ts': admin }, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_', sameRouter })) .toEqual(['web/src/app/(main)/my-agents/page.tsx MyAgentsPage /api/v1/agents']) }) + + it('经包装函数(路径别名导入)发的请求也找得到', async () => { + const sessions = SCHEMA.replace('export interface paths {', 'export interface paths {\n "/api/v1/sessions/{session_id}/sandbox/ide/start": {\n post: operations["start_sandbox_ide_api_v1_sessions__session_id__sandbox_ide_start_post"];\n };') + const wrapped = { + 'web/src/lib/api/schema.d.ts': sessions, + 'web/tsconfig.json': '{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }\n', + 'web/src/lib/api/authed-fetch.ts': 'export const authedFetch = async (input: RequestInfo | URL, init?: RequestInit) => {\n const res = await fetch(input, { credentials: "include", ...init })\n return res.status === 401 ? authedFetch(input, { ...init }) : res\n}\n', + 'web/src/app/(main)/chat/code/use-ide.ts': `import { authedFetch } from "@/lib/api/authed-fetch"\n\nexport function useIde(sessionId: string) {\n return () => authedFetch(\`/api/v1/sessions/\${sessionId}/sandbox/ide/start\`, { method: "POST" })\n}\n`, + } + expect(await calls(wrapped, { method: 'POST', path: '/sessions/{session_id}/sandbox/ide/start', operationIdPrefix: 'start_sandbox_ide_' })) + .toEqual([`web/src/app/(main)/chat/code/use-ide.ts useIde /api/v1/sessions/\${sessionId}/sandbox/ide/start`]) + }) }) diff --git a/vscode/src/codeReader/callTree/endpoint.ts b/vscode/src/codeReader/callTree/endpoint.ts index c932744..888e702 100644 --- a/vscode/src/codeReader/callTree/endpoint.ts +++ b/vscode/src/codeReader/callTree/endpoint.ts @@ -3,15 +3,12 @@ * 契约里对不上就交回找到了哪几份契约文件,不猜、不退回去只比网址结尾。 */ -import type { EndpointRoute, HttpEndpoint, IndexEntry } from '../contract' -import type { IndexedEntry, WorkspaceIndexOf } from '../workspaceIndex' +import type { EndpointRoute, HttpEndpoint } from '../contract' +import type { IndexedEntry, IndexFind } 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[]> +import { frontendRequests } from './requests' /** 接口在契约文件里没对上。 */ export interface ContractMiss { @@ -120,6 +117,6 @@ export async function endpointCalls(find: IndexFind, endpoint: HttpEndpoint): Pr } const [path] = candidates const paths = operations.filter(operation => operation.key === endpoint.method).map(operation => operation.path) - const requests = await find(REQUESTS_INDEX, endpoint.method) + const requests = await frontendRequests(find, endpoint.method) return { requests: requests.filter(({ entry }) => requestOperation(paths, entry.path) === path) } } diff --git a/vscode/src/codeReader/callTree/modules.ts b/vscode/src/codeReader/callTree/modules.ts new file mode 100644 index 0000000..fa3bbfc --- /dev/null +++ b/vscode/src/codeReader/callTree/modules.ts @@ -0,0 +1,112 @@ +/** + * import 写的模块对到工作区里的哪份文件:相对路径照写,别的按 tsconfig / jsconfig 的 compilerOptions.paths(路径别名,`@/*`)换成路径, + * 用离导入的文件最近的、写了 paths 的那个目录里的配置文件。包名(node_modules 里的)、只写了 baseUrl 的不认。 + * 文件用模块编号认:uri 去掉扩展名(`.ts`、`.tsx`、`.js` 这些),index 文件也能按所在目录导入。 + */ + +import type { IndexEntry, Node } from '../contract' +import type { IndexFind, WorkspaceIndexOf } from '../workspaceIndex' +import * as path from 'node:path' +import { named } from '../rules/reactTree' +import { jsonString, pairs } from './openapi' + +/** 配置文件里的路径别名:键是 paths,名字是写 paths 的那个键。 */ +interface AliasEntry extends IndexEntry { + /** 别名换成的路径从哪个目录算起:baseUrl,没写是配置文件所在的目录;相对配置文件所在的目录 */ + base: string + /** 各个别名(`@/*`)和它换成的路径,照写的顺序 */ + paths: { pattern: string, targets: string[] }[] +} + +function aliasesIn(root: Node): AliasEntry[] { + const options = pairs(pairs(named(root).find(node => node.type === 'object')).find(({ key }) => key === 'compilerOptions')?.value) + const paths = options.find(({ key }) => key === 'paths') + if (!paths) + return [] + return [{ + key: 'paths', + name: { startIndex: paths.keyNode.startIndex, endIndex: paths.keyNode.endIndex }, + base: jsonString(options.find(({ key }) => key === 'baseUrl')?.value) ?? '.', + paths: pairs(paths.value).map(({ key, value }) => ({ pattern: key, targets: named(value).flatMap(target => jsonString(target) ?? []) })), + }] +} + +/** tsconfig.json、tsconfig.app.json、jsconfig.json 这些(JSON 用索引专用的语法包解析,注释、结尾逗号不碍事)。 */ +const CONFIG_INDEX: WorkspaceIndexOf = { + id: 'callTree.modulePaths', + languageId: 'json', + include: '**/{tsconfig,jsconfig}*.json', + mentions: source => source.includes('"paths"'), + entries: root => aliasesIn(root), +} + +/** 能直接 import 的源码扩展名;`.js` 写法也指同名的 `.ts`。 */ +const EXTENSION = /\.(?:d\.)?[cm]?[jt]sx?$/ + +/** uri 拆成开头(协议和主机)和解码后的路径。 */ +function split(uri: string): [string, string] { + const at = /^[a-z][\w+.-]*:\/\/[^/]*/i.exec(uri)?.[0].length ?? 0 + return [uri.slice(0, at), decodeURIComponent(uri.slice(at))] +} + +/** 一份文件能被 import 成哪些模块:去掉扩展名;index 文件也是它所在的目录。 */ +export function moduleIds(uri: string): string[] { + const [root, file] = split(uri) + const base = file.replace(EXTENSION, '') + return path.posix.basename(base) === 'index' ? [root + base, root + path.posix.dirname(base)] : [root + base] +} + +/** + * 别名换成的路径,同 TypeScript:写的别名和模块一字不差的先用;带 `*` 的挑前缀最长的那个,`*` 配上的那截代进各个路径。 + * 一个都配不上时是空的。 + */ +function substitute(paths: AliasEntry['paths'], specifier: string): string[] { + const exact = paths.find(({ pattern }) => pattern === specifier) + if (exact) + return exact.targets + let best: { prefix: number, targets: string[] } | undefined + for (const { pattern, targets } of paths) { + const star = pattern.indexOf('*') + const prefix = pattern.slice(0, star) + const suffix = pattern.slice(star + 1) + if (star < 0 || specifier.length < prefix.length + suffix.length || !specifier.startsWith(prefix) || !specifier.endsWith(suffix) || prefix.length <= (best?.prefix ?? -1)) + continue + const matched = specifier.slice(prefix.length, specifier.length - suffix.length) + best = { prefix: prefix.length, targets: targets.map(target => target.replace('*', matched)) } + } + return best?.targets ?? [] +} + +/** 解析 import 的模块:导入它的文件(uri)、import 后面写的模块 → 可能是哪几个模块(模块编号,照 TypeScript 试的顺序)。 */ +export type ModuleResolver = (importer: string, specifier: string) => string[] + +/** 按工作区里眼下的配置文件解析 import 的模块。 */ +export async function moduleResolver(find: IndexFind): Promise { + const configs = (await find(CONFIG_INDEX, 'paths')).map(({ location, entry }) => { + const [root, file] = split(location.uri) + return { root, dir: path.posix.dirname(file), entry } + }) + const resolve = (root: string, dir: string, specifier: string): string[] => { + if (specifier === '.' || specifier === '..' || specifier.startsWith('./') || specifier.startsWith('../')) + return [root + path.posix.join(dir, specifier).replace(EXTENSION, '')] + const governing = configs.filter(config => config.root === root && (dir === config.dir || dir.startsWith(`${config.dir.replace(/\/$/, '')}/`))) + const nearest = Math.max(-1, ...governing.map(config => config.dir.length)) + const bases = governing + .filter(config => config.dir.length === nearest) + .flatMap(({ dir: at, entry }) => substitute(entry.paths, specifier).map(target => root + path.posix.join(at, entry.base, target).replace(EXTENSION, ''))) + return [...new Set(bases)] + } + // 同一个目录里的文件 import 同一个模块,解析出来一样 + const resolved = new Map() + return (importer, specifier) => { + const [root, file] = split(importer) + const dir = path.posix.dirname(file) + const key = `${root}${dir}\n${specifier}` + let modules = resolved.get(key) + if (!modules) { + modules = resolve(root, dir, specifier) + resolved.set(key, modules) + } + return modules + } +} diff --git a/vscode/src/codeReader/callTree/openapi.ts b/vscode/src/codeReader/callTree/openapi.ts index 85ab6d6..7585334 100644 --- a/vscode/src/codeReader/callTree/openapi.ts +++ b/vscode/src/codeReader/callTree/openapi.ts @@ -74,7 +74,8 @@ export const OPENAPI_TYPES_INDEX: WorkspaceIndexOf = { // ─── openapi*.json ─────────────────────────────────────────────────────────── -function jsonString(node: Node | null | undefined): string | undefined { +/** JSON 字符串的值;不是字符串时没有。 */ +export function jsonString(node: Node | null | undefined): string | undefined { if (node?.type !== 'string') return undefined try { @@ -87,7 +88,7 @@ function jsonString(node: Node | null | undefined): string | undefined { } /** JSON 对象的各个键值。 */ -function pairs(object: Node | null | undefined): { key: string, keyNode: Node, value?: Node }[] { +export function pairs(object: Node | null | undefined): { key: string, keyNode: Node, value?: Node }[] { if (object?.type !== 'object') return [] return named(object).flatMap((pair) => { diff --git a/vscode/src/codeReader/callTree/query.ts b/vscode/src/codeReader/callTree/query.ts index 5b82b20..9a7738f 100644 --- a/vscode/src/codeReader/callTree/query.ts +++ b/vscode/src/codeReader/callTree/query.ts @@ -5,8 +5,7 @@ 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 { IndexedEntry, IndexFind } from '../workspaceIndex' import type { CallEdge, CallRange, CallSymbol } from './model' import type { RequestEntry } from './requests' import type { CallTreeBlock, LoadedCalls } from './session' diff --git a/vscode/src/codeReader/callTree/requests.test.ts b/vscode/src/codeReader/callTree/requests.test.ts index 378cc1a..1e326e3 100644 --- a/vscode/src/codeReader/callTree/requests.test.ts +++ b/vscode/src/codeReader/callTree/requests.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { INDEX_GRAMMARS, MemoryIndexHost } from '../testSupport' import { WorkspaceIndexes } from '../workspaceIndex' -import { REQUESTS_INDEX } from './requests' +import { frontendRequests } from './requests' const STORE = [ 'export function useAgentStore() {', @@ -57,8 +57,9 @@ const AGENTS = [ '', ].join('\n') -async function requests(files: Record, method: string): Promise { - const found = await new WorkspaceIndexes(new MemoryIndexHost(files, INDEX_GRAMMARS)).find(REQUESTS_INDEX, method) +/** 方法是 method 的请求,一处一行:文件 调用者@名字位置 方法 网址 调用处。 */ +async function sent(indexes: WorkspaceIndexes, method: string): Promise { + const found = await frontendRequests((index, key) => indexes.find(index, key), method) return found.map(({ location, entry }) => { const file = location.uri.replace(`${MemoryIndexHost.FOLDER}/`, '') const call = `${entry.at.line}:${entry.at.character}` @@ -66,6 +67,10 @@ async function requests(files: Record, method: string): Promise< }) } +async function requests(files: Record, method: string): Promise { + return sent(new WorkspaceIndexes(new MemoryIndexHost(files, INDEX_GRAMMARS)), method) +} + describe('前端发请求的地方', () => { const files = { 'web/src/app/(main)/my-agents/page.tsx': PAGE, 'web/src/lib/agents.ts': AGENTS } @@ -93,3 +98,185 @@ describe('前端发请求的地方', () => { expect(await requests(files, 'DELETE')).toEqual([`web/src/lib/agents.ts useRemoveAgent@9:16 DELETE /api/v1/agents/\${id} 10:41`]) }) }) + +/** 带注释、结尾逗号的 tsconfig,`@/` 指 web/src。 */ +const TSCONFIG = [ + '{', + ' "compilerOptions": {', + ' /* Bundler mode */', + ' "paths": {', + ' "@/*": ["./src/*"],', + ' },', + ' },', + '}', + '', +].join('\n') + +/** 照搬 luffy 的 authedFetch:调用处的选项展开进给 fetch 的第二个参数,401 时自己调自己重试。 */ +const AUTHED_FETCH = [ + 'import { useAuthGate } from "@/lib/auth/auth-gate";', + '', + 'export const authedFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => {', + ' const headers = new Headers(init?.headers);', + ' const res = await fetch(input, {', + ' credentials: "include",', + ' ...init,', + ' headers,', + ' });', + ' if (res.status !== 401) return res;', + ' headers.set("x-auth-retried", "1");', + ' return authedFetch(input, { ...init, headers });', + '};', + '', +].join('\n') + +const USE_IDE = [ + 'import { useCallback } from "react";', + 'import { authedFetch } from "@/lib/api/authed-fetch";', + '', + 'export function useIde(sessionId: string) {', + ' const start = useCallback(async () => {', + ' const res = await authedFetch(', + ` \`/api/v1/sessions/\${sessionId}/sandbox/ide/start\`,`, + ' { method: "POST" },', + ' );', + ' return res.ok;', + ' }, [sessionId]);', + ` const status = () => authedFetch(\`/api/v1/sessions/\${sessionId}/sandbox/ide\`)`, + ' const guessed = (init: RequestInit) => authedFetch("/api/v1/sessions", init)', + ' const items = () => authedFetch(itemsPath(sessionId), { method: "POST" })', + ' return { start, status, guessed, items };', + '}', + '', + 'void authedFetch("/api/v1/health")', + '', +].join('\n') + +/** 第二层:把网址转给 authedFetch(相对路径导入)。 */ +const HTTP = [ + 'import { authedFetch } from "./api/authed-fetch";', + '', + 'export function send(url: string, init?: RequestInit) {', + ' return authedFetch(url, { ...init, cache: "no-store" });', + '}', + '', + 'export const postJson = (url: string, body: unknown) =>', + ' authedFetch(url, { method: "POST", body: JSON.stringify(body) });', + '', + 'export const putBoth = (url: string, init?: RequestInit) => authedFetch(url, { ...init, method: "PUT" });', + '', + 'export const byMethod = (url: string, method: string) => fetch(url, { method });', + '', + `export const prefixed = (url: string) => { url = \`/api/v1\${url}\`; return fetch(url) };`, + '', +].join('\n') + +/** 第三层:把网址转给 send,写死 DELETE。 */ +const REMOVE = [ + 'import { send } from "@/lib/http";', + '', + 'export const remove = (url: string) => send(url, { method: "DELETE" });', + '', +].join('\n') + +const SAVE = [ + 'import { byMethod, postJson, prefixed, putBoth, send } from "@/lib/http";', + 'import { remove } from "@/lib/agents";', + '', + 'export async function save(id: string) {', + ` await send(\`/api/v1/agents/\${id}\`, { method: "PATCH" });`, + ' await send("/api/v1/agents");', + ' await postJson("/api/v1/agents", { name: "a" });', + ' await putBoth("/api/v1/agents/x", {});', + ' await byMethod("/api/v1/agents", "POST");', + ' await prefixed("/agents");', + ` await remove(\`/api/v1/agents/\${id}\`);`, + '}', + '', +].join('\n') + +/** 本文件定义、没导出的包装函数,调用时带泛型实参。 */ +const REFERRAL = [ + 'import { authedFetch } from "@/lib/api/authed-fetch";', + '', + 'const getJson = async (path: string): Promise => {', + ' const res = await authedFetch(path, { method: "GET" });', + ' return res.json() as Promise;', + '};', + '', + 'export const fetchCenter = () => getJson<{ code: string }>("/api/v1/referral/center");', + '', +].join('\n') + +/** 和包装函数同名、却不是包装函数的:本文件定义的、从包导入的、导入不到的、被参数挡住的。 */ +const SAME_NAMES = [ + 'import { authedFetch as logged } from "some-lib";', + 'import { postJson } from "@/lib/http";', + 'import { send } from "@/lib/missing";', + '', + 'const authedFetch = (input: string) => console.log(input);', + '', + 'export function noise(postJson: (url: string) => void) {', + ' authedFetch("/api/v1/agents");', + ' logged("/api/v1/agents");', + ' send("/api/v1/agents");', + ' postJson("/api/v1/agents");', + '}', + '', + 'export const outside = () => postJson("/api/v1/agents", {});', + '', +].join('\n') + +const CACHE = [ + 'const getJson = (path: string) => localStorage.getItem(path);', + '', + 'export const cached = () => getJson("/api/v1/referral/center");', + '', +].join('\n') + +describe('经包装函数发的请求', () => { + const ide = { 'web/tsconfig.json': TSCONFIG, 'web/src/lib/api/authed-fetch.ts': AUTHED_FETCH, 'web/src/app/(main)/chat/code/use-ide.ts': USE_IDE } + const IDE_POST = `web/src/app/(main)/chat/code/use-ide.ts useIde@3:16 POST /api/v1/sessions/\${sessionId}/sandbox/ide/start 5:22` + + it('把收到的网址原样转给 fetch 的是包装函数:方法照 fetch 的规则从调用处的第二个参数读,网址跨行写也认,网址不是字面量的不认', async () => { + expect(await requests(ide, 'POST')).toEqual([IDE_POST]) + expect(await requests(ide, 'GET')).toEqual([ + `web/src/app/(main)/chat/code/use-ide.ts status@11:8 GET /api/v1/sessions/\${sessionId}/sandbox/ide 11:23`, + 'web/src/app/(main)/chat/code/use-ide.ts (顶层)@17:5 GET /api/v1/health 17:5', + ]) + }) + + it('多层转手、跨文件(相对路径、路径别名导入)都认;写死方法的取写死的,方法定不下来的不认', async () => { + const files = { ...ide, 'web/src/lib/http.ts': HTTP, 'web/src/lib/agents.ts': REMOVE, 'web/src/app/agents/page.tsx': SAVE } + expect(await requests(files, 'PATCH')).toEqual([`web/src/app/agents/page.tsx save@3:22 PATCH /api/v1/agents/\${id} 4:8`]) + expect(await requests(files, 'DELETE')).toEqual([`web/src/app/agents/page.tsx save@3:22 DELETE /api/v1/agents/\${id} 10:8`]) + expect(await requests(files, 'POST')).toEqual([IDE_POST, 'web/src/app/agents/page.tsx save@3:22 POST /api/v1/agents 6:8']) + expect((await requests(files, 'GET')).filter(line => line.startsWith('web/src/app/agents/'))).toEqual(['web/src/app/agents/page.tsx save@3:22 GET /api/v1/agents 5:8']) + expect(await requests(files, 'PUT')).toEqual([]) + }) + + it('调用的名字按本文件的定义、import 来源、参数判定指的是哪个函数,判定不了的不认', async () => { + const files = { ...ide, 'web/src/lib/http.ts': HTTP, 'web/src/lib/other.ts': SAME_NAMES, 'web/src/app/referral/api.ts': REFERRAL, 'web/src/app/cache/api.ts': CACHE } + expect((await requests(files, 'GET')).filter(line => !line.includes('use-ide.ts'))).toEqual(['web/src/app/referral/api.ts fetchCenter@7:13 GET /api/v1/referral/center 7:33']) + expect((await requests(files, 'POST')).filter(line => !line.includes('use-ide.ts'))).toEqual(['web/src/lib/other.ts outside@13:13 POST /api/v1/agents 13:29']) + }) + + it('包装函数、路径别名改了,结果跟着变', async () => { + const host = new MemoryIndexHost(ide, INDEX_GRAMMARS) + const indexes = new WorkspaceIndexes(host) + expect(await sent(indexes, 'POST')).toEqual([IDE_POST]) + host.emit('change', 'web/src/lib/api/authed-fetch.ts', 'export const authedFetch = async (input: string) => new Response(input)\n') + expect(await sent(indexes, 'POST')).toEqual([]) + host.emit('change', 'web/src/lib/api/authed-fetch.ts', 'export const authedFetch = (input: string, init?: RequestInit) => fetch(input, { method: "PUT" })\n') + expect(await sent(indexes, 'PUT')).toEqual([ + `web/src/app/(main)/chat/code/use-ide.ts useIde@3:16 PUT /api/v1/sessions/\${sessionId}/sandbox/ide/start 5:22`, + `web/src/app/(main)/chat/code/use-ide.ts status@11:8 PUT /api/v1/sessions/\${sessionId}/sandbox/ide 11:23`, + 'web/src/app/(main)/chat/code/use-ide.ts guessed@12:8 PUT /api/v1/sessions 12:41', + 'web/src/app/(main)/chat/code/use-ide.ts (顶层)@17:5 PUT /api/v1/health 17:5', + ]) + host.emit('change', 'web/src/lib/api/authed-fetch.ts', AUTHED_FETCH) + expect(await sent(indexes, 'POST')).toEqual([IDE_POST]) + host.emit('change', 'web/tsconfig.json', TSCONFIG.replace('./src/*', './lib/*')) + expect(await sent(indexes, 'POST')).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/callTree/requests.ts b/vscode/src/codeReader/callTree/requests.ts index 8052d57..4fd2826 100644 --- a/vscode/src/codeReader/callTree/requests.ts +++ b/vscode/src/codeReader/callTree/requests.ts @@ -1,19 +1,32 @@ /** - * 前端发 HTTP 请求的地方的工作区索引:openapi-fetch 的 `X.GET("/path", ...)` 这类(成员名是大写的 HTTP 方法), - * 和 `fetch("/path", { method: "POST" })`。路径要写成字符串字面量或模板字符串;接口的调用树拿它找前端调用了哪个接口(见 endpoint.ts)。 + * 前端发 HTTP 请求的地方:openapi-fetch 的 `X.GET("/path", ...)` 这类(成员名是大写的 HTTP 方法),`fetch("/path", { method: "POST" })`, + * 和经包装函数发的:一个函数把自己收到的网址参数原样作为第一个参数交给 fetch 或另一个包装函数(可以跨文件、可以多层),它就是包装函数, + * 调用它的地方照 fetch 的规则取网址和方法。网址要写成字符串字面量或模板字符串;接口的调用树拿它找前端调用了哪个接口(见 endpoint.ts)。 + * + * 工作区索引按文件记下相关的调用,连同被调的名字在这份文件里指的是谁(本文件的定义、import 来源、全局的 fetch); + * 谁是包装函数、方法是什么查的时候才从 fetch 起一层层认(见 senders),文件改了下回查就跟着变。 */ import type { IndexEntry, Node } from '../contract' -import type { WorkspaceIndexOf } from '../workspaceIndex' +import type { IndexedEntry, IndexFind, WorkspaceIndexOf } from '../workspaceIndex' import type { CallRange } from './model' -import { argumentsOf, isFunction, named, sameNode, stringContent, unwrap } from '../rules/reactTree' +import { importFacts } from '../languages/typescript' +import { argumentsOf, exportedAs, isFunction, named, parametersOf, sameNode, stringContent, unwrap } from '../rules/reactTree' +import { compareLocations } from '../workspaceIndex' +import { moduleIds, moduleResolver } from './modules' /** openapi-fetch 客户端上发请求的方法名,也是 fetch 的 method 认得的值。 */ const METHODS = new Set(['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE']) +/** 方法跟着调用处走:照 fetch 的规则读调用处的第二个参数(fetch 自己就是这样)。 */ +const FROM_CALLER = Symbol('fromCaller') + +/** 一处调用、一个发请求的函数的方法:大写的 HTTP 方法,或跟着调用处走。 */ +type Method = string | typeof FROM_CALLER + /** * 前端发的一个请求:键是大写的方法。名字是发请求的地方所在的最内层有名字的函数的名字(编辑器从它往上问调用者); - * 不在有名字的函数里时,名字是被调用的 fetch / GET 本身。 + * 不在有名字的函数里时,名字是被调用的 fetch / GET / 包装函数本身。 */ export interface RequestEntry extends IndexEntry { /** 写的路径,模板里的 `${...}` 照写,`?`、`#` 后面的去掉 */ @@ -24,6 +37,32 @@ export interface RequestEntry extends IndexEntry { caller?: string } +/** + * 索引记下的一处调用。键是被调的函数定义处的名字(导入的是它导出时的名字,默认导出是 default), + * `X.GET` 这类成员调用是点加成员名(`.GET`)。 + */ +interface CallEntry extends IndexEntry { + /** 被调的函数是导入的:import 后面写的模块 */ + from?: string + /** 被调的函数定义在这份文件里:定义处名字的起点下标 */ + defined?: number + /** 照 fetch 的规则从第二个参数读出的方法(见 methodOf);读不出、成员调用没有 */ + method?: Method +} + +/** 第一个参数写着网址的一处调用:名字、网址、范围、调用者同 RequestEntry。 */ +interface SendEntry extends CallEntry, Pick {} + +/** 包装函数把自己收到的网址参数原样交给被调的函数的一处:名字是包装函数定义处的名字。 */ +interface ForwardEntry extends CallEntry { + /** 包装函数叫什么 */ + wrapper: string + /** 包装函数在顶层定义、导出了时,导出时叫什么(默认导出是 default) */ + exported?: string +} + +type IndexedCall = IndexedEntry + /** 第一个参数写的路径:字符串字面量或模板字符串(`${...}` 照写),去掉 `?`、`#` 后面的;别的写法没有。 */ function pathOf(node: Node | undefined): string | undefined { if (node?.type === 'string') @@ -46,37 +85,29 @@ function keyName(key: Node | null): string | undefined { } /** - * fetch 的方法:没有第二个参数、对象里没写 method 是 GET,method 是字面量就照它; + * 照 fetch 的规则读第二个参数里的方法:没有第二个参数、对象里没写 method 是 GET,method 是字面量就照它; * method 不是字面量、第二个参数不是对象字面量、对象里带展开(可能藏着 method)都不认。 + * 包装函数转手时,fromCaller 认出它自己收到的选项(它的调用处的第二个参数):原样交出、在对象里展开它又没写 method 的, + * 方法跟着调用处走;展开了它又写了 method 的不认。 */ -function fetchMethod(init: Node | undefined): string | undefined { +function methodOf(init: Node | undefined, fromCaller: (node: Node | undefined) => boolean = () => false): Method | undefined { if (!init) return 'GET' + if (fromCaller(init)) + return FROM_CALLER if (init.type !== 'object') return undefined const members = named(init) + const spreads = members.flatMap(member => member.type === 'spread_element' ? [unwrap(named(member)[0])] : []) 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' + return spreads.length === 0 ? 'GET' : spreads.every(fromCaller) ? FROM_CALLER : undefined + if (spreads.some(fromCaller)) + return undefined 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 的没有。 @@ -103,28 +134,300 @@ function callerName(node: Node): Node | undefined { return undefined } -function requestsIn(root: Node): RequestEntry[] { - return root.descendantsOfType('call_expression').flatMap((call) => { - const request = call && requestOf(call) - if (!request) +// ─── 名字指的是谁 ───────────────────────────────────────────────────────────── + +/** 名字在这份文件里指的是什么:定义的函数(定义处的名字)、导入的(模块和导出时的名字)、某个函数的第几个参数,或别的变量。 */ +type Binding + = | { kind: 'function', name: Node } + | { kind: 'import', from: string, name: string } + | { kind: 'parameter', fn: Node, index: number } + | { kind: 'variable' } + +/** 参数、变量声明里写的各个名字(解构的、带默认值的、`...rest` 都算)。 */ +function patternNames(pattern: Node | null | undefined): string[] { + switch (pattern?.type) { + case 'identifier': + case 'shorthand_property_identifier_pattern': + return [pattern.text] + case 'object_pattern': + case 'array_pattern': + case 'rest_pattern': + return named(pattern).flatMap(patternNames) + case 'pair_pattern': + return patternNames(pattern.childForFieldName('value')) + case 'assignment_pattern': + case 'object_assignment_pattern': + return patternNames(pattern.childForFieldName('left')) + default: 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', +/** 一条语句声明的名字记进 names:函数声明和赋给 const 的函数是定义的函数,import 的是导入的(只导入类型的不算),别的是变量。 */ +function declare(statement: Node | null, names: Map): void { + const add = (name: string, binding: Binding): void => { + if (!names.has(name)) + names.set(name, binding) + } + switch (statement?.type) { + case 'export_statement': + declare(statement.childForFieldName('declaration'), names) + break + case 'function_declaration': + case 'generator_function_declaration': { + const name = statement.childForFieldName('name') + if (name) + add(name.text, { kind: 'function', name }) + break + } + case 'class_declaration': + case 'abstract_class_declaration': + patternNames(statement.childForFieldName('name')).forEach(name => add(name, { kind: 'variable' })) + break + case 'lexical_declaration': + case 'variable_declaration': + for (const declarator of named(statement).filter(child => child.type === 'variable_declarator')) { + const value = declarator.childForFieldName('value') + const name = value && isFunction(value) ? functionName(value) : undefined + if (name) + add(name.text, { kind: 'function', name }) + else + patternNames(declarator.childForFieldName('name')).forEach(declared => add(declared, { kind: 'variable' })) + } + break + case 'import_statement': { + const facts = importFacts(statement, true) + const from = stringContent(facts.sources[0]) + if (facts.typeOnly || from === undefined) + break + for (const item of facts.items) { + if (item.kind === 'named') + add((item.alias ?? item.name).text, { kind: 'import', from, name: item.name.text }) + else if (item.kind === 'default') + add(item.alias.text, { kind: 'import', from, name: 'default' }) + else if (item.alias) + add(item.alias.text, { kind: 'variable' }) + } + break + } + } +} + +/** 这一层声明的名字:函数的参数,程序和语句块里的声明,for 头里的、catch 的参数;别的节点不声明名字。 */ +function declarations(scope: Node): Map | undefined { + const names = new Map() + if (isFunction(scope)) { + parametersOf(scope).forEach(({ name }, index) => { + for (const declared of patternNames(name)) { + if (!names.has(declared)) + names.set(declared, name.type === 'identifier' ? { kind: 'parameter', fn: scope, index } : { kind: 'variable' }) + } + }) + return names + } + switch (scope.type) { + case 'program': + case 'statement_block': + named(scope).forEach(statement => declare(statement, names)) + return names + case 'for_statement': + declare(scope.childForFieldName('initializer'), names) + return names + case 'for_in_statement': + if (scope.childForFieldName('kind')) + patternNames(scope.childForFieldName('left')).forEach(name => names.set(name, { kind: 'variable' })) + return names + case 'catch_clause': + patternNames(scope.childForFieldName('parameter')).forEach(name => names.set(name, { kind: 'variable' })) + return names + default: + return undefined + } +} + +/** 一份文件里名字指的是什么:从用到它的地方往外一层层找声明,各层声明了哪些名字按节点记下。 */ +class Scopes { + private readonly declared = new Map | undefined>() + + /** 名字指的是哪个声明;哪层都没声明(全局的)时没有。 */ + resolve(name: Node): Binding | undefined { + for (let scope = name.parent; scope; scope = scope.parent) { + if (!this.declared.has(scope.id)) + this.declared.set(scope.id, declarations(scope)) + const binding = this.declared.get(scope.id)?.get(name.text) + if (binding) + return binding + } + return undefined + } + + /** node 是 fn 的第 index 个参数(同名的、被里层的声明挡住的不算)。 */ + isParameter(node: Node | null | undefined, fn: Node, index: number): boolean { + const binding = node?.type === 'identifier' ? this.resolve(node) : undefined + return binding?.kind === 'parameter' && binding.index === index && sameNode(binding.fn, fn) + } +} + +// ─── 索引 ───────────────────────────────────────────────────────────────────── + +/** 被调的是谁:这份文件定义的函数、导入的,或全局的 fetch;参数、别的变量、别的全局函数没有。 */ +function calleeOf(callee: Node, scopes: Scopes): Pick | undefined { + const binding = scopes.resolve(callee) + if (!binding) + return callee.text === 'fetch' ? { key: 'fetch' } : undefined + if (binding.kind === 'function') + return { key: binding.name.text, defined: binding.name.startIndex } + return binding.kind === 'import' ? { key: binding.name, from: binding.from } : undefined +} + +/** 函数里给它的第一个参数重新赋过值(`url = …`、`url += …`、`url++`):交出去的就不是收到的网址了。 */ +function reassigned(fn: Node, scopes: Scopes): boolean { + return fn.descendantsOfType(['assignment_expression', 'augmented_assignment_expression', 'update_expression']) + .some(node => !!node && scopes.isParameter(unwrap(node.childForFieldName('left') ?? node.childForFieldName('argument')), fn, 0)) +} + +/** 顶层定义的函数导出时叫什么(见 exportedAs);不在顶层定义、没导出时没有。 */ +function exportedName(fn: Node, name: Node): string | undefined { + const declaration = fn.type === 'function_declaration' || fn.type === 'generator_function_declaration' ? fn : fn.parent?.parent + const statement = declaration?.parent?.type === 'export_statement' ? declaration.parent : declaration + return statement?.parent?.type === 'program' ? exportedAs(statement, name.text) : undefined +} + +/** 第一个参数写着网址的一处:名字归最内层有名字的函数,不在有名字的函数里时是被调的 callee。 */ +function sendEntry(call: Node, callee: Node, target: Omit, path: string): SendEntry { + const caller = callerName(call) + const name = caller ?? callee + return { + ...target, + name: { startIndex: name.startIndex, endIndex: name.endIndex }, + path, + at: { line: call.startPosition.row, character: call.startPosition.column, endLine: call.endPosition.row, endCharacter: call.endPosition.column }, + ...(caller && { caller: caller.text }), + } +} + +/** + * 一处调用记不记:`X.GET(网址)` 这类;第一个参数写着网址、被调的是这份文件定义的函数、导入的或 fetch; + * 第一个参数是一个有名字的函数(函数声明、赋给 const 的)的第一个参数、函数里没给它重新赋过值的,记成那个函数转手。 + */ +function callOf(call: Node, scopes: Scopes): (SendEntry | ForwardEntry)[] { + const callee = unwrap(call.childForFieldName('function')) + const [first, init] = argumentsOf(call).map(unwrap) + const path = pathOf(first) + if (callee?.type === 'member_expression') { + const property = callee.childForFieldName('property') + return property && METHODS.has(property.text) && path !== undefined ? [sendEntry(call, property, { key: `.${property.text}` }, path)] : [] + } + if (callee?.type !== 'identifier' || (path === undefined && first?.type !== 'identifier')) + return [] + const target = calleeOf(callee, scopes) + if (!target) + return [] + if (path !== undefined) { + const method = methodOf(init) + return [sendEntry(call, callee, { ...target, ...(method && { method }) }, path)] + } + const binding = first && scopes.resolve(first) + const fn = binding?.kind === 'parameter' && binding.index === 0 ? binding.fn : undefined + const name = fn && functionName(fn) + if (!fn || name?.type !== 'identifier' || reassigned(fn, scopes)) + return [] + const method = methodOf(init, node => scopes.isParameter(node, fn, 1)) + const exported = exportedName(fn, name) + return [{ ...target, name: { startIndex: name.startIndex, endIndex: name.endIndex }, wrapper: name.text, ...(method && { method }), ...(exported && { exported }) }] +} + +/** + * 前端源码里和发请求有关的调用(TSX 的语法包也读得了 .ts、.js)。包装函数一层层转手最后总落到 fetch 上,跨文件要靠 import, + * 所以 fetch、import、`.GET(` 这类都不提的文件里不会有,不解析。 + */ +const CALLS_INDEX: WorkspaceIndexOf = { + id: 'callTree.calls', 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), + mentions: source => /\b(?:fetch|import)\b|\.(?:GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH|TRACE)\s*\(/.test(source), + entries: (root) => { + const scopes = new Scopes() + return root.descendantsOfType('call_expression').flatMap(call => call ? callOf(call, scopes) : []) + }, +} + +// ─── 查 ─────────────────────────────────────────────────────────────────────── + +/** 发请求的函数(fetch、认出的包装函数)的方法;转手的几处读出的方法对不上、读不出时是 null,这样的不认。 */ +type Status = Method | null + +function isSend(call: IndexedCall): call is IndexedEntry { + return 'path' in call.entry +} + +function isForward(call: IndexedCall): call is IndexedEntry { + return 'wrapper' in call.entry +} + +/** 包装函数的编号:定义在哪份文件,定义处名字的起点下标。 */ +function wrapperId(uri: string, offset: number): string { + return `${uri}#${offset}` +} + +/** + * 从 fetch 起一层层认包装函数:转手给已认出的函数的各处,所在的函数就是下一层的包装函数。方法按转手的那处读: + * 被调的写死了方法就是它,跟着调用处走的就照转手时交出的选项读;几处读出的不一样就定不下来。认出新的包装函数就接着查调它的各处, + * 直到不再有新的。被调的名字按调用处那份文件的定义、import 来源对到包装函数,对不上、对上不止一个的不认。 + * 交回查到的各处调用,和问一处调用调的函数的方法(不是发请求的函数时没有)。 + */ +async function senders(find: IndexFind): Promise<{ calls: IndexedCall[], statusOf: (call: IndexedCall) => Status | undefined }> { + const resolve = await moduleResolver(find) + const loaded = new Map() + const wrappers = new Map() + let status = new Map() + + const statusOf = ({ entry, location }: IndexedCall): Status | undefined => { + if (entry.defined !== undefined) + return status.get(wrapperId(location.uri, entry.defined)) + if (entry.from === undefined) + return entry.key === 'fetch' ? FROM_CALLER : undefined + for (const module of resolve(location.uri, entry.from)) { + const matched = [...wrappers].filter(([, wrapper]) => wrapper.exported === entry.key && moduleIds(wrapper.uri).includes(module)) + if (matched.length > 0) + return matched.length === 1 ? status.get(matched[0][0]) : undefined + } + return undefined + } + + for (let pending = ['fetch']; pending.length > 0;) { + for (const name of pending) + loaded.set(name, await find(CALLS_INDEX, name)) + const forwards = [...loaded.values()].flat().filter(isForward) + for (const { location, entry } of forwards) + wrappers.set(wrapperId(location.uri, entry.name.startIndex), { uri: location.uri, names: [entry.wrapper, ...(entry.exported ? [entry.exported] : [])], ...(entry.exported && { exported: entry.exported }) }) + for (let changed = true; changed;) { + const next = new Map() + for (const forward of forwards) { + const target = statusOf(forward) + if (target === undefined) + continue + const own = target === FROM_CALLER ? forward.entry.method ?? null : target + const id = wrapperId(forward.location.uri, forward.entry.name.startIndex) + next.set(id, next.has(id) && next.get(id) !== own ? null : own) + } + changed = next.size !== status.size || [...next].some(([id, value]) => status.get(id) !== value) + status = next + } + pending = [...new Set([...status.keys()].flatMap(id => wrappers.get(id)?.names ?? []))].filter(name => !loaded.has(name)) + } + return { calls: [...loaded.values()].flat(), statusOf } +} + +/** 前端发方法是 method 的请求的各处:`X.GET` 这类、调 fetch 的、调包装函数的,照文件、位置排。 */ +export async function frontendRequests(find: IndexFind, method: string): Promise[]> { + const { calls, statusOf } = await senders(find) + const members = await find(CALLS_INDEX, `.${method}`) + const wrapped = calls.filter(isSend).filter((call) => { + const target = statusOf(call) + return (target === FROM_CALLER ? call.entry.method : target) === method + }) + return [...members.filter(isSend), ...wrapped] + .map(({ location, entry }) => ({ location, entry: { key: method, name: entry.name, path: entry.path, at: entry.at, ...(entry.caller && { caller: entry.caller }) } })) + .sort((a, b) => compareLocations(a.location, b.location) || a.entry.at.line - b.entry.at.line || a.entry.at.character - b.entry.at.character) } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 8e9d399..e785e0c 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,6 +1,5 @@ import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode' import type { ClaudeImage } from '../../cc/spawnClaude' -import type { IndexFind } from '../callTree/endpoint' import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite' import type { BlockKind, LocalSymbol, RuleLocation, RuleReference } from '../contract' import type { OpenedFrom, PreviewDiffBases } from '../diff/diffBase' @@ -13,6 +12,7 @@ import type { LookupSources } from '../ruleLookups' import type { TreeSitterRuntime } from '../runtime' import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks' import type { ThemeColors } from '../theme/codeColors' +import type { IndexFind } from '../workspaceIndex' import type { BlockFilter } from './blockFilter' import type { FoundLocation } from './definitions' import type { ChatKind, DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, SymbolReference, WebviewToExtension } from './messages' diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index 858b370..bf32db3 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -186,7 +186,7 @@ function objectEntries(value: Node | null): EntryFacts[] | undefined { } /** import 语句:默认导入、`* as x`、`{ a as b }`、`import x = require('m')`;TypeScript(typed)的 `import type` 只导入类型。 */ -function importFacts(node: Node, typed: boolean): ImportFacts { +export function importFacts(node: Node, typed: boolean): ImportFacts { const require = named(node).find(child => child.type === 'import_require_clause') const source = (require ?? node).childForFieldName('source') const items: ImportItem[] = [] diff --git a/vscode/src/codeReader/rules/reactTree.ts b/vscode/src/codeReader/rules/reactTree.ts index 9bb4bc8..024c2f7 100644 --- a/vscode/src/codeReader/rules/reactTree.ts +++ b/vscode/src/codeReader/rules/reactTree.ts @@ -190,10 +190,13 @@ export function clientDirective(root: Node): Node | undefined { return directives(root).find(statement => stringContent(syntax(statement)[0]) === 'use client') } -/** 名字怎么导出的:声明前面写了 export(带 default 是默认导出),或文件里别处 `export default 名字` / `export { 名字 }`。 */ -function exportedAs(statement: Node, name: string): 'named' | 'default' | undefined { +/** + * 名字导出时叫什么(默认导出是 default):声明前面写了 export(带 default 是默认导出), + * 或文件里别处 `export default 名字` / `export { 名字 }` / `export { 名字 as 别名 }`;没导出时没有。 + */ +export function exportedAs(statement: Node, name: string): string | undefined { if (statement.type === 'export_statement') - return statement.children.some(child => child?.type === 'default') ? 'default' : 'named' + return statement.children.some(child => child?.type === 'default') ? 'default' : name for (const other of syntax(statement.tree.rootNode)) { if (other.type !== 'export_statement' || other.childForFieldName('source')) continue @@ -201,7 +204,7 @@ function exportedAs(statement: Node, name: string): 'named' | 'default' | undefi return 'default' for (const specifier of named(syntax(other).find(child => child.type === 'export_clause'))) { if (specifier.childForFieldName('name')?.text === name) - return specifier.childForFieldName('alias')?.text === 'default' ? 'default' : 'named' + return specifier.childForFieldName('alias')?.text ?? name } } return undefined @@ -248,7 +251,8 @@ export function componentAt(statement: Node): ComponentFacts | undefined { const body = declared.fn.childForFieldName('body') if (!body) return undefined - const exported = exportedAs(statement, declared.name.text) + const exportedName = exportedAs(statement, declared.name.text) + const exported = exportedName && (exportedName === 'default' ? 'default' : 'named') const doc = jsDocBefore(statement) return { statement, diff --git a/vscode/src/codeReader/workspaceIndex.ts b/vscode/src/codeReader/workspaceIndex.ts index 8aee90c..eddf24c 100644 --- a/vscode/src/codeReader/workspaceIndex.ts +++ b/vscode/src/codeReader/workspaceIndex.ts @@ -60,6 +60,9 @@ export interface WorkspaceIndexOf extends WorkspaceIndex { entries: (root: Node, file: IndexedFile) => E[] } +/** 查工作区索引里键是 key 的各项(宿主是 WorkspaceIndexes.find)。 */ +export type IndexFind = (index: WorkspaceIndexOf, key: string) => Promise[]> + /** 一份看过的文件:认出的各项,和建索引时问过在不在的文件(它们新建、删掉时这份要重看)。 */ interface Indexed { file: WorkspaceFile @@ -67,7 +70,8 @@ interface Indexed { asked: Set } -function compareLocations(a: RuleLocation, b: RuleLocation): number { +/** 照文件、行、列排。 */ +export function compareLocations(a: RuleLocation, b: RuleLocation): number { return a.uri.localeCompare(b.uri) || a.start.line - b.start.line || a.start.character - b.start.character }