use crate::schema::xsd::primitive::PrimitiveKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IdentityXPathKind {
Selector,
Field,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum NameTest {
Star,
PrefixStar(String),
Local(String),
Prefixed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Tok {
Dot,
Slash,
DoubleSlash,
ColonColon,
At,
Name(NameTest),
}
pub(crate) fn validate_identity_xpath(
xpath: &str,
kind: IdentityXPathKind,
prefix_declared: impl Fn(&str) -> bool,
) -> Result<(), String> {
if xpath.trim().is_empty() {
return Err("xpath must not be empty".to_string());
}
for alternative in xpath.split('|') {
let tokens = tokenize(alternative)?;
validate_path(&tokens, kind, &prefix_declared)
.map_err(|m| format!("{m} in '{}'", alternative.trim()))?;
}
Ok(())
}
fn is_name_start(c: char) -> bool {
c.is_alphabetic() || c == '_' || !c.is_ascii()
}
fn is_name_char(c: char) -> bool {
is_name_start(c) || c.is_ascii_digit() || c == '-' || c == '.'
}
fn tokenize(path: &str) -> Result<Vec<Tok>, String> {
let mut tokens = Vec::new();
let mut chars = path.char_indices().peekable();
let bytes = path;
while let Some((i, c)) = chars.next() {
match c {
c if c.is_whitespace() => {}
'/' => {
if matches!(chars.peek(), Some((_, '/'))) {
chars.next();
tokens.push(Tok::DoubleSlash);
} else {
tokens.push(Tok::Slash);
}
}
':' => {
if matches!(chars.peek(), Some((_, ':'))) {
chars.next();
tokens.push(Tok::ColonColon);
} else {
return Err(format!("unexpected ':' in '{}'", path.trim()));
}
}
'@' => tokens.push(Tok::At),
'*' => tokens.push(Tok::Name(NameTest::Star)),
'.' => {
tokens.push(Tok::Dot);
}
c if is_name_start(c) => {
let start = i;
let mut end = i + c.len_utf8();
while let Some(&(j, cc)) = chars.peek() {
if is_name_char(cc) {
end = j + cc.len_utf8();
chars.next();
} else {
break;
}
}
let name = &bytes[start..end];
if PrimitiveKind::Ncname.validate(name).is_err() {
return Err(format!("'{name}' is not a valid NCName"));
}
let is_qname_colon = matches!(chars.peek(), Some((j, ':')) if *j == end)
&& !bytes[end + 1..].starts_with(':');
if is_qname_colon {
chars.next(); match chars.peek().copied() {
Some((j, '*')) if j == end + 1 => {
chars.next();
tokens.push(Tok::Name(NameTest::PrefixStar(name.to_string())));
}
Some((j, cc)) if j == end + 1 && is_name_start(cc) => {
let lstart = j;
let mut lend = j + cc.len_utf8();
chars.next();
while let Some(&(k, c2)) = chars.peek() {
if is_name_char(c2) {
lend = k + c2.len_utf8();
chars.next();
} else {
break;
}
}
let local = &bytes[lstart..lend];
if PrimitiveKind::Ncname.validate(local).is_err() {
return Err(format!("'{local}' is not a valid NCName"));
}
tokens.push(Tok::Name(NameTest::Prefixed(name.to_string())));
}
_ => {
return Err(format!("dangling ':' after '{name}'"));
}
}
} else {
tokens.push(Tok::Name(NameTest::Local(name.to_string())));
}
}
_ => {
return Err(format!("unexpected character '{c}'"));
}
}
}
Ok(tokens)
}
fn name_test_prefix(test: &NameTest) -> Option<&str> {
match test {
NameTest::PrefixStar(p) | NameTest::Prefixed(p) => Some(p.as_str()),
NameTest::Star | NameTest::Local(_) => None,
}
}
fn validate_path(
tokens: &[Tok],
kind: IdentityXPathKind,
prefix_declared: &impl Fn(&str) -> bool,
) -> Result<(), String> {
if tokens.is_empty() {
return Err("empty path alternative".to_string());
}
let check_prefix = |test: &NameTest| -> Result<(), String> {
if let Some(prefix) = name_test_prefix(test)
&& prefix != "xml"
&& !prefix_declared(prefix)
{
return Err(format!("undeclared namespace prefix '{prefix}'"));
}
Ok(())
};
let mut i = 0;
if tokens.len() >= 2 && tokens[0] == Tok::Dot && tokens[1] == Tok::DoubleSlash {
i = 2;
if i == tokens.len() {
return Err("'.//' must be followed by a step".to_string());
}
}
loop {
match &tokens[i..] {
[Tok::At, Tok::Name(test)] => {
if kind != IdentityXPathKind::Field {
return Err("attribute test is not allowed in a selector".to_string());
}
check_prefix(test)?;
return Ok(());
}
[Tok::Name(axis), Tok::ColonColon, Tok::Name(test), rest @ ..] => {
let is_last = rest.is_empty();
let axis_name = match axis {
NameTest::Local(name) => name.as_str(),
_ => return Err("invalid axis".to_string()),
};
match axis_name {
"child" => {}
"attribute" if kind == IdentityXPathKind::Field && is_last => {
check_prefix(test)?;
return Ok(());
}
_ => return Err(format!("axis '{axis_name}::' is not allowed")),
}
check_prefix(test)?;
if is_last {
return Ok(());
}
i += 3;
}
[Tok::Dot, rest @ ..] => {
if rest.is_empty() {
return Ok(());
}
i += 1;
}
[Tok::Name(test), rest @ ..] => {
check_prefix(test)?;
if rest.is_empty() {
return Ok(());
}
i += 1;
}
_ => return Err("invalid step".to_string()),
}
match tokens.get(i) {
Some(Tok::Slash) => {
i += 1;
if i == tokens.len() {
return Err("trailing '/'".to_string());
}
}
_ => return Err("invalid step separator".to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn selector(xpath: &str) -> Result<(), String> {
validate_identity_xpath(xpath, IdentityXPathKind::Selector, |p| p == "tn")
}
fn field(xpath: &str) -> Result<(), String> {
validate_identity_xpath(xpath, IdentityXPathKind::Field, |p| p == "tn")
}
#[test]
fn valid_selectors() {
for x in [
".",
"a",
"a/b",
".//a",
"./a",
"tn:a/tn:b",
"*",
"tn:*",
"a | b",
".//*",
"child::a",
".//a/./b",
". //.",
"child ::a",
"child:: a",
"child :: a",
"a / b",
] {
assert!(selector(x).is_ok(), "selector '{x}' should be valid");
}
}
#[test]
fn invalid_selectors() {
for x in [
"",
" ",
"/",
"//",
"|",
"| a",
"a |",
"child::",
".//",
"./ /.",
".//.//a",
"a/.//b",
"/a",
"a//b",
"a/",
"@a",
"attribute::a",
"self::*",
"descendant::*",
"descendant-or-self::*",
"a[b]",
"a[@x='1']",
"..",
"a/..",
"ncname :*",
"ncname: *",
"ncname : *",
"child: :a",
"undeclared:a",
"count(a)",
"@*",
] {
assert!(selector(x).is_err(), "selector '{x}' should be invalid");
}
}
#[test]
fn valid_fields() {
for x in [
".",
"a",
"a/b",
"@a",
"a/@b",
".//a/@b",
"attribute::a",
"tn:a/@tn:b",
"@*",
"@ a",
] {
assert!(field(x).is_ok(), "field '{x}' should be valid");
}
}
#[test]
fn invalid_fields() {
for x in [
"",
"@",
"attribute::",
"@a/b",
"a/@b/c",
"/",
"//",
"a//@b",
"self::a",
] {
assert!(field(x).is_err(), "field '{x}' should be invalid");
}
}
}