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.
This commit is contained in:
dotCooCoo
2026-11-11 19:35:59 -08:00
parent 6b8be09699
commit db97cc1a8f
8 changed files with 156 additions and 20 deletions
+2 -1
View File
@@ -187,7 +187,8 @@ pub enum Expr {
qualifier: Option<XmlQualifier>,
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 {
+5 -1
View File
@@ -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<u32>]) -> Result<Effect, String> {
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),
})
+30 -1
View File
@@ -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);
+1
View File
@@ -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(),
+4 -3
View File
@@ -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)),
}
+24 -14
View File
@@ -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<ObjId> {
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) {
+89
View File
@@ -636,6 +636,95 @@ fn a_scope_name_skips_a_list_without_that_name() {
truth("var outer = <o id='1'/>; var inner = <><i id='2'/></>; with (outer) with (inner) { @id == '2' }");
}
#[test]
fn bare_attribute_assignment_uses_nearest_holder() {
truth("var x = <a b='1'/>; with (x) { @b = '2'; } x.@b == '2'");
truth("var outer = <o id='1'/>; var inner = <i/>; with (outer) with (inner) { @id = '9'; } outer.@id == '9' && inner.@id.length() === 0");
truth("var outer = <o id='1'/>; var inner = <i id='2'/>; with (outer) with (inner) { @id = '9'; } outer.@id == '1' && inner.@id == '9'");
truth("var outer = <o id='1'/>; var inner = <><i/></>; with (outer) with (inner) { @id = '9'; } outer.@id == '9' && inner.@id.length() === 0");
truth("var x = <a b='1'/>; with (x) with ({ b: 7 }) { @b = '3'; } x.@b == '3'");
truth("with (<a b='1'/>) { (@b = 5) === 5 }");
truth("var x = <a b='1' c='2'/>; with (x) { @b = @c = 'v'; } x.@b == 'v' && x.@c == 'v'");
truth("var n = 0; var x = <a b='1'/>; with (x) { @[(n++, 'b')] = 'z'; } n === 1 && x.@b == 'z' && x.attributes().length() === 1");
truth("var x = <a><c>1</c></a>; with (x) { *::c = '2'; } x.c == '2' && x.*.length() === 1");
truth("var x = <a b='1'/>; x.@b = '4'; x.@b == '4'");
}
#[test]
fn bare_qualified_assignment_keeps_namespace() {
truth(
"var ns = new Namespace('u'); var x = <a xmlns:p='u'><p:c>1</p:c><c>0</c></a>; 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 = <a xmlns:p='u' p:t='1' t='0'/>; 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 = <><i id='1'/><i id='2'/></>; var r = l.(@id = 'z', true); r.length() === 2 && l[0].@id == 'z' && l[1].@id == 'z'");
truth("var l = <><i n='1'/><i n='2'/></>; var r = l.(@n = 0); r.length() === 0 && l[0].@n == '0' && l[1].@n == '0'");
truth("var l = <><i n='1'/><i n='2'/></>; var r = l.(@n = 'k'); r.length() === 2");
truth("var o = <o k='1'/>; var l = <><i/><i/></>; 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 (<x/>) { @a = 1 }"), "ReferenceError");
assert_eq!(thrown_name("with (<x/>) { @a += 1 }"), "ReferenceError");
assert_eq!(thrown_name("with (<x/>) { @a++ }"), "ReferenceError");
assert_eq!(thrown_name("var ns = new Namespace('u'); with (<x><c/></x>) { ns::c = 1 }"), "ReferenceError");
truth("var n = 0; var e = ''; try { with (<x/>) { @a = (n++, 1); } } catch (err) { e = err.name; } e === 'ReferenceError' && n === 0");
truth(
"var b = 'g'; var x = <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 (<x/>) { @a }"), "ReferenceError");
assert_eq!(thrown_name("var l = <><i id='1'/><i/></>; l.(@id == '1')"), "ReferenceError");
truth("var o = <o id='0'/>; var l = <><i id='1'/><i/></>; 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 = <a t='1'/>; with (x) { @[(n++, 't')] += 1; } n === 1 && x.@t == '11'");
truth("var n = 0; var x = <a t='1'/>; with (x) { @[(n++, 't')]++; } n === 1 && x.@t == '2'");
truth("var x = <a t='1'/>; var r; with (x) { r = ++@t; } r === 2 && x.@t == '2'");
truth("var x = <a t='1'/>; var r; with (x) { r = @t++; } r === 1 && x.@t == '2'");
truth("var x = <a t='5'/>; var r; with (x) { r = --@t; } r === 4 && x.@t == '4'");
truth("var x = <a t='5'/>; var r; with (x) { r = @t--; } r === 5 && x.@t == '4'");
}
#[test]
fn bare_scope_delete_removes_attribute() {
truth("var x = <a t='1' u='2'/>; var r; with (x) { r = delete @t; } r === true && x.@t.length() === 0 && x.@u == '2'");
truth("var x = <a><c/><d/></a>; with (x) { delete *::c; } x.c.length() === 0 && x.d.length() === 1");
assert_eq!(thrown_name("with (<a/>) { delete @t }"), "ReferenceError");
}
#[test]
fn bare_scope_for_targets_assign_each_iteration() {
truth("var x = <a k=''/>; var s = ''; with (x) { for (@k in { p: 1, q: 2 }) s += @k; } s === 'pq' && x.@k == 'q'");
truth("var x = <a k=''/>; var s = ''; with (x) { for each (@k in [3, 4]) s += @k; } s === '34' && x.@k == '4'");
assert_eq!(thrown_name("with (<a/>) { 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 = <><i b='1'/></>; 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 = <a><true>1</true></a>; x[true] == '1' && (true in x) && !(false in x)");
+1
View File
@@ -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). `<!DOCTYPE>` 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.