Skip to main content

sqz_engine/
shell_scan.rs

1//! Just enough POSIX shell parsing for the PreToolUse rewrite to decide
2//! whether a command can run as `{ cmd; } | sqz compress`.
3//!
4//! A pipeline element runs in a subshell, so a builtin that changes shell
5//! state (cd, export, source, set, ...) stops affecting the session once
6//! the command is grouped, and a background job keeps the pipe open until
7//! it exits. [`plan`] refuses those and anything it can't follow, and
8//! accepts the rest: pipes, `&&`/`||`/`;` lists, loops, redirects,
9//! heredocs and comments. A leading `cd DIR &&` stays outside the group.
10
11/// Why a command is left alone.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum Skip {
14    /// Syntax the scanner doesn't follow, or that the shell would reject.
15    Unparsed,
16    /// A background job would hold the pipe open.
17    Background,
18    /// A builtin whose effect has to outlive the command.
19    StateChange,
20    /// An editor, pager, REPL, follower or server.
21    LongRunning,
22    /// The command already runs or pipes through sqz.
23    AlreadySqz,
24    /// Nothing to compress once a leading `cd` is set aside.
25    Empty,
26    /// A failed expansion would end one line and the shell would run the next.
27    LineError,
28}
29
30/// A command that can be piped through sqz.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Plan<'a> {
33    /// A leading run of `cd DIR &&` (or `;`), kept outside the group so the
34    /// directory change still applies afterwards. Usually empty.
35    pub prefix: &'a str,
36    /// What runs inside the group.
37    pub body: &'a str,
38    /// `body` is one command with no list, pipe, newline, comment or
39    /// heredoc, so it can take the one-line form and its formatter.
40    pub simple: bool,
41}
42
43/// Builtins whose effect is lost, or changes meaning, inside a subshell.
44const STATE_BUILTINS: &[&str] = &[
45    "cd", "pushd", "popd", "export", "unset", "source", ".", "alias", "unalias", "set",
46    "shopt", "setopt", "unsetopt", "ulimit", "umask", "trap", "eval", "exec", "exit",
47    "return", "logout", "declare", "typeset", "local", "readonly", "let", "hash", "rehash",
48    "enable", "bind", "complete", "autoload", "zmodload", "wait", "fg", "bg", "jobs",
49    "disown", "suspend", "history", "fc",
50];
51
52/// Decide whether, and how, `command` can be piped through sqz.
53pub fn plan(command: &str) -> Result<Plan<'_>, Skip> {
54    let zsh = basename(&std::env::var("SHELL").unwrap_or_default()) == "zsh";
55    plan_for(command, if zsh { Shell::Zsh } else { Shell::Bash })
56}
57
58fn plan_for(command: &str, shell: Shell) -> Result<Plan<'_>, Skip> {
59    let command = command.trim_end();
60    let mut scan = Scanner::new(command);
61    scan.list(false)?;
62    if !scan.pending.is_empty() {
63        return Err(Skip::Unparsed);
64    }
65    let segs = segments(command, &scan.toks)?;
66    if segs.iter().filter_map(|s| s.cmd.as_deref()).any(|c| is_sqz(basename(c))) {
67        return Err(Skip::AlreadySqz);
68    }
69
70    let mut hoisted = 0;
71    let mut prefix_end = 0;
72    for seg in &segs {
73        let leading_cd = seg.cmd.as_deref() == Some("cd")
74            && !seg.assign
75            && seg.kw == 0
76            && matches!(seg.sep, Some(Sep::And | Sep::Semi | Sep::Newline));
77        if !leading_cd {
78            break;
79        }
80        hoisted += 1;
81        prefix_end = seg.end;
82    }
83    let rest = &segs[hoisted..];
84    if rest.is_empty() {
85        return Err(Skip::Empty);
86    }
87    for seg in rest {
88        match seg.cmd.as_deref() {
89            Some(cmd) => {
90                if STATE_BUILTINS.contains(&cmd)
91                    || (cmd == "kill" && seg.args.iter().any(|a| a.starts_with('%')))
92                {
93                    return Err(Skip::StateChange);
94                }
95                if long_running(basename(cmd), &seg.args) {
96                    return Err(Skip::LongRunning);
97                }
98            }
99            // A bare assignment sets a shell variable, in a loop or `if` body too.
100            None if seg.assign && !seg.subshell => return Err(Skip::StateChange),
101            None => {}
102        }
103    }
104    if watch_text(command) {
105        return Err(Skip::LongRunning);
106    }
107    if hoisted > 0
108        && segs[hoisted - 1].sep == Some(Sep::And)
109        && rest[..rest.len() - 1]
110            .iter()
111            .any(|s| matches!(s.sep, Some(Sep::Semi | Sep::Or | Sep::Newline)))
112    {
113        return Err(Skip::StateChange);
114    }
115    let line = segs[..hoisted]
116        .iter()
117        .rposition(|s| s.sep == Some(Sep::Newline))
118        .map_or(0, |i| i + 1);
119    let line_error = (line..segs.len()).any(|i| {
120        segs[i].fatal == Fatal::Any
121            && segs[i..segs.len() - 1].iter().any(|s| s.sep == Some(Sep::Newline))
122    });
123    if shell == Shell::Bash && line_error {
124        return Err(Skip::LineError);
125    }
126
127    let simple = rest.len() == 1
128        && rest[0].sep.is_none()
129        && rest[0].kw == 0
130        && !scan.comment
131        && !scan.heredoc;
132    Ok(Plan {
133        prefix: &command[..prefix_end],
134        body: &command[prefix_end..],
135        simple,
136    })
137}
138
139/// One command of a [`flat`] list.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct FlatCmd {
142    /// Byte offset just past the command's last token.
143    pub end: usize,
144    /// The stats and formatter label, set when this command's output
145    /// should go through sqz.
146    pub label: Option<String>,
147    /// The command doesn't place stderr itself, so `2>&1` goes before the pipe.
148    pub merge_stderr: bool,
149}
150
151/// Commands too quiet to be worth a pipe.
152const QUIET: &[&str] = &[
153    "true", "false", ":", "sleep", "mkdir", "rmdir", "touch", "rm", "mv", "cp", "ln", "chmod",
154    "chown", "chgrp", "echo", "printf", "test", "[", "kill", "read", "clear", "unlink",
155    "truncate",
156];
157
158/// Split `command` into simple commands joined by `&&`, `||`, `;` or
159/// newlines, marking the ones whose output can go through sqz on their
160/// own. Nothing is grouped, so builtins keep running in the shell itself
161/// and a permission checker sees the user's commands as written.
162///
163/// `None` for a pipeline, subshell, group, loop, conditional, heredoc or
164/// background job, or when no command is worth piping.
165pub fn flat(command: &str) -> Option<Vec<FlatCmd>> {
166    flat_segments(command).map(|(_, cmds)| cmds)
167}
168
169/// The user's shell, for the expansions only zsh stops on.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum Shell {
172    Bash,
173    Zsh,
174}
175
176impl Shell {
177    /// From `$SHELL`: anything but bash counts as zsh.
178    pub fn from_env() -> Shell {
179        let shell = std::env::var("SHELL").unwrap_or_default();
180        if basename(&shell) == "bash" {
181            Shell::Bash
182        } else {
183            Shell::Zsh
184        }
185    }
186}
187
188/// [`flat`], unless piping its commands could run one the original never runs in `shell`.
189pub fn flat_for(command: &str, shell: Shell) -> Option<Vec<FlatCmd>> {
190    let (segs, cmds) = flat_segments(command)?;
191    let stops = |fatal| fatal == Fatal::Any || (fatal == Fatal::Zsh && shell == Shell::Zsh);
192    let runs_on = (0..segs.len()).any(|i| {
193        cmds[i].label.is_some()
194            && stops(segs[i].fatal)
195            && segs[i..segs.len() - 1]
196                .iter()
197                .any(|s| matches!(s.sep, Some(Sep::Semi | Sep::Newline | Sep::Or)))
198    });
199    (!runs_on).then_some(cmds)
200}
201
202fn flat_segments(command: &str) -> Option<(Vec<Seg>, Vec<FlatCmd>)> {
203    let command = command.trim_end();
204    let mut scan = Scanner::new(command);
205    scan.list(false).ok()?;
206    if !scan.pending.is_empty() || scan.heredoc {
207        return None;
208    }
209    let segs = segments(command, &scan.toks).ok()?;
210    if segs.iter().any(|s| s.sep == Some(Sep::Pipe) || s.subshell || s.kw > 0) {
211        return None;
212    }
213    if segs.iter().filter_map(|s| s.cmd.as_deref()).any(|c| is_sqz(basename(c))) {
214        return None;
215    }
216    let braces = command.contains(['{', '}']);
217    let cmds: Vec<FlatCmd> = segs
218        .iter()
219        .map(|seg| {
220            let label = seg.cmd.as_deref().and_then(|cmd| {
221                let base = basename(cmd);
222                let text = &command[seg.first.unwrap_or(0)..seg.last];
223                let skip = STATE_BUILTINS.contains(&cmd)
224                    || QUIET.contains(&base)
225                    || seg.out
226                    || long_running(base, &seg.args)
227                    || watch_text(text);
228                if skip {
229                    return None;
230                }
231                flat_label(command, &seg.words, braces)
232            });
233            FlatCmd { end: seg.last, label, merge_stderr: !seg.err }
234        })
235        .collect();
236    cmds.iter().any(|c| c.label.is_some()).then_some((segs, cmds))
237}
238
239/// The command word and the plain words after it, up to the first one with
240/// quotes, expansions or globs. With braces elsewhere in the command the
241/// label stays one unquoted word: some permission checkers treat a brace
242/// and a quote in the same command as possible obfuscation.
243fn flat_label(command: &str, words: &[(usize, usize)], braces: bool) -> Option<String> {
244    let plain = |w: &str| {
245        !w.is_empty()
246            && w.bytes().all(|c| c.is_ascii_alphanumeric() || b"_./:@+,=-".contains(&c))
247    };
248    let mut label = String::new();
249    for &(start, end) in words {
250        let word = &command[start..end];
251        if !plain(word) || label.len() + word.len() + 1 > 100 {
252            break;
253        }
254        if !label.is_empty() {
255            if braces {
256                break;
257            }
258            label.push(' ');
259        }
260        label.push_str(word);
261    }
262    (!label.is_empty()).then_some(label)
263}
264
265/// Watch and dev-server spellings matched on the whole command.
266fn watch_text(cmd: &str) -> bool {
267    cmd.contains("--watch")
268        || cmd.contains("run dev")
269        || cmd.contains("run start")
270        || cmd.contains("run serve")
271}
272
273/// A command's name with its directory and any `.exe` suffix removed.
274pub fn basename(cmd: &str) -> &str {
275    let base = cmd.rsplit(['/', '\\']).next().unwrap_or(cmd);
276    match base.len().checked_sub(4) {
277        Some(at) if base.get(at..).is_some_and(|e| e.eq_ignore_ascii_case(".exe")) => &base[..at],
278        _ => base,
279    }
280}
281
282fn is_sqz(base: &str) -> bool {
283    matches!(base, "sqz" | "sqz-mcp")
284}
285
286/// Editors, pagers, REPLs, followers and servers: commands whose output has
287/// to reach the agent while they run, or that never end.
288fn long_running(base: &str, args: &[String]) -> bool {
289    let has = |flag: &str| args.iter().any(|a| a == flag);
290    let short = |c: char| {
291        args.iter()
292            .any(|a| a.len() > 1 && a.starts_with('-') && !a.starts_with("--") && a[1..].contains(c))
293    };
294    let words: Vec<&str> = args.iter().map(String::as_str).filter(|a| !a.starts_with('-')).collect();
295    let sub = words.first().copied();
296    match base {
297        "vim" | "vi" | "nvim" | "nano" | "emacs" | "pico" | "less" | "more" | "most" | "top"
298        | "htop" | "btop" | "ssh" | "mosh" | "tmux" | "screen" | "watch" | "yes" | "stern"
299        | "ngrok" | "nodemon" | "uvicorn" | "gunicorn" | "hypercorn" | "http-server"
300        | "live-server" => true,
301        "python" | "python2" | "python3" | "node" | "irb" | "ghci" | "psql" | "mysql"
302        | "sqlite3" | "mongo" | "mongosh" | "redis-cli" => !finite_runtime(base, args, &words),
303        "tail" => short('f') || short('F') || has("--follow"),
304        "journalctl" => short('f') || has("--follow"),
305        "kubectl" | "oc" => match sub {
306            Some("logs") => short('f') || has("--follow"),
307            Some("get") => short('w') || has("--watch"),
308            Some("port-forward" | "proxy" | "attach") => true,
309            _ => false,
310        },
311        "docker" | "podman" | "nerdctl" | "docker-compose" => {
312            let words = if sub == Some("compose") { &words[1..] } else { &words[..] };
313            match words.first().copied() {
314                Some("logs") => short('f') || has("--follow"),
315                Some("events" | "attach" | "watch") => true,
316                Some("stats") => !has("--no-stream"),
317                Some("up") => !(short('d') || has("--detach") || has("--wait")),
318                _ => false,
319            }
320        }
321        "ping" | "ping6" => !short('c'),
322        "npm" | "pnpm" | "yarn" | "bun" => {
323            let script = if sub == Some("run") { words.get(1).copied() } else { sub };
324            matches!(script, Some("dev" | "start" | "serve" | "watch" | "preview"))
325        }
326        "vite" => !matches!(sub, Some("build" | "optimize")),
327        "next" | "nuxt" | "astro" | "remix" => matches!(sub, Some("dev" | "start" | "preview")),
328        "flask" => sub == Some("run"),
329        "rails" => matches!(sub, Some("server" | "s" | "console" | "c")),
330        "php" => has("-S"),
331        "hugo" | "mdbook" | "mkdocs" | "jekyll" | "zola" => {
332            matches!(sub, Some("server" | "serve" | "watch"))
333        }
334        "cargo" => sub == Some("watch"),
335        "tsc" | "jest" | "vitest" | "webpack" | "rollup" | "esbuild" | "parcel" | "babel"
336        | "sass" | "tailwindcss" => short('w') || has("--watch"),
337        _ => false,
338    }
339}
340
341/// A runtime or database client invoked to do one thing and exit, rather
342/// than a REPL, a server or a script that may be either.
343fn finite_runtime(base: &str, args: &[String], words: &[&str]) -> bool {
344    let has = |flag: &str| args.iter().any(|a| a == flag);
345    if has("-i") || has("--interactive") {
346        return false;
347    }
348    match base {
349        "python" | "python2" | "python3" => {
350            if let Some(module) = args.iter().position(|a| a == "-m").and_then(|i| args.get(i + 1)) {
351                return !matches!(
352                    module.as_str(),
353                    "http.server" | "uvicorn" | "gunicorn" | "flask" | "IPython" | "ipython"
354                        | "pdb" | "code" | "asyncio" | "idlelib"
355                );
356            }
357            if words.first().is_some_and(|w| w.ends_with("manage.py")) {
358                return !matches!(
359                    words.get(1).copied(),
360                    None | Some("runserver" | "runserver_plus" | "shell" | "shell_plus" | "dbshell")
361                );
362            }
363            // A script, `-c` code or `-` (stdin, usually a heredoc); bare
364            // `python` is the REPL.
365            !words.is_empty() || has("-c") || has("-") || has("--version") || has("-V")
366                || has("-h") || has("--help")
367        }
368        "node" => {
369            has("-") || has("-e") || has("--eval") || has("-p") || has("--print") || has("--test")
370                || has("-v") || has("--version") || has("-c") || has("--check")
371        }
372        "psql" => {
373            has("-c") || has("-f") || has("--command") || has("--file") || has("-l") || has("--list")
374        }
375        "mysql" => has("-e") || has("--execute"),
376        "sqlite3" => words.len() >= 2,
377        "mongo" | "mongosh" => has("--eval"),
378        _ => false,
379    }
380}
381
382// ── Lexer ────────────────────────────────────────────────────────────────────
383
384type Res<T> = Result<T, Skip>;
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387enum Sep {
388    Semi,
389    And,
390    Or,
391    Pipe,
392    Newline,
393}
394
395#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
396enum Fatal {
397    #[default]
398    No,
399    Zsh,
400    Any,
401}
402
403#[derive(Debug)]
404enum Tok {
405    Word { start: usize, end: usize, plain: String, fatal: Fatal },
406    Sep { kind: Sep, end: usize },
407    /// `out`: stdout goes somewhere other than the pipe. `err`: the
408    /// command already says where stderr goes.
409    Redirect { start: usize, end: usize, out: bool, err: bool, fatal: Fatal },
410    Subshell,
411}
412
413const MAX_NESTING: usize = 64;
414
415struct Scanner<'a> {
416    s: &'a str,
417    b: &'a [u8],
418    i: usize,
419    toks: Vec<Tok>,
420    /// Heredocs whose bodies start after the next unquoted newline:
421    /// (delimiter, strip leading tabs).
422    pending: Vec<(String, bool)>,
423    comment: bool,
424    heredoc: bool,
425    nesting: usize,
426    subshells: usize,
427    fatal: Fatal,
428}
429
430impl<'a> Scanner<'a> {
431    fn new(s: &'a str) -> Self {
432        Scanner {
433            s,
434            b: s.as_bytes(),
435            i: 0,
436            toks: Vec::new(),
437            pending: Vec::new(),
438            comment: false,
439            heredoc: false,
440            nesting: 0,
441            subshells: 0,
442            fatal: Fatal::No,
443        }
444    }
445
446    fn nest(&mut self) -> Res<()> {
447        self.nesting += 1;
448        if self.nesting > MAX_NESTING {
449            return Err(Skip::Unparsed);
450        }
451        Ok(())
452    }
453
454    fn flag(&mut self, fatal: Fatal) {
455        if self.subshells == 0 {
456            self.fatal = self.fatal.max(fatal);
457        }
458    }
459
460    fn peek(&self, k: usize) -> Option<u8> {
461        self.b.get(self.i + k).copied()
462    }
463
464    /// Step over one whole char, optionally keeping it.
465    fn bump(&mut self, keep: Option<&mut String>) {
466        if let Some(ch) = self.s[self.i..].chars().next() {
467            if let Some(plain) = keep {
468                plain.push(ch);
469            }
470            self.i += ch.len_utf8();
471        }
472    }
473
474    fn blanks(&mut self) {
475        loop {
476            match (self.peek(0), self.peek(1)) {
477                (Some(b' ' | b'\t'), _) => self.i += 1,
478                (Some(b'\\'), Some(b'\n')) => self.i += 2,
479                _ => return,
480            }
481        }
482    }
483
484    fn sep(&mut self, kind: Sep, nested: bool) {
485        if !nested {
486            self.toks.push(Tok::Sep { kind, end: self.i });
487        }
488    }
489
490    /// Scan a command list. The top level keeps its tokens; a nested list
491    /// (`( )`, `$( )`, `<( )`) ends at its `)` and only gets the syntax and
492    /// background checks.
493    fn list(&mut self, nested: bool) -> Res<()> {
494        if nested {
495            self.nest()?;
496            self.subshells += 1;
497        }
498        loop {
499            self.blanks();
500            let Some(c) = self.peek(0) else {
501                return if nested { Err(Skip::Unparsed) } else { Ok(()) };
502            };
503            match c {
504                b'\n' => {
505                    self.i += 1;
506                    self.sep(Sep::Newline, nested);
507                    self.heredoc_bodies()?;
508                }
509                b'#' => {
510                    self.comment = true;
511                    while self.peek(0).is_some_and(|c| c != b'\n') {
512                        self.i += 1;
513                    }
514                }
515                b';' => {
516                    if matches!(self.peek(1), Some(b';' | b'&')) {
517                        return Err(Skip::Unparsed);
518                    }
519                    self.i += 1;
520                    self.sep(Sep::Semi, nested);
521                }
522                b'&' => match self.peek(1) {
523                    Some(b'&') => {
524                        self.i += 2;
525                        self.sep(Sep::And, nested);
526                    }
527                    Some(b'>') => self.redirect(nested, self.i, None)?,
528                    _ => return Err(Skip::Background),
529                },
530                b'|' => {
531                    let (kind, len) = match self.peek(1) {
532                        Some(b'|') => (Sep::Or, 2),
533                        Some(b'&') => (Sep::Pipe, 2),
534                        _ => (Sep::Pipe, 1),
535                    };
536                    self.i += len;
537                    self.sep(kind, nested);
538                }
539                b'(' => {
540                    if self.peek(1) == Some(b'(') {
541                        return Err(Skip::Unparsed);
542                    }
543                    self.i += 1;
544                    self.list(true)?;
545                    if !nested {
546                        self.toks.push(Tok::Subshell);
547                    }
548                }
549                b')' => {
550                    if nested {
551                        self.i += 1;
552                        self.nesting -= 1;
553                        self.subshells -= 1;
554                        return Ok(());
555                    }
556                    return Err(Skip::Unparsed);
557                }
558                b'<' | b'>' if self.peek(1) != Some(b'(') => self.redirect(nested, self.i, None)?,
559                _ => {
560                    let digits = self.b[self.i..].iter().take_while(|c| c.is_ascii_digit()).count();
561                    let op = self.b.get(self.i + digits).copied();
562                    let after = self.b.get(self.i + digits + 1).copied();
563                    if digits > 0 && matches!(op, Some(b'<' | b'>')) && after != Some(b'(') {
564                        let start = self.i;
565                        let fd = self.s[start..start + digits].parse().ok();
566                        self.i += digits;
567                        self.redirect(nested, start, fd)?;
568                    } else {
569                        self.word(nested)?;
570                    }
571                }
572            }
573        }
574    }
575
576    fn word(&mut self, nested: bool) -> Res<()> {
577        let (start, plain) = self.word_text()?;
578        if nested {
579            // `case` patterns end in an unbalanced `)`.
580            if plain == "case" {
581                return Err(Skip::Unparsed);
582            }
583        } else {
584            let fatal = std::mem::take(&mut self.fatal);
585            self.toks.push(Tok::Word { start, end: self.i, plain, fatal });
586        }
587        Ok(())
588    }
589
590    /// One word, returned with quotes and escapes removed. Expansions are
591    /// skipped over and leave a `$` or `` ` `` in their place.
592    fn word_text(&mut self) -> Res<(usize, String)> {
593        let start = self.i;
594        let mut plain = String::new();
595        let mut glob = false;
596        while let Some(c) = self.peek(0) {
597            match c {
598                b' ' | b'\t' | b'\n' | b';' | b'&' | b'|' | b'(' | b')' => break,
599                b'<' | b'>' => {
600                    if self.peek(1) != Some(b'(') {
601                        break;
602                    }
603                    self.i += 2;
604                    self.list(true)?;
605                    plain.push_str("<()");
606                }
607                b'\\' => match self.peek(1) {
608                    None => return Err(Skip::Unparsed),
609                    Some(b'\n') => self.i += 2,
610                    Some(_) => {
611                        self.i += 1;
612                        self.bump(Some(&mut plain));
613                    }
614                },
615                b'\'' => {
616                    let close = self.s[self.i + 1..].find('\'').ok_or(Skip::Unparsed)?;
617                    plain.push_str(&self.s[self.i + 1..self.i + 1 + close]);
618                    self.i += close + 2;
619                }
620                b'"' => self.double_quoted(&mut plain)?,
621                b'`' => {
622                    self.backtick()?;
623                    plain.push('`');
624                }
625                b'$' => self.dollar(&mut plain, false)?,
626                b'*' | b'?' | b'[' => {
627                    glob = true;
628                    self.bump(Some(&mut plain));
629                }
630                _ => self.bump(Some(&mut plain)),
631            }
632        }
633        if self.i == start {
634            return Err(Skip::Unparsed);
635        }
636        let raw = &self.s[start..self.i];
637        if (glob && raw != "[") || zsh_lookup(raw) {
638            self.flag(Fatal::Zsh);
639        }
640        Ok((start, plain))
641    }
642
643    fn double_quoted(&mut self, plain: &mut String) -> Res<()> {
644        self.i += 1;
645        loop {
646            match self.peek(0) {
647                None => return Err(Skip::Unparsed),
648                Some(b'"') => {
649                    self.i += 1;
650                    return Ok(());
651                }
652                Some(b'\\') => match self.peek(1) {
653                    None => return Err(Skip::Unparsed),
654                    Some(n @ (b'$' | b'`' | b'"' | b'\\')) => {
655                        plain.push(n as char);
656                        self.i += 2;
657                    }
658                    Some(b'\n') => self.i += 2,
659                    Some(_) => {
660                        plain.push('\\');
661                        self.i += 1;
662                    }
663                },
664                Some(b'`') => {
665                    self.backtick()?;
666                    plain.push('`');
667                }
668                Some(b'$') => self.dollar(plain, true)?,
669                Some(_) => self.bump(Some(plain)),
670            }
671        }
672    }
673
674    /// `$` and what follows it: `$(...)`, `$((...))`, `${...}`, `$'...'`.
675    fn dollar(&mut self, plain: &mut String, quoted: bool) -> Res<()> {
676        plain.push('$');
677        match (self.peek(1), self.peek(2)) {
678            (Some(b'('), Some(b'(')) => {
679                self.flag(Fatal::Any);
680                self.i += 3;
681                self.closing(b'(', b')', 2)
682            }
683            (Some(b'('), _) => {
684                self.i += 2;
685                self.list(true)
686            }
687            (Some(b'{'), _) => {
688                if !plain_param(&self.b[self.i + 2..]) {
689                    self.flag(Fatal::Any);
690                }
691                self.i += 2;
692                self.closing(b'{', b'}', 1)
693            }
694            (Some(b'['), _) => {
695                self.flag(Fatal::Any);
696                self.i += 1;
697                Ok(())
698            }
699            (Some(b'?' | b'*'), _) => {
700                self.i += 1;
701                self.bump(Some(plain));
702                Ok(())
703            }
704            (Some(b'\''), _) if !quoted => {
705                self.i += 2;
706                loop {
707                    match self.peek(0) {
708                        None => return Err(Skip::Unparsed),
709                        Some(b'\'') => {
710                            self.i += 1;
711                            return Ok(());
712                        }
713                        Some(b'\\') if self.peek(1).is_some() => {
714                            self.i += 1;
715                            self.bump(None);
716                        }
717                        Some(_) => self.bump(None),
718                    }
719                }
720            }
721            _ => {
722                self.i += 1;
723                Ok(())
724            }
725        }
726    }
727
728    /// Skip to the `close` that balances `depth` opens already consumed, for
729    /// `$(( ))` and `${ }`.
730    fn closing(&mut self, open: u8, close: u8, mut depth: usize) -> Res<()> {
731        self.nest()?;
732        let mut scratch = String::new();
733        loop {
734            match self.peek(0) {
735                None => return Err(Skip::Unparsed),
736                Some(b'\\') => {
737                    if self.peek(1).is_none() {
738                        return Err(Skip::Unparsed);
739                    }
740                    self.i += 1;
741                    self.bump(None);
742                }
743                Some(b'\'') => {
744                    let end = self.s[self.i + 1..].find('\'').ok_or(Skip::Unparsed)?;
745                    self.i += end + 2;
746                }
747                Some(b'"') => self.double_quoted(&mut scratch)?,
748                Some(b'`') => self.backtick()?,
749                Some(b'$') if self.peek(1) == Some(b'(') && self.peek(2) != Some(b'(') => {
750                    self.i += 2;
751                    self.list(true)?;
752                }
753                Some(b'$') if matches!(self.peek(1), Some(b'{' | b'[' | b'(')) => {
754                    self.flag(Fatal::Any);
755                    self.i += 1;
756                }
757                Some(c) if c == open => {
758                    depth += 1;
759                    self.i += 1;
760                }
761                Some(c) if c == close => {
762                    depth -= 1;
763                    self.i += 1;
764                    if depth == 0 {
765                        self.nesting -= 1;
766                        return Ok(());
767                    }
768                }
769                Some(_) => self.bump(None),
770            }
771        }
772    }
773
774    fn backtick(&mut self) -> Res<()> {
775        self.i += 1;
776        loop {
777            match self.peek(0) {
778                None => return Err(Skip::Unparsed),
779                Some(b'`') => {
780                    self.i += 1;
781                    return Ok(());
782                }
783                Some(b'\\') if self.peek(1).is_some() => {
784                    self.i += 1;
785                    self.bump(None);
786                }
787                Some(_) => self.bump(None),
788            }
789        }
790    }
791
792    /// A redirection operator and its target word. `start` is where the
793    /// redirect began, including an fd number; `fd` is that number.
794    fn redirect(&mut self, nested: bool, start: usize, fd: Option<u32>) -> Res<()> {
795        let (op, heredoc) = match (self.peek(0), self.peek(1), self.peek(2)) {
796            (Some(b'<'), Some(b'<'), Some(b'<')) => ("<<<", None),
797            (Some(b'<'), Some(b'<'), Some(b'-')) => ("<<-", Some(true)),
798            (Some(b'<'), Some(b'<'), _) => ("<<", Some(false)),
799            (Some(b'<'), Some(b'>'), _) => ("<>", None),
800            (Some(b'<'), Some(b'&'), _) => ("<&", None),
801            (Some(b'>'), Some(b'>'), _) => (">>", None),
802            (Some(b'>'), Some(b'|'), _) => (">|", None),
803            (Some(b'>'), Some(b'&'), _) => (">&", None),
804            (Some(b'&'), Some(b'>'), Some(b'>')) => ("&>>", None),
805            (Some(b'&'), Some(b'>'), _) => ("&>", None),
806            (Some(b'<'), _, _) => ("<", None),
807            (Some(b'>'), _, _) => (">", None),
808            _ => return Err(Skip::Unparsed),
809        };
810        self.i += op.len();
811        while matches!(self.peek(0), Some(b' ' | b'\t')) {
812            self.i += 1;
813        }
814        if matches!(
815            self.peek(0),
816            None | Some(b'\n' | b';' | b'&' | b'|' | b'(' | b')' | b'<' | b'>')
817        ) {
818            return Err(Skip::Unparsed);
819        }
820        let (_, target) = self.word_text()?;
821        if let Some(strip_tabs) = heredoc {
822            self.heredoc = true;
823            self.pending.push((target.clone(), strip_tabs));
824        }
825        let to_fd = !target.is_empty() && target.bytes().all(|c| c.is_ascii_digit());
826        let (out, err) = match (op, fd) {
827            ("&>" | "&>>", _) => (true, true),
828            (">" | ">>" | ">|", None | Some(1)) => (true, false),
829            (">" | ">>" | ">|" | ">&", Some(2)) => (false, true),
830            // `>&2` and `>&-` move stdout; bash's `>& file` moves both.
831            (">&", None | Some(1)) if target == "1" => (false, false),
832            (">&", None | Some(1)) => (true, !to_fd && target != "-"),
833            ("<>", Some(1)) => (true, false),
834            ("<>", Some(2)) => (false, true),
835            _ => (false, false),
836        };
837        if !nested {
838            let fatal = std::mem::take(&mut self.fatal);
839            self.toks.push(Tok::Redirect { start, end: self.i, out, err, fatal });
840        }
841        Ok(())
842    }
843
844    /// Consume the bodies of the heredocs started on the line just ended.
845    fn heredoc_bodies(&mut self) -> Res<()> {
846        for (delim, strip_tabs) in std::mem::take(&mut self.pending) {
847            loop {
848                if self.i >= self.b.len() {
849                    return Err(Skip::Unparsed);
850                }
851                let end = self.s[self.i..].find('\n').map_or(self.b.len(), |k| self.i + k);
852                let line = &self.s[self.i..end];
853                let line = if strip_tabs { line.trim_start_matches('\t') } else { line };
854                let done = line == delim;
855                self.i = (end + 1).min(self.b.len());
856                if done {
857                    break;
858                }
859            }
860        }
861        Ok(())
862    }
863}
864
865// ── Commands ─────────────────────────────────────────────────────────────────
866
867/// One simple command between list or pipe separators.
868#[derive(Debug, Default)]
869struct Seg {
870    /// The command name, quotes removed.
871    cmd: Option<String>,
872    args: Vec<String>,
873    assign: bool,
874    /// Reserved words seen (`if`, `then`, `do`, `!`, `time`, ...).
875    kw: usize,
876    subshell: bool,
877    tokens: usize,
878    /// The separator after this command and the offset just past it.
879    sep: Option<Sep>,
880    end: usize,
881    /// Byte range from the first token to the end of the last one.
882    first: Option<usize>,
883    last: usize,
884    /// Source ranges of the command word and the words after it.
885    words: Vec<(usize, usize)>,
886    /// A redirect sends stdout away from the pipe / already places stderr.
887    out: bool,
888    err: bool,
889    fatal: Fatal,
890}
891
892impl Seg {
893    fn span(&mut self, start: usize, end: usize) {
894        self.first.get_or_insert(start);
895        self.last = end;
896    }
897}
898
899fn segments(command: &str, toks: &[Tok]) -> Res<Vec<Seg>> {
900    let mut segs = vec![Seg::default()];
901    let mut at_cmd = true;
902    // After `command`/`builtin`, the next non-option word is the command.
903    let mut wrapper = false;
904    for tok in toks {
905        match tok {
906            Tok::Sep { kind, end } => {
907                let seg = segs.last_mut().expect("never empty");
908                if seg.tokens == 0 {
909                    if *kind == Sep::Newline {
910                        continue;
911                    }
912                    return Err(Skip::Unparsed);
913                }
914                seg.sep = Some(*kind);
915                seg.end = *end;
916                segs.push(Seg::default());
917                at_cmd = true;
918                wrapper = false;
919            }
920            Tok::Redirect { start, end, out, err, fatal } => {
921                let seg = segs.last_mut().expect("never empty");
922                seg.tokens += 1;
923                seg.out |= *out;
924                seg.err |= *err;
925                seg.fatal = seg.fatal.max(*fatal);
926                seg.span(*start, *end);
927            }
928            Tok::Subshell => {
929                let seg = segs.last_mut().expect("never empty");
930                // Only reserved words may come before `( )`: anything else
931                // is a function definition or an array assignment.
932                if seg.tokens != seg.kw {
933                    return Err(Skip::Unparsed);
934                }
935                seg.tokens += 1;
936                seg.subshell = true;
937                at_cmd = false;
938            }
939            Tok::Word { start, end, plain, fatal } => {
940                let seg = segs.last_mut().expect("never empty");
941                seg.tokens += 1;
942                seg.fatal = seg.fatal.max(*fatal);
943                seg.span(*start, *end);
944                if !at_cmd {
945                    seg.args.push(plain.clone());
946                    seg.words.push((*start, *end));
947                    continue;
948                }
949                if is_assignment(&command[*start..]) {
950                    seg.assign = true;
951                    continue;
952                }
953                match plain.as_str() {
954                    "if" | "then" | "elif" | "else" | "do" | "while" | "until" | "!" | "time" => {
955                        seg.kw += 1;
956                        continue;
957                    }
958                    "fi" | "done" | "for" | "select" | "[[" => {
959                        seg.kw += 1;
960                        at_cmd = false;
961                        continue;
962                    }
963                    "case" | "esac" | "function" | "coproc" | "{" | "}" => {
964                        return Err(Skip::Unparsed)
965                    }
966                    _ => {}
967                }
968                if wrapper && plain.starts_with('-') {
969                    continue;
970                }
971                if !wrapper && (plain == "command" || plain == "builtin") {
972                    wrapper = true;
973                    continue;
974                }
975                // A command named by an expansion could be any builtin.
976                if (plain.contains('$') || plain.contains('`')) && !plain.contains('/') {
977                    return Err(Skip::Unparsed);
978                }
979                seg.cmd = Some(plain.clone());
980                seg.words.push((*start, *end));
981                at_cmd = false;
982                wrapper = false;
983            }
984        }
985    }
986    if segs.last().is_some_and(|s| s.tokens == 0) {
987        segs.pop();
988        // A list can't end waiting for its next command.
989        if segs.last().is_some_and(|s| matches!(s.sep, Some(Sep::And | Sep::Or | Sep::Pipe))) {
990            return Err(Skip::Unparsed);
991        }
992    }
993    Ok(segs)
994}
995
996/// `NAME=` or `NAME+=` at the start of a word.
997fn is_assignment(raw: &str) -> bool {
998    let b = raw.as_bytes();
999    if !b.first().is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_') {
1000        return false;
1001    }
1002    let name = b.iter().take_while(|c| c.is_ascii_alphanumeric() || **c == b'_').count();
1003    match b.get(name) {
1004        Some(b'=') => true,
1005        Some(b'+') => b.get(name + 1) == Some(&b'='),
1006        _ => false,
1007    }
1008}
1009
1010fn plain_param(text: &[u8]) -> bool {
1011    let name = match text.first() {
1012        Some(c) if c.is_ascii_alphabetic() || *c == b'_' => {
1013            text.iter().take_while(|c| c.is_ascii_alphanumeric() || **c == b'_').count()
1014        }
1015        Some(c) if c.is_ascii_digit() || b"@*#?$!-".contains(c) => 1,
1016        _ => return false,
1017    };
1018    match text.get(name) {
1019        Some(b'}' | b'-' | b'+' | b'#' | b'%' | b'/') => true,
1020        Some(b':') => matches!(text.get(name + 1), Some(b'-' | b'+')),
1021        _ => false,
1022    }
1023}
1024
1025fn zsh_lookup(raw: &str) -> bool {
1026    let tilde = |word: &str| {
1027        word.strip_prefix('~')
1028            .is_some_and(|rest| !matches!(rest.split('/').next(), Some("" | "+" | "-")))
1029    };
1030    let value = raw.split_once('=').filter(|_| is_assignment(raw)).map_or("", |(_, v)| v);
1031    (raw.len() > 1 && raw.starts_with('=')) || tilde(raw) || tilde(value)
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037
1038    fn simple(cmd: &str) {
1039        let p = plan(cmd).unwrap_or_else(|e| panic!("{cmd:?}: {e:?}"));
1040        assert!(p.simple && p.prefix.is_empty(), "{cmd:?}: {p:?}");
1041        assert_eq!(p.body, cmd.trim_end());
1042    }
1043
1044    fn grouped(cmd: &str) -> Plan<'_> {
1045        let p = plan(cmd).unwrap_or_else(|e| panic!("{cmd:?}: {e:?}"));
1046        assert!(!p.simple, "{cmd:?}: {p:?}");
1047        p
1048    }
1049
1050    fn skipped(cmd: &str, why: Skip) {
1051        assert_eq!(plan(cmd), Err(why), "{cmd:?}");
1052    }
1053
1054    #[test]
1055    fn plain_commands_stay_simple() {
1056        for cmd in [
1057            "git status",
1058            "  git status  \n",
1059            "cargo test 2>&1",
1060            "ls -la > out.txt",
1061            "make &> build.log",
1062            "grep -rn \"a|b;c&&d\" src",
1063            "echo 'semi;colon' \"pipe|d\" naïve",
1064            "echo \"say \\\"hi\\\"\"",
1065            "git log $(git merge-base HEAD main)..HEAD",
1066            "echo \"$(date) && later\"",
1067            "FOO=bar make test",
1068            "diff <(sort a.txt) <(sort b.txt)",
1069            "echo ${path#./} a#b",
1070            "echo $((1 + 2 & 3))",
1071            "(cd sub && make)",
1072            "jq '.items[] | select(.ok)' data.json",
1073            "find . -name '*.tmp' -exec rm {} \\;",
1074            "grep -w foo bar.txt",
1075            "curl -s -w '%{http_code}' https://example.com",
1076            "git commit -m \"first line\n\nbody\"",
1077            "python3 -m pytest -q",
1078            "PYTHONPATH=src python3 scripts/report.py --all",
1079            "python manage.py test",
1080            "psql -c 'select 1'",
1081            "docker compose up -d",
1082            "ping -c 3 example.com",
1083            "tail -n 50 app.log",
1084        ] {
1085            simple(cmd);
1086        }
1087    }
1088
1089    #[test]
1090    fn lists_and_pipelines_are_grouped() {
1091        for cmd in [
1092            "git log --oneline | head -20",
1093            "cargo build && cargo test",
1094            "make || echo failed",
1095            "make; make test",
1096            "cargo test 2>&1 | tail -30",
1097            "for f in *.rs; do wc -l \"$f\"; done",
1098            "if [ -f x ]; then cat x; else echo none; fi",
1099            "while read -r l; do echo \"$l\"; done < list.txt",
1100            "ls # list files",
1101            "git status\ngit diff --stat",
1102            "python3 - <<'EOF'\nprint('a|b; c &')\nEOF",
1103            "cat <<-EOF | sort\n\tb\n\ta\n\tEOF",
1104            "cat <<EOF\none\nEOF\necho after",
1105            "[[ -n \"$X\" && -f y ]] && cat y",
1106            "time cargo build",
1107            "! grep -q foo bar.txt",
1108            "echo done;",
1109            "make |& tee build.log",
1110        ] {
1111            let p = grouped(cmd);
1112            assert!(p.prefix.is_empty(), "{cmd:?}: {p:?}");
1113        }
1114    }
1115
1116    #[test]
1117    fn leading_cd_stays_outside_the_group() {
1118        let p = plan("cd sqz_engine && cargo test").unwrap();
1119        assert_eq!((p.prefix, p.body, p.simple), ("cd sqz_engine &&", " cargo test", true));
1120        let p = plan("cd \"my dir\"; cd sub && git log | head").unwrap();
1121        assert_eq!(p.prefix, "cd \"my dir\"; cd sub &&");
1122        assert!(!p.simple);
1123        let p = plan("cd /tmp\nls -la").unwrap();
1124        assert_eq!((p.prefix, p.body, p.simple), ("cd /tmp\n", "ls -la", true));
1125        let p = plan("builtin cd /tmp && ls").unwrap();
1126        assert_eq!(p.prefix, "builtin cd /tmp &&");
1127        let p = plan("cd sub; make\nmake test").unwrap();
1128        assert_eq!((p.prefix, p.body), ("cd sub;", " make\nmake test"));
1129        skipped("cd a;", Skip::Empty);
1130        skipped("cd /tmp", Skip::StateChange);
1131        skipped("cd a || exit 1", Skip::StateChange);
1132        skipped("cd sub && make\nmake test", Skip::StateChange);
1133        skipped("cd sub && make; make test", Skip::StateChange);
1134        skipped("cd sub && make || echo failed", Skip::StateChange);
1135        skipped("cd missing-dir && for f in a b; echo $f; echo next", Skip::StateChange);
1136        skipped("cd missing-dir && if [[ -n x ]] echo yes; echo next", Skip::StateChange);
1137        skipped("cd missing-dir && for f in a b; echo $f; repeat 2 do echo x; done", Skip::StateChange);
1138    }
1139
1140    #[test]
1141    fn state_changes_are_left_alone() {
1142        for cmd in [
1143            "export CI=1 && make",
1144            "make && cd build",
1145            "source .venv/bin/activate && pytest",
1146            ". ./env.sh; make",
1147            "X=1; echo $X",
1148            "set -e; make",
1149            "make || exit 1",
1150            "alias ll='ls -l'",
1151            "pushd src && make && popd",
1152            "trap 'echo bye' EXIT; make",
1153            "ulimit -n 4096 && make",
1154            "for d in a b; do cd $d; done",
1155            "kill %1",
1156            "for f in a b; do last=$f; done",
1157            "if [ -f x ]; then found=1; fi",
1158            "while read -r l; do n=$((n + 1)); done < list.txt",
1159        ] {
1160            skipped(cmd, Skip::StateChange);
1161        }
1162    }
1163
1164    #[test]
1165    fn background_jobs_are_left_alone() {
1166        for cmd in ["npm run build &", "sleep 5 & wait", "(server &)", "cmd >/dev/null 2>&1 &", "echo $(sleep 1 &)"] {
1167            skipped(cmd, Skip::Background);
1168        }
1169    }
1170
1171    #[test]
1172    fn long_running_commands_are_left_alone() {
1173        for cmd in [
1174            "tail -f app.log",
1175            "tail -n 100 -F app.log",
1176            "journalctl -fu nginx",
1177            "kubectl logs -f pod/web",
1178            "kubectl get pods -w",
1179            "docker compose logs --follow api",
1180            "docker compose up",
1181            "docker stats",
1182            "npm run dev",
1183            "pnpm dev",
1184            "yarn start",
1185            "vim notes.txt",
1186            "git log | less",
1187            "python3",
1188            "python3 -i run.py",
1189            "python3 -m http.server 8000",
1190            "python manage.py runserver",
1191            "watch -n 1 ls",
1192            "ping example.com",
1193            "tsc -w",
1194            "cargo test -- --watch",
1195            "uvicorn app:main --reload",
1196        ] {
1197            skipped(cmd, Skip::LongRunning);
1198        }
1199    }
1200
1201    #[test]
1202    fn commands_already_through_sqz() {
1203        for cmd in [
1204            "git status 2>&1 | sqz compress",
1205            "cargo test | /usr/local/bin/sqz compress --cmd cargo",
1206            "sqz stats",
1207            "cd x && sqz gain",
1208            "\"C:\\Users\\me\\.cargo\\bin\\sqz.exe\" stats",
1209            "git log | 'C:\\tools\\sqz.EXE' compress",
1210            "/c/Users/me/.cargo/bin/sqz.Exe gain",
1211        ] {
1212            skipped(cmd, Skip::AlreadySqz);
1213        }
1214    }
1215
1216    #[test]
1217    fn basenames_on_either_separator() {
1218        assert_eq!(basename("C:\\Users\\me\\sqz.exe"), "sqz");
1219        assert_eq!(basename("/usr/bin/sqz"), "sqz");
1220        assert_eq!(basename("tools\\Cargo.EXE"), "Cargo");
1221        assert_eq!(basename("exe"), "exe");
1222        assert_eq!(basename("naïve.exe"), "naïve");
1223        assert_eq!(basename("éabc"), "éabc");
1224    }
1225
1226    #[test]
1227    fn unparsed_syntax_is_left_alone() {
1228        for cmd in [
1229            "echo 'unterminated",
1230            "echo \"unterminated",
1231            "echo $(unterminated",
1232            "echo `unterminated",
1233            "make &&",
1234            "make |",
1235            "; ls",
1236            "ls ;; ls",
1237            "case $x in a) echo a;; esac",
1238            "f() { echo hi; }",
1239            "{ echo a; echo b; }",
1240            "cat <<EOF\nno terminator",
1241            "cat <<EOF",
1242            "cat >",
1243            "echo trailing \\",
1244            "((i++))",
1245            "$CMD --flag",
1246            "arr=(a b c)",
1247            "ls )",
1248        ] {
1249            skipped(cmd, Skip::Unparsed);
1250        }
1251    }
1252
1253    #[test]
1254    fn deep_nesting_is_left_alone() {
1255        let nest = |open: &str, close: &str, n: usize| {
1256            format!("echo {}x{}", open.repeat(n), close.repeat(n))
1257        };
1258        for (open, close) in [("$(", ")"), ("$(echo ", ")"), ("\"${a:-", "}\""), ("( ", " )")] {
1259            let deep = nest(open, close, 100_000);
1260            assert_eq!(plan(&deep), Err(Skip::Unparsed), "{open}");
1261            assert_eq!(flat(&deep), None, "{open}");
1262        }
1263        simple(&nest("$(echo ", ")", 3));
1264        simple(&nest("\"${a:-", "}\"", 3));
1265    }
1266
1267    #[test]
1268    fn heredoc_bodies_are_not_scanned_as_commands() {
1269        // Operators, quotes and `cd` inside the body are data.
1270        let p = grouped("cat <<'EOF' > notes.md\ncd away && rm -rf / &\nit's \"fine\" | ok\nEOF");
1271        assert!(p.body.ends_with("EOF"));
1272        // The terminator must be alone on its line.
1273        skipped("cat <<EOF\n  EOF\n", Skip::Unparsed);
1274        grouped("cat <<-EOF\n\tbody\n\tEOF");
1275    }
1276
1277    /// `(text up to each command's end, its label, merge_stderr)` for a flat list.
1278    fn flat_parts(cmd: &str) -> Vec<(&str, Option<String>, bool)> {
1279        let parts = flat(cmd).unwrap_or_else(|| panic!("{cmd:?} should be flat"));
1280        parts.into_iter().map(|p| (&cmd[..p.end], p.label, p.merge_stderr)).collect()
1281    }
1282
1283    #[test]
1284    fn flat_lists_mark_the_commands_worth_piping() {
1285        assert_eq!(flat_parts("git status"), [("git status", Some("git status".into()), true)]);
1286        assert_eq!(
1287            flat_parts("cd sub && cargo test --release; ls -la"),
1288            [
1289                ("cd sub", None, true),
1290                ("cd sub && cargo test --release", Some("cargo test --release".into()), true),
1291                ("cd sub && cargo test --release; ls -la", Some("ls -la".into()), true),
1292            ]
1293        );
1294        // Builtins, assignments and quiet commands stay unpiped, in the shell.
1295        let parts = flat_parts("export CI=1\nsource env.sh && mkdir -p out && X=1 make");
1296        let labels: Vec<_> = parts.iter().map(|p| p.1.as_deref()).collect();
1297        assert_eq!(labels, [None, None, None, Some("make")]);
1298        // A trailing comment stays after the inserted pipe.
1299        assert_eq!(flat_parts("ls # list"), [("ls", Some("ls".into()), true)]);
1300    }
1301
1302    #[test]
1303    fn flat_for_never_runs_past_a_fatal_expansion() {
1304        let piped = |cmd: &str, shell| flat_for(cmd, shell).is_some();
1305        for cmd in [
1306            "ls ${X:?unset}; echo next",
1307            "ls ${X?unset}\necho next",
1308            "ls \"${X:?unset}\" || echo next",
1309            "ls ${X:?unset} && echo a; echo next",
1310            "ls $((1/0)); echo next",
1311            "ls $[1/0]; echo next",
1312            "ls ${a b}; echo next",
1313            "ls ${A:-${B:?unset}}; echo next",
1314            "X=${Y:?unset} make; echo next",
1315            "make 2> ${LOG:?unset}; echo next",
1316            "ls ${X:?unset}$(echo hi); echo next",
1317            "ls \"${X:?unset}/$(echo hi)\"; echo next",
1318            "ls ${X:?$(echo unset)}; echo next",
1319            "ls $(( $(echo 1) / 0 )); echo next",
1320            "ls ${X:?unset}<(echo hi); echo next",
1321            "ls ${X:?unset}$(</dev/null); echo next",
1322        ] {
1323            assert!(flat(cmd).is_some(), "{cmd:?}");
1324            assert!(!piped(cmd, Shell::Bash) && !piped(cmd, Shell::Zsh), "{cmd:?}");
1325        }
1326        for cmd in [
1327            "ls *.rs; echo next",
1328            "curl -s http://x/?a=1; echo next",
1329            "jq .a[0] f.json || echo next",
1330            "ls =nosuchcmd; echo next",
1331            "ls ~nosuchuser; echo next",
1332            "X=~nosuchuser make; echo next",
1333        ] {
1334            assert!(piped(cmd, Shell::Bash) && !piped(cmd, Shell::Zsh), "{cmd:?}");
1335        }
1336        for cmd in [
1337            "ls ${X:?unset} && echo next",
1338            "ls ${X:?unset};",
1339            "git status; ls ${X:?unset}",
1340            "echo ${X:?unset}; git status",
1341            "ls '*.rs' \\*.rs \"*\"; echo next",
1342            "ls $(echo ${X:?unset} *.rs); echo next",
1343            "ls ${DIR} ${DIR:-.} ${DIR#./} ${DIR//a/b} $? $*; echo next",
1344            "[ -f x ]; ls ~ ~/x ~+ --x=~y; echo next",
1345        ] {
1346            assert!(piped(cmd, Shell::Bash) && piped(cmd, Shell::Zsh), "{cmd:?}");
1347        }
1348    }
1349
1350    #[test]
1351    fn groups_never_drop_the_lines_bash_runs_after_a_failed_one() {
1352        for cmd in [
1353            "ls $((1/0))\necho next",
1354            "echo $[1/0]\necho next",
1355            "for i in 1; do ls $((1/0)); done\necho next",
1356            "ls ${a b} | cat\necho next",
1357            "ls ${X:?unset} && make\n\necho next",
1358            "cd sub; ls \"${X^^}\"\nls",
1359            "cd $((1/0)); ls\necho next",
1360        ] {
1361            assert_eq!(plan_for(cmd, Shell::Bash), Err(Skip::LineError), "{cmd:?}");
1362            assert!(plan_for(cmd, Shell::Zsh).is_ok(), "{cmd:?}");
1363        }
1364        for cmd in [
1365            "ls $((1/0)); echo next",
1366            "echo next\nls $((1/0))",
1367            "ls ${X:-default} ${X#./}\necho next",
1368            "echo $(echo $((1/0)))\necho next",
1369            "(ls ${a b})\necho next",
1370            "cd ${D:?unset}\nls",
1371            "cat <<EOF\n$((1/0))\nEOF\necho next",
1372        ] {
1373            assert!(plan_for(cmd, Shell::Bash).is_ok(), "{cmd:?}");
1374        }
1375    }
1376
1377    #[test]
1378    fn flat_respects_the_commands_own_redirects() {
1379        // stdout already goes elsewhere: nothing to pipe.
1380        assert_eq!(flat("make > build.log 2>&1"), None);
1381        assert_eq!(flat("make &> build.log"), None);
1382        assert_eq!(flat("echo hi >&2"), None);
1383        // stderr already placed: pipe, but don't add a second `2>&1`.
1384        assert_eq!(flat_parts("cargo test 2>&1")[0].2, false);
1385        assert_eq!(flat_parts("cargo build 2>/dev/null")[0].2, false);
1386        assert_eq!(flat_parts("sort < in.txt")[0].2, true);
1387        let parts = flat_parts("make > log; make test");
1388        assert_eq!(parts[0].1, None);
1389        assert_eq!(parts[1].1.as_deref(), Some("make test"));
1390    }
1391
1392    #[test]
1393    fn flat_labels_stop_at_quotes_and_expansions() {
1394        assert_eq!(flat_parts("git commit -m \"fix it\"")[0].1.as_deref(), Some("git commit -m"));
1395        assert_eq!(flat_parts("cargo test $ARGS")[0].1.as_deref(), Some("cargo test"));
1396        assert_eq!(flat_parts("RUST_LOG=debug cargo run --bin x")[0].1.as_deref(), Some("cargo run --bin x"));
1397        // With a brace anywhere, the label stays one word so the rewrite
1398        // never pairs a brace with a quote.
1399        assert_eq!(flat_parts("find . -exec grep foo {} \\;")[0].1.as_deref(), Some("find"));
1400        assert_eq!(flat_parts("ls ${DIR}")[0].1.as_deref(), Some("ls"));
1401        // Nothing plain to name the command by: leave it unpiped.
1402        assert_eq!(flat("\"$TOOL\"/bin/run"), None);
1403    }
1404
1405    #[test]
1406    fn flat_refuses_what_needs_grouping() {
1407        for cmd in [
1408            "git log | head -20",
1409            "make |& tee log",
1410            "(cd sub && make)",
1411            "for f in *.rs; do wc -l \"$f\"; done",
1412            "if [ -f x ]; then cat x; fi",
1413            "time cargo build",
1414            "! grep -q x y",
1415            "cat <<EOF\nhi\nEOF",
1416            "make &",
1417            "git status 2>&1 | sqz compress",
1418            "cd x && sqz stats",
1419            "echo 'unterminated",
1420            // Only builtins, quiet or long-running commands: nothing to pipe.
1421            "cd sub",
1422            "export A=1 && cd b",
1423            "mkdir -p x && touch x/y",
1424            "tail -f app.log",
1425            "cd web && npm run dev",
1426        ] {
1427            assert_eq!(flat(cmd), None, "{cmd:?}");
1428        }
1429    }
1430
1431    #[test]
1432    fn assignment_detection() {
1433        assert!(is_assignment("FOO=1"));
1434        assert!(is_assignment("_x+=a"));
1435        assert!(!is_assignment("1X=2"));
1436        assert!(!is_assignment("--flag=1"));
1437        assert!(!is_assignment("\"A\"=b"));
1438    }
1439}