mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
d6a2bdb0d6
The single-file-component extractors kept the delegated script's file node and made the component contain it and every node, so the hierarchy ran file-inside-component, members had two parents, and a file with no script had no file node. And a per-instance script's top-level calls stayed on the file, so a walk from the component found nothing. Now: one file node (the whole file) holds the component; the component holds what the script holds at top level, nested symbols keep their parent; <script setup>, a Svelte instance script and Astro frontmatter give their top-level calls — a top-level constant's initializer's included — to the component, while imports and module-level scripts stay with the file. Svelte 5's `<script module>` is a module script too. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
102 lines
4.1 KiB
TypeScript
102 lines
4.1 KiB
TypeScript
/**
|
||
* A Vue / Svelte / Astro file is one file node holding the component it is,
|
||
* and the component holds its script: each symbol has one parent, and a
|
||
* component never contains its own file. What a per-instance script does at
|
||
* its top level — `<script setup>`, a Svelte instance script, Astro's
|
||
* frontmatter — is the component's doing (a Nuxt page's `useAsyncData(…)`, a
|
||
* Svelte page's `onMount(…)`); a module-level script's, and every import,
|
||
* stays with the file.
|
||
*/
|
||
import { describe, it, expect, beforeAll } from 'vitest';
|
||
import { extractFromSource } from '../src/extraction';
|
||
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||
import type { ExtractionResult } from '../src/types';
|
||
|
||
beforeAll(async () => {
|
||
await initGrammars();
|
||
await loadAllGrammars();
|
||
});
|
||
|
||
const componentOf = (r: ExtractionResult) => r.nodes.find((n) => n.kind === 'component')!;
|
||
const from = (r: ExtractionResult, name: string) =>
|
||
r.unresolvedReferences.filter((ref) => ref.referenceName === name && ref.referenceKind === 'calls').map((ref) => ref.fromNodeId);
|
||
const parents = (r: ExtractionResult) => {
|
||
const out = new Map<string, string[]>();
|
||
for (const e of r.edges) if (e.kind === 'contains') out.set(e.target, [...(out.get(e.target) ?? []), e.source]);
|
||
return out;
|
||
};
|
||
|
||
describe('a single-file component’s structure', () => {
|
||
it('is a file holding the component holding the script, one parent each', () => {
|
||
const r = extractFromSource('pages/index.vue', `<template><button @click="load">Load</button></template>
|
||
<script setup lang="ts">
|
||
import { useFetch } from '#app';
|
||
const data = useFetch('/api/items');
|
||
function load() {
|
||
return data;
|
||
}
|
||
</script>
|
||
`);
|
||
const file = r.nodes.find((n) => n.kind === 'file')!;
|
||
const component = componentOf(r);
|
||
expect(file).toMatchObject({ id: 'file:pages/index.vue', startLine: 1, endLine: 9 });
|
||
const p = parents(r);
|
||
expect(p.get(component.id)).toEqual([file.id]);
|
||
expect(p.get(file.id)).toBeUndefined();
|
||
const load = r.nodes.find((n) => n.name === 'load')!;
|
||
expect(p.get(load.id)).toEqual([component.id]);
|
||
for (const [, list] of p) expect(list).toHaveLength(1);
|
||
});
|
||
|
||
it('gives a <script setup>’s top-level calls to the component, and its imports to the file', () => {
|
||
const r = extractFromSource('pages/index.vue', `<template><div /></template>
|
||
<script setup lang="ts">
|
||
import { useFetch } from '#app';
|
||
const data = useFetch('/api/items');
|
||
</script>
|
||
`);
|
||
expect(from(r, 'useFetch').filter((id) => id.startsWith('component:'))).toHaveLength(1);
|
||
expect(r.unresolvedReferences.filter((ref) => ref.referenceKind === 'imports').every((ref) => ref.fromNodeId === 'file:pages/index.vue')).toBe(true);
|
||
});
|
||
|
||
it('leaves a plain <script>’s module-level calls with the file', () => {
|
||
const r = extractFromSource('src/Widget.vue', `<template><div /></template>
|
||
<script>
|
||
registerWidget('widget');
|
||
export default { name: 'Widget' };
|
||
</script>
|
||
`);
|
||
expect(from(r, 'registerWidget')).toEqual(['file:src/Widget.vue']);
|
||
});
|
||
|
||
it('gives a Svelte instance script to the component, and a module script to the file', () => {
|
||
const r = extractFromSource('src/routes/+page.svelte', `<script context="module">
|
||
preload();
|
||
</script>
|
||
<script module>
|
||
register();
|
||
</script>
|
||
<script>
|
||
import { onMount } from 'svelte';
|
||
onMount(() => fetchItems());
|
||
</script>
|
||
<p>items</p>
|
||
`);
|
||
const component = componentOf(r).id;
|
||
expect(from(r, 'onMount')).toEqual([component]);
|
||
expect(from(r, 'preload')).toEqual(['file:src/routes/+page.svelte']);
|
||
expect(from(r, 'register')).toEqual(['file:src/routes/+page.svelte']);
|
||
});
|
||
|
||
it('gives Astro frontmatter to the component, and makes a file node for a file without one', () => {
|
||
const r = extractFromSource('src/pages/blog.astro', `---
|
||
const posts = await getCollection('blog');
|
||
---
|
||
<ul>{posts.map((p) => <li>{p.title}</li>)}</ul>
|
||
`);
|
||
expect(from(r, 'getCollection')).toEqual([componentOf(r).id]);
|
||
const bare = extractFromSource('src/pages/about.astro', `<p>about</p>\n`);
|
||
expect(bare.nodes.map((n) => n.kind).sort()).toEqual(['component', 'file']);
|
||
});
|
||
});
|