feat: Analysis phase

pull/15820/head
S. Elliott Johnson 1 year ago
parent 134d43539e
commit fb8d6d7975

@ -208,6 +208,37 @@ Cannot assign to %thing%
Cannot bind to %thing%
```
### constructor_state_reassignment
```
Cannot redeclare stateful field `%name%` in the constructor. The field was originally declared here: `%original_location%`
```
To create stateful class fields in the constructor, the rune assignment must be the _first_ assignment to the class field.
Assignments thereafter must not use the rune.
```ts
constructor() {
this.count = $state(0);
this.count = $state(1); // invalid, assigning to the same property with `$state` again
}
constructor() {
this.count = $state(0);
this.count = $state.raw(1); // invalid, assigning to the same property with a different rune
}
constructor() {
this.count = 0;
this.count = $state(1); // invalid, this property was created as a regular property, not state
}
constructor() {
this.count = $state(0);
this.count = 1; // valid, this is setting the state that has already been declared
}
```
### css_empty_declaration
```
@ -855,7 +886,7 @@ Cannot export state from a module if it is reassigned. Either export a function
### state_invalid_placement
```
`%rune%(...)` can only be used as a variable declaration initializer or a class field
`%rune%(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.
```
### store_invalid_scoped_subscription

@ -10,6 +10,35 @@
> Cannot bind to %thing%
## constructor_state_reassignment
> Cannot redeclare stateful field `%name%` in the constructor. The field was originally declared here: `%original_location%`
To create stateful class fields in the constructor, the rune assignment must be the _first_ assignment to the class field.
Assignments thereafter must not use the rune.
```ts
constructor() {
this.count = $state(0);
this.count = $state(1); // invalid, assigning to the same property with `$state` again
}
constructor() {
this.count = $state(0);
this.count = $state.raw(1); // invalid, assigning to the same property with a different rune
}
constructor() {
this.count = 0;
this.count = $state(1); // invalid, this property was created as a regular property, not state
}
constructor() {
this.count = $state(0);
this.count = 1; // valid, this is setting the state that has already been declared
}
```
## declaration_duplicate
> `%name%` has already been declared
@ -218,7 +247,7 @@ It's possible to export a snippet from a `<script module>` block, but only if it
## state_invalid_placement
> `%rune%(...)` can only be used as a variable declaration initializer or a class field
> `%rune%(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.
## store_invalid_scoped_subscription

@ -104,6 +104,17 @@ export function constant_binding(node, thing) {
e(node, 'constant_binding', `Cannot bind to ${thing}\nhttps://svelte.dev/e/constant_binding`);
}
/**
* Cannot redeclare stateful field `%name%` in the constructor. The field was originally declared here: `%original_location%`
* @param {null | number | NodeLike} node
* @param {string} name
* @param {string} original_location
* @returns {never}
*/
export function constructor_state_reassignment(node, name, original_location) {
e(node, 'constructor_state_reassignment', `Cannot redeclare stateful field \`${name}\` in the constructor. The field was originally declared here: \`${original_location}\`\nhttps://svelte.dev/e/constructor_state_reassignment`);
}
/**
* `%name%` has already been declared
* @param {null | number | NodeLike} node
@ -471,13 +482,13 @@ export function state_invalid_export(node) {
}
/**
* `%rune%(...)` can only be used as a variable declaration initializer or a class field
* `%rune%(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.
* @param {null | number | NodeLike} node
* @param {string} rune
* @returns {never}
*/
export function state_invalid_placement(node, rune) {
e(node, 'state_invalid_placement', `\`${rune}(...)\` can only be used as a variable declaration initializer or a class field\nhttps://svelte.dev/e/state_invalid_placement`);
e(node, 'state_invalid_placement', `\`${rune}(...)\` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.\nhttps://svelte.dev/e/state_invalid_placement`);
}
/**

@ -22,7 +22,6 @@ import { Attribute } from './visitors/Attribute.js';
import { AwaitBlock } from './visitors/AwaitBlock.js';
import { BindDirective } from './visitors/BindDirective.js';
import { CallExpression } from './visitors/CallExpression.js';
import { ClassBody } from './visitors/ClassBody.js';
import { ClassDeclaration } from './visitors/ClassDeclaration.js';
import { ClassDirective } from './visitors/ClassDirective.js';
import { Component } from './visitors/Component.js';
@ -46,6 +45,7 @@ import { LetDirective } from './visitors/LetDirective.js';
import { MemberExpression } from './visitors/MemberExpression.js';
import { NewExpression } from './visitors/NewExpression.js';
import { OnDirective } from './visitors/OnDirective.js';
import { PropertyDefinition } from './visitors/PropertyDefinition.js';
import { RegularElement } from './visitors/RegularElement.js';
import { RenderTag } from './visitors/RenderTag.js';
import { SlotElement } from './visitors/SlotElement.js';
@ -135,7 +135,6 @@ const visitors = {
AwaitBlock,
BindDirective,
CallExpression,
ClassBody,
ClassDeclaration,
ClassDirective,
Component,
@ -159,6 +158,7 @@ const visitors = {
MemberExpression,
NewExpression,
OnDirective,
PropertyDefinition,
RegularElement,
RenderTag,
SlotElement,
@ -259,7 +259,7 @@ export function analyze_module(ast, options) {
scope,
scopes,
analysis: /** @type {ComponentAnalysis} */ (analysis),
derived_state: [],
class_state: null,
// TODO the following are not needed for modules, but we have to pass them in order to avoid type error,
// and reducing the type would result in a lot of tedious type casts elsewhere - find a good solution one day
ast_type: /** @type {any} */ (null),
@ -618,7 +618,7 @@ export function analyze_component(root, source, options) {
has_props_rune: false,
component_slots: new Set(),
expression: null,
derived_state: [],
class_state: null,
function_depth: scope.function_depth,
reactive_statement: null
};
@ -685,7 +685,7 @@ export function analyze_component(root, source, options) {
reactive_statement: null,
component_slots: new Set(),
expression: null,
derived_state: [],
class_state: null,
function_depth: scope.function_depth
};

@ -1,6 +1,7 @@
import type { Scope } from '../scope.js';
import type { ComponentAnalysis, ReactiveStatement } from '../types.js';
import type { AST, ExpressionMetadata, ValidatedCompileOptions } from '#compiler';
import type { ClassAnalysis } from './visitors/shared/class-analysis.js';
export interface AnalysisState {
scope: Scope;
@ -18,7 +19,9 @@ export interface AnalysisState {
component_slots: Set<string>;
/** Information about the current expression/directive/block value */
expression: ExpressionMetadata | null;
derived_state: { name: string; private: boolean }[];
/** Used to analyze class state. */
class_state: ClassAnalysis | null;
function_depth: number;
// legacy stuff

@ -23,5 +23,6 @@ export function AssignmentExpression(node, context) {
}
}
context.state.class_state?.register?.(node, context);
context.next();
}

@ -119,7 +119,10 @@ export function CallExpression(node, context) {
!(
call_expression_is_variable_declaration(parent, context) ||
call_expression_is_class_property_definition(parent) ||
call_expression_is_valid_class_property_assignment_in_constructor(parent, context)
context.state.class_state?.is_class_property_assignment_at_constructor_root(
parent,
context.path.slice(0, -1)
)
)
) {
e.state_invalid_placement(node, rune);
@ -289,78 +292,3 @@ function call_expression_is_variable_declaration(parent, context) {
function call_expression_is_class_property_definition(parent) {
return parent.type === 'PropertyDefinition' && !parent.static && !parent.computed;
}
/**
*
* @param {AST.SvelteNode} parent
* @param {Context} context
* @returns
*/
function call_expression_is_valid_class_property_assignment_in_constructor(parent, context) {
return (
expression_is_assignment_to_top_level_property_of_this(parent) &&
current_node_is_in_constructor_root_or_control_flow_blocks(context)
);
}
/**
* yes:
* - `this.foo = bar`
*
* no:
* - `this = bar`
* - `this.foo.baz = bar`
* - `anything_other_than_this = bar`
*
* @param {AST.SvelteNode} node
*/
function expression_is_assignment_to_top_level_property_of_this(node) {
return (
node.type === 'AssignmentExpression' &&
node.operator === '=' &&
node.left.type === 'MemberExpression' &&
node.left.object.type === 'ThisExpression' &&
node.left.property.type === 'Identifier'
);
}
/**
* @param {AST.SvelteNode} node
*/
function node_is_constructor(node) {
return (
node.type === 'MethodDefinition' &&
node.key.type === 'Identifier' &&
node.key.name === 'constructor'
);
}
// if blocks are just IfStatements with BlockStatements or other IfStatements as consequents
const allowed_parent_types = new Set([
'IfStatement',
'BlockStatement',
'SwitchCase',
'SwitchStatement'
]);
/**
* Succeeds if the node's only direct parents are `if` / `else if` / `else` blocks _and_
* those blocks are the direct children of the constructor.
*
* @param {Context} context
*/
function current_node_is_in_constructor_root_or_control_flow_blocks(context) {
let parent_index = -3; // this gets us from CallExpression -> AssignmentExpression -> ExpressionStatement -> Whatever is here
while (true) {
const grandparent = get_parent(context.path, parent_index - 1);
const parent = get_parent(context.path, parent_index);
if (grandparent && node_is_constructor(grandparent)) {
// if this is the case then `parent` is the FunctionExpression
return true;
}
if (!allowed_parent_types.has(parent.type)) {
return false;
}
parent_index--;
}
}

@ -1,30 +0,0 @@
/** @import { ClassBody } from 'estree' */
/** @import { Context } from '../types' */
import { get_rune } from '../../scope.js';
/**
* @param {ClassBody} node
* @param {Context} context
*/
export function ClassBody(node, context) {
/** @type {{name: string, private: boolean}[]} */
const derived_state = [];
for (const definition of node.body) {
if (
definition.type === 'PropertyDefinition' &&
(definition.key.type === 'PrivateIdentifier' || definition.key.type === 'Identifier') &&
definition.value?.type === 'CallExpression'
) {
const rune = get_rune(definition.value, context.state.scope);
if (rune === '$derived' || rune === '$derived.by') {
derived_state.push({
name: definition.key.name,
private: definition.key.type === 'PrivateIdentifier'
});
}
}
}
context.next({ ...context.state, derived_state });
}

@ -1,6 +1,7 @@
/** @import { ClassDeclaration } from 'estree' */
/** @import { Context } from '../types' */
import * as w from '../../../warnings.js';
import { ClassAnalysis } from './shared/class-analysis.js';
import { validate_identifier_name } from './shared/utils.js';
/**
@ -21,5 +22,5 @@ export function ClassDeclaration(node, context) {
w.perf_avoid_nested_class(node);
}
context.next();
context.next({ ...context.state, class_state: new ClassAnalysis() });
}

@ -0,0 +1,12 @@
/** @import { PropertyDefinition } from 'estree' */
/** @import { Context } from '../types' */
/**
*
* @param {PropertyDefinition} node
* @param {Context} context
*/
export function PropertyDefinition(node, context) {
context.state.class_state?.register?.(node, context);
context.next();
}

@ -0,0 +1,147 @@
/** @import { AssignmentExpression, PropertyDefinition, Expression } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { Context } from '../../types' */
import { get_parent } from '../../../../utils/ast.js';
import { get_rune } from '../../../scope.js';
import * as e from '../../../../errors.js';
import { locate_node } from '../../../../state.js';
/** @typedef {'$state' | '$state.raw' | '$derived' | '$derived.by' | 'regular'} PropertyAssignmentType */
/** @typedef {{ type: PropertyAssignmentType; node: AssignmentExpression | PropertyDefinition; }} PropertyAssignmentDetails */
const reassignable_assignments = new Set(['$state', '$state.raw', 'regular']);
const property_assignment_types = new Set([
'$state',
'$state.raw',
'$derived',
'$derived.by',
'regular'
]);
export class ClassAnalysis {
// TODO: Probably need to include property definitions here too
/** @type {Map<string, PropertyAssignmentDetails>} */
property_assignments = new Map();
/**
* Determines if the node is a valid assignment to a class property, and if so,
* registers the assignment.
* @param {AssignmentExpression | PropertyDefinition} node
* @param {Context} context
*/
register(node, context) {
/** @type {string} */
let name;
/** @type {PropertyAssignmentType} */
let type;
if (node.type === 'AssignmentExpression') {
if (!this.is_class_property_assignment_at_constructor_root(node, context.path)) {
return;
}
name = node.left.property.name;
type = this.#get_assignment_type(node, context);
this.#check_for_conflicts(node, name, type);
} else {
if (!this.#is_assigned_property(node)) {
return;
}
name = node.key.name;
type = this.#get_assignment_type(node, context);
// we don't need to check for conflicts here because they're not possible yet
}
// we don't have to validate anything other than conflicts here, because the rune placement rules
// catch all of the other weirdness.
if (!this.property_assignments.has(name)) {
this.property_assignments.set(name, { type, node });
}
}
/**
* @template {AST.SvelteNode} T
* @param {AST.SvelteNode} node
* @param {T[]} path
* @returns {node is AssignmentExpression & { left: { type: 'MemberExpression' } & { object: { type: 'ThisExpression' }; property: { type: 'Identifier' } } }}
*/
is_class_property_assignment_at_constructor_root(node, path) {
if (
!(
node.type === 'AssignmentExpression' &&
node.operator === '=' &&
node.left.type === 'MemberExpression' &&
node.left.object.type === 'ThisExpression' &&
node.left.property.type === 'Identifier'
)
) {
return false;
}
// AssignmentExpression (here) -> ExpressionStatement (-1) -> BlockStatement (-2) -> FunctionExpression (-3) -> MethodDefinition (-4)
const maybe_constructor = get_parent(path, -4);
return (
maybe_constructor &&
maybe_constructor.type === 'MethodDefinition' &&
maybe_constructor.kind === 'constructor'
);
}
/**
* We only care about properties that have values assigned to them -- if they don't,
* they can't be a conflict for state declared in the constructor.
* @param {PropertyDefinition} node
* @returns {node is PropertyDefinition & { key: { type: 'PrivateIdentifier' | 'Identifier' }; value: Expression; static: false; computed: false }}
*/
#is_assigned_property(node) {
return (
(node.key.type === 'PrivateIdentifier' || node.key.type === 'Identifier') &&
Boolean(node.value) &&
!node.static &&
!node.computed
);
}
/**
* Checks for conflicts with existing assignments. A conflict occurs if:
* - The original assignment used `$derived` or `$derived.by` (these can never be reassigned)
* - The original assignment used `$state`, `$state.raw`, or `regular` and is being assigned to with any type other than `regular`
* @param {AssignmentExpression} node
* @param {string} name
* @param {PropertyAssignmentType} type
*/
#check_for_conflicts(node, name, type) {
const existing = this.property_assignments.get(name);
if (!existing) {
return;
}
if (reassignable_assignments.has(existing.type) && type === 'regular') {
return;
}
e.constructor_state_reassignment(node, name, locate_node(existing.node));
}
/**
* @param {AssignmentExpression | PropertyDefinition} node
* @param {Context} context
* @returns {PropertyAssignmentType}
*/
#get_assignment_type(node, context) {
const value = node.type === 'AssignmentExpression' ? node.right : node.value;
const rune = get_rune(value, context.state.scope);
if (rune === null) {
return 'regular';
}
if (property_assignment_types.has(rune)) {
return /** @type {PropertyAssignmentType} */ (rune);
}
// this does mean we return `regular` for some other runes (like `$trace` or `$state.raw`)
// -- this is ok because the rune placement rules will throw if they're invalid.
return 'regular';
}
}

@ -4,7 +4,7 @@ export default test({
error: {
code: 'state_invalid_placement',
message:
'`$state(...)` can only be used as a variable declaration initializer or a class field',
'`$state(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.',
position: [33, 41]
}
});

@ -3,6 +3,7 @@ import { test } from '../../test';
export default test({
error: {
code: 'state_invalid_placement',
message: '`$state(...)` can only be used as a variable declaration initializer or a class field'
message:
'`$state(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.'
}
});

@ -4,6 +4,6 @@ export default test({
error: {
code: 'state_invalid_placement',
message:
'`$derived(...)` can only be used as a variable declaration initializer or a class field'
'`$derived(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.'
}
});

@ -3,6 +3,7 @@ import { test } from '../../test';
export default test({
error: {
code: 'state_invalid_placement',
message: '`$state(...)` can only be used as a variable declaration initializer or a class field'
message:
'`$state(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.'
}
});

@ -1,7 +1,7 @@
[
{
"code": "state_invalid_placement",
"message": "`$derived(...)` can only be used as a variable declaration initializer or a class field",
"message": "`$derived(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.",
"start": {
"line": 2,
"column": 15

Loading…
Cancel
Save