1use std::collections::HashMap;
4use std::fmt::Write as _;
5
6use crate::ast::ComparisonOperator;
7use crate::ir::render::{escape_string, render_literal, render_path, render_seconds};
8use crate::ir::{Block, BlockKind, InstrId, Op, Operand, Program};
9
10const MNEMONIC_WIDTH: usize = 12;
16
17fn prefix_width(name_width: usize) -> usize {
24 2 + name_width + 3
25}
26
27impl Program {
28 pub fn to_listing(&self) -> String {
34 let mut names: HashMap<InstrId, String> = HashMap::new();
35 let mut t = 0usize;
36 for b in &self.blocks {
37 for i in &b.instructions {
38 if produces_value(&i.op) {
39 names.insert(i.id, format!("t{t}"));
40 t += 1;
41 }
42 }
43 }
44 let mut o = 0usize;
45 for i in &self.main.instructions {
46 if produces_value(&i.op) {
47 names.insert(i.id, format!("o{o}"));
48 o += 1;
49 }
50 }
51
52 let name_width = names.values().map(String::len).max().unwrap_or(2);
53
54 let mut out = String::new();
55 for b in &self.blocks {
56 write_block(&mut out, b, &names, name_width);
57 out.push('\n');
58 }
59 write_block(&mut out, &self.main, &names, name_width);
60 out
61 }
62}
63
64fn produces_value(op: &Op) -> bool {
66 !matches!(op, Op::Yield { .. } | Op::Ret { .. })
67}
68
69fn write_block(out: &mut String, b: &Block, names: &HashMap<InstrId, String>, name_width: usize) {
70 match b.kind {
71 BlockKind::Comparison => {
72 let _ = writeln!(out, "block b{} (comparison):", b.id.0);
73 }
74 BlockKind::Main => {
75 let _ = writeln!(out, "block main (observation):");
76 }
77 }
78 for i in &b.instructions {
79 let dest = match names.get(&i.id) {
80 Some(n) => format!(" {n:<name_width$} = "),
81 None => " ".repeat(prefix_width(name_width)),
82 };
83 let (mnemonic, operands) = describe(&i.op, names);
84 let width = MNEMONIC_WIDTH.max(mnemonic.len() + 1);
85 let padded = format!("{mnemonic:<width$}");
86 if operands.is_empty() {
87 let _ = writeln!(out, "{dest}{}", padded.trim_end());
88 } else {
89 let _ = writeln!(out, "{dest}{padded}{operands}");
90 }
91 }
92}
93
94fn name_of(id: InstrId, names: &HashMap<InstrId, String>) -> String {
95 names
96 .get(&id)
97 .cloned()
98 .unwrap_or_else(|| format!("%{}", id.0))
99}
100
101fn describe(op: &Op, names: &HashMap<InstrId, String>) -> (String, String) {
103 match op {
104 Op::Load { path } => ("load".to_string(), escape_controls(&render_path(path))),
105 Op::Compare {
106 operator,
107 negated,
108 lhs,
109 rhs,
110 } => {
111 let mut m = String::new();
112 if *negated {
113 m.push_str("not ");
114 }
115 m.push_str(mnemonic_for(*operator));
116 let operands = match rhs {
117 Operand::Absent => name_of(*lhs, names),
118 Operand::Literal(lit) => {
119 format!(
120 "{}, {}",
121 name_of(*lhs, names),
122 escape_controls(&render_literal(lit))
123 )
124 }
125 Operand::Set(items) => {
126 let inner: Vec<String> = items
127 .iter()
128 .map(|l| escape_controls(&render_literal(l)))
129 .collect();
130 format!("{}, ({})", name_of(*lhs, names), inner.join(", "))
131 }
132 };
133 (m, operands)
134 }
135 Op::And { lhs, rhs } => (
136 "and".to_string(),
137 format!("{}, {}", name_of(*lhs, names), name_of(*rhs, names)),
138 ),
139 Op::Or { lhs, rhs } => (
140 "or".to_string(),
141 format!("{}, {}", name_of(*lhs, names), name_of(*rhs, names)),
142 ),
143 Op::Yield { value } => ("yield".to_string(), name_of(*value, names)),
144 Op::Observe { block } => ("observe".to_string(), format!("b{}", block.0)),
145 Op::FollowedBy { lhs, rhs } => (
146 "followedby".to_string(),
147 format!("{}, {}", name_of(*lhs, names), name_of(*rhs, names)),
148 ),
149 Op::Within { input, seconds } => (
150 "within".to_string(),
151 format!("{}, {}", name_of(*input, names), render_seconds(*seconds)),
152 ),
153 Op::Repeats { input, count } => (
154 "repeats".to_string(),
155 format!("{}, {}", name_of(*input, names), count),
156 ),
157 Op::StartStop { input, start, stop } => (
158 "startstop".to_string(),
159 format!(
160 "{}, '{}', '{}'",
161 name_of(*input, names),
162 escape_controls(&escape_string(start)),
163 escape_controls(&escape_string(stop))
164 ),
165 ),
166 Op::Ret { value } => ("ret".to_string(), name_of(*value, names)),
167 }
168}
169
170fn escape_controls(s: &str) -> String {
178 let mut out = String::with_capacity(s.len());
179 for c in s.chars() {
180 match c {
181 '\n' => out.push_str("\\n"),
182 '\r' => out.push_str("\\r"),
183 '\t' => out.push_str("\\t"),
184 c if c.is_control() => {
185 let _ = write!(out, "\\u{{{:x}}}", c as u32);
186 }
187 c => out.push(c),
188 }
189 }
190 out
191}
192
193fn mnemonic_for(op: ComparisonOperator) -> &'static str {
194 match op {
195 ComparisonOperator::Equal => "eq",
196 ComparisonOperator::NotEqual => "ne",
197 ComparisonOperator::GreaterThan => "gt",
198 ComparisonOperator::GreaterThanOrEqual => "ge",
199 ComparisonOperator::LessThan => "lt",
200 ComparisonOperator::LessThanOrEqual => "le",
201 ComparisonOperator::In => "in",
202 ComparisonOperator::Like => "like",
203 ComparisonOperator::Matches => "matches",
204 ComparisonOperator::IsSubset => "issubset",
205 ComparisonOperator::IsSuperset => "issuperset",
206 ComparisonOperator::Exists => "exists",
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use crate::ir::lower;
213 use crate::parse;
214
215 #[test]
216 fn prints_a_simple_comparison() {
217 let prog = lower(&parse("[file:size > 1024]").unwrap());
218 let expected = "\
219block b1 (comparison):
220 t0 = load file:size
221 t1 = gt t0, 1024
222 yield t1
223
224block main (observation):
225 o0 = observe b1
226 ret o0
227";
228 assert_eq!(prog.to_listing(), expected);
229 }
230
231 #[test]
236 fn prints_followedby_with_a_qualifier() {
237 let src = "[ipv4-addr:value = '1.2.3.4' AND file:size > 1024] \
238 FOLLOWEDBY [domain-name:value = 'evil.example'] WITHIN 300 SECONDS";
239 let prog = lower(&parse(src).unwrap());
240 let expected = "\
241block b1 (comparison):
242 t0 = load ipv4-addr:value
243 t1 = eq t0, '1.2.3.4'
244 t2 = load file:size
245 t3 = gt t2, 1024
246 t4 = and t1, t3
247 yield t4
248
249block b2 (comparison):
250 t5 = load domain-name:value
251 t6 = eq t5, 'evil.example'
252 yield t6
253
254block main (observation):
255 o0 = observe b1
256 o1 = observe b2
257 o2 = within o1, 300
258 o3 = followedby o0, o2
259 ret o3
260";
261 assert_eq!(prog.to_listing(), expected);
262 }
263
264 #[test]
265 fn prints_exists_and_in() {
266 let prog = lower(&parse("[EXISTS file:name]").unwrap());
267 assert!(
268 prog.to_listing().contains("t1 = exists t0"),
269 "{}",
270 prog.to_listing()
271 );
272
273 let prog = lower(&parse("[ipv4-addr:value IN ('1.1.1.1', '8.8.8.8')]").unwrap());
274 assert!(
275 prog.to_listing()
276 .contains("t1 = in t0, ('1.1.1.1', '8.8.8.8')"),
277 "{}",
278 prog.to_listing()
279 );
280 }
281
282 #[test]
283 fn marks_negation() {
284 let prog = lower(&parse("[file:name NOT = 'x']").unwrap());
285 assert!(
286 prog.to_listing().contains("t1 = not eq t0, 'x'"),
287 "{}",
288 prog.to_listing()
289 );
290 }
291
292 #[test]
293 fn keeps_a_space_after_an_overlong_mnemonic() {
294 let prog = lower(&parse("[ipv4-addr:value NOT ISSUPERSET '10.0.0.0/8']").unwrap());
297 let listing = prog.to_listing();
298 assert!(
299 listing.contains("t1 = not issuperset t0, '10.0.0.0/8'"),
300 "{listing}"
301 );
302
303 let prog = lower(&parse("[ipv4-addr:value NOT ISSUBSET '10.0.0.0/8']").unwrap());
304 let listing = prog.to_listing();
305 assert!(
306 listing.contains("t1 = not issubset t0, '10.0.0.0/8'"),
307 "{listing}"
308 );
309 }
310
311 #[test]
312 fn columns_stay_aligned_past_the_tenth_ordinal() {
313 let terms: Vec<String> = (0..6).map(|i| format!("file:size > {i}")).collect();
316 let src = format!("[{}]", terms.join(" AND "));
317 let prog = lower(&parse(&src).unwrap());
318 let listing = prog.to_listing();
319 assert!(listing.contains("t10 = "), "should reach t10:\n{listing}");
320
321 let instruction_lines: Vec<&str> =
325 listing.lines().filter(|l| l.starts_with(" ")).collect();
326 let mnemonic_columns: Vec<usize> = instruction_lines
327 .iter()
328 .map(|l| match l.find(" = ") {
329 Some(i) => i + 3,
331 None => l.len() - l.trim_start().len(),
333 })
334 .collect();
335 assert!(
336 mnemonic_columns.windows(2).all(|w| w[0] == w[1]),
337 "mnemonics should share one column, got {mnemonic_columns:?}:\n{listing}"
338 );
339
340 let equals_columns: Vec<usize> = instruction_lines
342 .iter()
343 .filter_map(|l| l.find(" = "))
344 .collect();
345 assert!(
346 equals_columns.windows(2).all(|w| w[0] == w[1]),
347 "`=` should share one column, got {equals_columns:?}:\n{listing}"
348 );
349 }
350
351 #[test]
352 fn escapes_quotes_in_a_start_stop_listing() {
353 let prog = lower(&parse(r"[file:name='a'] START t'2020\'x' STOP t'2021'").unwrap());
354 let listing = prog.to_listing();
355 assert!(
356 listing.contains(r"startstop o0, '2020\'x', '2021'"),
357 "{listing}"
358 );
359 }
360
361 #[test]
362 fn a_newline_in_a_literal_stays_on_one_listing_line() {
363 let src = "[file:name = 'a\n b']";
364 let ast = parse(src).unwrap();
365 let prog = lower(&ast);
366 let listing = prog.to_listing();
367 assert!(
368 listing.contains(r"t1 = eq t0, 'a\n b'"),
369 "{listing}"
370 );
371 for l in listing.lines().filter(|l| !l.is_empty()) {
373 assert!(l.starts_with("block ") || l.starts_with(" "), "{listing}");
374 }
375 assert_eq!(listing.lines().count(), 8, "{listing}");
376 let text = crate::ir::render(&prog);
378 assert!(text.contains("'a\n b'"), "{text:?}");
379 assert_eq!(parse(&text).unwrap(), ast);
380 }
381
382 #[test]
383 fn tabs_and_other_controls_are_escaped_in_the_listing() {
384 use crate::ast::Literal;
385 use crate::ir::{Op, Operand};
386 let mut prog = lower(&parse("[file:name IN ('x')]").unwrap());
389 for i in &mut prog.blocks[0].instructions {
390 if let Op::Compare { rhs, .. } = &mut i.op {
391 *rhs = Operand::Set(
392 ["a\tb", "c\u{85}d", "e\u{7f}f", "g\rh", "i\u{1}j"]
393 .map(|s| Literal::String(s.to_string()))
394 .to_vec(),
395 );
396 }
397 }
398 let listing = prog.to_listing();
399 assert!(
400 listing.contains(r"('a\tb', 'c\u{85}d', 'e\u{7f}f', 'g\rh', 'i\u{1}j')"),
401 "{listing}"
402 );
403 }
404
405 #[test]
406 fn controls_are_escaped_in_start_stop_and_path_keys() {
407 let prog = lower(&parse("[file:name='a'] START t'2020\n01' STOP t'2021\t'").unwrap());
408 let listing = prog.to_listing();
409 assert!(listing.contains(r"'2020\n01', '2021\t'"), "{listing}");
410 let prog = lower(&parse("[file:'a\nb' = 1]").unwrap());
411 let listing = prog.to_listing();
412 assert!(listing.contains(r"load file:'a\nb'"), "{listing}");
413 assert_eq!(listing.lines().count(), 8, "{listing}");
414 }
415}