Skip to main content

lowfat_core/
lf.rs

1//! lf — the lowfat filter DSL parser.
2//!
3//! Parses `.lf` files into a [`RuleSet`]. Execution lives elsewhere
4//! (Task 2+). The DSL is line-oriented and indentation-sensitive; we
5//! avoid INDENT/DEDENT tokens by working directly on `(indent, text)`
6//! pairs, which keeps the parser short and the error messages tied to
7//! source line numbers.
8
9use crate::level::Level;
10use anyhow::{Context, Result, anyhow, bail};
11use regex::Regex;
12
13// ──────────────────────────────────────────────────────────────────
14// AST
15// ──────────────────────────────────────────────────────────────────
16
17#[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    /// `if` / `elif` / `else` cascade — first matching branch runs.
71    Cascade(Vec<Branch>),
72}
73
74/// One arm of an [`Op::Cascade`]. `guard: None` is the `else` arm.
75#[derive(Debug, Clone)]
76pub struct Branch {
77    pub guard: Option<Guard>,
78    pub ops: Vec<Op>,
79}
80
81/// A guard is an AND of atoms — `if level ultra and --stat:`.
82#[derive(Debug, Clone)]
83pub struct Guard {
84    pub atoms: Vec<Atom>,
85}
86
87/// One closed-vocabulary condition inside a [`Guard`].
88#[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
119// ──────────────────────────────────────────────────────────────────
120// Selection
121// ──────────────────────────────────────────────────────────────────
122
123impl RuleSet {
124    /// First-match-wins. Returns `None` when no rule matches.
125    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// ──────────────────────────────────────────────────────────────────
149// Line preprocessing
150// ──────────────────────────────────────────────────────────────────
151
152#[derive(Debug, Clone)]
153struct Line {
154    indent: usize,
155    text: String, // trimmed of leading/trailing whitespace; "" if blank
156    raw: String,  // original line, no trailing newline
157    line_no: usize,
158    /// Blank or starts with `#` at top-level. Meta lines are skipped by
159    /// the structural parser but preserved as-is in block bodies.
160    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
184// ──────────────────────────────────────────────────────────────────
185// Parser
186// ──────────────────────────────────────────────────────────────────
187
188const 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    /// Advance past meta lines and return the next structural line without
243    /// consuming it.
244    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    // ── top-level ────────────────────────────────────────────────
268
269    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            // Inline ops after `:` are always a pipeline (v1 form).
333            ops.extend(self.parse_inline_ops(inline, line_no)?);
334            ops.extend(self.parse_indented_ops(parent_indent)?);
335        } else {
336            // An indented body may be a pipeline or an if/elif/else cascade.
337            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    // ── op chains ────────────────────────────────────────────────
352
353    /// Parse op-lines strictly deeper-indented than `parent_indent`.
354    /// Stops at first significant line whose indent <= parent_indent.
355    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    /// An indented rule body: a plain pipeline, or an `if`/`elif`/`else`
371    /// cascade when the first significant line opens with `if`.
372    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    /// Parse `if` / `elif`* / `else`? arms — all share one indent.
386    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            // `else` is glued to its colon (`else:`), so take the leading
403            // alphabetic run rather than the whitespace-delimited word.
404            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; // `else` is always the last arm
423            }
424        }
425        Ok(branches)
426    }
427
428    /// Parse one cascade arm: `<if|elif|else> <guard>:` then inline or
429    /// indented ops.
430    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    /// Parse a single op from the current significant line, advancing
460    /// past any block bodies and sub-blocks the op consumes.
461    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    /// Parse a `shell:` or `python:` body. Two forms:
538    ///   inline: `shell: <command on rest of line>`
539    ///   block:  `shell: |` then indented body lines until dedent.
540    /// Body lines preserve internal blank lines and relative indentation.
541    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        // Block form: scan lines until indent drops back to parent_indent.
561        // Include blank lines that fall between body lines.
562        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        // Trim trailing blank lines (they belong to the gap, not the body).
580        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    /// After a `split /regex/`, consume any sibling `pre:` / `post:`
603    /// blocks at the same indent.
604    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    /// Parse multiple ops appearing on the same line (after a rule
630    /// header's `:`). `shell:` / `python:` / `else-shell:` greedily
631    /// consume rest of line; other ops yield to the next op keyword
632    /// or macro name.
633    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
717// ──────────────────────────────────────────────────────────────────
718// Sub-parsers (free functions, no Parser state)
719// ──────────────────────────────────────────────────────────────────
720
721fn 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
765/// Glob match for subcommand selectors. `*` matches any run of chars
766/// (including empty); no other metacharacters. With no `*` it is an
767/// exact compare, so plain selectors behave exactly as in v1.
768fn 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
787/// Parse a guard — an AND of atoms joined by ` and `.
788fn 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
803/// Parse one guard atom: `exit ok|failed`, `level ultra|full|lite`, or a
804/// `--flag` / `-x`.
805fn 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
1040// ──────────────────────────────────────────────────────────────────
1041// Execution
1042// ──────────────────────────────────────────────────────────────────
1043
1044use std::io::Write;
1045use std::process::{Command, Stdio};
1046
1047/// Per-invocation context passed to the executor and propagated as env
1048/// vars to `shell:` / `python:` subprocesses.
1049#[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
1057/// Run the matching rule against `input` and return the filtered output.
1058/// If no rule matches, the input is returned unchanged (passthrough).
1059///
1060/// Non-empty output always ends in a newline, matching the convention
1061/// of shell tools like `echo` and `grep`.
1062pub 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/// One stage's input/output stats, recorded by [`execute_explain`].
1078#[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    /// Index into `RuleSet::rules` of the matched rule (None if no match).
1091    pub matched_rule: Option<usize>,
1092    pub stages: Vec<StageRecord>,
1093}
1094
1095/// Like [`execute`] but records per-op stats. Only top-level ops are
1096/// recorded — macros and split sub-chains run silently. Adds ~µs of
1097/// overhead per op for line/byte counting; safe for interactive use,
1098/// avoid in tight loops.
1099pub 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            // No arm matched and no `else` — leave the stream untouched.
1236            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
1281/// A guard holds when every atom holds (AND).
1282fn 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
1319/// Split input at the first line matching `re`. The matching line goes
1320/// into `post`. If no line matches, everything is `pre` and `post` is
1321/// empty.
1322fn 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
1348/// Replace `$1`..`$9` with macro positional args. Other `$NAME` tokens
1349/// (e.g. `$level`, `$sub`) are left intact so shell can expand them
1350/// from env vars.
1351fn 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
1456/// PEP 723: write the body to a temp file and let `uv run --script` resolve
1457/// inline dependencies. Data flows via stdin to the script.
1458fn 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// ──────────────────────────────────────────────────────────────────
1505// Tests
1506// ──────────────────────────────────────────────────────────────────
1507
1508#[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                // Blank line between imports and loop preserved
1644                assert!(s.contains("yaml\n\nfor"));
1645                // Internal indent preserved (4 spaces under `for`)
1646                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        // anyhow only renders the outermost message via Display; use {:#}
1805        // to walk the cause chain.
1806        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    // ── full plugin files parse cleanly ──────────────────────────
1817
1818    #[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        // Defines: strip-trailers, abbrev-hash, compact-diff
1825        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        // Selection sanity
1831        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        // Catch-all
1839        assert!(rs.select("nothing", Level::Full).is_some());
1840
1841        // Show rule is now a level cascade.
1842        let show_full = rs.select("show", Level::Full).unwrap();
1843        assert!(matches!(&show_full.ops[0], Op::Cascade(_)));
1844    }
1845
1846    // ── executor ─────────────────────────────────────────────────
1847
1848    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        // ensure_trailing_newline normalizes shell output without a final \n
1992        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        // No `diff ` line — everything goes to pre, post is empty.
2053        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        // Skip if python3 not on PATH.
2075        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        // Define: clean-yaml (with PEP 723 body)
2127        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        // get/logs/events/* selection
2138        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()); // catch-all
2143    }
2144
2145    // ── v2: cascades, guards, globs ───────────────────────────────
2146
2147    #[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}