Skip to main content

edikt_core/
ast.rs

1//! The expression AST.
2//!
3//! The v1 query language desugars dotted/indexed paths into a `Path` of steps,
4//! so the evaluator only deals with a handful of node kinds. Mutation forms
5//! (`=`, `|=`, `+=`, `del`) are not parsed yet - they arrive with M2.
6
7use crate::comment::CommentKind;
8use crate::value::Value;
9
10/// One navigation step within a path, applied to the current input.
11#[derive(Debug, Clone, PartialEq)]
12pub enum Step {
13    /// `.field` or `."quoted"` - object member access.
14    Field(String),
15    /// `[n]` - array index (negative counts from the end).
16    Index(i64),
17    /// `[]` - iterate array elements / object values.
18    Iterate,
19    /// `#` (head) / `#.head` / `#.inline` / `#.foot` - the comment of the node
20    /// reached by the preceding steps. Terminal: no step may follow it.
21    Comment(CommentKind),
22}
23
24/// A binary operator.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub enum BinOp {
27    Add,
28    Sub,
29    Mul,
30    Div,
31    Mod,
32    Eq,
33    Ne,
34    Lt,
35    Gt,
36    Le,
37    Ge,
38}
39
40/// An expression node.
41#[derive(Debug, Clone, PartialEq)]
42pub enum Expr {
43    /// A path from the current input; an empty step list is identity (`.`).
44    Path(Vec<Step>),
45    /// A literal scalar value.
46    Literal(Value),
47    /// Arithmetic negation.
48    Neg(Box<Expr>),
49    /// A binary operation.
50    Binary(BinOp, Box<Expr>, Box<Expr>),
51    /// `left | right` - pipe each output of `left` into `right`.
52    Pipe(Box<Expr>, Box<Expr>),
53    /// `left // right` - jq's alternative: `left`'s truthy outputs, or -
54    /// when there are none (a miss, `null`, `false`) - `right`'s.
55    Alternative(Box<Expr>, Box<Expr>),
56    /// `a, b, c` - concatenate output streams.
57    Comma(Vec<Expr>),
58    /// A function call, e.g. `length`, `select(.x == 1)`, `ltrimstr("pre")`.
59    Call(String, Vec<Expr>),
60    /// `[ expr ]` - collect the inner stream into an array (`None` = `[]`).
61    Collect(Option<Box<Expr>>),
62    /// `{ key: expr, ... }` - construct an object.
63    ObjectConstruct(Vec<(String, Expr)>),
64    /// `path = rhs` - assign; `rhs` is evaluated against the whole input.
65    Assign(Box<Expr>, Box<Expr>),
66    /// `path |= rhs` - update-assign; `rhs` sees the current value at `path`.
67    UpdateAssign(Box<Expr>, Box<Expr>),
68    /// `path += rhs` - add-assign; `path = path + rhs` (numeric add, string/array
69    /// concat). `rhs` is evaluated against the whole input.
70    AddAssign(Box<Expr>, Box<Expr>),
71    /// `^dN | body` - select document N of a multi-document YAML stream, then
72    /// apply `body` to it. A document-axis construct handled by the CLI/format
73    /// dispatch (not the value evaluator); only meaningful over a multi-document
74    /// stream, where it is the positional sibling of `select(...)`.
75    DocSelect(usize, Box<Expr>),
76}
77
78impl Expr {
79    /// Does this expression mutate the document (contains an assignment or a
80    /// `del(...)`)? The CLI uses this to pick mutation mode vs query mode.
81    pub fn is_mutation(&self) -> bool {
82        match self {
83            Expr::Assign(..) | Expr::UpdateAssign(..) | Expr::AddAssign(..) => true,
84            Expr::Call(name, args) => name == "del" || args.iter().any(Expr::is_mutation),
85            Expr::Pipe(a, b) => a.is_mutation() || b.is_mutation(),
86            Expr::Alternative(a, b) => a.is_mutation() || b.is_mutation(),
87            Expr::Comma(items) => items.iter().any(Expr::is_mutation),
88            Expr::Neg(inner) => inner.is_mutation(),
89            Expr::Binary(_, a, b) => a.is_mutation() || b.is_mutation(),
90            Expr::Collect(inner) => inner.as_ref().is_some_and(|e| e.is_mutation()),
91            Expr::ObjectConstruct(pairs) => pairs.iter().any(|(_, e)| e.is_mutation()),
92            Expr::DocSelect(_, body) => body.is_mutation(),
93            Expr::Path(_) | Expr::Literal(_) => false,
94        }
95    }
96
97    /// The path steps if this expression is a plain path (the only valid left
98    /// side of an assignment), else `None`.
99    pub fn as_path(&self) -> Option<&[Step]> {
100        match self {
101            Expr::Path(steps) => Some(steps),
102            _ => None,
103        }
104    }
105
106    /// Does this expression address a comment (a `#` step) anywhere? The CLI
107    /// routes such queries through the comment-aware evaluator and rejects
108    /// comment mutation (a Phase-2 feature).
109    pub fn has_comment(&self) -> bool {
110        match self {
111            Expr::Path(steps) => steps.iter().any(|s| matches!(s, Step::Comment(_))),
112            Expr::Pipe(a, b) | Expr::Alternative(a, b) | Expr::Binary(_, a, b) => {
113                a.has_comment() || b.has_comment()
114            }
115            Expr::Assign(a, b) | Expr::UpdateAssign(a, b) | Expr::AddAssign(a, b) => {
116                a.has_comment() || b.has_comment()
117            }
118            Expr::Comma(items) => items.iter().any(Expr::has_comment),
119            Expr::Neg(inner) => inner.has_comment(),
120            // The bare `comments` stream needs the commented projection too.
121            Expr::Call(name, args) => name == "comments" || args.iter().any(Expr::has_comment),
122            Expr::Collect(inner) => inner.as_ref().is_some_and(|e| e.has_comment()),
123            Expr::ObjectConstruct(pairs) => pairs.iter().any(|(_, e)| e.has_comment()),
124            Expr::DocSelect(_, body) => body.has_comment(),
125            Expr::Literal(_) => false,
126        }
127    }
128}
129
130/// Render a path of steps back to jq-ish source text (`.a.b[0]`, `.["a.b"]`) for
131/// error messages. An empty path is `.` (identity).
132pub fn render_path(steps: &[Step]) -> String {
133    if steps.is_empty() {
134        return ".".to_string();
135    }
136    let mut out = String::new();
137    for step in steps {
138        match step {
139            Step::Field(k) if is_bare_ident(k) => {
140                out.push('.');
141                out.push_str(k);
142            }
143            // Non-identifier keys use the bracket-string form so the rendered path
144            // is itself a valid expression.
145            Step::Field(k) => out.push_str(&format!(".[{k:?}]")),
146            Step::Index(i) => out.push_str(&format!("[{i}]")),
147            Step::Iterate => out.push_str("[]"),
148            Step::Comment(crate::CommentKind::Head) => out.push_str(".#"),
149            Step::Comment(kind) => out.push_str(&format!(".#.{}", kind.as_str())),
150        }
151    }
152    out
153}
154
155/// Whether `k` is a bare identifier that needs no quoting in a path (`.foo`).
156fn is_bare_ident(k: &str) -> bool {
157    let mut chars = k.chars();
158    chars
159        .next()
160        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
161        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn renders_paths() {
170        assert_eq!(render_path(&[]), ".");
171        assert_eq!(
172            render_path(&[Step::Field("a".into()), Step::Field("b".into())]),
173            ".a.b"
174        );
175        assert_eq!(
176            render_path(&[Step::Field("arr".into()), Step::Index(0)]),
177            ".arr[0]"
178        );
179        assert_eq!(
180            render_path(&[Step::Field("xs".into()), Step::Iterate]),
181            ".xs[]"
182        );
183        // A dotted key can't be a bare identifier - bracket-quote it.
184        assert_eq!(render_path(&[Step::Field("a.b".into())]), ".[\"a.b\"]");
185        // The comment step renders back to its accessor form.
186        assert_eq!(
187            render_path(&[Step::Field("a".into()), Step::Comment(CommentKind::Head)]),
188            ".a.#"
189        );
190        assert_eq!(
191            render_path(&[Step::Comment(CommentKind::Inline)]),
192            ".#.inline"
193        );
194    }
195
196    fn p(src: &str) -> Expr {
197        crate::parse(src).unwrap()
198    }
199
200    #[test]
201    fn is_mutation_covers_every_arm() {
202        assert!(p(".a = 1").is_mutation());
203        assert!(p(".a |= . + 1").is_mutation());
204        assert!(p(".a += 1").is_mutation());
205        assert!(p("del(.a)").is_mutation());
206        // Mutation nested inside each recursive form.
207        assert!(p(".a = 1 | .b").is_mutation()); // Pipe
208        assert!(p(".a = 1, .b").is_mutation()); // Comma
209        assert!(p("[.a = 1]").is_mutation()); // Collect
210        assert!(p("{k: (.a = 1)}").is_mutation()); // ObjectConstruct
211        assert!(p("select(.a = 1)").is_mutation()); // Call args
212        // Pure queries are not mutations.
213        assert!(!p(".a.b[0]").is_mutation());
214        assert!(!p("1 + 2").is_mutation());
215        assert!(!p("keys").is_mutation());
216        assert!(!p("-.a").is_mutation());
217    }
218
219    #[test]
220    fn has_comment_covers_every_arm() {
221        assert!(p(".a.#").has_comment());
222        assert!(p("comments").has_comment());
223        assert!(p(".a.# | ascii_upcase").has_comment()); // Pipe
224        assert!(p(".a.# // \"x\"").has_comment()); // Alternative
225        assert!(p(".a.#, .b").has_comment()); // Comma
226        assert!(p("[.a.#]").has_comment()); // Collect
227        assert!(p("select(.a.#)").has_comment()); // Call args
228        assert!(p(".a.# == \"x\"").has_comment()); // Binary
229        assert!(!p("-.a").has_comment()); // Neg, no comment
230        assert!(!p(".a.b").has_comment());
231    }
232
233    #[test]
234    fn as_path_only_for_plain_paths() {
235        assert!(p(".a.b").as_path().is_some());
236        assert!(p("1 + 2").as_path().is_none());
237        assert!(p("keys").as_path().is_none());
238    }
239}