Skip to main content

edikt_core/
parser.rs

1//! Recursive-descent / precedence parser for the expression language.
2//!
3//! Precedence, lowest to highest: `|` (pipe), `,` (comma), comparison,
4//! additive, multiplicative, unary `-`, primary. Dotted/indexed paths desugar
5//! into a `Path` of steps.
6
7use crate::ast::{BinOp, Expr, Step};
8use crate::comment::CommentKind;
9use crate::lexer::Lx;
10use crate::value::Value;
11use logos::Logos;
12
13/// A parse failure with a byte offset into the source expression.
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15#[error("{msg} (at offset {pos})")]
16pub struct ParseError {
17    pub msg: String,
18    pub pos: usize,
19}
20
21struct Tok {
22    kind: Lx,
23    text: String,
24    start: usize,
25}
26
27/// Parse a complete expression, consuming all of `src`.
28pub fn parse(src: &str) -> Result<Expr, ParseError> {
29    let toks = lex(src)?;
30    let mut p = Parser { toks, pos: 0 };
31    let e = match p.parse_program() {
32        Ok(e) => e,
33        Err(e) => return Err(with_hyphen_hint(e, src)),
34    };
35    if p.pos != p.toks.len() {
36        let t = &p.toks[p.pos];
37        return Err(with_hyphen_hint(
38            ParseError {
39                msg: format!("unexpected trailing token `{}`", t.text),
40                pos: t.start,
41            },
42            src,
43        ));
44    }
45    Ok(e)
46}
47
48/// Replace a parse failure with the hyphenated-key explanation when the source
49/// contains one.
50///
51/// Assignment targets no longer need this: [`Parser::try_assign_target`] parses
52/// hyphenated keys outright, because subtraction cannot appear on the left of
53/// `=`. Everywhere else the ambiguity is real, since `.total-length` is a
54/// legitimate subtraction of the `length` builtin, so a **query** like
55/// `.dev-dependencies.serde_json` still cannot be guessed at. It dies at the `.`
56/// after `dependencies` with "unexpected trailing token `.`", which names
57/// neither the hyphen nor the fix.
58///
59/// Only ever applied to an already failing parse, so it cannot change how a
60/// working expression behaves.
61fn with_hyphen_hint(err: ParseError, src: &str) -> ParseError {
62    // The LHS check got there first and knows more than this does.
63    if err.msg.contains("contains `-`") {
64        return err;
65    }
66    match hyphenated_key(src) {
67        // Same wording as the LHS check, so one mistake has one message.
68        Some((key, pos)) => ParseError {
69            msg: format!(
70                "key `{key}` contains `-` (parsed as subtraction); quote it: {}\"{key}\"",
71                &src[..pos]
72            ),
73            pos,
74        },
75        None => err,
76    }
77}
78
79/// The hyphenated-key hint for an `unknown function \`name\`` evaluation error,
80/// when `name` is literally the tail of a hyphenated bare key in `src`.
81///
82/// Closes the diagnostic gap in jhheider/edikt#63. `.dev-dependencies.serde`
83/// dies in the *parser* (at the `.` after the hyphenated key) and
84/// [`with_hyphen_hint`] catches it. Bare `.dev-dependencies` parses fine as
85/// `.dev - dependencies()` and only fails at eval, where the source is no
86/// longer in scope - so the worse message landed on the form people type first.
87///
88/// The tail check is what keeps this from being the blunt instrument the issue
89/// worried about: the hint fires only when the unknown function *is* the key's
90/// last segment, so an unrelated eval failure in an expression that merely
91/// contains a hyphen (`.a-b | nosuchfn`) stays quiet. `.total-length` never
92/// reaches here at all, since `length` is a real builtin and evaluates.
93pub fn hyphen_hint_for_unknown_function(src: &str, name: &str) -> Option<String> {
94    let (key, pos) = hyphenated_key(src)?;
95    if key.rsplit('-').next() != Some(name) {
96        return None;
97    }
98    Some(format!(
99        "key `{key}` contains `-` (parsed as subtraction); quote it: {}\"{key}\"",
100        &src[..pos]
101    ))
102}
103
104/// The first `.some-key` in the source, with the offset of its first character.
105///
106/// A hyphen only counts when it sits between two identifier characters, so
107/// `.a-1` and `.a - b` are left alone: the first is arithmetic on a literal and
108/// the second is spaced, and neither is the mistake being diagnosed.
109fn hyphenated_key(src: &str) -> Option<(String, usize)> {
110    let b = src.as_bytes();
111    let ident_start = |c: u8| c.is_ascii_alphabetic() || c == b'_';
112    let ident_char = |c: u8| c.is_ascii_alphanumeric() || c == b'_';
113
114    let mut i = 0;
115    while i < b.len() {
116        if b[i] != b'.' || i + 1 >= b.len() || !ident_start(b[i + 1]) {
117            i += 1;
118            continue;
119        }
120        let start = i + 1;
121        let mut j = start;
122        let mut hyphenated = false;
123        while j < b.len() {
124            if ident_char(b[j]) {
125                j += 1;
126            } else if b[j] == b'-' && j + 1 < b.len() && ident_start(b[j + 1]) {
127                hyphenated = true;
128                j += 1;
129            } else {
130                break;
131            }
132        }
133        if hyphenated {
134            return Some((src[start..j].to_string(), start));
135        }
136        i = j;
137    }
138    None
139}
140
141fn lex(src: &str) -> Result<Vec<Tok>, ParseError> {
142    let mut lx = Lx::lexer(src);
143    let mut out = Vec::new();
144    while let Some(res) = lx.next() {
145        let span = lx.span();
146        match res {
147            Ok(kind) => out.push(Tok {
148                kind,
149                text: lx.slice().to_string(),
150                start: span.start,
151            }),
152            Err(_) => {
153                return Err(ParseError {
154                    msg: format!("unexpected character `{}`", lx.slice()),
155                    pos: span.start,
156                });
157            }
158        }
159    }
160    Ok(out)
161}
162
163struct Parser {
164    toks: Vec<Tok>,
165    pos: usize,
166}
167
168impl Parser {
169    fn peek(&self) -> Option<Lx> {
170        self.toks.get(self.pos).map(|t| t.kind)
171    }
172    fn text(&self) -> &str {
173        self.toks
174            .get(self.pos)
175            .map(|t| t.text.as_str())
176            .unwrap_or("")
177    }
178    fn at_end(&self) -> usize {
179        self.toks
180            .last()
181            .map(|t| t.start + t.text.len())
182            .unwrap_or(0)
183    }
184    fn err_here(&self, msg: impl Into<String>) -> ParseError {
185        let pos = self
186            .toks
187            .get(self.pos)
188            .map(|t| t.start)
189            .unwrap_or_else(|| self.at_end());
190        ParseError {
191            msg: msg.into(),
192            pos,
193        }
194    }
195    fn expect(&mut self, kind: Lx, what: &str) -> Result<(), ParseError> {
196        if self.peek() == Some(kind) {
197            self.pos += 1;
198            Ok(())
199        } else {
200            Err(self.err_here(format!("expected {what}")))
201        }
202    }
203
204    /// A whole program: an optional leading `^dN` document selector (for
205    /// multi-document YAML streams), then the expression it scopes.
206    fn parse_program(&mut self) -> Result<Expr, ParseError> {
207        if self.peek() != Some(Lx::Caret) {
208            return self.parse_pipe();
209        }
210        self.pos += 1; // `^`
211        let n = match self.peek() {
212            Some(Lx::Ident) => {
213                let digits = self.text().strip_prefix('d').ok_or_else(|| {
214                    self.err_here("document selector is `^dN`, e.g. `^d0` (document 0)")
215                })?;
216                let n = digits.parse::<usize>().map_err(|_| {
217                    self.err_here("`^dN` needs a document index, e.g. `^d0` (document 0)")
218                })?;
219                self.pos += 1;
220                n
221            }
222            _ => {
223                return Err(self.err_here("expected a document index after `^`, e.g. `^d0`"));
224            }
225        };
226        // `^dN | body` and `^dN.body` both scope `body` to the document; a bare
227        // `^dN` selects the whole document (identity body).
228        if self.peek() == Some(Lx::Pipe) {
229            self.pos += 1;
230        }
231        let body = if self.peek().is_none() {
232            Expr::Path(Vec::new())
233        } else {
234            self.parse_pipe()?
235        };
236        Ok(Expr::DocSelect(n, Box::new(body)))
237    }
238
239    fn parse_pipe(&mut self) -> Result<Expr, ParseError> {
240        let mut left = self.parse_comma()?;
241        while self.peek() == Some(Lx::Pipe) {
242            self.pos += 1;
243            let right = self.parse_comma()?;
244            left = Expr::Pipe(Box::new(left), Box::new(right));
245        }
246        Ok(left)
247    }
248
249    fn parse_comma(&mut self) -> Result<Expr, ParseError> {
250        let first = self.parse_assign()?;
251        if self.peek() != Some(Lx::Comma) {
252            return Ok(first);
253        }
254        let mut items = vec![first];
255        while self.peek() == Some(Lx::Comma) {
256            self.pos += 1;
257            items.push(self.parse_assign()?);
258        }
259        Ok(Expr::Comma(items))
260    }
261
262    fn parse_assign(&mut self) -> Result<Expr, ParseError> {
263        let lhs = match self.try_assign_target() {
264            Some(path) => path,
265            None => self.parse_alt()?,
266        };
267        match self.peek() {
268            Some(Lx::Assign) => {
269                self.reject_hyphen_key_lhs(&lhs)?;
270                self.pos += 1;
271                let rhs = self.parse_assign()?; // right-associative
272                Ok(Expr::Assign(Box::new(lhs), Box::new(rhs)))
273            }
274            Some(Lx::PipeAssign) => {
275                self.reject_hyphen_key_lhs(&lhs)?;
276                self.pos += 1;
277                let rhs = self.parse_assign()?;
278                Ok(Expr::UpdateAssign(Box::new(lhs), Box::new(rhs)))
279            }
280            Some(Lx::PlusAssign) => {
281                self.reject_hyphen_key_lhs(&lhs)?;
282                self.pos += 1;
283                let rhs = self.parse_assign()?;
284                Ok(Expr::AddAssign(Box::new(lhs), Box::new(rhs)))
285            }
286            _ => Ok(lhs),
287        }
288    }
289
290    /// Parse an assignment target: a path whose bare keys may contain `-`.
291    ///
292    /// **The left side of an assignment must be a path, so subtraction cannot
293    /// occur there.** That removes the ambiguity that forces `."dev-dependencies"`
294    /// everywhere else: in this one position a hyphen between two identifier
295    /// characters is unambiguously part of the key, and edikt can simply parse
296    /// it instead of making people quote the most common key in a Cargo.toml.
297    ///
298    /// Speculative, and it rewinds on any miss, so nothing here can change how
299    /// a non-assignment expression parses. Returns `Some` only when a complete
300    /// path is followed by an assignment operator; otherwise the position is
301    /// restored and the ordinary precedence chain runs unchanged.
302    ///
303    /// Purely additive: every expression this accepts previously failed, since
304    /// a subtraction on the left of `=` was never legal.
305    fn try_assign_target(&mut self) -> Option<Expr> {
306        let save = self.pos;
307        let parsed = self.parse_hyphenated_path();
308        let is_target = parsed.is_some()
309            && matches!(
310                self.peek(),
311                Some(Lx::Assign | Lx::PipeAssign | Lx::PlusAssign)
312            );
313        if is_target {
314            return parsed;
315        }
316        self.pos = save;
317        None
318    }
319
320    /// A `.a.b-c[0]` path, joining `Ident - Ident` runs that are adjacent in the
321    /// source into one key. Only called speculatively from
322    /// [`Parser::try_assign_target`].
323    ///
324    /// Adjacency is what keeps this honest: `- ` with a space around it is never
325    /// joined, so a spaced expression cannot be silently reinterpreted.
326    fn parse_hyphenated_path(&mut self) -> Option<Expr> {
327        if self.peek() != Some(Lx::Dot) {
328            return None;
329        }
330        self.pos += 1;
331        let mut steps = Vec::new();
332        loop {
333            match self.peek() {
334                Some(Lx::Ident) => steps.push(Step::Field(self.take_hyphenated_ident())),
335                Some(Lx::Str) => {
336                    steps.push(Step::Field(unescape(self.text())));
337                    self.pos += 1;
338                }
339                Some(Lx::LBrack) => {
340                    self.pos += 1;
341                    if self.peek() == Some(Lx::Str) {
342                        let key = unescape(self.text());
343                        self.pos += 1;
344                        if self.peek() != Some(Lx::RBrack) {
345                            return None;
346                        }
347                        self.pos += 1;
348                        steps.push(Step::Field(key));
349                    } else {
350                        let neg = self.peek() == Some(Lx::Minus);
351                        if neg {
352                            self.pos += 1;
353                        }
354                        if self.peek() != Some(Lx::Num) {
355                            return None;
356                        }
357                        let n = parse_i64(self.text()).ok()?;
358                        self.pos += 1;
359                        if self.peek() != Some(Lx::RBrack) {
360                            return None;
361                        }
362                        self.pos += 1;
363                        steps.push(Step::Index(if neg { -n } else { n }));
364                    }
365                }
366                _ => return None,
367            }
368            match self.peek() {
369                Some(Lx::Dot) => self.pos += 1,
370                Some(Lx::LBrack) => {}
371                _ => break,
372            }
373        }
374        Some(Expr::Path(steps))
375    }
376
377    /// Consume `Ident (- Ident)*` where every token abuts the last, and return
378    /// the joined key.
379    fn take_hyphenated_ident(&mut self) -> String {
380        let mut name = self.text().to_string();
381        self.pos += 1;
382        while self.peek() == Some(Lx::Minus)
383            && self.abuts_previous(self.pos)
384            && self
385                .toks
386                .get(self.pos + 1)
387                .is_some_and(|t| t.kind == Lx::Ident)
388            && self.abuts_previous(self.pos + 1)
389        {
390            name.push('-');
391            name.push_str(&self.toks[self.pos + 1].text);
392            self.pos += 2;
393        }
394        name
395    }
396
397    /// Does token `i` start exactly where token `i - 1` ended? Whitespace
398    /// between them means the user wrote an operator, not a key.
399    fn abuts_previous(&self, i: usize) -> bool {
400        match (self.toks.get(i.wrapping_sub(1)), self.toks.get(i)) {
401            (Some(prev), Some(cur)) => prev.start + prev.text.len() == cur.start,
402            _ => false,
403        }
404    }
405
406    /// A hyphenated bare key that reached an assignment despite
407    /// [`Parser::try_assign_target`], which means it was not a plain path (a
408    /// pipe, a parenthesized expression). Name the fix rather than failing later
409    /// with "left side of an assignment must be a path".
410    fn reject_hyphen_key_lhs(&self, lhs: &Expr) -> Result<(), ParseError> {
411        let Some((path, key)) = hyphen_key(lhs) else {
412            return Ok(());
413        };
414        Err(self.err_here(format!(
415            "key `{key}` contains `-` (parsed as subtraction); quote it: {path}"
416        )))
417    }
418
419    /// `a // b`: right-associative, binding tighter than `=` (so
420    /// `.k = .a // "d"` defaults the RHS) and looser than comparison.
421    fn parse_alt(&mut self) -> Result<Expr, ParseError> {
422        let left = self.parse_cmp()?;
423        if self.peek() == Some(Lx::Alt) {
424            self.pos += 1;
425            let right = self.parse_alt()?;
426            return Ok(Expr::Alternative(Box::new(left), Box::new(right)));
427        }
428        Ok(left)
429    }
430
431    fn parse_cmp(&mut self) -> Result<Expr, ParseError> {
432        let left = self.parse_add()?;
433        let op = match self.peek() {
434            Some(Lx::EqEq) => BinOp::Eq,
435            Some(Lx::Ne) => BinOp::Ne,
436            Some(Lx::Lt) => BinOp::Lt,
437            Some(Lx::Gt) => BinOp::Gt,
438            Some(Lx::Le) => BinOp::Le,
439            Some(Lx::Ge) => BinOp::Ge,
440            _ => return Ok(left),
441        };
442        self.pos += 1;
443        let right = self.parse_add()?;
444        Ok(Expr::Binary(op, Box::new(left), Box::new(right)))
445    }
446
447    fn parse_add(&mut self) -> Result<Expr, ParseError> {
448        let mut left = self.parse_mul()?;
449        loop {
450            let op = match self.peek() {
451                Some(Lx::Plus) => BinOp::Add,
452                Some(Lx::Minus) => BinOp::Sub,
453                _ => break,
454            };
455            self.pos += 1;
456            let right = self.parse_mul()?;
457            left = Expr::Binary(op, Box::new(left), Box::new(right));
458        }
459        Ok(left)
460    }
461
462    fn parse_mul(&mut self) -> Result<Expr, ParseError> {
463        let mut left = self.parse_unary()?;
464        loop {
465            let op = match self.peek() {
466                Some(Lx::Star) => BinOp::Mul,
467                Some(Lx::Slash) => BinOp::Div,
468                Some(Lx::Percent) => BinOp::Mod,
469                _ => break,
470            };
471            self.pos += 1;
472            let right = self.parse_unary()?;
473            left = Expr::Binary(op, Box::new(left), Box::new(right));
474        }
475        Ok(left)
476    }
477
478    fn parse_unary(&mut self) -> Result<Expr, ParseError> {
479        if self.peek() == Some(Lx::Minus) {
480            self.pos += 1;
481            return Ok(Expr::Neg(Box::new(self.parse_unary()?)));
482        }
483        self.parse_primary()
484    }
485
486    fn parse_primary(&mut self) -> Result<Expr, ParseError> {
487        match self.peek() {
488            Some(Lx::Dot) => self.parse_path(),
489            Some(Lx::LParen) => {
490                self.pos += 1;
491                let e = self.parse_pipe()?;
492                self.expect(Lx::RParen, "`)`")?;
493                Ok(e)
494            }
495            Some(Lx::LBrack) => {
496                self.pos += 1;
497                if self.peek() == Some(Lx::RBrack) {
498                    self.pos += 1;
499                    return Ok(Expr::Collect(None));
500                }
501                let inner = self.parse_pipe()?;
502                self.expect(Lx::RBrack, "`]`")?;
503                Ok(Expr::Collect(Some(Box::new(inner))))
504            }
505            Some(Lx::LBrace) => self.parse_object_construct(),
506            Some(Lx::Num) => {
507                let v = number_value(self.text());
508                self.pos += 1;
509                Ok(Expr::Literal(v))
510            }
511            Some(Lx::Str) => {
512                let v = Value::Str(unescape(self.text()));
513                self.pos += 1;
514                Ok(Expr::Literal(v))
515            }
516            Some(Lx::Ident) => self.parse_ident(),
517            _ => Err(self.err_here("expected an expression")),
518        }
519    }
520
521    fn parse_object_construct(&mut self) -> Result<Expr, ParseError> {
522        self.pos += 1; // `{`
523        let mut pairs = Vec::new();
524        if self.peek() == Some(Lx::RBrace) {
525            self.pos += 1;
526            return Ok(Expr::ObjectConstruct(pairs));
527        }
528        loop {
529            // An object key is a literal name, never an expression: the pair is
530            // `(String, Expr)` and nothing here is ever evaluated. So a hyphen
531            // in a key cannot be subtraction, and `{default-features: false}`
532            // needs no quoting, for the same reason an assignment target does
533            // not. `take_hyphenated_ident` consumes the whole name.
534            let key = match self.peek() {
535                Some(Lx::Ident) => self.take_hyphenated_ident(),
536                Some(Lx::Str) => {
537                    let key = unescape(self.text());
538                    self.pos += 1;
539                    key
540                }
541                _ => return Err(self.err_here("expected an object key")),
542            };
543            // jq spells object entries `key: value`; TOML/KDL hands reach for
544            // `key = value` when the target file is TOML. Accept both; the
545            // expression parses before any file is read, so the grammar cannot
546            // be conditioned on the target format.
547            //
548            // A key with no separator is jq's pluck shorthand: `{a, b}` is
549            // `{a: .a, b: .b}`, the usual way to select a few keys. The key is
550            // already a literal name, so the desugared value is just that
551            // field read off the current input.
552            let value = match self.peek() {
553                Some(Lx::Colon) | Some(Lx::Assign) => {
554                    self.pos += 1;
555                    // A comparison-level value keeps `,` free to separate entries.
556                    self.parse_cmp()?
557                }
558                Some(Lx::Comma) | Some(Lx::RBrace) => Expr::Path(vec![Step::Field(key.clone())]),
559                _ => return Err(self.err_here("expected `:`, `=`, `,` or `}`")),
560            };
561            pairs.push((key, value));
562            match self.peek() {
563                Some(Lx::Comma) => self.pos += 1,
564                Some(Lx::RBrace) => {
565                    self.pos += 1;
566                    break;
567                }
568                _ => return Err(self.err_here("expected `,` or `}`")),
569            }
570        }
571        Ok(Expr::ObjectConstruct(pairs))
572    }
573
574    fn parse_path(&mut self) -> Result<Expr, ParseError> {
575        self.pos += 1; // leading `.`
576        let mut steps = Vec::new();
577        loop {
578            match self.peek() {
579                Some(Lx::Ident) => {
580                    steps.push(Step::Field(self.text().to_string()));
581                    self.pos += 1;
582                }
583                Some(Lx::Str) => {
584                    steps.push(Step::Field(unescape(self.text())));
585                    self.pos += 1;
586                }
587                Some(Lx::LBrack) => {
588                    self.pos += 1;
589                    if self.peek() == Some(Lx::RBrack) {
590                        self.pos += 1;
591                        steps.push(Step::Iterate);
592                    } else if self.peek() == Some(Lx::Str) {
593                        // `.["key"]`: a field by name (dotted/special keys).
594                        let key = unescape(self.text());
595                        self.pos += 1;
596                        self.expect(Lx::RBrack, "`]`")?;
597                        steps.push(Step::Field(key));
598                    } else {
599                        let neg = self.peek() == Some(Lx::Minus);
600                        if neg {
601                            self.pos += 1;
602                        }
603                        if self.peek() != Some(Lx::Num) {
604                            return Err(self.err_here("expected an array index or a string key"));
605                        }
606                        let n = parse_i64(self.text())
607                            .map_err(|_| self.err_here("array index out of range"))?;
608                        self.pos += 1;
609                        self.expect(Lx::RBrack, "`]`")?;
610                        steps.push(Step::Index(if neg { -n } else { n }));
611                    }
612                }
613                Some(Lx::Hash) => {
614                    // `#` addresses the current node's comment. `#` alone is the
615                    // head comment; `#.head`/`#.inline`/`#.foot` pick a kind.
616                    // Terminal: no navigation follows a comment.
617                    self.pos += 1;
618                    let mut kind = CommentKind::Head;
619                    if self.peek() == Some(Lx::Dot)
620                        && let Some(word) =
621                            self.toks.get(self.pos + 1).filter(|t| t.kind == Lx::Ident)
622                        && let Some(k) = comment_kind(&word.text)
623                    {
624                        self.pos += 2; // consume `.` and the kind word
625                        kind = k;
626                    }
627                    steps.push(Step::Comment(kind));
628                    break;
629                }
630                _ => break,
631            }
632            match self.peek() {
633                Some(Lx::Dot) => {
634                    self.pos += 1;
635                    continue;
636                }
637                Some(Lx::LBrack) => continue,
638                _ => break,
639            }
640        }
641        Ok(Expr::Path(steps))
642    }
643
644    fn parse_ident(&mut self) -> Result<Expr, ParseError> {
645        let name = self.text().to_string();
646        self.pos += 1;
647        match name.as_str() {
648            "true" => return Ok(Expr::Literal(Value::Bool(true))),
649            "false" => return Ok(Expr::Literal(Value::Bool(false))),
650            "null" => return Ok(Expr::Literal(Value::Null)),
651            _ => {}
652        }
653        if self.peek() == Some(Lx::LParen) {
654            self.pos += 1;
655            let mut args = vec![self.parse_pipe()?];
656            while self.peek() == Some(Lx::Semi) {
657                self.pos += 1;
658                args.push(self.parse_pipe()?);
659            }
660            self.expect(Lx::RParen, "`)`")?;
661            Ok(Expr::Call(name, args))
662        } else {
663            Ok(Expr::Call(name, Vec::new()))
664        }
665    }
666}
667
668/// Map a `#.<word>` refinement to its comment kind.
669/// Recognize the wreckage of a hyphenated bare key used as a path: a chain of
670/// subtractions whose leftmost operand is a path ending in a field and whose
671/// right operands are bare zero-argument calls (`.a.crate-type` lexes as
672/// `.a.crate`, `-`, `type`). Returns the corrected, quoted path and the
673/// offending key (`.a."crate-type"`, `crate-type`); `None` if the shape is
674/// anything else.
675fn hyphen_key(expr: &Expr) -> Option<(String, String)> {
676    let mut tail: Vec<&str> = Vec::new();
677    let mut cur = expr;
678    while let Expr::Binary(BinOp::Sub, l, r) = cur {
679        match r.as_ref() {
680            Expr::Call(name, args) if args.is_empty() => tail.push(name.as_str()),
681            _ => return None,
682        }
683        cur = l;
684    }
685    if tail.is_empty() {
686        return None;
687    }
688    let Expr::Path(steps) = cur else {
689        return None;
690    };
691    let Some((Step::Field(first), prefix)) = steps.split_last() else {
692        return None;
693    };
694    tail.push(first.as_str());
695    tail.reverse();
696    let key = tail.join("-");
697    let mut path = String::new();
698    for step in prefix {
699        match step {
700            Step::Field(f) if is_bare_key(f) => {
701                path.push('.');
702                path.push_str(f);
703            }
704            Step::Field(f) => {
705                path.push_str(&format!(".\"{f}\""));
706            }
707            Step::Index(i) => path.push_str(&format!("[{i}]")),
708            _ => return None,
709        }
710    }
711    path.push_str(&format!(".\"{key}\""));
712    Some((path, key))
713}
714
715/// Would this key lex as a single bare `Ident` in a path?
716fn is_bare_key(k: &str) -> bool {
717    let mut chars = k.chars();
718    chars
719        .next()
720        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
721        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
722}
723
724fn comment_kind(word: &str) -> Option<CommentKind> {
725    match word {
726        "head" => Some(CommentKind::Head),
727        "inline" => Some(CommentKind::Inline),
728        "foot" => Some(CommentKind::Foot),
729        _ => None,
730    }
731}
732
733fn number_value(t: &str) -> Value {
734    match t {
735        // JSON5 non-finite literals (lexed as `Num`).
736        "Infinity" | "+Infinity" => Value::Float(f64::INFINITY),
737        "-Infinity" => Value::Float(f64::NEG_INFINITY),
738        "NaN" => Value::Float(f64::NAN),
739        _ => {
740            if t.contains(['.', 'e', 'E']) {
741                Value::Float(t.parse().unwrap_or(0.0))
742            } else {
743                match t.parse::<i64>() {
744                    Ok(i) => Value::Int(i),
745                    Err(_) => Value::Float(t.parse().unwrap_or(0.0)),
746                }
747            }
748        }
749    }
750}
751
752fn parse_i64(t: &str) -> Result<i64, std::num::ParseIntError> {
753    t.parse::<i64>()
754}
755
756/// Unescape a JSON-style double-quoted string token (including its quotes).
757fn unescape(tok: &str) -> String {
758    let inner = tok
759        .strip_prefix('"')
760        .and_then(|s| s.strip_suffix('"'))
761        .unwrap_or(tok);
762    let mut out = String::with_capacity(inner.len());
763    let mut chars = inner.chars();
764    while let Some(c) = chars.next() {
765        if c != '\\' {
766            out.push(c);
767            continue;
768        }
769        match chars.next() {
770            Some('"') => out.push('"'),
771            Some('\\') => out.push('\\'),
772            Some('/') => out.push('/'),
773            Some('n') => out.push('\n'),
774            Some('r') => out.push('\r'),
775            Some('t') => out.push('\t'),
776            Some('b') => out.push('\u{0008}'),
777            Some('f') => out.push('\u{000c}'),
778            Some('u') => {
779                let hex: String = chars.by_ref().take(4).collect();
780                if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
781                    out.push(ch);
782                }
783            }
784            Some(other) => {
785                out.push('\\');
786                out.push(other);
787            }
788            None => out.push('\\'),
789        }
790    }
791    out
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    fn p(s: &str) -> Expr {
799        parse(s).unwrap_or_else(|e| panic!("parse `{s}`: {e}"))
800    }
801
802    #[test]
803    fn identity() {
804        assert_eq!(p("."), Expr::Path(vec![]));
805    }
806
807    #[test]
808    fn doc_select_parses() {
809        // `^dN | body`, `^dN.body`, and a bare `^dN` (identity body).
810        assert_eq!(
811            p("^d0 | .kind"),
812            Expr::DocSelect(0, Box::new(Expr::Path(vec![Step::Field("kind".into())])))
813        );
814        assert_eq!(
815            p("^d2.spec"),
816            Expr::DocSelect(2, Box::new(Expr::Path(vec![Step::Field("spec".into())])))
817        );
818        assert_eq!(p("^d1"), Expr::DocSelect(1, Box::new(Expr::Path(vec![]))));
819        // It scopes an assignment, and reports as a mutation.
820        assert!(p("^d0 | .replicas = 3").is_mutation());
821    }
822
823    #[test]
824    fn doc_select_bad_index_errors() {
825        assert!(parse("^dfoo | .x").is_err());
826        assert!(parse("^x").is_err());
827        assert!(parse("^ | .x").is_err());
828    }
829
830    #[test]
831    fn dotted_path() {
832        assert_eq!(
833            p(".a.b"),
834            Expr::Path(vec![Step::Field("a".into()), Step::Field("b".into())])
835        );
836    }
837
838    #[test]
839    fn index_and_iterate() {
840        assert_eq!(
841            p(".arr[0][]"),
842            Expr::Path(vec![
843                Step::Field("arr".into()),
844                Step::Index(0),
845                Step::Iterate
846            ])
847        );
848        assert_eq!(
849            p(".x[-1]"),
850            Expr::Path(vec![Step::Field("x".into()), Step::Index(-1)])
851        );
852    }
853
854    #[test]
855    fn quoted_field() {
856        assert_eq!(
857            p(r#"."weird key""#),
858            Expr::Path(vec![Step::Field("weird key".into())])
859        );
860    }
861
862    #[test]
863    fn literals() {
864        assert_eq!(p("true"), Expr::Literal(Value::Bool(true)));
865        assert_eq!(p("null"), Expr::Literal(Value::Null));
866        assert_eq!(p("42"), Expr::Literal(Value::Int(42)));
867        assert_eq!(p("1.5"), Expr::Literal(Value::Float(1.5)));
868        assert_eq!(p(r#""hi""#), Expr::Literal(Value::Str("hi".into())));
869    }
870
871    #[test]
872    fn arithmetic_precedence() {
873        // 1 + 2 * 3  ==  1 + (2 * 3)
874        assert_eq!(
875            p("1 + 2 * 3"),
876            Expr::Binary(
877                BinOp::Add,
878                Box::new(Expr::Literal(Value::Int(1))),
879                Box::new(Expr::Binary(
880                    BinOp::Mul,
881                    Box::new(Expr::Literal(Value::Int(2))),
882                    Box::new(Expr::Literal(Value::Int(3))),
883                )),
884            )
885        );
886    }
887
888    #[test]
889    fn pipe_and_select() {
890        let e = p(r#".items[] | select(.name == "x")"#);
891        match e {
892            Expr::Pipe(l, r) => {
893                assert_eq!(
894                    *l,
895                    Expr::Path(vec![Step::Field("items".into()), Step::Iterate])
896                );
897                match *r {
898                    Expr::Call(ref name, ref args) => {
899                        assert_eq!(name, "select");
900                        assert_eq!(args.len(), 1);
901                    }
902                    _ => panic!("expected select call"),
903                }
904            }
905            _ => panic!("expected pipe"),
906        }
907    }
908
909    #[test]
910    fn errors() {
911        assert!(parse(".a.").is_ok()); // lenient trailing dot
912        assert!(parse("(").is_err());
913        assert!(parse(".a b").is_err()); // trailing token
914        assert!(parse("@").is_err()); // bad char
915    }
916
917    #[test]
918    fn comment_accessor() {
919        use crate::comment::CommentKind;
920        // `#` alone is the head comment.
921        assert_eq!(
922            p(".foo.#"),
923            Expr::Path(vec![
924                Step::Field("foo".into()),
925                Step::Comment(CommentKind::Head)
926            ])
927        );
928        // `#.head` / `#.inline` / `#.foot` pick a kind.
929        assert_eq!(
930            p(".foo.#.inline"),
931            Expr::Path(vec![
932                Step::Field("foo".into()),
933                Step::Comment(CommentKind::Inline)
934            ])
935        );
936        assert_eq!(
937            p(".a.#.foot"),
938            Expr::Path(vec![
939                Step::Field("a".into()),
940                Step::Comment(CommentKind::Foot)
941            ])
942        );
943        // `.#` at the top is the document comment.
944        assert_eq!(p(".#"), Expr::Path(vec![Step::Comment(CommentKind::Head)]));
945        // After iteration: `.items[].#`.
946        assert_eq!(
947            p(".items[].#"),
948            Expr::Path(vec![
949                Step::Field("items".into()),
950                Step::Iterate,
951                Step::Comment(CommentKind::Head)
952            ])
953        );
954        // Comment is terminal: nothing may navigate past it.
955        assert!(parse(".foo.#.bar").is_err());
956    }
957
958    #[test]
959    fn object_construct_accepts_toml_equals() {
960        // `key = value` (TOML inline-table spelling) and jq's `key: value`
961        // both work, even mixed in one literal.
962        assert_eq!(
963            p(r#"{version = "1", optional: true}"#),
964            Expr::ObjectConstruct(vec![
965                ("version".into(), Expr::Literal(Value::Str("1".into()))),
966                ("optional".into(), Expr::Literal(Value::Bool(true))),
967            ])
968        );
969    }
970
971    #[test]
972    fn object_construct_names_both_separators() {
973        let e = parse("{a 1}").unwrap_err();
974        assert!(
975            e.to_string().contains("expected `:`, `=`, `,` or `}`"),
976            "got: {e}"
977        );
978    }
979
980    #[test]
981    fn object_construct_plucks_bare_keys() {
982        // jq's shorthand: `{a}` is `{a: .a}`, and it mixes with explicit pairs.
983        let pluck = |k: &str| Expr::Path(vec![Step::Field(k.into())]);
984        assert_eq!(
985            p("{MemoryMiB}"),
986            Expr::ObjectConstruct(vec![("MemoryMiB".into(), pluck("MemoryMiB"))])
987        );
988        assert_eq!(
989            p("{MemoryMiB, UseGrpcfuse}"),
990            Expr::ObjectConstruct(vec![
991                ("MemoryMiB".into(), pluck("MemoryMiB")),
992                ("UseGrpcfuse".into(), pluck("UseGrpcfuse")),
993            ])
994        );
995        // A quoted key plucks too, which is how a key that is not a bare
996        // identifier reaches the shorthand at all.
997        assert_eq!(
998            p(r#"{"a.b", c: 1}"#),
999            Expr::ObjectConstruct(vec![
1000                ("a.b".into(), pluck("a.b")),
1001                ("c".into(), Expr::Literal(Value::Int(1))),
1002            ])
1003        );
1004        // A hyphenated bare key is one name here (an object key is never
1005        // evaluated), so it plucks the hyphenated field.
1006        assert_eq!(
1007            p("{default-features}"),
1008            Expr::ObjectConstruct(vec![("default-features".into(), pluck("default-features"))])
1009        );
1010    }
1011
1012    #[test]
1013    fn hyphen_hint_leaves_real_subtraction_alone() {
1014        // Query-position subtraction still parses as arithmetic.
1015        assert_eq!(
1016            p(".a-b"),
1017            Expr::Binary(
1018                BinOp::Sub,
1019                Box::new(Expr::Path(vec![Step::Field("a".into())])),
1020                Box::new(Expr::Call("b".into(), vec![]))
1021            )
1022        );
1023        // An LHS that is subtraction-but-not-a-bare-key shape (numeric
1024        // operand) is not intercepted; it still parses into an Assign for
1025        // the eval-time "must be a path" check.
1026        assert!(parse(".a - 1 = 2").is_ok());
1027        // RHS containing subtraction is untouched.
1028        p(".x = .a - .b");
1029    }
1030
1031    /// The left of an assignment must be a path, so subtraction cannot occur
1032    /// there and a hyphen is unambiguously part of the key. No quoting needed.
1033    #[test]
1034    fn a_hyphenated_key_assigns_without_quoting() {
1035        let e = p(r#".dev-dependencies.serde_json = "1.0""#);
1036        let Expr::Assign(lhs, _) = e else {
1037            panic!("expected an assignment")
1038        };
1039        assert_eq!(
1040            *lhs,
1041            Expr::Path(vec![
1042                Step::Field("dev-dependencies".into()),
1043                Step::Field("serde_json".into()),
1044            ])
1045        );
1046    }
1047
1048    #[test]
1049    fn hyphenated_keys_work_for_every_assignment_operator() {
1050        for src in [
1051            r#".package.rust-version = "1.85""#,
1052            ".lib.crate-type |= 1",
1053            ".a.b-c += 1",
1054            ".rust-version = 1",
1055        ] {
1056            p(src);
1057        }
1058    }
1059
1060    #[test]
1061    fn a_multi_hyphen_key_is_one_field() {
1062        let Expr::Assign(lhs, _) = p(".lib.crate-type-x = 1") else {
1063            panic!("expected an assignment")
1064        };
1065        assert_eq!(
1066            *lhs,
1067            Expr::Path(vec![
1068                Step::Field("lib".into()),
1069                Step::Field("crate-type-x".into()),
1070            ])
1071        );
1072    }
1073
1074    /// Indices and quoted keys still work alongside hyphenated ones.
1075    #[test]
1076    fn a_hyphenated_path_still_takes_indices_and_quoted_keys() {
1077        p(r#".bin[0].required-features = ["x"]"#);
1078        p(r#".target."cfg(unix)".dev-dependencies.libc = "0.2""#);
1079        p(r#".a.b-c[-1] = 1"#);
1080    }
1081
1082    /// Adjacency is the guard. A spaced `-` is an operator, so this is still a
1083    /// subtraction and still an invalid assignment target.
1084    #[test]
1085    fn a_spaced_hyphen_is_not_absorbed_into_a_key() {
1086        assert!(parse(".a - b = 1").is_err());
1087    }
1088
1089    /// Nothing outside assignment position changed: subtraction still parses.
1090    #[test]
1091    fn queries_are_untouched_by_the_assignment_rule() {
1092        p(".total - length");
1093        p(".a - .b");
1094        p(".count-1");
1095        p(".a.b.c");
1096    }
1097
1098    /// An object key is a literal name, never an expression, so a hyphen there
1099    /// cannot be subtraction. `{default-features: false}` is every other line
1100    /// of a Cargo.toml dependency table.
1101    #[test]
1102    fn a_hyphenated_object_key_needs_no_quoting() {
1103        let e = p(r#"{version: "0.13", default-features: false}"#);
1104        let Expr::ObjectConstruct(pairs) = e else {
1105            panic!("expected an object")
1106        };
1107        let keys: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
1108        assert_eq!(keys, vec!["version", "default-features"]);
1109    }
1110
1111    /// The TOML-flavored `=` spelling too, which is what a Cargo.toml hand
1112    /// reaches for.
1113    #[test]
1114    fn hyphenated_object_keys_work_with_the_equals_spelling() {
1115        let Expr::ObjectConstruct(pairs) = p(r#"{default-features = false}"#) else {
1116            panic!("expected an object")
1117        };
1118        assert_eq!(pairs[0].0, "default-features");
1119    }
1120
1121    #[test]
1122    fn a_multi_hyphen_object_key_is_one_name() {
1123        let Expr::ObjectConstruct(pairs) = p("{a-b-c: 1}") else {
1124            panic!("expected an object")
1125        };
1126        assert_eq!(pairs[0].0, "a-b-c");
1127    }
1128
1129    #[test]
1130    fn quoted_object_keys_still_work() {
1131        let Expr::ObjectConstruct(pairs) = p(r#"{"default-features": false, plain: 1}"#) else {
1132            panic!("expected an object")
1133        };
1134        assert_eq!(pairs[0].0, "default-features");
1135        assert_eq!(pairs[1].0, "plain");
1136    }
1137
1138    /// Values are expressions, so subtraction inside one is untouched.
1139    #[test]
1140    fn an_object_value_can_still_subtract() {
1141        p("{n: .total - length}");
1142        p("{n: .count-1}");
1143    }
1144
1145    /// The whole line that started this, as it would be typed against a
1146    /// Cargo.toml.
1147    #[test]
1148    fn a_real_cargo_dependency_table_parses() {
1149        p(
1150            r#".dependencies.pulldown-cmark = {version: "0.13", default-features: false, features: ["html"]}"#,
1151        );
1152    }
1153
1154    /// A query with a hyphenated key remains ambiguous (subtraction is legal
1155    /// there), so it still gets the explanatory error rather than a guess.
1156    #[test]
1157    fn a_hyphenated_key_in_a_query_still_explains_itself() {
1158        let e = parse(".dev-dependencies.serde_json").unwrap_err();
1159        assert!(e.to_string().contains("contains `-`"), "{e}");
1160        assert!(e.to_string().contains(r#"."dev-dependencies""#), "{e}");
1161    }
1162
1163    #[test]
1164    fn the_scanner_ignores_what_is_not_a_hyphenated_key() {
1165        assert!(hyphenated_key(".total - length").is_none());
1166        assert!(hyphenated_key(".count-1").is_none());
1167        assert!(hyphenated_key(".a.b.c").is_none());
1168        assert!(hyphenated_key("-").is_none());
1169        assert!(hyphenated_key("").is_none());
1170    }
1171
1172    #[test]
1173    fn the_scanner_finds_the_key_and_its_offset() {
1174        assert_eq!(
1175            hyphenated_key(".dev-dependencies.x"),
1176            Some(("dev-dependencies".to_string(), 1))
1177        );
1178        assert_eq!(
1179            hyphenated_key(".a-b-c"),
1180            Some(("a-b-c".to_string(), 1)),
1181            "multiple hyphens are one key"
1182        );
1183    }
1184
1185    /// An unrelated syntax error must keep its own message rather than being
1186    /// blamed on a hyphen elsewhere in the expression.
1187    #[test]
1188    fn an_unrelated_error_keeps_its_own_message() {
1189        let e = parse(".a | ").unwrap_err();
1190        assert!(!e.msg.contains("contains `-`"), "{}", e.msg);
1191    }
1192}