1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum Skip {
14 Unparsed,
16 Background,
18 StateChange,
20 LongRunning,
22 AlreadySqz,
24 Empty,
26 LineError,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Plan<'a> {
33 pub prefix: &'a str,
36 pub body: &'a str,
38 pub simple: bool,
41}
42
43const 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
52pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct FlatCmd {
142 pub end: usize,
144 pub label: Option<String>,
147 pub merge_stderr: bool,
149}
150
151const 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
158pub fn flat(command: &str) -> Option<Vec<FlatCmd>> {
166 flat_segments(command).map(|(_, cmds)| cmds)
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum Shell {
172 Bash,
173 Zsh,
174}
175
176impl Shell {
177 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
188pub 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
239fn 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
265fn 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
273pub 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
286fn 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
341fn 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 !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
382type 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 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 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 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 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 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 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 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 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 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 (">&", 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 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#[derive(Debug, Default)]
869struct Seg {
870 cmd: Option<String>,
872 args: Vec<String>,
873 assign: bool,
874 kw: usize,
876 subshell: bool,
877 tokens: usize,
878 sep: Option<Sep>,
880 end: usize,
881 first: Option<usize>,
883 last: usize,
884 words: Vec<(usize, usize)>,
886 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 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 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 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 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
996fn 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 let p = grouped("cat <<'EOF' > notes.md\ncd away && rm -rf / &\nit's \"fine\" | ok\nEOF");
1271 assert!(p.body.ends_with("EOF"));
1272 skipped("cat <<EOF\n EOF\n", Skip::Unparsed);
1274 grouped("cat <<-EOF\n\tbody\n\tEOF");
1275 }
1276
1277 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 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 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 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 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 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 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 "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}