Skip to main content

quarb_xpath/
lib.rs

1//! XPath 1.0 importer for Quarb.
2//!
3//! Translates an XPath 1.0 expression into an equivalent Quarb
4//! query, following the mapping in the specification's comparative
5//! guide. The translatable subset covers the axes and predicate
6//! forms Quarb models directly; everything else is refused with an
7//! [`XPathError::Unsupported`] naming the construct, never silently
8//! approximated.
9//!
10//! - Axes: `child`, `descendant`, `parent` (`..`), `ancestor`,
11//!   `self` (`.`), `attribute` (`@`), `following-sibling` (`>>`),
12//!   `preceding-sibling` (`<<`). No `following`/`preceding`
13//!   (document-order) or `namespace` axes.
14//! - Node tests: names (namespace-prefixed names become quoted
15//!   Quarb segments), `*`, and terminal `text()`. No `node()`,
16//!   `comment()`, or `processing-instruction()`: Quarb navigates
17//!   elements only.
18//! - Predicates: positional `[n]`, `[last()]` → `[-1]` (and
19//!   `[last() - k]` → `[-(k+1)]`), `position()` comparisons →
20//!   index / range predicates (`[position() > 1]` → `[2..]`),
21//!   comparisons over attributes, `text()`, `.` and relative
22//!   descending paths, existence tests, `and`/`or`/`not()`,
23//!   `contains()`, `starts-with()`.
24//! - Top level: union `|` becomes `||`; `count(...)` and `sum(...)`
25//!   become `@| count` / `@| sum` aggregations.
26//!
27//! Known semantic divergences are reported as [`Translation::notes`]
28//! rather than errors:
29//!
30//! - `[n]` on an abbreviated `//` step: XPath expands `//name[n]`
31//!   to a per-parent `child::` step, positioning within each parent,
32//!   while Quarb positions within the hop's whole per-source result
33//!   list. The two agree when all matches share one parent. On the
34//!   child axis (`name[n]`) and the explicit descendant axis
35//!   (`descendant::name[n]`) the translation is exact.
36//! - Quarb's `::text` is the concatenated descendant text, while
37//!   XPath's `text()` selects immediate text-node children. The two
38//!   agree on leaf elements.
39
40mod export;
41pub use export::export;
42
43use std::fmt::Write as _;
44
45/// An error translating an XPath expression.
46#[derive(Debug, thiserror::Error)]
47pub enum XPathError {
48    #[error("XPath syntax error at offset {0}: {1}")]
49    Syntax(usize, String),
50    #[error("unsupported XPath construct: {0}")]
51    Unsupported(String),
52}
53
54/// A successful translation: the Quarb query, plus notes on known
55/// semantic divergences that apply to it.
56#[derive(Debug)]
57pub struct Translation {
58    pub query: String,
59    pub notes: Vec<String>,
60}
61
62/// Translate an XPath 1.0 expression to a Quarb query.
63pub fn translate(xpath: &str) -> Result<Translation, XPathError> {
64    let tokens = lex(xpath)?;
65    let mut parser = Parser {
66        tokens,
67        pos: 0,
68        notes: Vec::new(),
69    };
70    let query = parser.top()?;
71    if parser.pos < parser.tokens.len() {
72        return Err(XPathError::Syntax(
73            parser.tokens[parser.pos].1,
74            format!("unexpected trailing '{}'", parser.tokens[parser.pos].0),
75        ));
76    }
77    let mut notes = parser.notes;
78    notes.dedup();
79    Ok(Translation { query, notes })
80}
81
82// ---------------------------------------------------------------- lexer
83
84#[derive(Debug, Clone, PartialEq)]
85enum Tok {
86    Slash,
87    SlashSlash,
88    Union,
89    LBracket,
90    RBracket,
91    LParen,
92    RParen,
93    At,
94    Comma,
95    Dot,
96    DotDot,
97    Star,
98    Minus,
99    Cmp(&'static str),
100    Number(String),
101    Literal(String),
102    /// A name or QName; `true` when immediately followed by `(`
103    /// (function call) and `false` otherwise. The paired axis test
104    /// (`name::`) is a separate token.
105    Name(String, bool),
106    Axis(String),
107}
108
109impl std::fmt::Display for Tok {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            Tok::Slash => write!(f, "/"),
113            Tok::SlashSlash => write!(f, "//"),
114            Tok::Union => write!(f, "|"),
115            Tok::LBracket => write!(f, "["),
116            Tok::RBracket => write!(f, "]"),
117            Tok::LParen => write!(f, "("),
118            Tok::RParen => write!(f, ")"),
119            Tok::At => write!(f, "@"),
120            Tok::Comma => write!(f, ","),
121            Tok::Dot => write!(f, "."),
122            Tok::DotDot => write!(f, ".."),
123            Tok::Star => write!(f, "*"),
124            Tok::Minus => write!(f, "-"),
125            Tok::Cmp(op) => write!(f, "{op}"),
126            Tok::Number(n) => write!(f, "{n}"),
127            Tok::Literal(s) => write!(f, "'{s}'"),
128            Tok::Name(n, _) => write!(f, "{n}"),
129            Tok::Axis(a) => write!(f, "{a}::"),
130        }
131    }
132}
133
134fn is_name_start(c: char) -> bool {
135    c.is_alphabetic() || c == '_'
136}
137
138fn is_name_char(c: char) -> bool {
139    c.is_alphanumeric() || matches!(c, '.' | '-' | '_')
140}
141
142fn lex(input: &str) -> Result<Vec<(Tok, usize)>, XPathError> {
143    let chars: Vec<char> = input.chars().collect();
144    let mut tokens = Vec::new();
145    let mut i = 0;
146    while i < chars.len() {
147        let c = chars[i];
148        let at = i;
149        match c {
150            c if c.is_whitespace() => i += 1,
151            '/' => {
152                if chars.get(i + 1) == Some(&'/') {
153                    tokens.push((Tok::SlashSlash, at));
154                    i += 2;
155                } else {
156                    tokens.push((Tok::Slash, at));
157                    i += 1;
158                }
159            }
160            '|' => {
161                tokens.push((Tok::Union, at));
162                i += 1;
163            }
164            '[' => {
165                tokens.push((Tok::LBracket, at));
166                i += 1;
167            }
168            ']' => {
169                tokens.push((Tok::RBracket, at));
170                i += 1;
171            }
172            '(' => {
173                tokens.push((Tok::LParen, at));
174                i += 1;
175            }
176            ')' => {
177                tokens.push((Tok::RParen, at));
178                i += 1;
179            }
180            '@' => {
181                tokens.push((Tok::At, at));
182                i += 1;
183            }
184            ',' => {
185                tokens.push((Tok::Comma, at));
186                i += 1;
187            }
188            '*' => {
189                tokens.push((Tok::Star, at));
190                i += 1;
191            }
192            '-' => {
193                tokens.push((Tok::Minus, at));
194                i += 1;
195            }
196            '.' => {
197                if chars.get(i + 1) == Some(&'.') {
198                    tokens.push((Tok::DotDot, at));
199                    i += 2;
200                } else if chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) {
201                    // A number like `.5`.
202                    let mut text = String::from("0.");
203                    i += 1;
204                    while chars.get(i).is_some_and(char::is_ascii_digit) {
205                        text.push(chars[i]);
206                        i += 1;
207                    }
208                    tokens.push((Tok::Number(text), at));
209                } else {
210                    tokens.push((Tok::Dot, at));
211                    i += 1;
212                }
213            }
214            '=' => {
215                tokens.push((Tok::Cmp("="), at));
216                i += 1;
217            }
218            '!' => {
219                if chars.get(i + 1) == Some(&'=') {
220                    tokens.push((Tok::Cmp("!="), at));
221                    i += 2;
222                } else {
223                    return Err(XPathError::Syntax(at, "lone '!'".into()));
224                }
225            }
226            '<' => {
227                if chars.get(i + 1) == Some(&'=') {
228                    tokens.push((Tok::Cmp("<="), at));
229                    i += 2;
230                } else {
231                    tokens.push((Tok::Cmp("<"), at));
232                    i += 1;
233                }
234            }
235            '>' => {
236                if chars.get(i + 1) == Some(&'=') {
237                    tokens.push((Tok::Cmp(">="), at));
238                    i += 2;
239                } else {
240                    tokens.push((Tok::Cmp(">"), at));
241                    i += 1;
242                }
243            }
244            '\'' | '"' => {
245                let mut text = String::new();
246                i += 1;
247                while i < chars.len() && chars[i] != c {
248                    text.push(chars[i]);
249                    i += 1;
250                }
251                if i == chars.len() {
252                    return Err(XPathError::Syntax(at, "unterminated string literal".into()));
253                }
254                i += 1;
255                tokens.push((Tok::Literal(text), at));
256            }
257            c if c.is_ascii_digit() => {
258                let mut text = String::new();
259                while chars.get(i).is_some_and(char::is_ascii_digit) {
260                    text.push(chars[i]);
261                    i += 1;
262                }
263                if chars.get(i) == Some(&'.') {
264                    text.push('.');
265                    i += 1;
266                    while chars.get(i).is_some_and(char::is_ascii_digit) {
267                        text.push(chars[i]);
268                        i += 1;
269                    }
270                }
271                tokens.push((Tok::Number(text), at));
272            }
273            c if is_name_start(c) => {
274                let mut text = String::new();
275                while chars.get(i).is_some_and(|&c| is_name_char(c)) {
276                    text.push(chars[i]);
277                    i += 1;
278                }
279                // A QName: one `name:name` (but not `name::`).
280                if chars.get(i) == Some(&':')
281                    && chars.get(i + 1) != Some(&':')
282                    && chars.get(i + 1).is_some_and(|&c| is_name_start(c))
283                {
284                    text.push(':');
285                    i += 1;
286                    while chars.get(i).is_some_and(|&c| is_name_char(c)) {
287                        text.push(chars[i]);
288                        i += 1;
289                    }
290                }
291                if chars.get(i) == Some(&':') && chars.get(i + 1) == Some(&':') {
292                    tokens.push((Tok::Axis(text), at));
293                    i += 2;
294                } else {
295                    let calls = chars.get(i) == Some(&'(');
296                    tokens.push((Tok::Name(text, calls), at));
297                }
298            }
299            other => {
300                return Err(XPathError::Syntax(at, format!("unexpected '{other}'")));
301            }
302        }
303    }
304    Ok(tokens)
305}
306
307// --------------------------------------------------------------- parser
308
309/// The divergence note for a positional predicate on an abbreviated
310/// `//` step.
311const ABBREV_INDEX_NOTE: &str = "[n] on //: XPath's abbreviated // positions within each \
312     parent; Quarb positions within the hop's whole result list \
313     (equal when all matches share one parent)";
314
315struct Parser {
316    tokens: Vec<(Tok, usize)>,
317    pos: usize,
318    notes: Vec<String>,
319}
320
321impl Parser {
322    fn peek(&self) -> Option<&Tok> {
323        self.tokens.get(self.pos).map(|(t, _)| t)
324    }
325
326    fn peek2(&self) -> Option<&Tok> {
327        self.tokens.get(self.pos + 1).map(|(t, _)| t)
328    }
329
330    fn bump(&mut self) -> Option<Tok> {
331        let t = self.tokens.get(self.pos).map(|(t, _)| t.clone());
332        self.pos += 1;
333        t
334    }
335
336    fn expect(&mut self, tok: Tok, what: &str) -> Result<(), XPathError> {
337        if self.peek() == Some(&tok) {
338            self.pos += 1;
339            Ok(())
340        } else {
341            let at = self
342                .tokens
343                .get(self.pos)
344                .map(|(_, at)| *at)
345                .unwrap_or_default();
346            Err(XPathError::Syntax(at, format!("expected {what}")))
347        }
348    }
349
350    /// The whole expression: a union of paths, or a top-level
351    /// `count(...)` / `sum(...)` aggregation.
352    fn top(&mut self) -> Result<String, XPathError> {
353        if let Some(Tok::Name(name, true)) = self.peek() {
354            let func = name.clone();
355            match func.as_str() {
356                "count" => {
357                    self.pos += 1;
358                    self.expect(Tok::LParen, "'(' after count")?;
359                    let inner = self.union()?;
360                    self.expect(Tok::RParen, "')' to close count")?;
361                    return Ok(format!("{inner} @| count"));
362                }
363                "sum" => {
364                    self.pos += 1;
365                    self.expect(Tok::LParen, "'(' after sum")?;
366                    let (inner, projected) = self.path()?;
367                    self.expect(Tok::RParen, "')' to close sum")?;
368                    // `sum` needs values; project the node set if the
369                    // path did not already end in a projection.
370                    let projected = if projected {
371                        inner
372                    } else {
373                        format!("{inner}::")
374                    };
375                    return Ok(format!("{projected} @| sum"));
376                }
377                _ => {}
378            }
379        }
380        self.union()
381    }
382
383    fn union(&mut self) -> Result<String, XPathError> {
384        let mut parts = vec![self.path()?.0];
385        while self.peek() == Some(&Tok::Union) {
386            self.pos += 1;
387            parts.push(self.path()?.0);
388        }
389        Ok(parts.join(" || "))
390    }
391
392    /// An absolute location path. The flag reports whether the path
393    /// ends in a projection (a terminal attribute or `text()` step).
394    fn path(&mut self) -> Result<(String, bool), XPathError> {
395        let mut out = String::new();
396        let mut projection: Option<String> = None;
397
398        let mut sep = match self.peek() {
399            Some(Tok::Slash) => "/",
400            Some(Tok::SlashSlash) => "//",
401            _ => {
402                return Err(XPathError::Unsupported(
403                    "relative paths (Quarb queries are root-anchored; start with / or //)".into(),
404                ));
405            }
406        };
407        self.pos += 1;
408
409        // A bare `/` selects the document root.
410        if self.peek().is_none() || self.peek() == Some(&Tok::Union) {
411            if sep == "/" {
412                return Ok(("^".into(), false));
413            }
414            return Err(XPathError::Syntax(0, "'//' needs a step".into()));
415        }
416
417        loop {
418            if projection.is_some() {
419                return Err(XPathError::Unsupported(
420                    "steps after an attribute or text() step".into(),
421                ));
422            }
423            match self.step(sep, &mut out)? {
424                StepOut::Element => {}
425                StepOut::Projection(p) => projection = Some(p),
426            }
427            sep = match self.peek() {
428                Some(Tok::Slash) => "/",
429                Some(Tok::SlashSlash) => "//",
430                _ => break,
431            };
432            self.pos += 1;
433        }
434
435        let projected = projection.is_some();
436        if let Some(p) = projection {
437            out.push_str(&p);
438        }
439        Ok((out, projected))
440    }
441
442    /// One location step. Writes navigation into `out`; a terminal
443    /// attribute / `text()` step is returned as a projection instead.
444    fn step(&mut self, sep: &str, out: &mut String) -> Result<StepOut, XPathError> {
445        // Resolve an explicit axis to the Quarb hop syntax. XPath's
446        // abbreviated `//` expands to a per-parent `child::` step, so
447        // positional predicates on it diverge from Quarb's per-source
448        // list — an *explicit* descendant:: axis does not.
449        let mut abbreviated_descendant = sep == "//";
450        let (axis, sep) = match self.peek() {
451            Some(Tok::Axis(a)) => {
452                let a = a.clone();
453                self.pos += 1;
454                abbreviated_descendant = false;
455                match a.as_str() {
456                    "child" => ("child", sep),
457                    "descendant" | "descendant-or-self" => ("child", "//"),
458                    "parent" => ("parent", sep),
459                    "ancestor" | "ancestor-or-self" => ("ancestor", sep),
460                    "self" => ("self", sep),
461                    "attribute" => ("attribute", sep),
462                    "following-sibling" => ("following-sibling", sep),
463                    "preceding-sibling" => ("preceding-sibling", sep),
464                    other => {
465                        return Err(XPathError::Unsupported(format!(
466                            "the {other}:: axis (no Quarb equivalent)"
467                        )));
468                    }
469                }
470            }
471            Some(Tok::At) => {
472                self.pos += 1;
473                ("attribute", sep)
474            }
475            _ => ("child", sep),
476        };
477
478        match axis {
479            "attribute" => {
480                let name = match self.bump() {
481                    Some(Tok::Name(n, false)) => n,
482                    Some(Tok::Star) => {
483                        return Err(XPathError::Unsupported(
484                            "@* (Quarb projects one named property at a time)".into(),
485                        ));
486                    }
487                    _ => return Err(XPathError::Syntax(0, "expected an attribute name".into())),
488                };
489                return Ok(StepOut::Projection(format!("::{}", quarb_name(&name))));
490            }
491            "self" | "parent" | "ancestor" | "following-sibling" | "preceding-sibling" => {
492                let hop = match axis {
493                    "parent" => "\\",
494                    "ancestor" => "\\\\",
495                    "following-sibling" => ">>",
496                    "preceding-sibling" => "<<",
497                    _ => "",
498                };
499                match self.bump() {
500                    // `self::name` filters the current node by name.
501                    Some(Tok::Name(n, false)) if axis == "self" => {
502                        write!(out, "[:::name = {}]", quarb_literal(&n)?).unwrap();
503                    }
504                    Some(Tok::Star) if axis == "self" => {}
505                    Some(Tok::Name(n, false)) => {
506                        write!(out, "{hop}{}", quarb_name(&n)).unwrap();
507                    }
508                    Some(Tok::Star) => {
509                        write!(out, "{hop}*").unwrap();
510                    }
511                    Some(Tok::Name(n, true)) if n == "node" => {
512                        self.expect(Tok::LParen, "'('")?;
513                        self.expect(Tok::RParen, "')'")?;
514                        if axis != "self" {
515                            write!(out, "{hop}*").unwrap();
516                        }
517                    }
518                    _ => {
519                        return Err(XPathError::Syntax(0, format!("bad {axis}:: node test")));
520                    }
521                }
522                return Ok(StepOut::Element);
523            }
524            _ => {}
525        }
526
527        // The child/descendant axis.
528        match self.bump() {
529            Some(Tok::Name(n, false)) => {
530                write!(out, "{sep}{}", quarb_name(&n)).unwrap();
531            }
532            Some(Tok::Star) => {
533                write!(out, "{sep}*").unwrap();
534            }
535            Some(Tok::Dot) => return Ok(StepOut::Element),
536            Some(Tok::DotDot) => {
537                write!(out, "\\*").unwrap();
538                return Ok(StepOut::Element);
539            }
540            Some(Tok::Name(n, true)) if n == "text" => {
541                self.expect(Tok::LParen, "'('")?;
542                self.expect(Tok::RParen, "')'")?;
543                self.notes.push(
544                    "text(): Quarb's bare :: is the concatenated descendant text; \
545                     XPath text() selects immediate text nodes (equal on leaf elements)"
546                        .into(),
547                );
548                return Ok(StepOut::Projection("::".into()));
549            }
550            Some(Tok::Name(n, true)) if n == "node" => {
551                return Err(XPathError::Unsupported(
552                    "node() (Quarb navigates elements only)".into(),
553                ));
554            }
555            Some(Tok::Name(n, true)) => {
556                return Err(XPathError::Unsupported(format!("the {n}() node test")));
557            }
558            other => {
559                return Err(XPathError::Syntax(
560                    0,
561                    format!(
562                        "expected a step, found {}",
563                        other.map(|t| t.to_string()).unwrap_or_else(|| "end".into())
564                    ),
565                ));
566            }
567        }
568
569        // Predicates.
570        while self.peek() == Some(&Tok::LBracket) {
571            self.pos += 1;
572            self.predicate(abbreviated_descendant, out)?;
573            self.expect(Tok::RBracket, "']' to close the predicate")?;
574        }
575        Ok(StepOut::Element)
576    }
577
578    /// One `[...]` predicate on an element step. Quarb's predicate
579    /// model matches XPath's (sequential, positions among the step's
580    /// results) except on an abbreviated `//` step, where XPath
581    /// positions within each parent.
582    fn predicate(
583        &mut self,
584        abbreviated_descendant: bool,
585        out: &mut String,
586    ) -> Result<(), XPathError> {
587        // Positional forms first.
588        match (self.peek(), self.peek2()) {
589            (Some(Tok::Number(n)), Some(Tok::RBracket)) => {
590                let n = n.clone();
591                if n.contains('.') {
592                    return Err(XPathError::Unsupported(format!(
593                        "the non-integer position [{n}]"
594                    )));
595                }
596                if abbreviated_descendant {
597                    self.notes.push(ABBREV_INDEX_NOTE.into());
598                }
599                self.pos += 1;
600                write!(out, "[{n}]").unwrap();
601                return Ok(());
602            }
603            // `[last()]` → `[-1]`; `[last() - k]` → `[-(k+1)]`.
604            (Some(Tok::Name(f, true)), _) if f == "last" => {
605                self.pos += 1;
606                self.expect(Tok::LParen, "'('")?;
607                self.expect(Tok::RParen, "')'")?;
608                let back: i64 = if self.peek() == Some(&Tok::Minus) {
609                    self.pos += 1;
610                    match self.bump() {
611                        Some(Tok::Number(k)) => k.parse().map_err(|_| {
612                            XPathError::Unsupported(format!("last() - {k} (non-integer)"))
613                        })?,
614                        _ => {
615                            return Err(XPathError::Syntax(
616                                0,
617                                "expected a number after last() -".into(),
618                            ));
619                        }
620                    }
621                } else {
622                    0
623                };
624                if abbreviated_descendant {
625                    self.notes.push(ABBREV_INDEX_NOTE.into());
626                }
627                write!(out, "[-{}]", back + 1).unwrap();
628                return Ok(());
629            }
630            // `position()` comparisons → index / range predicates.
631            (Some(Tok::Name(f, true)), _) if f == "position" => {
632                self.pos += 1;
633                self.expect(Tok::LParen, "'('")?;
634                self.expect(Tok::RParen, "')'")?;
635                let op = match self.bump() {
636                    Some(Tok::Cmp(op)) => op,
637                    _ => {
638                        return Err(XPathError::Syntax(
639                            0,
640                            "expected a comparison after position()".into(),
641                        ));
642                    }
643                };
644                let n: u64 = match self.bump() {
645                    Some(Tok::Number(n)) => n.parse().map_err(|_| {
646                        XPathError::Unsupported(format!("position() {op} {n} (non-integer)"))
647                    })?,
648                    _ => {
649                        return Err(XPathError::Syntax(
650                            0,
651                            "expected a number after position() comparison".into(),
652                        ));
653                    }
654                };
655                if abbreviated_descendant {
656                    self.notes.push(ABBREV_INDEX_NOTE.into());
657                }
658                match op {
659                    "=" => write!(out, "[{n}]").unwrap(),
660                    ">" => write!(out, "[{}..]", n + 1).unwrap(),
661                    ">=" => write!(out, "[{n}..]").unwrap(),
662                    "<" => write!(out, "[..{}]", n.saturating_sub(1)).unwrap(),
663                    "<=" => write!(out, "[..{n}]").unwrap(),
664                    other => {
665                        return Err(XPathError::Unsupported(format!("position() {other} {n}")));
666                    }
667                }
668                return Ok(());
669            }
670            _ => {}
671        }
672
673        let expr = self.pred_or()?;
674        write!(out, "[{expr}]").unwrap();
675        Ok(())
676    }
677
678    fn pred_or(&mut self) -> Result<String, XPathError> {
679        let mut left = self.pred_and()?;
680        while matches!(self.peek(), Some(Tok::Name(n, false)) if n == "or") {
681            self.pos += 1;
682            let right = self.pred_and()?;
683            left = format!("{left} or {right}");
684        }
685        Ok(left)
686    }
687
688    fn pred_and(&mut self) -> Result<String, XPathError> {
689        let mut left = self.pred_cmp()?;
690        while matches!(self.peek(), Some(Tok::Name(n, false)) if n == "and") {
691            self.pos += 1;
692            let right = self.pred_cmp()?;
693            left = format!("{left} and {right}");
694        }
695        Ok(left)
696    }
697
698    fn pred_cmp(&mut self) -> Result<String, XPathError> {
699        let (left, left_bare) = self.pred_primary()?;
700        if let Some(Tok::Cmp(op)) = self.peek() {
701            let op = *op;
702            self.pos += 1;
703            let (right, right_bare) = self.pred_primary()?;
704            // A compared element path is compared by value: project it.
705            let left = if left_bare { format!("{left}::") } else { left };
706            let right = if right_bare {
707                format!("{right}::")
708            } else {
709                right
710            };
711            return Ok(format!("{left} {op} {right}"));
712        }
713        // A bare path stays structural: an existence test.
714        Ok(left)
715    }
716
717    /// A predicate operand or boolean atom. The flag reports a bare
718    /// element path (one that needs `::` appended when its *value* is
719    /// wanted rather than its existence).
720    fn pred_primary(&mut self) -> Result<(String, bool), XPathError> {
721        match self.peek() {
722            Some(Tok::LParen) => {
723                self.pos += 1;
724                let inner = self.pred_or()?;
725                self.expect(Tok::RParen, "')' to close the group")?;
726                Ok((format!("({inner})"), false))
727            }
728            Some(Tok::Name(f, true)) if f == "not" => {
729                self.pos += 1;
730                self.expect(Tok::LParen, "'(' after not")?;
731                let inner = self.pred_or()?;
732                self.expect(Tok::RParen, "')' to close not")?;
733                Ok((format!("not ({inner})"), false))
734            }
735            Some(Tok::Name(f, true)) if f == "contains" => {
736                self.pos += 1;
737                self.expect(Tok::LParen, "'(' after contains")?;
738                let (hay, hay_bare) = self.pred_primary()?;
739                let hay = if hay_bare { format!("{hay}::") } else { hay };
740                self.expect(Tok::Comma, "',' between contains arguments")?;
741                let (needle, needle_bare) = self.pred_primary()?;
742                let needle = if needle_bare {
743                    format!("{needle}::")
744                } else {
745                    needle
746                };
747                self.expect(Tok::RParen, "')' to close contains")?;
748                Ok((format!("{hay} *= {needle}"), false))
749            }
750            Some(Tok::Name(f, true)) if f == "starts-with" => {
751                self.pos += 1;
752                self.expect(Tok::LParen, "'(' after starts-with")?;
753                let (subject, bare) = self.pred_primary()?;
754                let subject = if bare {
755                    format!("{subject}::")
756                } else {
757                    subject
758                };
759                self.expect(Tok::Comma, "',' between starts-with arguments")?;
760                let Some(Tok::Literal(prefix)) = self.bump() else {
761                    return Err(XPathError::Unsupported(
762                        "starts-with with a non-literal prefix".into(),
763                    ));
764                };
765                self.expect(Tok::RParen, "')' to close starts-with")?;
766                if prefix.contains(['(', ')']) {
767                    return Err(XPathError::Unsupported(
768                        "starts-with prefix containing parentheses".into(),
769                    ));
770                }
771                self.expect_nothing_weird(&prefix)?;
772                Ok((format!("{subject} =~ ~(^{})", regex_escape(&prefix)), false))
773            }
774            Some(Tok::Name(f, true)) if f == "text" => self.pred_path(),
775            Some(Tok::Name(f, true)) => Err(XPathError::Unsupported(format!(
776                "the {f}() function in a predicate"
777            ))),
778            Some(Tok::Literal(s)) => {
779                let s = s.clone();
780                self.pos += 1;
781                Ok((quarb_literal(&s)?, false))
782            }
783            Some(Tok::Number(n)) => {
784                let n = n.clone();
785                self.pos += 1;
786                Ok((n, false))
787            }
788            _ => self.pred_path(),
789        }
790    }
791
792    /// A relative path operand inside a predicate: `@attr`, `text()`,
793    /// `.`, or a descending element path with an optional terminal
794    /// `@attr` / `text()`. Bare (projection-less) paths are flagged.
795    fn pred_path(&mut self) -> Result<(String, bool), XPathError> {
796        let mut out = String::new();
797        let mut sep = "/";
798        // Leading `.` / `./` / `.//`.
799        if self.peek() == Some(&Tok::Dot) {
800            self.pos += 1;
801            match self.peek() {
802                Some(Tok::Slash) => {
803                    self.pos += 1;
804                }
805                Some(Tok::SlashSlash) => {
806                    sep = "//";
807                    self.pos += 1;
808                }
809                // A bare `.` is the candidate's own value.
810                _ => return Ok(("::".into(), false)),
811            }
812        } else if self.peek() == Some(&Tok::SlashSlash) || self.peek() == Some(&Tok::Slash) {
813            return Err(XPathError::Unsupported(
814                "absolute paths inside predicates (Quarb predicate paths are \
815                 relative to the candidate node)"
816                    .into(),
817            ));
818        }
819
820        loop {
821            match self.peek() {
822                Some(Tok::At) => {
823                    self.pos += 1;
824                    let Some(Tok::Name(name, false)) = self.bump() else {
825                        return Err(XPathError::Syntax(0, "expected an attribute name".into()));
826                    };
827                    write!(out, "::{}", quarb_name(&name)).unwrap();
828                    return Ok((out, false));
829                }
830                Some(Tok::Name(f, true)) if f == "text" => {
831                    self.pos += 1;
832                    self.expect(Tok::LParen, "'('")?;
833                    self.expect(Tok::RParen, "')'")?;
834                    out.push_str("::");
835                    return Ok((out, false));
836                }
837                Some(Tok::Name(n, false)) => {
838                    let n = n.clone();
839                    self.pos += 1;
840                    write!(out, "{sep}{}", quarb_name(&n)).unwrap();
841                }
842                Some(Tok::Star) => {
843                    self.pos += 1;
844                    write!(out, "{sep}*").unwrap();
845                }
846                Some(Tok::DotDot) | Some(Tok::Axis(_)) => {
847                    return Err(XPathError::Unsupported(
848                        "upward navigation inside predicates (Quarb predicate \
849                         paths descend from the candidate node)"
850                            .into(),
851                    ));
852                }
853                other => {
854                    return Err(XPathError::Syntax(
855                        0,
856                        format!(
857                            "expected a predicate operand, found {}",
858                            other.map(|t| t.to_string()).unwrap_or_else(|| "end".into())
859                        ),
860                    ));
861                }
862            }
863            sep = match self.peek() {
864                Some(Tok::Slash) => "/",
865                Some(Tok::SlashSlash) => "//",
866                _ => break,
867            };
868            self.pos += 1;
869        }
870        Ok((out, true))
871    }
872
873    fn expect_nothing_weird(&self, literal: &str) -> Result<(), XPathError> {
874        if literal.contains(['\'', '"']) {
875            return Err(XPathError::Unsupported(
876                "a literal containing quote characters".into(),
877            ));
878        }
879        Ok(())
880    }
881}
882
883enum StepOut {
884    Element,
885    Projection(String),
886}
887
888/// Render a name as a Quarb name segment, quoting it when it
889/// contains characters outside Quarb's bare-name set (e.g. the `:`
890/// of a namespace-prefixed name).
891fn quarb_name(name: &str) -> String {
892    let bare = name
893        .chars()
894        .all(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_'));
895    if bare {
896        name.to_string()
897    } else {
898        format!("'{name}'")
899    }
900}
901
902/// Render a string literal as a Quarb quoted literal.
903fn quarb_literal(s: &str) -> Result<String, XPathError> {
904    if !s.contains('"') {
905        Ok(format!("\"{s}\""))
906    } else if !s.contains('\'') {
907        Ok(format!("'{s}'"))
908    } else {
909        Err(XPathError::Unsupported(
910            "a literal containing both quote characters".into(),
911        ))
912    }
913}
914
915/// Escape regex metacharacters in a literal (for `starts-with`).
916fn regex_escape(s: &str) -> String {
917    let mut out = String::with_capacity(s.len());
918    for c in s.chars() {
919        if "\\^$.|?*+[]{}".contains(c) {
920            out.push('\\');
921        }
922        out.push(c);
923    }
924    out
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930
931    fn t(xpath: &str) -> String {
932        translate(xpath).unwrap().query
933    }
934
935    fn unsupported(xpath: &str) -> String {
936        match translate(xpath) {
937            Err(XPathError::Unsupported(msg)) => msg,
938            other => panic!("expected Unsupported, got {other:?}"),
939        }
940    }
941
942    #[test]
943    fn plain_paths() {
944        assert_eq!(t("/EXAMPLE"), "/EXAMPLE");
945        assert_eq!(t("/EXAMPLE/head/title"), "/EXAMPLE/head/title");
946        assert_eq!(t("//p"), "//p");
947        assert_eq!(t("/a//b/c"), "/a//b/c");
948        assert_eq!(t("/*"), "/*");
949        assert_eq!(t("/"), "^");
950    }
951
952    #[test]
953    fn explicit_axes() {
954        assert_eq!(t("/child::EXAMPLE/child::head"), "/EXAMPLE/head");
955        assert_eq!(t("/descendant::title"), "//title");
956        assert_eq!(t("/descendant::p/ancestor::chapter"), "//p\\\\chapter");
957        assert_eq!(t("//image/parent::chapter"), "//image\\chapter");
958        assert_eq!(t("//image/.."), "//image\\*");
959        assert_eq!(t("//image/../title"), "//image\\*/title");
960        assert_eq!(t("//p/self::p"), "//p[:::name = \"p\"]");
961    }
962
963    #[test]
964    fn attributes_and_text() {
965        assert_eq!(t("//chapter/@id"), "//chapter::id");
966        assert_eq!(t("/EXAMPLE/attribute::prop1"), "/EXAMPLE::prop1");
967        assert_eq!(t("//title/text()"), "//title::");
968    }
969
970    #[test]
971    fn predicates() {
972        assert_eq!(t("//chapter[2]"), "//chapter[2]");
973        assert_eq!(t("//*[2]"), "//*[2]");
974        assert_eq!(
975            t("//chapter[@id='chapter2']"),
976            "//chapter[::id = \"chapter2\"]"
977        );
978        assert_eq!(t("//chapter[title]"), "//chapter[/title]");
979        assert_eq!(
980            t("//chapter[title='Chapter 2']"),
981            "//chapter[/title:: = \"Chapter 2\"]"
982        );
983        assert_eq!(t("//chapter[@id][image]"), "//chapter[::id][/image]");
984        assert_eq!(
985            t("//chapter[image/@href='linus.gif']/title"),
986            "//chapter[/image::href = \"linus.gif\"]/title"
987        );
988        assert_eq!(t("//chapter[.//image]"), "//chapter[//image]");
989        assert_eq!(t("//p[.='...']"), "//p[:: = \"...\"]");
990        assert_eq!(t("//chapter[text()='x']"), "//chapter[:: = \"x\"]");
991        assert_eq!(
992            t("//chapter[@id='a' or @id='b']"),
993            "//chapter[::id = \"a\" or ::id = \"b\"]"
994        );
995        assert_eq!(
996            t("//chapter[not(image) and title]"),
997            "//chapter[not (/image) and /title]"
998        );
999        assert_eq!(
1000            t("//chapter[contains(@id, 'apt')]"),
1001            "//chapter[::id *= \"apt\"]"
1002        );
1003        assert_eq!(
1004            t("//chapter[starts-with(@id, 'chap')]"),
1005            "//chapter[::id =~ ~(^chap)]"
1006        );
1007        assert_eq!(
1008            t("//city[population > 100000]"),
1009            "//city[/population:: > 100000]"
1010        );
1011    }
1012
1013    #[test]
1014    fn positional_predicates() {
1015        assert_eq!(t("//chapter[position() = 2]"), "//chapter[2]");
1016        assert_eq!(t("//chapter[last()]"), "//chapter[-1]");
1017        assert_eq!(t("//chapter[last()]/@id"), "//chapter[-1]::id");
1018        assert_eq!(t("//chapter[last()]/title"), "//chapter[-1]/title");
1019        assert_eq!(t("//chapter[last()-1]"), "//chapter[-2]");
1020        assert_eq!(t("//chapter[position() > 2]"), "//chapter[3..]");
1021        assert_eq!(t("//chapter[position() >= 2]"), "//chapter[2..]");
1022        assert_eq!(t("//chapter[position() < 3]"), "//chapter[..2]");
1023        assert_eq!(t("//chapter[position() <= 3]"), "//chapter[..3]");
1024        // positional predicates mid-path are ordinary predicates now
1025        assert_eq!(t("//chapter[1]/p[last()]/img"), "//chapter[1]/p[-1]/img");
1026    }
1027
1028    #[test]
1029    fn unions_and_aggregates() {
1030        assert_eq!(t("//title | //p"), "//title || //p");
1031        assert_eq!(t("count(//chapter)"), "//chapter @| count");
1032        assert_eq!(t("sum(//book/@pages)"), "//book::pages @| sum");
1033        assert_eq!(t("sum(//population)"), "//population:: @| sum");
1034    }
1035
1036    #[test]
1037    fn qualified_names_quote() {
1038        assert_eq!(t("//dc:title"), "//'dc:title'");
1039        assert_eq!(t("//dc:title/text()"), "//'dc:title'::");
1040    }
1041
1042    #[test]
1043    fn unsupported_constructs() {
1044        assert!(unsupported("chapter/title").contains("relative"));
1045        assert!(unsupported("/following::p").contains("following"));
1046        assert!(unsupported("//node()").contains("elements only"));
1047        assert!(unsupported("//p[../title]").contains("upward"));
1048        assert!(unsupported("//chapter/@id/x").contains("after an attribute"));
1049    }
1050
1051    #[test]
1052    fn notes_flag_divergences() {
1053        // [n] on an abbreviated // step: XPath positions per parent.
1054        let tr = translate("//p[1]").unwrap();
1055        assert_eq!(tr.query, "//p[1]");
1056        assert_eq!(tr.notes.len(), 1);
1057        assert!(tr.notes[0].contains("within each parent"));
1058        assert_eq!(translate("//*[2]").unwrap().notes.len(), 1);
1059        // ... but child-axis and explicit descendant:: [n] are exact.
1060        assert!(translate("/a/b[1]").unwrap().notes.is_empty());
1061        assert!(translate("/a/*[2]").unwrap().notes.is_empty());
1062        assert!(translate("/descendant::p[1]").unwrap().notes.is_empty());
1063        assert_eq!(translate("/descendant::p[1]").unwrap().query, "//p[1]");
1064        assert!(!translate("//title/text()").unwrap().notes.is_empty());
1065    }
1066}