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