feat: add partial evaluation (#15494)

* feat: add partial evaluation

* fix

* tweak

* more

* more

* evaluate stuff in template

* update test

* SSR

* unused

* changeset

* remove TODO

* Apply suggestions from code review

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* allow unknown operators

* use blocks and block-scoping in switch statement

---------

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
pull/15764/head
Rich Harris 5 months ago committed by GitHub
parent a051f96ed6
commit 90563e903f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: partially evaluate certain expressions

@ -685,14 +685,13 @@ function build_element_special_value_attribute(element, node_id, attribute, cont
: value
);
const evaluated = context.state.scope.evaluate(value);
const assignment = b.assignment('=', b.member(node_id, '__value'), value);
const inner_assignment = b.assignment(
'=',
b.member(node_id, 'value'),
b.conditional(
b.binary('==', b.null, b.assignment('=', b.member(node_id, '__value'), value)),
b.literal(''), // render null/undefined values as empty string to support placeholder options
value
)
evaluated.is_defined ? assignment : b.logical('??', assignment, b.literal(''))
);
const update = b.stmt(

@ -89,21 +89,21 @@ export function build_template_chunk(
}
}
const is_defined =
value.type === 'BinaryExpression' ||
(value.type === 'UnaryExpression' && value.operator !== 'void') ||
(value.type === 'LogicalExpression' && value.right.type === 'Literal') ||
(value.type === 'Identifier' && value.name === state.analysis.props_id?.name);
if (!is_defined) {
// add `?? ''` where necessary (TODO optimise more cases)
value = b.logical('??', value, b.literal(''));
}
const evaluated = state.scope.evaluate(value);
expressions.push(value);
if (evaluated.is_known) {
quasi.value.cooked += evaluated.value + '';
} else {
if (!evaluated.is_defined) {
// add `?? ''` where necessary
value = b.logical('??', value, b.literal(''));
}
quasi = b.quasi('', i + 1 === values.length);
quasis.push(quasi);
expressions.push(value);
quasi = b.quasi('', i + 1 === values.length);
quasis.push(quasi);
}
}
}

@ -44,15 +44,17 @@ export function process_children(nodes, { visit, state }) {
if (node.type === 'Text' || node.type === 'Comment') {
quasi.value.cooked +=
node.type === 'Comment' ? `<!--${node.data}-->` : escape_html(node.data);
} else if (node.type === 'ExpressionTag' && node.expression.type === 'Literal') {
if (node.expression.value != null) {
quasi.value.cooked += escape_html(node.expression.value + '');
}
} else {
expressions.push(b.call('$.escape', /** @type {Expression} */ (visit(node.expression))));
const evaluated = state.scope.evaluate(node.expression);
if (evaluated.is_known) {
quasi.value.cooked += escape_html((evaluated.value ?? '') + '');
} else {
expressions.push(b.call('$.escape', /** @type {Expression} */ (visit(node.expression))));
quasi = b.quasi('', i + 1 === sequence.length);
quasis.push(quasi);
quasi = b.quasi('', i + 1 === sequence.length);
quasis.push(quasi);
}
}
}

@ -1,4 +1,4 @@
/** @import { ArrowFunctionExpression, ClassDeclaration, Expression, FunctionDeclaration, FunctionExpression, Identifier, ImportDeclaration, MemberExpression, Node, Pattern, VariableDeclarator } from 'estree' */
/** @import { ArrowFunctionExpression, BinaryOperator, ClassDeclaration, Expression, FunctionDeclaration, FunctionExpression, Identifier, ImportDeclaration, MemberExpression, LogicalOperator, Node, Pattern, UnaryOperator, VariableDeclarator } from 'estree' */
/** @import { Context, Visitor } from 'zimmerframe' */
/** @import { AST, BindingKind, DeclarationKind } from '#compiler' */
import is_reference from 'is-reference';
@ -16,6 +16,11 @@ 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 const UNKNOWN = Symbol('unknown');
/** Includes `BigInt` */
export const NUMBER = Symbol('number');
export const STRING = Symbol('string');
export class Binding {
/** @type {Scope} */
scope;
@ -34,7 +39,7 @@ export class Binding {
* 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;
initial = null;
/** @type {Array<{ node: Identifier; path: AST.SvelteNode[] }>} */
references = [];
@ -100,6 +105,264 @@ export class Binding {
}
}
class Evaluation {
/** @type {Set<any>} */
values = new Set();
/**
* True if there is exactly one possible value
* @readonly
* @type {boolean}
*/
is_known = true;
/**
* True if the value is known to not be null/undefined
* @readonly
* @type {boolean}
*/
is_defined = true;
/**
* True if the value is known to be a string
* @readonly
* @type {boolean}
*/
is_string = true;
/**
* True if the value is known to be a number
* @readonly
* @type {boolean}
*/
is_number = true;
/**
* @readonly
* @type {any}
*/
value = undefined;
/**
*
* @param {Scope} scope
* @param {Expression} expression
*/
constructor(scope, expression) {
switch (expression.type) {
case 'Literal': {
this.values.add(expression.value);
break;
}
case 'Identifier': {
const binding = scope.get(expression.name);
if (binding) {
if (
binding.initial?.type === 'CallExpression' &&
get_rune(binding.initial, scope) === '$props.id'
) {
this.values.add(STRING);
break;
}
const is_prop =
binding.kind === 'prop' ||
binding.kind === 'rest_prop' ||
binding.kind === 'bindable_prop';
if (!binding.updated && binding.initial !== null && !is_prop) {
const evaluation = binding.scope.evaluate(/** @type {Expression} */ (binding.initial));
for (const value of evaluation.values) {
this.values.add(value);
}
break;
}
// TODO each index is always defined
}
// TODO glean what we can from reassignments
// TODO one day, expose props and imports somehow
this.values.add(UNKNOWN);
break;
}
case 'BinaryExpression': {
const a = scope.evaluate(/** @type {Expression} */ (expression.left)); // `left` cannot be `PrivateIdentifier` unless operator is `in`
const b = scope.evaluate(expression.right);
if (a.is_known && b.is_known) {
this.values.add(binary[expression.operator](a.value, b.value));
break;
}
switch (expression.operator) {
case '!=':
case '!==':
case '<':
case '<=':
case '>':
case '>=':
case '==':
case '===':
case 'in':
case 'instanceof':
this.values.add(true);
this.values.add(false);
break;
case '%':
case '&':
case '*':
case '**':
case '-':
case '/':
case '<<':
case '>>':
case '>>>':
case '^':
case '|':
this.values.add(NUMBER);
break;
case '+':
if (a.is_string || b.is_string) {
this.values.add(STRING);
} else if (a.is_number && b.is_number) {
this.values.add(NUMBER);
} else {
this.values.add(STRING);
this.values.add(NUMBER);
}
break;
default:
this.values.add(UNKNOWN);
}
break;
}
case 'ConditionalExpression': {
const test = scope.evaluate(expression.test);
const consequent = scope.evaluate(expression.consequent);
const alternate = scope.evaluate(expression.alternate);
if (test.is_known) {
for (const value of (test.value ? consequent : alternate).values) {
this.values.add(value);
}
} else {
for (const value of consequent.values) {
this.values.add(value);
}
for (const value of alternate.values) {
this.values.add(value);
}
}
break;
}
case 'LogicalExpression': {
const a = scope.evaluate(expression.left);
const b = scope.evaluate(expression.right);
if (a.is_known) {
if (b.is_known) {
this.values.add(logical[expression.operator](a.value, b.value));
break;
}
if (
(expression.operator === '&&' && !a.value) ||
(expression.operator === '||' && a.value) ||
(expression.operator === '??' && a.value != null)
) {
this.values.add(a.value);
} else {
for (const value of b.values) {
this.values.add(value);
}
}
break;
}
for (const value of a.values) {
this.values.add(value);
}
for (const value of b.values) {
this.values.add(value);
}
break;
}
case 'UnaryExpression': {
const argument = scope.evaluate(expression.argument);
if (argument.is_known) {
this.values.add(unary[expression.operator](argument.value));
break;
}
switch (expression.operator) {
case '!':
case 'delete':
this.values.add(false);
this.values.add(true);
break;
case '+':
case '-':
case '~':
this.values.add(NUMBER);
break;
case 'typeof':
this.values.add(STRING);
break;
case 'void':
this.values.add(undefined);
break;
default:
this.values.add(UNKNOWN);
}
break;
}
default: {
this.values.add(UNKNOWN);
}
}
for (const value of this.values) {
this.value = value; // saves having special logic for `size === 1`
if (value !== STRING && typeof value !== 'string') {
this.is_string = false;
}
if (value !== NUMBER && typeof value !== 'number') {
this.is_number = false;
}
if (value == null || value === UNKNOWN) {
this.is_defined = false;
}
}
if (this.values.size > 1 || typeof this.value === 'symbol') {
this.is_known = false;
}
}
}
export class Scope {
/** @type {ScopeRoot} */
root;
@ -279,8 +542,63 @@ export class Scope {
this.root.conflicts.add(node.name);
}
}
/**
* Does partial evaluation to find an exact value or at least the rough type of the expression.
* 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<any>} values
*/
evaluate(expression, values = new Set()) {
return new Evaluation(this, expression);
}
}
/** @type {Record<BinaryOperator, (left: any, right: any) => any>} */
const binary = {
'!=': (left, right) => left != right,
'!==': (left, right) => left !== right,
'<': (left, right) => left < right,
'<=': (left, right) => left <= right,
'>': (left, right) => left > right,
'>=': (left, right) => left >= right,
'==': (left, right) => left == right,
'===': (left, right) => left === right,
in: (left, right) => left in right,
instanceof: (left, right) => left instanceof right,
'%': (left, right) => left % right,
'&': (left, right) => left & right,
'*': (left, right) => left * right,
'**': (left, right) => left ** right,
'+': (left, right) => left + right,
'-': (left, right) => left - right,
'/': (left, right) => left / right,
'<<': (left, right) => left << right,
'>>': (left, right) => left >> right,
'>>>': (left, right) => left >>> right,
'^': (left, right) => left ^ right,
'|': (left, right) => left | right
};
/** @type {Record<UnaryOperator, (argument: any) => any>} */
const unary = {
'-': (argument) => -argument,
'+': (argument) => +argument,
'!': (argument) => !argument,
'~': (argument) => ~argument,
typeof: (argument) => typeof argument,
void: () => undefined,
delete: () => true
};
/** @type {Record<LogicalOperator, (left: any, right: any) => any>} */
const logical = {
'||': (left, right) => left || right,
'&&': (left, right) => left && right,
'??': (left, right) => left ?? right
};
export class ScopeRoot {
/** @type {Set<string>} */
conflicts = new Set();

@ -10,11 +10,11 @@ export default function Nullish_coallescence_omittance($$anchor) {
var fragment = root();
var h1 = $.first_child(fragment);
h1.textContent = `Hello, ${name ?? ''}!`;
h1.textContent = 'Hello, world!';
var b = $.sibling(h1, 2);
b.textContent = `${1 ?? 'stuff'}${2 ?? 'more stuff'}${3 ?? 'even more stuff'}`;
b.textContent = '123';
var button = $.sibling(b, 2);
@ -26,7 +26,7 @@ export default function Nullish_coallescence_omittance($$anchor) {
var h1_1 = $.sibling(button, 2);
h1_1.textContent = `Hello, ${name ?? 'earth' ?? ''}`;
h1_1.textContent = 'Hello, world';
$.template_effect(() => $.set_text(text, `Count is ${$.get(count) ?? ''}`));
$.append($$anchor, fragment);
}

@ -4,5 +4,5 @@ export default function Nullish_coallescence_omittance($$payload) {
let name = 'world';
let count = 0;
$$payload.out += `<h1>Hello, ${$.escape(name)}!</h1> <b>${$.escape(1 ?? 'stuff')}${$.escape(2 ?? 'more stuff')}${$.escape(3 ?? 'even more stuff')}</b> <button>Count is ${$.escape(count)}</button> <h1>Hello, ${$.escape(name ?? 'earth' ?? null)}</h1>`;
$$payload.out += `<h1>Hello, world!</h1> <b>123</b> <button>Count is ${$.escape(count)}</button> <h1>Hello, world</h1>`;
}

@ -38,7 +38,7 @@ export default function Skip_static_subtree($$anchor, $$props) {
var select = $.sibling(div_1, 2);
var option = $.child(select);
option.value = null == (option.__value = 'a') ? '' : 'a';
option.value = option.__value = 'a';
$.reset(select);
var img = $.sibling(select, 2);

@ -8,4 +8,4 @@
replace_me_script = 'hello'
;
</script>
<h1 class="done_replace_style_2">{done_replace_script_2}</h1>
<h1 class="done_replace_style_2">{Math.random() < 1 && done_replace_script_2}</h1>

Loading…
Cancel
Save