Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph_explore` and `codegraph context` now find your project's busiest file, and favor the code around it, even when twenty or more test or generated files are busier still, such as a package of protobuf stubs. Before, the search looked only at the 20 busiest files and then set the tests and generated ones aside, so it could find no file at all, or judge one far ahead of the next when it was not. `getTopRouteFile()` in the library counts every file's routes the same way. Thanks @inth3shadows for the report. (#2578)
- In JavaScript and TypeScript, a call written as the body of an `if`, `while`, `for` or `do` without braces, like `if (e.key === "Escape") setOpen(false);`, no longer links to a same-named property or method in another file when the name is the file's own, such as a `setOpen` from `useState` (#2585).
- `codegraph sync` no longer fails with "FOREIGN KEY constraint failed" in a project whose code lazy-loads a workspace package, like Supabase Studio's `dynamic(() => import('icons').then((mod) => mod.EdgeFunctions))` or vben's `() => import('@vben/layouts').then((m) => m.IFrameView)`. Before, every sync after an edit in such a project rolled back, so the index stopped following your changes until a full re-index. Thanks @bompus for the report and @inth3shadows. (#2565)
- In Vue, a `<script lang="tsx">` block is now read as TSX. Before, it was read as JavaScript, which cannot parse its TypeScript: a script holding only `defineProps` or `withDefaults` was reported as producing no symbols, and an `interface` declared in the script was missing from the graph. The source viewer now highlights these blocks as TSX too. Re-index Vue projects that use `lang="tsx"` after upgrading. Thanks @leadingGo for the report. (#2570)
- In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty.
- In Delphi, a routine with an attribute on one of its parameters, like `const [ref] AValue: Integer` or DelphiMVCFramework's `const [MVCFromBody] Person: TPerson`, is now indexed with its calls, and so is every class member declared after it. Before, the routine was lost or its calls were credited to the file, the parameter showed up as a field of the class, and the members after it were missing. Thanks @stalexteam for the report. (#2545)
- In Delphi, a `for … in` loop that declares its variable inline, like `for var lItem in AList do` or `for var lHandle: THandle in lHandles do`, now has its calls indexed, and a call on a typed loop variable reaches that type's method. Before, the whole loop was skipped, so a routine calling a method only from such a loop was missing from that method's callers. Thanks @stalexteam for the report. (#2546)
Expand Down
98 changes: 98 additions & 0 deletions __tests__/vue-tsx-script.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* A Vue SFC's `<script lang="tsx">` is TSX, and is parsed with the TSX
* grammar (#2570). It used to fall through to JavaScript: a macro-only script
* like `withDefaults(defineProps<T>(), …)` reported "parse produced no
* symbols", and a script with any other declaration hid the same broken parse
* because that warning only fires when nothing survives.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { getParser, initGrammars, loadAllGrammars } from '../src/extraction/grammars';
import { syntaxRegionsFor } from '../src/extraction/syntax-tokens';

beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});

const MACRO_ONLY = `<template><h1>{{ title }}</h1></template>
<script setup lang="tsx">
withDefaults(defineProps<{ title?: string }>(), { title: 'Hello' });
</script>
`;

// TypeScript types and JSX together: only the TSX grammar reads this cleanly,
// and the extra declarations keep the "no symbols" warning from firing either way.
const TYPES_AND_JSX = `<template><Row /></template>
<script setup lang="tsx">
interface RowProps { label: string }
const props = defineProps<RowProps>();
const Row = (p: RowProps) => <span class="row">{shout(p.label)}</span>;
function shout(s: string): string { return s.toUpperCase(); }
</script>
`;

/** The script body of a single-script SFC, as the viewer locates it. */
function scriptRegion(source: string) {
const regions = syntaxRegionsFor(source, 'vue');
expect(regions).toHaveLength(1);
const region = regions![0]!;
return { language: region.language, body: source.slice(region.start, region.end) };
}

describe('Vue <script lang="tsx">', () => {
it('parses a macro-only script without a parse warning', () => {
const result = extractFromSource('Title.vue', MACRO_ONLY);
expect(result.errors.filter((e) => e.code === 'parse_error')).toEqual([]);
});

it('reads the script with the TSX grammar, which parses it without errors', () => {
for (const source of [MACRO_ONLY, TYPES_AND_JSX]) {
const { language, body } = scriptRegion(source);
expect(language).toBe('tsx');
const tree = getParser('tsx')!.parse(body)!;
try {
expect(tree.rootNode.hasError).toBe(false);
} finally {
tree.delete();
}
}
});

it('extracts TypeScript declarations and calls made inside JSX', () => {
const result = extractFromSource('Row.vue', TYPES_AND_JSX);
const find = (kind: string, name: string) => result.nodes.find((n) => n.kind === kind && n.name === name);
expect(find('interface', 'RowProps')).toBeDefined();
expect(find('function', 'shout')).toBeDefined();
const row = find('function', 'Row');
expect(row).toBeDefined();
expect(
result.unresolvedReferences.some(
(r) => r.fromNodeId === row!.id && r.referenceName === 'shout' && r.referenceKind === 'calls'
)
).toBe(true);
expect(result.errors.filter((e) => e.code === 'parse_error')).toEqual([]);
});

it('reads lang the same way in the extractor and the viewer, whatever its case or quotes', () => {
for (const attr of [`lang="TSX"`, `lang='tsx'`]) {
const source = TYPES_AND_JSX.replace('lang="tsx"', attr);
expect(scriptRegion(source).language).toBe('tsx');
const result = extractFromSource('Row.vue', source);
expect(result.nodes.some((n) => n.kind === 'interface' && n.name === 'RowProps')).toBe(true);
expect(result.errors.filter((e) => e.code === 'parse_error')).toEqual([]);
}
const ts = `<script setup lang="TS">\ninterface P { a: string }\n</script>\n`;
expect(scriptRegion(ts).language).toBe('typescript');
expect(extractFromSource('C.vue', ts).nodes.some((n) => n.kind === 'interface' && n.name === 'P')).toBe(true);
});

it('still reads lang="ts" as TypeScript and no lang as JavaScript', () => {
const ts = `<script setup lang="ts">\nconst n: number = 1;\n</script>\n`;
const js = `<script setup>\nconst n = 1;\n</script>\n`;
expect(scriptRegion(ts).language).toBe('typescript');
expect(scriptRegion(js).language).toBe('javascript');
expect(extractFromSource('A.vue', ts).nodes.some((n) => n.name === 'n')).toBe(true);
expect(extractFromSource('B.vue', js).nodes.some((n) => n.name === 'n')).toBe(true);
});
});
10 changes: 9 additions & 1 deletion src/extraction/syntax-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ export interface SyntaxRegion {

const SCRIPT_BLOCK = /<script(\s[^>]*)?>([\s\S]*?)<\/script>/gi;
const TS_LANG_ATTR = /lang\s*=\s*["'](ts|typescript)["']/i;
/** Vue's extractor reads `lang="tsx"` with the TSX grammar; Svelte's has no TSX. */
const TSX_LANG_ATTR = /lang\s*=\s*["']tsx["']/i;
/** Astro's frontmatter: a `---` fence at the very top of the file. */
const ASTRO_FRONTMATTER = /^(---\r?\n)([\s\S]*?)\r?\n---/;

Expand Down Expand Up @@ -392,12 +394,18 @@ export function syntaxRegionsFor(source: string, language: Language): SyntaxRegi
regions.push({
start,
end: start + body.length,
language: TS_LANG_ATTR.test(match[1] ?? '') ? 'typescript' : 'javascript',
language: scriptLanguage(match[1] ?? '', language),
});
}
return regions;
}

/** The grammar a `<script>` block with these attributes is read with. */
function scriptLanguage(attrs: string, host: Language): Language {
if (host === 'vue' && TSX_LANG_ATTR.test(attrs)) return 'tsx';
return TS_LANG_ATTR.test(attrs) ? 'typescript' : 'javascript';
}

/* --------------------------------------------------------------- the API -- */

export interface TokenizeResult {
Expand Down
16 changes: 9 additions & 7 deletions src/extraction/vue-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -208,8 +208,10 @@ 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);
// The script's language from its lang attribute: tsx, TypeScript or JavaScript.
// Matched case-insensitively, as the viewer's syntax regions do.
const lang = /lang\s*=\s*["'](tsx|ts|typescript)["']/i.exec(attrs)?.[1]?.toLowerCase();
const language: Language = lang === 'tsx' ? 'tsx' : lang ? 'typescript' : 'javascript';

// Detect <script setup>
const isSetup = /\bsetup\b/.test(attrs);
Expand All @@ -229,7 +231,7 @@ export class VueExtractor {
content,
startLine: contentStartLine,
isSetup,
isTypeScript,
language,
});
}

Expand All @@ -240,10 +242,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)) {
Expand Down
6 changes: 3 additions & 3 deletions src/ui-server/highlight/languages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, or 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';
Expand Down