GitNexus/gitnexus/test/integration/resolvers/vue.test.ts
Nguyen Hai Son dd0f5eed7d
feat(vue): Vue SFC support + destructured call result tracking (#604)
* feat(vue): add Vue SFC (.vue) support for indexing

Vue Single File Components are now fully supported in the indexing pipeline.
The implementation extracts <script> / <script setup> blocks from .vue files
and parses them using the existing TypeScript tree-sitter grammar — no new
npm dependencies required.

Key changes:
- SFC script extractor: regex-based extraction of <script setup lang="ts">
  blocks with correct line offset mapping back to the .vue file
- Vue language provider: reuses TypeScript queries, type config, field
  extractors, and named binding extraction
- Import resolution: .vue added to EXTENSIONS so `import Foo from './Foo'`
  resolves to Foo.vue; Vue import resolver delegates to TS resolver for
  tsconfig path alias support
- Export detection: <script setup> top-level bindings are implicitly exported
- Template component detection: PascalCase tags in <template> emit CALLS edges
- Line offsets applied to all emitted positions (startLine, endLine, route
  lineNumbers, decorator positions) in both worker and sequential paths

Validated on a 3,553-file Vue project:
  Before: 24,693 nodes | 73,614 edges | 0 symbols from .vue
  After:  30,495 nodes | 112,324 edges | 5,213 symbols from .vue
          18,682 imports from .vue | 5,826 vue-to-vue imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(typescript): track destructured call results in TypeEnv

Extend `extractPendingAssignment` to handle object destructuring from
function calls and await expressions:

  const { isMaker } = useUserRole()
  const { data } = await fetchData()
  const { name } = repo.getProfile()

Previously, only `const { x } = someVariable` (identifier RHS) produced
TypeEnv bindings. Call-expression RHS was silently skipped, leaving
destructured properties untracked.

The fix emits a synthetic `callResult` item plus N `fieldAccess` items
per destructured property, which the existing fixpoint resolver processes
in 2 iterations. No changes needed to type-env.ts, PendingAssignment
types, or call-processor — the existing infrastructure handles it.

Also extracts a `collectDestructuredFields` helper to share the
object_pattern property iteration logic between the identifier and
call-expression branches.

Note: Full property-type resolution requires the callee to have a
declared returnType in the SymbolTable. Arrow-function composables
without type annotations (common in Vue/React) won't resolve property
types until return-type inference is added in a future change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(vue): address PR review issues for Vue SFC support

- Extract duplicated isVueSetupTopLevel to vue-sfc-extractor.ts shared
  utility, removing identical copies from parse-worker.ts and
  parsing-processor.ts
- Fix VUE_BUILT_INS to be a superset of TS BUILT_INS by importing and
  spreading the TypeScript set, preventing spurious unresolved calls for
  standard built-ins (Symbol, BigInt, WeakMap, array methods, etc.)
- Add Vue template component CALLS edge resolution in both sequential
  and worker paths (call-processor.ts), matching PascalCase template
  tags against imported .vue file basenames via the import map
- Add integration test for template PascalCase CALLS edges
  (App.vue → Button.vue)
- Add integration test for isExported: false on non-setup <script>
  blocks (OldStyle.vue options API)
- Add comment explaining TEMPLATE_RE greedy regex behavior for nested
  template tags
- Fix stale language count comment (14 → 15) and remove dead code
  branch in test

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:18:55 +05:30

122 lines
4.9 KiB
TypeScript

/**
* Vue SFC: script extraction, symbol parsing, import resolution, template component edges
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
describe('Vue SFC support', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'vue-basic'), () => {});
}, 60000);
// -------------------------------------------------------------------------
// Symbol extraction from <script setup>
// -------------------------------------------------------------------------
it('extracts Function nodes from <script setup> .vue files', () => {
const functions = getNodesByLabel(result, 'Function');
// App.vue: onButtonClick; Button.vue: handleClick; types.ts: formatUser
// OldStyle.vue: defineComponent call not a function definition, but greet/data might be
expect(functions).toContain('handleClick');
expect(functions).toContain('onButtonClick');
expect(functions).toContain('formatUser');
});
it('extracts Interface nodes from .ts files used by .vue', () => {
const interfaces = getNodesByLabel(result, 'Interface');
expect(interfaces).toContain('User');
});
it('marks <script setup> top-level bindings as exported', () => {
const allNodes = getNodesByLabelFull(result, 'Function');
const handleClick = allNodes.find(
(n) => n.properties.name === 'handleClick' && n.properties.filePath.endsWith('Button.vue'),
);
expect(handleClick).toBeDefined();
expect(handleClick!.properties.isExported).toBe(true);
});
// -------------------------------------------------------------------------
// Line offset accuracy
// -------------------------------------------------------------------------
it('reports correct startLine in the .vue file (not offset 0)', () => {
const allNodes = getNodesByLabelFull(result, 'Function');
const handleClick = allNodes.find(
(n) => n.properties.name === 'handleClick' && n.properties.filePath.endsWith('Button.vue'),
);
expect(handleClick).toBeDefined();
// handleClick is inside <script setup> which starts after 7 lines of template
// The function starts several lines into the script block
expect(handleClick!.properties.startLine).toBeGreaterThan(5);
});
// -------------------------------------------------------------------------
// Import resolution: .vue ↔ .ts
// -------------------------------------------------------------------------
it('resolves imports from .vue to .ts files', () => {
const imports = getRelationships(result, 'IMPORTS');
const vueToTs = imports.filter(
(e) => e.sourceFilePath.endsWith('App.vue') && e.targetFilePath.endsWith('types.ts'),
);
expect(vueToTs.length).toBeGreaterThanOrEqual(1);
});
it('resolves imports between .vue files', () => {
const imports = getRelationships(result, 'IMPORTS');
const vueToVue = imports.filter(
(e) => e.sourceFilePath.endsWith('App.vue') && e.targetFilePath.endsWith('Button.vue'),
);
expect(vueToVue.length).toBeGreaterThanOrEqual(1);
});
// -------------------------------------------------------------------------
// Cross-file function calls
// -------------------------------------------------------------------------
it('resolves CALLS edges from .vue to .ts functions', () => {
const calls = getRelationships(result, 'CALLS');
const vueToTs = calls.filter(
(e) => e.sourceFilePath.endsWith('App.vue') && e.target === 'formatUser',
);
expect(vueToTs.length).toBeGreaterThanOrEqual(1);
});
it('emits CALLS edge for PascalCase component used in <template>', () => {
const calls = getRelationships(result, 'CALLS');
const templateCalls = calls.filter(
(e) => e.sourceFilePath.endsWith('App.vue') && e.targetFilePath.endsWith('Button.vue'),
);
expect(templateCalls.length).toBeGreaterThanOrEqual(1);
});
it('does not mark non-setup <script> symbols as implicitly exported', () => {
const allNodes = getNodesByLabelFull(result, 'Function');
const oldStyleFns = allNodes.filter((n) => n.properties.filePath.endsWith('OldStyle.vue'));
// OldStyle.vue uses options API (no <script setup>), so any extracted
// symbols without an explicit `export` keyword should have isExported: false.
for (const fn of oldStyleFns) {
expect(fn.properties.isExported).toBe(false);
}
});
// -------------------------------------------------------------------------
// File nodes exist for .vue files
// -------------------------------------------------------------------------
it('creates File nodes for .vue files', () => {
const files = getNodesByLabel(result, 'File');
expect(files.some((f) => f.endsWith('.vue'))).toBe(true);
});
});