diff --git a/vscode/src/codeReader/callTree/modules.ts b/vscode/src/codeReader/callTree/modules.ts index fa3bbfc..f0c0380 100644 --- a/vscode/src/codeReader/callTree/modules.ts +++ b/vscode/src/codeReader/callTree/modules.ts @@ -50,7 +50,7 @@ function split(uri: string): [string, string] { } /** 一份文件能被 import 成哪些模块:去掉扩展名;index 文件也是它所在的目录。 */ -export function moduleIds(uri: string): string[] { +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] @@ -110,3 +110,22 @@ export async function moduleResolver(find: IndexFind): Promise { return modules } } + +/** 导入的名字:import 后面写的模块,和它导出时的名字(默认导出是 default)。 */ +export interface ImportedName { + from: string + name: string +} + +/** + * 导入的名字对到哪一处导出(exports 各带所在文件的 uri 和导出时的名字):照 TypeScript 试模块的顺序, + * 头一个有这个名字导出的模块里正好一处才算;对不上、对上不止一处时没有。 + */ +export function exportOf(resolve: ModuleResolver, importer: string, imported: ImportedName, exports: readonly T[]): T | undefined { + for (const module of resolve(importer, imported.from)) { + const matched = exports.filter(candidate => candidate.exported === imported.name && moduleIds(candidate.uri).includes(module)) + if (matched.length > 0) + return matched.length === 1 ? matched[0] : undefined + } + return undefined +} diff --git a/vscode/src/codeReader/callTree/requests.test.ts b/vscode/src/codeReader/callTree/requests.test.ts index 1e326e3..f0f8fe3 100644 --- a/vscode/src/codeReader/callTree/requests.test.ts +++ b/vscode/src/codeReader/callTree/requests.test.ts @@ -280,3 +280,114 @@ describe('经包装函数发的请求', () => { expect(await sent(indexes, 'POST')).toEqual([]) }) }) + +/** 导出的常量,值引用另一份文件导出的常量;PONG 和 config.ts 的 PING 互相引用。 */ +const ROOT = [ + 'import { PING } from "./config";', + '', + 'export const API_ROOT = "/api";', + `export const PONG = \`\${PING}/pong\`;`, + '', +].join('\n') + +const CONFIG = [ + 'import { API_ROOT, PONG } from "./root";', + '', + `export const API_V1 = \`\${API_ROOT}/v1\`;`, + `export const PING = \`\${PONG}/ping\`;`, + '', +].join('\n') + +/** 照搬 luffy 的 chat/code/api.ts:网址开头拼本文件的 API_PREFIX;也拼导入的、引用常量的,和取不到值的。 */ +const SESSION_API = [ + 'import { API_V1, PING } from "@/lib/config";', + 'import { API_ROOT } from "../../../../lib/root";', + 'import { BASE } from "some-lib";', + '', + 'const API_PREFIX = "/api/v1";', + 'const VERSION = `v2`;', + `const SESSIONS = \`\${API_PREFIX}/sessions\` as const;`, + 'const DYNAMIC = process.env.API ?? "/api";', + `const DERIVED = \`\${DYNAMIC}/v1\`;`, + 'let MUTABLE = "/api/v1";', + `const LOOP_A = \`\${LOOP_B}/a\`;`, + `const LOOP_B = \`\${LOOP_A}/b\`;`, + '', + `export const cancel = (id: string) => fetch(\`\${API_PREFIX}/sessions/\${id}/cancel\`, { method: "POST" });`, + `export const versioned = (id: string) => fetch(\`/api/\${VERSION}/agents/\${id}\`);`, + `export const stream = (id: string) => fetch(\`\${SESSIONS}/\${id}/stream\`);`, + `export const models = (id: string) => fetch(\`\${API_V1}/agents/\${id}/models\`);`, + `export const health = () => fetch(\`\${API_ROOT}/health\`);`, + '', + 'export function shadowed(API_PREFIX: string) {', + ' const API_V1 = DYNAMIC;', + ` return [fetch(\`\${API_PREFIX}/agents\`), fetch(\`\${API_V1}/agents\`)];`, + '}', + '', + 'export const unresolved = (cfg: { prefix: string }) => [', + ` fetch(\`\${DYNAMIC}/agents\`),`, + ` fetch(\`\${DERIVED}/agents\`),`, + ` fetch(\`\${MUTABLE}/agents\`),`, + ` fetch(\`\${LOOP_A}/agents\`),`, + ` fetch(\`\${PING}/agents\`),`, + ` fetch(\`\${BASE}/agents\`),`, + ` fetch(\`\${cfg.prefix}/agents\`),`, + ` fetch(\`\${String(API_PREFIX)}/agents\`),`, + '];', + '', +].join('\n') + +describe('网址里拼的常量', () => { + const files = { 'web/tsconfig.json': TSCONFIG, 'web/src/lib/root.ts': ROOT, 'web/src/lib/config.ts': CONFIG, 'web/src/app/(main)/chat/code/api.ts': SESSION_API } + const API = 'web/src/app/(main)/chat/code/api.ts' + + it('网址模板里拼的名字指向本文件的字符串常量就拼进它的值,开头、中间段都算,值引用常量的逐层展开', async () => { + expect(await requests(files, 'POST')).toEqual([`${API} cancel@13:13 POST /api/v1/sessions/\${id}/cancel 13:38`]) + expect((await requests(files, 'GET')).slice(0, 2)).toEqual([ + `${API} versioned@14:13 GET /api/v2/agents/\${id} 14:41`, + `${API} stream@15:13 GET /api/v1/sessions/\${id}/stream 15:38`, + ]) + }) + + it('导入的常量(路径别名、相对路径)按 import 来源对到导出它的文件,值里又导入的接着展开', async () => { + expect((await requests(files, 'GET')).slice(2, 4)).toEqual([ + `${API} models@16:13 GET /api/v1/agents/\${id}/models 16:38`, + `${API} health@17:13 GET /api/health 17:28`, + ]) + }) + + it('被参数、局部变量挡住的,不是字面量的,有循环的,包里导入的,不是名字本身的,都照写成参数段', async () => { + expect((await requests(files, 'GET')).slice(4)).toEqual([ + `${API} shadowed@19:16 GET \${API_PREFIX}/agents 21:10`, + `${API} shadowed@19:16 GET \${API_V1}/agents 21:41`, + `${API} unresolved@24:13 GET \${DYNAMIC}/agents 25:2`, + `${API} unresolved@24:13 GET \${DERIVED}/agents 26:2`, + `${API} unresolved@24:13 GET \${MUTABLE}/agents 27:2`, + `${API} unresolved@24:13 GET \${LOOP_A}/agents 28:2`, + `${API} unresolved@24:13 GET \${PING}/agents 29:2`, + `${API} unresolved@24:13 GET \${BASE}/agents 30:2`, + `${API} unresolved@24:13 GET \${cfg.prefix}/agents 31:2`, + `${API} unresolved@24:13 GET \${String(API_PREFIX)}/agents 32:2`, + ]) + }) + + it('导出常量的文件改了,结果跟着变', async () => { + const host = new MemoryIndexHost(files, INDEX_GRAMMARS) + const indexes = new WorkspaceIndexes(host) + const imported = async (): Promise => (await sent(indexes, 'GET')).filter(line => / (?:models|health)@/.test(line)) + expect(await imported()).toEqual([ + `${API} models@16:13 GET /api/v1/agents/\${id}/models 16:38`, + `${API} health@17:13 GET /api/health 17:28`, + ]) + host.emit('change', 'web/src/lib/root.ts', 'export const API_ROOT = "/gateway";\n') + expect(await imported()).toEqual([ + `${API} models@16:13 GET /gateway/v1/agents/\${id}/models 16:38`, + `${API} health@17:13 GET /gateway/health 17:28`, + ]) + host.emit('change', 'web/src/lib/root.ts', 'const API_ROOT = "/gateway";\n') + expect(await imported()).toEqual([ + `${API} models@16:13 GET \${API_V1}/agents/\${id}/models 16:38`, + `${API} health@17:13 GET \${API_ROOT}/health 17:28`, + ]) + }) +}) diff --git a/vscode/src/codeReader/callTree/requests.ts b/vscode/src/codeReader/callTree/requests.ts index 4fd2826..7e1f1bd 100644 --- a/vscode/src/codeReader/callTree/requests.ts +++ b/vscode/src/codeReader/callTree/requests.ts @@ -1,19 +1,21 @@ /** * 前端发 HTTP 请求的地方:openapi-fetch 的 `X.GET("/path", ...)` 这类(成员名是大写的 HTTP 方法),`fetch("/path", { method: "POST" })`, * 和经包装函数发的:一个函数把自己收到的网址参数原样作为第一个参数交给 fetch 或另一个包装函数(可以跨文件、可以多层),它就是包装函数, - * 调用它的地方照 fetch 的规则取网址和方法。网址要写成字符串字面量或模板字符串;接口的调用树拿它找前端调用了哪个接口(见 endpoint.ts)。 + * 调用它的地方照 fetch 的规则取网址和方法。网址要写成字符串字面量或模板字符串,模板里的 `${名字}` 指向字符串常量的拼进常量的值, + * 别的 `${...}` 照写,当参数段;接口的调用树拿它找前端调用了哪个接口(见 endpoint.ts)。 * - * 工作区索引按文件记下相关的调用,连同被调的名字在这份文件里指的是谁(本文件的定义、import 来源、全局的 fetch); - * 谁是包装函数、方法是什么查的时候才从 fetch 起一层层认(见 senders),文件改了下回查就跟着变。 + * 工作区索引按文件记下相关的调用和导出的字符串常量,连同被调的名字、网址里的名字在这份文件里指的是谁(本文件的定义、import 来源、全局的 fetch); + * 谁是包装函数、方法是什么查的时候才从 fetch 起一层层认(见 senders),导入的常量查的时候才取值(见 pathsOf),文件改了下回查就跟着变。 */ import type { IndexEntry, Node } from '../contract' import type { IndexedEntry, IndexFind, WorkspaceIndexOf } from '../workspaceIndex' import type { CallRange } from './model' +import type { ImportedName, ModuleResolver } from './modules' 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' +import { exportOf, moduleResolver } from './modules' /** openapi-fetch 客户端上发请求的方法名,也是 fetch 的 method 认得的值。 */ const METHODS = new Set(['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE']) @@ -29,7 +31,7 @@ type Method = string | typeof FROM_CALLER * 不在有名字的函数里时,名字是被调用的 fetch / GET / 包装函数本身。 */ export interface RequestEntry extends IndexEntry { - /** 写的路径,模板里的 `${...}` 照写,`?`、`#` 后面的去掉 */ + /** 写的路径:模板里指向字符串常量的 `${名字}` 换成常量的值,别的 `${...}` 照写,`?`、`#` 后面的去掉 */ path: string /** 发请求那个调用的范围,行和列从 0 起 */ at: CallRange @@ -50,8 +52,17 @@ interface CallEntry extends IndexEntry { method?: Method } -/** 第一个参数写着网址的一处调用:名字、网址、范围、调用者同 RequestEntry。 */ -interface SendEntry extends CallEntry, Pick {} +/** + * 网址的一截:照写的字,或一个 `${...}`(written 是照写的样子)。`${名字}` 的名字指向字符串常量时,constant 记着去哪取值: + * 本文件定义的已经展开成几截,导入的记下模块和导出时的名字,查的时候才对到导出它的那份文件。 + */ +type UrlPiece = string | { written: string, constant?: UrlPiece[] | ImportedName } + +/** 第一个参数写着网址的一处调用:名字、范围、调用者同 RequestEntry。 */ +interface SendEntry extends CallEntry, Pick { + /** 写的网址,拆成几截 */ + url: UrlPiece[] +} /** 包装函数把自己收到的网址参数原样交给被调的函数的一处:名字是包装函数定义处的名字。 */ interface ForwardEntry extends CallEntry { @@ -61,23 +72,15 @@ interface ForwardEntry extends CallEntry { exported?: string } +/** 顶层定义、导出了的字符串常量:键是导出时的名字(默认导出是 default),名字是定义处的名字。 */ +interface ConstantEntry extends IndexEntry { + /** 常量的值,同网址拆成几截 */ + pieces: UrlPiece[] +} + type IndexedCall = IndexedEntry -/** 第一个参数写的路径:字符串字面量或模板字符串(`${...}` 照写),去掉 `?`、`#` 后面的;别的写法没有。 */ -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 -} +type Indexed = IndexedEntry /** 对象字面量的键名:`method`、`"method"`。 */ function keyName(key: Node | null): string | undefined { @@ -136,10 +139,11 @@ function callerName(node: Node): Node | undefined { // ─── 名字指的是谁 ───────────────────────────────────────────────────────────── -/** 名字在这份文件里指的是什么:定义的函数(定义处的名字)、导入的(模块和导出时的名字)、某个函数的第几个参数,或别的变量。 */ +/** 名字在这份文件里指的是什么:定义的函数(定义处的名字)、字符串常量(它的值)、导入的、某个函数的第几个参数,或别的变量。 */ type Binding = | { kind: 'function', name: Node } - | { kind: 'import', from: string, name: string } + | { kind: 'constant', value: Node } + | ({ kind: 'import' } & ImportedName) | { kind: 'parameter', fn: Node, index: number } | { kind: 'variable' } @@ -163,7 +167,19 @@ function patternNames(pattern: Node | null | undefined): string[] { } } -/** 一条语句声明的名字记进 names:函数声明和赋给 const 的函数是定义的函数,import 的是导入的(只导入类型的不算),别的是变量。 */ +/** 赋给 const 的字符串常量:名字和值(字符串字面量或模板字符串,`as const` 这类包裹去掉);别的声明没有。 */ +function stringConstant(declarator: Node): { name: Node, value: Node } | undefined { + const name = declarator.childForFieldName('name') + const value = unwrap(declarator.childForFieldName('value')) + if (declarator.parent?.childForFieldName('kind')?.text !== 'const' || name?.type !== 'identifier' || (value?.type !== 'string' && value?.type !== 'template_string')) + return undefined + return { name, value } +} + +/** + * 一条语句声明的名字记进 names:函数声明和赋给 const 的函数是定义的函数,赋给 const 的字符串字面量、模板字符串是字符串常量, + * import 的是导入的(只导入类型的不算),别的是变量。 + */ function declare(statement: Node | null, names: Map): void { const add = (name: string, binding: Binding): void => { if (!names.has(name)) @@ -189,8 +205,11 @@ function declare(statement: Node | null, names: Map): void { 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 + const constant = stringConstant(declarator) if (name) add(name.text, { kind: 'function', name }) + else if (constant) + add(constant.name.text, { kind: 'constant', value: constant.value }) else patternNames(declarator.childForFieldName('name')).forEach(declared => add(declared, { kind: 'variable' })) } @@ -270,6 +289,59 @@ class Scopes { // ─── 索引 ───────────────────────────────────────────────────────────────────── +/** + * 写的网址:字符串字面量或模板字符串拆成几截,别的写法没有。模板里的 `${名字}` 指向字符串常量的记下去哪取值(见 constantOf), + * outer 是正在展开的各个常量的值。 + */ +function urlOf(node: Node | undefined, scopes: Scopes, outer: readonly Node[] = []): UrlPiece[] | undefined { + if (node?.type === 'string') + return [stringContent(node) ?? ''] + if (node?.type !== 'template_string') + return undefined + return named(node).map((part) => { + if (part.type !== 'template_substitution') + return part.text + const constant = constantOf(named(part)[0], scopes, outer) + return { written: part.text, ...(constant && { constant }) } + }) +} + +/** 本文件的字符串常量的值展开成几截:值里的 `${...}` 都得指向常量才算,绕回正在展开的常量(循环)不算。 */ +function expanded(value: Node, scopes: Scopes, outer: readonly Node[]): UrlPiece[] | undefined { + if (outer.some(other => sameNode(other, value))) + return undefined + const pieces = urlOf(value, scopes, [...outer, value]) + return pieces?.every(piece => typeof piece === 'string' || piece.constant) ? pieces : undefined +} + +/** + * `${名字}` 的名字指向的字符串常量去哪取值:本文件定义的展开成几截(见 expanded),导入的是模块和导出时的名字。 + * 不是名字本身的(`${cfg.prefix}`、函数调用)、被参数和别的变量挡住的、展开不了的没有。 + */ +function constantOf(expression: Node | undefined, scopes: Scopes, outer: readonly Node[]): UrlPiece[] | ImportedName | undefined { + const binding = expression?.type === 'identifier' ? scopes.resolve(expression) : undefined + if (binding?.kind === 'import') + return { from: binding.from, name: binding.name } + return binding?.kind === 'constant' ? expanded(binding.value, scopes, outer) : undefined +} + +/** 顶层定义、导出了的字符串常量(别的文件的网址可能导入它),值展开不了的不记。 */ +function constantsOf(root: Node, scopes: Scopes): ConstantEntry[] { + return named(root).flatMap((statement) => { + const declaration = statement.type === 'export_statement' ? statement.childForFieldName('declaration') : statement + if (declaration?.type !== 'lexical_declaration') + return [] + return named(declaration).flatMap((declarator): ConstantEntry[] => { + const constant = declarator.type === 'variable_declarator' ? stringConstant(declarator) : undefined + const exported = constant && exportedAs(statement, constant.name.text) + if (!constant || !exported) + return [] + const pieces = expanded(constant.value, scopes, []) + return pieces ? [{ key: exported, name: { startIndex: constant.name.startIndex, endIndex: constant.name.endIndex }, pieces }] : [] + }) + }) +} + /** 被调的是谁:这份文件定义的函数、导入的,或全局的 fetch;参数、别的变量、别的全局函数没有。 */ function calleeOf(callee: Node, scopes: Scopes): Pick | undefined { const binding = scopes.resolve(callee) @@ -294,13 +366,13 @@ function exportedName(fn: Node, name: Node): string | undefined { } /** 第一个参数写着网址的一处:名字归最内层有名字的函数,不在有名字的函数里时是被调的 callee。 */ -function sendEntry(call: Node, callee: Node, target: Omit, path: string): SendEntry { +function sendEntry(call: Node, callee: Node, target: Omit, url: UrlPiece[]): SendEntry { const caller = callerName(call) const name = caller ?? callee return { ...target, name: { startIndex: name.startIndex, endIndex: name.endIndex }, - path, + url, at: { line: call.startPosition.row, character: call.startPosition.column, endLine: call.endPosition.row, endCharacter: call.endPosition.column }, ...(caller && { caller: caller.text }), } @@ -313,21 +385,22 @@ function sendEntry(call: Node, callee: Node, target: Omit, pa 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)] : [] + const url = property && METHODS.has(property.text) ? urlOf(first, scopes) : undefined + return property && url ? [sendEntry(call, property, { key: `.${property.text}` }, url)] : [] } - if (callee?.type !== 'identifier' || (path === undefined && first?.type !== 'identifier')) + if (callee?.type !== 'identifier') return [] const target = calleeOf(callee, scopes) if (!target) return [] - if (path !== undefined) { + const url = urlOf(first, scopes) + if (url) { const method = methodOf(init) - return [sendEntry(call, callee, { ...target, ...(method && { method }) }, path)] + return [sendEntry(call, callee, { ...target, ...(method && { method }) }, url)] } - const binding = first && scopes.resolve(first) + const binding = first?.type === 'identifier' ? scopes.resolve(first) : undefined const fn = binding?.kind === 'parameter' && binding.index === 0 ? binding.fn : undefined const name = fn && functionName(fn) if (!fn || name?.type !== 'identifier' || reassigned(fn, scopes)) @@ -338,17 +411,17 @@ function callOf(call: Node, scopes: Scopes): (SendEntry | ForwardEntry)[] { } /** - * 前端源码里和发请求有关的调用(TSX 的语法包也读得了 .ts、.js)。包装函数一层层转手最后总落到 fetch 上,跨文件要靠 import, - * 所以 fetch、import、`.GET(` 这类都不提的文件里不会有,不解析。 + * 前端源码里和发请求有关的调用,和导出的字符串常量(TSX 的语法包也读得了 .ts、.js)。包装函数一层层转手最后总落到 fetch 上, + * 跨文件要靠 import,导出常量要写 export,所以 fetch、import、export、`.GET(` 这类都不提的文件里不会有,不解析。 */ -const CALLS_INDEX: WorkspaceIndexOf = { +const CALLS_INDEX: WorkspaceIndexOf = { id: 'callTree.calls', languageId: 'typescriptreact', include: '**/*.{ts,tsx,js,jsx}', - mentions: source => /\b(?:fetch|import)\b|\.(?:GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH|TRACE)\s*\(/.test(source), + mentions: source => /\b(?:fetch|import|export)\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) : []) + return [...root.descendantsOfType('call_expression').flatMap(call => call ? callOf(call, scopes) : []), ...constantsOf(root, scopes)] }, } @@ -357,12 +430,16 @@ const CALLS_INDEX: WorkspaceIndexOf = { /** 发请求的函数(fetch、认出的包装函数)的方法;转手的几处读出的方法对不上、读不出时是 null,这样的不认。 */ type Status = Method | null -function isSend(call: IndexedCall): call is IndexedEntry { - return 'path' in call.entry +function isSend(indexed: Indexed): indexed is IndexedEntry { + return 'url' in indexed.entry } -function isForward(call: IndexedCall): call is IndexedEntry { - return 'wrapper' in call.entry +function isForward(indexed: Indexed): indexed is IndexedEntry { + return 'wrapper' in indexed.entry +} + +function isConstant(indexed: Indexed): indexed is IndexedEntry { + return 'pieces' in indexed.entry } /** 包装函数的编号:定义在哪份文件,定义处名字的起点下标。 */ @@ -373,13 +450,12 @@ function wrapperId(uri: string, offset: number): string { /** * 从 fetch 起一层层认包装函数:转手给已认出的函数的各处,所在的函数就是下一层的包装函数。方法按转手的那处读: * 被调的写死了方法就是它,跟着调用处走的就照转手时交出的选项读;几处读出的不一样就定不下来。认出新的包装函数就接着查调它的各处, - * 直到不再有新的。被调的名字按调用处那份文件的定义、import 来源对到包装函数,对不上、对上不止一个的不认。 - * 交回查到的各处调用,和问一处调用调的函数的方法(不是发请求的函数时没有)。 + * 直到不再有新的。被调的名字按调用处那份文件的定义、import 来源对到包装函数,对不上、对上不止一个的不认(见 exportOf)。 + * 交回查到的各处,和问一处调用调的函数的方法(不是发请求的函数时没有)。 */ -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() +async function senders(find: IndexFind, resolve: ModuleResolver): Promise<{ calls: Indexed[], statusOf: (call: IndexedCall) => Status | undefined }> { + const loaded = new Map() + const wrappers = new Map() let status = new Map() const statusOf = ({ entry, location }: IndexedCall): Status | undefined => { @@ -387,20 +463,18 @@ async function senders(find: IndexFind): Promise<{ calls: IndexedCall[], statusO 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 + const wrapper = exportOf(resolve, location.uri, { from: entry.from, name: entry.key }, [...wrappers.values()]) + return wrapper && status.get(wrapper.id) } 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 (const { location, entry } of forwards) { + const id = wrapperId(location.uri, entry.name.startIndex) + wrappers.set(id, { id, 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) { @@ -419,15 +493,74 @@ async function senders(find: IndexFind): Promise<{ calls: IndexedCall[], statusO return { calls: [...loaded.values()].flat(), statusOf } } +/** + * 网址拼成路径(uri 是网址所在的文件):`${名字}` 取得到常量的值就拼进去,取不到的照写;`?`、`#` 后面的去掉,照写的 `${...}` 里的不算。 + * 导入的常量和包装函数一样按 import 来源对到导出它的那处(见 exportOf),它的值里又导入的接着取;对不上、绕回正在取的(循环)取不到。 + * 一份查询里同一个常量只取一次;取值时记着正在取的,所以要一处处拼,不能同时拼。 + */ +function pathsOf(find: IndexFind, resolve: ModuleResolver): (uri: string, url: readonly UrlPiece[]) => Promise { + const values = new Map() + + /** 一个 `${...}` 指向的常量的值;不指向常量、取不到的没有。 */ + async function valueOf(uri: string, constant: UrlPiece[] | ImportedName | undefined): Promise { + if (!constant) + return undefined + if (Array.isArray(constant)) + return joined(uri, constant) + const exports = (await find(CALLS_INDEX, constant.name)).filter(isConstant).map(found => ({ uri: found.location.uri, exported: found.entry.key, found })) + const target = exportOf(resolve, uri, constant, exports) + if (!target) + return undefined + const id = `${target.uri}#${target.found.entry.name.startIndex}` + if (!values.has(id)) { + // 先占上:取值途中绕回这个常量就是循环,取不到 + values.set(id, undefined) + values.set(id, await joined(target.uri, target.found.entry.pieces)) + } + return values.get(id) + } + + /** 常量的值拼起来:每个 `${...}` 都取得到才算。 */ + async function joined(uri: string, pieces: readonly UrlPiece[]): Promise { + let text = '' + for (const piece of pieces) { + const value = typeof piece === 'string' ? piece : await valueOf(uri, piece.constant) + if (value === undefined) + return undefined + text += value + } + return text + } + + return async (uri, url) => { + let path = '' + for (const piece of url) { + const value = typeof piece === 'string' ? piece : await valueOf(uri, piece.constant) + if (value === undefined) { + path += typeof piece === 'string' ? piece : piece.written + continue + } + const end = value.search(/[?#]/) + if (end >= 0) + return path + value.slice(0, end) + path += value + } + return path + } +} + /** 前端发方法是 method 的请求的各处:`X.GET` 这类、调 fetch 的、调包装函数的,照文件、位置排。 */ export async function frontendRequests(find: IndexFind, method: string): Promise[]> { - const { calls, statusOf } = await senders(find) + const resolve = await moduleResolver(find) + const { calls, statusOf } = await senders(find, resolve) 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) + const pathOf = pathsOf(find, resolve) + const found: IndexedEntry[] = [] + for (const { location, entry } of [...members.filter(isSend), ...wrapped]) + found.push({ location, entry: { key: method, name: entry.name, path: await pathOf(location.uri, entry.url), at: entry.at, ...(entry.caller && { caller: entry.caller }) } }) + return found.sort((a, b) => compareLocations(a.location, b.location) || a.entry.at.line - b.entry.at.line || a.entry.at.character - b.entry.at.character) }