1use crate::level::Level;
10use anyhow::{Context, Result, anyhow, bail};
11use regex::Regex;
12
13#[derive(Debug, Default)]
18pub struct RuleSet {
19 pub defines: Vec<Define>,
20 pub rules: Vec<Rule>,
21}
22
23#[derive(Debug, Clone)]
24pub struct Define {
25 pub name: String,
26 pub params: Vec<String>,
27 pub ops: Vec<Op>,
28}
29
30#[derive(Debug, Clone)]
31pub struct Rule {
32 pub sub: SubPattern,
33 pub level: LevelPattern,
34 pub ops: Vec<Op>,
35 pub line_no: usize,
36}
37
38#[derive(Debug, Clone)]
39pub enum SubPattern {
40 Star,
41 Alt(Vec<String>),
42}
43
44#[derive(Debug, Clone)]
45pub enum LevelPattern {
46 Star,
47 Specific(Level),
48}
49
50#[derive(Debug, Clone)]
51pub enum Op {
52 Keep(PatternRegex),
53 Drop(PatternRegex),
54 Head(HeadArg),
55 Tail(HeadArg),
56 Or(String),
57 OrShell(String),
58 Shell(String),
59 Python(String),
60 Raw,
61 MacroCall {
62 name: String,
63 args: Vec<MacroArg>,
64 },
65 Split {
66 delimiter: PatternRegex,
67 pre: Vec<Op>,
68 post: Vec<Op>,
69 },
70 Cascade(Vec<Branch>),
72}
73
74#[derive(Debug, Clone)]
76pub struct Branch {
77 pub guard: Option<Guard>,
78 pub ops: Vec<Op>,
79}
80
81#[derive(Debug, Clone)]
83pub struct Guard {
84 pub atoms: Vec<Atom>,
85}
86
87#[derive(Debug, Clone)]
89pub enum Atom {
90 Exit(ExitMatch),
91 Level(Level),
92 Flag(String),
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum ExitMatch {
97 Ok,
98 Failed,
99}
100
101#[derive(Debug, Clone)]
102pub struct PatternRegex {
103 pub source: String,
104 pub compiled: Regex,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum HeadArg {
109 Number(usize),
110 Auto,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum MacroArg {
115 Number(usize),
116 String(String),
117}
118
119impl RuleSet {
124 pub fn select(&self, sub: &str, level: Level) -> Option<&Rule> {
126 self.rules.iter().find(|r| r.matches(sub, level))
127 }
128
129 pub fn find_define(&self, name: &str) -> Option<&Define> {
130 self.defines.iter().find(|d| d.name == name)
131 }
132}
133
134impl Rule {
135 pub fn matches(&self, sub: &str, level: Level) -> bool {
136 let sub_ok = match &self.sub {
137 SubPattern::Star => true,
138 SubPattern::Alt(alts) => alts.iter().any(|a| glob_match(a, sub)),
139 };
140 let lvl_ok = match &self.level {
141 LevelPattern::Star => true,
142 LevelPattern::Specific(l) => *l == level,
143 };
144 sub_ok && lvl_ok
145 }
146}
147
148#[derive(Debug, Clone)]
153struct Line {
154 indent: usize,
155 text: String, raw: String, line_no: usize,
158 is_meta: bool,
161}
162
163fn split_lines(input: &str) -> Vec<Line> {
164 input
165 .split('\n')
166 .enumerate()
167 .map(|(i, raw_line)| {
168 let raw = raw_line.trim_end_matches('\r').to_string();
169 let stripped = raw.trim_start();
170 let indent = raw.len() - stripped.len();
171 let text = stripped.trim_end().to_string();
172 let is_meta = text.is_empty() || text.starts_with('#');
173 Line {
174 indent,
175 text,
176 raw,
177 line_no: i + 1,
178 is_meta,
179 }
180 })
181 .collect()
182}
183
184const OP_KEYWORDS: &[&str] = &[
189 "keep",
190 "drop",
191 "head",
192 "tail",
193 "or",
194 "or-shell:",
195 "else",
196 "else-shell:",
197 "shell:",
198 "python:",
199 "split",
200 "raw",
201 "passthrough",
202 "if",
203 "elif",
204];
205
206pub fn parse(input: &str) -> Result<RuleSet> {
207 let lines = split_lines(input);
208 let macro_names = collect_macro_names(&lines);
209 let mut p = Parser {
210 lines: &lines,
211 pos: 0,
212 macro_names,
213 };
214 p.parse_ruleset()
215}
216
217fn collect_macro_names(lines: &[Line]) -> Vec<String> {
218 let mut names = Vec::new();
219 for l in lines {
220 if l.is_meta {
221 continue;
222 }
223 if let Some(rest) = l.text.strip_prefix("define ") {
224 let end = rest
225 .find(|c: char| c == '(' || c == ':' || c.is_whitespace())
226 .unwrap_or(rest.len());
227 let name = rest[..end].trim().to_string();
228 if !name.is_empty() {
229 names.push(name);
230 }
231 }
232 }
233 names
234}
235
236struct Parser<'a> {
237 lines: &'a [Line],
238 pos: usize,
239 macro_names: Vec<String>,
240}
241
242impl<'a> Parser<'a> {
243 fn peek_significant(&mut self) -> Option<&'a Line> {
246 while let Some(l) = self.lines.get(self.pos) {
247 if l.is_meta {
248 self.pos += 1;
249 } else {
250 return Some(l);
251 }
252 }
253 None
254 }
255
256 fn advance(&mut self) -> Option<&'a Line> {
257 let l = self.lines.get(self.pos);
258 if l.is_some() {
259 self.pos += 1;
260 }
261 l
262 }
263
264 fn is_macro(&self, name: &str) -> bool {
265 self.macro_names.iter().any(|n| n == name)
266 }
267
268 fn parse_ruleset(&mut self) -> Result<RuleSet> {
271 let mut rs = RuleSet::default();
272 while let Some(line) = self.peek_significant() {
273 if line.indent != 0 {
274 bail!("line {}: unexpected indent at top level", line.line_no);
275 }
276 if line.text.starts_with("define ") {
277 let d = self.parse_define()?;
278 rs.defines.push(d);
279 } else {
280 let r = self.parse_rule()?;
281 rs.rules.push(r);
282 }
283 }
284 Ok(rs)
285 }
286
287 fn parse_define(&mut self) -> Result<Define> {
288 let header = self.advance().unwrap();
289 let line_no = header.line_no;
290 let rest = header
291 .text
292 .strip_prefix("define ")
293 .ok_or_else(|| anyhow!("line {}: expected `define`", line_no))?;
294 let (name, params, after_paren) =
295 parse_define_header(rest).with_context(|| format!("line {line_no}"))?;
296 if !after_paren.starts_with(':') {
297 bail!(
298 "line {}: expected `:` after define header, got `{}`",
299 line_no,
300 after_paren
301 );
302 }
303 let trailing = after_paren[1..].trim();
304 if !trailing.is_empty() {
305 bail!(
306 "line {}: one-line `define` body not supported (use indented body)",
307 line_no
308 );
309 }
310 let ops = self.parse_indented_ops(header.indent)?;
311 if ops.is_empty() {
312 bail!("line {}: `define {}` has empty body", line_no, name);
313 }
314 Ok(Define { name, params, ops })
315 }
316
317 fn parse_rule(&mut self) -> Result<Rule> {
318 let header = self.advance().unwrap();
319 let line_no = header.line_no;
320 let parent_indent = header.indent;
321 let colon_pos = header
322 .text
323 .find(':')
324 .ok_or_else(|| anyhow!("line {}: missing `:` in rule header", line_no))?;
325 let selector = &header.text[..colon_pos];
326 let after = &header.text[colon_pos + 1..];
327 let (sub, level) =
328 parse_selector(selector).with_context(|| format!("line {line_no}"))?;
329
330 let mut ops = Vec::new();
331 let inline = after.trim();
332 if !inline.is_empty() {
333 ops.extend(self.parse_inline_ops(inline, line_no)?);
335 ops.extend(self.parse_indented_ops(parent_indent)?);
336 } else {
337 ops = self.parse_body(parent_indent)?;
339 }
340
341 if ops.is_empty() {
342 bail!("line {}: rule has no ops", line_no);
343 }
344 Ok(Rule {
345 sub,
346 level,
347 ops,
348 line_no,
349 })
350 }
351
352 fn parse_indented_ops(&mut self, parent_indent: usize) -> Result<Vec<Op>> {
357 let mut ops = Vec::new();
358 loop {
359 let Some(line) = self.peek_significant() else {
360 break;
361 };
362 if line.indent <= parent_indent {
363 break;
364 }
365 let op = self.parse_op_line()?;
366 ops.push(op);
367 }
368 Ok(ops)
369 }
370
371 fn parse_body(&mut self, parent_indent: usize) -> Result<Vec<Op>> {
374 if let Some(line) = self.peek_significant() {
375 if line.indent > parent_indent {
376 let (head, _) = split_first_word(&line.text);
377 if head == "if" {
378 let branches = self.parse_cascade(parent_indent)?;
379 return Ok(vec![Op::Cascade(branches)]);
380 }
381 }
382 }
383 self.parse_indented_ops(parent_indent)
384 }
385
386 fn parse_cascade(&mut self, parent_indent: usize) -> Result<Vec<Branch>> {
388 let mut branches: Vec<Branch> = Vec::new();
389 let mut arm_indent: Option<usize> = None;
390 loop {
391 let Some(line) = self.peek_significant() else {
392 break;
393 };
394 if line.indent <= parent_indent {
395 break;
396 }
397 match arm_indent {
398 None => arm_indent = Some(line.indent),
399 Some(ai) if line.indent != ai => break,
400 Some(_) => {}
401 }
402 let line_no = line.line_no;
403 let kw: String = line
406 .text
407 .chars()
408 .take_while(|c| c.is_ascii_alphabetic())
409 .collect();
410 match kw.as_str() {
411 "if" if branches.is_empty() => {}
412 "elif" | "else" if !branches.is_empty() => {}
413 "if" => bail!("line {}: unexpected `if` — cascade already open", line_no),
414 "elif" | "else" => {
415 bail!("line {}: `{}` without a leading `if`", line_no, kw)
416 }
417 _ => break,
418 }
419 let branch = self.parse_branch(&kw)?;
420 let is_else = branch.guard.is_none();
421 branches.push(branch);
422 if is_else {
423 break; }
425 }
426 Ok(branches)
427 }
428
429 fn parse_branch(&mut self, head: &str) -> Result<Branch> {
432 let line = self.advance().unwrap();
433 let line_no = line.line_no;
434 let indent = line.indent;
435 let rest = line.text[head.len()..].trim_start();
436 let colon = rest
437 .find(':')
438 .ok_or_else(|| anyhow!("line {}: missing `:` in `{}` arm", line_no, head))?;
439 let guard_str = rest[..colon].trim();
440 let after = rest[colon + 1..].trim();
441 let guard = if head == "else" {
442 if !guard_str.is_empty() {
443 bail!("line {}: `else` takes no guard", line_no);
444 }
445 None
446 } else {
447 Some(parse_guard(guard_str, line_no)?)
448 };
449 let mut ops = Vec::new();
450 if !after.is_empty() {
451 ops.extend(self.parse_inline_ops(after, line_no)?);
452 }
453 ops.extend(self.parse_indented_ops(indent)?);
454 if ops.is_empty() {
455 bail!("line {}: `{}` arm has no ops", line_no, head);
456 }
457 Ok(Branch { guard, ops })
458 }
459
460 fn parse_op_line(&mut self) -> Result<Op> {
463 let line = self.advance().unwrap();
464 let line_no = line.line_no;
465 let indent = line.indent;
466 let text = line.text.as_str();
467 let (head, _) = split_first_word(text);
468
469 match head {
470 "keep" => {
471 let rest = text[head.len()..].trim_start();
472 Ok(Op::Keep(parse_regex_literal(rest, line_no)?))
473 }
474 "drop" => {
475 let rest = text[head.len()..].trim_start();
476 Ok(Op::Drop(parse_regex_literal(rest, line_no)?))
477 }
478 "head" => {
479 let rest = text[head.len()..].trim();
480 Ok(Op::Head(parse_head_arg(rest, line_no)?))
481 }
482 "tail" => {
483 let rest = text[head.len()..].trim();
484 Ok(Op::Tail(parse_head_arg(rest, line_no)?))
485 }
486 "or" | "else" => {
487 let rest = text[head.len()..].trim_start();
488 Ok(Op::Or(parse_string_literal(rest, line_no)?))
489 }
490 "or-shell:" | "else-shell:" => {
491 let body = text[head.len()..].trim_start().to_string();
492 if body.is_empty() {
493 bail!("line {}: `{}` requires a command", line_no, head);
494 }
495 Ok(Op::OrShell(body))
496 }
497 "raw" | "passthrough" => Ok(Op::Raw),
499 "shell:" => Ok(Op::Shell(self.parse_block_body(
500 text,
501 head,
502 indent,
503 line_no,
504 )?)),
505 "python:" => Ok(Op::Python(self.parse_block_body(
506 text,
507 head,
508 indent,
509 line_no,
510 )?)),
511 "split" => {
512 let rest = text[head.len()..].trim_start();
513 let delim = parse_regex_literal(rest, line_no)?;
514 let (pre, post) = self.parse_split_branches(indent)?;
515 if pre.is_empty() && post.is_empty() {
516 bail!(
517 "line {}: `split` needs at least one `pre:` or `post:` block",
518 line_no
519 );
520 }
521 Ok(Op::Split {
522 delimiter: delim,
523 pre,
524 post,
525 })
526 }
527 name if self.is_macro(name) => {
528 let rest = text[head.len()..].trim();
529 let args = parse_macro_args(rest, line_no)?;
530 Ok(Op::MacroCall {
531 name: name.to_string(),
532 args,
533 })
534 }
535 _ => bail!("line {}: unknown op `{}`", line_no, head),
536 }
537 }
538
539 fn parse_block_body(
544 &mut self,
545 line_text: &str,
546 head: &str,
547 parent_indent: usize,
548 line_no: usize,
549 ) -> Result<String> {
550 let after = line_text[head.len()..].trim_start();
551 if after != "|" {
552 if after.is_empty() {
553 bail!(
554 "line {}: empty `{}` body (use `| <newline>` for block form)",
555 line_no,
556 head
557 );
558 }
559 return Ok(after.to_string());
560 }
561
562 let mut collected: Vec<&'a Line> = Vec::new();
565 let mut base: Option<usize> = None;
566 while let Some(l) = self.lines.get(self.pos) {
567 if l.text.is_empty() {
568 collected.push(l);
569 self.pos += 1;
570 continue;
571 }
572 if l.indent <= parent_indent {
573 break;
574 }
575 if base.is_none() {
576 base = Some(l.indent);
577 }
578 collected.push(l);
579 self.pos += 1;
580 }
581 while collected.last().map_or(false, |l| l.text.is_empty()) {
583 collected.pop();
584 }
585 if collected.is_empty() {
586 bail!("line {}: `{}` block is empty", line_no, head);
587 }
588 let base = base.unwrap_or(parent_indent + 4);
589 let dedented: Vec<String> = collected
590 .iter()
591 .map(|l| {
592 if l.text.is_empty() {
593 String::new()
594 } else if l.raw.len() >= base {
595 l.raw[base..].to_string()
596 } else {
597 l.raw.trim_start().to_string()
598 }
599 })
600 .collect();
601 Ok(dedented.join("\n"))
602 }
603
604 fn parse_split_branches(&mut self, parent_indent: usize) -> Result<(Vec<Op>, Vec<Op>)> {
607 let mut pre = Vec::new();
608 let mut post = Vec::new();
609 loop {
610 let Some(line) = self.peek_significant() else {
611 break;
612 };
613 if line.indent != parent_indent {
614 break;
615 }
616 match line.text.as_str() {
617 "pre:" => {
618 self.advance();
619 pre = self.parse_indented_ops(parent_indent)?;
620 }
621 "post:" => {
622 self.advance();
623 post = self.parse_indented_ops(parent_indent)?;
624 }
625 _ => break,
626 }
627 }
628 Ok((pre, post))
629 }
630
631 fn parse_inline_ops(&self, text: &str, line_no: usize) -> Result<Vec<Op>> {
636 let mut ops = Vec::new();
637 let mut remaining = text.trim();
638 while !remaining.is_empty() {
639 let (head, _) = split_first_word(remaining);
640 match head {
641 "shell:" => {
642 let body = remaining[head.len()..].trim_start().to_string();
643 if body.is_empty() {
644 bail!("line {}: inline `shell:` needs a command", line_no);
645 }
646 ops.push(Op::Shell(body));
647 remaining = "";
648 }
649 "python:" => {
650 let body = remaining[head.len()..].trim_start().to_string();
651 if body.is_empty() {
652 bail!("line {}: inline `python:` needs a command", line_no);
653 }
654 ops.push(Op::Python(body));
655 remaining = "";
656 }
657 "or-shell:" | "else-shell:" => {
658 let body = remaining[head.len()..].trim_start().to_string();
659 if body.is_empty() {
660 bail!("line {}: inline `{}` needs a command", line_no, head);
661 }
662 ops.push(Op::OrShell(body));
663 remaining = "";
664 }
665 "raw" | "passthrough" => {
666 ops.push(Op::Raw);
667 remaining = remaining[head.len()..].trim_start();
668 }
669 "keep" | "drop" => {
670 let rest = remaining[head.len()..].trim_start();
671 let (re, after) = parse_regex_literal_and_rest(rest, line_no)?;
672 ops.push(if head == "keep" {
673 Op::Keep(re)
674 } else {
675 Op::Drop(re)
676 });
677 remaining = after.trim_start();
678 }
679 "head" | "tail" => {
680 let rest = remaining[head.len()..].trim_start();
681 let (arg_word, after) = take_word(rest);
682 let h = parse_head_arg(arg_word, line_no)?;
683 ops.push(if head == "head" {
684 Op::Head(h)
685 } else {
686 Op::Tail(h)
687 });
688 remaining = after.trim_start();
689 }
690 "or" | "else" => {
691 let rest = remaining[head.len()..].trim_start();
692 let (s, after) = parse_string_literal_and_rest(rest, line_no)?;
693 ops.push(Op::Or(s));
694 remaining = after.trim_start();
695 }
696 "split" => {
697 bail!(
698 "line {}: `split` cannot appear inline (needs pre:/post: blocks)",
699 line_no
700 )
701 }
702 name if self.is_macro(name) => {
703 let rest = remaining[head.len()..].trim_start();
704 let (args, after) =
705 parse_macro_args_until_op(rest, &self.macro_names, line_no)?;
706 ops.push(Op::MacroCall {
707 name: name.to_string(),
708 args,
709 });
710 remaining = after.trim_start();
711 }
712 _ => bail!("line {}: unknown op `{}` in inline chain", line_no, head),
713 }
714 }
715 Ok(ops)
716 }
717}
718
719fn split_first_word(s: &str) -> (&str, &str) {
724 let s = s.trim_start();
725 let end = s.find(char::is_whitespace).unwrap_or(s.len());
726 (&s[..end], &s[end..])
727}
728
729fn take_word(s: &str) -> (&str, &str) {
730 let s = s.trim_start();
731 let end = s.find(char::is_whitespace).unwrap_or(s.len());
732 (&s[..end], &s[end..])
733}
734
735fn parse_selector(s: &str) -> Result<(SubPattern, LevelPattern)> {
736 let s = s.trim();
737 if s.is_empty() {
738 bail!("empty selector");
739 }
740 let mut parts = s.splitn(2, ',');
741 let sub_str = parts.next().unwrap().trim();
742 let level_str = parts.next().map(|s| s.trim()).unwrap_or("*");
743
744 let sub = if sub_str == "*" {
745 SubPattern::Star
746 } else {
747 let alts: Vec<String> = sub_str
748 .split('|')
749 .map(|s| s.trim().to_string())
750 .collect();
751 if alts.iter().any(|a| a.is_empty()) {
752 bail!("empty alternative in sub pattern `{}`", sub_str);
753 }
754 SubPattern::Alt(alts)
755 };
756
757 let level = if level_str == "*" {
758 LevelPattern::Star
759 } else {
760 let lvl: Level = level_str.parse().map_err(|e: String| anyhow!(e))?;
761 LevelPattern::Specific(lvl)
762 };
763
764 Ok((sub, level))
765}
766
767fn glob_match(pat: &str, text: &str) -> bool {
771 match pat.find('*') {
772 None => pat == text,
773 Some(star) => {
774 let prefix = &pat[..star];
775 let rest = &pat[star + 1..];
776 let Some(tail) = text.strip_prefix(prefix) else {
777 return false;
778 };
779 if rest.is_empty() {
780 return true;
781 }
782 (0..=tail.len())
783 .filter(|&i| tail.is_char_boundary(i))
784 .any(|i| glob_match(rest, &tail[i..]))
785 }
786 }
787}
788
789fn parse_guard(s: &str, line_no: usize) -> Result<Guard> {
791 let mut atoms = Vec::new();
792 for part in s.split(" and ") {
793 let part = part.trim();
794 if part.is_empty() {
795 bail!("line {}: empty guard", line_no);
796 }
797 atoms.push(parse_atom(part, line_no)?);
798 }
799 if atoms.is_empty() {
800 bail!("line {}: empty guard", line_no);
801 }
802 Ok(Guard { atoms })
803}
804
805fn parse_atom(s: &str, line_no: usize) -> Result<Atom> {
808 if s.starts_with('-') {
809 return Ok(Atom::Flag(s.to_string()));
810 }
811 let mut words = s.split_whitespace();
812 let dim = words.next().unwrap_or("");
813 let val = words.next();
814 if words.next().is_some() {
815 bail!("line {}: guard `{}` has too many words", line_no, s);
816 }
817 match (dim, val) {
818 ("exit", Some("ok")) => Ok(Atom::Exit(ExitMatch::Ok)),
819 ("exit", Some("failed")) => Ok(Atom::Exit(ExitMatch::Failed)),
820 ("exit", Some(v)) => {
821 bail!("line {}: unknown exit value `{}` (expected ok|failed)", line_no, v)
822 }
823 ("exit", None) => bail!("line {}: `exit` guard needs a value (ok|failed)", line_no),
824 ("level", Some(v)) => {
825 let lvl: Level = v.parse().map_err(|e: String| anyhow!("line {line_no}: {e}"))?;
826 Ok(Atom::Level(lvl))
827 }
828 ("level", None) => bail!("line {}: `level` guard needs a value", line_no),
829 (other, _) => bail!(
830 "line {}: unknown guard `{}` (expected `exit ...`, `level ...`, or a --flag)",
831 line_no,
832 other
833 ),
834 }
835}
836
837fn parse_define_header(s: &str) -> Result<(String, Vec<String>, &str)> {
838 let s = s.trim_start();
839 let end = s
840 .find(|c: char| c == '(' || c == ':' || c.is_whitespace())
841 .unwrap_or(s.len());
842 let name = s[..end].to_string();
843 if name.is_empty() {
844 bail!("define needs a name");
845 }
846 let rest = s[end..].trim_start();
847 if let Some(rest) = rest.strip_prefix('(') {
848 let close = rest
849 .find(')')
850 .ok_or_else(|| anyhow!("missing `)` in define params"))?;
851 let params: Vec<String> = rest[..close]
852 .split(',')
853 .map(|p| p.trim().to_string())
854 .filter(|p| !p.is_empty())
855 .collect();
856 Ok((name, params, rest[close + 1..].trim_start()))
857 } else {
858 Ok((name, Vec::new(), rest))
859 }
860}
861
862fn parse_regex_literal(s: &str, line_no: usize) -> Result<PatternRegex> {
863 let (re, after) = parse_regex_literal_and_rest(s, line_no)?;
864 let after = after.trim();
865 if !after.is_empty() {
866 bail!(
867 "line {}: unexpected trailing input after regex: `{}`",
868 line_no,
869 after
870 );
871 }
872 Ok(re)
873}
874
875fn parse_regex_literal_and_rest(s: &str, line_no: usize) -> Result<(PatternRegex, &str)> {
876 let s = s.trim_start();
877 if !s.starts_with('/') {
878 bail!(
879 "line {}: expected `/regex/`, got `{}`",
880 line_no,
881 preview(s)
882 );
883 }
884 let body = &s[1..];
885 let mut src = String::new();
886 let mut chars = body.char_indices().peekable();
887 let mut end_byte: Option<usize> = None;
888 while let Some((i, c)) = chars.next() {
889 if c == '\\' {
890 if let Some((_, n)) = chars.next() {
891 if n == '/' {
892 src.push('/');
893 } else {
894 src.push('\\');
895 src.push(n);
896 }
897 } else {
898 bail!("line {}: trailing backslash in regex", line_no);
899 }
900 } else if c == '/' {
901 end_byte = Some(i);
902 break;
903 } else {
904 src.push(c);
905 }
906 }
907 let end_byte = end_byte.ok_or_else(|| anyhow!("line {}: unterminated regex", line_no))?;
908 let after = &body[end_byte + 1..];
909 let compiled = Regex::new(&src)
910 .map_err(|e| anyhow!("line {}: invalid regex `{}`: {}", line_no, src, e))?;
911 Ok((
912 PatternRegex {
913 source: src,
914 compiled,
915 },
916 after,
917 ))
918}
919
920fn parse_string_literal(s: &str, line_no: usize) -> Result<String> {
921 let (s, after) = parse_string_literal_and_rest(s, line_no)?;
922 let after = after.trim();
923 if !after.is_empty() {
924 bail!(
925 "line {}: unexpected trailing input after string: `{}`",
926 line_no,
927 after
928 );
929 }
930 Ok(s)
931}
932
933fn parse_string_literal_and_rest(s: &str, line_no: usize) -> Result<(String, &str)> {
934 let s = s.trim_start();
935 if !s.starts_with('"') {
936 bail!(
937 "line {}: expected `\"...\"`, got `{}`",
938 line_no,
939 preview(s)
940 );
941 }
942 let body = &s[1..];
943 let mut out = String::new();
944 let mut chars = body.char_indices();
945 let mut end_byte: Option<usize> = None;
946 while let Some((i, c)) = chars.next() {
947 if c == '\\' {
948 if let Some((_, n)) = chars.next() {
949 match n {
950 'n' => out.push('\n'),
951 't' => out.push('\t'),
952 'r' => out.push('\r'),
953 '\\' => out.push('\\'),
954 '"' => out.push('"'),
955 other => {
956 out.push('\\');
957 out.push(other);
958 }
959 }
960 } else {
961 bail!("line {}: trailing backslash in string", line_no);
962 }
963 } else if c == '"' {
964 end_byte = Some(i);
965 break;
966 } else {
967 out.push(c);
968 }
969 }
970 let end_byte = end_byte.ok_or_else(|| anyhow!("line {}: unterminated string", line_no))?;
971 let after = &body[end_byte + 1..];
972 Ok((out, after))
973}
974
975fn parse_head_arg(s: &str, line_no: usize) -> Result<HeadArg> {
976 let s = s.trim();
977 if s == "auto" {
978 return Ok(HeadArg::Auto);
979 }
980 s.parse::<usize>().map(HeadArg::Number).map_err(|_| {
981 anyhow!(
982 "line {}: expected number or `auto`, got `{}`",
983 line_no,
984 s
985 )
986 })
987}
988
989fn parse_macro_args(s: &str, line_no: usize) -> Result<Vec<MacroArg>> {
990 let mut out = Vec::new();
991 let mut rest = s.trim();
992 while !rest.is_empty() {
993 if rest.starts_with('"') {
994 let (sv, after) = parse_string_literal_and_rest(rest, line_no)?;
995 out.push(MacroArg::String(sv));
996 rest = after.trim_start();
997 } else {
998 let (word, after) = take_word(rest);
999 out.push(match word.parse::<usize>() {
1000 Ok(n) => MacroArg::Number(n),
1001 Err(_) => MacroArg::String(word.to_string()),
1002 });
1003 rest = after.trim_start();
1004 }
1005 }
1006 Ok(out)
1007}
1008
1009fn parse_macro_args_until_op<'a>(
1010 s: &'a str,
1011 macro_names: &[String],
1012 line_no: usize,
1013) -> Result<(Vec<MacroArg>, &'a str)> {
1014 let mut out = Vec::new();
1015 let mut rest = s.trim_start();
1016 while !rest.is_empty() {
1017 let (word, _) = take_word(rest);
1018 if OP_KEYWORDS.contains(&word) || macro_names.iter().any(|n| n == word) {
1019 break;
1020 }
1021 if rest.starts_with('"') {
1022 let (sv, after) = parse_string_literal_and_rest(rest, line_no)?;
1023 out.push(MacroArg::String(sv));
1024 rest = after.trim_start();
1025 } else {
1026 let (w, after) = take_word(rest);
1027 out.push(match w.parse::<usize>() {
1028 Ok(n) => MacroArg::Number(n),
1029 Err(_) => MacroArg::String(w.to_string()),
1030 });
1031 rest = after.trim_start();
1032 }
1033 }
1034 Ok((out, rest))
1035}
1036
1037fn preview(s: &str) -> &str {
1038 let n = s.char_indices().nth(40).map(|(i, _)| i).unwrap_or(s.len());
1039 &s[..n]
1040}
1041
1042use std::io::Write;
1047use std::process::{Command, Stdio};
1048
1049#[derive(Debug, Clone)]
1052pub struct ExecCtx<'a> {
1053 pub sub: &'a str,
1054 pub level: Level,
1055 pub exit_code: i32,
1056 pub args: &'a [String],
1057}
1058
1059pub fn execute(rs: &RuleSet, ctx: &ExecCtx, input: &str) -> Result<String> {
1065 let Some(rule) = rs.select(ctx.sub, ctx.level) else {
1066 return Ok(input.to_string());
1067 };
1068 let out = run_ops(&rule.ops, ctx, input, rs, &[])?;
1069 Ok(ensure_trailing_newline(out))
1070}
1071
1072fn ensure_trailing_newline(mut s: String) -> String {
1073 if !s.is_empty() && !s.ends_with('\n') {
1074 s.push('\n');
1075 }
1076 s
1077}
1078
1079#[derive(Debug, Clone)]
1081pub struct StageRecord {
1082 pub op_desc: String,
1083 pub stdin_lines: usize,
1084 pub stdin_bytes: usize,
1085 pub stdout_lines: usize,
1086 pub stdout_bytes: usize,
1087 pub elapsed_us: u128,
1088}
1089
1090#[derive(Debug, Default, Clone)]
1091pub struct ExplainTrace {
1092 pub matched_rule: Option<usize>,
1094 pub stages: Vec<StageRecord>,
1095}
1096
1097pub fn execute_explain(
1102 rs: &RuleSet,
1103 ctx: &ExecCtx,
1104 input: &str,
1105) -> Result<(String, ExplainTrace)> {
1106 let mut trace = ExplainTrace::default();
1107 let Some((idx, rule)) = rs
1108 .rules
1109 .iter()
1110 .enumerate()
1111 .find(|(_, r)| r.matches(ctx.sub, ctx.level))
1112 else {
1113 return Ok((input.to_string(), trace));
1114 };
1115 trace.matched_rule = Some(idx);
1116
1117 let raw = input.to_string();
1118 let mut state = input.to_string();
1119 for op in &rule.ops {
1120 let stdin_lines = state.lines().count();
1121 let stdin_bytes = state.len();
1122 let start = std::time::Instant::now();
1123 let new_state = apply_op(op, &state, &raw, ctx, rs, &[])?;
1124 let elapsed_us = start.elapsed().as_micros();
1125 trace.stages.push(StageRecord {
1126 op_desc: describe_op(op),
1127 stdin_lines,
1128 stdin_bytes,
1129 stdout_lines: new_state.lines().count(),
1130 stdout_bytes: new_state.len(),
1131 elapsed_us,
1132 });
1133 state = new_state;
1134 }
1135 Ok((ensure_trailing_newline(state), trace))
1136}
1137
1138fn describe_op(op: &Op) -> String {
1139 match op {
1140 Op::Keep(p) => format!("keep /{}/", p.source),
1141 Op::Drop(p) => format!("drop /{}/", p.source),
1142 Op::Head(arg) => format!("head {}", describe_head(arg)),
1143 Op::Tail(arg) => format!("tail {}", describe_head(arg)),
1144 Op::Or(s) => format!("or {s:?}"),
1145 Op::OrShell(s) => format!("or-shell: {}", first_line(s)),
1146 Op::Raw => "raw".to_string(),
1147 Op::Cascade(branches) => format!("cascade ({} arms)", branches.len()),
1148 Op::Shell(s) => format!("shell: {}", first_line(s)),
1149 Op::Python(s) => {
1150 if has_pep723_header(s) {
1151 format!("python (uv): {}", first_line(s))
1152 } else {
1153 format!("python: {}", first_line(s))
1154 }
1155 }
1156 Op::MacroCall { name, args } => {
1157 let parts: Vec<String> = args
1158 .iter()
1159 .map(|a| match a {
1160 MacroArg::Number(n) => n.to_string(),
1161 MacroArg::String(s) => s.clone(),
1162 })
1163 .collect();
1164 if parts.is_empty() {
1165 name.clone()
1166 } else {
1167 format!("{name} {}", parts.join(" "))
1168 }
1169 }
1170 Op::Split { delimiter, .. } => format!("split /{}/", delimiter.source),
1171 }
1172}
1173
1174fn describe_head(a: &HeadArg) -> String {
1175 match a {
1176 HeadArg::Number(n) => n.to_string(),
1177 HeadArg::Auto => "auto".into(),
1178 }
1179}
1180
1181fn first_line(s: &str) -> String {
1182 s.lines().next().unwrap_or("").chars().take(60).collect()
1183}
1184
1185fn run_ops(
1186 ops: &[Op],
1187 ctx: &ExecCtx,
1188 input: &str,
1189 rs: &RuleSet,
1190 macro_args: &[MacroArg],
1191) -> Result<String> {
1192 let raw = input.to_string();
1193 let mut state = input.to_string();
1194 for op in ops {
1195 state = apply_op(op, &state, &raw, ctx, rs, macro_args)?;
1196 }
1197 Ok(state)
1198}
1199
1200fn apply_op(
1201 op: &Op,
1202 state: &str,
1203 raw: &str,
1204 ctx: &ExecCtx,
1205 rs: &RuleSet,
1206 macro_args: &[MacroArg],
1207) -> Result<String> {
1208 match op {
1209 Op::Keep(pat) => Ok(filter_lines(state, |l| pat.compiled.is_match(l))),
1210 Op::Drop(pat) => Ok(filter_lines(state, |l| !pat.compiled.is_match(l))),
1211 Op::Head(arg) => Ok(take_head(state, resolve_head(arg, ctx.level))),
1212 Op::Tail(arg) => Ok(take_tail(state, resolve_head(arg, ctx.level))),
1213 Op::Or(s) => Ok(if state.trim().is_empty() {
1214 s.clone()
1215 } else {
1216 state.to_string()
1217 }),
1218 Op::OrShell(cmd) => {
1219 if state.trim().is_empty() {
1220 let expanded = expand_args(cmd, macro_args);
1221 run_shell(&expanded, raw, ctx)
1222 } else {
1223 Ok(state.to_string())
1224 }
1225 }
1226 Op::Raw => Ok(state.to_string()),
1227 Op::Cascade(branches) => {
1228 for br in branches {
1229 let hit = match &br.guard {
1230 None => true,
1231 Some(g) => guard_matches(g, ctx),
1232 };
1233 if hit {
1234 return run_ops(&br.ops, ctx, state, rs, macro_args);
1235 }
1236 }
1237 Ok(state.to_string())
1239 }
1240 Op::Shell(cmd) => {
1241 let expanded = expand_args(cmd, macro_args);
1242 run_shell(&expanded, state, ctx)
1243 }
1244 Op::Python(body) => {
1245 let expanded = expand_args(body, macro_args);
1246 run_python(&expanded, state, ctx)
1247 }
1248 Op::MacroCall { name, args } => {
1249 let def = rs
1250 .find_define(name)
1251 .ok_or_else(|| anyhow!("undefined macro `{}`", name))?;
1252 if args.len() != def.params.len() {
1253 bail!(
1254 "macro `{}` expects {} arg(s), got {}",
1255 name,
1256 def.params.len(),
1257 args.len()
1258 );
1259 }
1260 run_ops(&def.ops, ctx, state, rs, args)
1261 }
1262 Op::Split {
1263 delimiter,
1264 pre,
1265 post,
1266 } => {
1267 let (a, b) = split_at_first_match(state, &delimiter.compiled);
1268 let pre_out = if pre.is_empty() {
1269 a
1270 } else {
1271 run_ops(pre, ctx, &a, rs, macro_args)?
1272 };
1273 let post_out = if post.is_empty() {
1274 b
1275 } else {
1276 run_ops(post, ctx, &b, rs, macro_args)?
1277 };
1278 Ok(join_nonempty(&pre_out, &post_out))
1279 }
1280 }
1281}
1282
1283fn guard_matches(g: &Guard, ctx: &ExecCtx) -> bool {
1285 g.atoms.iter().all(|a| atom_matches(a, ctx))
1286}
1287
1288fn atom_matches(a: &Atom, ctx: &ExecCtx) -> bool {
1289 match a {
1290 Atom::Exit(ExitMatch::Ok) => ctx.exit_code == 0,
1291 Atom::Exit(ExitMatch::Failed) => ctx.exit_code != 0,
1292 Atom::Level(l) => *l == ctx.level,
1293 Atom::Flag(f) => ctx.args.iter().any(|arg| arg == f),
1294 }
1295}
1296
1297fn resolve_head(arg: &HeadArg, level: Level) -> usize {
1298 match arg {
1299 HeadArg::Number(n) => *n,
1300 HeadArg::Auto => level.head_limit(30),
1301 }
1302}
1303
1304fn filter_lines(s: &str, mut keep: impl FnMut(&str) -> bool) -> String {
1305 s.lines()
1306 .filter(|l| keep(l))
1307 .collect::<Vec<_>>()
1308 .join("\n")
1309}
1310
1311fn take_head(s: &str, n: usize) -> String {
1312 s.lines().take(n).collect::<Vec<_>>().join("\n")
1313}
1314
1315fn take_tail(s: &str, n: usize) -> String {
1316 let lines: Vec<&str> = s.lines().collect();
1317 let start = lines.len().saturating_sub(n);
1318 lines[start..].join("\n")
1319}
1320
1321fn split_at_first_match(s: &str, re: &Regex) -> (String, String) {
1325 let mut pre = String::new();
1326 let mut post = String::new();
1327 let mut in_post = false;
1328 for line in s.lines() {
1329 if !in_post && re.is_match(line) {
1330 in_post = true;
1331 }
1332 let buf = if in_post { &mut post } else { &mut pre };
1333 if !buf.is_empty() {
1334 buf.push('\n');
1335 }
1336 buf.push_str(line);
1337 }
1338 (pre, post)
1339}
1340
1341fn join_nonempty(a: &str, b: &str) -> String {
1342 match (a.is_empty(), b.is_empty()) {
1343 (true, true) => String::new(),
1344 (true, false) => b.to_string(),
1345 (false, true) => a.to_string(),
1346 (false, false) => format!("{a}\n{b}"),
1347 }
1348}
1349
1350fn expand_args(body: &str, args: &[MacroArg]) -> String {
1354 if args.is_empty() {
1355 return body.to_string();
1356 }
1357 let mut out = String::with_capacity(body.len());
1358 let bytes = body.as_bytes();
1359 let mut i = 0;
1360 while i < bytes.len() {
1361 let c = bytes[i];
1362 if c == b'$' && i + 1 < bytes.len() {
1363 let n = bytes[i + 1];
1364 if n.is_ascii_digit() && n != b'0' {
1365 let idx = (n - b'0') as usize;
1366 if idx <= args.len() {
1367 match &args[idx - 1] {
1368 MacroArg::Number(v) => out.push_str(&v.to_string()),
1369 MacroArg::String(v) => out.push_str(v),
1370 }
1371 i += 2;
1372 continue;
1373 }
1374 }
1375 }
1376 out.push(c as char);
1377 i += 1;
1378 }
1379 out
1380}
1381
1382fn run_shell(cmd: &str, stdin_data: &str, ctx: &ExecCtx) -> Result<String> {
1383 let mut child = Command::new("sh")
1384 .arg("-c")
1385 .arg(cmd)
1386 .env("level", ctx.level.to_string())
1387 .env("sub", ctx.sub)
1388 .env("exit", ctx.exit_code.to_string())
1389 .env("args", ctx.args.join(" "))
1390 .stdin(Stdio::piped())
1391 .stdout(Stdio::piped())
1392 .stderr(Stdio::piped())
1393 .spawn()
1394 .context("spawning sh")?;
1395
1396 if let Some(mut stdin) = child.stdin.take() {
1397 stdin
1398 .write_all(stdin_data.as_bytes())
1399 .context("writing to sh stdin")?;
1400 }
1401
1402 let output = child.wait_with_output().context("waiting for sh")?;
1403 if !output.status.success() {
1404 let stderr = String::from_utf8_lossy(&output.stderr);
1405 bail!(
1406 "shell exited {}: {}",
1407 output.status.code().unwrap_or(-1),
1408 stderr.trim()
1409 );
1410 }
1411 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1412}
1413
1414fn run_python(body: &str, stdin_data: &str, ctx: &ExecCtx) -> Result<String> {
1415 if has_pep723_header(body) {
1416 run_python_uv(body, stdin_data, ctx)
1417 } else {
1418 run_python_plain(body, stdin_data, ctx)
1419 }
1420}
1421
1422fn has_pep723_header(body: &str) -> bool {
1423 body.lines()
1424 .any(|l| l.trim_start().starts_with("# /// script"))
1425}
1426
1427fn run_python_plain(body: &str, stdin_data: &str, ctx: &ExecCtx) -> Result<String> {
1428 let mut child = Command::new("python3")
1429 .arg("-c")
1430 .arg(body)
1431 .env("level", ctx.level.to_string())
1432 .env("sub", ctx.sub)
1433 .env("exit", ctx.exit_code.to_string())
1434 .env("args", ctx.args.join(" "))
1435 .stdin(Stdio::piped())
1436 .stdout(Stdio::piped())
1437 .stderr(Stdio::piped())
1438 .spawn()
1439 .context("spawning python3")?;
1440
1441 if let Some(mut stdin) = child.stdin.take() {
1442 stdin
1443 .write_all(stdin_data.as_bytes())
1444 .context("writing to python stdin")?;
1445 }
1446 let output = child.wait_with_output().context("waiting for python")?;
1447 if !output.status.success() {
1448 let stderr = String::from_utf8_lossy(&output.stderr);
1449 bail!(
1450 "python exited {}: {}",
1451 output.status.code().unwrap_or(-1),
1452 stderr.trim()
1453 );
1454 }
1455 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1456}
1457
1458fn run_python_uv(body: &str, stdin_data: &str, ctx: &ExecCtx) -> Result<String> {
1461 let mut script = tempfile::Builder::new()
1462 .prefix("lowfat-lf-")
1463 .suffix(".py")
1464 .tempfile()
1465 .context("creating temp script file")?;
1466 script
1467 .write_all(body.as_bytes())
1468 .context("writing temp script")?;
1469 script.flush().ok();
1470
1471 let path = script
1472 .path()
1473 .to_str()
1474 .ok_or_else(|| anyhow!("non-UTF8 temp path"))?
1475 .to_string();
1476
1477 let mut child = Command::new("uv")
1478 .args(["run", "--script", &path])
1479 .env("level", ctx.level.to_string())
1480 .env("sub", ctx.sub)
1481 .env("exit", ctx.exit_code.to_string())
1482 .env("args", ctx.args.join(" "))
1483 .stdin(Stdio::piped())
1484 .stdout(Stdio::piped())
1485 .stderr(Stdio::piped())
1486 .spawn()
1487 .context("spawning uv (is `uv` installed?)")?;
1488
1489 if let Some(mut stdin) = child.stdin.take() {
1490 stdin
1491 .write_all(stdin_data.as_bytes())
1492 .context("writing to uv stdin")?;
1493 }
1494 let output = child.wait_with_output().context("waiting for uv")?;
1495 if !output.status.success() {
1496 let stderr = String::from_utf8_lossy(&output.stderr);
1497 bail!(
1498 "uv exited {}: {}",
1499 output.status.code().unwrap_or(-1),
1500 stderr.trim()
1501 );
1502 }
1503 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1504}
1505
1506#[cfg(test)]
1511mod tests {
1512 use super::*;
1513
1514 fn parse_ok(src: &str) -> RuleSet {
1515 parse(src).unwrap_or_else(|e| panic!("parse failed: {e}\n--- src ---\n{src}"))
1516 }
1517
1518 #[test]
1519 fn empty_input() {
1520 let rs = parse_ok("");
1521 assert!(rs.rules.is_empty());
1522 assert!(rs.defines.is_empty());
1523 }
1524
1525 #[test]
1526 fn comments_and_blanks_only() {
1527 let rs = parse_ok("# hi\n\n# more\n");
1528 assert!(rs.rules.is_empty());
1529 }
1530
1531 #[test]
1532 fn simple_rule() {
1533 let rs = parse_ok(
1534 r#"
1535status:
1536 keep /foo/
1537 head 10
1538"#,
1539 );
1540 assert_eq!(rs.rules.len(), 1);
1541 let r = &rs.rules[0];
1542 assert!(matches!(&r.sub, SubPattern::Alt(a) if a == &["status".to_string()]));
1543 assert!(matches!(r.level, LevelPattern::Star));
1544 assert_eq!(r.ops.len(), 2);
1545 match &r.ops[0] {
1546 Op::Keep(p) => assert_eq!(p.source, "foo"),
1547 _ => panic!("expected Keep"),
1548 }
1549 assert!(matches!(r.ops[1], Op::Head(HeadArg::Number(10))));
1550 }
1551
1552 #[test]
1553 fn sub_with_alternation_and_level() {
1554 let rs = parse_ok(
1555 r#"
1556build|check, ultra:
1557 head 15
1558"#,
1559 );
1560 let r = &rs.rules[0];
1561 match &r.sub {
1562 SubPattern::Alt(a) => assert_eq!(a, &["build".to_string(), "check".to_string()]),
1563 _ => panic!("expected Alt"),
1564 }
1565 assert!(matches!(r.level, LevelPattern::Specific(Level::Ultra)));
1566 }
1567
1568 #[test]
1569 fn star_wildcards() {
1570 let rs = parse_ok(
1571 r#"
1572*:
1573 head 30
1574"#,
1575 );
1576 assert!(matches!(rs.rules[0].sub, SubPattern::Star));
1577 assert!(matches!(rs.rules[0].level, LevelPattern::Star));
1578 }
1579
1580 #[test]
1581 fn else_string_fallback() {
1582 let rs = parse_ok(
1583 r#"
1584status:
1585 keep /^M /
1586 head 5
1587 else "clean"
1588"#,
1589 );
1590 match &rs.rules[0].ops[2] {
1591 Op::Or(s) => assert_eq!(s, "clean"),
1592 _ => panic!("expected Or"),
1593 }
1594 }
1595
1596 #[test]
1597 fn shell_inline_and_block() {
1598 let rs = parse_ok(
1599 r#"
1600define a:
1601 shell: sed -E 's/x/y/'
1602
1603define b:
1604 shell: |
1605 awk '
1606 BEGIN { n=0 }
1607 { print; n++ }
1608 '
1609"#,
1610 );
1611 match &rs.defines[0].ops[0] {
1612 Op::Shell(s) => assert_eq!(s, "sed -E 's/x/y/'"),
1613 _ => panic!("expected inline Shell"),
1614 }
1615 match &rs.defines[1].ops[0] {
1616 Op::Shell(s) => {
1617 assert!(s.starts_with("awk '"));
1618 assert!(s.contains("BEGIN { n=0 }"));
1619 assert!(s.contains("{ print; n++ }"));
1620 }
1621 _ => panic!("expected block Shell"),
1622 }
1623 }
1624
1625 #[test]
1626 fn python_block_preserves_pep723_and_blanks() {
1627 let rs = parse_ok(
1628 r#"
1629define clean:
1630 python: |
1631 # /// script
1632 # dependencies = ["pyyaml>=6"]
1633 # ///
1634 import sys, yaml
1635
1636 for d in yaml.safe_load_all(sys.stdin):
1637 print(d)
1638"#,
1639 );
1640 match &rs.defines[0].ops[0] {
1641 Op::Python(s) => {
1642 assert!(s.contains("# /// script"));
1643 assert!(s.contains("# dependencies = [\"pyyaml>=6\"]"));
1644 assert!(s.contains("import sys, yaml"));
1645 assert!(s.contains("yaml\n\nfor"));
1647 assert!(s.contains(" print(d)"));
1649 }
1650 _ => panic!("expected Python"),
1651 }
1652 }
1653
1654 #[test]
1655 fn macro_call_with_args() {
1656 let rs = parse_ok(
1657 r#"
1658define compact(n):
1659 head 1
1660
1661diff, ultra:
1662 compact 30
1663"#,
1664 );
1665 match &rs.rules[0].ops[0] {
1666 Op::MacroCall { name, args } => {
1667 assert_eq!(name, "compact");
1668 assert_eq!(args, &[MacroArg::Number(30)]);
1669 }
1670 _ => panic!("expected MacroCall"),
1671 }
1672 }
1673
1674 #[test]
1675 fn inline_ops_after_rule_header() {
1676 let rs = parse_ok(
1677 r#"
1678define compact(n):
1679 head 1
1680
1681diff, ultra: compact 30 else-shell: awk 'NF' | head -50
1682"#,
1683 );
1684 let ops = &rs.rules[0].ops;
1685 assert_eq!(ops.len(), 2);
1686 assert!(matches!(&ops[0], Op::MacroCall { name, .. } if name == "compact"));
1687 match &ops[1] {
1688 Op::OrShell(s) => assert_eq!(s, "awk 'NF' | head -50"),
1689 _ => panic!("expected OrShell, got {:?}", &ops[1]),
1690 }
1691 }
1692
1693 #[test]
1694 fn split_with_pre_and_post() {
1695 let rs = parse_ok(
1696 r#"
1697define ah:
1698 shell: cat
1699
1700show:
1701 split /^diff /
1702 pre:
1703 keep /^commit /
1704 ah
1705 post:
1706 head 10
1707 head 100
1708"#,
1709 );
1710 let ops = &rs.rules[0].ops;
1711 assert_eq!(ops.len(), 2);
1712 match &ops[0] {
1713 Op::Split {
1714 delimiter,
1715 pre,
1716 post,
1717 } => {
1718 assert_eq!(delimiter.source, "^diff ");
1719 assert_eq!(pre.len(), 2);
1720 assert_eq!(post.len(), 1);
1721 assert!(matches!(&pre[0], Op::Keep(_)));
1722 assert!(matches!(&pre[1], Op::MacroCall { name, .. } if name == "ah"));
1723 assert!(matches!(post[0], Op::Head(HeadArg::Number(10))));
1724 }
1725 _ => panic!("expected Split"),
1726 }
1727 assert!(matches!(ops[1], Op::Head(HeadArg::Number(100))));
1728 }
1729
1730 #[test]
1731 fn first_match_wins_selection() {
1732 let rs = parse_ok(
1733 r#"
1734diff, ultra:
1735 head 5
1736
1737diff:
1738 head 20
1739
1740*:
1741 head 30
1742"#,
1743 );
1744 let r = rs.select("diff", Level::Ultra).unwrap();
1745 assert!(matches!(r.ops[0], Op::Head(HeadArg::Number(5))));
1746 let r = rs.select("diff", Level::Full).unwrap();
1747 assert!(matches!(r.ops[0], Op::Head(HeadArg::Number(20))));
1748 let r = rs.select("status", Level::Ultra).unwrap();
1749 assert!(matches!(r.ops[0], Op::Head(HeadArg::Number(30))));
1750 }
1751
1752 #[test]
1753 fn alternation_in_selector_matches() {
1754 let rs = parse_ok(
1755 r#"
1756build|check, ultra:
1757 head 15
1758"#,
1759 );
1760 assert!(rs.select("build", Level::Ultra).is_some());
1761 assert!(rs.select("check", Level::Ultra).is_some());
1762 assert!(rs.select("test", Level::Ultra).is_none());
1763 assert!(rs.select("build", Level::Full).is_none());
1764 }
1765
1766 #[test]
1767 fn head_auto_keyword() {
1768 let rs = parse_ok(
1769 r#"
1770foo:
1771 head auto
1772"#,
1773 );
1774 assert!(matches!(rs.rules[0].ops[0], Op::Head(HeadArg::Auto)));
1775 }
1776
1777 #[test]
1778 fn regex_with_escaped_slash() {
1779 let rs = parse_ok(
1780 r#"
1781foo:
1782 keep /a\/b/
1783"#,
1784 );
1785 match &rs.rules[0].ops[0] {
1786 Op::Keep(p) => assert_eq!(p.source, "a/b"),
1787 _ => panic!(),
1788 }
1789 }
1790
1791 #[test]
1792 fn errors_on_unterminated_regex() {
1793 let err = parse("foo:\n keep /abc\n").unwrap_err();
1794 assert!(err.to_string().contains("unterminated regex"), "got: {err}");
1795 }
1796
1797 #[test]
1798 fn errors_on_unknown_op() {
1799 let err = parse("foo:\n nonsense 1\n").unwrap_err();
1800 assert!(err.to_string().contains("unknown op"), "got: {err}");
1801 }
1802
1803 #[test]
1804 fn errors_on_invalid_level() {
1805 let err = parse("foo, gigamax:\n head 5\n").unwrap_err();
1806 let chain = format!("{err:#}");
1809 assert!(chain.contains("unknown level"), "got: {chain}");
1810 }
1811
1812 #[test]
1813 fn errors_on_empty_rule_body() {
1814 let err = parse("foo:\nbar:\n head 5\n").unwrap_err();
1815 assert!(err.to_string().contains("rule has no ops"), "got: {err}");
1816 }
1817
1818 #[test]
1821 fn git_compact_plugin_parses() {
1822 let src = include_str!(
1823 "../../../plugins/git/git-compact/filter.lf"
1824 );
1825 let rs = parse_ok(src);
1826 assert_eq!(rs.defines.len(), 3);
1828 let names: Vec<&str> = rs.defines.iter().map(|d| d.name.as_str()).collect();
1829 assert_eq!(names, ["strip-trailers", "abbrev-hash", "compact-diff"]);
1830 assert_eq!(rs.defines[2].params, vec!["limit".to_string()]);
1831
1832 assert!(rs.select("status", Level::Full).is_some());
1834 assert!(rs.select("diff", Level::Ultra).is_some());
1835 assert!(rs.select("diff", Level::Lite).is_some());
1836 assert!(rs.select("diff", Level::Full).is_some());
1837 assert!(rs.select("log", Level::Ultra).is_some());
1838 assert!(rs.select("show", Level::Ultra).is_some());
1839 assert!(rs.select("show", Level::Full).is_some());
1840 assert!(rs.select("nothing", Level::Full).is_some());
1842
1843 let show_full = rs.select("show", Level::Full).unwrap();
1845 assert!(matches!(&show_full.ops[0], Op::Cascade(_)));
1846 }
1847
1848 fn ctx<'a>(sub: &'a str, level: Level) -> ExecCtx<'a> {
1851 ExecCtx {
1852 sub,
1853 level,
1854 exit_code: 0,
1855 args: &[],
1856 }
1857 }
1858
1859 #[test]
1860 fn exec_keep_drop_head_tail() {
1861 let rs = parse_ok(
1862 r#"
1863foo:
1864 keep /^a/
1865 drop /skip/
1866 head 3
1867"#,
1868 );
1869 let input = "alpha\nbeta\na-skip\namber\naxe\nakira\n";
1870 let out = execute(&rs, &ctx("foo", Level::Full), input).unwrap();
1871 assert_eq!(out, "alpha\namber\naxe\n");
1872 }
1873
1874 #[test]
1875 fn exec_tail() {
1876 let rs = parse_ok(
1877 r#"
1878foo:
1879 tail 2
1880"#,
1881 );
1882 let out = execute(&rs, &ctx("foo", Level::Full), "a\nb\nc\nd").unwrap();
1883 assert_eq!(out, "c\nd\n");
1884 }
1885
1886 #[test]
1887 fn exec_else_string_when_empty() {
1888 let rs = parse_ok(
1889 r#"
1890status:
1891 keep /^M /
1892 else "clean"
1893"#,
1894 );
1895 let out = execute(&rs, &ctx("status", Level::Full), "?? new.txt\n").unwrap();
1896 assert_eq!(out, "clean\n");
1897 }
1898
1899 #[test]
1900 fn exec_else_string_passthrough_when_nonempty() {
1901 let rs = parse_ok(
1902 r#"
1903status:
1904 keep /^M /
1905 else "clean"
1906"#,
1907 );
1908 let out = execute(&rs, &ctx("status", Level::Full), "M file.txt\n").unwrap();
1909 assert_eq!(out, "M file.txt\n");
1910 }
1911
1912 #[test]
1913 fn exec_no_match_passes_through() {
1914 let rs = parse_ok(
1915 r#"
1916foo:
1917 head 1
1918"#,
1919 );
1920 let input = "x\ny\nz";
1921 let out = execute(&rs, &ctx("other", Level::Full), input).unwrap();
1922 assert_eq!(out, input);
1923 }
1924
1925 #[test]
1926 fn exec_first_match_wins() {
1927 let rs = parse_ok(
1928 r#"
1929diff, ultra:
1930 head 1
1931diff:
1932 head 3
1933"#,
1934 );
1935 let input = "a\nb\nc\nd\n";
1936 let u = execute(&rs, &ctx("diff", Level::Ultra), input).unwrap();
1937 let f = execute(&rs, &ctx("diff", Level::Full), input).unwrap();
1938 assert_eq!(u, "a\n");
1939 assert_eq!(f, "a\nb\nc\n");
1940 }
1941
1942 #[test]
1943 fn exec_head_auto_uses_level() {
1944 let rs = parse_ok(
1945 r#"
1946foo:
1947 head auto
1948"#,
1949 );
1950 let input: String = (1..=80).map(|i| format!("{i}\n")).collect();
1951 let u = execute(&rs, &ctx("foo", Level::Ultra), &input).unwrap();
1952 let f = execute(&rs, &ctx("foo", Level::Full), &input).unwrap();
1953 let l = execute(&rs, &ctx("foo", Level::Lite), &input).unwrap();
1954 assert_eq!(u.lines().count(), 15);
1955 assert_eq!(f.lines().count(), 30);
1956 assert_eq!(l.lines().count(), 60);
1957 }
1958
1959 #[test]
1960 fn exec_shell_inline() {
1961 let rs = parse_ok(
1962 r#"
1963foo:
1964 shell: tr a-z A-Z
1965"#,
1966 );
1967 let out = execute(&rs, &ctx("foo", Level::Full), "hello\n").unwrap();
1968 assert_eq!(out.trim_end(), "HELLO");
1969 }
1970
1971 #[test]
1972 fn exec_shell_block() {
1973 let rs = parse_ok(
1974 r#"
1975foo:
1976 shell: |
1977 awk '{ print NR, $0 }'
1978"#,
1979 );
1980 let out = execute(&rs, &ctx("foo", Level::Full), "a\nb\n").unwrap();
1981 assert_eq!(out.trim_end(), "1 a\n2 b");
1982 }
1983
1984 #[test]
1985 fn exec_shell_sees_env_vars() {
1986 let rs = parse_ok(
1987 r#"
1988build:
1989 shell: printf '%s:%s' "$sub" "$level"
1990"#,
1991 );
1992 let out = execute(&rs, &ctx("build", Level::Ultra), "").unwrap();
1993 assert_eq!(out, "build:ultra\n");
1995 }
1996
1997 #[test]
1998 fn exec_else_shell_uses_raw_input() {
1999 let rs = parse_ok(
2000 r#"
2001diff:
2002 keep /^IMPOSSIBLE/
2003 else-shell: head -2
2004"#,
2005 );
2006 let out = execute(&rs, &ctx("diff", Level::Full), "x\ny\nz\n").unwrap();
2007 assert_eq!(out, "x\ny\n");
2008 }
2009
2010 #[test]
2011 fn exec_macro_expansion_with_args() {
2012 let rs = parse_ok(
2013 r#"
2014define n-up(count):
2015 shell: head -$1
2016
2017foo:
2018 n-up 2
2019"#,
2020 );
2021 let out = execute(&rs, &ctx("foo", Level::Full), "a\nb\nc\nd\n").unwrap();
2022 assert_eq!(out, "a\nb\n");
2023 }
2024
2025 #[test]
2026 fn exec_split_pre_post() {
2027 let rs = parse_ok(
2028 r#"
2029show:
2030 split /^diff /
2031 pre:
2032 head 1
2033 post:
2034 head 2
2035"#,
2036 );
2037 let input = "commit abc\nAuthor: x\nDate: y\ndiff --git a b\n+line1\n+line2\n+line3\n";
2038 let out = execute(&rs, &ctx("show", Level::Full), input).unwrap();
2039 assert_eq!(out, "commit abc\ndiff --git a b\n+line1\n");
2040 }
2041
2042 #[test]
2043 fn exec_split_no_match() {
2044 let rs = parse_ok(
2045 r#"
2046show:
2047 split /^diff /
2048 pre:
2049 head 2
2050 post:
2051 head 10
2052"#,
2053 );
2054 let out = execute(&rs, &ctx("show", Level::Full), "a\nb\nc\nd\n").unwrap();
2056 assert_eq!(out, "a\nb\n");
2057 }
2058
2059 #[test]
2060 fn exec_macro_arg_count_mismatch_errors() {
2061 let rs = parse_ok(
2062 r#"
2063define needs-two(a, b):
2064 head 1
2065
2066foo:
2067 needs-two 5
2068"#,
2069 );
2070 let err = execute(&rs, &ctx("foo", Level::Full), "x").unwrap_err();
2071 assert!(err.to_string().contains("expects 2 arg"), "got: {err}");
2072 }
2073
2074 #[test]
2075 fn exec_python_plain_when_no_pep723() {
2076 if Command::new("python3").arg("--version").output().is_err() {
2078 eprintln!("skipping: python3 not available");
2079 return;
2080 }
2081 let rs = parse_ok(
2082 r#"
2083foo:
2084 python: |
2085 import sys
2086 for line in sys.stdin:
2087 print(line.upper(), end="")
2088"#,
2089 );
2090 let out = execute(&rs, &ctx("foo", Level::Full), "hello\nworld\n").unwrap();
2091 assert_eq!(out, "HELLO\nWORLD\n");
2092 }
2093
2094 #[test]
2095 fn exec_macro_arg_substitution_in_shell() {
2096 let rs = parse_ok(
2097 r#"
2098define grab(limit):
2099 shell: |
2100 awk -v lim=$1 '{ if (NR<=lim) print }'
2101
2102foo:
2103 grab 3
2104"#,
2105 );
2106 let out = execute(&rs, &ctx("foo", Level::Full), "a\nb\nc\nd\ne\n").unwrap();
2107 assert_eq!(out, "a\nb\nc\n");
2108 }
2109
2110 #[test]
2111 fn pep723_detection() {
2112 assert!(has_pep723_header(
2113 "# /// script\n# dependencies = []\n# ///\nimport sys"
2114 ));
2115 assert!(has_pep723_header(
2116 " # /// script\n # ///\nimport sys"
2117 ));
2118 assert!(!has_pep723_header("import sys\nprint('hi')"));
2119 assert!(!has_pep723_header("# not pep 723\nprint('hi')"));
2120 }
2121
2122 #[test]
2123 fn kubectl_compact_plugin_parses() {
2124 let src = include_str!(
2125 "../../../plugins/kubectl/kubectl-compact/filter.lf"
2126 );
2127 let rs = parse_ok(src);
2128 assert_eq!(rs.defines.len(), 1);
2130 assert_eq!(rs.defines[0].name, "clean-yaml");
2131 match &rs.defines[0].ops[0] {
2132 Op::Python(body) => {
2133 assert!(body.contains("# /// script"));
2134 assert!(body.contains("dependencies = [\"pyyaml>=6\"]"));
2135 assert!(body.contains("yaml.safe_load_all"));
2136 }
2137 other => panic!("expected Python op, got {other:?}"),
2138 }
2139 assert!(rs.select("get", Level::Full).is_some());
2141 assert!(rs.select("logs", Level::Ultra).is_some());
2142 assert!(rs.select("logs", Level::Full).is_some());
2143 assert!(rs.select("events", Level::Ultra).is_some());
2144 assert!(rs.select("describe", Level::Full).is_some()); }
2146
2147 #[test]
2150 fn parse_cascade_arms() {
2151 let rs = parse_ok(
2152 r#"
2153diff:
2154 if exit failed: raw
2155 elif level ultra: head 5
2156 else: head 99
2157"#,
2158 );
2159 match &rs.rules[0].ops[..] {
2160 [Op::Cascade(branches)] => {
2161 assert_eq!(branches.len(), 3);
2162 assert!(branches[0].guard.is_some());
2163 assert!(branches[1].guard.is_some());
2164 assert!(branches[2].guard.is_none());
2165 }
2166 other => panic!("expected one Cascade op, got {other:?}"),
2167 }
2168 }
2169
2170 #[test]
2171 fn exec_cascade_branches_on_exit() {
2172 let rs = parse_ok(
2173 r#"
2174diff:
2175 if exit failed: raw
2176 else: head 1
2177"#,
2178 );
2179 let input = "a\nb\nc\n";
2180 let failed = ExecCtx { sub: "diff", level: Level::Full, exit_code: 1, args: &[] };
2181 let ok = ExecCtx { sub: "diff", level: Level::Full, exit_code: 0, args: &[] };
2182 assert_eq!(execute(&rs, &failed, input).unwrap(), "a\nb\nc\n");
2183 assert_eq!(execute(&rs, &ok, input).unwrap(), "a\n");
2184 }
2185
2186 #[test]
2187 fn exec_cascade_level_and_flag_guards() {
2188 let rs = parse_ok(
2189 r#"
2190diff:
2191 if level ultra and --stat: head 1
2192 elif --stat: head 2
2193 else: head 3
2194"#,
2195 );
2196 let input = "1\n2\n3\n4\n";
2197 let stat = vec!["--stat".to_string()];
2198 let ultra_stat = ExecCtx { sub: "diff", level: Level::Ultra, exit_code: 0, args: &stat };
2199 let full_stat = ExecCtx { sub: "diff", level: Level::Full, exit_code: 0, args: &stat };
2200 let plain = ExecCtx { sub: "diff", level: Level::Full, exit_code: 0, args: &[] };
2201 assert_eq!(execute(&rs, &ultra_stat, input).unwrap(), "1\n");
2202 assert_eq!(execute(&rs, &full_stat, input).unwrap(), "1\n2\n");
2203 assert_eq!(execute(&rs, &plain, input).unwrap(), "1\n2\n3\n");
2204 }
2205
2206 #[test]
2207 fn exec_cascade_no_match_no_else_passes_through() {
2208 let rs = parse_ok("diff:\n if exit failed: head 1\n");
2209 let out = execute(&rs, &ctx("diff", Level::Full), "x\ny\n").unwrap();
2210 assert_eq!(out, "x\ny\n");
2211 }
2212
2213 #[test]
2214 fn exec_raw_is_identity() {
2215 for kw in ["raw", "passthrough"] {
2217 let rs = parse_ok(&format!("diff:\n {kw}\n"));
2218 let out = execute(&rs, &ctx("diff", Level::Full), "x\ny\n").unwrap();
2219 assert_eq!(out, "x\ny\n");
2220 }
2221 }
2222
2223 #[test]
2224 fn glob_selector_matches_prefix() {
2225 let rs = parse_ok("apply*:\n head 1\n");
2226 assert!(rs.select("apply", Level::Full).is_some());
2227 assert!(rs.select("apply-set", Level::Full).is_some());
2228 assert!(rs.select("delete", Level::Full).is_none());
2229 }
2230
2231 #[test]
2232 fn or_is_alias_of_else() {
2233 let new = parse_ok("s:\n keep /Z/\n or \"clean\"\n");
2234 let old = parse_ok("s:\n keep /Z/\n else \"clean\"\n");
2235 assert_eq!(execute(&new, &ctx("s", Level::Full), "nope\n").unwrap(), "clean\n");
2236 assert_eq!(execute(&old, &ctx("s", Level::Full), "nope\n").unwrap(), "clean\n");
2237 }
2238
2239 #[test]
2240 fn errors_on_unknown_guard_value() {
2241 let chain = format!("{:#}", parse("diff:\n if exit boom: head 1\n").unwrap_err());
2242 assert!(chain.contains("unknown exit value"), "got: {chain}");
2243 }
2244}