From 09b0a43ef8d826adbe4ee8414aa79b2eed8a1298 Mon Sep 17 00:00:00 2001 From: Yurii Rashkovskii Date: Wed, 18 Mar 2026 17:18:10 -0700 Subject: [PATCH] Fix keywords not recognized as NCNames in QNames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token::ncname() only included reserved function names (XPath 3.1 A.3) but omitted axis names and other keywords. This caused the lexer to fail combining tokens like `ex:child` into a PrefixedQName, since Token::Child.ncname() returned None. Per the spec: "Keywords in XPath 3.1 [...] are not reserved—that is, names in XPath 3.1 expressions are allowed to be the same as language keywords." Add all keyword tokens to Token::ncname() to match what the parser-level parser_keyword() already handles. Co-Authored-By: Claude Opus 4.6 (1M context) --- xee-xpath-lexer/src/explicit_whitespace.rs | 97 +++++++++++++++++++ xee-xpath-lexer/src/reserved.rs | 62 +++++++++++- xee-xpath/tests/common/mod.rs | 16 +++ ...path__keyword_element_name_unprefixed.snap | 17 ++++ .../xpath__keyword_qname_ancestor.snap | 17 ++++ .../snapshots/xpath__keyword_qname_and.snap | 17 ++++ .../snapshots/xpath__keyword_qname_child.snap | 17 ++++ .../xpath__keyword_qname_descendant.snap | 17 ++++ .../snapshots/xpath__keyword_qname_or.snap | 17 ++++ .../xpath__keyword_qname_parent.snap | 17 ++++ .../snapshots/xpath__keyword_qname_self.snap | 17 ++++ xee-xpath/tests/xpath.rs | 86 +++++++++++++++- 12 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 xee-xpath/tests/snapshots/xpath__keyword_element_name_unprefixed.snap create mode 100644 xee-xpath/tests/snapshots/xpath__keyword_qname_ancestor.snap create mode 100644 xee-xpath/tests/snapshots/xpath__keyword_qname_and.snap create mode 100644 xee-xpath/tests/snapshots/xpath__keyword_qname_child.snap create mode 100644 xee-xpath/tests/snapshots/xpath__keyword_qname_descendant.snap create mode 100644 xee-xpath/tests/snapshots/xpath__keyword_qname_or.snap create mode 100644 xee-xpath/tests/snapshots/xpath__keyword_qname_parent.snap create mode 100644 xee-xpath/tests/snapshots/xpath__keyword_qname_self.snap diff --git a/xee-xpath-lexer/src/explicit_whitespace.rs b/xee-xpath-lexer/src/explicit_whitespace.rs index 6fbd51aa..04a25520 100644 --- a/xee-xpath-lexer/src/explicit_whitespace.rs +++ b/xee-xpath-lexer/src/explicit_whitespace.rs @@ -305,4 +305,101 @@ mod tests { assert_eq!(iter.next(), Some((Token::CommentStart, 6..13))); assert_eq!(iter.next(), Some((Token::NCName("ncname"), 13..19))); } + + // Axis names and other keywords must be valid as local names in QNames. + // For example, "ex:child" must produce a PrefixedQName, not separate tokens. + #[test] + fn test_prefixed_qname_with_axis_name_local_names() { + let cases = [ + ("ex:child", "child", 8), + ("ex:parent", "parent", 9), + ("ex:self", "self", 7), + ("ex:attribute", "attribute", 12), + ("ex:descendant", "descendant", 13), + ("ex:descendant-or-self", "descendant-or-self", 21), + ("ex:ancestor", "ancestor", 11), + ("ex:ancestor-or-self", "ancestor-or-self", 19), + ("ex:following", "following", 12), + ("ex:following-sibling", "following-sibling", 20), + ("ex:preceding", "preceding", 12), + ("ex:preceding-sibling", "preceding-sibling", 20), + ("ex:namespace", "namespace", 12), + ]; + for (input, local_name, len) in cases { + let mut iter = ExplicitWhitespace::from_str(input); + assert_eq!( + iter.next(), + Some(( + Token::PrefixedQName(PrefixedQName { + prefix: "ex", + local_name + }), + 0..len + )), + "failed for input: {input}" + ); + assert_eq!(iter.next(), None, "unexpected trailing token for: {input}"); + } + } + + #[test] + fn test_prefixed_qname_with_keyword_local_names() { + let cases = [ + ("ex:and", "and", 6), + ("ex:or", "or", 5), + ("ex:div", "div", 6), + ("ex:mod", "mod", 6), + ("ex:for", "for", 6), + ("ex:let", "let", 6), + ("ex:some", "some", 7), + ("ex:every", "every", 8), + ("ex:is", "is", 5), + ("ex:to", "to", 5), + ("ex:union", "union", 8), + ("ex:intersect", "intersect", 12), + ("ex:except", "except", 9), + ("ex:instance", "instance", 11), + ("ex:treat", "treat", 8), + ("ex:cast", "cast", 7), + ("ex:castable", "castable", 11), + ("ex:as", "as", 5), + ("ex:in", "in", 5), + ("ex:return", "return", 9), + ("ex:satisfies", "satisfies", 12), + ("ex:then", "then", 7), + ("ex:else", "else", 7), + ]; + for (input, local_name, len) in cases { + let mut iter = ExplicitWhitespace::from_str(input); + assert_eq!( + iter.next(), + Some(( + Token::PrefixedQName(PrefixedQName { + prefix: "ex", + local_name + }), + 0..len + )), + "failed for input: {input}" + ); + assert_eq!(iter.next(), None, "unexpected trailing token for: {input}"); + } + } + + // Keywords should also work as prefixes in QNames + #[test] + fn test_prefixed_qname_with_keyword_prefix() { + let mut iter = ExplicitWhitespace::from_str("child:foo"); + assert_eq!( + iter.next(), + Some(( + Token::PrefixedQName(PrefixedQName { + prefix: "child", + local_name: "foo" + }), + 0..9 + )) + ); + assert_eq!(iter.next(), None); + } } diff --git a/xee-xpath-lexer/src/reserved.rs b/xee-xpath-lexer/src/reserved.rs index bde4844e..f6d85421 100644 --- a/xee-xpath-lexer/src/reserved.rs +++ b/xee-xpath-lexer/src/reserved.rs @@ -3,11 +3,18 @@ use crate::Token; impl<'a> Token<'a> { // tokens that can count as an ncname as a local name or as a prefix pub(crate) fn ncname(&self) -> Option<&'a str> { - // in section A.3 of the XPath 3.1 specification - // a bunch of tokens are listed as reserved functions. - // They can be used as a valid prefix or local name, just like - // an ncname + // Per XPath 3.1 spec (https://www.w3.org/TR/xpath-31/#terminal-symbols): + // + // "Keywords in XPath 3.1 use lower-case characters and are not + // reserved—that is, names in XPath 3.1 expressions are allowed + // to be the same as language keywords, except for certain + // unprefixed function-names listed in A.3 Reserved Function Names." + // + // All keyword tokens can therefore be used as valid NCNames (prefixes + // or local names in QNames). For example, "ex:child" is a valid + // qualified name where "child" is the local name, not the child axis. match self { + // reserved function names (XPath 3.1 spec A.3) Token::Array => Some("array"), Token::Attribute => Some("attribute"), Token::Comment => Some("comment"), @@ -27,6 +34,53 @@ impl<'a> Token<'a> { Token::Text => Some("text"), Token::Typeswitch => Some("typeswitch"), + // axis names + Token::Ancestor => Some("ancestor"), + Token::AncestorOrSelf => Some("ancestor-or-self"), + Token::Child => Some("child"), + Token::Descendant => Some("descendant"), + Token::DescendantOrSelf => Some("descendant-or-self"), + Token::Following => Some("following"), + Token::FollowingSibling => Some("following-sibling"), + Token::Namespace => Some("namespace"), + Token::Parent => Some("parent"), + Token::Preceding => Some("preceding"), + Token::PrecedingSibling => Some("preceding-sibling"), + Token::Self_ => Some("self"), + + // other keywords + Token::And => Some("and"), + Token::As => Some("as"), + Token::Cast => Some("cast"), + Token::Castable => Some("castable"), + Token::Div => Some("div"), + Token::Else => Some("else"), + Token::Eq => Some("eq"), + Token::Every => Some("every"), + Token::Except => Some("except"), + Token::For => Some("for"), + Token::Ge => Some("ge"), + Token::Gt => Some("gt"), + Token::Idiv => Some("idiv"), + Token::In => Some("in"), + Token::Instance => Some("instance"), + Token::Intersect => Some("intersect"), + Token::Is => Some("is"), + Token::Le => Some("le"), + Token::Let => Some("let"), + Token::Lt => Some("lt"), + Token::Mod => Some("mod"), + Token::Ne => Some("ne"), + Token::Of => Some("of"), + Token::Or => Some("or"), + Token::Return => Some("return"), + Token::Satisfies => Some("satisfies"), + Token::Some => Some("some"), + Token::Then => Some("then"), + Token::To => Some("to"), + Token::Treat => Some("treat"), + Token::Union => Some("union"), + // an NCName of course can also be a prefix or a local name Token::NCName(name) => Some(name), _ => None, diff --git a/xee-xpath/tests/common/mod.rs b/xee-xpath/tests/common/mod.rs index 7373d033..8462946a 100644 --- a/xee-xpath/tests/common/mod.rs +++ b/xee-xpath/tests/common/mod.rs @@ -63,6 +63,22 @@ where Ok(()) } +pub(crate) fn run_xml_with_ns( + xml: &str, + xpath: &str, + namespaces: &[(&str, &str)], +) -> error::Result { + let mut documents = Documents::new(); + let handle = documents.add_string_without_uri(xml).unwrap(); + let mut static_context_builder = StaticContextBuilder::default(); + for (prefix, uri) in namespaces { + static_context_builder.add_namespace(prefix, uri); + } + let queries = Queries::new(static_context_builder); + let q = queries.sequence(xpath)?; + q.execute(&mut documents, handle) +} + fn xot_nodes_to_items(node: &[xot::Node]) -> Sequence { Sequence::from( node.iter() diff --git a/xee-xpath/tests/snapshots/xpath__keyword_element_name_unprefixed.snap b/xee-xpath/tests/snapshots/xpath__keyword_element_name_unprefixed.snap new file mode 100644 index 00000000..9a208b0c --- /dev/null +++ b/xee-xpath/tests/snapshots/xpath__keyword_element_name_unprefixed.snap @@ -0,0 +1,17 @@ +--- +source: xee-xpath/tests/xpath.rs +assertion_line: 1355 +expression: "run_xml(\"found it\", \"//child/string()\",)" +--- +Ok( + One( + One { + item: Atomic( + String( + String, + "found it", + ), + ), + }, + ), +) diff --git a/xee-xpath/tests/snapshots/xpath__keyword_qname_ancestor.snap b/xee-xpath/tests/snapshots/xpath__keyword_qname_ancestor.snap new file mode 100644 index 00000000..e6a25ac7 --- /dev/null +++ b/xee-xpath/tests/snapshots/xpath__keyword_qname_ancestor.snap @@ -0,0 +1,17 @@ +--- +source: xee-xpath/tests/xpath.rs +assertion_line: 1389 +expression: "run_xml_with_ns(r#\"found it\"#,\n\"//ex:ancestor/string()\", &[(\"ex\", \"http://example.com/ex\")])" +--- +Ok( + One( + One { + item: Atomic( + String( + String, + "found it", + ), + ), + }, + ), +) diff --git a/xee-xpath/tests/snapshots/xpath__keyword_qname_and.snap b/xee-xpath/tests/snapshots/xpath__keyword_qname_and.snap new file mode 100644 index 00000000..7b68d6ed --- /dev/null +++ b/xee-xpath/tests/snapshots/xpath__keyword_qname_and.snap @@ -0,0 +1,17 @@ +--- +source: xee-xpath/tests/xpath.rs +assertion_line: 1407 +expression: "run_xml_with_ns(r#\"found it\"#,\n\"//ex:and/string()\", &[(\"ex\", \"http://example.com/ex\")])" +--- +Ok( + One( + One { + item: Atomic( + String( + String, + "found it", + ), + ), + }, + ), +) diff --git a/xee-xpath/tests/snapshots/xpath__keyword_qname_child.snap b/xee-xpath/tests/snapshots/xpath__keyword_qname_child.snap new file mode 100644 index 00000000..22f62454 --- /dev/null +++ b/xee-xpath/tests/snapshots/xpath__keyword_qname_child.snap @@ -0,0 +1,17 @@ +--- +source: xee-xpath/tests/xpath.rs +assertion_line: 1353 +expression: "run_xml_with_ns(r#\"found it\"#,\n\"//ex:child/string()\", &[(\"ex\", \"http://example.com/ex\")])" +--- +Ok( + One( + One { + item: Atomic( + String( + String, + "found it", + ), + ), + }, + ), +) diff --git a/xee-xpath/tests/snapshots/xpath__keyword_qname_descendant.snap b/xee-xpath/tests/snapshots/xpath__keyword_qname_descendant.snap new file mode 100644 index 00000000..f16072e1 --- /dev/null +++ b/xee-xpath/tests/snapshots/xpath__keyword_qname_descendant.snap @@ -0,0 +1,17 @@ +--- +source: xee-xpath/tests/xpath.rs +assertion_line: 1380 +expression: "run_xml_with_ns(r#\"found it\"#,\n\"//ex:descendant/string()\", &[(\"ex\", \"http://example.com/ex\")])" +--- +Ok( + One( + One { + item: Atomic( + String( + String, + "found it", + ), + ), + }, + ), +) diff --git a/xee-xpath/tests/snapshots/xpath__keyword_qname_or.snap b/xee-xpath/tests/snapshots/xpath__keyword_qname_or.snap new file mode 100644 index 00000000..b36fb338 --- /dev/null +++ b/xee-xpath/tests/snapshots/xpath__keyword_qname_or.snap @@ -0,0 +1,17 @@ +--- +source: xee-xpath/tests/xpath.rs +assertion_line: 1398 +expression: "run_xml_with_ns(r#\"found it\"#,\n\"//ex:or/string()\", &[(\"ex\", \"http://example.com/ex\")])" +--- +Ok( + One( + One { + item: Atomic( + String( + String, + "found it", + ), + ), + }, + ), +) diff --git a/xee-xpath/tests/snapshots/xpath__keyword_qname_parent.snap b/xee-xpath/tests/snapshots/xpath__keyword_qname_parent.snap new file mode 100644 index 00000000..9b4701e2 --- /dev/null +++ b/xee-xpath/tests/snapshots/xpath__keyword_qname_parent.snap @@ -0,0 +1,17 @@ +--- +source: xee-xpath/tests/xpath.rs +assertion_line: 1362 +expression: "run_xml_with_ns(r#\"found it\"#,\n\"//ex:parent/string()\", &[(\"ex\", \"http://example.com/ex\")])" +--- +Ok( + One( + One { + item: Atomic( + String( + String, + "found it", + ), + ), + }, + ), +) diff --git a/xee-xpath/tests/snapshots/xpath__keyword_qname_self.snap b/xee-xpath/tests/snapshots/xpath__keyword_qname_self.snap new file mode 100644 index 00000000..dbeb3197 --- /dev/null +++ b/xee-xpath/tests/snapshots/xpath__keyword_qname_self.snap @@ -0,0 +1,17 @@ +--- +source: xee-xpath/tests/xpath.rs +assertion_line: 1371 +expression: "run_xml_with_ns(r#\"found it\"#,\n\"//ex:self/string()\", &[(\"ex\", \"http://example.com/ex\")])" +--- +Ok( + One( + One { + item: Atomic( + String( + String, + "found it", + ), + ), + }, + ), +) diff --git a/xee-xpath/tests/xpath.rs b/xee-xpath/tests/xpath.rs index c7082a8b..98d560fc 100644 --- a/xee-xpath/tests/xpath.rs +++ b/xee-xpath/tests/xpath.rs @@ -3,7 +3,7 @@ use xee_xpath::{context::Variables, error, Atomic, Item, Sequence}; mod common; -use common::{assert_nodes, run, run_with_variables, run_xml, run_xml_default_ns}; +use common::{assert_nodes, run, run_with_variables, run_xml, run_xml_default_ns, run_xml_with_ns}; #[test] fn test_compile_add() { @@ -1336,3 +1336,87 @@ fn test_curly_map() { fn test_cast_negative_zero() { assert_debug_snapshot!(run("xs:unsignedLong('-0')")); } + +// XML elements whose local names match XPath axis names or keywords must be +// addressable via qualified names like `ex:child`, `ex:parent`, etc. +// +// Example XML that uses axis names as element names: +// +// +// ... +// ... +// ... +// + +#[test] +fn test_keyword_element_name_unprefixed() { + // "child" as a plain element name (no namespace prefix) — the parser + // handles this via parser_keyword() which already includes all keywords + assert_debug_snapshot!(run_xml( + "found it", + "//child/string()", + )); +} + +#[test] +fn test_keyword_qname_child() { + assert_debug_snapshot!(run_xml_with_ns( + r#"found it"#, + "//ex:child/string()", + &[("ex", "http://example.com/ex")] + )); +} + +#[test] +fn test_keyword_qname_parent() { + assert_debug_snapshot!(run_xml_with_ns( + r#"found it"#, + "//ex:parent/string()", + &[("ex", "http://example.com/ex")] + )); +} + +#[test] +fn test_keyword_qname_self() { + assert_debug_snapshot!(run_xml_with_ns( + r#"found it"#, + "//ex:self/string()", + &[("ex", "http://example.com/ex")] + )); +} + +#[test] +fn test_keyword_qname_descendant() { + assert_debug_snapshot!(run_xml_with_ns( + r#"found it"#, + "//ex:descendant/string()", + &[("ex", "http://example.com/ex")] + )); +} + +#[test] +fn test_keyword_qname_ancestor() { + assert_debug_snapshot!(run_xml_with_ns( + r#"found it"#, + "//ex:ancestor/string()", + &[("ex", "http://example.com/ex")] + )); +} + +#[test] +fn test_keyword_qname_or() { + assert_debug_snapshot!(run_xml_with_ns( + r#"found it"#, + "//ex:or/string()", + &[("ex", "http://example.com/ex")] + )); +} + +#[test] +fn test_keyword_qname_and() { + assert_debug_snapshot!(run_xml_with_ns( + r#"found it"#, + "//ex:and/string()", + &[("ex", "http://example.com/ex")] + )); +}