• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gripmock / grpctestify-rust / 24582789334

17 Apr 2026 07:23PM UTC coverage: 76.58% (+0.1%) from 76.438%
24582789334

push

github

web-flow
Merge pull request #39 from gripmock/refactoring-v3

refactoring part3

579 of 719 new or added lines in 11 files covered. (80.53%)

5 existing lines in 3 files now uncovered.

17585 of 22963 relevant lines covered (76.58%)

2430.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.79
/src/parser/assertion_ast.rs
1
//! AST nodes for assertion expressions.
2
//!
3
//! Pipeline: `text → tokenize_assertion() → Vec<Token> → parse_assertion() → AssertionExpr`
4
//!
5
//! The tokenizer (`super::tokenizer`) is the single source of tokens with exact byte positions.
6
//!
7
//! Precedence (low to high):
8
//!   pipe (`| not`)
9
//!   or
10
//!   xor
11
//!   and
12
//!   binary (==, !=, >, <, contains, matches, …)
13
//!   unary (!, not, not not, !!)
14
//!   atom (literal, @plugin, .path, paren)
15

16
use serde::{Deserialize, Serialize};
17

18
use super::tokenizer::{TokenKind, tokenize_assertion};
19

20
/// A complete assertion expression (top-level).
21
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22
pub enum AssertionExpr {
23
    Binary {
24
        op: BinaryOp,
25
        left: Box<AssertionExpr>,
26
        right: Box<AssertionExpr>,
27
    },
28
    Not(Box<AssertionExpr>),
29
    NotNot(Box<AssertionExpr>),
30
    And {
31
        left: Box<AssertionExpr>,
32
        right: Box<AssertionExpr>,
33
    },
34
    Or {
35
        left: Box<AssertionExpr>,
36
        right: Box<AssertionExpr>,
37
    },
38
    Xor {
39
        left: Box<AssertionExpr>,
40
        right: Box<AssertionExpr>,
41
    },
42
    IfThenElse {
43
        condition: Box<AssertionExpr>,
44
        then_branch: Box<AssertionExpr>,
45
        else_branch: Box<AssertionExpr>,
46
    },
47
    Paren(Box<AssertionExpr>),
48
    Atom(Expr),
49
    Raw(String),
50
}
51

52
/// Atomic expressions (leaf nodes).
53
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54
pub enum Expr {
55
    JqPath(String),
56
    PluginCall {
57
        name: String,
58
        args: Vec<AssertionExpr>,
59
    },
60
    Literal(Literal),
61
    Variable(String),
62
    RegExp {
63
        pattern: String,
64
        flags: String,
65
    },
66
    Json(String),
67
    Yaml(String),
68
}
69

70
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71
pub enum Literal {
72
    Bool(bool),
73
    Number(String),
74
    Str(String),
75
    Null,
76
}
77

78
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79
pub enum BinaryOp {
80
    Eq,
81
    Ne,
82
    Gt,
83
    Lt,
84
    Ge,
85
    Le,
86
    Contains,
87
    Matches,
88
    StartsWith,
89
    EndsWith,
90
}
91

92
impl BinaryOp {
93
    pub fn as_str(&self) -> &'static str {
35✔
94
        match self {
35✔
95
            Self::Eq => "==",
31✔
96
            Self::Ne => "!=",
×
97
            Self::Gt => ">",
×
98
            Self::Lt => "<",
×
99
            Self::Ge => ">=",
×
100
            Self::Le => "<=",
×
101
            Self::Contains => "contains",
×
102
            Self::Matches => "matches",
4✔
103
            Self::StartsWith => "startsWith",
×
104
            Self::EndsWith => "endsWith",
×
105
        }
106
    }
35✔
107
    fn try_parse(s: &str) -> Option<Self> {
77✔
108
        match s {
77✔
109
            "==" => Some(Self::Eq),
77✔
110
            "!=" => Some(Self::Ne),
19✔
111
            ">" => Some(Self::Gt),
18✔
112
            "<" => Some(Self::Lt),
16✔
113
            ">=" => Some(Self::Ge),
14✔
114
            "<=" => Some(Self::Le),
13✔
115
            "contains" => Some(Self::Contains),
12✔
116
            "matches" => Some(Self::Matches),
8✔
117
            "startsWith" | "startswith" => Some(Self::StartsWith),
3✔
118
            "endsWith" | "endswith" => Some(Self::EndsWith),
1✔
119
            _ => None,
×
120
        }
121
    }
77✔
122
}
123

124
// ─── Display ───────────────────────────────────────────────────────────
125

126
impl std::fmt::Display for Expr {
127
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61✔
128
        match self {
30✔
129
            Self::JqPath(p) => write!(f, "{}", p),
23✔
130
            Self::PluginCall { name, args } => {
7✔
131
                write!(f, "@{}(", name)?;
7✔
132
                for (i, a) in args.iter().enumerate() {
7✔
133
                    if i > 0 {
6✔
134
                        write!(f, ", ")?;
1✔
135
                    }
5✔
136
                    write!(f, "{}", a)?;
6✔
137
                }
138
                write!(f, ")")
7✔
139
            }
140
            Self::Literal(Literal::Bool(b)) => write!(f, "{}", b),
6✔
141
            Self::Literal(Literal::Number(n)) => write!(f, "{}", n),
15✔
142
            Self::Literal(Literal::Str(s)) => write!(f, "\"{}\"", s),
9✔
143
            Self::Literal(Literal::Null) => write!(f, "null"),
×
144
            Self::RegExp { pattern, flags } => {
1✔
145
                write!(f, "/{}/", pattern)?;
1✔
146
                if !flags.is_empty() {
1✔
147
                    write!(f, "{}", flags)?;
×
148
                }
1✔
149
                Ok(())
1✔
150
            }
151
            Self::Json(s) => write!(f, "{}", s),
×
152
            Self::Yaml(s) => write!(f, "{}", s),
×
153
            Self::Variable(n) => write!(f, "{{{{{}}}}}", n),
×
154
        }
155
    }
61✔
156
}
157

158
impl std::fmt::Display for AssertionExpr {
159
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41✔
160
        fmt_assertion(self, f, 0)
41✔
161
    }
41✔
162
}
163

164
fn fmt_assertion(e: &AssertionExpr, f: &mut std::fmt::Formatter<'_>, prec: u8) -> std::fmt::Result {
77✔
165
    match e {
77✔
166
        AssertionExpr::Or { left, right } => {
×
167
            if prec > 1 {
×
168
                write!(f, "(")?;
×
169
            }
×
170
            fmt_assertion(left, f, 1)?;
×
171
            write!(f, " or ")?;
×
172
            fmt_assertion(right, f, 1)?;
×
173
            if prec > 1 {
×
174
                write!(f, ")")?;
×
175
            }
×
176
            Ok(())
×
177
        }
178
        AssertionExpr::Xor { left, right } => {
1✔
179
            if prec > 1 {
1✔
NEW
180
                write!(f, "(")?;
×
181
            }
1✔
182
            fmt_assertion(left, f, 1)?;
1✔
183
            write!(f, " xor ")?;
1✔
184
            fmt_assertion(right, f, 1)?;
1✔
185
            if prec > 1 {
1✔
NEW
186
                write!(f, ")")?;
×
187
            }
1✔
188
            Ok(())
1✔
189
        }
190
        AssertionExpr::And { left, right } => {
×
191
            if prec > 2 {
×
192
                write!(f, "(")?;
×
193
            }
×
194
            fmt_assertion(left, f, 2)?;
×
195
            write!(f, " and ")?;
×
196
            fmt_assertion(right, f, 2)?;
×
197
            if prec > 2 {
×
198
                write!(f, ")")?;
×
199
            }
×
200
            Ok(())
×
201
        }
202
        AssertionExpr::Binary { op, left, right } => {
9✔
203
            if prec > 3 {
9✔
204
                write!(f, "(")?;
×
205
            }
9✔
206
            fmt_assertion(left, f, 3)?;
9✔
207
            write!(f, " {} ", op.as_str())?;
9✔
208
            fmt_assertion(right, f, 3)?;
9✔
209
            if prec > 3 {
9✔
210
                write!(f, ")")?;
×
211
            }
9✔
212
            Ok(())
9✔
213
        }
214
        AssertionExpr::Not(inner) => {
1✔
215
            write!(f, "!")?;
1✔
216
            fmt_assertion(inner, f, 4)
1✔
217
        }
218
        AssertionExpr::NotNot(inner) => {
×
219
            write!(f, "not not ")?;
×
220
            fmt_assertion(inner, f, 4)
×
221
        }
222
        AssertionExpr::IfThenElse {
223
            condition,
5✔
224
            then_branch,
5✔
225
            else_branch,
5✔
226
        } => {
227
            write!(f, "(")?;
5✔
228
            fmt_assertion(condition, f, 0)?;
5✔
229
            write!(f, " ? ")?;
5✔
230
            fmt_assertion(then_branch, f, 0)?;
5✔
231
            write!(f, " : ")?;
5✔
232
            fmt_assertion(else_branch, f, 0)?;
5✔
233
            write!(f, ")")
5✔
234
        }
235
        AssertionExpr::Paren(inner) => {
×
236
            write!(f, "(")?;
×
237
            fmt_assertion(inner, f, 0)?;
×
238
            write!(f, ")")
×
239
        }
240
        AssertionExpr::Atom(e) => write!(f, "{}", e),
61✔
241
        AssertionExpr::Raw(s) => write!(f, "{}", s),
×
242
    }
243
}
77✔
244

245
// ─── Parser ────────────────────────────────────────────────────────────
246

247
/// Parse a raw assertion string into an AST.
248
/// Falls back to `Raw` if parsing fails.
249
pub fn parse_assertion(raw: &str) -> AssertionExpr {
150✔
250
    let tokens = tokenize_assertion(raw);
150✔
251
    if tokens.is_empty() {
150✔
252
        return AssertionExpr::Raw(raw.to_string());
×
253
    }
150✔
254
    let mut pos = 0;
150✔
255
    let expr = parse_pipe(&tokens, &mut pos);
150✔
256
    if pos >= tokens.len() {
150✔
257
        expr
148✔
258
    } else {
259
        AssertionExpr::Raw(raw.to_string())
2✔
260
    }
261
}
150✔
262

263
fn parse_pipe(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
281✔
264
    let mut expr = parse_or(ts, p);
281✔
265
    while *p < ts.len() {
287✔
266
        if !matches!(ts[*p].kind, TokenKind::Pipe) {
139✔
267
            break;
132✔
268
        }
7✔
269
        *p += 1;
7✔
270
        if *p < ts.len() && is_keyword(ts, *p, "not") {
7✔
271
            *p += 1;
6✔
272
            if *p < ts.len() && is_keyword(ts, *p, "not") {
6✔
273
                *p += 1;
2✔
274
            } else {
4✔
275
                expr = AssertionExpr::Not(Box::new(expr));
4✔
276
            }
4✔
277
        } else {
278
            break;
1✔
279
        }
280
    }
281
    expr
281✔
282
}
281✔
283

284
fn parse_or(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
281✔
285
    let mut left = parse_xor(ts, p);
281✔
286
    while *p < ts.len() && is_keyword(ts, *p, "or") {
292✔
287
        *p += 1;
11✔
288
        let right = parse_xor(ts, p);
11✔
289
        left = AssertionExpr::Or {
11✔
290
            left: Box::new(left),
11✔
291
            right: Box::new(right),
11✔
292
        };
11✔
293
    }
11✔
294
    left
281✔
295
}
281✔
296

297
fn parse_xor(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
292✔
298
    let mut left = parse_and(ts, p);
292✔
299
    while *p < ts.len() && is_keyword(ts, *p, "xor") {
298✔
300
        *p += 1;
6✔
301
        let right = parse_and(ts, p);
6✔
302
        left = AssertionExpr::Xor {
6✔
303
            left: Box::new(left),
6✔
304
            right: Box::new(right),
6✔
305
        };
6✔
306
    }
6✔
307
    left
292✔
308
}
292✔
309

310
fn parse_and(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
298✔
311
    let mut left = parse_bin(ts, p);
298✔
312
    while *p < ts.len() && is_keyword(ts, *p, "and") {
304✔
313
        *p += 1;
6✔
314
        let right = parse_bin(ts, p);
6✔
315
        left = AssertionExpr::And {
6✔
316
            left: Box::new(left),
6✔
317
            right: Box::new(right),
6✔
318
        };
6✔
319
    }
6✔
320
    left
298✔
321
}
298✔
322

323
fn parse_bin(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
304✔
324
    let mut left = parse_unary(ts, p);
304✔
325
    loop {
326
        let op_info = if *p < ts.len() {
381✔
327
            match &ts[*p].kind {
239✔
328
                TokenKind::Op(s) => Some(s.clone()),
77✔
329
                TokenKind::Ident(s) if is_bin_op_keyword(s) => Some(s.clone()),
41✔
330
                _ => None,
162✔
331
            }
332
        } else {
333
            None
142✔
334
        };
335

336
        let op_str = match op_info {
381✔
337
            Some(s) => s,
77✔
338
            None => break,
304✔
339
        };
340
        *p += 1;
77✔
341
        let right = parse_unary(ts, p);
77✔
342
        let op = BinaryOp::try_parse(&op_str).unwrap_or(match op_str.as_str() {
77✔
343
            "contains" => BinaryOp::Contains,
77✔
344
            "matches" => BinaryOp::Matches,
73✔
345
            "startsWith" | "startswith" => BinaryOp::StartsWith,
68✔
346
            _ => BinaryOp::EndsWith,
66✔
347
        });
348
        left = AssertionExpr::Binary {
77✔
349
            op,
77✔
350
            left: Box::new(left),
77✔
351
            right: Box::new(right),
77✔
352
        };
77✔
353
    }
354
    left
304✔
355
}
304✔
356

357
fn parse_unary(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
393✔
358
    if *p >= ts.len() {
393✔
359
        return AssertionExpr::Raw(String::new());
×
360
    }
393✔
361
    match &ts[*p].kind {
393✔
362
        TokenKind::Bang => {
363
            *p += 1;
8✔
364
            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Bang) {
8✔
365
                *p += 1;
2✔
366
                let inner = parse_unary(ts, p);
2✔
367
                AssertionExpr::NotNot(Box::new(inner))
2✔
368
            } else {
369
                let inner = parse_unary(ts, p);
6✔
370
                AssertionExpr::Not(Box::new(inner))
6✔
371
            }
372
        }
373
        TokenKind::Ident(s) if s == "not" => {
24✔
374
            *p += 1;
4✔
375
            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Ident(ref s2) if s2 == "not") {
4✔
376
                *p += 1;
2✔
377
                let inner = parse_unary(ts, p);
2✔
378
                AssertionExpr::NotNot(Box::new(inner))
2✔
379
            } else {
380
                let inner = parse_unary(ts, p);
2✔
381
                AssertionExpr::Not(Box::new(inner))
2✔
382
            }
383
        }
384
        TokenKind::Ident(s) if s == "if" => parse_if(ts, p),
20✔
385
        TokenKind::LParen => {
386
            *p += 1;
7✔
387
            let inner = parse_pipe(ts, p);
7✔
388
            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::RParen) {
7✔
389
                *p += 1;
7✔
390
            }
7✔
391
            AssertionExpr::Paren(Box::new(inner))
7✔
392
        }
393
        _ => parse_atom(ts, p),
368✔
394
    }
395
}
393✔
396

397
fn parse_if(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
6✔
398
    *p += 1;
6✔
399
    let cond = parse_pipe(ts, p);
6✔
400
    if *p >= ts.len() || !is_keyword(ts, *p, "then") {
6✔
401
        return AssertionExpr::Raw("if..then missing".into());
×
402
    }
6✔
403
    *p += 1;
6✔
404
    let then_b = parse_pipe(ts, p);
6✔
405
    if *p >= ts.len() || !is_keyword(ts, *p, "else") {
6✔
406
        return AssertionExpr::Raw("if..else missing".into());
×
407
    }
6✔
408
    *p += 1;
6✔
409
    let else_b = parse_pipe(ts, p);
6✔
410
    if *p < ts.len() && is_keyword(ts, *p, "end") {
6✔
411
        *p += 1;
6✔
412
    }
6✔
413
    AssertionExpr::IfThenElse {
6✔
414
        condition: Box::new(cond),
6✔
415
        then_branch: Box::new(then_b),
6✔
416
        else_branch: Box::new(else_b),
6✔
417
    }
6✔
418
}
6✔
419

420
fn parse_atom(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
368✔
421
    if *p >= ts.len() {
368✔
422
        return AssertionExpr::Atom(Expr::JqPath(String::new()));
×
423
    }
368✔
424
    let expr = match &ts[*p].kind {
368✔
425
        TokenKind::StringLit(s) => {
36✔
426
            *p += 1;
36✔
427
            Expr::Literal(Literal::Str(s.clone()))
36✔
428
        }
429
        TokenKind::NumberLit(n) => {
47✔
430
            *p += 1;
47✔
431
            Expr::Literal(Literal::Number(n.clone()))
47✔
432
        }
433
        TokenKind::Ident(s) if s == "true" => {
14✔
434
            *p += 1;
9✔
435
            Expr::Literal(Literal::Bool(true))
9✔
436
        }
437
        TokenKind::Ident(s) if s == "false" => {
5✔
438
            *p += 1;
4✔
439
            Expr::Literal(Literal::Bool(false))
4✔
440
        }
441
        TokenKind::Ident(s) if s == "null" => {
1✔
442
            *p += 1;
×
443
            Expr::Literal(Literal::Null)
×
444
        }
445
        TokenKind::VarDelim => {
446
            *p += 1;
×
447
            let mut name = String::new();
×
448
            while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::VarDelim) {
×
449
                if let TokenKind::Ident(s) = &ts[*p].kind {
×
450
                    if !name.is_empty() {
×
451
                        name.push(' ');
×
452
                    }
×
453
                    name.push_str(s);
×
454
                }
×
455
                *p += 1;
×
456
            }
457
            if *p < ts.len() {
×
458
                *p += 1;
×
459
            }
×
460
            Expr::Variable(name)
×
461
        }
462
        TokenKind::At => {
463
            *p += 1;
113✔
464
            let name = if *p < ts.len() {
113✔
465
                if let TokenKind::Ident(s) = &ts[*p].kind {
113✔
466
                    *p += 1;
113✔
467
                    s.clone()
113✔
468
                } else {
469
                    String::new()
×
470
                }
471
            } else {
472
                String::new()
×
473
            };
474
            let args = if *p < ts.len() && matches!(ts[*p].kind, TokenKind::LParen) {
113✔
475
                *p += 1;
106✔
476
                let mut args = Vec::new();
106✔
477
                while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::RParen) {
212✔
478
                    let arg = parse_pipe(ts, p);
106✔
479
                    args.push(arg);
106✔
480
                    if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Comma) {
106✔
481
                        *p += 1;
2✔
482
                    }
104✔
483
                }
484
                if *p < ts.len() {
106✔
485
                    *p += 1;
106✔
486
                }
106✔
487
                merge_hyphenated_args(args)
106✔
488
            } else {
489
                Vec::new()
7✔
490
            };
491
            Expr::PluginCall { name, args }
113✔
492
        }
493
        TokenKind::RegExpLit { pattern, flags } => {
2✔
494
            *p += 1;
2✔
495
            Expr::RegExp {
2✔
496
                pattern: pattern.clone(),
2✔
497
                flags: flags.clone(),
2✔
498
            }
2✔
499
        }
500
        TokenKind::LBrace => {
501
            *p += 1;
1✔
502
            let mut json = String::from("{");
1✔
503
            let mut depth = 1;
1✔
504
            while *p < ts.len() && depth > 0 {
4✔
505
                match &ts[*p].kind {
3✔
506
                    TokenKind::LBrace => {
×
507
                        depth += 1;
×
508
                        json.push('{');
×
509
                        *p += 1;
×
510
                    }
×
511
                    TokenKind::RBrace => {
512
                        depth -= 1;
1✔
513
                        if depth > 0 {
1✔
514
                            json.push('}');
×
515
                        }
1✔
516
                        *p += 1;
1✔
517
                    }
518
                    TokenKind::LParen => {
×
519
                        json.push('(');
×
520
                        *p += 1;
×
521
                    }
×
522
                    TokenKind::RParen => {
×
523
                        json.push(')');
×
524
                        *p += 1;
×
525
                    }
×
526
                    TokenKind::LBracket => {
×
527
                        json.push('[');
×
528
                        *p += 1;
×
529
                    }
×
530
                    TokenKind::RBracket => {
×
531
                        json.push(']');
×
532
                        *p += 1;
×
533
                    }
×
534
                    TokenKind::StringLit(s) => {
2✔
535
                        json.push('"');
2✔
536
                        json.push_str(s);
2✔
537
                        json.push('"');
2✔
538
                        *p += 1;
2✔
539
                    }
2✔
540
                    TokenKind::NumberLit(n) => {
×
541
                        json.push_str(n);
×
542
                        *p += 1;
×
543
                    }
×
544
                    TokenKind::Ident(s) => {
×
545
                        json.push_str(s);
×
546
                        *p += 1;
×
547
                    }
×
548
                    TokenKind::Comma => {
×
549
                        json.push(',');
×
550
                        *p += 1;
×
551
                    }
×
552
                    TokenKind::Op(s) => {
×
553
                        json.push_str(s);
×
554
                        *p += 1;
×
555
                    }
×
556
                    TokenKind::Dot => {
×
557
                        json.push('.');
×
558
                        *p += 1;
×
559
                    }
×
560
                    TokenKind::At => {
×
561
                        json.push('@');
×
562
                        *p += 1;
×
563
                    }
×
564
                    TokenKind::VarDelim => {
×
565
                        json.push_str("{{ }}");
×
566
                        *p += 1;
×
567
                    }
×
568
                    _ => {
×
569
                        *p += 1;
×
570
                    }
×
571
                }
572
            }
573
            json.push('}');
1✔
574
            Expr::Json(json)
1✔
575
        }
576
        TokenKind::Dot | TokenKind::Ident(_) => {
577
            let mut path = String::new();
156✔
578
            while *p < ts.len() {
476✔
579
                if let TokenKind::Ident(s) = &ts[*p].kind
474✔
580
                    && (is_bin_op_keyword(s) || is_keyword_token(&ts[*p].kind))
159✔
581
                {
582
                    break;
×
583
                }
474✔
584
                match &ts[*p].kind {
474✔
585
                    TokenKind::Dot => {
158✔
586
                        path.push('.');
158✔
587
                        *p += 1;
158✔
588
                    }
158✔
589
                    TokenKind::Ident(s) => {
159✔
590
                        path.push_str(s);
159✔
591
                        *p += 1;
159✔
592
                    }
159✔
593
                    TokenKind::LBracket => {
594
                        path.push('[');
3✔
595
                        *p += 1;
3✔
596
                        while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::RBracket) {
6✔
597
                            if let TokenKind::NumberLit(n) = &ts[*p].kind {
3✔
598
                                path.push_str(n);
1✔
599
                            } else if let TokenKind::Ident(s) = &ts[*p].kind {
2✔
600
                                path.push_str(s);
×
601
                            }
2✔
602
                            *p += 1;
3✔
603
                        }
604
                        if *p < ts.len() {
3✔
605
                            path.push(']');
3✔
606
                            *p += 1;
3✔
607
                        }
3✔
608
                    }
609
                    _ => break,
154✔
610
                }
611
            }
612
            Expr::JqPath(path)
156✔
613
        }
614
        _ => {
615
            *p += 1;
×
616
            Expr::JqPath(String::new())
×
617
        }
618
    };
619
    AssertionExpr::Atom(expr)
368✔
620
}
368✔
621

622
fn is_keyword(ts: &[super::tokenizer::Token], idx: usize, kw: &str) -> bool {
495✔
623
    matches!(ts.get(idx), Some(t) if matches!(&t.kind, TokenKind::Ident(s) if s == kw))
495✔
624
}
495✔
625

626
/// Merge consecutive bare JqPath args that were split by `-`.
627
/// e.g. `[JqPath("content"), JqPath("type")]` → `[JqPath("content-type")]`
628
fn merge_hyphenated_args(args: Vec<AssertionExpr>) -> Vec<AssertionExpr> {
106✔
629
    if args.len() <= 1 {
106✔
630
        return args;
104✔
631
    }
2✔
632
    let mut merged = Vec::new();
2✔
633
    let mut i = 0;
2✔
634
    while i < args.len() {
6✔
635
        let mut current = args[i].clone();
4✔
636
        while i + 1 < args.len() {
4✔
637
            let next = &args[i + 1];
2✔
638
            if let (
NEW
639
                AssertionExpr::Atom(Expr::JqPath(cur)),
×
NEW
640
                AssertionExpr::Atom(Expr::JqPath(nxt)),
×
641
            ) = (&current, next)
2✔
642
            {
NEW
643
                let cur_no_dot = !cur.contains('.');
×
NEW
644
                let nxt_no_dot = !nxt.contains('.');
×
NEW
645
                if cur_no_dot && nxt_no_dot {
×
NEW
646
                    current = AssertionExpr::Atom(Expr::JqPath(format!("{}-{}", cur, nxt)));
×
NEW
647
                    i += 1;
×
NEW
648
                    continue;
×
NEW
649
                }
×
650
            }
2✔
651
            break;
2✔
652
        }
653
        merged.push(current);
4✔
654
        i += 1;
4✔
655
    }
656
    merged
2✔
657
}
106✔
658

659
fn is_bin_op_keyword(s: &str) -> bool {
200✔
660
    matches!(
×
661
        s,
200✔
662
        "contains" | "matches" | "startsWith" | "endsWith" | "startswith" | "endswith"
200✔
663
    )
664
}
200✔
665

666
fn is_keyword_token(k: &TokenKind) -> bool {
159✔
NEW
667
    matches!(
×
668
        k,
159✔
669
        TokenKind::Ident(s)
159✔
NEW
670
            if matches!(
×
671
                s.as_str(),
159✔
672
                "and" | "or" | "xor" | "contains" | "matches" | "startsWith"
159✔
673
                    | "endsWith" | "startswith" | "endswith"
159✔
674
            )
675
    )
676
}
159✔
677

678
// ─── Public helpers ─────────────────────────────────────────────────────
679

680
/// Convert AssertionExpr back to string (ternary for if-then-else).
681
pub fn assertion_to_string(expr: &AssertionExpr) -> String {
9✔
682
    expr.to_string()
9✔
683
}
9✔
684

685
/// Remove redundant parentheses.
686
pub fn remove_redundant_parens(expr: &AssertionExpr) -> AssertionExpr {
5✔
687
    match expr {
5✔
688
        AssertionExpr::Paren(inner) => remove_redundant_parens(inner),
2✔
689
        AssertionExpr::Binary { op, left, right } => AssertionExpr::Binary {
1✔
690
            op: *op,
1✔
691
            left: Box::new(remove_redundant_parens(left)),
1✔
692
            right: Box::new(remove_redundant_parens(right)),
1✔
693
        },
1✔
694
        AssertionExpr::Not(e) => AssertionExpr::Not(Box::new(remove_redundant_parens(e))),
×
695
        AssertionExpr::NotNot(e) => AssertionExpr::NotNot(Box::new(remove_redundant_parens(e))),
×
696
        AssertionExpr::And { left, right } => AssertionExpr::And {
×
697
            left: Box::new(remove_redundant_parens(left)),
×
698
            right: Box::new(remove_redundant_parens(right)),
×
699
        },
×
700
        AssertionExpr::Or { left, right } => AssertionExpr::Or {
×
701
            left: Box::new(remove_redundant_parens(left)),
×
702
            right: Box::new(remove_redundant_parens(right)),
×
703
        },
×
NEW
704
        AssertionExpr::Xor { left, right } => AssertionExpr::Xor {
×
NEW
705
            left: Box::new(remove_redundant_parens(left)),
×
NEW
706
            right: Box::new(remove_redundant_parens(right)),
×
NEW
707
        },
×
708
        AssertionExpr::IfThenElse {
709
            condition,
×
710
            then_branch,
×
711
            else_branch,
×
712
        } => AssertionExpr::IfThenElse {
×
713
            condition: Box::new(remove_redundant_parens(condition)),
×
714
            then_branch: Box::new(remove_redundant_parens(then_branch)),
×
715
            else_branch: Box::new(remove_redundant_parens(else_branch)),
×
716
        },
×
717
        _ => expr.clone(),
2✔
718
    }
719
}
5✔
720

721
// ─── Tests ──────────────────────────────────────────────────────────────
722

723
#[cfg(test)]
724
mod tests {
725
    use super::*;
726

727
    #[test]
728
    fn test_parse_simple_equality() {
1✔
729
        let expr = parse_assertion(".id == 123");
1✔
730
        if let AssertionExpr::Binary { op, left, right } = expr {
1✔
731
            assert_eq!(op, BinaryOp::Eq);
1✔
732
            assert!(matches!(*left, AssertionExpr::Atom(Expr::JqPath(_))));
1✔
733
            assert!(matches!(
1✔
734
                *right,
1✔
735
                AssertionExpr::Atom(Expr::Literal(Literal::Number(_)))
736
            ));
737
        } else {
NEW
738
            panic!("Expected Binary, got: {:?}", expr);
×
739
        }
740
    }
1✔
741

742
    #[test]
743
    fn test_parse_plugin_call() {
1✔
744
        let expr = parse_assertion("@uuid(.user_id) == true");
1✔
745
        if let AssertionExpr::Binary { op, left, .. } = expr {
1✔
746
            assert_eq!(op, BinaryOp::Eq);
1✔
747
            if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &*left {
1✔
748
                assert_eq!(name, "uuid");
1✔
749
                assert_eq!(args.len(), 1);
1✔
750
            } else {
751
                panic!("Expected PluginCall");
×
752
            }
753
        } else {
NEW
754
            panic!("Expected Binary, got: {:?}", expr);
×
755
        }
756
    }
1✔
757

758
    #[test]
759
    fn test_parse_negation_bang() {
1✔
760
        let expr = parse_assertion("!@has_header(\"x\")");
1✔
761
        if let AssertionExpr::Not(inner) = expr {
1✔
762
            if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1✔
763
                assert_eq!(name, "has_header");
1✔
764
            } else {
NEW
765
                panic!("Expected PluginCall inside Not");
×
766
            }
767
        } else {
NEW
768
            panic!("Expected Not, got: {:?}", expr);
×
769
        }
770
    }
1✔
771

772
    #[test]
773
    fn test_parse_pipe_not() {
1✔
774
        let expr = parse_assertion("@empty(.id) | not");
1✔
775
        if let AssertionExpr::Not(inner) = expr {
1✔
776
            if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1✔
777
                assert_eq!(name, "empty");
1✔
778
            } else {
NEW
779
                panic!("Expected PluginCall inside Not, got: {:?}", inner);
×
780
            }
781
        } else {
NEW
782
            panic!("Expected Not, got: {:?}", expr);
×
783
        }
784
    }
1✔
785

786
    #[test]
787
    fn test_parse_pipe_not_not() {
1✔
788
        let expr = parse_assertion("@empty(.id) | not not");
1✔
789
        if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = expr {
1✔
790
            assert_eq!(name, "empty");
1✔
791
        } else {
NEW
792
            panic!(
×
793
                "Expected bare PluginCall (double negation cancels), got: {:?}",
794
                expr
795
            );
796
        }
797
    }
1✔
798

799
    #[test]
800
    fn test_parse_negation_not_keyword() {
1✔
801
        let expr = parse_assertion("not @empty(.id)");
1✔
802
        if let AssertionExpr::Not(inner) = expr {
1✔
803
            if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1✔
804
                assert_eq!(name, "empty");
1✔
805
            } else {
806
                panic!("Expected PluginCall");
×
807
            }
808
        } else {
NEW
809
            panic!("Expected Not, got: {:?}", expr);
×
810
        }
811
    }
1✔
812

813
    #[test]
814
    fn test_parse_xor() {
1✔
815
        let expr = parse_assertion("@uuid(.id) xor @email(.name)");
1✔
816
        if let AssertionExpr::Xor { left, right, .. } = expr {
1✔
817
            assert!(matches!(
1✔
818
                *left,
1✔
819
                AssertionExpr::Atom(Expr::PluginCall { .. })
820
            ));
821
            assert!(matches!(
1✔
822
                *right,
1✔
823
                AssertionExpr::Atom(Expr::PluginCall { .. })
824
            ));
825
        } else {
NEW
826
            panic!("Expected Xor, got: {:?}", expr);
×
827
        }
828
    }
1✔
829

830
    #[test]
831
    fn test_parse_or() {
1✔
832
        let expr = parse_assertion("@uuid(.id) or @email(.name)");
1✔
833
        assert!(
1✔
834
            matches!(expr, AssertionExpr::Or { .. }),
1✔
835
            "Expected Or, got: {:?}",
836
            expr
837
        );
838
    }
1✔
839

840
    #[test]
841
    fn test_parse_and_or_xor_precedence() {
1✔
842
        let expr = parse_assertion("@a or @b xor @c and @d");
1✔
843
        assert!(
1✔
844
            matches!(expr, AssertionExpr::Or { .. }),
1✔
845
            "Expected Or, got: {:?}",
846
            expr
847
        );
848
    }
1✔
849

850
    #[test]
851
    fn test_parse_paren_or_in_and() {
1✔
852
        let expr = parse_assertion("(@a or @b) and @c");
1✔
853
        if let AssertionExpr::And { left, .. } = expr {
1✔
854
            assert!(
1✔
855
                matches!(*left, AssertionExpr::Paren(_)),
1✔
856
                "Left should be Paren(Or)"
857
            );
858
        } else {
NEW
859
            panic!("Expected And, got: {:?}", expr);
×
860
        }
861
    }
1✔
862

863
    #[test]
864
    fn test_parse_negated_paren_or() {
1✔
865
        let expr = parse_assertion("!(@empty(.id) or @uuid(.id))");
1✔
866
        if let AssertionExpr::Not(inner) = expr {
1✔
867
            if let AssertionExpr::Paren(or_expr) = &*inner {
1✔
868
                assert!(matches!(**or_expr, AssertionExpr::Or { .. }));
1✔
869
            } else {
NEW
870
                panic!("Expected Paren(Or), got: {:?}", inner);
×
871
            }
872
        } else {
NEW
873
            panic!("Expected Not, got: {:?}", expr);
×
874
        }
875
    }
1✔
876

877
    #[test]
878
    fn test_roundtrip_xor() {
1✔
879
        let expr = parse_assertion("@a() xor @b()");
1✔
880
        let s = assertion_to_string(&expr);
1✔
881
        assert!(s.contains(" xor "), "Should contain xor: {}", s);
1✔
882
    }
1✔
883

884
    #[test]
885
    fn test_roundtrip_pipe_not() {
1✔
886
        let expr = parse_assertion("@empty(.id) | not");
1✔
887
        let s = assertion_to_string(&expr);
1✔
888
        assert!(s.contains('!'), "Pipe not should serialize as !: {}", s);
1✔
889
    }
1✔
890

891
    #[test]
892
    fn test_contains() {
1✔
893
        let expr = parse_assertion(".name contains \"test\"");
1✔
894
        if let AssertionExpr::Binary { op, .. } = expr {
1✔
895
            assert_eq!(op, BinaryOp::Contains);
1✔
896
        } else {
897
            panic!("Expected Binary");
×
898
        }
899
    }
1✔
900

901
    #[test]
902
    fn test_startswith() {
1✔
903
        let expr = parse_assertion(".name startsWith \"te\"");
1✔
904
        if let AssertionExpr::Binary { op, .. } = expr {
1✔
905
            assert_eq!(op, BinaryOp::StartsWith);
1✔
906
        } else {
907
            panic!("Expected Binary");
×
908
        }
909
    }
1✔
910

911
    #[test]
912
    fn test_matches() {
1✔
913
        let expr = parse_assertion(".name matches \"^te.*t$\"");
1✔
914
        if let AssertionExpr::Binary { op, .. } = expr {
1✔
915
            assert_eq!(op, BinaryOp::Matches);
1✔
916
        } else {
917
            panic!("Expected Binary");
×
918
        }
919
    }
1✔
920

921
    #[test]
922
    fn test_if_then_else() {
1✔
923
        let expr = parse_assertion("if @len(.items) == 0 then true else false end");
1✔
924
        if let AssertionExpr::IfThenElse {
925
            condition,
1✔
926
            then_branch,
1✔
927
            else_branch,
1✔
928
        } = expr
1✔
929
        {
930
            assert!(matches!(*condition, AssertionExpr::Binary { .. }));
1✔
931
            assert!(matches!(
1✔
932
                *then_branch,
1✔
933
                AssertionExpr::Atom(Expr::Literal(Literal::Bool(true)))
934
            ));
935
            assert!(matches!(
1✔
936
                *else_branch,
1✔
937
                AssertionExpr::Atom(Expr::Literal(Literal::Bool(false)))
938
            ));
939
        } else {
940
            panic!("Expected IfThenElse");
×
941
        }
942
    }
1✔
943

944
    #[test]
945
    fn test_if_then_else_serializes_as_ternary() {
1✔
946
        let expr = parse_assertion("if .x == 0 then true else false end");
1✔
947
        let s = assertion_to_string(&expr);
1✔
948
        assert!(s.contains('?'), "Should contain ternary: {}", s);
1✔
949
        assert!(s.contains(':'), "Should contain colon: {}", s);
1✔
950
    }
1✔
951

952
    #[test]
953
    fn test_nested_if_serializes_correctly() {
1✔
954
        let expr =
1✔
955
            parse_assertion("if .a == 1 then if .b == 2 then \"A\" else \"B\" end else \"C\" end");
1✔
956
        if let AssertionExpr::IfThenElse {
957
            then_branch,
1✔
958
            else_branch,
1✔
959
            ..
960
        } = &expr
1✔
961
        {
962
            assert!(matches!(**then_branch, AssertionExpr::IfThenElse { .. }));
1✔
963
            assert!(matches!(
1✔
964
                **else_branch,
1✔
965
                AssertionExpr::Atom(Expr::Literal(Literal::Str(_)))
966
            ));
967
        } else {
968
            panic!("Expected IfThenElse");
×
969
        }
970
        let s = assertion_to_string(&expr);
1✔
971
        assert!(s.contains('('), "Nested ternary should have parens: {}", s);
1✔
972
    }
1✔
973

974
    #[test]
975
    fn test_remove_redundant_parens() {
1✔
976
        let expr = parse_assertion("((.x == 1))");
1✔
977
        let simplified = remove_redundant_parens(&expr);
1✔
978
        let s = assertion_to_string(&simplified);
1✔
979
        assert!(!s.starts_with("(("), "Should not have double parens: {}", s);
1✔
980
    }
1✔
981

982
    #[test]
983
    fn test_roundtrip_simple() {
1✔
984
        let original = ".id == 123";
1✔
985
        let expr = parse_assertion(original);
1✔
986
        assert_eq!(assertion_to_string(&expr), original);
1✔
987
    }
1✔
988

989
    #[test]
990
    fn test_roundtrip_with_plugin() {
1✔
991
        let original = "@len(.items) == 0";
1✔
992
        let expr = parse_assertion(original);
1✔
993
        assert_eq!(assertion_to_string(&expr), original);
1✔
994
    }
1✔
995

996
    #[test]
997
    fn test_parse_and_or() {
1✔
998
        assert!(matches!(
1✔
999
            parse_assertion(".x == 1 and .y == 2"),
1✔
1000
            AssertionExpr::And { .. }
1001
        ));
1002
        assert!(matches!(
1✔
1003
            parse_assertion(".x == 1 or .y == 2"),
1✔
1004
            AssertionExpr::Or { .. }
1005
        ));
1006
    }
1✔
1007

1008
    #[test]
1009
    fn test_parse_not_not() {
1✔
1010
        if let AssertionExpr::NotNot(inner) = parse_assertion("not not .x") {
1✔
1011
            assert!(matches!(*inner, AssertionExpr::Atom(Expr::JqPath(_))));
1✔
1012
        } else {
1013
            panic!("Expected NotNot");
×
1014
        }
1015
    }
1✔
1016

1017
    #[test]
1018
    fn test_parse_double_bang() {
1✔
1019
        if let AssertionExpr::NotNot(inner) = parse_assertion("!!.x") {
1✔
1020
            assert!(matches!(*inner, AssertionExpr::Atom(Expr::JqPath(_))));
1✔
1021
        } else {
1022
            panic!("Expected NotNot");
×
1023
        }
1024
    }
1✔
1025

1026
    #[test]
1027
    fn test_parse_regex_literal() {
1✔
1028
        let expr = parse_assertion("@regex(.name, /^hello/i) == true");
1✔
1029
        if let AssertionExpr::Binary { op, left, .. } = expr {
1✔
1030
            assert_eq!(op, BinaryOp::Eq);
1✔
1031
            if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &*left {
1✔
1032
                assert_eq!(name, "regex");
1✔
1033
                assert_eq!(args.len(), 2);
1✔
1034
                if let AssertionExpr::Atom(a) = &args[1] {
1✔
1035
                    if let Expr::RegExp { pattern, flags } = &a {
1✔
1036
                        assert_eq!(pattern, "^hello");
1✔
1037
                        assert_eq!(flags, "");
1✔
1038
                    } else {
1039
                        panic!("Expected RegExp");
×
1040
                    }
1041
                } else {
1042
                    panic!("Expected Atom");
×
1043
                }
1044
            } else {
1045
                panic!("Expected PluginCall");
×
1046
            }
1047
        } else {
1048
            panic!("Expected Binary");
×
1049
        }
1050
    }
1✔
1051

1052
    #[test]
1053
    fn test_parse_regex_serializes_correctly() {
1✔
1054
        let expr = parse_assertion("@regex(.x, /\\d{4}/gi) == true");
1✔
1055
        let s = assertion_to_string(&expr);
1✔
1056
        assert!(s.contains("/\\d{4}/"), "Should contain regex: {}", s);
1✔
1057
    }
1✔
1058

1059
    #[test]
1060
    fn test_parse_json_literal() {
1✔
1061
        let expr = parse_assertion("@json(.data) == {\"key\": \"value\"}");
1✔
1062
        if let AssertionExpr::Binary { op, right, .. } = expr {
1✔
1063
            assert_eq!(op, BinaryOp::Eq);
1✔
1064
            if let AssertionExpr::Atom(Expr::Json(s)) = &*right {
1✔
1065
                assert!(s.contains("\"key\""));
1✔
1066
                assert!(s.contains("\"value\""));
1✔
1067
            } else {
1068
                panic!("Expected Json");
×
1069
            }
1070
        } else {
1071
            panic!("Expected Binary");
×
1072
        }
1073
    }
1✔
1074

1075
    #[test]
1076
    fn test_nested_ternary_roundtrip() {
1✔
1077
        let original = "if .x == 1 then if .y == 2 then true else false end else false end";
1✔
1078
        let expr = parse_assertion(original);
1✔
1079
        let s = assertion_to_string(&expr);
1✔
1080
        assert!(s.contains('?'), "Should contain ternary: {}", s);
1✔
1081
        assert!(s.contains('('), "Should contain parens: {}", s);
1✔
1082
    }
1✔
1083
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc