diff --git a/.github/workflows/rust-linting.yml b/.github/workflows/rust-linting.yml new file mode 100644 index 0000000..dc51ad6 --- /dev/null +++ b/.github/workflows/rust-linting.yml @@ -0,0 +1,46 @@ +name: Rust Linting + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Run cargo fmt + run: cargo fmt -- --check + + clippy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Run cargo clippy + run: cargo clippy --all-targets --workspace -- -D warnings diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml new file mode 100644 index 0000000..1da39b0 --- /dev/null +++ b/.github/workflows/rust-tests.yml @@ -0,0 +1,30 @@ +name: Rust + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Run tests + run: cargo test --manifest-path crates/shell/Cargo.toml --all-targets diff --git a/crates/deno_task_shell/src/grammar.pest b/crates/deno_task_shell/src/grammar.pest index fa4071f..0c7e8b3 100644 --- a/crates/deno_task_shell/src/grammar.pest +++ b/crates/deno_task_shell/src/grammar.pest @@ -28,10 +28,10 @@ QUOTED_PENDING_WORD = ${ ( QUOTED_CHAR )* } -UNQUOTED_ESCAPE_CHAR = ${ ("\\" ~ "$" | "$" ~ !"(" ~ !VARIABLE) | "\\" ~ ("`" | "\"" | "(" | ")")* } +UNQUOTED_ESCAPE_CHAR = ${ ("\\" ~ "$" | "$" ~ !"(" ~ !VARIABLE) | "\\" ~ (" " | "`" | "\"" | "(" | ")")* } QUOTED_ESCAPE_CHAR = ${ "\\" ~ "$" | "$" ~ !"(" ~ !VARIABLE | "\\" ~ ("`" | "\"" | "(" | ")" | "'")* } -UNQUOTED_CHAR = ${ ("\\" ~ " ") | !(WHITESPACE | "~" | "(" | ")" | "{" | "}" | "<" | ">" | "|" | "&" | ";" | "\"" | "'") ~ ANY } +UNQUOTED_CHAR = ${ ("\\" ~ " ") | !("~" | "(" | ")" | "{" | "}" | "<" | ">" | "|" | "&" | ";" | "\"" | "'" | "$") ~ ANY } QUOTED_CHAR = ${ !"\"" ~ ANY } VARIABLE = ${ (ASCII_ALPHANUMERIC | "_")+ } @@ -64,7 +64,7 @@ EXIT_STATUS = ${ "$?" } // Operators OPERATOR = _{ AND_IF | OR_IF | DSEMI | DLESS | DGREAT | LESSAND | GREATAND | LESSGREAT | DLESSDASH | CLOBBER | - "(" | ")" | "{" | "}" | ";" | "&" | "|" | "<" | ">" | "!" + "(" | ")" | "{" | "}" | ";" | "&" | "|" | "<" | ">" } // Reserved words @@ -96,12 +96,12 @@ RESERVED_WORD = _{ // 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)? } +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 = { +command = !{ simple_command | compound_command ~ redirect_list? | function_definition @@ -117,9 +117,9 @@ compound_command = { until_clause } -subshell = { "(" ~ compound_list ~ ")" } -compound_list = { (newline_list? ~ term ~ separator?)+ } -term = { and_or ~ (separator ~ and_or)* } +subshell = !{ "(" ~ compound_list ~ ")" } +compound_list = !{ (newline_list? ~ term ~ separator?)+ } +term = !{ and_or ~ (separator ~ and_or)* } for_clause = { For ~ name ~ linebreak ~ @@ -127,70 +127,70 @@ for_clause = { linebreak ~ do_group } -case_clause = { +case_clause = !{ Case ~ UNQUOTED_PENDING_WORD ~ linebreak ~ linebreak ~ In ~ linebreak ~ (case_list | case_list_ns)? ~ Esac } -case_list = { +case_list = !{ case_item+ } -case_list_ns = { +case_list_ns = !{ case_item_ns+ } -case_item = { +case_item = !{ "("? ~ pattern ~ ")" ~ (compound_list | linebreak) ~ DSEMI ~ linebreak } -case_item_ns = { +case_item_ns = !{ "("? ~ pattern ~ ")" ~ compound_list? ~ linebreak } -pattern = { +pattern = !{ (Esac | UNQUOTED_PENDING_WORD) ~ ("|" ~ UNQUOTED_PENDING_WORD)* } -if_clause = { +if_clause = !{ If ~ compound_list ~ Then ~ compound_list ~ else_part? ~ Fi } -else_part = { +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 } +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? } +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 } +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?) +simple_command = !{ + cmd_prefix ~ cmd_word ~ cmd_suffix? | + (!cmd_prefix ~ cmd_name ~ cmd_suffix?) } -cmd_prefix = { (io_redirect | ASSIGNMENT_WORD)+ } -cmd_suffix = { (io_redirect | QUOTED_WORD | UNQUOTED_PENDING_WORD)+ } +cmd_prefix = !{ (io_redirect | ASSIGNMENT_WORD)+ } +cmd_suffix = !{ (io_redirect | 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 = { +redirect_list = !{ io_redirect+ } +io_redirect = !{ (IO_NUMBER | AMPERSAND)? ~ (io_file | io_here) } +io_file = !{ LESS ~ filename | GREAT ~ filename | DGREAT ~ filename | @@ -200,16 +200,16 @@ io_file = { CLOBBER ~ filename } filename = _{ FILE_NAME_PENDING_WORD } -io_here = { (DLESS | DLESSDASH) ~ here_end } +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 } +sequential_sep = !{ ";" ~ linebreak | newline_list } -wordlist = { UNQUOTED_PENDING_WORD+ } +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 4e85e9c..27919df 100644 --- a/crates/deno_task_shell/src/parser.rs +++ b/crates/deno_task_shell/src/parser.rs @@ -332,6 +332,8 @@ struct ShellParser; pub fn parse(input: &str) -> Result { let mut pairs = ShellParser::parse(Rule::FILE, input)?; + // println!("pairs: {:?}", pairs); + parse_file(pairs.next().unwrap()) } @@ -402,11 +404,13 @@ fn parse_compound_list( Rule::newline_list => { // Ignore newlines } + Rule::separator_op => { + if let Some(last) = items.last_mut() { + last.is_async = item.as_str() == "&"; + } + } _ => { - return Err(anyhow::anyhow!( - "Unexpected rule in compound_list: {:?}", - item.as_rule() - )); + anyhow::bail!("Unexpected rule in compound_list: {:?}", item.as_rule()); } } } @@ -494,6 +498,7 @@ fn parse_shell_var(pair: Pair) -> Result { } fn parse_pipeline(pair: Pair) -> Result { + let pipeline_str = pair.as_str(); let mut inner = pair.into_inner(); // Check if the first element is Bang (negation) @@ -501,7 +506,16 @@ fn parse_pipeline(pair: Pair) -> Result { .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 + // If it's Bang, check for whitespace + if pipeline_str.len() > 1 + && !pipeline_str[1..2].chars().next().unwrap().is_whitespace() + { + anyhow::bail!( + "Perhaps you meant to add a space after the exclamation point to negate the command?\n ! {}", + pipeline_str + ); + } + // Get the actual pipe sequence after whitespace let pipe_sequence = inner.next().ok_or_else(|| { anyhow::anyhow!("Expected pipe sequence after negation") })?; @@ -669,13 +683,38 @@ fn parse_word(pair: Pair) -> Result { 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 => { + 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::UNQUOTED_ESCAPE_CHAR => { + let mut chars = part.as_str().chars(); + let mut escaped_char = String::new(); + while let Some(c) = chars.next() { + match c { + '\\' => { + let next_char = chars.next().unwrap_or('\0'); + escaped_char.push(next_char); + } + '$' => { + escaped_char.push(c); + break; + } + _ => { + escaped_char.push(c); + break; + } + } + } + if let Some(WordPart::Text(ref mut text)) = parts.last_mut() { + text.push_str(&escaped_char); + } else { + parts.push(WordPart::Text(escaped_char)); + } + } Rule::SUB_COMMAND => { let command = parse_complete_command(part.into_inner().next().unwrap())?;