Skip to main content

arct_core/
shell_syntax.rs

1//! Shell syntax for the simulated sandbox: quoting, variables, globs,
2//! redirection, pipes and command lists.
3//!
4//! A small, honest subset of POSIX sh, enough for lessons to teach what a
5//! real shell does with a line before any program runs. Parsing is pure:
6//! variable values come from a lookup closure, and globs are only marked
7//! here (the sandbox expands them against its own files).
8//!
9//! Not supported, and reported as such: command substitution (`$(...)`,
10//! backticks), background jobs (`&`), here-documents, subshells.
11
12/// One word after quoting and variable expansion
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Word {
15    pub text: String,
16    /// Contains an unquoted `*`, `?` or `[`: the sandbox may expand it
17    pub glob: bool,
18}
19
20/// Where a stream goes, or comes from
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Redirect {
23    /// `> file` / `>> file`
24    Out { target: String, append: bool },
25    /// `2> file` / `2>> file`
26    Err { target: String, append: bool },
27    /// `2>&1`: errors go wherever output goes
28    ErrToOut,
29    /// `< file`
30    In(String),
31}
32
33/// One command in a pipeline
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct Stage {
36    /// `NAME=value` words before the command
37    pub assignments: Vec<(String, String)>,
38    pub words: Vec<Word>,
39    pub redirects: Vec<Redirect>,
40}
41
42/// Commands joined by `|`
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct Pipeline {
45    pub stages: Vec<Stage>,
46}
47
48/// How a pipeline follows the one before it
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Joiner {
51    /// First in the line, or after `;`
52    Always,
53    /// After `&&`: only if the previous one worked
54    IfOk,
55    /// After `||`: only if the previous one failed
56    IfFailed,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60enum Token {
61    Word(Word),
62    Pipe,
63    And,
64    Or,
65    Semi,
66    Out(bool),
67    Err(bool),
68    ErrToOut,
69    In,
70}
71
72/// Is this a valid shell variable name?
73pub fn is_name(s: &str) -> bool {
74    let mut chars = s.chars();
75    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
76        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
77}
78
79/// Split a line at unquoted `;`, `&&` and `||`, without expanding
80/// anything, so each part can be expanded just before it runs (as sh
81/// does: in `X=5; echo $X` the echo sees 5). A comment ends the line.
82pub fn split_list(line: &str) -> Vec<(Joiner, String)> {
83    let chars: Vec<char> = line.chars().collect();
84    let mut parts = Vec::new();
85    let mut joiner = Joiner::Always;
86    let mut current = String::new();
87    let mut i = 0;
88    let mut quote: Option<char> = None;
89    let mut word_start = true;
90    while i < chars.len() {
91        let c = chars[i];
92        if let Some(q) = quote {
93            current.push(c);
94            if c == '\\' && q == '"' {
95                if let Some(&next) = chars.get(i + 1) {
96                    current.push(next);
97                    i += 1;
98                }
99            } else if c == q {
100                quote = None;
101            }
102            i += 1;
103            continue;
104        }
105        let next = chars.get(i + 1).copied();
106        let op = match (c, next) {
107            (';', _) => Some((Joiner::Always, 1)),
108            ('&', Some('&')) => Some((Joiner::IfOk, 2)),
109            ('|', Some('|')) => Some((Joiner::IfFailed, 2)),
110            _ => None,
111        };
112        if let Some((next_joiner, width)) = op {
113            parts.push((joiner, std::mem::take(&mut current)));
114            joiner = next_joiner;
115            i += width;
116            word_start = true;
117            continue;
118        }
119        match c {
120            '#' if word_start => break,
121            '\'' | '"' => quote = Some(c),
122            '\\' => {
123                current.push(c);
124                if let Some(next) = next {
125                    current.push(next);
126                }
127                i += 2;
128                word_start = false;
129                continue;
130            }
131            _ => {}
132        }
133        word_start = c.is_whitespace() || c == '|';
134        current.push(c);
135        i += 1;
136    }
137    parts.push((joiner, current));
138    // Drop empty parts after ';' (a trailing ';' is fine); keep empty
139    // parts after && and || so parsing reports the missing command
140    parts
141        .into_iter()
142        .filter(|(j, text)| !(text.trim().is_empty() && *j == Joiner::Always))
143        .collect()
144}
145
146/// Parse one line into pipelines joined by `;`, `&&` and `||`.
147///
148/// `lookup` returns a variable's value (`HOME`, `1`, `#`, `@`, `?` ...);
149/// unknown variables expand to nothing, as in sh.
150pub fn parse(
151    line: &str,
152    lookup: &dyn Fn(&str) -> Option<String>,
153) -> Result<Vec<(Joiner, Pipeline)>, String> {
154    let tokens = tokenize(line, lookup)?;
155    let mut list: Vec<(Joiner, Pipeline)> = Vec::new();
156    let mut joiner = Joiner::Always;
157    let mut pipeline = Pipeline::default();
158    let mut stage = Stage::default();
159    let mut iter = tokens.into_iter().peekable();
160
161    fn finish_stage(stage: &mut Stage, pipeline: &mut Pipeline, op: &str) -> Result<(), String> {
162        if stage.words.is_empty() && stage.assignments.is_empty() {
163            return Err(format!("syntax error near '{op}': a command is missing"));
164        }
165        pipeline.stages.push(std::mem::take(stage));
166        Ok(())
167    }
168
169    while let Some(token) = iter.next() {
170        match token {
171            Token::Word(word) => {
172                // NAME=value before the command name is an assignment
173                if stage.words.is_empty() && !word.glob {
174                    if let Some((name, value)) = word.text.split_once('=') {
175                        if is_name(name) {
176                            stage.assignments.push((name.to_string(), value.to_string()));
177                            continue;
178                        }
179                    }
180                }
181                stage.words.push(word);
182            }
183            Token::Out(append) | Token::Err(append) => {
184                let is_err = matches!(token, Token::Err(_));
185                let Some(Token::Word(target)) = iter.next() else {
186                    return Err("syntax error: a file name is missing after '>'".to_string());
187                };
188                stage.redirects.push(if is_err {
189                    Redirect::Err { target: target.text, append }
190                } else {
191                    Redirect::Out { target: target.text, append }
192                });
193            }
194            Token::ErrToOut => stage.redirects.push(Redirect::ErrToOut),
195            Token::In => {
196                let Some(Token::Word(target)) = iter.next() else {
197                    return Err("syntax error: a file name is missing after '<'".to_string());
198                };
199                stage.redirects.push(Redirect::In(target.text));
200            }
201            Token::Pipe => finish_stage(&mut stage, &mut pipeline, "|")?,
202            Token::And | Token::Or | Token::Semi => {
203                let op = match token {
204                    Token::And => "&&",
205                    Token::Or => "||",
206                    _ => ";",
207                };
208                if stage.words.is_empty() && stage.assignments.is_empty() {
209                    // A trailing or doubled `;` is harmless; `&&`/`||` need both sides
210                    if token != Token::Semi || !pipeline.stages.is_empty() {
211                        return Err(format!("syntax error near '{op}': a command is missing"));
212                    }
213                } else {
214                    finish_stage(&mut stage, &mut pipeline, op)?;
215                }
216                if !pipeline.stages.is_empty() {
217                    list.push((joiner, std::mem::take(&mut pipeline)));
218                } else if token != Token::Semi {
219                    return Err(format!("syntax error near '{op}': a command is missing"));
220                }
221                joiner = match token {
222                    Token::And => Joiner::IfOk,
223                    Token::Or => Joiner::IfFailed,
224                    _ => Joiner::Always,
225                };
226            }
227        }
228    }
229    if !stage.words.is_empty() || !stage.assignments.is_empty() || !stage.redirects.is_empty() {
230        finish_stage(&mut stage, &mut pipeline, "end of line")?;
231    } else if !pipeline.stages.is_empty() {
232        return Err("syntax error: a command is missing after '|'".to_string());
233    }
234    if !pipeline.stages.is_empty() {
235        list.push((joiner, pipeline));
236    } else if joiner != Joiner::Always {
237        return Err("syntax error: a command is missing at the end of the line".to_string());
238    }
239    Ok(list)
240}
241
242/// Stands for `$?` in text expanded for a real shell: [`render`] turns it
243/// back into `"$?"`, which the shell fills in with a number as it runs.
244/// Private-use characters, so ordinary typing never produces it (and if it
245/// did, the worst it can become is that number).
246pub const STATUS_MARK: &str = "\u{E000}?\u{E001}";
247
248/// Write an expanded command list back as one line for a real `sh -c`,
249/// quoted so the shell takes every word, value and file name literally and
250/// expands nothing a second time: no variables, no `~`, no globs (expand
251/// those before rendering). What a guard checks in this line is what runs.
252/// The one thing left for the shell is [`STATUS_MARK`], written as `"$?"`.
253pub fn render(list: &[(Joiner, Pipeline)]) -> String {
254    let mut line = String::new();
255    for (i, (joiner, pipeline)) in list.iter().enumerate() {
256        if i > 0 {
257            line.push_str(match joiner {
258                Joiner::Always => "; ",
259                Joiner::IfOk => " && ",
260                Joiner::IfFailed => " || ",
261            });
262        }
263        let stages: Vec<String> = pipeline.stages.iter().map(render_stage).collect();
264        line.push_str(&stages.join(" | "));
265    }
266    line
267}
268
269fn render_stage(stage: &Stage) -> String {
270    let mut parts: Vec<String> = Vec::new();
271    for (name, value) in &stage.assignments {
272        parts.push(format!("{name}={}", render_text(value)));
273    }
274    for word in &stage.words {
275        parts.push(render_text(&word.text));
276    }
277    for redirect in &stage.redirects {
278        parts.push(match redirect {
279            Redirect::Out { target, append } => {
280                format!("{} {}", if *append { ">>" } else { ">" }, render_text(target))
281            }
282            Redirect::Err { target, append } => {
283                format!("{} {}", if *append { "2>>" } else { "2>" }, render_text(target))
284            }
285            Redirect::ErrToOut => "2>&1".to_string(),
286            Redirect::In(file) => format!("< {}", render_text(file)),
287        });
288    }
289    parts.join(" ")
290}
291
292/// One word for the shell: as it is when every character is plain (none
293/// of them means anything to sh), otherwise in single quotes
294fn render_text(text: &str) -> String {
295    let plain = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ',' | ':' | '+' | '@' | '%');
296    let mut out = String::new();
297    for (i, piece) in text.split(STATUS_MARK).enumerate() {
298        if i > 0 {
299            out.push_str("\"$?\"");
300        }
301        if piece.chars().all(plain) {
302            out.push_str(piece);
303        } else {
304            out.push_str(&single_quote(piece));
305        }
306    }
307    if out.is_empty() {
308        out.push_str("''");
309    }
310    out
311}
312
313fn single_quote(text: &str) -> String {
314    format!("'{}'", text.replace('\'', r"'\''"))
315}
316
317// end_word! resets its state even after the last word
318#[allow(unused_assignments)]
319fn tokenize(line: &str, lookup: &dyn Fn(&str) -> Option<String>) -> Result<Vec<Token>, String> {
320    let chars: Vec<char> = line.chars().collect();
321    let mut tokens = Vec::new();
322    let mut i = 0;
323    // The word being built: text, has-unquoted-glob, and whether it has
324    // started (so "" makes an empty word)
325    let mut text = String::new();
326    let mut glob = false;
327    let mut started = false;
328
329    macro_rules! end_word {
330        () => {
331            if started {
332                tokens.push(Token::Word(Word { text: std::mem::take(&mut text), glob }));
333                glob = false;
334                started = false;
335            }
336        };
337    }
338
339    while i < chars.len() {
340        let c = chars[i];
341        match c {
342            ' ' | '\t' => {
343                end_word!();
344                i += 1;
345            }
346            '#' if !started => break, // comment
347            '\'' => {
348                started = true;
349                i += 1;
350                let close = chars[i..].iter().position(|&ch| ch == '\'').ok_or("a ' quote is never closed")?;
351                text.extend(&chars[i..i + close]);
352                i += close + 1;
353            }
354            '"' => {
355                started = true;
356                i += 1;
357                loop {
358                    let Some(&ch) = chars.get(i) else {
359                        return Err("a \" quote is never closed".to_string());
360                    };
361                    match ch {
362                        '"' => {
363                            i += 1;
364                            break;
365                        }
366                        '\\' if matches!(chars.get(i + 1), Some('"' | '\\' | '$' | '`')) => {
367                            text.push(chars[i + 1]);
368                            i += 2;
369                        }
370                        '$' => {
371                            let (value, used) = expand_dollar(&chars[i..], lookup)?;
372                            text.push_str(&value);
373                            i += used;
374                        }
375                        '`' => return Err(unsupported_substitution()),
376                        _ => {
377                            text.push(ch);
378                            i += 1;
379                        }
380                    }
381                }
382            }
383            '\\' => {
384                started = true;
385                if let Some(&next) = chars.get(i + 1) {
386                    text.push(next);
387                }
388                i += 2;
389            }
390            '$' => {
391                let (value, used) = expand_dollar(&chars[i..], lookup)?;
392                i += used;
393                // Unquoted, a value is split into words at spaces, and an
394                // empty one vanishes (as in sh). Not in NAME=value, though.
395                let assigning = text.split_once('=').is_some_and(|(n, _)| is_name(n)) && !glob;
396                if assigning {
397                    text.push_str(&value);
398                    started = true;
399                    continue;
400                }
401                if value.starts_with(char::is_whitespace) {
402                    end_word!();
403                }
404                let mut pieces = value.split_whitespace().peekable();
405                while let Some(piece) = pieces.next() {
406                    text.push_str(piece);
407                    started = true;
408                    if pieces.peek().is_some() {
409                        end_word!();
410                    }
411                }
412                if value.ends_with(char::is_whitespace) {
413                    end_word!();
414                }
415            }
416            '`' => return Err(unsupported_substitution()),
417            '~' if !started => {
418                // ~ and ~/path at the start of a word mean the home folder
419                let next = chars.get(i + 1);
420                if next.is_none() || matches!(next, Some('/' | ' ' | '\t' | ';' | '|' | '&')) {
421                    text.push_str(&lookup("HOME").unwrap_or_else(|| "~".to_string()));
422                } else {
423                    text.push('~');
424                }
425                started = true;
426                i += 1;
427            }
428            '|' => {
429                end_word!();
430                if chars.get(i + 1) == Some(&'|') {
431                    tokens.push(Token::Or);
432                    i += 2;
433                } else {
434                    tokens.push(Token::Pipe);
435                    i += 1;
436                }
437            }
438            '&' => {
439                end_word!();
440                if chars.get(i + 1) == Some(&'&') {
441                    tokens.push(Token::And);
442                    i += 2;
443                } else {
444                    return Err("running in the background with & isn't available in the sandbox".to_string());
445                }
446            }
447            ';' => {
448                end_word!();
449                tokens.push(Token::Semi);
450                i += 1;
451            }
452            '>' => {
453                // A bare "2" right before > means the error stream
454                let is_err = started && text == "2" && !glob && i > 0 && chars[i - 1] == '2';
455                if is_err {
456                    text.clear();
457                    started = false;
458                } else {
459                    end_word!();
460                }
461                let append = chars.get(i + 1) == Some(&'>');
462                i += if append { 2 } else { 1 };
463                if is_err {
464                    if !append && chars.get(i) == Some(&'&') && chars.get(i + 1) == Some(&'1') {
465                        tokens.push(Token::ErrToOut);
466                        i += 2;
467                    } else {
468                        tokens.push(Token::Err(append));
469                    }
470                } else {
471                    tokens.push(Token::Out(append));
472                }
473            }
474            '<' => {
475                end_word!();
476                tokens.push(Token::In);
477                i += 1;
478            }
479            '*' | '?' | '[' => {
480                started = true;
481                glob = true;
482                text.push(c);
483                i += 1;
484            }
485            _ => {
486                started = true;
487                text.push(c);
488                i += 1;
489            }
490        }
491    }
492    end_word!();
493    Ok(tokens)
494}
495
496fn unsupported_substitution() -> String {
497    "running a command inside another with $(...) or backticks isn't available in the sandbox".to_string()
498}
499
500/// Expand `$NAME`, `${NAME}`, `$1`, `$#`, `$@`, `$?` at the start of `chars`.
501/// Returns the value and how many characters were used.
502fn expand_dollar(
503    chars: &[char],
504    lookup: &dyn Fn(&str) -> Option<String>,
505) -> Result<(String, usize), String> {
506    match chars.get(1) {
507        Some('(') => Err(unsupported_substitution()),
508        Some('{') => {
509            let close = chars
510                .iter()
511                .position(|&c| c == '}')
512                .ok_or("a ${ is never closed with }")?;
513            let name: String = chars[2..close].iter().collect();
514            if !(is_name(&name) || name.chars().all(|c| c.is_ascii_digit()) && !name.is_empty()) {
515                return Err(format!("${{{name}}}: bad variable name"));
516            }
517            Ok((lookup(&name).unwrap_or_default(), close + 1))
518        }
519        Some(&c) if c.is_ascii_digit() || matches!(c, '#' | '@' | '?' | '*') => {
520            Ok((lookup(&c.to_string()).unwrap_or_default(), 2))
521        }
522        Some(&c) if c.is_ascii_alphabetic() || c == '_' => {
523            let len = chars[1..]
524                .iter()
525                .take_while(|c| c.is_ascii_alphanumeric() || **c == '_')
526                .count();
527            let name: String = chars[1..1 + len].iter().collect();
528            Ok((lookup(&name).unwrap_or_default(), 1 + len))
529        }
530        // A lone $ is just a dollar sign
531        _ => Ok(("$".to_string(), 1)),
532    }
533}
534
535/// Does `name` match the glob `pattern` the way the shell expands it?
536/// Supports `*`, `?`, `[abc]`, `[a-z]` and `[!abc]`; a leading `.` must
537/// be matched explicitly, so `*` skips hidden files.
538pub fn glob_match(pattern: &str, name: &str) -> bool {
539    if name.starts_with('.') && !pattern.starts_with('.') {
540        return false;
541    }
542    fnmatch(pattern, name)
543}
544
545/// Pattern match without the shell's hidden-file rule (as `find -name` does)
546pub fn fnmatch(pattern: &str, name: &str) -> bool {
547    let p: Vec<char> = pattern.chars().collect();
548    let n: Vec<char> = name.chars().collect();
549    match_from(&p, &n)
550}
551
552fn match_from(p: &[char], n: &[char]) -> bool {
553    match p.first() {
554        None => n.is_empty(),
555        Some('*') => (0..=n.len()).any(|skip| match_from(&p[1..], &n[skip..])),
556        Some('?') => !n.is_empty() && match_from(&p[1..], &n[1..]),
557        Some('[') => {
558            let Some(close) = p.iter().skip(1).position(|&c| c == ']').map(|x| x + 1) else {
559                return n.first() == Some(&'[') && match_from(&p[1..], &n[1..]);
560            };
561            let Some(&ch) = n.first() else { return false };
562            let mut set = &p[1..close];
563            let negate = matches!(set.first(), Some('!' | '^'));
564            if negate {
565                set = &set[1..];
566            }
567            let mut hit = false;
568            let mut k = 0;
569            while k < set.len() {
570                if k + 2 < set.len() && set[k + 1] == '-' {
571                    hit |= set[k] <= ch && ch <= set[k + 2];
572                    k += 3;
573                } else {
574                    hit |= set[k] == ch;
575                    k += 1;
576                }
577            }
578            hit != negate && match_from(&p[close + 1..], &n[1..])
579        }
580        Some(&c) => n.first() == Some(&c) && match_from(&p[1..], &n[1..]),
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    fn vars(name: &str) -> Option<String> {
589        match name {
590            "HOME" => Some("/lesson-home".into()),
591            "NAME" => Some("Ada".into()),
592            "SPACED" => Some("a b".into()),
593            "EMPTY" => Some(String::new()),
594            "1" => Some("first".into()),
595            "#" => Some("1".into()),
596            _ => None,
597        }
598    }
599
600    fn one(line: &str) -> Stage {
601        let list = parse(line, &vars).unwrap();
602        assert_eq!(list.len(), 1, "{line}");
603        assert_eq!(list[0].1.stages.len(), 1, "{line}");
604        list[0].1.stages[0].clone()
605    }
606
607    fn texts(stage: &Stage) -> Vec<&str> {
608        stage.words.iter().map(|w| w.text.as_str()).collect()
609    }
610
611    #[test]
612    fn quotes_and_variables() {
613        assert_eq!(texts(&one("echo $NAME")), ["echo", "Ada"]);
614        assert_eq!(texts(&one("echo \"hi $NAME\"")), ["echo", "hi Ada"]);
615        assert_eq!(texts(&one("echo '$NAME'")), ["echo", "$NAME"]);
616        assert_eq!(texts(&one("echo ${NAME}s")), ["echo", "Adas"]);
617        assert_eq!(texts(&one("echo $1 $#")), ["echo", "first", "1"]);
618        assert_eq!(texts(&one("echo $MISSING end")), ["echo", "end"]);
619        assert_eq!(texts(&one("echo \"$MISSING\" end")), ["echo", "", "end"]);
620        assert_eq!(texts(&one("echo $SPACED")), ["echo", "a", "b"]);
621        assert_eq!(texts(&one("echo \"$SPACED\"")), ["echo", "a b"]);
622        assert_eq!(texts(&one("echo x$SPACED")), ["echo", "xa", "b"]);
623        assert_eq!(texts(&one("echo $EMPTY")), ["echo"]);
624        let s = one("X=$SPACED");
625        assert_eq!(s.assignments, [("X".to_string(), "a b".to_string())]);
626        assert_eq!(texts(&one("echo \"\"")), ["echo", ""]);
627        assert_eq!(texts(&one("echo a\\ b")), ["echo", "a b"]);
628        assert_eq!(texts(&one("echo costs $5")), ["echo", "costs"]);
629        assert_eq!(texts(&one("echo 5$")), ["echo", "5$"]);
630    }
631
632    #[test]
633    fn home_tilde() {
634        assert_eq!(texts(&one("cd ~")), ["cd", "/lesson-home"]);
635        assert_eq!(texts(&one("ls ~/notes")), ["ls", "/lesson-home/notes"]);
636        assert_eq!(texts(&one("echo a~b '~'")), ["echo", "a~b", "~"]);
637    }
638
639    #[test]
640    fn globs_are_marked_only_when_unquoted() {
641        let s = one("find . -name '*.txt' *.log");
642        assert!(!s.words[3].glob);
643        assert!(s.words[4].glob);
644    }
645
646    #[test]
647    fn assignments() {
648        let s = one("GREETING=hello");
649        assert_eq!(s.assignments, [("GREETING".to_string(), "hello".to_string())]);
650        assert!(s.words.is_empty());
651        let s = one("LANG=C sort names.txt");
652        assert_eq!(s.assignments.len(), 1);
653        assert_eq!(texts(&s), ["sort", "names.txt"]);
654        // Only before the command
655        assert_eq!(texts(&one("echo A=1")), ["echo", "A=1"]);
656    }
657
658    #[test]
659    fn redirections() {
660        let s = one("ls > out.txt");
661        assert_eq!(texts(&s), ["ls"]);
662        assert_eq!(s.redirects, [Redirect::Out { target: "out.txt".into(), append: false }]);
663
664        let s = one("echo hi>>log.txt");
665        assert_eq!(texts(&s), ["echo", "hi"]);
666        assert_eq!(s.redirects, [Redirect::Out { target: "log.txt".into(), append: true }]);
667
668        let s = one("cat missing 2>/dev/null");
669        assert_eq!(texts(&s), ["cat", "missing"]);
670        assert_eq!(s.redirects, [Redirect::Err { target: "/dev/null".into(), append: false }]);
671
672        let s = one("cat a b > all.txt 2>&1");
673        assert_eq!(s.redirects.len(), 2);
674        assert_eq!(s.redirects[1], Redirect::ErrToOut);
675
676        let s = one("wc -l < names.txt");
677        assert_eq!(texts(&s), ["wc", "-l"]);
678        assert_eq!(s.redirects, [Redirect::In("names.txt".into())]);
679
680        // "2" that isn't right before > is an ordinary word
681        assert_eq!(texts(&one("echo 2 > f")), ["echo", "2"]);
682        assert_eq!(texts(&one("echo a2> f")), ["echo", "a2"]);
683    }
684
685    #[test]
686    fn pipes_and_lists() {
687        let list = parse("cat log | grep ERROR | wc -l", &vars).unwrap();
688        assert_eq!(list[0].1.stages.len(), 3);
689
690        let list = parse("mkdir x && cd x; ls || echo empty", &vars).unwrap();
691        let joiners: Vec<Joiner> = list.iter().map(|(j, _)| *j).collect();
692        assert_eq!(joiners, [Joiner::Always, Joiner::IfOk, Joiner::Always, Joiner::IfFailed]);
693
694        assert!(parse("ls;", &vars).is_ok());
695        assert!(parse("ls && ", &vars).is_err());
696        assert!(parse("| grep x", &vars).is_err());
697        assert!(parse("ls |", &vars).is_err());
698        assert!(parse("echo > ", &vars).is_err());
699    }
700
701    #[test]
702    fn split_list_respects_quotes() {
703        let parts = split_list("X=5; echo $X && echo 'a;b' || echo \"c&&d\" # done");
704        let texts: Vec<&str> = parts.iter().map(|(_, t)| t.trim()).collect();
705        assert_eq!(texts, ["X=5", "echo $X", "echo 'a;b'", "echo \"c&&d\""]);
706        let joiners: Vec<Joiner> = parts.iter().map(|(j, _)| *j).collect();
707        assert_eq!(joiners, [Joiner::Always, Joiner::Always, Joiner::IfOk, Joiner::IfFailed]);
708        assert_eq!(split_list("ls;").len(), 1);
709        assert_eq!(split_list("cat a | grep b").len(), 1);
710        // A missing command after && is kept so parse can report it
711        let parts = split_list("ls &&");
712        assert_eq!(parts.len(), 2);
713        assert!(parse(&parts[1].1, &vars).unwrap().is_empty());
714    }
715
716    #[test]
717    fn unsupported_syntax_says_so() {
718        for line in ["echo $(date)", "echo `date`", "sleep 5 &", "echo 'open"] {
719            assert!(parse(line, &vars).is_err(), "{line}");
720        }
721    }
722
723    #[test]
724    fn comments() {
725        assert!(parse("# just a note", &vars).unwrap().is_empty());
726        assert_eq!(texts(&one("echo hi # note")), ["echo", "hi"]);
727        assert_eq!(texts(&one("echo a#b")), ["echo", "a#b"]);
728    }
729
730    #[test]
731    fn glob_matching() {
732        assert!(glob_match("*.txt", "notes.txt"));
733        assert!(!glob_match("*.txt", "notes.md"));
734        assert!(glob_match("log?.txt", "log1.txt"));
735        assert!(!glob_match("log?.txt", "log10.txt"));
736        assert!(glob_match("[abc]*", "beta"));
737        assert!(glob_match("[a-c]*", "cat"));
738        assert!(!glob_match("[!a-c]*", "cat"));
739        assert!(!glob_match("*", ".hidden"));
740        assert!(glob_match(".*", ".hidden"));
741        assert!(glob_match("*", ""));
742        assert!(fnmatch("*.txt", ".hidden.txt"));
743    }
744
745    fn rendered(line: &str) -> String {
746        let lookup = |name: &str| match name {
747            "?" => Some(STATUS_MARK.to_string()),
748            "TRICK" => Some("$(touch pwned) `x` $HOME".to_string()),
749            "Q" => Some("it's".to_string()),
750            n => vars(n),
751        };
752        render(&parse(line, &lookup).unwrap())
753    }
754
755    #[test]
756    fn render_quotes_everything_the_shell_would_expand() {
757        assert_eq!(rendered("echo $NAME"), "echo Ada");
758        assert_eq!(rendered("echo \"Hi, $NAME!\""), "echo 'Hi, Ada!'");
759        assert_eq!(rendered("echo $SPACED"), "echo a b");
760        assert_eq!(rendered("echo \"$SPACED\""), "echo 'a b'");
761        assert_eq!(rendered("echo \"[$EMPTY]\""), "echo '[]'");
762        assert_eq!(rendered("echo \"$EMPTY\""), "echo ''");
763        assert_eq!(rendered("echo \"$Q\""), r"echo 'it'\''s'");
764        // a value is never run: substitutions inside it stay text
765        assert_eq!(rendered("echo \"$TRICK\""), "echo '$(touch pwned) `x` $HOME'");
766        // single-quoted $ is written as typed, for scripts to use later
767        assert_eq!(rendered("echo 'echo $1' > greet.sh"), "echo 'echo $1' > greet.sh");
768        assert_eq!(rendered("cd ~"), "cd /lesson-home");
769        assert_eq!(rendered("X=$NAME env"), "X=Ada env");
770        // assignments can't hide in words
771        assert_eq!(rendered("echo X=1"), "echo 'X=1'");
772    }
773
774    #[test]
775    fn render_keeps_globs_lists_and_redirects() {
776        // globs are the caller's to expand: render keeps them as text
777        assert_eq!(rendered("ls *.txt [!a]? .*"), "ls '*.txt' '[!a]?' '.*'");
778        assert_eq!(rendered("ls '*.txt'"), "ls '*.txt'");
779        assert_eq!(
780            rendered("grep x a 2>/dev/null | sort >> out && echo ok || echo no; cat < in 2>&1"),
781            "grep x a 2> /dev/null | sort >> out && echo ok || echo no; cat < in 2>&1"
782        );
783        assert_eq!(rendered("false; echo $?"), "false; echo \"$?\"");
784        assert_eq!(rendered("echo \"status $?\""), "echo 'status '\"$?\"");
785    }
786
787    #[test]
788    fn rendered_lines_mean_the_same_to_sh() {
789        // The real shell must print exactly the expanded words, expanding
790        // nothing again
791        let line = "echo \"$TRICK\" $SPACED \"$Q\" x=1 '$NAME'";
792        let out = std::process::Command::new("sh")
793            .arg("-c")
794            .arg(rendered(line))
795            .env_clear()
796            .current_dir(std::env::temp_dir())
797            .output()
798            .expect("sh runs");
799        assert_eq!(
800            String::from_utf8_lossy(&out.stdout),
801            "$(touch pwned) `x` $HOME a b it's x=1 $NAME\n"
802        );
803        let status = std::process::Command::new("sh")
804            .arg("-c")
805            .arg(rendered("false; echo $?"))
806            .output()
807            .expect("sh runs");
808        assert_eq!(String::from_utf8_lossy(&status.stdout), "1\n");
809    }
810}