diff --git a/modules/ensemble/lib/framework/error_handling.dart b/modules/ensemble/lib/framework/error_handling.dart index 1e6e037de..665bb5da4 100644 --- a/modules/ensemble/lib/framework/error_handling.dart +++ b/modules/ensemble/lib/framework/error_handling.dart @@ -47,6 +47,18 @@ class CodeError extends EnsembleError { error = 'Line: $line in YAML and Line: ${exception.line} within the code block. Error Message: $error'; } + + @override + String toString() { + final buffer = StringBuffer(error); + if (detailedError != null && detailedError!.trim().isNotEmpty) { + buffer.write('\n\nDetails:\n${detailedError!.trim()}'); + } + if (recovery != null && recovery!.trim().isNotEmpty) { + buffer.write('\n\nRecovery:\n${recovery!.trim()}'); + } + return buffer.toString(); + } } class RuntimeError extends EnsembleError { diff --git a/packages/ensemble_ts_interpreter/CHANGELOG.md b/packages/ensemble_ts_interpreter/CHANGELOG.md index a9f6dfabe..af71ef36a 100644 --- a/packages/ensemble_ts_interpreter/CHANGELOG.md +++ b/packages/ensemble_ts_interpreter/CHANGELOG.md @@ -1,3 +1,21 @@ +## [1.3.0] - ES6 Runtime Support + +* Added practical ES6 support for `let`, `const`, block scoping, const reassignment errors, and read-before-initialization errors. +* Added template literal evaluation, default parameters, rest parameters, and spread in array literals and function calls. +* Added practical `for...of` loops and per-iteration `let`/`const` bindings so closures capture the expected iteration value. +* Added practical optional chaining (`?.`) and nullish coalescing (`??`) support for app logic. +* Added common ES6 syntax support for single-parameter arrow functions, object/array destructuring declarations, object shorthand properties, enhanced object methods, computed property names, and `Symbol` keys. +* Verified practical `Promise.then(...)` callbacks with arrow functions. +* Added practical `async`/`await` support for async functions and async arrows that await `Promise`, Dart `Future`, and plain values. +* Added practical support for destructuring parameters, destructuring assignment, object spread, `Array.from`, `Array.of`, `new Array(length)`, `Promise.all`, and `Promise.allSettled`. +* Added practical tagged template support for app-level formatting helpers such as currency/string tag functions. +* Added final ES6 convenience coverage for `Array.from` collections/array-like objects, `Object.fromEntries`, `Object.hasOwn`, `Object.getOwnPropertyNames`, `Promise.race`, `Promise.any`, `Promise.prototype.finally`, destructuring defaults/rest, and `for...of` destructuring. +* Added practical `Array.prototype.findLast` and `findLastIndex` support. +* Fixed method references such as `fetchData().then(console.log)` by exposing invokable methods as readable callback values. +* Fixed out-of-range array reads to return JavaScript `undefined` instead of Dart `null`. +* Added focused ES6 compatibility tests while preserving the ES5 runtime test gate and existing ES6 conveniences. +* Updated parser dependency to `parsejs_null_safety` 2.1.0 for the required ES6 AST support. + ## [1.2.0] - Practical ES5 Runtime Stability * Added a documented practical ES5 compatibility baseline with focused regression tests. diff --git a/packages/ensemble_ts_interpreter/doc/architecture.md b/packages/ensemble_ts_interpreter/doc/architecture.md new file mode 100644 index 000000000..a17dd2c6f --- /dev/null +++ b/packages/ensemble_ts_interpreter/doc/architecture.md @@ -0,0 +1,118 @@ +# Interpreter Architecture & Execution Model + +`ensemble_ts_interpreter` is a pure Dart interpreter that executes JavaScript source code inside Flutter applications. It interprets the Abstract Syntax Tree (AST) nodes generated by `parsejs_null_safety` to run calculations, conditional logic, API callback handling, and dynamic UI state binding. + +--- + +## 1. Execution Flow & Compilation Pipeline + +The interpreter executes code through a clean pipeline: + +```mermaid +graph TD + Code["JavaScript Code (String)"] --> Parser["Parser (parsejs_null_safety)"] + Parser -->|AST| Interpreter["Interpreter (newjs_interpreter.dart)"] + Interpreter -->|Recursive AST Traversal| Output["Resulting Dart Object"] +``` + +--- + +## 2. AST Visitor Evaluation Loop (`newjs_interpreter.dart`) + +The core execution engine is defined by `JSInterpreter`, which extends `RecursiveVisitor`. +* **Visitor Evaluation**: It evaluates expressions and statements by overriding AST visitor hooks. For example: + * `visitIf(IfStatement)` evaluates the condition, casts it to a boolean, and conditionally executes the `then` or `otherwise` block. + * `visitBinary(BinaryExpression)` evaluates the left and right operands, then executes the corresponding Dart operator (e.g. arithmetic, logical, or comparison). +* **Control Flow Hijacking**: To support statements like `return`, `break`, and `continue`, the interpreter throws control-flow exceptions (`ControlFlowReturnException`, `ControlFlowBreakException`, `ControlFlowContinueException`) which are caught by parent block/loop visitors to redirect execution. + +--- + +## 3. Scope & Scope Lifecycles + +Variable resolution operates on two independent scope context models: + +```mermaid +graph TD + subgraph ExecutionScope ["Active Scopes"] + VarScope["Dynamic Scope Context (Context Map)"] + LexScope["Lexical Scope Stack (List<_LexicalContext>)"] + end + + VarLookup["Name Reference"] --> LexCheck{"Is name in LexScope?"} + LexCheck -->|Yes| LexResolve["Resolve _LexicalBinding (Checks TDZ)"] + LexCheck -->|No| VarResolve["Resolve from Dynamic Context / Globals"] +``` + +### A. Dynamic Scope (`Context`) +Variables declared via `var` and function declarations are hoisted to the enclosing function or global context. Context variables are stored in simple key-value maps. The interpreter resolves these dynamically during runtime property lookup. + +### B. Lexical Scope (`_LexicalContext`) +Block-level variables (`let` and `const`) are managed on a stack of `_LexicalContext` structures: +* **Scope Entry**: Every `visitBlock` pushes a new `_LexicalContext`. +* **Temporal Dead Zone (TDZ)**: Lexical variables are hoisted as uninitialized bindings. Accessing an uninitialized binding throws a `JSException`. +* **Const Protection**: Const bindings cannot be reassigned; doing so throws a runtime exception. +* **Per-Iteration Scopes**: Loops push a new `_LexicalContext` for every iteration. This ensures closures created inside loops capture a snapshot of the iteration's state rather than sharing a single reference. + +--- + +## 4. The Invokable Framework + +To bridge Dart/Flutter widgets with JavaScript scripts, the interpreter uses the `Invokable` framework. + +```mermaid +classDiagram + class Invokable { + <> + +getters() Map + +setters() Map + +methods() Map + +getProperty(prop) + +setProperty(prop, val) + +invokeMethod(method, args) + } + class InvokableObject { + } + class StaticArray { + } + class JSMap { + } + + Invokable <|-- InvokableObject + Invokable <|-- StaticArray + Invokable <|-- JSMap +``` + +### Invokable Interface +Any Dart class that mixes in `Invokable` can be passed directly to the interpreter. The interpreter calls: +* `getters()`: To list properties readable by JS. +* `setters()`: To list properties writable by JS. +* `methods()`: To list functions executable by JS. +This makes native Flutter UI controllers, fields, and action behaviors immediately scriptable in JavaScript. + +--- + +## 5. Type Marshaling & Interoperability + +The interpreter marshals data between JavaScript and Dart boundaries: + +| JavaScript Concept | Dart Type / Object | Boundary Conversion | +| :--- | :--- | :--- | +| **`null`** | `null` | Directly mapped to Dart `null` | +| **`undefined`** | `jsUndefined` (`JSUndefined`) | Out-of-bounds list index reads or empty returns result in `jsUndefined`. | +| **Numbers / Strings** | `num` / `String` | Maps to standard Dart primitives | +| **Array** | `List` | JS arrays are standard Dart lists | +| **Object** | `Map` / `Invokable` | Standard JS objects are represented as Dart Maps or `Invokable` objects. | +| **Symbols** | `JSSymbol` | Encapsulates ES6 Symbol keys for maps | + +--- + +## 6. Asynchronous Chaining & Promises + +Asynchronous execution uses Dart's native `Future` systems without blocking UI thread rendering. + +* **Analysis**: `_containsAwait` checks if a statement/expression contains `await`. If not, it executes synchronously with zero overhead. +* **Execution**: If `await` is present, it runs in `_executeAsyncStatement`, yielding control back to Dart's event loop via async/await futures. +* **Polyfills**: + * `Promise`: Modeled by `JSPromise` wrapping a Dart `Completer`. + * Promise methods (`all`, `allSettled`, `race`, `any`, `finally`) are mapped directly to Dart `Future` helpers like `Future.wait` and `Future.any`. + * `toFuture()`: Facilitates converting `JSPromise` instances to Dart `Future` objects at the Dart-to-JS integration boundary. diff --git a/packages/ensemble_ts_interpreter/doc/known_supported_js.md b/packages/ensemble_ts_interpreter/doc/known_supported_js.md index 03f13a475..916559b99 100644 --- a/packages/ensemble_ts_interpreter/doc/known_supported_js.md +++ b/packages/ensemble_ts_interpreter/doc/known_supported_js.md @@ -1,6 +1,6 @@ # Known Supported JavaScript -`ensemble_ts_interpreter` targets practical JavaScript ES5 for app logic inside Ensemble and Flutter apps. Some ES6+ conveniences already work, but ES5 is the compatibility baseline. +`ensemble_ts_interpreter` targets practical JavaScript ES5 for app logic inside Ensemble and Flutter apps. A small ES6+ subset is also supported for common developer ergonomics, but ES5 remains the compatibility baseline. ## Supported Baseline @@ -24,13 +24,30 @@ The runtime uses Dart `Map`, `List`, and `Invokable` values at the public bounda - Sparse array holes are preserved for `delete`, skipped by callback methods, and serialized as `null` by `JSON.stringify`. - `Function.prototype.call`, `apply`, and `bind` work for interpreter functions and compatible Dart callbacks. -## ES6+ Conveniences That May Work - -These features are available today where tests cover them, but they are not the baseline compatibility contract yet: - -- Arrow functions. +## Supported ES6 Conveniences + +These features are covered by focused package tests: + +- Arrow functions, including the common single-parameter form without parentheses. +- `let` and `const` declarations with practical block scoping. +- `const` reassignment errors. +- Read-before-initialization errors for lexical declarations. +- Per-iteration `let` bindings for `for` loops. +- Practical `for...of` loops over arrays/lists, strings, `Map`, and `Set`. +- Destructuring in practical `for...of` declarations and assignment targets. +- Template literals with `${...}` interpolation and practical tagged-template calls. +- Object and array destructuring declarations, defaults, object rest properties, destructuring parameters, and practical destructuring assignment. +- Object property shorthand, enhanced object methods, and computed property names. +- Default parameters and rest parameters for normal functions and arrow functions. +- Spread in array literals, function calls, and object literals. +- `Array.from`, including arrays/lists, strings, `Map`, `Set`, and array-like objects, plus `Array.of` and `new Array(length)`. +- `Object.fromEntries`, `Object.hasOwn`, and `Object.getOwnPropertyNames`. +- Optional chaining with `?.`, including property access, index access, and optional calls. +- Nullish coalescing with `??`, falling back only for practical null/undefined values. - `Map` and `Set` globals. -- `Promise`, `queueMicrotask`, and promise-style helpers. +- `Symbol` values as computed object keys. +- `Promise`, `Promise.all`, `Promise.allSettled`, `Promise.race`, `Promise.any`, `Promise.prototype.finally`, `queueMicrotask`, and promise-style helpers. +- Practical `async` functions and async arrows with `await` for `Promise`, Dart `Future`, and plain values. - Some modern array and string helpers such as `includes`, `flat`, `flatMap`, `startsWith`, and `endsWith`. Treat these as supported conveniences, not full ES6 conformance. @@ -39,8 +56,11 @@ Treat these as supported conveniences, not full ES6 conformance. Do not rely on these areas for production app logic yet: -- `let`, `const`, block scoping, temporal dead zone behavior, and strict mode conformance. -- ES6 classes, modules, imports, exports, destructuring, spread/rest, template literals, generators, and async/await. +- Strict mode conformance. +- ES6 classes, modules, imports, exports, and generators. +- Full tagged-template edge cases such as `strings.raw` identity/caching semantics. +- Full destructuring edge-case coverage beyond practical declaration, parameter, assignment, and `for...of` patterns. +- Full async/await coverage in every statement/expression position; supported app patterns include awaited variable initializers, awaited returns, and simple expressions around awaited values. - Direct `eval` scope behavior. - Full Test262 conformance, especially edge cases around property descriptors, coercion, `Date`, `RegExp`, strict mode, and host objects. - Browser-specific APIs beyond the globals provided by Ensemble. @@ -49,7 +69,7 @@ Do not rely on these areas for production app logic yet: For the most reliable app code: -- Prefer ES5-style functions and `var`. +- Prefer ES5-style functions and `var` for maximum portability; use the documented ES6 subset where tests cover your app pattern. - Use `Object.*`, array callbacks, and JSON APIs normally, but keep logic app-focused rather than engine-conformance-heavy. - Treat Dart `null` at the public boundary as the practical representation for JavaScript `null`/`undefined`. - Add focused interpreter tests when introducing new JavaScript patterns that app screens depend on. diff --git a/packages/ensemble_ts_interpreter/lib/invokables/invokable.dart b/packages/ensemble_ts_interpreter/lib/invokables/invokable.dart index 1ea2e6a9f..158cb9b99 100644 --- a/packages/ensemble_ts_interpreter/lib/invokables/invokable.dart +++ b/packages/ensemble_ts_interpreter/lib/invokables/invokable.dart @@ -116,6 +116,10 @@ mixin Invokable { if (func != null) { return func(); } + final method = getMethod(prop); + if (method != null) { + return (dynamic args) => method(args); + } throw InvalidPropertyException( 'Object with id:${id ?? ''} does not have a gettable property named $prop'); } diff --git a/packages/ensemble_ts_interpreter/lib/invokables/invokablecollections.dart b/packages/ensemble_ts_interpreter/lib/invokables/invokablecollections.dart index a78bd9b71..ee28c253b 100644 --- a/packages/ensemble_ts_interpreter/lib/invokables/invokablecollections.dart +++ b/packages/ensemble_ts_interpreter/lib/invokables/invokablecollections.dart @@ -6,7 +6,22 @@ class JSMapConstructor extends Object with Invokable { @override Map methods() => { - 'init': () => JSMap({}), + 'init': ([List? entries]) { + final map = JSMap({}); + if (entries != null) { + for (final entry in entries) { + if (entry is List && entry.length >= 2) { + map._data[entry[0]] = entry[1]; + } else if (entry is Map) { + final key = entry.containsKey('key') ? entry['key'] : entry[0]; + final value = + entry.containsKey('value') ? entry['value'] : entry[1]; + map._data[key] = value; + } + } + } + return map; + }, }; @override @@ -39,6 +54,17 @@ class JSMap extends Object with Invokable { @override Map setters() => {}; + + String toConsoleString() { + final entries = _data.entries + .map((entry) => + '${_formatCollectionValue(entry.key)} => ${_formatCollectionValue(entry.value)}') + .join(', '); + return 'Map(${_data.length}) {$entries}'; + } + + @override + String toString() => toConsoleString(); } class JSSetConstructor extends Object with Invokable { @@ -72,6 +98,7 @@ class JSSet extends Object with Invokable { 'has': (dynamic value) => _data.contains(value), 'delete': (dynamic value) => _data.remove(value), 'clear': () => _data.clear(), + 'keys': () => _data.toList(), 'values': () => _data.toList(), 'entries': () => _data.map((e) => [e, e]).toList(), 'forEach': (Function f) => _data.forEach((e) => f([e, e, this])), @@ -79,4 +106,20 @@ class JSSet extends Object with Invokable { @override Map setters() => {}; + + String toConsoleString() { + final values = _data.map(_formatCollectionValue).join(', '); + return 'Set(${_data.length}) {$values}'; + } + + @override + String toString() => toConsoleString(); +} + +String _formatCollectionValue(dynamic value) { + if (value == null) return 'null'; + if (value is String) return '"$value"'; + if (value is JSMap) return value.toConsoleString(); + if (value is JSSet) return value.toConsoleString(); + return value.toString(); } diff --git a/packages/ensemble_ts_interpreter/lib/invokables/invokablecommons.dart b/packages/ensemble_ts_interpreter/lib/invokables/invokablecommons.dart index 7fd751201..5bac41b20 100644 --- a/packages/ensemble_ts_interpreter/lib/invokables/invokablecommons.dart +++ b/packages/ensemble_ts_interpreter/lib/invokables/invokablecommons.dart @@ -170,6 +170,29 @@ class InvokableObject extends Object with Invokable { .map((key) => [key, InvokableController.getProperty(value, key)]) .toList() : (value == null ? [] : null), + 'fromEntries': (dynamic entries) { + final result = {}; + List entryList = []; + if (entries is List) { + entryList = entries; + } else if (entries is Invokable) { + final method = entries.methods()['entries']; + final value = + method == null ? null : Function.apply(method, const []); + if (value is List) entryList = value; + } + for (final entry in entryList) { + if (entry is List && entry.length >= 2) { + result[entry[0]] = entry[1]; + } else if (entry is Map) { + final key = entry.containsKey('key') ? entry['key'] : entry[0]; + final value = + entry.containsKey('value') ? entry['value'] : entry[1]; + result[key] = value; + } + } + return result; + }, 'create': (dynamic proto) { final Map obj = {}; if (proto != null) { @@ -179,8 +202,13 @@ class InvokableObject extends Object with Invokable { }, 'getPrototypeOf': (dynamic value) => InvokableController.getPrototype(value), + 'hasOwn': (dynamic value, dynamic key) => + InvokableController.hasOwnProperty(value, key), 'hasOwnProperty': (dynamic value, String key) => InvokableController.hasOwnProperty(value, key), + 'getOwnPropertyNames': (dynamic value) => (value is Map) + ? InvokableController.ownPropertyKeys(value) + : (value == null ? [] : null), 'getPropertyNames': (dynamic value) => (value is Map) ? InvokableController.ownPropertyKeys(value) : (value == null ? [] : null), diff --git a/packages/ensemble_ts_interpreter/lib/invokables/invokablecontroller.dart b/packages/ensemble_ts_interpreter/lib/invokables/invokablecontroller.dart index 92ce31a21..785c9e1b5 100644 --- a/packages/ensemble_ts_interpreter/lib/invokables/invokablecontroller.dart +++ b/packages/ensemble_ts_interpreter/lib/invokables/invokablecontroller.dart @@ -36,7 +36,35 @@ class JSArrayHole { } const Object _missingDescriptorValue = _MissingDescriptorValue(); +const Object _missingArrayItem = Object(); const JSArrayHole jsArrayHole = JSArrayHole(); +/// Constant representing the JavaScript `undefined` value. +const JSUndefined jsUndefined = JSUndefined(); + +/// Represents the JavaScript `undefined` type. +class JSUndefined { + /// Creates a constant `undefined` value. + const JSUndefined(); + + @override + String toString() => 'undefined'; +} + +/// Returns true if the given value is JavaScript `undefined`. +bool isJSUndefined(dynamic value) => value is JSUndefined; + +/// Represents an ES6 Symbol value in the interpreter runtime. +class JSSymbol { + /// Creates an ES6 Symbol with an optional description. + JSSymbol([this.description]); + + /// The description of the Symbol. + final String? description; + + @override + String toString() => + description == null ? 'Symbol()' : 'Symbol($description)'; +} class JSPropertyDescriptor { JSPropertyDescriptor({ @@ -179,6 +207,8 @@ abstract class GlobalContext { 'performance': StaticPerformance(), 'Map': JSMapConstructor(), 'Set': JSSetConstructor(), + 'Symbol': ([dynamic description]) => + JSSymbol(description == null ? null : description.toString()), 'queueMicrotask': (Function cb) => scheduleMicrotask(() { try { cb([]); @@ -262,6 +292,7 @@ class InvokableController { static void addGlobals(Map context) { context.addAll(GlobalContext.context); context['globalThis'] = context; + context['undefined'] = jsUndefined; // context['debug'] = () async { // await waitForCondition(); // }; @@ -662,6 +693,7 @@ class Console extends Object with Invokable, MethodExecutor { } String _formatArg(dynamic value) { + if (isJSUndefined(value)) return 'undefined'; if (value == null) return 'null'; if (value is bool || value is num) return value.toString(); if (value is String) return value; @@ -835,7 +867,74 @@ class StaticArray extends Object with Invokable { @override Map methods() { return { + 'init': ( + [dynamic first, + dynamic second = _missingArrayItem, + dynamic third = _missingArrayItem, + dynamic fourth = _missingArrayItem, + dynamic fifth = _missingArrayItem]) { + final values = [first, second, third, fourth, fifth] + .where((value) => !identical(value, _missingArrayItem)) + .toList(); + if (values.length == 1 && values.first is num) { + final length = (values.first as num).toInt(); + _List._checkArrayLength(length); + return List.filled(length, jsArrayHole); + } + return values; + }, 'isArray': (dynamic value) => value is List, + 'from': (dynamic value, [Function? mapFn]) { + List list; + if (value is String) { + list = value.split(''); + } else if (value is List) { + list = value.map(_List._visibleValue).toList(); + } else if (value is Iterable) { + list = value.toList(); + } else if (value is Invokable) { + final methods = value.methods(); + final isMapLike = + methods.containsKey('get') && methods.containsKey('set'); + final iteratorMethod = + isMapLike ? methods['entries'] : methods['values']; + final result = iteratorMethod == null + ? null + : Function.apply(iteratorMethod, const []); + list = result is List ? List.from(result) : []; + } else if (value is Map && value['length'] is num) { + final length = (value['length'] as num).toInt(); + _List._checkArrayLength(length); + list = List.generate(length, (index) { + if (InvokableController.hasProperty(value, index)) { + return InvokableController.getProperty(value, index); + } + return InvokableController.getProperty(value, index.toString()); + }); + } else { + list = []; + } + if (mapFn != null) { + return list + .asMap() + .entries + .map((entry) => mapFn([entry.value, entry.key])) + .toList(); + } + return list; + }, + 'of': ( + [dynamic first = _missingArrayItem, + dynamic second = _missingArrayItem, + dynamic third = _missingArrayItem, + dynamic fourth = _missingArrayItem, + dynamic fifth = _missingArrayItem, + dynamic sixth = _missingArrayItem, + dynamic seventh = _missingArrayItem, + dynamic eighth = _missingArrayItem]) => + [first, second, third, fourth, fifth, sixth, seventh, eighth] + .where((value) => !identical(value, _missingArrayItem)) + .toList(), }; } @@ -1459,6 +1558,18 @@ class _List { } return -1; }, + 'findLast': (Function f) { + for (final entry in _presentEntries(list).toList().reversed) { + if (f([entry.value, entry.key, list])) return entry.value; + } + return null; + }, + 'findLastIndex': (Function f) { + for (final entry in _presentEntries(list).toList().reversed) { + if (f([entry.value, entry.key, list])) return entry.key; + } + return -1; + }, 'includes': (dynamic v) => _presentValues(list).contains(v), 'contains': (dynamic v) => _presentValues(list).contains(v), 'join': ([String str = ',']) => list.join(str), @@ -1641,7 +1752,7 @@ class _List { static dynamic getProperty(List list, dynamic prop) { prop = InvokableController._normalizeIndexProperty(prop); if (prop is int) { - if (prop < 0 || prop >= list.length) return null; + if (prop < 0 || prop >= list.length) return jsUndefined; return _visibleValue(list[prop]); } Function? f = getters(list)[prop]; diff --git a/packages/ensemble_ts_interpreter/lib/invokables/invokablepromises.dart b/packages/ensemble_ts_interpreter/lib/invokables/invokablepromises.dart index a02ca95a4..7f738e41b 100644 --- a/packages/ensemble_ts_interpreter/lib/invokables/invokablepromises.dart +++ b/packages/ensemble_ts_interpreter/lib/invokables/invokablepromises.dart @@ -15,6 +15,10 @@ class JSPromiseConstructor extends Object with Invokable { 'resolve': (dynamic value) => JSPromise.resolve(value), 'reject': (dynamic reason) => JSPromise.reject(reason), 'fromFuture': (Future fut) => JSPromise.fromFuture(fut), + 'all': (List values) => JSPromise.all(values), + 'allSettled': (List values) => JSPromise.allSettled(values), + 'race': (List values) => JSPromise.race(values), + 'any': (List values) => JSPromise.any(values), }; } @@ -50,6 +54,54 @@ class JSPromise extends Object with Invokable { }); } + static JSPromise all(List values) { + return JSPromise.fromFuture( + Future.wait(values.map((value) => _awaitValue(value)).toList())); + } + + static JSPromise allSettled(List values) { + return JSPromise.fromFuture(Future.wait(values.map((value) async { + try { + return {'status': 'fulfilled', 'value': await _awaitValue(value)}; + } catch (error) { + return {'status': 'rejected', 'reason': error}; + } + }).toList())); + } + + static JSPromise race(List values) { + return JSPromise.fromFuture( + Future.any(values.map((value) => _awaitValue(value)).toList())); + } + + static JSPromise any(List values) { + return JSPromise((List funcs) { + final resolve = funcs[0]; + final reject = funcs[1]; + if (values.isEmpty) { + reject([]); + return; + } + final errors = []; + var remaining = values.length; + for (final value in values) { + _awaitValue(value).then((resolved) { + resolve(resolved); + }).catchError((error) { + errors.add(error); + remaining--; + if (remaining == 0) reject(errors); + }); + } + }); + } + + static Future _awaitValue(dynamic value) { + if (value is JSPromise) return value.toFuture(); + if (value is Future) return value; + return Future.value(value); + } + JSPromise(Function executor) { void resolve(value) => _resolve(value); void reject(reason) => _reject(reason); @@ -109,28 +161,41 @@ class JSPromise extends Object with Invokable { JSPromise finally_(Function onFinally) { return then((v) { + final original = v is List && v.isNotEmpty ? v.first : v; try { final r = onFinally([]); if (r is JSPromise) { - return r.then((_) => v); + return r.then((_) => original); } - return v; + return original; } catch (e) { throw e; } }, (e) { + final reason = e is List && e.isNotEmpty ? e.first : e; try { final r = onFinally([]); if (r is JSPromise) { - return r.then((_) => throw e); + return r.then((_) => throw reason); } - throw e; + throw reason; } catch (err) { throw err; } }); } + Future toFuture() { + final completer = Completer(); + then((args) { + completer.complete(args is List && args.isNotEmpty ? args.first : args); + }, (args) { + final reason = args is List && args.isNotEmpty ? args.first : args; + completer.completeError(reason); + }); + return completer.future; + } + @override Map getters() => {}; @@ -163,7 +228,8 @@ class _PromiseHandler { try { final result = onFulfilled!([value]); if (result is JSPromise) { - result.then((v) => resolve(v), (e) => reject(e)); + result.then( + (v) => resolve(_singleArg(v)), (e) => reject(_singleArg(e))); } else { resolve(result); } @@ -180,7 +246,8 @@ class _PromiseHandler { try { final result = onRejected!([reason]); if (result is JSPromise) { - result.then((v) => resolve(v), (e) => reject(e)); + result.then( + (v) => resolve(_singleArg(v)), (e) => reject(_singleArg(e))); } else { resolve(result); } @@ -188,4 +255,7 @@ class _PromiseHandler { reject(e); } } + + dynamic _singleArg(dynamic value) => + value is List && value.isNotEmpty ? value.first : value; } diff --git a/packages/ensemble_ts_interpreter/lib/parser/js_validator.dart b/packages/ensemble_ts_interpreter/lib/parser/js_validator.dart index 232caeb1b..89f81ae67 100644 --- a/packages/ensemble_ts_interpreter/lib/parser/js_validator.dart +++ b/packages/ensemble_ts_interpreter/lib/parser/js_validator.dart @@ -60,7 +60,9 @@ class JSValidator extends RecursiveVisitor { _interpreter.contexts[stmt.function] = functionContext; } else if (stmt is VariableDeclaration) { for (var declarator in stmt.declarations) { - _interpreter.addToContext(declarator.name, true); + for (final name in _bindingNames(declarator.name)) { + _interpreter.addToContext(name, true); + } } } } @@ -272,7 +274,9 @@ class JSValidator extends RecursiveVisitor { @override bool visitVariableDeclaration(VariableDeclaration node) { for (var declarator in node.declarations) { - _interpreter.addToContext(declarator.name, true); + for (final name in _bindingNames(declarator.name)) { + _interpreter.addToContext(name, true); + } if (declarator.init != null) { visit(declarator.init!); } @@ -280,6 +284,28 @@ class JSValidator extends RecursiveVisitor { return true; } + List _bindingNames(Node binding) { + if (binding is Name) return [binding]; + if (binding is ObjectPattern) { + return binding.properties.expand((property) { + final value = property.value; + if (value is Name) return [value]; + return _bindingNames(value); + }).toList(); + } + if (binding is ArrayPattern) { + return binding.elements + .whereType() + .expand((element) => element is RestParameter + ? _bindingNames(element.name) + : _bindingNames(element)) + .toList(); + } + if (binding is DefaultParameter) return _bindingNames(binding.name); + if (binding is RestParameter) return _bindingNames(binding.name); + return []; + } + @override bool visitCall(CallExpression node) { try { @@ -381,8 +407,10 @@ class JSValidator extends RecursiveVisitor { } // Store the object properties in the context for later validation if (node.parent != null && node.parent is VariableDeclarator) { - String varName = (node.parent as VariableDeclarator).name.value; - context.addDataContextById(varName, objProperties); + final binding = (node.parent as VariableDeclarator).name; + if (binding is Name) { + context.addDataContextById(binding.value, objProperties); + } } return true; } diff --git a/packages/ensemble_ts_interpreter/lib/parser/newjs_interpreter.dart b/packages/ensemble_ts_interpreter/lib/parser/newjs_interpreter.dart index 9017ef89c..191bef52e 100644 --- a/packages/ensemble_ts_interpreter/lib/parser/newjs_interpreter.dart +++ b/packages/ensemble_ts_interpreter/lib/parser/newjs_interpreter.dart @@ -1,10 +1,12 @@ // ignore_for_file: unnecessary_null_comparison, unused_catch_clause, unnecessary_non_null_assertion, unused_local_variable +import 'dart:async'; import 'dart:convert'; import 'package:ensemble_ts_interpreter/errors.dart'; import 'package:ensemble_ts_interpreter/invokables/context.dart'; import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; import 'package:ensemble_ts_interpreter/invokables/invokablecommons.dart'; import 'package:ensemble_ts_interpreter/invokables/invokablecontroller.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokablepromises.dart'; import 'package:parsejs_null_safety/jsparser.dart'; import 'package:ensemble_ts_interpreter/parser/regex_ext.dart'; @@ -25,9 +27,31 @@ class Bindings extends RecursiveVisitor { @override visitVariableDeclarator(VariableDeclarator node) { - String name = visitName(node.name); - bindings.add(name); - return name; + final names = _bindingNames(node.name).map(visitName).toList(); + bindings.addAll(names); + return names.join(','); + } + + List _bindingNames(Node binding) { + if (binding is Name) return [binding]; + if (binding is ObjectPattern) { + return binding.properties.expand((property) { + final value = property.value; + if (value is Name) return [value]; + return _bindingNames(value); + }).toList(); + } + if (binding is ArrayPattern) { + return binding.elements + .whereType() + .expand((element) => element is RestParameter + ? _bindingNames(element.name) + : _bindingNames(element)) + .toList(); + } + if (binding is DefaultParameter) return _bindingNames(binding.name); + if (binding is RestParameter) return _bindingNames(binding.name); + return []; } @override @@ -128,12 +152,79 @@ class Bindings extends RecursiveVisitor { } } +class _LexicalBinding { + dynamic value; + final bool mutable; + bool initialized; + + _LexicalBinding.uninitialized({required this.mutable}) + : initialized = false, + value = null; + + _LexicalBinding.initialized(this.value, {required this.mutable}) + : initialized = true; +} + +class _LexicalContext { + final Map bindings = {}; + + bool has(String name) => bindings.containsKey(name); + + dynamic get(String name, int line) { + final binding = bindings[name]!; + if (!binding.initialized) { + throw JSException(line, + "Cannot access lexical variable '$name' before initialization."); + } + return binding.value; + } + + void declare(String name, {required bool mutable}) { + bindings.putIfAbsent( + name, () => _LexicalBinding.uninitialized(mutable: mutable)); + } + + void initialize(String name, dynamic value, {required bool mutable}) { + bindings[name] = _LexicalBinding.initialized(value, mutable: mutable); + } + + void set(String name, dynamic value, int line) { + final binding = bindings[name]!; + if (!binding.mutable && binding.initialized) { + throw JSException(line, "Assignment to constant variable '$name'."); + } + binding.value = value; + binding.initialized = true; + } + + _LexicalContext snapshot() { + final next = _LexicalContext(); + bindings.forEach((key, binding) { + next.bindings[key] = + _LexicalBinding.uninitialized(mutable: binding.mutable) + ..value = binding.value + ..initialized = binding.initialized; + }); + return next; + } +} + +class _ForLoopBinding { + final Node binding; + final String kind; + final VariableDeclaration? declaration; + + _ForLoopBinding(this.binding, this.kind, this.declaration); +} + class JSInterpreter extends RecursiveVisitor { late String code; late Program program; Map contexts = {}; final List _dynamicContexts = []; + final List<_LexicalContext> _lexicalContexts = []; String? _nextStatementLabel; + String _currentDeclarationKind = 'var'; @override defaultNode(Node node) { dynamic rtn; @@ -154,9 +245,10 @@ class JSInterpreter extends RecursiveVisitor { InvokableController.addGlobals(programContext.getContextMap()); InvokableController.updateLocale(programContext.getContextMap()); } - static const String parsingErrorAppendage = "Only ES5 is supported. " - "Key words such as let, const, operators such as -> and templated strings are not yet supported. " - "Here's a full list of features that are only available in ES6 and are therefore NOT supported in Ensemble at this time. https://www.w3schools.com/js/js_es6.asp"; + static const String parsingErrorAppendage = + "ES5 is the compatibility baseline. " + "Selected ES6+ conveniences such as arrow functions, let/const, template literals, tagged templates, destructuring declarations, object shorthand/methods, computed property names, default/rest parameters, spread in arrays/calls, for...of, optional chaining, nullish coalescing, Symbol keys, Promises, and practical async/await are supported. " + "Classes, modules, generators, and full ES6+ conformance are not supported yet."; JSInterpreter.fromCode(String code, Context programContext) : this(code, parseCode(code), programContext); static Program parseCode(String code) { @@ -167,18 +259,48 @@ class JSInterpreter extends RecursiveVisitor { try { return parsejs(code); } on ParseError catch (e) { + final line = e.line ?? 1; + final column = _columnForOffset(code, e.startOffset); throw JSException( - e.line ?? 1, - "Parsing error Occurred while parsing javascript code block. " - "Error Message: ${e.message}", - detailedError: 'Code: $code . FYI: $parsingErrorAppendage'); + line, + "JavaScript parse error: ${e.message}.", + column: column, + recovery: parsingErrorAppendage, + detailedError: _formatParseDetails(code, line, column), + originalError: e, + ); } catch (error) { throw JSException( - 1, - "Parsing error Occurred while parsing javascript code block. " - "Error Message: ${error.toString()}", - detailedError: 'Code: $code . FYI: $parsingErrorAppendage'); + 1, + "JavaScript parse error: ${error.toString()}.", + recovery: parsingErrorAppendage, + detailedError: _formatParseDetails(code, 1, 1), + originalError: error, + ); + } + } + + static int _columnForOffset(String code, int? offset) { + if (offset == null || offset < 0 || offset > code.length) return 1; + final lineStart = code.lastIndexOf('\n', offset - 1) + 1; + return offset - lineStart + 1; + } + + static String _formatParseDetails(String code, int line, int column) { + final lines = code.split('\n'); + if (lines.isEmpty) return parsingErrorAppendage; + final index = (line - 1).clamp(0, lines.length - 1); + final start = (index - 1).clamp(0, lines.length - 1); + final end = (index + 1).clamp(0, lines.length - 1); + final buffer = StringBuffer('Near line $line, column $column:\n'); + for (var i = start; i <= end; i++) { + final lineNumber = i + 1; + buffer.writeln('${lineNumber.toString().padLeft(4)} | ${lines[i]}'); + if (i == index) { + buffer.writeln(' | ${' ' * (column - 1)}^'); + } } + return buffer.toString(); } static String toJSString(Map map) { @@ -220,6 +342,7 @@ class JSInterpreter extends RecursiveVisitor { JSInterpreter i = JSInterpreter( this.code, this.program, getContextForScope(this.program)); i._dynamicContexts.addAll(_dynamicContexts); + i._lexicalContexts.addAll(_lexicalContexts); if (inheritContexts) { contexts.keys.forEach((key) { i.contexts[key] = contexts[key]!; @@ -258,6 +381,12 @@ class JSInterpreter extends RecursiveVisitor { } void addToContext(Name node, dynamic value) { + for (final _LexicalContext lexical in _lexicalContexts.reversed) { + if (lexical.has(node.value)) { + lexical.set(node.value, value, node.line ?? 1); + return; + } + } for (final Context candidate in _dynamicContexts.reversed) { if (candidate.hasContext(node.value)) { candidate.addDataContextById(node.value, value); @@ -275,6 +404,216 @@ class JSInterpreter extends RecursiveVisitor { ctx.addDataContextById(node.value, value); } + List _bindingNames(Node binding) { + if (binding is Name) return [binding]; + if (binding is ObjectPattern) { + return binding.properties.expand((property) { + final value = property.value; + if (value is Name) return [value]; + return _bindingNames(value); + }).toList(); + } + if (binding is ArrayPattern) { + return binding.elements + .whereType() + .expand((element) => element is RestParameter + ? _bindingNames(element.name) + : _bindingNames(element)) + .toList(); + } + if (binding is DefaultParameter) return _bindingNames(binding.name); + if (binding is RestParameter) return _bindingNames(binding.name); + throw JSException(binding.line ?? 1, 'Unsupported binding pattern.'); + } + + void _bindPattern(Node binding, dynamic value, + {required bool lexical, required bool mutable, required int line}) { + if (binding is Name) { + if (lexical) { + for (final lexicalContext in _lexicalContexts.reversed) { + if (lexicalContext.has(binding.value)) { + lexicalContext.set(binding.value, value, line); + return; + } + } + final lexicalContext = _LexicalContext(); + lexicalContext.initialize(binding.value, value, mutable: mutable); + _lexicalContexts.add(lexicalContext); + } else { + addToThisContext(binding, value); + } + return; + } + if (binding is ObjectPattern) { + final excludedKeys = {}; + for (final property in binding.properties) { + if (property.isSpread) { + final rest = {}; + if (value is Map) { + for (final key in InvokableController.ownEnumerableKeys(value)) { + if (!excludedKeys.contains(key)) { + rest[key] = InvokableController.getProperty(value, key); + } + } + } + _bindPattern(property.value, rest, + lexical: lexical, mutable: mutable, line: line); + continue; + } + final key = _propertyKey(property); + excludedKeys.add(key); + final propertyValue = + value == null || !InvokableController.hasProperty(value, key) + ? jsUndefined + : InvokableController.getProperty(value, key); + _bindPattern(property.value, propertyValue, + lexical: lexical, mutable: mutable, line: line); + } + return; + } + if (binding is ArrayPattern) { + final values = value is List ? value : []; + for (int i = 0; i < binding.elements.length; i++) { + final element = binding.elements[i]; + if (element == null) continue; + if (element is RestParameter) { + _bindPattern(element.name, values.skip(i).toList(), + lexical: lexical, mutable: mutable, line: line); + break; + } + final elementValue = i < values.length ? values[i] : jsUndefined; + _bindPattern(element, elementValue, + lexical: lexical, mutable: mutable, line: line); + } + return; + } + if (binding is RestParameter) { + _bindPattern(binding.name, value, + lexical: lexical, mutable: mutable, line: line); + return; + } + if (binding is DefaultParameter) { + final actualValue = isJSUndefined(value) + ? getValueFromExpression(binding.defaultValue) + : value; + _bindPattern(binding.name, actualValue, + lexical: lexical, mutable: mutable, line: line); + return; + } + throw JSException(binding.line ?? line, 'Unsupported binding pattern.'); + } + + void _bindParameter(Map ctx, Node binding, dynamic value, + {required int line}) { + if (binding is Name) { + ctx[binding.value] = value; + return; + } + if (binding is ObjectPattern) { + final excludedKeys = {}; + for (final property in binding.properties) { + if (property.isSpread) { + final rest = {}; + if (value is Map) { + for (final key in InvokableController.ownEnumerableKeys(value)) { + if (!excludedKeys.contains(key)) { + rest[key] = InvokableController.getProperty(value, key); + } + } + } + _bindParameter(ctx, property.value, rest, line: line); + continue; + } + final key = _propertyKey(property); + excludedKeys.add(key); + final propertyValue = + value == null || !InvokableController.hasProperty(value, key) + ? jsUndefined + : InvokableController.getProperty(value, key); + _bindParameter(ctx, property.value, propertyValue, line: line); + } + return; + } + if (binding is ArrayPattern) { + final values = value is List ? value : []; + for (int i = 0; i < binding.elements.length; i++) { + final element = binding.elements[i]; + if (element == null) continue; + if (element is RestParameter) { + _bindParameter(ctx, element.name, values.skip(i).toList(), + line: line); + break; + } + _bindParameter( + ctx, element, i < values.length ? values[i] : jsUndefined, + line: line); + } + return; + } + if (binding is RestParameter) { + _bindParameter(ctx, binding.name, value, line: line); + return; + } + if (binding is DefaultParameter) { + final actualValue = isJSUndefined(value) || value == null + ? getValueFromExpression(binding.defaultValue) + : value; + _bindParameter(ctx, binding.name, actualValue, line: line); + return; + } + throw JSException(binding.line ?? line, 'Unsupported function parameter.'); + } + + void _assignPattern(Expression pattern, dynamic value, int line) { + if (pattern is NameExpression) { + addToContext(pattern.name, value); + return; + } + if (pattern is ArrayExpression) { + final values = value is List ? value : []; + for (int i = 0; i < pattern.expressions.length; i++) { + final element = pattern.expressions[i]; + if (element == null) continue; + if (element is SpreadExpression) { + _assignPattern(element.argument, values.skip(i).toList(), line); + break; + } + _assignPattern( + element, i < values.length ? values[i] : jsUndefined, line); + } + return; + } + if (pattern is ObjectExpression) { + for (final property in pattern.properties) { + if (property.isSpread) { + throw JSException(line, + 'Rest properties in destructuring assignment are not supported yet.'); + } + final key = _propertyKey(property); + final propertyValue = value == null + ? jsUndefined + : InvokableController.getProperty(value, key); + if (property.value is NameExpression) { + _assignPattern(property.value as NameExpression, propertyValue, line); + } else { + throw JSException(line, + 'Nested object destructuring assignment is not supported yet.'); + } + } + return; + } + throw JSException(line, 'Unsupported destructuring assignment target.'); + } + + void _hoistBinding(Node binding, dynamic value) { + for (final name in _bindingNames(binding)) { + final ctx = getContextForScope(name.scope!); + if (!ctx.hasContext(name.value)) { + addToThisContext(name, value); + } + } + } + // dynamic removeFromContext(Name node) { // Map m = getContextForScope(node.scope!); // return m.remove(node.value); @@ -306,6 +645,9 @@ class JSInterpreter extends RecursiveVisitor { } dynamic getValueFromString(String name) { + for (final _LexicalContext lexical in _lexicalContexts.reversed) { + if (lexical.has(name)) return lexical.get(name, 1); + } for (final Context ctx in _dynamicContexts.reversed) { if (ctx.hasContext(name)) return ctx.getContextById(name); } @@ -318,6 +660,9 @@ class JSInterpreter extends RecursiveVisitor { } bool _hasName(String name) { + for (final _LexicalContext lexical in _lexicalContexts.reversed) { + if (lexical.has(name)) return true; + } for (final Context ctx in _dynamicContexts.reversed) { if (ctx.hasContext(name)) return true; } @@ -328,6 +673,12 @@ class JSInterpreter extends RecursiveVisitor { } dynamic getValue(Name node) { + for (final _LexicalContext lexical in _lexicalContexts.reversed) { + if (lexical.has(node.value)) { + return lexical.get(node.value, node.line ?? 1); + } + } + // 1) Dynamic object scopes from `with` shadow lexical scopes. for (final Context ctx in _dynamicContexts.reversed) { if (ctx.hasContext(node.value)) { @@ -445,15 +796,7 @@ class JSInterpreter extends RecursiveVisitor { @override visitProperty(Property node) { - String key; - if (node.key is Name) { - key = (node.key as Name).value; - } else if (node.key is LiteralExpression) { - key = (node.key as LiteralExpression).value; - } else { - throw JSException(node.line ?? -1, - 'Property of object ${node.toString()} is not supported. Only Name or LiteralExpression are supported.'); - } + final key = _propertyKey(node); if (node.isGetter) { return { 'key': key, @@ -479,7 +822,10 @@ class JSInterpreter extends RecursiveVisitor { return { 'key': key, 'descriptor': JSPropertyDescriptor( - value: getValueFromNode(node.value), + value: node.value is FunctionNode + ? visitFunctionNode(node.value as FunctionNode, + inheritContext: true) + : getValueFromNode(node.value), writable: true, enumerable: true, configurable: true, @@ -487,10 +833,34 @@ class JSInterpreter extends RecursiveVisitor { }; } + dynamic _propertyKey(Property node) { + if (node.computed) { + return getValueFromNode(node.key); + } + if (node.key is Name) { + return (node.key as Name).value; + } + if (node.key is LiteralExpression) { + return (node.key as LiteralExpression).value; + } + throw JSException(node.line ?? -1, + 'Property of object ${node.toString()} is not supported.'); + } + @override visitObject(ObjectExpression node) { Map obj = {}; for (Property property in node.properties) { + if (property.isSpread) { + final source = getValueFromNode(property.value); + if (source is Map) { + for (final key in InvokableController.ownEnumerableKeys(source)) { + InvokableController.setProperty( + obj, key, InvokableController.getProperty(source, key)); + } + } + continue; + } Map prop = visitProperty(property); InvokableController.defineProperty( obj, prop['key'], prop['descriptor'] as JSPropertyDescriptor); @@ -519,7 +889,7 @@ class JSInterpreter extends RecursiveVisitor { visitUnary(UnaryExpression node) { // Handle 'delete' operator specially - it needs to work on property expressions if (node.operator == 'delete') { - ObjectPattern? pattern; + PropertyPattern? pattern; if (node.argument is MemberExpression) { pattern = visitMember(node.argument as MemberExpression, computeAsPattern: true); @@ -577,6 +947,7 @@ class JSInterpreter extends RecursiveVisitor { } String _jsTypeOf(dynamic val) { + if (isJSUndefined(val)) return 'undefined'; if (val == null) return 'object'; // In JavaScript, typeof null is 'object' if (val is num) return 'number'; if (val is String) return 'string'; @@ -587,7 +958,7 @@ class JSInterpreter extends RecursiveVisitor { } bool toBoolean(dynamic val) { - if (val == null || + if (_isNullish(val) || val == 0 || val == false || val == '' || @@ -601,7 +972,9 @@ class JSInterpreter extends RecursiveVisitor { } num toNumber(dynamic val) { - if (val == null) { + if (isJSUndefined(val)) { + return double.nan; + } else if (val == null) { return 0; } else if (val is num) { return val; @@ -618,6 +991,9 @@ class JSInterpreter extends RecursiveVisitor { int _toInt32(dynamic val) => toNumber(val).toInt() & 0xffffffff; bool _strictEquals(dynamic left, dynamic right) { + if (isJSUndefined(left) || isJSUndefined(right)) { + return isJSUndefined(left) && isJSUndefined(right); + } if (left == null || right == null) return left == null && right == null; if (left.runtimeType != right.runtimeType) return false; if (left is num && right is num && (left.isNaN || right.isNaN)) { @@ -627,6 +1003,7 @@ class JSInterpreter extends RecursiveVisitor { } bool _looseEquals(dynamic left, dynamic right) { + if (_isNullish(left) && _isNullish(right)) return true; if (left == null && right == null) return true; if (left is num && right is num && (left.isNaN || right.isNaN)) { return false; @@ -639,6 +1016,8 @@ class JSInterpreter extends RecursiveVisitor { return false; } + bool _isNullish(dynamic value) => value == null || isJSUndefined(value); + bool _hasProperty(dynamic obj, dynamic property) => InvokableController.hasProperty(obj, property); @@ -656,7 +1035,7 @@ class JSInterpreter extends RecursiveVisitor { @override visitUpdateExpression(UpdateExpression node) { dynamic val = getValueFromExpression(node.argument!); - ObjectPattern? pattern; + PropertyPattern? pattern; num number; num originalNumber; if (val is num) { @@ -731,6 +1110,8 @@ class JSInterpreter extends RecursiveVisitor { @override visitFunctionNode(FunctionNode node, {bool? inheritContext}) { final List args = computeArguments(node.params); + final capturedLexicalContexts = + List<_LexicalContext>.from(_lexicalContexts); late JavascriptFunction fn; fn = JavascriptFunction((List? _params, [dynamic thisArg]) { /* @@ -744,8 +1125,19 @@ class JSInterpreter extends RecursiveVisitor { if (node.params != null) { for (int i = 0; i < node.params.length; i++) { - // Use the value from params if available, otherwise use null - ctx[node.params[i].value] = i < params.length ? params[i] : null; + final param = node.params[i]; + if (param is RestParameter) { + _bindParameter(ctx, param.name, params.skip(i).toList(), + line: param.line ?? node.line ?? 1); + break; + } + dynamic value = i < params.length ? params[i] : null; + if (param is DefaultParameter && value == null) { + value = getValueFromExpression(param.defaultValue); + } + _bindParameter( + ctx, param is DefaultParameter ? param.name : param, value, + line: param.line ?? node.line ?? 1); } } ctx['arguments'] = _buildArgumentsObject(params, fn); @@ -753,6 +1145,12 @@ class JSInterpreter extends RecursiveVisitor { Context context = SimpleContext(ctx); // Inherit parent contexts so nested functions can see outer declarations. JSInterpreter i = cloneForContext(node, context, inheritContext ?? true); + i._lexicalContexts + ..clear() + ..addAll(capturedLexicalContexts); + if (node.isAsync) { + return JSPromise.fromFuture(i._executeAsyncBody(node.body)); + } dynamic rtn; try { if (node.body != null) { @@ -786,20 +1184,187 @@ class JSInterpreter extends RecursiveVisitor { return visitFunctionNode(node.function, inheritContext: true); } + Future _executeAsyncBody(Statement body) async { + try { + if (body is BlockStatement) { + dynamic rtn; + final lexicalContext = _LexicalContext(); + for (Statement stmt in body.body) { + if (stmt is VariableDeclaration && stmt.kind != 'var') { + for (final declarator in stmt.declarations) { + for (final name in _bindingNames(declarator.name)) { + lexicalContext.declare(name.value, + mutable: stmt.kind != 'const'); + } + } + } + } + _lexicalContexts.add(lexicalContext); + try { + for (final stmt in body.body) { + rtn = await _executeAsyncStatement(stmt); + } + return rtn; + } finally { + _lexicalContexts.removeLast(); + } + } + return await _executeAsyncStatement(body); + } on ControlFlowReturnException catch (e) { + return e.returnValue; + } + } + + Future _executeAsyncStatement(Statement stmt) async { + if (!_containsAwait(stmt)) { + return stmt.visitBy(this); + } + if (stmt is ReturnStatement) { + final value = stmt.argument == null + ? null + : await _getValueFromExpressionAsync(stmt.argument!); + throw ControlFlowReturnException(stmt.line ?? -1, '', value); + } + if (stmt is ExpressionStatement) { + return _getValueFromExpressionAsync(stmt.expression); + } + if (stmt is VariableDeclaration) { + final previousKind = _currentDeclarationKind; + _currentDeclarationKind = stmt.kind; + try { + for (final declarator in stmt.declarations) { + final value = declarator.init == null + ? null + : await _getValueFromExpressionAsync(declarator.init!); + _bindPattern(declarator.name, value, + lexical: stmt.kind != 'var', + mutable: stmt.kind != 'const', + line: declarator.line ?? stmt.line ?? 1); + } + } finally { + _currentDeclarationKind = previousKind; + } + return null; + } + if (stmt is IfStatement) { + final condition = await _getValueFromExpressionAsync(stmt.condition); + if (toBoolean(condition)) { + return _executeAsyncStatement(stmt.then); + } + if (stmt.otherwise != null) { + return _executeAsyncStatement(stmt.otherwise!); + } + return null; + } + if (stmt is ThrowStatement) { + final value = await _getValueFromExpressionAsync(stmt.argument); + if (value is JSCustomException) throw value; + throw JSCustomException(value); + } + throw JSException( + stmt.line ?? 1, 'await is not supported in this statement form yet.', + detailedError: 'Code: ${getCode(stmt)}'); + } + + Future _getValueFromExpressionAsync(Expression expression) async { + if (expression is AwaitExpression) { + final value = await _getValueFromExpressionAsync(expression.argument); + return _awaitJsValue(value); + } + if (!_containsAwait(expression)) { + return getValueFromExpression(expression); + } + if (expression is BinaryExpression) { + final left = await _getValueFromExpressionAsync(expression.left); + if (expression.operator == '&&' && !toBoolean(left)) return left; + if (expression.operator == '||' && toBoolean(left)) return left; + if (expression.operator == '??' && !_isNullish(left)) return left; + final right = await _getValueFromExpressionAsync(expression.right); + return BinaryExpression(LiteralExpression(left), expression.operator, + LiteralExpression(right)) + .visitBy(this); + } + if (expression is TemplateLiteral) { + final buffer = StringBuffer(); + for (int i = 0; i < expression.strings.length; i++) { + buffer.write(expression.strings[i]); + if (i < expression.expressions.length) { + final value = + await _getValueFromExpressionAsync(expression.expressions[i]); + buffer.write(value?.toString() ?? 'null'); + } + } + return buffer.toString(); + } + if (expression is ConditionalExpression) { + final condition = + await _getValueFromExpressionAsync(expression.condition); + return toBoolean(condition) + ? _getValueFromExpressionAsync(expression.then) + : _getValueFromExpressionAsync(expression.otherwise); + } + throw JSException(expression.line ?? 1, + 'await is not supported in this expression form yet.', + detailedError: 'Code: ${getCode(expression)}'); + } + + Future _awaitJsValue(dynamic value) { + if (value is JSPromise) return value.toFuture(); + if (value is Future) return value; + return Future.value(value); + } + + bool _containsAwait(Node node) { + var found = false; + void walk(Node child) { + if (found) return; + if (child is AwaitExpression) { + found = true; + return; + } + child.forEach(walk); + } + + walk(node); + return found; + } + + @override + visitAwait(AwaitExpression node) { + throw JSException( + node.line ?? 1, 'await can only be used inside async functions.'); + } + @override visitArrowFunctionNode(ArrowFunctionNode node) { final List args = computeArguments(node.params); + final capturedLexicalContexts = + List<_LexicalContext>.from(_lexicalContexts); return JavascriptFunction((List? _params, [dynamic thisArg]) { List params = _params ?? []; Map ctx = {}; if (node.params != null) { for (int i = 0; i < node.params.length; i++) { - // Use the value from params if available, otherwise use null - ctx[node.params[i].value] = i < params.length ? params[i] : null; + final param = node.params[i]; + if (param is RestParameter) { + _bindParameter(ctx, param.name, params.skip(i).toList(), + line: param.line ?? node.line ?? 1); + break; + } + dynamic value = i < params.length ? params[i] : null; + if (param is DefaultParameter && value == null) { + value = getValueFromExpression(param.defaultValue); + } + _bindParameter( + ctx, param is DefaultParameter ? param.name : param, value, + line: param.line ?? node.line ?? 1); } } Context context = SimpleContext(ctx); JSInterpreter i = cloneForContext(node, context, true); + i._lexicalContexts + ..clear() + ..addAll(capturedLexicalContexts); dynamic rtn; try { if (node.body != null) { @@ -818,35 +1383,78 @@ class JSInterpreter extends RecursiveVisitor { @override visitBlock(BlockStatement node) { dynamic rtn; - // Hoist function declarations in this block before executing statements. + final lexicalContext = _LexicalContext(); for (Statement stmt in node.body) { - if (stmt is FunctionDeclaration && stmt.function.name != null) { - addToThisContext(stmt.function.name!, visitFunctionNode(stmt.function)); + if (stmt is VariableDeclaration && stmt.kind != 'var') { + for (final declarator in stmt.declarations) { + for (final name in _bindingNames(declarator.name)) { + lexicalContext.declare(name.value, mutable: stmt.kind != 'const'); + } + } } } - for (Node stmt in node.body) { - rtn = stmt.visitBy(this); + _lexicalContexts.add(lexicalContext); + // Hoist function declarations in this block before executing statements. + try { + for (Statement stmt in node.body) { + if (stmt is FunctionDeclaration && stmt.function.name != null) { + addToThisContext( + stmt.function.name!, visitFunctionNode(stmt.function)); + } + } + for (Node stmt in node.body) { + rtn = stmt.visitBy(this); + } + return rtn; + } finally { + _lexicalContexts.removeLast(); + } + } + + @override + visitVariableDeclaration(VariableDeclaration node) { + final previousKind = _currentDeclarationKind; + _currentDeclarationKind = node.kind; + dynamic rtn; + try { + for (final declarator in node.declarations) { + rtn = declarator.visitBy(this); + } + } finally { + _currentDeclarationKind = previousKind; } return rtn; } @override visitVariableDeclarator(VariableDeclarator node) { - Name name = node.name; + Node binding = node.name; dynamic value; + if (_currentDeclarationKind != 'var') { + value = node.init != null ? getValueFromExpression(node.init!) : null; + _bindPattern(binding, value, + lexical: true, + mutable: _currentDeclarationKind != 'const', + line: node.line ?? 1); + return binding; + } if (node.init != null) { value = getValueFromExpression(node.init!); - addToThisContext(name, value); + _bindPattern(binding, value, + lexical: false, mutable: true, line: node.line ?? 1); } else { //hoisting - Context ctx = getContextForScope(name.scope!); - if (!ctx.hasContext(name.value)) { - addToThisContext(name, value); - } + _hoistBinding(binding, value); } - return name; + return binding; } + @override + visitObjectPattern(ObjectPattern node) => node; + + @override + visitArrayPattern(ArrayPattern node) => node; + @override visitBinary(BinaryExpression node) { try { @@ -855,7 +1463,7 @@ class JSInterpreter extends RecursiveVisitor { //https://github.com/EnsembleUI/ensemble/issues/574 if (node.operator == '||') { if (left != false && - left != null && + !_isNullish(left) && left != 0 && left != '' && !(left is num && left.isNaN)) { @@ -865,7 +1473,7 @@ class JSInterpreter extends RecursiveVisitor { } } else if (node.operator == '&&') { if (left == false || - left == null || + _isNullish(left) || left == 0 || left == '' || (left is num && left.isNaN)) { @@ -873,6 +1481,8 @@ class JSInterpreter extends RecursiveVisitor { } else { return getValueFromExpression(node.right); } + } else if (node.operator == '??') { + return _isNullish(left) ? getValueFromExpression(node.right) : left; } dynamic right = getValueFromExpression(node.right); dynamic rtn = false; @@ -1008,14 +1618,31 @@ class JSInterpreter extends RecursiveVisitor { visitFor(ForStatement node) { final String? loopLabel = _nextStatementLabel; _nextStatementLabel = null; + final List loopLetNames = node.init is VariableDeclaration && + (node.init as VariableDeclaration).kind == 'let' + ? (node.init as VariableDeclaration) + .declarations + .expand((declaration) => _bindingNames(declaration.name)) + .map((name) => name.value) + .toList() + : []; if (node.init != null) { node.init!.visitBy(this); } while (node.condition == null || toBoolean(getValueFromNode(node.condition!))) { + _LexicalContext? iterationContext; + if (loopLetNames.isNotEmpty) { + iterationContext = _snapshotLoopBindings(loopLetNames); + _lexicalContexts.add(iterationContext); + } try { node.body.visitBy(this); } on ControlFlowBreakException catch (e) { + if (iterationContext != null) { + _copyLoopBindings(iterationContext, loopLetNames); + _lexicalContexts.removeLast(); + } if (e.label == loopLabel) break; if (e.label != null) rethrow; break; @@ -1026,6 +1653,12 @@ class JSInterpreter extends RecursiveVisitor { rethrow; } //skip as we are executing the update anyway + } finally { + if (iterationContext != null && + identical(_lexicalContexts.last, iterationContext)) { + _copyLoopBindings(iterationContext, loopLetNames); + _lexicalContexts.removeLast(); + } } // Execute the update expression after each loop iteration // see https://github.com/EnsembleUI/ensemble/issues/1704 @@ -1035,6 +1668,39 @@ class JSInterpreter extends RecursiveVisitor { } } + _LexicalContext _snapshotLoopBindings(List names) { + final iteration = _LexicalContext(); + for (final name in names) { + for (final lexical in _lexicalContexts.reversed) { + if (lexical.has(name)) { + final binding = lexical.bindings[name]!; + iteration.bindings[name] = + _LexicalBinding.uninitialized(mutable: binding.mutable) + ..value = binding.value + ..initialized = binding.initialized; + break; + } + } + } + return iteration; + } + + void _copyLoopBindings(_LexicalContext iteration, List names) { + for (final name in names) { + if (!iteration.has(name)) continue; + for (final lexical in _lexicalContexts.reversed.skip(1)) { + if (lexical.has(name)) { + final binding = iteration.bindings[name]!; + lexical.bindings[name] = + _LexicalBinding.uninitialized(mutable: binding.mutable) + ..value = binding.value + ..initialized = binding.initialized; + break; + } + } + } + } + @override visitWhile(WhileStatement node) { final String? loopLabel = _nextStatementLabel; @@ -1103,6 +1769,111 @@ class JSInterpreter extends RecursiveVisitor { //removeFromContext(left); } + @override + visitForOf(ForOfStatement node) { + final String? loopLabel = _nextStatementLabel; + _nextStatementLabel = null; + final dynamic right = getValueFromNode(node.right); + final List values = _toForOfValues(right, node); + final _ForLoopBinding binding = _forLoopBinding(node.left, node); + + if (binding.kind == 'var') { + binding.declaration?.visitBy(this); + } + + for (final value in values) { + _LexicalContext? iterationContext; + if (binding.kind == 'assignment' && binding.binding is Expression) { + _assignPattern(binding.binding as Expression, value, node.line ?? 1); + } else if (binding.kind != 'var') { + iterationContext = _LexicalContext(); + for (final name in _bindingNames(binding.binding)) { + iterationContext.declare(name.value, + mutable: binding.kind != 'const'); + } + _lexicalContexts.add(iterationContext); + _bindPattern(binding.binding, value, + lexical: true, + mutable: binding.kind != 'const', + line: node.line ?? 1); + } else { + _bindPattern(binding.binding, value, + lexical: false, mutable: true, line: node.line ?? 1); + } + + try { + node.body.visitBy(this); + } on ControlFlowBreakException catch (e) { + if (iterationContext != null) { + _lexicalContexts.removeLast(); + } + if (e.label == loopLabel) break; + if (e.label != null) rethrow; + break; + } on ControlFlowContinueException catch (e) { + if (e.label == loopLabel) { + // Continue with the next iterable value. + } else if (e.label != null) { + rethrow; + } + } finally { + if (iterationContext != null && + _lexicalContexts.isNotEmpty && + identical(_lexicalContexts.last, iterationContext)) { + _lexicalContexts.removeLast(); + } + } + } + } + + List _toForOfValues(dynamic value, Node node) { + if (value is List) return List.from(value); + if (value is String) return value.split(''); + if (value is Invokable) { + final methods = InvokableController.methods(value); + final isMapLike = + methods.containsKey('get') && methods.containsKey('set'); + final values = methods['values']; + final entries = methods['entries']; + if (isMapLike && entries != null) { + final result = Function.apply(entries, const []); + if (result is List) return List.from(result); + } + if (!isMapLike && values != null) { + final result = Function.apply(values, const []); + if (result is List) return List.from(result); + } + if (entries != null) { + final result = Function.apply(entries, const []); + if (result is List) return List.from(result); + } + } + throw JSException(node.line ?? 1, + 'for...of requires a practical iterable value such as an array, string, Map, or Set.', + detailedError: 'Code: ${getCode(node)}'); + } + + _ForLoopBinding _forLoopBinding(Node left, Node node) { + if (left is VariableDeclaration) { + if (left.declarations.length != 1) { + throw JSException( + node.line ?? 1, 'for...of supports one loop variable.'); + } + return _ForLoopBinding(left.declarations.first.name, left.kind, left); + } + if (left is NameExpression) { + return _ForLoopBinding(left.name, 'var', null); + } + if (left is ArrayExpression || left is ObjectExpression) { + return _ForLoopBinding(left, 'assignment', null); + } + if (left is Name) { + return _ForLoopBinding(left, 'var', null); + } + throw JSException( + node.line ?? 1, 'left side in for...of must be a variable name.'); + } + @override visitSwitch(SwitchStatement node) { final dynamic argument = getValueFromExpression(node.argument); @@ -1173,11 +1944,73 @@ class JSInterpreter extends RecursiveVisitor { return node.value; } + @override + visitTemplateLiteral(TemplateLiteral node) { + final buffer = StringBuffer(); + for (int i = 0; i < node.strings.length; i++) { + buffer.write(node.strings[i]); + if (i < node.expressions.length) { + final value = getValueFromExpression(node.expressions[i]); + buffer.write(value?.toString() ?? 'null'); + } + } + return buffer.toString(); + } + + @override + visitTaggedTemplate(TaggedTemplateExpression node) { + dynamic tag; + dynamic thisArg = getContextForScope(program).getContextMap(); + + if (node.tag is MemberExpression) { + final pattern = + visitMember(node.tag as MemberExpression, computeAsPattern: true); + if (pattern == null) return jsUndefined; + thisArg = pattern.obj; + tag = InvokableController.methods(pattern.obj)[pattern.property]; + tag ??= InvokableController.getProperty(pattern.obj, pattern.property); + } else if (node.tag is IndexExpression) { + final pattern = + visitIndex(node.tag as IndexExpression, computeAsPattern: true); + if (pattern == null) return jsUndefined; + thisArg = pattern.obj; + tag = InvokableController.getProperty(pattern.obj, pattern.property); + } else { + tag = getValueFromExpression(node.tag); + } + + if (tag == null || identical(tag, jsUndefined)) { + throw JSException( + node.line ?? -1, 'Tagged template tag is not callable.'); + } + + final strings = List.from(node.template.strings); + final values = node.template.expressions + .map((expression) => getValueFromExpression(expression)) + .toList(); + return _callAnyFunction(tag, [strings, ...values], thisArg); + } + + @override + visitSpread(SpreadExpression node) { + return getValueFromExpression(node.argument); + } + @override visitArray(ArrayExpression node) { List arr = []; node.forEach((node) { - arr.add(node.visitBy(this)); + if (node is SpreadExpression) { + final spreadValue = getValueFromExpression(node.argument); + if (spreadValue is List) { + arr.addAll(spreadValue); + } else { + throw JSException(node.line ?? 1, + 'Spread in array literals requires a list value.'); + } + } else { + arr.add(node.visitBy(this)); + } }); return arr; } @@ -1192,6 +2025,15 @@ class JSInterpreter extends RecursiveVisitor { for (Node node in args) { if (resolveNames) { if (node is Expression) { + if (node is SpreadExpression) { + dynamic spreadValue = getValueFromExpression(node.argument); + if (spreadValue is List) { + l.addAll(spreadValue); + continue; + } + throw JSException(node.line ?? 1, + 'Spread in function calls requires a list value.'); + } dynamic v = getValueFromExpression(node); if (v is JavascriptFunction) { l.add(v._onCall); @@ -1220,10 +2062,14 @@ class JSInterpreter extends RecursiveVisitor { } if (method is Function) { //functions being called from js to dart - if (arguments.length == 0) { - return Function.apply(method, null); - } else { - return Function.apply(method, arguments); + try { + if (arguments.length == 0) { + return Function.apply(method, null); + } else { + return Function.apply(method, arguments); + } + } catch (_) { + return Function.apply(method, [arguments, thisArg]); } } else { if (arguments.length == 0) { @@ -1317,6 +2163,7 @@ class JSInterpreter extends RecursiveVisitor { } else { method = getValue((node.callee as NameExpression).name); if (method == null) { + if (node.optional) return jsUndefined; throw JSException( node.line ?? -1, 'No definition found for ${getCode(node)}.', recovery: 'Check your syntax and try again.'); @@ -1330,13 +2177,14 @@ class JSInterpreter extends RecursiveVisitor { thisArg: getContextForScope(program).getContextMap()); } else if (node.callee is MemberExpression || node.callee is IndexExpression) { - ObjectPattern? pattern; + PropertyPattern? pattern; dynamic method; MethodExecutor? methodExecutor; String? methodName; if (node.callee is MemberExpression) { pattern = visitMember(node.callee as MemberExpression, computeAsPattern: true); + if (pattern == null) return jsUndefined; if (pattern!.obj is MethodExecutor) { methodExecutor = pattern!.obj as MethodExecutor; methodName = pattern.property; @@ -1356,6 +2204,7 @@ class JSInterpreter extends RecursiveVisitor { } else if (node.callee is IndexExpression) { pattern = visitIndex(node.callee as IndexExpression, computeAsPattern: true); + if (pattern == null) return jsUndefined; if ((pattern!.obj is JavascriptFunction || pattern.obj is Function) && (pattern.property == 'call' || pattern.property == 'apply' || @@ -1368,6 +2217,7 @@ class JSInterpreter extends RecursiveVisitor { //old: method = pattern!.obj.getProperty(pattern.property); } if (method == null) { + if (node.optional) return jsUndefined; throw JSException( node.line ?? -1, "cannot compute statement=" + @@ -1446,7 +2296,7 @@ class JSInterpreter extends RecursiveVisitor { visitAssignment(AssignmentExpression node) { try { dynamic val = getValueFromExpression(node.right); - ObjectPattern? pattern; + PropertyPattern? pattern; if (node.left is MemberExpression) { pattern = visitMember(node.left as MemberExpression, computeAsPattern: true); @@ -1480,7 +2330,11 @@ class JSInterpreter extends RecursiveVisitor { value, val, node.operator!, node.line ?? -1, getCode(node)); } addToContext(n, value); + } else if (node.operator == '=' && + (node.left is ArrayExpression || node.left is ObjectExpression)) { + _assignPattern(node.left, val, node.line ?? 1); } + return val; } on JSException catch (e) { rethrow; } on InvalidPropertyException catch (e) { @@ -1559,6 +2413,13 @@ class JSInterpreter extends RecursiveVisitor { visitIndex(IndexExpression node, {bool computeAsPattern = false}) { dynamic val; try { + if (node.optional) { + final obj = getValueFromExpression(node.object); + if (_isNullish(obj)) return computeAsPattern ? null : jsUndefined; + final property = getValueFromExpression(node.property); + return _getObjectPropertyValue(obj, property, + computeAsPattern: computeAsPattern); + } val = visitObjectPropertyExpression( node.object, getValueFromExpression(node.property), computeAsPattern: computeAsPattern); @@ -1571,30 +2432,38 @@ class JSInterpreter extends RecursiveVisitor { visitObjectPropertyExpression(Expression object, dynamic property, {bool computeAsPattern = false}) { dynamic obj = getValueFromExpression(object); - dynamic val; - if (obj == null) { + if (_isNullish(obj)) { throw InvalidPropertyException( '${getCode(object)} is undefined. Check your syntax.'); } + return _getObjectPropertyValue(obj, property, + computeAsPattern: computeAsPattern); + } + + dynamic _getObjectPropertyValue(dynamic obj, dynamic property, + {bool computeAsPattern = false}) { if (obj is JavascriptFunction && property == 'prototype') { - return computeAsPattern ? ObjectPattern(obj, property) : obj.prototype; + return computeAsPattern ? PropertyPattern(obj, property) : obj.prototype; } if ((obj is JavascriptFunction || obj is Function) && (property == 'call' || property == 'apply' || property == 'bind')) { return computeAsPattern - ? ObjectPattern(obj, property) + ? PropertyPattern(obj, property) : _functionMethod(obj, property); } - if (computeAsPattern) { - val = ObjectPattern(obj, property); - } else { - val = InvokableController.getProperty(obj, property); - } - return val; + return computeAsPattern + ? PropertyPattern(obj, property) + : InvokableController.getProperty(obj, property); } @override visitMember(MemberExpression node, {bool computeAsPattern = false}) { + if (node.optional) { + final obj = getValueFromExpression(node.object); + if (_isNullish(obj)) return computeAsPattern ? null : jsUndefined; + return _getObjectPropertyValue(obj, node.property.value, + computeAsPattern: computeAsPattern); + } return visitObjectPropertyExpression(node.object, node.property.value, computeAsPattern: computeAsPattern); } @@ -1723,10 +2592,14 @@ class ControlFlowContinueException extends JSException { : super(line, message); } -class ObjectPattern { +/// Represents a key-value pattern resolved from a property access. +class PropertyPattern { + /// The target object of the property. Object obj; + /// The key/property name. dynamic property; - ObjectPattern(this.obj, this.property); + /// Creates a property pattern with the given object and property. + PropertyPattern(this.obj, this.property); } typedef OnCall = dynamic Function(List arguments, [dynamic thisArg]); diff --git a/packages/ensemble_ts_interpreter/pubspec.yaml b/packages/ensemble_ts_interpreter/pubspec.yaml index 2070b4b0a..24f95b7a0 100644 --- a/packages/ensemble_ts_interpreter/pubspec.yaml +++ b/packages/ensemble_ts_interpreter/pubspec.yaml @@ -1,6 +1,6 @@ name: ensemble_ts_interpreter description: A JavaScript (ES5) interpreter written entirely in Dart for Flutter applications. Execute JavaScript code inline within your Dart/Flutter app without external engines. -version: 1.2.0 +version: 1.3.0 homepage: https://ensembleui.com repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_ts_interpreter @@ -26,7 +26,7 @@ dependencies: json_path: ^0.7.2 duration: ^3.0.11 ensemble_date: ^1.6.1 - parsejs_null_safety: ^2.0.4 + parsejs_null_safety: ^2.1.0 source_span: ^1.9.1 intl: '>=0.17.0 <=0.20.2' diff --git a/packages/ensemble_ts_interpreter/test/es5_compatibility_test.dart b/packages/ensemble_ts_interpreter/test/es5_compatibility_test.dart index f95fd4e4a..fad111c02 100644 --- a/packages/ensemble_ts_interpreter/test/es5_compatibility_test.dart +++ b/packages/ensemble_ts_interpreter/test/es5_compatibility_test.dart @@ -857,18 +857,14 @@ void main() { skip: 'Direct eval scope semantics are not supported yet.', ); - test( - 'ES6 declarations remain outside the ES5 compatibility contract', - () { - final context = {}; - evalJs(''' - let x = 1; - const y = 2; - var result = x + y; - ''', context); - expect(context['result'], 3); - }, - skip: 'let/const/block scoping are part of the future ES6+ track.', - ); + test('ES6+ declarations work as supported conveniences', () { + final context = {}; + evalJs(''' + let x = 1; + const y = 2; + var result = x + y; + ''', context); + expect(context['result'], 3); + }); }); } diff --git a/packages/ensemble_ts_interpreter/test/es6_compatibility_test.dart b/packages/ensemble_ts_interpreter/test/es6_compatibility_test.dart new file mode 100644 index 000000000..87f6b5a49 --- /dev/null +++ b/packages/ensemble_ts_interpreter/test/es6_compatibility_test.dart @@ -0,0 +1,812 @@ +import 'package:ensemble_ts_interpreter/errors.dart'; +import 'package:ensemble_ts_interpreter/invokables/context.dart'; +import 'package:ensemble_ts_interpreter/parser/newjs_interpreter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:test/test.dart'; + +dynamic evalJs(String code, [Map? context]) { + final ctx = context ?? {}; + return JSInterpreter.fromCode(code, SimpleContext(ctx)).evaluate(); +} + +void main() { + group('ES6 compatibility', () { + test('let is block scoped and can shadow outer bindings', () { + final context = {}; + evalJs(''' + var outer = 1; + { + let outer = 2; + var inner = outer; + } + var result = outer + ':' + inner; + ''', context); + expect(context['result'], '1:2'); + }); + + test('let closures capture block bindings', () { + final context = {}; + evalJs(''' + var fn; + { + let value = 7; + fn = function() { return value; }; + } + var result = fn(); + ''', context); + expect(context['result'], 7); + }); + + test('let in for loop creates per-iteration closure bindings', () { + final context = {}; + evalJs(''' + var fns = []; + for (let i = 0; i < 3; i++) { + fns.push(function() { return i; }); + } + var result = fns[0]() + ',' + fns[1]() + ',' + fns[2](); + ''', context); + expect(context['result'], '0,1,2'); + }); + + test('read before lexical initialization throws a JSException', () { + expect( + () => evalJs(''' + { + var result = value; + let value = 1; + } + '''), + throwsA(isA()), + ); + }); + + test('const can hold mutable objects but cannot be reassigned', () { + final context = {}; + evalJs(''' + const obj = { count: 1 }; + obj.count = 2; + var result = obj.count; + ''', context); + expect(context['result'], 2); + + expect( + () => evalJs(''' + const locked = 1; + locked = 2; + '''), + throwsA(isA()), + ); + }); + + test('template literals interpolate expressions in order', () { + final context = {}; + evalJs(r''' + var count = 0; + function next() { + count++; + return count; + } + var result = `first ${next()} second ${next()}`; + ''', context); + expect(context['result'], 'first 1 second 2'); + }); + + test('template literals support multiline text and escaped backticks', () { + final context = {}; + evalJs(r''' + var result = `a +b \`c\``; + ''', context); + expect(context['result'], 'a\nb `c`'); + }); + + test('tagged templates call tag with strings and values', () { + final context = {}; + evalJs(r''' + function currency(strings, ...values) { + return strings.reduce((result, str, i) => { + const value = values[i] !== undefined + ? `$${values[i].toFixed(2)}` + : ""; + + return result + str + value; + }, ""); + } + + const price = 99.99; + var result = currency`Price: ${price}`; + ''', context); + expect(context['result'], r'Price: $99.99'); + }); + + test('array out-of-range reads return undefined', () { + final context = {}; + evalJs(r''' + var values = [1]; + var result = [ + values[1] === undefined, + values[1] == null, + typeof values[1] + ].join('|'); + ''', context); + expect(context['result'], 'true|true|undefined'); + }); + + test('default and rest parameters work for normal functions', () { + final context = {}; + evalJs(''' + function join(prefix = 'x', ...items) { + return prefix + ':' + items.join(','); + } + var result = join(null, 1, 2, 3); + ''', context); + expect(context['result'], 'x:1,2,3'); + }); + + test('rest parameters can be reduced with arrow callbacks', () { + final context = {}; + evalJs(''' + function sum(...numbers) { + return numbers.reduce((total, num) => total + num, 0); + } + var result = sum(1, 2, 3, 4); + ''', context); + expect(context['result'], 10); + }); + + test('default and rest parameters work for arrow functions', () { + final context = {}; + evalJs(''' + var fn = (prefix = 'x', ...items) => prefix + ':' + items.length; + var result = fn(null, 1, 2); + ''', context); + expect(context['result'], 'x:2'); + }); + + test('spread expands lists in array literals and calls', () { + final context = { + 'fromDart': [3, 4] + }; + evalJs(''' + function sum(a, b, c, d) { + return a + b + c + d; + } + var values = [1, 2, ...fromDart]; + var result = sum(...values); + ''', context); + expect(context['result'], 10); + }); + + test('for of iterates arrays, rest parameters, and strings', () { + final context = {}; + evalJs(''' + function sum(...numbers) { + let total = 0; + for (let n of numbers) { + total += n; + } + return total; + } + let letters = ''; + for (const ch of 'abc') { + letters += ch; + } + var result = sum(1, 2, 3) + ':' + letters; + ''', context); + expect(context['result'], '6:abc'); + }); + + test('for of lexical declarations capture per-iteration values', () { + final context = {}; + evalJs(''' + var fns = []; + for (const value of [1, 2, 3]) { + fns.push(function() { return value; }); + } + var result = fns[0]() + ',' + fns[1]() + ',' + fns[2](); + ''', context); + expect(context['result'], '1,2,3'); + }); + + test('for of iterates Set values and Map entries', () { + final context = {}; + evalJs(''' + var set = new Set([1, 2, 3]); + var total = 0; + for (let value of set) { + total += value; + } + + var map = new Map(); + map.set('a', 1); + map.set('b', 2); + var pairs = []; + for (let entry of map) { + pairs.push(entry.key + entry.value); + } + + var result = total + ':' + pairs.join(','); + ''', context); + expect(context['result'], '6:a1,b2'); + }); + + test('existing ES6 conveniences remain available', () { + final context = {}; + evalJs(''' + var doubled = [1, 2, 3].map((value) => value * 2).join(','); + var map = new Map(); + map.set('a', 1); + var set = new Set(); + set.add(2); + var result = doubled + ':' + map.get('a') + ':' + set.has(2); + ''', context); + expect(context['result'], '2,4,6:1:true'); + }); + + test('console formats Set and Map values readably', () { + final logs = []; + final oldDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logs.add(message); + }; + try { + evalJs(''' + const numbers = new Set([1, 2, 2, 3, 4]); + console.log("Numbers:", numbers); + + const labels = new Map(); + labels.set("one", 1); + console.log("Labels:", labels); + '''); + } finally { + debugPrint = oldDebugPrint; + } + + expect(logs, contains('Numbers: Set(4) {1, 2, 3, 4}')); + expect(logs, contains('Labels: Map(1) {"one" => 1}')); + }); + + test('nullish coalescing only falls back for nullish values', () { + final context = {}; + evalJs(''' + var fromNull = null ?? 'fallback'; + var fromMissing = missing ?? 'fallback'; + var fromFalse = false ?? true; + var fromZero = 0 ?? 99; + var fromEmpty = '' ?? 'fallback'; + var result = [fromNull, fromMissing, fromFalse, fromZero, fromEmpty].join('|'); + ''', context); + + expect(context['result'], 'fallback|fallback|false|0|'); + }); + + test('optional chaining short-circuits property and index access', () { + final context = {}; + evalJs(''' + var calls = 0; + function key() { + calls++; + return 'name'; + } + + var user = { profile: { name: 'Ada' } }; + var first = user?.profile?.name; + var second = user?.missing?.name ?? 'Guest'; + var third = null?.profile?.name ?? 'Guest'; + var skipped = null?.[key()]; + var called = user.profile?.[key()]; + var result = [first, second, third, skipped, called, calls].join('|'); + ''', context); + + expect(context['result'], 'Ada|Guest|Guest|undefined|Ada|1'); + }); + + test('optional chaining returns undefined instead of null', () { + final context = {}; + evalJs(''' + const person = { + address: { + city: 'London' + } + }; + + var missing = person.company?.name; + var result = [ + person.address?.city, + typeof missing, + missing == null, + missing === null, + missing ?? 'fallback', + !!missing + ].join('|'); + ''', context); + + expect(context['result'], 'London|undefined|true|false|fallback|false'); + }); + + test('console distinguishes optional-chain undefined from null', () { + final logs = []; + final oldDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logs.add(message); + }; + try { + evalJs(''' + const person = { + address: { + city: 'London' + } + }; + + console.log(person.address?.city); + console.log(person.company?.name); + console.log(null); + '''); + } finally { + debugPrint = oldDebugPrint; + } + + expect(logs, contains('London')); + expect(logs, contains('undefined')); + expect(logs, contains('null')); + }); + + test('optional chaining short-circuits calls', () { + final context = {}; + evalJs(''' + var calls = 0; + function next() { + calls++; + return 'called'; + } + var obj = { + run: function(value) { + return value + ':' + this.prefix; + }, + prefix: 'ok' + }; + + var missingFn = null; + var first = missingFn?.(next()) ?? 'skipped'; + var second = null?.run(next()) ?? 'skipped'; + var third = obj.run?.('yes'); + var result = [first, second, third, calls].join('|'); + ''', context); + + expect(context['result'], 'skipped|skipped|yes:ok|0'); + }); + + test('single-parameter arrow functions do not require parentheses', () { + final context = {}; + evalJs(''' + const square = num => num * num; + var result = square(4); + ''', context); + + expect(context['result'], 16); + }); + + test('object and array destructuring declarations bind values', () { + final context = {}; + evalJs(''' + const person = { + name: 'John', + age: 30, + address: { + city: 'London' + } + }; + const { name, age, address: { city } } = person; + const [first, , third, ...rest] = [1, 2, 3, 4, 5]; + var result = [name, age, city, first, third, rest.join(',')].join('|'); + ''', context); + + expect(context['result'], 'John|30|London|1|3|4,5'); + }); + + test('object shorthand, enhanced methods, and computed keys work', () { + final context = {}; + evalJs(''' + const email = 'john@example.com'; + const key = 'email'; + const user = { + email, + [key + 'Verified']: true + }; + const calculator = { + add(a, b) { + return a + b; + } + }; + var result = [ + user.email, + user.emailVerified, + calculator.add(2, 3) + ].join('|'); + ''', context); + + expect(context['result'], 'john@example.com|true|5'); + }); + + test('Promise then callbacks run with arrow functions', () async { + final logs = []; + final oldDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logs.add(message); + }; + try { + evalJs(''' + const promise = new Promise((resolve, reject) => { + resolve('Success'); + }); + + promise.then(result => console.log(result)); + '''); + await Future.delayed(Duration.zero); + } finally { + debugPrint = oldDebugPrint; + } + + expect(logs, contains('Success')); + }); + + test('Symbol values can be used as computed object keys', () { + final context = {}; + evalJs(''' + const id = Symbol('id'); + const user = { + [id]: 101 + }; + var result = user[id]; + ''', context); + + expect(context['result'], 101); + }); + + test('async functions await promises and return promises', () async { + final logs = []; + final oldDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logs.add(message); + }; + try { + evalJs(''' + async function loadMessage() { + const value = await Promise.resolve('Success'); + return `Result: \${value}`; + } + + loadMessage().then(result => console.log(result)); + '''); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + } finally { + debugPrint = oldDebugPrint; + } + + expect(logs, contains('Result: Success')); + }); + + test('async arrows await plain values and resolved promises', () async { + final context = {}; + evalJs(''' + const doubleLater = async value => { + const base = await value; + const extra = await Promise.resolve(2); + return base * extra; + }; + + doubleLater(4).then(result => { + asyncResult = result; + }); + ''', context); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(context['asyncResult'], 8); + }); + + test('async functions reject when awaited promises reject', () async { + final context = {}; + evalJs(''' + async function failLater() { + return await Promise.reject('Nope'); + } + + failLater().catch(error => { + asyncError = error; + }); + ''', context); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(context['asyncError'], 'Nope'); + }); + + test('promise callbacks can use console methods directly', () async { + final logs = []; + final oldDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logs.add(message); + }; + try { + evalJs(''' + const fetchData = async () => { + return 'Data Loaded'; + }; + + fetchData().then(console.log); + '''); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + } finally { + debugPrint = oldDebugPrint; + } + + expect(logs, contains('Data Loaded')); + }); + + test('destructuring parameters and assignment work for practical patterns', + () { + final context = {}; + evalJs(r''' + const displayUser = ({ name, age }) => `${name}:${age}`; + let a = 10; + let b = 20; + [a, b] = [b, a]; + var result = [displayUser({ name: 'Sara', age: 22 }), a, b].join('|'); + ''', context); + + expect(context['result'], 'Sara:22|20|10'); + }); + + test('object spread copies enumerable own properties', () { + final context = {}; + evalJs(''' + const person = { + name: 'Ali', + age: 20 + }; + const updatedPerson = { + ...person, + city: 'Lahore' + }; + var result = [ + updatedPerson.name, + updatedPerson.age, + updatedPerson.city + ].join('|'); + ''', context); + + expect(context['result'], 'Ali|20|Lahore'); + }); + + test('Array from of and constructor helpers work', () { + final context = {}; + evalJs(''' + const letters = Array.from('Hi').join(','); + const numbers = Array.of(10, 20, 30).join(','); + const filled = new Array(3).fill(0).join(','); + var result = [letters, numbers, filled].join('|'); + ''', context); + + expect(context['result'], 'H,i|10,20,30|0,0,0'); + }); + + test('Promise all and allSettled resolve practical results', () async { + final context = {}; + evalJs(''' + const p1 = Promise.resolve(1); + const p2 = Promise.resolve(2); + Promise.all([p1, p2]).then(values => { + allResult = values.join(','); + }); + + Promise.allSettled([ + Promise.resolve('Success'), + Promise.reject('Error') + ]).then(results => { + settledResult = results.map(item => item.status).join(','); + settledReason = results[1].reason; + }); + ''', context); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(context['allResult'], '1,2'); + expect(context['settledResult'], 'fulfilled,rejected'); + expect(context['settledReason'], 'Error'); + }); + + test('Array.from supports collections and array-like objects', () { + final context = {}; + evalJs(''' + const setValues = Array.from(new Set([1, 2, 2, 3])).join(','); + const map = new Map([['a', 1], ['b', 2]]); + const mapValues = Array.from(map).map(entry => entry.key + entry.value).join(','); + const arrayLike = Array.from({ 0: 'x', 1: 'y', length: 2 }).join(''); + const mapped = Array.from({ '0': 2, '1': 3, length: 2 }, value => value * 2).join(','); + var result = [setValues, mapValues, arrayLike, mapped].join('|'); + ''', context); + + expect(context['result'], '1,2,3|a1,b2|xy|4,6'); + }); + + test('Object modern helpers use practical own property semantics', () { + final context = {}; + evalJs(''' + const source = { visible: 1 }; + Object.defineProperty(source, 'hidden', { + value: 2, + enumerable: false + }); + const roundTrip = Object.fromEntries(Object.entries(source)); + const names = Object.getOwnPropertyNames(source).sort().join(','); + var result = [ + roundTrip.visible, + Object.hasOwn(source, 'visible'), + Object.hasOwn(source, 'missing'), + Object.keys(source).join(','), + names + ].join('|'); + ''', context); + + expect(context['result'], '1|true|false|visible|hidden,visible'); + }); + + test('Promise race any and finally cover common paths', () async { + final context = {}; + evalJs(''' + Promise.race([ + Promise.resolve('first'), + Promise.resolve('second') + ]).then(value => { + raceResult = value; + }); + + Promise.any([ + Promise.reject('bad'), + Promise.resolve('good') + ]).then(value => { + anyResult = value; + }); + + Promise.resolve('done') + .finally(() => { + finallyFulfilled = 'cleanup'; + return Promise.resolve('ignored'); + }) + .then(value => { + finallyValue = value; + }); + + Promise.reject('nope') + .finally(() => { + finallyRejected = 'cleanup'; + }) + .catch(reason => { + finallyReason = reason; + }); + ''', context); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(context['raceResult'], 'first'); + expect(context['anyResult'], 'good'); + expect(context['finallyFulfilled'], 'cleanup'); + expect(context['finallyValue'], 'done'); + expect(context['finallyRejected'], 'cleanup'); + expect(context['finallyReason'], 'nope'); + }); + + test('for of supports destructuring declarations and assignment targets', + () { + final context = {}; + evalJs(''' + const obj = { a: 1, b: 2 }; + const pairs = []; + for (const [key, value] of Object.entries(obj)) { + pairs.push(key + value); + } + + const ids = []; + for (const { id } of [{ id: 3 }, { id: 4 }]) { + ids.push(id); + } + + let assignedKey = ''; + let assignedValue = 0; + for ([assignedKey, assignedValue] of [['z', 9]]) {} + + var result = [ + pairs.join(','), + ids.join(','), + assignedKey + assignedValue + ].join('|'); + ''', context); + + expect(context['result'], 'a1,b2|3,4|z9'); + }); + + test( + 'destructuring defaults and object rest work in declarations and params', + () { + final context = {}; + evalJs(''' + const user = { id: 7, city: 'Paris' }; + const { name = 'Guest', id, ...rest } = user; + const [first = 'x', second = 'y'] = [undefined, 'b']; + + function label({ title = 'Untitled', ...details }) { + return title + ':' + details.count; + } + + var result = [ + name, + id, + rest.city, + first, + second, + label({ count: 3 }) + ].join('|'); + ''', context); + + expect(context['result'], 'Guest|7|Paris|x|b|Untitled:3'); + }); + + test('existing string collection and array helpers are covered', () { + final context = {}; + evalJs(''' + const text = ' hi'; + const strings = [ + text.trimStart(), + 'x'.padStart(3, '0'), + 'x'.padEnd(3, '0') + ].join(','); + + const set = new Set(['a', 'b']); + const map = new Map([['k', 5]]); + let collection = [ + set.keys().join(''), + set.values().join(''), + set.entries().map(entry => entry[0] + entry[1]).join(''), + map.keys().join(''), + map.values().join('') + ].join('|'); + + const nums = [1, 2, 3, 2]; + var result = [ + strings, + collection, + nums.findLast(value => value < 3), + nums.findLastIndex(value => value < 3) + ].join('|'); + ''', context); + + expect(context['result'], 'hi,00x,x00|ab|ab|aabb|k|5|2|3'); + }); + + test('unsupported syntax parse errors use stable guidance', () { + try { + evalJs(''' + class Person {} + '''); + fail('Expected parser to reject classes.'); + } on JSException catch (e) { + expect(e.message, contains('JavaScript parse error')); + expect(e.recovery, contains('ES5 is the compatibility baseline')); + expect(e.detailedError, contains('class Person {}')); + expect(e.detailedError, contains('^')); + expect(e.detailedError, + isNot(contains('ES5 is the compatibility baseline'))); + } + }); + + test('classes remain outside ES6', () {}, skip: 'Deferred.'); + test('modules remain outside ES6', () {}, skip: 'Deferred.'); + test('generators remain outside ES6', () {}, skip: 'Deferred.'); + }); +} diff --git a/packages/ensemble_ts_interpreter/test/new_interpreter_tests.dart b/packages/ensemble_ts_interpreter/test/new_interpreter_tests.dart index 3149cbc1b..9de72b2c2 100644 --- a/packages/ensemble_ts_interpreter/test/new_interpreter_tests.dart +++ b/packages/ensemble_ts_interpreter/test/new_interpreter_tests.dart @@ -2551,7 +2551,7 @@ function createRandomizedTiles() { JSInterpreter.fromCode(codeToEvaluate, SimpleContext(context)).evaluate(); expect(context['area'], 200); }); - test('arrow function with one parm, no parentheses and automatic return', + test('arrow function with one parm, parenthesized and automatic return', () async { String codeToEvaluate = """ var numbers = [1, 2, 3, 4, 5]; @@ -3099,7 +3099,7 @@ function createRandomizedTiles() { """; Map context = initContext(); JSInterpreter.fromCode(codeToEvaluate, SimpleContext(context)).evaluate(); - expect(context['caught'], null); + expect(isJSUndefined(context['caught']), isTrue); }); test('throwing null', () { diff --git a/packages/parsejs_null_safety/CHANGELOG.md b/packages/parsejs_null_safety/CHANGELOG.md index 743d31f99..afea3cc57 100644 --- a/packages/parsejs_null_safety/CHANGELOG.md +++ b/packages/parsejs_null_safety/CHANGELOG.md @@ -1,3 +1,13 @@ +## 2.1.0 (2026-07-01) +- Added parser support for ES6 syntax used by Ensemble: `let`, `const`, `for...of`, untagged template literals, default parameters, rest parameters, and spread in arrays/calls. +- Added parser support for optional chaining (`?.`) and nullish coalescing (`??`). +- Added parser and AST support for single-parameter arrow functions, object/array binding patterns, object shorthand properties, enhanced object methods, and computed property names. +- Added parser and AST support for practical `async` functions, async arrows, and `await` expressions. +- Added parser support for destructuring parameters, destructuring assignment targets, and object spread. +- Added parser and AST support for tagged template expressions. +- Added parser support for destructuring defaults, object rest binding properties, and destructuring in `for...of` declarations. +- Added AST nodes and visitor hooks for declaration kinds, templates, spread, default parameters, and rest parameters. + ## 2.0.4 (2025-08-13) - Fixes for Dart 3.5+ compatibility diff --git a/packages/parsejs_null_safety/doc/parser_design.md b/packages/parsejs_null_safety/doc/parser_design.md new file mode 100644 index 000000000..65cf9a2a2 --- /dev/null +++ b/packages/parsejs_null_safety/doc/parser_design.md @@ -0,0 +1,143 @@ +# JavaScript Parser Architecture & Design + +`parsejs_null_safety` is a complete JavaScript parser written in Dart. It parses JavaScript source code (supporting ES5 syntax plus selected ES6 features) and builds a strongly typed Abstract Syntax Tree (AST) conforming to the ESTree specification. + +--- + +## 1. High-Level Architecture + +The parser translates raw source text into a structured, resolved AST through three distinct stages: + +```mermaid +graph TD + Source["JavaScript Source Code (String)"] --> Lexer["Lexer (lexer.dart)"] + Lexer -->|Token stream| Parser["Parser (parser.dart)"] + Parser -->|Raw AST| Annotator["Annotator (annotations.dart)"] + Annotator -->|Scope & parent links| AnnotatedAST["Resolved AST (ast.dart)"] +``` + +1. **Lexical Analysis (`lexer.dart`)**: Scans code units character-by-character, discarding whitespace and comments, and emitting a stream of `Token` instances. +2. **Syntactic Analysis (`parser.dart`)**: A recursive descent parser that consumes tokens, applies grammar rules, and constructs `Node` instances representing language constructs. +3. **AST Annotation (`annotations.dart`)**: Performs a post-parse traversal over the raw AST to build scope environments, link variable references to their scopes, and wire `parent` pointers. + +--- + +## 2. Lexical Scanning & Tokenization (`lexer.dart`) + +The Lexer tokenizes the source text efficiently without regex, scanning character codes directly. + +### A. Core Token Classification +Each `Token` has a `type` (corresponding to character codes or token constants) and metadata (like precedence, line number, and character offsets). +* **Keywords & Identifiers**: Decoded via `scanIdentifier`. Keywords (`function`, `var`, `let`, `const`, `if`, `for`, `async`, `await`, etc.) are resolved from a static lookup table. +* **Numeric Literals**: Parsed by `scanNumber` supporting decimal, hexadecimal (prefixed with `0x`), octal, and scientific notation exponents. +* **Regular Expressions**: When the parser expects an expression, it instructs the lexer to scan a RegExp literal using `scanRegexpBody` (handling escaped forward slashes and flags). +* **Template Literals**: `scanTemplateLiteral` parses backtick-wrapped text, tracking brace depth `{}` to capture nested interpolations correctly. + +### B. Comment & Noise Handling +* **Standard Comments**: Single-line (`//`) and multi-line (`/* */`) comments are skipped. +* **HTML comments / Hashbangs**: Tolerates noise at the start of scripts (such as `#!` shell flags or HTML comment tags like ``) if `handleNoise` is enabled. + +--- + +## 3. Syntactic Parsing (`parser.dart`) + +The parser is structured as a recursive descent engine. It is organized into hierarchical parsing layers, from statements down to expressions: + +```mermaid +flowchart TD + Program[parseProgram] --> Statement[parseStatement] + Statement --> Block[parseBlock] + Statement --> Var[parseVariableDeclarationStatement] + Statement --> Exp[parseExpressionStatement] + + Exp --> Assignment[parseAssignment] + Assignment --> Conditional[parseConditional] + Conditional --> Binary[parseBinary] + Binary --> Unary[parseUnary] + Unary --> Primary[parsePrimary] + + Primary --> Identifier[parseName] + Primary --> Destructure[parseBindingTarget] + Primary --> Literal[parseLiteral] +``` + +### A. Statement Grammar (`parseStatement`) +Processes block statements, variables, loops (`for`, `for...in`, `for...of`), conditional branch paths (`if`/`else`), exception blocks (`try`/`catch`/`finally`), switches, labels, returns, throws, and function declarations. + +### B. Expressions Grammar (`parseExpression`) +Uses operator precedence parsing to build expression nodes: +* **Optional Chaining (`?.`)**: Handles optional properties, optional bracket access, and optional function calls. If an optional chain is encountered, it sets the `optional` flag to `true` on the resulting AST node. +* **Nullish Coalescing (`??`)**: Parsed at the logical operator level with logical OR precedence (`Precedence.NULLISH_COALESCING`). +* **Spread and Destructuring**: + * `parseBindingTarget()` parses destructuring patterns (both `ObjectPattern` and `ArrayPattern`) during declarations. + * `parseParameter()` parses default values (`DefaultParameter`) and rest parameters (`RestParameter`). + * `parsePrimary()` parses spread nodes (`SpreadExpression`) in array literals and calls. + +--- + +## 4. AST Structure & Node Hierarchy (`ast.dart`) + +Every syntax construct in JavaScript maps to a class inheriting from the abstract class `Node`. + +```mermaid +classDiagram + class Node { + +int start + +int end + +int line + +Node parent + +forEach(callback) + +visitBy(visitor) + } + class Statement { + } + class Expression { + } + class Declaration { + } + class Pattern { + } + Node <|-- Statement + Node <|-- Expression + Node <|-- Declaration + Node <|-- Pattern + + Statement <|-- BlockStatement + Statement <|-- ForOfStatement + Statement <|-- IfStatement + Expression <|-- CallExpression + Expression <|-- TemplateLiteral + Declaration <|-- FunctionDeclaration + Declaration <|-- VariableDeclaration + Pattern <|-- ObjectPattern + Pattern <|-- ArrayPattern +``` + +* **Position Tracking**: Nodes store source code offsets (`start`, `end`) and the line number (`line`) for accurate runtime stack traces and debug diagnostics. +* **Property Nodes**: Represents object properties, handling shorthand properties (`{ x }`), computed keys (`{ [key]: value }`), and getter/setter accessors. + +--- + +## 5. Visitor Pattern (`ast_visitor.dart`) + +To inspect or transform the AST without casting, `parsejs_null_safety` uses the Visitor Pattern: + +### A. Visitor Interfaces +* `Visitor`: A visitor that takes a single AST node and returns `T`. +* `Visitor1`: A visitor that takes an AST node and an additional argument of type `A`, returning `T`. + +### B. Visitor Implementations +* `BaseVisitor` / `BaseVisitor1`: Basic implementations that route all nodes through a central `defaultNode(node)` fallback method. Implementing a custom compiler or analyzer only requires overriding the nodes of interest. +* `RecursiveVisitor`: Automatically traverses child nodes recursively. + +--- + +## 6. Scope Resolution & Semantic Binding (`annotations.dart`) + +Calling `annotateAST(Program)` resolves names and scopes before evaluation: + +1. **Parent Linking**: Builds the tree's upward links by setting `Node.parent` on all child nodes. +2. **Environment Extraction (`EnvironmentBuilder`)**: Traverses the AST to build local scopes (`Scope`). It maps binding declarations (`var`, `let`, `const`, function parameters, catch exception variables) to their corresponding lexical scopes: + * `let` and `const` bindings are bound to the enclosing block scope. + * `var` and `function` declarations are hoisted and bound to the enclosing function or program scope. +3. **Reference Resolving (`Resolver`)**: Links every variable reference (`Name`) to the `Scope` that defines it, resolving closures and outer scopes statically. diff --git a/packages/parsejs_null_safety/lib/src/annotations.dart b/packages/parsejs_null_safety/lib/src/annotations.dart index 2b5623700..4318f750f 100644 --- a/packages/parsejs_null_safety/lib/src/annotations.dart +++ b/packages/parsejs_null_safety/lib/src/annotations.dart @@ -39,11 +39,24 @@ class EnvironmentBuilder extends RecursiveVisitor { if (node.isExpression && node.name != null) { addVar(node.name!); } - node.params.forEach(addVar); + node.params.forEach(addParam); visit(node.body); currentScope = oldScope; } + void addParam(Node node) { + if (node is Name) { + addVar(node); + } else if (node is DefaultParameter) { + bindingNames(node.name).forEach(addVar); + visit(node.defaultValue); + } else if (node is RestParameter) { + bindingNames(node.name).forEach(addVar); + } else { + bindingNames(node).forEach(addVar); + } + } + visitFunctionDeclaration(FunctionDeclaration node) { addVar(node.function.name!); visit(node.function); @@ -54,7 +67,7 @@ class EnvironmentBuilder extends RecursiveVisitor { } visitVariableDeclarator(VariableDeclarator node) { - addVar(node.name); + bindingNames(node.name).forEach(addVar); node.forEach(visit); } @@ -65,6 +78,28 @@ class EnvironmentBuilder extends RecursiveVisitor { } } +List bindingNames(Node binding) { + if (binding is Name) return [binding]; + if (binding is ObjectPattern) { + return binding.properties.expand((property) { + final value = property.value; + if (value is Name) return [value]; + return bindingNames(value); + }).toList(); + } + if (binding is ArrayPattern) { + return binding.elements + .whereType() + .expand((element) => element is RestParameter + ? bindingNames(element.name) + : bindingNames(element)) + .toList(); + } + if (binding is DefaultParameter) return bindingNames(binding.name); + if (binding is RestParameter) return bindingNames(binding.name); + return []; +} + /// Initializes the [Name.scope] link on all [Name] nodes. class Resolver extends RecursiveVisitor { void resolve(Program ast) { diff --git a/packages/parsejs_null_safety/lib/src/ast.dart b/packages/parsejs_null_safety/lib/src/ast.dart index 02f8674de..4afd44aa5 100644 --- a/packages/parsejs_null_safety/lib/src/ast.dart +++ b/packages/parsejs_null_safety/lib/src/ast.dart @@ -103,10 +103,11 @@ class Program extends Scope { /// A function, which may occur as a function expression, function declaration, or property accessor in an object literal. class FunctionNode extends Scope { Name? name; - List params; + List params; Statement body; + bool isAsync; - FunctionNode(this.name, this.params, this.body); + FunctionNode(this.name, this.params, this.body, {this.isAsync = false}); bool get isExpression => parent is FunctionExpression; bool get isDeclaration => parent is FunctionDeclaration; @@ -125,10 +126,11 @@ class FunctionNode extends Scope { } class ArrowFunctionNode extends Scope { - final List params; + final List params; final Statement body; + final bool isAsync; - ArrowFunctionNode(this.params, this.body); + ArrowFunctionNode(this.params, this.body, {this.isAsync = false}); bool get isExpression => true; // Arrow functions are always expressions bool get isDeclaration => false; // Arrow functions are not declarations @@ -161,7 +163,13 @@ class Name extends Node { bool get isVariable => parent is NameExpression || parent is FunctionNode || + parent is DefaultParameter || + parent is RestParameter || parent is VariableDeclarator || + (parent is Property && + parent!.parent is ObjectPattern && + (parent as Property).value == this) || + parent is ArrayPattern || parent is CatchClause; /// True if this refers to a property name. @@ -502,6 +510,27 @@ class ForInStatement extends Statement { visitBy1(Visitor1 v, A arg) => v.visitForIn(this, arg); } +/// Statement of form: `for ([left] of [right]) [body]` +class ForOfStatement extends Statement { + /// May be VariableDeclaration or Expression. + Node left; + Expression right; + Statement body; + + ForOfStatement(this.left, this.right, this.body); + + forEach(callback) { + callback(left); + callback(right); + callback(body); + } + + String toString() => 'ForOfStatement'; + + visitBy(Visitor v) => v.visitForOf(this); + visitBy1(Visitor1 v, A arg) => v.visitForOf(this, arg); +} + /// Statement of form: `function [function.name])([function.params]) { [function.body] }`. class FunctionDeclaration extends Statement { FunctionNode function; @@ -519,9 +548,10 @@ class FunctionDeclaration extends Statement { /// Statement of form: `var [declarations];` class VariableDeclaration extends Statement { + String kind; List declarations; - VariableDeclaration(this.declarations); + VariableDeclaration(this.declarations, [this.kind = 'var']); forEach(callback) => declarations.forEach(callback); @@ -532,9 +562,78 @@ class VariableDeclaration extends Statement { v.visitVariableDeclaration(this, arg); } +/// Represents a default parameter value in a function declaration or destructuring pattern (e.g. `x = 1`). +class DefaultParameter extends Node { + /// The parameter name or binding pattern. + Node name; + /// The default expression evaluated when the parameter is undefined. + Expression defaultValue; + + DefaultParameter(this.name, this.defaultValue); + + forEach(callback) { + callback(name); + callback(defaultValue); + } + + String toString() => 'DefaultParameter'; + + visitBy(Visitor v) => v.visitDefaultParameter(this); + visitBy1(Visitor1 v, A arg) => v.visitDefaultParameter(this, arg); +} + +/// Represents a rest parameter or rest property (e.g. `...rest`). +class RestParameter extends Node { + /// The binding target name or pattern for the rest parameters. + Node name; + + RestParameter(this.name); + + forEach(callback) => callback(name); + + String toString() => 'RestParameter'; + + visitBy(Visitor v) => v.visitRestParameter(this); + visitBy1(Visitor1 v, A arg) => v.visitRestParameter(this, arg); +} + +/// Represents an object destructuring pattern (e.g. `{ name, age }`). +class ObjectPattern extends Node { + /// The list of properties being destructured. + List properties; + + ObjectPattern(this.properties); + + forEach(callback) => properties.forEach(callback); + + String toString() => 'ObjectPattern'; + + visitBy(Visitor v) => v.visitObjectPattern(this); + visitBy1(Visitor1 v, A arg) => v.visitObjectPattern(this, arg); +} + +/// Represents an array destructuring pattern (e.g. `[first, , third]`). +class ArrayPattern extends Node { + /// The elements in the array pattern, where null represents a hole/skipped element. + List elements; + + ArrayPattern(this.elements); + + forEach(callback) { + for (Node? element in elements) { + if (element != null) callback(element); + } + } + + String toString() => 'ArrayPattern'; + + visitBy(Visitor v) => v.visitArrayPattern(this); + visitBy1(Visitor1 v, A arg) => v.visitArrayPattern(this, arg); +} + /// Variable declaration: `[name]` or `[name] = [init]`. class VariableDeclarator extends Node { - Name name; + Node name; Expression? init; // May be null. VariableDeclarator(this.name, this.init); @@ -597,6 +696,58 @@ class ArrayExpression extends Expression { visitBy1(Visitor1 v, A arg) => v.visitArray(this, arg); } +/// Represents a spread expression (e.g. `...items` in an array literal or function call). +class SpreadExpression extends Expression { + /// The expression being spread. + Expression argument; + + SpreadExpression(this.argument); + + forEach(callback) => callback(argument); + + String toString() => 'SpreadExpression'; + + visitBy(Visitor v) => v.visitSpread(this); + visitBy1(Visitor1 v, A arg) => v.visitSpread(this, arg); +} + +/// Represents a template literal (e.g. ``value ${x}``). +class TemplateLiteral extends Expression { + /// The raw static text parts of the template literal. + List strings; + /// The dynamic expressions evaluated between string segments. + List expressions; + + TemplateLiteral(this.strings, this.expressions); + + forEach(callback) => expressions.forEach(callback); + + String toString() => 'TemplateLiteral'; + + visitBy(Visitor v) => v.visitTemplateLiteral(this); + visitBy1(Visitor1 v, A arg) => v.visitTemplateLiteral(this, arg); +} + +/// Represents a tagged template expression (e.g. `html`template``). +class TaggedTemplateExpression extends Expression { + /// The tag function expression invoked with the template literal. + Expression tag; + /// The template literal being tagged. + TemplateLiteral template; + + TaggedTemplateExpression(this.tag, this.template); + + forEach(callback) { + callback(tag); + callback(template); + } + + String toString() => 'TaggedTemplateExpression'; + + visitBy(Visitor v) => v.visitTaggedTemplate(this); + visitBy1(Visitor1 v, A arg) => v.visitTaggedTemplate(this, arg); +} + /// Expression of form: `{ [properties] }` class ObjectExpression extends Expression { List properties; @@ -624,18 +775,26 @@ class Property extends Node { /// May be "init", "get", or "set". String kind; - Property(this.key, this.value, [this.kind = 'init']); + bool computed; + bool method; + + Property(this.key, this.value, + [this.kind = 'init', this.computed = false, this.method = false]); // Property.getter(this.key, FunctionExpression this.value) : kind = 'get'; // Property.setter(this.key, FunctionExpression this.value) : kind = 'set'; bool get isInit => kind == 'init'; bool get isGetter => kind == 'get'; bool get isSetter => kind == 'set'; + bool get isSpread => kind == 'spread'; bool get isAccessor => isGetter || isSetter; - String? get nameString => key is Name - ? (key as Name).value - : (key as LiteralExpression).value.toString(); + String? get nameString { + if (key is Name) return (key as Name).value; + if (key is LiteralExpression) + return (key as LiteralExpression).value.toString(); + return null; + } /// Returns the value as a FunctionNode. Useful for getters/setters. FunctionNode get function => value as FunctionNode; @@ -699,6 +858,21 @@ class UnaryExpression extends Expression { visitBy1(Visitor1 v, A arg) => v.visitUnary(this, arg); } +/// Represents an await expression (e.g. `await promise`). +class AwaitExpression extends Expression { + /// The promise/value expression being awaited. + Expression argument; + + AwaitExpression(this.argument); + + forEach(callback) => callback(argument); + + String toString() => 'AwaitExpression'; + + visitBy(Visitor v) => v.visitAwait(this); + visitBy1(Visitor1 v, A arg) => v.visitAwait(this, arg); +} + /// Expression of form: `[left] + [right]`, or using any of the binary operators: /// `==, !=, ===, !==, <, <=, >, >=, <<, >>, >>>, +, -, *, /, %, |, ^, &, &&, ||, in, instanceof` class BinaryExpression extends Expression { @@ -783,11 +957,15 @@ class ConditionalExpression extends Expression { /// Expression of form: `[callee](..[arguments]..)` or `new [callee](..[arguments]..)`. class CallExpression extends Expression { bool isNew; + bool optional; Expression callee; List arguments; - CallExpression(this.callee, this.arguments, {this.isNew = false}); - CallExpression.newCall(this.callee, this.arguments) : isNew = true; + CallExpression(this.callee, this.arguments, + {this.isNew = false, this.optional = false}); + CallExpression.newCall(this.callee, this.arguments) + : isNew = true, + optional = false; forEach(callback) { callback(callee); @@ -804,8 +982,9 @@ class CallExpression extends Expression { class MemberExpression extends Expression { Expression object; Name property; + bool optional; - MemberExpression(this.object, this.property); + MemberExpression(this.object, this.property, {this.optional = false}); forEach(callback) { callback(object); @@ -822,8 +1001,9 @@ class MemberExpression extends Expression { class IndexExpression extends Expression { Expression object; Expression property; + bool optional; - IndexExpression(this.object, this.property); + IndexExpression(this.object, this.property, {this.optional = false}); forEach(callback) { callback(object); diff --git a/packages/parsejs_null_safety/lib/src/ast_visitor.dart b/packages/parsejs_null_safety/lib/src/ast_visitor.dart index 2f2227ce0..3ff5333cb 100644 --- a/packages/parsejs_null_safety/lib/src/ast_visitor.dart +++ b/packages/parsejs_null_safety/lib/src/ast_visitor.dart @@ -30,19 +30,28 @@ abstract class Visitor { T visitDoWhile(DoWhileStatement node); T visitFor(ForStatement node); T visitForIn(ForInStatement node); + T visitForOf(ForOfStatement node); T visitFunctionDeclaration(FunctionDeclaration node); T visitArrowFunctionNode(ArrowFunctionNode node); T visitVariableDeclaration(VariableDeclaration node); T visitVariableDeclarator(VariableDeclarator node); + T visitDefaultParameter(DefaultParameter node); + T visitRestParameter(RestParameter node); + T visitObjectPattern(ObjectPattern node); + T visitArrayPattern(ArrayPattern node); T visitDebugger(DebuggerStatement node); T visitThis(ThisExpression node); T visitArray(ArrayExpression node); + T visitSpread(SpreadExpression node); + T visitTemplateLiteral(TemplateLiteral node); + T visitTaggedTemplate(TaggedTemplateExpression node); T visitObject(ObjectExpression node); T visitProperty(Property node); T visitFunctionExpression(FunctionExpression node); T visitSequence(SequenceExpression node); T visitUnary(UnaryExpression node); + T visitAwait(AwaitExpression node); T visitBinary(BinaryExpression node); T visitAssignment(AssignmentExpression node); T visitUpdateExpression(UpdateExpression node); @@ -87,19 +96,28 @@ class BaseVisitor implements Visitor { T? visitDoWhile(DoWhileStatement node) => defaultNode(node); T? visitFor(ForStatement node) => defaultNode(node); T? visitForIn(ForInStatement node) => defaultNode(node); + T? visitForOf(ForOfStatement node) => defaultNode(node); T? visitFunctionDeclaration(FunctionDeclaration node) => defaultNode(node); T? visitVariableDeclaration(VariableDeclaration node) => defaultNode(node); T? visitVariableDeclarator(VariableDeclarator node) => defaultNode(node); + T? visitDefaultParameter(DefaultParameter node) => defaultNode(node); + T? visitRestParameter(RestParameter node) => defaultNode(node); + T? visitObjectPattern(ObjectPattern node) => defaultNode(node); + T? visitArrayPattern(ArrayPattern node) => defaultNode(node); T? visitDebugger(DebuggerStatement node) => defaultNode(node); T? visitThis(ThisExpression node) => defaultNode(node); T? visitArray(ArrayExpression node) => defaultNode(node); + T? visitSpread(SpreadExpression node) => defaultNode(node); + T? visitTemplateLiteral(TemplateLiteral node) => defaultNode(node); + T? visitTaggedTemplate(TaggedTemplateExpression node) => defaultNode(node); T? visitObject(ObjectExpression node) => defaultNode(node); T? visitProperty(Property node) => defaultNode(node); T? visitFunctionExpression(FunctionExpression node) => defaultNode(node); T? visitArrowFunctionNode(ArrowFunctionNode node) => defaultNode(node); T? visitSequence(SequenceExpression node) => defaultNode(node); T? visitUnary(UnaryExpression node) => defaultNode(node); + T? visitAwait(AwaitExpression node) => defaultNode(node); T? visitBinary(BinaryExpression node) => defaultNode(node); T? visitAssignment(AssignmentExpression node) => defaultNode(node); T? visitUpdateExpression(UpdateExpression node) => defaultNode(node); @@ -168,19 +186,28 @@ abstract class Visitor1 { T visitDoWhile(DoWhileStatement node, A arg); T visitFor(ForStatement node, A arg); T visitForIn(ForInStatement node, A arg); + T visitForOf(ForOfStatement node, A arg); T visitFunctionDeclaration(FunctionDeclaration node, A arg); T visitArrowFunctionNode(ArrowFunctionNode node, A arg); T visitVariableDeclaration(VariableDeclaration node, A arg); T visitVariableDeclarator(VariableDeclarator node, A arg); + T visitDefaultParameter(DefaultParameter node, A arg); + T visitRestParameter(RestParameter node, A arg); + T visitObjectPattern(ObjectPattern node, A arg); + T visitArrayPattern(ArrayPattern node, A arg); T visitDebugger(DebuggerStatement node, A arg); T visitThis(ThisExpression node, A arg); T visitArray(ArrayExpression node, A arg); + T visitSpread(SpreadExpression node, A arg); + T visitTemplateLiteral(TemplateLiteral node, A arg); + T visitTaggedTemplate(TaggedTemplateExpression node, A arg); T visitObject(ObjectExpression node, A arg); T visitProperty(Property node, A arg); T visitFunctionExpression(FunctionExpression node, A arg); T visitSequence(SequenceExpression node, A arg); T visitUnary(UnaryExpression node, A arg); + T visitAwait(AwaitExpression node, A arg); T visitBinary(BinaryExpression node, A arg); T visitAssignment(AssignmentExpression node, A arg); T visitUpdateExpression(UpdateExpression node, A arg); @@ -228,6 +255,7 @@ class BaseVisitor1 implements Visitor1 { T visitDoWhile(DoWhileStatement node, A arg) => defaultNode(node, arg); T visitFor(ForStatement node, A arg) => defaultNode(node, arg); T visitForIn(ForInStatement node, A arg) => defaultNode(node, arg); + T visitForOf(ForOfStatement node, A arg) => defaultNode(node, arg); T visitFunctionDeclaration(FunctionDeclaration node, A arg) => defaultNode(node, arg); T visitArrowFunctionNode(ArrowFunctionNode node, A arg) => @@ -236,16 +264,26 @@ class BaseVisitor1 implements Visitor1 { defaultNode(node, arg); T visitVariableDeclarator(VariableDeclarator node, A arg) => defaultNode(node, arg); + T visitDefaultParameter(DefaultParameter node, A arg) => + defaultNode(node, arg); + T visitRestParameter(RestParameter node, A arg) => defaultNode(node, arg); + T visitObjectPattern(ObjectPattern node, A arg) => defaultNode(node, arg); + T visitArrayPattern(ArrayPattern node, A arg) => defaultNode(node, arg); T visitDebugger(DebuggerStatement node, A arg) => defaultNode(node, arg); T visitThis(ThisExpression node, A arg) => defaultNode(node, arg); T visitArray(ArrayExpression node, A arg) => defaultNode(node, arg); + T visitSpread(SpreadExpression node, A arg) => defaultNode(node, arg); + T visitTemplateLiteral(TemplateLiteral node, A arg) => defaultNode(node, arg); + T visitTaggedTemplate(TaggedTemplateExpression node, A arg) => + defaultNode(node, arg); T visitObject(ObjectExpression node, A arg) => defaultNode(node, arg); T visitProperty(Property node, A arg) => defaultNode(node, arg); T visitFunctionExpression(FunctionExpression node, A arg) => defaultNode(node, arg); T visitSequence(SequenceExpression node, A arg) => defaultNode(node, arg); T visitUnary(UnaryExpression node, A arg) => defaultNode(node, arg); + T visitAwait(AwaitExpression node, A arg) => defaultNode(node, arg); T visitBinary(BinaryExpression node, A arg) => defaultNode(node, arg); T visitAssignment(AssignmentExpression node, A arg) => defaultNode(node, arg); T visitUpdateExpression(UpdateExpression node, A arg) => diff --git a/packages/parsejs_null_safety/lib/src/lexer.dart b/packages/parsejs_null_safety/lib/src/lexer.dart index be8220caa..3c98fdcf3 100644 --- a/packages/parsejs_null_safety/lib/src/lexer.dart +++ b/packages/parsejs_null_safety/lib/src/lexer.dart @@ -53,6 +53,9 @@ class Token { static const int STRING = 7; static const int REGEXP = 8; static const int ARROW = 9; + static const int ELLIPSIS = 10; + static const int TEMPLATE = 11; + static const int OPTIONAL_CHAIN = 12; // Tokens without a text have type equal to their corresponding char code // All these are >31 @@ -89,6 +92,12 @@ class Token { return 'unary operator'; case STRING: return 'string literal'; + case ELLIPSIS: + return '...'; + case TEMPLATE: + return 'template literal'; + case OPTIONAL_CHAIN: + return '?.'; default: return '[type $type]'; } @@ -99,6 +108,7 @@ class Precedence { static const int EXPRESSION = 0; static const int CONDITIONAL = 1; static const int LOGICAL_OR = 2; + static const int NULLISH_COALESCING = 2; static const int LOGICAL_AND = 3; static const int BITWISE_OR = 4; static const int BITWISE_XOR = 5; @@ -176,7 +186,7 @@ bool isEOL(int x) { class Lexer { Lexer(String text, - {this.filename, this.currentLine=1, this.index= 0, this.endOfFile}) { + {this.filename, this.currentLine = 1, this.index = 0, this.endOfFile}) { input = text.codeUnits; if (endOfFile == null) { endOfFile = input.length; @@ -566,6 +576,14 @@ class Lexer { case char.DOT: x = next(); + if (x == char.DOT) { + x = next(); + if (x == char.DOT) { + ++index; + return emitToken(Token.ELLIPSIS, '...'); + } + fail("Unexpected '..'"); + } if (isDigit(x)) { return scanDecimalPart(x); } @@ -575,6 +593,9 @@ class Lexer { case char.DQUOTE: return scanStringLiteral(x); + case char.BACKTICK: + return scanTemplateLiteral(x); + case char.LPAREN: case char.RPAREN: case char.LBRACKET: @@ -584,10 +605,23 @@ class Lexer { case char.COMMA: case char.COLON: case char.SEMICOLON: - case char.QUESTION: ++index; return emitToken(x); + case char.QUESTION: + x = index + 1 == endOfFile ? char.NULL : input[index + 1]; + if (x == char.DOT) { + index += 2; + return emitToken(Token.OPTIONAL_CHAIN, '?.'); + } + if (x == char.QUESTION) { + index += 2; + return emitToken(Token.BINARY, '??') + ..binaryPrecedence = Precedence.NULLISH_COALESCING; + } + ++index; + return emitToken(Token.QUESTION); + case char.BACKSLASH: return scanComplexName(x); @@ -743,4 +777,51 @@ class Lexer { String value = new String.fromCharCodes(buffer); return emitValueToken(Token.STRING)..value = value; } + + Token scanTemplateLiteral(int x) { + List buffer = []; + int braceDepth = 0; + x = next(); + while (true) { + if (x == char.NULL) { + fail("Unterminated template literal"); + } + if (x == char.BACKSLASH) { + buffer.add(x); + x = next(); + if (x == char.NULL) fail("Unterminated template literal"); + buffer.add(x); + if (x == char.CR || x == char.LF || x == char.LS || x == char.PS) { + ++currentLine; + } + x = next(); + continue; + } + if (x == char.DOLLAR) { + buffer.add(x); + x = next(); + if (x == char.LBRACE) { + braceDepth++; + } + buffer.add(x); + x = next(); + continue; + } + if (x == char.LBRACE && braceDepth > 0) { + braceDepth++; + } else if (x == char.RBRACE && braceDepth > 0) { + braceDepth--; + } else if (x == char.BACKTICK && braceDepth == 0) { + ++index; + Token tok = emitValueToken(Token.TEMPLATE); + tok.value = new String.fromCharCodes( + input.getRange(tokenStart! + 1, index - 1)); + return tok; + } else if (x == char.CR || x == char.LF || x == char.LS || x == char.PS) { + ++currentLine; + } + buffer.add(x); + x = next(); + } + } } diff --git a/packages/parsejs_null_safety/lib/src/parser.dart b/packages/parsejs_null_safety/lib/src/parser.dart index ab65457db..a632d2a13 100644 --- a/packages/parsejs_null_safety/lib/src/parser.dart +++ b/packages/parsejs_null_safety/lib/src/parser.dart @@ -85,6 +85,28 @@ class Parser { } } + Token peekToken([int distance = 1]) { + Token? savedToken = token; + int? savedEndOffset = endOffset; + int savedIndex = lexer.index; + int savedCurrentLine = lexer.currentLine; + int? savedTokenStart = lexer.tokenStart; + int? savedTokenLine = lexer.tokenLine; + bool? savedSeenLinebreak = lexer.seenLinebreak; + Token peeked = token!; + for (int i = 0; i < distance; i++) { + peeked = lexer.scan(); + } + token = savedToken; + endOffset = savedEndOffset; + lexer.index = savedIndex; + lexer.currentLine = savedCurrentLine; + lexer.tokenStart = savedTokenStart; + lexer.tokenLine = savedTokenLine; + lexer.seenLinebreak = savedSeenLinebreak; + return peeked; + } + Name makeName(Token tok) => new Name(tok.value!) ..start = tok.startOffset ..end = tok.endOffset @@ -94,19 +116,43 @@ class Parser { ///// FUNCTIONS ////// - List parseParameters() { + List parseParameters() { consume(Token.LPAREN); - List list = []; + List list = []; while (token!.type != Token.RPAREN) { if (list.isNotEmpty) { consume(Token.COMMA); } - list.add(parseName()); + list.add(parseParameter()); + if (list.last is RestParameter && token!.type != Token.RPAREN) { + fail(message: 'Rest parameter must be last'); + } } consume(Token.RPAREN); return list; } + Node parseParameter() { + if (token!.type == Token.ELLIPSIS) { + Token tok = next(); + Node name = parseBindingTarget(); + return new RestParameter(name) + ..start = tok.startOffset + ..end = endOffset + ..line = tok.line; + } + Node name = parseBindingTarget(); + if (token!.type == Token.ASSIGN && token!.text == '=') { + next(); + Expression defaultValue = parseAssignment(); + return new DefaultParameter(name, defaultValue) + ..start = name.start + ..end = endOffset + ..line = name.line; + } + return name; + } + BlockStatement parseFunctionBody() { return parseBlock(); } @@ -115,35 +161,25 @@ class Parser { if (token!.type == Token.LBRACE) { return parseBlock(); } - Expression exp = parseExpression(); + Expression exp = parseExpression(allowComma: false); if (token!.type == Token.SEMICOLON) { consumeSemicolon(); } return new BlockStatement([new ReturnStatement(exp)]); } - FunctionNode parseArrowFunction(Expression? exp) { + FunctionNode parseArrowFunction(Expression? exp, {bool isAsync = false}) { int? start = token!.startOffset; - // List params; - // if (token!.type == Token.LPAREN) { - // params = parseParameters(); - // } else { - // params = []; - // } - List params = []; + List params = []; if (exp != null) { if (exp is SequenceExpression) { for (var e in exp.expressions) { - if (e is NameExpression) { - params.add(e.name); - } else { - throw fail(); - } + params.add(_expressionToParameter(e)); } } else if (exp is NameExpression) { params.add(exp.name); } else { - throw fail(); + params.add(_expressionToParameter(exp)); } } consume(Token.ARROW); @@ -151,23 +187,144 @@ class Parser { //Expression body = parseExpression(); //return new FunctionNode(null, params, new BlockStatement([new ReturnStatement(body)])) BlockStatement body = parseArrowFunctionBody(); - return new FunctionNode(null, params, body) + return new FunctionNode(null, params, body, isAsync: isAsync) + ..start = start + ..end = endOffset + ..line = token!.line; + } + + FunctionNode parseArrowFunctionFromParams(List params, int? start, + {bool isAsync = false}) { + consume(Token.ARROW); + next(); + BlockStatement body = parseArrowFunctionBody(); + return new FunctionNode(null, params, body, isAsync: isAsync) ..start = start ..end = endOffset ..line = token!.line; } - FunctionNode parseFunction() { + Node _expressionToParameter(Expression exp) { + if (exp is NameExpression) return exp.name; + if (exp is AssignmentExpression && + exp.operator == '=' && + exp.left is NameExpression) { + return new DefaultParameter((exp.left as NameExpression).name, exp.right) + ..start = exp.start + ..end = exp.end + ..line = exp.line; + } + throw fail(message: 'Invalid arrow function parameter'); + } + + Node parseBindingTarget() { + if (token!.type == Token.LBRACE) return parseObjectBindingPattern(); + if (token!.type == Token.LBRACKET) return parseArrayBindingPattern(); + return parseName(); + } + + ObjectPattern parseObjectBindingPattern() { + int? start = token!.startOffset; + Token open = requireNext(Token.LBRACE); + List properties = []; + while (token!.type != Token.RBRACE) { + if (properties.isNotEmpty) consume(Token.COMMA); + if (token!.type == Token.RBRACE) break; + if (token!.type == Token.ELLIPSIS) { + Token rest = next(); + Node value = parseBindingTarget(); + properties.add(new Property(new LiteralExpression('__rest__'), + new RestParameter(value), 'spread') + ..start = rest.startOffset + ..end = endOffset + ..line = rest.line); + if (token!.type != Token.RBRACE) { + fail(message: 'Rest property must be last'); + } + break; + } + Token keyTok = next(); + Node key = makePropertyName(keyTok); + Node value; + if (token!.type == Token.COLON) { + next(); + value = parseBindingTarget(); + } else if (key is Name) { + value = new Name(key.value) + ..start = key.start + ..end = key.end + ..line = key.line; + } else { + throw fail(tok: keyTok, message: 'Invalid destructuring binding'); + } + if (token!.type == Token.ASSIGN && token!.text == '=') { + next(); + Expression defaultValue = parseAssignment(); + value = new DefaultParameter(value, defaultValue) + ..start = value.start + ..end = endOffset + ..line = value.line; + } + properties.add(new Property(key, value) + ..start = key.start + ..end = endOffset + ..line = key.line); + } + consume(Token.RBRACE); + return new ObjectPattern(properties) + ..start = start + ..end = endOffset + ..line = open.line; + } + + ArrayPattern parseArrayBindingPattern() { int? start = token!.startOffset; + Token open = requireNext(Token.LBRACKET); + List elements = []; + while (token!.type != Token.RBRACKET) { + if (token!.type == Token.COMMA) { + next(); + elements.add(null); + continue; + } + if (token!.type == Token.ELLIPSIS) { + Token rest = next(); + elements.add(new RestParameter(parseBindingTarget()) + ..start = rest.startOffset + ..end = endOffset + ..line = rest.line); + } else { + Node element = parseBindingTarget(); + if (token!.type == Token.ASSIGN && token!.text == '=') { + next(); + Expression defaultValue = parseAssignment(); + element = new DefaultParameter(element, defaultValue) + ..start = element.start + ..end = endOffset + ..line = element.line; + } + elements.add(element); + } + if (token!.type != Token.RBRACKET) consume(Token.COMMA); + } + consume(Token.RBRACKET); + return new ArrayPattern(elements) + ..start = start + ..end = endOffset + ..line = open.line; + } + + FunctionNode parseFunction({bool isAsync = false, int? asyncStart}) { + int? start = asyncStart ?? token!.startOffset; assert(token!.text == 'function'); Token funToken = next(); Name? name; if (token!.type == Token.NAME) { name = parseName(); } - List params = parseParameters(); + List params = parseParameters(); BlockStatement body = parseFunctionBody(); - return new FunctionNode(name, params, body) + return new FunctionNode(name, params, body, isAsync: isAsync) ..start = start ..end = endOffset ..line = funToken.line; @@ -180,6 +337,29 @@ class Parser { switch (token!.type) { case Token.NAME: switch (token!.text) { + case 'async': + if (peekToken().type == Token.NAME && + peekToken().text == 'function') { + Token asyncTok = next(); + return new FunctionExpression(parseFunction( + isAsync: true, asyncStart: asyncTok.startOffset)); + } + if (peekToken().type == Token.LPAREN && _asyncParenIsArrow()) { + Token asyncTok = next(); + List params = parseParameters(); + return new FunctionExpression(parseArrowFunctionFromParams( + params, asyncTok.startOffset, + isAsync: true)); + } + if (peekToken().type == Token.NAME && + peekToken(2).type == Token.ARROW) { + Token asyncTok = next(); + Name param = parseName(); + return new FunctionExpression(parseArrowFunctionFromParams( + [param], asyncTok.startOffset, + isAsync: true)); + } + break; case 'this': Token tok = next(); return new ThisExpression() @@ -227,6 +407,9 @@ class Parser { ..end = endOffset ..line = tok.line; + case Token.TEMPLATE: + return parseTemplateLiteral(); + case Token.LBRACKET: return parseArrayLiteral(); @@ -234,6 +417,12 @@ class Parser { return parseObjectLiteral(); case Token.LPAREN: + if (_parenIsArrowParameters()) { + int? arrowStart = token!.startOffset; + List params = parseParameters(); + return new FunctionExpression( + parseArrowFunctionFromParams(params, arrowStart)); + } next(); Expression? exp; if (token!.type != Token.RPAREN) { @@ -264,6 +453,127 @@ class Parser { } } + bool _parenIsArrowParameters() { + int depth = 0; + for (int i = token!.startOffset!; i < lexer.endOfFile!; i++) { + int ch = lexer.input[i]; + if (ch == 40) depth++; + if (ch == 41) { + depth--; + if (depth == 0) { + int j = i + 1; + while (j < lexer.endOfFile!) { + int ws = lexer.input[j]; + if (ws == 32 || ws == 9 || ws == 10 || ws == 13) { + j++; + continue; + } + return ws == 61 && + j + 1 < lexer.endOfFile! && + lexer.input[j + 1] == 62; + } + return false; + } + } + } + return false; + } + + bool _asyncParenIsArrow() { + int depth = 0; + for (int i = peekToken().startOffset!; i < lexer.endOfFile!; i++) { + int ch = lexer.input[i]; + if (ch == 40) depth++; + if (ch == 41) { + depth--; + if (depth == 0) { + int j = i + 1; + while (j < lexer.endOfFile!) { + int ws = lexer.input[j]; + if (ws == 32 || ws == 9 || ws == 10 || ws == 13) { + j++; + continue; + } + return ws == 61 && + j + 1 < lexer.endOfFile! && + lexer.input[j + 1] == 62; + } + return false; + } + } + } + return false; + } + + Expression parseTemplateLiteral() { + int? start = token!.startOffset; + Token tok = next(); + List strings = []; + List expressions = []; + String raw = tok.value ?? ''; + StringBuffer current = new StringBuffer(); + int i = 0; + while (i < raw.length) { + String ch = raw[i]; + if (ch == '\\' && i + 1 < raw.length) { + current.write(_templateEscape(raw[i + 1])); + i += 2; + continue; + } + if (ch == r'$' && i + 1 < raw.length && raw[i + 1] == '{') { + strings.add(current.toString()); + current = new StringBuffer(); + i += 2; + int expressionStart = i; + int depth = 1; + while (i < raw.length && depth > 0) { + if (raw[i] == '\\') { + i += 2; + continue; + } + if (raw[i] == '{') depth++; + if (raw[i] == '}') depth--; + if (depth > 0) i++; + } + if (depth != 0) + fail(tok: tok, message: 'Unterminated template expression'); + String expressionSource = raw.substring(expressionStart, i); + Parser expressionParser = new Parser(new Lexer(expressionSource)); + expressions.add(expressionParser.parseExpression()); + i++; + continue; + } + current.write(ch); + i++; + } + strings.add(current.toString()); + return new TemplateLiteral(strings, expressions) + ..start = start + ..end = endOffset + ..line = tok.line; + } + + String _templateEscape(String ch) { + switch (ch) { + case 'n': + return '\n'; + case 'r': + return '\r'; + case 't': + return '\t'; + case 'b': + return '\b'; + case 'f': + return '\f'; + case '`': + return '`'; + case '\\': + return '\\'; + default: + return ch; + } + } + Expression parseArrayLiteral() { int? start = token!.startOffset; Token open = requireNext(Token.LBRACKET); @@ -273,7 +583,15 @@ class Parser { next(); expressions.add(null); } else { - expressions.add(parseAssignment()); + if (token!.type == Token.ELLIPSIS) { + Token spread = next(); + expressions.add(new SpreadExpression(parseAssignment()) + ..start = spread.startOffset + ..end = endOffset + ..line = spread.line); + } else { + expressions.add(parseAssignment()); + } if (token!.type != Token.RBRACKET) { consume(Token.COMMA); } @@ -315,6 +633,39 @@ class Parser { Property parseProperty() { int? start = token!.startOffset; + if (token!.type == Token.ELLIPSIS) { + Token spread = next(); + Expression value = parseAssignment(); + return new Property( + new LiteralExpression('__spread__', '__spread__'), value, 'spread') + ..start = start + ..end = endOffset + ..line = spread.line; + } + if (token!.type == Token.LBRACKET) { + Token open = next(); + Expression key = parseExpression(); + consume(Token.RBRACKET); + if (token!.type == Token.LPAREN) { + int? lparen = token!.startOffset; + List params = parseParameters(); + BlockStatement body = parseFunctionBody(); + Node value = new FunctionNode(null, params, body) + ..start = lparen + ..end = endOffset + ..line = open.line; + return new Property(key, value, 'init', true, true) + ..start = start + ..end = endOffset + ..line = open.line; + } + consume(Token.COLON); + Expression value = parseAssignment(); + return new Property(key, value, 'init', true) + ..start = start + ..end = endOffset + ..line = open.line; + } Token nameTok = next(); if (token!.type == Token.COLON) { int? line = token!.line; @@ -326,6 +677,20 @@ class Parser { ..end = endOffset ..line = line; } + if (nameTok.type == Token.NAME && token!.type == Token.LPAREN) { + Node name = makePropertyName(nameTok); + int? lparen = token!.startOffset; + List params = parseParameters(); + BlockStatement body = parseFunctionBody(); + Node value = new FunctionNode(null, params, body) + ..start = lparen + ..end = endOffset + ..line = name.line; + return new Property(name, value, 'init', false, true) + ..start = start + ..end = endOffset + ..line = name.line; + } if (nameTok.type == Token.NAME && (nameTok.text == 'get' || nameTok.text == 'set')) { Token kindTok = nameTok; @@ -334,7 +699,7 @@ class Parser { nameTok = next(); Node name = makePropertyName(nameTok); int? lparen = token!.startOffset; - List params = parseParameters(); + List params = parseParameters(); BlockStatement body = parseFunctionBody(); Node value = new FunctionNode(null, params, body) ..start = lparen @@ -345,6 +710,14 @@ class Parser { ..end = endOffset ..line = kindTok.line; } + if (nameTok.type == Token.NAME) { + Name key = makeName(nameTok); + Name value = makeName(nameTok); + return new Property(key, new NameExpression(value)) + ..start = start + ..end = endOffset + ..line = key.line; + } throw fail(expected: 'property', tok: nameTok); } @@ -373,7 +746,15 @@ class Parser { if (list.length > 0) { consume(Token.COMMA); } - list.add(parseAssignment()); + if (token!.type == Token.ELLIPSIS) { + Token spread = next(); + list.add(new SpreadExpression(parseAssignment()) + ..start = spread.startOffset + ..end = endOffset + ..line = spread.line); + } else { + list.add(parseAssignment()); + } } consume(Token.RPAREN); return list; @@ -395,6 +776,31 @@ class Parser { ..line = line; break; + case Token.OPTIONAL_CHAIN: + next(); + if (token!.type == Token.LBRACKET) { + next(); + Expression index = parseExpression(); + requireNext(Token.RBRACKET); + exp = new IndexExpression(exp, index, optional: true) + ..start = start + ..end = endOffset + ..line = line; + } else if (token!.type == Token.LPAREN) { + List args = parseArguments(); + exp = new CallExpression(exp, args, optional: true) + ..start = start + ..end = endOffset + ..line = line; + } else { + Name name = parseName(); + exp = new MemberExpression(exp, name, optional: true) + ..start = start + ..end = endOffset + ..line = line; + } + break; + case Token.LBRACKET: next(); Expression index = parseExpression(); @@ -422,6 +828,17 @@ class Parser { } break; + case Token.TEMPLATE: + if (newTok != null) { + break loop; + } + exp = new TaggedTemplateExpression( + exp, parseTemplateLiteral() as TemplateLiteral) + ..start = start + ..end = endOffset + ..line = line; + break; + default: break loop; } @@ -488,6 +905,14 @@ class Parser { ..line = operator.line; case Token.NAME: + if (token!.text == 'await') { + Token operator = next(); + Expression exp = parseUnary(); + return new AwaitExpression(exp) + ..start = operator.startOffset + ..end = endOffset + ..line = operator.line; + } if (token!.text == 'delete' || token!.text == 'void' || token!.text == 'typeof') { @@ -542,6 +967,16 @@ class Parser { Expression parseAssignment({bool allowIn = true}) { int? start = token!.startOffset; Expression exp = parseConditional(allowIn); + if (token!.type == Token.ARROW) { + if (exp is! NameExpression) { + fail(message: 'Invalid arrow function parameter'); + } + exp = new FunctionExpression(parseArrowFunction(exp)) + ..start = start + ..end = endOffset + ..line = exp.line; + return exp; + } if (token!.type == Token.ASSIGN) { Token operator = next(); Expression right = parseAssignment(allowIn: allowIn); @@ -553,10 +988,10 @@ class Parser { return exp; } - Expression parseExpression({bool allowIn = true}) { + Expression parseExpression({bool allowIn = true, bool allowComma = true}) { int? start = token!.startOffset; Expression exp = parseAssignment(allowIn: allowIn); - if (token!.type == Token.COMMA) { + if (allowComma && token!.type == Token.COMMA) { List expressions = [exp]; while (token!.type == Token.COMMA) { next(); @@ -590,11 +1025,13 @@ class Parser { VariableDeclaration parseVariableDeclarationList({bool allowIn = true}) { int? start = token!.startOffset; int? line = token!.line; - assert(token!.text == 'var'); + assert( + token!.text == 'var' || token!.text == 'let' || token!.text == 'const'); + String kind = token!.text!; consume(Token.NAME); List list = []; while (true) { - Name name = parseName(); + Node name = parseBindingTarget(); Expression? init = null; if (token!.type == Token.ASSIGN) { if (token!.text != '=') { @@ -610,7 +1047,7 @@ class Parser { if (token!.type != Token.COMMA) break; next(); } - return new VariableDeclaration(list) + return new VariableDeclaration(list, kind) ..start = start ..end = endOffset ..line = line; @@ -719,7 +1156,7 @@ class Parser { consume(Token.NAME); consume(Token.LPAREN); Node? exp1; - if (peekName('var')) { + if (peekName('var') || peekName('let') || peekName('const')) { exp1 = parseVariableDeclarationList(allowIn: false); } else if (token!.type != Token.SEMICOLON) { exp1 = parseExpression(allowIn: false); @@ -735,6 +1172,17 @@ class Parser { ..start = start ..end = endOffset ..line = line; + } else if (exp1 != null && tryName('of')) { + if (exp1 is VariableDeclaration && exp1.declarations.length > 1) { + fail(message: 'Multiple vars declared in for-of loop'); + } + Expression exp2 = parseExpression(); + consume(Token.RPAREN); + Statement body = parseStatement(); + return new ForOfStatement(exp1, exp2, body) + ..start = start + ..end = endOffset + ..line = line; } else { consume(Token.SEMICOLON); Expression? exp2, exp3; @@ -936,12 +1384,46 @@ class Parser { ..line = line; } + Statement parseAsyncFunctionDeclaration() { + int? start = token!.startOffset; + int? line = token!.line; + assert(token!.text == 'async'); + Token asyncTok = next(); + consumeName('function'); + FunctionNode func = parseFunctionAfterKeyword( + isAsync: true, asyncStart: asyncTok.startOffset); + if (func.name == null) { + fail(message: 'Function declaration must have a name'); + } + return new FunctionDeclaration(func) + ..start = start + ..end = endOffset + ..line = line; + } + + FunctionNode parseFunctionAfterKeyword( + {bool isAsync = false, int? asyncStart}) { + int? start = asyncStart ?? token!.startOffset; + Name? name; + if (token!.type == Token.NAME) { + name = parseName(); + } + List params = parseParameters(); + BlockStatement body = parseFunctionBody(); + return new FunctionNode(name, params, body, isAsync: isAsync) + ..start = start + ..end = endOffset + ..line = name?.line; + } + Statement parseStatement() { if (token!.type == Token.LBRACE) return parseBlock(); if (token!.type == Token.SEMICOLON) return parseEmptyStatement(); if (token!.type != Token.NAME) return parseExpressionStatement(); switch (token!.value) { case 'var': + case 'let': + case 'const': return parseVariableDeclarationStatement(); case 'if': return parseIf(); @@ -969,6 +1451,11 @@ class Parser { return parseDebuggerStatement(); case 'function': return parseFunctionDeclaration(); + case 'async': + if (peekToken().type == Token.NAME && peekToken().text == 'function') { + return parseAsyncFunctionDeclaration(); + } + return parseExpressionOrLabeledStatement(); default: return parseExpressionOrLabeledStatement(); } diff --git a/packages/parsejs_null_safety/pubspec.yaml b/packages/parsejs_null_safety/pubspec.yaml index 728cf8b8c..fc729dde6 100644 --- a/packages/parsejs_null_safety/pubspec.yaml +++ b/packages/parsejs_null_safety/pubspec.yaml @@ -1,5 +1,5 @@ name: parsejs_null_safety -version: 2.0.4 +version: 2.1.0 description: A robust JavaScript parser for Dart with full null safety support. This package provides a complete JavaScript parsing solution that generates Abstract Syntax Trees (ASTs) for JavaScript code, enabling code analysis, transformation, and compilation tools. homepage: https://ensembleui.com diff --git a/packages/parsejs_null_safety/test/ast_json.dart b/packages/parsejs_null_safety/test/ast_json.dart index ed72d7572..20a1794b2 100644 --- a/packages/parsejs_null_safety/test/ast_json.dart +++ b/packages/parsejs_null_safety/test/ast_json.dart @@ -37,7 +37,8 @@ class Ast2Json extends Visitor { 'body': visit(node.body), 'rest': null, 'generator': false, - 'expression': false + 'expression': false, + 'async': node.isAsync }; visitProgram(Program node) => {'type': 'Program', 'body': list(node.body)}; @@ -139,6 +140,14 @@ class Ast2Json extends Visitor { 'each': false }; + visitForOf(ForOfStatement node) => { + 'type': 'ForOfStatement', + 'left': visit(node.left), + 'right': visit(node.right), + 'body': visit(node.body), + 'await': false + }; + visitFunctionDeclaration(FunctionDeclaration node) => { 'type': 'FunctionDeclaration', 'id': visit(node.function.name), @@ -147,13 +156,14 @@ class Ast2Json extends Visitor { 'body': visit(node.function.body), 'rest': null, 'generator': false, - 'expression': false + 'expression': false, + 'async': node.function.isAsync }; visitVariableDeclaration(VariableDeclaration node) => { 'type': 'VariableDeclaration', 'declarations': list(node.declarations), - 'kind': 'var' + 'kind': node.kind }; visitVariableDeclarator(VariableDeclarator node) => { @@ -162,6 +172,21 @@ class Ast2Json extends Visitor { 'init': visit(node.init) }; + visitDefaultParameter(DefaultParameter node) => { + 'type': 'AssignmentPattern', + 'left': visit(node.name), + 'right': visit(node.defaultValue) + }; + + visitRestParameter(RestParameter node) => + {'type': 'RestElement', 'argument': visit(node.name)}; + + visitObjectPattern(ObjectPattern node) => + {'type': 'ObjectPattern', 'properties': list(node.properties)}; + + visitArrayPattern(ArrayPattern node) => + {'type': 'ArrayPattern', 'elements': list(node.elements)}; + visitDebugger(DebuggerStatement node) => {'type': 'DebuggerStatement'}; visitThis(ThisExpression node) => {'type': 'ThisExpression'}; @@ -169,6 +194,26 @@ class Ast2Json extends Visitor { visitArray(ArrayExpression node) => {'type': 'ArrayExpression', 'elements': list(node.expressions)}; + visitSpread(SpreadExpression node) => + {'type': 'SpreadElement', 'argument': visit(node.argument)}; + + visitTemplateLiteral(TemplateLiteral node) => { + 'type': 'TemplateLiteral', + 'quasis': node.strings + .map((value) => { + 'type': 'TemplateElement', + 'value': {'raw': value, 'cooked': value} + }) + .toList(), + 'expressions': list(node.expressions) + }; + + visitTaggedTemplate(TaggedTemplateExpression node) => { + 'type': 'TaggedTemplateExpression', + 'tag': visit(node.tag), + 'quasi': visit(node.template) + }; + visitObject(ObjectExpression node) => {'type': 'ObjectExpression', 'properties': list(node.properties)}; @@ -176,7 +221,13 @@ class Ast2Json extends Visitor { 'type': 'Property', 'key': visit(node.key), 'value': visit(node.value), - 'kind': node.kind + 'kind': node.kind, + 'computed': node.computed, + 'method': node.method, + 'shorthand': node.key is Name && + node.value is NameExpression && + (node.value as NameExpression).name.value == + (node.key as Name).value }; visitFunctionExpression(FunctionExpression node) => visit(node.function); @@ -191,6 +242,9 @@ class Ast2Json extends Visitor { 'prefix': true }; + visitAwait(AwaitExpression node) => + {'type': 'AwaitExpression', 'argument': visit(node.argument)}; + visitBinary(BinaryExpression node) => { 'type': (node.operator == '&&' || node.operator == '||') ? 'LogicalExpression' @@ -224,7 +278,8 @@ class Ast2Json extends Visitor { visitCall(CallExpression node) => { 'type': node.isNew ? 'NewExpression' : 'CallExpression', 'callee': visit(node.callee), - 'arguments': list(node.arguments) + 'arguments': list(node.arguments), + 'optional': node.optional, }; visitMember(MemberExpression node) => { @@ -232,6 +287,7 @@ class Ast2Json extends Visitor { 'computed': false, 'object': visit(node.object), 'property': visit(node.property), + 'optional': node.optional, }; visitIndex(IndexExpression node) => { @@ -239,13 +295,16 @@ class Ast2Json extends Visitor { 'computed': true, 'object': visit(node.object), 'property': visit(node.property), + 'optional': node.optional, }; visitNameExpression(NameExpression node) => visit(node.name); // Some values cannot be encoded in JSON. We simply represent these as null. bool isUnencodable(Object? x) => - x == double.infinity || x == double.negativeInfinity || x == double.nan; + x == double.infinity || + x == double.negativeInfinity || + (x is double && x.isNaN); visitLiteral(LiteralExpression node) => { 'type': 'Literal', @@ -257,5 +316,5 @@ class Ast2Json extends Visitor { {'type': 'Literal', 'value': {}, 'raw': node.regexp}; @override - Object? visitArrowFunctionNode(ArrowFunctionNode node) {} + Object? visitArrowFunctionNode(ArrowFunctionNode node) => null; } diff --git a/packages/parsejs_null_safety/test/lexer_test.dart b/packages/parsejs_null_safety/test/lexer_tool.dart similarity index 100% rename from packages/parsejs_null_safety/test/lexer_test.dart rename to packages/parsejs_null_safety/test/lexer_tool.dart diff --git a/packages/parsejs_null_safety/test/parsejs_test.dart b/packages/parsejs_null_safety/test/parsejs_test.dart index 507f2814a..6e8f8401f 100644 --- a/packages/parsejs_null_safety/test/parsejs_test.dart +++ b/packages/parsejs_null_safety/test/parsejs_test.dart @@ -107,6 +107,91 @@ void main() { expect(ast.body.length, equals(3)); }); + test('should parse ES6 syntax', () { + const code = r''' + let x = 1; + const y = `value ${x}`; + function collect(prefix = 'x', ...items) { + for (let item of items) { + prefix = prefix + item; + } + return [prefix, ...items]; + } + var arrow = (value = 1, ...rest) => `${value}:${rest.length}`; + var single = value => value * 2; + const { name, address: { city } } = person; + const [first, , third, ...rest] = values; + const user = { name, [key]: value, add(a, b) { return a + b; } }; + async function load() { return await Promise.resolve(1); } + const loadArrow = async value => await value; + const names = users.map(({ name }) => name); + [a, b] = [b, a]; + const copied = { ...user, city: 'Lahore' }; + const price = currency`Price: ${amount}`; + const { label = 'Guest', ...rest } = user; + const [first = 1] = values; + for (const [key, value] of Object.entries(copied)) {} + '''; + final ast = parsejs(code); + + expect(ast, isNotNull); + expect(ast.body.length, equals(17)); + expect((ast.body.first as VariableDeclaration).kind, equals('let')); + expect((ast.body[1] as VariableDeclaration).kind, equals('const')); + }); + + test('should parse tagged template expressions', () { + const code = r''' + var result = currency`Price: ${amount}`; + '''; + final ast = parsejs(code); + + final declaration = ast.body.single as VariableDeclaration; + expect(declaration.declarations.single.init, + isA()); + }); + + test('arrow expression body stops before call argument comma', () { + const code = ''' + numbers.reduce((total, value) => total + value, 0); + '''; + final ast = parsejs(code); + + final statement = ast.body.single as ExpressionStatement; + final call = statement.expression as CallExpression; + expect(call.arguments.length, equals(2)); + final callback = call.arguments.first as FunctionExpression; + final block = callback.function.body as BlockStatement; + final returnStatement = block.body.single as ReturnStatement; + expect(returnStatement.argument, isA()); + }); + + test('should parse optional chaining and nullish coalescing', () { + const code = ''' + var value = user?.profile?.name ?? "Guest"; + var first = items?.[0]; + var result = handler?.(value); + '''; + final ast = parsejs(code); + + expect(ast.body.length, equals(3)); + final valueDecl = ast.body.first as VariableDeclaration; + final coalesce = valueDecl.declarations.single.init as BinaryExpression; + expect(coalesce.operator, equals('??')); + final nameAccess = coalesce.left as MemberExpression; + expect(nameAccess.optional, isTrue); + final profileAccess = nameAccess.object as MemberExpression; + expect(profileAccess.optional, isTrue); + + final firstDecl = ast.body[1] as VariableDeclaration; + final indexAccess = firstDecl.declarations.single.init as IndexExpression; + expect(indexAccess.optional, isTrue); + + final resultDecl = ast.body[2] as VariableDeclaration; + final call = resultDecl.declarations.single.init as CallExpression; + expect(call.optional, isTrue); + }); + test('should parse function expressions', () { const code = ''' var add = function(a, b) { diff --git a/packages/parsejs_null_safety/test/parser_test.dart b/packages/parsejs_null_safety/test/parser_tool.dart similarity index 100% rename from packages/parsejs_null_safety/test/parser_test.dart rename to packages/parsejs_null_safety/test/parser_tool.dart diff --git a/packages/parsejs_null_safety/test/runbench b/packages/parsejs_null_safety/test/runbench index 217f1cbbb..c024b42cb 100644 --- a/packages/parsejs_null_safety/test/runbench +++ b/packages/parsejs_null_safety/test/runbench @@ -18,7 +18,7 @@ for file in $FILES do name=$(basename $file) name=${name%.js} - T=$(dart parser_test.dart --time $file) + T=$(dart parser_tool.dart --time $file) printf "%-30s %4d\n" $name $T TOTAL=$((TOTAL+T)) done diff --git a/packages/parsejs_null_safety/test/runtest b/packages/parsejs_null_safety/test/runtest index fa4054dea..3652f71f5 100644 --- a/packages/parsejs_null_safety/test/runtest +++ b/packages/parsejs_null_safety/test/runtest @@ -36,7 +36,7 @@ do path=$(python -c "import os.path; print os.path.relpath('$file', '$BASEDIR')") printf "%-50s" $path - if dart parser_test.dart --json $file > tmp/mine.json 2> tmp/$name.err; then + if dart parser_tool.dart --json $file > tmp/mine.json 2> tmp/$name.err; then rm -f tmp/$name.err else echo "[EXCEPTION]"