1#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Word {
15 pub text: String,
16 pub glob: bool,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Redirect {
23 Out { target: String, append: bool },
25 Err { target: String, append: bool },
27 ErrToOut,
29 In(String),
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct Stage {
36 pub assignments: Vec<(String, String)>,
38 pub words: Vec<Word>,
39 pub redirects: Vec<Redirect>,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct Pipeline {
45 pub stages: Vec<Stage>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Joiner {
51 Always,
53 IfOk,
55 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
72pub 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
79pub 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 parts
141 .into_iter()
142 .filter(|(j, text)| !(text.trim().is_empty() && *j == Joiner::Always))
143 .collect()
144}
145
146pub 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 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 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
242pub const STATUS_MARK: &str = "\u{E000}?\u{E001}";
247
248pub 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
292fn 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#[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 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, '\'' => {
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 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 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 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
500fn 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 _ => Ok(("$".to_string(), 1)),
532 }
533}
534
535pub 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
545pub 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 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 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 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 assert_eq!(rendered("echo \"$TRICK\""), "echo '$(touch pwned) `x` $HOME'");
766 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 assert_eq!(rendered("echo X=1"), "echo 'X=1'");
772 }
773
774 #[test]
775 fn render_keeps_globs_lists_and_redirects() {
776 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 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}