From f526c5310c2743fed82d9d5f8dcc7fd4df4e3423 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:01:32 -0800 Subject: [PATCH] JS: XML and XMLList xpath evaluates XPath 1.0 expressions xml.xpath(expression[, variables]) evaluates an XPath 1.0 expression (https://www.w3.org/TR/1999/REC-xpath-19991116/) with the XML value as the context node and returns the selected nodes as an XMLList, in document order and without repeats. On an XMLList the expression runs on each element in turn, and the results are joined. - All 13 axes and the 27 core functions. id() selects nothing, since E4X trees carry no DTD data. - Each property of `variables` binds the variable of its name. A prefix in a name test resolves through the context element's in-scope namespaces; `xml` is always bound. - An expression that breaks the grammar gives a SyntaxError. An unknown function, a wrong argument count, an unbound prefix or variable, a result that is not a node-set, and a result that holds the root or a namespace node give a TypeError. - The parser and the evaluator keep their state on their own stacks, so expressions of any depth use no native stack; nesting past Options::max_depth gives "nesting too deep". Traversal stacks, node lists, strings and the values that string functions build count against the heap limit, and every node visited and string read takes fuel. Docs: docs/target-app-parity.md (E4X line), crate doc in lib.rs. --- crates/js/src/lib.rs | 7 +- crates/js/src/string_builtins.rs | 4 +- crates/js/src/xml_builtins.rs | 9 +- crates/js/src/xml_tests.rs | 467 +++++- crates/js/src/xpath.rs | 2377 ++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 6 files changed, 2857 insertions(+), 9 deletions(-) create mode 100644 crates/js/src/xpath.rs diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 7df9953a6..cad290f1a 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -41,9 +41,11 @@ //! With [`Options::e4x`], the parser also reads E4X (ECMA-357, second edition, December 2005): //! XML and XMLList literals, the `.@`, `..`, `::` and `.( )` operators, `for each … in` and //! `default xml namespace`; the realm then provides `XML`, `XMLList`, `Namespace`, `QName`, -//! `isXMLName` and `setDefaultXMLNamespace`. Without the option, the language and the built-ins are +//! `isXMLName` and `setDefaultXMLNamespace`, and XML and XMLList values have an `xpath` method that +//! evaluates an XPath 1.0 expression. Without the option, the language and the built-ins are //! those of ECMAScript 5.1 above. XML trees live in the heap, and parsing, copying, comparing and -//! serializing them use no native recursion and take fuel for each node. +//! serializing them, and evaluating XPath on them, use no native recursion and take fuel for each +//! node. #![forbid(unsafe_code)] pub mod ast; @@ -70,6 +72,7 @@ mod token; mod value; mod xml; mod xml_builtins; +mod xpath; pub use ast::{ AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index b3758bdf5..6b963e0ee 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -98,7 +98,7 @@ fn check_string_room(interp: &mut Interp, len: usize) -> JsResult<()> { /// Pushes `item` onto `list`, first holding the bytes of the list's new capacity when it must /// grow. -fn push_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, item: T) -> JsResult<()> { +pub(crate) fn push_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, item: T) -> JsResult<()> { if list.len() == list.capacity() { let more = list.capacity().max(4); held.add(interp, more.saturating_mul(size_of::()))?; @@ -179,7 +179,7 @@ fn clamp(n: f64, len: usize) -> usize { /// The first index at `from` or after where `needle` occurs in `hay`. Every position tried takes /// fuel. -fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult> { +pub(crate) fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult> { if needle.len() > hay.len() { return Ok(None); } diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index c74b9fac2..9cd797f48 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -14,6 +14,7 @@ use crate::value::{Str, Value}; use crate::xml::{ Access, Content, NameRef, RESERVED_NS_BINDING, STAR, Xv, canonical_index, is_ncname, is_script_xml_name, ns_binding_allowed, xml_name_writable, }; +use crate::xpath::xpath; const SETTINGS: [(&str, Value); 5] = [ ("ignoreComments", Value::Bool(false)), @@ -45,7 +46,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { method(interp, xml, "settings", settings, 0)?; method(interp, xml, "setSettings", set_settings, 1)?; method(interp, xml, "defaultSettings", default_settings, 0)?; - let xml_methods: [(&'static str, NativeFn, u32); 39] = [ + let xml_methods: [(&'static str, NativeFn, u32); 40] = [ ("addNamespace", add_namespace, 1), ("appendChild", append_child, 1), ("attribute", attribute, 1), @@ -85,6 +86,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { ("toString", to_string, 0), ("toXMLString", to_xml_string, 0), ("valueOf", value_of, 0), + ("xpath", xpath, 1), ]; for (name, f, n) in xml_methods { method(interp, xml_proto, name, f, n)?; @@ -92,7 +94,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { let list = native(interp, "XMLList", xml_list_ctor, 1, NativeKind::Plain, true)?; constructor(interp, "XMLList", list, list_proto)?; - let list_methods: [(&'static str, NativeFn, u32); 21] = [ + let list_methods: [(&'static str, NativeFn, u32); 22] = [ ("attribute", attribute, 1), ("attributes", attributes, 0), ("child", child, 1), @@ -114,6 +116,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { ("toString", to_string, 0), ("toXMLString", to_xml_string, 0), ("valueOf", value_of, 0), + ("xpath", xpath, 1), ]; for (name, f, n) in list_methods { method(interp, list_proto, name, f, n)?; @@ -665,7 +668,7 @@ fn has_simple_content(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> /// The namespaces in scope at `x`, nearest declarations first, one for each prefix. Takes one unit /// of fuel for each node it climbs through and for each declaration it reads. -fn scope_of(interp: &mut Interp, x: Option) -> JsResult> { +pub(crate) fn scope_of(interp: &mut Interp, x: Option) -> JsResult> { let mut out: Vec = Vec::new(); let mut seen: HashSet> = HashSet::new(); let mut cur = x; diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index cfe508a48..a25d86746 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -372,7 +372,7 @@ fn the_xml_methods_behave_as_specified() { assert_eq!(string("(1).toString()"), "1"); assert_eq!(string("().toString()"), "\n \n"); truth("var x = ; x.valueOf() === x"); - truth("!Object.prototype.hasOwnProperty.call(XML.prototype, 'xpath') && !Object.prototype.hasOwnProperty.call(XMLList.prototype, 'xpath')"); + truth("typeof XML.prototype.xpath === 'function' && typeof XMLList.prototype.xpath === 'function' && XML.prototype.xpath.length === 1"); truth("var x = ; x.b.attributes !== undefined"); } @@ -1090,6 +1090,471 @@ fn an_interrupt_stops_serialization() { assert_eq!(r.run("stop(); x.toXMLString()").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted))); } +// ---- xpath ---- + +/// A script function that joins the local names of a list's items. +const NAMES: &str = "function names(l) { var s = ''; for each (var n in l) s += n.localName(); return s; }"; + +#[test] +fn xpath_selects_child_attribute_and_text_nodes() { + for check in [ + "xml.xpath('book').length() === 4", + "xml.xpath('book[2]/title') == 'Oz'", + "xml.xpath('book[last()]/title') == 'Opera'", + "xml.xpath('book[position() < 3]').length() === 2", + "xml.xpath('book[year > 1890]/title').length() === 3", + "xml.xpath('book[count(author) > 1]/title') == 'Alice'", + "xml.xpath('book[@category = \"CHILDREN\"]/title').length() === 2", + "xml.xpath('//author').length() === 6", + "xml.xpath('book/@category').length() === 4 && xml.xpath('book/@category')[0].nodeKind() === 'attribute'", + "xml.xpath('book[1]/title/text()')[0].nodeKind() === 'text' && xml.xpath('book[1]/title/text()') == 'Cookbook'", + "xml.xpath('book[1]/title/@lang') == 'en'", + "xml.xpath('nothing').length() === 0", + "xml.xpath('child::book[3]/child::author[2]') == 'B'", + "xml.xpath('*').length() === 4 && xml.xpath('book/node()').length() === 14", + ] { + truth(&format!("{BOOKS} {check}")); + } +} + +#[test] +fn xpath_returns_the_nodes_of_the_tree() { + truth(&format!("{BOOKS} xml.book[1].title[0].xpath('..')[0] === xml.book[1]")); + truth(&format!("{BOOKS} xml.xpath('book[2]')[0] === xml.book[1] && xml.xpath('.')[0] === xml")); + truth(&format!("{BOOKS} xml.xpath('book[1]/year')[0].setChildren('2000'); xml.book[0].year == '2000'")); + truth(&format!("{BOOKS} xml.xpath('book/@category')[1].setName('kind'); xml.book[1].@kind == 'CHILDREN'")); + truth("var x = t; x.a.text()[0].xpath('..')[0] === x.a[0]"); +} + +#[test] +fn xpath_absolute_paths_start_above_the_topmost_element() { + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/bookstore/book').length() === 4")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/*')[0] === xml")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('//title').length() === 4")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/title').length() === 0")); + truth("var d = ; d.e[0].xpath('/d')[0] === d"); + for src in ["().xpath('/')", "().xpath('..')", "().xpath('ancestor-or-self::node()')"] { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_walks_every_axis() { + let tree = "var x = ;"; + for (expr, want) in [ + ("x.a[0].xpath('following-sibling::*')", "df"), + ("x.f[0].xpath('preceding-sibling::*')", "ad"), + ("x.f[0].xpath('preceding-sibling::*[1]')", "d"), + ("x.d.e[0].xpath('ancestor::*')", "rd"), + ("x.d.e[0].xpath('ancestor::*[1]')", "d"), + ("x.d.e[0].xpath('ancestor-or-self::*')", "rde"), + ("x.a.b[0].xpath('following::*')", "cdef"), + ("x.d.e[0].xpath('preceding::*')", "abc"), + ("x.d.e[0].xpath('preceding::*[1]')", "c"), + ("x.xpath('descendant::*')", "abcdef"), + ("x.xpath('descendant-or-self::*')", "rabcdef"), + ("x.a[0].xpath('self::a')", "a"), + ("x.a[0].xpath('self::b')", ""), + ("x.a.b[0].xpath('parent::*')", "a"), + ("x.xpath('//*[2]')", "cd"), + ("x.xpath('(//*)[2]')", "a"), + ("x.xpath('d/e/../preceding-sibling::*/c')", "c"), + ] { + assert_eq!(string(&format!("{NAMES} {tree} names({expr})")), want, "{expr}"); + } + let attr = "var x = ;"; + assert_eq!(string(&format!("{NAMES} {attr} names(x.a.@p[0].xpath('following::*'))")), "bc"); + assert_eq!(string(&format!("{NAMES} {attr} names(x.a.@p[0].xpath('ancestor::*'))")), "ra"); + truth(&format!("{attr} x.a.@p[0].xpath('following-sibling::node()').length() === 0 && x.a.@p[0].xpath('preceding::*').length() === 0")); +} + +#[test] +fn xpath_results_are_in_document_order_without_repeats() { + let tree = "var x = ;"; + for (expr, want) in [ + ("x.xpath('c | a | //b')", "abbc"), + ("x.xpath('a | a')", "a"), + ("x.xpath('//b/..')", "ra"), + ("x.xpath('//*/ancestor::*')", "ra"), + ("x.c[0].xpath('preceding::*[position() < 3]')", "bb"), + ] { + assert_eq!(string(&format!("{NAMES} {tree} names({expr})")), want, "{expr}"); + } + truth(&format!("{tree} x.xpath('c | @p')[0].nodeKind() === 'attribute' && x.xpath('c | @p').length() === 2")); +} + +#[test] +fn xpath_names_match_by_namespace_uri() { + let tree = "var x = ;"; + for check in [ + "x.xpath('p:a').length() === 2", + "x.xpath('a').length() === 1 && x.xpath('a')[0].name().uri === ''", + "x.xpath('p:*').length() === 2", + "x.xpath('*').length() === 4", + "x.xpath('*[local-name() = \"a\"]').length() === 4", + "x.xpath('*[namespace-uri() = \"urn:s\"]').length() === 1", + "x.xpath('*[name() = \"p:a\"]').length() === 1", + ] { + truth(&format!("{tree} {check}")); + } + truth("var x = ; x.xpath('a').length() === 0 && x.xpath('*').length() === 1"); + truth("var x = ; default xml namespace = 'urn:d'; x.xpath('a').length() === 1"); + truth("var x = ; x.xpath('a/@xml:lang') == 'en'"); + let rebound = "var x = ;"; + truth(&format!("{rebound} x.xpath('//p:a').length() === 1 && x.xpath('//p:a')[0].parent() === x")); + truth(&format!("{rebound} x.b[0].xpath('p:a').length() === 1 && x.b[0].xpath('/r/p:a').length() === 0")); + assert_eq!(thrown_name("().xpath('z:a')"), "TypeError"); +} + +#[test] +fn xpath_namespace_nodes_can_be_tested_but_not_returned() { + let tree = "var x = ;"; + truth(&format!("{tree} x.xpath('self::*[namespace::xml]').length() === 1")); + truth(&format!("{tree} x.xpath('a[count(namespace::*) = 3]').length() === 1")); + truth(&format!("{tree} x.xpath('a[namespace::q = \"urn:q\"]').length() === 1 && x.xpath('self::*[namespace::q]').length() === 0")); + truth(&format!("{tree} x.xpath('a[namespace::*[name() = \"p\"]/.. = .]').length() === 1")); + assert_eq!(thrown_name(&format!("{tree} x.xpath('namespace::*')")), "TypeError"); +} + +#[test] +fn xpath_variables_come_from_the_object_properties() { + truth(&format!("{BOOKS} xml.xpath('book[title = $t]/author', {{ t: 'Oz' }}) == 'Baum'")); + truth(&format!("{BOOKS} xml.xpath('book[$n]/title', {{ n: 3 }}) == 'Alice'")); + truth(&format!("{BOOKS} xml.xpath('book[$yes]', {{ yes: true }}).length() === 4 && xml.xpath('book[$no]', {{ no: false }}).length() === 0")); + truth(&format!("{BOOKS} xml.xpath('$b/title', {{ b: xml.book[1] }}) == 'Oz'")); + truth(&format!("{BOOKS} xml.xpath('$l/year', {{ l: xml.book.(@category == 'CHILDREN') }}).length() === 2")); + truth(&format!("{BOOKS} xml.xpath('book[year = $y]/title', {{ y: {{ toString: function () {{ return '1922'; }} }} }}) == 'Opera'")); + truth(&format!( + "{BOOKS} var o = {{}}; Object.defineProperty(o, 'v', {{ get: function () {{ return 'Alice'; }} }}); xml.xpath('book[title = $v]', o).length() === 1" + )); + for src in + ["().xpath('$v')", "().xpath('$v', {})", "().xpath('$v', { v: undefined })", "().xpath('a', 5)", "().xpath('a', null)"] + { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_keeps_the_items_of_a_list_variable_that_a_later_getter_empties() { + let setup = "var l = <>;"; + let action = "var o = { get v() { return l; }, get w() { delete l[0]; l = null; squeeze(100000000); return 1; } }; + var r = ().xpath('$v/b | $v[$w]', o); r.length() === 2 && r[0].localName() === 'a' && r[1].localName() === 'b'"; + assert_eq!(after_setup(setup, action), Ok(JsValue::Bool(true))); +} + +#[test] +fn xpath_functions_and_operators_follow_xpath_1() { + let tree = "var x = 12x y12.5;"; + let checks = [ + "last() = 1 and position() = 1", + "count(a) = 2", + "count(id('a')) = 0", + "local-name() = 'r' and name(a) = 'a' and namespace-uri() = ''", + "local-name(nothing) = '' and name(@xml:lang) = 'xml:lang'", + "string(a) = '1' and string() = '12x y12.5' and string(nothing) = ''", + "concat('a', 'b', 'c') = 'abc'", + "starts-with('abc', 'ab') and not(starts-with('abc', 'b'))", + "contains('abc', 'bc') and contains('abc', '') and not(contains('abc', 'd'))", + "substring-before('1999/04/01', '/') = '1999' and substring-before('abc', 'd') = ''", + "substring-after('1999/04/01', '/') = '04/01' and substring-after('abc', '') = 'abc'", + "substring('12345', 2, 3) = '234' and substring('12345', 2) = '2345'", + "substring('12345', 1.5, 2.6) = '234' and substring('12345', 0, 3) = '12'", + "substring('12345', 0 div 0, 3) = '' and substring('12345', 1, 0 div 0) = ''", + "substring('12345', -42, 1 div 0) = '12345' and substring('12345', -1 div 0, 1 div 0) = ''", + "string-length('abc') = 3 and string-length(b) = 4 and string-length() = 10", + "normalize-space(b) = 'x y' and normalize-space(' x \t y ') = 'x y' and normalize-space(' ') = ''", + "translate('bar', 'abc', 'ABC') = 'BAr' and translate('--aaa--', 'abc-', 'ABC') = 'AAA'", + "boolean(a) and not(boolean(nothing)) and boolean('x') and not(boolean('')) and not(boolean(0)) and not(boolean(0 div 0))", + "true() and not(false())", + "lang('en') and lang('EN-us') and not(lang('fr')) and a[lang('en')]", + "number(n) = 12.5 and number(' -3 ') = -3 and string(number('1e3')) = 'NaN' and string(number('+1')) = 'NaN'", + "sum(a) = 3 and sum(nothing) = 0", + "floor(2.5) = 2 and ceiling(2.1) = 3 and floor(-2.5) = -3", + "round(2.5) = 3 and round(-2.5) = -2 and round(2.4) = 2 and string(round(-0.4)) = '0'", + "string(1 div 0) = 'Infinity' and string(-1 div 0) = '-Infinity' and string(0 div 0) = 'NaN'", + "string(1.5) = '1.5' and string(2) = '2' and string(-0.25) = '-0.25' and string(1 div 3) = '0.3333333333333333'", + "string(1000000 * 1000000 * 1000000 * 1000) = '1000000000000000000000'", + "7 mod 3 = 1 and -7 mod 3 = -1 and 5 div 2 = 2.5 and 2 - 3 = -1 and --1 = 1 and -(1 + 1) = -2", + "1 + 2 * 3 = 7 and (1 + 2) * 3 = 9 and 3 > 2 > 1 = false()", + "a = 2 and a = '1' and a != 1 and a > 1 and not(a > 2) and a < 2 and 1 < a and 2 >= a", + "a = a and a != a and not(a[1] != a[1]) and not(nothing = nothing) and not(nothing != a) and a < a", + "true() = a and false() = nothing and '1' = 1 and true() = 'x' and 1 = true() and not(0 = true())", + "a > false() and not(a > true()) and nothing < true() and not(nothing >= true())", + "1 < 2 and 2 > 1 or false()", + "false() and 1 div 0 or true()", + "'a' < 'b' = false() and '1' < '2'", + "*[2] = 2 and *[last()] = 12.5", + "(a | b)[2] = 2", + ]; + for check in checks { + let expr = format!("self::*[{check}]"); + truth(&format!("{tree} x.xpath({expr:?}).length() === 1")); + } +} + +#[test] +fn xpath_throws_a_type_error_for_a_value_that_is_not_a_node_set() { + for expr in ["count(book)", "string(book)", "1", "'a'", "true()", "book = 1", "-book"] { + let src = format!("{BOOKS} try {{ xml.xpath({expr:?}); 'no error' }} catch (e) {{ e.name + ': ' + e.message }}"); + assert_eq!(string(&src), "TypeError: the XPath expression does not evaluate to a node-set", "{expr}"); + } +} + +#[test] +fn xpath_errors_name_their_cause() { + for expr in [ + "", + " ", + "/[1]", + "book[", + "book]", + "(", + ")", + "1 +", + "book/", + "@", + "foo::bar", + ".[1]", + "..[1]", + "'abc", + "$", + "book book", + "!", + "a//", + ",", + "count(", + "count(1,", + "a::", + "::a", + "a:", + "1..2", + "book[1]]", + "count(book))", + "//", + "/ /a", + "(1)(2)", + "processing-instruction(1)", + "node(1)", + "*:a", + "a:b:c", + ] { + assert_eq!(thrown_name(&format!("{BOOKS} xml.xpath({expr:?})")), "SyntaxError", "{expr:?}"); + } + for expr in + ["foo()", "count()", "count(1)", "p:count(book)", "sum('1')", "1 | book", "'a'/b", "'a'[1]", "concat('a')", "name(1)", "substring('a')"] + { + assert_eq!(thrown_name(&format!("{BOOKS} xml.xpath({expr:?})")), "TypeError", "{expr:?}"); + } + for src in ["().xpath()", "().xpath(5)", "().xpath(new String('a'))", "XML.prototype.xpath.call({}, 'a')"] { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_on_an_xml_list_runs_on_each_element_in_turn() { + truth( + "var l = <>123; l.xpath('b').length() === 3 && l.xpath('b').toXMLString() === '1\\n2\\n3'", + ); + truth("var l = <>123; l.xpath('b[1]').length() === 2"); + truth("var x = ; var l = x.*; var p = l.xpath('..'); p.length() === 2 && p[0] === x && p[1] === x"); + truth("var x = t; var l = x.a.text() + x.a; l.xpath('.').length() === 1 && l.xpath('.')[0] === x.a[0]"); + truth("var x = ; (x.@p + x.comments()).xpath('.').length() === 0"); + truth("new XMLList().xpath('a').length() === 0 && new XMLList().xpath('z:a').length() === 0"); + truth("var l = <>; l.xpath('p:b').length() === 2"); + assert_eq!(thrown_name("var l = <>; l.xpath('p:b')"), "TypeError"); +} + +#[test] +fn xpath_expressions_of_any_depth_use_no_native_stack() { + let deep = format!("{}1{}", "(".repeat(100_000), ")".repeat(100_000)); + assert_eq!(thrown_name(&format!("().xpath('{deep}')")), "SyntaxError"); + let predicates = format!("{}{}", "a[".repeat(100_000), "]".repeat(100_000)); + assert_eq!(thrown_name(&format!("().xpath('{predicates}')")), "SyntaxError"); + let sum = format!("1{}", " + 1".repeat(100_000)); + truth(&format!("().xpath('self::*[{sum} = 100001]').length() === 1")); + let negated = format!("{}1", "-".repeat(100_001)); + truth(&format!("().xpath('self::*[{negated} = -1]').length() === 1")); + let union = format!("a{}", " | a".repeat(50_000)); + truth(&format!("().xpath('{union}').length() === 1")); + let mut shallow = Realm::with_options(Options { max_depth: 8, ..e4x() }, Limits::default()); + let seven = format!("{}1{}", "(".repeat(6), ")".repeat(6)); + assert_eq!(shallow.run(&format!("().xpath('self::*[{seven}]').length()")), Ok(JsValue::Number(1.0))); + let eight = format!("{}1{}", "(".repeat(8), ")".repeat(8)); + assert_eq!(shallow.run(&format!("try {{ ().xpath('{eight}') }} catch (e) {{ e.message }}")), Ok(JsValue::String("nesting too deep".into()))); +} + +#[test] +fn xpath_stops_at_the_fuel_and_heap_limits_leaving_the_tree() { + let setup = "var x = ; for (var i = 0; i < 6; i++) x.appendChild({i});"; + let check = "x.a.length() === 6 && x.a[5].b == '5' && x.*.length() === 6"; + assert!(fuel_stops(setup, "x.xpath('//b[. > 2]/preceding::*')", check) > 0); + assert!(heap_stops(setup, "x.xpath((SPARE, '//b[contains(concat(., ., .), \"3\")] | //a'))", check) > 0); + let mut r = realm(); + r.run("var x = ; for (var i = 0; i < 500; i++) x.appendChild();").unwrap(); + let interrupt = r.interrupt(); + r.define_function("stop", move |_| { + interrupt.set(); + Ok(JsValue::Undefined) + }); + assert_eq!(r.run("stop(); x.xpath('//a/following::a')").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted))); +} + +#[test] +fn xpath_walks_of_deep_trees_and_long_lists_count_against_the_heap_limit() { + let deep = "var s = ''; for (var i = 0; i < 5000; i++) s += ''; s += ''; for (var i = 0; i < 5000; i++) s += ''; var x = new XML(s);"; + for (action, room) in [ + ("x.xpath('descendant::c').length()", JsValue::Number(0.0)), + ("x.xpath('self::*[string() = \"q\"]').length()", JsValue::Number(0.0)), + ("x.xpath('descendant::b | self::a').length()", JsValue::Number(2.0)), + ] { + for (spare, want) in [(20_000, Err(RunErrorKind::Limit(LimitKind::Heap))), (50_000_000, Ok(room.clone()))] { + let mut r = Realm::with_options(Options { max_depth: 1_000_000, ..e4x() }, Limits::default()); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + r.run(deep).unwrap(); + assert_eq!(r.run(&format!("squeeze({spare}); {action}")).map_err(|e| e.kind), want, "{action} {spare}"); + } + } + let long = "var s = ''; for (var i = 0; i < 20000; i++) s += ''; var l = new XML(s + '').a;"; + assert_eq!(after_setup(long, "squeeze(50000); l.xpath('self::c').length()"), Err(RunErrorKind::Limit(LimitKind::Heap))); + assert_eq!(after_setup(long, "squeeze(50000000); l.xpath('self::c').length()"), Ok(JsValue::Number(0.0))); +} + +#[test] +fn xpath_reading_a_long_string_takes_fuel_for_its_length() { + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var x = ; for (var i = 0; i < 200; i++) x.appendChild(y);"; + for expr in ["a[string-length($s) = 1]", "a[starts-with($s, $s) = false()]", "a[$s = concat('', 'x')]", "a[number($s) = 1]", "a[. = $s]"] { + let mut r = realm(); + r.run(setup).unwrap(); + let src = format!("x.xpath({expr:?}, {{ s: s }}).length()"); + assert_eq!(r.run(&src), Ok(JsValue::Number(0.0)), "{expr}"); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run(&src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)), "{expr}"); + } +} + +#[test] +fn xpath_string_functions_hold_the_bytes_they_build() { + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var d = '1'; for (var i = 0; i < 20; i++) d += d; var o = { s: s, d: d };"; + for (expr, spare) in [("translate('a', 'b', $s) = 'a'", 1_000_000), ("number($d) > 1", 500_000), ("substring($s, 1) != 'z'", 4_000_000)] { + let action = |spare: u32| format!("squeeze({spare}); ().xpath(\"self::*[{expr}]\", o).length()"); + assert_eq!(after_setup(setup, &action(spare)), Err(RunErrorKind::Limit(LimitKind::Heap)), "{expr}"); + assert_eq!(after_setup(setup, &action(100_000_000)), Ok(JsValue::Number(1.0)), "{expr}"); + } +} + +#[test] +fn xpath_string_variables_hold_the_bytes_of_their_flat_copy() { + // The setup ends with `0` so that its completion value does not flatten `s`. + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var o = { s: s }; 0;"; + let action = |spare: u32| format!("squeeze({spare}); ().xpath('self::*[$s]', o).length()"); + assert_eq!(after_setup(setup, &action(1_000_000)), Err(RunErrorKind::Limit(LimitKind::Heap))); + assert_eq!(after_setup(setup, &action(100_000_000)), Ok(JsValue::Number(1.0))); +} + +#[test] +fn xpath_substring_searches_take_fuel_for_every_position_tried() { + let setup = "var h = 'a'; for (var i = 0; i < 16; i++) h += h; var n = h.substring(0, 4095) + 'b'; var o = { h: h, n: n };"; + for expr in ["contains($h, $n)", "substring-before($h, $n) = ''", "substring-after($h, $n) = ''"] { + let mut r = realm(); + r.run(setup).unwrap(); + let src = format!("().xpath(\"self::*[{expr}]\", o).length()"); + let want = if expr.starts_with("contains") { 0.0 } else { 1.0 }; + assert_eq!(r.run(&src), Ok(JsValue::Number(want)), "{expr}"); + r.set_limits(Limits { fuel: 5_000_000, ..Limits::default() }); + assert_eq!(r.run(&src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)), "{expr}"); + } +} + +#[test] +fn xpath_lang_takes_fuel_for_every_attribute_it_reads() { + let setup = "var s = '');"; + let mut r = realm(); + r.run(setup).unwrap(); + let src = "x.xpath('a[lang(\"en\")]').length()"; + assert_eq!(r.run(src), Ok(JsValue::Number(0.0))); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run(src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel))); +} + +#[test] +fn xpath_on_a_large_tree_ends_within_the_default_limits() { + let setup = "var x = ; for (var i = 0; i < 3000; i++) x.appendChild();"; + truth(&format!("{setup} x.xpath('//a[@n = 2999]/b').length() === 1 && x.xpath('//b/..').length() === 3000")); + let mut r = realm(); + r.run("var x = ; for (var i = 0; i < 2000; i++) x.appendChild();").unwrap(); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run("x.xpath('//a/following::a')").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel))); +} + +const XPATH_PIECES: &[&str] = &[ + "/", + "//", + "a", + "b", + "*", + "@", + "@*", + ".", + "..", + "[", + "]", + "(", + ")", + "1", + "'x'", + "$v", + "|", + "+", + "-", + "=", + "!=", + "<", + ">=", + " and ", + " or ", + " div ", + " mod ", + "child::", + "following::", + "preceding::", + "ancestor::", + "namespace::", + "node()", + "text()", + "count(", + "last()", + "position()", + "concat(", + "substring(", + ",", + "p:", + "p:*", + "xml:lang", + "::", + ":", + "'", + " ", +]; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn random_xpath_expressions_select_nodes_or_throw(parts in proptest::collection::vec(0..XPATH_PIECES.len(), 0..30)) { + let expr: String = parts.iter().map(|&i| XPATH_PIECES[i]).collect(); + let quoted = format!("{expr:?}"); + let src = format!("var x = 23t; typeof XML.prototype.xpath === 'function' && x.a[0].xpath({quoted}, {{ v: 1 }}).length() >= 0"); + let mut r = limited(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + match r.run(&src) { + Ok(v) => prop_assert_eq!(v, JsValue::Bool(true), "{}", expr), + Err(e) => prop_assert!(matches!(e.kind, RunErrorKind::Thrown | RunErrorKind::Limit(_)), "{expr:?}: {e:?}"), + } + } +} + // ---- generated input ---- const PIECES: &[&str] = &[ diff --git a/crates/js/src/xpath.rs b/crates/js/src/xpath.rs new file mode 100644 index 000000000..30707e4de --- /dev/null +++ b/crates/js/src/xpath.rs @@ -0,0 +1,2377 @@ +//! `xpath` on XML and XMLList values: XPath 1.0 expressions +//! () evaluated on E4X trees. +//! +//! An expression is read into tokens, then into a syntax tree whose nodes live in one arena, and is +//! evaluated with an explicit task stack, so neither step uses native recursion. Brackets nest at +//! most [`crate::Options::max_depth`] deep. Every token, task and visited node takes fuel, and the +//! token, tree, node-set and string buffers are held against the heap limit while they live. +//! +//! `/` is a root node above the topmost node of the context node's tree. A node-set result is +//! returned as an XMLList of the selected nodes themselves, in document order with attributes +//! before children. The root node and namespace nodes can be selected and tested inside an +//! expression, but a result that holds one throws a `TypeError`, since E4X has no such value. The +//! tree keeps no DTD, so `id()` selects nothing. Prefixes in the expression are read from the +//! namespaces in scope on the context element, and `xml` is always bound. + +use std::collections::{HashMap, HashSet}; +use std::mem::replace; +use std::rc::Rc; + +use crate::builtins::arg; +use crate::error::TOO_DEEP; +use crate::heap::{Key, ObjId, XmlKind, XmlName, XmlNs, key}; +use crate::interp::{Abrupt, ErrorType, HeldBytes, Interp, JsResult}; +use crate::regexp_vm::use_fuel_n; +use crate::string_builtins::{self, charge_units, flat_charged, push_held}; +use crate::value::Value; +use crate::xml::{Xv, is_name_part, is_name_start, is_xml_space, valid_xml_units}; +use crate::xml_builtins::scope_of; + +const XML_URI: &str = "http://www.w3.org/XML/1998/namespace"; +const COLON: u16 = b':' as u16; +const STAR: u16 = b'*' as u16; +const SLASH: u16 = b'/' as u16; +const DOT: u16 = b'.' as u16; +const EQUALS: u16 = b'=' as u16; +const DASH: u16 = b'-' as u16; +/// The estimated bytes of a syntax tree node's own lists, beyond its slot in the arena. +const EXPR_BYTES: usize = 64; +/// The estimated bytes of one entry of a hash map or set the evaluator keeps. +const ENTRY_BYTES: usize = 64; + +fn empty() -> Key { + Rc::from(Vec::new()) +} + +fn syntax(interp: &mut Interp, message: &str) -> Abrupt { + interp.throw(ErrorType::Syntax, message) +} + +fn internal() -> Abrupt { + Abrupt::Internal("XPath evaluation state".into()) +} + +fn is_digit(c: u16) -> bool { + (0x30..=0x39).contains(&c) +} + +// ---- tokens ---- + +#[derive(Clone, Copy, PartialEq)] +enum Punct { + LParen, + RParen, + LBracket, + RBracket, + Dot, + DotDot, + At, + Comma, + ColonColon, + Slash, + SlashSlash, + Pipe, + Plus, + Minus, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +/// A token as the lexer reads it, before names and `*` are told apart by their position. +enum Raw { + Punct(Punct), + Star, + Name(Option, Key), + PrefixStar(Key), + Literal(Key), + Number(f64), + Var(Key), +} + +#[derive(Clone, Copy, PartialEq)] +enum Axis { + Ancestor, + AncestorOrSelf, + Attribute, + Child, + Descendant, + DescendantOrSelf, + Following, + FollowingSibling, + Namespace, + Parent, + Preceding, + PrecedingSibling, + Itself, +} + +const AXES: [(&str, Axis); 13] = [ + ("ancestor", Axis::Ancestor), + ("ancestor-or-self", Axis::AncestorOrSelf), + ("attribute", Axis::Attribute), + ("child", Axis::Child), + ("descendant", Axis::Descendant), + ("descendant-or-self", Axis::DescendantOrSelf), + ("following", Axis::Following), + ("following-sibling", Axis::FollowingSibling), + ("namespace", Axis::Namespace), + ("parent", Axis::Parent), + ("preceding", Axis::Preceding), + ("preceding-sibling", Axis::PrecedingSibling), + ("self", Axis::Itself), +]; + +impl Axis { + /// Whether the axis lists its nodes in reverse document order. + fn reverse(self) -> bool { + matches!(self, Axis::Ancestor | Axis::AncestorOrSelf | Axis::Preceding | Axis::PrecedingSibling) + } +} + +/// A node test. A prefix is resolved when the expression runs. +#[derive(Clone)] +enum Test { + Any, + Prefix(Key), + Name(Option, Key), + Node, + Text, + Comment, + Pi(Option), +} + +#[derive(Clone, Copy, PartialEq)] +enum Bin { + Or, + And, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + Add, + Sub, + Mul, + Div, + Mod, + Union, +} + +impl Bin { + fn precedence(self) -> u8 { + match self { + Bin::Or => 1, + Bin::And => 2, + Bin::Eq | Bin::Ne => 3, + Bin::Lt | Bin::Le | Bin::Gt | Bin::Ge => 4, + Bin::Add | Bin::Sub => 5, + Bin::Mul | Bin::Div | Bin::Mod => 6, + Bin::Union => 8, + } + } +} + +/// The precedence of unary minus: tighter than `*`, looser than `|`. +const NEG_PRECEDENCE: u8 = 7; + +#[derive(Clone, Copy, PartialEq)] +enum Func { + Last, + Position, + Count, + Id, + LocalName, + NamespaceUri, + Name, + String, + Concat, + StartsWith, + Contains, + SubstringBefore, + SubstringAfter, + Substring, + StringLength, + NormalizeSpace, + Translate, + Boolean, + Not, + True, + False, + Lang, + Number, + Sum, + Floor, + Ceiling, + Round, +} + +/// The core function library (XPath 1.0 section 4): each name, and its least and most arguments. +const FUNCS: [(&str, Func, usize, usize); 27] = [ + ("last", Func::Last, 0, 0), + ("position", Func::Position, 0, 0), + ("count", Func::Count, 1, 1), + ("id", Func::Id, 1, 1), + ("local-name", Func::LocalName, 0, 1), + ("namespace-uri", Func::NamespaceUri, 0, 1), + ("name", Func::Name, 0, 1), + ("string", Func::String, 0, 1), + ("concat", Func::Concat, 2, usize::MAX), + ("starts-with", Func::StartsWith, 2, 2), + ("contains", Func::Contains, 2, 2), + ("substring-before", Func::SubstringBefore, 2, 2), + ("substring-after", Func::SubstringAfter, 2, 2), + ("substring", Func::Substring, 2, 3), + ("string-length", Func::StringLength, 0, 1), + ("normalize-space", Func::NormalizeSpace, 0, 1), + ("translate", Func::Translate, 3, 3), + ("boolean", Func::Boolean, 1, 1), + ("not", Func::Not, 1, 1), + ("true", Func::True, 0, 0), + ("false", Func::False, 0, 0), + ("lang", Func::Lang, 1, 1), + ("number", Func::Number, 0, 1), + ("sum", Func::Sum, 1, 1), + ("floor", Func::Floor, 1, 1), + ("ceiling", Func::Ceiling, 1, 1), + ("round", Func::Round, 1, 1), +]; + +/// A token the parser reads: names and `*` with their roles decided (XPath 1.0 section 3.7). +enum Tok { + Punct(Punct), + Op(Bin), + Test(Test), + Axis(Axis), + Func(Func, &'static str), + Literal(Key), + Number(f64), + Var(Key), +} + +fn ncname_end(src: &[u16], at: usize) -> Option { + let first = *src.get(at)?; + if first == COLON || !is_name_start(first) { + return None; + } + let mut end = at + 1; + while src.get(end).is_some_and(|&c| c != COLON && is_name_part(c)) { + end += 1; + } + valid_xml_units(src.get(at..end)?).then_some(end) +} + +fn lex_name(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let end = ncname_end(src, at)?; + let first: Key = Rc::from(src.get(at..end)?); + if src.get(end) != Some(&COLON) || src.get(end + 1) == Some(&COLON) { + return Some((Raw::Name(None, first), end)); + } + if src.get(end + 1) == Some(&STAR) { + return Some((Raw::PrefixStar(first), end + 2)); + } + let local_end = ncname_end(src, end + 1)?; + Some((Raw::Name(Some(first), Rc::from(src.get(end + 1..local_end)?)), local_end)) +} + +fn lex_number(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let mut end = at; + while src.get(end).copied().is_some_and(is_digit) { + end += 1; + } + if src.get(end) == Some(&DOT) { + end += 1; + while src.get(end).copied().is_some_and(is_digit) { + end += 1; + } + } + let text = String::from_utf16(src.get(at..end)?).ok()?; + Some((Raw::Number(text.parse().ok()?), end)) +} + +fn lex_literal(src: &[u16], at: usize, quote: u16) -> Option<(Raw, usize)> { + let rest = src.get(at + 1..)?; + let len = rest.iter().position(|&c| c == quote)?; + Some((Raw::Literal(Rc::from(rest.get(..len)?)), at + len + 2)) +} + +fn lex_var(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let (Raw::Name(..), end) = lex_name(src, at + 1)? else { return None }; + Some((Raw::Var(Rc::from(src.get(at + 1..end)?)), end)) +} + +/// The token that starts at `src[at]`, and the offset after it. +fn lex_one(src: &[u16], at: usize, c: u16) -> Option<(Raw, usize)> { + if c != COLON && is_name_start(c) { + return lex_name(src, at); + } + let next = src.get(at + 1).copied(); + let p = |p: Punct, n: usize| Some((Raw::Punct(p), at + n)); + match char::from_u32(u32::from(c))? { + '(' => p(Punct::LParen, 1), + ')' => p(Punct::RParen, 1), + '[' => p(Punct::LBracket, 1), + ']' => p(Punct::RBracket, 1), + '@' => p(Punct::At, 1), + ',' => p(Punct::Comma, 1), + '|' => p(Punct::Pipe, 1), + '+' => p(Punct::Plus, 1), + '-' => p(Punct::Minus, 1), + '=' => p(Punct::Eq, 1), + '*' => Some((Raw::Star, at + 1)), + '/' if next == Some(SLASH) => p(Punct::SlashSlash, 2), + '/' => p(Punct::Slash, 1), + ':' if next == Some(COLON) => p(Punct::ColonColon, 2), + '!' if next == Some(EQUALS) => p(Punct::Ne, 2), + '<' if next == Some(EQUALS) => p(Punct::Le, 2), + '<' => p(Punct::Lt, 1), + '>' if next == Some(EQUALS) => p(Punct::Ge, 2), + '>' => p(Punct::Gt, 1), + '.' if next == Some(DOT) => p(Punct::DotDot, 2), + '.' if next.is_some_and(is_digit) => lex_number(src, at), + '.' => p(Punct::Dot, 1), + '"' | '\'' => lex_literal(src, at, c), + '$' => lex_var(src, at), + _ if is_digit(c) => lex_number(src, at), + _ => None, + } +} + +fn lex(interp: &mut Interp, held: &mut HeldBytes, src: &[u16]) -> JsResult> { + let mut out = Vec::new(); + let mut at = 0; + loop { + while src.get(at).copied().is_some_and(is_xml_space) { + at += 1; + } + let Some(&c) = src.get(at) else { return Ok(out) }; + let Some((raw, end)) = lex_one(src, at, c) else { + return Err(syntax(interp, &format!("the XPath expression cannot be read at offset {at}"))); + }; + charge_units(interp, end - at)?; + push_held(interp, held, &mut out, raw)?; + at = end; + } +} + +fn units_are(units: &[u16], text: &str) -> bool { + units.iter().copied().eq(text.encode_utf16()) +} + +fn operator_name(name: &[u16]) -> Option { + [("and", Bin::And), ("or", Bin::Or), ("mod", Bin::Mod), ("div", Bin::Div)].into_iter().find(|(n, _)| units_are(name, n)).map(|(_, b)| b) +} + +fn punct_token(p: Punct) -> Tok { + match p { + Punct::Pipe => Tok::Op(Bin::Union), + Punct::Plus => Tok::Op(Bin::Add), + Punct::Minus => Tok::Op(Bin::Sub), + Punct::Eq => Tok::Op(Bin::Eq), + Punct::Ne => Tok::Op(Bin::Ne), + Punct::Lt => Tok::Op(Bin::Lt), + Punct::Le => Tok::Op(Bin::Le), + Punct::Gt => Tok::Op(Bin::Gt), + Punct::Ge => Tok::Op(Bin::Ge), + other => Tok::Punct(other), + } +} + +/// Whether a token at this point is an operator: there is a token before it, and it is not `@`, +/// `::`, `(`, `[`, `,` or an operator (XPath 1.0 section 3.7). +fn operator_position(last: Option<&Tok>) -> bool { + last.is_some_and(|t| { + !matches!( + t, + Tok::Punct(Punct::LParen | Punct::LBracket | Punct::At | Punct::Comma | Punct::Slash | Punct::SlashSlash) + | Tok::Op(_) + | Tok::Axis(_) + | Tok::Func(..) + ) + }) +} + +/// A name followed by `(`: a node type test, which also takes its `)`, or a function call. +/// Returns the token and how many raw tokens it takes. +fn call_or_node_type(interp: &mut Interp, prefix: Option<&Key>, name: &Key, after: &[Raw]) -> JsResult<(Tok, usize)> { + if prefix.is_none() { + let plain = [("node", Test::Node), ("text", Test::Text), ("comment", Test::Comment)].into_iter().find(|(n, _)| units_are(name, n)); + if let Some((_, test)) = plain { + return match after.first() { + Some(Raw::Punct(Punct::RParen)) => Ok((Tok::Test(test), 3)), + _ => Err(syntax(interp, "an XPath node type test takes no argument")), + }; + } + if units_are(name, "processing-instruction") { + return match after { + [Raw::Punct(Punct::RParen), ..] => Ok((Tok::Test(Test::Pi(None)), 3)), + [Raw::Literal(target), Raw::Punct(Punct::RParen), ..] => Ok((Tok::Test(Test::Pi(Some(target.clone()))), 4)), + _ => Err(syntax(interp, "processing-instruction() takes one string literal or none")), + }; + } + } + let found = FUNCS.iter().find(|(n, ..)| prefix.is_none() && units_are(name, n)); + match found { + Some(&(n, f, ..)) => Ok((Tok::Func(f, n), 2)), + None => { + let shown = String::from_utf16_lossy(name); + Err(interp.type_error(&format!("{shown} is not an XPath 1.0 function"))) + } + } +} + +fn classify(interp: &mut Interp, held: &mut HeldBytes, raw: &[Raw]) -> JsResult> { + let mut out: Vec = Vec::new(); + let mut at = 0; + while let Some(r) = raw.get(at) { + interp.use_fuel()?; + let operator = operator_position(out.last()); + let next = raw.get(at + 1); + let (tok, used) = match r { + Raw::Star if operator => (Tok::Op(Bin::Mul), 1), + Raw::Star => (Tok::Test(Test::Any), 1), + Raw::Name(None, n) if operator => match operator_name(n) { + Some(op) => (Tok::Op(op), 1), + None => return Err(syntax(interp, "an XPath operator is missing")), + }, + Raw::Name(p, n) if matches!(next, Some(Raw::Punct(Punct::LParen))) => { + call_or_node_type(interp, p.as_ref(), n, raw.get(at + 2..).unwrap_or_default())? + } + Raw::Name(p, n) if matches!(next, Some(Raw::Punct(Punct::ColonColon))) => { + let axis = AXES.iter().find(|(a, _)| p.is_none() && units_are(n, a)).map(|&(_, a)| a); + match axis { + Some(a) => (Tok::Axis(a), 2), + None => return Err(syntax(interp, &format!("{} is not an XPath axis", String::from_utf16_lossy(n)))), + } + } + Raw::Name(p, n) => (Tok::Test(Test::Name(p.clone(), n.clone())), 1), + Raw::PrefixStar(p) => (Tok::Test(Test::Prefix(p.clone())), 1), + Raw::Punct(Punct::ColonColon) => return Err(syntax(interp, "an XPath axis name is missing before ::")), + Raw::Punct(p) => (punct_token(*p), 1), + Raw::Literal(s) => (Tok::Literal(s.clone()), 1), + Raw::Number(n) => (Tok::Number(*n), 1), + Raw::Var(v) => (Tok::Var(v.clone()), 1), + }; + push_held(interp, held, &mut out, tok)?; + at += used; + } + Ok(out) +} + +// ---- the syntax tree ---- + +struct Step { + axis: Axis, + test: Test, + preds: Vec, +} + +enum Expr { + Bin(Bin, usize, usize), + Neg(usize), + Num(f64), + Lit(Key), + Var(usize), + Call(Func, Vec), + /// A location path, or a filter expression with its predicates, followed by steps. + Path { + filter: Option<(usize, Vec)>, + absolute: bool, + steps: Vec, + }, +} + +/// A parsed expression: the tree in its arena, its root, and the variables and prefixes it names. +struct Parsed { + exprs: Vec, + root: usize, + vars: Vec, + prefixes: Vec, + _held: HeldBytes, +} + +#[derive(Clone, Copy)] +enum Op { + Bin(Bin), + Neg, +} + +impl Op { + fn precedence(self) -> u8 { + match self { + Op::Bin(b) => b.precedence(), + Op::Neg => NEG_PRECEDENCE, + } + } +} + +/// A path expression being read. +#[derive(Default)] +struct Operand { + filter: Option<(usize, Vec)>, + absolute: bool, + steps: Vec, + /// The last step is `.` or `..`, which take no predicate. + abbreviated: bool, +} + +impl Operand { + fn bare_root(&self) -> bool { + self.absolute && self.steps.is_empty() && self.filter.is_none() + } +} + +enum FrameKind { + Top, + Group, + Pred, + Args(Func, &'static str, Vec), +} + +/// One bracket level: its operators and operands, read by operator precedence. +struct Frame { + kind: FrameKind, + ops: Vec, + vals: Vec, + operand: Option, +} + +impl Frame { + fn new(kind: FrameKind) -> Self { + Frame { kind, ops: Vec::new(), vals: Vec::new(), operand: None } + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Want { + Operand, + Step, + Test(Axis), + After, +} + +struct Parser<'i> { + interp: &'i mut Interp, + held: HeldBytes, + exprs: Vec, + vars: Vec, + var_index: HashMap, + prefixes: HashSet, + frames: Vec, + want: Want, +} + +impl Parser<'_> { + fn error(&mut self, message: &str) -> Abrupt { + syntax(self.interp, message) + } + + fn push_expr(&mut self, e: Expr) -> JsResult { + self.held.add(self.interp, EXPR_BYTES)?; + push_held(self.interp, &mut self.held, &mut self.exprs, e)?; + Ok(self.exprs.len() - 1) + } + + fn top(&mut self) -> JsResult<&mut Frame> { + self.frames.last_mut().ok_or_else(internal) + } + + fn operand(&mut self) -> JsResult<&mut Operand> { + self.top()?.operand.as_mut().ok_or_else(internal) + } + + fn open(&mut self, kind: FrameKind) -> JsResult<()> { + if self.frames.len() >= self.interp.options.max_depth { + return Err(self.error(TOO_DEEP)); + } + self.held.add(self.interp, size_of::())?; + self.frames.push(Frame::new(kind)); + self.want = Want::Operand; + Ok(()) + } + + fn token(&mut self, tok: Tok) -> JsResult<()> { + match self.want { + Want::Operand => self.operand_token(tok), + Want::Step => self.step_token(tok), + Want::Test(axis) => match tok { + Tok::Test(test) => self.push_step(axis, test, false), + _ => Err(self.error("an XPath node test is missing")), + }, + Want::After => self.after_token(tok), + } + } + + fn primary(&mut self, e: Expr) -> JsResult<()> { + let id = self.push_expr(e)?; + self.top()?.operand = Some(Operand { filter: Some((id, Vec::new())), ..Operand::default() }); + self.want = Want::After; + Ok(()) + } + + fn operand_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Op(Bin::Sub) => { + self.top()?.ops.push(Op::Neg); + Ok(()) + } + Tok::Punct(Punct::Slash) => { + self.top()?.operand = Some(Operand { absolute: true, ..Operand::default() }); + self.want = Want::Step; + Ok(()) + } + Tok::Punct(Punct::SlashSlash) => { + self.top()?.operand = Some(Operand { absolute: true, ..Operand::default() }); + self.descendants_step() + } + Tok::Literal(s) => self.primary(Expr::Lit(s)), + Tok::Number(n) => self.primary(Expr::Num(n)), + Tok::Var(name) => { + let next = self.vars.len(); + let at = *self.var_index.entry(name.clone()).or_insert(next); + if at == next { + push_held(self.interp, &mut self.held, &mut self.vars, name)?; + } + self.primary(Expr::Var(at)) + } + Tok::Func(f, n) => self.open(FrameKind::Args(f, n, Vec::new())), + Tok::Punct(Punct::LParen) => self.open(FrameKind::Group), + Tok::Punct(Punct::RParen) if self.empty_call()? => self.close_paren(), + tok @ (Tok::Test(_) | Tok::Axis(_) | Tok::Punct(Punct::At | Punct::Dot | Punct::DotDot)) => { + self.top()?.operand = Some(Operand::default()); + self.step_token(tok) + } + _ => Err(self.error("an XPath operand is missing")), + } + } + + /// Whether the open bracket is a function call's, with nothing in it yet. + fn empty_call(&mut self) -> JsResult { + let f = self.top()?; + Ok(matches!(&f.kind, FrameKind::Args(_, _, args) if args.is_empty()) && f.ops.is_empty() && f.vals.is_empty() && f.operand.is_none()) + } + + fn descendants_step(&mut self) -> JsResult<()> { + self.operand()?.steps.push(Step { axis: Axis::DescendantOrSelf, test: Test::Node, preds: Vec::new() }); + self.want = Want::Step; + Ok(()) + } + + fn push_step(&mut self, axis: Axis, test: Test, abbreviated: bool) -> JsResult<()> { + if let Test::Prefix(p) | Test::Name(Some(p), _) = &test + && !self.prefixes.contains(p) + { + self.held.add(self.interp, ENTRY_BYTES)?; + self.prefixes.insert(p.clone()); + } + self.held.add(self.interp, size_of::())?; + let operand = self.operand()?; + operand.steps.push(Step { axis, test, preds: Vec::new() }); + operand.abbreviated = abbreviated; + self.want = Want::After; + Ok(()) + } + + fn step_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Punct(Punct::Dot) => self.push_step(Axis::Itself, Test::Node, true), + Tok::Punct(Punct::DotDot) => self.push_step(Axis::Parent, Test::Node, true), + Tok::Punct(Punct::At) => { + self.want = Want::Test(Axis::Attribute); + Ok(()) + } + Tok::Axis(a) => { + self.want = Want::Test(a); + Ok(()) + } + Tok::Test(t) => self.push_step(Axis::Child, t, false), + tok if self.operand()?.bare_root() => { + self.want = Want::After; + self.after_token(tok) + } + _ => Err(self.error("an XPath step is missing")), + } + } + + fn after_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Punct(p @ (Punct::LBracket | Punct::Slash | Punct::SlashSlash)) => { + let operand = self.operand()?; + let (bare, abbreviated) = (operand.bare_root(), operand.abbreviated); + if bare || (p == Punct::LBracket && abbreviated) { + return Err(self.error("an XPath predicate or step cannot follow here")); + } + match p { + Punct::LBracket => self.open(FrameKind::Pred), + Punct::Slash => { + self.want = Want::Step; + Ok(()) + } + _ => self.descendants_step(), + } + } + Tok::Op(b) => self.binary(b), + Tok::Punct(Punct::RParen) => self.close_paren(), + Tok::Punct(Punct::RBracket) => self.close_bracket(), + Tok::Punct(Punct::Comma) => self.next_argument(), + _ => Err(self.error("an XPath operator is missing")), + } + } + + fn finish_operand(&mut self, operand: Operand) -> JsResult { + match operand { + Operand { filter: Some((e, preds)), steps, .. } if preds.is_empty() && steps.is_empty() => Ok(e), + Operand { filter, absolute, steps, .. } => self.push_expr(Expr::Path { filter, absolute, steps }), + } + } + + fn reduce_one(&mut self, frame: &mut Frame) -> JsResult<()> { + let op = frame.ops.pop().ok_or_else(internal)?; + let rhs = frame.vals.pop().ok_or_else(internal)?; + let e = match op { + Op::Neg => Expr::Neg(rhs), + Op::Bin(b) => Expr::Bin(b, frame.vals.pop().ok_or_else(internal)?, rhs), + }; + let id = self.push_expr(e)?; + frame.vals.push(id); + Ok(()) + } + + fn binary(&mut self, b: Bin) -> JsResult<()> { + let mut frame = self.frames.pop().ok_or_else(internal)?; + let result = self.binary_in(&mut frame, b); + self.frames.push(frame); + result?; + self.want = Want::Operand; + Ok(()) + } + + fn binary_in(&mut self, frame: &mut Frame, b: Bin) -> JsResult<()> { + let operand = frame.operand.take().ok_or_else(internal)?; + let e = self.finish_operand(operand)?; + frame.vals.push(e); + while frame.ops.last().is_some_and(|op| op.precedence() >= b.precedence()) { + self.interp.use_fuel()?; + self.reduce_one(frame)?; + } + self.held.add(self.interp, 2 * size_of::())?; + frame.ops.push(Op::Bin(b)); + Ok(()) + } + + /// Ends the innermost bracket and returns its kind and expression. + fn close(&mut self) -> JsResult<(FrameKind, usize)> { + if self.frames.len() < 2 { + return Err(self.error("the XPath expression has an unmatched closing bracket")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let e = self.reduce(&mut frame)?; + Ok((frame.kind, e)) + } + + fn reduce(&mut self, frame: &mut Frame) -> JsResult { + let operand = frame.operand.take().ok_or_else(|| syntax(self.interp, "an XPath operand is missing"))?; + let e = self.finish_operand(operand)?; + frame.vals.push(e); + while !frame.ops.is_empty() { + self.interp.use_fuel()?; + self.reduce_one(frame)?; + } + match frame.vals.as_slice() { + [e] => Ok(*e), + _ => Err(internal()), + } + } + + fn close_paren(&mut self) -> JsResult<()> { + let (kind, e) = if self.top()?.operand.is_none() { + (self.frames.pop().ok_or_else(internal)?.kind, None) + } else { + let (kind, e) = self.close()?; + (kind, Some(e)) + }; + match kind { + FrameKind::Group => self.primary_id(e.ok_or_else(internal)?), + FrameKind::Args(f, name, mut args) => { + args.extend(e); + let (_, _, least, most) = FUNCS.iter().find(|(_, g, ..)| *g == f).copied().ok_or_else(internal)?; + if args.len() < least || args.len() > most { + return Err(self.interp.type_error(&format!("the XPath function {name} takes the wrong number of arguments"))); + } + let call = self.push_expr(Expr::Call(f, args))?; + self.primary_id(call) + } + _ => Err(self.error("the XPath expression has an unmatched )")), + } + } + + fn primary_id(&mut self, id: usize) -> JsResult<()> { + if self.frames.is_empty() { + return Err(self.error("the XPath expression has an unmatched )")); + } + self.top()?.operand = Some(Operand { filter: Some((id, Vec::new())), ..Operand::default() }); + self.want = Want::After; + Ok(()) + } + + fn close_bracket(&mut self) -> JsResult<()> { + let (kind, e) = self.close()?; + if !matches!(kind, FrameKind::Pred) { + return Err(self.error("the XPath expression has an unmatched ]")); + } + let operand = self.operand()?; + match (operand.steps.last_mut(), operand.filter.as_mut()) { + (Some(step), _) => step.preds.push(e), + (None, Some((_, preds))) => preds.push(e), + (None, None) => return Err(internal()), + } + self.want = Want::After; + Ok(()) + } + + fn next_argument(&mut self) -> JsResult<()> { + if !matches!(self.top()?.kind, FrameKind::Args(..)) || self.frames.len() < 2 { + return Err(self.error("a comma in an XPath expression must separate function arguments")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let e = self.reduce(&mut frame); + if let (Ok(e), FrameKind::Args(_, _, args)) = (&e, &mut frame.kind) { + args.push(*e); + } + frame.ops.clear(); + frame.vals.clear(); + self.frames.push(frame); + e?; + self.want = Want::Operand; + Ok(()) + } + + fn finish(mut self) -> JsResult { + let complete = self.want == Want::After || (self.want == Want::Step && self.operand()?.bare_root()); + if !complete || self.frames.len() != 1 { + return Err(self.error("the XPath expression ends early")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let root = self.reduce(&mut frame)?; + Ok(Parsed { exprs: self.exprs, root, vars: self.vars, prefixes: self.prefixes.into_iter().collect(), _held: self.held }) + } +} + +fn parse(interp: &mut Interp, src: &[u16]) -> JsResult { + let mut held = HeldBytes::new(interp); + let raw = lex(interp, &mut held, src)?; + let toks = classify(interp, &mut held, &raw)?; + drop(raw); + let mut p = Parser { + interp, + held, + exprs: Vec::new(), + vars: Vec::new(), + var_index: HashMap::new(), + prefixes: HashSet::new(), + frames: vec![Frame::new(FrameKind::Top)], + want: Want::Operand, + }; + for tok in toks { + p.interp.use_fuel()?; + p.token(tok)?; + } + p.finish() +} + +// ---- values ---- + +/// A node of the XPath data model. `Root` is the root above the topmost node it names, and `Ns` +/// is a namespace node of `owner`, at position `at` among its namespaces. +#[derive(Clone, PartialEq, Eq, Hash)] +enum XNode { + Root(ObjId), + Node(ObjId), + Ns { owner: ObjId, at: u32, prefix: Key, uri: Key }, +} + +#[derive(Clone, Copy, PartialEq)] +enum NodeKind { + Root, + Ns, + Xml(XmlKind), +} + +/// A list of nodes whose buffer is held against the heap limit. +struct Nodes { + list: Vec, + held: HeldBytes, +} + +impl Nodes { + fn new(interp: &Interp) -> Self { + Nodes { list: Vec::new(), held: HeldBytes::new(interp) } + } + + fn push(&mut self, interp: &mut Interp, n: XNode) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.list, n) + } +} + +/// A string value whose units are held against the heap limit when the evaluator built them. +struct Text { + units: Key, + _held: HeldBytes, +} + +impl Text { + fn plain(interp: &Interp, units: Key) -> Self { + Text { units, _held: HeldBytes::new(interp) } + } +} + +struct TextBuf { + units: Vec, + held: HeldBytes, +} + +impl TextBuf { + fn new(interp: &Interp) -> Self { + TextBuf { units: Vec::new(), held: HeldBytes::new(interp) } + } + + fn push(&mut self, interp: &mut Interp, more: &[u16]) -> JsResult<()> { + charge_units(interp, more.len())?; + let want = self.units.len().saturating_add(more.len()); + let cap = self.units.capacity(); + if want > cap { + let grown = want.max(cap.saturating_mul(2)).max(16); + self.held.add(interp, (grown - cap).saturating_mul(2))?; + self.units.reserve_exact(grown - self.units.len()); + } + self.units.extend_from_slice(more); + Ok(()) + } + + fn finish(self) -> Text { + Text { units: Rc::from(self.units), _held: self.held } + } +} + +enum Val { + Set(Nodes), + Bool(bool), + Num(f64), + Str(Text), +} + +/// A variable's value, read from the variables object before evaluation starts. +enum Bound { + Set(Vec), + Bool(bool), + Num(f64), + Str(Key), +} + +#[derive(Clone)] +struct Ctx { + node: XNode, + pos: usize, + size: usize, +} + +/// A location path being evaluated. Stage 0 applies the filter expression's predicates to the +/// filter's nodes; stage `s` applies step `s - 1` to each node of `inputs` in turn. +struct PathRun { + path: usize, + stage: usize, + inputs: Nodes, + ci: usize, + cur: Nodes, + pred: usize, + at: usize, + kept: Nodes, + waiting: bool, + out: Nodes, + seen: HashSet, + seen_held: HeldBytes, +} + +enum Task { + Eval(usize, Ctx), + Bin(Bin), + Neg, + Logic { and: bool, rhs: usize, ctx: Ctx }, + Bool, + Call(Func, usize, Ctx), + PathStart(usize), + Path(Box), +} + +fn to_bool(v: &Val) -> bool { + match v { + Val::Set(s) => !s.list.is_empty(), + Val::Bool(b) => *b, + Val::Num(n) => *n != 0.0 && !n.is_nan(), + Val::Str(t) => !t.units.is_empty(), + } +} + +/// The number a string stands for (XPath 1.0 section 4.4): optional white space, an optional +/// minus, digits with an optional fraction, optional white space; anything else is NaN. +fn str_num(interp: &mut Interp, units: &[u16]) -> JsResult { + let start = units.iter().position(|&c| !is_xml_space(c)).unwrap_or(units.len()); + let end = units.iter().rposition(|&c| !is_xml_space(c)).map_or(start, |e| e + 1); + let body = units.get(start..end).unwrap_or_default(); + let (negative, digits) = match body.split_first() { + Some((&DASH, rest)) => (true, rest), + _ => (false, body), + }; + let dots = digits.iter().filter(|&&c| c == DOT).count(); + if dots > 1 || !digits.iter().all(|&c| c == DOT || is_digit(c)) || !digits.iter().any(|&c| is_digit(c)) { + return Ok(f64::NAN); + } + let mut held = HeldBytes::new(interp); + held.add(interp, digits.len())?; + let text: String = digits.iter().map(|&c| char::from(c as u8)).collect(); + let n: f64 = text.parse().unwrap_or(f64::NAN); + Ok(if negative { -n } else { n }) +} + +/// A number as a string (XPath 1.0 section 4.2): integers without a decimal point, and no +/// exponent. +fn num_text(n: f64) -> String { + if n.is_nan() { + "NaN".into() + } else if n.is_infinite() { + if n > 0.0 { "Infinity".into() } else { "-Infinity".into() } + } else if n == 0.0 { + "0".into() + } else { + format!("{n}") + } +} + +/// `round` (XPath 1.0 section 4.4): the nearest integer, halves toward positive infinity, with +/// negative zero for values from -0.5 up to zero. +fn round(n: f64) -> f64 { + if !n.is_finite() || n == 0.0 { + return n; + } + let floor = n.floor(); + let r = if n - floor >= 0.5 { floor + 1.0 } else { floor }; + if r == 0.0 && n < 0.0 { -0.0 } else { r } +} + +fn compare_numbers(op: Bin, a: f64, b: f64) -> bool { + match op { + Bin::Eq => a == b, + Bin::Ne => a != b, + Bin::Lt => a < b, + Bin::Le => a <= b, + Bin::Gt => a > b, + _ => a >= b, + } +} + +fn flip(op: Bin) -> Bin { + match op { + Bin::Lt => Bin::Gt, + Bin::Le => Bin::Ge, + Bin::Gt => Bin::Lt, + Bin::Ge => Bin::Le, + other => other, + } +} + +fn code_points(units: &[u16]) -> Vec { + char::decode_utf16(units.iter().copied()).map(|r| r.map_or_else(|e| u32::from(e.unpaired_surrogate()), u32::from)).collect() +} + +fn push_point(out: &mut Vec, p: u32) { + match char::from_u32(p) { + Some(c) => { + let mut buf = [0u16; 2]; + out.extend_from_slice(c.encode_utf16(&mut buf)); + } + None => out.extend(u16::try_from(p).ok()), + } +} + +fn lower(c: u16) -> u16 { + if (0x41..=0x5A).contains(&c) { c + 0x20 } else { c } +} + +fn same_ignoring_case(a: &[u16], b: &[u16]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(&x, &y)| lower(x) == lower(y)) +} + +fn name_uri(name: &XmlName) -> &[u16] { + name.uri.as_deref().unwrap_or_default() +} + +fn kind_of(interp: &Interp, n: &XNode) -> JsResult { + Ok(match n { + XNode::Root(_) => NodeKind::Root, + XNode::Ns { .. } => NodeKind::Ns, + XNode::Node(x) => NodeKind::Xml(interp.xml_kind(*x)?), + }) +} + +fn parent_of(interp: &Interp, n: &XNode) -> JsResult> { + Ok(match n { + XNode::Root(_) => None, + XNode::Node(x) => Some(interp.xml_node(*x)?.parent.map_or(XNode::Root(*x), XNode::Node)), + XNode::Ns { owner, .. } => Some(XNode::Node(*owner)), + }) +} + +/// The parent of `x` and the position of `x` among its children, when `x` is a child. +fn child_position(interp: &mut Interp, x: ObjId) -> JsResult> { + let node = interp.xml_node(x)?; + let Some(p) = node.parent.filter(|_| node.kind != XmlKind::Attribute) else { return Ok(None) }; + let len = interp.xml_node(p)?.children.len(); + for i in 0..len { + interp.use_fuel()?; + if interp.xml_node(p)?.children.get(i) == Some(&x) { + return Ok(Some((p, i))); + } + } + Ok(None) +} + +fn top_of(interp: &mut Interp, mut x: ObjId) -> JsResult { + while let Some(p) = interp.xml_node(x)?.parent { + interp.use_fuel()?; + x = p; + } + Ok(x) +} + +/// The XPath expression evaluator for one call: the parsed expression, the variables, the +/// prefixes resolved for the current context element, and the document order of the trees met. +struct Ev<'p> { + parsed: &'p Parsed, + bound: Vec, + uris: HashMap, + order: HashMap, + root_order: HashMap, + next: u64, + held: HeldBytes, + tasks: Vec, + vals: Vec, +} + +impl<'p> Ev<'p> { + fn new(interp: &mut Interp, parsed: &'p Parsed, bound: Vec) -> JsResult { + let mut ev = Ev { + parsed, + bound: Vec::new(), + uris: HashMap::new(), + order: HashMap::new(), + root_order: HashMap::new(), + next: 0, + held: HeldBytes::new(interp), + tasks: Vec::new(), + vals: Vec::new(), + }; + for b in bound { + let b = match b { + Bound::Set(list) => { + let mut nodes = Nodes::new(interp); + for n in list { + nodes.push(interp, n)?; + } + let sorted = ev.sort(interp, nodes)?; + ev.held.add(interp, sorted.list.len().saturating_mul(size_of::()))?; + Bound::Set(sorted.list) + } + other => other, + }; + ev.bound.push(b); + } + Ok(ev) + } + + // ---- document order ---- + + fn put_order(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.held.add(interp, ENTRY_BYTES)?; + self.order.insert(x, self.next); + self.next += 1; + Ok(()) + } + + /// Numbers the tree under `top` in document order: the root, then each node followed by its + /// attributes and then its children. + fn number_tree(&mut self, interp: &mut Interp, top: ObjId) -> JsResult<()> { + if self.root_order.contains_key(&top) { + return Ok(()); + } + self.held.add(interp, ENTRY_BYTES)?; + self.root_order.insert(top, self.next); + self.next += 1; + self.number_node(interp, top)?; + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (top, 0usize))?; + while let Some(&(x, at)) = stack.last() { + interp.use_fuel()?; + let Some(c) = interp.xml_node(x)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + self.number_node(interp, c)?; + push_held(interp, &mut held, &mut stack, (c, 0))?; + } + Ok(()) + } + + fn number_node(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.put_order(interp, x)?; + let count = interp.xml_node(x)?.attrs.len(); + for i in 0..count { + interp.use_fuel()?; + if let Some(a) = interp.xml_node(x)?.attrs.get(i).copied() { + self.put_order(interp, a)?; + } + } + Ok(()) + } + + fn order_of(&mut self, interp: &mut Interp, n: &XNode) -> JsResult<(u64, u32)> { + match n { + XNode::Root(t) => { + self.number_tree(interp, *t)?; + Ok((self.root_order.get(t).copied().ok_or_else(internal)?, 0)) + } + XNode::Node(x) => { + if !self.order.contains_key(x) { + let top = top_of(interp, *x)?; + self.number_tree(interp, top)?; + } + Ok((self.order.get(x).copied().ok_or_else(internal)?, 0)) + } + XNode::Ns { owner, at, .. } => { + let (o, _) = self.order_of(interp, &XNode::Node(*owner))?; + Ok((o, at.saturating_add(1))) + } + } + } + + /// The nodes in document order, without repeats. + fn sort(&mut self, interp: &mut Interp, set: Nodes) -> JsResult { + if set.list.len() < 2 { + return Ok(set); + } + let n = set.list.len() as u64; + let mut held = HeldBytes::new(interp); + let mut keyed = Vec::new(); + for x in set.list { + let k = self.order_of(interp, &x)?; + push_held(interp, &mut held, &mut keyed, (k, x))?; + } + use_fuel_n(interp, n.saturating_mul(u64::from(n.ilog2()) + 1) / 8 + 1)?; + keyed.sort_by_key(|(k, _)| *k); + keyed.dedup_by_key(|(k, _)| *k); + let mut out = Nodes::new(interp); + for (_, x) in keyed { + out.push(interp, x)?; + } + Ok(out) + } + + // ---- axes and node tests ---- + + fn prefix_uri(&self, p: &Key) -> JsResult<&Key> { + self.uris.get(p).ok_or_else(internal) + } + + fn matches(&self, interp: &Interp, axis: Axis, test: &Test, n: &XNode) -> JsResult { + let kind = kind_of(interp, n)?; + let principal = match axis { + Axis::Attribute => NodeKind::Xml(XmlKind::Attribute), + Axis::Namespace => NodeKind::Ns, + _ => NodeKind::Xml(XmlKind::Element), + }; + let name = match n { + XNode::Node(x) => interp.xml_node(*x)?.name.as_ref(), + _ => None, + }; + Ok(match test { + Test::Node => true, + Test::Text => kind == NodeKind::Xml(XmlKind::Text), + Test::Comment => kind == NodeKind::Xml(XmlKind::Comment), + Test::Pi(target) => kind == NodeKind::Xml(XmlKind::Pi) && target.as_ref().is_none_or(|t| name.is_some_and(|q| q.local == *t)), + Test::Any => kind == principal, + Test::Prefix(p) => { + let uri = self.prefix_uri(p)?; + kind == principal && name.is_some_and(|q| name_uri(q) == &**uri) + } + Test::Name(p, local) => kind == principal && self.name_matches(n, name, p.as_ref(), local)?, + }) + } + + fn name_matches(&self, n: &XNode, name: Option<&XmlName>, prefix: Option<&Key>, local: &Key) -> JsResult { + if let XNode::Ns { prefix: ns_prefix, .. } = n { + return Ok(prefix.is_none() && ns_prefix == local); + } + let Some(q) = name else { return Ok(false) }; + let uri: &[u16] = match prefix { + Some(p) => self.prefix_uri(p)?, + None => &[], + }; + Ok(q.local == *local && name_uri(q) == uri) + } + + fn add(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: XNode) -> JsResult<()> { + interp.use_fuel()?; + if self.matches(interp, axis, test, &n)? { + out.push(interp, n)?; + } + Ok(()) + } + + /// Adds `x` and the nodes under it, in document order. + fn subtree(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, x: ObjId) -> JsResult<()> { + self.add(interp, out, axis, test, XNode::Node(x))?; + self.under(interp, out, axis, test, x) + } + + /// Adds the nodes under `x`, in document order. + fn under(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, x: ObjId) -> JsResult<()> { + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (x, 0usize))?; + while let Some(&(p, at)) = stack.last() { + let Some(c) = interp.xml_node(p)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + self.add(interp, out, axis, test, XNode::Node(c))?; + push_held(interp, &mut held, &mut stack, (c, 0))?; + } + Ok(()) + } + + fn descendants(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + match n { + XNode::Root(t) => self.subtree(interp, out, axis, test, *t), + XNode::Node(x) => self.under(interp, out, axis, test, *x), + XNode::Ns { .. } => Ok(()), + } + } + + fn children(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let x = match n { + XNode::Root(t) => return self.add(interp, out, axis, test, XNode::Node(*t)), + XNode::Node(x) => *x, + XNode::Ns { .. } => return Ok(()), + }; + let count = interp.xml_node(x)?.children.len(); + for i in 0..count { + if let Some(c) = interp.xml_node(x)?.children.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(c))?; + } + } + Ok(()) + } + + fn ancestors(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let mut cur = parent_of(interp, n)?; + while let Some(p) = cur { + cur = parent_of(interp, &p)?; + self.add(interp, out, axis, test, p)?; + } + Ok(()) + } + + fn siblings(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + let Some((p, at)) = child_position(interp, *x)? else { return Ok(()) }; + let count = interp.xml_node(p)?.children.len(); + let range: Box> = if axis == Axis::FollowingSibling { Box::new(at + 1..count) } else { Box::new((0..at).rev()) }; + for i in range { + if let Some(c) = interp.xml_node(p)?.children.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(c))?; + } + } + Ok(()) + } + + /// The node the following and preceding axes start from: an attribute's or a namespace + /// node's element, or the node itself. The flag is true for the first two. + fn start_of(interp: &Interp, n: &XNode) -> JsResult> { + Ok(match n { + XNode::Root(_) => None, + XNode::Ns { owner, .. } => Some((*owner, true)), + XNode::Node(x) => match interp.xml_node(*x)?.parent { + Some(p) if interp.xml_kind(*x)? == XmlKind::Attribute => Some((p, true)), + _ => Some((*x, false)), + }, + }) + } + + fn following(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let Some((mut cur, owned)) = Self::start_of(interp, n)? else { return Ok(()) }; + if owned { + self.under(interp, out, axis, test, cur)?; + } + while let Some((p, at)) = child_position(interp, cur)? { + let count = interp.xml_node(p)?.children.len(); + for i in at + 1..count { + if let Some(s) = interp.xml_node(p)?.children.get(i).copied() { + self.subtree(interp, out, axis, test, s)?; + } + } + cur = p; + } + Ok(()) + } + + fn preceding(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let Some((mut cur, _)) = Self::start_of(interp, n)? else { return Ok(()) }; + while let Some((p, at)) = child_position(interp, cur)? { + for i in (0..at).rev() { + let Some(s) = interp.xml_node(p)?.children.get(i).copied() else { continue }; + let mut part = Nodes::new(interp); + self.subtree(interp, &mut part, axis, test, s)?; + for x in part.list.into_iter().rev() { + out.push(interp, x)?; + } + } + cur = p; + } + Ok(()) + } + + fn attributes(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + if interp.xml_kind(*x)? != XmlKind::Element { + return Ok(()); + } + let count = interp.xml_node(*x)?.attrs.len(); + for i in 0..count { + if let Some(a) = interp.xml_node(*x)?.attrs.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(a))?; + } + } + Ok(()) + } + + fn namespaces(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + if interp.xml_kind(*x)? != XmlKind::Element { + return Ok(()); + } + let xml = key("xml"); + let mut at = 0u32; + let mut has_xml = false; + for ns in scope_of(interp, Some(*x))? { + let Some(prefix) = ns.prefix else { continue }; + if prefix.is_empty() && ns.uri.is_empty() { + continue; + } + has_xml |= prefix == xml; + self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix, uri: ns.uri })?; + at += 1; + } + if !has_xml { + self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix: xml, uri: key(XML_URI) })?; + } + Ok(()) + } + + /// The nodes `axis` reaches from `n` that pass `test`, nearest first. + fn axis(&self, interp: &mut Interp, axis: Axis, test: &Test, n: &XNode) -> JsResult { + let mut out = Nodes::new(interp); + match axis { + Axis::Itself => self.add(interp, &mut out, axis, test, n.clone())?, + Axis::Child => self.children(interp, &mut out, axis, test, n)?, + Axis::Descendant => self.descendants(interp, &mut out, axis, test, n)?, + Axis::DescendantOrSelf => { + self.add(interp, &mut out, axis, test, n.clone())?; + self.descendants(interp, &mut out, axis, test, n)?; + } + Axis::Parent => { + if let Some(p) = parent_of(interp, n)? { + self.add(interp, &mut out, axis, test, p)?; + } + } + Axis::Ancestor => self.ancestors(interp, &mut out, axis, test, n)?, + Axis::AncestorOrSelf => { + self.add(interp, &mut out, axis, test, n.clone())?; + self.ancestors(interp, &mut out, axis, test, n)?; + } + Axis::FollowingSibling | Axis::PrecedingSibling => self.siblings(interp, &mut out, axis, test, n)?, + Axis::Following => self.following(interp, &mut out, axis, test, n)?, + Axis::Preceding => self.preceding(interp, &mut out, axis, test, n)?, + Axis::Attribute => self.attributes(interp, &mut out, axis, test, n)?, + Axis::Namespace => self.namespaces(interp, &mut out, axis, test, n)?, + } + Ok(out) + } + + // ---- string values and conversions ---- + + /// The string-value of a node (XPath 1.0 section 5): the text under an element or the root, + /// the value of any other node, and a namespace node's URI. + fn string_value(&mut self, interp: &mut Interp, n: &XNode) -> JsResult { + let x = match n { + XNode::Ns { uri, .. } => return Ok(Text::plain(interp, uri.clone())), + XNode::Root(t) => *t, + XNode::Node(x) => *x, + }; + let kind = interp.xml_kind(x)?; + if kind == XmlKind::Element { + return self.text_under(interp, x); + } + if matches!(n, XNode::Root(_)) && kind != XmlKind::Text { + return Ok(Text::plain(interp, empty())); + } + let value = interp.xml_node(x)?.value.clone(); + let units = flat_charged(interp, &value)?; + charge_units(interp, units.len())?; + Ok(Text::plain(interp, units)) + } + + fn text_under(&mut self, interp: &mut Interp, x: ObjId) -> JsResult { + let mut buf = TextBuf::new(interp); + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (x, 0usize))?; + while let Some(&(p, at)) = stack.last() { + interp.use_fuel()?; + let Some(c) = interp.xml_node(p)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + match interp.xml_kind(c)? { + XmlKind::Text => { + let value = interp.xml_node(c)?.value.clone(); + let units = flat_charged(interp, &value)?; + buf.push(interp, &units)?; + } + XmlKind::Element => push_held(interp, &mut held, &mut stack, (c, 0))?, + _ => {} + } + } + Ok(buf.finish()) + } + + fn text_of(&mut self, interp: &mut Interp, v: Val) -> JsResult { + match v { + Val::Set(s) => match s.list.first() { + Some(n) => self.string_value(interp, n), + None => Ok(Text::plain(interp, empty())), + }, + Val::Bool(b) => Ok(Text::plain(interp, key(if b { "true" } else { "false" }))), + Val::Num(n) => Ok(Text::plain(interp, key(&num_text(n)))), + Val::Str(t) => { + // The caller reads the units, so they take fuel each time a value is used. + charge_units(interp, t.units.len())?; + Ok(t) + } + } + } + + /// The number a node's string-value stands for. + fn node_number(&mut self, interp: &mut Interp, n: &XNode) -> JsResult { + let t = self.string_value(interp, n)?; + str_num(interp, &t.units) + } + + fn number_of(&mut self, interp: &mut Interp, v: Val) -> JsResult { + match v { + Val::Num(n) => Ok(n), + Val::Bool(b) => Ok(f64::from(u8::from(b))), + other => { + let t = self.text_of(interp, other)?; + str_num(interp, &t.units) + } + } + } + + // ---- comparisons and operators ---- + + fn compare(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult { + match (a, b) { + (Val::Set(x), Val::Set(y)) => self.compare_sets(interp, op, &x, &y), + (Val::Set(x), other) => self.compare_set(interp, op, &x, other), + (other, Val::Set(y)) => self.compare_set(interp, flip(op), &y, other), + (x, y) => self.compare_values(interp, op, x, y), + } + } + + fn compare_values(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult { + if !matches!(op, Bin::Eq | Bin::Ne) { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + return Ok(compare_numbers(op, x, y)); + } + let equal = if matches!(a, Val::Bool(_)) || matches!(b, Val::Bool(_)) { + to_bool(&a) == to_bool(&b) + } else if matches!(a, Val::Num(_)) || matches!(b, Val::Num(_)) { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + return Ok(compare_numbers(op, x, y)); + } else { + self.text_of(interp, a)?.units == self.text_of(interp, b)?.units + }; + Ok(equal == (op == Bin::Eq)) + } + + /// Compares each node of `set` with `other`, which is not a node-set; `set` is on the left. + fn compare_set(&mut self, interp: &mut Interp, op: Bin, set: &Nodes, other: Val) -> JsResult { + if let Val::Str(t) = &other { + charge_units(interp, t.units.len())?; + } + let n = match other { + Val::Bool(b) => return self.compare_values(interp, op, Val::Bool(!set.list.is_empty()), Val::Bool(b)), + Val::Str(t) if matches!(op, Bin::Eq | Bin::Ne) => { + for node in &set.list { + if (self.string_value(interp, node)?.units == t.units) == (op == Bin::Eq) { + return Ok(true); + } + } + return Ok(false); + } + Val::Str(t) => str_num(interp, &t.units)?, + Val::Num(n) => n, + Val::Set(_) => return Err(internal()), + }; + for node in &set.list { + let v = self.node_number(interp, node)?; + if compare_numbers(op, v, n) { + return Ok(true); + } + } + Ok(false) + } + + fn strings_of(&mut self, interp: &mut Interp, held: &mut HeldBytes, set: &Nodes) -> JsResult> { + let mut out = HashSet::new(); + for node in &set.list { + let t = self.string_value(interp, node)?; + held.add(interp, ENTRY_BYTES.saturating_add(t.units.len().saturating_mul(2)))?; + out.insert(t.units); + } + Ok(out) + } + + /// The smallest and largest numbers the nodes' string-values stand for, leaving out NaN. + fn number_range(&mut self, interp: &mut Interp, set: &Nodes) -> JsResult> { + let mut range: Option<(f64, f64)> = None; + for node in &set.list { + let v = self.node_number(interp, node)?; + if !v.is_nan() { + range = Some(range.map_or((v, v), |(lo, hi)| (lo.min(v), hi.max(v)))); + } + } + Ok(range) + } + + fn compare_sets(&mut self, interp: &mut Interp, op: Bin, a: &Nodes, b: &Nodes) -> JsResult { + if a.list.is_empty() || b.list.is_empty() { + return Ok(false); + } + if matches!(op, Bin::Eq | Bin::Ne) { + let mut held = HeldBytes::new(interp); + let right = self.strings_of(interp, &mut held, b)?; + for node in &a.list { + let s = self.string_value(interp, node)?; + let found = if op == Bin::Eq { right.contains(&s.units) } else { right.len() > 1 || !right.contains(&s.units) }; + if found { + return Ok(true); + } + } + return Ok(false); + } + let (Some((a_lo, a_hi)), Some((b_lo, b_hi))) = (self.number_range(interp, a)?, self.number_range(interp, b)?) else { return Ok(false) }; + Ok(match op { + Bin::Lt | Bin::Le => compare_numbers(op, a_lo, b_hi), + _ => compare_numbers(op, a_hi, b_lo), + }) + } + + fn binary(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult { + match op { + Bin::Union => { + let (Val::Set(mut x), Val::Set(y)) = (a, b) else { + return Err(interp.type_error("both sides of an XPath | must select nodes")); + }; + if x.list.is_empty() { + return Ok(Val::Set(y)); + } + for n in y.list { + x.push(interp, n)?; + } + Ok(Val::Set(self.sort(interp, x)?)) + } + Bin::Add | Bin::Sub | Bin::Mul | Bin::Div | Bin::Mod => { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + Ok(Val::Num(match op { + Bin::Add => x + y, + Bin::Sub => x - y, + Bin::Mul => x * y, + Bin::Div => x / y, + _ => x % y, + })) + } + Bin::Or | Bin::And => Err(internal()), + _ => Ok(Val::Bool(self.compare(interp, op, a, b)?)), + } + } + + // ---- functions ---- + + fn set_arg(interp: &mut Interp, v: Option, name: &str) -> JsResult { + match v { + Some(Val::Set(s)) => Ok(s), + _ => Err(interp.type_error(&format!("the XPath function {name} needs a node-set"))), + } + } + + fn text_arg(&mut self, interp: &mut Interp, v: Option, ctx: &Ctx) -> JsResult { + match v { + Some(v) => self.text_of(interp, v), + None => self.string_value(interp, &ctx.node), + } + } + + fn number_arg(&mut self, interp: &mut Interp, v: Option) -> JsResult { + let v = v.ok_or_else(internal)?; + self.number_of(interp, v) + } + + fn call(&mut self, interp: &mut Interp, f: Func, args: Vec, ctx: &Ctx) -> JsResult { + let mut args = args.into_iter(); + Ok(match f { + Func::Last => Val::Num(ctx.size as f64), + Func::Position => Val::Num(ctx.pos as f64), + Func::Count => Val::Num(Self::set_arg(interp, args.next(), "count")?.list.len() as f64), + Func::Id => Val::Set(Nodes::new(interp)), + Func::LocalName | Func::NamespaceUri | Func::Name => Val::Str(self.name_function(interp, f, args.next(), ctx)?), + Func::Sum => { + let set = Self::set_arg(interp, args.next(), "sum")?; + let mut total = 0.0; + for n in &set.list { + total += self.node_number(interp, n)?; + } + Val::Num(total) + } + Func::Boolean => Val::Bool(to_bool(&args.next().ok_or_else(internal)?)), + Func::Not => Val::Bool(!to_bool(&args.next().ok_or_else(internal)?)), + Func::True => Val::Bool(true), + Func::False => Val::Bool(false), + Func::Lang => { + let wanted = self.text_arg(interp, args.next(), ctx)?; + Val::Bool(self.lang(interp, &ctx.node, &wanted.units)?) + } + Func::Number => match args.next() { + Some(v) => Val::Num(self.number_of(interp, v)?), + None => Val::Num(self.node_number(interp, &ctx.node)?), + }, + Func::Floor => Val::Num(self.number_arg(interp, args.next())?.floor()), + Func::Ceiling => Val::Num(self.number_arg(interp, args.next())?.ceil()), + Func::Round => Val::Num(round(self.number_arg(interp, args.next())?)), + _ => return self.string_function(interp, f, args.collect(), ctx), + }) + } + + fn string_function(&mut self, interp: &mut Interp, f: Func, args: Vec, ctx: &Ctx) -> JsResult { + let mut texts = Vec::new(); + let mut held = HeldBytes::new(interp); + for v in args { + let t = self.text_of(interp, v)?; + push_held(interp, &mut held, &mut texts, t)?; + } + if texts.is_empty() && matches!(f, Func::String | Func::StringLength | Func::NormalizeSpace) { + texts.push(self.string_value(interp, &ctx.node)?); + } + if f == Func::String { + return Ok(Val::Str(texts.into_iter().next().ok_or_else(internal)?)); + } + let units = |at: usize| texts.get(at).map(|t| &*t.units).unwrap_or_default(); + Ok(match f { + Func::StringLength => Val::Num(char::decode_utf16(units(0).iter().copied()).count() as f64), + Func::StartsWith => Val::Bool(units(0).starts_with(units(1))), + Func::Contains => Val::Bool(find(interp, units(0), units(1))?.is_some()), + Func::SubstringBefore => { + let at = find(interp, units(0), units(1))?; + Val::Str(self.slice(interp, at.and_then(|i| units(0).get(..i)).unwrap_or_default())?) + } + Func::SubstringAfter => { + let at = find(interp, units(0), units(1))?; + Val::Str(self.slice(interp, at.and_then(|i| units(0).get(i + units(1).len()..)).unwrap_or_default())?) + } + Func::Concat => { + let mut buf = TextBuf::new(interp); + for t in &texts { + buf.push(interp, &t.units)?; + } + Val::Str(buf.finish()) + } + Func::NormalizeSpace => Val::Str(normalize_space(interp, units(0))?), + Func::Translate => Val::Str(translate(interp, units(0), units(1), units(2))?), + _ => return Err(internal()), + }) + } + + fn slice(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult { + let mut buf = TextBuf::new(interp); + buf.push(interp, units)?; + Ok(buf.finish()) + } + + fn substring(&mut self, interp: &mut Interp, args: Vec) -> JsResult { + let mut args = args.into_iter(); + let text = self.text_of(interp, args.next().ok_or_else(internal)?)?; + let start = round(self.number_arg(interp, args.next())?); + let end = match args.next() { + Some(len) => start + round(self.number_of(interp, len)?), + None => f64::INFINITY, + }; + charge_units(interp, text.units.len())?; + let mut held = HeldBytes::new(interp); + // The code points take 4 bytes each, and the result up to twice its 2-byte units. + held.add(interp, text.units.len().saturating_mul(8))?; + let mut out = Vec::new(); + for (i, p) in code_points(&text.units).into_iter().enumerate() { + let position = (i + 1) as f64; + if position >= start && position < end { + push_point(&mut out, p); + } + } + Ok(Val::Str(Text { units: Rc::from(out), _held: held })) + } + + fn name_function(&mut self, interp: &mut Interp, f: Func, arg: Option, ctx: &Ctx) -> JsResult { + let node = match arg { + None => Some(ctx.node.clone()), + Some(Val::Set(s)) => s.list.into_iter().next(), + Some(_) => return Err(interp.type_error("the XPath name functions need a node-set")), + }; + let units = match node { + None | Some(XNode::Root(_)) => empty(), + Some(XNode::Ns { prefix, .. }) => { + if f == Func::NamespaceUri { + empty() + } else { + prefix + } + } + Some(XNode::Node(x)) => node_name(interp, f, x)?, + }; + Ok(Text::plain(interp, units)) + } + + /// Whether the context node's language, from the nearest `xml:lang` attribute on it or an + /// ancestor, is `wanted` or a sublanguage of it, ignoring case. + fn lang(&mut self, interp: &mut Interp, n: &XNode, wanted: &[u16]) -> JsResult { + let mut cur = match n { + XNode::Root(_) => None, + XNode::Node(x) => Some(*x), + XNode::Ns { owner, .. } => Some(*owner), + }; + let xml = key(XML_URI); + while let Some(x) = cur { + interp.use_fuel()?; + let node = interp.xml_node(x)?; + let parent = node.parent; + let count = node.attrs.len(); + for i in 0..count { + interp.use_fuel()?; + let Some(a) = interp.xml_node(x)?.attrs.get(i).copied() else { break }; + let attr = interp.xml_node(a)?; + if attr.name.as_ref().is_some_and(|q| name_uri(q) == &*xml && units_are(&q.local, "lang")) { + let value = attr.value.clone(); + let value = flat_charged(interp, &value)?; + let head = value.get(..wanted.len()).unwrap_or_default(); + return Ok(same_ignoring_case(head, wanted) && (value.len() == wanted.len() || value.get(wanted.len()) == Some(&DASH))); + } + } + cur = parent; + } + Ok(false) + } + + // ---- running ---- + + fn task(&mut self, interp: &mut Interp, t: Task) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.tasks, t) + } + + fn val(&mut self, interp: &mut Interp, v: Val) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.vals, v) + } + + fn pop(&mut self) -> JsResult { + self.vals.pop().ok_or_else(internal) + } + + fn root_of(interp: &mut Interp, n: &XNode) -> JsResult { + Ok(match n { + XNode::Root(t) => XNode::Root(*t), + XNode::Node(x) => XNode::Root(top_of(interp, *x)?), + XNode::Ns { owner, .. } => XNode::Root(top_of(interp, *owner)?), + }) + } + + fn bound_value(&mut self, interp: &mut Interp, at: usize) -> JsResult { + Ok(match self.bound.get(at).ok_or_else(internal)? { + Bound::Bool(b) => Val::Bool(*b), + Bound::Num(n) => Val::Num(*n), + Bound::Str(s) => Val::Str(Text::plain(interp, s.clone())), + Bound::Set(list) => { + let list = list.clone(); + let mut nodes = Nodes::new(interp); + nodes.held.add(interp, list.len().saturating_mul(size_of::()))?; + nodes.list = list; + Val::Set(nodes) + } + }) + } + + fn eval(&mut self, interp: &mut Interp, e: usize, ctx: Ctx) -> JsResult<()> { + let parsed = self.parsed; + match parsed.exprs.get(e).ok_or_else(internal)? { + Expr::Num(n) => self.val(interp, Val::Num(*n)), + Expr::Lit(s) => { + let t = Text::plain(interp, s.clone()); + self.val(interp, Val::Str(t)) + } + Expr::Var(at) => { + let v = self.bound_value(interp, *at)?; + self.val(interp, v) + } + Expr::Neg(a) => { + self.task(interp, Task::Neg)?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Bin(op @ (Bin::And | Bin::Or), a, b) => { + self.task(interp, Task::Logic { and: *op == Bin::And, rhs: *b, ctx: ctx.clone() })?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Bin(op, a, b) => { + self.task(interp, Task::Bin(*op))?; + self.task(interp, Task::Eval(*b, ctx.clone()))?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Call(f, args) => { + self.task(interp, Task::Call(*f, args.len(), ctx.clone()))?; + for a in args.iter().rev() { + self.task(interp, Task::Eval(*a, ctx.clone()))?; + } + Ok(()) + } + Expr::Path { filter: Some((primary, _)), .. } => { + self.task(interp, Task::PathStart(e))?; + self.task(interp, Task::Eval(*primary, ctx)) + } + Expr::Path { filter: None, absolute, steps } => { + let start = if *absolute { Self::root_of(interp, &ctx.node)? } else { ctx.node }; + let mut inputs = Nodes::new(interp); + inputs.push(interp, start)?; + if steps.is_empty() { + return self.val(interp, Val::Set(inputs)); + } + let first = self.step_axis(interp, e, 1, &inputs.list.first().cloned().ok_or_else(internal)?)?; + let run = self.new_run(interp, e, 1, inputs, first); + self.run_path(interp, Box::new(run)) + } + } + } + + fn new_run(&self, interp: &Interp, path: usize, stage: usize, inputs: Nodes, cur: Nodes) -> PathRun { + PathRun { + path, + stage, + inputs, + ci: 0, + cur, + pred: 0, + at: 0, + kept: Nodes::new(interp), + waiting: false, + out: Nodes::new(interp), + seen: HashSet::new(), + seen_held: HeldBytes::new(interp), + } + } + + fn step(&mut self, interp: &mut Interp, t: Task) -> JsResult<()> { + match t { + Task::Eval(e, ctx) => self.eval(interp, e, ctx), + Task::Bin(op) => { + let b = self.pop()?; + let a = self.pop()?; + let v = self.binary(interp, op, a, b)?; + self.val(interp, v) + } + Task::Neg => { + let a = self.pop()?; + let n = self.number_of(interp, a)?; + self.val(interp, Val::Num(-n)) + } + Task::Logic { and, rhs, ctx } => { + let left = to_bool(&self.pop()?); + if left != and { + return self.val(interp, Val::Bool(left)); + } + self.task(interp, Task::Bool)?; + self.task(interp, Task::Eval(rhs, ctx)) + } + Task::Bool => { + let v = to_bool(&self.pop()?); + self.val(interp, Val::Bool(v)) + } + Task::Call(f, n, ctx) => { + let args = self.vals.split_off(self.vals.len().checked_sub(n).ok_or_else(internal)?); + let v = if f == Func::Substring { self.substring(interp, args)? } else { self.call(interp, f, args, &ctx)? }; + self.val(interp, v) + } + Task::PathStart(e) => { + let Val::Set(set) = self.pop()? else { + return Err(interp.type_error("an XPath predicate or step follows a value that is not a node-set")); + }; + let run = self.new_run(interp, e, 0, Nodes::new(interp), set); + self.run_path(interp, Box::new(run)) + } + Task::Path(run) => self.run_path(interp, run), + } + } + + /// The predicates of a path's stage, and its step for a stage after the filter's. + fn stage_of(&self, path: usize, stage: usize) -> JsResult<(&'p [usize], Option<&'p Step>)> { + let parsed = self.parsed; + let Some(Expr::Path { filter, steps, .. }) = parsed.exprs.get(path) else { return Err(internal()) }; + match stage.checked_sub(1) { + None => Ok((filter.as_ref().map(|(_, p)| p.as_slice()).unwrap_or_default(), None)), + Some(s) => { + let step = steps.get(s).ok_or_else(internal)?; + Ok((&step.preds, Some(step))) + } + } + } + + fn step_axis(&self, interp: &mut Interp, path: usize, stage: usize, n: &XNode) -> JsResult { + let (_, step) = self.stage_of(path, stage)?; + let step = step.ok_or_else(internal)?; + self.axis(interp, step.axis, &step.test, n) + } + + /// Runs a path until a predicate needs evaluating, which it schedules before itself, or until + /// the path's node-set is complete, which it pushes. + fn run_path(&mut self, interp: &mut Interp, mut r: Box) -> JsResult<()> { + loop { + interp.use_fuel()?; + if r.waiting { + r.waiting = false; + let v = self.pop()?; + let keep = match v { + Val::Num(n) => n == (r.at + 1) as f64, + other => to_bool(&other), + }; + if keep && let Some(n) = r.cur.list.get(r.at).cloned() { + r.kept.push(interp, n)?; + } + r.at += 1; + } + let (preds, _) = self.stage_of(r.path, r.stage)?; + if let Some(&pred) = preds.get(r.pred) { + if let Some(node) = r.cur.list.get(r.at).cloned() { + let ctx = Ctx { node, pos: r.at + 1, size: r.cur.list.len() }; + r.waiting = true; + self.task(interp, Task::Path(r))?; + return self.task(interp, Task::Eval(pred, ctx)); + } + r.cur = replace(&mut r.kept, Nodes::new(interp)); + r.pred += 1; + r.at = 0; + continue; + } + if !self.next_context(interp, &mut r)? { + return Ok(()); + } + } + } + + /// Adds the current context's nodes to the stage's result and moves to the next context or + /// stage. Returns false when the path is complete and its node-set has been pushed. + fn next_context(&mut self, interp: &mut Interp, r: &mut PathRun) -> JsResult { + let cur = replace(&mut r.cur, Nodes::new(interp)); + if r.inputs.list.len() > 1 { + for n in cur.list { + if !r.seen.contains(&n) { + r.seen_held.add(interp, ENTRY_BYTES)?; + r.seen.insert(n.clone()); + r.out.push(interp, n)?; + } + } + } else { + r.out = cur; + } + r.ci += 1; + if let Some(n) = r.inputs.list.get(r.ci).cloned() { + r.cur = self.step_axis(interp, r.path, r.stage, &n)?; + r.pred = 0; + r.at = 0; + return Ok(true); + } + let out = replace(&mut r.out, Nodes::new(interp)); + let (_, step) = self.stage_of(r.path, r.stage)?; + let done = if r.inputs.list.len() > 1 { + self.sort(interp, out)? + } else if step.is_some_and(|s| s.axis.reverse()) { + let mut out = out; + out.list.reverse(); + out + } else { + out + }; + r.seen.clear(); + r.seen_held = HeldBytes::new(interp); + r.stage += 1; + let more = self.stage_of(r.path, r.stage).is_ok(); + let Some(first) = done.list.first().cloned().filter(|_| more) else { + self.val(interp, Val::Set(done))?; + return Ok(false); + }; + r.inputs = done; + r.ci = 0; + r.cur = self.step_axis(interp, r.path, r.stage, &first)?; + r.pred = 0; + r.at = 0; + Ok(true) + } + + fn run(&mut self, interp: &mut Interp, e: usize, ctx: Ctx) -> JsResult { + self.tasks.clear(); + self.vals.clear(); + self.task(interp, Task::Eval(e, ctx))?; + while let Some(t) = self.tasks.pop() { + interp.use_fuel()?; + self.step(interp, t)?; + } + let v = self.pop()?; + if !self.vals.is_empty() { + return Err(internal()); + } + Ok(v) + } + + /// Binds the expression's prefixes to the namespaces in scope on `x`. + fn resolve_prefixes(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.uris.clear(); + if self.parsed.prefixes.is_empty() { + return Ok(()); + } + let scope = scope_of(interp, Some(x))?; + let parsed = self.parsed; + for p in &parsed.prefixes { + let uri = if units_are(p, "xml") { + key(XML_URI) + } else { + match scope.iter().find(|ns| ns.prefix.as_ref() == Some(p)) { + Some(ns) if !ns.uri.is_empty() => ns.uri.clone(), + _ => { + let shown = String::from_utf16_lossy(p); + return Err(interp.type_error(&format!("the XPath prefix {shown} is not bound to a namespace"))); + } + } + }; + self.held.add(interp, ENTRY_BYTES)?; + self.uris.insert(p.clone(), uri); + } + Ok(()) + } + + /// The nodes the expression selects with `x` as the context node. + fn select(&mut self, interp: &mut Interp, x: ObjId) -> JsResult> { + self.resolve_prefixes(interp, x)?; + let root = self.parsed.root; + let Val::Set(set) = self.run(interp, root, Ctx { node: XNode::Node(x), pos: 1, size: 1 })? else { + return Err(interp.type_error("the XPath expression does not evaluate to a node-set")); + }; + let mut out = Vec::new(); + let mut held = HeldBytes::new(interp); + for n in set.list { + match n { + XNode::Node(id) => push_held(interp, &mut held, &mut out, id)?, + XNode::Root(_) => return Err(interp.type_error("the XPath expression selects the root node, which is not an XML value")), + XNode::Ns { .. } => return Err(interp.type_error("the XPath expression selects a namespace node, which is not an XML value")), + } + } + Ok(out) + } +} + +fn node_name(interp: &mut Interp, f: Func, x: ObjId) -> JsResult { + let node = interp.xml_node(x)?; + let (kind, parent) = (node.kind, node.parent); + let Some(name) = node.name.clone() else { return Ok(empty()) }; + match (f, kind) { + (_, XmlKind::Text | XmlKind::Comment) => Ok(empty()), + (Func::NamespaceUri, XmlKind::Pi) => Ok(empty()), + (Func::NamespaceUri, _) => Ok(Rc::from(name_uri(&name))), + (Func::LocalName, _) | (_, XmlKind::Pi) => Ok(name.local), + _ => qualified_name(interp, x, parent, &name, kind == XmlKind::Attribute), + } +} + +/// An element's or attribute's name as a QName, with a prefix declared for its namespace. +fn qualified_name(interp: &mut Interp, x: ObjId, parent: Option, name: &XmlName, attribute: bool) -> JsResult { + let uri = name_uri(name); + if uri.is_empty() { + return Ok(name.local.clone()); + } + let prefix = if units_are(uri, XML_URI) { + key("xml") + } else { + match &name.prefix { + Some(p) if !(attribute && p.is_empty()) => p.clone(), + _ => { + let scope = scope_of(interp, if attribute { parent } else { Some(x) })?; + let usable = |ns: &XmlNs| *ns.uri == *uri && ns.prefix.as_ref().is_some_and(|p| !(attribute && p.is_empty())); + scope.into_iter().find(usable).and_then(|ns| ns.prefix).unwrap_or_else(empty) + } + } + }; + if prefix.is_empty() { + return Ok(name.local.clone()); + } + let mut units = prefix.to_vec(); + units.push(COLON); + units.extend_from_slice(&name.local); + Ok(Rc::from(units)) +} + +/// The first index where `needle` occurs in `hay`. Every position tried takes fuel. +fn find(interp: &mut Interp, hay: &[u16], needle: &[u16]) -> JsResult> { + string_builtins::find(interp, hay, needle, 0) +} + +fn normalize_space(interp: &mut Interp, units: &[u16]) -> JsResult { + let mut buf = TextBuf::new(interp); + for word in units.split(|&c| is_xml_space(c)).filter(|w| !w.is_empty()) { + if !buf.units.is_empty() { + buf.push(interp, &[0x20])?; + } + buf.push(interp, word)?; + } + Ok(buf.finish()) +} + +fn translate(interp: &mut Interp, units: &[u16], from: &[u16], to: &[u16]) -> JsResult { + charge_units(interp, units.len() + from.len() + to.len())?; + let mut held = HeldBytes::new(interp); + held.add(interp, from.len().saturating_add(units.len()).saturating_add(to.len()).saturating_mul(ENTRY_BYTES))?; + let to = code_points(to); + let mut map: HashMap> = HashMap::new(); + for (i, p) in code_points(from).into_iter().enumerate() { + map.entry(p).or_insert_with(|| to.get(i).copied()); + } + let mut out = Vec::new(); + for p in code_points(units) { + match map.get(&p) { + Some(Some(t)) => push_point(&mut out, *t), + Some(None) => {} + None => push_point(&mut out, p), + } + } + Ok(Text { units: Rc::from(out), _held: held }) +} + +/// Reads the variables the expression names from `variables`. +fn bind(interp: &mut Interp, held: &mut HeldBytes, parsed: &Parsed, variables: &Value) -> JsResult> { + let mut out = Vec::new(); + for name in &parsed.vars { + let v = match variables { + Value::Object(o) if interp.has_property(*o, name)? => interp.get_from(*o, name, variables)?, + _ => Value::Undefined, + }; + if matches!(v, Value::Undefined) { + let shown = String::from_utf16_lossy(name); + return Err(interp.type_error(&format!("the XPath variable ${shown} is not bound"))); + } + interp.temp_roots.push(v.clone()); + let b = match (interp.classify(&v), &v) { + (Xv::Node(n), _) => Bound::Set(vec![XNode::Node(n)]), + (Xv::List(l), _) => { + // A later getter may change the list, so its items are held as roots themselves. + let count = interp.xml_list(l)?.items.len(); + held.add(interp, count.saturating_mul(size_of::() + size_of::() + size_of::()))?; + let items = interp.xml_list(l)?.items.clone(); + interp.temp_roots.extend(items.iter().map(|&n| Value::Object(n))); + Bound::Set(items.into_iter().map(XNode::Node).collect()) + } + (Xv::Other, Value::Bool(b)) => Bound::Bool(*b), + (Xv::Other, Value::Number(n)) => Bound::Num(*n), + (Xv::Other, _) => { + let s = interp.to_str(&v)?; + Bound::Str(flat_charged(interp, &s)?) + } + }; + push_held(interp, held, &mut out, b)?; + } + Ok(out) +} + +/// The nodes an expression runs on: the XML value itself, or each element of an XMLList. +fn contexts(interp: &mut Interp, held: &mut HeldBytes, this: &Value) -> JsResult> { + match interp.classify(this) { + Xv::Node(n) => Ok(vec![n]), + Xv::List(l) => { + let count = interp.xml_list(l)?.items.len(); + let mut out = Vec::new(); + for i in 0..count { + interp.use_fuel()?; + let Some(item) = interp.xml_list(l)?.items.get(i).copied() else { break }; + if interp.xml_kind(item)? == XmlKind::Element { + push_held(interp, held, &mut out, item)?; + } + } + Ok(out) + } + Xv::Other => Err(interp.type_error("the method needs an XML value")), + } +} + +/// `xpath(expression[, variables])`: the nodes the XPath 1.0 `expression` selects with this XML +/// value as the context node, or, for an XMLList, with each of its elements in turn, as one +/// XMLList. Each property of `variables` binds the variable of its name. +pub(crate) fn xpath(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + if matches!(interp.classify(&this), Xv::Other) { + return Err(interp.type_error("the method needs an XML value")); + } + let Value::String(source) = arg(args, 0) else { + return Err(interp.type_error("xpath needs the expression as a string")); + }; + let variables = arg(args, 1); + if !matches!(variables, Value::Undefined | Value::Object(_)) { + return Err(interp.type_error("xpath needs its variables as an object")); + } + interp.rooted(&[this.clone(), variables.clone()], |i| { + let units = flat_charged(i, &source)?; + let parsed = parse(i, &units)?; + let mut held = HeldBytes::new(i); + let bound = bind(i, &mut held, &parsed, &variables)?; + let items = contexts(i, &mut held, &this)?; + let out = i.new_xml_list(None, None)?; + i.xml_keep(out); + let mut ev = Ev::new(i, &parsed, bound)?; + for item in items { + for id in ev.select(i, item)? { + i.list_push(out, id)?; + } + } + Ok(Value::Object(out)) + }) +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad6..f5e85d2c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -765,7 +765,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `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`. + - `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. `xml.xpath(expression[, variables])` evaluates an XPath 1.0 expression (https://www.w3.org/TR/1999/REC-xpath-19991116/) with all 13 axes and the 27 core functions, and returns the selected nodes as an XMLList in document order without repeats. Each property of `variables` binds the variable of its name, an XMLList runs the expression on each of its elements in turn, and a prefix in a name test resolves through the context element's in-scope namespaces. An expression that breaks the grammar gives a `SyntaxError`; an unknown function, a wrong argument count, an unbound prefix or variable, and a result that is not a node-set or that holds the root or a namespace node give a `TypeError`. `id()` selects nothing, and evaluation uses no native stack, takes fuel and counts against the heap limit. `` 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`. - **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.