Skip to main content

quarb_xpath/
export.rs

1//! The reverse direction: Quarb → XPath 1.0.
2//!
3//! Walks the query's *reflection arbor* — the locked vocabulary is
4//! the stable surface for query-rewriting tooling — and emits an
5//! equivalent XPath 1.0 expression, refusing what XPath cannot
6//! express. Fragments and pure macros expand before reflection, so
7//! they translate for free.
8//!
9//! The translatable subset: downward navigation (`/`, `//`),
10//! parents and ancestors (`\` → `parent::`, `\\` → `ancestor::`),
11//! the sibling reach family (`>>` → `following-sibling::`, `<<` →
12//! `preceding-sibling::`), predicates (comparisons over attributes
13//! and child text, `and`/`or`/`not`, existence, indexes → `[n]` /
14//! `[last()]`, ranges → `position()`), the leaf anchor →
15//! `[not(*)]`, unions (`|`), terminal projections (`::a` → `/@a`,
16//! `::text` → `/text()`), and the `count` / `sum` aggregates as
17//! function wrappers.
18//!
19//! Notes carry the standing divergences (Quarb's `//` is proper
20//! descendants where XPath's is descendant-or-self; `::text`
21//! concatenates where `text()` selects immediate text nodes).
22
23use crate::{Translation, XPathError};
24use quarb::reflect::QueryArbor;
25use quarb::{AstAdapter, NodeId, Value};
26
27/// Translate a Quarb query to an XPath 1.0 expression.
28pub fn export(quarb: &str) -> Result<Translation, XPathError> {
29    let arbor = QueryArbor::parse(quarb)
30        .map_err(|e| XPathError::Syntax(0, format!("parsing Quarb: {e}")))?;
31    if has_group(&arbor) {
32        return Err(XPathError::Syntax(0, "path patterns (groups and quantifiers) have no XPath 1.0 translation".into()));
33    }
34    let mut ex = Exporter {
35        arbor,
36        notes: Vec::new(),
37    };
38    let query = ex.query()?;
39    Ok(Translation {
40        query,
41        notes: ex.notes,
42    })
43}
44
45/// Whether the reflected query carries any path-pattern group; the
46/// walkers below count only `step` children, so an unguarded group
47/// would silently vanish from the translation.
48fn has_group(arbor: &QueryArbor) -> bool {
49    let mut stack = vec![arbor.root()];
50    while let Some(n) = stack.pop() {
51        if arbor.name(n).as_deref() == Some("group") {
52            return true;
53        }
54        stack.extend(arbor.children(n));
55    }
56    false
57}
58
59struct Exporter {
60    arbor: QueryArbor,
61    notes: Vec<String>,
62}
63
64impl Exporter {
65    fn kids(&self, n: NodeId, kind: &str) -> Vec<NodeId> {
66        self.arbor
67            .children(n)
68            .into_iter()
69            .filter(|&c| self.arbor.name(c).as_deref() == Some(kind))
70            .collect()
71    }
72
73    fn kid(&self, n: NodeId, kind: &str) -> Option<NodeId> {
74        self.kids(n, kind).into_iter().next()
75    }
76
77    fn prop(&self, n: NodeId, key: &str) -> Option<Value> {
78        self.arbor.property(n, key)
79    }
80
81    fn prop_s(&self, n: NodeId, key: &str) -> String {
82        self.prop(n, key).map(|v| v.to_string()).unwrap_or_default()
83    }
84
85    fn kind(&self, n: NodeId) -> String {
86        self.arbor.name(n).unwrap_or_default()
87    }
88
89    fn query(&mut self) -> Result<String, XPathError> {
90        let root = self.arbor.root();
91        let q = self
92            .kid(root, "query")
93            .ok_or_else(|| XPathError::Unsupported("empty query".into()))?;
94        if self.kid(q, "query").is_some() {
95            return Err(XPathError::Unsupported(
96                "correlation (<=>) has no XPath equivalent".into(),
97            ));
98        }
99        let branches = self.kids(q, "branch");
100        let paths: Vec<String> = branches
101            .iter()
102            .map(|&b| self.branch(b))
103            .collect::<Result<_, _>>()?;
104        let mut out = paths.join(" | ");
105
106        if let Some(pipe) = self.kid(q, "pipeline") {
107            let stages = self.arbor.children(pipe);
108            if stages.len() != 1 {
109                return Err(XPathError::Unsupported(
110                    "a multi-stage pipeline (XPath 1.0 has no pipeline)".into(),
111                ));
112            }
113            let s = stages[0];
114            if self.kind(s) != "agg" {
115                return Err(XPathError::Unsupported(format!(
116                    "the '{}' stage (XPath 1.0 has no pipeline)",
117                    self.kind(s)
118                )));
119            }
120            out = match self.prop_s(s, "name").as_str() {
121                "count" => format!("count({out})"),
122                "sum" => format!("sum({out})"),
123                other => {
124                    return Err(XPathError::Unsupported(format!(
125                        "the '{other}' aggregate (XPath 1.0 has count() and sum())"
126                    )));
127                }
128            };
129        }
130        Ok(out)
131    }
132
133    fn branch(&mut self, b: NodeId) -> Result<String, XPathError> {
134        let mut out = String::new();
135        for step in self.kids(b, "step") {
136            out.push_str(&self.step(step)?);
137        }
138        if let Some(p) = self.kid(b, "projection") {
139            out.push_str(&self.projection(p)?);
140        }
141        if out.is_empty() {
142            out.push('.');
143        }
144        Ok(out)
145    }
146
147    fn projection(&mut self, p: NodeId) -> Result<String, XPathError> {
148        match self.prop_s(p, "kind").as_str() {
149            "property" => match self.prop(p, "key") {
150                Some(k) => Ok(format!("/@{k}")),
151                None => {
152                    self.notes.push(
153                        "text(): Quarb's bare :: is the concatenated descendant text; \
154                         XPath text() selects immediate text nodes (equal on leaf elements)"
155                            .to_string(),
156                    );
157                    Ok("/text()".to_string())
158                }
159            },
160            other => Err(XPathError::Unsupported(format!(
161                "the {other} metadata projection"
162            ))),
163        }
164    }
165
166    fn step(&mut self, s: NodeId) -> Result<String, XPathError> {
167        let name = match self.prop_s(s, "matcher-kind").as_str() {
168            "name" => self.prop_s(s, "matcher"),
169            "any" => "*".to_string(),
170            other => {
171                return Err(XPathError::Unsupported(format!(
172                    "{other} name matching (XPath names are literal)"
173                )));
174            }
175        };
176        let mut out = match self.prop_s(s, "axis").as_str() {
177            "/" => format!("/{name}"),
178            "//" => {
179                self.notes.push(
180                    "//: Quarb selects proper descendants; XPath's // is \
181                     descendant-or-self"
182                        .to_string(),
183                );
184                format!("//{name}")
185            }
186            "\\" => format!("/parent::{name}"),
187            "\\\\" => format!("/ancestor::{name}"),
188            ">>" => format!("/following-sibling::{name}"),
189            "<<" => format!("/preceding-sibling::{name}"),
190            other => {
191                return Err(XPathError::Unsupported(format!(
192                    "the '{other}' axis (XPath 1.0 lacks it, or the reach variant)"
193                )));
194            }
195        };
196        if self.kid(s, "trait").is_some() {
197            return Err(XPathError::Unsupported("trait filters (<...>)".into()));
198        }
199        for p in self.kids(s, "predicate") {
200            out.push_str(&self.predicate(p)?);
201        }
202        if self.prop(s, "leaf") == Some(Value::Bool(true)) {
203            out.push_str("[not(*)]");
204        }
205        Ok(out)
206    }
207
208    fn predicate(&mut self, p: NodeId) -> Result<String, XPathError> {
209        match self.prop_s(p, "kind").as_str() {
210            "index" => match self.prop(p, "value") {
211                Some(Value::Int(n)) if n > 0 => Ok(format!("[{n}]")),
212                Some(Value::Int(-1)) => Ok("[last()]".to_string()),
213                Some(Value::Int(n)) => Ok(format!("[last() - {}]", -n - 1)),
214                _ => Err(XPathError::Unsupported("a non-integer index".into())),
215            },
216            "range" => {
217                let from = match self.prop(p, "from") {
218                    Some(Value::Int(n)) => Some(n),
219                    _ => None,
220                };
221                let to = match self.prop(p, "to") {
222                    Some(Value::Int(n)) => Some(n),
223                    _ => None,
224                };
225                if from.is_some_and(|n| n < 0) || to.is_some_and(|n| n < 0) {
226                    return Err(XPathError::Unsupported("negative range ends".into()));
227                }
228                Ok(match (from, to) {
229                    (Some(a), Some(b)) => {
230                        format!("[position() >= {a} and position() <= {b}]")
231                    }
232                    (Some(a), None) => format!("[position() >= {a}]"),
233                    (None, Some(b)) => format!("[position() <= {b}]"),
234                    (None, None) => String::new(),
235                })
236            }
237            _ => {
238                let parts: Vec<String> = self
239                    .arbor
240                    .children(p)
241                    .into_iter()
242                    .map(|c| self.pred_expr(c))
243                    .collect::<Result<_, _>>()?;
244                Ok(format!("[{}]", parts.join(" and ")))
245            }
246        }
247    }
248
249    fn pred_expr(&mut self, e: NodeId) -> Result<String, XPathError> {
250        match self.kind(e).as_str() {
251            "and" | "or" => {
252                let op = self.kind(e);
253                let kids: Vec<String> = self
254                    .arbor
255                    .children(e)
256                    .into_iter()
257                    .map(|c| self.pred_expr(c))
258                    .collect::<Result<_, _>>()?;
259                Ok(format!("({})", kids.join(&format!(" {op} "))))
260            }
261            "not" => {
262                let inner: Vec<String> = self
263                    .arbor
264                    .children(e)
265                    .into_iter()
266                    .map(|c| self.pred_expr(c))
267                    .collect::<Result<_, _>>()?;
268                Ok(format!("not({})", inner.join(" and ")))
269            }
270            "parens" => {
271                let kids: Vec<String> = self
272                    .arbor
273                    .children(e)
274                    .into_iter()
275                    .map(|c| self.pred_expr(c))
276                    .collect::<Result<_, _>>()?;
277                Ok(format!("({})", kids.join(" and ")))
278            }
279            "compare" => {
280                let op = self.prop_s(e, "op");
281                let kids = self.arbor.children(e);
282                let l = self.operand(kids[0])?;
283                let r = self.operand(kids[1])?;
284                Ok(match op.as_str() {
285                    "=" | "<" | "<=" | ">" | ">=" => format!("{l} {op} {r}"),
286                    "!=" => format!("{l} != {r}"),
287                    "*=" => format!("contains({l}, {r})"),
288                    "=~" => {
289                        return Err(XPathError::Unsupported(
290                            "regex matching (XPath 1.0 has contains/starts-with)".into(),
291                        ));
292                    }
293                    other => {
294                        return Err(XPathError::Unsupported(format!("the '{other}' comparison")));
295                    }
296                })
297            }
298            _ => self.operand(e),
299        }
300    }
301
302    fn operand(&mut self, o: NodeId) -> Result<String, XPathError> {
303        match self.kind(o).as_str() {
304            "literal" => {
305                let v = self.prop(o, "value").unwrap_or(Value::Null);
306                Ok(match self.prop_s(o, "type").as_str() {
307                    "text" => format!("\"{v}\""),
308                    _ => v.to_string(),
309                })
310            }
311            "path" => {
312                let steps = self.kids(o, "step");
313                let mut out = String::new();
314                for s in &steps {
315                    out.push_str(&self.step(*s)?);
316                }
317                let out = out.trim_start_matches('/').to_string();
318                match self.kid(o, "projection") {
319                    Some(p) => {
320                        let proj = self.projection(p)?;
321                        Ok(if out.is_empty() {
322                            proj.trim_start_matches('/').to_string()
323                        } else {
324                            format!("{out}{proj}")
325                        })
326                    }
327                    None => Ok(if out.is_empty() { ".".into() } else { out }),
328                }
329            }
330            other => Err(XPathError::Unsupported(format!(
331                "the '{other}' operand (registers and topics are Quarb-side state)"
332            ))),
333        }
334    }
335}