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) if k.to_string() == "text" => {
151                    self.notes.push(
152                        "text(): Quarb ::text is the concatenated descendant text; \
153                         XPath text() selects immediate text nodes (equal on leaf elements)"
154                            .to_string(),
155                    );
156                    Ok("/text()".to_string())
157                }
158                Some(k) => Ok(format!("/@{k}")),
159                None => Err(XPathError::Unsupported(
160                    "the bare '::' projection (adapter-specific default value)".into(),
161                )),
162            },
163            other => Err(XPathError::Unsupported(format!(
164                "the {other} metadata projection"
165            ))),
166        }
167    }
168
169    fn step(&mut self, s: NodeId) -> Result<String, XPathError> {
170        let name = match self.prop_s(s, "matcher-kind").as_str() {
171            "name" => self.prop_s(s, "matcher"),
172            "any" => "*".to_string(),
173            other => {
174                return Err(XPathError::Unsupported(format!(
175                    "{other} name matching (XPath names are literal)"
176                )));
177            }
178        };
179        let mut out = match self.prop_s(s, "axis").as_str() {
180            "/" => format!("/{name}"),
181            "//" => {
182                self.notes.push(
183                    "//: Quarb selects proper descendants; XPath's // is \
184                     descendant-or-self"
185                        .to_string(),
186                );
187                format!("//{name}")
188            }
189            "\\" => format!("/parent::{name}"),
190            "\\\\" => format!("/ancestor::{name}"),
191            ">>" => format!("/following-sibling::{name}"),
192            "<<" => format!("/preceding-sibling::{name}"),
193            other => {
194                return Err(XPathError::Unsupported(format!(
195                    "the '{other}' axis (XPath 1.0 lacks it, or the reach variant)"
196                )));
197            }
198        };
199        if self.kid(s, "trait").is_some() {
200            return Err(XPathError::Unsupported("trait filters (<...>)".into()));
201        }
202        for p in self.kids(s, "predicate") {
203            out.push_str(&self.predicate(p)?);
204        }
205        if self.prop(s, "leaf") == Some(Value::Bool(true)) {
206            out.push_str("[not(*)]");
207        }
208        Ok(out)
209    }
210
211    fn predicate(&mut self, p: NodeId) -> Result<String, XPathError> {
212        match self.prop_s(p, "kind").as_str() {
213            "index" => match self.prop(p, "value") {
214                Some(Value::Int(n)) if n > 0 => Ok(format!("[{n}]")),
215                Some(Value::Int(-1)) => Ok("[last()]".to_string()),
216                Some(Value::Int(n)) => Ok(format!("[last() - {}]", -n - 1)),
217                _ => Err(XPathError::Unsupported("a non-integer index".into())),
218            },
219            "range" => {
220                let from = match self.prop(p, "from") {
221                    Some(Value::Int(n)) => Some(n),
222                    _ => None,
223                };
224                let to = match self.prop(p, "to") {
225                    Some(Value::Int(n)) => Some(n),
226                    _ => None,
227                };
228                if from.is_some_and(|n| n < 0) || to.is_some_and(|n| n < 0) {
229                    return Err(XPathError::Unsupported("negative range ends".into()));
230                }
231                Ok(match (from, to) {
232                    (Some(a), Some(b)) => {
233                        format!("[position() >= {a} and position() <= {b}]")
234                    }
235                    (Some(a), None) => format!("[position() >= {a}]"),
236                    (None, Some(b)) => format!("[position() <= {b}]"),
237                    (None, None) => String::new(),
238                })
239            }
240            _ => {
241                let parts: Vec<String> = self
242                    .arbor
243                    .children(p)
244                    .into_iter()
245                    .map(|c| self.pred_expr(c))
246                    .collect::<Result<_, _>>()?;
247                Ok(format!("[{}]", parts.join(" and ")))
248            }
249        }
250    }
251
252    fn pred_expr(&mut self, e: NodeId) -> Result<String, XPathError> {
253        match self.kind(e).as_str() {
254            "and" | "or" => {
255                let op = self.kind(e);
256                let kids: Vec<String> = self
257                    .arbor
258                    .children(e)
259                    .into_iter()
260                    .map(|c| self.pred_expr(c))
261                    .collect::<Result<_, _>>()?;
262                Ok(format!("({})", kids.join(&format!(" {op} "))))
263            }
264            "not" => {
265                let inner: Vec<String> = self
266                    .arbor
267                    .children(e)
268                    .into_iter()
269                    .map(|c| self.pred_expr(c))
270                    .collect::<Result<_, _>>()?;
271                Ok(format!("not({})", inner.join(" and ")))
272            }
273            "parens" => {
274                let kids: Vec<String> = self
275                    .arbor
276                    .children(e)
277                    .into_iter()
278                    .map(|c| self.pred_expr(c))
279                    .collect::<Result<_, _>>()?;
280                Ok(format!("({})", kids.join(" and ")))
281            }
282            "compare" => {
283                let op = self.prop_s(e, "op");
284                let kids = self.arbor.children(e);
285                let l = self.operand(kids[0])?;
286                let r = self.operand(kids[1])?;
287                Ok(match op.as_str() {
288                    "=" | "<" | "<=" | ">" | ">=" => format!("{l} {op} {r}"),
289                    "!=" => format!("{l} != {r}"),
290                    "*=" => format!("contains({l}, {r})"),
291                    "=~" => {
292                        return Err(XPathError::Unsupported(
293                            "regex matching (XPath 1.0 has contains/starts-with)".into(),
294                        ));
295                    }
296                    other => {
297                        return Err(XPathError::Unsupported(format!("the '{other}' comparison")));
298                    }
299                })
300            }
301            _ => self.operand(e),
302        }
303    }
304
305    fn operand(&mut self, o: NodeId) -> Result<String, XPathError> {
306        match self.kind(o).as_str() {
307            "literal" => {
308                let v = self.prop(o, "value").unwrap_or(Value::Null);
309                Ok(match self.prop_s(o, "type").as_str() {
310                    "text" => format!("\"{v}\""),
311                    _ => v.to_string(),
312                })
313            }
314            "path" => {
315                let steps = self.kids(o, "step");
316                let mut out = String::new();
317                for s in &steps {
318                    out.push_str(&self.step(*s)?);
319                }
320                let out = out.trim_start_matches('/').to_string();
321                match self.kid(o, "projection") {
322                    Some(p) => {
323                        let proj = self.projection(p)?;
324                        Ok(if out.is_empty() {
325                            proj.trim_start_matches('/').to_string()
326                        } else {
327                            format!("{out}{proj}")
328                        })
329                    }
330                    None => Ok(if out.is_empty() { ".".into() } else { out }),
331                }
332            }
333            other => Err(XPathError::Unsupported(format!(
334                "the '{other}' operand (registers and topics are Quarb-side state)"
335            ))),
336        }
337    }
338}