fix(vue): a <script lang="tsx"> block is read with the TSX grammar (#2824)

A Vue component's <script lang="tsx"> went to the JavaScript grammar:
the Vue extractor knew only lang="ts" and "typescript". That grammar
stops at the first type. A script holding only
withDefaults(defineProps<{ title?: string }>(), ...) was reported as
"parse produced no symbols", and a script with any other code failed the
same way without a warning, because the warning fires only when nothing
survives: its interfaces and type aliases were missing, a typed arrow
function was indexed as a constant, error recovery made nodes named after
a type or a keyword (a constant "CSSProperties", a function "if"), and
calls were credited to the component instead of the function they are in.

Fix:
- sfcScriptLanguage (sfc-script.ts) reads a block's grammar from its
  attributes: ts or typescript is TypeScript, a Vue tsx block is TSX, and
  anything else JavaScript, whose grammar reads lang="jsx" already. The
  Vue and Svelte extractors and the viewer's syntax regions all ask it, so
  the viewer highlights a component with the grammar it was indexed with.
- A Vue project preloads the TSX grammar (expandGrammarLanguages). It came
  along only with a .tsx or .js file in the project; without it a
  lang="tsx" file is never parsed, and nothing of it is stored.

Validation:
- vue-tsx-script.test.ts indexes a project of .vue files only. On main 4
  of its 5 tests fail; with only the preload reverted 3 fail (two because
  the files are not stored at all), with only the extractor reverted 2,
  with only the viewer reverted 1. 19 related test files, 879 tests, pass.
- A/B against main 628f69e2, kernel on, natural-key dumps and a site-keyed
  edge diff. Byte-identical: vue-vben-admin (2,014 files, no tsx blocks),
  elk and nuxt/movies, and the controls svelte-realworld and shadcn-svelte's
  registry (Svelte, which shares the new helper) and gin (Go).
- Changed only in lang="tsx" files, in node and edge counts added/removed:
  vue-element-plus-admin (3 such files) 0/1 and 1/3; a Vue and TS only
  folder of ant-design-x-vue's docs (91; TSX loads there only through the
  preload) 112/15 and 151/7; ant-design-x-vue (122) 143/27 and 293/26;
  a tiny-vue workspace subset (39) 26/59 and 119/93; element-plus (10)
  43/16 and 55/13. Every removal and retarget was read, the additions
  sampled: new nodes are the scripts' types, interfaces, typed constants
  and functions; removed ones are error-recovery debris, duplicates of
  Options API methods among them; moved calls now start in the function
  they are written in. ant-design-x-vue's src/bubble/loading.vue loses its
  "no symbols" warning.
- Index time unchanged within noise, medians of 3 interleaved runs, main
  then fix: element-plus 14.8 s and 13.5 s, ant-design-x-vue 2.94 s and
  2.95 s, the docs folder that now preloads TSX 1.83 s and 1.82 s.

Fixes #2570

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-23 03:37:25 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 013b91c1be
commit 9ee6718ad1
8 changed files with 194 additions and 21 deletions
+1
View File
@@ -65,6 +65,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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)
- In Vue, a `<script lang="tsx">` block is now read as TSX. Before, it was read as JavaScript, which can't read its TypeScript: a script holding only `defineProps` or `withDefaults` was reported as producing no symbols, and in a script with other code an `interface` or type it declared was missing from the graph without any warning. Re-index Vue projects that use `lang="tsx"` after upgrading. Thanks @leadingGo for the report, and @maxmilian and @yanhongwang1017. (#2570)
- In COBOL, asking `codegraph_explore` (or `codegraph explore` and `codegraph context`) about a copybook by name, such as `CVACT01Y` or a member brought in with `EXEC SQL INCLUDE`, now answers with the copybook's own source when it is indexed and every `COPY` or `EXEC SQL INCLUDE` statement that includes it, and says so when the copybook's source isn't part of the project. Before, it answered "No relevant code found", or showed an unrelated program whose name was a letter or two away. Thanks @popolusiak for the report. (#2342)
- In Dart, reading a getter such as `box.area` or `status.label` now counts as calling it, so `codegraph callers` and impact list the code that reads it — whether the getter is declared on the class, inherited, or added by an extension — and a method an extension adds to an enum, like `shape.shout()`, now links as well. A plain field, a getter of a type from outside your project, or a value whose type isn't written down still links nothing. Re-index Dart projects after upgrading. Thanks @brookly255-lv for the report. (#2338)
- In Dart, a type is now linked from more of the places it is written, not only from parameters and return types: the type an `extension … on` targets, a field's type, a top-level variable's type, generic arguments such as `Future<Report?>.value(null)` or Riverpod's `final reportProvider = Family<Report?, String>()`, and a local variable's type, a cast or a type check inside a function. So `callers` and impact for a model class now reach the extensions, models and providers that use it. Thanks @mg-mg-mg for the report. (#2327)
+144
View File
@@ -0,0 +1,144 @@
/**
* A Vue `<script lang="tsx">` is TSX, and is read with the TSX grammar (#2570).
*
* The extractor knew only `ts` / `typescript`, so a `lang="tsx"` block went to
* the JavaScript grammar, which stops at the first type. A script holding only
* `withDefaults(defineProps<{…}>(), …)` came out as "parse produced no
* symbols", and a script with any other declaration hid the same broken parse,
* because that warning fires only when nothing survives: an `interface` the
* script declared was simply missing.
*
* Reading the block as TSX also needs the TSX grammar loaded. A Vue project
* loads the grammars its scripts delegate to, and that list had TypeScript and
* JavaScript only; TSX came along only when some `.tsx` or `.js` file was in
* the project. So the project below has nothing but `.vue` files, and this
* file never loads every grammar: a test that did would hide that half.
*/
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/index';
import { readGrammarWasmBytes } from '../src/extraction/grammars';
import { syntaxRegionsFor } from '../src/extraction/syntax-tokens';
import type { Node } from '../src/types';
const PROJECT: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3.5.0' } }),
// The issue's file: compiler macros only.
'src/Title.vue': `<template><h1>{{ title }}</h1></template>
<script setup lang="tsx">
withDefaults(defineProps<{ title?: string }>(), { title: 'Hello' });
</script>
`,
// Types and JSX together, which only the TSX grammar reads.
'src/Card.vue': `<script lang="tsx">
import { defineComponent } from 'vue';
interface Props { label: string }
function renderLabel(p: Props) {
return <span class="label">{p.label}</span>;
}
export default defineComponent({
props: { label: String },
setup(props) {
const helper = (x: number): number => x * 2;
return () => <div onClick={() => helper(1)}>{renderLabel(props as Props)}</div>;
},
});
</script>
`,
// Controls: TypeScript stays TypeScript, no lang stays JavaScript.
'src/Plain.vue': `<template><p>{{ n }}</p></template>
<script setup lang="ts">
interface Count { n: number }
const count: Count = { n: 1 };
const n = count.n;
</script>
`,
'src/Legacy.vue': `<template><p>{{ total }}</p></template>
<script>
export default { computed: { total() { return sum(1, 2); } } };
function sum(a, b) { return a + b; }
</script>
`,
};
let roots: string[] = [];
let graphs: CodeGraph[] = [];
afterEach(() => {
for (const graph of graphs) graph.close();
graphs = [];
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});
async function indexProject(): Promise<CodeGraph> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vue-tsx-'));
roots.push(root);
for (const [file, text] of Object.entries(PROJECT)) {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), text);
}
const graph = await CodeGraph.init(root, { index: true });
graphs.push(graph);
return graph;
}
const named = (nodes: Node[], kind: string, name: string) => nodes.find((n) => n.kind === kind && n.name === name);
/**
* The file's extraction errors. The file must be stored: one whose grammar
* was not loaded is never parsed, and nothing at all is stored for it.
*/
function errorsOf(graph: CodeGraph, file: string) {
const record = graph.getFile(file);
expect(record, `${file} is stored`).not.toBeNull();
return record!.errors ?? [];
}
describe('Vue <script lang="tsx">', () => {
it('indexes a macro-only script without a parse warning', async () => {
const graph = await indexProject();
expect(errorsOf(graph, 'src/Title.vue')).toEqual([]);
});
it('keeps the TypeScript declarations of a script with JSX, and the calls made inside the JSX', async () => {
const graph = await indexProject();
expect(errorsOf(graph, 'src/Card.vue')).toEqual([]);
const nodes = graph.getNodesInFile('src/Card.vue');
const props = named(nodes, 'interface', 'Props');
expect(props).toBeDefined();
expect(nodes.some((n) => n.kind === 'property' && n.qualifiedName === 'Props::label')).toBe(true);
const renderLabel = named(nodes, 'function', 'renderLabel')!;
const setup = named(nodes, 'method', 'setup')!;
const helper = named(nodes, 'function', 'helper')!;
// `p: Props` is a reference to the interface the script declares.
expect(graph.getOutgoingEdgesFrom([renderLabel.id], ['references']).map((e) => e.target)).toContain(props!.id);
// `<div onClick={() => helper(1)}>{renderLabel(…)}</div>` calls both.
const called = graph.getOutgoingEdgesFrom([setup.id], ['calls']).map((e) => e.target);
expect(called).toEqual(expect.arrayContaining([helper.id, renderLabel.id]));
});
it('still reads lang="ts" as TypeScript and a script without lang as JavaScript', async () => {
const graph = await indexProject();
expect(errorsOf(graph, 'src/Plain.vue')).toEqual([]);
expect(named(graph.getNodesInFile('src/Plain.vue'), 'interface', 'Count')).toBeDefined();
expect(errorsOf(graph, 'src/Legacy.vue')).toEqual([]);
expect(named(graph.getNodesInFile('src/Legacy.vue'), 'function', 'sum')).toBeDefined();
});
it('loads the TSX grammar for a Vue project, and not for a Svelte one', async () => {
expect(Object.keys(await readGrammarWasmBytes(['vue'])).sort()).toEqual(['javascript', 'tsx', 'typescript']);
expect(Object.keys(await readGrammarWasmBytes(['svelte'])).sort()).toEqual(['javascript', 'typescript']);
});
it('is highlighted in the viewer with the grammar it was indexed with', () => {
const regionLanguage = (source: string, host: 'vue' | 'svelte') => syntaxRegionsFor(source, host)?.map((r) => r.language);
expect(regionLanguage(PROJECT['src/Card.vue']!, 'vue')).toEqual(['tsx']);
expect(regionLanguage(PROJECT['src/Plain.vue']!, 'vue')).toEqual(['typescript']);
expect(regionLanguage(PROJECT['src/Legacy.vue']!, 'vue')).toEqual(['javascript']);
// Svelte has no TSX scripts: its extractor reads anything but ts as JavaScript.
expect(regionLanguage('<script lang="tsx">\nlet x = 1;\n</script>\n', 'svelte')).toEqual(['javascript']);
});
});
+6 -1
View File
@@ -463,7 +463,9 @@ function resolveWasmPath(lang: GrammarLanguage): string {
* SFC languages (svelte/vue/astro) have no grammar of their own — their
* extractors delegate <script>/frontmatter content to the TS/JS extractor, so
* those grammars must be loaded even when no plain .ts/.js file is in the index
* set (e.g. a pure-.astro content site). CFML (.cfc/.cfm) likewise delegates
* set (e.g. a pure-.astro content site). A Vue `<script lang="tsx">` is read
* with the TSX grammar (#2570), which nothing else loads in a project without
* a .tsx or .js file. CFML (.cfc/.cfm) likewise delegates
* bare-script content, <cfscript> tag bodies, and <cfquery> SQL bodies to the
* cfscript/cfquery grammars (see injections.scm in tree-sitter-cfml).
*/
@@ -471,6 +473,9 @@ function expandGrammarLanguages(languages: Language[]): Language[] {
if (languages.some((l) => l === 'svelte' || l === 'vue' || l === 'astro')) {
languages = [...languages, 'typescript', 'javascript'];
}
if (languages.includes('vue')) {
languages = [...languages, 'tsx'];
}
if (languages.some((l) => l === 'cfml')) {
languages = [...languages, 'cfscript', 'cfquery'];
}
+21
View File
@@ -25,6 +25,27 @@
import type { Edge, ExtractionError, ExtractionResult, Language, Node, UnresolvedReference } from '../types';
import * as path from 'path';
const TS_LANG_ATTR = /lang\s*=\s*["'](ts|typescript)["']/;
const TSX_LANG_ATTR = /lang\s*=\s*["']tsx["']/;
/**
* The grammar a `<script>` block is read with, from its opening tag's
* attributes — by the Vue and Svelte extractors, and by the viewer's syntax
* regions, so a component's code is highlighted with the grammar its symbols
* came from.
*
* `ts` / `typescript` is TypeScript. A Vue block can also be `tsx` (#2570):
* TypeScript with JSX, which only the TSX grammar reads — the TypeScript
* grammar has no JSX, and the JavaScript one stops at the first type, so a
* `defineProps<{…}>()` or an `interface` was lost. Svelte has no TSX scripts.
* Anything else is JavaScript, whose grammar reads JSX (`lang="jsx"`) already.
*/
export function sfcScriptLanguage(attrs: string, host: Language): Language {
if (TS_LANG_ATTR.test(attrs)) return 'typescript';
if (host === 'vue' && TSX_LANG_ATTR.test(attrs)) return 'tsx';
return 'javascript';
}
/** The SFC's own file node — the whole file, as the tree-sitter extractor makes one. */
export function sfcFileNode(filePath: string, source: string, language: Language): Node {
return {
+2 -2
View File
@@ -2,7 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Lan
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode } from './sfc-script';
import { foldScriptResult, sfcFileNode, sfcScriptLanguage } from './sfc-script';
/** Svelte 5 rune names — compiler builtins, not real functions */
const SVELTE_RUNES = new Set([
@@ -133,7 +133,7 @@ export class SvelteExtractor {
const content = match.groups?.content || match[2] || '';
// Detect TypeScript from lang attribute
const isTypeScript = /lang\s*=\s*["'](ts|typescript)["']/.test(attrs);
const isTypeScript = sfcScriptLanguage(attrs, 'svelte') === 'typescript';
// Detect module script: Svelte 4's `context="module"`, Svelte 5's `module`
const isModule = /context\s*=\s*["']module["']|(?:^|\s)module(?=[\s=]|$)/.test(attrs);
+9 -7
View File
@@ -43,6 +43,7 @@ import { Language } from '../types';
import { EXTRACTORS } from './languages';
import { getParser, loadGrammarsForLanguages } from './grammars';
import { parseWithinBudget } from './parse-budget';
import { sfcScriptLanguage } from './sfc-script';
import type { LanguageExtractor } from './tree-sitter-types';
/* ------------------------------------------------------------- the classes -- */
@@ -348,11 +349,12 @@ function emit(ctx: WalkContext, start: number, end: number, cls: SyntaxTokenClas
* Single-file components are the only case: a `.svelte`, `.vue` or `.astro`
* file has no tree-sitter grammar of its own here, but its `<script>` block —
* where every symbol the engine indexed in that file lives — is ordinary
* TypeScript or JavaScript. The extractors already delegate exactly this way,
* so the viewer reads a component's code with the same grammar the graph was
* built from. The surrounding markup stays unclassified, which under a
* near-monochrome theme costs the recession on tag names and attribute strings
* and nothing else.
* TypeScript or JavaScript (or TSX, for a Vue `lang="tsx"` block). The
* extractors already delegate exactly this way, so the viewer reads a
* component's code with the same grammar the graph was built from (it asks
* `sfcScriptLanguage`, as the Vue and Svelte extractors do). The surrounding
* markup stays unclassified, which under a near-monochrome theme costs the
* recession on tag names and attribute strings and nothing else.
*/
export interface SyntaxRegion {
start: number;
@@ -361,7 +363,6 @@ export interface SyntaxRegion {
}
const SCRIPT_BLOCK = /<script(\s[^>]*)?>([\s\S]*?)<\/script>/gi;
const TS_LANG_ATTR = /lang\s*=\s*["'](ts|typescript)["']/i;
/** Astro's frontmatter: a `---` fence at the very top of the file. */
const ASTRO_FRONTMATTER = /^(---\r?\n)([\s\S]*?)\r?\n---/;
@@ -392,7 +393,8 @@ export function syntaxRegionsFor(source: string, language: Language): SyntaxRegi
regions.push({
start,
end: start + body.length,
language: TS_LANG_ATTR.test(match[1] ?? '') ? 'typescript' : 'javascript',
// The grammar the extractor read this block with.
language: sfcScriptLanguage(match[1] ?? '', language),
});
}
return regions;
+8 -8
View File
@@ -2,7 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Lan
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode } from './sfc-script';
import { foldScriptResult, sfcFileNode, sfcScriptLanguage } from './sfc-script';
import { vueOptionsMembers } from './vue-options-api';
import { vueTemplateCalls } from './vue-template-calls';
@@ -192,13 +192,13 @@ export class VueExtractor {
content: string;
startLine: number;
isSetup: boolean;
isTypeScript: boolean;
language: Language;
}> {
const blocks: Array<{
content: string;
startLine: number;
isSetup: boolean;
isTypeScript: boolean;
language: Language;
}> = [];
const scriptRegex = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/g;
@@ -208,8 +208,8 @@ export class VueExtractor {
const attrs = match[1] || '';
const content = match.groups?.content || match[2] || '';
// Detect TypeScript from lang attribute
const isTypeScript = /lang\s*=\s*["'](ts|typescript)["']/.test(attrs);
// TypeScript, TSX or JavaScript, from the lang attribute
const language = sfcScriptLanguage(attrs, 'vue');
// Detect <script setup>
const isSetup = /\bsetup\b/.test(attrs);
@@ -229,7 +229,7 @@ export class VueExtractor {
content,
startLine: contentStartLine,
isSetup,
isTypeScript,
language,
});
}
@@ -240,10 +240,10 @@ export class VueExtractor {
* Process a script block by delegating to TreeSitterExtractor
*/
private processScriptBlock(
block: { content: string; startLine: number; isSetup: boolean; isTypeScript: boolean },
block: { content: string; startLine: number; isSetup: boolean; language: Language },
componentNodeId: string
): void {
const scriptLanguage: Language = block.isTypeScript ? 'typescript' : 'javascript';
const scriptLanguage = block.language;
// Check if the script language parser is available
if (!isLanguageSupported(scriptLanguage)) {
+3 -3
View File
@@ -11,9 +11,9 @@
* The three single-file-component formats are the exception worth naming. A
* `.svelte`, `.vue` or `.astro` file has no grammar of its own here — the
* extractors pull the `<script>` block out and hand it to TypeScript or
* JavaScript — and the classifier does exactly the same thing, so a component's
* code is read by the grammar its symbols came from while the surrounding
* markup stays plain.
* JavaScript (TSX for a Vue `lang="tsx"` block) — and the classifier does
* exactly the same thing, so a component's code is read by the grammar its
* symbols came from while the surrounding markup stays plain.
*/
import type { Language } from '../../types';