1use std::collections::HashSet;
4
5use crate::ast::{ComparisonOperator, Literal, ObjectPath, PathStep};
6use crate::ir::index::{BlockIndex, ProgramIndex};
7use crate::ir::{InstrId, Instruction, Op, Operand, Program};
8
9pub fn render(program: &Program) -> String {
44 let mut out = String::new();
45 if let Some(Instruction {
48 op: Op::Ret { value },
49 ..
50 }) = program.main.terminator()
51 {
52 let index = ProgramIndex::new(program);
53 Renderer::new(&index).run(*value, &mut out);
54 }
55 out
56}
57
58fn obs_prec(op: &Op) -> u8 {
60 match op {
61 Op::FollowedBy { .. } => 1,
62 Op::Or { .. } => 2,
63 Op::And { .. } => 3,
64 Op::Within { .. } | Op::Repeats { .. } | Op::StartStop { .. } => 4,
65 _ => 5,
66 }
67}
68
69fn cmp_prec(op: &Op) -> u8 {
71 match op {
72 Op::Or { .. } => 1,
73 Op::And { .. } => 2,
74 _ => 3,
75 }
76}
77
78#[derive(Clone, Copy, PartialEq, Eq, Hash)]
80enum Tier {
81 Observation,
82 Comparison,
83}
84
85enum Work<'p> {
92 Observation { value: InstrId, min_prec: u8 },
95 Comparison {
98 block: &'p BlockIndex<'p>,
99 value: InstrId,
100 min_prec: u8,
101 },
102 Text(&'static str),
104 Owned(String),
106 Leave(Tier, InstrId),
108}
109
110struct Renderer<'p> {
112 index: &'p ProgramIndex<'p>,
113 stack: Vec<Work<'p>>,
114 on_path: HashSet<(Tier, InstrId)>,
123}
124
125impl<'p> Renderer<'p> {
126 fn new(index: &'p ProgramIndex<'p>) -> Self {
127 Renderer {
128 index,
129 stack: Vec::new(),
130 on_path: HashSet::new(),
131 }
132 }
133
134 fn run(mut self, root: InstrId, out: &mut String) {
136 self.stack.push(Work::Observation {
137 value: root,
138 min_prec: 1,
139 });
140 while let Some(work) = self.stack.pop() {
141 match work {
142 Work::Observation { value, min_prec } => self.observation(value, min_prec, out),
143 Work::Comparison {
144 block,
145 value,
146 min_prec,
147 } => self.comparison(block, value, min_prec, out),
148 Work::Text(text) => out.push_str(text),
149 Work::Owned(text) => out.push_str(&text),
150 Work::Leave(tier, value) => {
151 self.on_path.remove(&(tier, value));
152 }
153 }
154 }
155 }
156
157 fn enter(
161 &mut self,
162 tier: Tier,
163 value: InstrId,
164 prec: u8,
165 min_prec: u8,
166 out: &mut String,
167 ) -> bool {
168 if !self.on_path.insert((tier, value)) {
169 return false;
170 }
171 self.stack.push(Work::Leave(tier, value));
172 if prec < min_prec {
173 out.push('(');
174 self.stack.push(Work::Text(")"));
175 }
176 true
177 }
178
179 fn binary(&mut self, lhs: Work<'p>, sep: &'static str, rhs: Work<'p>) {
181 self.stack.push(rhs);
182 self.stack.push(Work::Text(sep));
183 self.stack.push(lhs);
184 }
185
186 fn observation(&mut self, value: InstrId, min_prec: u8, out: &mut String) {
202 let index = self.index;
203 let Some(instr) = index.main().instruction(value) else {
204 return;
205 };
206 if !self.enter(Tier::Observation, value, obs_prec(&instr.op), min_prec, out) {
207 return;
208 }
209 let obs = |value: InstrId, min_prec: u8| Work::Observation { value, min_prec };
210 match &instr.op {
211 Op::Observe { block: target } => {
212 out.push('[');
213 self.stack.push(Work::Text("]"));
214 if let Some(cb) = index.block(*target) {
215 if let Some(Op::Yield { value }) = cb.block().terminator().map(|t| &t.op) {
216 self.stack.push(Work::Comparison {
217 block: cb,
218 value: *value,
219 min_prec: 1,
220 });
221 }
222 }
223 }
224 Op::FollowedBy { lhs, rhs } => self.binary(obs(*lhs, 1), " FOLLOWEDBY ", obs(*rhs, 2)),
225 Op::Or { lhs, rhs } => self.binary(obs(*lhs, 2), " OR ", obs(*rhs, 3)),
226 Op::And { lhs, rhs } => self.binary(obs(*lhs, 3), " AND ", obs(*rhs, 4)),
227 Op::Within { input, seconds } => {
228 self.stack.push(Work::Owned(format!(
229 " WITHIN {} SECONDS",
230 render_seconds(*seconds)
231 )));
232 self.stack.push(obs(*input, 4));
233 }
234 Op::Repeats { input, count } => {
235 self.stack
236 .push(Work::Owned(format!(" REPEATS {count} TIMES")));
237 self.stack.push(obs(*input, 4));
238 }
239 Op::StartStop { input, start, stop } => {
240 self.stack.push(Work::Owned(format!(
241 " START t'{}' STOP t'{}'",
242 escape_string(start),
243 escape_string(stop)
244 )));
245 self.stack.push(obs(*input, 4));
246 }
247 _ => {}
249 }
250 }
251
252 fn comparison(
264 &mut self,
265 block: &'p BlockIndex<'p>,
266 value: InstrId,
267 min_prec: u8,
268 out: &mut String,
269 ) {
270 let Some(instr) = block.instruction(value) else {
271 return;
272 };
273 if !self.enter(Tier::Comparison, value, cmp_prec(&instr.op), min_prec, out) {
274 return;
275 }
276 let cmp = |value: InstrId, min_prec: u8| Work::Comparison {
277 block,
278 value,
279 min_prec,
280 };
281 match &instr.op {
282 Op::Or { lhs, rhs } => self.binary(cmp(*lhs, 1), " OR ", cmp(*rhs, 2)),
283 Op::And { lhs, rhs } => self.binary(cmp(*lhs, 2), " AND ", cmp(*rhs, 3)),
284 Op::Compare {
285 operator,
286 negated,
287 lhs,
288 rhs,
289 } => render_compare(block, *operator, *negated, *lhs, rhs, out),
290 _ => {}
292 }
293 }
294}
295
296fn render_compare(
298 block: &BlockIndex<'_>,
299 operator: ComparisonOperator,
300 negated: bool,
301 lhs: InstrId,
302 rhs: &Operand,
303 out: &mut String,
304) {
305 let path = match block.instruction(lhs).map(|i| &i.op) {
306 Some(Op::Load { path }) => Some(path),
307 _ => None,
308 };
309 if operator == ComparisonOperator::Exists {
310 out.push_str("EXISTS ");
311 if let Some(p) = path {
312 out.push_str(&render_path(p));
313 }
314 return;
315 }
316 if let Some(p) = path {
317 out.push_str(&render_path(p));
318 }
319 out.push(' ');
320 if negated {
321 out.push_str("NOT ");
322 }
323 out.push_str(operator_text(operator));
324 out.push(' ');
325 match rhs {
326 Operand::Literal(lit) => out.push_str(&render_literal(lit)),
327 Operand::Set(items) => {
328 let inner: Vec<String> = items.iter().map(render_literal).collect();
329 out.push('(');
330 out.push_str(&inner.join(", "));
331 out.push(')');
332 }
333 Operand::Absent => {}
334 }
335}
336
337fn operator_text(op: ComparisonOperator) -> &'static str {
338 match op {
339 ComparisonOperator::Equal => "=",
340 ComparisonOperator::NotEqual => "!=",
341 ComparisonOperator::GreaterThan => ">",
342 ComparisonOperator::GreaterThanOrEqual => ">=",
343 ComparisonOperator::LessThan => "<",
344 ComparisonOperator::LessThanOrEqual => "<=",
345 ComparisonOperator::In => "IN",
346 ComparisonOperator::Like => "LIKE",
347 ComparisonOperator::Matches => "MATCHES",
348 ComparisonOperator::IsSubset => "ISSUBSET",
349 ComparisonOperator::IsSuperset => "ISSUPERSET",
350 ComparisonOperator::Exists => "EXISTS",
352 }
353}
354
355pub(crate) fn render_path(path: &ObjectPath) -> String {
357 let mut out = String::new();
358 out.push_str(&path.object_type);
359 out.push(':');
360 let mut first = true;
361 for step in &path.steps {
362 match step {
363 PathStep::Key(k) => {
364 if !first {
365 out.push('.');
366 }
367 out.push_str(&render_key(k));
368 }
369 PathStep::Index(n) => {
370 out.push_str(&format!("[{n}]"));
371 }
372 PathStep::AnyIndex => out.push_str("[*]"),
373 }
374 first = false;
375 }
376 out
377}
378
379fn render_key(key: &str) -> String {
381 let plain = !key.is_empty()
382 && key
383 .chars()
384 .next()
385 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
386 && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
387 && !is_keyword(key);
388 if plain {
389 key.to_string()
390 } else {
391 format!("'{}'", escape_string(key))
392 }
393}
394
395fn is_keyword(word: &str) -> bool {
399 matches!(
400 word.to_ascii_uppercase().as_str(),
401 "AND"
402 | "OR"
403 | "NOT"
404 | "FOLLOWEDBY"
405 | "LIKE"
406 | "MATCHES"
407 | "IN"
408 | "ISSUBSET"
409 | "ISSUPERSET"
410 | "EXISTS"
411 | "WITHIN"
412 | "REPEATS"
413 | "SECONDS"
414 | "TIMES"
415 | "START"
416 | "STOP"
417 | "TRUE"
418 | "FALSE"
419 )
420}
421
422pub(crate) fn escape_string(s: &str) -> String {
424 s.replace('\\', r"\\").replace('\'', r"\'")
425}
426
427pub(crate) fn render_float(v: f64) -> String {
438 for p in 1..=1100usize {
439 let s = format!("{v:.p$}");
440 if s.parse::<f64>() == Ok(v) {
441 return s;
442 }
443 }
444 format!("{v:.1}")
446}
447
448pub(crate) fn render_literal(lit: &Literal) -> String {
450 match lit {
451 Literal::String(s) => format!("'{}'", escape_string(s)),
452 Literal::Integer(n) => n.to_string(),
453 Literal::Float(f) => render_float(*f),
454 Literal::Boolean(b) => b.to_string(),
455 Literal::Timestamp(s) => format!("t'{}'", escape_string(s)),
456 Literal::Binary(s) => format!("b'{}'", escape_string(s)),
457 Literal::Hex(s) => format!("h'{}'", escape_string(s)),
458 }
459}
460
461pub(crate) fn render_seconds(seconds: f64) -> String {
468 if seconds.fract() == 0.0 && seconds.is_finite() {
469 let as_int = seconds as i64;
470 if as_int as f64 == seconds {
471 return format!("{as_int}");
472 }
473 }
474 render_float(seconds)
475}
476
477#[cfg(test)]
478mod tests {
479 use crate::ir::{lower, render};
480 use crate::parse;
481
482 fn round(src: &str) -> String {
484 render(&lower(&parse(src).unwrap()))
485 }
486
487 fn assert_round_trips(src: &str) {
489 let original = parse(src).unwrap();
490 let text = render(&lower(&original));
491 let reparsed =
492 parse(&text).unwrap_or_else(|e| panic!("rendered text failed to parse: {text}\n{e}"));
493 assert_eq!(
494 reparsed.without_spans(),
495 original.without_spans(),
496 "round trip changed the AST\n src: {src}\n rendered: {text}"
497 );
498 }
499
500 #[test]
501 fn renders_canonical_whitespace_and_operators() {
502 assert_eq!(round("[file:name='a']"), "[file:name = 'a']");
503 assert_eq!(round("[file:size != 1]"), "[file:size != 1]");
504 }
505
506 #[test]
507 fn renders_exists_and_in() {
508 assert_eq!(round("[EXISTS file:name]"), "[EXISTS file:name]");
509 assert_eq!(
510 round("[ipv4-addr:value IN ('1.1.1.1', '8.8.8.8')]"),
511 "[ipv4-addr:value IN ('1.1.1.1', '8.8.8.8')]"
512 );
513 }
514
515 #[test]
516 fn renders_negation_in_operator_position() {
517 assert_eq!(round("[file:name NOT = 'x']"), "[file:name NOT = 'x']");
518 }
519
520 #[test]
521 fn omits_parens_where_precedence_makes_them_redundant() {
522 assert_eq!(
523 round("[(file:name = 'a') AND file:size = 1]"),
524 "[file:name = 'a' AND file:size = 1]"
525 );
526 }
527
528 #[test]
529 fn adds_parens_where_precedence_demands_them() {
530 assert_eq!(
531 round("[(file:name = 'a' OR file:name = 'b') AND file:size = 1]"),
532 "[(file:name = 'a' OR file:name = 'b') AND file:size = 1]"
533 );
534 }
535
536 #[test]
537 fn parenthesizes_a_right_nested_same_operator() {
538 assert_round_trips("[file:name = 'a' OR (file:name = 'b' OR file:name = 'c')]");
540 assert_eq!(
541 round("[file:name = 'a' OR (file:name = 'b' OR file:name = 'c')]"),
542 "[file:name = 'a' OR (file:name = 'b' OR file:name = 'c')]"
543 );
544 }
545
546 #[test]
547 fn parenthesizes_a_qualified_boolean_observation() {
548 assert_round_trips("([file:name='a'] AND [file:name='b']) WITHIN 60 SECONDS");
550 assert_eq!(
551 round("([file:name='a'] AND [file:name='b']) WITHIN 60 SECONDS"),
552 "([file:name = 'a'] AND [file:name = 'b']) WITHIN 60 SECONDS"
553 );
554 }
555
556 #[test]
557 fn renders_nested_qualifiers_without_parens() {
558 assert_eq!(
559 round("[file:name='a'] REPEATS 2 TIMES WITHIN 60 SECONDS"),
560 "[file:name = 'a'] REPEATS 2 TIMES WITHIN 60 SECONDS"
561 );
562 }
563
564 #[test]
565 fn preserves_float_literals_as_floats() {
566 assert_eq!(round("[file:size > 60.0]"), "[file:size > 60.0]");
567 assert_round_trips("[file:size > 60.0]");
568 }
569
570 #[test]
571 fn renders_extreme_floats_in_decimal_notation() {
572 assert_round_trips("[file:size > 0.0000000001]");
576 assert_round_trips("[file:size > 123456789012345680.0]");
577 }
578
579 #[test]
580 fn float_rendering_stays_short_for_ordinary_values() {
581 use super::render_float;
582 assert_eq!(render_float(60.0), "60.0");
583 assert_eq!(render_float(1.5), "1.5");
584 assert_eq!(render_float(0.001), "0.001");
585 }
586
587 #[test]
588 fn float_rendering_round_trips_every_extreme() {
589 use super::render_float;
590 for v in [
591 0.0_f64,
592 -1.0,
593 1e-10,
594 1e16,
595 1e-300,
596 f64::MAX,
597 f64::MIN_POSITIVE,
598 f64::EPSILON,
599 5e-324,
600 ] {
601 let s = render_float(v);
602 assert!(!s.contains('e'), "{v:?} rendered as {s}");
603 assert!(
604 s.contains('.'),
605 "{v:?} rendered as {s}, needs a decimal point"
606 );
607 assert_eq!(s.parse::<f64>().unwrap(), v, "{v:?} rendered as {s}");
608 }
609 }
610
611 #[test]
612 fn renders_typed_literals() {
613 assert_eq!(
614 round("[file:created = t'2020-01-01T00:00:00Z']"),
615 "[file:created = t'2020-01-01T00:00:00Z']"
616 );
617 assert_eq!(
618 round("[artifact:payload_bin = b'aGVsbG8=']"),
619 "[artifact:payload_bin = b'aGVsbG8=']"
620 );
621 assert_eq!(
622 round("[file:magic_number_hex = h'cafebabe']"),
623 "[file:magic_number_hex = h'cafebabe']"
624 );
625 assert_eq!(
626 round("[file:is_encrypted = true]"),
627 "[file:is_encrypted = true]"
628 );
629 }
630
631 #[test]
632 fn quotes_path_keys_that_need_it() {
633 assert_eq!(
634 round("[file:hashes.'SHA-256' = 'abc']"),
635 "[file:hashes.'SHA-256' = 'abc']"
636 );
637 assert_eq!(
638 round("[file:hashes.MD5 = 'abc']"),
639 "[file:hashes.MD5 = 'abc']"
640 );
641 }
642
643 #[test]
644 fn renders_index_steps() {
645 assert_eq!(
646 round("[network-traffic:protocols[0] = 'tcp']"),
647 "[network-traffic:protocols[0] = 'tcp']"
648 );
649 assert_eq!(
650 round("[x-custom:list[*] = 'y']"),
651 "[x-custom:list[*] = 'y']"
652 );
653 }
654
655 #[test]
656 fn escapes_quotes_and_backslashes_in_strings() {
657 let src = r"[file:name = 'it\'s a \\ backslash']";
658 assert_round_trips(src);
659 }
660
661 #[test]
662 fn renders_start_stop() {
663 assert_eq!(
664 round("[file:name='a'] START t'2020-01-01T00:00:00Z' STOP t'2020-01-02T00:00:00Z'"),
665 "[file:name = 'a'] START t'2020-01-01T00:00:00Z' STOP t'2020-01-02T00:00:00Z'"
666 );
667 }
668
669 #[test]
670 fn escapes_quotes_in_start_stop_timestamps() {
671 assert_round_trips(
674 r"[file:name='a'] START t'2020-01-01T00:00:00Z\'x' STOP t'2020-01-02T00:00:00Z'",
675 );
676 }
677
678 #[test]
679 fn non_ascii_strings_round_trip() {
680 assert_round_trips("[file:name = 'café']");
681 assert_round_trips("[file:name = '日本語 😀 it\\'s é']");
682 assert_eq!(round("[file:name='café']"), "[file:name = 'café']");
683 }
684
685 #[test]
686 fn diamond_not_equal_renders_as_bang_equal() {
687 assert_eq!(round("[file:size<>1]"), "[file:size != 1]");
688 assert_round_trips("[file:size <> 1]");
689 }
690
691 fn on_small_stack(f: impl FnOnce() + Send + 'static) {
694 std::thread::Builder::new()
695 .stack_size(256 * 1024)
696 .spawn(f)
697 .expect("spawn test thread")
698 .join()
699 .expect("test thread panicked");
700 }
701
702 const DEEP: u32 = 10_000;
704
705 fn qualifier_chain(levels: u32) -> crate::ir::Program {
707 use crate::ir::{InstrId, Instruction, Op};
708
709 let mut p = lower(&parse("[file:size > 1024]").unwrap());
710 let observe = p.main.instructions[0].clone();
711 let mut acc = observe.id;
712 let mut instrs = vec![observe];
713 let mut next = 1000u32;
714 for _ in 0..levels {
715 let id = InstrId(next);
716 next += 1;
717 instrs.push(Instruction {
718 id,
719 op: Op::Within {
720 input: acc,
721 seconds: 1.0,
722 },
723 span: None,
724 });
725 acc = id;
726 }
727 instrs.push(Instruction {
728 id: InstrId(next),
729 op: Op::Ret { value: acc },
730 span: None,
731 });
732 p.main.instructions = instrs;
733 p
734 }
735
736 fn comparison_and_chain(terms: u32, right_nested: bool) -> crate::ir::Program {
739 use crate::ast::{ComparisonOperator, Literal};
740 use crate::ir::{InstrId, Instruction, Op, Operand};
741
742 let mut p = lower(&parse("[file:size > 1024]").unwrap());
743 let path = match &p.blocks[0].instructions[0].op {
744 Op::Load { path } => path.clone(),
745 other => panic!("expected a load, got {other:?}"),
746 };
747 let mut next = 1000u32;
748 let mut fresh = || {
749 next += 1;
750 InstrId(next)
751 };
752 let mut instrs = Vec::new();
753 let compare = |instrs: &mut Vec<Instruction>, fresh: &mut dyn FnMut() -> InstrId| {
754 let load = fresh();
755 instrs.push(Instruction {
756 id: load,
757 op: Op::Load { path: path.clone() },
758 span: None,
759 });
760 let cmp = fresh();
761 instrs.push(Instruction {
762 id: cmp,
763 op: Op::Compare {
764 operator: ComparisonOperator::GreaterThan,
765 negated: false,
766 lhs: load,
767 rhs: Operand::Literal(Literal::Integer(1024)),
768 },
769 span: None,
770 });
771 cmp
772 };
773 let mut acc = compare(&mut instrs, &mut fresh);
774 for _ in 1..terms {
775 let term = compare(&mut instrs, &mut fresh);
776 let id = fresh();
777 let (lhs, rhs) = if right_nested {
778 (term, acc)
779 } else {
780 (acc, term)
781 };
782 instrs.push(Instruction {
783 id,
784 op: Op::And { lhs, rhs },
785 span: None,
786 });
787 acc = id;
788 }
789 instrs.push(Instruction {
790 id: fresh(),
791 op: Op::Yield { value: acc },
792 span: None,
793 });
794 p.blocks[0].instructions = instrs;
795 p
796 }
797
798 #[test]
799 fn a_deep_qualifier_chain_validates_and_renders_on_a_small_stack() {
800 on_small_stack(|| {
801 let p = qualifier_chain(DEEP);
802 assert_eq!(p.validate(), Ok(()));
803 let expected = format!(
804 "[file:size > 1024]{}",
805 " WITHIN 1 SECONDS".repeat(DEEP as usize)
806 );
807 assert_eq!(render(&p), expected);
808 });
809 }
810
811 #[test]
812 fn a_deep_left_nested_comparison_chain_renders_without_parens() {
813 on_small_stack(|| {
814 let p = comparison_and_chain(DEEP, false);
815 assert_eq!(p.validate(), Ok(()));
816 let expected = format!(
817 "[{}]",
818 vec!["file:size > 1024"; DEEP as usize].join(" AND ")
819 );
820 assert_eq!(render(&p), expected);
821 });
822 }
823
824 #[test]
825 fn a_deep_right_nested_comparison_chain_parenthesizes_every_level() {
826 on_small_stack(|| {
827 let p = comparison_and_chain(DEEP, true);
828 assert_eq!(p.validate(), Ok(()));
829 let x = "file:size > 1024";
830 let wraps = DEEP as usize - 2;
831 let expected = format!(
832 "[{}{x} AND {x}{}]",
833 format!("{x} AND (").repeat(wraps),
834 ")".repeat(wraps)
835 );
836 assert_eq!(render(&p), expected);
837 });
838 }
839
840 #[test]
841 fn a_deep_right_nested_observation_chain_parenthesizes_every_level() {
842 use crate::ir::{BlockKind, Instruction, Op};
843
844 on_small_stack(|| {
845 let template = lower(&parse("[file:size > 1024]").unwrap());
848 let mut p = template.clone();
849 p.blocks.clear();
850 p.main.instructions.clear();
851 let mut next_instr = 100_000u32;
852 let mut acc = None;
853 for term in 0..DEEP {
854 let mut b = template.blocks[0].clone();
855 b.id = crate::ir::BlockId(1000 + term);
856 for instr in &mut b.instructions {
857 let old = instr.id;
858 instr.id = crate::ir::InstrId(next_instr + old.0);
859 match &mut instr.op {
860 Op::Compare { lhs, .. } => lhs.0 += next_instr,
861 Op::Yield { value } => value.0 += next_instr,
862 _ => {}
863 }
864 }
865 next_instr += 10;
866 assert_eq!(b.kind, BlockKind::Comparison);
867 let observe = crate::ir::InstrId(next_instr);
868 next_instr += 10;
869 p.main.instructions.push(Instruction {
870 id: observe,
871 op: Op::Observe { block: b.id },
872 span: None,
873 });
874 p.blocks.push(b);
875 acc = Some(match acc {
876 None => observe,
877 Some(prev) => {
878 let or = crate::ir::InstrId(next_instr);
879 next_instr += 10;
880 p.main.instructions.push(Instruction {
881 id: or,
882 op: Op::Or {
883 lhs: observe,
884 rhs: prev,
885 },
886 span: None,
887 });
888 or
889 }
890 });
891 }
892 p.main.instructions.push(Instruction {
893 id: crate::ir::InstrId(next_instr),
894 op: Op::Ret {
895 value: acc.unwrap(),
896 },
897 span: None,
898 });
899 assert_eq!(p.validate(), Ok(()));
900 let x = "[file:size > 1024]";
901 let wraps = DEEP as usize - 2;
902 let expected = format!(
903 "{}{x} OR {x}{}",
904 format!("{x} OR (").repeat(wraps),
905 ")".repeat(wraps)
906 );
907 assert_eq!(render(&p), expected);
908 });
909 }
910
911 #[test]
912 fn a_cyclic_unvalidated_program_renders_without_hanging() {
913 use crate::ir::Op;
914
915 on_small_stack(|| {
918 let mut p = lower(&parse("[file:size > 1] AND [file:size > 2]").unwrap());
919 let and = p
920 .main
921 .instructions
922 .iter_mut()
923 .find(|i| matches!(i.op, Op::And { .. }))
924 .expect("an and");
925 let id = and.id;
926 and.op = Op::And { lhs: id, rhs: id };
927 assert!(p.validate().is_err());
928 assert_eq!(render(&p), " AND ");
929 });
930 }
931
932 #[test]
933 fn large_within_windows_survive_the_round_trip() {
934 assert_round_trips("[file:name='a'] WITHIN 10000000000000000000.0 SECONDS");
937 assert_eq!(
939 round("[file:name='a'] WITHIN 60 SECONDS"),
940 "[file:name = 'a'] WITHIN 60 SECONDS"
941 );
942 assert_eq!(
944 round("[file:name='a'] WITHIN 0.5 SECONDS"),
945 "[file:name = 'a'] WITHIN 0.5 SECONDS"
946 );
947 }
948}