diff --git a/crates/deno_task_shell/src/grammar.pest b/crates/deno_task_shell/src/grammar.pest index a37f606..cd40045 100644 --- a/crates/deno_task_shell/src/grammar.pest +++ b/crates/deno_task_shell/src/grammar.pest @@ -132,16 +132,16 @@ VARIABLE_MODIFIER = _{ VAR_CHECK_SET } -VAR_DEFAULT_VALUE = !{ ":-" ~ PARAMETER_PENDING_WORD? } -VAR_ASSIGN_DEFAULT = !{ ":=" ~ PARAMETER_PENDING_WORD } -VAR_ALTERNATE_VALUE = !{ ":+" ~ PARAMETER_PENDING_WORD } +VAR_DEFAULT_VALUE = !{ ":-" ~ PATTERN_PENDING_WORD? } +VAR_ASSIGN_DEFAULT = !{ ":=" ~ PATTERN_PENDING_WORD } +VAR_ALTERNATE_VALUE = !{ ":+" ~ PATTERN_PENDING_WORD } VAR_SUBSTRING = !{ ":" ~ PARAMETER_PENDING_WORD ~ (":" ~ PARAMETER_PENDING_WORD)? } VAR_LONGEST_SUFFIX_REMOVE = !{ "%%" ~ PATTERN_PENDING_WORD } VAR_SHORTEST_SUFFIX_REMOVE = !{ "%" ~ PATTERN_PENDING_WORD } VAR_LONGEST_PREFIX_REMOVE = !{ "##" ~ PATTERN_PENDING_WORD } VAR_SHORTEST_PREFIX_REMOVE = !{ "#" ~ PATTERN_PENDING_WORD } -VAR_CHECK_UNSET = !{ "-" ~ PARAMETER_PENDING_WORD? } -VAR_CHECK_SET = !{ "+" ~ PARAMETER_PENDING_WORD? } +VAR_CHECK_UNSET = !{ "-" ~ PATTERN_PENDING_WORD? } +VAR_CHECK_SET = !{ "+" ~ PATTERN_PENDING_WORD? } // Like PARAMETER_PENDING_WORD but allows ":" in patterns PATTERN_PENDING_WORD = ${ @@ -395,13 +395,13 @@ else_part = !{ // a compound list (e.g., `if [ ... ] && [ ... ]; then` or `if command; then`). if_condition = !{ compound_list } -condition_inner = !{ unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD } +condition_inner = !{ condition_negation? ~ (unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD) } condition_chain_op = { "||" | "&&" } conditional_expression = !{ ("[[" ~ condition_inner ~ (condition_chain_op ~ condition_inner)* ~ "]]" ~ ";"?) | - ("[" ~ (unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD) ~ "]" ~ ";"?) | - ("test" ~ (unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD) ~ ";"?) + ("[" ~ condition_negation? ~ (unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD) ~ "]" ~ ";"?) | + ("test" ~ condition_negation? ~ (unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD) ~ ";"?) } unary_conditional_expression = !{ @@ -481,7 +481,7 @@ here_end = @{ ("\"" ~ UNQUOTED_PENDING_WORD ~ "\"") | UNQUOTED_PENDING_WORD } newline_list = _{ NEWLINE+ } linebreak = _{ NEWLINE* } -separator_op = { "&" | ";" } +separator_op = { "&" | ";" ~ !";" } separator = _{ separator_op ~ linebreak | newline_list } wordlist = !{ UNQUOTED_PENDING_WORD+ } diff --git a/crates/deno_task_shell/src/parser.rs b/crates/deno_task_shell/src/parser.rs index 2987dbb..a1b7bef 100644 --- a/crates/deno_task_shell/src/parser.rs +++ b/crates/deno_task_shell/src/parser.rs @@ -302,6 +302,7 @@ pub enum ConditionInner { op: Option, right: Word, }, + Negation(Box), LogicalOr(Box, Box), LogicalAnd(Box, Box), } @@ -632,6 +633,21 @@ pub enum IoFile { Word(Word), #[error("Invalid file descriptor")] Fd(u32), + #[error("Invalid heredoc")] + HereDoc(HereDoc), +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Invalid heredoc")] +pub struct HereDoc { + /// The raw body content of the heredoc + pub body: String, + /// Whether the delimiter was quoted (no variable expansion) + pub quoted: bool, + /// Whether to strip leading tabs (<<- syntax) + pub strip_tabs: bool, } #[cfg_attr(feature = "serialization", derive(serde::Serialize))] @@ -705,10 +721,160 @@ pub fn debug_parse(input: &str) { pest_ascii_tree::print_ascii_tree(parsed); } +use std::cell::RefCell; + +thread_local! { + static HEREDOC_BODIES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Pre-process input to extract heredoc bodies before PEG parsing. +/// +/// Heredoc bodies appear on lines after the command containing `< String { + let lines: Vec<&str> = input.split('\n').collect(); + let mut output_lines: Vec<&str> = Vec::new(); + let mut heredocs: Vec = Vec::new(); + + // Pending heredocs: (delimiter, quoted, strip_tabs, body_lines) + let mut pending: Vec<(String, bool, bool, Vec)> = Vec::new(); + let mut i = 0; + + while i < lines.len() { + if !pending.is_empty() { + // We're reading heredoc body lines + let line = lines[i]; + let (ref delim, _, strip_tabs, _) = pending[0]; + let trimmed = if strip_tabs { + line.trim_start_matches('\t') + } else { + line + }; + if trimmed == *delim { + // End of this heredoc body + let (delim, quoted, strip_tabs, body_lines) = pending.remove(0); + let body = body_lines.join("\n"); + heredocs.push(HereDoc { + body, + quoted, + strip_tabs, + }); + // If there are more pending heredocs, the next body starts + // on the next line. The delimiter line itself is consumed. + let _ = delim; + } else { + pending[0].3.push(line.to_string()); + } + i += 1; + continue; + } + + // Not in a heredoc body - scan the line for << patterns + let line = lines[i]; + output_lines.push(line); + + // Simple scanner: find << outside of quotes + let chars: Vec = line.chars().collect(); + let mut j = 0; + let mut in_single_quote = false; + let mut in_double_quote = false; + + while j < chars.len() { + match chars[j] { + '\'' if !in_double_quote => in_single_quote = !in_single_quote, + '"' if !in_single_quote => in_double_quote = !in_double_quote, + '\\' if !in_single_quote => { + j += 1; // skip escaped char + } + '<' if !in_single_quote && !in_double_quote => { + if j + 1 < chars.len() && chars[j + 1] == '<' { + // Found << + let mut k = j + 2; + // Check for <<- (strip tabs) + let strip_tabs = k < chars.len() && chars[k] == '-'; + if strip_tabs { + k += 1; + } + // Skip whitespace after << or <<- + while k < chars.len() && chars[k] == ' ' { + k += 1; + } + // Read delimiter (possibly quoted) + let mut delim = String::new(); + let mut quoted = false; + if k < chars.len() + && (chars[k] == '\'' || chars[k] == '"') + { + quoted = true; + let quote_char = chars[k]; + k += 1; + while k < chars.len() && chars[k] != quote_char { + delim.push(chars[k]); + k += 1; + } + if k < chars.len() { + k += 1; // skip closing quote + } + } else { + while k < chars.len() + && chars[k] != ' ' + && chars[k] != '\t' + && chars[k] != '\n' + && chars[k] != ';' + && chars[k] != '&' + && chars[k] != '|' + && chars[k] != ')' + && chars[k] != '>' + && chars[k] != '<' + { + delim.push(chars[k]); + k += 1; + } + } + if !delim.is_empty() { + pending.push(( + delim, + quoted, + strip_tabs, + Vec::new(), + )); + j = k; + continue; + } + } + } + _ => {} + } + j += 1; + } + i += 1; + } + + HEREDOC_BODIES.with(|hb| { + *hb.borrow_mut() = heredocs; + }); + + output_lines.join("\n") +} + +fn take_next_heredoc() -> Option { + HEREDOC_BODIES.with(|hb| { + let mut bodies = hb.borrow_mut(); + if bodies.is_empty() { + None + } else { + Some(bodies.remove(0)) + } + }) +} + pub fn parse(input: &str) -> Result { - let mut pairs = ShellParser::parse(Rule::FILE, input).map_err(|e| { - miette::Error::new(e.into_miette()).context("Failed to parse input") - })?; + let processed = preprocess_heredocs(input); + let mut pairs = + ShellParser::parse(Rule::FILE, &processed).map_err(|e| { + miette::Error::new(e.into_miette()).context("Failed to parse input") + })?; parse_file(pairs.next().ok_or_else(|| miette!("Empty parse result"))?) } @@ -1364,23 +1530,51 @@ fn parse_else_part(pair: Pair) -> Result { } fn parse_condition_inner(pair: Pair) -> Result { - let inner = pair - .into_inner() + let mut parts = pair.into_inner(); + let first = parts .next() .ok_or_else(|| miette!("Expected condition_inner content"))?; - match inner.as_rule() { + // Check for negation + let (negated, inner) = if first.as_rule() == Rule::condition_negation { + let next = parts + .next() + .ok_or_else(|| miette!("Expected expression after negation"))?; + (true, next) + } else { + (false, first) + }; + + let mut result = match inner.as_rule() { Rule::unary_conditional_expression => { parse_unary_conditional_expression(inner) } Rule::binary_conditional_expression => { parse_binary_conditional_expression(inner) } + Rule::UNQUOTED_PENDING_WORD => { + // Bare word in condition: treated as -n (non-empty string check) + let word = parse_word(inner)?; + Ok(Condition { + condition_inner: ConditionInner::Unary { + op: Some(UnaryOp::NonEmptyString), + right: word, + }, + }) + } _ => Err(miette!( "Unexpected rule in condition_inner: {:?}", inner.as_rule() )), + }?; + + if negated { + result = Condition { + condition_inner: ConditionInner::Negation(Box::new(result)), + }; } + + Ok(result) } fn parse_conditional_expression(pair: Pair) -> Result { @@ -1389,6 +1583,16 @@ fn parse_conditional_expression(pair: Pair) -> Result { .next() .ok_or_else(|| miette!("Expected conditional expression content"))?; + // Check for negation + let (negated, first) = if first.as_rule() == Rule::condition_negation { + let next = inner + .next() + .ok_or_else(|| miette!("Expected expression after negation"))?; + (true, next) + } else { + (false, first) + }; + let mut result = match first.as_rule() { Rule::condition_inner => parse_condition_inner(first)?, Rule::unary_conditional_expression => { @@ -1397,6 +1601,16 @@ fn parse_conditional_expression(pair: Pair) -> Result { Rule::binary_conditional_expression => { parse_binary_conditional_expression(first)? } + Rule::UNQUOTED_PENDING_WORD => { + // Bare word in condition: treated as -n (non-empty string check) + let word = parse_word(first)?; + Condition { + condition_inner: ConditionInner::Unary { + op: Some(UnaryOp::NonEmptyString), + right: word, + }, + } + } _ => { return Err(miette!( "Unexpected rule in conditional expression: {:?}", @@ -1405,6 +1619,12 @@ fn parse_conditional_expression(pair: Pair) -> Result { } }; + if negated { + result = Condition { + condition_inner: ConditionInner::Negation(Box::new(result)), + }; + } + // Handle chained || and && operators while let Some(op_pair) = inner.next() { if op_pair.as_rule() != Rule::condition_chain_op { @@ -1762,6 +1982,15 @@ fn parse_word(pair: Pair) -> Result { parse_arithmetic_expression(part)?; parts.push(WordPart::Arithmetic(arithmetic_expression)); } + Rule::SUB_COMMAND => { + let command = parse_complete_command( + part.into_inner().next().unwrap(), + )?; + parts.push(WordPart::Command(command)); + } + Rule::EXIT_STATUS => { + parts.push(WordPart::ExitStatus); + } Rule::QUOTED_CHAR => { if let Some(WordPart::Text(ref mut s)) = parts.last_mut() @@ -2430,7 +2659,16 @@ fn parse_io_redirect(pair: Pair) -> Result { None => return Err(miette!("Unexpected end of input in io_redirect")), }; - let (op, io_file) = parse_io_file(op_and_file)?; + let (op, io_file) = match op_and_file.as_rule() { + Rule::io_file => parse_io_file(op_and_file)?, + Rule::io_here => parse_io_here(op_and_file)?, + _ => { + return Err(miette!( + "Expected io_file or io_here, got: {:?}", + op_and_file.as_rule() + )) + } + }; Ok(Redirect { maybe_fd, @@ -2439,6 +2677,34 @@ fn parse_io_redirect(pair: Pair) -> Result { }) } +fn parse_io_here(pair: Pair) -> Result<(RedirectOp, IoFile)> { + let mut heredoc = take_next_heredoc().unwrap_or(HereDoc { + body: String::new(), + quoted: false, + strip_tabs: false, + }); + + // Check if DLESSDASH was used + let mut inner = pair.into_inner(); + let op = inner.next().ok_or_else(|| miette!("Expected << or <<-"))?; + if op.as_rule() == Rule::DLESSDASH { + heredoc.strip_tabs = true; + } + + // Check if delimiter was quoted in the here_end rule + if let Some(delim) = inner.next() { + let delim_str = delim.as_str(); + if delim_str.starts_with('"') || delim_str.starts_with('\'') { + heredoc.quoted = true; + } + } + + Ok(( + RedirectOp::Input(RedirectOpInput::Redirect), + IoFile::HereDoc(heredoc), + )) +} + fn parse_io_file(pair: Pair) -> Result<(RedirectOp, IoFile)> { let mut inner = pair.into_inner(); let op = inner diff --git a/crates/deno_task_shell/src/shell/execute.rs b/crates/deno_task_shell/src/shell/execute.rs index cd93d3e..85a42f9 100644 --- a/crates/deno_task_shell/src/shell/execute.rs +++ b/crates/deno_task_shell/src/shell/execute.rs @@ -421,6 +421,59 @@ async fn resolve_redirect_pipe( resolve_redirect_word_pipe(word, &redirect.op, state, stdin, stderr) .await } + IoFile::HereDoc(heredoc) => { + let mut body = heredoc.body.clone(); + if heredoc.strip_tabs { + body = body + .lines() + .map(|l| l.trim_start_matches('\t')) + .collect::>() + .join("\n"); + } + if !heredoc.quoted { + // Expand variables in the body line by line + let mut expanded_lines = Vec::new(); + for line in body.split('\n') { + // Parse each line as a double-quoted string for variable expansion + let escaped_line = + line.replace('\\', "\\\\").replace('"', "\\\""); + let parse_input = format!("echo \"{}\"", escaped_line); + match crate::parser::parse(&parse_input) { + Ok(seq) => { + let (reader, writer) = crate::shell::types::pipe(); + let handle = reader.pipe_to_string_handle(); + let sub_state = state.clone(); + let _ = execute_sequential_list( + seq, + sub_state, + stdin.clone(), + writer, + stderr.clone(), + AsyncCommandBehavior::Wait, + ) + .await; + let output = handle.await.unwrap_or_default(); + // Remove trailing newline added by echo + let trimmed = + output.strip_suffix('\n').unwrap_or(&output); + expanded_lines.push(trimmed.to_string()); + } + Err(_) => { + expanded_lines.push(line.to_string()); + } + } + } + body = expanded_lines.join("\n"); + } + // Add trailing newline if body is non-empty + if !body.is_empty() && !body.ends_with('\n') { + body.push('\n'); + } + let (reader, mut writer) = crate::shell::types::pipe(); + let _ = ShellPipeWriter::write_all(&mut writer, body.as_bytes()); + drop(writer); + Ok(RedirectPipe::Input(reader, None)) + } IoFile::Fd(fd) => match &redirect.op { RedirectOp::Input(RedirectOpInput::Redirect) => { let _ = stderr.write_line( @@ -1711,6 +1764,20 @@ async fn evaluate_condition( }) } } + ConditionInner::Negation(inner) => { + let inner_result = Box::pin(evaluate_condition( + *inner, + state, + stdin.clone(), + stderr.clone(), + )) + .await?; + changes.extend(inner_result.changes); + Ok(ConditionalResult { + value: !inner_result.value, + changes, + }) + } ConditionInner::LogicalAnd(left, right) => { let left_result = Box::pin(evaluate_condition( *left, diff --git a/crates/deno_task_shell/src/shell/types.rs b/crates/deno_task_shell/src/shell/types.rs index c5adf0b..85db206 100644 --- a/crates/deno_task_shell/src/shell/types.rs +++ b/crates/deno_task_shell/src/shell/types.rs @@ -445,6 +445,8 @@ pub enum ShellOptions { ExitOnError, /// If set, the shell print a trace of simple commands when they are invoked `-x` PrintTrace, + /// If set, the shell will treat unset variables as an error during expansion `-u` + NoUnset, } pub type FutureExecuteResult = LocalBoxFuture<'static, ExecuteResult>; diff --git a/crates/shell/src/commands/set.rs b/crates/shell/src/commands/set.rs index fa987c5..dd88cdd 100644 --- a/crates/shell/src/commands/set.rs +++ b/crates/shell/src/commands/set.rs @@ -41,6 +41,12 @@ fn execute_set(args: Vec) -> Result<(i32, Vec)> { ArgKind::PlusFlag('x') => { env_changes.push(EnvChange::SetShellOptions(ShellOptions::PrintTrace, false)); } + ArgKind::ShortFlag('u') => { + env_changes.push(EnvChange::SetShellOptions(ShellOptions::NoUnset, true)); + } + ArgKind::PlusFlag('u') => { + env_changes.push(EnvChange::SetShellOptions(ShellOptions::NoUnset, false)); + } _ => bail!(format!("Unsupported argument: {:?}", arg)), } } diff --git a/crates/tests/test-data/case.sh b/crates/tests/test-data/case.sh index 2334210..07bdc3d 100644 --- a/crates/tests/test-data/case.sh +++ b/crates/tests/test-data/case.sh @@ -103,3 +103,52 @@ This is a tempname. > ;; > esac Letter is between A and C. + +> val="hello" +> case "$val" in hello) echo "matched" ;; *) echo "no match" ;; esac +matched + +> val="world" +> case "$val" in hello) echo "matched" ;; *) echo "no match" ;; esac +no match + +> val="~/.local" +> case "$val" in '~' | '~'/*) echo "tilde" ;; *) echo "other" ;; esac +tilde + +# Empty case body (just ;;) +> case "x" in y) ;; *) echo "default" ;; esac +default + +# Case with only default +> case "anything" in *) echo "always matches" ;; esac +always matches + +# Case without default (no match = no output) +> case "x" in y) echo "y" ;; z) echo "z" ;; esac +> echo "done" +done + +# Inline with multiple commands separated by ; +> case "a" in a) echo "first"; echo "second" ;; esac +first +second + +# Case with command substitution in word +> val=$(echo hello) +> case "$val" in hello) echo "matched cmd sub" ;; esac +matched cmd sub + +# Nested case statements +> outer="a" +> inner="x" +> case "$outer" in +> a) +> case "$inner" in +> x) echo "a-x" ;; +> *) echo "a-other" ;; +> esac +> ;; +> *) echo "other" ;; +> esac +a-x diff --git a/crates/tests/test-data/case_real_world.sh b/crates/tests/test-data/case_real_world.sh new file mode 100644 index 0000000..68057f5 --- /dev/null +++ b/crates/tests/test-data/case_real_world.sh @@ -0,0 +1,108 @@ +> PIXI_HOME="~/.local" +> case "$PIXI_HOME" in '~' | '~'/*) echo "tilde match" ;; *) echo "no match" ;; esac +tilde match + +> PLATFORM="Darwin" +> case "$PLATFORM" in 'Darwin') PLATFORM="apple-darwin" ;; 'Linux') PLATFORM="unknown-linux-musl" ;; esac +> echo "$PLATFORM" +apple-darwin + +> ARCH="arm64" +> case "${ARCH-}" in arm64 | aarch64) ARCH="aarch64" ;; riscv64) ARCH="riscv64gc" ;; esac +> echo "$ARCH" +aarch64 + +> _ostype="Linux" +> _clibtype="gnu" +> case "$_ostype" in +> Android) +> _ostype=linux-android +> ;; +> Linux) +> _ostype=unknown-linux-$_clibtype +> ;; +> FreeBSD) +> _ostype=unknown-freebsd +> ;; +> Darwin) +> _ostype=apple-darwin +> ;; +> *) +> _ostype=unknown +> ;; +> esac +> echo "$_ostype" +unknown-linux-gnu + +> _cputype="x86_64" +> case "$_cputype" in +> i386 | i486 | i686 | i786 | x86) +> _cputype=i686 +> ;; +> aarch64 | arm64) +> _cputype=aarch64 +> ;; +> x86_64 | x86-64 | x64 | amd64) +> _cputype=x86_64 +> ;; +> ppc64le) +> _cputype=powerpc64le +> ;; +> *) +> _cputype=unknown +> ;; +> esac +> echo "$_cputype" +x86_64 + +> uname_r="5.15.0-microsoft-standard" +> case "$uname_r" in *microsoft*) echo "WSL 2" ;; *Microsoft*) echo "WSL 1" ;; *) echo "native" ;; esac +WSL 2 + +> uname_r="5.15.0-plain-kernel" +> case "$uname_r" in *microsoft*) echo "WSL 2" ;; *Microsoft*) echo "WSL 1" ;; *) echo "native" ;; esac +native + +> CHANNEL="stable" +> case "$CHANNEL" in stable|test) echo "valid" ;; *) echo "invalid" ;; esac +valid + +> mirror="Aliyun" +> case "$mirror" in +> Aliyun) +> DOWNLOAD_URL="https://mirrors.aliyun.com/docker-ce" +> ;; +> AzureChinaCloud) +> DOWNLOAD_URL="https://mirror.azure.cn/docker-ce" +> ;; +> "") +> DOWNLOAD_URL="https://download.docker.com" +> ;; +> *) +> echo "unknown mirror" +> ;; +> esac +> echo "$DOWNLOAD_URL" +https://mirrors.aliyun.com/docker-ce + +> dist="debian" +> dist_version="12" +> case "$dist_version" in 13) dist_version="trixie" ;; 12) dist_version="bookworm" ;; 11) dist_version="bullseye" ;; *) dist_version="unknown" ;; esac +> echo "$dist_version" +bookworm + +> TEST_PROFILE="/home/user/.bashrc" +> case "${TEST_PROFILE-}" in *"/.bashrc" | *"/.bash_profile" | *"/.zshrc" | *"/.zprofile") echo "known" ;; *) echo "unknown" ;; esac +known + +> arg="--quiet" +> case "$arg" in --help) echo "help" ;; --quiet) echo "quiet" ;; *) echo "other" ;; esac +quiet + +> SHELL_NAME="bash" +> case "$SHELL_NAME" in bash) echo "bashrc" ;; fish) echo "config.fish" ;; zsh) echo "zshrc" ;; '') echo "unknown" ;; *) echo "unsupported" ;; esac +bashrc + +> TERM="xterm-256color" +> case "$TERM" in xterm*|rxvt*|linux*|vt*) echo "ansi" ;; *) echo "no-ansi" ;; esac +ansi diff --git a/crates/tests/test-data/conditions.sh b/crates/tests/test-data/conditions.sh index 0a55e4b..16e2a4b 100644 --- a/crates/tests/test-data/conditions.sh +++ b/crates/tests/test-data/conditions.sh @@ -116,4 +116,53 @@ false # true # > if [[ 1 -eq 2 || 2 -eq 2 ]]; then echo true; else echo false; fi -# true \ No newline at end of file +# true + +# Negation with [ ! ] (use string tests to avoid platform-specific paths) +> if [ ! "hello" = "world" ]; then echo "ok"; else echo "fail"; fi +ok + +> if [ ! "hello" = "hello" ]; then echo "fail"; else echo "ok"; fi +ok + +# Negation with [[ ! ]] +> if [[ ! "abc" == "xyz" ]]; then echo "ok"; else echo "fail"; fi +ok + +> if [[ ! "hello" == "world" ]]; then echo "ok"; else echo "fail"; fi +ok + +# Negation with -n and -z +> if [ ! -n "" ]; then echo "ok"; else echo "fail"; fi +ok + +> if [ ! -z "hello" ]; then echo "ok"; else echo "fail"; fi +ok + +> if [ ! -z "" ]; then echo "fail"; else echo "ok"; fi +ok + +# Negation with numeric comparison +> if [ ! 5 -eq 3 ]; then echo "ok"; else echo "fail"; fi +ok + +> if [ ! 5 -gt 10 ]; then echo "ok"; else echo "fail"; fi +ok + +# Negation in compound conditions (string-based, cross-platform) +> if [ ! "a" = "b" ] && [ "c" = "c" ]; then echo "ok"; else echo "fail"; fi +ok + +> if [ ! "a" = "b" ] || [ ! "c" = "c" ]; then echo "ok"; else echo "fail"; fi +ok + +# test builtin with negation +> if test ! "x" = "y"; then echo "ok"; else echo "fail"; fi +ok + +# Bare word in [[ ]] (implicit -n check) +> if [[ "notempty" ]]; then echo "ok"; else echo "fail"; fi +ok + +> if [[ "" ]]; then echo "fail"; else echo "ok"; fi +ok \ No newline at end of file diff --git a/crates/tests/test-data/heredoc.sh b/crates/tests/test-data/heredoc.sh new file mode 100644 index 0000000..4ba6c15 --- /dev/null +++ b/crates/tests/test-data/heredoc.sh @@ -0,0 +1,107 @@ +# Basic heredoc +> cat < hello world +> EOF +hello world + +# Multi-line heredoc +> cat < line one +> line two +> line three +> EOF +line one +line two +line three + +# Heredoc with variable expansion +> NAME="World" +> cat < Hello $NAME! +> EOF +Hello World! + +# Quoted heredoc (no expansion) +> NAME="World" +> cat <<'EOF' +> Hello $NAME! +> EOF +Hello $NAME! + +# Heredoc in a function +> greet() { +> cat < Hello from function +> EOF +> } +> greet +Hello from function + +# Heredoc with command after +> cat < first +> EOF +> echo "after heredoc" +first +after heredoc + +# Empty heredoc produces no output +> cat < EOF +> echo "after empty" +after empty + +# Heredoc with different delimiter +> cat < custom delimiter +> MYDELIM +custom delimiter + +# Heredoc with special characters +> cat <<'EOF' +> angle and pipes | and amps & +> semicolons; and parens () +> EOF +angle and pipes | and amps & +semicolons; and parens () + +# Heredoc with command substitution expansion +> cat < today is $(echo Tuesday) +> EOF +today is Tuesday + +# Multiple heredocs in sequence +> cat < first heredoc +> EOF +> cat < second heredoc +> END +first heredoc +second heredoc + +# Heredoc with tab stripping (<<-) +> cat <<-EOF +> indented with tab +> another tabbed line +> EOF +indented with tab +another tabbed line + +# Heredoc with multiple variable expansions +> X="hello" +> Y="world" +> cat < $X $Y +> ${X}/${Y} +> EOF +hello world +hello/world + +# Double-quoted delimiter (same as single-quoted: no expansion) +> VAR="test" +> cat <<"EOF" +> no $VAR expansion +> EOF +no $VAR expansion diff --git a/crates/tests/test-data/variable_expansion.sh b/crates/tests/test-data/variable_expansion.sh index 533d987..349a8a5 100644 --- a/crates/tests/test-data/variable_expansion.sh +++ b/crates/tests/test-data/variable_expansion.sh @@ -66,4 +66,45 @@ a}b > export VERSION="1.2.3" > echo "Version: ${VERSION:2}" -Version: 2.3 \ No newline at end of file +Version: 2.3 + +# Command substitution inside parameter expansion +> export GREETING="hello" +> echo "${GREETING:-$(echo fallback)}" +hello + +> unset MISSING_VAR +> echo "${MISSING_VAR:-$(echo fallback)}" +fallback + +# Colons inside default values (e.g. URLs) +> unset REPO_URL +> echo "${REPO_URL:-https://github.com/prefix-dev/pixi}" +https://github.com/prefix-dev/pixi + +> echo "${REPO_URL:-https://example.com:8080/path}" +https://example.com:8080/path + +# Colons inside check-unset values +> echo "${REPO_URL-https://default.example.com:443}" +https://default.example.com:443 + +# Nested defaults +> unset A B +> echo "${A:-${B:-deep_default}}" +deep_default + +# Colons in alternate value +> export HAS_VAL="yes" +> echo "${HAS_VAL:+https://example.com:443/path}" +https://example.com:443/path + +# Colons in assign default +> unset ASSIGN_URL +> echo "${ASSIGN_URL:=https://example.com:443}" +https://example.com:443 + +# Command substitution in default with pipes +> unset MISSING +> echo "${MISSING:-$(echo hello | tr h H)}" +Hello \ No newline at end of file