diff --git a/vscode/src/codeReader/callTree/query.ts b/vscode/src/codeReader/callTree/query.ts index 2c6f1c0..2eb93ad 100644 --- a/vscode/src/codeReader/callTree/query.ts +++ b/vscode/src/codeReader/callTree/query.ts @@ -51,7 +51,8 @@ interface StartCallers { * 这时编辑器认不出起点也照样收前端这一路,起点按名字起点自己写一个。 * 给了 references 时查所有用到这个名字的地方:起点按名字起点自己写一个(变量),各处引用(名字自己的定义处、落在 references 这几段里的不算, * 见 [countedSites])按所在的函数、组件并成起点的调用者,从它们往上照常问编辑器。 - * 都没给、编辑器又认不出起点(数组解构出来的 `setOpen` 这类,见 [referenceCalls])时,起点的调用者同样改查引用、只留调用处, + * 都没给、编辑器又认不出起点(数组解构出来的 `setOpen`、`memo(…)` 包着的组件这类,见 [referenceCalls])时,起点的调用者同样改查引用、 + * 只留调用处(调用它、渲染成 JSX 开始标签的), * 起点同样按名字起点自己写一个。 */ export async function loadCallEdges( @@ -118,7 +119,8 @@ export async function loadCallEdges( ? countedSites(sites, { uri: document.uri.toString(), line: position.line, character: position.character }, references.map(item => toRange(rangeOf(document, item)))) : sites return referenceCalls(counted, { - after: site => textAfter(site.uri, site.range), + before: async site => (await lineText(site.uri, site.range.line)).slice(0, site.range.character), + after: async site => (await lineText(site.uri, site.range.endLine)).slice(site.range.endCharacter), outline: async uri => outlineOf(await run<(DocumentSymbol | SymbolInformation)[]>('vscode.executeDocumentSymbolProvider', Uri.parse(uri)) ?? []), prepare: async (uri, line, character) => { const [item] = await prepare(Uri.parse(uri), new Position(line, character)) @@ -187,10 +189,10 @@ function outlineOf(symbols: readonly (DocumentSymbol | SymbolInformation)[]): Ou : { range: toRange(item.location.range), selection: toRange(item.location.range), children: [] }) } -/** 这个范围所在那一行里,范围后面的文字;文件打不开时是空的。 */ -async function textAfter(uri: string, range: CallRange): Promise { +/** 文件里第 `line` 行(从 0 起)的文字,用来看一处引用的名字前后写了什么;文件打不开时是空的。 */ +async function lineText(uri: string, line: number): Promise { try { - return (await workspace.openTextDocument(Uri.parse(uri))).lineAt(range.endLine).text.slice(range.endCharacter) + return (await workspace.openTextDocument(Uri.parse(uri))).lineAt(line).text } catch { return '' diff --git a/vscode/src/codeReader/callTree/referenceCalls.test.ts b/vscode/src/codeReader/callTree/referenceCalls.test.ts index bf476cf..5687903 100644 --- a/vscode/src/codeReader/callTree/referenceCalls.test.ts +++ b/vscode/src/codeReader/callTree/referenceCalls.test.ts @@ -27,8 +27,10 @@ const OUTLINE: OutlineSymbol[] = [ const KNOWN: Record = { '27:16': 'Panel', '41:11': 'handleClick' } -function lookup(after: Record, asked: string[] = []): ReferenceLookup { +/** 问编辑器的几样换成假的:after、before 按引用起点(「行:列」)给名字后面、前面的文字,没给的是空的。 */ +function lookup(after: Record, asked: string[] = [], before: Record = {}): ReferenceLookup { return { + before: async site => before[`${site.range.line}:${site.range.character}`] ?? '', after: async site => after[`${site.range.line}:${site.range.character}`] ?? '', outline: async (uri) => { asked.push(uri) @@ -42,10 +44,30 @@ function lookup(after: Record, asked: string[] = []): ReferenceL describe('调用关系认不出的名字改查引用', () => { it('只留调用处:名字后面紧跟实参的括号,中间可以隔着空白、?.、泛型实参', () => { - expect(['(next)', ' (x)', '?.(x)', '(x)', '] = useState(false)', ' = useCallback(() => {', ', setOpen] =', ')', '.current', '} />'].map(isCallSite)) + expect(['(next)', ' (x)', '?.(x)', '(x)', '] = useState(false)', ' = useCallback(() => {', ', setOpen] =', ')', '.current', '} />'].map(after => isCallSite(' ', after))) .toEqual([true, true, true, true, false, false, false, false, false, false]) }) + it('渲染也算调用处:名字是 JSX 开始标签的标签名(前面紧挨着 <);闭合标签、成员标签、import、类型位置、当值传递的不算', () => { + const sites: [before: string, after: string][] = [ + [' <', ' title="a" />'], + [' <', '>'], + [' <', ''], + [' {open && <', ' />}'], + [' return <', ' value="x" />'], + [' 共 {n} 项<', ' />'], + [' '], + [' <', '.Item />'], + ['import { ', ', ArrowCard } from \'./Widgets\''], + ['type CardProps = Parameters[0]'], + [' const ref = useRef<', '>(null)'], + ['const registry = [', ', ArrowCard]'], + ['
'], + ] + expect(sites.map(([before, after]) => isCallSite(before, after))) + .toEqual([true, true, true, true, true, true, false, false, false, false, false, false, false]) + }) + it('每处调用往外找第一个调用关系认得的符号当调用者,同一个调用者的几处并成一项;写在文件顶层的按文件算', async () => { const sites: ReferenceSite[] = [ { uri: URI, range: range(30, 15, 7) }, @@ -64,6 +86,22 @@ describe('调用关系认不出的名字改查引用', () => { expect(asked).toEqual([URI, 'file:///main.tsx']) }) + it('渲染成 JSX 开始标签的按所在的组件并进调用者(同调用一样),闭合标签、当值传递的不算', async () => { + const sites: ReferenceSite[] = [ + { uri: URI, range: range(44, 7, 4) }, + { uri: URI, range: range(44, 27, 4) }, + { uri: URI, range: range(42, 30, 4) }, + { uri: 'file:///main.tsx', range: range(5, 5, 4) }, + ] + const before = { '44:7': ' <', '44:27': ' [', '5:5': 'root.render(<' } + const after = { '44:7': ' title="a">', '44:27': '>', '42:30': '])', '5:5': ' />)' } + const calls = await referenceCalls(sites, lookup(after, [], before), () => false, { callsOnly: true }) + expect(calls).toEqual([ + { from: 'Panel', ranges: [range(44, 7, 4)] }, + { from: '文件 file:///main.tsx', ranges: [range(5, 5, 4)] }, + ]) + }) + it('作废了就不要已经收到的', async () => { let done = false const calls = await referenceCalls([{ uri: URI, range: range(34, 4, 7) }], { @@ -78,16 +116,16 @@ describe('调用关系认不出的名字改查引用', () => { }) describe('查所有引用(React 状态卡片上的「谁用了这个状态」)', () => { - it('不只调用处:各处引用都按所在的函数并起来,不问名字后面写了什么', async () => { + it('不只调用处:各处引用都按所在的函数并起来,不问名字前后写了什么', async () => { const sites: ReferenceSite[] = [ { uri: URI, range: range(38, 20, 4) }, { uri: URI, range: range(42, 30, 4) }, { uri: URI, range: range(44, 10, 4) }, ] - const after = async (): Promise => { - throw new Error('查所有引用不看名字后面') + const around = async (): Promise => { + throw new Error('查所有引用不看名字前后') } - const calls = await referenceCalls(sites, { ...lookup({}), after }, () => false, { callsOnly: false }) + const calls = await referenceCalls(sites, { ...lookup({}), before: around, after: around }, () => false, { callsOnly: false }) expect(calls).toEqual([ { from: 'Panel', ranges: [range(38, 20, 4), range(44, 10, 4)] }, { from: 'handleClick', ranges: [range(42, 30, 4)] }, diff --git a/vscode/src/codeReader/callTree/referenceCalls.ts b/vscode/src/codeReader/callTree/referenceCalls.ts index 805a09c..65c401e 100644 --- a/vscode/src/codeReader/callTree/referenceCalls.ts +++ b/vscode/src/codeReader/callTree/referenceCalls.ts @@ -1,6 +1,7 @@ /** * 按引用找调用者。两种时候用:编辑器的调用关系认不出的名字改查引用(TypeScript 的调用关系只认函数、类、方法这些声明, - * 数组解构出来的 `setOpen`(`const [open, setOpen] = useState()`)、`const toggle = useCallback(…)` 的 toggle 都准备不出符号),只留调用处; + * 数组解构出来的 `setOpen`(`const [open, setOpen] = useState()`)、`const toggle = useCallback(…)` 的 toggle、`memo(…)` / `forwardRef(…)` 包着的组件 + * 都准备不出符号),只留调用处(见 [isCallSite]); * 查的就是所有引用(React 状态卡片上「谁用了这个状态」),各处都算,名字自己的定义处、依赖数组里写的那几处除外(见 [countedSites])。 * 每处往外找第一个调用关系认得的符号当调用者,再从它往上照常问编辑器。 * 问编辑器的动作由外面传入:测试喂假的,宿主去问编辑器命令(见 [查调用关系](vscode/src/codeReader/callTree/query.ts#loadCallEdges))。 @@ -23,6 +24,8 @@ export interface OutlineSymbol { /** 改查引用时要问编辑器的几样;Caller 是认出来的调用者。 */ export interface ReferenceLookup { + /** 引用那一行里,名字前面的文字 */ + before: (site: ReferenceSite) => Promise /** 引用那一行里,名字后面的文字 */ after: (site: ReferenceSite) => Promise /** 文件的大纲 */ @@ -41,9 +44,20 @@ export interface ReferenceCall { ranges: CallRange[] } -/** 名字后面紧跟着实参的括号,这处引用就是在调用它;中间可以隔着空白、可选调用的 `?.`、泛型实参 ``。 */ -export function isCallSite(after: string): boolean { - return /^\s*(?:\?\.\s*)?(?:<[^<>()]*>\s*)?\(/.test(after) +/** + * 这处引用算不算调用它:调用(名字后面紧跟着实参的括号),或者渲染(名字是 JSX 开始标签的标签名),同编辑器的调用关系一样都算。 + * + * - 调用:名字和括号中间可以隔着空白、可选调用的 `?.`、泛型实参 ``。 + * - 渲染:名字前面紧挨着 `<`(``、``)。闭合标签 `` 不算;`<` 前面是字母、数字时是泛型实参(`useRef`)或比较,不算; + * 名字后面跟着 `.`、`:` 的(``)渲染的是别的组件,也不算。 + * + * @param before 引用那一行里名字前面的文字 + * @param after 引用那一行里名字后面的文字 + */ +export function isCallSite(before: string, after: string): boolean { + const called = /^\s*(?:\?\.\s*)?(?:<[^<>()]*>\s*)?\(/.test(after) + const rendered = /(?:^|[^\w$/])<$/.test(before) && !/^[.:]/.test(after) + return called || rendered } /** 名字在一个文件里的一处位置(名字起点),行和列从 0 起。 */ @@ -69,7 +83,7 @@ export function countedSites(sites: readonly ReferenceSite[], declaration: SiteP /** * 各处引用按调用者并起来,照第一次出现的顺序:调用者是包着这一处、调用关系认得的最内层符号,都不认得时按文件算。 - * callsOnly 时只留调用处(名字后面紧跟着实参,见 [isCallSite])。cancelled 变成真就停,已经收到的也不要了。 + * callsOnly 时只留调用处(调用它、渲染成 JSX 开始标签的,见 [isCallSite])。cancelled 变成真就停,已经收到的也不要了。 */ export async function referenceCalls(sites: readonly ReferenceSite[], lookup: ReferenceLookup, cancelled: () => boolean, { callsOnly }: { callsOnly: boolean }): Promise[]> { const outlines = new Map>() @@ -77,7 +91,7 @@ export async function referenceCalls(sites: readonly ReferenceSite[], lo for (const site of sites) { if (cancelled()) return [] - if (callsOnly && !isCallSite(await lookup.after(site))) + if (callsOnly && !isCallSite(await lookup.before(site), await lookup.after(site))) continue const outline = outlines.get(site.uri) ?? lookup.outline(site.uri) outlines.set(site.uri, outline) diff --git a/vscode/src/codeReader/callTree/session.ts b/vscode/src/codeReader/callTree/session.ts index 0093934..fe3bee6 100644 --- a/vscode/src/codeReader/callTree/session.ts +++ b/vscode/src/codeReader/callTree/session.ts @@ -1,5 +1,5 @@ /** - * 文档发出去之后,在后台数各个调用树的次数:能看调用树的块(函数、类、接口卡片、自定义钩子)落在几棵调用树里, + * 文档发出去之后,在后台数各个调用树的次数:能看调用树的块(函数、类、接口卡片、React 的组件、自定义钩子、事件处理函数)落在几棵调用树里, * 卡片属性表里的操作(React 状态卡片解构出的各项)按它的查法数(见 [CallTarget.mode])。问到的都记下(没人调用的也记),数完把次数发给页面。 * 页面点开某一个时,问到过有调用者就把树发回去;没问过、或上回没人调用就当场问这一个,问到了补发次数再发树,没问到发一棵空的; * 接口在契约文件里没对上时,发出去的树带着 contractMiss,页面照它说明,不当成没人调用。 diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index c16408b..88a77ce 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -813,7 +813,7 @@ export interface CallTreeAction { /** 查的那个名字在源码里的范围 */ name: SourceRange /** - * 怎么查:calls 是查谁调用了它(编辑器的调用关系,认不出这个名字时改查引用、只留调用处),次数是落在几棵调用树里; + * 怎么查:calls 是查谁调用了它(编辑器的调用关系,认不出这个名字时改查引用、只留调用处和 JSX 开始标签),次数是落在几棵调用树里; * references 是查所有用到它的地方,按所在的函数、组件分组,次数是用到几处 */ mode: 'calls' | 'references' @@ -1237,7 +1237,10 @@ export interface RuleCard { languageKind?: BlockKind /** 卡片的淡底用这个颜色,不填就用种类色([RuleCard.badge] 的颜色);边框仍是种类色。React 钩子返回值的卡片描边是钩子紫,淡底按钩子从哪来分。 */ tint?: ThemeColor - /** 卡片能看调用树,查卡片自己的名字([RuleCard.defines])被谁调用,按钮在卡片右上角(React 自定义钩子);讲 HTTP 接口的卡片另见 [RuleCard.endpoint]。 */ + /** + * 卡片能看调用树,查卡片自己的名字([RuleCard.defines])被谁调用,按钮在卡片右上角:React 的组件(JSX 开始标签也算调用)、 + * 自定义钩子、事件处理函数。讲 HTTP 接口的卡片另见 [RuleCard.endpoint]。 + */ calls?: true /** * 原块里的这几个函数值(实参里的箭头函数、函数表达式)的主体也排成卡片的子块(见 [LanguageAdapter.functionBody]): diff --git a/vscode/src/codeReader/editor/pageBlocks.test.ts b/vscode/src/codeReader/editor/pageBlocks.test.ts index 73971db..80864aa 100644 --- a/vscode/src/codeReader/editor/pageBlocks.test.ts +++ b/vscode/src/codeReader/editor/pageBlocks.test.ts @@ -93,7 +93,7 @@ describe('链式调用卡片发给页面', () => { }) describe('react 钩子卡片发给页面', () => { - it('钩子返回值带着按来源分的淡底;属性表里各项的操作查的名字、不算的范围同别的下标从块第一行的行首算,键原样带着', async () => { + it('组件卡片带着能看调用树;钩子返回值带着按来源分的淡底;属性表里各项的操作查的名字、不算的范围同别的下标从块第一行的行首算,键原样带着', async () => { const ts = createTypeScriptAdapter(WASM_DIR) const source = [ 'import { useEffect, useState } from "react";', @@ -106,7 +106,10 @@ describe('react 钩子卡片发给页面', () => { ].join('\n') const views = await withBlocks(ts, 'typescriptreact', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator, { languageId: 'typescriptreact', rules: [reactRule], report: () => {} })) - const [state, id] = pageBlocks(views, source)[1].children + const component = pageBlocks(views, source)[1] + expect(component.kind).toBe('react.component') + expect(component.callTree).toEqual({}) + const [state, id] = component.children expect(state.kind).toBe('react.state') expect(state.callTree).toBeUndefined() const base = source.indexOf(' const [n') diff --git a/vscode/src/codeReader/rules/react.test.ts b/vscode/src/codeReader/rules/react.test.ts index be512ef..85b9649 100644 --- a/vscode/src/codeReader/rules/react.test.ts +++ b/vscode/src/codeReader/rules/react.test.ts @@ -157,6 +157,12 @@ describe('react 库规则:认领', () => { expect(view.children.map(child => child.slot)).toEqual([undefined, undefined, undefined, undefined]) }) + it('组件卡片能看调用树(右上角按钮),查组件名在哪被渲染、被调用', async () => { + const view = await card(TITLEBAR, 'DesktopTitlebar') + expect(view.callTree).toEqual({}) + expect(callTargets([view]).find(target => target.key === view.anchor)).toEqual({ key: view.anchor, offset: TITLEBAR.indexOf('DesktopTitlebar ='), mode: 'calls', kind: 'react.component' }) + }) + it('文件里没有组件时 "use client" 照常成块;组件上面没并进块头部的 JSDoc 讲成说明、不单独成块', async () => { expect((await views('"use client";\nexport const useX = () => 1;')).map(view => view.kind)).toEqual(['statement', 'react.customHook']) const source = ['import { memo } from "react";', '/** 一行标签 */', 'export const Label = memo(({ text }: { text: string }) => {text});'].join('\n') @@ -469,6 +475,13 @@ describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」 expect(QUEUED.slice(handler.defines?.startIndex, handler.defines?.endIndex)).toBe('handleKeyDown') }) + it('事件处理函数卡片能看调用树(右上角按钮),查它在哪被调用', async () => { + const view = await card(QUEUED, 'QueuedEditor') + const handler = childAt(view, 186) + expect(handler.callTree).toEqual({}) + expect(callTargets([handler])).toEqual([{ key: handler.anchor, offset: QUEUED.indexOf('handleKeyDown ='), mode: 'calls', kind: 'react.handler' }]) + }) + it('排队消息组 QueuedMessages:提前 return null 是如果卡片、里面是返回卡片,最后返回的 JSX 照大纲画三元和列表', async () => { const view = await card(QUEUED, 'QueuedMessages') expect(kinds(view)).toEqual(['if 36', 'variable 37', 'react.render 39']) diff --git a/vscode/src/codeReader/rules/react.ts b/vscode/src/codeReader/rules/react.ts index d430f5e..7c0c6e8 100644 --- a/vscode/src/codeReader/rules/react.ts +++ b/vscode/src/codeReader/rules/react.ts @@ -515,8 +515,8 @@ function propsRow(props: PropsFacts, entries: readonly PropEntry[], written: { d } /** - * 组件卡片:名称 / 说明 / 对不上的参数 / 构造参数 / 异常(说明里的块标签拆开放,见 propDocs)。组件体的语句照常是子块; - * 直接返回 JSX 的箭头函数没有子块,它渲染的大纲写在卡片里。 + * 组件卡片:名称 / 说明 / 对不上的参数 / 构造参数 / 异常(说明里的块标签拆开放,见 [propDocs])。组件体的语句照常是子块; + * 直接返回 JSX 的箭头函数没有子块,它渲染的大纲写在卡片里。卡片右上角能看调用树,查这个组件在哪被渲染(JSX 开始标签)、被调用。 */ function componentCard(component: ComponentFacts, env: Env): RuleCard { const { context, words } = env @@ -533,7 +533,7 @@ function componentCard(component: ComponentFacts, env: Env): RuleCard { ] if (component.body.type !== 'statement_block') rows.push(outlineRow(component.body, env)) - return card('component', rows, env, { defines: range(component.name) }) + return card('component', rows, env, { defines: range(component.name), calls: true }) } // ─── 组件体:名字用在哪 ─────────────────────────────────────────────────────── @@ -1149,6 +1149,7 @@ function eventsText(handler: HandlerFacts, analysis: Analysis, env: Env): Inline /** * 事件处理函数:名称 / 说明 / 对不上的参数 / 参数 / 返回(说明里写了 `@return` 才有)/ 异常 / 绑定事件。参数逗号隔开写在一行, * 说明里有参数对上了 `@param` 时排成一张表,多一列说明。函数里的语句照常是这张卡片的子块。 + * 卡片右上角能看调用树,查它在哪被调用;绑在 JSX 属性上(`onClick={handleClick}`)是把函数当值交出去,不算调用。 */ function handlerCard(block: CodeBlock, handler: HandlerFacts, analysis: Analysis, env: Env): RuleCard { const { context, words } = env @@ -1189,7 +1190,7 @@ function handlerCard(block: CodeBlock, handler: HandlerFacts, analysis: Analysis ...(doc?.returns ? [{ label: words.labels.returns, value: [], ...returnNote(doc) }] : []), ...throwsRows(doc), { label: words.labels.events, value: eventsText(handler, analysis, env) }, - ], env, { defines: range(handler.name) }) + ], env, { defines: range(handler.name), calls: true }) } // ─── 渲染输出 ─────────────────────────────────────────────────────────────────