diff --git a/Cargo.lock b/Cargo.lock index 3ea15eb..7b00cfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,15 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytes" version = "1.7.0" @@ -68,6 +77,25 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cpufeatures" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deno_task_shell" version = "0.17.0" @@ -75,10 +103,11 @@ dependencies = [ "anyhow", "futures", "glob", - "monch", "os_pipe", "parking_lot", "path-dedot", + "pest", + "pest_derive", "pretty_assertions", "serde", "serde_json", @@ -94,6 +123,16 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "errno" version = "0.3.9" @@ -199,6 +238,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gimli" version = "0.29.0" @@ -272,12 +321,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "monch" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b52c1b33ff98142aecea13138bd399b68aa7ab5d9546c300988c345004001eea" - [[package]] name = "object" version = "0.36.2" @@ -335,6 +378,51 @@ dependencies = [ "once_cell", ] +[[package]] +name = "pest" +version = "2.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd53dff83f26735fdc1ca837098ccf133605d794cdae66acfc2bfac3ec809d95" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a548d2beca6773b1c244554d36fcf8548a8a58e74156968211567250e48e49a" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c93a82e8d145725dcbaf44e5ea887c8a869efdcc28706df2d08c69e17077183" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a941429fea7e08bedec25e4f6785b6ffaacc6b755da98df5ef3e7dcf4a124c4f" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "pin-project-lite" version = "0.2.14" @@ -447,6 +535,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shell" version = "0.1.0" @@ -562,12 +661,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "ucd-trie" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/crates/deno_task_shell/Cargo.toml b/crates/deno_task_shell/Cargo.toml index 2fc7dde..5830b1f 100644 --- a/crates/deno_task_shell/Cargo.toml +++ b/crates/deno_task_shell/Cargo.toml @@ -23,8 +23,9 @@ tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "proce tokio-util = { version = "0.7.10", optional = true } os_pipe = { version = "1.1.4", optional = true } serde = { version = "1", features = ["derive"], optional = true } -monch = "0.5.0" thiserror = "1.0.58" +pest = "2.6.0" +pest_derive = "2.6.0" [dev-dependencies] parking_lot = "0.12.1" diff --git a/crates/deno_task_shell/src/grammar.pest b/crates/deno_task_shell/src/grammar.pest new file mode 100644 index 0000000..b87ab16 --- /dev/null +++ b/crates/deno_task_shell/src/grammar.pest @@ -0,0 +1,215 @@ +// grammar.pest + +// Whitespace and comments +WHITESPACE = _{ " " | "\t" | ("\\" ~ WHITESPACE* ~ NEWLINE) } +COMMENT = _{ "#" ~ (!NEWLINE ~ ANY)* } + +// Basic tokens +QUOTED_WORD = { DOUBLE_QUOTED | SINGLE_QUOTED } + +UNQUOTED_PENDING_WORD = ${ + (!(WHITESPACE | OPERATOR | NEWLINE) ~ ( + EXIT_STATUS | + UNQUOTED_ESCAPE_CHAR | + SUB_COMMAND | + ("$" ~ VARIABLE) | + UNQUOTED_CHAR | + QUOTED_WORD + ) +)+ } + +FILE_NAME_PENDING_WORD = ${ (!(WHITESPACE | OPERATOR | NEWLINE) ~ (UNQUOTED_ESCAPE_CHAR | VARIABLE | UNQUOTED_CHAR | QUOTED_WORD))+ } + +QUOTED_PENDING_WORD = ${ ( + EXIT_STATUS | + QUOTED_ESCAPE_CHAR | + SUB_COMMAND | + ("$" ~ VARIABLE) | + QUOTED_CHAR +)* } + +UNQUOTED_ESCAPE_CHAR = ${ ("\\" ~ "$" | "$" ~ !"(" ~ !VARIABLE) | "\\" ~ ("`" | "\"" | "(" | ")")* } +QUOTED_ESCAPE_CHAR = ${ "\\" ~ "$" | "$" ~ !"(" ~ !VARIABLE | "\\" ~ ("`" | "\"" | "(" | ")" | "'")* } + +UNQUOTED_CHAR = ${ ("\\" ~ " ") | !(WHITESPACE | "~" | "(" | ")" | "{" | "}" | "<" | ">" | "|" | "&" | ";" | "\"" | "'") ~ ANY } +QUOTED_CHAR = ${ !"\"" ~ ANY } + +VARIABLE = ${ (ASCII_ALPHANUMERIC | "_")+ } +SUB_COMMAND = { "$(" ~ complete_command ~ ")" } + +DOUBLE_QUOTED = @{ "\"" ~ QUOTED_PENDING_WORD ~ "\"" } +SINGLE_QUOTED = @{ "'" ~ (!"'" ~ ANY)* ~ "'" } + +NAME = ${ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* } +ASSIGNMENT_WORD = { NAME ~ "=" ~ UNQUOTED_PENDING_WORD? } +IO_NUMBER = @{ ASCII_DIGIT+ } + +// Special tokens +AND_IF = { "&&" } +OR_IF = { "||" } +DSEMI = { ";;" } +LESS = { "<" } +GREAT = { ">" } +DLESS = { "<<" } +DGREAT = { ">>" } +LESSAND = { "<&" } +GREATAND = { ">&" } +LESSGREAT = { "<>" } +DLESSDASH = { "<<-" } +CLOBBER = { ">|" } +AMPERSAND = { "&" } +EXIT_STATUS = ${ "$?" } + + +// Operators +OPERATOR = _{ + AND_IF | OR_IF | DSEMI | DLESS | DGREAT | LESSAND | GREATAND | LESSGREAT | DLESSDASH | CLOBBER | + "(" | ")" | "{" | "}" | ";" | "&" | "|" | "<" | ">" | "!" +} + +// Reserved words +If = { "if" } +Then = { "then" } +Else = { "else" } +Elif = { "elif" } +Fi = { "fi" } +Do = { "do" } +Done = { "done" } +Case = { "case" } +Esac = { "esac" } +While = { "while" } +Until = { "until" } +For = { "for" } +Lbrace = { "{" } +Rbrace = { "}" } +Bang = { "!" } +In = { "in" } +Stdout = ${ "|" ~ !"|" ~ !"&"} +StdoutStderr = { "|&" } + +RESERVED_WORD = _{ + If | Then | Else | Elif | Fi | Do | Done | + Case | Esac | While | Until | For | + Lbrace | Rbrace | Bang | In | + StdoutStderr | Stdout +} + +// Main grammar rules +complete_command = { list? ~ (separator+ ~ list)* ~ separator? } +list = { and_or ~ (separator_op ~ and_or)* ~ separator_op? } +and_or = { (pipeline | ASSIGNMENT_WORD+) ~ ((AND_IF | OR_IF) ~ linebreak ~ and_or)? } +pipeline = { Bang? ~ pipe_sequence } +pipe_sequence = { command ~ ((StdoutStderr | Stdout) ~ linebreak ~ pipe_sequence)? } + +command = { + simple_command | + compound_command ~ redirect_list? | + function_definition +} + +compound_command = { + brace_group | + subshell | + for_clause | + case_clause | + if_clause | + while_clause | + until_clause +} + +subshell = { "(" ~ compound_list ~ ")" } +compound_list = { (newline_list? ~ term ~ separator?)+ } +term = { and_or ~ (separator ~ and_or)* } + +for_clause = { + For ~ name ~ linebreak ~ + (linebreak ~ In ~ wordlist? ~ sequential_sep)? ~ + linebreak ~ do_group +} + +case_clause = { + Case ~ UNQUOTED_PENDING_WORD ~ linebreak ~ + linebreak ~ In ~ linebreak ~ + (case_list | case_list_ns)? ~ + Esac +} + +case_list = { + case_item+ +} + +case_list_ns = { + case_item_ns+ +} + +case_item = { + "("? ~ pattern ~ ")" ~ (compound_list | linebreak) ~ DSEMI ~ linebreak +} + +case_item_ns = { + "("? ~ pattern ~ ")" ~ compound_list? ~ linebreak +} + +pattern = { + (Esac | UNQUOTED_PENDING_WORD) ~ ("|" ~ UNQUOTED_PENDING_WORD)* +} + +if_clause = { + If ~ compound_list ~ + Then ~ compound_list ~ + else_part? ~ + Fi +} + +else_part = { + Elif ~ compound_list ~ Then ~ else_part | + Else ~ compound_list +} + +while_clause = { While ~ compound_list ~ do_group } +until_clause = { Until ~ compound_list ~ do_group } + +function_definition = { fname ~ "(" ~ ")" ~ linebreak ~ function_body } +function_body = { compound_command ~ redirect_list? } + +fname = @{ RESERVED_WORD | NAME | ASSIGNMENT_WORD | UNQUOTED_PENDING_WORD } +name = @{ NAME } + +brace_group = { Lbrace ~ compound_list ~ Rbrace } +do_group = { Do ~ compound_list ~ Done } + +simple_command = { + cmd_prefix ~ WHITESPACE* ~ cmd_word ~ WHITESPACE* ~ cmd_suffix? | + (!cmd_prefix ~ cmd_name ~ WHITESPACE* ~ cmd_suffix?) +} + +cmd_prefix = { (io_redirect | ASSIGNMENT_WORD)+ } +cmd_suffix = { (io_redirect | QUOTED_WORD | UNQUOTED_PENDING_WORD)+ } +cmd_name = @{ (RESERVED_WORD | UNQUOTED_PENDING_WORD) } +cmd_word = @{ (ASSIGNMENT_WORD | UNQUOTED_PENDING_WORD) } + +redirect_list = { io_redirect+ } +io_redirect = { (IO_NUMBER | AMPERSAND)? ~ (io_file | io_here) } +io_file = { + LESS ~ filename | + GREAT ~ filename | + DGREAT ~ filename | + LESSAND ~ filename | + GREATAND ~ filename | + LESSGREAT ~ filename | + CLOBBER ~ filename +} +filename = _{ FILE_NAME_PENDING_WORD } +io_here = { (DLESS | DLESSDASH) ~ here_end } +here_end = @{ ("\"" ~ UNQUOTED_PENDING_WORD ~ "\"") | UNQUOTED_PENDING_WORD } + +newline_list = _{ NEWLINE+ } +linebreak = _{ NEWLINE* } +separator_op = { "&" | ";" } +separator = _{ separator_op ~ linebreak | newline_list } +sequential_sep = { ";" ~ linebreak | newline_list } + +wordlist = { UNQUOTED_PENDING_WORD+ } + +// Entry point +FILE = { SOI ~ complete_command ~ EOI } \ No newline at end of file diff --git a/crates/deno_task_shell/src/parser.rs b/crates/deno_task_shell/src/parser.rs index a6ad40f..d0c830c 100644 --- a/crates/deno_task_shell/src/parser.rs +++ b/crates/deno_task_shell/src/parser.rs @@ -1,8 +1,9 @@ // Copyright 2018-2024 the Deno authors. MIT license. -use anyhow::bail; -use anyhow::Result; -use monch::*; +use anyhow::{anyhow, Result}; +use pest::iterators::Pair; +use pest::Parser; +use pest_derive::Parser; // Shell grammar rules this is loosely based on: // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_10_02 @@ -218,6 +219,14 @@ impl EnvVar { pub struct Word(Vec); impl Word { + pub fn new(parts: Vec) -> Self { + Word(parts) + } + + pub fn new_empty() -> Self { + Word(vec![]) + } + pub fn new_string(text: &str) -> Self { Word(vec![WordPart::Quoted(vec![WordPart::Text( text.to_string(), @@ -316,601 +325,564 @@ pub enum RedirectOpOutput { Append, } +#[derive(Parser)] +#[grammar = "grammar.pest"] +struct ShellParser; + pub fn parse(input: &str) -> Result { - match parse_sequential_list(input) { - Ok((input, expr)) => { - if input.trim().is_empty() { - if expr.items.is_empty() { - bail!("Empty command.") - } else { - Ok(expr) - } - } else { - fail_for_trailing_input(input) - .into_result() - .map_err(|err| err.into()) - } - } - Err(ParseError::Backtrace) => fail_for_trailing_input(input) - .into_result() - .map_err(|err| err.into()), - Err(ParseError::Failure(e)) => e.into_result().map_err(|err| err.into()), - } -} + let mut pairs = ShellParser::parse(Rule::FILE, input)?; -fn parse_sequential_list(input: &str) -> ParseResult { - let (input, items) = separated_list( - terminated(parse_sequential_list_item, skip_whitespace), - terminated( - skip_whitespace, - or( - map(parse_sequential_list_op, |_| ()), - map(parse_async_list_op, |_| ()), - ), - ), - )(input)?; - Ok((input, SequentialList { items })) + parse_file(pairs.next().unwrap()) } -fn parse_sequential_list_item(input: &str) -> ParseResult { - let (input, sequence) = parse_sequence(input)?; - Ok(( - input, - SequentialListItem { - is_async: maybe(parse_async_list_op)(input)?.1.is_some(), - sequence, - }, - )) +fn parse_file(pairs: Pair) -> Result { + parse_complete_command(pairs.into_inner().next().unwrap()) } -fn parse_sequence(input: &str) -> ParseResult { - let (input, current) = terminated( - or( - parse_shell_var_command, - map(parse_pipeline, Sequence::Pipeline), - ), - skip_whitespace, - )(input)?; - - Ok(match parse_boolean_list_op(input) { - Ok((input, op)) => { - let (input, next_sequence) = assert_exists( - &parse_sequence, - "Expected command following boolean operator.", - )(input)?; - ( - input, - Sequence::BooleanList(Box::new(BooleanList { - current, - op, - next: next_sequence, - })), - ) +fn parse_complete_command(pair: Pair) -> Result { + assert!(pair.as_rule() == Rule::complete_command); + let mut items = Vec::new(); + for command in pair.into_inner() { + match command.as_rule() { + Rule::list => { + parse_list(command, &mut items)?; + } + Rule::EOI => { + break; + } + _ => { + return Err(anyhow::anyhow!( + "Unexpected rule in complete_command: {:?}", + command.as_rule() + )); + } } - Err(ParseError::Backtrace) => (input, current), - Err(err) => return Err(err), - }) + } + Ok(SequentialList { items }) +} + +fn parse_list( + pair: Pair, + items: &mut Vec, +) -> Result<()> { + for item in pair.into_inner() { + match item.as_rule() { + Rule::and_or => { + let sequence = parse_and_or(item)?; + items.push(SequentialListItem { + is_async: false, + sequence, + }); + } + Rule::separator_op => { + if let Some(last) = items.last_mut() { + last.is_async = item.as_str() == "&"; + } + } + _ => { + return Err(anyhow::anyhow!( + "Unexpected rule in list: {:?}", + item.as_rule() + )); + } + } + } + Ok(()) } -fn parse_shell_var_command(input: &str) -> ParseResult { - let env_vars_input = input; - let (input, mut env_vars) = if_not_empty(parse_env_vars)(input)?; - let (input, args) = parse_command_args(input)?; - if !args.is_empty() { - return ParseError::backtrace(); +fn parse_compound_list( + pair: Pair, + items: &mut Vec, +) -> Result<()> { + for item in pair.into_inner() { + match item.as_rule() { + Rule::term => { + parse_term(item, items)?; + } + Rule::newline_list => { + // Ignore newlines + } + _ => { + return Err(anyhow::anyhow!( + "Unexpected rule in compound_list: {:?}", + item.as_rule() + )); + } + } } - if env_vars.len() > 1 { - ParseError::fail(env_vars_input, "Cannot set multiple environment variables when there is no following command.") - } else { - ParseResult::Ok((input, Sequence::ShellVar(env_vars.remove(0)))) + Ok(()) +} + +fn parse_term( + pair: Pair, + items: &mut Vec, +) -> Result<()> { + for item in pair.into_inner() { + match item.as_rule() { + Rule::and_or => { + let sequence = parse_and_or(item)?; + items.push(SequentialListItem { + sequence, + is_async: false, + }); + } + Rule::separator_op => { + if let Some(last) = items.last_mut() { + last.is_async = item.as_str() == "&"; + } + } + _ => { + return Err(anyhow::anyhow!( + "Unexpected rule in term: {:?}", + item.as_rule() + )); + } + } } + Ok(()) } -/// Parses a pipeline, which is a sequence of one or more commands. -/// https://www.gnu.org/software/bash/manual/html_node/Pipelines.html -fn parse_pipeline(input: &str) -> ParseResult { - let (input, maybe_negated) = maybe(parse_negated_op)(input)?; - let (input, inner) = parse_pipeline_inner(input)?; - - let pipeline = Pipeline { - negated: maybe_negated.is_some(), - inner, +fn parse_and_or(pair: Pair) -> Result { + assert!(pair.as_rule() == Rule::and_or); + let mut items = pair.into_inner(); + let first_item = items.next().unwrap(); + let mut current = match first_item.as_rule() { + Rule::ASSIGNMENT_WORD => parse_shell_var(first_item)?, + Rule::pipeline => parse_pipeline(first_item)?, + _ => unreachable!(), }; - Ok((input, pipeline)) -} - -fn parse_pipeline_inner(input: &str) -> ParseResult { - let original_input = input; - let (input, command) = parse_command(input)?; - - let (input, inner) = match parse_pipe_sequence_op(input) { - Ok((input, op)) => { - let (input, next_inner) = assert_exists( - &parse_pipeline_inner, - "Expected command following pipeline operator.", - )(input)?; - - if command.redirect.is_some() { - return ParseError::fail( - original_input, - "Redirects in pipe sequence commands are currently not supported.", + match items.next() { + Some(next_item) => { + if next_item.as_rule() == Rule::ASSIGNMENT_WORD { + anyhow::bail!( + "Multiple assignment words before && or || is not supported yet" ); + } else { + let op = match next_item.as_str() { + "&&" => BooleanListOperator::And, + "||" => BooleanListOperator::Or, + _ => unreachable!(), + }; + + let next_item = items.next().unwrap(); + let next = parse_and_or(next_item)?; + current = + Sequence::BooleanList(Box::new(BooleanList { current, op, next })); } - - ( - input, - PipelineInner::PipeSequence(Box::new(PipeSequence { - current: command, - op, - next: next_inner, - })), - ) } - Err(ParseError::Backtrace) => (input, PipelineInner::Command(command)), - Err(err) => return Err(err), - }; - - Ok((input, inner)) -} - -fn parse_command(input: &str) -> ParseResult { - let (input, inner) = terminated( - or( - map(parse_subshell, |l| CommandInner::Subshell(Box::new(l))), - map(parse_simple_command, CommandInner::Simple), - ), - skip_whitespace, - )(input)?; - - let before_redirects_input = input; - let (input, mut redirects) = - many0(terminated(parse_redirect, skip_whitespace))(input)?; - - if redirects.len() > 1 { - return ParseError::fail( - before_redirects_input, - "Multiple redirects are currently not supported.", - ); - } - - let command = Command { - redirect: redirects.pop(), - inner, - }; - - Ok((input, command)) -} - -fn parse_simple_command(input: &str) -> ParseResult { - let (input, env_vars) = parse_env_vars(input)?; - let (input, args) = if_not_empty(parse_command_args)(input)?; - ParseResult::Ok((input, SimpleCommand { env_vars, args })) -} - -fn parse_command_args(input: &str) -> ParseResult> { - many_till( - terminated(parse_shell_arg, assert_whitespace_or_end_and_skip), - or4( - parse_list_op, - map(parse_redirect, |_| ()), - map(parse_pipe_sequence_op, |_| ()), - map(ch(')'), |_| ()), - ), - )(input) -} - -fn parse_shell_arg(input: &str) -> ParseResult { - let (input, value) = parse_word(input)?; - if value.parts().is_empty() { - ParseError::backtrace() - } else { - Ok((input, value)) + None => { + return Ok(current); + } } -} - -fn parse_list_op(input: &str) -> ParseResult<()> { - or( - map(parse_boolean_list_op, |_| ()), - map(or(parse_sequential_list_op, parse_async_list_op), |_| ()), - )(input) -} - -fn parse_boolean_list_op(input: &str) -> ParseResult { - or( - map(parse_op_str(BooleanListOperator::And.as_str()), |_| { - BooleanListOperator::And - }), - map(parse_op_str(BooleanListOperator::Or.as_str()), |_| { - BooleanListOperator::Or - }), - )(input) -} - -fn parse_sequential_list_op(input: &str) -> ParseResult<&str> { - terminated(tag(";"), skip_whitespace)(input) -} - -fn parse_async_list_op(input: &str) -> ParseResult<&str> { - parse_op_str("&")(input) -} - -fn parse_negated_op(input: &str) -> ParseResult<&str> { - terminated( - tag("!"), - // must have whitespace following - whitespace, - )(input) -} -fn parse_op_str<'a>( - operator: &str, -) -> impl Fn(&'a str) -> ParseResult<'a, &'a str> { - debug_assert!(operator == "&&" || operator == "||" || operator == "&"); - let operator = operator.to_string(); - terminated( - tag(operator), - terminated(check_not(one_of("|&")), skip_whitespace), - ) -} - -fn parse_pipe_sequence_op(input: &str) -> ParseResult { - terminated( - or( - map(tag("|&"), |_| PipeSequenceOperator::StdoutStderr), - map(ch('|'), |_| PipeSequenceOperator::Stdout), - ), - terminated(check_not(one_of("|&")), skip_whitespace), - )(input) -} - -fn parse_redirect(input: &str) -> ParseResult { - // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_07 - let (input, maybe_fd) = maybe(parse_u32)(input)?; - let (input, maybe_ampersand) = if maybe_fd.is_none() { - maybe(ch('&'))(input)? - } else { - (input, None) - }; - let (input, op) = or3( - map(tag(">>"), |_| RedirectOp::Output(RedirectOpOutput::Append)), - map(or(tag(">"), tag(">|")), |_| { - RedirectOp::Output(RedirectOpOutput::Overwrite) - }), - map(ch('<'), |_| RedirectOp::Input(RedirectOpInput::Redirect)), - )(input)?; - let (input, io_file) = or( - map(preceded(ch('&'), parse_u32), IoFile::Fd), - map(preceded(skip_whitespace, parse_word), IoFile::Word), - )(input)?; - - let maybe_fd = if let Some(fd) = maybe_fd { - Some(RedirectFd::Fd(fd)) - } else if maybe_ampersand.is_some() { - Some(RedirectFd::StdoutStderr) + Ok(current) +} + +fn parse_shell_var(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let name = inner + .next() + .ok_or_else(|| anyhow::anyhow!("Expected variable name"))? + .as_str() + .to_string(); + let value = inner + .next() + .ok_or_else(|| anyhow::anyhow!("Expected variable value"))?; + let value = parse_word(value)?; + Ok(Sequence::ShellVar(EnvVar { name, value })) +} + +fn parse_pipeline(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + + // Check if the first element is Bang (negation) + let first = inner + .next() + .ok_or_else(|| anyhow::anyhow!("Expected pipeline content"))?; + let (negated, pipe_sequence) = if first.as_rule() == Rule::Bang { + // If it's Bang, the next element should be the pipe_sequence + let pipe_sequence = inner.next().ok_or_else(|| { + anyhow::anyhow!("Expected pipe sequence after negation") + })?; + (true, pipe_sequence) } else { - None + // If it's not Bang, this element itself is the pipe_sequence + (false, first) }; - Ok(( - input, - Redirect { - maybe_fd, - op, - io_file, - }, - )) -} - -fn parse_env_vars(input: &str) -> ParseResult> { - many0(terminated(parse_env_var, skip_whitespace))(input) -} - -fn parse_env_var(input: &str) -> ParseResult { - let (input, name) = parse_env_var_name(input)?; - let (input, _) = ch('=')(input)?; - let (input, value) = with_error_context( - terminated(parse_env_var_value, assert_whitespace_or_end), - "Invalid environment variable value.", - )(input)?; - Ok((input, EnvVar::new(name.to_string(), value))) -} - -fn parse_env_var_name(input: &str) -> ParseResult<&str> { - if_not_empty(take_while(is_valid_env_var_char))(input) -} + let pipeline_inner = parse_pipe_sequence(pipe_sequence)?; + + Ok(Sequence::Pipeline(Pipeline { + negated, + inner: pipeline_inner, + })) +} + +fn parse_pipe_sequence(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + + // Parse the first command + let first_command = inner.next().ok_or_else(|| { + anyhow::anyhow!("Expected at least one command in pipe sequence") + })?; + let current = parse_command(first_command)?; + + // Check if there's a pipe operator + match inner.next() { + Some(pipe_op) => { + let op = match pipe_op.as_rule() { + Rule::Stdout => PipeSequenceOperator::Stdout, + Rule::StdoutStderr => PipeSequenceOperator::StdoutStderr, + _ => { + return Err(anyhow::anyhow!( + "Expected pipe operator, found {:?}", + pipe_op.as_rule() + )) + } + }; -fn parse_env_var_value(input: &str) -> ParseResult { - parse_word(input) + // Parse the rest of the pipe sequence + let next_sequence = inner.next().ok_or_else(|| { + anyhow::anyhow!("Expected command after pipe operator") + })?; + let next = parse_pipe_sequence(next_sequence)?; + + Ok(PipelineInner::PipeSequence(Box::new(PipeSequence { + current, + op, + next, + }))) + } + None => Ok(PipelineInner::Command(current)), + } } -fn parse_word(input: &str) -> ParseResult { - let parse_quoted_or_unquoted = or( - map(parse_quoted_string, |parts| vec![WordPart::Quoted(parts)]), - parse_unquoted_word, - ); - let (input, mut parts) = parse_quoted_or_unquoted(input)?; - if parts.is_empty() { - Ok((input, Word(parts))) - } else { - let (input, result) = many0(if_not_empty(parse_quoted_or_unquoted))(input)?; - parts.extend(result.into_iter().flatten()); - Ok((input, Word(parts))) +fn parse_command(pair: Pair) -> Result { + let inner = pair.into_inner().next().unwrap(); + match inner.as_rule() { + Rule::simple_command => parse_simple_command(inner), + Rule::compound_command => parse_compound_command(inner), + Rule::function_definition => { + todo!("function definitions are not supported yet") + } + _ => Err(anyhow::anyhow!( + "Unexpected rule in command: {:?}", + inner.as_rule() + )), } } -fn parse_unquoted_word(input: &str) -> ParseResult> { - assert( - parse_word_parts(ParseWordPartsMode::Unquoted), - |result| { - result - .ok() - .map(|(_, parts)| { - if parts.len() == 1 { - if let WordPart::Text(text) = &parts[0] { - return !is_reserved_word(text); +fn parse_simple_command(pair: Pair) -> Result { + let mut env_vars = Vec::new(); + let mut args = Vec::new(); + let mut redirect = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::cmd_prefix => { + for prefix in item.into_inner() { + match prefix.as_rule() { + Rule::ASSIGNMENT_WORD => env_vars.push(parse_env_var(prefix)?), + Rule::io_redirect => todo!("io_redirect as prefix"), + _ => { + return Err(anyhow::anyhow!( + "Unexpected rule in cmd_prefix: {:?}", + prefix.as_rule() + )) } } - true - }) - .unwrap_or(true) - }, - "Unsupported reserved word.", - )(input) -} - -fn parse_quoted_string(input: &str) -> ParseResult> { - // Strings may be up beside each other, and if they are they - // should be categorized as the same argument. - map( - many1(or( - map(parse_single_quoted_string, |text| { - vec![WordPart::Text(text.to_string())] - }), - parse_double_quoted_string, - )), - |vecs| vecs.into_iter().flatten().collect(), - )(input) -} + } + } + Rule::cmd_word | Rule::cmd_name => { + args.push(parse_word(item.into_inner().next().unwrap())?) + } + Rule::cmd_suffix => { + for suffix in item.into_inner() { + match suffix.as_rule() { + Rule::UNQUOTED_PENDING_WORD => args.push(parse_word(suffix)?), + Rule::io_redirect => { + redirect = Some(parse_io_redirect(suffix)?); + } + Rule::QUOTED_WORD => { + args.push(Word::new(vec![parse_quoted_word(suffix)?])) + } + _ => { + return Err(anyhow::anyhow!( + "Unexpected rule in cmd_suffix: {:?}", + suffix.as_rule() + )) + } + } + } + } + _ => { + return Err(anyhow::anyhow!( + "Unexpected rule in simple_command: {:?}", + item.as_rule() + )) + } + } + } -fn parse_single_quoted_string(input: &str) -> ParseResult<&str> { - // single quoted strings cannot contain a single quote - // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_02 - delimited( - ch('\''), - take_while(|c| c != '\''), - with_failure_input( - input, - assert_exists(ch('\''), "Expected closing single quote."), - ), - )(input) + Ok(Command { + inner: CommandInner::Simple(SimpleCommand { env_vars, args }), + redirect, + }) } -fn parse_double_quoted_string(input: &str) -> ParseResult> { - // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03 - // Double quotes may have escaped - delimited( - ch('"'), - parse_word_parts(ParseWordPartsMode::DoubleQuotes), - with_failure_input( - input, - assert_exists(ch('"'), "Expected closing double quote."), - ), - )(input) +fn parse_compound_command(pair: Pair) -> Result { + let inner = pair.into_inner().next().unwrap(); + match inner.as_rule() { + Rule::brace_group => todo!("brace_group"), + Rule::subshell => parse_subshell(inner), + Rule::for_clause => todo!("for_clause"), + Rule::case_clause => todo!("case_clause"), + Rule::if_clause => todo!("if_clause"), + Rule::while_clause => todo!("while_clause"), + Rule::until_clause => todo!("until_clause"), + _ => Err(anyhow::anyhow!( + "Unexpected rule in compound_command: {:?}", + inner.as_rule() + )), + } } -#[derive(Clone, Copy, PartialEq, Eq)] -enum ParseWordPartsMode { - DoubleQuotes, - Unquoted, +fn parse_subshell(pair: Pair) -> Result { + let mut items = Vec::new(); + if let Some(inner) = pair.into_inner().next() { + parse_compound_list(inner, &mut items)?; + Ok(Command { + inner: CommandInner::Subshell(Box::new(SequentialList { items })), + redirect: None, + }) + } else { + Err(anyhow::anyhow!("Unexpected end of input in subshell")) + } } -fn parse_word_parts( - mode: ParseWordPartsMode, -) -> impl Fn(&str) -> ParseResult> { - fn parse_escaped_dollar_sign(input: &str) -> ParseResult { - or( - parse_escaped_char('$'), - terminated( - ch('$'), - check_not(or(map(parse_env_var_name, |_| ()), map(ch('('), |_| ()))), - ), - )(input) - } +fn parse_word(pair: Pair) -> Result { + let mut parts = Vec::new(); - fn parse_special_shell_var(input: &str) -> ParseResult { - // for now, these hard error - preceded(ch('$'), |input| { - if let Some(char) = input.chars().next() { - // $$ - process id - // $# - number of arguments in $* - // $* - list of arguments passed to the current process - if "$#*".contains(char) { - return ParseError::fail( - input, - format!("${char} is currently not supported."), - ); + match pair.as_rule() { + Rule::UNQUOTED_PENDING_WORD => { + for part in pair.into_inner() { + match part.as_rule() { + Rule::EXIT_STATUS => parts.push(WordPart::Variable("?".to_string())), + Rule::UNQUOTED_ESCAPE_CHAR | Rule::UNQUOTED_CHAR => { + if let Some(WordPart::Text(ref mut text)) = parts.last_mut() { + text.push(part.as_str().chars().next().unwrap()); + } else { + parts.push(WordPart::Text(part.as_str().to_string())); + } + } + Rule::SUB_COMMAND => { + let command = + parse_complete_command(part.into_inner().next().unwrap())?; + parts.push(WordPart::Command(command)); + } + Rule::VARIABLE => { + parts.push(WordPart::Variable(part.as_str().to_string())) + } + Rule::QUOTED_WORD => { + let quoted = parse_quoted_word(part)?; + parts.push(quoted); + } + _ => { + return Err(anyhow::anyhow!( + "Unexpected rule in UNQUOTED_PENDING_WORD: {:?}", + part.as_rule() + )) + } } } - ParseError::backtrace() - })(input) - } - - fn parse_escaped_char<'a>( - c: char, - ) -> impl Fn(&'a str) -> ParseResult<'a, char> { - preceded(ch('\\'), ch(c)) + } + Rule::QUOTED_WORD => { + let quoted = parse_quoted_word(pair)?; + parts.push(quoted); + } + Rule::ASSIGNMENT_WORD => { + let assignment_str = pair.as_str().to_string(); + parts.push(WordPart::Text(assignment_str)); + } + _ => { + return Err(anyhow::anyhow!( + "Unexpected rule in word: {:?}", + pair.as_rule() + )) + } } - fn first_escaped_char<'a>( - mode: ParseWordPartsMode, - ) -> impl Fn(&'a str) -> ParseResult<'a, char> { - or7( - parse_special_shell_var, - parse_escaped_dollar_sign, - parse_escaped_char('`'), - parse_escaped_char('"'), - parse_escaped_char('('), - parse_escaped_char(')'), - if_true(parse_escaped_char('\''), move |_| { - mode == ParseWordPartsMode::DoubleQuotes - }), - ) + if parts.is_empty() { + Ok(Word::new_empty()) + } else { + Ok(Word::new(parts)) } +} - move |input| { - enum PendingPart<'a> { - Char(char), - Variable(&'a str), - Command(SequentialList), - Parts(Vec), - } - - let (input, parts) = many0(or7( - or( - map(tag("$?"), |_| PendingPart::Variable("?")), - map(first_escaped_char(mode), PendingPart::Char), - ), - map(parse_command_substitution, PendingPart::Command), - map(preceded(ch('$'), parse_env_var_name), PendingPart::Variable), - |input| { - let (_, _) = ch('`')(input)?; - ParseError::fail( - input, - "Back ticks in strings is currently not supported.", - ) - }, - // words can have escaped spaces - map( - if_true(preceded(ch('\\'), ch(' ')), |_| { - mode == ParseWordPartsMode::Unquoted - }), - PendingPart::Char, - ), - map( - if_true(next_char, |&c| match mode { - ParseWordPartsMode::DoubleQuotes => c != '"', - ParseWordPartsMode::Unquoted => { - !c.is_whitespace() && !"~(){}<>|&;\"'".contains(c) +fn parse_quoted_word(pair: Pair) -> Result { + let mut parts = Vec::new(); + let inner = pair.into_inner().next().unwrap(); + + match inner.as_rule() { + Rule::DOUBLE_QUOTED => { + let inner = inner.into_inner().next().unwrap(); + for part in inner.into_inner() { + match part.as_rule() { + Rule::EXIT_STATUS => parts.push(WordPart::Text("$?".to_string())), + Rule::QUOTED_ESCAPE_CHAR => { + if let Some(WordPart::Text(ref mut s)) = parts.last_mut() { + s.push_str(part.as_str()); + } else { + parts.push(WordPart::Text(part.as_str().to_string())); + } } - }), - PendingPart::Char, - ), - |input| match mode { - ParseWordPartsMode::DoubleQuotes => ParseError::backtrace(), - ParseWordPartsMode::Unquoted => { - let (input, parts) = - map(parse_quoted_string, |parts| vec![WordPart::Quoted(parts)])( - input, - )?; - Ok((input, PendingPart::Parts(parts))) - } - }, - ))(input)?; - - let mut result = Vec::new(); - for part in parts { - match part { - PendingPart::Char(c) => { - if let Some(WordPart::Text(text)) = result.last_mut() { - text.push(c); - } else { - result.push(WordPart::Text(c.to_string())); + Rule::SUB_COMMAND => { + let command = + parse_complete_command(part.into_inner().next().unwrap())?; + parts.push(WordPart::Command(command)); + } + Rule::VARIABLE => { + parts.push(WordPart::Variable(part.as_str()[1..].to_string())) + } + Rule::QUOTED_CHAR => { + if let Some(WordPart::Text(ref mut s)) = parts.last_mut() { + s.push_str(part.as_str()); + } else { + parts.push(WordPart::Text(part.as_str().to_string())); + } + } + _ => { + return Err(anyhow::anyhow!( + "Unexpected rule in DOUBLE_QUOTED: {:?}", + part.as_rule() + )) } - } - PendingPart::Command(s) => result.push(WordPart::Command(s)), - PendingPart::Variable(v) => { - result.push(WordPart::Variable(v.to_string())) - } - PendingPart::Parts(parts) => { - result.extend(parts); } } + Ok(WordPart::Quoted(parts)) } - - Ok((input, result)) + Rule::SINGLE_QUOTED => { + let inner_str = inner.as_str(); + let trimmed_str = &inner_str[1..inner_str.len() - 1]; + Ok(WordPart::Quoted(vec![WordPart::Text( + trimmed_str.to_string(), + )])) + } + _ => Err(anyhow::anyhow!( + "Unexpected rule in QUOTED_WORD: {:?}", + inner.as_rule() + )), } } -fn parse_command_substitution(input: &str) -> ParseResult { - delimited(tag("$("), parse_sequential_list, ch(')'))(input) +fn parse_env_var(pair: Pair) -> Result { + let mut parts = pair.into_inner(); + + // Get the name of the environment variable + let name = parts + .next() + .ok_or_else(|| anyhow!("Expected variable name"))? + .as_str() + .to_string(); + + // Get the value of the environment variable + let word_value = if let Some(value) = parts.next() { + parse_word(value)? + } else { + Word::new_empty() + }; + + Ok(EnvVar { + name, + value: word_value, + }) } -fn parse_subshell(input: &str) -> ParseResult { - delimited( - terminated(ch('('), skip_whitespace), - parse_sequential_list, - with_failure_input( - input, - assert_exists(ch(')'), "Expected closing parenthesis on subshell."), +fn parse_io_redirect(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + + // Parse the optional IO number or AMPERSAND + let (maybe_fd, op_and_file) = match inner.next() { + Some(p) if p.as_rule() == Rule::IO_NUMBER => ( + Some(RedirectFd::Fd(p.as_str().parse::().unwrap())), + inner.next().ok_or_else(|| { + anyhow!("Expected redirection operator after IO number") + })?, ), - )(input) -} + Some(p) if p.as_rule() == Rule::AMPERSAND => ( + Some(RedirectFd::StdoutStderr), + inner + .next() + .ok_or_else(|| anyhow!("Expected redirection operator after &"))?, + ), + Some(p) => (None, p), + None => return Err(anyhow!("Unexpected end of input in io_redirect")), + }; -fn parse_u32(input: &str) -> ParseResult { - let mut value: u32 = 0; - let mut byte_index = 0; - for c in input.chars() { - if c.is_ascii_digit() { - let shifted_val = match value.checked_mul(10) { - Some(val) => val, - None => return ParseError::backtrace(), - }; - value = match shifted_val.checked_add(c.to_digit(10).unwrap()) { - Some(val) => val, - None => return ParseError::backtrace(), - }; - } else if byte_index == 0 { - return ParseError::backtrace(); - } else { - break; - } - byte_index += c.len_utf8(); - } - Ok((&input[byte_index..], value)) -} + let (op, io_file) = parse_io_file(op_and_file)?; -fn assert_whitespace_or_end_and_skip(input: &str) -> ParseResult<()> { - terminated(assert_whitespace_or_end, skip_whitespace)(input) + Ok(Redirect { + maybe_fd, + op, + io_file, + }) } -fn assert_whitespace_or_end(input: &str) -> ParseResult<()> { - if let Some(next_char) = input.chars().next() { - if !next_char.is_whitespace() - && !matches!(next_char, ';' | '&' | '|' | '(' | ')') - { - return Err(ParseError::Failure(fail_for_trailing_input(input))); +fn parse_io_file(pair: Pair) -> Result<(RedirectOp, IoFile)> { + let mut inner = pair.into_inner(); + let op = inner + .next() + .ok_or_else(|| anyhow!("Expected redirection operator"))?; + let filename = inner + .next() + .ok_or_else(|| anyhow!("Expected filename after redirection operator"))?; + + let redirect_op = match op.as_rule() { + Rule::LESS => RedirectOp::Input(RedirectOpInput::Redirect), + Rule::GREAT => RedirectOp::Output(RedirectOpOutput::Overwrite), + Rule::DGREAT => RedirectOp::Output(RedirectOpOutput::Append), + Rule::LESSAND | Rule::GREATAND => { + // For these operators, the target must be a number (fd) + let target = filename.as_str(); + if let Ok(fd) = target.parse::() { + return Ok(( + if op.as_rule() == Rule::LESSAND { + RedirectOp::Input(RedirectOpInput::Redirect) + } else { + RedirectOp::Output(RedirectOpOutput::Overwrite) + }, + IoFile::Fd(fd), + )); + } else { + return Err(anyhow!( + "Expected a number after {} operator", + if op.as_rule() == Rule::LESSAND { + "<&" + } else { + ">&" + } + )); + } } - } - Ok((input, ())) -} - -fn is_valid_env_var_char(c: char) -> bool { - // [a-zA-Z0-9_]+ - c.is_ascii_alphanumeric() || c == '_' -} + _ => { + return Err(anyhow!( + "Unexpected redirection operator: {:?}", + op.as_rule() + )) + } + }; -fn is_reserved_word(text: &str) -> bool { - matches!( - text, - "if" - | "then" - | "else" - | "elif" - | "fi" - | "do" - | "done" - | "case" - | "esac" - | "while" - | "until" - | "for" - | "in" - ) -} + let io_file = if filename.as_rule() == Rule::FILE_NAME_PENDING_WORD { + IoFile::Word(parse_word(filename)?) + } else { + return Err(anyhow!( + "Unexpected filename type: {:?}", + filename.as_rule() + )); + }; -fn fail_for_trailing_input(input: &str) -> ParseErrorFailure { - ParseErrorFailure::new(input, "Unexpected character.") + Ok((redirect_op, io_file)) } #[cfg(test)] @@ -920,46 +892,18 @@ mod test { #[test] fn test_main() { - assert_eq!(parse("").err().unwrap().to_string(), "Empty command."); - assert_eq!( - parse("&& testing").err().unwrap().to_string(), - concat!("Unexpected character.\n", " && testing\n", " ~",), - ); - assert_eq!( - parse("test { test").err().unwrap().to_string(), - concat!("Unexpected character.\n", " { test\n", " ~",), - ); + assert!(parse("&& testing").is_err()); + assert!(parse("test { test").is_err()); assert!(parse("cp test/* other").is_ok()); assert!(parse("cp test/? other").is_ok()); - assert_eq!( - parse("(test").err().unwrap().to_string(), - concat!( - "Expected closing parenthesis on subshell.\n", - " (test\n", - " ~" - ), - ); - assert_eq!( - parse("cmd \"test").err().unwrap().to_string(), - concat!("Expected closing double quote.\n", " \"test\n", " ~"), - ); - assert_eq!( - parse("cmd 'test").err().unwrap().to_string(), - concat!("Expected closing single quote.\n", " 'test\n", " ~"), - ); + assert!(parse("(test").is_err()); + assert!(parse("cmd \"test").is_err()); + assert!(parse("cmd 'test").is_err()); assert!(parse("( test ||other&&test;test);(t&est );").is_ok()); assert!(parse("command --arg='value'").is_ok()); assert!(parse("command --arg=\"value\"").is_ok()); - assert_eq!( - parse("echo `echo 1`").err().unwrap().to_string(), - concat!( - "Back ticks in strings is currently not supported.\n", - " `echo 1`\n", - " ~", - ), - ); assert!( parse("deno run --allow-read=. --allow-write=./testing main.ts").is_ok(), ); @@ -967,337 +911,340 @@ mod test { #[test] fn test_sequential_list() { - run_test( - parse_sequential_list, - concat!( - "Name=Value OtherVar=Other command arg1 || command2 arg12 arg13 ; ", - "command3 && command4 & command5 ; export ENV6=5 ; ", - "ENV7=other && command8 || command9 ; ", - "cmd10 && (cmd11 || cmd12)" - ), - Ok(SequentialList { - items: vec![ - SequentialListItem { - is_async: false, - sequence: Sequence::BooleanList(Box::new(BooleanList { - current: SimpleCommand { - env_vars: vec![ - EnvVar::new("Name".to_string(), Word::new_word("Value")), - EnvVar::new("OtherVar".to_string(), Word::new_word("Other")), - ], - args: vec![Word::new_word("command"), Word::new_word("arg1")], - } - .into(), - op: BooleanListOperator::Or, - next: SimpleCommand { - env_vars: vec![], - args: vec![ - Word::new_word("command2"), - Word::new_word("arg12"), - Word::new_word("arg13"), - ], - } - .into(), - })), - }, - SequentialListItem { - is_async: true, - sequence: Sequence::BooleanList(Box::new(BooleanList { - current: SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("command3")], - } - .into(), - op: BooleanListOperator::And, - next: SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("command4")], - } - .into(), - })), - }, - SequentialListItem { - is_async: false, - sequence: SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("command5")], + let parse_and_create = |input: &str| -> Result { + let pairs = ShellParser::parse(Rule::complete_command, input) + .map_err(|e| anyhow::Error::msg(e.to_string()))? + .next() + .unwrap(); + // println!("pairs: {:?}", pairs); + parse_complete_command(pairs) + }; + + // Test case 1 + let input = concat!( + "Name=Value OtherVar=Other command arg1 || command2 arg12 arg13 ; ", + "command3 && command4 & command5 ; export ENV6=5 ; ", + "ENV7=other && command8 || command9 ; ", + "cmd10 && (cmd11 || cmd12)" + ); + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![ + SequentialListItem { + is_async: false, + sequence: Sequence::BooleanList(Box::new(BooleanList { + current: SimpleCommand { + env_vars: vec![ + EnvVar::new("Name".to_string(), Word::new_word("Value")), + EnvVar::new("OtherVar".to_string(), Word::new_word("Other")), + ], + args: vec![Word::new_word("command"), Word::new_word("arg1")], } .into(), - }, - SequentialListItem { - is_async: false, - sequence: SimpleCommand { + op: BooleanListOperator::Or, + next: SimpleCommand { env_vars: vec![], - args: vec![Word::new_word("export"), Word::new_word("ENV6=5")], + args: vec![ + Word::new_word("command2"), + Word::new_word("arg12"), + Word::new_word("arg13"), + ], } .into(), - }, - SequentialListItem { - is_async: false, - sequence: Sequence::BooleanList(Box::new(BooleanList { - current: Sequence::ShellVar(EnvVar::new( - "ENV7".to_string(), - Word::new_word("other"), - )), - op: BooleanListOperator::And, - next: Sequence::BooleanList(Box::new(BooleanList { - current: SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("command8")], - } - .into(), - op: BooleanListOperator::Or, - next: SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("command9")], - } - .into(), - })), - })), - }, - SequentialListItem { - is_async: false, - sequence: Sequence::BooleanList(Box::new(BooleanList { - current: SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("cmd10")], - } - .into(), - op: BooleanListOperator::And, - next: Command { - inner: CommandInner::Subshell(Box::new(SequentialList { - items: vec![SequentialListItem { - is_async: false, - sequence: Sequence::BooleanList(Box::new(BooleanList { - current: SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("cmd11")], - } - .into(), - op: BooleanListOperator::Or, - next: SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("cmd12")], - } - .into(), - })), - }], - })), - redirect: None, - } - .into(), - })), - }, - ], - }), - ); - - run_test( - parse_sequential_list, - "command1 ; command2 ; A='b' command3", - Ok(SequentialList { - items: vec![ - SequentialListItem { - is_async: false, - sequence: SimpleCommand { + })), + }, + SequentialListItem { + is_async: true, + sequence: Sequence::BooleanList(Box::new(BooleanList { + current: SimpleCommand { env_vars: vec![], - args: vec![Word::new_word("command1")], + args: vec![Word::new_word("command3")], } .into(), - }, - SequentialListItem { - is_async: false, - sequence: SimpleCommand { + op: BooleanListOperator::And, + next: SimpleCommand { env_vars: vec![], - args: vec![Word::new_word("command2")], + args: vec![Word::new_word("command4")], } .into(), - }, - SequentialListItem { - is_async: false, - sequence: SimpleCommand { - env_vars: vec![EnvVar::new( - "A".to_string(), - Word::new_string("b"), - )], - args: vec![Word::new_word("command3")], - } - .into(), - }, - ], - }), - ); - - run_test( - parse_sequential_list, - "test &&", - Err("Expected command following boolean operator."), - ); - - run_test( - parse_sequential_list, - "command &", - Ok(SequentialList { - items: vec![SequentialListItem { - is_async: true, + })), + }, + SequentialListItem { + is_async: false, sequence: SimpleCommand { env_vars: vec![], - args: vec![Word::new_word("command")], + args: vec![Word::new_word("command5")], } .into(), - }], - }), - ); - - run_test( - parse_sequential_list, - "test | other", - Ok(SequentialList { - items: vec![SequentialListItem { + }, + SequentialListItem { is_async: false, - sequence: PipeSequence { - current: SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("test")], - } - .into(), - op: PipeSequenceOperator::Stdout, - next: SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("other")], - } - .into(), + sequence: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("export"), Word::new_word("ENV6=5")], } .into(), - }], - }), - ); - - run_test( - parse_sequential_list, - "test |& other", - Ok(SequentialList { - items: vec![SequentialListItem { + }, + SequentialListItem { is_async: false, - sequence: PipeSequence { + sequence: Sequence::BooleanList(Box::new(BooleanList { + current: Sequence::ShellVar(EnvVar::new( + "ENV7".to_string(), + Word::new_word("other"), + )), + op: BooleanListOperator::And, + next: Sequence::BooleanList(Box::new(BooleanList { + current: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command8")], + } + .into(), + op: BooleanListOperator::Or, + next: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command9")], + } + .into(), + })), + })), + }, + SequentialListItem { + is_async: false, + sequence: Sequence::BooleanList(Box::new(BooleanList { current: SimpleCommand { env_vars: vec![], - args: vec![Word::new_word("test")], + args: vec![Word::new_word("cmd10")], } .into(), - op: PipeSequenceOperator::StdoutStderr, - next: SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("other")], + op: BooleanListOperator::And, + next: Command { + inner: CommandInner::Subshell(Box::new(SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: Sequence::BooleanList(Box::new(BooleanList { + current: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("cmd11")], + } + .into(), + op: BooleanListOperator::Or, + next: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("cmd12")], + } + .into(), + })), + }], + })), + redirect: None, } .into(), + })), + }, + ], + }; + assert_eq!(result, expected); + + // Test case 2 + let input = "command1 ; command2 ; A='b' command3"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![ + SequentialListItem { + is_async: false, + sequence: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command1")], } .into(), - }], - }), - ); - - run_test( - parse_sequential_list, - "ENV=1 ENV2=3 && test", - Err("Cannot set multiple environment variables when there is no following command."), - ); - - run_test( - parse_sequential_list, - "echo $MY_ENV;", - Ok(SequentialList { - items: vec![SequentialListItem { + }, + SequentialListItem { is_async: false, sequence: SimpleCommand { env_vars: vec![], - args: vec![ - Word::new_word("echo"), - Word(vec![WordPart::Variable("MY_ENV".to_string())]), - ], + args: vec![Word::new_word("command2")], } .into(), - }], - }), - ); - - run_test( - parse_sequential_list, - "! cmd1 | cmd2 && cmd3", - Ok(SequentialList { - items: vec![SequentialListItem { + }, + SequentialListItem { is_async: false, - sequence: Sequence::BooleanList(Box::new(BooleanList { - current: Pipeline { - negated: true, - inner: PipeSequence { - current: SimpleCommand { - args: vec![Word::new_word("cmd1")], - env_vars: vec![], - } - .into(), - op: PipeSequenceOperator::Stdout, - next: SimpleCommand { - args: vec![Word::new_word("cmd2")], - env_vars: vec![], - } - .into(), + sequence: SimpleCommand { + env_vars: vec![EnvVar::new("A".to_string(), Word::new_string("b"))], + args: vec![Word::new_word("command3")], + } + .into(), + }, + ], + }; + assert_eq!(result, expected); + + // Test case 3 + let input = "test &&"; + assert!(parse_and_create(input).is_err()); + + // Test case 4 + let input = "command &"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![SequentialListItem { + is_async: true, + sequence: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("command")], + } + .into(), + }], + }; + assert_eq!(result, expected); + + // Test case 5 + let input = "test | other"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: PipeSequence { + current: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("test")], + } + .into(), + op: PipeSequenceOperator::Stdout, + next: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("other")], + } + .into(), + } + .into(), + }], + }; + assert_eq!(result, expected); + + // Test case 6 + let input = "test |& other"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: PipeSequence { + current: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("test")], + } + .into(), + op: PipeSequenceOperator::StdoutStderr, + next: SimpleCommand { + env_vars: vec![], + args: vec![Word::new_word("other")], + } + .into(), + } + .into(), + }], + }; + assert_eq!(result, expected); + + // Test case 8 + let input = "echo $MY_ENV;"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: SimpleCommand { + env_vars: vec![], + args: vec![ + Word::new_word("echo"), + Word(vec![WordPart::Variable("MY_ENV".to_string())]), + ], + } + .into(), + }], + }; + assert_eq!(result, expected); + + // Test case 9 + let input = "! cmd1 | cmd2 && cmd3"; + let result = parse_and_create(input).unwrap(); + let expected = SequentialList { + items: vec![SequentialListItem { + is_async: false, + sequence: Sequence::BooleanList(Box::new(BooleanList { + current: Pipeline { + negated: true, + inner: PipeSequence { + current: SimpleCommand { + args: vec![Word::new_word("cmd1")], + env_vars: vec![], + } + .into(), + op: PipeSequenceOperator::Stdout, + next: SimpleCommand { + args: vec![Word::new_word("cmd2")], + env_vars: vec![], } .into(), } .into(), - op: BooleanListOperator::And, - next: SimpleCommand { - args: vec![Word::new_word("cmd3")], - env_vars: vec![], - } - .into(), - })), - }], - }), - ); + } + .into(), + op: BooleanListOperator::And, + next: SimpleCommand { + args: vec![Word::new_word("cmd3")], + env_vars: vec![], + } + .into(), + })), + }], + }; + assert_eq!(result, expected); } #[test] fn test_env_var() { - run_test( - parse_env_var, - "Name=Value", - Ok(EnvVar { + let parse_and_create = |input: &str| -> Result { + let pairs = ShellParser::parse(Rule::ASSIGNMENT_WORD, input) + .map_err(|e| anyhow::anyhow!(e.to_string()))? + .next() + .unwrap(); + parse_env_var(pairs) + }; + + assert_eq!( + parse_and_create("Name=Value").unwrap(), + EnvVar { name: "Name".to_string(), value: Word::new_word("Value"), - }), + } ); - run_test( - parse_env_var, - "Name='quoted value'", - Ok(EnvVar { + + assert_eq!( + parse_and_create("Name='quoted value'").unwrap(), + EnvVar { name: "Name".to_string(), value: Word::new_string("quoted value"), - }), + } ); - run_test( - parse_env_var, - "Name=\"double quoted value\"", - Ok(EnvVar { + + assert_eq!( + parse_and_create("Name=\"double quoted value\"").unwrap(), + EnvVar { name: "Name".to_string(), value: Word::new_string("double quoted value"), - }), + } ); - run_test_with_end( - parse_env_var, - "Name= command_name", - Ok(EnvVar { + + assert_eq!( + parse_and_create("Name=").unwrap(), + EnvVar { name: "Name".to_string(), value: Word(vec![]), - }), - " command_name", + } ); - run_test( - parse_env_var, - "Name=$(test)", - Ok(EnvVar { + assert_eq!( + parse_and_create("Name=$(test)").unwrap(), + EnvVar { name: "Name".to_string(), value: Word(vec![WordPart::Command(SequentialList { items: vec![SequentialListItem { @@ -1309,13 +1256,12 @@ mod test { .into(), }], })]), - }), + } ); - run_test( - parse_env_var, - "Name=$(OTHER=5)", - Ok(EnvVar { + assert_eq!( + parse_and_create("Name=$(OTHER=5)").unwrap(), + EnvVar { name: "Name".to_string(), value: Word(vec![WordPart::Command(SequentialList { items: vec![SequentialListItem { @@ -1326,310 +1272,7 @@ mod test { }), }], })]), - }), - ); - } - - #[test] - fn test_single_quotes() { - run_test( - parse_quoted_string, - "'test'", - Ok(vec![WordPart::Text("test".to_string())]), - ); - run_test( - parse_quoted_string, - r"'te\\'", - Ok(vec![WordPart::Text(r"te\\".to_string())]), - ); - run_test_with_end( - parse_quoted_string, - r"'te\'st'", - Ok(vec![WordPart::Text(r"te\".to_string())]), - "st'", - ); - run_test( - parse_quoted_string, - "' '", - Ok(vec![WordPart::Text(" ".to_string())]), - ); - run_test( - parse_quoted_string, - "' ", - Err("Expected closing single quote."), - ); - } - - #[test] - fn test_single_quotes_mid_word() { - run_test( - parse_word, - "--inspect='[::0]:3366'", - Ok(Word(vec![ - WordPart::Text("--inspect=".to_string()), - WordPart::Quoted(vec![WordPart::Text("[::0]:3366".to_string())]), - ])), - ); - } - - #[test] - fn test_double_quotes() { - run_test( - parse_quoted_string, - r#"" ""#, - Ok(vec![WordPart::Text(" ".to_string())]), - ); - run_test( - parse_quoted_string, - r#""test""#, - Ok(vec![WordPart::Text("test".to_string())]), - ); - run_test( - parse_quoted_string, - r#""te\"\$\`st""#, - Ok(vec![WordPart::Text(r#"te"$`st"#.to_string())]), - ); - run_test( - parse_quoted_string, - r#"" "#, - Err("Expected closing double quote."), - ); - run_test( - parse_quoted_string, - r#""$Test""#, - Ok(vec![WordPart::Variable("Test".to_string())]), - ); - run_test( - parse_quoted_string, - r#""$Test,$Other_Test""#, - Ok(vec![ - WordPart::Variable("Test".to_string()), - WordPart::Text(",".to_string()), - WordPart::Variable("Other_Test".to_string()), - ]), - ); - run_test( - parse_quoted_string, - r#""asdf`""#, - Err("Back ticks in strings is currently not supported."), - ); - - run_test_with_end( - parse_quoted_string, - r#""test" asdf"#, - Ok(vec![WordPart::Text("test".to_string())]), - " asdf", - ); - } - - #[test] - fn test_parse_word() { - run_test(parse_unquoted_word, "if", Err("Unsupported reserved word.")); - run_test( - parse_unquoted_word, - "$", - Ok(vec![WordPart::Text("$".to_string())]), - ); - // unsupported shell variables - run_test( - parse_unquoted_word, - "$$", - Err("$$ is currently not supported."), - ); - run_test( - parse_unquoted_word, - "$#", - Err("$# is currently not supported."), - ); - run_test( - parse_unquoted_word, - "$*", - Err("$* is currently not supported."), - ); - run_test( - parse_unquoted_word, - "test\\ test", - Ok(vec![WordPart::Text("test test".to_string())]), - ); - } - - #[test] - fn test_parse_u32() { - run_test(parse_u32, "999", Ok(999)); - run_test(parse_u32, "11", Ok(11)); - run_test(parse_u32, "0", Ok(0)); - run_test_with_end(parse_u32, "1>", Ok(1), ">"); - run_test(parse_u32, "-1", Err("backtrace")); - run_test(parse_u32, "a", Err("backtrace")); - run_test( - parse_u32, - "16116951273372934291112534924737", - Err("backtrace"), - ); - run_test(parse_u32, "4294967295", Ok(4294967295)); - run_test(parse_u32, "4294967296", Err("backtrace")); - } - - #[track_caller] - fn run_test<'a, T: PartialEq + std::fmt::Debug>( - combinator: impl Fn(&'a str) -> ParseResult<'a, T>, - input: &'a str, - expected: Result, - ) { - run_test_with_end(combinator, input, expected, ""); - } - - #[track_caller] - fn run_test_with_end<'a, T: PartialEq + std::fmt::Debug>( - combinator: impl Fn(&'a str) -> ParseResult<'a, T>, - input: &'a str, - expected: Result, - expected_end: &str, - ) { - match combinator(input) { - Ok((input, value)) => { - assert_eq!(value, expected.unwrap()); - assert_eq!(input, expected_end); - } - Err(ParseError::Backtrace) => { - assert_eq!("backtrace", expected.err().unwrap()); } - Err(ParseError::Failure(err)) => { - assert_eq!( - err.message, - match expected.err() { - Some(err) => err, - None => - panic!("Got error: {:#}", err.into_result::().err().unwrap()), - } - ); - } - } - } - - #[test] - fn test_redirects() { - let expected = Ok(Command { - inner: CommandInner::Simple(SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("echo"), Word::new_word("1")], - }), - redirect: Some(Redirect { - maybe_fd: None, - op: RedirectOp::Output(RedirectOpOutput::Overwrite), - io_file: IoFile::Word(Word(vec![WordPart::Text( - "test.txt".to_string(), - )])), - }), - }); - - run_test(parse_command, "echo 1 > test.txt", expected.clone()); - run_test(parse_command, "echo 1 >test.txt", expected.clone()); - - // append - run_test( - parse_command, - r#"command >> "test.txt""#, - Ok(Command { - inner: CommandInner::Simple(SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("command")], - }), - redirect: Some(Redirect { - maybe_fd: None, - op: RedirectOp::Output(RedirectOpOutput::Append), - io_file: IoFile::Word(Word(vec![WordPart::Quoted(vec![ - WordPart::Text("test.txt".to_string()), - ])])), - }), - }), - ); - - // fd - run_test( - parse_command, - r#"command 2> test.txt"#, - Ok(Command { - inner: CommandInner::Simple(SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("command")], - }), - redirect: Some(Redirect { - maybe_fd: Some(RedirectFd::Fd(2)), - op: RedirectOp::Output(RedirectOpOutput::Overwrite), - io_file: IoFile::Word(Word(vec![WordPart::Text( - "test.txt".to_string(), - )])), - }), - }), - ); - - // both - run_test( - parse_command, - r#"command &> test.txt"#, - Ok(Command { - inner: CommandInner::Simple(SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("command")], - }), - redirect: Some(Redirect { - maybe_fd: Some(RedirectFd::StdoutStderr), - op: RedirectOp::Output(RedirectOpOutput::Overwrite), - io_file: IoFile::Word(Word(vec![WordPart::Text( - "test.txt".to_string(), - )])), - }), - }), - ); - - // output redirect to fd - run_test( - parse_command, - r#"command 2>&1"#, - Ok(Command { - inner: CommandInner::Simple(SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("command")], - }), - redirect: Some(Redirect { - maybe_fd: Some(RedirectFd::Fd(2)), - op: RedirectOp::Output(RedirectOpOutput::Overwrite), - io_file: IoFile::Fd(1), - }), - }), - ); - - // input redirect to fd - run_test( - parse_command, - r#"command <&0"#, - Ok(Command { - inner: CommandInner::Simple(SimpleCommand { - env_vars: vec![], - args: vec![Word::new_word("command")], - }), - redirect: Some(Redirect { - maybe_fd: None, - op: RedirectOp::Input(RedirectOpInput::Redirect), - io_file: IoFile::Fd(0), - }), - }), - ); - - run_test_with_end( - parse_command, - "echo 1 1> stdout.txt 2> stderr.txt", - Err("Multiple redirects are currently not supported."), - "1> stdout.txt 2> stderr.txt", - ); - - // redirect in pipeline sequence command should error - run_test_with_end( - parse_sequence, - "echo 1 1> stdout.txt | cat", - Err("Redirects in pipe sequence commands are currently not supported."), - "echo 1 1> stdout.txt | cat", ); } diff --git a/crates/deno_task_shell/src/shell/commands/mkdir.rs b/crates/deno_task_shell/src/shell/commands/mkdir.rs index 85d9741..25636ca 100644 --- a/crates/deno_task_shell/src/shell/commands/mkdir.rs +++ b/crates/deno_task_shell/src/shell/commands/mkdir.rs @@ -170,13 +170,17 @@ mod test { "cannot create directory 'file.txt': File exists" ); - assert_eq!(execute_mkdir( - dir.path(), - vec!["-p".to_string(), "file.txt".to_string()], - ) - .await - .err() - .unwrap().to_string(), "cannot create directory 'file.txt': File exists"); + assert_eq!( + execute_mkdir( + dir.path(), + vec!["-p".to_string(), "file.txt".to_string()], + ) + .await + .err() + .unwrap() + .to_string(), + "cannot create directory 'file.txt': File exists" + ); assert_eq!( execute_mkdir(dir.path(), vec!["folder".to_string()],) diff --git a/crates/shell/src/main.rs b/crates/shell/src/main.rs index 0ed2550..c2a63ed 100644 --- a/crates/shell/src/main.rs +++ b/crates/shell/src/main.rs @@ -11,7 +11,10 @@ async fn main() { // read text from stdin and print it let script_text = std::fs::read_to_string(&args[1]).unwrap(); - println!("Executing:\n\n{}\n\n-----------------------------------\n\n", script_text); + println!( + "Executing:\n\n{}\n\n-----------------------------------\n\n", + script_text + ); let list = deno_task_shell::parser::parse(&script_text).unwrap(); diff --git a/scripts/script_1.sh b/scripts/script_1.sh index 5543a6b..2fcee71 100644 --- a/scripts/script_1.sh +++ b/scripts/script_1.sh @@ -1 +1 @@ -echo "Hello World!" \ No newline at end of file +echo "Hello World" \ No newline at end of file diff --git a/scripts/script_2.sh b/scripts/script_2.sh index 589b475..7dae140 100644 --- a/scripts/script_2.sh +++ b/scripts/script_2.sh @@ -1,2 +1 @@ -ls; -cat Cargo.toml +echo "Hello, world" | grep "Hello" > output.txt && cat output.txt \ No newline at end of file diff --git a/scripts/script_3.sh b/scripts/script_3.sh new file mode 100644 index 0000000..77556c4 --- /dev/null +++ b/scripts/script_3.sh @@ -0,0 +1,2 @@ +echo $PATH +echo "Hello, world" \ No newline at end of file diff --git a/scripts/script_4.sh b/scripts/script_4.sh new file mode 100644 index 0000000..8c1a164 --- /dev/null +++ b/scripts/script_4.sh @@ -0,0 +1,2 @@ +ls; +cat Cargo.toml \ No newline at end of file diff --git a/scripts/script_5.sh b/scripts/script_5.sh new file mode 100644 index 0000000..237614e --- /dev/null +++ b/scripts/script_5.sh @@ -0,0 +1,2 @@ +TEST=1 +echo $TEST