From a7f4c5c5bb3742c8272aaa56bd84aebe02fc4664 Mon Sep 17 00:00:00 2001 From: Nic <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:01:10 -0400 Subject: [PATCH] the template is read by the parser: one call per component, the compiler's shape put on the tree after it, the scope tables cut per expression for the analysis, the parser's errors mapped to ours; phase 1's own reader goes --- packages/svelte/src/compiler/migrate/index.js | 3 +- .../src/compiler/phases/1-parse/grammar.js | 80 + .../src/compiler/phases/1-parse/index.js | 1008 ++++++-- .../svelte/src/compiler/phases/1-parse/js.js | 165 +- .../src/compiler/phases/1-parse/script.js | 83 - .../compiler/phases/1-parse/state/element.js | 988 -------- .../compiler/phases/1-parse/state/fragment.js | 17 - .../src/compiler/phases/1-parse/state/tag.js | 664 ----- .../src/compiler/phases/1-parse/state/text.js | 23 - .../compiler/phases/1-parse/utils/entities.js | 2234 ----------------- .../src/compiler/phases/1-parse/utils/html.js | 134 - packages/svelte/src/compiler/phases/scope.js | 11 +- .../samples/animation/output.json | 6 +- .../samples/attribute-shorthand/output.json | 6 +- .../samples/await-catch/output.json | 6 +- .../samples/await-then-catch/output.json | 12 +- .../samples/binding-shorthand/output.json | 12 +- .../samples/each-block-else/output.json | 6 +- .../samples/each-block-indexed/output.json | 6 +- .../samples/each-block-keyed/output.json | 6 +- .../samples/each-block/output.json | 6 +- .../samples/generic-snippets/output.json | 12 +- .../samples/loose-invalid-block/output.json | 12 +- .../loose-invalid-expression/output.json | 151 +- .../samples/loose-unclosed-block/output.json | 6 +- .../no-error-if-before-closing/output.json | 12 +- .../samples/unusual-identifier/output.json | 6 +- .../samples/comment-in-tag/output.json | 138 +- .../const-tag-parenthesized-init/output.json | 96 +- .../generic-snippets-arrow/output.json | 6 +- .../samples/generic-snippets/output.json | 12 +- .../samples/loose-invalid-block/output.json | 12 +- .../loose-invalid-expression/output.json | 137 +- .../samples/snippets/output.json | 6 +- 34 files changed, 1244 insertions(+), 4838 deletions(-) create mode 100644 packages/svelte/src/compiler/phases/1-parse/grammar.js delete mode 100644 packages/svelte/src/compiler/phases/1-parse/script.js delete mode 100644 packages/svelte/src/compiler/phases/1-parse/state/element.js delete mode 100644 packages/svelte/src/compiler/phases/1-parse/state/fragment.js delete mode 100644 packages/svelte/src/compiler/phases/1-parse/state/tag.js delete mode 100644 packages/svelte/src/compiler/phases/1-parse/state/text.js delete mode 100644 packages/svelte/src/compiler/phases/1-parse/utils/entities.js delete mode 100644 packages/svelte/src/compiler/phases/1-parse/utils/html.js diff --git a/packages/svelte/src/compiler/migrate/index.js b/packages/svelte/src/compiler/migrate/index.js index 3da3d90034..531374b632 100644 --- a/packages/svelte/src/compiler/migrate/index.js +++ b/packages/svelte/src/compiler/migrate/index.js @@ -5,8 +5,7 @@ /** @import { AST, Binding, ValidatedCompileOptions } from '#compiler' */ import MagicString from 'magic-string'; import { walk } from 'zimmerframe'; -import { parse } from '../phases/1-parse/index.js'; -import { regex_valid_component_name } from '../phases/1-parse/state/element.js'; +import { parse, regex_valid_component_name } from '../phases/1-parse/index.js'; import { analyze_component } from '../phases/2-analyze/index.js'; import { get_rune } from '../phases/scope.js'; import { reset, UNKNOWN_FILENAME } from '../state.js'; diff --git a/packages/svelte/src/compiler/phases/1-parse/grammar.js b/packages/svelte/src/compiler/phases/1-parse/grammar.js new file mode 100644 index 0000000000..a3bd3359ef --- /dev/null +++ b/packages/svelte/src/compiler/phases/1-parse/grammar.js @@ -0,0 +1,80 @@ +/** The template language, as the parser reads it: the delimiters, the elements and their fields, the directives and the blocks. */ +export const grammar = ` +host svelte + +document Root css=style js=list options=null comments=comments module?=script:module { instance?=script { fragment=fragment } } +delimiters { } +attributes expressions shorthand +sigils open=# branch=: close=/ tag=@ +autoclose +trim +void area base br col command embed hr img input keygen link meta param source track wbr +fragment Fragment nodes scope +elements name=name attributes=attributes children=fragment +text Text data=data raw=raw +comment Comment data=data + +element svelte:element SvelteElement this=tag:text +element svelte:component SvelteComponent this=expression +element svelte:self SvelteSelf +element svelte:window SvelteWindow root once +element svelte:document SvelteDocument root once +element svelte:body SvelteBody root once +element svelte:head SvelteHead root once +element svelte:options SvelteOptions root once +element svelte:fragment SvelteFragment +element svelte:boundary SvelteBoundary +element title TitleElement inside svelte:head +element slot SlotElement outside shadowrootmode +element textarea RegularElement rcdata +element script RegularElement raw +element style RegularElement raw +element component-name Component +element * RegularElement + +script script module=context:module module=module typescript=lang:ts +style style + +directives arg=: modifier=| field:arg=name field:modifiers=modifiers +directive bind BindDirective expression?name unique:attribute +directive on OnDirective expression? +directive use UseDirective expression? +directive class ClassDirective expression?name unique +directive style StyleDirective value unique +directive transition TransitionDirective expression? intro outro +directive in TransitionDirective expression? intro !outro +directive out TransitionDirective expression? !intro outro +directive animate AnimateDirective expression? +directive let LetDirective pattern?name declares + +spread SpreadAttribute expression + +block if IfBlock chain=elseif + open test=expression -> consequent + branch else if test=expression -> alternate chain consequent + branch else -> alternate + +block each EachBlock + open expression=expression [ as context=pattern ] [ , index?=identifier ] [ ( key?=expression ) ] -> body declares context index + branch else -> fallback? + +block await AwaitBlock + open expression=expression [ then [ value=pattern ] -> then declares value | catch [ error=pattern ] -> catch declares error ] -> pending + branch then [ value=pattern ] -> then declares value + branch catch [ error=pattern ] -> catch declares error + +block key KeyBlock + open expression=expression -> fragment + +block snippet SnippetBlock + open expression=identifier [ typeParams?=typeParameters ] parameters=params -> body declares expression:outside parameters + +tag html HtmlTag expression=expression +tag debug DebugTag identifiers=identifiers +tag const ConstTag declaration=const +tag render RenderTag expression=expression +tag attach AttachTag expression=expression attribute + +declaration DeclarationTag declaration=statement +expression ExpressionTag expression=expression +`; diff --git a/packages/svelte/src/compiler/phases/1-parse/index.js b/packages/svelte/src/compiler/phases/1-parse/index.js index bc027efbd1..4426c7538d 100644 --- a/packages/svelte/src/compiler/phases/1-parse/index.js +++ b/packages/svelte/src/compiler/phases/1-parse/index.js @@ -1,190 +1,840 @@ /** @import { AST } from '#compiler' */ -/** @import { Location } from 'locate-character' */ -/** @import * as ESTree from 'estree' */ -import { Source, isIdentifierStart, isIdentifierChar } from '@teasel/parser'; -import fragment from './state/fragment.js'; -import { value_names } from '../../utils/ast.js'; +/** @import { Expression, Identifier, Node, Pattern, Program } from 'estree' */ +/** @import { Parsed, Scope } from '@teasel/parser' */ +import { Source, parentOf, scopeOf } from '@teasel/parser'; import * as e from '../../errors.js'; -import { create_fragment, disallow_children } from '../nodes.js'; -import read_options from './options.js'; -import { is_reserved } from '../../../utils.js'; +import * as w from '../../warnings.js'; import * as state from '../../state.js'; +import { ExpressionMetadata, disallow_children } from '../nodes.js'; +import { keep_tables } from '../../utils/ast.js'; +import { grammar } from './grammar.js'; +import { dedent, unsupported } from './js.js'; +import read_options from './options.js'; import { is_whitespace } from './utils/whitespace.js'; +import { list } from '../../utils/string.js'; + +const VOID = + 'area base br col command embed hr img input keygen link meta param source track wbr'.split(' '); +const SCRIPT_RESERVED = ['server', 'client', 'worker', 'test', 'default']; +const SCRIPT_ALLOWED = ['context', 'generics', 'lang', 'module']; + +/** The nodes of the template language; the JavaScript under them the parser read on its own. */ +const TEMPLATE_TYPES = new Set([ + 'Root', + 'Fragment', + 'Text', + 'Comment', + 'Script', + 'StyleSheet', + 'RegularElement', + 'SvelteElement', + 'SvelteComponent', + 'SvelteSelf', + 'SvelteWindow', + 'SvelteDocument', + 'SvelteBody', + 'SvelteHead', + 'SvelteOptions', + 'SvelteFragment', + 'SvelteBoundary', + 'TitleElement', + 'SlotElement', + 'Component', + 'Attribute', + 'SpreadAttribute', + 'AttachTag', + 'BindDirective', + 'ClassDirective', + 'StyleDirective', + 'OnDirective', + 'UseDirective', + 'TransitionDirective', + 'AnimateDirective', + 'LetDirective', + 'IfBlock', + 'EachBlock', + 'AwaitBlock', + 'KeyBlock', + 'SnippetBlock', + 'HtmlTag', + 'DebugTag', + 'ConstTag', + 'RenderTag', + 'ExpressionTag', + 'DeclarationTag' +]); + +/** @param {{ type: string }} node */ +export function is_template_node(node) { + return TEMPLATE_TYPES.has(node.type); +} + +const META_TAGS = [ + 'svelte:head', + 'svelte:options', + 'svelte:window', + 'svelte:document', + 'svelte:body', + 'svelte:element', + 'svelte:component', + 'svelte:self', + 'svelte:fragment', + 'svelte:boundary' +]; const regex_lang_attribute = /|]*|(?:[^=>'"/]+=(?:"[^"]*"|'[^']*'|[^>\s]+)\s+)*)lang=(["'])?([^"' >]+)\1[^>]*>/g; -export class Parser { - /** - * @readonly - * @type {string} - */ - template; +export const regex_valid_component_name = + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#identifiers adjusted for our needs + // (must start with uppercase letter if no dots, can contain dots) + /^(?:\p{Lu}[$‌‍\p{ID_Continue}.]*|\p{ID_Start}[$‌‍\p{ID_Continue}]*(?:\.[$‌‍\p{ID_Continue}]+)+)$/u; - /** - * Whether or not we're in loose parsing mode, in which - * case we try to continue parsing as much as possible - * @type {boolean} - */ - loose; +/** + * @param {string} template + * @param {boolean} [loose] keep reading past what cannot be read, for editors + * @param {boolean} [erase] hand back JavaScript for TypeScript input, as the compiler wants it + * @returns {AST.Root} + */ +export function parse(template, loose = false, erase = false) { + if (typeof template !== 'string') { + throw new TypeError('Template must be a string'); + } - /** */ - index = 0; + state.set_source(template); + const trimmed = template.trimEnd(); + + let match_lang; + do match_lang = regex_lang_attribute.exec(trimmed); + while (match_lang && match_lang[0][1] !== 's'); // ensure it starts with '} */ + let answer; + try { + answer = /** @type {any} */ ( + new Source(trimmed, { + host: grammar, + sourceType: 'module', + typescript: ts && (erase ? 'erase' : true), + comments: true, + locations: true, + scopes: true, + errorRecovery: loose, + parenthesized: true, + // a script may export what the component declares elsewhere + allowUndeclaredExports: true + }) + ).parse(); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + throw_error(/** @type {any} */ (error), trimmed); + } + unsupported(answer.typescript); + + const root = answer.node; + delete (/** @type {any} */ (root).loc); + root.start = 0; + root.end = template.length; + root.metadata = { ts }; + + /** @type {(Node | Node[])[]} the JavaScript of the template, each piece with its own tables */ + const roots = []; + const finish = new Finish(trimmed, roots); + finish.root(root); + tables(answer, roots); + + // a comment between attributes is kept whole, one in JavaScript loses its line's indentation + const spans = roots.map((root) => { + const [first, last] = Array.isArray(root) ? [root[0], root[root.length - 1]] : [root, root]; + return [/** @type {number} */ (first?.start), /** @type {number} */ (last?.end)]; + }); + for (const comment of root.comments) { + if (spans.some(([start, end]) => comment.start >= start && comment.end <= end)) + dedent(comment, trimmed); + } - /** @type {AST.CSS.CSSComment[]} */ - css_comments = []; + return root; +} +/** + * Brings the parser's tree to the compiler's shape: the metadata each node carries, the + * location of each name, the options read out of ``. + */ +class Finish { /** - * Creates a minimal parser instance for CSS-only parsing. - * Skips Svelte component parsing setup. - * @param {string} source - * @returns {Parser} + * @param {string} template + * @param {(Node | Node[])[]} roots */ - static forCss(source) { - const parser = Object.create(Parser.prototype); - parser.template = source; - parser.index = 0; - parser.loose = false; - parser.css_comments = []; - return parser; + constructor(template, roots) { + this.template = template; + this.roots = roots; } - /** Whether we're parsing in TypeScript mode */ - ts = false; + /** @param {AST.Root} root */ + root(root) { + if (root.module) this.script(root.module, root); + if (root.instance) this.script(root.instance, root); + if (root.css) this.css(root.css, root); + this.fragment(root.fragment, false); - /** The template as the JavaScript parser holds it, for every expression, pattern and tag in it */ - js; + const index = root.fragment.nodes.findIndex((node) => node.type === 'SvelteOptions'); + if (index !== -1) { + const [options] = /** @type {AST.SvelteOptionsRaw[]} */ ( + root.fragment.nodes.splice(index, 1) + ); + root.options = read_options(options); + disallow_children(options); + // We need this for the old AST format + Object.defineProperty(root.options, '__raw__', { value: options, enumerable: false }); + } + } - /** @type {AST.TemplateNode[]} */ - stack = []; + /** + * @param {AST.Script} script + * @param {AST.Root} root + */ + script(script, root) { + delete (/** @type {any} */ (script).loc); + this.attributes(script.attributes); + for (const attribute of /** @type {AST.Attribute[]} */ (script.attributes)) { + if (SCRIPT_RESERVED.includes(attribute.name)) + e.script_reserved_attribute(attribute, attribute.name); + if (!SCRIPT_ALLOWED.includes(attribute.name)) w.script_unknown_attribute(attribute); + } + this.js(script.content); + const { loc } = script.content; + if (loc) { + // the legacy AST places the program at the tag, not at its contents + ({ line: loc.start.line, column: loc.start.column } = state.locator(script.start)); + ({ line: loc.end.line, column: loc.end.column } = state.locator(script.end)); + } + const comment = comment_before(root.fragment.nodes, script.start); + if (comment) { + // We take advantage of the fact that the root will never have leadingComments set, + // and set the previous comment to it so that the warning mechanism can later + // inspect the root and see if there was a html comment before it silencing specific warnings. + script.content.leadingComments = [{ type: 'Line', value: comment.data }]; + } + } - /** @type {AST.Fragment[]} */ - fragments = []; + /** + * @param {AST.CSS.StyleSheet} css + * @param {AST.Root} root + */ + css(css, root) { + delete (/** @type {any} */ (css).loc); + this.attributes(css.attributes); + css.content.comment = comment_before(root.fragment.nodes, css.start); + const walk = (/** @type {any} */ node) => { + if (Array.isArray(node)) return node.forEach(walk); + if (!node || typeof node !== 'object') return; + delete node.loc; + switch (node.type) { + case 'Rule': + node.metadata = { + parent_rule: null, + has_local_selectors: false, + has_global_selectors: false, + is_global_block: false + }; + break; + case 'ComplexSelector': + node.metadata = { rule: null, is_global: false, used: false }; + break; + case 'RelativeSelector': + node.metadata = { is_global: false, is_global_like: false, scoped: false }; + break; + } + for (const key in node) if (key !== 'metadata') walk(node[key]); + }; + walk(css.children); + walk(css.comments); + delete (/** @type {any} */ (css.content).loc); + } - /** @type {AST.Root} */ - root; + /** + * @param {AST.Fragment} fragment + * @param {boolean} transparent + */ + fragment(fragment, transparent) { + delete (/** @type {any} */ (fragment).loc); + fragment.metadata = { transparent, dynamic: false }; + for (const node of fragment.nodes) this.node(node); + if (transparent && fragment.nodes.some((node) => node.type === 'DeclarationTag')) { + fragment.metadata.transparent = false; + } + } - /** @type {Record} */ - meta_tags = {}; + /** @param {AST.Fragment | null | undefined} fragment */ + body(fragment) { + if (fragment) this.fragment(fragment, false); + } - /** @type {LastAutoClosedTag | undefined} */ - last_auto_closed_tag; + /** @param {AST.TemplateNode} node */ + node(node) { + // the parser locates every node; the AST locates the JavaScript ones + delete (/** @type {any} */ (node).loc); + switch (node.type) { + case 'Text': + case 'Comment': + return; + case 'RegularElement': + case 'SvelteElement': + case 'SvelteComponent': + case 'SvelteSelf': + case 'SvelteWindow': + case 'SvelteDocument': + case 'SvelteBody': + case 'SvelteHead': + case 'SvelteOptions': + case 'SvelteFragment': + case 'SvelteBoundary': + case 'TitleElement': + case 'SlotElement': + case 'Component': { + node.name_loc = loc(node.start + 1, node.start + 1 + node.name.length); + /** @type {any} */ (node).metadata = + node.type === 'RegularElement' + ? { + svg: false, + mathml: false, + scoped: false, + has_spread: false, + path: [], + synthetic_value_node: null + } + : /** @type {any} */ ({}); + if (node.type === 'SvelteElement') { + // a tag named in text is the literal Svelte writes by hand, quoted its way + if (node.tag.type === 'Literal' && node.tag.raw === node.tag.value) + node.tag.raw = `'${node.tag.value}'`; + else this.js(node.tag); + node.metadata.expression = new ExpressionMetadata(); + } + if (node.type === 'SvelteComponent') this.js(node.expression); + if (node.type === 'SvelteComponent' || node.type === 'Component') { + node.metadata.expression = new ExpressionMetadata(); + } + this.attributes(node.attributes); + this.fragment(node.fragment, true); + if (node.type === 'RegularElement') this.implicitly_closed(node); + return; + } + case 'ExpressionTag': + case 'HtmlTag': + this.expression(node, node.expression); + return; + case 'RenderTag': + this.expression(node, node.expression); + node.metadata = { + ...node.metadata, + dynamic: false, + arguments: [], + path: [], + snippets: new Set() + }; + return; + case 'ConstTag': + case 'DeclarationTag': + this.expression(node, node.declaration); + return; + case 'DebugTag': + for (const identifier of node.identifiers) this.js(identifier); + return; + case 'IfBlock': + this.expression(node, node.test); + this.body(node.consequent); + this.body(node.alternate); + return; + case 'EachBlock': { + const index = /** @type {Identifier | string | undefined} */ (node.index); + if (index !== undefined && typeof index !== 'string') node.index = index.name; + node.metadata = /** @type {any} */ (null); // filled in later + this.js(node.expression); + if (node.context) this.js(node.context); + if (node.key) this.js(node.key); + this.body(node.body); + this.body(node.fallback); + return; + } + case 'AwaitBlock': + this.expression(node, node.expression); + if (node.value) this.js(node.value); + if (node.error) this.js(node.error); + this.body(node.pending); + this.body(node.then); + this.body(node.catch); + return; + case 'KeyBlock': + this.expression(node, node.expression); + this.body(node.fragment); + return; + case 'SnippetBlock': + node.metadata = { can_hoist: false, sites: new Set() }; + this.js(node.parameters); + this.body(node.body); + return; + } + } /** - * @param {string} template - * @param {boolean} loose - * @param {boolean} [erase] hand back JavaScript for TypeScript input, as the compiler wants it + * @param {AST.ExpressionTag | AST.HtmlTag | AST.RenderTag | AST.ConstTag | AST.DeclarationTag | AST.IfBlock | AST.AwaitBlock | AST.KeyBlock | AST.SpreadAttribute | AST.AttachTag | AST.Directive} node + * @param {Node | null} expression */ - constructor(template, loose, erase = false) { - if (typeof template !== 'string') { - throw new TypeError('Template must be a string'); + expression(node, expression) { + /** @type {any} */ (node).metadata = { expression: new ExpressionMetadata() }; + if (expression) this.js(expression); + } + + /** @param {Array} attributes */ + attributes(attributes) { + for (const attribute of attributes) { + delete (/** @type {any} */ (attribute).loc); + switch (attribute.type) { + case 'Attribute': + attribute.metadata = { delegated: false, needs_clsx: false }; + attribute.name_loc = this.name_loc(attribute); + this.value(attribute.value); + break; + case 'SpreadAttribute': + case 'AttachTag': + this.expression(attribute, attribute.expression); + break; + case 'StyleDirective': + this.expression(attribute, null); + attribute.name_loc = this.name_loc(attribute); + this.value(attribute.value); + break; + case 'LetDirective': { + this.expression(attribute, null); + attribute.name_loc = this.name_loc(attribute); + const expression = /** @type {Pattern | null} */ (attribute.expression); + if ( + expression?.type === 'Identifier' && + expression.name === attribute.name && + expression.start === attribute.start + 4 + ) { + attribute.expression = null; + } else if (expression) { + attribute.expression = /** @type {any} */ (to_expression(expression)); + this.js(/** @type {Node} */ (attribute.expression)); + } + break; + } + default: + this.expression(attribute, attribute.expression); + attribute.name_loc = this.name_loc(attribute); + } } + } - this.loose = loose; - this.template = template.trimEnd(); + /** @param {true | AST.ExpressionTag | Array} value */ + value(value) { + if (value === true) return; + for (const chunk of Array.isArray(value) ? value : [value]) { + delete (/** @type {any} */ (chunk).loc); + if (chunk.type === 'ExpressionTag') this.expression(chunk, chunk.expression); + } + } - let match_lang; + /** + * The location of an attribute's name as the tag was read: up to whitespace, a slash, a + * quote or `=`; a shorthand's is its identifier's. + * @param {AST.Attribute | AST.Directive} attribute + */ + name_loc(attribute) { + const { template } = this; + const start = attribute.start; + const value = /** @type {AST.Attribute} */ (attribute).value; + if (template[start] === '{' && value !== true && !Array.isArray(value)) { + const { expression } = value; + return loc(/** @type {number} */ (expression.start), /** @type {number} */ (expression.end)); + } + let end = start; + while (end < template.length) { + const code = template.charCodeAt(end); + if ( + is_whitespace(code) || + code === 47 || + code === 62 || + code === 34 || + code === 39 || + code === 61 + ) + break; + end += 1; + } + return loc(start, end); + } - do match_lang = regex_lang_attribute.exec(template); - while (match_lang && match_lang[0][1] !== 's'); // ensure it starts with '', end - 2)) return; + const closing = template.lastIndexOf('$/.test(template.slice(closing + 2 + name.length, end)) + ) { + return; + } + const closer = /^<(\/?)([^\s/>]+)/.exec(template.slice(end, end + 200)); + if (!closer) return; + w.element_implicitly_closed( + { start: node.start, end: node.fragment.nodes[0]?.start ?? end }, + `<${closer[1]}${closer[2]}>`, + `` + ); + } - regex_lang_attribute.lastIndex = 0; // reset matched index to pass tests - otherwise declare the regex inside the constructor + /** @param {Node | Node[]} node a piece of JavaScript the parser read on its own */ + js(node) { + this.roots.push(node); + } +} - this.ts = match_lang?.[2] === 'ts'; - this.js = new Source(this.template, { - sourceType: 'module', - typescript: this.ts && (erase ? 'erase' : true), - comments: true, - locations: true, - scopes: true, - errorRecovery: loose, - parenthesized: true, - // a script may export what the component declares elsewhere - allowUndeclaredExports: true +/** + * The parser's tables cut to each piece of JavaScript, as the scope analysis reads them piece by + * piece: the scopes opened inside it, the bindings declared and the references made there, and + * first the scope around it. + * @param {Parsed} answer + * @param {(Node | Node[])[]} roots + */ +function tables(answer, roots) { + const scopes = /** @type {Scope[]} */ (answer.scopes); + const bindings = /** @type {import('@teasel/parser').Binding[]} */ (answer.bindings); + const references = /** @type {import('@teasel/parser').Reference[]} */ (answer.references); + /** @type {Scope} */ + const nowhere = { kind: 'fragment', node: null, parent: null, topLevelAwait: false }; + // each table in source order, so a root's entries are one run of it; a fragment has no span + // and holds JavaScript rather than sitting in it + const opening = scopes + .filter((scope) => typeof (/** @type {any} */ (scope.node)?.start) === 'number') + .sort((a, b) => /** @type {any} */ (a.node).start - /** @type {any} */ (b.node).start); + const named = scopes.filter((scope) => scope.node === null); + const declaring = bindings + .filter((binding) => binding.node !== null) + .sort((a, b) => /** @type {any} */ (a.node).start - /** @type {any} */ (b.node).start); + const nameless = bindings.filter((binding) => binding.node === null); + const referring = [...references].sort( + (a, b) => /** @type {number} */ (a.node.start) - /** @type {number} */ (b.node.start) + ); + /** + * The entries of a sorted table inside a span. + * @template T + * @param {T[]} table + * @param {(entry: T) => any} node + * @param {number} start + * @param {number} end + */ + const within = (table, node, start, end) => { + let low = 0; + let high = table.length; + while (low < high) { + const mid = (low + high) >> 1; + if (node(table[mid]).start < start) low = mid + 1; + else high = mid; + } + /** @type {T[]} */ + const found = []; + for (let i = low; i < table.length && node(table[i]).start < end; i += 1) { + if (node(table[i]).end <= end) found.push(table[i]); + } + return found; + }; + for (const root of roots) { + const list = Array.isArray(root) ? root : [root]; + if (list.length === 0) continue; + const start = /** @type {number} */ (list[0].start); + const end = /** @type {number} */ (list[list.length - 1].end); + // a script's program is the scope itself; any other piece sits in the scope around it + const own = + /** @type {any} */ (root).type === 'Program' + ? scopes.find((scope) => scope.node === root) + : undefined; + const inside = within(opening, (scope) => scope.node, start, end).filter( + (scope) => scope !== own + ); + // a function-name scope has no node of its own; it sits between a scope and the function it names + const parents = new Set(inside.map((scope) => scope.parent)); + const opened = + named.length === 0 + ? inside + : scopes.filter( + (scope) => + scope !== own && (scope.node === null ? parents.has(scope) : inside.includes(scope)) + ); + const declared = within(declaring, (binding) => binding.node, start, end); + if (nameless.length > 0) { + for (const binding of nameless) if (opened.includes(binding.scope)) declared.push(binding); + } + const made = within(referring, (reference) => reference.node, start, end); + const outermost = + own ?? + [...opened.map((scope) => scope.parent), ...declared, ...made] + .map((entry) => (entry && 'scope' in entry ? entry.scope : entry)) + .find((scope) => scope !== null && !opened.includes(/** @type {Scope} */ (scope))) ?? + around(list[0]) ?? + nowhere; + keep_tables(root, { + node: root, + end, + scopes: [outermost, ...opened], + bindings: declared, + references: made }); + } +} - this.root = { - css: null, - js: [], - // @ts-ignore - start: null, - // @ts-ignore - end: null, - type: 'Root', - fragment: create_fragment(), - options: null, - comments: [], - metadata: { - ts: this.ts - } - }; +/** + * The scope a piece of JavaScript sits in when nothing in it says: the nearest ancestor that opens one. + * @param {Node} node + */ +function around(node) { + for (let parent = parentOf(node); parent !== undefined; parent = parentOf(parent)) { + const scope = scopeOf(parent); + if (scope !== undefined) return scope; + } + return undefined; +} - this.stack.push(this.root); - this.fragments.push(this.root.fragment); +/** + * The comment right before a script or style element, whitespace apart. + * @param {AST.TemplateNode[]} nodes + * @param {number} start + */ +function comment_before(nodes, start) { + let i = nodes.findIndex((node) => /** @type {number} */ (node.start) >= start); + if (i === -1) i = nodes.length; + if (i === 0 || nodes[i - 1].end !== start) return null; + for (i -= 1; i >= 0; i -= 1) { + const node = nodes[i]; + if (node.type === 'Comment') return node; + if (node.type !== 'Text' || node.data.trim()) break; + } + return null; +} - /** @type {ParserState} */ - let state = fragment; +/** + * @param {number} start + * @param {number} end + */ +function loc(start, end) { + return { start: state.locator(start), end: state.locator(end) }; +} - while (this.index < this.template.length) { - state = state(this) || fragment; - } +/** + * The parser reads a `let:` directive's value as a pattern; the AST holds it as the expression it looks like. + * @param {Pattern} node + * @returns {Expression} + */ +function to_expression(node) { + switch (node.type) { + case 'ObjectPattern': + return { + ...node, + type: 'ObjectExpression', + properties: node.properties.map((property) => + property.type === 'Property' + ? { ...property, value: to_expression(property.value) } + : /** @type {any} */ ({ + ...property, + type: 'SpreadElement', + argument: to_expression(property.argument) + }) + ) + }; + case 'ArrayPattern': + return { + ...node, + type: 'ArrayExpression', + elements: node.elements.map((element) => element && to_expression(element)) + }; + case 'AssignmentPattern': + return { + .../** @type {any} */ (node), + type: 'AssignmentExpression', + operator: '=', + left: to_expression(node.left), + right: node.right + }; + case 'RestElement': + return /** @type {any} */ ({ + ...node, + type: 'SpreadElement', + argument: to_expression(node.argument) + }); + default: + return /** @type {Expression} */ (node); + } +} - if (this.stack.length > 1) { - const current = this.current(); - - if (this.loose) { - current.end = this.template.length; - } else if (current.type === 'RegularElement') { - current.end = current.start + 1; - e.element_unclosed(current, current.name); - } else { - current.end = current.start + 1; - e.block_unclosed(current); +/** + * The parser's error as the compiler's: what it expected or found, at the same place. + * @param {{ code: string, message: string, pos: number, end: number }} error + * @param {string} template + * @returns {never} + */ +function throw_error(error, template) { + const { code, message, pos, end } = error; + const range = { start: pos, end }; + switch (code) { + case 'unexpected_eof': + e.unexpected_eof(pos); + // eslint-disable-next-line no-fallthrough + case 'expected': { + const what = message.slice('Expected '.length); + if (what === 'whitespace') e.expected_whitespace(pos); + if (what === 'an identifier') { + let brace = pos; + while (brace > 0 && is_whitespace(template.charCodeAt(brace - 1))) brace -= 1; + if (template[brace - 1] === '{' && template[pos] === '}') + e.attribute_empty_shorthand(brace - 1); + e.expected_identifier(pos); } + if (what === 'a directive name') { + const colon = template.indexOf(':', pos); + e.directive_missing_name({ start: pos, end: colon + 1 }, template.slice(pos, colon + 1)); + } + if (what === 'a single declaration') e.const_tag_invalid_expression(range); + if (what === '}' && template.lastIndexOf('{@debug', pos) > template.lastIndexOf('}', pos)) { + e.debug_tag_invalid_arguments(template.lastIndexOf('{@debug', pos) + '{@debug '.length); + } + // a branch that fits no open block, reported at its sigil + if (what === 'else') e.expected_token(pos + 1, '{:else}'); + if (what === 'else if or else') e.expected_token(pos + 1, '{:else} or {:else if}'); + if (what === 'then or catch') e.expected_token(pos + 1, '{:then ...} or {:catch ...}'); + if (what === 'a block name') e.expected_block_type(pos); + if (what === 'an attribute value') e.expected_attribute_value(pos); + if (what === 'a tag name') e.expected_tag(pos); + if (what === 'a this attribute') { + if (template.startsWith(' + template.lastIndexOf(']*>$/.test(template)) e.unexpected_eof(pos); + if (template[pos] === '{') e.block_unclosed(range); + e.element_unclosed(range, name); } + // eslint-disable-next-line no-fallthrough + case 'unexpected_close': { + const [name, reason] = message.slice('Unexpected closing '.length).split(', closed by '); + if (template[pos] === '{') e.block_unexpected_close(pos + 1); + if (reason) e.element_invalid_closing_tag_autoclosed(pos, name, reason); + e.element_invalid_closing_tag(pos, name); + } + // eslint-disable-next-line no-fallthrough + case 'duplicate': { + const name = message.slice('Duplicate '.length); + if (name === 'script') e.script_duplicate(pos); + if (name === 'style') e.style_duplicate(pos); + if (name.startsWith('svelte:')) e.svelte_meta_duplicate(pos, name); + e.attribute_duplicate(range); + } + // eslint-disable-next-line no-fallthrough + case 'placement': { + const what = message.slice(0, -' is not allowed here'.length); + if (what === 'A closing tag of a void element') e.void_element_invalid_content(pos); + if (what === 'A declaration of that kind') e.declaration_tag_invalid_type(range); + if (what === 'A branch outside its block') e.block_invalid_continuation_placement(pos + 1); + const misplaced = /^A block or tag in (.+)$/.exec(what); + if (misplaced) { + const location = + misplaced[1] === 'an attribute value' ? 'in attribute value' : `inside ${misplaced[1]}`; + const [, sigil, name] = /** @type {RegExpExecArray} */ ( + /^\{([#@])(\w+)/.exec(template.slice(pos)) + ); + if (sigil === '#') e.block_invalid_placement(pos, name, location); + e.tag_invalid_placement(pos, name, location); + } + if (what.startsWith('svelte:')) e.svelte_meta_invalid_placement(pos, what); + e.js_parse_error(range, message); + } + // eslint-disable-next-line no-fallthrough + case 'redeclaration': + e.declaration_duplicate( + range, + message.slice("Identifier '".length, -"' has already been declared".length) + ); + // eslint-disable-next-line no-fallthrough + case 'invalid_name': + if (template.startsWith('svelte:', pos)) e.svelte_meta_invalid_tag(range, list(META_TAGS)); + e.tag_invalid_name(range); + // eslint-disable-next-line no-fallthrough + case 'reserved_word': + e.unexpected_reserved_word( + pos, + message.slice("The keyword '".length, -"' is reserved".length) + ); + // eslint-disable-next-line no-fallthrough + default: { + // a keyword where the template wanted a name of its own, an each block's context say + const keyword = /^Unexpected keyword '(\w+)'$/.exec(message); + if (keyword && /(\bas|\bthen|\bcatch|,)\s*$/.test(template.slice(0, pos))) { + e.unexpected_reserved_word(pos, keyword[1]); + } + e.js_parse_error(range, message); + } + } +} - this.root.start = 0; - this.root.end = template.length; - - const options_index = this.root.fragment.nodes.findIndex( - /** @param {any} thing */ - (thing) => thing.type === 'SvelteOptions' - ); - if (options_index !== -1) { - const options = /** @type {AST.SvelteOptionsRaw} */ (this.root.fragment.nodes[options_index]); - this.root.fragment.nodes.splice(options_index, 1); - this.root.options = read_options(options); +/** + * The cursor the stylesheet reader moves over a stylesheet parsed on its own. + */ +export class Parser { + template = ''; - disallow_children(options); + index = 0; - // We need this for the old AST format - Object.defineProperty(this.root.options, '__raw__', { - value: options, - enumerable: false - }); - } - } + /** @type {AST.CSS.CSSComment[]} */ + css_comments = []; - current() { - return this.stack[this.stack.length - 1]; + /** @param {string} source */ + static forCss(source) { + const parser = new Parser(); + parser.template = source; + return parser; } /** * @param {string} str * @param {boolean} required - * @param {boolean} required_in_loose */ - eat(str, required = false, required_in_loose = true) { + eat(str, required = false) { if (this.match(str)) { this.index += str.length; return true; } - if (required && (!this.loose || required_in_loose)) { + if (required) { e.expected_token(this.index, str); } @@ -233,54 +883,9 @@ export class Parser { return result; } - /** - * @returns {ESTree.Identifier & { start: number, end: number, loc: { start: Location, end: Location } }} - */ - read_identifier() { - const start = this.index; - let end = start; - let name = ''; - - const code = /** @type {number} */ (this.template.codePointAt(this.index)); - - if (isIdentifierStart(code)) { - let i = this.index; - end += code <= 0xffff ? 1 : 2; - - while (end < this.template.length) { - const code = /** @type {number} */ (this.template.codePointAt(end)); - - if (!isIdentifierChar(code)) break; - end += code <= 0xffff ? 1 : 2; - } - - name = this.template.slice(start, end); - this.index = end; - - if (is_reserved(name)) { - e.unexpected_reserved_word(start, name); - } - } - - /** @type {ESTree.Identifier & { start: number, end: number, loc: { start: Location, end: Location } }} */ - const identifier = { - type: 'Identifier', - name, - start, - end, - loc: { - start: state.locator(start), - end: state.locator(end) - } - }; - value_names.add(identifier); - return identifier; - } - /** @param {string} delimiter */ read_until(delimiter) { if (this.index >= this.template.length) { - if (this.loose) return ''; e.unexpected_eof(this.template.length); } @@ -299,7 +904,6 @@ export class Parser { /** @param {RegExp} pattern */ read_until_regex(pattern) { if (this.index >= this.template.length) { - if (this.loose) return ''; e.unexpected_eof(this.template.length); } @@ -314,52 +918,4 @@ export class Parser { this.index = this.template.length; return this.template.slice(start); } - - require_whitespace() { - if (!is_whitespace(this.template.charCodeAt(this.index))) { - e.expected_whitespace(this.index); - } - - this.index++; - this.allow_whitespace(); - } - - pop() { - const fragment = this.fragments.pop(); - if (fragment?.metadata.transparent && fragment.nodes.some((n) => n.type === 'DeclarationTag')) { - fragment.metadata.transparent = false; - } - return this.stack.pop(); - } - - /** - * @template {AST.Fragment['nodes'][number]} T - * @param {T} node - * @returns {T} - */ - append(node) { - this.fragments.at(-1)?.nodes.push(node); - return node; - } -} - -/** - * @param {string} template - * @param {boolean} [loose] - * @param {boolean} [erase] hand back JavaScript for TypeScript input, as the compiler wants it - * @returns {AST.Root} - */ -export function parse(template, loose = false, erase = false) { - state.set_source(template); - - const parser = new Parser(template, loose, erase); - return parser.root; } - -/** @typedef {(parser: Parser) => ParserState | void} ParserState */ - -/** @typedef {Object} LastAutoClosedTag - * @property {string} tag - * @property {string} reason - * @property {number} depth - */ diff --git a/packages/svelte/src/compiler/phases/1-parse/js.js b/packages/svelte/src/compiler/phases/1-parse/js.js index 2882536ab4..3cbd53c449 100644 --- a/packages/svelte/src/compiler/phases/1-parse/js.js +++ b/packages/svelte/src/compiler/phases/1-parse/js.js @@ -1,6 +1,5 @@ -/** @import { Expression, Pattern, Program, Statement } from 'estree' */ +/** @import { Program } from 'estree' */ /** @import { AST } from '#compiler' */ -/** @import { Parser } from './index.js' */ import * as teasel from '@teasel/parser'; import * as e from '../../errors.js'; import { keep_tables } from '../../utils/ast.js'; @@ -26,143 +25,12 @@ export function parse(source, comments, typescript) { return handle_parse_error(err); } - add_comments(source, comments, /** @type {teasel.Comment[]} */ (answer.comments)); - keep_tables(answer.node, answer); - - return answer.node; -} - -/** - * The program inside a `