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 = p.parse_pipe()?;
32    if p.pos != p.toks.len() {
33        let t = &p.toks[p.pos];
34        return Err(ParseError {
35            msg: format!("unexpected trailing token `{}`", t.text),
36            pos: t.start,
37        });
38    }
39    Ok(e)
40}
41
42fn lex(src: &str) -> Result<Vec<Tok>, ParseError> {
43    let mut lx = Lx::lexer(src);
44    let mut out = Vec::new();
45    while let Some(res) = lx.next() {
46        let span = lx.span();
47        match res {
48            Ok(kind) => out.push(Tok {
49                kind,
50                text: lx.slice().to_string(),
51                start: span.start,
52            }),
53            Err(_) => {
54                return Err(ParseError {
55                    msg: format!("unexpected character `{}`", lx.slice()),
56                    pos: span.start,
57                });
58            }
59        }
60    }
61    Ok(out)
62}
63
64struct Parser {
65    toks: Vec<Tok>,
66    pos: usize,
67}
68
69impl Parser {
70    fn peek(&self) -> Option<Lx> {
71        self.toks.get(self.pos).map(|t| t.kind)
72    }
73    fn text(&self) -> &str {
74        self.toks
75            .get(self.pos)
76            .map(|t| t.text.as_str())
77            .unwrap_or("")
78    }
79    fn at_end(&self) -> usize {
80        self.toks
81            .last()
82            .map(|t| t.start + t.text.len())
83            .unwrap_or(0)
84    }
85    fn err_here(&self, msg: impl Into<String>) -> ParseError {
86        let pos = self
87            .toks
88            .get(self.pos)
89            .map(|t| t.start)
90            .unwrap_or_else(|| self.at_end());
91        ParseError {
92            msg: msg.into(),
93            pos,
94        }
95    }
96    fn expect(&mut self, kind: Lx, what: &str) -> Result<(), ParseError> {
97        if self.peek() == Some(kind) {
98            self.pos += 1;
99            Ok(())
100        } else {
101            Err(self.err_here(format!("expected {what}")))
102        }
103    }
104
105    fn parse_pipe(&mut self) -> Result<Expr, ParseError> {
106        let mut left = self.parse_comma()?;
107        while self.peek() == Some(Lx::Pipe) {
108            self.pos += 1;
109            let right = self.parse_comma()?;
110            left = Expr::Pipe(Box::new(left), Box::new(right));
111        }
112        Ok(left)
113    }
114
115    fn parse_comma(&mut self) -> Result<Expr, ParseError> {
116        let first = self.parse_assign()?;
117        if self.peek() != Some(Lx::Comma) {
118            return Ok(first);
119        }
120        let mut items = vec![first];
121        while self.peek() == Some(Lx::Comma) {
122            self.pos += 1;
123            items.push(self.parse_assign()?);
124        }
125        Ok(Expr::Comma(items))
126    }
127
128    fn parse_assign(&mut self) -> Result<Expr, ParseError> {
129        let lhs = self.parse_alt()?;
130        match self.peek() {
131            Some(Lx::Assign) => {
132                self.reject_hyphen_key_lhs(&lhs)?;
133                self.pos += 1;
134                let rhs = self.parse_assign()?; // right-associative
135                Ok(Expr::Assign(Box::new(lhs), Box::new(rhs)))
136            }
137            Some(Lx::PipeAssign) => {
138                self.reject_hyphen_key_lhs(&lhs)?;
139                self.pos += 1;
140                let rhs = self.parse_assign()?;
141                Ok(Expr::UpdateAssign(Box::new(lhs), Box::new(rhs)))
142            }
143            Some(Lx::PlusAssign) => {
144                self.reject_hyphen_key_lhs(&lhs)?;
145                self.pos += 1;
146                let rhs = self.parse_assign()?;
147                Ok(Expr::AddAssign(Box::new(lhs), Box::new(rhs)))
148            }
149            _ => Ok(lhs),
150        }
151    }
152
153    /// A hyphenated bare key on the left of an assignment (`.package.rust-version
154    /// = ...`) parses as subtraction - `Path(.package.rust) - version()` - and
155    /// would only fail later with "left side of an assignment must be a path",
156    /// which points nowhere near the cause. When the doomed LHS has exactly that
157    /// subtraction shape, fail now and name the fix. Anything that doesn't match
158    /// is left alone for the eval-time check.
159    fn reject_hyphen_key_lhs(&self, lhs: &Expr) -> Result<(), ParseError> {
160        let Some((path, key)) = hyphen_key(lhs) else {
161            return Ok(());
162        };
163        Err(self.err_here(format!(
164            "key `{key}` contains `-` (parsed as subtraction); quote it: {path}"
165        )))
166    }
167
168    /// `a // b` - right-associative, binding tighter than `=` (so
169    /// `.k = .a // "d"` defaults the RHS) and looser than comparison.
170    fn parse_alt(&mut self) -> Result<Expr, ParseError> {
171        let left = self.parse_cmp()?;
172        if self.peek() == Some(Lx::Alt) {
173            self.pos += 1;
174            let right = self.parse_alt()?;
175            return Ok(Expr::Alternative(Box::new(left), Box::new(right)));
176        }
177        Ok(left)
178    }
179
180    fn parse_cmp(&mut self) -> Result<Expr, ParseError> {
181        let left = self.parse_add()?;
182        let op = match self.peek() {
183            Some(Lx::EqEq) => BinOp::Eq,
184            Some(Lx::Ne) => BinOp::Ne,
185            Some(Lx::Lt) => BinOp::Lt,
186            Some(Lx::Gt) => BinOp::Gt,
187            Some(Lx::Le) => BinOp::Le,
188            Some(Lx::Ge) => BinOp::Ge,
189            _ => return Ok(left),
190        };
191        self.pos += 1;
192        let right = self.parse_add()?;
193        Ok(Expr::Binary(op, Box::new(left), Box::new(right)))
194    }
195
196    fn parse_add(&mut self) -> Result<Expr, ParseError> {
197        let mut left = self.parse_mul()?;
198        loop {
199            let op = match self.peek() {
200                Some(Lx::Plus) => BinOp::Add,
201                Some(Lx::Minus) => BinOp::Sub,
202                _ => break,
203            };
204            self.pos += 1;
205            let right = self.parse_mul()?;
206            left = Expr::Binary(op, Box::new(left), Box::new(right));
207        }
208        Ok(left)
209    }
210
211    fn parse_mul(&mut self) -> Result<Expr, ParseError> {
212        let mut left = self.parse_unary()?;
213        loop {
214            let op = match self.peek() {
215                Some(Lx::Star) => BinOp::Mul,
216                Some(Lx::Slash) => BinOp::Div,
217                Some(Lx::Percent) => BinOp::Mod,
218                _ => break,
219            };
220            self.pos += 1;
221            let right = self.parse_unary()?;
222            left = Expr::Binary(op, Box::new(left), Box::new(right));
223        }
224        Ok(left)
225    }
226
227    fn parse_unary(&mut self) -> Result<Expr, ParseError> {
228        if self.peek() == Some(Lx::Minus) {
229            self.pos += 1;
230            return Ok(Expr::Neg(Box::new(self.parse_unary()?)));
231        }
232        self.parse_primary()
233    }
234
235    fn parse_primary(&mut self) -> Result<Expr, ParseError> {
236        match self.peek() {
237            Some(Lx::Dot) => self.parse_path(),
238            Some(Lx::LParen) => {
239                self.pos += 1;
240                let e = self.parse_pipe()?;
241                self.expect(Lx::RParen, "`)`")?;
242                Ok(e)
243            }
244            Some(Lx::LBrack) => {
245                self.pos += 1;
246                if self.peek() == Some(Lx::RBrack) {
247                    self.pos += 1;
248                    return Ok(Expr::Collect(None));
249                }
250                let inner = self.parse_pipe()?;
251                self.expect(Lx::RBrack, "`]`")?;
252                Ok(Expr::Collect(Some(Box::new(inner))))
253            }
254            Some(Lx::LBrace) => self.parse_object_construct(),
255            Some(Lx::Num) => {
256                let v = number_value(self.text());
257                self.pos += 1;
258                Ok(Expr::Literal(v))
259            }
260            Some(Lx::Str) => {
261                let v = Value::Str(unescape(self.text()));
262                self.pos += 1;
263                Ok(Expr::Literal(v))
264            }
265            Some(Lx::Ident) => self.parse_ident(),
266            _ => Err(self.err_here("expected an expression")),
267        }
268    }
269
270    fn parse_object_construct(&mut self) -> Result<Expr, ParseError> {
271        self.pos += 1; // `{`
272        let mut pairs = Vec::new();
273        if self.peek() == Some(Lx::RBrace) {
274            self.pos += 1;
275            return Ok(Expr::ObjectConstruct(pairs));
276        }
277        loop {
278            let key = match self.peek() {
279                Some(Lx::Ident) => self.text().to_string(),
280                Some(Lx::Str) => unescape(self.text()),
281                _ => return Err(self.err_here("expected an object key")),
282            };
283            self.pos += 1;
284            // jq spells object entries `key: value`; TOML/KDL hands reach for
285            // `key = value` when the target file is TOML. Accept both - the
286            // expression parses before any file is read, so the grammar cannot
287            // be conditioned on the target format.
288            match self.peek() {
289                Some(Lx::Colon) | Some(Lx::Assign) => self.pos += 1,
290                _ => return Err(self.err_here("expected `:` or `=`")),
291            }
292            // A comparison-level value keeps `,` free to separate entries.
293            let value = self.parse_cmp()?;
294            pairs.push((key, value));
295            match self.peek() {
296                Some(Lx::Comma) => self.pos += 1,
297                Some(Lx::RBrace) => {
298                    self.pos += 1;
299                    break;
300                }
301                _ => return Err(self.err_here("expected `,` or `}`")),
302            }
303        }
304        Ok(Expr::ObjectConstruct(pairs))
305    }
306
307    fn parse_path(&mut self) -> Result<Expr, ParseError> {
308        self.pos += 1; // leading `.`
309        let mut steps = Vec::new();
310        loop {
311            match self.peek() {
312                Some(Lx::Ident) => {
313                    steps.push(Step::Field(self.text().to_string()));
314                    self.pos += 1;
315                }
316                Some(Lx::Str) => {
317                    steps.push(Step::Field(unescape(self.text())));
318                    self.pos += 1;
319                }
320                Some(Lx::LBrack) => {
321                    self.pos += 1;
322                    if self.peek() == Some(Lx::RBrack) {
323                        self.pos += 1;
324                        steps.push(Step::Iterate);
325                    } else if self.peek() == Some(Lx::Str) {
326                        // `.["key"]` - a field by name (dotted/special keys).
327                        let key = unescape(self.text());
328                        self.pos += 1;
329                        self.expect(Lx::RBrack, "`]`")?;
330                        steps.push(Step::Field(key));
331                    } else {
332                        let neg = self.peek() == Some(Lx::Minus);
333                        if neg {
334                            self.pos += 1;
335                        }
336                        if self.peek() != Some(Lx::Num) {
337                            return Err(self.err_here("expected an array index or a string key"));
338                        }
339                        let n = parse_i64(self.text())
340                            .map_err(|_| self.err_here("array index out of range"))?;
341                        self.pos += 1;
342                        self.expect(Lx::RBrack, "`]`")?;
343                        steps.push(Step::Index(if neg { -n } else { n }));
344                    }
345                }
346                Some(Lx::Hash) => {
347                    // `#` addresses the current node's comment. `#` alone is the
348                    // head comment; `#.head`/`#.inline`/`#.foot` pick a kind.
349                    // Terminal - no navigation follows a comment.
350                    self.pos += 1;
351                    let mut kind = CommentKind::Head;
352                    if self.peek() == Some(Lx::Dot)
353                        && let Some(word) =
354                            self.toks.get(self.pos + 1).filter(|t| t.kind == Lx::Ident)
355                        && let Some(k) = comment_kind(&word.text)
356                    {
357                        self.pos += 2; // consume `.` and the kind word
358                        kind = k;
359                    }
360                    steps.push(Step::Comment(kind));
361                    break;
362                }
363                _ => break,
364            }
365            match self.peek() {
366                Some(Lx::Dot) => {
367                    self.pos += 1;
368                    continue;
369                }
370                Some(Lx::LBrack) => continue,
371                _ => break,
372            }
373        }
374        Ok(Expr::Path(steps))
375    }
376
377    fn parse_ident(&mut self) -> Result<Expr, ParseError> {
378        let name = self.text().to_string();
379        self.pos += 1;
380        match name.as_str() {
381            "true" => return Ok(Expr::Literal(Value::Bool(true))),
382            "false" => return Ok(Expr::Literal(Value::Bool(false))),
383            "null" => return Ok(Expr::Literal(Value::Null)),
384            _ => {}
385        }
386        if self.peek() == Some(Lx::LParen) {
387            self.pos += 1;
388            let mut args = vec![self.parse_pipe()?];
389            while self.peek() == Some(Lx::Semi) {
390                self.pos += 1;
391                args.push(self.parse_pipe()?);
392            }
393            self.expect(Lx::RParen, "`)`")?;
394            Ok(Expr::Call(name, args))
395        } else {
396            Ok(Expr::Call(name, Vec::new()))
397        }
398    }
399}
400
401/// Map a `#.<word>` refinement to its comment kind.
402/// Recognize the wreckage of a hyphenated bare key used as a path: a chain of
403/// subtractions whose leftmost operand is a path ending in a field and whose
404/// right operands are bare zero-argument calls (`.a.crate-type` lexes as
405/// `.a.crate`, `-`, `type`). Returns the corrected, quoted path and the
406/// offending key (`.a."crate-type"`, `crate-type`); `None` if the shape is
407/// anything else.
408fn hyphen_key(expr: &Expr) -> Option<(String, String)> {
409    let mut tail: Vec<&str> = Vec::new();
410    let mut cur = expr;
411    while let Expr::Binary(BinOp::Sub, l, r) = cur {
412        match r.as_ref() {
413            Expr::Call(name, args) if args.is_empty() => tail.push(name.as_str()),
414            _ => return None,
415        }
416        cur = l;
417    }
418    if tail.is_empty() {
419        return None;
420    }
421    let Expr::Path(steps) = cur else {
422        return None;
423    };
424    let Some((Step::Field(first), prefix)) = steps.split_last() else {
425        return None;
426    };
427    tail.push(first.as_str());
428    tail.reverse();
429    let key = tail.join("-");
430    let mut path = String::new();
431    for step in prefix {
432        match step {
433            Step::Field(f) if is_bare_key(f) => {
434                path.push('.');
435                path.push_str(f);
436            }
437            Step::Field(f) => {
438                path.push_str(&format!(".\"{f}\""));
439            }
440            Step::Index(i) => path.push_str(&format!("[{i}]")),
441            _ => return None,
442        }
443    }
444    path.push_str(&format!(".\"{key}\""));
445    Some((path, key))
446}
447
448/// Would this key lex as a single bare `Ident` in a path?
449fn is_bare_key(k: &str) -> bool {
450    let mut chars = k.chars();
451    chars
452        .next()
453        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
454        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
455}
456
457fn comment_kind(word: &str) -> Option<CommentKind> {
458    match word {
459        "head" => Some(CommentKind::Head),
460        "inline" => Some(CommentKind::Inline),
461        "foot" => Some(CommentKind::Foot),
462        _ => None,
463    }
464}
465
466fn number_value(t: &str) -> Value {
467    if t.contains(['.', 'e', 'E']) {
468        Value::Float(t.parse().unwrap_or(0.0))
469    } else {
470        match t.parse::<i64>() {
471            Ok(i) => Value::Int(i),
472            Err(_) => Value::Float(t.parse().unwrap_or(0.0)),
473        }
474    }
475}
476
477fn parse_i64(t: &str) -> Result<i64, std::num::ParseIntError> {
478    t.parse::<i64>()
479}
480
481/// Unescape a JSON-style double-quoted string token (including its quotes).
482fn unescape(tok: &str) -> String {
483    let inner = tok
484        .strip_prefix('"')
485        .and_then(|s| s.strip_suffix('"'))
486        .unwrap_or(tok);
487    let mut out = String::with_capacity(inner.len());
488    let mut chars = inner.chars();
489    while let Some(c) = chars.next() {
490        if c != '\\' {
491            out.push(c);
492            continue;
493        }
494        match chars.next() {
495            Some('"') => out.push('"'),
496            Some('\\') => out.push('\\'),
497            Some('/') => out.push('/'),
498            Some('n') => out.push('\n'),
499            Some('r') => out.push('\r'),
500            Some('t') => out.push('\t'),
501            Some('b') => out.push('\u{0008}'),
502            Some('f') => out.push('\u{000c}'),
503            Some('u') => {
504                let hex: String = chars.by_ref().take(4).collect();
505                if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
506                    out.push(ch);
507                }
508            }
509            Some(other) => {
510                out.push('\\');
511                out.push(other);
512            }
513            None => out.push('\\'),
514        }
515    }
516    out
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    fn p(s: &str) -> Expr {
524        parse(s).unwrap_or_else(|e| panic!("parse `{s}`: {e}"))
525    }
526
527    #[test]
528    fn identity() {
529        assert_eq!(p("."), Expr::Path(vec![]));
530    }
531
532    #[test]
533    fn dotted_path() {
534        assert_eq!(
535            p(".a.b"),
536            Expr::Path(vec![Step::Field("a".into()), Step::Field("b".into())])
537        );
538    }
539
540    #[test]
541    fn index_and_iterate() {
542        assert_eq!(
543            p(".arr[0][]"),
544            Expr::Path(vec![
545                Step::Field("arr".into()),
546                Step::Index(0),
547                Step::Iterate
548            ])
549        );
550        assert_eq!(
551            p(".x[-1]"),
552            Expr::Path(vec![Step::Field("x".into()), Step::Index(-1)])
553        );
554    }
555
556    #[test]
557    fn quoted_field() {
558        assert_eq!(
559            p(r#"."weird key""#),
560            Expr::Path(vec![Step::Field("weird key".into())])
561        );
562    }
563
564    #[test]
565    fn literals() {
566        assert_eq!(p("true"), Expr::Literal(Value::Bool(true)));
567        assert_eq!(p("null"), Expr::Literal(Value::Null));
568        assert_eq!(p("42"), Expr::Literal(Value::Int(42)));
569        assert_eq!(p("1.5"), Expr::Literal(Value::Float(1.5)));
570        assert_eq!(p(r#""hi""#), Expr::Literal(Value::Str("hi".into())));
571    }
572
573    #[test]
574    fn arithmetic_precedence() {
575        // 1 + 2 * 3  ==  1 + (2 * 3)
576        assert_eq!(
577            p("1 + 2 * 3"),
578            Expr::Binary(
579                BinOp::Add,
580                Box::new(Expr::Literal(Value::Int(1))),
581                Box::new(Expr::Binary(
582                    BinOp::Mul,
583                    Box::new(Expr::Literal(Value::Int(2))),
584                    Box::new(Expr::Literal(Value::Int(3))),
585                )),
586            )
587        );
588    }
589
590    #[test]
591    fn pipe_and_select() {
592        let e = p(r#".items[] | select(.name == "x")"#);
593        match e {
594            Expr::Pipe(l, r) => {
595                assert_eq!(
596                    *l,
597                    Expr::Path(vec![Step::Field("items".into()), Step::Iterate])
598                );
599                match *r {
600                    Expr::Call(ref name, ref args) => {
601                        assert_eq!(name, "select");
602                        assert_eq!(args.len(), 1);
603                    }
604                    _ => panic!("expected select call"),
605                }
606            }
607            _ => panic!("expected pipe"),
608        }
609    }
610
611    #[test]
612    fn errors() {
613        assert!(parse(".a.").is_ok()); // lenient trailing dot
614        assert!(parse("(").is_err());
615        assert!(parse(".a b").is_err()); // trailing token
616        assert!(parse("@").is_err()); // bad char
617    }
618
619    #[test]
620    fn comment_accessor() {
621        use crate::comment::CommentKind;
622        // `#` alone is the head comment.
623        assert_eq!(
624            p(".foo.#"),
625            Expr::Path(vec![
626                Step::Field("foo".into()),
627                Step::Comment(CommentKind::Head)
628            ])
629        );
630        // `#.head` / `#.inline` / `#.foot` pick a kind.
631        assert_eq!(
632            p(".foo.#.inline"),
633            Expr::Path(vec![
634                Step::Field("foo".into()),
635                Step::Comment(CommentKind::Inline)
636            ])
637        );
638        assert_eq!(
639            p(".a.#.foot"),
640            Expr::Path(vec![
641                Step::Field("a".into()),
642                Step::Comment(CommentKind::Foot)
643            ])
644        );
645        // `.#` at the top is the document comment.
646        assert_eq!(p(".#"), Expr::Path(vec![Step::Comment(CommentKind::Head)]));
647        // After iteration: `.items[].#`.
648        assert_eq!(
649            p(".items[].#"),
650            Expr::Path(vec![
651                Step::Field("items".into()),
652                Step::Iterate,
653                Step::Comment(CommentKind::Head)
654            ])
655        );
656        // Comment is terminal - nothing may navigate past it.
657        assert!(parse(".foo.#.bar").is_err());
658    }
659
660    #[test]
661    fn object_construct_accepts_toml_equals() {
662        // `key = value` (TOML inline-table spelling) and jq's `key: value`
663        // both work, even mixed in one literal.
664        assert_eq!(
665            p(r#"{version = "1", optional: true}"#),
666            Expr::ObjectConstruct(vec![
667                ("version".into(), Expr::Literal(Value::Str("1".into()))),
668                ("optional".into(), Expr::Literal(Value::Bool(true))),
669            ])
670        );
671    }
672
673    #[test]
674    fn object_construct_names_both_separators() {
675        let e = parse("{a 1}").unwrap_err();
676        assert!(e.to_string().contains("expected `:` or `=`"), "got: {e}");
677    }
678
679    #[test]
680    fn hyphenated_assign_lhs_hints_the_quoted_form() {
681        let e = parse(r#".package.rust-version = "1.85""#).unwrap_err();
682        assert_eq!(
683            e.to_string(),
684            "key `rust-version` contains `-` (parsed as subtraction); \
685             quote it: .package.\"rust-version\" (at offset 22)"
686        );
687        // Multi-hyphen keys reassemble fully, and `|=` / `+=` hint too.
688        let e = parse(".lib.crate-type-x |= 1").unwrap_err();
689        assert!(e.to_string().contains("`crate-type-x`"), "got: {e}");
690        assert!(e.to_string().contains(".lib.\"crate-type-x\""), "got: {e}");
691        let e = parse(".a.b-c += 1").unwrap_err();
692        assert!(e.to_string().contains("`b-c`"), "got: {e}");
693        // A top-level hyphenated key hints the bare quoted form.
694        let e = parse(".rust-version = 1").unwrap_err();
695        assert!(e.to_string().contains(".\"rust-version\""), "got: {e}");
696    }
697
698    #[test]
699    fn hyphen_hint_leaves_real_subtraction_alone() {
700        // Query-position subtraction still parses as arithmetic.
701        assert_eq!(
702            p(".a-b"),
703            Expr::Binary(
704                BinOp::Sub,
705                Box::new(Expr::Path(vec![Step::Field("a".into())])),
706                Box::new(Expr::Call("b".into(), vec![]))
707            )
708        );
709        // An LHS that is subtraction-but-not-a-bare-key shape (numeric
710        // operand) is not intercepted; it still parses into an Assign for
711        // the eval-time "must be a path" check.
712        assert!(parse(".a - 1 = 2").is_ok());
713        // RHS containing subtraction is untouched.
714        p(".x = .a - .b");
715    }
716}