Skip to main content

safe_chains/cst/
parse.rs

1use super::*;
2use winnow::ModalResult;
3use winnow::combinator::{alt, delimited, not, opt, preceded, repeat, separated, terminated};
4use winnow::error::{ContextError, ErrMode};
5use winnow::prelude::*;
6use winnow::token::{any, take_while};
7
8pub fn parse(input: &str) -> Option<Script> {
9    reset_heredoc_queue();
10    let result = script.parse(input).ok();
11    reset_heredoc_queue();
12    result
13}
14
15fn backtrack<T>() -> ModalResult<T> {
16    Err(ErrMode::Backtrack(ContextError::new()))
17}
18
19fn comment(input: &mut &str) -> ModalResult<()> {
20    if input.starts_with('#') {
21        if let Some(pos) = input.find('\n') {
22            *input = &input[pos + 1..];
23        } else {
24            *input = "";
25        }
26    }
27    Ok(())
28}
29
30fn ws(input: &mut &str) -> ModalResult<()> {
31    loop {
32        take_while(0.., [' ', '\t']).void().parse_next(input)?;
33        if input.starts_with('#') {
34            comment(input)?;
35        } else {
36            break;
37        }
38    }
39    Ok(())
40}
41
42fn sep(input: &mut &str) -> ModalResult<()> {
43    loop {
44        take_while(0.., [' ', '\t', ';', '\n']).void().parse_next(input)?;
45        if input.starts_with('#') {
46            comment(input)?;
47        } else {
48            break;
49        }
50    }
51    Ok(())
52}
53
54fn eat_keyword(input: &mut &str, kw: &str) -> ModalResult<()> {
55    if !input.starts_with(kw) {
56        return backtrack();
57    }
58    if input
59        .as_bytes()
60        .get(kw.len())
61        .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
62    {
63        return backtrack();
64    }
65    *input = &input[kw.len()..];
66    Ok(())
67}
68
69const SCRIPT_STOPS: &[&str] = &["do", "done", "elif", "else", "fi", "then"];
70
71fn at_script_stop(input: &str) -> bool {
72    input.starts_with(')')
73        || input.starts_with('}')
74        || SCRIPT_STOPS.iter().any(|kw| {
75            input.starts_with(kw)
76                && !input
77                    .as_bytes()
78                    .get(kw.len())
79                    .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
80        })
81}
82
83fn is_word_boundary(c: char) -> bool {
84    matches!(c, ' ' | '\t' | '\n' | ';' | '|' | '&' | ')' | '>' | '<')
85}
86
87fn is_word_literal(c: char) -> bool {
88    !is_word_boundary(c) && !matches!(c, '\'' | '"' | '`' | '\\' | '(' | '$')
89}
90
91fn is_dq_literal(c: char) -> bool {
92    !matches!(c, '"' | '\\' | '`' | '$')
93}
94
95// === Script ===
96
97fn script(input: &mut &str) -> ModalResult<Script> {
98    sep.parse_next(input)?;
99    let mut stmts = Vec::new();
100    while let Some(pl) = opt(pipeline).parse_next(input)? {
101        ws.parse_next(input)?;
102        let op = opt(list_op).parse_next(input)?;
103        stmts.push(Stmt { pipeline: pl, op });
104        // Drain any heredoc bodies pending from this statement before
105        // the next pipeline starts; otherwise the body would be parsed
106        // as the next statement (which would either misvalidate or
107        // misalign the line counter).
108        drain_pending_heredocs(input);
109        if op.is_none() {
110            break;
111        }
112        sep.parse_next(input)?;
113    }
114    Ok(Script(stmts))
115}
116
117fn list_op(input: &mut &str) -> ModalResult<ListOp> {
118    ws.parse_next(input)?;
119    alt((
120        "&&".value(ListOp::And),
121        "||".value(ListOp::Or),
122        '\n'.value(ListOp::Semi),
123        ';'.value(ListOp::Semi),
124        ('&', not('>')).value(ListOp::Amp),
125    ))
126    .parse_next(input)
127}
128
129fn pipe_sep(input: &mut &str) -> ModalResult<()> {
130    (ws, '|', not('|'), ws).void().parse_next(input)
131}
132
133// === Pipeline ===
134
135fn pipeline(input: &mut &str) -> ModalResult<Pipeline> {
136    ws.parse_next(input)?;
137    if at_script_stop(input) {
138        return backtrack();
139    }
140    let bang = opt(terminated('!', ws)).parse_next(input)?.is_some();
141    let commands: Vec<Cmd> = separated(1.., command, pipe_sep).parse_next(input)?;
142    Ok(Pipeline { bang, commands })
143}
144
145// === Command ===
146
147fn command(input: &mut &str) -> ModalResult<Cmd> {
148    ws.parse_next(input)?;
149    if at_script_stop(input) {
150        return backtrack();
151    }
152    alt((
153        subshell,
154        brace_group,
155        for_cmd,
156        while_cmd,
157        until_cmd,
158        if_cmd,
159        double_bracket_cmd,
160        simple_cmd.map(Cmd::Simple),
161    ))
162    .parse_next(input)
163}
164
165fn trailing_redirs(input: &mut &str) -> ModalResult<Vec<Redir>> {
166    let mut redirs = Vec::new();
167    loop {
168        ws.parse_next(input)?;
169        if let Some(r) = opt(redirect).parse_next(input)? {
170            redirs.push(r);
171        } else {
172            break;
173        }
174    }
175    Ok(redirs)
176}
177
178fn subshell(input: &mut &str) -> ModalResult<Cmd> {
179    let body = delimited(('(', ws), script, (ws, ')')).parse_next(input)?;
180    let redirs = trailing_redirs(input)?;
181    Ok(Cmd::Subshell { body, redirs })
182}
183
184fn brace_group(input: &mut &str) -> ModalResult<Cmd> {
185    if !input.starts_with('{') {
186        return backtrack();
187    }
188    if !input
189        .as_bytes()
190        .get(1)
191        .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n'))
192    {
193        return backtrack();
194    }
195    *input = &input[1..];
196    sep.parse_next(input)?;
197    let body = script.parse_next(input)?;
198    if body.0.is_empty() {
199        return backtrack();
200    }
201    sep.parse_next(input)?;
202    if !input.starts_with('}') {
203        return backtrack();
204    }
205    let last_op = body.0.last().and_then(|s| s.op);
206    if last_op.is_none() {
207        return backtrack();
208    }
209    *input = &input[1..];
210    let redirs = trailing_redirs(input)?;
211    Ok(Cmd::BraceGroup { body, redirs })
212}
213
214// === Simple Command ===
215
216fn simple_cmd(input: &mut &str) -> ModalResult<SimpleCmd> {
217    let env: Vec<(String, Word)> =
218        repeat(0.., terminated(assignment, ws)).parse_next(input)?;
219    let mut words = Vec::new();
220    let mut redirs = Vec::new();
221
222    loop {
223        ws.parse_next(input)?;
224        if at_cmd_end(input) {
225            break;
226        }
227        if let Some(r) = opt(redirect).parse_next(input)? {
228            redirs.push(r);
229        } else if let Some(w) = opt(word).parse_next(input)? {
230            words.push(w);
231        } else {
232            break;
233        }
234    }
235
236    if env.is_empty() && words.is_empty() && redirs.is_empty() {
237        return backtrack();
238    }
239    Ok(SimpleCmd { env, words, redirs })
240}
241
242fn at_cmd_end(input: &str) -> bool {
243    input.is_empty()
244        || matches!(
245            input.as_bytes().first(),
246            Some(b'\n' | b';' | b'|' | b'&' | b')')
247        )
248}
249
250fn assignment(input: &mut &str) -> ModalResult<(String, Word)> {
251    let n: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
252        .parse_next(input)?;
253    '='.parse_next(input)?;
254    let value = opt(word)
255        .parse_next(input)?
256        .unwrap_or(Word(vec![WordPart::Lit(String::new())]));
257    Ok((n.to_string(), value))
258}
259
260// === Redirect ===
261
262fn redirect(input: &mut &str) -> ModalResult<Redir> {
263    let fd = opt(fd_prefix).parse_next(input)?;
264    alt((
265        preceded("<<<", (ws, word)).map(|(_, target)| Redir::HereStr(target)),
266        heredoc,
267        preceded(">>", (ws, word)).map(move |(_, target)| Redir::Write {
268            fd: fd.unwrap_or(1),
269            target,
270            append: true,
271        }),
272        preceded(">&", fd_target).map(move |dst| Redir::DupFd {
273            src: fd.unwrap_or(1),
274            dst,
275        }),
276        preceded('>', (ws, word)).map(move |(_, target)| Redir::Write {
277            fd: fd.unwrap_or(1),
278            target,
279            append: false,
280        }),
281        preceded('<', (ws, word)).map(move |(_, target)| Redir::Read {
282            fd: fd.unwrap_or(0),
283            target,
284        }),
285    ))
286    .parse_next(input)
287}
288
289fn heredoc(input: &mut &str) -> ModalResult<Redir> {
290    "<<".parse_next(input)?;
291    let strip_tabs = opt('-').parse_next(input)?.is_some();
292    ws.parse_next(input)?;
293    let delimiter = heredoc_delimiter.parse_next(input)?;
294    // Bash semantics: the heredoc body lives on lines AFTER the
295    // command line is finished, not immediately after `<<DELIM`. The
296    // command line can continue with more redirects, a pipe, etc.
297    // Push the delimiter onto a thread-local queue; the body is
298    // drained at the next `\n`/`;` separator by drain_pending_heredocs.
299    PENDING_HEREDOCS.with(|q| {
300        q.borrow_mut().push(PendingHeredoc {
301            delimiter: delimiter.clone(),
302            strip_tabs,
303        });
304    });
305    Ok(Redir::HereDoc { delimiter, strip_tabs })
306}
307
308#[derive(Debug, Clone)]
309struct PendingHeredoc {
310    delimiter: String,
311    strip_tabs: bool,
312}
313
314thread_local! {
315    static PENDING_HEREDOCS: std::cell::RefCell<Vec<PendingHeredoc>> =
316        const { std::cell::RefCell::new(Vec::new()) };
317}
318
319fn drain_pending_heredocs(input: &mut &str) {
320    let pending: Vec<PendingHeredoc> =
321        PENDING_HEREDOCS.with(|q| std::mem::take(&mut *q.borrow_mut()));
322    for h in pending {
323        if !skip_heredoc_body(input, &h.delimiter, h.strip_tabs) {
324            // Couldn't find the matching delimiter line. Leave input
325            // as-is; the parser will likely fail on the leftover body
326            // text, which is the safe outcome (we deny on parse fail).
327            return;
328        }
329    }
330}
331
332fn skip_heredoc_body(input: &mut &str, delimiter: &str, strip_tabs: bool) -> bool {
333    let s = *input;
334    let bytes = s.as_bytes();
335    let mut line_start = 0;
336    while line_start <= bytes.len() {
337        let line_end = match s[line_start..].find('\n') {
338            Some(rel) => line_start + rel,
339            None => bytes.len(),
340        };
341        let line_bytes = &bytes[line_start..line_end];
342        let line = if strip_tabs {
343            std::str::from_utf8(line_bytes)
344                .unwrap_or("")
345                .trim_start_matches('\t')
346        } else {
347            std::str::from_utf8(line_bytes).unwrap_or("")
348        };
349        if line == delimiter {
350            // Advance past the delimiter line + its newline.
351            let advance = line_end + usize::from(line_end < bytes.len());
352            *input = &s[advance..];
353            return true;
354        }
355        if line_end >= bytes.len() {
356            return false;
357        }
358        line_start = line_end + 1;
359    }
360    false
361}
362
363fn reset_heredoc_queue() {
364    PENDING_HEREDOCS.with(|q| q.borrow_mut().clear());
365}
366
367fn heredoc_delimiter(input: &mut &str) -> ModalResult<String> {
368    alt((
369        delimited('\'', take_while(0.., |c| c != '\''), '\'').map(|s: &str| s.to_string()),
370        delimited('"', take_while(0.., |c| c != '"'), '"').map(|s: &str| s.to_string()),
371        take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_').map(|s: &str| s.to_string()),
372    ))
373    .parse_next(input)
374}
375
376fn fd_prefix(input: &mut &str) -> ModalResult<u32> {
377    let b = input.as_bytes();
378    if b.len() >= 2 && b[0].is_ascii_digit() && matches!(b[1], b'>' | b'<') {
379        let d = (b[0] - b'0') as u32;
380        *input = &input[1..];
381        Ok(d)
382    } else {
383        backtrack()
384    }
385}
386
387fn fd_target(input: &mut &str) -> ModalResult<String> {
388    alt((
389        '-'.value("-".to_string()),
390        take_while(1.., |c: char| c.is_ascii_digit()).map(|s: &str| s.to_string()),
391    ))
392    .parse_next(input)
393}
394
395// === Word ===
396
397fn word(input: &mut &str) -> ModalResult<Word> {
398    repeat(1.., word_part)
399        .map(Word)
400        .parse_next(input)
401}
402
403fn word_part(input: &mut &str) -> ModalResult<WordPart> {
404    if input.is_empty() {
405        return backtrack();
406    }
407    if input.starts_with("<(") || input.starts_with(">(") {
408        return proc_sub(input);
409    }
410    if is_word_boundary(input.as_bytes()[0] as char) {
411        return backtrack();
412    }
413    alt((single_quoted, double_quoted, arith_sub, cmd_sub, backtick_part, escaped, dollar_lit(is_word_literal), lit(is_word_literal)))
414        .parse_next(input)
415}
416
417fn single_quoted(input: &mut &str) -> ModalResult<WordPart> {
418    delimited('\'', take_while(0.., |c| c != '\''), '\'')
419        .map(|s: &str| WordPart::SQuote(s.to_string()))
420        .parse_next(input)
421}
422
423fn double_quoted(input: &mut &str) -> ModalResult<WordPart> {
424    delimited('"', repeat(0.., dq_part).map(Word), '"')
425        .map(WordPart::DQuote)
426        .parse_next(input)
427}
428
429fn cmd_sub(input: &mut &str) -> ModalResult<WordPart> {
430    delimited(("$(", ws), script, (ws, ')'))
431        .map(WordPart::CmdSub)
432        .parse_next(input)
433}
434
435fn proc_sub(input: &mut &str) -> ModalResult<WordPart> {
436    if !(input.starts_with("<(") || input.starts_with(">(")) {
437        return backtrack();
438    }
439    *input = &input[1..];
440    delimited(('(', ws), script, (ws, ')'))
441        .map(WordPart::ProcSub)
442        .parse_next(input)
443}
444
445fn arith_sub(input: &mut &str) -> ModalResult<WordPart> {
446    if !input.starts_with("$((") {
447        return backtrack();
448    }
449    let body_start = 3;
450    let bytes = input.as_bytes();
451    let mut depth: i32 = 1;
452    let mut i = body_start;
453    while i < bytes.len() {
454        match bytes[i] {
455            b'(' => depth += 1,
456            b')' => {
457                if depth == 1 && i + 1 < bytes.len() && bytes[i + 1] == b')' {
458                    let body = input[body_start..i].to_string();
459                    if body.contains("$(") || body.contains('`') {
460                        return backtrack();
461                    }
462                    *input = &input[i + 2..];
463                    return Ok(WordPart::Arith(body));
464                }
465                depth -= 1;
466                if depth < 0 {
467                    return backtrack();
468                }
469            }
470            _ => {}
471        }
472        i += 1;
473    }
474    backtrack()
475}
476
477fn backtick_part(input: &mut &str) -> ModalResult<WordPart> {
478    delimited('`', backtick_inner, '`')
479        .map(WordPart::Backtick)
480        .parse_next(input)
481}
482
483fn escaped(input: &mut &str) -> ModalResult<WordPart> {
484    preceded('\\', any).map(WordPart::Escape).parse_next(input)
485}
486
487fn lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
488    move |input: &mut &str| {
489        take_while(1.., pred)
490            .map(|s: &str| WordPart::Lit(s.to_string()))
491            .parse_next(input)
492    }
493}
494
495fn dollar_lit(pred: fn(char) -> bool) -> impl FnMut(&mut &str) -> ModalResult<WordPart> {
496    move |input: &mut &str| {
497        ('$', not('(')).void().parse_next(input)?;
498        let rest: &str = take_while(0.., pred).parse_next(input)?;
499        Ok(WordPart::Lit(format!("${rest}")))
500    }
501}
502
503// === Double-quoted parts ===
504
505fn dq_part(input: &mut &str) -> ModalResult<WordPart> {
506    if input.is_empty() || input.starts_with('"') {
507        return backtrack();
508    }
509    alt((dq_escape, arith_sub, cmd_sub, backtick_part, dollar_lit(is_dq_literal), lit(is_dq_literal)))
510        .parse_next(input)
511}
512
513fn dq_escape(input: &mut &str) -> ModalResult<WordPart> {
514    preceded('\\', any)
515        .map(|c: char| match c {
516            '"' | '\\' | '$' | '`' => WordPart::Escape(c),
517            _ => WordPart::Lit(format!("\\{c}")),
518        })
519        .parse_next(input)
520}
521
522// === Backtick inner content ===
523
524fn backtick_inner(input: &mut &str) -> ModalResult<String> {
525    repeat(0.., alt((bt_escape, bt_literal)))
526        .fold(String::new, |mut acc, chunk: &str| {
527            acc.push_str(chunk);
528            acc
529        })
530        .parse_next(input)
531}
532
533fn bt_escape<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
534    ('\\', any).take().parse_next(input)
535}
536
537fn bt_literal<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
538    take_while(1.., |c: char| c != '`' && c != '\\').parse_next(input)
539}
540
541// === Compound Commands ===
542
543fn for_cmd(input: &mut &str) -> ModalResult<Cmd> {
544    eat_keyword(input, "for")?;
545    ws.parse_next(input)?;
546    let var = name.parse_next(input)?;
547    ws.parse_next(input)?;
548
549    let items = if eat_keyword(input, "in").is_ok() {
550        ws.parse_next(input)?;
551        repeat(0.., terminated(word, ws)).parse_next(input)?
552    } else {
553        vec![]
554    };
555
556    let body = do_done_body.parse_next(input)?;
557    let redirs = trailing_redirs(input)?;
558    Ok(Cmd::For { var, items, body, redirs })
559}
560
561fn while_cmd(input: &mut &str) -> ModalResult<Cmd> {
562    eat_keyword(input, "while")?;
563    ws.parse_next(input)?;
564    let cond = script.parse_next(input)?;
565    let body = do_done_body.parse_next(input)?;
566    let redirs = trailing_redirs(input)?;
567    Ok(Cmd::While { cond, body, redirs })
568}
569
570fn until_cmd(input: &mut &str) -> ModalResult<Cmd> {
571    eat_keyword(input, "until")?;
572    ws.parse_next(input)?;
573    let cond = script.parse_next(input)?;
574    let body = do_done_body.parse_next(input)?;
575    let redirs = trailing_redirs(input)?;
576    Ok(Cmd::Until { cond, body, redirs })
577}
578
579fn do_done_body(input: &mut &str) -> ModalResult<Script> {
580    sep.parse_next(input)?;
581    eat_keyword(input, "do")?;
582    sep.parse_next(input)?;
583    let body = script.parse_next(input)?;
584    sep.parse_next(input)?;
585    eat_keyword(input, "done")?;
586    Ok(body)
587}
588
589fn if_cmd(input: &mut &str) -> ModalResult<Cmd> {
590    eat_keyword(input, "if")?;
591    ws.parse_next(input)?;
592    let mut branches = vec![cond_then_body.parse_next(input)?];
593    let mut else_body = None;
594
595    loop {
596        sep.parse_next(input)?;
597        if eat_keyword(input, "elif").is_ok() {
598            ws.parse_next(input)?;
599            branches.push(cond_then_body.parse_next(input)?);
600        } else if eat_keyword(input, "else").is_ok() {
601            sep.parse_next(input)?;
602            else_body = Some(script.parse_next(input)?);
603            break;
604        } else {
605            break;
606        }
607    }
608
609    sep.parse_next(input)?;
610    eat_keyword(input, "fi")?;
611    let redirs = trailing_redirs(input)?;
612    Ok(Cmd::If { branches, else_body, redirs })
613}
614
615fn cond_then_body(input: &mut &str) -> ModalResult<Branch> {
616    let cond = script.parse_next(input)?;
617    sep.parse_next(input)?;
618    eat_keyword(input, "then")?;
619    sep.parse_next(input)?;
620    let body = script.parse_next(input)?;
621    Ok(Branch { cond, body })
622}
623
624fn double_bracket_cmd(input: &mut &str) -> ModalResult<Cmd> {
625    if !input.starts_with("[[") {
626        return backtrack();
627    }
628    let bytes = input.as_bytes();
629    if bytes.len() < 3 || !matches!(bytes[2], b' ' | b'\t' | b'\n') {
630        return backtrack();
631    }
632    *input = &input[2..];
633
634    let mut words: Vec<Word> = Vec::new();
635    loop {
636        ws.parse_next(input)?;
637        if at_double_bracket_end(input) {
638            *input = &input[2..];
639            let redirs = trailing_redirs(input)?;
640            return Ok(Cmd::DoubleBracket { words, redirs });
641        }
642        if input.is_empty() {
643            return backtrack();
644        }
645        let w = bracket_word.parse_next(input)?;
646        words.push(w);
647    }
648}
649
650fn at_double_bracket_end(input: &str) -> bool {
651    if !input.starts_with("]]") {
652        return false;
653    }
654    let after = &input[2..];
655    after.is_empty()
656        || after.starts_with(|c: char| {
657            matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | ')' | '>' | '<')
658        })
659}
660
661fn bracket_word(input: &mut &str) -> ModalResult<Word> {
662    repeat(1.., bracket_word_part).map(Word).parse_next(input)
663}
664
665fn bracket_word_part(input: &mut &str) -> ModalResult<WordPart> {
666    if input.is_empty() {
667        return backtrack();
668    }
669    if matches!(input.as_bytes()[0], b' ' | b'\t' | b'\n') {
670        return backtrack();
671    }
672    if at_double_bracket_end(input) {
673        return backtrack();
674    }
675    alt((
676        single_quoted,
677        double_quoted,
678        arith_sub,
679        cmd_sub,
680        backtick_part,
681        escaped,
682        dollar_lit(is_bracket_literal),
683        bracket_lit,
684    ))
685    .parse_next(input)
686}
687
688fn is_bracket_literal(c: char) -> bool {
689    !matches!(c, '\'' | '"' | '`' | '\\' | '$' | ' ' | '\t' | '\n')
690}
691
692fn bracket_lit(input: &mut &str) -> ModalResult<WordPart> {
693    // Byte-by-byte scan relies on every stop char being single-byte ASCII —
694    // multibyte UTF-8 continuation bytes always pass `is_bracket_literal` and
695    // get consumed as part of the same `Lit`, so `end` only lands on a char
696    // boundary.
697    let bytes = input.as_bytes();
698    let mut end = 0;
699    while end < bytes.len() {
700        let c = bytes[end] as char;
701        if !is_bracket_literal(c) {
702            break;
703        }
704        if c == ']' && at_double_bracket_end(&input[end..]) {
705            break;
706        }
707        end += 1;
708    }
709    if end == 0 {
710        return backtrack();
711    }
712    let lit = input[..end].to_string();
713    *input = &input[end..];
714    Ok(WordPart::Lit(lit))
715}
716
717fn name(input: &mut &str) -> ModalResult<String> {
718    take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
719        .map(|s: &str| s.to_string())
720        .parse_next(input)
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    fn p(input: &str) -> Script {
728        parse(input).unwrap_or_else(|| panic!("failed to parse: {input}"))
729    }
730
731    fn words(script: &Script) -> Vec<String> {
732        match &script.0[0].pipeline.commands[0] {
733            Cmd::Simple(s) => s.words.iter().map(|w| w.eval()).collect(),
734            _ => panic!("expected simple command"),
735        }
736    }
737
738    fn simple(script: &Script) -> &SimpleCmd {
739        match &script.0[0].pipeline.commands[0] {
740            Cmd::Simple(s) => s,
741            _ => panic!("expected simple command"),
742        }
743    }
744
745    #[test]
746    fn simple_command() { assert_eq!(words(&p("echo hello")), ["echo", "hello"]); }
747    #[test]
748    fn flags() { assert_eq!(words(&p("ls -la")), ["ls", "-la"]); }
749    #[test]
750    fn single_quoted() { assert_eq!(words(&p("echo 'hello world'")), ["echo", "hello world"]); }
751    #[test]
752    fn double_quoted() { assert_eq!(words(&p("echo \"hello world\"")), ["echo", "hello world"]); }
753    #[test]
754    fn mixed_quotes() { assert_eq!(words(&p("jq '.key' file.json")), ["jq", ".key", "file.json"]); }
755
756    #[test]
757    fn pipeline_test() { assert_eq!(p("grep foo | head -5").0[0].pipeline.commands.len(), 2); }
758    #[test]
759    fn sequence_and() { assert_eq!(p("ls && echo done").0[0].op, Some(ListOp::And)); }
760    #[test]
761    fn sequence_semi() { assert_eq!(p("ls; echo done").0.len(), 2); }
762    #[test]
763    fn newline_separator() { assert_eq!(p("echo foo\necho bar").0.len(), 2); }
764    #[test]
765    fn blank_line_between_statements() { assert_eq!(p("echo foo\n\necho bar").0.len(), 2); }
766    #[test]
767    fn multiple_blank_lines() { assert_eq!(p("echo foo\n\n\n\necho bar").0.len(), 2); }
768    #[test]
769    fn blank_line_with_whitespace() { assert_eq!(p("echo foo\n   \necho bar").0.len(), 2); }
770    #[test]
771    fn comment_between_statements() { assert_eq!(p("echo foo\n# comment\necho bar").0.len(), 2); }
772    #[test]
773    fn semi_then_blank() { assert_eq!(p("echo foo;\n\necho bar").0.len(), 2); }
774    #[test]
775    fn and_then_blank() { assert_eq!(p("echo foo &&\n\necho bar").0.len(), 2); }
776
777    #[test]
778    fn brace_group_simple() {
779        assert!(matches!(
780            &p("{ echo hello; }").0[0].pipeline.commands[0],
781            Cmd::BraceGroup { body, redirs } if body.0.len() == 1 && redirs.is_empty()
782        ));
783    }
784    #[test]
785    fn brace_group_multiple_stmts() {
786        if let Cmd::BraceGroup { body, .. } = &p("{ echo a; echo b; echo c; }").0[0].pipeline.commands[0] {
787            assert_eq!(body.0.len(), 3);
788        } else { panic!("expected BraceGroup"); }
789    }
790    #[test]
791    fn brace_group_with_redirect() {
792        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; echo b; } > /tmp/out.txt").0[0].pipeline.commands[0] {
793            assert_eq!(redirs.len(), 1);
794            assert!(matches!(redirs[0], Redir::Write { .. }));
795        } else { panic!("expected BraceGroup"); }
796    }
797    #[test]
798    fn brace_group_with_append_redirect() {
799        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } >> log.txt").0[0].pipeline.commands[0] {
800            assert!(matches!(redirs[0], Redir::Write { append: true, .. }));
801        } else { panic!("expected BraceGroup"); }
802    }
803    #[test]
804    fn brace_group_with_stderr_redirect() {
805        if let Cmd::BraceGroup { redirs, .. } = &p("{ echo a; } 2>&1").0[0].pipeline.commands[0] {
806            assert!(matches!(redirs[0], Redir::DupFd { src: 2, .. }));
807        } else { panic!("expected BraceGroup"); }
808    }
809    #[test]
810    fn brace_group_newline_separated() {
811        if let Cmd::BraceGroup { body, .. } = &p("{\n  echo a\n  echo b\n}").0[0].pipeline.commands[0] {
812            assert_eq!(body.0.len(), 2);
813        } else { panic!("expected BraceGroup"); }
814    }
815    #[test]
816    fn brace_group_in_pipeline() {
817        let pl = &p("{ echo a; echo b; } | grep a").0[0].pipeline;
818        assert_eq!(pl.commands.len(), 2);
819        assert!(matches!(&pl.commands[0], Cmd::BraceGroup { .. }));
820    }
821    #[test]
822    fn brace_group_followed_by_other() {
823        let stmts = &p("{ echo a; }; echo b").0;
824        assert_eq!(stmts.len(), 2);
825        assert!(matches!(&stmts[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
826    }
827    #[test]
828    fn brace_group_nested() {
829        if let Cmd::BraceGroup { body, .. } = &p("{ { echo inner; }; echo outer; }").0[0].pipeline.commands[0] {
830            assert_eq!(body.0.len(), 2);
831            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
832        } else { panic!("expected outer BraceGroup"); }
833    }
834    #[test]
835    fn brace_group_with_subshell_inside() {
836        if let Cmd::BraceGroup { body, .. } = &p("{ (echo sub); echo grp; }").0[0].pipeline.commands[0] {
837            assert_eq!(body.0.len(), 2);
838            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::Subshell { .. }));
839        } else { panic!("expected BraceGroup"); }
840    }
841    #[test]
842    fn brace_open_requires_whitespace() {
843        // {echo (no space) is NOT a brace group; it's a literal word
844        // that becomes part of a simple command. Parser should not
845        // treat it as a brace group.
846        let cmds = &p("{echo a}").0;
847        // Either parsed as a simple_cmd with a literal `{echo` token,
848        // or fails. Either way, it should NOT be a BraceGroup.
849        if !cmds.is_empty() {
850            assert!(!matches!(&cmds[0].pipeline.commands[0], Cmd::BraceGroup { .. }));
851        }
852    }
853    #[test]
854    fn subshell_with_redirect() {
855        if let Cmd::Subshell { redirs, .. } = &p("(echo hello) > /tmp/out.txt").0[0].pipeline.commands[0] {
856            assert_eq!(redirs.len(), 1);
857        } else { panic!("expected Subshell with redir"); }
858    }
859    #[test]
860    fn for_loop_with_redirect() {
861        if let Cmd::For { redirs, .. } = &p("for f in a b; do echo $f; done 2>/dev/null").0[0].pipeline.commands[0] {
862            assert_eq!(redirs.len(), 1);
863        } else { panic!("expected For with redir"); }
864    }
865    #[test]
866    fn for_loop_redirect_then_pipe() {
867        // `done 2>&1 | head` — redirect on the loop, then a pipe.
868        let pl = &p("for f in a b; do echo $f; done 2>&1 | head -5").0[0].pipeline;
869        assert_eq!(pl.commands.len(), 2);
870        assert!(matches!(&pl.commands[0], Cmd::For { redirs, .. } if redirs.len() == 1));
871    }
872    #[test]
873    fn while_and_if_with_redirect() {
874        assert!(matches!(
875            &p("while true; do echo x; done 2>/dev/null").0[0].pipeline.commands[0],
876            Cmd::While { redirs, .. } if redirs.len() == 1
877        ));
878        assert!(matches!(
879            &p("if true; then echo x; fi 2>&1").0[0].pipeline.commands[0],
880            Cmd::If { redirs, .. } if redirs.len() == 1
881        ));
882    }
883    #[test]
884    fn background() { assert_eq!(p("ls & echo done").0[0].op, Some(ListOp::Amp)); }
885
886    #[test]
887    fn redirect_dev_null() {
888        let s = p("echo hello > /dev/null");
889        let cmd = simple(&s);
890        assert_eq!(cmd.words.len(), 2);
891        assert!(matches!(&cmd.redirs[0], Redir::Write { fd: 1, append: false, .. }));
892    }
893    #[test]
894    fn redirect_stderr() {
895        assert!(matches!(&simple(&p("echo hello 2>&1")).redirs[0], Redir::DupFd { src: 2, dst } if dst == "1"));
896    }
897    #[test]
898    fn here_string() {
899        assert!(matches!(&simple(&p("grep -c , <<< 'hello,world,test'")).redirs[0], Redir::HereStr(_)));
900    }
901    #[test]
902    fn heredoc_bare() {
903        assert!(matches!(&simple(&p("cat <<EOF")).redirs[0], Redir::HereDoc { delimiter, strip_tabs: false } if delimiter == "EOF"));
904    }
905    #[test]
906    fn heredoc_with_content() {
907        let s = p("cat <<EOF\nhello world\nEOF");
908        assert!(matches!(&simple(&s).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
909    }
910    #[test]
911    fn heredoc_quoted_delimiter() {
912        assert!(matches!(&simple(&p("cat <<'EOF'")).redirs[0], Redir::HereDoc { delimiter, .. } if delimiter == "EOF"));
913    }
914    #[test]
915    fn heredoc_strip_tabs() {
916        assert!(matches!(&simple(&p("cat <<-EOF")).redirs[0], Redir::HereDoc { strip_tabs: true, .. }));
917    }
918    #[test]
919    fn heredoc_pipe_on_command_line() {
920        // Correct bash: pipe is on the command line BEFORE the body,
921        // body terminator is on its own line.
922        let s = p("cat <<EOF | grep hello\nhello\nEOF");
923        assert_eq!(s.0[0].pipeline.commands.len(), 2);
924    }
925    #[test]
926    fn heredoc_body_does_not_swallow_pipe() {
927        // Regression for the `cat <<EOF | bash\n...\nEOF` bypass: the
928        // heredoc parser must NOT consume the pipe + downstream
929        // commands as part of the body.
930        let s = p("cat <<EOF | bash\nrm\nEOF");
931        assert_eq!(
932            s.0[0].pipeline.commands.len(),
933            2,
934            "pipeline must keep `bash` as a second command"
935        );
936    }
937    #[test]
938    fn heredoc_followed_by_next_statement() {
939        // After the heredoc body terminator, the script can continue
940        // with another statement.
941        let s = p("cat <<EOF\nhello\nEOF\nls");
942        assert_eq!(s.0.len(), 2);
943    }
944
945    #[test]
946    fn env_prefix() {
947        let s = p("FOO='bar baz' ls -la");
948        let cmd = simple(&s);
949        assert_eq!(cmd.env[0].0, "FOO");
950        assert_eq!(cmd.env[0].1.eval(), "bar baz");
951    }
952    #[test]
953    fn cmd_substitution() { assert!(matches!(&simple(&p("echo $(ls)")).words[1].0[0], WordPart::CmdSub(_))); }
954    #[test]
955    fn backtick_substitution() { assert_eq!(simple(&p("ls `pwd`")).words[1].eval(), "__SAFE_CHAINS_SUB__"); }
956    #[test]
957    fn nested_substitution() {
958        if let WordPart::CmdSub(inner) = &simple(&p("echo $(echo $(ls))")).words[1].0[0] {
959            assert!(matches!(&simple(inner).words[1].0[0], WordPart::CmdSub(_)));
960        } else { panic!("expected CmdSub"); }
961    }
962
963    #[test]
964    fn subshell_test() { assert!(matches!(&p("(echo hello)").0[0].pipeline.commands[0], Cmd::Subshell { .. })); }
965    #[test]
966    fn negation() { assert!(p("! echo hello").0[0].pipeline.bang); }
967
968    #[test]
969    fn for_loop() { assert!(matches!(&p("for x in 1 2 3; do echo $x; done").0[0].pipeline.commands[0], Cmd::For { var, .. } if var == "x")); }
970    #[test]
971    fn while_loop() { assert!(matches!(&p("while test -f /tmp/foo; do sleep 1; done").0[0].pipeline.commands[0], Cmd::While { .. })); }
972    #[test]
973    fn if_then_fi() {
974        if let Cmd::If { branches, else_body, .. } = &p("if test -f foo; then echo exists; fi").0[0].pipeline.commands[0] {
975            assert_eq!(branches.len(), 1);
976            assert!(else_body.is_none());
977        } else { panic!("expected If"); }
978    }
979    #[test]
980    fn if_elif_else() {
981        if let Cmd::If { branches, else_body, .. } = &p("if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi").0[0].pipeline.commands[0] {
982            assert_eq!(branches.len(), 2);
983            assert!(else_body.is_some());
984        } else { panic!("expected If"); }
985    }
986
987    #[test]
988    fn escaped_outside_quotes() { assert_eq!(words(&p("echo hello\\ world")), ["echo", "hello world"]); }
989    #[test]
990    fn double_quoted_escape() { assert_eq!(words(&p("echo \"hello\\\"world\"")), ["echo", "hello\"world"]); }
991    #[test]
992    fn assign_subst() { assert_eq!(simple(&p("out=$(ls)")).env[0].0, "out"); }
993
994    #[test]
995    fn unmatched_single_quote_fails() { assert!(parse("echo 'hello").is_none()); }
996    #[test]
997    fn unmatched_double_quote_fails() { assert!(parse("echo \"hello").is_none()); }
998    #[test]
999    fn unclosed_subshell_fails() { assert!(parse("(echo hello").is_none()); }
1000    #[test]
1001    fn unclosed_cmd_sub_fails() { assert!(parse("echo $(ls").is_none()); }
1002    #[test]
1003    fn for_missing_do_fails() { assert!(parse("for x in 1 2 3; echo $x; done").is_none()); }
1004    #[test]
1005    fn if_missing_fi_fails() { assert!(parse("if true; then echo hello").is_none()); }
1006
1007    #[test]
1008    fn subshell_for() {
1009        if let Cmd::Subshell { body, .. } = &p("(for x in 1 2; do echo $x; done)").0[0].pipeline.commands[0] {
1010            assert!(matches!(&body.0[0].pipeline.commands[0], Cmd::For { .. }));
1011        } else { panic!("expected Subshell"); }
1012    }
1013    #[test]
1014    fn proc_sub_input() {
1015        let s = p("diff <(sort a.txt) <(sort b.txt)");
1016        let cmd = simple(&s);
1017        assert_eq!(cmd.words.len(), 3);
1018        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1019        assert!(matches!(&cmd.words[2].0[0], WordPart::ProcSub(_)));
1020    }
1021    #[test]
1022    fn proc_sub_output() {
1023        let s = p("tee >(grep error > /dev/null)");
1024        let cmd = simple(&s);
1025        assert_eq!(cmd.words.len(), 2);
1026        assert!(matches!(&cmd.words[1].0[0], WordPart::ProcSub(_)));
1027    }
1028    #[test]
1029    fn comment_only() {
1030        let s = p("# just a comment");
1031        assert!(s.0.is_empty());
1032    }
1033    #[test]
1034    fn comment_before_command() {
1035        let s = p("# comment\necho hello");
1036        assert_eq!(words(&s), ["echo", "hello"]);
1037    }
1038    #[test]
1039    fn inline_comment() {
1040        let s = p("echo hello # this is a comment");
1041        assert_eq!(words(&s), ["echo", "hello"]);
1042    }
1043    #[test]
1044    fn comment_between_commands() {
1045        let s = p("echo hello\n# middle comment\necho world");
1046        assert_eq!(s.0.len(), 2);
1047    }
1048    #[test]
1049    fn comment_after_semicolon() {
1050        let s = p("echo hello; # comment\necho world");
1051        assert_eq!(s.0.len(), 2);
1052    }
1053    #[test]
1054    fn comment_in_for_loop() {
1055        assert!(parse("for x in 1 2; do\n# loop body\necho $x\ndone").is_some());
1056    }
1057    #[test]
1058    fn quoted_redirect_in_echo() {
1059        let s = p("echo 'greater > than' test");
1060        let cmd = simple(&s);
1061        assert_eq!(cmd.words.len(), 3);
1062        assert_eq!(cmd.redirs.len(), 0);
1063    }
1064
1065    #[test]
1066    fn parses_all_safe_commands() {
1067        let cmds = [
1068            "grep foo file.txt", "cat /etc/hosts", "jq '.key' file.json", "base64 -d",
1069            "ls -la", "wc -l file.txt", "ps aux", "echo hello", "cat file.txt",
1070            "echo $(ls)", "ls `pwd`", "echo $(echo $(ls))", "echo \"$(ls)\"",
1071            "out=$(ls)", "out=$(git status)", "a=$(ls) b=$(pwd)",
1072            "(echo hello)", "(ls)", "(ls && echo done)", "(echo hello; echo world)",
1073            "(ls | grep foo)", "(echo hello) | grep hello", "(ls) && echo done",
1074            "((echo hello))", "(for x in 1 2; do echo $x; done)",
1075            "echo 'greater > than' test", "echo '$(safe)' arg",
1076            "FOO='bar baz' ls -la", "FOO=\"bar baz\" ls -la",
1077            "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1078            "grep foo file.txt | head -5", "cat file | sort | uniq",
1079            "ls && echo done", "ls; echo done", "ls & echo done",
1080            "grep -c , <<< 'hello,world,test'",
1081            "cat <<EOF\nhello world\nEOF",
1082            "cat <<'MARKER'\nsome text\nMARKER",
1083            "cat <<-EOF\n\thello\nEOF",
1084            "echo foo\necho bar", "ls\ncat file.txt",
1085            "git log --oneline -20 | head -5",
1086            "echo hello > /dev/null", "echo hello 2> /dev/null",
1087            "echo hello >> /dev/null", "git log > /dev/null 2>&1",
1088            "ls 2>&1", "cargo clippy 2>&1", "git log < /dev/null",
1089            "for x in 1 2 3; do echo $x; done",
1090            "for f in *.txt; do cat $f | grep pattern; done",
1091            "for x in 1 2 3; do; done",
1092            "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
1093            "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1094            "for x in 1 2; do echo $x; done && echo finished",
1095            "for x in $(seq 1 5); do echo $x; done",
1096            "while test -f /tmp/foo; do sleep 1; done",
1097            "while ! test -f /tmp/done; do sleep 1; done",
1098            "until test -f /tmp/ready; do sleep 1; done",
1099            "if test -f foo; then echo exists; fi",
1100            "if test -f foo; then echo yes; else echo no; fi",
1101            "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1102            "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1103            "if true; then for x in 1 2; do echo $x; done; fi",
1104            "diff <(sort a.txt) <(sort b.txt)",
1105            "comm -23 file.txt <(sort other.txt)",
1106            "cat <(echo hello)",
1107            "# comment only",
1108            "# comment\necho hello",
1109            "echo hello # inline comment",
1110            "echo one\n# between\necho two",
1111            "! echo hello", "! test -f foo",
1112            "echo for; echo done; echo if; echo fi",
1113        ];
1114        let mut failures = Vec::new();
1115        for cmd in &cmds {
1116            if parse(cmd).is_none() { failures.push(*cmd); }
1117        }
1118        assert!(failures.is_empty(), "failed on {} commands:\n{}", failures.len(), failures.join("\n"));
1119    }
1120}