fix(vue): a self-closing <script /> no longer swallows the next script (#3070)

The Vue extractor read a self-closing `<script src="./legacy.js" />` block
as an opening tag running on to the next `</script>`, as the Astro and
Svelte extractors did until PR #3063 (PRs #2995 and #2997) landed. The
`<script setup>` after it was parsed with whatever lay in between and
contributed nothing, and a `<template>` in between lost its calls and its
components too. One copy of the same pattern sat in each of 9 places in 5
files, and that fix reached the 6 copies in Astro, Svelte and the viewer.

sfc-script.ts, which the Vue, Svelte and Astro extractors already share,
now exports the script pattern and the script-or-style pattern, built from
@maxmilian's regexes, plus sfcBlockLineRanges, the line ranges a template
scan skips. A self-closing tag has no body, so it covers nothing and isn't
blanked, and calls in its attributes are read. All 9 sites use them: each
extractor's script blocks, Astro's covered ranges, both Svelte template
scans, Vue's template-components scan, the Vue template-calls blanking
and the viewer's highlight regions. The pair also accepts a closing tag
written with a space, `</script >`, as the template-calls blanking always
did; the viewer finds a block's body from its opening tag for that reason.

Validation on main 61776927 (Windows 11):
- Red/green: 4 Vue tests (a `<script src ... />` block before
  `<script setup>`, one before `<template>`, an attribute call on a
  self-closing script in the template, and `</script >`) fail with the
  previous sources and pass; the 6 Astro and Svelte tests still pass.
- A/B: 5 Vue repos (vuejs/core, vuejs/docs, NekoBox, justgage,
  element-plus-playground; none has a self-closing script) are
  byte-identical to main. 7 Astro repos, 2 Svelte subtrees (shadcn-svelte
  docs routes, SvelteKit's basics test app) and the flask and express
  controls are byte-identical to the Astro and Svelte fix before this one.

Fixes #2989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 14:02:53 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent cb7fb61e76
commit de0a4eb362
8 changed files with 180 additions and 57 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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 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 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 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 Astro and Svelte, a self-closing script like Astro's `<script is:inline set:html={code} />` or `<script src="/analytics.js" />` in `<svelte:head>` no longer swallows the `<script>` that follows it. Before, that script's symbols and calls were dropped with a "parse produced no symbols" warning, and calls and components in the markup between the two were missed too, as they were after a self-closing `<style />`. Re-index Astro and Svelte projects after upgrading. Thanks @rezaularif for the report and @maxmilian for the fix. (#2989) - In Astro, Svelte and Vue, a self-closing script like Astro's `<script is:inline set:html={code} />`, `<script src="/analytics.js" />` in `<svelte:head>` or a Vue `<script src="./legacy.js" />` block no longer swallows the `<script>` that follows it. Before, that script's symbols and calls were dropped with a "parse produced no symbols" warning, and calls and components in the markup between the two were missed too, as they were after a self-closing `<style />`. Re-index Astro, Svelte and Vue projects after upgrading. Thanks @rezaularif for the report and @maxmilian for the fix. (#2989)
- 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 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, 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) - 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)
+102
View File
@@ -0,0 +1,102 @@
/**
* A self-closing `<script ... />` in a Vue single-file component, such as a
* `<script src="./legacy.js" />` block, has no body. Vue, Svelte and Astro
* shared one script pattern that read it as an opening tag running on to the
* next `</script>`: the `<script setup>` after it was parsed with whatever lay
* in between, failed, and contributed no symbols, and a template in between
* lost its calls and components too (#2989, #2995 and #2997 for Astro and
* Svelte). The three extractors now share one pattern pair, and a
* self-closing tag covers nothing a template scan should skip.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});
const calls = (result: ReturnType<typeof extractFromSource>, name: string, line: number) =>
result.unresolvedReferences.some((ref) => ref.referenceKind === 'calls' && ref.referenceName === name && ref.line === line);
const renders = (result: ReturnType<typeof extractFromSource>, name: string, line: number) =>
result.unresolvedReferences.some((ref) => ref.referenceKind === 'references' && ref.referenceName === name && ref.line === line);
describe('Vue self-closing <script />', () => {
it('does not swallow the <script setup> that follows it', () => {
const code = `<template>
<p>{{ formatDate(date) }}</p>
<Header />
</template>
<script src="./legacy.js" />
<script setup>
const ready = true;
function go() { return helper(); }
</script>
`;
const result = extractFromSource('src/components/Page.vue', code);
expect(result.errors).toEqual([]);
expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(7);
expect(result.nodes.find((n) => n.name === 'go')?.startLine).toBe(8);
expect(calls(result, 'helper', 8)).toBe(true);
expect(calls(result, 'formatDate', 2)).toBe(true);
expect(renders(result, 'Header', 3)).toBe(true);
});
it('keeps the template it comes before', () => {
const code = `<script src="./legacy.js" />
<template>
<p>{{ formatDate(date) }}</p>
<Header />
</template>
<script setup>
const ready = true;
</script>
`;
const result = extractFromSource('src/components/First.vue', code);
expect(result.errors).toEqual([]);
expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(7);
expect(calls(result, 'formatDate', 3)).toBe(true);
expect(renders(result, 'Header', 4)).toBe(true);
});
it("still reads a call in a self-closing <script />'s attributes", () => {
const code = `<template>
<article>
<script type="application/ld+json" :innerHTML="toJsonLd(post)" />
<p>{{ formatDate(post.date) }}</p>
<Header />
</article>
</template>
<script setup>
const ready = true;
</script>
`;
const result = extractFromSource('src/components/Post.vue', code);
expect(result.errors).toEqual([]);
expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(9);
expect(calls(result, 'toJsonLd', 3)).toBe(true);
expect(calls(result, 'formatDate', 4)).toBe(true);
expect(renders(result, 'Header', 5)).toBe(true);
});
// The template-call scan always took `</script >` as a closing tag; with one
// pattern for every site, the script reader does too.
it('ends a block at a closing tag written with a space, `</script >`', () => {
const code = `<template>
<p>{{ formatDate(date) }}</p>
</template>
<script setup>
const ready = true;
</script >
`;
const result = extractFromSource('src/components/Spaced.vue', code);
expect(result.errors).toEqual([]);
expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(5);
expect(calls(result, 'formatDate', 2)).toBe(true);
});
});
+5 -12
View File
@@ -2,7 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } fr
import { generateNodeId } from './tree-sitter-helpers'; import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter'; import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars'; import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode } from './sfc-script'; import { foldScriptResult, sfcFileNode, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script';
/** /**
* Astro built-in components — compiler-provided (`<Fragment>`) or shipped by * Astro built-in components — compiler-provided (`<Fragment>`) or shipped by
@@ -161,7 +161,7 @@ export class AstroExtractor {
// A self-closing `<script ... />` (e.g. `is:inline set:html={...}`) has no // A self-closing `<script ... />` (e.g. `is:inline set:html={...}`) has no
// body; match it on its own so it cannot run on to the next `</script>`. // body; match it on its own so it cannot run on to the next `</script>`.
const scriptRegex = /<script(\s[^>]*?)?(?:\/>|>(?<content>[\s\S]*?)<\/script>)/g; const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT);
let match; let match;
while ((match = scriptRegex.exec(this.source)) !== null) { while ((match = scriptRegex.exec(this.source)) !== null) {
@@ -228,16 +228,9 @@ export class AstroExtractor {
coveredRanges.push([frontmatter.startLine - 1, frontmatter.endLine]); coveredRanges.push([frontmatter.startLine - 1, frontmatter.endLine]);
} }
const tagRegex = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1>)/g; // A self-closing tag covers nothing: its attributes
let tagMatch; // (`set:html={serializeJsonLd(schema)}`) are template expressions.
while ((tagMatch = tagRegex.exec(this.source)) !== null) { coveredRanges.push(...sfcBlockLineRanges(this.source));
// A self-closing tag has no body to skip, and its attributes
// (`set:html={serializeJsonLd(schema)}`) are template expressions.
if (tagMatch[0].endsWith('/>')) continue;
const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
return coveredRanges; return coveredRanges;
} }
+45
View File
@@ -46,6 +46,51 @@ export function sfcScriptLanguage(attrs: string, host: Language): Language {
return 'javascript'; return 'javascript';
} }
/**
* A `<script>` element of a Vue, Svelte or Astro file: group 1 its opening
* tag's attributes, `content` its body. A self-closing tag — Astro's
* `<script is:inline set:html={code} />`, a third-party
* `<script src="/analytics.js" />` — is matched on its own, with no
* `content`: read as an opening tag it ran on to the next script's
* `</script>`, and that script was parsed with the markup in between and
* contributed nothing (#2989).
*
* Shared by the three extractors and the viewer's syntax regions. It carries
* the `g` flag, so each loop runs on its own copy (`new RegExp(...)`).
*/
export const SFC_SCRIPT_ELEMENT = /<script(\s[^>]*?)?(?:\/>|>(?<content>[\s\S]*?)<\/script\s*>)/g;
/**
* A `<script>` or `<style>` element, which the template scans skip: what it
* holds is code or CSS, not markup. A self-closing one (see
* {@link SFC_SCRIPT_ELEMENT}) has no body to skip, and its attributes, like
* `set:html={serializeJsonLd(schema)}`, are template expressions like any
* other's: {@link sfcElementHasBody} says which a match is.
*/
export const SFC_SCRIPT_OR_STYLE_ELEMENT = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1\s*>)/g;
/** Whether a match of {@link SFC_SCRIPT_OR_STYLE_ELEMENT} has a body: false for a self-closing tag. */
export function sfcElementHasBody(element: string): boolean {
return !element.endsWith('/>');
}
/**
* The lines (0-indexed, inclusive) of each `<script>` and `<style>` element
* with a body in `source`, which a template scan skips. A self-closing tag
* covers nothing.
*/
export function sfcBlockLineRanges(source: string): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
const elements = new RegExp(SFC_SCRIPT_OR_STYLE_ELEMENT);
let match: RegExpExecArray | null;
while ((match = elements.exec(source)) !== null) {
if (!sfcElementHasBody(match[0])) continue;
const startLine = (source.substring(0, match.index).match(/\n/g) || []).length;
ranges.push([startLine, startLine + (match[0].match(/\n/g) || []).length]);
}
return ranges;
}
/** The SFC's own file node — the whole file, as the tree-sitter extractor makes one. */ /** 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 { export function sfcFileNode(filePath: string, source: string, language: Language): Node {
return { return {
+7 -26
View File
@@ -2,7 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Lan
import { generateNodeId } from './tree-sitter-helpers'; import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter'; import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars'; import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode, sfcScriptLanguage } from './sfc-script'; import { foldScriptResult, sfcFileNode, sfcScriptLanguage, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script';
/** Svelte 5 rune names — compiler builtins, not real functions */ /** Svelte 5 rune names — compiler builtins, not real functions */
const SVELTE_RUNES = new Set([ const SVELTE_RUNES = new Set([
@@ -128,7 +128,7 @@ export class SvelteExtractor {
// A self-closing `<script ... />` (e.g. a third-party script in // A self-closing `<script ... />` (e.g. a third-party script in
// `<svelte:head>`) has no body; match it on its own so it cannot run on to // `<svelte:head>`) has no body; match it on its own so it cannot run on to
// the next `</script>`. // the next `</script>`.
const scriptRegex = /<script(\s[^>]*?)?(?:\/>|>(?<content>[\s\S]*?)<\/script>)/g; const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT);
let match; let match;
while ((match = scriptRegex.exec(this.source)) !== null) { while ((match = scriptRegex.exec(this.source)) !== null) {
@@ -204,20 +204,10 @@ export class SvelteExtractor {
componentNodeId: string, componentNodeId: string,
_scriptBlocks: Array<{ content: string; startLine: number }> _scriptBlocks: Array<{ content: string; startLine: number }>
): void { ): void {
// Build a set of line ranges covered by <script> and <style> blocks so we skip them // Line ranges covered by <script> and <style> blocks, so we skip them. A
const coveredRanges: Array<[number, number]> = []; // self-closing tag covers nothing: its attributes
// (`src={assetUrl('/analytics.js')}`) are template expressions.
// Find all <script>...</script> and <style>...</style> ranges const coveredRanges = sfcBlockLineRanges(this.source);
const tagRegex = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1>)/g;
let tagMatch;
while ((tagMatch = tagRegex.exec(this.source)) !== null) {
// A self-closing tag has no body to skip, and its attributes
// (`src={assetUrl('/analytics.js')}`) are template expressions.
if (tagMatch[0].endsWith('/>')) continue;
const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
// Find template expressions: {...} outside of script/style blocks // Find template expressions: {...} outside of script/style blocks
// Matches curly-brace expressions, excluding Svelte block syntax ({#if}, {:else}, {/if}, {@html}, {@render}) // Matches curly-brace expressions, excluding Svelte block syntax ({#if}, {:else}, {/if}, {@html}, {@render})
@@ -266,16 +256,7 @@ export class SvelteExtractor {
*/ */
private extractTemplateComponents(componentNodeId: string): void { private extractTemplateComponents(componentNodeId: string): void {
// Build ranges covered by <script> and <style> blocks to skip them // Build ranges covered by <script> and <style> blocks to skip them
const coveredRanges: Array<[number, number]> = []; const coveredRanges = sfcBlockLineRanges(this.source);
const tagRegex = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1>)/g;
let tagMatch;
while ((tagMatch = tagRegex.exec(this.source)) !== null) {
// A self-closing tag has no body to skip.
if (tagMatch[0].endsWith('/>')) continue;
const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
const lines = this.source.split('\n'); const lines = this.source.split('\n');
// Match PascalCase opening/self-closing tags (closing tags </Foo> start with </ so won't match) // Match PascalCase opening/self-closing tags (closing tags </Foo> start with </ so won't match)
+6 -4
View File
@@ -43,7 +43,7 @@ import { Language } from '../types';
import { EXTRACTORS } from './languages'; import { EXTRACTORS } from './languages';
import { getParser, loadGrammarsForLanguages } from './grammars'; import { getParser, loadGrammarsForLanguages } from './grammars';
import { parseWithinBudget } from './parse-budget'; import { parseWithinBudget } from './parse-budget';
import { sfcScriptLanguage } from './sfc-script'; import { sfcScriptLanguage, SFC_SCRIPT_ELEMENT } from './sfc-script';
import type { LanguageExtractor } from './tree-sitter-types'; import type { LanguageExtractor } from './tree-sitter-types';
/* ------------------------------------------------------------- the classes -- */ /* ------------------------------------------------------------- the classes -- */
@@ -362,8 +362,9 @@ export interface SyntaxRegion {
language: Language; language: Language;
} }
// A self-closing `<script ... />` has no body and must not run on to the next `</script>`. // The extractors' own script element, read case-insensitively here. A
const SCRIPT_BLOCK = /<script(\s[^>]*?)?(?:\/>|>([\s\S]*?)<\/script>)/gi; // self-closing `<script ... />` has no body and must not run on to the next `</script>`.
const SCRIPT_BLOCK = new RegExp(SFC_SCRIPT_ELEMENT.source, 'gi');
/** Astro's frontmatter: a `---` fence at the very top of the file. */ /** Astro's frontmatter: a `---` fence at the very top of the file. */
const ASTRO_FRONTMATTER = /^(---\r?\n)([\s\S]*?)\r?\n---/; const ASTRO_FRONTMATTER = /^(---\r?\n)([\s\S]*?)\r?\n---/;
@@ -390,7 +391,8 @@ export function syntaxRegionsFor(source: string, language: Language): SyntaxRegi
while ((match = SCRIPT_BLOCK.exec(source)) !== null) { while ((match = SCRIPT_BLOCK.exec(source)) !== null) {
const body = match[2] ?? ''; const body = match[2] ?? '';
if (body.trim() === '') continue; if (body.trim() === '') continue;
const start = match.index + match[0].length - body.length - '</script>'.length; // The body starts right after the opening tag, whose attributes hold no `>`.
const start = match.index + match[0].indexOf('>') + 1;
regions.push({ regions.push({
start, start,
end: start + body.length, end: start + body.length,
+9 -12
View File
@@ -2,7 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Lan
import { generateNodeId } from './tree-sitter-helpers'; import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter'; import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars'; import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode, sfcScriptLanguage } from './sfc-script'; import { foldScriptResult, sfcFileNode, sfcScriptLanguage, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script';
import { vueOptionsMembers } from './vue-options-api'; import { vueOptionsMembers } from './vue-options-api';
import { vueTemplateCalls } from './vue-template-calls'; import { vueTemplateCalls } from './vue-template-calls';
@@ -201,12 +201,15 @@ export class VueExtractor {
language: Language; language: Language;
}> = []; }> = [];
const scriptRegex = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/g; // A self-closing `<script src="./legacy.js" />` has no body; match it on
// its own so it cannot run on to the next block's `</script>`.
const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT);
let match; let match;
while ((match = scriptRegex.exec(this.source)) !== null) { while ((match = scriptRegex.exec(this.source)) !== null) {
if (match.groups?.content === undefined) continue;
const attrs = match[1] || ''; const attrs = match[1] || '';
const content = match.groups?.content || match[2] || ''; const content = match.groups.content;
// TypeScript, TSX or JavaScript, from the lang attribute // TypeScript, TSX or JavaScript, from the lang attribute
const language = sfcScriptLanguage(attrs, 'vue'); const language = sfcScriptLanguage(attrs, 'vue');
@@ -288,15 +291,9 @@ export class VueExtractor {
// Ranges covered by <script> / <style> blocks — skip them so script // Ranges covered by <script> / <style> blocks — skip them so script
// identifiers and CSS selectors aren't mistaken for template tags. This // identifiers and CSS selectors aren't mistaken for template tags. This
// also correctly handles nested <template> tags (v-if / slots), which a // also correctly handles nested <template> tags (v-if / slots), which a
// single non-greedy <template>…</template> match would mis-bound. // single non-greedy <template>…</template> match would mis-bound. A
const coveredRanges: Array<[number, number]> = []; // self-closing `<script ... />` covers nothing.
const blockRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g; const coveredRanges = sfcBlockLineRanges(this.source);
let blockMatch;
while ((blockMatch = blockRegex.exec(this.source)) !== null) {
const startLine = (this.source.substring(0, blockMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (blockMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
const lines = this.source.split('\n'); const lines = this.source.split('\n');
// Opening / self-closing tags (closing `</Foo>` starts with `</`, so the // Opening / self-closing tags (closing `</Foo>` starts with `</`, so the
+5 -2
View File
@@ -27,6 +27,8 @@
* Approach after the template-binding scan in #2073 by @L0garithmic. * Approach after the template-binding scan in #2073 by @L0garithmic.
*/ */
import { SFC_SCRIPT_OR_STYLE_ELEMENT, sfcElementHasBody } from './sfc-script';
export interface VueTemplateCall { export interface VueTemplateCall {
/** The callee as a script-side call names it: `useBar`, `store.fetchUsers`. */ /** The callee as a script-side call names it: `useBar`, `store.fetchUsers`. */
name: string; name: string;
@@ -74,10 +76,11 @@ 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. */ /** Every call written in the root `<template>` of a Vue SFC, in source order. */
export function vueTemplateCalls(source: string): VueTemplateCall[] { export function vueTemplateCalls(source: string): VueTemplateCall[] {
// Blank <script>/<style> blocks and HTML comments, keeping every offset. // Blank <script>/<style> blocks and HTML comments, keeping every offset. A
// self-closing `<script ... />` has no body to blank: its attributes are markup.
const blank = (m: string) => m.replace(/[^\n]/g, ' '); const blank = (m: string) => m.replace(/[^\n]/g, ' ');
const masked = source const masked = source
.replace(/<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1\s*>/g, blank) .replace(new RegExp(SFC_SCRIPT_OR_STYLE_ELEMENT), (m) => (sfcElementHasBody(m) ? blank(m) : m))
.replace(/<!--[\s\S]*?-->/g, blank); .replace(/<!--[\s\S]*?-->/g, blank);
const root = rootTemplate(masked); const root = rootTemplate(masked);
if (!root) return []; if (!root) return [];