UNPKG

svelte

Version:

Cybernetically enhanced web apps

827 lines (691 loc) 21.4 kB
/** @import { ArrowFunctionExpression, ClassDeclaration, Expression, FunctionDeclaration, FunctionExpression, Identifier, ImportDeclaration, MemberExpression, Node, Pattern, VariableDeclarator } from 'estree' */ /** @import { Context, Visitor } from 'zimmerframe' */ /** @import { AST, BindingKind, DeclarationKind } from '#compiler' */ import is_reference from 'is-reference'; import { walk } from 'zimmerframe'; import { create_expression_metadata } from './nodes.js'; import * as b from '../utils/builders.js'; import * as e from '../errors.js'; import { extract_identifiers, extract_identifiers_from_destructuring, object, unwrap_pattern } from '../utils/ast.js'; import { is_reserved, is_rune } from '../../utils.js'; import { determine_slot } from '../utils/slot.js'; import { validate_identifier_name } from './2-analyze/visitors/shared/utils.js'; export class Binding { /** @type {Scope} */ scope; /** @type {Identifier} */ node; /** @type {BindingKind} */ kind; /** @type {DeclarationKind} */ declaration_kind; /** * What the value was initialized with. * For destructured props such as `let { foo = 'bar' } = $props()` this is `'bar'` and not `$props()` * @type {null | Expression | FunctionDeclaration | ClassDeclaration | ImportDeclaration | AST.EachBlock | AST.SnippetBlock} */ initial; /** @type {Array<{ node: Identifier; path: AST.SvelteNode[] }>} */ references = []; /** * For `legacy_reactive`: its reactive dependencies * @type {Binding[]} */ legacy_dependencies = []; /** * Legacy props: the `class` in `{ export klass as class}`. $props(): The `class` in { class: klass } = $props() * @type {string | null} */ prop_alias = null; /** * Additional metadata, varies per binding type * @type {null | { inside_rest?: boolean }} */ metadata = null; mutated = false; reassigned = false; /** * * @param {Scope} scope * @param {Identifier} node * @param {BindingKind} kind * @param {DeclarationKind} declaration_kind * @param {Binding['initial']} initial */ constructor(scope, node, kind, declaration_kind, initial) { this.scope = scope; this.node = node; this.initial = initial; this.kind = kind; this.declaration_kind = declaration_kind; } get updated() { return this.mutated || this.reassigned; } /** * @returns {this is Binding & { initial: ArrowFunctionExpression | FunctionDeclaration | FunctionExpression }} */ is_function() { if (this.updated) { // even if it's reassigned to another function, // we can't use it directly as e.g. an event handler return false; } const type = this.initial?.type; return ( type === 'ArrowFunctionExpression' || type === 'FunctionExpression' || type === 'FunctionDeclaration' ); } } export class Scope { /** @type {ScopeRoot} */ root; /** * The immediate parent scope * @type {Scope | null} */ parent; /** * Whether or not `var` declarations are contained by this scope * @type {boolean} */ #porous; /** * A map of every identifier declared by this scope, and all the * identifiers that reference it * @type {Map<string, Binding>} */ declarations = new Map(); /** * A map of declarators to the bindings they declare * @type {Map<VariableDeclarator | AST.LetDirective, Binding[]>} */ declarators = new Map(); /** * A set of all the names referenced with this scope * — useful for generating unique names * @type {Map<string, { node: Identifier; path: AST.SvelteNode[] }[]>} */ references = new Map(); /** * The scope depth allows us to determine if a state variable is referenced in its own scope, * which is usually an error. Block statements do not increase this value */ function_depth = 0; /** * If tracing of reactive dependencies is enabled for this scope * @type {null | Expression} */ tracing = null; /** * * @param {ScopeRoot} root * @param {Scope | null} parent * @param {boolean} porous */ constructor(root, parent, porous) { this.root = root; this.parent = parent; this.#porous = porous; this.function_depth = parent ? parent.function_depth + (porous ? 0 : 1) : 0; } /** * @param {Identifier} node * @param {Binding['kind']} kind * @param {DeclarationKind} declaration_kind * @param {null | Expression | FunctionDeclaration | ClassDeclaration | ImportDeclaration | AST.EachBlock | AST.SnippetBlock} initial * @returns {Binding} */ declare(node, kind, declaration_kind, initial = null) { if (this.parent) { if (declaration_kind === 'var' && this.#porous) { return this.parent.declare(node, kind, declaration_kind); } if (declaration_kind === 'import') { return this.parent.declare(node, kind, declaration_kind, initial); } } if (this.declarations.has(node.name)) { const binding = this.declarations.get(node.name); if (binding && binding.declaration_kind !== 'var' && declaration_kind !== 'var') { // This also errors on function types, but that's arguably a good thing // declaring function twice is also caught by acorn in the parse phase e.declaration_duplicate(node, node.name); } } const binding = new Binding(this, node, kind, declaration_kind, initial); validate_identifier_name(binding, this.function_depth); this.declarations.set(node.name, binding); this.root.conflicts.add(node.name); return binding; } child(porous = false) { return new Scope(this.root, this, porous); } /** * @param {string} preferred_name * @returns {string} */ generate(preferred_name) { if (this.#porous) { return /** @type {Scope} */ (this.parent).generate(preferred_name); } preferred_name = preferred_name.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^[0-9]/, '_'); let name = preferred_name; let n = 1; while ( this.references.has(name) || this.declarations.has(name) || this.root.conflicts.has(name) || is_reserved(name) ) { name = `${preferred_name}_${n++}`; } this.references.set(name, []); this.root.conflicts.add(name); return name; } /** * @param {string} name * @returns {Binding | null} */ get(name) { return this.declarations.get(name) ?? this.parent?.get(name) ?? null; } /** * @param {VariableDeclarator | AST.LetDirective} node * @returns {Binding[]} */ get_bindings(node) { const bindings = this.declarators.get(node); if (!bindings) { throw new Error('No binding found for declarator'); } return bindings; } /** * @param {string} name * @returns {Scope | null} */ owner(name) { return this.declarations.has(name) ? this : this.parent && this.parent.owner(name); } /** * @param {Identifier} node * @param {AST.SvelteNode[]} path */ reference(node, path) { path = [...path]; // ensure that mutations to path afterwards don't affect this reference let references = this.references.get(node.name); if (!references) this.references.set(node.name, (references = [])); references.push({ node, path }); const binding = this.declarations.get(node.name); if (binding) { binding.references.push({ node, path }); } else if (this.parent) { this.parent.reference(node, path); } else { // no binding was found, and this is the top level scope, // which means this is a global this.root.conflicts.add(node.name); } } } export class ScopeRoot { /** @type {Set<string>} */ conflicts = new Set(); /** * @param {string} preferred_name */ unique(preferred_name) { preferred_name = preferred_name.replace(/[^a-zA-Z0-9_$]/g, '_'); let final_name = preferred_name; let n = 1; while (this.conflicts.has(final_name)) { final_name = `${preferred_name}_${n++}`; } this.conflicts.add(final_name); const id = b.id(final_name); return id; } } /** * @param {AST.SvelteNode} ast * @param {ScopeRoot} root * @param {boolean} allow_reactive_declarations * @param {Scope | null} parent */ export function create_scopes(ast, root, allow_reactive_declarations, parent) { /** @typedef {{ scope: Scope }} State */ /** * A map of node->associated scope. A node appearing in this map does not necessarily mean that it created a scope * @type {Map<AST.SvelteNode, Scope>} */ const scopes = new Map(); const scope = new Scope(root, parent, false); scopes.set(ast, scope); /** @type {State} */ const state = { scope }; /** @type {[Scope, { node: Identifier; path: AST.SvelteNode[] }][]} */ const references = []; /** @type {[Scope, Pattern | MemberExpression][]} */ const updates = []; /** * An array of reactive declarations, i.e. the `a` in `$: a = b * 2` * @type {Identifier[]} */ const possible_implicit_declarations = []; /** * @param {Scope} scope * @param {Pattern[]} params */ function add_params(scope, params) { for (const param of params) { for (const node of extract_identifiers(param)) { scope.declare(node, 'normal', param.type === 'RestElement' ? 'rest_param' : 'param'); } } } /** * @type {Visitor<Node, State, AST.SvelteNode>} */ const create_block_scope = (node, { state, next }) => { const scope = state.scope.child(true); scopes.set(node, scope); next({ scope }); }; /** * @type {Visitor<AST.ElementLike, State, AST.SvelteNode>} */ const SvelteFragment = (node, { state, next }) => { const scope = state.scope.child(); scopes.set(node, scope); next({ scope }); }; /** * @type {Visitor<AST.Component | AST.SvelteComponent | AST.SvelteSelf, State, AST.SvelteNode>} */ const Component = (node, context) => { node.metadata.scopes = { default: context.state.scope.child() }; if (node.type === 'SvelteComponent') { context.visit(node.expression); } const default_state = determine_slot(node) ? context.state : { scope: node.metadata.scopes.default }; for (const attribute of node.attributes) { if (attribute.type === 'LetDirective') { context.visit(attribute, default_state); } else { context.visit(attribute); } } for (const child of node.fragment.nodes) { let state = default_state; const slot_name = determine_slot(child); if (slot_name !== null) { node.metadata.scopes[slot_name] = context.state.scope.child(); state = { scope: node.metadata.scopes[slot_name] }; } context.visit(child, state); } }; /** * @type {Visitor<AST.AnimateDirective | AST.TransitionDirective | AST.UseDirective, State, AST.SvelteNode>} */ const SvelteDirective = (node, { state, path, visit }) => { state.scope.reference(b.id(node.name.split('.')[0]), path); if (node.expression) { visit(node.expression); } }; walk(ast, state, { // references Identifier(node, { path, state }) { const parent = path.at(-1); if ( parent && is_reference(node, /** @type {Node} */ (parent)) && // TSTypeAnnotation, TSInterfaceDeclaration etc - these are normally already filtered out, // but for the migration they aren't, so we need to filter them out here // TODO -> once migration script is gone we can remove this check !parent.type.startsWith('TS') ) { references.push([state.scope, { node, path: path.slice() }]); } }, LabeledStatement(node, { path, next }) { if (path.length > 1 || !allow_reactive_declarations) return next(); if (node.label.name !== '$') return next(); // create a scope for the $: block const scope = state.scope.child(); scopes.set(node, scope); if ( node.body.type === 'ExpressionStatement' && node.body.expression.type === 'AssignmentExpression' ) { for (const id of extract_identifiers(node.body.expression.left)) { if (!id.name.startsWith('$')) { possible_implicit_declarations.push(id); } } } next({ scope }); }, SvelteFragment, SlotElement: SvelteFragment, SvelteElement: SvelteFragment, RegularElement: SvelteFragment, LetDirective(node, context) { const scope = context.state.scope; /** @type {Binding[]} */ const bindings = []; scope.declarators.set(node, bindings); if (node.expression) { for (const id of extract_identifiers_from_destructuring(node.expression)) { const binding = scope.declare(id, 'template', 'const'); scope.reference(id, [context.path[context.path.length - 1], node]); bindings.push(binding); } } else { /** @type {Identifier} */ const id = { name: node.name, type: 'Identifier', start: node.start, end: node.end }; const binding = scope.declare(id, 'template', 'const'); scope.reference(id, [context.path[context.path.length - 1], node]); bindings.push(binding); } }, Component: (node, context) => { context.state.scope.reference(b.id(node.name), context.path); Component(node, context); }, SvelteSelf: Component, SvelteComponent: Component, // updates AssignmentExpression(node, { state, next }) { updates.push([state.scope, node.left]); next(); }, UpdateExpression(node, { state, next }) { updates.push([state.scope, /** @type {Identifier | MemberExpression} */ (node.argument)]); next(); }, ImportDeclaration(node, { state }) { for (const specifier of node.specifiers) { state.scope.declare(specifier.local, 'normal', 'import', node); } }, FunctionExpression(node, { state, next }) { const scope = state.scope.child(); scopes.set(node, scope); if (node.id) scope.declare(node.id, 'normal', 'function'); add_params(scope, node.params); next({ scope }); }, FunctionDeclaration(node, { state, next }) { if (node.id) state.scope.declare(node.id, 'normal', 'function', node); const scope = state.scope.child(); scopes.set(node, scope); add_params(scope, node.params); next({ scope }); }, ArrowFunctionExpression(node, { state, next }) { const scope = state.scope.child(); scopes.set(node, scope); add_params(scope, node.params); next({ scope }); }, ForStatement: create_block_scope, ForInStatement: create_block_scope, ForOfStatement: create_block_scope, SwitchStatement: create_block_scope, BlockStatement(node, context) { const parent = context.path.at(-1); if ( parent?.type === 'FunctionDeclaration' || parent?.type === 'FunctionExpression' || parent?.type === 'ArrowFunctionExpression' ) { // We already created a new scope for the function context.next(); } else { create_block_scope(node, context); } }, ClassDeclaration(node, { state, next }) { if (node.id) state.scope.declare(node.id, 'normal', 'let', node); next(); }, VariableDeclaration(node, { state, path, next }) { const is_parent_const_tag = path.at(-1)?.type === 'ConstTag'; for (const declarator of node.declarations) { /** @type {Binding[]} */ const bindings = []; state.scope.declarators.set(declarator, bindings); for (const id of extract_identifiers(declarator.id)) { const binding = state.scope.declare( id, is_parent_const_tag ? 'template' : 'normal', node.kind, declarator.init ); bindings.push(binding); } } next(); }, CatchClause(node, { state, next }) { if (node.param) { const scope = state.scope.child(true); scopes.set(node, scope); for (const id of extract_identifiers(node.param)) { scope.declare(id, 'normal', 'let'); } next({ scope }); } else { next(); } }, EachBlock(node, { state, visit }) { visit(node.expression); // context and children are a new scope const scope = state.scope.child(); scopes.set(node, scope); if (node.context) { // declarations for (const id of extract_identifiers(node.context)) { const binding = scope.declare(id, 'each', 'const'); let inside_rest = false; let is_rest_id = false; walk(node.context, null, { Identifier(node) { if (inside_rest && node === id) { is_rest_id = true; } }, RestElement(_, { next }) { const prev = inside_rest; inside_rest = true; next(); inside_rest = prev; } }); binding.metadata = { inside_rest: is_rest_id }; } // Visit to pick up references from default initializers visit(node.context, { scope }); } if (node.index) { const is_keyed = node.key && (node.key.type !== 'Identifier' || !node.index || node.key.name !== node.index); scope.declare(b.id(node.index), is_keyed ? 'template' : 'normal', 'const', node); } if (node.key) visit(node.key, { scope }); // children for (const child of node.body.nodes) { visit(child, { scope }); } if (node.fallback) visit(node.fallback, { scope }); node.metadata = { expression: create_expression_metadata(), keyed: false, contains_group_binding: false, index: scope.root.unique('$$index'), declarations: scope.declarations, is_controlled: false }; }, AwaitBlock(node, context) { context.visit(node.expression); if (node.pending) { context.visit(node.pending); } if (node.then) { context.visit(node.then); if (node.value) { const then_scope = /** @type {Scope} */ (scopes.get(node.then)); const value_scope = context.state.scope.child(); scopes.set(node.value, value_scope); context.visit(node.value, { scope: value_scope }); for (const id of extract_identifiers(node.value)) { then_scope.declare(id, 'template', 'const'); value_scope.declare(id, 'normal', 'const'); } } } if (node.catch) { context.visit(node.catch); if (node.error) { const catch_scope = /** @type {Scope} */ (scopes.get(node.catch)); const error_scope = context.state.scope.child(); scopes.set(node.error, error_scope); context.visit(node.error, { scope: error_scope }); for (const id of extract_identifiers(node.error)) { catch_scope.declare(id, 'template', 'const'); error_scope.declare(id, 'normal', 'const'); } } } }, SnippetBlock(node, context) { const state = context.state; let scope = state.scope; scope.declare(node.expression, 'normal', 'function', node); const child_scope = state.scope.child(); scopes.set(node, child_scope); for (const param of node.parameters) { for (const id of extract_identifiers(param)) { child_scope.declare(id, 'snippet', 'let'); } } context.next({ scope: child_scope }); }, Fragment: (node, context) => { const scope = context.state.scope.child(node.metadata.transparent); scopes.set(node, scope); context.next({ scope }); }, BindDirective(node, context) { updates.push([ context.state.scope, /** @type {Identifier | MemberExpression} */ (node.expression) ]); context.next(); }, TransitionDirective: SvelteDirective, AnimateDirective: SvelteDirective, UseDirective: SvelteDirective, // using it's own function instead of `SvelteDirective` because // StyleDirective doesn't have expressions and are generally already // handled by `Identifier`. This is the special case for the shorthand // eg <button style:height /> where the variable has the same name of // the css property StyleDirective(node, { path, state, next }) { if (node.value === true) { state.scope.reference(b.id(node.name), path.concat(node)); } next(); } // TODO others }); for (const id of possible_implicit_declarations) { const binding = scope.get(id.name); if (binding) continue; // TODO can also be legacy_reactive if declared outside of reactive statement scope.declare(id, 'legacy_reactive', 'let'); } // we do this after the fact, so that we don't need to worry // about encountering references before their declarations for (const [scope, { node, path }] of references) { scope.reference(node, path); } for (const [scope, node] of updates) { for (const expression of unwrap_pattern(node)) { const left = object(expression); const binding = left && scope.get(left.name); if (binding !== null && left !== binding.node) { if (left === expression) { binding.reassigned = true; } else { binding.mutated = true; } } } } return { scope, scopes }; } /** * @template {{ scope: Scope, scopes: Map<AST.SvelteNode, Scope> }} State * @param {AST.SvelteNode} node * @param {Context<AST.SvelteNode, State>} context */ export function set_scope(node, { next, state }) { const scope = state.scopes.get(node); next(scope !== undefined && scope !== state.scope ? { ...state, scope } : state); } /** * Returns the name of the rune if the given expression is a `CallExpression` using a rune. * @param {Node | null | undefined} node * @param {Scope} scope */ export function get_rune(node, scope) { if (!node) return null; if (node.type !== 'CallExpression') return null; let n = node.callee; let joined = ''; while (n.type === 'MemberExpression') { if (n.computed) return null; if (n.property.type !== 'Identifier') return null; joined = '.' + n.property.name + joined; n = n.object; } if (n.type === 'CallExpression' && n.callee.type === 'Identifier') { joined = '()' + joined; n = n.callee; } if (n.type !== 'Identifier') return null; joined = n.name + joined; if (!is_rune(joined)) return null; const binding = scope.get(n.name); if (binding !== null) return null; // rune name, but references a variable or store return joined; }