1use serde::{Deserialize, Serialize};
17
18use crate::tokenizer::{TokenKind, tokenize_assertion};
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub 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 As(Box<Expr>, String),
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub enum Literal {
75 Bool(bool),
76 Number(String),
77 Str(String),
78 Null,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82pub enum BinaryOp {
83 Eq,
84 Ne,
85 Gt,
86 Lt,
87 Ge,
88 Le,
89 Contains,
90 Matches,
91 StartsWith,
92 EndsWith,
93}
94
95impl BinaryOp {
96 pub fn as_str(&self) -> &'static str {
97 match self {
98 Self::Eq => "==",
99 Self::Ne => "!=",
100 Self::Gt => ">",
101 Self::Lt => "<",
102 Self::Ge => ">=",
103 Self::Le => "<=",
104 Self::Contains => "contains",
105 Self::Matches => "matches",
106 Self::StartsWith => "startsWith",
107 Self::EndsWith => "endsWith",
108 }
109 }
110 fn try_parse(s: &str) -> Option<Self> {
111 match s {
112 "==" => Some(Self::Eq),
113 "!=" => Some(Self::Ne),
114 ">" => Some(Self::Gt),
115 "<" => Some(Self::Lt),
116 ">=" => Some(Self::Ge),
117 "<=" => Some(Self::Le),
118 "contains" => Some(Self::Contains),
119 "matches" => Some(Self::Matches),
120 "startsWith" | "startswith" => Some(Self::StartsWith),
121 "endsWith" | "endswith" => Some(Self::EndsWith),
122 _ => None,
123 }
124 }
125}
126
127impl std::fmt::Display for Expr {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 match self {
132 Self::JqPath(p) => write!(f, "{}", p),
133 Self::PluginCall { name, args } => {
134 write!(f, "@{}(", name)?;
135 for (i, a) in args.iter().enumerate() {
136 if i > 0 {
137 write!(f, ", ")?;
138 }
139 write!(f, "{}", a)?;
140 }
141 write!(f, ")")
142 }
143 Self::Literal(Literal::Bool(b)) => write!(f, "{}", b),
144 Self::Literal(Literal::Number(n)) => write!(f, "{}", n),
145 Self::Literal(Literal::Str(s)) => write!(f, "\"{}\"", s),
146 Self::Literal(Literal::Null) => write!(f, "null"),
147 Self::RegExp { pattern, flags } => {
148 write!(f, "/{}/", pattern)?;
149 if !flags.is_empty() {
150 write!(f, "{}", flags)?;
151 }
152 Ok(())
153 }
154 Self::Json(s) => write!(f, "{}", s),
155 Self::Yaml(s) => write!(f, "{}", s),
156 Self::Variable(n) => write!(f, "${}", n),
157 Self::As(inner, type_name) => write!(f, "{}:{}", inner, type_name),
158 }
159 }
160}
161
162impl std::fmt::Display for AssertionExpr {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 fmt_assertion(self, f, 0)
165 }
166}
167
168fn fmt_assertion(e: &AssertionExpr, f: &mut std::fmt::Formatter<'_>, prec: u8) -> std::fmt::Result {
169 match e {
170 AssertionExpr::Or { left, right } => {
171 if prec > 1 {
172 write!(f, "(")?;
173 }
174 fmt_assertion(left, f, 1)?;
175 write!(f, " or ")?;
176 fmt_assertion(right, f, 1)?;
177 if prec > 1 {
178 write!(f, ")")?;
179 }
180 Ok(())
181 }
182 AssertionExpr::Xor { left, right } => {
183 if prec > 1 {
184 write!(f, "(")?;
185 }
186 fmt_assertion(left, f, 1)?;
187 write!(f, " xor ")?;
188 fmt_assertion(right, f, 1)?;
189 if prec > 1 {
190 write!(f, ")")?;
191 }
192 Ok(())
193 }
194 AssertionExpr::And { left, right } => {
195 if prec > 2 {
196 write!(f, "(")?;
197 }
198 fmt_assertion(left, f, 2)?;
199 write!(f, " and ")?;
200 fmt_assertion(right, f, 2)?;
201 if prec > 2 {
202 write!(f, ")")?;
203 }
204 Ok(())
205 }
206 AssertionExpr::Binary { op, left, right } => {
207 if prec > 3 {
208 write!(f, "(")?;
209 }
210 fmt_assertion(left, f, 3)?;
211 write!(f, " {} ", op.as_str())?;
212 fmt_assertion(right, f, 3)?;
213 if prec > 3 {
214 write!(f, ")")?;
215 }
216 Ok(())
217 }
218 AssertionExpr::Not(inner) => {
219 write!(f, "!")?;
220 fmt_assertion(inner, f, 4)
221 }
222 AssertionExpr::NotNot(inner) => {
223 write!(f, "not not ")?;
224 fmt_assertion(inner, f, 4)
225 }
226 AssertionExpr::IfThenElse {
227 condition,
228 then_branch,
229 else_branch,
230 } => {
231 write!(f, "(")?;
232 fmt_assertion(condition, f, 0)?;
233 write!(f, " ? ")?;
234 fmt_assertion(then_branch, f, 0)?;
235 write!(f, " : ")?;
236 fmt_assertion(else_branch, f, 0)?;
237 write!(f, ")")
238 }
239 AssertionExpr::Paren(inner) => {
240 write!(f, "(")?;
241 fmt_assertion(inner, f, 0)?;
242 write!(f, ")")
243 }
244 AssertionExpr::Atom(e) => write!(f, "{}", e),
245 AssertionExpr::Raw(s) => write!(f, "{}", s),
246 }
247}
248
249pub fn parse_assertion(raw: &str) -> AssertionExpr {
254 let tokens = tokenize_assertion(raw);
255 if tokens.is_empty() {
256 return AssertionExpr::Raw(raw.to_string());
257 }
258 let mut pos = 0;
259 let expr = parse_pipe(&tokens, &mut pos);
260 if pos >= tokens.len() {
261 expr
262 } else {
263 AssertionExpr::Raw(raw.to_string())
264 }
265}
266
267fn parse_pipe(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
268 let mut expr = parse_or(ts, p);
269 while *p < ts.len() {
270 if !matches!(ts[*p].kind, TokenKind::Pipe) {
271 break;
272 }
273 *p += 1;
274 if *p < ts.len() && is_keyword(ts, *p, "not") {
275 *p += 1;
276 if *p < ts.len() && is_keyword(ts, *p, "not") {
277 *p += 1;
278 } else {
279 expr = AssertionExpr::Not(Box::new(expr));
280 }
281 } else {
282 break;
283 }
284 }
285 expr
286}
287
288fn parse_or(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
289 let mut left = parse_xor(ts, p);
290 while *p < ts.len() && is_keyword(ts, *p, "or") {
291 *p += 1;
292 let right = parse_xor(ts, p);
293 left = AssertionExpr::Or {
294 left: Box::new(left),
295 right: Box::new(right),
296 };
297 }
298 left
299}
300
301fn parse_xor(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
302 let mut left = parse_and(ts, p);
303 while *p < ts.len() && is_keyword(ts, *p, "xor") {
304 *p += 1;
305 let right = parse_and(ts, p);
306 left = AssertionExpr::Xor {
307 left: Box::new(left),
308 right: Box::new(right),
309 };
310 }
311 left
312}
313
314fn parse_and(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
315 let mut left = parse_bin(ts, p);
316 while *p < ts.len() && is_keyword(ts, *p, "and") {
317 *p += 1;
318 let right = parse_bin(ts, p);
319 left = AssertionExpr::And {
320 left: Box::new(left),
321 right: Box::new(right),
322 };
323 }
324 left
325}
326
327fn parse_bin(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
328 let mut left = parse_unary(ts, p);
329 loop {
330 let op = match ts.get(*p).map(|t| &t.kind) {
331 Some(TokenKind::Op(s)) => match s.as_str() {
332 "==" => BinaryOp::Eq,
333 "!=" => BinaryOp::Ne,
334 ">" => BinaryOp::Gt,
335 "<" => BinaryOp::Lt,
336 ">=" => BinaryOp::Ge,
337 "<=" => BinaryOp::Le,
338 "contains" => BinaryOp::Contains,
339 "matches" => BinaryOp::Matches,
340 "startsWith" | "startswith" => BinaryOp::StartsWith,
341 "endsWith" | "endswith" => BinaryOp::EndsWith,
342 _ => BinaryOp::EndsWith,
343 },
344 Some(TokenKind::Ident(s)) if is_bin_op_keyword(s) => {
345 BinaryOp::try_parse(s).unwrap_or(BinaryOp::EndsWith)
346 }
347 None => break,
348 _ => break,
349 };
350
351 *p += 1;
352 let right = parse_unary(ts, p);
353 left = AssertionExpr::Binary {
354 op,
355 left: Box::new(left),
356 right: Box::new(right),
357 };
358 }
359 left
360}
361
362fn parse_unary(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
363 if *p >= ts.len() {
364 return AssertionExpr::Raw(String::new());
365 }
366 match &ts[*p].kind {
367 TokenKind::Bang => {
368 *p += 1;
369 if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Bang) {
370 *p += 1;
371 let inner = parse_unary(ts, p);
372 AssertionExpr::NotNot(Box::new(inner))
373 } else {
374 let inner = parse_unary(ts, p);
375 AssertionExpr::Not(Box::new(inner))
376 }
377 }
378 TokenKind::Ident(s) if s == "not" => {
379 *p += 1;
380 if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Ident(ref s2) if s2 == "not") {
381 *p += 1;
382 let inner = parse_unary(ts, p);
383 AssertionExpr::NotNot(Box::new(inner))
384 } else {
385 let inner = parse_unary(ts, p);
386 AssertionExpr::Not(Box::new(inner))
387 }
388 }
389 TokenKind::Ident(s) if s == "if" => parse_if(ts, p),
390 TokenKind::LParen => {
391 *p += 1;
392 let inner = parse_pipe(ts, p);
393 if *p < ts.len() && matches!(ts[*p].kind, TokenKind::RParen) {
394 *p += 1;
395 }
396 AssertionExpr::Paren(Box::new(inner))
397 }
398 _ => parse_atom(ts, p),
399 }
400}
401
402fn parse_if(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
403 *p += 1;
404 let cond = parse_pipe(ts, p);
405 if *p >= ts.len() || !is_keyword(ts, *p, "then") {
406 return AssertionExpr::Raw("if..then missing".into());
407 }
408 *p += 1;
409 let then_b = parse_pipe(ts, p);
410 if *p >= ts.len() || !is_keyword(ts, *p, "else") {
411 return AssertionExpr::Raw("if..else missing".into());
412 }
413 *p += 1;
414 let else_b = parse_pipe(ts, p);
415 if *p < ts.len() && is_keyword(ts, *p, "end") {
416 *p += 1;
417 }
418 AssertionExpr::IfThenElse {
419 condition: Box::new(cond),
420 then_branch: Box::new(then_b),
421 else_branch: Box::new(else_b),
422 }
423}
424
425fn parse_atom(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
426 if *p >= ts.len() {
427 return AssertionExpr::Atom(Expr::JqPath(String::new()));
428 }
429 let mut expr = match &ts[*p].kind {
430 TokenKind::StringLit(s) => {
431 *p += 1;
432 Expr::Literal(Literal::Str(s.clone()))
433 }
434 TokenKind::NumberLit(n) => {
435 *p += 1;
436 Expr::Literal(Literal::Number(n.clone()))
437 }
438 TokenKind::Ident(s) if s == "true" => {
439 *p += 1;
440 Expr::Literal(Literal::Bool(true))
441 }
442 TokenKind::Ident(s) if s == "false" => {
443 *p += 1;
444 Expr::Literal(Literal::Bool(false))
445 }
446 TokenKind::Ident(s) if s == "null" => {
447 *p += 1;
448 Expr::Literal(Literal::Null)
449 }
450 TokenKind::VarDelim => {
451 *p += 1;
454 while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::VarDelim) {
455 *p += 1;
456 }
457 if *p < ts.len() {
458 *p += 1;
459 }
460 Expr::JqPath(String::new())
461 }
462 TokenKind::At => {
463 *p += 1;
464 let name = if *p < ts.len() {
465 if let TokenKind::Ident(s) = &ts[*p].kind {
466 *p += 1;
467 let mut name = s.clone();
468 if *p + 1 < ts.len()
470 && matches!(&ts[*p].kind, TokenKind::Dot)
471 && matches!(&ts[*p + 1].kind, TokenKind::Ident(_))
472 {
473 *p += 1; if let TokenKind::Ident(method) = &ts[*p].kind {
475 name.push('.');
476 name.push_str(method);
477 *p += 1; }
479 }
480 name
481 } else {
482 String::new()
483 }
484 } else {
485 String::new()
486 };
487 let args = if *p < ts.len() && matches!(ts[*p].kind, TokenKind::LParen) {
488 *p += 1;
489 let mut args = Vec::with_capacity(4);
490 while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::RParen) {
491 let arg = parse_pipe(ts, p);
492 args.push(arg);
493 if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Comma) {
494 *p += 1;
495 }
496 }
497 if *p < ts.len() {
498 *p += 1;
499 }
500 merge_hyphenated_args(args)
501 } else {
502 Vec::new()
503 };
504 Expr::PluginCall { name, args }
505 }
506 TokenKind::Op(op) if op == "-" => {
507 *p += 1;
508 if *p < ts.len()
509 && let TokenKind::NumberLit(n) = &ts[*p].kind
510 {
511 let neg = format!("-{}", n);
512 *p += 1;
513 Expr::Literal(Literal::Number(neg))
514 } else {
515 Expr::JqPath("-".to_string())
516 }
517 }
518 TokenKind::RegExpLit { pattern, flags } => {
519 *p += 1;
520 Expr::RegExp {
521 pattern: pattern.clone(),
522 flags: flags.clone(),
523 }
524 }
525 TokenKind::LBrace => {
526 *p += 1;
527 let mut json = String::with_capacity(64);
528 json.push('{');
529 let mut depth = 1;
530 while *p < ts.len() && depth > 0 {
531 match &ts[*p].kind {
532 TokenKind::LBrace => {
533 depth += 1;
534 json.push('{');
535 *p += 1;
536 }
537 TokenKind::RBrace => {
538 depth -= 1;
539 if depth > 0 {
540 json.push('}');
541 }
542 *p += 1;
543 }
544 TokenKind::LParen => {
545 json.push('(');
546 *p += 1;
547 }
548 TokenKind::RParen => {
549 json.push(')');
550 *p += 1;
551 }
552 TokenKind::LBracket => {
553 json.push('[');
554 *p += 1;
555 }
556 TokenKind::RBracket => {
557 json.push(']');
558 *p += 1;
559 }
560 TokenKind::StringLit(s) => {
561 json.push('"');
562 json.push_str(s);
563 json.push('"');
564 *p += 1;
565 }
566 TokenKind::NumberLit(n) => {
567 json.push_str(n);
568 *p += 1;
569 }
570 TokenKind::Ident(s) => {
571 json.push_str(s);
572 *p += 1;
573 }
574 TokenKind::Comma => {
575 json.push(',');
576 *p += 1;
577 }
578 TokenKind::Op(s) => {
579 json.push_str(s);
580 *p += 1;
581 }
582 TokenKind::Dot => {
583 json.push('.');
584 *p += 1;
585 }
586 TokenKind::At => {
587 json.push('@');
588 *p += 1;
589 }
590 TokenKind::Colon => {
591 json.push(':');
592 *p += 1;
593 }
594 TokenKind::VarDelim => {
595 json.push_str("{{ ");
596 *p += 1;
597 while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::VarDelim) {
598 if let TokenKind::Ident(s) = &ts[*p].kind {
599 json.push_str(s);
600 }
601 *p += 1;
602 }
603 if *p < ts.len() {
604 json.push_str(" }}");
605 *p += 1;
606 } else {
607 json.push_str(" }}");
608 }
609 }
610 _ => {
611 *p += 1;
612 }
613 }
614 }
615 json.push('}');
616 Expr::Json(json)
617 }
618 TokenKind::Ident(s) if s.starts_with('$') => {
619 *p += 1;
620 Expr::Variable(s[1..].to_string())
621 }
622 TokenKind::Dot | TokenKind::Ident(_) => {
623 let mut path = String::with_capacity(24);
624 while *p < ts.len() {
625 if let TokenKind::Ident(s) = &ts[*p].kind
626 && (is_bin_op_keyword(s) || is_keyword_token(&ts[*p].kind))
627 {
628 break;
629 }
630 match &ts[*p].kind {
631 TokenKind::Dot => {
632 path.push('.');
633 *p += 1;
634 }
635 TokenKind::Ident(s) => {
636 path.push_str(s);
637 *p += 1;
638 }
639 TokenKind::StringLit(s) => {
640 path.push('.');
641 path.push('"');
642 path.push_str(s);
643 path.push('"');
644 *p += 1;
645 }
646 TokenKind::Op(op) if op == "-" || op == ":" => {
647 path.push_str(op);
648 *p += 1;
649 }
650 TokenKind::LBracket => {
651 path.push('[');
652 *p += 1;
653 while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::RBracket) {
654 if let TokenKind::NumberLit(n) = &ts[*p].kind {
655 path.push_str(n);
656 } else if let TokenKind::Ident(s) = &ts[*p].kind {
657 path.push_str(s);
658 } else if let TokenKind::StringLit(s) = &ts[*p].kind {
659 path.push('"');
660 path.push_str(s);
661 path.push('"');
662 } else if let TokenKind::Op(op) = &ts[*p].kind
663 && (op == ":" || op == "-")
664 {
665 path.push_str(op);
666 } else if let TokenKind::Dot = &ts[*p].kind {
667 path.push('.');
668 }
669 *p += 1;
670 }
671 if *p < ts.len() {
672 path.push(']');
673 *p += 1;
674 }
675 }
676 _ => break,
677 }
678 }
679 Expr::JqPath(path)
680 }
681 _ => {
682 *p += 1;
683 Expr::JqPath(String::new())
684 }
685 };
686
687 if *p < ts.len() && matches!(&ts[*p].kind, TokenKind::Colon) {
689 *p += 1; if let TokenKind::Ident(type_name) = &ts[*p].kind {
691 let name = type_name.clone();
692 *p += 1; expr = Expr::As(Box::new(expr), name);
694 }
695 }
696
697 AssertionExpr::Atom(expr)
698}
699
700fn is_keyword(ts: &[crate::tokenizer::Token], idx: usize, kw: &str) -> bool {
701 matches!(ts.get(idx), Some(t) if matches!(&t.kind, TokenKind::Ident(s) if s == kw))
702}
703
704fn merge_hyphenated_args(args: Vec<AssertionExpr>) -> Vec<AssertionExpr> {
707 if args.len() <= 1 {
708 return args;
709 }
710
711 let mut merged = Vec::with_capacity(args.len());
712
713 let mut iter = args.into_iter().peekable();
714 while let Some(current) = iter.next() {
715 match current {
716 AssertionExpr::Atom(Expr::JqPath(mut cur)) if !cur.contains('.') => {
717 while let Some(AssertionExpr::Atom(Expr::JqPath(nxt))) = iter.peek() {
718 if nxt.contains('.') {
719 break;
720 }
721
722 let Some(AssertionExpr::Atom(Expr::JqPath(nxt_owned))) = iter.next() else {
723 break;
724 };
725
726 cur.push('-');
727 cur.push_str(&nxt_owned);
728 }
729
730 merged.push(AssertionExpr::Atom(Expr::JqPath(cur)));
731 }
732 other => merged.push(other),
733 }
734 }
735
736 merged
737}
738
739fn is_bin_op_keyword(s: &str) -> bool {
740 matches!(
741 s,
742 "contains" | "matches" | "startsWith" | "endsWith" | "startswith" | "endswith"
743 )
744}
745
746fn is_keyword_token(k: &TokenKind) -> bool {
747 matches!(
748 k,
749 TokenKind::Ident(s)
750 if matches!(
751 s.as_str(),
752 "and" | "or" | "xor" | "contains" | "matches" | "startsWith"
753 | "endsWith" | "startswith" | "endswith"
754 )
755 )
756}
757
758pub fn assertion_to_string(expr: &AssertionExpr) -> String {
762 let mut out = String::with_capacity(64);
763 push_assertion(expr, &mut out, 0);
764 out
765}
766
767fn push_expr(expr: &Expr, out: &mut String) {
768 match expr {
769 Expr::JqPath(p) => out.push_str(p),
770 Expr::PluginCall { name, args } => {
771 out.push('@');
772 out.push_str(name);
773 out.push('(');
774 for (i, a) in args.iter().enumerate() {
775 if i > 0 {
776 out.push_str(", ");
777 }
778 push_assertion(a, out, 0);
779 }
780 out.push(')');
781 }
782 Expr::Literal(Literal::Bool(b)) => out.push_str(if *b { "true" } else { "false" }),
783 Expr::Literal(Literal::Number(n)) => out.push_str(n),
784 Expr::Literal(Literal::Str(s)) => {
785 out.push('"');
786 out.push_str(s);
787 out.push('"');
788 }
789 Expr::Literal(Literal::Null) => out.push_str("null"),
790 Expr::Variable(n) => {
791 out.push('$');
792 out.push_str(n);
793 }
794 Expr::RegExp { pattern, flags } => {
795 out.push('/');
796 out.push_str(pattern);
797 out.push('/');
798 out.push_str(flags);
799 }
800 Expr::Json(s) | Expr::Yaml(s) => out.push_str(s),
801 Expr::As(inner, type_name) => {
802 push_expr(inner, out);
803 out.push(':');
804 out.push_str(type_name);
805 }
806 }
807}
808
809fn push_assertion(expr: &AssertionExpr, out: &mut String, prec: u8) {
810 match expr {
811 AssertionExpr::Or { left, right } => {
812 if prec > 1 {
813 out.push('(');
814 }
815 push_assertion(left, out, 1);
816 out.push_str(" or ");
817 push_assertion(right, out, 1);
818 if prec > 1 {
819 out.push(')');
820 }
821 }
822 AssertionExpr::Xor { left, right } => {
823 if prec > 1 {
824 out.push('(');
825 }
826 push_assertion(left, out, 1);
827 out.push_str(" xor ");
828 push_assertion(right, out, 1);
829 if prec > 1 {
830 out.push(')');
831 }
832 }
833 AssertionExpr::And { left, right } => {
834 if prec > 2 {
835 out.push('(');
836 }
837 push_assertion(left, out, 2);
838 out.push_str(" and ");
839 push_assertion(right, out, 2);
840 if prec > 2 {
841 out.push(')');
842 }
843 }
844 AssertionExpr::Binary { op, left, right } => {
845 if prec > 3 {
846 out.push('(');
847 }
848 push_assertion(left, out, 3);
849 out.push(' ');
850 out.push_str(op.as_str());
851 out.push(' ');
852 push_assertion(right, out, 3);
853 if prec > 3 {
854 out.push(')');
855 }
856 }
857 AssertionExpr::Not(inner) => {
858 out.push('!');
859 push_assertion(inner, out, 4);
860 }
861 AssertionExpr::NotNot(inner) => {
862 out.push_str("not not ");
863 push_assertion(inner, out, 4);
864 }
865 AssertionExpr::IfThenElse {
866 condition,
867 then_branch,
868 else_branch,
869 } => {
870 out.push('(');
871 push_assertion(condition, out, 0);
872 out.push_str(" ? ");
873 push_assertion(then_branch, out, 0);
874 out.push_str(" : ");
875 push_assertion(else_branch, out, 0);
876 out.push(')');
877 }
878 AssertionExpr::Paren(inner) => {
879 out.push('(');
880 push_assertion(inner, out, 0);
881 out.push(')');
882 }
883 AssertionExpr::Atom(e) => push_expr(e, out),
884 AssertionExpr::Raw(s) => out.push_str(s),
885 }
886}
887
888pub fn remove_redundant_parens(expr: &AssertionExpr) -> AssertionExpr {
890 match expr {
891 AssertionExpr::Paren(inner) => remove_redundant_parens(inner),
892 AssertionExpr::Binary { op, left, right } => AssertionExpr::Binary {
893 op: *op,
894 left: Box::new(remove_redundant_parens(left)),
895 right: Box::new(remove_redundant_parens(right)),
896 },
897 AssertionExpr::Not(e) => AssertionExpr::Not(Box::new(remove_redundant_parens(e))),
898 AssertionExpr::NotNot(e) => AssertionExpr::NotNot(Box::new(remove_redundant_parens(e))),
899 AssertionExpr::And { left, right } => AssertionExpr::And {
900 left: Box::new(remove_redundant_parens(left)),
901 right: Box::new(remove_redundant_parens(right)),
902 },
903 AssertionExpr::Or { left, right } => AssertionExpr::Or {
904 left: Box::new(remove_redundant_parens(left)),
905 right: Box::new(remove_redundant_parens(right)),
906 },
907 AssertionExpr::Xor { left, right } => AssertionExpr::Xor {
908 left: Box::new(remove_redundant_parens(left)),
909 right: Box::new(remove_redundant_parens(right)),
910 },
911 AssertionExpr::IfThenElse {
912 condition,
913 then_branch,
914 else_branch,
915 } => AssertionExpr::IfThenElse {
916 condition: Box::new(remove_redundant_parens(condition)),
917 then_branch: Box::new(remove_redundant_parens(then_branch)),
918 else_branch: Box::new(remove_redundant_parens(else_branch)),
919 },
920 _ => expr.clone(),
921 }
922}
923
924#[cfg(test)]
927mod tests {
928 use super::*;
929 #[cfg(not(miri))]
930 use std::hint::black_box;
931 #[cfg(not(miri))]
932 use std::time::Instant;
933
934 #[cfg(not(miri))]
935 fn bench_phase(name: &str, iterations: u32, mut f: impl FnMut()) {
936 let start = Instant::now();
937 for _ in 0..iterations {
938 f();
939 }
940 let elapsed = start.elapsed();
941 let per_call = elapsed / iterations;
942 eprintln!(
943 "{}: {} iterations in {:?} ({:?}/call)",
944 name, iterations, elapsed, per_call
945 );
946 }
947
948 #[test]
949 #[cfg(not(miri))]
950 fn perf_phase_breakdown_simple() {
951 let expr = ".id == 42 and .active == true";
952
953 bench_phase("ast_phase_simple_tokenize", 200_000, || {
954 let tokens = tokenize_assertion(black_box(expr));
955 black_box(tokens.len());
956 });
957
958 bench_phase("ast_phase_simple_parse_from_tokens", 200_000, || {
959 let tokens = tokenize_assertion(black_box(expr));
960 let mut pos = 0;
961 let ast = parse_pipe(&tokens, &mut pos);
962 black_box(matches!(ast, AssertionExpr::Raw(_)));
963 black_box(pos);
964 });
965
966 bench_phase("ast_phase_simple_serialize", 200_000, || {
967 let ast = parse_assertion(black_box(expr));
968 let s = assertion_to_string(&ast);
969 black_box(s.len());
970 });
971 }
972
973 #[test]
974 #[cfg(not(miri))]
975 fn perf_phase_breakdown_complex() {
976 let expr = "if @len(.items) > 0 then (@regex(.name, /foo.*/i) and .meta.version >= 2) else (.status == \"empty\" or {{ feature_flag }} == true) end";
977
978 bench_phase("ast_phase_complex_tokenize", 100_000, || {
979 let tokens = tokenize_assertion(black_box(expr));
980 black_box(tokens.len());
981 });
982
983 bench_phase("ast_phase_complex_parse_from_tokens", 100_000, || {
984 let tokens = tokenize_assertion(black_box(expr));
985 let mut pos = 0;
986 let ast = parse_pipe(&tokens, &mut pos);
987 black_box(matches!(ast, AssertionExpr::Raw(_)));
988 black_box(pos);
989 });
990
991 bench_phase("ast_phase_complex_serialize", 50_000, || {
992 let ast = parse_assertion(black_box(expr));
993 let s = assertion_to_string(&ast);
994 black_box(s.len());
995 });
996 }
997
998 #[test]
999 fn test_parse_simple_equality() {
1000 let expr = parse_assertion(".id == 123");
1001 if let AssertionExpr::Binary { op, left, right } = expr {
1002 assert_eq!(op, BinaryOp::Eq);
1003 assert!(matches!(*left, AssertionExpr::Atom(Expr::JqPath(_))));
1004 assert!(matches!(
1005 *right,
1006 AssertionExpr::Atom(Expr::Literal(Literal::Number(_)))
1007 ));
1008 } else {
1009 panic!("Expected Binary, got: {:?}", expr);
1010 }
1011 }
1012
1013 #[test]
1014 fn test_parse_plugin_call() {
1015 let expr = parse_assertion("@uuid(.user_id) == true");
1016 if let AssertionExpr::Binary { op, left, .. } = expr {
1017 assert_eq!(op, BinaryOp::Eq);
1018 if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &*left {
1019 assert_eq!(name, "uuid");
1020 assert_eq!(args.len(), 1);
1021 } else {
1022 panic!("Expected PluginCall");
1023 }
1024 } else {
1025 panic!("Expected Binary, got: {:?}", expr);
1026 }
1027 }
1028
1029 #[test]
1030 fn test_parse_negation_bang() {
1031 let expr = parse_assertion("!@has_header(\"x\")");
1032 if let AssertionExpr::Not(inner) = expr {
1033 if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1034 assert_eq!(name, "has_header");
1035 } else {
1036 panic!("Expected PluginCall inside Not");
1037 }
1038 } else {
1039 panic!("Expected Not, got: {:?}", expr);
1040 }
1041 }
1042
1043 #[test]
1044 fn test_parse_pipe_not() {
1045 let expr = parse_assertion("@empty(.id) | not");
1046 if let AssertionExpr::Not(inner) = expr {
1047 if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1048 assert_eq!(name, "empty");
1049 } else {
1050 panic!("Expected PluginCall inside Not, got: {:?}", inner);
1051 }
1052 } else {
1053 panic!("Expected Not, got: {:?}", expr);
1054 }
1055 }
1056
1057 #[test]
1058 fn test_parse_pipe_not_not() {
1059 let expr = parse_assertion("@empty(.id) | not not");
1060 if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = expr {
1061 assert_eq!(name, "empty");
1062 } else {
1063 panic!(
1064 "Expected bare PluginCall (double negation cancels), got: {:?}",
1065 expr
1066 );
1067 }
1068 }
1069
1070 #[test]
1071 fn test_parse_negation_not_keyword() {
1072 let expr = parse_assertion("not @empty(.id)");
1073 if let AssertionExpr::Not(inner) = expr {
1074 if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1075 assert_eq!(name, "empty");
1076 } else {
1077 panic!("Expected PluginCall");
1078 }
1079 } else {
1080 panic!("Expected Not, got: {:?}", expr);
1081 }
1082 }
1083
1084 #[test]
1085 fn test_parse_xor() {
1086 let expr = parse_assertion("@uuid(.id) xor @email(.name)");
1087 if let AssertionExpr::Xor { left, right, .. } = expr {
1088 assert!(matches!(
1089 *left,
1090 AssertionExpr::Atom(Expr::PluginCall { .. })
1091 ));
1092 assert!(matches!(
1093 *right,
1094 AssertionExpr::Atom(Expr::PluginCall { .. })
1095 ));
1096 } else {
1097 panic!("Expected Xor, got: {:?}", expr);
1098 }
1099 }
1100
1101 #[test]
1102 fn test_parse_or() {
1103 let expr = parse_assertion("@uuid(.id) or @email(.name)");
1104 assert!(
1105 matches!(expr, AssertionExpr::Or { .. }),
1106 "Expected Or, got: {:?}",
1107 expr
1108 );
1109 }
1110
1111 #[test]
1112 fn test_parse_and_or_xor_precedence() {
1113 let expr = parse_assertion("@a or @b xor @c and @d");
1114 assert!(
1115 matches!(expr, AssertionExpr::Or { .. }),
1116 "Expected Or, got: {:?}",
1117 expr
1118 );
1119 }
1120
1121 #[test]
1122 fn test_parse_paren_or_in_and() {
1123 let expr = parse_assertion("(@a or @b) and @c");
1124 if let AssertionExpr::And { left, .. } = expr {
1125 assert!(
1126 matches!(*left, AssertionExpr::Paren(_)),
1127 "Left should be Paren(Or)"
1128 );
1129 } else {
1130 panic!("Expected And, got: {:?}", expr);
1131 }
1132 }
1133
1134 #[test]
1135 fn test_parse_negated_paren_or() {
1136 let expr = parse_assertion("!(@empty(.id) or @uuid(.id))");
1137 if let AssertionExpr::Not(inner) = expr {
1138 if let AssertionExpr::Paren(or_expr) = &*inner {
1139 assert!(matches!(**or_expr, AssertionExpr::Or { .. }));
1140 } else {
1141 panic!("Expected Paren(Or), got: {:?}", inner);
1142 }
1143 } else {
1144 panic!("Expected Not, got: {:?}", expr);
1145 }
1146 }
1147
1148 #[test]
1149 fn test_roundtrip_xor() {
1150 let expr = parse_assertion("@a() xor @b()");
1151 let s = assertion_to_string(&expr);
1152 assert!(s.contains(" xor "), "Should contain xor: {}", s);
1153 }
1154
1155 #[test]
1156 fn test_roundtrip_pipe_not() {
1157 let expr = parse_assertion("@empty(.id) | not");
1158 let s = assertion_to_string(&expr);
1159 assert!(s.contains('!'), "Pipe not should serialize as !: {}", s);
1160 }
1161
1162 #[test]
1163 fn test_contains() {
1164 let expr = parse_assertion(".name contains \"test\"");
1165 if let AssertionExpr::Binary { op, .. } = expr {
1166 assert_eq!(op, BinaryOp::Contains);
1167 } else {
1168 panic!("Expected Binary");
1169 }
1170 }
1171
1172 #[test]
1173 fn test_startswith() {
1174 let expr = parse_assertion(".name startsWith \"te\"");
1175 if let AssertionExpr::Binary { op, .. } = expr {
1176 assert_eq!(op, BinaryOp::StartsWith);
1177 } else {
1178 panic!("Expected Binary");
1179 }
1180 }
1181
1182 #[test]
1183 fn test_matches() {
1184 let expr = parse_assertion(".name matches \"^te.*t$\"");
1185 if let AssertionExpr::Binary { op, .. } = expr {
1186 assert_eq!(op, BinaryOp::Matches);
1187 } else {
1188 panic!("Expected Binary");
1189 }
1190 }
1191
1192 #[test]
1193 fn test_if_then_else() {
1194 let expr = parse_assertion("if @len(.items) == 0 then true else false end");
1195 if let AssertionExpr::IfThenElse {
1196 condition,
1197 then_branch,
1198 else_branch,
1199 } = expr
1200 {
1201 assert!(matches!(*condition, AssertionExpr::Binary { .. }));
1202 assert!(matches!(
1203 *then_branch,
1204 AssertionExpr::Atom(Expr::Literal(Literal::Bool(true)))
1205 ));
1206 assert!(matches!(
1207 *else_branch,
1208 AssertionExpr::Atom(Expr::Literal(Literal::Bool(false)))
1209 ));
1210 } else {
1211 panic!("Expected IfThenElse");
1212 }
1213 }
1214
1215 #[test]
1216 fn test_if_then_else_serializes_as_ternary() {
1217 let expr = parse_assertion("if .x == 0 then true else false end");
1218 let s = assertion_to_string(&expr);
1219 assert!(s.contains('?'), "Should contain ternary: {}", s);
1220 assert!(s.contains(':'), "Should contain colon: {}", s);
1221 }
1222
1223 #[test]
1224 fn test_nested_if_serializes_correctly() {
1225 let expr =
1226 parse_assertion("if .a == 1 then if .b == 2 then \"A\" else \"B\" end else \"C\" end");
1227 if let AssertionExpr::IfThenElse {
1228 then_branch,
1229 else_branch,
1230 ..
1231 } = &expr
1232 {
1233 assert!(matches!(**then_branch, AssertionExpr::IfThenElse { .. }));
1234 assert!(matches!(
1235 **else_branch,
1236 AssertionExpr::Atom(Expr::Literal(Literal::Str(_)))
1237 ));
1238 } else {
1239 panic!("Expected IfThenElse");
1240 }
1241 let s = assertion_to_string(&expr);
1242 assert!(s.contains('('), "Nested ternary should have parens: {}", s);
1243 }
1244
1245 #[test]
1246 fn test_remove_redundant_parens() {
1247 let expr = parse_assertion("((.x == 1))");
1248 let simplified = remove_redundant_parens(&expr);
1249 let s = assertion_to_string(&simplified);
1250 assert!(!s.starts_with("(("), "Should not have double parens: {}", s);
1251 }
1252
1253 #[test]
1254 fn test_roundtrip_simple() {
1255 let original = ".id == 123";
1256 let expr = parse_assertion(original);
1257 assert_eq!(assertion_to_string(&expr), original);
1258 }
1259
1260 #[test]
1261 fn test_roundtrip_with_plugin() {
1262 let original = "@len(.items) == 0";
1263 let expr = parse_assertion(original);
1264 assert_eq!(assertion_to_string(&expr), original);
1265 }
1266
1267 #[test]
1268 fn test_parse_and_or() {
1269 assert!(matches!(
1270 parse_assertion(".x == 1 and .y == 2"),
1271 AssertionExpr::And { .. }
1272 ));
1273 assert!(matches!(
1274 parse_assertion(".x == 1 or .y == 2"),
1275 AssertionExpr::Or { .. }
1276 ));
1277 }
1278
1279 #[test]
1280 fn test_parse_not_not() {
1281 if let AssertionExpr::NotNot(inner) = parse_assertion("not not .x") {
1282 assert!(matches!(*inner, AssertionExpr::Atom(Expr::JqPath(_))));
1283 } else {
1284 panic!("Expected NotNot");
1285 }
1286 }
1287
1288 #[test]
1289 fn test_parse_double_bang() {
1290 if let AssertionExpr::NotNot(inner) = parse_assertion("!!.x") {
1291 assert!(matches!(*inner, AssertionExpr::Atom(Expr::JqPath(_))));
1292 } else {
1293 panic!("Expected NotNot");
1294 }
1295 }
1296
1297 #[test]
1298 fn test_parse_regex_literal() {
1299 let expr = parse_assertion("@regex(.name, /^hello/i) == true");
1300 if let AssertionExpr::Binary { op, left, .. } = expr {
1301 assert_eq!(op, BinaryOp::Eq);
1302 if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &*left {
1303 assert_eq!(name, "regex");
1304 assert_eq!(args.len(), 2);
1305 if let AssertionExpr::Atom(a) = &args[1] {
1306 if let Expr::RegExp { pattern, flags } = &a {
1307 assert_eq!(pattern, "^hello");
1308 assert_eq!(flags, "");
1309 } else {
1310 panic!("Expected RegExp");
1311 }
1312 } else {
1313 panic!("Expected Atom");
1314 }
1315 } else {
1316 panic!("Expected PluginCall");
1317 }
1318 } else {
1319 panic!("Expected Binary");
1320 }
1321 }
1322
1323 #[test]
1324 fn test_parse_regex_serializes_correctly() {
1325 let expr = parse_assertion("@regex(.x, /\\d{4}/gi) == true");
1326 let s = assertion_to_string(&expr);
1327 assert!(s.contains("/\\d{4}/"), "Should contain regex: {}", s);
1328 }
1329
1330 #[test]
1331 fn test_parse_json_literal() {
1332 let expr = parse_assertion("@json(.data) == {\"key\": \"value\"}");
1333 if let AssertionExpr::Binary { op, right, .. } = expr {
1334 assert_eq!(op, BinaryOp::Eq);
1335 if let AssertionExpr::Atom(Expr::Json(s)) = &*right {
1336 assert!(s.contains("\"key\""));
1337 assert!(s.contains("\"value\""));
1338 } else {
1339 panic!("Expected Json");
1340 }
1341 } else {
1342 panic!("Expected Binary");
1343 }
1344 }
1345
1346 #[test]
1347 fn test_nested_ternary_roundtrip() {
1348 let original = "if .x == 1 then if .y == 2 then true else false end else false end";
1349 let expr = parse_assertion(original);
1350 let s = assertion_to_string(&expr);
1351 assert!(s.contains('?'), "Should contain ternary: {}", s);
1352 assert!(s.contains('('), "Should contain parens: {}", s);
1353 }
1354
1355 #[test]
1358 fn test_parse_type_cast_number() {
1359 let expr = parse_assertion(".price:number >= 0");
1360 if let AssertionExpr::Binary { op, left, right } = expr {
1361 assert_eq!(op, BinaryOp::Ge);
1362 assert!(matches!(&*left, AssertionExpr::Atom(Expr::As(_, tn)) if tn == "number"));
1363 if let AssertionExpr::Atom(Expr::As(inner, tn)) = &*left {
1364 assert_eq!(tn, "number");
1365 assert!(matches!(&**inner, Expr::JqPath(p) if p == ".price"));
1366 }
1367 assert!(
1368 matches!(&*right, AssertionExpr::Atom(Expr::Literal(Literal::Number(n))) if n == "0")
1369 );
1370 } else {
1371 panic!("Expected Binary, got: {:?}", expr);
1372 }
1373 }
1374
1375 #[test]
1376 fn test_parse_type_cast_string() {
1377 let expr = parse_assertion(".name:string contains \"hello\"");
1378 assert!(matches!(
1379 expr,
1380 AssertionExpr::Binary {
1381 op: BinaryOp::Contains,
1382 ..
1383 }
1384 ));
1385 }
1386
1387 #[test]
1388 fn test_parse_type_cast_uint() {
1389 let expr = parse_assertion("@len(.items):uint > 0");
1390 if let AssertionExpr::Binary { op, left, .. } = expr {
1391 assert_eq!(op, BinaryOp::Gt);
1392 assert!(matches!(&*left, AssertionExpr::Atom(Expr::As(_, tn)) if tn == "uint"));
1393 } else {
1394 panic!("Expected Binary, got: {:?}", expr);
1395 }
1396 }
1397
1398 #[test]
1399 fn test_parse_type_cast_bool() {
1400 let expr = parse_assertion(".active:bool == true");
1401 if let AssertionExpr::Binary { op, left, right } = expr {
1402 assert_eq!(op, BinaryOp::Eq);
1403 assert!(matches!(&*left, AssertionExpr::Atom(Expr::As(_, tn)) if tn == "bool"));
1404 assert!(matches!(
1405 &*right,
1406 AssertionExpr::Atom(Expr::Literal(Literal::Bool(true)))
1407 ));
1408 } else {
1409 panic!("Expected Binary, got: {:?}", expr);
1410 }
1411 }
1412
1413 #[test]
1414 fn test_parse_type_cast_all_types_parsed() {
1415 let types = [
1416 "bool", "uint", "number", "string", "json", "yaml", "uuid", "email", "url", "ip",
1417 ];
1418 for type_name in &types {
1419 let raw = format!(".x:{} == 0", type_name);
1420 let expr = parse_assertion(&raw);
1421 assert!(
1422 matches!(&expr, AssertionExpr::Binary { left, .. }
1423 if matches!(&**left, AssertionExpr::Atom(Expr::As(_, tn)) if tn == type_name)),
1424 "Failed for type: {}",
1425 type_name
1426 );
1427 }
1428 }
1429
1430 #[test]
1431 fn test_parse_type_cast_roundtrip() {
1432 let original = ".price:number >= 0";
1433 let expr = parse_assertion(original);
1434 let s = assertion_to_string(&expr);
1435 assert_eq!(s, original);
1436 }
1437
1438 #[test]
1439 fn test_parse_type_cast_compound() {
1440 let expr =
1441 parse_assertion(r#".ips_to_decorations["10.0.0.1"].environment == "production""#);
1442 assert_eq!(
1443 assertion_to_string(&expr),
1444 r#".ips_to_decorations["10.0.0.1"].environment == "production""#
1445 );
1446
1447 let expr = parse_assertion(".items:json == {\"key\": \"value\"}");
1448 if let AssertionExpr::Binary { op, left, right } = expr {
1449 assert_eq!(op, BinaryOp::Eq);
1450 assert!(matches!(&*left, AssertionExpr::Atom(Expr::As(_, tn)) if tn == "json"));
1451 assert!(matches!(&*right, AssertionExpr::Atom(Expr::Json(_))));
1452 } else {
1453 panic!("Expected Binary, got: {:?}", expr);
1454 }
1455 }
1456
1457 #[test]
1458 fn test_parse_type_cast_jq_path_preserved() {
1459 let expr = parse_assertion(".user.name:string == \"alice\"");
1460 if let AssertionExpr::Binary { left, .. } = &expr {
1461 if let AssertionExpr::Atom(Expr::As(inner, tn)) = &**left {
1462 assert_eq!(tn, "string");
1463 assert!(matches!(&**inner, Expr::JqPath(p) if p == ".user.name"));
1464 } else {
1465 panic!("Expected As, got: {:?}", left);
1466 }
1467 } else {
1468 panic!("Expected Binary, got: {:?}", expr);
1469 }
1470 }
1471
1472 #[test]
1473 fn test_parse_type_cast_assertion_to_string_preserves_cast() {
1474 let cases = [
1475 ".x:number > 0",
1476 ".x:string == \"hello\"",
1477 ".x:bool == true",
1478 ".x:uint >= 5",
1479 ];
1480 for original in &cases {
1481 let expr = parse_assertion(original);
1482 let s = assertion_to_string(&expr);
1483 assert_eq!(s.as_str(), *original, "Roundtrip failed for: {}", original);
1484 }
1485 }
1486
1487 #[test]
1488 fn test_bracket_with_template_string() {
1489 let expr = parse_assertion(r#".x["{{var}}"] == "val""#);
1490 if let AssertionExpr::Binary { left, .. } = &expr {
1491 if let AssertionExpr::Atom(Expr::JqPath(p)) = &**left {
1492 assert_eq!(p, r#".x["{{var}}"]"#);
1493 } else {
1494 panic!("Expected JqPath, got: {:?}", left);
1495 }
1496 } else {
1497 panic!("Expected Binary, got: {:?}", expr);
1498 }
1499 let s = assertion_to_string(&expr);
1500 assert_eq!(s, r#".x["{{var}}"] == "val""#);
1501 }
1502
1503 #[test]
1504 fn test_bracket_with_var_index() {
1505 let expr = parse_assertion(r#".x[.idx] == 0"#);
1506 if let AssertionExpr::Binary { left, .. } = &expr {
1507 if let AssertionExpr::Atom(Expr::JqPath(p)) = &**left {
1508 assert_eq!(p, ".x[.idx]", "path mismatch, full expr: {:?}", expr);
1509 } else {
1510 panic!("Expected JqPath, got: {:?}", left);
1511 }
1512 } else {
1513 panic!("Expected Binary, got: {:?}", expr);
1514 }
1515 let s = assertion_to_string(&expr);
1516 assert_eq!(s, ".x[.idx] == 0");
1517 }
1518
1519 #[test]
1520 fn test_type_method_syntax() {
1521 let expr = parse_assertion(r#"@url.scheme(.webhook) == "https""#);
1522 if let AssertionExpr::Binary { left, .. } = &expr {
1523 if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &**left {
1524 assert_eq!(name, "url.scheme");
1525 assert_eq!(args.len(), 1);
1526 } else {
1527 panic!("Expected PluginCall, got: {:?}", left);
1528 }
1529 } else {
1530 panic!("Expected Binary, got: {:?}", expr);
1531 }
1532 let s = assertion_to_string(&expr);
1533 assert_eq!(s, r#"@url.scheme(.webhook) == "https""#);
1534 }
1535
1536 #[test]
1537 fn test_type_method_roundtrip() {
1538 let cases = [
1539 "@email.domain(.x) == \"example.com\"",
1540 "@json.key(.config, \"timeout\") == 30",
1541 "@regexp.test(.pattern, \"input\")",
1542 ];
1543 for original in &cases {
1544 let expr = parse_assertion(original);
1545 let s = assertion_to_string(&expr);
1546 assert_eq!(s.as_str(), *original, "Roundtrip failed for: {}", original);
1547 }
1548 }
1549
1550 #[test]
1551 fn test_parse_type_cast_with_plugin() {
1552 let expr = parse_assertion("@len(.items):number >= 0");
1553 if let AssertionExpr::Binary { left, .. } = &expr {
1554 if let AssertionExpr::Atom(Expr::As(inner, tn)) = &**left {
1555 assert_eq!(tn, "number");
1556 assert!(matches!(&**inner, Expr::PluginCall { name, .. } if name == "len"));
1557 } else {
1558 panic!("Expected As(PluginCall), got: {:?}", left);
1559 }
1560 } else {
1561 panic!("Expected Binary, got: {:?}", expr);
1562 }
1563 }
1564}