From ed7f90ca268d061c6ebbd53d8a0c9dd5fdfda190 Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Thu, 17 Apr 2025 21:29:04 -0700 Subject: [PATCH] add more globals, template literals, try functions and (some) member expressions --- .../client/visitors/RegularElement.js | 2 +- .../client/visitors/shared/utils.js | 8 +- .../server/visitors/shared/utils.js | 2 +- packages/svelte/src/compiler/phases/scope.js | 569 +++++++++++++++++- 4 files changed, 552 insertions(+), 29 deletions(-) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index fa4ee9867f..e90a367927 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -685,7 +685,7 @@ function build_element_special_value_attribute(element, node_id, attribute, cont : value ); - const evaluated = context.state.scope.evaluate(value); + const evaluated = context.state.scope.evaluate(value, new Set(), context.state.scopes); const assignment = b.assignment('=', b.member(node_id, '__value'), value); const inner_assignment = b.assignment( diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index 2aae1d0cb3..5a5daca89d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -64,12 +64,9 @@ export function build_template_chunk( node.expression.name !== 'undefined' || state.scope.get('undefined') ) { - let value = memoize( - /** @type {Expression} */ (visit(node.expression, state)), - node.metadata.expression - ); + let value = /** @type {Expression} */ (visit(node.expression, state)); - const evaluated = state.scope.evaluate(value); + const evaluated = state.scope.evaluate(value, new Set(), state.scopes); has_state ||= node.metadata.expression.has_state && !evaluated.is_known; @@ -98,6 +95,7 @@ export function build_template_chunk( if (evaluated.is_known) { quasi.value.cooked += evaluated.value + ''; } else { + value = memoize(value, node.metadata.expression); if (!evaluated.is_defined) { // add `?? ''` where necessary value = b.logical('??', value, b.literal('')); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js index 807e12a8fa..3cfb5abdd9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js @@ -45,7 +45,7 @@ export function process_children(nodes, { visit, state }) { quasi.value.cooked += node.type === 'Comment' ? `` : escape_html(node.data); } else { - const evaluated = state.scope.evaluate(node.expression); + const evaluated = state.scope.evaluate(node.expression, new Set(), state.scopes); if (evaluated.is_known) { quasi.value.cooked += escape_html((evaluated.value ?? '') + ''); diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js index 316179bd2a..a2a28a2ebb 100644 --- a/packages/svelte/src/compiler/phases/scope.js +++ b/packages/svelte/src/compiler/phases/scope.js @@ -1,4 +1,4 @@ -/** @import { ArrowFunctionExpression, BinaryOperator, ClassDeclaration, Expression, FunctionDeclaration, FunctionExpression, Identifier, ImportDeclaration, MemberExpression, LogicalOperator, Node, Pattern, UnaryOperator, VariableDeclarator } from 'estree' */ +/** @import { ArrowFunctionExpression, BinaryOperator, ClassDeclaration, Expression, FunctionDeclaration, FunctionExpression, Identifier, ImportDeclaration, MemberExpression, LogicalOperator, Node, Pattern, UnaryOperator, VariableDeclarator, Super, Function, Program, PrivateIdentifier } from 'estree' */ /** @import { Context, Visitor } from 'zimmerframe' */ /** @import { AST, BindingKind, DeclarationKind } from '#compiler' */ import is_reference from 'is-reference'; @@ -20,7 +20,124 @@ export const UNKNOWN = Symbol('unknown'); /** Includes `BigInt` */ export const NUMBER = Symbol('number'); export const STRING = Symbol('string'); +/** Used for when you need to add `true` and `false` to the values, but can't do it for whatever reason */ +const BOOLEAN = Symbol('boolean'); + +const TYPES = [UNKNOWN, NUMBER, STRING]; + +const global_calls = Object.freeze({ + String: ['fromCharCode', 'fromCodepoint'], + Math: [ + 'min', + 'max', + 'random', + 'floor', + 'f16round', + 'round', + 'abs', + 'acos', + 'asin', + 'atan', + 'atan2', + 'ceil', + 'cos', + 'sin', + 'tan', + 'exp', + 'log', + 'pow', + 'sqrt', + 'clz32', + 'imul', + 'sign', + 'log10', + 'log2', + 'log1p', + 'expm1', + 'cosh', + 'sinh', + 'tanh', + 'acosh', + 'asinh', + 'atanh', + 'trunc', + 'fround', + 'cbrt' + ], + Number: ['isInteger', 'isFinite', 'isNaN', 'isSafeInteger', 'parseFloat', 'parseInt'] +}); + +const math_constants = Object.freeze([ + 'PI', + 'E', + 'LN10', + 'LN2', + 'LOG10E', + 'LOG2E', + 'SQRT_2', + 'SQRT1_2' +]); +/** + * @param {Expression | Super} callee + * @param {Scope} scope + * @returns {null | [keyof typeof global_calls, (typeof global_calls)[keyof typeof global_calls][number]] | [string]} + */ +function is_global_call(callee, scope) { + if ( + callee.type === 'MemberExpression' && + callee.property?.type !== 'PrivateIdentifier' && + !callee.computed + ) { + const root = object(callee); + if (root?.type !== 'Identifier') { + return null; + } + if (scope.get(root.name)) { + return null; + } + if (callee.object === root && root.name === 'globalThis') { + return is_global_call(callee.property, scope); + } + /** @type {keyof typeof global_calls | undefined} */ + let root_name; + + if ( + callee.object.type === 'MemberExpression' && + callee.object.property.type === 'Identifier' && + !callee.object.computed + ) { + if ( + callee.object.object.type === 'Identifier' && + callee.object.object.name === 'globalThis' + ) { + root_name = /** @type {keyof typeof global_calls} */ (callee.object.property?.name); + } else if (callee.object.object === root) { + root_name = /** @type {keyof typeof global_calls} */ (root.name); + } + } else { + //@ts-ignore + root_name = /** @type {Identifier} */ (callee.object).name; + } + console.log(root_name); + if (root_name === undefined) return null; + if (!(root_name in global_calls)) { + return null; + } + const valid_members = global_calls[root_name]; + const property = /** @type {Identifier} */ (callee.property).name; + console.log(property, valid_members, valid_members.includes(property)); + if (!valid_members.includes(property)) { + return null; + } + return [root_name, property]; + } else if (callee.type === 'Identifier') { + return callee.name === 'Number' || callee.name === 'BigInt' || callee.name === 'String' + ? [callee.name] + : null; + } + return null; +} export class Binding { /** @type {Scope} */ scope; @@ -148,8 +265,9 @@ class Evaluation { * @param {Scope} scope * @param {Expression} expression * @param {Set} values + * @param {Map} scopes */ - constructor(scope, expression, values = new Set()) { + constructor(scope, expression, values, scopes) { this.values = values; switch (expression.type) { @@ -181,7 +299,11 @@ class Evaluation { } if (!binding.updated && binding.initial !== null && !is_prop) { - binding.scope.evaluate(/** @type {Expression} */ (binding.initial), this.values); + binding.scope.evaluate( + /** @type {Expression} */ (binding.initial), + this.values, + scopes + ); break; } } else if (expression.name === 'undefined') { @@ -197,8 +319,8 @@ class Evaluation { } case 'BinaryExpression': { - const a = scope.evaluate(/** @type {Expression} */ (expression.left)); // `left` cannot be `PrivateIdentifier` unless operator is `in` - const b = scope.evaluate(expression.right); + const a = scope.evaluate(/** @type {Expression} */ (expression.left), new Set(), scopes); // `left` cannot be `PrivateIdentifier` unless operator is `in` + const b = scope.evaluate(expression.right, new Set(), scopes); if (a.is_known && b.is_known) { this.values.add(binary[expression.operator](a.value, b.value)); @@ -252,9 +374,9 @@ class Evaluation { } case 'ConditionalExpression': { - const test = scope.evaluate(expression.test); - const consequent = scope.evaluate(expression.consequent); - const alternate = scope.evaluate(expression.alternate); + const test = scope.evaluate(expression.test, new Set(), scopes); + const consequent = scope.evaluate(expression.consequent, new Set(), scopes); + const alternate = scope.evaluate(expression.alternate, new Set(), scopes); if (test.is_known) { for (const value of (test.value ? consequent : alternate).values) { @@ -273,8 +395,8 @@ class Evaluation { } case 'LogicalExpression': { - const a = scope.evaluate(expression.left); - const b = scope.evaluate(expression.right); + const a = scope.evaluate(expression.left, new Set(), scopes); + const b = scope.evaluate(expression.right, new Set(), scopes); if (a.is_known) { if (b.is_known) { @@ -308,7 +430,7 @@ class Evaluation { } case 'UnaryExpression': { - const argument = scope.evaluate(expression.argument); + const argument = scope.evaluate(expression.argument, new Set(), scopes); if (argument.is_known) { this.values.add(unary[expression.operator](argument.value)); @@ -353,7 +475,7 @@ class Evaluation { case '$state.raw': case '$derived': if (arg) { - scope.evaluate(arg, this.values); + scope.evaluate(arg, this.values, scopes); } else { this.values.add(undefined); } @@ -370,7 +492,7 @@ class Evaluation { case '$derived.by': if (arg?.type === 'ArrowFunctionExpression' && arg.body.type !== 'BlockStatement') { - scope.evaluate(arg.body, this.values); + scope.evaluate(arg.body, this.values, scopes); break; } @@ -385,11 +507,12 @@ class Evaluation { break; } - if (expression.callee.type === 'Identifier' && scope.get(expression.callee.name) === null) { - switch (expression.callee.name) { + if (is_global_call(expression.callee, scope)?.length === 1) { + const [call] = /** @type {[string]} */ (is_global_call(expression.callee, scope)); + switch (call) { case 'Number': { const arg = /** @type {Expression | undefined} */ (expression.arguments[0]); - const e = arg && scope.evaluate(arg); + const e = arg && scope.evaluate(arg, new Set(), scopes); this.values.add(e ? (e.is_known ? Number(e.value) : NUMBER) : 0); break; @@ -397,19 +520,203 @@ class Evaluation { case 'String': { const arg = /** @type {Expression | undefined} */ (expression.arguments[0]); - const e = arg && scope.evaluate(arg); + const e = arg && scope.evaluate(arg, new Set(), scopes); - this.values.add(e ? (e.is_known ? String(e.value) : STRING) : 0); + this.values.add(e ? (e.is_known ? String(e.value) : STRING) : ''); + break; + } + + case 'BigInt': { + const arg = /** @type {Expression | undefined} */ (expression.arguments[0]); + const e = arg && scope.evaluate(arg, new Set(), scopes); + this.values.add(e ? (e.is_known ? BigInt(e.value) : NUMBER) : 0n); break; } default: this.values.add(UNKNOWN); } + break; + } else if ( + expression.callee.type === 'MemberExpression' && + is_global_call(expression.callee, scope)?.length === 2 + ) { + const [object, property] = /** @type {[keyof typeof global_calls, string]} */ ( + is_global_call(expression.callee, scope) + ); + switch (object) { + case 'Math': { + const args = /** @type {Expression[]} */ (expression.arguments).map((arg) => + scope.evaluate(arg, new Set(), scopes) + ); + const all_are_known = args.every((evaluated) => evaluated.is_known); + const vals = all_are_known + ? //@ts-expect-error + /** @type {() => unknown} */ (Math[property])(...args.map((arg) => arg.value)) + : NUMBER; + this.values.add(vals); + break; + } + case 'Number': { + const args = /** @type {Expression[]} */ (expression.arguments).map((arg) => + scope.evaluate(arg, new Set(), scopes) + ); + const all_are_known = args.every((evaluated) => evaluated.is_known); + const vals = all_are_known + ? Number[ + /** @type {'isSafeInteger' | 'isFinite' | 'isNaN' | 'parseFloat' | 'parseInt' | 'isInteger'} */ ( + property + ) + //@ts-expect-error types are a monster here + ](...args.map((arg) => arg.value)) + : property.startsWith('is') + ? BOOLEAN + : NUMBER; + if (vals === BOOLEAN) { + this.values.add(true); + this.values.add(false); + } else { + this.values.add(vals); + } + break; + } + case 'String': { + const args = /** @type {Expression[]} */ (expression.arguments).map((arg) => + scope.evaluate(arg, new Set(), scopes) + ); + const all_are_known = args.every((evaluated) => evaluated.is_known); + this.values.add( + all_are_known + ? String[/** @type {'fromCharCode' | 'fromCodePoint'} */ (property)]( + ...args.map((arg) => arg.value) + ) + : STRING + ); + break; + } + } + break; + } else if (expression.callee.type === 'Identifier' && !expression.arguments.length) { + const binding = scope.get(expression.callee.name); + if (binding) { + if ( + binding.kind === 'normal' && + !binding.reassigned && + (binding.declaration_kind === 'function' || + binding.declaration_kind === 'const' || + binding.declaration_kind === 'let' || + binding.declaration_kind === 'var') + ) { + const fn = + /** @type {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} */ ( + binding.initial + ); + if (fn && fn.async === false && !fn?.generator) { + const fn_analysis = evaluate_function(fn, binding, scopes, new Set()); + if (fn_analysis.pure && fn_analysis.never_throws) { + for (let value of fn_analysis.values) { + this.values.add(value); + } + break; + } + } + } + } + } + + this.values.add(UNKNOWN); + break; + } + + case 'TemplateLiteral': { + const expressions = expression.expressions.map((expr) => + scope.evaluate(expr, new Set(), scopes) + ); + const all_are_known = expressions.every((evaluated) => evaluated.is_known); + if (all_are_known) { + let res = ''; + let quasi_index = 0; + let expr_index = 0; + let last_quasi = false; + for (let i = 0; i < expressions.length + expression.quasis.length; i++) { + if (last_quasi) { + const expression = expressions[expr_index++]; + res += expression.value; + } else { + res += expression.quasis[quasi_index++].value.raw; + } + last_quasi = !last_quasi; + } + this.values.add(res); } else { - this.values.add(UNKNOWN); + this.values.add(STRING); } + break; + } + case 'MemberExpression': { + if ( + expression.object.type !== 'Identifier' && + expression.object.type !== 'MemberExpression' + ) { + this.values.add(UNKNOWN); + break; + } + const { object, property, computed } = expression; + if ( + object.type === 'MemberExpression' && + object.object.type === 'Identifier' && + object.object.name === 'globalThis' && + !scope.get('globalThis') + ) { + if (object.computed && object.property.type !== 'PrivateIdentifier') { + const key = scope.evaluate(object.property, new Set(), scopes); + if (key.is_known && key.value === 'Math') { + if (computed && property.type !== 'PrivateIdentifier') { + const math_prop = scope.evaluate(property, new Set(), scopes); + if (math_prop.is_known && math_constants.includes(math_prop.value)) { + this.values.add(Math[/** @type {keyof typeof Math} */ (math_prop.value)]); + break; + } + } else if (property.type === 'Identifier') { + if (math_constants.includes(property.name)) { + this.values.add(Math[/** @type {keyof typeof Math} */ (property.name)]); + break; + } + } + } + } else if (object.property.type === 'Identifier' && object.property.name === 'Math') { + if (computed && property.type !== 'PrivateIdentifier') { + const math_prop = scope.evaluate(property, new Set(), scopes); + if (math_prop.is_known && math_constants.includes(math_prop.value)) { + this.values.add(Math[/** @type {keyof typeof Math} */ (math_prop.value)]); + break; + } + } else if (property.type === 'Identifier') { + if (math_constants.includes(property.name)) { + this.values.add(Math[/** @type {keyof typeof Math} */ (property.name)]); + break; + } + } + } else { + this.values.add(UNKNOWN); + break; + } + } else if (object.type === 'Identifier' && object.name === 'Math' && !scope.get('Math')) { + if (computed && property.type !== 'PrivateIdentifier') { + const math_prop = scope.evaluate(property, new Set(), scopes); + if (math_prop.is_known && math_constants.includes(math_prop.value)) { + this.values.add(Math[/** @type {keyof typeof Math} */ (math_prop.value)]); + break; + } + } else if (property.type === 'Identifier') { + if (math_constants.includes(property.name)) { + this.values.add(Math[/** @type {keyof typeof Math} */ (property.name)]); + break; + } + } + } + this.values.add(UNKNOWN); break; } @@ -440,6 +747,223 @@ class Evaluation { } } +// TODO add more +const global_classes = [ + 'String', + 'BigInt', + 'Object', + 'Set', + 'Array', + 'Proxy', + 'Map', + 'Boolean', + 'WeakMap', + 'WeakRef', + 'WeakSet', + 'Number', + 'RegExp', + 'Error', + 'Date' +]; + +// TODO ditto +const known_globals = [ + ...global_classes, + 'Symbol', + 'console', + 'Math', + 'isNaN', + 'isFinite', + 'setTimeout', + 'setInterval', + 'NaN', + 'undefined' +]; + +/** + * @param {FunctionExpression | ArrowFunctionExpression | FunctionDeclaration} fn + * @param {Binding} binding + * @param {Map} scopes + * @param {Set} [stack] + */ +function evaluate_function(fn, binding, scopes, stack = new Set()) { + const analysis = { + pure: true, + is_known: false, + is_defined: false, + values: new Set(), + /** @type {any} */ + value: undefined, + never_throws: true + }; + /** @type {Set} */ + const walked_scopes = new Set(/** @type {Scope[]} */ ([binding.scope])); + const fn_binding = binding; + walk( + /** @type {AST.SvelteNode} */ (fn), + { scope: binding.scope }, + { + MemberExpression(node, context) { + if ( + !context.state.scope.evaluate(node, new Set(), scopes).is_known && + !is_global_call(node, context.state.scope) + ) { + analysis.pure = false; + analysis.never_throws = false; // getters/proxies could throw + } + context.next(); + }, + Identifier(node, context) { + if (is_reference(node, /** @type {Node} */ (context.path.at(-1)))) { + const binding = context.state.scope.get(node.name); + if (binding !== fn_binding) { + if (binding === null) { + if (known_globals.includes(node.name)) { + context.next(); + return; + } + analysis.pure = false; + } else if (!walked_scopes.has(binding?.scope)) { + const evaluated = context.state.scope.evaluate(node, new Set(), scopes); + if (!evaluated.is_known) { + analysis.pure = false; + } + } + } + } + context.next(); + }, + CallExpression(node, context) { + if (node.callee.type === 'Identifier' && !node.arguments.length) { + const binding = context.state.scope.get(node.callee.name); + if ( + binding && + fn_binding !== binding && + binding.kind === 'normal' && + !binding.reassigned && + (binding.declaration_kind === 'function' || + binding.declaration_kind === 'const' || + binding.declaration_kind === 'let' || + binding.declaration_kind === 'var') + ) { + if (!stack.has(binding)) { + const fn_analysis = evaluate_function( + /** @type {FunctionExpression | ArrowFunctionExpression | FunctionDeclaration} */ ( + binding.initial + ), + binding, + scopes, + new Set([...stack]) + ); + analysis.pure &&= fn_analysis.pure; + analysis.never_throws &&= fn_analysis.never_throws; + } + } else if (!(node.callee.name in global_calls)) { + analysis.pure = false; + } + } else if (!is_global_call(node.callee, context.state.scope)) { + analysis.pure = false; + console.log(node.callee); + } + context.next(); + }, + NewExpression(node, context) { + const { callee } = node; + if (callee.type === 'Identifier') { + if (context.state.scope.get(callee.name) || !global_classes.includes(callee.name)) { + analysis.pure = false; + } + } + context.next(); + }, + AssignmentExpression(node, context) { + const { left } = node; + const assigned_to = unwrap_pattern(/** @type {Pattern} */ (context.visit(left))); + if (assigned_to.some((assigned) => assigned.type === 'MemberExpression')) { + analysis.pure = false; + analysis.never_throws = false; + } else if ( + assigned_to.some((assigned) => { + const binding = context.state.scope.get(/** @type {Identifier} */ (assigned).name); + if (binding) { + return !walked_scopes.has(binding.scope); + } else { + return true; + } + }) + ) { + analysis.pure = false; + } + context.next(); + }, + UpdateExpression(node, context) { + analysis.pure = false; + analysis.never_throws = false; + context.next(); + }, + ReturnStatement(node, context) { + if ( + context.path.findLast( + (parent) => + parent.type === 'FunctionDeclaration' || + parent.type === 'FunctionExpression' || + parent.type === 'ArrowFunctionExpression' + ) === fn + ) { + const return_values = node.argument + ? context.state.scope.evaluate( + /** @type {Expression} */ (context.visit(node.argument)), + new Set(), + scopes + ) + : undefined; + if (return_values === undefined) { + analysis.values.add(undefined); + } else { + for (let value of return_values.values) { + analysis.values.add(value); + } + } + } + }, + ThrowStatement(node, context) { + if ( + context.path.findLast( + (parent) => + parent.type === 'FunctionDeclaration' || + parent.type === 'FunctionExpression' || + parent.type === 'ArrowFunctionExpression' + ) === fn + ) { + analysis.never_throws = false; + } + context.next(); + }, + _(node, context) { + const scope = scopes.get(node); + if (scope && node.type !== 'FunctionDeclaration') { + walked_scopes.add(scope); + context.next({ scope }); + } else if (node.type !== 'FunctionDeclaration' || node === fn) { + context.next(context.state); + } + } + } + ); + for (const value of analysis.values) { + analysis.value = value; // saves having special logic for `size === 1` + + if (value == null || value === UNKNOWN) { + analysis.is_defined = false; + } + } + + if (analysis.values.size <= 1 && !TYPES.includes(analysis.value)) { + analysis.is_known = true; + } + console.log(analysis); + return analysis; +} export class Scope { /** @type {ScopeRoot} */ root; @@ -625,10 +1149,11 @@ export class Scope { * Only call this once scope has been fully generated in a first pass, * else this evaluates on incomplete data and may yield wrong results. * @param {Expression} expression - * @param {Set} [values] + * @param {Set} values + * @param {Map} scopes */ - evaluate(expression, values) { - return new Evaluation(this, expression, values); + evaluate(expression, values, scopes) { + return new Evaluation(this, expression, values, scopes); } }