Skip to content

Commit 0293025

Browse files
yrashkclaude
andauthored
Fix keywords not recognized as NCNames in QNames (#142)
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) <noreply@anthropic.com>
1 parent 878fb74 commit 0293025

12 files changed

Lines changed: 392 additions & 5 deletions

xee-xpath-lexer/src/explicit_whitespace.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,4 +305,101 @@ mod tests {
305305
assert_eq!(iter.next(), Some((Token::CommentStart, 6..13)));
306306
assert_eq!(iter.next(), Some((Token::NCName("ncname"), 13..19)));
307307
}
308+
309+
// Axis names and other keywords must be valid as local names in QNames.
310+
// For example, "ex:child" must produce a PrefixedQName, not separate tokens.
311+
#[test]
312+
fn test_prefixed_qname_with_axis_name_local_names() {
313+
let cases = [
314+
("ex:child", "child", 8),
315+
("ex:parent", "parent", 9),
316+
("ex:self", "self", 7),
317+
("ex:attribute", "attribute", 12),
318+
("ex:descendant", "descendant", 13),
319+
("ex:descendant-or-self", "descendant-or-self", 21),
320+
("ex:ancestor", "ancestor", 11),
321+
("ex:ancestor-or-self", "ancestor-or-self", 19),
322+
("ex:following", "following", 12),
323+
("ex:following-sibling", "following-sibling", 20),
324+
("ex:preceding", "preceding", 12),
325+
("ex:preceding-sibling", "preceding-sibling", 20),
326+
("ex:namespace", "namespace", 12),
327+
];
328+
for (input, local_name, len) in cases {
329+
let mut iter = ExplicitWhitespace::from_str(input);
330+
assert_eq!(
331+
iter.next(),
332+
Some((
333+
Token::PrefixedQName(PrefixedQName {
334+
prefix: "ex",
335+
local_name
336+
}),
337+
0..len
338+
)),
339+
"failed for input: {input}"
340+
);
341+
assert_eq!(iter.next(), None, "unexpected trailing token for: {input}");
342+
}
343+
}
344+
345+
#[test]
346+
fn test_prefixed_qname_with_keyword_local_names() {
347+
let cases = [
348+
("ex:and", "and", 6),
349+
("ex:or", "or", 5),
350+
("ex:div", "div", 6),
351+
("ex:mod", "mod", 6),
352+
("ex:for", "for", 6),
353+
("ex:let", "let", 6),
354+
("ex:some", "some", 7),
355+
("ex:every", "every", 8),
356+
("ex:is", "is", 5),
357+
("ex:to", "to", 5),
358+
("ex:union", "union", 8),
359+
("ex:intersect", "intersect", 12),
360+
("ex:except", "except", 9),
361+
("ex:instance", "instance", 11),
362+
("ex:treat", "treat", 8),
363+
("ex:cast", "cast", 7),
364+
("ex:castable", "castable", 11),
365+
("ex:as", "as", 5),
366+
("ex:in", "in", 5),
367+
("ex:return", "return", 9),
368+
("ex:satisfies", "satisfies", 12),
369+
("ex:then", "then", 7),
370+
("ex:else", "else", 7),
371+
];
372+
for (input, local_name, len) in cases {
373+
let mut iter = ExplicitWhitespace::from_str(input);
374+
assert_eq!(
375+
iter.next(),
376+
Some((
377+
Token::PrefixedQName(PrefixedQName {
378+
prefix: "ex",
379+
local_name
380+
}),
381+
0..len
382+
)),
383+
"failed for input: {input}"
384+
);
385+
assert_eq!(iter.next(), None, "unexpected trailing token for: {input}");
386+
}
387+
}
388+
389+
// Keywords should also work as prefixes in QNames
390+
#[test]
391+
fn test_prefixed_qname_with_keyword_prefix() {
392+
let mut iter = ExplicitWhitespace::from_str("child:foo");
393+
assert_eq!(
394+
iter.next(),
395+
Some((
396+
Token::PrefixedQName(PrefixedQName {
397+
prefix: "child",
398+
local_name: "foo"
399+
}),
400+
0..9
401+
))
402+
);
403+
assert_eq!(iter.next(), None);
404+
}
308405
}

xee-xpath-lexer/src/reserved.rs

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@ use crate::Token;
33
impl<'a> Token<'a> {
44
// tokens that can count as an ncname as a local name or as a prefix
55
pub(crate) fn ncname(&self) -> Option<&'a str> {
6-
// in section A.3 of the XPath 3.1 specification
7-
// a bunch of tokens are listed as reserved functions.
8-
// They can be used as a valid prefix or local name, just like
9-
// an ncname
6+
// Per XPath 3.1 spec (https://www.w3.org/TR/xpath-31/#terminal-symbols):
7+
//
8+
// "Keywords in XPath 3.1 use lower-case characters and are not
9+
// reserved—that is, names in XPath 3.1 expressions are allowed
10+
// to be the same as language keywords, except for certain
11+
// unprefixed function-names listed in A.3 Reserved Function Names."
12+
//
13+
// All keyword tokens can therefore be used as valid NCNames (prefixes
14+
// or local names in QNames). For example, "ex:child" is a valid
15+
// qualified name where "child" is the local name, not the child axis.
1016
match self {
17+
// reserved function names (XPath 3.1 spec A.3)
1118
Token::Array => Some("array"),
1219
Token::Attribute => Some("attribute"),
1320
Token::Comment => Some("comment"),
@@ -27,6 +34,53 @@ impl<'a> Token<'a> {
2734
Token::Text => Some("text"),
2835
Token::Typeswitch => Some("typeswitch"),
2936

37+
// axis names
38+
Token::Ancestor => Some("ancestor"),
39+
Token::AncestorOrSelf => Some("ancestor-or-self"),
40+
Token::Child => Some("child"),
41+
Token::Descendant => Some("descendant"),
42+
Token::DescendantOrSelf => Some("descendant-or-self"),
43+
Token::Following => Some("following"),
44+
Token::FollowingSibling => Some("following-sibling"),
45+
Token::Namespace => Some("namespace"),
46+
Token::Parent => Some("parent"),
47+
Token::Preceding => Some("preceding"),
48+
Token::PrecedingSibling => Some("preceding-sibling"),
49+
Token::Self_ => Some("self"),
50+
51+
// other keywords
52+
Token::And => Some("and"),
53+
Token::As => Some("as"),
54+
Token::Cast => Some("cast"),
55+
Token::Castable => Some("castable"),
56+
Token::Div => Some("div"),
57+
Token::Else => Some("else"),
58+
Token::Eq => Some("eq"),
59+
Token::Every => Some("every"),
60+
Token::Except => Some("except"),
61+
Token::For => Some("for"),
62+
Token::Ge => Some("ge"),
63+
Token::Gt => Some("gt"),
64+
Token::Idiv => Some("idiv"),
65+
Token::In => Some("in"),
66+
Token::Instance => Some("instance"),
67+
Token::Intersect => Some("intersect"),
68+
Token::Is => Some("is"),
69+
Token::Le => Some("le"),
70+
Token::Let => Some("let"),
71+
Token::Lt => Some("lt"),
72+
Token::Mod => Some("mod"),
73+
Token::Ne => Some("ne"),
74+
Token::Of => Some("of"),
75+
Token::Or => Some("or"),
76+
Token::Return => Some("return"),
77+
Token::Satisfies => Some("satisfies"),
78+
Token::Some => Some("some"),
79+
Token::Then => Some("then"),
80+
Token::To => Some("to"),
81+
Token::Treat => Some("treat"),
82+
Token::Union => Some("union"),
83+
3084
// an NCName of course can also be a prefix or a local name
3185
Token::NCName(name) => Some(name),
3286
_ => None,

xee-xpath/tests/common/mod.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ where
6363
Ok(())
6464
}
6565

66+
pub(crate) fn run_xml_with_ns(
67+
xml: &str,
68+
xpath: &str,
69+
namespaces: &[(&str, &str)],
70+
) -> error::Result<Sequence> {
71+
let mut documents = Documents::new();
72+
let handle = documents.add_string_without_uri(xml).unwrap();
73+
let mut static_context_builder = StaticContextBuilder::default();
74+
for (prefix, uri) in namespaces {
75+
static_context_builder.add_namespace(prefix, uri);
76+
}
77+
let queries = Queries::new(static_context_builder);
78+
let q = queries.sequence(xpath)?;
79+
q.execute(&mut documents, handle)
80+
}
81+
6682
fn xot_nodes_to_items(node: &[xot::Node]) -> Sequence {
6783
Sequence::from(
6884
node.iter()
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: xee-xpath/tests/xpath.rs
3+
assertion_line: 1355
4+
expression: "run_xml(\"<data><child>found it</child></data>\", \"//child/string()\",)"
5+
---
6+
Ok(
7+
One(
8+
One {
9+
item: Atomic(
10+
String(
11+
String,
12+
"found it",
13+
),
14+
),
15+
},
16+
),
17+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: xee-xpath/tests/xpath.rs
3+
assertion_line: 1389
4+
expression: "run_xml_with_ns(r#\"<data xmlns:ex=\"http://example.com/ex\"><ex:ancestor>found it</ex:ancestor></data>\"#,\n\"//ex:ancestor/string()\", &[(\"ex\", \"http://example.com/ex\")])"
5+
---
6+
Ok(
7+
One(
8+
One {
9+
item: Atomic(
10+
String(
11+
String,
12+
"found it",
13+
),
14+
),
15+
},
16+
),
17+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: xee-xpath/tests/xpath.rs
3+
assertion_line: 1407
4+
expression: "run_xml_with_ns(r#\"<data xmlns:ex=\"http://example.com/ex\"><ex:and>found it</ex:and></data>\"#,\n\"//ex:and/string()\", &[(\"ex\", \"http://example.com/ex\")])"
5+
---
6+
Ok(
7+
One(
8+
One {
9+
item: Atomic(
10+
String(
11+
String,
12+
"found it",
13+
),
14+
),
15+
},
16+
),
17+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: xee-xpath/tests/xpath.rs
3+
assertion_line: 1353
4+
expression: "run_xml_with_ns(r#\"<data xmlns:ex=\"http://example.com/ex\"><ex:child>found it</ex:child></data>\"#,\n\"//ex:child/string()\", &[(\"ex\", \"http://example.com/ex\")])"
5+
---
6+
Ok(
7+
One(
8+
One {
9+
item: Atomic(
10+
String(
11+
String,
12+
"found it",
13+
),
14+
),
15+
},
16+
),
17+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: xee-xpath/tests/xpath.rs
3+
assertion_line: 1380
4+
expression: "run_xml_with_ns(r#\"<data xmlns:ex=\"http://example.com/ex\"><ex:descendant>found it</ex:descendant></data>\"#,\n\"//ex:descendant/string()\", &[(\"ex\", \"http://example.com/ex\")])"
5+
---
6+
Ok(
7+
One(
8+
One {
9+
item: Atomic(
10+
String(
11+
String,
12+
"found it",
13+
),
14+
),
15+
},
16+
),
17+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: xee-xpath/tests/xpath.rs
3+
assertion_line: 1398
4+
expression: "run_xml_with_ns(r#\"<data xmlns:ex=\"http://example.com/ex\"><ex:or>found it</ex:or></data>\"#,\n\"//ex:or/string()\", &[(\"ex\", \"http://example.com/ex\")])"
5+
---
6+
Ok(
7+
One(
8+
One {
9+
item: Atomic(
10+
String(
11+
String,
12+
"found it",
13+
),
14+
),
15+
},
16+
),
17+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
source: xee-xpath/tests/xpath.rs
3+
assertion_line: 1362
4+
expression: "run_xml_with_ns(r#\"<data xmlns:ex=\"http://example.com/ex\"><ex:parent>found it</ex:parent></data>\"#,\n\"//ex:parent/string()\", &[(\"ex\", \"http://example.com/ex\")])"
5+
---
6+
Ok(
7+
One(
8+
One {
9+
item: Atomic(
10+
String(
11+
String,
12+
"found it",
13+
),
14+
),
15+
},
16+
),
17+
)

0 commit comments

Comments
 (0)