From db97cc1a8faaa751b370376703f9f2021d7610a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:35:33 -0800 Subject: [PATCH] JavaScript: a bare E4X name inside with or a filter can be assigned With Options::e4x set, an E4X name standing alone (@a, @ns::a, @[expr], ns::a, *) is now a valid target for =, compound assignment, ++, --, delete and for/for each loops, as well as a read. The target is the nearest XML or XMLList value in scope whose [[HasProperty]] accepts the name (ECMA-357 section 11.1). The name is evaluated once, and the target resolves before the right-hand side runs. When no XML value in scope holds the name, a read or write throws a ReferenceError and creates no child or attribute. A read no longer falls back to the first XML value in scope, so a filter such as list.(@id == '1') throws when an item has no @id and no outer XML value holds one, instead of treating that item as not matching. --- crates/js/src/ast.rs | 3 +- crates/js/src/bytecode.rs | 6 ++- crates/js/src/compiler.rs | 31 ++++++++++++- crates/js/src/interp.rs | 1 + crates/js/src/parser.rs | 7 +-- crates/js/src/xml.rs | 38 ++++++++++------ crates/js/src/xml_tests.rs | 89 ++++++++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 8 files changed, 156 insertions(+), 20 deletions(-) diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs index 5a4363108..8d9ffc06c 100644 --- a/crates/js/src/ast.rs +++ b/crates/js/src/ast.rs @@ -187,7 +187,8 @@ pub enum Expr { qualifier: Option, name: XmlNamePart, }, - /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope. + /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope + /// that holds it, and also an assignment, update or `delete` target. XmlScopeName(ExprId), /// `object..name`. XmlDescendants { diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 3092530d4..72bd9ef90 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -160,8 +160,11 @@ pub(crate) enum Op { parts: u32, list: bool, }, - /// `name` → the value of the name on the nearest XML value in scope. + /// `name` → the value of the name on the nearest XML value in scope that holds it. XmlScopeGet, + /// `name` → `object name`: the nearest XML value in scope that holds the name, for an + /// assignment, update or `delete`. + XmlScopeRef, /// `object` → the filter's state. XmlFilterStart, /// `state` → `state item`, or jumps with `state` when the items are used up. @@ -308,6 +311,7 @@ fn effect(op: Op, finally_tables: &[Vec]) -> Result { Op::XmlDescendants | Op::XmlFilterTest => e(2, 1), Op::XmlEscape(_) | Op::XmlScopeGet | Op::XmlFilterStart | Op::XmlFilterEnd => e(1, 1), Op::XmlLiteral { parts, .. } => e(parts, 1), + Op::XmlScopeRef => e(1, 2), Op::ForEachValue => e(2, 2), Op::SetDefaultNamespace => e(1, 0), }) diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index 071e99a60..effb475f9 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -538,6 +538,11 @@ impl<'a> FnCompiler<'a> { self.emit(Op::Rot3); self.emit(Op::SetIndex); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::Rot3); + self.emit(Op::SetIndex); + } _ => { self.expr(target)?; self.emit(Op::Pop); @@ -854,6 +859,15 @@ impl<'a> FnCompiler<'a> { Ok(()) } + /// Pushes the nearest XML value in scope that holds the E4X name `name` of the expression + /// `id`, then the name, as an assignment, update or `delete` uses them. + fn xml_scope_ref(&mut self, id: ExprId, name: ExprId) -> CResult<()> { + self.expr(name)?; + self.at(self.ast.expr_span(id).start); + self.emit(Op::XmlScopeRef); + Ok(()) + } + fn xml_literal(&mut self, parts: &[XmlPart], list: bool) -> CResult<()> { for part in parts { match part { @@ -1096,6 +1110,10 @@ impl<'a> FnCompiler<'a> { self.expr(*index)?; self.emit(Op::DeleteIndex); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(arg, *name)?; + self.emit(Op::DeleteIndex); + } _ => { self.expr(arg)?; self.emit(Op::Pop); @@ -1202,6 +1220,13 @@ impl<'a> FnCompiler<'a> { self.emit(Op::GetIndex); } } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + if op.is_some() { + self.emit(Op::Dup2); + self.emit(Op::GetIndex); + } + } _ => { self.expr(target)?; if op.is_none() { @@ -1222,7 +1247,7 @@ impl<'a> FnCompiler<'a> { let n = self.name_key(key_of(&property.name)); self.emit(Op::SetMember(n)); } - Some(Expr::Index { .. }) => self.emit(Op::SetIndex), + Some(Expr::Index { .. } | Expr::XmlScopeName(_)) => self.emit(Op::SetIndex), _ => self.throw_invalid_target(), } Ok(()) @@ -1246,6 +1271,10 @@ impl<'a> FnCompiler<'a> { self.expr(*index)?; self.emit(Op::IncIndex { prefix, dec }); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::IncIndex { prefix, dec }); + } _ => { self.expr(target)?; self.emit(Op::ToNumber); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afb6cde63..b8213fbaf 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -922,6 +922,7 @@ impl Interp { Op::XmlEscape(kind) => self.op_xml_escape(kind), Op::XmlLiteral { parts, list } => self.op_xml_literal(parts, list), Op::XmlScopeGet => self.op_xml_scope_get(), + Op::XmlScopeRef => self.op_xml_scope_ref(), Op::XmlFilterStart => self.op_xml_filter_start(), Op::XmlFilterNext(t) => self.op_xml_filter_next(t), Op::XmlFilterTest => self.op_xml_filter_test(), diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index 3c2ab3ed3..fa201c58f 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -873,8 +873,9 @@ impl<'s> Parser<'s> { } /// An assignment's, `++`'s, `--`'s or `for-in`'s target: an identifier (in strict code - /// neither `eval` nor `arguments`), a property, or outside strict mode code a call (assigning - /// to its result fails at run time, as ECMA-262 2015 Annex B and web browsers have it). + /// neither `eval` nor `arguments`), a property, an E4X name standing alone (`@a`, `ns::a`; + /// ECMA-357 section 11.1), or outside strict mode code a call (assigning to its result fails + /// at run time, as ECMA-262 2015 Annex B and web browsers have it). fn check_target(&self, expr: ExprId, message: &str) -> Result<()> { let inner = self.ast.unparenthesized(expr); match self.ast.expr(inner) { @@ -884,7 +885,7 @@ impl<'s> Parser<'s> { } Ok(()) } - Some(Expr::Member { .. } | Expr::Index { .. }) => Ok(()), + Some(Expr::Member { .. } | Expr::Index { .. } | Expr::XmlScopeName(_)) => Ok(()), Some(Expr::Call { .. }) if !self.ctx.strict => Ok(()), _ => Err(self.error_at_offset(self.ast.expr_span(expr).start, message)), } diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 9dc6d71f8..9104ba83f 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -2797,27 +2797,24 @@ impl Interp { self.replace(n, value) } - pub(crate) fn op_xml_scope_get(&mut self) -> JsResult<()> { - let name = self.peek(0)?; - let n = self.xml_name_only(&name)?; + /// The nearest XML or XMLList value in scope whose [[HasProperty]] accepts `n`, or a + /// ReferenceError when none does (ECMA-357 section 11.1). + fn xml_scope_holder(&mut self, n: &NameRef) -> JsResult { let mut cur = Some(self.frame()?.env); - let mut first = None; + let mut any_xml = false; while let Some(e) = cur { match self.heap.env(e) { Some(Env::Decl { outer, .. }) => cur = *outer, Some(&Env::Obj { object, outer, .. }) => { let v = Value::Object(object); if self.is_xml(&v) { - if first.is_none() { - first = Some(object); - } + any_xml = true; let found = match self.classify(&v) { - Xv::Node(x) => self.node_has_name(x, &n)?, - _ => self.list_has_name(object, &n)?, + Xv::Node(x) => self.node_has_name(x, n)?, + _ => self.list_has_name(object, n)?, }; if found { - first = Some(object); - break; + return Ok(object); } } cur = outer; @@ -2825,13 +2822,26 @@ impl Interp { None => return Err(Abrupt::Internal("environment out of range".into())), } } - let Some(object) = first else { - return Err(self.throw(ErrorType::Reference, "an XML name is used outside any XML value")); - }; + let message = if any_xml { "no XML value in scope has the XML name" } else { "an XML name is used outside any XML value" }; + Err(self.throw(ErrorType::Reference, message)) + } + + pub(crate) fn op_xml_scope_get(&mut self) -> JsResult<()> { + let name = self.peek(0)?; + let n = self.xml_name_only(&name)?; + let object = self.xml_scope_holder(&n)?; let v = self.xml_select(object, &n)?; self.replace(1, Value::Object(v)) } + pub(crate) fn op_xml_scope_ref(&mut self) -> JsResult<()> { + let name = self.peek(0)?; + let n = self.xml_name_only(&name)?; + let object = self.xml_scope_holder(&n)?; + self.replace(1, Value::Object(object))?; + self.push(name) + } + pub(crate) fn op_xml_filter_start(&mut self) -> JsResult<()> { let base = self.peek(0)?; let input = match self.classify(&base) { diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index cfe508a48..96ac9c3ae 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -636,6 +636,95 @@ fn a_scope_name_skips_a_list_without_that_name() { truth("var outer = ; var inner = <>; with (outer) with (inner) { @id == '2' }"); } +#[test] +fn bare_attribute_assignment_uses_nearest_holder() { + truth("var x = ; with (x) { @b = '2'; } x.@b == '2'"); + truth("var outer = ; var inner = ; with (outer) with (inner) { @id = '9'; } outer.@id == '9' && inner.@id.length() === 0"); + truth("var outer = ; var inner = ; with (outer) with (inner) { @id = '9'; } outer.@id == '1' && inner.@id == '9'"); + truth("var outer = ; var inner = <>; with (outer) with (inner) { @id = '9'; } outer.@id == '9' && inner.@id.length() === 0"); + truth("var x = ; with (x) with ({ b: 7 }) { @b = '3'; } x.@b == '3'"); + truth("with () { (@b = 5) === 5 }"); + truth("var x = ; with (x) { @b = @c = 'v'; } x.@b == 'v' && x.@c == 'v'"); + truth("var n = 0; var x = ; with (x) { @[(n++, 'b')] = 'z'; } n === 1 && x.@b == 'z' && x.attributes().length() === 1"); + truth("var x = 1; with (x) { *::c = '2'; } x.c == '2' && x.*.length() === 1"); + truth("var x = ; x.@b = '4'; x.@b == '4'"); +} + +#[test] +fn bare_qualified_assignment_keeps_namespace() { + truth( + "var ns = new Namespace('u'); var x = 10; with (x) { ns::c = '2'; } \ + x.ns::c == '2' && x.ns::c.name().uri === 'u' && x.c == '0' && x.*.length() === 2", + ); + truth( + "var ns = new Namespace('u'); var x = ; with (x) { @ns::t = '5'; } \ + x.@ns::t == '5' && x.@ns::t.name().uri === 'u' && x.@t == '0'", + ); +} + +#[test] +fn bare_assignment_in_filter_mutates_each_matching_item() { + truth("var l = <>; var r = l.(@id = 'z', true); r.length() === 2 && l[0].@id == 'z' && l[1].@id == 'z'"); + truth("var l = <>; var r = l.(@n = 0); r.length() === 0 && l[0].@n == '0' && l[1].@n == '0'"); + truth("var l = <>; var r = l.(@n = 'k'); r.length() === 2"); + truth("var o = ; var l = <>; with (o) { l.(@k += 'x', true); } o.@k == '1xx' && l[0].@k.length() === 0"); +} + +#[test] +fn bare_assignment_without_holder_throws_reference_error_before_rhs() { + assert_eq!(thrown_name("@a = 1"), "ReferenceError"); + assert_eq!(thrown_name("with ({ a: 1 }) { @a = 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with () { @a = 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with () { @a += 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with () { @a++ }"), "ReferenceError"); + assert_eq!(thrown_name("var ns = new Namespace('u'); with () { ns::c = 1 }"), "ReferenceError"); + truth("var n = 0; var e = ''; try { with () { @a = (n++, 1); } } catch (err) { e = err.name; } e === 'ReferenceError' && n === 0"); + truth( + "var b = 'g'; var x = ; var e = ''; try { with (x) { @b = 'w'; } } catch (err) { e = err.name; } \ + e === 'ReferenceError' && b === 'g' && x.@b.length() === 0 && x.*.length() === 0", + ); +} + +#[test] +fn bare_name_read_without_holder_throws_reference_error() { + assert_eq!(thrown_name("with () { @a }"), "ReferenceError"); + assert_eq!(thrown_name("var l = <>; l.(@id == '1')"), "ReferenceError"); + truth("var o = ; var l = <>; var r; with (o) { r = l.(@id == '1'); } r.length() === 1"); +} + +#[test] +fn bare_scope_compound_and_updates_resolve_once() { + truth("var n = 0; var x = ; with (x) { @[(n++, 't')] += 1; } n === 1 && x.@t == '11'"); + truth("var n = 0; var x = ; with (x) { @[(n++, 't')]++; } n === 1 && x.@t == '2'"); + truth("var x = ; var r; with (x) { r = ++@t; } r === 2 && x.@t == '2'"); + truth("var x = ; var r; with (x) { r = @t++; } r === 1 && x.@t == '2'"); + truth("var x = ; var r; with (x) { r = --@t; } r === 4 && x.@t == '4'"); + truth("var x = ; var r; with (x) { r = @t--; } r === 5 && x.@t == '4'"); +} + +#[test] +fn bare_scope_delete_removes_attribute() { + truth("var x = ; var r; with (x) { r = delete @t; } r === true && x.@t.length() === 0 && x.@u == '2'"); + truth("var x = ; with (x) { delete *::c; } x.c.length() === 0 && x.d.length() === 1"); + assert_eq!(thrown_name("with () { delete @t }"), "ReferenceError"); +} + +#[test] +fn bare_scope_for_targets_assign_each_iteration() { + truth("var x = ; var s = ''; with (x) { for (@k in { p: 1, q: 2 }) s += @k; } s === 'pq' && x.@k == 'q'"); + truth("var x = ; var s = ''; with (x) { for each (@k in [3, 4]) s += @k; } s === '34' && x.@k == '4'"); + assert_eq!(thrown_name("with () { for (@k in { p: 1 }); }"), "ReferenceError"); +} + +#[test] +fn bare_scope_targets_need_e4x_option() { + for src in ["@a = 1", "ns::a = 1", "@a += 1", "++@a", "@a--", "for (@a in o);", "for each (@a in o);", "\"use strict\"; @a = 1"] { + assert!(parse(src, &Options::default()).is_err(), "{src}"); + assert!(parse(src, &e4x()).is_ok(), "{src}"); + } + truth("'use strict'; var l = <>; l.(@b = '2', true); l[0].@b == '2'"); +} + #[test] fn a_bracket_key_converts_to_a_string_before_it_names_a_child() { truth("var x = 1; x[true] == '1' && (true in x) && !(false in x)"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447f..ce1200ef6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -764,6 +764,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings (`xpath` is not provided). `` in an XML string gives a `SyntaxError`. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. + - With `Options::e4x` set, an E4X name standing alone (`@a`, `@ns::a`, `@[expr]`, `ns::a`, `*`) inside `with` or a filter predicate can be assigned, with `=` or a compound operator, incremented, decremented, deleted and used as a `for` or `for each` variable, as well as read. Each of these uses the nearest XML or XMLList value in scope that holds the name and evaluates the name once. When no XML value in scope holds the name, the read or write throws a `ReferenceError` before the right-hand side runs and creates no child or attribute, as ECMA-357 section 11.1 (https://ecma-international.org/publications-and-standards/standards/ecma-357/) specifies, so a filter such as `list.(@id == '1')` throws when an item has no `@id` and no outer XML value holds one. Tests: `crates/js/src/xml_tests.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus.