mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
`codegraph callers <composable>` missed most of the components using it in a Nuxt app (Issue #2340), through two extraction gaps: - A destructuring declaration at module or `<script setup>` scope (`const { a } = useFoo(1)`, `const [b] = useFoo(1)`) mints no symbol, and extractVariable skipped its declarator with `continue` before walking the initializer, so the call was never recorded (inside a function the body walker reached it). The declarator is now walked the way a function body walks it, with the enclosing scope on the stack: the file for module code, which a `<script setup>` block folds into its component. Defaults in the pattern and calls inside callbacks in the initializer count too. Mirrored in the native kernel (tsjs/extractors.rs), which extracts TS/JS in released builds. - The Vue extractor parsed only `<script>`, so calls in the template (`{{ useBar(link) }}`, `:to="useBar(link.location)"`, `v-if`, `v-for` sources, `@click="save(item)"`) made no reference. The new vue-template-calls scanner reads the root `<template>` quote-aware, collects interpolations and directive values, and names each callee the way a script call is named (`useBar`, `store.fetchUsers`). It skips `v-for` aliases and slot props in their element's subtree, arrow and function parameters, `$`-helpers, Vue's template globals, strings, regex literals, `new X()`, object-method shorthand, `v-pre` subtrees, custom blocks and non-HTML templates. Each call is a `calls` reference from the component at its own line and column; a bare call to the component's own Options API method is linked at extraction, since resolution only binds those through `this.`. Two resolution follow-ons, found validating on elk: - The vue-handler synthesizer resolved the leading identifier of every `@event` value, so `@click="save(item)"` got a second, line-1 edge, and `@click="query = ''"`, a slot prop's `hide()` or a `refresh()` destructured from useAsyncData were bound to any same-named function in another file. It now resolves only a bare handler name (`@click="save"`) and keeps its composable-destructure mapping. - The Nuxt auto-import rule answered a call to a file's own `clearError` or `navigateTo` with an edge from the caller to itself. A same-file declaration of that name now shadows the auto-import. Verification: new tests in vue-template-calls.test.ts (scanner shapes and four indexed projects, incl. the issue's repro), extraction.test.ts (module-scope destructuring in ts/tsx/js/jsx; script-setup + template refs, LF and CRLF) and kernel-tsjs-parity.test.ts (destructuring, kernel vs wasm). On main (src + kernel reverted) the 10 indexed/extraction tests fail and the 4 parity cases fail with the main kernel; all pass with the fix (CODEGRAPH_KERNEL_EXPECT=1). Issue repro on the built CLI with the kernel: callers useFoo 2 -> 5 (A.vue, B.vue, F.vue, G.ts, D.ts), callers useBar 0 -> 2 (H.vue, I.vue). kernel-parity.mjs over elk, nuxt/movies and fastify: 467/471 files byte-identical, 0 diffs, 4 deferred. Validation (kernel loaded, main vs branch, edges by natural key): elk 6282 -> 6633 (+443 / -92), vue-element-admin 2337 -> 2372 (+60 / -25), nuxt/movies 939 -> 959 (+26 / -6), fastify (plain JS control) no edge change. Every removed edge is a line-1 vue-handler edge whose pair is now linked at the handler's line (101), a name-collision guess (8), or an auto-import self-loop now resolved to the local function or route (14). Self-loops: elk 44 -> 30, others unchanged. Builds on #2341 by @drakeo338 (walk the destructuring initializer) and the template scan in #2073 by @L0garithmic. Co-authored-by: drakeo338 <paranoyouz@gmail.com> Co-authored-by: L0garithmic <L0garithmic@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- `codegraph status` no longer says the index is up to date while indexed files are missing their symbols: it now names files the parser couldn't read and files stored without their symbols (which `codegraph sync` repairs), `status --json` counts both, and `codegraph files --json` lists each file's recorded errors. Thanks @lipchey for the report. (#2336)
|
||||
- Indexing large Python projects is much faster again and needs less memory: since 1.6.2, resolving Python references re-read source files over and over, so a project the size of CPython took several times as long to index. The graph it builds is unchanged. Thanks @bompus for the report. (#2332)
|
||||
- In VB.NET, reading or setting a `Shared` field or property through its class or module name, like `AppSession.SessionId`, `AppSession.CurrentUser = "demo"` or `AppSession.Items(0)`, now counts as a use of that member and of the class, and so do reading an `Enum` value like `Mode.Fast` and calling a `Shared` function without parentheses. Before, only calls written with parentheses were linked, so `codegraph callers` on such a field, property or class came back empty and impact missed most of the code that depends on it. A local, parameter or field that only shares a class's name is not mistaken for the class. Thanks @serkanince for the report and @ChrisPrapas. (#2305)
|
||||
- In Vue and Nuxt components, calls written in the template (`{{ formatDate(post) }}`, `:to="getAccountRoute(account)"`, `@click="save(item)"`) and calls in a destructuring declaration at the top of `<script setup>` or of a TypeScript or JavaScript file (`const { t } = useI18n()`) now link to the function they call, so `callers` and impact for a composable or helper used this way are no longer mostly empty. A `v-for` item or slot prop is never taken for a project function, a handler like `@click="open = false"` no longer links to an unrelated function of the same name, and a component's own function named like a Nuxt helper, such as `clearError`, is now the one its calls reach. Thanks @deniskern for the report, and @drakeo338 and @L0garithmic. (#2340)
|
||||
|
||||
## [1.6.2] - 2026-10-03
|
||||
|
||||
|
||||
@@ -1202,6 +1202,44 @@ export const exported = { handler: () => target() };
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// A destructuring declaration mints no symbol, so its initializer was never
|
||||
// walked at module scope: `const { a } = useFoo(1)` recorded no call at all,
|
||||
// where the same line inside a function did (#2340).
|
||||
it.each(['ts', 'tsx', 'js', 'jsx'])('records the calls of a module-scope destructuring declaration (%s, #2340)', (ext) => {
|
||||
const code = [
|
||||
'const { a } = useFoo(1)',
|
||||
'let [b, c] = pair()',
|
||||
'var { d: { e } } = nested()',
|
||||
'const { f = fallback() } = withDefault()',
|
||||
'const [g = other(), ...rest] = list()',
|
||||
'export const { h } = exported()',
|
||||
'const { i } = await load(() => inArrow())',
|
||||
'export function probe() { const { j } = inner(); return j }',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = extractFromSource(`app.${ext}`, code);
|
||||
const byId = new Map(result.nodes.map((n) => [n.id, n]));
|
||||
const calls = result.unresolvedReferences
|
||||
.filter((r) => r.referenceKind === 'calls')
|
||||
.map((r) => `${byId.get(r.fromNodeId)?.kind}:${r.referenceName}@${r.line}`)
|
||||
.sort();
|
||||
expect(calls).toEqual([
|
||||
'file:exported@6',
|
||||
'file:fallback@4',
|
||||
'file:inArrow@7',
|
||||
'file:list@5',
|
||||
'file:load@7',
|
||||
'file:nested@3',
|
||||
'file:other@5',
|
||||
'file:pair@2',
|
||||
'file:useFoo@1',
|
||||
'file:withDefault@4',
|
||||
'function:inner@8', // inside a function: the function's, as before
|
||||
]);
|
||||
// Still no symbol for a destructured binding.
|
||||
expect(result.nodes.filter((n) => n.kind === 'constant' || n.kind === 'variable')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Node Extraction', () => {
|
||||
@@ -9165,6 +9203,33 @@ const token = getTokenMp();
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
it.each(['LF', 'CRLF'])('should attribute calls in <script setup> destructuring and in the template to the component (%s, #2340)', (ending) => {
|
||||
const lf = `<template>
|
||||
<NuxtLink :to="useBar(link.location)">{{ useBar(link) }}</NuxtLink>
|
||||
<li v-for="item in items" @click="item.open()">{{ label(item) }}</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { a } = useFoo(1)
|
||||
const [b] = useFoo(2)
|
||||
</script>
|
||||
`;
|
||||
const code = ending === 'CRLF' ? lf.replace(/\n/g, '\r\n') : lf;
|
||||
const result = extractFromSource('Card.vue', code);
|
||||
const component = result.nodes.find((n) => n.kind === 'component')!;
|
||||
const calls = result.unresolvedReferences
|
||||
.filter((r) => r.referenceKind === 'calls')
|
||||
.map((r) => `${r.fromNodeId === component.id ? 'component' : r.fromNodeId}:${r.referenceName}@${r.line}`)
|
||||
.sort();
|
||||
expect(calls).toEqual([
|
||||
'component:label@3',
|
||||
'component:useBar@2',
|
||||
'component:useBar@2',
|
||||
'component:useFoo@7',
|
||||
'component:useFoo@8',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract calls from Vue Options API object methods', () => {
|
||||
const code = `<template>
|
||||
<button @click="save">Save</button>
|
||||
|
||||
@@ -188,6 +188,30 @@ async function exprReceivers(x, y) {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
|
||||
] as const)('walks a module-scope destructuring declaration like a body does: %s (#2340)', (ext, language) => {
|
||||
const typed = language === 'typescript' || language === 'tsx';
|
||||
const result = assertParity(`fixture.${ext}`, `
|
||||
import { handler } from './h';
|
||||
const { a } = useFoo(1);
|
||||
let [b, c] = pair();
|
||||
var { d: { e } } = nested();
|
||||
const { f = fallback() } = withDefault(handler);
|
||||
const [g = other(), ...rest] = list(() => inArrow(handler));
|
||||
export const { h } = exported(new Store());
|
||||
export const { useGetUserQuery } = api;
|
||||
${typed ? 'const { k }: Shape = make();' : ''}
|
||||
function probe() { const { j } = inner(); return j; }
|
||||
export function second() { return probe(); }
|
||||
`, language);
|
||||
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
|
||||
expect(calls).toEqual(expect.arrayContaining([
|
||||
'useFoo', 'pair', 'nested', 'fallback', 'withDefault', 'other', 'list', 'inArrow', 'exported', 'inner',
|
||||
...(typed ? ['make'] : []),
|
||||
]));
|
||||
});
|
||||
|
||||
it('torture fixture (tsx): components, stores, RTK, fn-refs, value-refs, decorators', () => {
|
||||
const file = path.join(FIXTURE_DIR, 'torture.tsx');
|
||||
assertParity('fixtures/torture.tsx', fs.readFileSync(file, 'utf8'), 'tsx');
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* A call is a call wherever a Vue component writes it (#2340): in the
|
||||
* `<template>` (`{{ useBar(link) }}`, `:to="useBar(link.location)"`,
|
||||
* `@click="save(item)"`), and in a destructuring declaration at module or
|
||||
* `<script setup>` scope (`const { a } = useFoo(1)`). Neither used to record
|
||||
* one, so `callers` on a Nuxt composable missed most of the components using
|
||||
* it. (`src/extraction/vue-template-calls.ts`, wired in `vue-extractor.ts`;
|
||||
* the destructuring walk is in `tree-sitter.ts` extractVariable and the
|
||||
* kernel's `tsjs/extractors.rs`.)
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { vueTemplateCalls } from '../src/extraction/vue-template-calls';
|
||||
|
||||
const names = (source: string) => vueTemplateCalls(source).map((c) => c.name);
|
||||
|
||||
describe('vueTemplateCalls', () => {
|
||||
it('finds calls in interpolations, bindings, directives and handlers, at their offsets', () => {
|
||||
const source = `<template>
|
||||
<NuxtLink :to="useBar(link.location)" v-if="canShow(link)">{{ useBar(link) }}</NuxtLink>
|
||||
<li v-for="row in rowsFor(user)" v-show="visible()" v-my-dir="setup(1)">{{ label(row) }}</li>
|
||||
<button @click="save(item)" v-on:keyup.enter="submit()" @input="onInput">go</button>
|
||||
<Comp :style="{ color: tint() }" :title="\`#\${index(n)}\`" @change="(v) => update(v)" />
|
||||
</template>
|
||||
`;
|
||||
const calls = vueTemplateCalls(source);
|
||||
expect(calls.map((c) => c.name)).toEqual([
|
||||
'useBar', 'canShow', 'useBar',
|
||||
'rowsFor', 'visible', 'setup', 'label',
|
||||
'save', 'submit',
|
||||
'tint', 'index', 'update',
|
||||
]);
|
||||
// Offsets point at the callee in the SFC source.
|
||||
for (const c of calls) expect(source.slice(c.offset, c.offset + c.name.length)).toBe(c.name);
|
||||
});
|
||||
|
||||
it('names a member call by its path, as a script call is named', () => {
|
||||
expect(names(`<template>
|
||||
<p @click="store.fetchUsers()" :a="user?.profile?.load()" :b="fn?.(1)">{{ props.format(v) }}</p>
|
||||
<p :c="list[0].go()" :d="make().then(x => x.done())">{{ (a as B).run() }}</p>
|
||||
</template>`)).toEqual(['store.fetchUsers', 'user.profile.load', 'fn', 'props.format', 'make']);
|
||||
});
|
||||
|
||||
it('skips names the template binds: v-for aliases and slot props in their subtree, arrow and function parameters', () => {
|
||||
expect(names(`<template>
|
||||
<ul>
|
||||
<li v-for="(item, i) in items" :key="item.id" @click="item.select(i)">
|
||||
{{ item.label() }} {{ format(item) }}
|
||||
<Row #default="{ row, toggle }">{{ toggle(row) }} {{ rowLabel(row) }}</Row>
|
||||
</li>
|
||||
</ul>
|
||||
<Comp :cb="function (x) { return x.go() }" :keys="(k) => k.trim()" @done="cb => cb()" />
|
||||
<p>{{ toggle() }} {{ item() }}</p>
|
||||
</template>`)).toEqual(['format', 'rowLabel', 'toggle', 'item']);
|
||||
});
|
||||
|
||||
it('skips what is never project code: strings, Vue template globals, $-helpers, keywords, new, object methods', () => {
|
||||
expect(names(`<template>
|
||||
<p :title="$t('x')" :class="'a(b)'">{{ Math.max(a(), 1) }} {{ JSON.stringify(c) }} {{ $route.query.x }}</p>
|
||||
<p v-if="typeof x === 'function' && /f(o)/.test(s)">{{ new Date(stamp()).getTime() }} {{ "no()" }}</p>
|
||||
<Chart :options="{ formatter(v) { return fmt(v) } }" @click="$emit('close')" />
|
||||
</template>`)).toEqual(['a', 'stamp', 'fmt']);
|
||||
});
|
||||
|
||||
it('reads a CRLF file the same way', () => {
|
||||
const lf = '<template>\n <p\n :title="label(\n item)"\n >{{ useBar(x) }}</p>\n</template>\n';
|
||||
const crlf = lf.replace(/\n/g, '\r\n');
|
||||
expect(names(crlf)).toEqual(['label', 'useBar']);
|
||||
for (const c of vueTemplateCalls(crlf)) expect(crlf.slice(c.offset, c.offset + c.name.length)).toBe(c.name);
|
||||
});
|
||||
|
||||
it('reads only the root template, and only what Vue compiles', () => {
|
||||
expect(names(`<template>
|
||||
<!-- {{ commented() }} -->
|
||||
<div v-pre>{{ raw() }} <span :x="raw()"></span></div>
|
||||
<template v-if="cond()"><span>{{ inner() }}</span></template>
|
||||
<p data-x="plain(1)">{{ after() }}</p>
|
||||
</template>
|
||||
<script setup>
|
||||
const x = scriptCall()
|
||||
</script>
|
||||
<i18n>{ "en": { "a": "{{ notTemplate() }}" } }</i18n>`)).toEqual(['cond', 'inner', 'after']);
|
||||
expect(names('<template lang="pug">\ndiv {{ pug() }}\n</template>')).toEqual([]);
|
||||
expect(names('<script setup>\nconst a = b()\n</script>')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calls written in Vue components, indexed (#2340)', () => {
|
||||
let root = '';
|
||||
let cg: CodeGraph | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
cg?.close();
|
||||
cg = undefined;
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
root = '';
|
||||
});
|
||||
|
||||
function write(files: Record<string, string>): void {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vue-calls-'));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
}
|
||||
|
||||
function callersOf(name: string, file: string): string[] {
|
||||
const target = cg!.getNodesInFile(file).find((n) => n.name === name && (n.kind === 'function' || n.kind === 'method'));
|
||||
expect(target, `${name} in ${file}`).toBeDefined();
|
||||
return cg!.getCallers(target!.id).map(({ node }) => `${node.filePath}:${node.kind}`).sort();
|
||||
}
|
||||
|
||||
it("finds every caller in the issue's repro", async () => {
|
||||
write({
|
||||
'package.json': JSON.stringify({ name: 'nuxt-app', dependencies: { nuxt: '^4.0.0' } }),
|
||||
'composables/useFoo.ts': 'export function useFoo(n: number) { return { a: n }; }\n',
|
||||
'composables/useBar.ts': 'export function useBar(x: unknown) { return String(x); }\n',
|
||||
'components/A.vue': '<script setup lang="ts">\nconst { a } = useFoo(1)\n</script>\n',
|
||||
'components/F.vue': '<script setup lang="ts">\nconst [b] = useFoo(1)\n</script>\n',
|
||||
'components/B.vue': '<script setup lang="ts">\nconst x = useFoo(1)\n</script>\n',
|
||||
'components/H.vue': '<template><div>{{ useBar(link) }}</div></template>\n',
|
||||
'components/I.vue': '<template><NuxtLink :to="useBar(link.location)">x</NuxtLink></template>\n',
|
||||
'G.ts': 'const { a } = useFoo(1)\nexport const g = a\n',
|
||||
'D.ts': 'export function probe() { const { a } = useFoo(1); return a }\n',
|
||||
});
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
|
||||
expect(callersOf('useFoo', 'composables/useFoo.ts')).toEqual([
|
||||
'D.ts:function', // inside a function: already found
|
||||
'G.ts:file', // module scope: the file is the caller
|
||||
'components/A.vue:component',
|
||||
'components/B.vue:component',
|
||||
'components/F.vue:component',
|
||||
]);
|
||||
expect(callersOf('useBar', 'composables/useBar.ts')).toEqual([
|
||||
'components/H.vue:component',
|
||||
'components/I.vue:component',
|
||||
]);
|
||||
});
|
||||
|
||||
it('links a template call to an imported helper, a script-setup function and an Options API method', async () => {
|
||||
write({
|
||||
'utils/format.ts': 'export function formatPrice(n: number) { return `$${n}`; }\n',
|
||||
'other/format.ts': 'export function formatPrice(n: number) { return n; }\n',
|
||||
'components/Setup.vue': `<template>
|
||||
<p>{{ formatPrice(item.price) }}</p>
|
||||
<button @click="save(item)">save</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatPrice } from '../utils/format';
|
||||
const props = defineProps<{ item: { price: number } }>();
|
||||
function save(item: unknown) { return item; }
|
||||
</script>
|
||||
`,
|
||||
'components/Options.vue': `<template>
|
||||
<p>{{ total(items) }}</p>
|
||||
<button @click="submit(form)">send</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
methods: {
|
||||
total(items) { return items.length },
|
||||
submit(form) { return form }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
`,
|
||||
});
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
|
||||
const edgesFrom = (file: string) => {
|
||||
const comp = cg!.getNodesInFile(file).find((n) => n.kind === 'component')!;
|
||||
return cg!.getOutgoingEdges(comp.id)
|
||||
.filter((e) => e.kind === 'calls')
|
||||
.map((e) => {
|
||||
const t = cg!.getNode(e.target)!;
|
||||
return `${t.name}@${t.filePath}:${e.line}`;
|
||||
})
|
||||
.sort();
|
||||
};
|
||||
// The import decides which `formatPrice`; each edge is on its template line.
|
||||
expect(edgesFrom('components/Setup.vue')).toEqual([
|
||||
'formatPrice@utils/format.ts:2',
|
||||
'save@components/Setup.vue:3',
|
||||
]);
|
||||
// A template calls the component's own methods without `this.`.
|
||||
expect(edgesFrom('components/Options.vue')).toEqual([
|
||||
'submit@components/Options.vue:3',
|
||||
'total@components/Options.vue:2',
|
||||
]);
|
||||
});
|
||||
|
||||
it("calls a component's own function even where Nuxt auto-imports one of that name", async () => {
|
||||
write({
|
||||
'package.json': JSON.stringify({ name: 'nuxt-app', dependencies: { nuxt: '^4.0.0' } }),
|
||||
'pages/lists.vue': `<template>
|
||||
<button @click="clearError(true)">x</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
function clearError(focus: boolean) { return focus; }
|
||||
onDeactivated(() => clearError(false));
|
||||
</script>
|
||||
`,
|
||||
});
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
|
||||
const nodes = cg.getNodesInFile('pages/lists.vue');
|
||||
const comp = nodes.find((n) => n.kind === 'component')!;
|
||||
const own = nodes.find((n) => n.kind === 'function' && n.name === 'clearError')!;
|
||||
const calls = cg.getOutgoingEdges(comp.id).filter((e) => e.kind === 'calls');
|
||||
// Both calls are the local function's — not Nuxt's `clearError`, which
|
||||
// the framework rule used to answer with an edge from the component to itself.
|
||||
expect(calls.filter((e) => e.target === own.id).map((e) => e.line).sort()).toEqual([2, 7]);
|
||||
expect(calls.filter((e) => e.target === comp.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a bare handler linked, and binds a handler expression only through its own call', async () => {
|
||||
write({
|
||||
'lib/bus.ts': 'export class Bus {\n emit(event: string) { return event; }\n}\n',
|
||||
'components/Dialog.vue': `<template>
|
||||
<button @click="close">x</button>
|
||||
<button @click="emit('cancel')">cancel</button>
|
||||
<button @click="open = !open">toggle</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['cancel']);
|
||||
let open = false;
|
||||
function close() { open = false; }
|
||||
</script>
|
||||
`,
|
||||
});
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
|
||||
const comp = cg.getNodesInFile('components/Dialog.vue').find((n) => n.kind === 'component')!;
|
||||
const targets = cg.getOutgoingEdges(comp.id)
|
||||
.filter((e) => e.kind === 'calls')
|
||||
.map((e) => cg!.getNode(e.target)!)
|
||||
.map((n) => `${n.kind}:${n.name}`);
|
||||
// `@click="close"` is still the handler it names.
|
||||
expect(targets).toContain('function:close');
|
||||
// `emit('cancel')` is the component's `defineEmits` binding, and `open =
|
||||
// !open` calls nothing: neither reaches Bus.emit.
|
||||
expect(targets).not.toContain('method:emit');
|
||||
});
|
||||
});
|
||||
@@ -387,7 +387,7 @@ impl<'t> Walker<'t> {
|
||||
let Some(name_node) = child.child_by_field_name("name") else { continue };
|
||||
let value = child.child_by_field_name("value");
|
||||
|
||||
// Destructured patterns are skipped — except RTK Query generated
|
||||
// Destructured patterns mint no node — except RTK Query generated
|
||||
// hooks (`export const { useGetXQuery } = api`).
|
||||
if matches!(name_node.kind(), "object_pattern" | "array_pattern") {
|
||||
if name_node.kind() == "object_pattern"
|
||||
@@ -395,6 +395,11 @@ impl<'t> Walker<'t> {
|
||||
{
|
||||
self.extract_rtk_hook_bindings(name_node, is_exported);
|
||||
}
|
||||
// The initializer (and any default in the pattern) still runs:
|
||||
// walk the declarator as a function body walks it, with the
|
||||
// enclosing scope on the stack (#2340). Mirrors
|
||||
// TreeSitterExtractor.extractVariable.
|
||||
self.visit_function_body(child);
|
||||
continue;
|
||||
}
|
||||
let name = self.text(name_node).to_string();
|
||||
|
||||
@@ -2989,6 +2989,14 @@ export class TreeSitterExtractor {
|
||||
if (nameNode.type === 'object_pattern' && valueNode?.type === 'identifier') {
|
||||
this.extractRtkHookBindings(nameNode, isExported);
|
||||
}
|
||||
// A pattern declares no symbol of its own, but its initializer
|
||||
// (and any default value in the pattern) still runs. Walk the
|
||||
// declarator the way a function body walks it, with the
|
||||
// enclosing scope on the stack: the file for module code, which
|
||||
// a `<script setup>` block hands to its component. Without this
|
||||
// `const { data } = useFetch()` recorded no call at all (#2340).
|
||||
// Mirrored in the kernel (tsjs/extractors.rs extract_variable).
|
||||
this.visitFunctionBody(child, '');
|
||||
continue;
|
||||
}
|
||||
const name = getNodeText(nameNode, this.source);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TreeSitterExtractor } from './tree-sitter';
|
||||
import { isLanguageSupported } from './grammars';
|
||||
import { foldScriptResult, sfcFileNode } from './sfc-script';
|
||||
import { vueOptionsMembers } from './vue-options-api';
|
||||
import { vueTemplateCalls } from './vue-template-calls';
|
||||
|
||||
/**
|
||||
* Vue built-in components — skipped so a `<Transition>` / `<KeepAlive>` in the
|
||||
@@ -74,6 +75,11 @@ export class VueExtractor {
|
||||
// markup (incl. through a barrel import) is invisible to callers /
|
||||
// impact (#629 follow-up).
|
||||
this.extractTemplateComponents(componentNode.id);
|
||||
|
||||
// Calls the template makes — `{{ useBar(link) }}`, `:to="localePath(x)"`,
|
||||
// `@click="save(item)"` — are the component's, like the calls its
|
||||
// `<script setup>` makes (#2340).
|
||||
this.extractTemplateCalls(componentNode.id);
|
||||
} catch (error) {
|
||||
this.errors.push({
|
||||
message: `Vue extraction error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -326,4 +332,50 @@ export class VueExtractor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls written in the `<template>` (see ./vue-template-calls), each made by
|
||||
* the component at the line it is written on — the same `calls` reference a
|
||||
* call in `<script setup>` makes, so a composable or helper used only in
|
||||
* markup has its callers.
|
||||
*
|
||||
* A template calls an Options API component's own methods by their bare
|
||||
* name (`{{ price(item) }}`, `@click="save(form)"`), where script code
|
||||
* writes `this.save()`. Resolution only binds a bare call to a component
|
||||
* method when it reads `this.` at the call site, so these are linked here,
|
||||
* where the method is known to be this component's.
|
||||
*/
|
||||
private extractTemplateCalls(componentNodeId: string): void {
|
||||
const calls = vueTemplateCalls(this.source);
|
||||
if (calls.length === 0) return;
|
||||
const component = this.nodes.find((n) => n.id === componentNodeId);
|
||||
const ownMethods = new Map<string, string>();
|
||||
for (const n of this.nodes) {
|
||||
if (n.kind === 'method' && n.qualifiedName === `${component?.name}::${n.name}`) ownMethods.set(n.name, n.id);
|
||||
}
|
||||
const lineStarts = [0];
|
||||
for (let i = 0; i < this.source.length; i++) {
|
||||
if (this.source.charCodeAt(i) === 10) lineStarts.push(i + 1);
|
||||
}
|
||||
let line = 0;
|
||||
for (const call of calls) {
|
||||
// Calls come in source order, so the line only moves forward.
|
||||
while (line + 1 < lineStarts.length && lineStarts[line + 1]! <= call.offset) line++;
|
||||
const column = call.offset - lineStarts[line]!;
|
||||
const ownMethod = call.name.includes('.') ? undefined : ownMethods.get(call.name);
|
||||
if (ownMethod) {
|
||||
this.edges.push({ source: componentNodeId, target: ownMethod, kind: 'calls', line: line + 1, column });
|
||||
continue;
|
||||
}
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: componentNodeId,
|
||||
referenceName: call.name,
|
||||
referenceKind: 'calls',
|
||||
line: line + 1, // 1-indexed
|
||||
column,
|
||||
filePath: this.filePath,
|
||||
language: 'vue',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* Calls written in a Vue single-file component's `<template>` (#2340).
|
||||
*
|
||||
* A template runs code as surely as the `<script>` does: `{{ formatDate(at) }}`,
|
||||
* `:to="localePath(link)"`, `v-if="canEdit(user)"` and `@click="save(item)"`
|
||||
* call a function every time the component renders or the event fires. The
|
||||
* Vue extractor hands only `<script>` blocks to the TypeScript extractor, so
|
||||
* none of these were calls: `callers` on a Nuxt composable used in markup
|
||||
* came back empty.
|
||||
*
|
||||
* This finds them without a template compiler. A tag scanner reads the root
|
||||
* `<template>` (quote-aware, so a `>` inside an attribute value never ends a
|
||||
* tag) and collects the expressions Vue compiles: `{{ }}` interpolations in
|
||||
* text, and the values of directives (`:x` / `v-bind`, `@x` / `v-on`, `v-if`,
|
||||
* `v-show`, `v-model`, `v-for`'s source, custom `v-*`). A small JS lexer then
|
||||
* names each call's callee the way a script-side call is named (`useBar`,
|
||||
* `store.fetchUsers`), skipping strings and comments and reading into
|
||||
* template-literal substitutions.
|
||||
*
|
||||
* What is never a call to project code is dropped: names the template binds
|
||||
* itself (`v-for` aliases and slot props for their element's subtree, arrow
|
||||
* and function parameters for the rest of their expression), `$`-prefixed
|
||||
* instance helpers (`$t`, `$emit`, `$router.push`), the globals Vue lets a
|
||||
* template reach (`Math`, `JSON`, `Date`...), keywords, `new X()`, and an
|
||||
* object literal's method shorthand. A `v-pre` subtree is not compiled by Vue
|
||||
* and is skipped, as is a template in another language (`lang="pug"`).
|
||||
* Approach after the template-binding scan in #2073 by @L0garithmic.
|
||||
*/
|
||||
|
||||
export interface VueTemplateCall {
|
||||
/** The callee as a script-side call names it: `useBar`, `store.fetchUsers`. */
|
||||
name: string;
|
||||
/** Offset of the callee's first character in the SFC source. */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** Elements that never have children: no closing tag, no scope of their own. */
|
||||
const VOID_ELEMENTS = new Set([
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
|
||||
'param', 'source', 'track', 'wbr',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The globals a Vue template can reach — Vue's own allow-list
|
||||
* (`isGloballyAllowed` in @vue/shared). Every other name in a template is the
|
||||
* component's: a script binding, a prop, an auto-import.
|
||||
*/
|
||||
const TEMPLATE_GLOBALS = new Set([
|
||||
'Infinity', 'undefined', 'NaN', 'isFinite', 'isNaN', 'parseFloat', 'parseInt',
|
||||
'decodeURI', 'decodeURIComponent', 'encodeURI', 'encodeURIComponent', 'Math',
|
||||
'Number', 'Date', 'Array', 'Object', 'Boolean', 'String', 'RegExp', 'Map', 'Set',
|
||||
'JSON', 'Intl', 'BigInt', 'console', 'Error', 'Symbol',
|
||||
]);
|
||||
|
||||
/** Words that can stand before `(` without being a call. */
|
||||
const KEYWORDS = new Set([
|
||||
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default', 'break',
|
||||
'continue', 'return', 'throw', 'try', 'catch', 'finally', 'function', 'class',
|
||||
'new', 'delete', 'typeof', 'void', 'instanceof', 'in', 'of', 'var', 'let',
|
||||
'const', 'this', 'super', 'import', 'export', 'await', 'async', 'yield', 'with',
|
||||
'debugger', 'true', 'false', 'null', 'undefined',
|
||||
]);
|
||||
|
||||
/** Keywords that end an operand, as an identifier does (`this`, `true`). */
|
||||
const VALUE_KEYWORDS = new Set(['this', 'super', 'true', 'false', 'null', 'undefined']);
|
||||
|
||||
const IDENT_START = /[A-Za-z_$]/;
|
||||
const IDENT_CHAR = /[\w$]/;
|
||||
const IDENTIFIERS = /[A-Za-z_$][\w$]*/g;
|
||||
const WS = /\s/;
|
||||
|
||||
/** Vue's own `v-for` split: aliases, then `in` / `of`, then the source expression. */
|
||||
const FOR_ALIAS = /([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/;
|
||||
|
||||
/** Every call written in the root `<template>` of a Vue SFC, in source order. */
|
||||
export function vueTemplateCalls(source: string): VueTemplateCall[] {
|
||||
// Blank <script>/<style> blocks and HTML comments, keeping every offset.
|
||||
const blank = (m: string) => m.replace(/[^\n]/g, ' ');
|
||||
const masked = source
|
||||
.replace(/<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1\s*>/g, blank)
|
||||
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||
const root = rootTemplate(masked);
|
||||
if (!root) return [];
|
||||
const calls: VueTemplateCall[] = [];
|
||||
scanMarkup(masked, root.start, root.end, calls);
|
||||
return calls.sort((a, b) => a.offset - b.offset);
|
||||
}
|
||||
|
||||
/** The content range of the SFC's root `<template>`; null for none or a non-HTML one. */
|
||||
function rootTemplate(s: string): { start: number; end: number } | null {
|
||||
const tagRe = /<(\/?)template(?=[\s/>])((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
let start = -1;
|
||||
let depth = 0;
|
||||
while ((m = tagRe.exec(s)) !== null) {
|
||||
const closing = m[1] === '/';
|
||||
const attrs = m[2] ?? '';
|
||||
if (start === -1) {
|
||||
if (closing || attrs.trimEnd().endsWith('/')) continue;
|
||||
const lang = /\blang\s*=\s*["']?([\w-]+)/.exec(attrs)?.[1];
|
||||
if (lang && lang.toLowerCase() !== 'html') return null;
|
||||
start = m.index + m[0].length;
|
||||
depth = 1;
|
||||
} else if (closing) {
|
||||
if (--depth === 0) return { start, end: m.index };
|
||||
} else if (!attrs.trimEnd().endsWith('/')) {
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
return start === -1 ? null : { start, end: s.length };
|
||||
}
|
||||
|
||||
interface Attr {
|
||||
name: string;
|
||||
value: string | null;
|
||||
/** Offset of the value's first character (inside any quotes). */
|
||||
valueStart: number;
|
||||
}
|
||||
|
||||
interface Frame {
|
||||
tag: string;
|
||||
locals: Set<string> | null;
|
||||
pre: boolean;
|
||||
}
|
||||
|
||||
/** The markup of `s[start, end)`: element nesting, directive values, interpolations. */
|
||||
function scanMarkup(s: string, start: number, end: number, out: VueTemplateCall[]): void {
|
||||
const stack: Frame[] = [];
|
||||
const inScope = (name: string, own?: Set<string>) =>
|
||||
own?.has(name) === true || stack.some((f) => f.locals?.has(name) === true);
|
||||
const inPre = () => stack.some((f) => f.pre);
|
||||
const closeTag = /<\/([A-Za-z][^\s/>]*)\s*>/y;
|
||||
|
||||
// The next `{{` at or after `i`, searched for again only once `i` passes it
|
||||
// (a fresh search per tag would rescan the rest of a mustache-free template).
|
||||
let nextMustache = -1;
|
||||
let i = start;
|
||||
while (i < end) {
|
||||
if (nextMustache < i) {
|
||||
const mu = s.indexOf('{{', i);
|
||||
nextMustache = mu === -1 || mu >= end ? end : mu;
|
||||
}
|
||||
const lt = s.indexOf('<', i);
|
||||
const nextTag = lt === -1 || lt >= end ? end : lt;
|
||||
if (nextMustache < nextTag) {
|
||||
// A `{{ }}` interpolation runs to the first `}}`, as Vue's parser reads it.
|
||||
const close = s.indexOf('}}', nextMustache + 2);
|
||||
const exprEnd = close === -1 || close > end ? end : close;
|
||||
if (!inPre()) scanExpression(s, nextMustache + 2, exprEnd, (n) => inScope(n), out);
|
||||
i = exprEnd + 2;
|
||||
continue;
|
||||
}
|
||||
if (nextTag >= end) break;
|
||||
|
||||
closeTag.lastIndex = nextTag;
|
||||
const cm = closeTag.exec(s);
|
||||
if (cm) {
|
||||
const name = cm[1]!;
|
||||
for (let k = stack.length - 1; k >= 0; k--) {
|
||||
if (stack[k]!.tag === name || stack[k]!.tag.toLowerCase() === name.toLowerCase()) {
|
||||
stack.length = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
i = nextTag + cm[0].length;
|
||||
continue;
|
||||
}
|
||||
if (!/[A-Za-z]/.test(s[nextTag + 1] ?? '')) {
|
||||
i = nextTag + 1; // a `<` in text, not a tag
|
||||
continue;
|
||||
}
|
||||
|
||||
const tag = parseOpenTag(s, nextTag, end);
|
||||
i = tag.close;
|
||||
// Names this element binds for itself and its subtree: `v-for` aliases,
|
||||
// slot props (`#item="{ item }"`, `v-slot="props"`, Vue 2 `slot-scope`).
|
||||
let own: Set<string> | null = null;
|
||||
let pre = false;
|
||||
let forSource: { start: number; end: number } | null = null;
|
||||
for (const a of tag.attrs) {
|
||||
if (a.name === 'v-pre') pre = true;
|
||||
if (a.value === null) continue;
|
||||
const kind = directiveKind(a.name);
|
||||
if (kind === 'for') {
|
||||
const fm = FOR_ALIAS.exec(a.value);
|
||||
if (fm) {
|
||||
own ??= new Set();
|
||||
for (const id of fm[1]!.match(IDENTIFIERS) ?? []) own.add(id);
|
||||
forSource = { start: a.valueStart + a.value.length - fm[2]!.length, end: a.valueStart + a.value.length };
|
||||
}
|
||||
} else if (kind === 'slot') {
|
||||
own ??= new Set();
|
||||
for (const id of a.value.match(IDENTIFIERS) ?? []) own.add(id);
|
||||
}
|
||||
}
|
||||
if (!pre && !inPre()) {
|
||||
const ownLocals = own ?? undefined;
|
||||
const local = (n: string) => inScope(n, ownLocals);
|
||||
for (const a of tag.attrs) {
|
||||
if (a.value === null) continue;
|
||||
const kind = directiveKind(a.name);
|
||||
if (kind === 'expr' || kind === 'on') {
|
||||
scanExpression(s, a.valueStart, a.valueStart + a.value.length, local, out);
|
||||
} else if (kind === 'for' && forSource) {
|
||||
scanExpression(s, forSource.start, forSource.end, local, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tag.selfClosing && !VOID_ELEMENTS.has(tag.name.toLowerCase())) {
|
||||
stack.push({ tag: tag.name, locals: own, pre });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What an attribute is to Vue: a `v-for`, a slot-props pattern, a handler, an expression, or plain HTML. */
|
||||
function directiveKind(name: string): 'for' | 'slot' | 'on' | 'expr' | null {
|
||||
if (name === 'v-for') return 'for';
|
||||
if (name.startsWith('#') || name === 'v-slot' || name.startsWith('v-slot:') || name === 'slot-scope' || name === 'scope') {
|
||||
return 'slot';
|
||||
}
|
||||
if (name.startsWith('@') || name === 'v-on' || name.startsWith('v-on:')) return 'on';
|
||||
// `:x` / `.x` (v-bind and its `.prop` shorthand), `v-bind`, `v-if`, `v-model`, custom `v-*`.
|
||||
if (name.startsWith(':') || name.startsWith('.') || name.startsWith('v-')) return 'expr';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An opening tag at `at`: its name, its attributes, where it ends. Quote-aware. */
|
||||
function parseOpenTag(
|
||||
s: string,
|
||||
at: number,
|
||||
end: number
|
||||
): { name: string; attrs: Attr[]; close: number; selfClosing: boolean } {
|
||||
let p = at + 1;
|
||||
const nameStart = p;
|
||||
while (p < end && !/[\s/>]/.test(s[p]!)) p++;
|
||||
const name = s.slice(nameStart, p);
|
||||
const attrs: Attr[] = [];
|
||||
while (p < end) {
|
||||
while (p < end && WS.test(s[p]!)) p++;
|
||||
if (p >= end) break;
|
||||
const c = s[p]!;
|
||||
if (c === '>') return { name, attrs, close: p + 1, selfClosing: false };
|
||||
if (c === '/' && s[p + 1] === '>') return { name, attrs, close: p + 2, selfClosing: true };
|
||||
const attrStart = p;
|
||||
while (p < end && !/[\s"'>/=]/.test(s[p]!)) p++;
|
||||
if (p === attrStart) {
|
||||
p++; // a stray `/`, quote or `=`
|
||||
continue;
|
||||
}
|
||||
const attrName = s.slice(attrStart, p);
|
||||
let q = p;
|
||||
while (q < end && WS.test(s[q]!)) q++;
|
||||
if (s[q] !== '=') {
|
||||
attrs.push({ name: attrName, value: null, valueStart: -1 });
|
||||
continue;
|
||||
}
|
||||
q++;
|
||||
while (q < end && WS.test(s[q]!)) q++;
|
||||
const quote = s[q];
|
||||
if (quote === '"' || quote === "'") {
|
||||
const closeQuote = s.indexOf(quote, q + 1);
|
||||
const valueEnd = closeQuote === -1 || closeQuote > end ? end : closeQuote;
|
||||
attrs.push({ name: attrName, value: s.slice(q + 1, valueEnd), valueStart: q + 1 });
|
||||
p = valueEnd + 1;
|
||||
} else {
|
||||
const valueStart = q;
|
||||
while (q < end && !/[\s>]/.test(s[q]!)) q++;
|
||||
attrs.push({ name: attrName, value: s.slice(valueStart, q), valueStart });
|
||||
p = q;
|
||||
}
|
||||
}
|
||||
return { name, attrs, close: end, selfClosing: false };
|
||||
}
|
||||
|
||||
/** Prior token class, as the lexer needs it: what may follow, and what a `/` means. */
|
||||
type Prev = 'start' | 'operand' | 'dot' | 'new' | 'decl';
|
||||
|
||||
interface Paren {
|
||||
open: number;
|
||||
/** Index in `found` of the call this paren opens, or -1. */
|
||||
call: number;
|
||||
/** The parameter list of a `function`. */
|
||||
params: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calls in one template expression `s[start, end)`. `outerLocal` is the
|
||||
* template scope (aliases and slot props in force); arrow and function
|
||||
* parameters bound inside the expression join it as they are met.
|
||||
*/
|
||||
function scanExpression(
|
||||
s: string,
|
||||
start: number,
|
||||
end: number,
|
||||
outerLocal: (name: string) => boolean,
|
||||
out: VueTemplateCall[]
|
||||
): void {
|
||||
const params = new Set<string>();
|
||||
const isLocal = (name: string) => params.has(name) || outerLocal(name);
|
||||
const found: Array<VueTemplateCall & { dropped?: boolean }> = [];
|
||||
const parens: Paren[] = [];
|
||||
let lastGroup: { open: number; close: number } | null = null;
|
||||
let lastIdent = '';
|
||||
let functionPending = false;
|
||||
let prev: Prev = 'start';
|
||||
|
||||
const skipWs = (k: number) => {
|
||||
while (k < end && WS.test(s[k]!)) k++;
|
||||
return k;
|
||||
};
|
||||
const wordEnd = (k: number) => {
|
||||
while (k < end && IDENT_CHAR.test(s[k]!)) k++;
|
||||
return k;
|
||||
};
|
||||
const addParams = (from: number, to: number) => {
|
||||
for (const id of s.slice(from, to).match(IDENTIFIERS) ?? []) params.add(id);
|
||||
};
|
||||
|
||||
let i = start;
|
||||
while (i < end) {
|
||||
const c = s[i]!;
|
||||
if (WS.test(c)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'") {
|
||||
i = skipString(s, i, end);
|
||||
prev = 'operand';
|
||||
continue;
|
||||
}
|
||||
if (c === '`') {
|
||||
i = skipTemplateLiteral(s, i, end, (a, b) => scanExpression(s, a, b, isLocal, out));
|
||||
prev = 'operand';
|
||||
continue;
|
||||
}
|
||||
if (c === '/') {
|
||||
if (s[i + 1] === '/') {
|
||||
const nl = s.indexOf('\n', i);
|
||||
i = nl === -1 || nl > end ? end : nl;
|
||||
continue;
|
||||
}
|
||||
if (s[i + 1] === '*') {
|
||||
const close = s.indexOf('*/', i + 2);
|
||||
i = close === -1 || close > end ? end : close + 2;
|
||||
continue;
|
||||
}
|
||||
if (prev !== 'operand') {
|
||||
i = skipRegex(s, i, end);
|
||||
prev = 'operand';
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
prev = 'start';
|
||||
continue;
|
||||
}
|
||||
if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(s[i + 1] ?? ''))) {
|
||||
i++;
|
||||
while (i < end && /[\w.]/.test(s[i]!)) i++;
|
||||
prev = 'operand';
|
||||
continue;
|
||||
}
|
||||
if (IDENT_START.test(c)) {
|
||||
const wEnd = wordEnd(i);
|
||||
const word = s.slice(i, wEnd);
|
||||
if (prev === 'dot') {
|
||||
// A member tail of something that is not a plain path: `a().b`, `x[0].y`.
|
||||
i = wEnd;
|
||||
prev = 'operand';
|
||||
lastIdent = '';
|
||||
continue;
|
||||
}
|
||||
if (KEYWORDS.has(word)) {
|
||||
i = wEnd;
|
||||
lastIdent = '';
|
||||
if (word === 'new') prev = 'new';
|
||||
else if (word === 'function' || word === 'class') {
|
||||
prev = 'decl';
|
||||
if (word === 'function') functionPending = true;
|
||||
} else prev = VALUE_KEYWORDS.has(word) ? 'operand' : 'start';
|
||||
continue;
|
||||
}
|
||||
if (prev === 'decl') {
|
||||
// A function or class name, not a call.
|
||||
i = wEnd;
|
||||
prev = 'operand';
|
||||
continue;
|
||||
}
|
||||
// The member path rooted here: `a`, `a.b.c`, `a?.b`, TS `a!.b`.
|
||||
const segments = [word];
|
||||
let p = wEnd;
|
||||
for (;;) {
|
||||
let q = skipWs(p);
|
||||
if (s[q] === '!' && s[q + 1] === '.') q++;
|
||||
let r: number;
|
||||
if (s[q] === '.' && s[q + 1] !== '.') r = skipWs(q + 1);
|
||||
else if (s[q] === '?' && s[q + 1] === '.' && !/[0-9]/.test(s[q + 2] ?? '')) r = skipWs(q + 2);
|
||||
else break;
|
||||
if (!IDENT_START.test(s[r] ?? '')) break;
|
||||
const segEnd = wordEnd(r);
|
||||
segments.push(s.slice(r, segEnd));
|
||||
p = segEnd;
|
||||
}
|
||||
// Called? `path(`, or optionally `path?.(`.
|
||||
let q = skipWs(p);
|
||||
if (s[q] === '?' && s[q + 1] === '.' && s[skipWs(q + 2)] === '(') q = skipWs(q + 2);
|
||||
const root = segments[0]!;
|
||||
if (s[q] === '(') {
|
||||
let call = -1;
|
||||
if (prev !== 'new' && !root.startsWith('$') && !TEMPLATE_GLOBALS.has(root) && !isLocal(root)) {
|
||||
found.push({ name: segments.join('.'), offset: i });
|
||||
call = found.length - 1;
|
||||
}
|
||||
parens.push({ open: q, call, params: false });
|
||||
i = q + 1;
|
||||
prev = 'start';
|
||||
lastIdent = '';
|
||||
continue;
|
||||
}
|
||||
i = p;
|
||||
prev = 'operand';
|
||||
lastIdent = segments.length === 1 ? word : '';
|
||||
continue;
|
||||
}
|
||||
// Punctuation.
|
||||
if (c === '(') {
|
||||
parens.push({ open: i, call: -1, params: functionPending });
|
||||
functionPending = false;
|
||||
i++;
|
||||
prev = 'start';
|
||||
continue;
|
||||
}
|
||||
if (c === ')') {
|
||||
const paren = parens.pop();
|
||||
if (paren) {
|
||||
lastGroup = { open: paren.open, close: i };
|
||||
if (paren.params) addParams(paren.open + 1, i);
|
||||
// `{ fmt(v) { … } }` — an object literal's method, not a call.
|
||||
if (paren.call >= 0 && s[skipWs(i + 1)] === '{') found[paren.call]!.dropped = true;
|
||||
}
|
||||
i++;
|
||||
prev = 'operand';
|
||||
lastIdent = '';
|
||||
continue;
|
||||
}
|
||||
if (c === '=' && s[i + 1] === '>') {
|
||||
// Arrow parameters: `(a, { b }) =>` or `a =>`.
|
||||
if (lastIdent) params.add(lastIdent);
|
||||
else if (lastGroup && lastGroup.close === previousNonWs(s, i, start)) addParams(lastGroup.open + 1, lastGroup.close);
|
||||
i += 2;
|
||||
prev = 'start';
|
||||
lastIdent = '';
|
||||
continue;
|
||||
}
|
||||
if (c === '.') {
|
||||
if (s[i + 1] === '.' && s[i + 2] === '.') {
|
||||
i += 3;
|
||||
prev = 'start';
|
||||
} else {
|
||||
i++;
|
||||
prev = 'dot';
|
||||
}
|
||||
lastIdent = '';
|
||||
continue;
|
||||
}
|
||||
if (c === '?' && s[i + 1] === '.' && !/[0-9]/.test(s[i + 2] ?? '')) {
|
||||
i += 2;
|
||||
prev = 'dot';
|
||||
lastIdent = '';
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
prev = c === ']' || c === '}' ? 'operand' : 'start';
|
||||
lastIdent = '';
|
||||
}
|
||||
|
||||
for (const f of found) if (!f.dropped) out.push({ name: f.name, offset: f.offset });
|
||||
}
|
||||
|
||||
/** Index of the last non-whitespace character before `i` (not before `floor`). */
|
||||
function previousNonWs(s: string, i: number, floor: number): number {
|
||||
let k = i - 1;
|
||||
while (k >= floor && WS.test(s[k]!)) k--;
|
||||
return k;
|
||||
}
|
||||
|
||||
/** Past a `'…'` / `"…"` string starting at `i`. */
|
||||
function skipString(s: string, i: number, end: number): number {
|
||||
const quote = s[i];
|
||||
let k = i + 1;
|
||||
while (k < end) {
|
||||
const c = s[k]!;
|
||||
if (c === '\\') k += 2;
|
||||
else if (c === quote) return k + 1;
|
||||
else k++;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
/** Past a template literal starting at `i`, reporting each `${…}` substitution's range. */
|
||||
function skipTemplateLiteral(
|
||||
s: string,
|
||||
i: number,
|
||||
end: number,
|
||||
onSubstitution?: (start: number, end: number) => void
|
||||
): number {
|
||||
let k = i + 1;
|
||||
while (k < end) {
|
||||
const c = s[k]!;
|
||||
if (c === '\\') k += 2;
|
||||
else if (c === '`') return k + 1;
|
||||
else if (c === '$' && s[k + 1] === '{') {
|
||||
const close = matchingBrace(s, k + 2, end);
|
||||
onSubstitution?.(k + 2, close);
|
||||
k = close + 1;
|
||||
} else k++;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
/** The `}` closing a brace opened just before `i` (strings and template literals skipped). */
|
||||
function matchingBrace(s: string, i: number, end: number): number {
|
||||
let depth = 0;
|
||||
let k = i;
|
||||
while (k < end) {
|
||||
const c = s[k]!;
|
||||
if (c === '"' || c === "'") k = skipString(s, k, end);
|
||||
else if (c === '`') k = skipTemplateLiteral(s, k, end);
|
||||
else if (c === '{') {
|
||||
depth++;
|
||||
k++;
|
||||
} else if (c === '}') {
|
||||
if (depth === 0) return k;
|
||||
depth--;
|
||||
k++;
|
||||
} else k++;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
/** Past a regex literal starting at `i` (a `/` where an operand is expected). */
|
||||
function skipRegex(s: string, i: number, end: number): number {
|
||||
let k = i + 1;
|
||||
let inClass = false;
|
||||
while (k < end) {
|
||||
const c = s[k]!;
|
||||
if (c === '\\') {
|
||||
k += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '\n') return i + 1; // not a regex after all
|
||||
if (c === '[') inClass = true;
|
||||
else if (c === ']') inClass = false;
|
||||
else if (c === '/' && !inClass) {
|
||||
k++;
|
||||
while (k < end && /[a-z]/i.test(s[k]!)) k++;
|
||||
return k;
|
||||
}
|
||||
k++;
|
||||
}
|
||||
return i + 1;
|
||||
}
|
||||
@@ -1443,9 +1443,14 @@ async function vueTemplateEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
|
||||
const event = m[1]!;
|
||||
const expr = m[2]!.trim();
|
||||
if (expr.includes('=>') || expr.startsWith('$')) continue; // inline arrow / $emit
|
||||
const name = expr.match(/^([A-Za-z_]\w*)/)?.[1];
|
||||
// `@click="save"` names a method Vue calls with the event. A handler that
|
||||
// is an expression — `save(item)`, `emit('close')`, `open = true` — is
|
||||
// the template's own code: the Vue extractor records its calls, on their
|
||||
// line (#2340), so resolving its leading name here too doubled those
|
||||
// edges and bound `emit(…)` / `open = …` to any function of that name.
|
||||
const name = expr.match(/^([A-Za-z_$][\w$]*)\s*(?:\(|$)/)?.[1];
|
||||
if (!name) continue;
|
||||
const direct = resolve(name, HANDLER_KINDS);
|
||||
const direct = name === expr ? resolve(name, HANDLER_KINDS) : undefined;
|
||||
if (direct) { addEdge(direct, { synthesizedBy: 'vue-handler', event }); continue; }
|
||||
// Composable-destructure handler → resolve to the composable's returned fn.
|
||||
const d = destructured.get(name);
|
||||
|
||||
@@ -121,8 +121,10 @@ export const vueResolver: FrameworkResolver = {
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern 2: Nuxt auto-imported composables
|
||||
if (NUXT_AUTO_IMPORTS.has(ref.referenceName)) {
|
||||
// Pattern 2: Nuxt auto-imported composables — unless the file declares
|
||||
// its own function of that name, which shadows the auto-import: elk's
|
||||
// lists page calls its own `clearError(true)`, not Nuxt's (#2340).
|
||||
if (NUXT_AUTO_IMPORTS.has(ref.referenceName) && !declaresOwnFunction(ref, context)) {
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: ref.fromNodeId,
|
||||
@@ -301,6 +303,13 @@ export const nuxtResolver: FrameworkResolver = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Does the reference's own file declare a function (or a const holding one) by that name? */
|
||||
function declaresOwnFunction(ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
return context
|
||||
.getNodesInFile(ref.filePath)
|
||||
.some((n) => n.name === ref.referenceName && (n.kind === 'function' || n.kind === 'constant' || n.kind === 'variable'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string is PascalCase
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user