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}
72
73impl Expr {
74    /// Does this expression mutate the document (contains an assignment or a
75    /// `del(...)`)? The CLI uses this to pick mutation mode vs query mode.
76    pub fn is_mutation(&self) -> bool {
77        match self {
78            Expr::Assign(..) | Expr::UpdateAssign(..) | Expr::AddAssign(..) => true,
79            Expr::Call(name, args) => name == "del" || args.iter().any(Expr::is_mutation),
80            Expr::Pipe(a, b) => a.is_mutation() || b.is_mutation(),
81            Expr::Alternative(a, b) => a.is_mutation() || b.is_mutation(),
82            Expr::Comma(items) => items.iter().any(Expr::is_mutation),
83            Expr::Neg(inner) => inner.is_mutation(),
84            Expr::Binary(_, a, b) => a.is_mutation() || b.is_mutation(),
85            Expr::Collect(inner) => inner.as_ref().is_some_and(|e| e.is_mutation()),
86            Expr::ObjectConstruct(pairs) => pairs.iter().any(|(_, e)| e.is_mutation()),
87            Expr::Path(_) | Expr::Literal(_) => false,
88        }
89    }
90
91    /// The path steps if this expression is a plain path (the only valid left
92    /// side of an assignment), else `None`.
93    pub fn as_path(&self) -> Option<&[Step]> {
94        match self {
95            Expr::Path(steps) => Some(steps),
96            _ => None,
97        }
98    }
99
100    /// Does this expression address a comment (a `#` step) anywhere? The CLI
101    /// routes such queries through the comment-aware evaluator and rejects
102    /// comment mutation (a Phase-2 feature).
103    pub fn has_comment(&self) -> bool {
104        match self {
105            Expr::Path(steps) => steps.iter().any(|s| matches!(s, Step::Comment(_))),
106            Expr::Pipe(a, b) | Expr::Alternative(a, b) | Expr::Binary(_, a, b) => {
107                a.has_comment() || b.has_comment()
108            }
109            Expr::Assign(a, b) | Expr::UpdateAssign(a, b) | Expr::AddAssign(a, b) => {
110                a.has_comment() || b.has_comment()
111            }
112            Expr::Comma(items) => items.iter().any(Expr::has_comment),
113            Expr::Neg(inner) => inner.has_comment(),
114            // The bare `comments` stream needs the commented projection too.
115            Expr::Call(name, args) => name == "comments" || args.iter().any(Expr::has_comment),
116            Expr::Collect(inner) => inner.as_ref().is_some_and(|e| e.has_comment()),
117            Expr::ObjectConstruct(pairs) => pairs.iter().any(|(_, e)| e.has_comment()),
118            Expr::Literal(_) => false,
119        }
120    }
121}
122
123/// Render a path of steps back to jq-ish source text (`.a.b[0]`, `.["a.b"]`) for
124/// error messages. An empty path is `.` (identity).
125pub fn render_path(steps: &[Step]) -> String {
126    if steps.is_empty() {
127        return ".".to_string();
128    }
129    let mut out = String::new();
130    for step in steps {
131        match step {
132            Step::Field(k) if is_bare_ident(k) => {
133                out.push('.');
134                out.push_str(k);
135            }
136            // Non-identifier keys use the bracket-string form so the rendered path
137            // is itself a valid expression.
138            Step::Field(k) => out.push_str(&format!(".[{k:?}]")),
139            Step::Index(i) => out.push_str(&format!("[{i}]")),
140            Step::Iterate => out.push_str("[]"),
141            Step::Comment(crate::CommentKind::Head) => out.push_str(".#"),
142            Step::Comment(kind) => out.push_str(&format!(".#.{}", kind.as_str())),
143        }
144    }
145    out
146}
147
148/// Whether `k` is a bare identifier that needs no quoting in a path (`.foo`).
149fn is_bare_ident(k: &str) -> bool {
150    let mut chars = k.chars();
151    chars
152        .next()
153        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
154        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn renders_paths() {
163        assert_eq!(render_path(&[]), ".");
164        assert_eq!(
165            render_path(&[Step::Field("a".into()), Step::Field("b".into())]),
166            ".a.b"
167        );
168        assert_eq!(
169            render_path(&[Step::Field("arr".into()), Step::Index(0)]),
170            ".arr[0]"
171        );
172        assert_eq!(
173            render_path(&[Step::Field("xs".into()), Step::Iterate]),
174            ".xs[]"
175        );
176        // A dotted key can't be a bare identifier - bracket-quote it.
177        assert_eq!(render_path(&[Step::Field("a.b".into())]), ".[\"a.b\"]");
178        // The comment step renders back to its accessor form.
179        assert_eq!(
180            render_path(&[Step::Field("a".into()), Step::Comment(CommentKind::Head)]),
181            ".a.#"
182        );
183        assert_eq!(
184            render_path(&[Step::Comment(CommentKind::Inline)]),
185            ".#.inline"
186        );
187    }
188
189    fn p(src: &str) -> Expr {
190        crate::parse(src).unwrap()
191    }
192
193    #[test]
194    fn is_mutation_covers_every_arm() {
195        assert!(p(".a = 1").is_mutation());
196        assert!(p(".a |= . + 1").is_mutation());
197        assert!(p(".a += 1").is_mutation());
198        assert!(p("del(.a)").is_mutation());
199        // Mutation nested inside each recursive form.
200        assert!(p(".a = 1 | .b").is_mutation()); // Pipe
201        assert!(p(".a = 1, .b").is_mutation()); // Comma
202        assert!(p("[.a = 1]").is_mutation()); // Collect
203        assert!(p("{k: (.a = 1)}").is_mutation()); // ObjectConstruct
204        assert!(p("select(.a = 1)").is_mutation()); // Call args
205        // Pure queries are not mutations.
206        assert!(!p(".a.b[0]").is_mutation());
207        assert!(!p("1 + 2").is_mutation());
208        assert!(!p("keys").is_mutation());
209        assert!(!p("-.a").is_mutation());
210    }
211
212    #[test]
213    fn has_comment_covers_every_arm() {
214        assert!(p(".a.#").has_comment());
215        assert!(p("comments").has_comment());
216        assert!(p(".a.# | ascii_upcase").has_comment()); // Pipe
217        assert!(p(".a.# // \"x\"").has_comment()); // Alternative
218        assert!(p(".a.#, .b").has_comment()); // Comma
219        assert!(p("[.a.#]").has_comment()); // Collect
220        assert!(p("select(.a.#)").has_comment()); // Call args
221        assert!(p(".a.# == \"x\"").has_comment()); // Binary
222        assert!(!p("-.a").has_comment()); // Neg, no comment
223        assert!(!p(".a.b").has_comment());
224    }
225
226    #[test]
227    fn as_path_only_for_plain_paths() {
228        assert!(p(".a.b").as_path().is_some());
229        assert!(p("1 + 2").as_path().is_none());
230        assert!(p("keys").as_path().is_none());
231    }
232}