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