diff --git a/crates/bashkit/src/builtins/alias.rs b/crates/bashkit/src/builtins/alias.rs index 6333413cf..c877c0748 100644 --- a/crates/bashkit/src/builtins/alias.rs +++ b/crates/bashkit/src/builtins/alias.rs @@ -24,7 +24,34 @@ impl Builtin for Alias { return Ok(ExecResult::ok(String::new())); }; - if ctx.args.is_empty() { + // Leading options: `-p` lists, `--` ends them. + let mut args: &[String] = ctx.args; + let mut print_all = false; + while let Some(arg) = args.first() { + if arg == "--" { + args = &args[1..]; + break; + } + if arg.len() < 2 || !arg.starts_with('-') { + break; + } + if arg.chars().skip(1).all(|c| c == 'p') { + print_all = true; + args = &args[1..]; + continue; + } + let bad = arg.chars().nth(1).unwrap_or('-'); + return Ok(ExecResult::err( + format!( + "bash: alias: -{bad}: invalid option\n\ + alias: usage: alias [-p] [name[=value] ... ]\n" + ), + 2, + )); + } + + let mut print_listing = String::new(); + if args.is_empty() || print_all { // List all aliases let mut sorted: Vec<_> = shell.aliases.iter().collect(); sorted.sort_by_key(|(k, _)| (*k).clone()); @@ -32,14 +59,17 @@ impl Builtin for Alias { for (name, value) in sorted { output.push_str(&format!("alias {name}={}\n", single_quote(value))); } - return Ok(ExecResult::ok(output)); + if args.is_empty() { + return Ok(ExecResult::ok(output)); + } + print_listing = output; } - let mut output = String::new(); + let mut output = print_listing; let mut exit_code = 0; let mut stderr = String::new(); - for arg in ctx.args { + for arg in args { if let Some(eq_pos) = arg.find('=') { // alias name=value — set directly let name = &arg[..eq_pos]; @@ -92,7 +122,11 @@ impl Builtin for Unalias { let mut exit_code = 0; let mut stderr = String::new(); - for arg in ctx.args { + let args = match ctx.args.first().map(String::as_str) { + Some("--") => &ctx.args[1..], + _ => ctx.args, + }; + for arg in args { if arg == "-a" { shell.aliases.clear(); } else if shell.aliases.remove(arg.as_str()).is_none() { diff --git a/crates/bashkit/src/builtins/navigation.rs b/crates/bashkit/src/builtins/navigation.rs index b83f2c806..4fe5f701e 100644 --- a/crates/bashkit/src/builtins/navigation.rs +++ b/crates/bashkit/src/builtins/navigation.rs @@ -8,70 +8,189 @@ use crate::error::Result; use crate::interpreter::ExecResult; /// The cd builtin - change directory. +/// +/// `cd [-L|-P [-e]] [-@] [dir]`. Paths resolve logically (`..` drops the +/// previous component, L-FS-001) but, as in bash, every component before a +/// `..` must exist. `-P` resolves symlinks. A relative `dir` that does not +/// start with `.` or `..` is looked up in `CDPATH` first; `cd -` and a +/// non-empty `CDPATH` hit print the new directory. pub struct Cd; #[async_trait] impl Builtin for Cd { async fn execute(&self, ctx: Context<'_>) -> Result { - let Some(target) = ctx - .args - .first() - .map(|s| s.as_str()) - .or_else(|| ctx.variables.get("HOME").map(|s| s.as_str())) - .or_else(|| ctx.env.get("HOME").map(|s| s.as_str())) - else { + let mut physical = false; + let mut operands: &[String] = ctx.args; + while let Some(arg) = operands.first() { + if arg == "--" { + operands = &operands[1..]; + break; + } + if arg.len() < 2 || !arg.starts_with('-') { + break; + } + for ch in arg.chars().skip(1) { + match ch { + 'L' => physical = false, + 'P' => physical = true, + 'e' | '@' => {} + _ => { + return Ok(ExecResult::err( + format!( + "bash: cd: -{ch}: invalid option\n\ + cd: usage: cd [-L|[-P [-e]] [-@]] [dir]\n" + ), + 2, + )); + } + } + } + operands = &operands[1..]; + } + if operands.len() > 1 { + return Ok(ExecResult::err("bash: cd: too many arguments\n", 1)); + } + + let var = |name: &str| { + ctx.variables + .get(name) + .or_else(|| ctx.env.get(name)) + .cloned() + }; + let mut print_dir = false; + let target = match operands.first() { + Some(t) if t == "-" => { + print_dir = true; + match var("OLDPWD") { + Some(old) => old, + None => return Ok(ExecResult::err("bash: cd: OLDPWD not set\n", 1)), + } + } + Some(t) => t.clone(), // Bare `cd` with HOME unset fails, as in bash. - return Ok(ExecResult::err("bash: cd: HOME not set\n", 1)); + None => match var("HOME") { + Some(home) => home, + None => return Ok(ExecResult::err("bash: cd: HOME not set\n", 1)), + }, }; + // `cd ""` stays put (bash 5.2). + if target.is_empty() { + return Ok(ExecResult::ok("")); + } - let new_path = if target.starts_with('/') { - PathBuf::from(target) - } else if target == "-" { - // Go to previous directory - ctx.variables - .get("OLDPWD") - .or_else(|| ctx.env.get("OLDPWD")) - .map(PathBuf::from) - .unwrap_or_else(|| ctx.cwd.clone()) + let mut candidates: Vec<(PathBuf, bool)> = Vec::new(); + let dot_relative = target == "." + || target == ".." + || target.starts_with("./") + || target.starts_with("../"); + if !target.starts_with('/') + && !dot_relative + && let Some(cdpath) = var("CDPATH") + { + for entry in cdpath.split(':') { + let base = if entry.is_empty() { + ctx.cwd.clone() + } else { + vfs_join(ctx.cwd, entry) + }; + candidates.push((vfs_join(&base, &target), !entry.is_empty())); + } + } + let direct = if target.starts_with('/') { + PathBuf::from(&target) } else { - vfs_join(ctx.cwd, target) + vfs_join(ctx.cwd, &target) }; + candidates.push((direct, false)); - // Normalize the path - let normalized = normalize_path(&new_path); - - // Check if directory exists - if ctx.fs.exists(&normalized).await? { - let metadata = ctx.fs.stat(&normalized).await?; - if metadata.file_type.is_dir() { - // Set OLDPWD before changing directory - let old_cwd = ctx.cwd.to_string_lossy().to_string(); - ctx.variables.insert("OLDPWD".to_string(), old_cwd); - *ctx.cwd = normalized; - Ok(ExecResult::ok("")) - } else { - Ok(ExecResult::err( - format!("cd: {}: Not a directory\n", target), - 1, - )) + for (path, from_cdpath) in candidates { + let Some(mut resolved) = logical_dir(ctx.fs.as_ref(), &path).await else { + continue; + }; + if physical { + match crate::fs::canonicalize(ctx.fs.as_ref(), &resolved).await { + Ok(real) => resolved = real, + Err(_) => continue, + } } + let old_cwd = ctx.cwd.to_string_lossy().to_string(); + ctx.variables.insert("OLDPWD".to_string(), old_cwd); + *ctx.cwd = resolved; + let out = if print_dir || from_cdpath { + format!("{}\n", ctx.cwd.to_string_lossy()) + } else { + String::new() + }; + return Ok(ExecResult::ok(out)); + } + + // Report why the direct path failed. + let path = if target.starts_with('/') { + PathBuf::from(&target) } else { - Ok(ExecResult::err( - format!("cd: {}: No such file or directory\n", target), - 1, - )) + vfs_join(ctx.cwd, &target) + }; + let normalized = normalize_path(&path); + let reason = match ctx.fs.stat(&normalized).await { + Ok(m) if !m.file_type.is_dir() => "Not a directory", + _ => "No such file or directory", + }; + Ok(ExecResult::err(format!("cd: {target}: {reason}\n"), 1)) + } +} + +/// Resolve `path` logically: `.` drops, `..` pops the previous component, +/// but only after checking that what it pops is an existing directory +/// (bash: `cd nope/..` fails). Returns the normalized path when it names a +/// directory. +async fn logical_dir(fs: &dyn crate::fs::FileSystem, path: &std::path::Path) -> Option { + use std::path::Component; + let mut out = PathBuf::from("/"); + for comp in path.components() { + match comp { + Component::Normal(name) => out.push(name), + Component::ParentDir => { + if !fs.stat(&out).await.is_ok_and(|m| m.file_type.is_dir()) { + return None; + } + out.pop(); + } + _ => {} } } + let out = normalize_path(&out); + fs.stat(&out) + .await + .is_ok_and(|m| m.file_type.is_dir()) + .then_some(out) } /// The pwd builtin - print working directory. +/// +/// `-P` prints the path with every symlink resolved; `-L` (default) the +/// logical one. pub struct Pwd; #[async_trait] impl Builtin for Pwd { async fn execute(&self, ctx: Context<'_>) -> Result { - let cwd = ctx.cwd.to_string_lossy(); - Ok(ExecResult::ok(format!("{}\n", cwd))) + let mut physical = false; + for arg in ctx.args { + match arg.as_str() { + "-P" => physical = true, + "-L" => physical = false, + "-LP" | "-PL" => physical = arg.ends_with('P'), + _ => {} + } + } + let cwd = if physical { + crate::fs::canonicalize(ctx.fs.as_ref(), ctx.cwd) + .await + .unwrap_or_else(|_| ctx.cwd.clone()) + } else { + ctx.cwd.clone() + }; + Ok(ExecResult::ok(format!("{}\n", cwd.to_string_lossy()))) } } diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index 42f16659b..160ba35a1 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -5,6 +5,12 @@ //! (`${x:-y}`, `${x/a/b}`, `${x#p}`, ...), IFS field splitting, operand //! quoting, and pattern matching helpers. Command-substitution and //! subshell-snapshot machinery stay in the parent module. +//! +//! Important decision: an unquoted word with more than one part is split +//! part by part (`split_word_segments`): literal and quoted parts are +//! protected, only expansion results split, and a used `${v:-"..."}` +//! operand contributes its own quoted/unquoted parts. A lone expansion +//! keeps the cheaper whole-string `ifs_split` path. use super::*; @@ -510,9 +516,14 @@ impl Interpreter { &self.expand_array_access_part(name, index), ); } - WordPart::ArrayIndices(name) => { + WordPart::ArrayIndices { name, star } => { let keys = self.array_keys(name); - Self::append_expansion_for_word(&mut result, word, &keys.join(" ")); + let sep = if *star { + self.get_ifs_separator() + } else { + " ".to_string() + }; + Self::append_expansion_for_word(&mut result, word, &keys.join(&sep)); } WordPart::Substring { name, @@ -606,27 +617,14 @@ impl Interpreter { } } } - WordPart::PrefixMatch(prefix) => { - let mut names: Vec = self - .scoped - .variables - .keys() - .filter(|k| k.starts_with(prefix.as_str())) - // THREAT[TM-INF-017]: Hide internal/hidden marker variables - .filter(|k| !Self::is_hidden_variable(k)) - .cloned() - .collect(); - for k in self.env.keys() { - if k.starts_with(prefix.as_str()) - && !names.contains(k) - // THREAT[TM-INF-017]: Hide internal/hidden marker variables - && !Self::is_hidden_variable(k) - { - names.push(k.clone()); - } - } - names.sort(); - Self::append_expansion_for_word(&mut result, word, &names.join(" ")); + WordPart::PrefixMatch { prefix, star } => { + let names = self.prefix_names(prefix); + let sep = if *star { + self.get_ifs_separator() + } else { + " ".to_string() + }; + Self::append_expansion_for_word(&mut result, word, &names.join(&sep)); } WordPart::ArrayLength(name) => { let resolved = self.resolve_nameref(name); @@ -684,12 +682,7 @@ impl Interpreter { // "$@" preserves individual positional params return Ok(positional); } - // $@ unquoted: each param is subject to further IFS splitting - let mut fields = Vec::new(); - for p in &positional { - fields.extend(self.ifs_split(p)?); - } - return Ok(fields); + return self.unquoted_positional_fields(&positional); } if name == "*" { let positional = self @@ -710,12 +703,7 @@ impl Interpreter { }; return Ok(vec![positional.join(&sep)]); } - // $* unquoted: each param is subject to IFS splitting - let mut fields = Vec::new(); - for p in &positional { - fields.extend(self.ifs_split(p)?); - } - return Ok(fields); + return self.unquoted_positional_fields(&positional); } } if let WordPart::ArrayAccess { name, index } = &word.parts[0] @@ -746,9 +734,30 @@ impl Interpreter { } return Ok(fields); } - // "${!arr[@]}" - array keys/indices as separate fields - if let WordPart::ArrayIndices(name) = &word.parts[0] { - return Ok(self.array_keys(name)); + // `${!arr[@]}` / `${!prefix@}` and their `*` forms. + if let Some((names, star)) = self.name_list_part(&word.parts[0]) { + if word.quoted { + if star { + return Ok(vec![names.join(&self.get_ifs_separator())]); + } + return Ok(names); + } + if star && matches!(self.scoped.variables.get("IFS"), Some(v) if v.is_empty()) { + // bash: a null IFS joins the `*` form into one field, + // keys with a space, names with nothing. + let sep = if matches!(word.parts[0], WordPart::ArrayIndices { .. }) { + " " + } else { + "" + }; + let joined = names.join(sep); + return Ok(if joined.is_empty() { + Vec::new() + } else { + vec![joined] + }); + } + return self.unquoted_positional_fields(&names); } } @@ -762,33 +771,33 @@ impl Interpreter { let has_mixed_part_quotes = word.part_quoted.iter().any(|q| *q) && word.part_quoted.iter().any(|q| !*q); if has_mixed_part_quotes { - let mut segments = Vec::new(); - for (idx, part) in word.parts.iter().enumerate() { - let part_is_quoted = word.part_quoted.get(idx).copied().unwrap_or(word.quoted); - segments.push( - self.mixed_word_segment(word, idx, part, part_is_quoted) - .await?, - ); - } - return self.split_marked_segments(segments); + return self.split_word_segments(word).await; } // For other words, expand to a single field then apply IFS word splitting // when the word is unquoted and contains an expansion. // Per POSIX, unquoted variable/command/arithmetic expansion results undergo // field splitting on IFS. - let expanded = self.expand_word_inner(word).await?; - // IFS splitting applies to unquoted expansions only. // Skip splitting for assignment-like words (e.g., result="$1") where // the lexer stripped quotes from a mixed-quoted word (produces Token::Word // with quoted: false even though the expansion was inside double quotes). - let is_assignment_word = - matches!(word.parts.first(), Some(WordPart::Literal(s)) if s.contains('=')); + let is_assignment_word = matches!( + word.parts.first(), + Some(WordPart::Literal(s)) if Self::is_assignment_prefix(s) + ); let has_expansion = !word.quoted && !is_assignment_word && word.parts.iter().any(Self::is_field_split_expansion); + // Only expansion results split: literal text next to them + // (`${w}:b` with IFS=:) and quoted text inside an operand + // (`${v:-"a b"}`) stay whole. + if has_expansion && (word.parts.len() > 1 || self.quoted_operand_used(&word.parts[0])) { + return self.split_word_segments(word).await; + } + + let expanded = self.expand_word_inner(word).await?; if has_expansion { self.ifs_split(&expanded) } else { @@ -797,6 +806,200 @@ impl Interpreter { }) } + /// `name=` / `name+=` / `name[i]=` at the start of a word: the + /// assignment-looking argument whose value is not field-split. + fn is_assignment_prefix(s: &str) -> bool { + let Some(eq) = s.find('=') else { + return false; + }; + let lhs = s[..eq].strip_suffix('+').unwrap_or(&s[..eq]); + let name = match lhs.find('[') { + Some(open) if lhs.ends_with(']') => &lhs[..open], + Some(_) => return false, + None => lhs, + }; + let mut chars = name.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + } + + /// Unquoted `$@` / `$*` alone: bash joins the parameters with the first + /// IFS character and splits the result, so a non-whitespace IFS keeps + /// empty parameters as empty fields; a null IFS keeps each non-empty + /// parameter as its own field. + fn unquoted_positional_fields(&self, positional: &[String]) -> Result> { + match self.scoped.variables.get("IFS") { + Some(ifs) if ifs.is_empty() => Ok(positional + .iter() + .filter(|p| !p.is_empty()) + .cloned() + .collect()), + ifs => { + let sep = ifs.and_then(|v| v.chars().next()).unwrap_or(' '); + self.ifs_split(&positional.join(sep.encode_utf8(&mut [0; 4]))) + } + } + } + + /// Names listed by `${!arr[@]}` (keys) or `${!prefix@}` (variable + /// names), with whether it is the `*` form. + fn name_list_part(&self, part: &WordPart) -> Option<(Vec, bool)> { + match part { + WordPart::ArrayIndices { name, star } => Some((self.array_keys(name), *star)), + WordPart::PrefixMatch { prefix, star } => Some((self.prefix_names(prefix), *star)), + _ => None, + } + } + + /// Sorted names of set variables starting with `prefix`. + fn prefix_names(&self, prefix: &str) -> Vec { + let mut names: Vec = self + .scoped + .variables + .keys() + .filter(|k| k.starts_with(prefix)) + // THREAT[TM-INF-017]: Hide internal/hidden marker variables + .filter(|k| !Self::is_hidden_variable(k)) + .cloned() + .collect(); + for k in self.env.keys() { + if k.starts_with(prefix) + && !names.contains(k) + // THREAT[TM-INF-017]: Hide internal/hidden marker variables + && !Self::is_hidden_variable(k) + { + names.push(k.clone()); + } + } + names.sort(); + names + } + + /// `${v:-"a b"}` / `${v:+...}` whose operand holds double quotes and will + /// be used: its quoted text must be kept out of field splitting. + fn quoted_operand_used(&self, part: &WordPart) -> bool { + let WordPart::ParameterExpansion { + name, + operator, + operand, + colon_variant, + } = part + else { + return false; + }; + let use_when_set = match operator { + ParameterOp::UseDefault => false, + ParameterOp::UseReplacement => true, + _ => return false, + }; + if !operand.contains('"') { + return false; + } + let (is_set, value) = self.resolve_param_expansion_name(name); + let unset_or_null = !is_set || (*colon_variant && value.is_empty()); + use_when_set != unset_or_null + } + + /// Field-split a word part by part: literal and quoted parts are + /// protected, unquoted expansion results split, and the fields they + /// make join the text around them (`1${u:-"2 3" "4 5"}6` gives + /// `12 3` and `4 56`). + async fn split_word_segments(&mut self, word: &Word) -> Result> { + let ifs_null = matches!(self.scoped.variables.get("IFS"), Some(v) if v.is_empty()); + // A null IFS never splits, but unquoted `$@`/`$*` still yield one + // field per parameter: each starts a new group of segments. + let mut groups: Vec> = vec![Vec::new()]; + for (idx, part) in word.parts.iter().enumerate() { + let quoted = word.part_quoted.get(idx).copied().unwrap_or(word.quoted); + let elements = match part { + WordPart::Variable(name) if !quoted && (name == "@" || name == "*") => Some(( + self.call_stack + .last() + .map(|f| f.positional.clone()) + .unwrap_or_default(), + None, + )), + // bash joins the `*` forms under a null IFS: keys with a + // space, names with nothing. + WordPart::ArrayIndices { star, .. } | WordPart::PrefixMatch { star, .. } + if !quoted => + { + let sep = match part { + WordPart::ArrayIndices { .. } => " ", + _ => "", + }; + self.name_list_part(part) + .map(|(names, _)| (names, star.then_some(sep))) + } + _ => None, + }; + if let Some((positional, null_ifs_join)) = elements { + if ifs_null && let Some(sep) = null_ifs_join { + if let Some(group) = groups.last_mut() { + group.push((positional.join(sep), false, false)); + } + } else if ifs_null { + for (i, p) in positional.into_iter().enumerate() { + if i > 0 { + groups.push(Vec::new()); + } + if let Some(group) = groups.last_mut() { + group.push((p, false, false)); + } + } + } else { + let sep = self + .scoped + .variables + .get("IFS") + .and_then(|v| v.chars().next()) + .unwrap_or(' '); + let joined = positional.join(sep.encode_utf8(&mut [0; 4])); + if let Some(group) = groups.last_mut() { + group.push((joined, false, false)); + } + } + continue; + } + if !quoted && self.quoted_operand_used(part) { + let WordPart::ParameterExpansion { operand, .. } = part else { + continue; + }; + let inner = self.operand_word(operand, false); + for (i, p) in inner.parts.iter().enumerate() { + let q = inner.part_quoted.get(i).copied().unwrap_or(false); + let segment = match p { + // Unquoted operand text is expansion output: it splits. + WordPart::Literal(s) if !q => (s.clone(), false, false), + _ => self.mixed_word_segment(&inner, i, p, q).await?, + }; + if let Some(group) = groups.last_mut() { + group.push(segment); + } + } + continue; + } + let segment = self.mixed_word_segment(word, idx, part, quoted).await?; + if let Some(group) = groups.last_mut() { + group.push(segment); + } + } + if groups.len() == 1 { + return self.split_marked_segments(groups.pop().unwrap_or_default()); + } + let mut out = Vec::new(); + for group in groups { + out.extend(self.split_marked_segments(group)?); + if out.len() > self.limits.max_word_split_fields { + out.truncate(self.limits.max_word_split_fields); + break; + } + } + Ok(out) + } + /// One part of a mixed-quote word as `(value, protected, keeps_empty)`: /// quoted parts and literals are protected from IFS splitting; an empty /// quoted part still makes a field. @@ -978,6 +1181,7 @@ impl Interpreter { } WordPart::Transformation { name, .. } => name == "@" || name.ends_with("[@]"), WordPart::Substring { name, .. } => name == "@", + WordPart::ArrayIndices { star, .. } | WordPart::PrefixMatch { star, .. } => !star, _ => false, } } @@ -1042,6 +1246,10 @@ impl Interpreter { .array_view(name) .map(|items| items.into_iter().map(|(_, v)| v).collect()) .unwrap_or_default(), + WordPart::ArrayIndices { .. } | WordPart::PrefixMatch { .. } => self + .name_list_part(part) + .map(|(n, _)| n) + .unwrap_or_default(), _ => match self.elementwise_fields(part) { Some((elems, _)) => elems, None => return Ok(None), @@ -1102,10 +1310,13 @@ impl Interpreter { WordPart::Variable(_) | WordPart::CommandSubstitution(_) | WordPart::ArithmeticExpansion(_) + | WordPart::Length(_) + | WordPart::ArrayLength(_) | WordPart::ParameterExpansion { .. } | WordPart::ArrayAccess { .. } | WordPart::IndirectExpansion { .. } - | WordPart::PrefixMatch(_) + | WordPart::PrefixMatch { .. } + | WordPart::ArrayIndices { .. } | WordPart::Substring { .. } | WordPart::ArraySlice { .. } | WordPart::Transformation { .. } @@ -1256,6 +1467,10 @@ impl Interpreter { .into_iter() .map(|(c, _)| c) .collect(); + // An empty unquoted result makes no field (`IFS=; $empty`). + if field.is_empty() { + return Ok(Vec::new()); + } let bytes = field.len(); return self.push_ifs_field(Vec::new(), field, limit, bytes); } diff --git a/crates/bashkit/src/interpreter/glob.rs b/crates/bashkit/src/interpreter/glob.rs index d7d9641cc..5b98cbd21 100644 --- a/crates/bashkit/src/interpreter/glob.rs +++ b/crates/bashkit/src/interpreter/glob.rs @@ -818,7 +818,8 @@ impl Interpreter { if !(self.contains_glob_chars(item) || self.contains_extglob(item)) || self.is_noglob() { return Ok(vec![literal(item)]); } - let glob_matches = self.expand_glob(item).await.unwrap_or_default(); + let mut glob_matches = self.expand_glob(item).await.unwrap_or_default(); + self.apply_globignore(&mut glob_matches); if glob_matches.is_empty() { if self.is_failglob() { return Err(literal(item)); @@ -839,6 +840,103 @@ impl Interpreter { } } + /// Non-empty `GLOBIGNORE`, if set. Bash treats an empty value as unset. + fn globignore(&self) -> Option<&str> { + self.scoped + .variables + .get("GLOBIGNORE") + .map(String::as_str) + .filter(|v| !v.is_empty()) + } + + /// Drop matches named by `GLOBIGNORE` (colon-separated patterns), the way + /// bash's `glob_name_is_acceptable` does: `.` and `..` always go, and each + /// pattern matches the whole match string with `FNM_PATHNAME` (a `*` + /// never crosses `/`), so `*.txt` keeps `foo/two.txt`. An emptied list + /// then falls back to the no-match rules (literal word, nullglob, failglob). + fn apply_globignore(&self, matches: &mut Vec) { + let Some(ignore) = self.globignore() else { + return; + }; + let opts = self.glob_opts(); + let patterns = Self::split_globignore(ignore); + matches.retain(|name| { + let last = name + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or(name); + if last == "." || last == ".." { + return false; + } + !patterns + .iter() + .any(|pat| self.glob_match_pathname(name, pat, opts)) + }); + } + + /// Split `GLOBIGNORE` on `:` outside bracket expressions and escapes, + /// so `[[:alnum:]]*` stays one pattern (bash `split_ignorespec`). + fn split_globignore(spec: &str) -> Vec<&str> { + let bytes = spec.as_bytes(); + let mut patterns = Vec::new(); + let mut start = 0; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 1, + b'[' => { + // Find the closing `]`: a leading `!`/`^` and `]` are + // members, and `[:class:]` may hold a `]`-free name. + let mut j = i + 1; + if j < bytes.len() && matches!(bytes[j], b'!' | b'^') { + j += 1; + } + if j < bytes.len() && bytes[j] == b']' { + j += 1; + } + while j < bytes.len() && bytes[j] != b']' { + if bytes[j] == b'[' + && bytes.get(j + 1) == Some(&b':') + && let Some(end) = spec[j + 2..].find(":]") + { + j += end + 4; + continue; + } + j += 1; + } + if j < bytes.len() { + i = j; + } + } + b':' => { + if i > start { + patterns.push(&spec[start..i]); + } + start = i + 1; + } + _ => {} + } + i += 1; + } + if start < spec.len() { + patterns.push(&spec[start..]); + } + patterns + } + + /// `fnmatch(FNM_PATHNAME)`: match component by component so wildcards + /// stay inside one path segment. + fn glob_match_pathname(&self, name: &str, pattern: &str, opts: PatternOpts) -> bool { + let names: Vec<&str> = name.split('/').collect(); + let pats: Vec<&str> = pattern.split('/').collect(); + names.len() == pats.len() + && names + .iter() + .zip(&pats) + .all(|(n, p)| self.glob_match_impl(n, p, opts, 0)) + } + /// Strip only parser-inserted glob metacharacter escapes from a path string. /// /// `quote_expansion_for_quoted_glob` inserts `\` before metacharacters in @@ -910,7 +1008,8 @@ impl Interpreter { /// (`d/*/` gives `d/e/`). Under `shopt -s globstar` a `**` component /// matches zero or more directories, and files too when it is last. pub(crate) async fn expand_glob(&self, pattern: &str) -> Result> { - let dotglob = self.is_dotglob(); + // A non-empty GLOBIGNORE turns dotglob on (bash `setup_glob_ignore`). + let dotglob = self.is_dotglob() || self.globignore().is_some(); let globstar = self.is_globstar(); let opts = self.glob_opts(); let is_absolute = pattern.starts_with('/'); diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 13079c2ce..880e06866 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -4694,10 +4694,13 @@ impl Interpreter { (false, false) => 1, }; self.last_exit_code = exit_code; + // `$(...)` stderr from the operands belongs to this command, so a + // redirect on it (`[[ $(f) ]] 2>log`) catches it. + let subst_stderr = std::mem::take(&mut self.subst_stderr); Ok(ExecResult { stdout: crate::StreamData::new(), - stderr: cond_stderr.into(), + stderr: subst_stderr + &crate::StreamData::from(cond_stderr), exit_code, control_flow: ControlFlow::None, ..Default::default() @@ -4705,7 +4708,10 @@ impl Interpreter { } fn conditional_word_literal(word: &Word) -> Option<&str> { + // A quoted `'!'` or `"("` is an operand, never an operator. if word.parts.len() == 1 + && !word.quoted + && !word.part_quoted.first().copied().unwrap_or(false) && let WordPart::Literal(s) = &word.parts[0] { return Some(s); @@ -4900,23 +4906,47 @@ impl Interpreter { return false; } + // A leaf `x OP y` is a binary test even when an operand reads + // like `!` or `(` (`[[ '!' == ! ]]`): the parser already + // settled the structure. + let binary = args.len() == 3 + && matches!( + args[1].as_str(), + "=" | "==" + | "!=" + | "<" + | ">" + | "=~" + | "-nt" + | "-ot" + | "-ef" + | "-eq" + | "-ne" + | "-lt" + | "-le" + | "-gt" + | "-ge" + ); + // Handle parentheses only when they wrap the whole expression. - if Self::conditional_args_wrapped(args) { + if !binary && Self::conditional_args_wrapped(args) { return self.evaluate_conditional(&args[1..args.len() - 1]).await; } // Look for logical operators at current paren depth: || lowest, then &&. - if let Some(i) = Self::find_top_level_conditional_arg_operator(args, "||") { - return self.evaluate_conditional(&args[..i]).await - || self.evaluate_conditional(&args[i + 1..]).await; - } - if let Some(i) = Self::find_top_level_conditional_arg_operator(args, "&&") { - return self.evaluate_conditional(&args[..i]).await - && self.evaluate_conditional(&args[i + 1..]).await; + if !binary { + if let Some(i) = Self::find_top_level_conditional_arg_operator(args, "||") { + return self.evaluate_conditional(&args[..i]).await + || self.evaluate_conditional(&args[i + 1..]).await; + } + if let Some(i) = Self::find_top_level_conditional_arg_operator(args, "&&") { + return self.evaluate_conditional(&args[..i]).await + && self.evaluate_conditional(&args[i + 1..]).await; + } } // `!` binds tighter than `&&`/`||`. - if args[0] == "!" { + if !binary && args[0] == "!" { return !self.evaluate_conditional(&args[1..]).await; } @@ -7415,21 +7445,49 @@ impl Interpreter { } } - // Build expanded command: alias value + original args - let mut expanded_cmd = expansion; - let trailing_space = expanded_cmd.ends_with(' '); - let mut args_iter = command.args.iter(); - if trailing_space && let Some(first_arg) = args_iter.next() { - let arg_str = Self::format_word_for_alias_reparse(first_arg); - if let Some(arg_expansion) = self.scoped.aliases.get(&arg_str).cloned() { - expanded_cmd.push_str(&arg_expansion); - } else { - expanded_cmd.push_str(&arg_str); + // Build expanded command: prefix assignments (`FOO=1 al`), alias + // value, original args. + let mut expanded_cmd = String::new(); + for assignment in &command.assignments { + expanded_cmd.push_str(&Self::xtrace_assignment_lhs(assignment)); + expanded_cmd.push('='); + match &assignment.value { + AssignmentValue::Scalar(w) => { + expanded_cmd.push_str(&Self::format_word_for_alias_reparse(w)); + } + AssignmentValue::Array(elems) => { + expanded_cmd.push('('); + let elems: Vec = elems + .iter() + .map(Self::format_word_for_alias_reparse) + .collect(); + expanded_cmd.push_str(&elems.join(" ")); + expanded_cmd.push(')'); + } } - } - for word in args_iter { expanded_cmd.push(' '); - expanded_cmd.push_str(&Self::format_word_for_alias_reparse(word)); + } + expanded_cmd.push_str(&expansion); + // A value ending in a blank alias-expands the next word too, and so + // on down the chain (`e_ one two` with `one='ONE '`). + let mut trailing_space = expansion.ends_with(' '); + for word in &command.args { + let arg_str = Self::format_word_for_alias_reparse(word); + let plain = + !word.quoted && word.parts.iter().all(|p| matches!(p, WordPart::Literal(_))); + if trailing_space + && plain + && let Some(arg_expansion) = self.scoped.aliases.get(&arg_str) + { + trailing_space = arg_expansion.ends_with(' '); + expanded_cmd.push_str(arg_expansion); + continue; + } + if !trailing_space { + expanded_cmd.push(' '); + } + trailing_space = false; + expanded_cmd.push_str(&arg_str); } for redir in &command.redirects { expanded_cmd.push(' '); @@ -7466,9 +7524,11 @@ impl Interpreter { self.execute_script_body(&s, false, true).await } } + // A syntax error in the expanded text fails like any syntax + // error: status 2. Err(e) => Ok(ExecResult::err( self.diag(format!("alias expansion: parse error: {e}\n")), - 1, + 2, )), }; @@ -7775,9 +7835,15 @@ impl Interpreter { return Err(err); } - // Alias expansion - if let Some(result) = self - .try_alias_expansion(&name, command, stdin.clone(), var_saves.clone()) + // Alias expansion. Boxed and gated on a defined alias so its + // re-parse state stays off this hot frame (TM-DOS-089). + if self.scoped.aliases.contains_key(&name) + && let Some(result) = Box::pin(self.try_alias_expansion( + &name, + command, + stdin.clone(), + var_saves.clone(), + )) .await { self.discard_deferred_proc_subs_from(deferred_proc_sub_start); @@ -9739,7 +9805,11 @@ impl Interpreter { out.push_str(&format!("${{{}[{}]}}", name, index)) } WordPart::ArrayLength(name) => out.push_str(&format!("${{#{}[@]}}", name)), - WordPart::ArrayIndices(name) => out.push_str(&format!("${{!{}[@]}}", name)), + WordPart::ArrayIndices { name, star } => out.push_str(&format!( + "${{!{}[{}]}}", + name, + if *star { '*' } else { '@' } + )), WordPart::Substring { name, offset, @@ -9782,7 +9852,11 @@ impl Interpreter { out.push_str(&format!("${{!{}}}", name)); } } - WordPart::PrefixMatch(prefix) => out.push_str(&format!("${{!{}*}}", prefix)), + WordPart::PrefixMatch { prefix, star } => out.push_str(&format!( + "${{!{}{}}}", + prefix, + if *star { '*' } else { '@' } + )), WordPart::ProcessSubstitution { commands, is_input } => { let prefix = if *is_input { "<" } else { ">" }; out.push_str(&format!("{}({:?})", prefix, commands)); diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index 96eb77fe0..16bde0949 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -324,7 +324,11 @@ impl fmt::Display for Word { WordPart::Literal(s) => write!(f, "{}", s)?, WordPart::Variable(name) => write!(f, "${}", name)?, WordPart::BadSubstitution(text) => write!(f, "{}", text)?, - WordPart::CommandSubstitution(cmd) => write!(f, "$({:?})", cmd)?, + // Printed as shell text (re-parsed by alias expansion); + // never `Debug` (TM-INF-022). + WordPart::CommandSubstitution(cmd) => { + write!(f, "$({})", super::print_cmd::inline_commands(cmd))? + } WordPart::ArithmeticExpansion(expr) => write!(f, "$(({}))", expr)?, WordPart::ParameterExpansion { name, @@ -370,7 +374,9 @@ impl fmt::Display for Word { WordPart::Length(name) => write!(f, "${{#{}}}", name)?, WordPart::ArrayAccess { name, index } => write!(f, "${{{}[{}]}}", name, index)?, WordPart::ArrayLength(name) => write!(f, "${{#{}[@]}}", name)?, - WordPart::ArrayIndices(name) => write!(f, "${{!{}[@]}}", name)?, + WordPart::ArrayIndices { name, star } => { + write!(f, "${{!{}[{}]}}", name, if *star { '*' } else { '@' })? + } WordPart::Substring { name, offset, @@ -413,10 +419,17 @@ impl fmt::Display for Word { write!(f, "${{!{}}}", name)? } } - WordPart::PrefixMatch(prefix) => write!(f, "${{!{}*}}", prefix)?, + WordPart::PrefixMatch { prefix, star } => { + write!(f, "${{!{}{}}}", prefix, if *star { '*' } else { '@' })? + } WordPart::ProcessSubstitution { commands, is_input } => { let prefix = if *is_input { "<" } else { ">" }; - write!(f, "{}({:?})", prefix, commands)? + write!( + f, + "{}({})", + prefix, + super::print_cmd::inline_commands(commands) + )? } WordPart::Transformation { name, operator } => { write!(f, "${{{}@{}}}", name, operator)? @@ -479,8 +492,8 @@ pub enum WordPart { ArrayAccess { name: String, index: String }, /// Array length `${#arr[@]}` or `${#arr[*]}` ArrayLength(String), - /// Array indices `${!arr[@]}` or `${!arr[*]}` - ArrayIndices(String), + /// Array indices `${!arr[@]}` or (`star`) `${!arr[*]}` + ArrayIndices { name: String, star: bool }, /// Substring extraction `${var:offset}` or `${var:offset:length}` Substring { name: String, @@ -501,8 +514,9 @@ pub enum WordPart { operand: String, colon_variant: bool, }, - /// Prefix matching `${!prefix*}` or `${!prefix@}` - names of variables with given prefix - PrefixMatch(String), + /// Prefix matching `${!prefix*}` (`star`) or `${!prefix@}` - names of + /// variables with given prefix + PrefixMatch { prefix: String, star: bool }, /// Process substitution <(cmd) or >(cmd) ProcessSubstitution { /// The commands to run @@ -726,7 +740,10 @@ mod tests { #[test] fn word_display_array_indices() { let w = Word { - parts: vec![WordPart::ArrayIndices("arr".into())], + parts: vec![WordPart::ArrayIndices { + name: "arr".into(), + star: false, + }], quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), @@ -819,7 +836,10 @@ mod tests { #[test] fn word_display_prefix_match() { let w = Word { - parts: vec![WordPart::PrefixMatch("MY_".into())], + parts: vec![WordPart::PrefixMatch { + prefix: "MY_".into(), + star: true, + }], quoted: false, has_unquoted_glob: false, part_quoted: Vec::new(), diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index f22f1b113..5b5df01bf 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -359,8 +359,23 @@ impl<'a> Lexer<'a> { self.read_word_starting_with("[") } _ => { - // Part of a glob bracket expression [abc], read the whole thing - self.read_bracket_word() + // A backslash inside the brackets is quoting + // (`[\\]_` is the pattern `[\]` then `_`): read + // it as an ordinary word, which removes quotes. + let mut ahead = self.lookahead(); + let escaped = loop { + match ahead.next() { + Some('\\') => break true, + None | Some(']' | ' ' | '\t' | '\n') => break false, + _ => {} + } + }; + if escaped { + self.read_word_from("[".to_string()) + } else { + // Part of a glob bracket expression [abc], read the whole thing + self.read_bracket_word() + } } } } @@ -657,7 +672,7 @@ impl<'a> Lexer<'a> { } continue; } - if quote_char == '\'' && c == '$' { + if quote_char == '\'' && matches!(c, '$' | '\u{1e}' | '\u{1f}') { // Preserve literal '$' semantics from single-quoted // segments when concatenated into an existing word // (e.g. foo'$(id)' or VAR='${HOME}'). @@ -750,7 +765,11 @@ impl<'a> Lexer<'a> { } fn read_word(&mut self) -> Option { - let mut word = String::new(); + self.read_word_from(String::new()) + } + + /// `read_word` with `word` already holding unquoted text (a `[`). + fn read_word_from(&mut self, mut word: String) -> Option { // Track whether any double-quoted segment contained a variable/command // expansion. When true the whole token is promoted to QuotedWord so // the interpreter suppresses IFS field splitting — matching POSIX @@ -760,7 +779,7 @@ impl<'a> Lexer<'a> { // unquoted portion of the word. When both `has_quoted_expansion` and // this flag are true, the word needs IFS-splitting suppression (quoted) // *and* glob expansion on the unquoted portion — e.g. `"$var"*.ext`. - let mut has_unquoted_glob = false; + let mut has_unquoted_glob = word.contains(['*', '?', '[']); // Byte ranges of quoted or backslash-escaped text, and whether an // unquoted `$`/backtick expansion appears. Together they decide how // quoted glob characters stay literal (see the end of this function). @@ -780,9 +799,10 @@ impl<'a> Lexer<'a> { let quote_char = ch; self.advance(); // consume opening quote let mut closed = false; - if quote_char == '"' { - word.push('\u{1e}'); - } + // Both quote kinds mark their span, so the quoted text is + // never IFS-split (`$x'a b'`) and an empty pair still makes + // a field (`$x""`). + word.push('\u{1e}'); let seg_start = word.len(); while let Some(c) = self.peek_char() { if c == quote_char { @@ -852,7 +872,7 @@ impl<'a> Lexer<'a> { } continue; } - if quote_char == '\'' && c == '$' { + if quote_char == '\'' && matches!(c, '$' | '\u{1e}' | '\u{1f}') { // Preserve literal '$' semantics from single-quoted // segments when concatenated into an existing word // (e.g. foo'$(id)' or VAR='${HOME}'). @@ -872,9 +892,7 @@ impl<'a> Lexer<'a> { ))); } quoted_ranges.push((seg_start, word.len())); - if quote_char == '"' { - word.push('\u{1f}'); - } + word.push('\u{1f}'); continue; } else if ch == '$' { // Handle variable references and command substitution @@ -1184,9 +1202,11 @@ impl<'a> Lexer<'a> { // start with a quote. Without one the word never globs, so it is // simply quoted. A word with an unquoted expansion keeps the old // classification: quoting it would suppress field splitting. - let quoted_glob = quoted_ranges - .iter() - .any(|&(start, end)| word[start..end].contains(['*', '?', '[', '{', '}', ','])); + let quoted_glob = quoted_ranges.iter().any(|&(start, end)| { + // `(`, `)` and `|` count too: `*\(\)` must not become the + // extglob `*()`. So does `\`: `[\\]` stays one bracket. + word[start..end].contains(['*', '?', '[', '{', '}', ',', '(', ')', '|', '\\']) + }); if quoted_glob && has_unquoted_glob { return Some(Token::QuotedGlobWord( Self::escape_glob_metas_in_quoted_ranges(&word, "ed_ranges), @@ -1357,6 +1377,12 @@ impl<'a> Lexer<'a> { flags.has_unquoted_expansion = true; content.push('$'); self.advance(); + // `"a"$(cmd)`, `"a"$((n))`, `"a"${x:-a b}`: read the + // whole expansion, as `read_word` does. + if let Err(e) = self.read_continuation_expansion(content) { + flags.error = Some(e); + break; + } } } Some('\\') => { @@ -1393,10 +1419,48 @@ impl<'a> Lexer<'a> { flags } - /// Add quote markers in one rebuild pass. Empty ranges carry no protected - /// bytes, and adjacent quoted ranges are equivalent to one quoted span. + /// After an unquoted `$` in a quoted word's continuation, read a + /// `$(...)`, `$((...))` or `${...}` body into `content`. Other forms + /// (`$name`, `$?`) are word chars the caller reads itself. + fn read_continuation_expansion(&mut self, content: &mut String) -> Result<(), String> { + match self.peek_char() { + Some('(') => { + content.push('('); + self.advance(); + if self.peek_char() == Some('(') { + content.push('('); + self.advance(); + let mut depth = 2; + while let Some(c) = self.peek_char() { + content.push(c); + self.advance(); + if c == '(' { + depth += 1; + } else if c == ')' { + depth -= 1; + if depth == 0 { + break; + } + } + } + } else if !self.read_command_subst_body(content) { + return Err("unterminated command substitution".to_string()); + } + } + Some('{') => { + content.push('{'); + self.advance(); + self.read_unquoted_param_body(content)?; + } + _ => {} + } + Ok(()) + } + + /// Add quote markers in one rebuild pass. Adjacent quoted ranges are + /// equivalent to one quoted span. An empty range (`""$x`) keeps its + /// marker pair: an empty quoted part still makes a field. fn apply_quote_markers(content: &mut String, mut ranges: Vec<(usize, usize)>) { - ranges.retain(|(start, end)| start < end); if ranges.is_empty() { return; } @@ -1439,9 +1503,8 @@ impl<'a> Lexer<'a> { /// braces are NUL-escaped for the same reason. Bash keeps `'` literal in a /// double-quoted `${x:-'d'}`, which `read_param_expansion_into` handles. /// - /// TODO: quoted operand text is still IFS-split when the expansion is - /// unquoted (`${x:-'a b'}` gives two fields, bash gives one). Fixing it - /// needs quote markers carried out of `expand_operand` into field splitting. + /// Field splitting of an unquoted `${x:-'a b'}` keeps those quoted + /// spans whole (`Interpreter::split_word_segments`). fn read_unquoted_param_body(&mut self, word: &mut String) -> Result<(), String> { fn push_escaped(word: &mut String, content: &str) { for ch in content.chars() { @@ -2599,7 +2662,9 @@ mod tests { assert_eq!(lexer.next_token(), Some(Token::Word("echo".to_string()))); assert_eq!( lexer.next_token(), - Some(Token::Word("foo\x00$(id)".to_string())) + // Quote markers bound the single-quoted span; `(`/`)` inside it + // make the word quoted-literal. + Some(Token::QuotedWord("foo\u{1e}\x00$(id)\u{1f}".to_string())) ); assert_eq!(lexer.next_token(), None); } diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 00c7a60b9..9fb2f083d 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -1835,6 +1835,8 @@ impl<'a> Parser<'a> { self.advance(); // consume '[[' let mut words = Vec::new(); + // Token shapes for the grammar check below, one per word. + let mut kinds: Vec = Vec::new(); let mut saw_regex_op = false; loop { @@ -1854,6 +1856,11 @@ impl<'a> Parser<'a> { ); let is_literal = matches!(self.current_token, Some(tokens::Token::LiteralWord(_))); + // Only a bare unquoted word can be an operator (`'!'` and + // `$op` are operands). + let op_text = (matches!(self.current_token, Some(tokens::Token::Word(_))) + && !w_clone.contains(['$', '`', '\\', '\x00', '\u{1e}', '\u{1f}'])) + .then(|| w_clone.clone()); // After =~, handle regex pattern. // If the pattern contains $ (variable reference), parse it as a @@ -1869,6 +1876,7 @@ impl<'a> Parser<'a> { let pattern = self.collect_conditional_regex_pattern(&w_clone); words.push(Word::literal(&pattern)); } + kinds.push(CondTok::Word(None)); saw_regex_op = false; continue; } @@ -1878,6 +1886,7 @@ impl<'a> Parser<'a> { // regex word, where `#`, `|` and spaces in groups are // pattern text, not comments or operators. words.push(Word::literal("=~")); + kinds.push(CondTok::Word(Some("=~".to_string()))); let (raw, unclosed) = self.lexer.read_cond_regex_checked(); if unclosed { // bash reads the group to end of input, then the @@ -1898,6 +1907,7 @@ impl<'a> Parser<'a> { let mut word = self.cond_regex_word(raw.clone()); word.raw = Some(raw.trim().to_string()); words.push(word); + kinds.push(CondTok::Word(None)); } self.advance(); continue; @@ -1925,15 +1935,18 @@ impl<'a> Parser<'a> { parsed }; words.push(self.with_raw(word)); + kinds.push(CondTok::Word(op_text)); self.advance(); } // Operators that the lexer tokenizes separately Some(tokens::Token::And) => { words.push(Word::literal("&&")); + kinds.push(CondTok::Op("&&")); self.advance(); } Some(tokens::Token::Or) => { words.push(Word::literal("||")); + kinds.push(CondTok::Op("||")); self.advance(); } Some(tokens::Token::LeftParen) => { @@ -1941,24 +1954,33 @@ impl<'a> Parser<'a> { // Regex pattern starts with '(' — collect it let pattern = self.collect_conditional_regex_pattern("("); words.push(Word::literal(&pattern)); + kinds.push(CondTok::Word(None)); saw_regex_op = false; continue; } words.push(Word::literal("(")); + kinds.push(CondTok::Op("(")); self.advance(); } Some(tokens::Token::RightParen) => { words.push(Word::literal(")")); + kinds.push(CondTok::Op(")")); self.advance(); } // Inside `[[ ]]` these are string comparison operators, not // redirections (bash's conditional grammar has no redirects). Some(tokens::Token::RedirectIn) => { words.push(Word::literal("<")); + kinds.push(CondTok::Op("<")); self.advance(); } Some(tokens::Token::RedirectOut) => { words.push(Word::literal(">")); + kinds.push(CondTok::Op(">")); + self.advance(); + } + // `[[ a\n&& b\n]]`: newlines inside are blanks. + Some(tokens::Token::Newline) => { self.advance(); } None => { @@ -1967,12 +1989,21 @@ impl<'a> Parser<'a> { )); } _ => { - // Skip unknown tokens + // Any other token (`;`, `3<`, `|`) is outside the + // conditional grammar. + kinds.push(CondTok::Op("token")); self.advance(); } } } + if let Err(msg) = check_conditional(&kinds) { + return Err(Error::parse_at( + msg, + self.current_span.start.line, + self.current_span.start.column, + )); + } Ok(CompoundCommand::Conditional(words)) } @@ -3020,6 +3051,14 @@ impl<'a> Parser<'a> { words.push(Word::literal(sym)); self.advance(); } + // After an assignment `[[` is no longer a reserved word: + // `FOO=bar [[ x ]]` runs a command named `[[` (bash: 127). + Some(tokens::Token::DoubleLeftBracket) + if !words.is_empty() || !assignments.is_empty() => + { + words.push(Word::literal("[[")); + self.advance(); + } Some(tokens::Token::Newline) | Some(tokens::Token::Semicolon) | Some(tokens::Token::Pipe) @@ -3603,6 +3642,9 @@ impl<'a> Parser<'a> { let mut chars = s.chars().peekable(); let mut current = String::new(); let mut in_quoted_segment = false; + // Parts count when the current quoted segment opened, to spot `""`. + let mut segment_start = 0usize; + let mut has_empty_quoted = false; macro_rules! push_part { ($part:expr) => {{ parts.push($part); @@ -3623,6 +3665,14 @@ impl<'a> Parser<'a> { let quoted = ch == '\u{1e}'; if quoted != in_quoted_segment && !current.is_empty() { push_part!(WordPart::Literal(std::mem::take(&mut current))); + } else if !quoted && in_quoted_segment && parts.len() == segment_start { + // An empty quoted segment (`$x""`): keep it as an empty + // quoted part so field splitting can still make a field. + push_part!(WordPart::Literal(String::new())); + has_empty_quoted = true; + } + if quoted && !in_quoted_segment { + segment_start = parts.len(); } in_quoted_segment = quoted; } else if ch == '$' { @@ -3808,7 +3858,10 @@ impl<'a> Parser<'a> { chars.next(); } if index == "@" || index == "*" { - push_part!(WordPart::ArrayIndices(var_name)); + push_part!(WordPart::ArrayIndices { + name: var_name, + star: index == "*", + }); } else { // ${!arr[n]} - not standard, treat as variable push_part!(WordPart::Variable(format!("!{}[{}]", var_name, index))); @@ -3892,7 +3945,10 @@ impl<'a> Parser<'a> { if suffix.ends_with('*') || suffix.ends_with('@') { let full_prefix = format!("{}{}", var_name, &suffix[..suffix.len() - 1]); - push_part!(WordPart::PrefixMatch(full_prefix)); + push_part!(WordPart::PrefixMatch { + prefix: full_prefix, + star: suffix.ends_with('*'), + }); } else { push_part!(WordPart::Variable(format!("!{}{}", var_name, suffix))); } @@ -4411,6 +4467,28 @@ impl<'a> Parser<'a> { push_part!(WordPart::Literal(current)); } + // Empty quoted parts only matter next to an unquoted expansion + // (`""$x""` keeps empty fields); elsewhere drop them so the word + // keeps its plain shape. + if has_empty_quoted { + let unquoted_expansion = parts + .iter() + .zip(&part_quoted) + .any(|(p, q)| !*q && !matches!(p, WordPart::Literal(_))); + if !unquoted_expansion { + let mut kept_parts = Vec::with_capacity(parts.len()); + let mut kept_quoted = Vec::with_capacity(parts.len()); + for (p, q) in parts.into_iter().zip(part_quoted) { + if !matches!(&p, WordPart::Literal(s) if s.is_empty()) { + kept_parts.push(p); + kept_quoted.push(q); + } + } + parts = kept_parts; + part_quoted = kept_quoted; + } + } + // If no parts, create an empty literal if parts.is_empty() { push_part!(WordPart::Literal(String::new())); @@ -4814,6 +4892,137 @@ fn heredoc_body_escapes(content: &str) -> String { out } +/// One `[[ ]]` token for [`check_conditional`]: a word (with its text when +/// it is a bare unquoted word that may act as an operator) or an operator +/// token the lexer split off (`&&`, `||`, `(`, `)`, `<`, `>`, other). +enum CondTok { + Word(Option), + Op(&'static str), +} + +/// bash's `[[ ]]` grammar (parse.y `cond_expr`), checked at parse time so +/// `[[ a $op b ]]`, `[[ -z ]]` or `[[ '(' x ]]` are syntax errors (status +/// 2) as in bash, not runtime tests. Evaluation still runs on the words. +fn check_conditional(toks: &[CondTok]) -> std::result::Result<(), String> { + let mut pos = 0; + cond_or(toks, &mut pos, 0)?; + if pos < toks.len() { + return Err(format!( + "syntax error in conditional expression: unexpected token `{}'", + cond_shown(toks.get(pos)) + )); + } + Ok(()) +} + +const COND_UNARY: &[&str] = &[ + "-a", "-b", "-c", "-d", "-e", "-f", "-g", "-h", "-k", "-n", "-o", "-p", "-r", "-s", "-t", "-u", + "-v", "-w", "-x", "-z", "-G", "-L", "-N", "-O", "-R", "-S", +]; +const COND_BINARY: &[&str] = &[ + "=", "==", "!=", "=~", "-nt", "-ot", "-ef", "-eq", "-ne", "-lt", "-le", "-gt", "-ge", +]; +/// THREAT[TM-DOS-044]: bound recursion on `(((...` / `! ! ! ...`. +const COND_MAX_DEPTH: usize = 256; + +fn cond_shown(tok: Option<&CondTok>) -> String { + match tok { + None => "]]".to_string(), + Some(CondTok::Op(op)) => op.to_string(), + Some(CondTok::Word(Some(text))) => text.clone(), + Some(CondTok::Word(None)) => "word".to_string(), + } +} + +fn cond_word_text(tok: Option<&CondTok>) -> Option<&str> { + match tok { + Some(CondTok::Word(Some(text))) => Some(text), + _ => None, + } +} + +fn cond_or(t: &[CondTok], pos: &mut usize, depth: usize) -> std::result::Result<(), String> { + if depth > COND_MAX_DEPTH { + return Err("conditional expression nested too deeply".to_string()); + } + cond_and(t, pos, depth)?; + while matches!(t.get(*pos), Some(CondTok::Op("||"))) { + *pos += 1; + cond_and(t, pos, depth)?; + } + Ok(()) +} + +fn cond_and(t: &[CondTok], pos: &mut usize, depth: usize) -> std::result::Result<(), String> { + cond_term(t, pos, depth)?; + while matches!(t.get(*pos), Some(CondTok::Op("&&"))) { + *pos += 1; + cond_term(t, pos, depth)?; + } + Ok(()) +} + +fn cond_term(t: &[CondTok], pos: &mut usize, depth: usize) -> std::result::Result<(), String> { + if depth > COND_MAX_DEPTH { + return Err("conditional expression nested too deeply".to_string()); + } + match t.get(*pos) { + None => Err("syntax error in conditional expression".to_string()), + Some(CondTok::Op("(")) => { + *pos += 1; + if matches!(t.get(*pos), Some(CondTok::Op(")"))) { + return Err("unexpected token `)' in conditional command".to_string()); + } + cond_or(t, pos, depth + 1)?; + if !matches!(t.get(*pos), Some(CondTok::Op(")"))) { + return Err(format!( + "unexpected token `{}', expected `)'", + cond_shown(t.get(*pos)) + )); + } + *pos += 1; + Ok(()) + } + Some(CondTok::Word(_)) if cond_word_text(t.get(*pos)) == Some("!") => { + *pos += 1; + cond_term(t, pos, depth + 1) + } + Some(CondTok::Word(_)) => { + let first = cond_word_text(t.get(*pos)); + *pos += 1; + if first.is_some_and(|w| COND_UNARY.contains(&w)) { + if !matches!(t.get(*pos), Some(CondTok::Word(_))) { + return Err(format!( + "unexpected argument `{}' to conditional unary operator", + cond_shown(t.get(*pos)) + )); + } + *pos += 1; + return Ok(()); + } + let binary = match t.get(*pos) { + Some(CondTok::Op("<" | ">")) => true, + Some(CondTok::Word(Some(op))) => COND_BINARY.contains(&op.as_str()), + None | Some(CondTok::Op("&&" | "||" | ")")) => return Ok(()), + _ => false, + }; + if !binary { + return Err("conditional binary operator expected".to_string()); + } + *pos += 1; + if !matches!(t.get(*pos), Some(CondTok::Word(_))) { + return Err(format!( + "unexpected argument `{}' to conditional binary operator", + cond_shown(t.get(*pos)) + )); + } + *pos += 1; + Ok(()) + } + Some(CondTok::Op(op)) => Err(format!("unexpected token `{op}' in conditional command")), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/bashkit/src/parser/print_cmd.rs b/crates/bashkit/src/parser/print_cmd.rs index 211fe8410..53f897d7a 100644 --- a/crates/bashkit/src/parser/print_cmd.rs +++ b/crates/bashkit/src/parser/print_cmd.rs @@ -742,7 +742,7 @@ fn reconstruct_word(w: &Word) -> String { } /// Commands printed on one line (inside `$(...)` reconstructions). -fn inline_commands(cmds: &[Command]) -> String { +pub(crate) fn inline_commands(cmds: &[Command]) -> String { let mut p = Printer::default(); p.print_list(cmds); p.print_deferred_if_any(""); diff --git a/crates/bashkit/tests/spec_cases/bash/alias.test.sh b/crates/bashkit/tests/spec_cases/bash/alias.test.sh index 2bbdb2032..9694c68fd 100644 --- a/crates/bashkit/tests/spec_cases/bash/alias.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/alias.test.sh @@ -203,3 +203,51 @@ show "$dir"/*.txt ### expect ### end + +### alias_double_dash_and_p +shopt -s expand_aliases +alias -- foo=echo; echo "status=$?" +foo x +alias -p +unalias -- foo +foo y 2>/dev/null; echo "after=$?" +### expect +status=0 +x +alias foo='echo' +after=127 +### end + +### alias_trailing_blank_chains +shopt -s expand_aliases +alias e_='echo ' one='ONE ' two='TWO ' three='THREE' +e_ one two one \ + two three two one +### expect +ONE TWO ONE TWO THREE two one +### end + +### alias_keeps_prefix_assignment +shopt -s expand_aliases +alias show='printenv FOO' +FOO=2 show +### expect +2 +### end + +### alias_with_command_substitution_arg +shopt -s expand_aliases +alias a='printf [%s]' +a `echo foo bar` $(echo baz); echo +### expect +[foo][bar][baz] +### end + +### alias_syntax_error_status +### exit_code: 2 +# A syntax error in the expanded text fails with status 2 +shopt -s expand_aliases +alias e_=';; oops' +e_ x +### expect +### end diff --git a/crates/bashkit/tests/spec_cases/bash/cd-builtin.test.sh b/crates/bashkit/tests/spec_cases/bash/cd-builtin.test.sh new file mode 100644 index 000000000..89d6c7304 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/cd-builtin.test.sh @@ -0,0 +1,46 @@ +# cd and pwd builtin tests: options, OLDPWD, CDPATH, logical vs physical paths + +### cd_option_and_operand_errors +cd -x 2>/dev/null; echo "invalid=$?" +cd /tmp /tmp 2>/dev/null; echo "two=$?" +unset OLDPWD; cd - 2>/dev/null; echo "oldpwd=$?" +cd /nonexistent_bk/.. 2>/dev/null; echo "missing_dotdot=$?" +cd -- /; echo "dashdash=$? $PWD" +### expect +invalid=2 +two=1 +oldpwd=1 +missing_dotdot=1 +dashdash=0 / +### end + +### cd_dash_prints_directory +cd /tmp; cd /; cd -; pwd +### expect +/tmp +/tmp +### end + +### cd_cdpath_lookup +d=/tmp/bk_cdpath_$$; mkdir -p $d/spam/foo $d/eggs/foo +CDPATH="$d/spam:$d/eggs"; cd foo >/dev/null; echo "$? ${PWD#$d}" +cd $d; CDPATH=$d/eggs; cd ./foo 2>/dev/null; echo "dot=$?" +cd /; rm -rf $d +### expect +0 /spam/foo +dot=1 +### end + +### cd_physical_and_pwd_p +d=/tmp/bk_cdp_$$; mkdir -p $d/target/sub; ln -s $d/target $d/link +cd $d/link/sub; echo "${PWD#$d}" +cd -L ..; echo "${PWD#$d}" +cd -P $d/link; echo "${PWD#$d}" +cd $d/link; echo "$(basename "$(pwd)") $(basename "$(pwd -P)")" +cd /; rm -rf $d +### expect +/link/sub +/link +/target +link target +### end diff --git a/crates/bashkit/tests/spec_cases/bash/conditional.test.sh b/crates/bashkit/tests/spec_cases/bash/conditional.test.sh index 43ff38380..9d415b823 100644 --- a/crates/bashkit/tests/spec_cases/bash/conditional.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/conditional.test.sh @@ -283,3 +283,48 @@ q6 q7 q8 ### end + +### dbracket_grammar_errors_are_syntax_errors +# Operators must be bare words at parse time: status 2, nothing runs +for c in '[[ a $op a ]]' '[[ -z ]]' "[[ '(' x ]]" '[[ -f < ]]' '[[ ]]' '[[ && ]]' '[[ a == b c ]]' '[[ ( a ]]'; do + op='=='; bash -c "$c; echo ran" 2>/dev/null; echo "$? $c" +done +### expect +2 [[ a $op a ]] +2 [[ -z ]] +2 [[ '(' x ]] +2 [[ -f < ]] +2 [[ ]] +2 [[ && ]] +2 [[ a == b c ]] +2 [[ ( a ]] +### end + +### dbracket_quoted_operator_text_is_operand +[[ '!' == ! ]] && echo bang +[[ -n ! ]] && echo n +[[ x == x +&& y == y +]] && echo multiline +[[ 'foo()' == *\(\) ]] && echo m1 +[[ 'foo()' == *'()' ]] && echo m2 +### expect +bang +n +multiline +m1 +m2 +### end + +### dbracket_after_assignment_is_a_command +FOO=bar [[ a == a ]] 2>/dev/null +echo $? +### expect +127 +### end + +### dbracket_redirect_catches_subst_stderr +[[ $(echo err >&2; echo x) == x ]] 2>/dev/null; echo $? +### expect +0 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/glob-options.test.sh b/crates/bashkit/tests/spec_cases/bash/glob-options.test.sh index bc7f6aacf..ee5d8b708 100644 --- a/crates/bashkit/tests/spec_cases/bash/glob-options.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/glob-options.test.sh @@ -154,3 +154,32 @@ echo /tmp/gs_dot/**/*.txt ### expect /tmp/gs_dot/sub/visible.txt ### end + +### globignore_filters_matches +# GLOBIGNORE drops matches; `*` does not cross `/`; an emptied list is literal +d=/tmp/bk_globignore_$$; mkdir -p $d/foo && cd $d +touch one.md one.txt foo/two.md foo/two.txt hello.c hello.h hello.o +GLOBIGNORE=*.txt; echo *.* foo/*.* +GLOBIGNORE='*.o:*.h'; echo hello* +GLOBIGNORE='*'; echo * +GLOBIGNORE=; echo hello.* +cd /; rm -rf $d +### expect +hello.c hello.h hello.o one.md foo/two.md foo/two.txt +hello.c +* +hello.c hello.h hello.o +### end + +### globignore_enables_dotglob_and_classes +d=/tmp/bk_globignore2_$$; mkdir -p $d && cd $d +touch .env _t.py 2023.log a.txt +GLOBIGNORE='[[:alnum:]]*'; echo * +GLOBIGNORE='[[:digit:]_]*:a*'; echo * +shopt -s nullglob; GLOBIGNORE='*'; echo none * +cd /; rm -rf $d +### expect +.env _t.py +.env +none +### end diff --git a/crates/bashkit/tests/spec_cases/bash/word-split.test.sh b/crates/bashkit/tests/spec_cases/bash/word-split.test.sh index 5d0b8c53d..950446368 100644 --- a/crates/bashkit/tests/spec_cases/bash/word-split.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/word-split.test.sh @@ -491,3 +491,100 @@ set -- "a b" c; printf '[%s]' "${1+"$@"}" ${1+"$@"} "${zz-$@}"; echo ### expect [a b][c][a b][c][a b][c] ### end + +### literal_ifs_char_beside_expansion_not_split +# Only expansion results split: a literal `:` next to ${w} stays text +IFS=:; w=a:; printf '[%s]' ${w}:b ${w}:; echo +IFS=z; w=az; printf '[%s]' ${w}zb; echo +### expect +[a][:b][a][:] +[a][zb] +### end + +### empty_quotes_beside_expansion_keep_field +# $space"" and ""$A"" keep the empty quoted parts as fields +space=" "; A=" abc def " +printf '[%s]' 1 $space"" 2 $space'' 3; echo +printf '[%s]' ""$A""; echo +for i in ""$A""; do echo "=$i="; done +### expect +[1][][2][][3] +[][abc][def][] +== +=abc= +=def= +== +### end + +### quoted_text_inside_unquoted_word_not_split +x=1; y="p q"; printf '[%s]' $x'a b' $y\ r; echo +### expect +[1a b][p][q r] +### end + +### default_operand_quotes_protect_text +# ${u:-"2 3" "4 5"}: quoted operand text stays whole, the blank between splits +printf '[%s]' 1 ${u:-"2 3" "4 5"} 6; echo +printf '[%s]' 1${u:-"2 3" "4 5"}6; echo +IFS=_; printf '[%s]' 1${u:-"2_3"x_x"4_5"}6; echo +IFS=x; v=; echo ${v:-AxBxC}x ${v:-"AxBxC"}x +### expect +[1][2 3][4 5][6] +[12 3][4 56] +[12_3x][x4_56] +A B Cx AxBxCx +### end + +### unquoted_star_nonwhitespace_ifs_keeps_empty +# Unquoted $* / $@ join on the first IFS char, then split +set -- one '' two; IFS=zx +printf '[%s]' $* ; echo +printf '[%s]' =$@=; echo +set -- '' '' '' ''; printf '<%s>' $*; echo " $#" +### expect +[one][][two] +[=one][][two=] +<><><> 4 +### end + +### unquoted_at_null_ifs +# A null IFS never splits, but $@ / $* still give one field per parameter +IFS=; set -- 'a b' '' c +printf '[%s]' $@; echo +printf '[%s]' =$*=; echo +x=; printf '[%s]' $x; echo "n=$#" +### expect +[a b][c] +[=a b][c=] +[]n=3 +### end + +### length_result_is_split +v=hello; IFS=5; echo "[${#v}]" ${#v} end +### expect +[5] end +### end + +### name_list_star_forms_under_null_ifs +a=(x y z); pa=1; pb=2; IFS= +printf '[%s]' ${!a[*]} "${!a[*]}" ${!a[@]}; echo +printf '[%s]' ${!p*} "${!p*}" ${!p@} "x${!p@}y"; echo +### expect +[0 1 2][012][0][1][2] +[papb][papb][pa][pb][xpa][pby] +### end + +### expansion_after_quoted_prefix_runs_once +i=0; echo {a,b}-$((i++)) "q"$((i++)) "c"$(echo d) "u"${z:-e f}; echo $i +### expect +a-0 b-1 q2 cd ue f +3 +### end + +### escaped_backslash_in_bracket_word +printf '[%s]' [\\]_ "[\\]_"; echo +set -f; printf '[%s]' [\\]_; echo +### expect +[[\]_][[\]_] +[[\]_] +### end diff --git a/knowledge/log.md b/knowledge/log.md index e773870c6..97f7bd174 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -3,6 +3,11 @@ ## 2026-10-08 * **Test**: Oils spec pass rate. `just oils-spec` (`scripts/oils-spec/run.py`) fetches the upstream Oils spec suite at a pinned commit (`57d3f0d088c3`, not vendored), runs every case of the 132 bash-column spec files through bashkit (`-c`, `realfs` mounts) and real bash (the Oils way), and scores the bash column of each case with Oils' own assertion rules (`## OK/N-I/BUG bash` overrides win). First run: bashkit passes 1997 of the 2693 cases real bash 5.2.21 passes, **74.2%** (2003/2759 raw); bash itself passes 97.6%. Results go to `scripts/oils-spec/results/` and the `/benches` page. Python 2 helpers (`argv.py`, ...) are re-implemented in bash under `scripts/oils-spec/bin/`. See operations/oils-spec. +* **Fix**: field splitting splits only expansion results. Literal text beside an unquoted expansion stays whole (`IFS=:; w=a:; ${w}:b` is `a` `:b`, it was `a` `` `b`), an empty quoted part keeps a field (`$space""`, `""$A""` give empty fields), single-quoted text inside a word is protected (`$x'a b'`), and quoted text inside a used `${v:-...}`/`${v:+...}` operand is protected while its unquoted blanks split (`1${u:-"2 3" "4 5"}6` is `12 3` `4 56`; also closes the `${x:-'a b'}` gap). Unquoted `$*`/`$@` join on the first IFS character and split, so a non-whitespace IFS keeps empty parameters (`IFS=x; set -- '' ''; $*`) and a null IFS keeps one field per non-empty parameter; an empty unquoted result under a null IFS makes no field. `${#v}` splits, and `${!a[*]}` / `${!prefix*}` are told apart from their `@` forms (AST `ArrayIndices`/`PrefixMatch` carry `star`). Arguments shaped like `=x` are no longer exempt from splitting (only `name=`). `"a"$((i++))`, `"a"$(cmd)` and `"a"${x:-y z}` read their expansion whole (`"a"$((k++))` printed `a$`). `[\\]_` is a bracket holding a backslash (it kept both backslashes), and `*\(\)` / `*'()'` no longer turn into an extglob. +* **Fix**: `GLOBIGNORE` is honored: matches whose path matches a pattern are dropped (FNM_PATHNAME, `:` inside brackets kept), `.` and `..` always go, a non-empty value turns `dotglob` on, and a list emptied this way is a no-match (literal word, `nullglob`, `failglob`). See limitations "`GLOBIGNORE`". +* **Fix**: `[[ ]]` is checked against bash's conditional grammar at parse time: `[[ a $op b ]]`, `[[ -z ]]`, `[[ '(' x ]]`, `[[ -f < ]]`, `[[ ]]`, `[[ && ]]`, `[[ a == b c ]]` are syntax errors (status 2) with bash's wording, newlines inside are blanks, quoted `'!'`/`'('` are operands (`[[ '!' == ! ]]` is true), `FOO=bar [[ ... ]]` runs a command named `[[` (127), and `$(...)` stderr in the operands goes through the command's redirects. `CommandSubstitution`/`ProcessSubstitution` print as shell text in `Word`'s `Display` (was `Debug`, TM-INF-022), which alias expansion re-parses. +* **Fix**: `cd` takes `-L`/`-P`/`-e`/`-@`/`--`, reports `too many arguments`, `OLDPWD not set` and invalid options like bash, fails on `cd nope/..`, prints the directory for `cd -` and `CDPATH` hits, and resolves symlinks under `-P`; `pwd -P` prints the physical path. See limitations "`cd` / `pwd`". +* **Fix**: aliases: `alias --`/`alias -p`/`unalias --`, trailing-blank chaining continues down the chain (`e_ one two` with `one='ONE '`), prefix assignments survive expansion (`FOO=2 al`), backquoted arguments re-parse, and a syntax error in the expansion exits 2. Parse-time alias semantics stay a gap (limitations "Aliases"). Oils spec pass rate 74.2% to 77.3% (`word-split` 16 misses to 0, `globignore` 16 to 1, `dbracket` 13 to 1, `builtin-cd` 14 to 5, `alias` 13 to 7). * **Fix**: process substitution expands to bash's `/dev/fd/63`, counting down per command (`echo <(true) <(true)` is `/dev/fd/63 /dev/fd/62`), skipping fds still open (a caller's substitution, `exec 63>f`), and reusing 63 once the command, or the compound command whose words or redirects opened it, finishes. The fds live in a per-interpreter namespace (`ProcSubFs` wraps each shell's VFS view, TM-ISO-028) instead of `/dev/fd/proc_sub_` files in the shared VFS, so tenants on one filesystem each get their own `/dev/fd/63`; a closed one is "No such file or directory". `set -C` no longer refuses `> >(cmd)` (a FIFO is not clobbered). Remaining gaps in [Limitations](operations/limitations.md). bash-oracle match 103 to 104, strict 101 to 102. * **Fix**: coprocesses run concurrently. `coproc NAME cmd` is a background job wired to the shell through two bounded in-process pipes: writes to `${NAME[1]}` feed its stdin, `exec {NAME[1]}>&-` delivers EOF, `read <&${NAME[0]}` / `read -u` take its output line by line, so `coproc UPPER { tr a-z A-Z; }` and request/response loops (`echo q >&${C[1]}; read r <&${C[0]}`) work; the body used to run once at `coproc` with empty input. fds follow bash (63/60, then 62/58), `$NAME_PID`/`$!`/`wait $NAME_PID` give the job, a second coproc while one runs prints bash's `still exists` warning, NAME and NAME_PID are unset and the fds closed once `wait` reaps it, and `exec()` closes the shell's ends before waiting for jobs. `<&$empty` is now an error instead of reading the shell's stdin. See foundations/parallel-execution "Coprocesses", L-PROC-005. bash-oracle match 102 to 103, strict 100 to 101. * **Fix**: `$SHLVL` follows bash's fork suppression. bash execs some simple commands in place: the last one of a `bash -c` string or `$( )` body (also the right side of a trailing `&&`/`||`/`;`; no redirections, no EXIT/ERR or signal trap set), the last one of a `( )`/`<( )` body (a sole one even with redirections), and any simple command run with `&`; a shell started there lowers `$SHLVL` first and so keeps the level. `exec bash` lowers it too, except directly inside `( )`; a substitution inside a pipeline stage does not. `bash -c 'bash -c "echo \$SHLVL"'` printed 3 in a script started at 1, now 2. A `bash`/`sh` child no longer inherits trap handlers (an ignored signal stays ignored), so `bash -c 'trap "echo bye" EXIT; bash -c true'` prints `bye` once. L-PROC-003 narrowed. bash-oracle match 101 to 102, strict 99 to 100. diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 3694e36c3..1c00dae4e 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -109,6 +109,8 @@ Target: IEEE 1003.1-2024 Shell Command Language. | `bash`/`sh` | `-c`, `-n`, `-e`, `-x`, `-u`, `-f`, `-o option`, script files, stdin, `--version`, `--help` | Login shell | | Expansion errors | `set -u` unbound variables stop the shell | `$((1/0))` evaluates to 0 and `${x!}` expands empty; bash reports them and drops the rest of the line the failing command ends on (`bashbox_line_number_an_expansion_error_drops_the_rest_of_the_line_it_ends_on`, skipped) | | Process substitution | `<(cmd)` and `>(cmd)` as filenames, nesting, redirections | Paths are bash's `/dev/fd/63`, `/dev/fd/62`, ... per command, resolved in the shell's own fd namespace, never the shared VFS (TM-ISO-028; `process_substitution_fd_tests.rs`). The data is buffered, not a pipe: `<(cmd)` runs to completion before the command, `>(cmd)` runs after it. `readlink` prints `pipe:[N]` with the fd as N, not an inode; `ls /dev/fd` lists none of them; `exec 3> >(cmd)` runs `cmd` right after the `exec` with no input; later writes to fd 3 are dropped. `[[ -e <(true) ]]` and `for x in <(a)` do not parse a substitution there. A substitution is always its own word: bash joins it to adjacent text (`x<(true)` is `x/dev/fd/63`, `x=<(true)` assigns the path), Bashkit splits it off (`bashbox-process-substitution.test.sh`, skipped). The substituted list runs in a subshell, as in bash | +| `cd` / `pwd` | `-L`/`-P`/`--`, `cd -` (prints the directory), `CDPATH`, `pwd -P`, bash's errors (`too many arguments`, `OLDPWD not set`, `cd nope/..` fails) | `$PWD` always reads the working directory: assigning or unsetting `PWD` does not stick (bash keeps the variable apart from `pwd`); `OLDPWD` is set but not exported (`cd-builtin.test.sh`) | +| `GLOBIGNORE` | Colon-separated patterns (`:` inside `[...]` kept), matched per path component, `.`/`..` dropped, non-empty value turns `dotglob` on, an emptied match list falls back to the literal word / `nullglob` / `failglob` | `shopt globskipdots` is unknown, and `.*` never yields `.` and `..` (bash 5.2 does once `globskipdots` is off) | | Aliases | `alias`/`unalias`, `shopt -s expand_aliases`, trailing-space chaining, recursion guard | Expanded when the simple command runs, not when its line is read: bash parses a whole line (and a function body at definition) before an `alias`/`unalias` on it takes effect; Bashkit applies them to the very next command | | Named fds / `<>` | `{var}>file`, `{var}file`, `{var}>>file`, `{var}<<&N`/`<&N` allocate the lowest free fd >= 10 on any command (persisting, as in bash); `{var}>&-` closes; `>&N` with N closed is `Bad file descriptor`; `N<>file`, `N<<file` has no shared read/write offset (writes append); `3&1`, `( ... ) 3>&1`) is dropped; a `Bad file descriptor` message ignores redirect order (`2>&1 >&7`); output to a stdout closed by the command's own `>&-` reports `write error: Bad file descriptor` without naming the command, since the redirect is applied after it ran | | Locale | UTF-8 text everywhere; `${#v}` counts bytes when `LC_ALL`/`LC_CTYPE`/`LANG` (first non-empty) is `C` or `POSIX` | With no locale variable set bashkit is UTF-8, where bash falls back to C. Under C, `${s:off:len}`, `${s^^}`, `${s#?}` and glob `?` still work on characters, not bytes: shell words are Rust strings and cannot hold a split UTF-8 sequence (L-STREAM-001) | diff --git a/knowledge/operations/oils-spec.md b/knowledge/operations/oils-spec.md index 99168260a..081c4f3a0 100644 --- a/knowledge/operations/oils-spec.md +++ b/knowledge/operations/oils-spec.md @@ -17,15 +17,17 @@ Implemented. Harness: `scripts/oils-spec/run.py` (`just oils-spec`). ## Headline -**74.2%**: bashkit passes 1997 of the 2693 Oils spec cases real bash 5.2.21 -passes (2759 cases in 132 spec files; bashkit passes 2003 of all cases, -72.6%). Oils `57d3f0d088c3`, bashkit `f86bd7c81ae3`, 2026-10-08, 27 s with 4 -jobs. Report: `scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T103235Z.md`. +**77.3%**: bashkit passes 2080 of the 2692 Oils spec cases real bash 5.2.21 +passes (2759 cases in 132 spec files; bashkit passes 2086 of all cases, +75.6%). Oils `57d3f0d088c3`, 2026-10-08. Report: +`scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T115238Z.md`. +The first run (74.2%, `...T103235Z.md`) missed 16 cases each in +`globignore` and `word-split`, 14 in `builtin-cd`, 13 in `alias` and +`dbracket`; those files now miss 1, 0, 5, 7 and 1. -Spec files with the most misses at that run: `builtin-completion` (34), -`array` (25), `prompt` (25), `builtin-trap-bash` (19), `globignore` (16), -`word-split` (16), `var-ref` (15), `builtin-cd` (14), `alias` (13), -`dbracket` (13). 22 of the 132 files pass completely. +Spec files with the most misses at the latest run: `builtin-completion` +(34), `array` (25), `prompt` (25), `builtin-trap-bash` (19), `var-ref` (15). +24 of the 132 files pass completely. ## Why diff --git a/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T115238Z.json b/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T115238Z.json new file mode 100644 index 000000000..a271699d7 --- /dev/null +++ b/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T115238Z.json @@ -0,0 +1,5624 @@ +{ + "schema": 1, + "oils_repo": "https://github.com/oils-for-unix/oils", + "oils_rev": "57d3f0d088c36340bc4b3038208e58ea02acf15c", + "bashkit_commit": "bae540476f21", + "bash_version": "5.2.21(1)-release", + "timestamp": "2026-10-08T11:52:38Z", + "platform": "linux-x86_64", + "jobs": 4, + "timeout_secs": 10, + "elapsed_secs": 50.6, + "files_run": 132, + "files_skipped": 91, + "total": { + "cases": 2759, + "bashkit_pass": 2086, + "bashkit_pass_pct": 75.6, + "bash_pass": 2692, + "bash_pass_pct": 97.6, + "bashkit_pass_of_bash": 2080, + "pass_rate": 77.3 + }, + "failure_classes": { + "behavior": 447, + "host": 27, + "interactive": 110, + "processes": 18, + "signals": 10 + }, + "files": { + "builtin-completion": { + "summary": { + "cases": 51, + "bashkit_pass": 17, + "bashkit_pass_pct": 33.3, + "bash_pass": 51, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 17, + "pass_rate": 33.3 + }, + "missed": [ + { + "case": 0, + "name": "complete with no args and complete -p both print completion spec", + "class": "interactive", + "on": "stdout" + }, + { + "case": 1, + "name": "complete -F f is usage error", + "class": "interactive", + "on": "stdout" + }, + { + "case": 2, + "name": "complete with nonexistent function", + "class": "interactive", + "on": "stdout" + }, + { + "case": 3, + "name": "complete with no action", + "class": "interactive", + "on": "stdout" + }, + { + "case": 5, + "name": "Invalid syntax", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "how compgen calls completion functions", + "class": "interactive", + "on": "stdout" + }, + { + "case": 7, + "name": "complete -o -F (git)", + "class": "interactive", + "on": "status" + }, + { + "case": 8, + "name": "compopt with invalid syntax", + "class": "interactive", + "on": "stdout" + }, + { + "case": 9, + "name": "compopt fails when not in completion function", + "class": "interactive", + "on": "status" + }, + { + "case": 14, + "name": "compgen -v P", + "class": "interactive", + "on": "stdout" + }, + { + "case": 15, + "name": "compgen -e with global/local exported vars", + "class": "interactive", + "on": "stdout" + }, + { + "case": 18, + "name": "compgen -e P", + "class": "interactive", + "on": "stdout" + }, + { + "case": 19, + "name": "compgen with actions: function / variable / file", + "class": "interactive", + "on": "stdout" + }, + { + "case": 20, + "name": "compgen with actions: alias, setopt", + "class": "interactive", + "on": "stdout" + }, + { + "case": 21, + "name": "compgen with actions: shopt", + "class": "interactive", + "on": "stdout" + }, + { + "case": 22, + "name": "compgen with action and suffix: helptopic", + "class": "interactive", + "on": "stdout" + }, + { + "case": 25, + "name": "compgen -A user", + "class": "interactive", + "on": "status" + }, + { + "case": 28, + "name": "compgen -A command completes builtins and keywords", + "class": "interactive", + "on": "stdout" + }, + { + "case": 29, + "name": "compgen -k shows the same keywords as bash", + "class": "processes", + "on": "stdout" + }, + { + "case": 31, + "name": "compgen -k completes reserved shell keywords", + "class": "interactive", + "on": "stdout" + }, + { + "case": 32, + "name": "-o filenames and -o nospace have no effect with compgen", + "class": "interactive", + "on": "stdout" + }, + { + "case": 33, + "name": "-o plusdirs and -o dirnames with compgen", + "class": "interactive", + "on": "stdout" + }, + { + "case": 34, + "name": "compgen -o default completes files and dirs", + "class": "interactive", + "on": "stdout" + }, + { + "case": 35, + "name": "compgen doesn't respect -X for user-defined functions", + "class": "interactive", + "on": "stdout" + }, + { + "case": 36, + "name": "compgen -W words -X filter", + "class": "interactive", + "on": "stdout" + }, + { + "case": 37, + "name": "compgen -f -X filter -- $cur", + "class": "interactive", + "on": "stdout" + }, + { + "case": 39, + "name": "compgen -W 'one two three'", + "class": "interactive", + "on": "stdout" + }, + { + "case": 40, + "name": "compgen -W evaluates code in $()", + "class": "interactive", + "on": "stdout" + }, + { + "case": 41, + "name": "compgen -W uses IFS, and delimiters are escaped with \\", + "class": "interactive", + "on": "stdout" + }, + { + "case": 42, + "name": "Parse errors for compgen -W and complete -W", + "class": "interactive", + "on": "stdout" + }, + { + "case": 43, + "name": "Runtime errors for compgen -W", + "class": "interactive", + "on": "stdout" + }, + { + "case": 46, + "name": "compgen -A builtin", + "class": "interactive", + "on": "stdout" + }, + { + "case": 47, + "name": "complete -C vs. compgen -C", + "class": "interactive", + "on": "stdout" + }, + { + "case": 50, + "name": "compgen -F with scalar COMPREPLY", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "array": { + "summary": { + "cases": 78, + "bashkit_pass": 53, + "bashkit_pass_pct": 67.9, + "bash_pass": 78, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 53, + "pass_rate": 67.9 + }, + "missed": [ + { + "case": 3, + "name": "space before ( in array initialization", + "class": "behavior", + "on": "status" + }, + { + "case": 5, + "name": "array with invalid token", + "class": "behavior", + "on": "status" + }, + { + "case": 9, + "name": "Negative index", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "Negative index and sparse array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "Negative index and sparse array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "Length after unset", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "Retrieve index that is a command sub", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "${!a[1]} is named ref in bash", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "All elements unquoted", + "class": "behavior", + "on": "stdout" + }, + { + "case": 22, + "name": "$*", + "class": "behavior", + "on": "stdout" + }, + { + "case": 25, + "name": "Exporting array doesn't do anything, not even first element", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "Arrays can't be used as env bindings", + "class": "behavior", + "on": "stdout" + }, + { + "case": 28, + "name": "Associative arrays can't be used as env bindings either", + "class": "behavior", + "on": "stdout" + }, + { + "case": 32, + "name": "Set array item to array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "Slice of array with [@]", + "class": "behavior", + "on": "stdout" + }, + { + "case": 34, + "name": "Negative slice begin", + "class": "behavior", + "on": "stdout" + }, + { + "case": 36, + "name": "Slice with arithmetic", + "class": "behavior", + "on": "stdout" + }, + { + "case": 59, + "name": "Slice of sparse array with [@]", + "class": "behavior", + "on": "stdout" + }, + { + "case": 63, + "name": "Dynamic parsing of LHS a[$code]=value", + "class": "behavior", + "on": "stdout" + }, + { + "case": 65, + "name": "Is element set? test -v a[i]", + "class": "behavior", + "on": "stdout" + }, + { + "case": 67, + "name": "test -v a[i] with arith expressions", + "class": "behavior", + "on": "stdout" + }, + { + "case": 71, + "name": "Regression: Negative out-of-range index in [[ -v a[index] ]]", + "class": "behavior", + "on": "stderr" + }, + { + "case": 73, + "name": "Regression: unset a[-2]: out-of-bound negative index should cause error", + "class": "behavior", + "on": "stderr" + }, + { + "case": 75, + "name": "Regression: Array length after unset", + "class": "behavior", + "on": "stdout" + }, + { + "case": 77, + "name": "Regression: silent out-of-bound negative index in ${a[-2]} and $((a[-2]))", + "class": "behavior", + "on": "stderr" + } + ], + "bash_fails": [] + }, + "prompt": { + "summary": { + "cases": 33, + "bashkit_pass": 4, + "bashkit_pass_pct": 12.1, + "bash_pass": 29, + "bash_pass_pct": 87.9, + "bashkit_pass_of_bash": 4, + "pass_rate": 13.8 + }, + "missed": [ + { + "case": 2, + "name": "literal escapes", + "class": "interactive", + "on": "stdout" + }, + { + "case": 4, + "name": "PS1 evaluation order", + "class": "interactive", + "on": "stdout" + }, + { + "case": 5, + "name": "PS1 evaluation order 2", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "\\1004", + "class": "interactive", + "on": "stdout" + }, + { + "case": 7, + "name": "\\001 octal literals are supported", + "class": "interactive", + "on": "stdout" + }, + { + "case": 8, + "name": "\\555 is beyond max octal byte of \\377 and wrapped to m", + "class": "interactive", + "on": "stdout" + }, + { + "case": 11, + "name": "Escaped backslash", + "class": "interactive", + "on": "stdout" + }, + { + "case": 12, + "name": "\\0001 octal literals are not supported", + "class": "interactive", + "on": "stdout" + }, + { + "case": 13, + "name": "\\u0001 unicode literals not supported", + "class": "interactive", + "on": "stdout" + }, + { + "case": 15, + "name": "hostname", + "class": "interactive", + "on": "stdout" + }, + { + "case": 16, + "name": "username", + "class": "interactive", + "on": "stdout" + }, + { + "case": 17, + "name": "current working dir", + "class": "interactive", + "on": "stdout" + }, + { + "case": 18, + "name": "\\W is basename of working dir", + "class": "interactive", + "on": "stdout" + }, + { + "case": 19, + "name": "\\t for 24h time (HH:MM:SS)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 20, + "name": "\\T for 12h time (HH:MM:SS)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 21, + "name": "\\@ for 12h time (HH:MM AM/PM)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 22, + "name": "\\A for 24h time (HH:MM)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 23, + "name": "\\d for date", + "class": "interactive", + "on": "stdout" + }, + { + "case": 24, + "name": "\\D{%H:%M} for strftime", + "class": "interactive", + "on": "stdout" + }, + { + "case": 25, + "name": "\\D{} for locale specific strftime", + "class": "interactive", + "on": "stdout" + }, + { + "case": 26, + "name": "\\s for shell, \\v for major.minor version, and \\V for full version", + "class": "interactive", + "on": "stdout" + }, + { + "case": 27, + "name": "\\j for number of jobs", + "class": "signals", + "on": "stdout" + }, + { + "case": 28, + "name": "\\l for TTY device basename", + "class": "interactive", + "on": "stdout" + }, + { + "case": 29, + "name": "\\! for history number", + "class": "interactive", + "on": "stdout" + }, + { + "case": 30, + "name": "\\# for command number", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [ + 0, + 1, + 3, + 32 + ] + }, + "builtin-trap-bash": { + "summary": { + "cases": 23, + "bashkit_pass": 4, + "bashkit_pass_pct": 17.4, + "bash_pass": 23, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 17.4 + }, + "missed": [ + { + "case": 3, + "name": "trap DEBUG ignores $?", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "but trap DEBUG respects errexit", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "trap DEBUG with 'return'", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "trap DEBUG with 'exit'", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "trap DEBUG with non-compound commands", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "trap DEBUG and control flow", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "trap DEBUG and command sub / subshell", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "trap DEBUG not run in forked interpreter for first pipeline part", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "One 'echo' in first pipeline part - why does bash behave differently from case above?", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "trap DEBUG and pipeline (lastpipe difference)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "trap DEBUG function call", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "trap DEBUG case", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "trap DEBUG for each", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "trap DEBUG for expr", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "trap DEBUG if while", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "Compare trap DEBUG vs. trap ERR", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "Combine DEBUG trap and USR1 trap", + "class": "signals", + "on": "stdout" + }, + { + "case": 21, + "name": "Combine ERR trap and USR1 trap", + "class": "signals", + "on": "stdout" + }, + { + "case": 22, + "name": "Combine DEBUG trap and ERR trap", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-ref": { + "summary": { + "cases": 31, + "bashkit_pass": 15, + "bashkit_pass_pct": 48.4, + "bash_pass": 30, + "bash_pass_pct": 96.8, + "bashkit_pass_of_bash": 15, + "pass_rate": 50.0 + }, + "missed": [ + { + "case": 2, + "name": "${!undef:-}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "${!a[@]-'default'} is legal but fails with more than one element", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "var ref: 1, @, *", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "var ref TO array var", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "var ref TO array var, with subscripts", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "var ref TO assoc array a[key]", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "var ref TO array with arbitrary subscripts", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "Indirect expansion TO fancy expansion features bash disallows", + "class": "behavior", + "on": "stdout" + }, + { + "case": 22, + "name": "Bad var ref", + "class": "behavior", + "on": "stdout" + }, + { + "case": 23, + "name": "Bad var ref 2", + "class": "behavior", + "on": "stdout" + }, + { + "case": 26, + "name": "Var Ref Code Injection $(tee PWNED)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "${!array_ref:-set} and ${!array_ref:=assign}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 28, + "name": "Array indirect expansion with suffix operators", + "class": "behavior", + "on": "stdout" + }, + { + "case": 29, + "name": "Array indirect expansion with replacements", + "class": "behavior", + "on": "stdout" + }, + { + "case": 30, + "name": "Array indirect expansion with @? conversion", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 12 + ] + }, + "builtin-fc": { + "summary": { + "cases": 14, + "bashkit_pass": 2, + "bashkit_pass_pct": 14.3, + "bash_pass": 14, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 14.3 + }, + "missed": [ + { + "case": 0, + "name": "fc -l lists history commands", + "class": "interactive", + "on": "stdout" + }, + { + "case": 1, + "name": "fc -ln lists history commands without numbers", + "class": "interactive", + "on": "stdout" + }, + { + "case": 2, + "name": "fc -lr lists history commands in reverse order", + "class": "interactive", + "on": "stdout" + }, + { + "case": 3, + "name": "fc -lnr lists history commands without numbers in reverse order", + "class": "interactive", + "on": "stdout" + }, + { + "case": 4, + "name": "fc -l lists history commands with default page size", + "class": "interactive", + "on": "stdout" + }, + { + "case": 5, + "name": "fc -l [first] where first is an index", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "fc -l [first] where first is an offset from current command", + "class": "interactive", + "on": "stdout" + }, + { + "case": 7, + "name": "fc -l [first] [last] where first and last are indexes", + "class": "interactive", + "on": "stdout" + }, + { + "case": 8, + "name": "fc -l [first] [last] where first and last are offsets from current command", + "class": "interactive", + "on": "stdout" + }, + { + "case": 9, + "name": "fc -l [first] [last] where first and last are reversed indexes", + "class": "interactive", + "on": "stdout" + }, + { + "case": 10, + "name": "fc -lr [first] [last] where first and last are reversed indexes does not undo reverse", + "class": "interactive", + "on": "stdout" + }, + { + "case": 12, + "name": "fc errors out on too many args with strict_arg_parse", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-history": { + "summary": { + "cases": 17, + "bashkit_pass": 5, + "bashkit_pass_pct": 29.4, + "bash_pass": 17, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 29.4 + }, + "missed": [ + { + "case": 0, + "name": "history -a", + "class": "interactive", + "on": "stdout" + }, + { + "case": 1, + "name": "history -w writes out the in-memory history to the history file", + "class": "interactive", + "on": "stdout" + }, + { + "case": 2, + "name": "history -r reads from the history file, and appends it to the current history", + "class": "interactive", + "on": "stdout" + }, + { + "case": 3, + "name": "history -n reads *new* commands from the history file, and appends them to the current history", + "class": "interactive", + "on": "stdout" + }, + { + "case": 4, + "name": "history -c clears in-memory history", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "history -d to delete history from end", + "class": "interactive", + "on": "stdout" + }, + { + "case": 9, + "name": "HISTFILE set to array", + "class": "interactive", + "on": "stdout" + }, + { + "case": 11, + "name": "history usage", + "class": "interactive", + "on": "stdout" + }, + { + "case": 12, + "name": "HISTSIZE shrinks the in-memory history when changed", + "class": "interactive", + "on": "stdout" + }, + { + "case": 13, + "name": "HISTFILESIZE shrinks the history file when changed", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "recording history can be toggled with set -o/+o history", + "class": "interactive", + "on": "stdout" + }, + { + "case": 16, + "name": "shopt histappend - osh ignores shopt and appends, bash sometimes overwrites", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-type-bash": { + "summary": { + "cases": 31, + "bashkit_pass": 19, + "bashkit_pass_pct": 61.3, + "bash_pass": 31, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 19, + "pass_rate": 61.3 + }, + "missed": [ + { + "case": 1, + "name": "type -t -> alias", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "type -t doesn't find non-executable (like command -v)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "type -a -P gives multiple files", + "class": "host", + "on": "stdout" + }, + { + "case": 14, + "name": "type -P builtin -> not a file but file found", + "class": "host", + "on": "stdout" + }, + { + "case": 16, + "name": "type -f builtin -> function and file exists", + "class": "host", + "on": "stdout" + }, + { + "case": 19, + "name": "type -a -> alias; prints alias definition", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "type -ap -> alias", + "class": "behavior", + "on": "status" + }, + { + "case": 24, + "name": "type -a -> file", + "class": "host", + "on": "stdout" + }, + { + "case": 25, + "name": "type -ap -> file; abbreviated", + "class": "host", + "on": "stdout" + }, + { + "case": 26, + "name": "type -a -> builtin and file", + "class": "host", + "on": "stdout" + }, + { + "case": 27, + "name": "type -a -> builtin and file and shell function", + "class": "host", + "on": "stdout" + }, + { + "case": 28, + "name": "type -ap -> builtin and file; doesn't print builtin or function", + "class": "host", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "interactive": { + "summary": { + "cases": 18, + "bashkit_pass": 7, + "bashkit_pass_pct": 38.9, + "bash_pass": 18, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 7, + "pass_rate": 38.9 + }, + "missed": [ + { + "case": 0, + "name": "'exit' in oshrc (regression)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 2, + "name": "interactive shell loads rcfile (when combined with -c)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 7, + "name": "interactive shell runs PROMPT_COMMAND after each command", + "class": "interactive", + "on": "stdout" + }, + { + "case": 10, + "name": "Error message with bad oshrc file (currently ignored)", + "class": "interactive", + "on": "stdout" + }, + { + "case": 11, + "name": "PROMPT_COMMAND can see $?, like bash", + "class": "interactive", + "on": "stdout" + }, + { + "case": 12, + "name": "PROMPT_COMMAND that writes to BASH_REMATCH", + "class": "interactive", + "on": "stdout" + }, + { + "case": 13, + "name": "NO ASSERTIONS: Are startup files sourced before or after job control?", + "class": "processes", + "on": "status" + }, + { + "case": 14, + "name": "HISTFILE is written in interactive shell", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "HISTFILE default value", + "class": "interactive", + "on": "stdout" + }, + { + "case": 16, + "name": "HISTFILE=my-history loads history from that file, and writes back to it", + "class": "interactive", + "on": "stdout" + }, + { + "case": 17, + "name": "HISTFILE=my-history with history -a", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "sh-options": { + "summary": { + "cases": 39, + "bashkit_pass": 28, + "bashkit_pass_pct": 71.8, + "bash_pass": 39, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 28, + "pass_rate": 71.8 + }, + "missed": [ + { + "case": 1, + "name": "$- with pipefail", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "$- with interactive shell", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "pass shopt options like sh -O nullglob", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "vi and emacs are mutually exclusive", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "interactive shell starts with emacs mode on", + "class": "interactive", + "on": "stdout" + }, + { + "case": 17, + "name": "shopt with no flags prints options", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "noclobber on <>", + "class": "behavior", + "on": "stdout" + }, + { + "case": 22, + "name": "noclobber on &> >", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "stubbed out bash options", + "class": "behavior", + "on": "stdout" + }, + { + "case": 35, + "name": "Unimplemented options - print, query, set, unset", + "class": "behavior", + "on": "stdout" + }, + { + "case": 36, + "name": "Unimplemented options - OSH shopt -s ignore_shopt_not_impl", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "vars-special": { + "summary": { + "cases": 42, + "bashkit_pass": 29, + "bashkit_pass_pct": 69.0, + "bash_pass": 40, + "bash_pass_pct": 95.2, + "bashkit_pass_of_bash": 29, + "pass_rate": 72.5 + }, + "missed": [ + { + "case": 1, + "name": "$PWD is not only set, but exported", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "$PATH is set if unset at startup", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "$HOME is NOT set", + "class": "interactive", + "on": "stdout" + }, + { + "case": 4, + "name": "Vars set interactively only: $HISTFILE", + "class": "interactive", + "on": "stdout" + }, + { + "case": 5, + "name": "Some vars are set, even without startup file, or env: PATH, PWD", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "UID EUID PPID can't be changed", + "class": "processes", + "on": "stdout" + }, + { + "case": 13, + "name": "$BASHPID DOES change with subshell and command sub", + "class": "processes", + "on": "stdout" + }, + { + "case": 25, + "name": "$LINENO in ((", + "class": "behavior", + "on": "stdout" + }, + { + "case": 28, + "name": "$LINENO in for (( loop", + "class": "behavior", + "on": "stdout" + }, + { + "case": 37, + "name": "$_ with assignments, arrays, etc.", + "class": "behavior", + "on": "stdout" + }, + { + "case": 39, + "name": "$_ is not undefined on first use", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 34, + 40 + ] + }, + "array-assign": { + "summary": { + "cases": 11, + "bashkit_pass": 1, + "bashkit_pass_pct": 9.1, + "bash_pass": 11, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 1, + "pass_rate": 9.1 + }, + "missed": [ + { + "case": 1, + "name": "Indexed LHS with spaces", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "Nested a[i[0]]=0", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "Multiple LHS array words", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "LHS array is protected with shopt -s eval_unsafe_arith, e.g. 'a[$(echo 2)]'", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "file named a[ is not executed", + "class": "host", + "on": "stdout" + }, + { + "case": 6, + "name": "More fragments like a[ a[5 a[5 + a[5 + 3]", + "class": "host", + "on": "stdout" + }, + { + "case": 7, + "name": "Are quotes allowed?", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "Tricky parsing - a[ a[0]=1 ]=X a[ a[0]+=1 ]+=X", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "argv.py a[1 + 2]=", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "declare builtin doesn't allow spaces", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "array-sparse": { + "summary": { + "cases": 40, + "bashkit_pass": 30, + "bashkit_pass_pct": 75.0, + "bash_pass": 40, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 30, + "pass_rate": 75.0 + }, + "missed": [ + { + "case": 11, + "name": "unset -v a[i] with out-of-bound negative index", + "class": "behavior", + "on": "stderr" + }, + { + "case": 12, + "name": "unset -v a[i] for max index", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "[[ -v a[i] ]] with invalid negative index", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "((sp[i])) and ((sp[i]++)) with invalid negative index", + "class": "behavior", + "on": "stderr" + }, + { + "case": 18, + "name": "${sp[i]} with negative invalid index", + "class": "behavior", + "on": "stderr" + }, + { + "case": 19, + "name": "${a[@]:offset:length}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 25, + "name": "${a[@]@P}, ${a[@]@Q}, and ${a[@]@a}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "${a-}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 28, + "name": "${!a[0]}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 31, + "name": "compgen -F _set_COMPREPLY", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-bind": { + "summary": { + "cases": 9, + "bashkit_pass": 0, + "bashkit_pass_pct": 0.0, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 0, + "pass_rate": 0.0 + }, + "missed": [ + { + "case": 0, + "name": "bind -l should report readline functions", + "class": "interactive", + "on": "stdout" + }, + { + "case": 1, + "name": "bind -p -P to print function names and key bindings", + "class": "interactive", + "on": "stdout" + }, + { + "case": 2, + "name": "bind -s -S accepted", + "class": "interactive", + "on": "status" + }, + { + "case": 3, + "name": "bind -v -V accepted", + "class": "interactive", + "on": "stdout" + }, + { + "case": 4, + "name": "bind -q", + "class": "interactive", + "on": "stdout" + }, + { + "case": 5, + "name": "bind -X", + "class": "interactive", + "on": "stdout" + }, + { + "case": 6, + "name": "bind -m with bind -x/-X", + "class": "interactive", + "on": "stdout" + }, + { + "case": 7, + "name": "bind -r", + "class": "interactive", + "on": "stdout" + }, + { + "case": 8, + "name": "bind -r of bind -x commands", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-read": { + "summary": { + "cases": 64, + "bashkit_pass": 55, + "bashkit_pass_pct": 85.9, + "bash_pass": 64, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 55, + "pass_rate": 85.9 + }, + "missed": [ + { + "case": 33, + "name": "read -t 0 tests if input is available", + "class": "behavior", + "on": "stdout" + }, + { + "case": 36, + "name": "read -u", + "class": "behavior", + "on": "stdout" + }, + { + "case": 38, + "name": "read -u -s", + "class": "behavior", + "on": "stdout" + }, + { + "case": 39, + "name": "read -u 3 -d 5", + "class": "behavior", + "on": "stdout" + }, + { + "case": 40, + "name": "read -u 3 -d b -N 6", + "class": "behavior", + "on": "stdout" + }, + { + "case": 41, + "name": "read -N doesn't respect delimiter, while read -n does", + "class": "behavior", + "on": "stdout" + }, + { + "case": 48, + "name": "mapfile from directory (bash doesn't handle errors)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 54, + "name": "empty input and splitting", + "class": "behavior", + "on": "stdout" + }, + { + "case": 63, + "name": "read bash bug", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-trap": { + "summary": { + "cases": 33, + "bashkit_pass": 24, + "bashkit_pass_pct": 72.7, + "bash_pass": 33, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 24, + "pass_rate": 72.7 + }, + "missed": [ + { + "case": 0, + "name": "traps are not active inside subshells $() () trap | cat", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "trap EXIT calling exit", + "class": "behavior", + "on": "status" + }, + { + "case": 20, + "name": "trap EXIT with PARSE error and explicit exit", + "class": "behavior", + "on": "status" + }, + { + "case": 24, + "name": "exit codes for traps are isolated", + "class": "signals", + "on": "stdout" + }, + { + "case": 25, + "name": "traps are cleared in subshell (started with &)", + "class": "signals", + "on": "stdout" + }, + { + "case": 26, + "name": "trap USR1, sleep, SIGINT: non-interactively", + "class": "behavior", + "on": "stdout" + }, + { + "case": 28, + "name": "trap EXIT, sleep, SIGINT: non-interactively", + "class": "behavior", + "on": "stdout" + }, + { + "case": 29, + "name": "Remove trap with an unsigned integer", + "class": "behavior", + "on": "stdout" + }, + { + "case": 32, + "name": "trap with command.NoOp - check internal invariant", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "introspect": { + "summary": { + "cases": 13, + "bashkit_pass": 3, + "bashkit_pass_pct": 23.1, + "bash_pass": 12, + "bash_pass_pct": 92.3, + "bashkit_pass_of_bash": 3, + "pass_rate": 25.0 + }, + "missed": [ + { + "case": 0, + "name": "${FUNCNAME[@]} array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 1, + "name": "FUNCNAME with source (scalar or array)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "BASH_SOURCE and BASH_LINENO scalar or array (e.g. for virtualenv)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "$((BASH_LINENO)) (scalar form in arith)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "${BASH_SOURCE[@]} with source and function name", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "${BASH_LINENO[@]} is a stack of line numbers for function calls", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "Locations with temp frame", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "Locations when sourcing", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "Sourcing inside function grows the debug stack", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 8 + ] + }, + "nul-bytes": { + "summary": { + "cases": 16, + "bashkit_pass": 7, + "bashkit_pass_pct": 43.8, + "bash_pass": 16, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 7, + "pass_rate": 43.8 + }, + "missed": [ + { + "case": 1, + "name": "printf - literal NUL in format string", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "printf - NUL byte in value (OSH and zsh agree)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "NUL bytes with echo $'\\0' (OSH and zsh agree)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "NUL bytes with test -n", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "NUL bytes with test -f", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "NUL bytes with ${#s} (OSH and zsh agree)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "Compare \\x00 byte versus \\x01 byte - read builtin", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "Compare \\x00 byte versus \\x01 byte - read -n", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "Compare \\x00 byte versus \\x01 byte - mapfile builtin", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "redirect-multi": { + "summary": { + "cases": 13, + "bashkit_pass": 4, + "bashkit_pass_pct": 30.8, + "bash_pass": 13, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 30.8 + }, + "missed": [ + { + "case": 0, + "name": "File redirects with glob args (bash and zsh only)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "File redirect without matching any file, with failglob", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "File redirect to $var with glob char", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "File redirect that globs to more than one file (bash and zsh only)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "File redirect with extended glob", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "Extended glob that doesn't match anything", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "Non-file redirects don't respect glob args (we differe from bash)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "Redirect with brace expansion isn't allowed", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "File redirects have word splitting too!", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "tilde": { + "summary": { + "cases": 14, + "bashkit_pass": 5, + "bashkit_pass_pct": 35.7, + "bash_pass": 14, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 35.7 + }, + "missed": [ + { + "case": 1, + "name": "~ expansion in readonly assignment", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "No tilde expansion in word that looks like assignment but isn't", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "${undef:-~}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "${x//~/~root}", + "class": "host", + "on": "stdout" + }, + { + "case": 8, + "name": "x=foo:~ has tilde expansion", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "a[x]=foo:~ has tilde expansion", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "tilde expansion an assignment keyword", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "x=${undef-~:~}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "temp assignment x=~ env", + "class": "host", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-op-patsub": { + "summary": { + "cases": 28, + "bashkit_pass": 19, + "bashkit_pass_pct": 67.9, + "bash_pass": 28, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 19, + "pass_rate": 67.9 + }, + "missed": [ + { + "case": 11, + "name": "Confusing unquoted slash matches bash (and ash)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "Synthesized ${x///} bug (similar to above)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "Replacement with special chars (bug fix)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "Replace backslash", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "When LC_ALL=C, pattern ? doesn't match multibyte character", + "class": "behavior", + "on": "stdout" + }, + { + "case": 23, + "name": "patsub with [^]]", + "class": "behavior", + "on": "stdout" + }, + { + "case": 24, + "name": "[a-z] Invalid range end is syntax error", + "class": "behavior", + "on": "stdout" + }, + { + "case": 26, + "name": "Chromium from http://www.oilshell.org/blog/2016/11/07.html", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "${x//~homedir/}", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "ysh-builtin-private": { + "summary": { + "cases": 23, + "bashkit_pass": 13, + "bashkit_pass_pct": 56.5, + "bash_pass": 22, + "bash_pass_pct": 95.7, + "bashkit_pass_of_bash": 13, + "pass_rate": 59.1 + }, + "missed": [ + { + "case": 9, + "name": "builtin sleep behaves like external sleep", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "builtin cat", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "builtin cat usage", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "builtin cat non nonexistent file", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "builtin cat accept - for stdin", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "builtin rm: usage, removing files", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "builtin rm -f - ignores arguments that don't exist", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "builtin rm always fails on directories (regardless of -f)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 22, + "name": "compgen -A builtin doesn't find private builtins", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [ + 18 + ] + }, + "arith": { + "summary": { + "cases": 74, + "bashkit_pass": 66, + "bashkit_pass_pct": 89.2, + "bash_pass": 74, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 66, + "pass_rate": 89.2 + }, + "missed": [ + { + "case": 0, + "name": "Side Effect in Array Indexing", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "Arith sub within arith sub", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "Backticks within arith sub", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "Integer constant validation", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "Increment undefined variables with nounset", + "class": "behavior", + "on": "stdout" + }, + { + "case": 40, + "name": "nounset with arithmetic", + "class": "behavior", + "on": "stdout" + }, + { + "case": 50, + "name": "Comment not allowed in the middle of multiline arithmetic", + "class": "behavior", + "on": "stdout" + }, + { + "case": 70, + "name": "undef[0] with nounset", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "array-assoc": { + "summary": { + "cases": 42, + "bashkit_pass": 28, + "bashkit_pass_pct": 66.7, + "bash_pass": 36, + "bash_pass_pct": 85.7, + "bashkit_pass_of_bash": 28, + "pass_rate": 77.8 + }, + "missed": [ + { + "case": 15, + "name": "lookup with unquoted $key and quoted \"$i$i\"", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "Indexed array as key of associative array coerces to string (without shopt -s strict_array)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 30, + "name": "unset -v and assoc array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 31, + "name": "nameref and assoc array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 32, + "name": "${!ref} and assoc array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "printf -v and assoc array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 36, + "name": "test -v assoc[key]", + "class": "behavior", + "on": "stdout" + }, + { + "case": 37, + "name": "test -v with dynamic parsing", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 1, + 2, + 21, + 26, + 34, + 41 + ] + }, + "assign-extended": { + "summary": { + "cases": 39, + "bashkit_pass": 27, + "bashkit_pass_pct": 69.2, + "bash_pass": 35, + "bash_pass_pct": 89.7, + "bashkit_pass_of_bash": 27, + "pass_rate": 77.1 + }, + "missed": [ + { + "case": 4, + "name": "declare -F with shopt -s extdebug prints more info", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "declare -F with shopt -s extdebug and main file", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "ble.sh: eval -- \"$(declare -p var arr)\"", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "eval -- \"$(declare -p arr)\" (restore arrays w/ unset elements)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "declare -p UNDEF (and typeset) -- prints something to stderr", + "class": "behavior", + "on": "stdout" + }, + { + "case": 29, + "name": "syntax error in array assignment", + "class": "behavior", + "on": "stdout" + }, + { + "case": 31, + "name": "myvar=typeset (another form of dynamic assignment)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 38, + "name": "unset and shell funcs", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 7, + 8, + 10, + 11 + ] + }, + "brace-expansion": { + "summary": { + "cases": 55, + "bashkit_pass": 47, + "bashkit_pass_pct": 85.5, + "bash_pass": 55, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 47, + "pass_rate": 85.5 + }, + "missed": [ + { + "case": 11, + "name": "double expansion with simple var -- bash bug", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "double expansion with literal and simple var", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "{ in expansion", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "Empty alternative with empty string suffix", + "class": "behavior", + "on": "stdout" + }, + { + "case": 29, + "name": "Two kinds of tilde expansion", + "class": "behavior", + "on": "stdout" + }, + { + "case": 30, + "name": "Tilde expansion come before var expansion", + "class": "behavior", + "on": "stdout" + }, + { + "case": 34, + "name": "regression: 0 step disallowed", + "class": "behavior", + "on": "stdout" + }, + { + "case": 42, + "name": "Mixed case char expansion is invalid", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "bugs": { + "summary": { + "cases": 29, + "bashkit_pass": 21, + "bashkit_pass_pct": 72.4, + "bash_pass": 29, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 21, + "pass_rate": 72.4 + }, + "missed": [ + { + "case": 5, + "name": "assign readonly -- multiple lines -- set -o posix", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "Function names", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "file with NUL byte", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "'echo' and printf fail on writing to full disk", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "other builtins fail on writing to full disk", + "class": "processes", + "on": "stdout" + }, + { + "case": 19, + "name": "command execution $(echo 42 | tee PWNED) not allowed", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "unset doesn't allow command execution", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "Crash after changing $[] to be alias of $(( ))", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-op-bash": { + "summary": { + "cases": 27, + "bashkit_pass": 18, + "bashkit_pass_pct": 66.7, + "bash_pass": 26, + "bash_pass_pct": 96.3, + "bashkit_pass_of_bash": 18, + "pass_rate": 69.2 + }, + "missed": [ + { + "case": 11, + "name": "${!prefix@} matches var name (regression)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "undef and @P @Q @a", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "assoc array and @P @Q @a", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "${!A@a} and ${!A[@]@a}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "undef vs. empty string in var ops", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "-o nounset with var ops", + "class": "behavior", + "on": "stdout" + }, + { + "case": 23, + "name": "${!r@a} with r='a[0]' (attribute for indirect expansion of an array element)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 26, + "name": "Array expansion with nullary var op @a", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 3 + ] + }, + "var-op-test": { + "summary": { + "cases": 37, + "bashkit_pass": 29, + "bashkit_pass_pct": 78.4, + "bash_pass": 37, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 29, + "pass_rate": 78.4 + }, + "missed": [ + { + "case": 15, + "name": "Nix idiom ${!hooksSlice+\"${!hooksSlice}\"} - was workaround for obsolete bash 4.3 bug", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "array and - and +", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "$@ (\"\" \"\") and - and +", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "$* (\"\" \"\") and - and + (IFS=)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 32, + "name": "op-test for ${a} and ${a[0]}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 34, + "name": "op-test for ${!array} with array=\"a\" and array=\"a[0]\"", + "class": "behavior", + "on": "stdout" + }, + { + "case": 35, + "name": "op-test for ${!array} with array=\"a[@]\" or array=\"a[*]\"", + "class": "behavior", + "on": "stdout" + }, + { + "case": 36, + "name": "op-test for unquoted ${a[*]:-empty} with IFS=", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "alias": { + "summary": { + "cases": 48, + "bashkit_pass": 41, + "bashkit_pass_pct": 85.4, + "bash_pass": 48, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 41, + "pass_rate": 85.4 + }, + "missed": [ + { + "case": 2, + "name": "define and use alias on a single line", + "class": "behavior", + "on": "stdout" + }, + { + "case": 23, + "name": "Loop split across alias in another way", + "class": "behavior", + "on": "stdout" + }, + { + "case": 24, + "name": "Loop split across both iterative and recursive aliases", + "class": "behavior", + "on": "stdout" + }, + { + "case": 30, + "name": "Alias can be defined and used on a single line", + "class": "behavior", + "on": "stdout" + }, + { + "case": 35, + "name": "alias for left brace", + "class": "behavior", + "on": "stdout" + }, + { + "case": 36, + "name": "alias for left paren", + "class": "behavior", + "on": "stdout" + }, + { + "case": 39, + "name": "here doc inside alias", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "array-literal": { + "summary": { + "cases": 19, + "bashkit_pass": 12, + "bashkit_pass_pct": 63.2, + "bash_pass": 19, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 12, + "pass_rate": 63.2 + }, + "missed": [ + { + "case": 0, + "name": "Tilde expansions in RHS of [k]=v (BashArray)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 1, + "name": "Tilde expansions in RHS of [k]=v (BashAssoc)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "append to element (BashAssoc)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "non-index forms of element (BashAssoc)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "Evaluation order (1)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "Evaluation order (2)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "Evaluation order (3)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "ble-idioms": { + "summary": { + "cases": 26, + "bashkit_pass": 19, + "bashkit_pass_pct": 73.1, + "bash_pass": 26, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 19, + "pass_rate": 73.1 + }, + "missed": [ + { + "case": 5, + "name": "recursive arith: recursion", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "dynamic arith varname: copy/add", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "is-array with ${var@a}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "shift unshift reverse", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "shopt -u expand_aliases and eval", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "Issue #1069 [53] BUG: a[1 + 1]=2, etc. fails", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "Issue #1069 [53] - LHS array parsing a[1 + 2]=3 (see spec/array-assign for more)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-bracket": { + "summary": { + "cases": 52, + "bashkit_pass": 43, + "bashkit_pass_pct": 82.7, + "bash_pass": 50, + "bash_pass_pct": 96.2, + "bashkit_pass_of_bash": 43, + "pass_rate": 86.0 + }, + "missed": [ + { + "case": 27, + "name": "-k for sticky bit", + "class": "behavior", + "on": "stdout" + }, + { + "case": 36, + "name": "-G and -O for effective user ID and group ID", + "class": "behavior", + "on": "stdout" + }, + { + "case": 37, + "name": "-u for setuid, -g too", + "class": "host", + "on": "stdout" + }, + { + "case": 38, + "name": "-v to test variable (bash)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 39, + "name": "test -o for options", + "class": "behavior", + "on": "stdout" + }, + { + "case": 41, + "name": "-ef", + "class": "behavior", + "on": "stdout" + }, + { + "case": 44, + "name": "test -c", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 25, + 26 + ] + }, + "builtin-eval-source": { + "summary": { + "cases": 23, + "bashkit_pass": 14, + "bashkit_pass_pct": 60.9, + "bash_pass": 21, + "bash_pass_pct": 91.3, + "bashkit_pass_of_bash": 14, + "pass_rate": 66.7 + }, + "missed": [ + { + "case": 1, + "name": "eval accepts/ignores --", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "eval usage", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "source accepts/ignores --", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "Eval in does tilde expansion", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "Eval in bash does tilde expansion in array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "source looks in PATH for files", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "source finds files in PATH before current dir", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 13, + 14 + ] + }, + "command_": { + "summary": { + "cases": 16, + "bashkit_pass": 9, + "bashkit_pass_pct": 56.2, + "bash_pass": 16, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 9, + "pass_rate": 56.2 + }, + "missed": [ + { + "case": 3, + "name": "Name too long", + "class": "behavior", + "on": "status" + }, + { + "case": 8, + "name": "$PATH lookup", + "class": "host", + "on": "stdout" + }, + { + "case": 9, + "name": "filling $PATH cache, then insert the same command earlier in cache", + "class": "host", + "on": "stdout" + }, + { + "case": 10, + "name": "filling $PATH cache, then deleting command", + "class": "host", + "on": "stdout" + }, + { + "case": 11, + "name": "Non-executable on $PATH", + "class": "host", + "on": "stdout" + }, + { + "case": 12, + "name": "hash without args prints the cache", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "hash with args", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "redirect": { + "summary": { + "cases": 41, + "bashkit_pass": 34, + "bashkit_pass_pct": 82.9, + "bash_pass": 41, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 34, + "pass_rate": 82.9 + }, + "missed": [ + { + "case": 0, + "name": ">& and <& are the same", + "class": "behavior", + "on": "stderr" + }, + { + "case": 23, + "name": "&> redirects stdout and stderr", + "class": "processes", + "on": "stdout" + }, + { + "case": 24, + "name": ">&word redirects stdout and stderr when word is not a number or -", + "class": "processes", + "on": "stdout" + }, + { + "case": 26, + "name": "1>&2- to move file descriptor", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "1>&2- (Bash bug: fail to restore closed fd)", + "class": "processes", + "on": "stdout" + }, + { + "case": 29, + "name": "<> for read/write named pipes", + "class": "host", + "on": "stdout" + }, + { + "case": 30, + "name": "&>> appends stdout and stderr", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "sh-usage": { + "summary": { + "cases": 24, + "bashkit_pass": 17, + "bashkit_pass_pct": 70.8, + "bash_pass": 24, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 17, + "pass_rate": 70.8 + }, + "missed": [ + { + "case": 2, + "name": "sh +c is accepted", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "sh - and sh -- stop flag processing", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "Set LC_ALL LC_CTYPE LC_COLLATE LANG - affects glob ?", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "LC_CTYPE=invalid", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "sh -c -- 'echo hi' does not run anything (#2637)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 22, + "name": "'sh -c -z' does not try to run -z", + "class": "behavior", + "on": "stdout" + }, + { + "case": 23, + "name": "sh -c -x 'echo hi' - order is reversed", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "background": { + "summary": { + "cases": 27, + "bashkit_pass": 20, + "bashkit_pass_pct": 74.1, + "bash_pass": 26, + "bash_pass_pct": 96.3, + "bashkit_pass_of_bash": 20, + "pass_rate": 76.9 + }, + "missed": [ + { + "case": 1, + "name": "wait -n with arguments - arguments are respected", + "class": "processes", + "on": "stdout" + }, + { + "case": 5, + "name": "wait with invalid arg", + "class": "processes", + "on": "status" + }, + { + "case": 7, + "name": "wait for N parallel jobs and check failure", + "class": "processes", + "on": "stdout" + }, + { + "case": 11, + "name": "Start background pipeline, wait %job_spec", + "class": "processes", + "on": "stdout" + }, + { + "case": 13, + "name": "Wait for job and PIPESTATUS - cat", + "class": "processes", + "on": "stdout" + }, + { + "case": 26, + "name": "Signal message for killed background job", + "class": "signals", + "on": "stdout" + } + ], + "bash_fails": [ + 12 + ] + }, + "builtin-process": { + "summary": { + "cases": 26, + "bashkit_pass": 19, + "bashkit_pass_pct": 73.1, + "bash_pass": 24, + "bash_pass_pct": 92.3, + "bashkit_pass_of_bash": 18, + "pass_rate": 75.0 + }, + "missed": [ + { + "case": 3, + "name": "exec builtin accepts --", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "exec -- 2>&1", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "exec -a sets argv[0]", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "arg that would overflow 64 bits is detected", + "class": "processes", + "on": "stdout" + }, + { + "case": 21, + "name": "ulimit -f 1 prevents files larger 512 bytes", + "class": "processes", + "on": "stdout" + }, + { + "case": 25, + "name": "ulimit -n limits file descriptors", + "class": "processes", + "on": "stdout" + } + ], + "bash_fails": [ + 9, + 22 + ] + }, + "builtin-vars": { + "summary": { + "cases": 41, + "bashkit_pass": 33, + "bashkit_pass_pct": 80.5, + "bash_pass": 39, + "bash_pass_pct": 95.1, + "bashkit_pass_of_bash": 33, + "pass_rate": 84.6 + }, + "missed": [ + { + "case": 16, + "name": "Make an existing local variable readonly", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "assign to readonly variable - errexit", + "class": "behavior", + "on": "status" + }, + { + "case": 22, + "name": "Unset a function without -f", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "unset -v assoc (related to issue #661)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 35, + "name": "Unset array member with dynamic parsing", + "class": "behavior", + "on": "stdout" + }, + { + "case": 39, + "name": "unset a[-1] (bf.bash regression)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 26, + 32 + ] + }, + "here-doc": { + "summary": { + "cases": 36, + "bashkit_pass": 30, + "bashkit_pass_pct": 83.3, + "bash_pass": 36, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 30, + "pass_rate": 83.3 + }, + "missed": [ + { + "case": 3, + "name": "Here doc from another input file descriptor", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "Multiple here docs with different descriptors", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "Here doc line continuation", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "Here doc with line continuation, then pipe. Syntax error.", + "class": "behavior", + "on": "status" + }, + { + "case": 32, + "name": "Multiple here docs in pipeline", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "Multiple here docs in pipeline on multiple lines", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "sh-options-bash": { + "summary": { + "cases": 9, + "bashkit_pass": 3, + "bashkit_pass_pct": 33.3, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 3, + "pass_rate": 33.3 + }, + "missed": [ + { + "case": 0, + "name": "SHELLOPTS is updated when options are changed", + "class": "behavior", + "on": "stdout" + }, + { + "case": 1, + "name": "SHELLOPTS is readonly", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "SHELLOPTS and BASHOPTS are non-empty", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "SHELLOPTS reflects flags like sh -x", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "export SHELLOPTS does cross-process tracing", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "export SHELLOPTS does cross-process tracing with bash", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "xtrace": { + "summary": { + "cases": 19, + "bashkit_pass": 13, + "bashkit_pass_pct": 68.4, + "bash_pass": 19, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 13, + "pass_rate": 68.4 + }, + "missed": [ + { + "case": 0, + "name": "unset PS4", + "class": "interactive", + "on": "stderr" + }, + { + "case": 1, + "name": "set -o verbose prints unevaluated code", + "class": "behavior", + "on": "stderr" + }, + { + "case": 2, + "name": "xtrace with unprintable chars", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "Assignments and assign builtins", + "class": "behavior", + "on": "stderr" + }, + { + "case": 10, + "name": "[[ ]]", + "class": "behavior", + "on": "stderr" + }, + { + "case": 17, + "name": "Regression: xtrace for \"declare -a a+=(v)\"", + "class": "behavior", + "on": "stderr" + } + ], + "bash_fails": [] + }, + "arith-context": { + "summary": { + "cases": 16, + "bashkit_pass": 11, + "bashkit_pass_pct": 68.8, + "bash_pass": 16, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 11, + "pass_rate": 68.8 + }, + "missed": [ + { + "case": 6, + "name": "Ambiguous colon in slice", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "$[ is a synonym for $((", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "$[$var is a synonym for $(($var (#2426)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "$[$undefined] is a synonym for $(($undefined (#2566)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "Empty expression a[]", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "assign": { + "summary": { + "cases": 48, + "bashkit_pass": 40, + "bashkit_pass_pct": 83.3, + "bash_pass": 45, + "bash_pass_pct": 93.8, + "bashkit_pass_of_bash": 40, + "pass_rate": 88.9 + }, + "missed": [ + { + "case": 8, + "name": "Escaped = in command name", + "class": "behavior", + "on": "stdout" + }, + { + "case": 25, + "name": "Test above without 'local' (which is not POSIX)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 30, + "name": "assignment using dynamic keyword (splits in most shells, not in zsh/osh)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 32, + "name": "assign and glob", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "declare and glob", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 24, + 27, + 46 + ] + }, + "blog1": { + "summary": { + "cases": 9, + "bashkit_pass": 4, + "bashkit_pass_pct": 44.4, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 44.4 + }, + "missed": [ + { + "case": 1, + "name": "${###}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "${####}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "${##2}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "${###2}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "${#1#'###'}", + "class": "behavior", + "on": "status" + } + ], + "bash_fails": [] + }, + "builtin-cd": { + "summary": { + "cases": 30, + "bashkit_pass": 23, + "bashkit_pass_pct": 76.7, + "bash_pass": 28, + "bash_pass_pct": 93.3, + "bashkit_pass_of_bash": 23, + "pass_rate": 82.1 + }, + "missed": [ + { + "case": 5, + "name": "$OLDPWD", + "class": "behavior", + "on": "stdout" + }, + { + "case": 9, + "name": "setting $PWD doesn't affect the value of 'pwd' builtin", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "unset PWD; then pwd", + "class": "behavior", + "on": "stdout" + }, + { + "case": 14, + "name": "pwd in symlinked dir on shell initialization", + "class": "behavior", + "on": "stdout" + }, + { + "case": 26, + "name": "Survey of getcwd() syscall", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 24, + 25 + ] + }, + "builtin-meta": { + "summary": { + "cases": 18, + "bashkit_pass": 13, + "bashkit_pass_pct": 72.2, + "bash_pass": 18, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 13, + "pass_rate": 72.2 + }, + "missed": [ + { + "case": 3, + "name": "command -v doesn't find non-executable file", + "class": "host", + "on": "stdout" + }, + { + "case": 5, + "name": "command -V", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "command -V nonexistent", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "command -p (override existing program)", + "class": "host", + "on": "stdout" + }, + { + "case": 11, + "name": "command -p (hide tool in custom path)", + "class": "host", + "on": "status" + } + ], + "bash_fails": [] + }, + "builtin-meta-assign": { + "summary": { + "cases": 11, + "bashkit_pass": 6, + "bashkit_pass_pct": 54.5, + "bash_pass": 11, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 6, + "pass_rate": 54.5 + }, + "missed": [ + { + "case": 0, + "name": "builtin declare a=(x y) is allowed", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "export, builtin export", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "\\builtin declare - ble.sh relies on it", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "\\command readonly - similar issue", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "Dynamic $c readonly - bash and dash change behavior, mksh bug", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-printf": { + "summary": { + "cases": 63, + "bashkit_pass": 56, + "bashkit_pass_pct": 88.9, + "bash_pass": 58, + "bash_pass_pct": 92.1, + "bashkit_pass_of_bash": 53, + "pass_rate": 91.4 + }, + "missed": [ + { + "case": 16, + "name": "%06s is no-op", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "printf %6.0s and %0.0s", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "printf %6.s and %0.s", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "printf octal backslash escapes", + "class": "behavior", + "on": "stdout" + }, + { + "case": 51, + "name": "bash truncates long strftime string at 128", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 26, + 27, + 46, + 47, + 48 + ] + }, + "glob": { + "summary": { + "cases": 39, + "bashkit_pass": 32, + "bashkit_pass_pct": 82.1, + "bash_pass": 37, + "bash_pass_pct": 94.9, + "bashkit_pass_of_bash": 32, + "pass_rate": 86.5 + }, + "missed": [ + { + "case": 5, + "name": "looks like glob at the start, but isn't", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "looks like glob plus negation at the start, but isn't", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "glob can expand to command and arg", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "glob with escaped - in char class", + "class": "behavior", + "on": "stdout" + }, + { + "case": 38, + "name": "shopt -u globskipdots shows . and ..", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 23, + 31 + ] + }, + "loop": { + "summary": { + "cases": 29, + "bashkit_pass": 24, + "bashkit_pass_pct": 82.8, + "bash_pass": 29, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 24, + "pass_rate": 82.8 + }, + "missed": [ + { + "case": 14, + "name": "continue in subshell", + "class": "behavior", + "on": "stdout" + }, + { + "case": 15, + "name": "continue in subshell aborts with errexit", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "too many args to continue", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "break in condition of loop", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "break in condition of nested loop", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "nameref": { + "summary": { + "cases": 32, + "bashkit_pass": 27, + "bashkit_pass_pct": 84.4, + "bash_pass": 32, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 27, + "pass_rate": 84.4 + }, + "missed": [ + { + "case": 15, + "name": "-n attribute on array is hard error, not a warning", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "exported nameref", + "class": "behavior", + "on": "stdout" + }, + { + "case": 26, + "name": "a[expr] in nameref", + "class": "behavior", + "on": "stdout" + }, + { + "case": 27, + "name": "a[@] in nameref", + "class": "behavior", + "on": "stdout" + }, + { + "case": 29, + "name": "bad mutation through nameref: ref[0]= where ref is array[0]", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "paren-ambiguity": { + "summary": { + "cases": 9, + "bashkit_pass": 4, + "bashkit_pass_pct": 44.4, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 44.4 + }, + "missed": [ + { + "case": 0, + "name": "(( closed with ) ) after multiple lines is command - #2337", + "class": "behavior", + "on": "stdout" + }, + { + "case": 1, + "name": "$(( closed with ) ) after multiple lines is command - #2337", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "(((grep example - 4+ instances in regtest/aports - #2337", + "class": "processes", + "on": "status" + }, + { + "case": 5, + "name": "((gzip example - zdiff package - #2337", + "class": "behavior", + "on": "stdout" + }, + { + "case": 7, + "name": "((test example - liblo package - #2337", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "ble-features": { + "summary": { + "cases": 9, + "bashkit_pass": 5, + "bashkit_pass_pct": 55.6, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 55.6 + }, + "missed": [ + { + "case": 1, + "name": "[bash_unset] local-unset / dynamic-unset for localvar (mutated from tempenv)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "[bash_unset] local-unset / dynamic-unset for tempenv", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "[bash_unset] function call with tempenv vs tempenv-eval", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "[bash_unset] localvar-inherit from tempenv", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "ble-unset": { + "summary": { + "cases": 5, + "bashkit_pass": 1, + "bashkit_pass_pct": 20.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 1, + "pass_rate": 20.0 + }, + "missed": [ + { + "case": 1, + "name": "[bash_unset] local-unset / dynamic-unset for localvar on nested-context", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "[bash_unset] dynamic-unset for nested localvars", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "[bash_unset] dynamic-unset for nested tempenvs", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "[bash_unset] local-unset for nested tempenvs", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-dirs": { + "summary": { + "cases": 18, + "bashkit_pass": 14, + "bashkit_pass_pct": 77.8, + "bash_pass": 18, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 14, + "pass_rate": 77.8 + }, + "missed": [ + { + "case": 1, + "name": "pushd usage", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "popd usage error", + "class": "behavior", + "on": "stdout" + }, + { + "case": 16, + "name": "pushd does not take more than one argument", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "dirs does not take arguments", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-kill": { + "summary": { + "cases": 20, + "bashkit_pass": 16, + "bashkit_pass_pct": 80.0, + "bash_pass": 20, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 16, + "pass_rate": 80.0 + }, + "missed": [ + { + "case": 8, + "name": "kill -l 10 TERM translates between names and numbers", + "class": "signals", + "on": "stdout" + }, + { + "case": 9, + "name": "kill -L checks for invalid input", + "class": "signals", + "on": "stdout" + }, + { + "case": 12, + "name": "kill -l 0 returns EXIT", + "class": "signals", + "on": "stdout" + }, + { + "case": 13, + "name": "kill -l 0 INT lists both signals", + "class": "signals", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-special": { + "summary": { + "cases": 12, + "bashkit_pass": 8, + "bashkit_pass_pct": 66.7, + "bash_pass": 12, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 8, + "pass_rate": 66.7 + }, + "missed": [ + { + "case": 1, + "name": "Prefix assignments persist after special builtins, like : (set -o posix)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "Prefix assignments persist after readonly, but NOT exported (set -o posix)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "Special builtins can't be redefined as shell functions (set -o posix)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "bash 'type' gets confused - says 'function', but runs builtin", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "parse-errors": { + "summary": { + "cases": 27, + "bashkit_pass": 21, + "bashkit_pass_pct": 77.8, + "bash_pass": 25, + "bash_pass_pct": 92.6, + "bashkit_pass_of_bash": 21, + "pass_rate": 84.0 + }, + "missed": [ + { + "case": 11, + "name": "} on the second line", + "class": "behavior", + "on": "status" + }, + { + "case": 16, + "name": "misplaced parentheses are not a subshell", + "class": "behavior", + "on": "status" + }, + { + "case": 22, + "name": "array literal inside array is a parse error", + "class": "behavior", + "on": "stdout" + }, + { + "case": 25, + "name": "%foo=() is parse error (regression)", + "class": "behavior", + "on": "status" + } + ], + "bash_fails": [ + 0, + 1 + ] + }, + "regex": { + "summary": { + "cases": 37, + "bashkit_pass": 33, + "bashkit_pass_pct": 89.2, + "bash_pass": 37, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 33, + "pass_rate": 89.2 + }, + "missed": [ + { + "case": 16, + "name": "Unquoted { is a regex parse error", + "class": "behavior", + "on": "stdout" + }, + { + "case": 18, + "name": "Quoted { and +", + "class": "behavior", + "on": "stdout" + }, + { + "case": 32, + "name": "Quotes '' \"\" $'' $\"\" in pattern", + "class": "behavior", + "on": "stdout" + }, + { + "case": 35, + "name": "make a lisp example", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-sub-quote": { + "summary": { + "cases": 41, + "bashkit_pass": 37, + "bashkit_pass_pct": 90.2, + "bash_pass": 41, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 37, + "pass_rate": 90.2 + }, + "missed": [ + { + "case": 18, + "name": "part_value tree with multiple words", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "part_value tree on RHS", + "class": "behavior", + "on": "stdout" + }, + { + "case": 38, + "name": "Right Brace as argument (similar to #702)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 39, + "name": "Var substitution with newlines (#2492)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "assign-dialects": { + "summary": { + "cases": 4, + "bashkit_pass": 1, + "bashkit_pass_pct": 25.0, + "bash_pass": 4, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 1, + "pass_rate": 25.0 + }, + "missed": [ + { + "case": 1, + "name": "test -v with strings", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "test -v with arrays", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "test -v with assoc arrays", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "blog2": { + "summary": { + "cases": 8, + "bashkit_pass": 5, + "bashkit_pass_pct": 62.5, + "bash_pass": 8, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 62.5 + }, + "missed": [ + { + "case": 4, + "name": "-a -a -a -a -a", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "-a -a -a -a -a -a", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "-a -a -a -a -a -a -a", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "bool-parse": { + "summary": { + "cases": 8, + "bashkit_pass": 5, + "bashkit_pass_pct": 62.5, + "bash_pass": 8, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 62.5 + }, + "missed": [ + { + "case": 0, + "name": "test builtin - Unexpected trailing word '--' (#2409)", + "class": "behavior", + "on": "stdout" + }, + { + "case": 1, + "name": "test builtin: ( = ) is confusing: equality test or non-empty string test", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "test builtin: ( == ) is confusing: equality test or non-empty string test", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-bash": { + "summary": { + "cases": 13, + "bashkit_pass": 10, + "bashkit_pass_pct": 76.9, + "bash_pass": 13, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 10, + "pass_rate": 76.9 + }, + "missed": [ + { + "case": 0, + "name": "help", + "class": "behavior", + "on": "stderr" + }, + { + "case": 1, + "name": "bad help topic", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "mapfile (delimiter): -d '' (null-separated)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-misc": { + "summary": { + "cases": 7, + "bashkit_pass": 4, + "bashkit_pass_pct": 57.1, + "bash_pass": 7, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 57.1 + }, + "missed": [ + { + "case": 0, + "name": "history builtin usage", + "class": "interactive", + "on": "stdout" + }, + { + "case": 1, + "name": "Print shell strings with weird chars: set and printf %q and ${x@Q}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "time pipeline", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-set": { + "summary": { + "cases": 24, + "bashkit_pass": 21, + "bashkit_pass_pct": 87.5, + "bash_pass": 24, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 21, + "pass_rate": 87.5 + }, + "missed": [ + { + "case": 6, + "name": "set -u with undefined var in interactive shell does NOT exit the interpreter", + "class": "interactive", + "on": "stdout" + }, + { + "case": 16, + "name": "A single + is an ignored flag; not an argument", + "class": "behavior", + "on": "stdout" + }, + { + "case": 17, + "name": "set - + and + -", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-trap-err": { + "summary": { + "cases": 23, + "bashkit_pass": 19, + "bashkit_pass_pct": 82.6, + "bash_pass": 22, + "bash_pass_pct": 95.7, + "bashkit_pass_of_bash": 19, + "pass_rate": 86.4 + }, + "missed": [ + { + "case": 6, + "name": "trap ERR pipelines without simple commands", + "class": "behavior", + "on": "stdout" + }, + { + "case": 12, + "name": "set -o errtrace: trap ERR with &", + "class": "processes", + "on": "stdout" + }, + { + "case": 16, + "name": "trap ERR with \"atoms\": assignment (( [[", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 18 + ] + }, + "builtin-umask": { + "summary": { + "cases": 24, + "bashkit_pass": 21, + "bashkit_pass_pct": 87.5, + "bash_pass": 24, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 21, + "pass_rate": 87.5 + }, + "missed": [ + { + "case": 10, + "name": "'umask 0002' sets the umask", + "class": "host", + "on": "stdout" + }, + { + "case": 11, + "name": "set umask with symbolic mode: g-w,o-w", + "class": "host", + "on": "stdout" + }, + { + "case": 13, + "name": "umask with too many arguments (i.e. extra spaces)", + "class": "host", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "extglob-files": { + "summary": { + "cases": 23, + "bashkit_pass": 20, + "bashkit_pass_pct": 87.0, + "bash_pass": 23, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 20, + "pass_rate": 87.0 + }, + "missed": [ + { + "case": 15, + "name": "Extended glob in same word as array", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "no match", + "class": "behavior", + "on": "stdout" + }, + { + "case": 21, + "name": "noglob", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "fatal-errors": { + "summary": { + "cases": 5, + "bashkit_pass": 2, + "bashkit_pass_pct": 40.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 40.0 + }, + "missed": [ + { + "case": 1, + "name": "Unrecoverable: divide by zero in conditional word", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "Unrecoverable: ${undef?message}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "${undef} with nounset", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "func-parsing": { + "summary": { + "cases": 15, + "bashkit_pass": 12, + "bashkit_pass_pct": 80.0, + "bash_pass": 15, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 12, + "pass_rate": 80.0 + }, + "missed": [ + { + "case": 8, + "name": "= in function name", + "class": "behavior", + "on": "status" + }, + { + "case": 9, + "name": "Function name with $", + "class": "behavior", + "on": "status" + }, + { + "case": 10, + "name": "Function name with command sub", + "class": "behavior", + "on": "status" + } + ], + "bash_fails": [] + }, + "nix-idioms": { + "summary": { + "cases": 6, + "bashkit_pass": 3, + "bashkit_pass_pct": 50.0, + "bash_pass": 6, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 3, + "pass_rate": 50.0 + }, + "missed": [ + { + "case": 0, + "name": "var ref to array 'preHooks[@]'", + "class": "behavior", + "on": "stdout" + }, + { + "case": 1, + "name": "Similar to above with set -u", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "${!ref} to undefined string var is fatal, INCONSISTENT with array", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "quote": { + "summary": { + "cases": 35, + "bashkit_pass": 32, + "bashkit_pass_pct": 91.4, + "bash_pass": 35, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 32, + "pass_rate": 91.4 + }, + "missed": [ + { + "case": 27, + "name": "$'' octal escapes don't have leading 0", + "class": "behavior", + "on": "stdout" + }, + { + "case": 29, + "name": "OSH allows invalid backslashes", + "class": "behavior", + "on": "stdout" + }, + { + "case": 33, + "name": "$'' supports \\cA escape for Ctrl-A - mask with 0x1f", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "redirect-command": { + "summary": { + "cases": 23, + "bashkit_pass": 20, + "bashkit_pass_pct": 87.0, + "bash_pass": 23, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 20, + "pass_rate": 87.0 + }, + "missed": [ + { + "case": 8, + "name": "Redirect in the middle of two assignments", + "class": "behavior", + "on": "stdout" + }, + { + "case": 11, + "name": "Redirect in function body is evaluated multiple times", + "class": "behavior", + "on": "stdout" + }, + { + "case": 13, + "name": "redirect bash extensions: [[ (( for ((", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "serialize": { + "summary": { + "cases": 10, + "bashkit_pass": 7, + "bashkit_pass_pct": 70.0, + "bash_pass": 10, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 7, + "pass_rate": 70.0 + }, + "missed": [ + { + "case": 3, + "name": "printf %q unprintable", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "printf %q invalid unicode", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "${var@Q}", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-op-len": { + "summary": { + "cases": 9, + "bashkit_pass": 6, + "bashkit_pass_pct": 66.7, + "bash_pass": 8, + "bash_pass_pct": 88.9, + "bashkit_pass_of_bash": 5, + "pass_rate": 62.5 + }, + "missed": [ + { + "case": 3, + "name": "String length with incomplete utf-8", + "class": "behavior", + "on": "stdout" + }, + { + "case": 4, + "name": "String length with invalid utf-8 continuation bytes", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "Length of undefined variable with nounset", + "class": "behavior", + "on": "status" + } + ], + "bash_fails": [ + 8 + ] + }, + "arg-parse": { + "summary": { + "cases": 3, + "bashkit_pass": 1, + "bashkit_pass_pct": 33.3, + "bash_pass": 3, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 1, + "pass_rate": 33.3 + }, + "missed": [ + { + "case": 1, + "name": "shift 1 extra", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "continue 1 extra, break, etc.", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "array-basic": { + "summary": { + "cases": 5, + "bashkit_pass": 3, + "bashkit_pass_pct": 60.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 3, + "pass_rate": 60.0 + }, + "missed": [ + { + "case": 1, + "name": "${a[@]} and ${a[*]}", + "class": "behavior", + "on": "stdout" + }, + { + "case": 2, + "name": "4 ways to interpolate empty array", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "assign-deferred": { + "summary": { + "cases": 9, + "bashkit_pass": 7, + "bashkit_pass_pct": 77.8, + "bash_pass": 9, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 7, + "pass_rate": 77.8 + }, + "missed": [ + { + "case": 7, + "name": "is 'builtin' prefix and array allowed? OSH is smarter", + "class": "behavior", + "on": "stdout" + }, + { + "case": 8, + "name": "is 'command' prefix and array allowed? OSH is smarter", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-getopts": { + "summary": { + "cases": 31, + "bashkit_pass": 29, + "bashkit_pass_pct": 93.5, + "bash_pass": 31, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 29, + "pass_rate": 93.5 + }, + "missed": [ + { + "case": 6, + "name": "getopts with invalid variable name", + "class": "behavior", + "on": "stdout" + }, + { + "case": 19, + "name": "flag and arg: -c10", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "builtin-type": { + "summary": { + "cases": 6, + "bashkit_pass": 4, + "bashkit_pass_pct": 66.7, + "bash_pass": 6, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 66.7 + }, + "missed": [ + { + "case": 1, + "name": "type -> alias external", + "class": "host", + "on": "stdout" + }, + { + "case": 2, + "name": "type of relative path", + "class": "host", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "command-sub": { + "summary": { + "cases": 30, + "bashkit_pass": 26, + "bashkit_pass_pct": 86.7, + "bash_pass": 28, + "bash_pass_pct": 93.3, + "bashkit_pass_of_bash": 26, + "pass_rate": 92.9 + }, + "missed": [ + { + "case": 20, + "name": "Quoting \\ within ``", + "class": "behavior", + "on": "stdout" + }, + { + "case": 24, + "name": "Quoting non-special characters within ``", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 11, + 12 + ] + }, + "divergence": { + "summary": { + "cases": 4, + "bashkit_pass": 2, + "bashkit_pass_pct": 50.0, + "bash_pass": 4, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 50.0 + }, + "missed": [ + { + "case": 1, + "name": "!( as negation and subshell versus extended glob - #2463", + "class": "behavior", + "on": "stdout" + }, + { + "case": 3, + "name": "builtin cat crashes a subshell (#2530)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "errexit": { + "summary": { + "cases": 35, + "bashkit_pass": 33, + "bashkit_pass_pct": 94.3, + "bash_pass": 35, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 33, + "pass_rate": 94.3 + }, + "missed": [ + { + "case": 33, + "name": "set -e in function #2", + "class": "behavior", + "on": "stdout" + }, + { + "case": 34, + "name": "Command sub exit code is lost", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "exit-status": { + "summary": { + "cases": 11, + "bashkit_pass": 9, + "bashkit_pass_pct": 81.8, + "bash_pass": 11, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 9, + "pass_rate": 81.8 + }, + "missed": [ + { + "case": 7, + "name": "If subshell true WITH ARGUMENT", + "class": "behavior", + "on": "stdout" + }, + { + "case": 10, + "name": "More test cases with empty argv", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "glob-bash": { + "summary": { + "cases": 8, + "bashkit_pass": 6, + "bashkit_pass_pct": 75.0, + "bash_pass": 8, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 6, + "pass_rate": 75.0 + }, + "missed": [ + { + "case": 3, + "name": "shopt -s failglob in array literal context", + "class": "behavior", + "on": "stdout" + }, + { + "case": 6, + "name": "shopt -s failglob behavior on single line with semicolon", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "toysh": { + "summary": { + "cases": 8, + "bashkit_pass": 6, + "bashkit_pass_pct": 75.0, + "bash_pass": 8, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 6, + "pass_rate": 75.0 + }, + "missed": [ + { + "case": 3, + "name": "char class / extglob", + "class": "behavior", + "on": "stdout" + }, + { + "case": 5, + "name": "Brace Expansion", + "class": "host", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-op-slice": { + "summary": { + "cases": 22, + "bashkit_pass": 20, + "bashkit_pass_pct": 90.9, + "bash_pass": 22, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 20, + "pass_rate": 90.9 + }, + "missed": [ + { + "case": 9, + "name": "Slice undefined", + "class": "behavior", + "on": "stdout" + }, + { + "case": 20, + "name": "${array[@]:} vs ${array[@]: } - bash and zsh inconsistent", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "append": { + "summary": { + "cases": 20, + "bashkit_pass": 19, + "bashkit_pass_pct": 95.0, + "bash_pass": 20, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 19, + "pass_rate": 95.0 + }, + "missed": [ + { + "case": 11, + "name": "Try to append list to element", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "dbracket": { + "summary": { + "cases": 49, + "bashkit_pass": 48, + "bashkit_pass_pct": 98.0, + "bash_pass": 49, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 48, + "pass_rate": 98.0 + }, + "missed": [ + { + "case": 44, + "name": "tilde expansion with =~ (confusing)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "dparen": { + "summary": { + "cases": 15, + "bashkit_pass": 13, + "bashkit_pass_pct": 86.7, + "bash_pass": 14, + "bash_pass_pct": 93.3, + "bashkit_pass_of_bash": 13, + "pass_rate": 92.9 + }, + "missed": [ + { + "case": 11, + "name": "(( )) with redirect", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 10 + ] + }, + "errexit-osh": { + "summary": { + "cases": 35, + "bashkit_pass": 34, + "bashkit_pass_pct": 97.1, + "bash_pass": 35, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 34, + "pass_rate": 97.1 + }, + "missed": [ + { + "case": 30, + "name": "command_sub_errexit and command sub in array", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "extglob-match": { + "summary": { + "cases": 29, + "bashkit_pass": 28, + "bashkit_pass_pct": 96.6, + "bash_pass": 29, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 28, + "pass_rate": 96.6 + }, + "missed": [ + { + "case": 21, + "name": "Turning extglob on changes the meaning of [[ !(str) ]] in bash", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "for-expr": { + "summary": { + "cases": 9, + "bashkit_pass": 7, + "bashkit_pass_pct": 77.8, + "bash_pass": 8, + "bash_pass_pct": 88.9, + "bashkit_pass_of_bash": 7, + "pass_rate": 87.5 + }, + "missed": [ + { + "case": 2, + "name": "Accepts { } syntax too", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 6 + ] + }, + "globignore": { + "summary": { + "cases": 18, + "bashkit_pass": 17, + "bashkit_pass_pct": 94.4, + "bash_pass": 18, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 17, + "pass_rate": 94.4 + }, + "missed": [ + { + "case": 12, + "name": "Ignore .:..", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "history-expand": { + "summary": { + "cases": 1, + "bashkit_pass": 0, + "bashkit_pass_pct": 0.0, + "bash_pass": 1, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 0, + "pass_rate": 0.0 + }, + "missed": [ + { + "case": 0, + "name": "!! expansion", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "pipeline": { + "summary": { + "cases": 26, + "bashkit_pass": 24, + "bashkit_pass_pct": 92.3, + "bash_pass": 25, + "bash_pass_pct": 96.2, + "bashkit_pass_of_bash": 24, + "pass_rate": 96.0 + }, + "missed": [ + { + "case": 6, + "name": "Initial value of PIPESTATUS is empty string", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 10 + ] + }, + "process-sub": { + "summary": { + "cases": 9, + "bashkit_pass": 8, + "bashkit_pass_pct": 88.9, + "bash_pass": 8, + "bash_pass_pct": 88.9, + "bashkit_pass_of_bash": 7, + "pass_rate": 87.5 + }, + "missed": [ + { + "case": 2, + "name": "Process sub from shell to stdin", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 4 + ] + }, + "smoke": { + "summary": { + "cases": 18, + "bashkit_pass": 17, + "bashkit_pass_pct": 94.4, + "bash_pass": 18, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 17, + "pass_rate": 94.4 + }, + "missed": [ + { + "case": 11, + "name": "Here doc with redirect", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "toysh-posix": { + "summary": { + "cases": 23, + "bashkit_pass": 22, + "bashkit_pass_pct": 95.7, + "bash_pass": 23, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 22, + "pass_rate": 95.7 + }, + "missed": [ + { + "case": 13, + "name": "for loop parsing - http://landley.net/notes.html#04-03-2020", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "unicode": { + "summary": { + "cases": 7, + "bashkit_pass": 1, + "bashkit_pass_pct": 14.3, + "bash_pass": 2, + "bash_pass_pct": 28.6, + "bashkit_pass_of_bash": 1, + "pass_rate": 50.0 + }, + "missed": [ + { + "case": 0, + "name": "OSH source code doesn't have to be valid Unicode (like other shells)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [ + 2, + 3, + 4, + 5, + 6 + ] + }, + "var-op-strip": { + "summary": { + "cases": 29, + "bashkit_pass": 28, + "bashkit_pass_pct": 96.6, + "bash_pass": 29, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 28, + "pass_rate": 96.6 + }, + "missed": [ + { + "case": 26, + "name": "Strip Right Brace (#702)", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "var-sub": { + "summary": { + "cases": 6, + "bashkit_pass": 5, + "bashkit_pass_pct": 83.3, + "bash_pass": 6, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 83.3 + }, + "missed": [ + { + "case": 4, + "name": "Descriptor redirect to bad \"$@\"", + "class": "behavior", + "on": "status" + } + ], + "bash_fails": [] + }, + "vars-bash": { + "summary": { + "cases": 1, + "bashkit_pass": 0, + "bashkit_pass_pct": 0.0, + "bash_pass": 1, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 0, + "pass_rate": 0.0 + }, + "missed": [ + { + "case": 0, + "name": "$SHELL is set to what is in /etc/passwd", + "class": "interactive", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "zsh-idioms": { + "summary": { + "cases": 3, + "bashkit_pass": 2, + "bashkit_pass_pct": 66.7, + "bash_pass": 3, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 66.7 + }, + "missed": [ + { + "case": 2, + "name": "zsh var sub is rejected at runtime", + "class": "behavior", + "on": "stdout" + } + ], + "bash_fails": [] + }, + "arith-dynamic": { + "summary": { + "cases": 4, + "bashkit_pass": 4, + "bashkit_pass_pct": 100.0, + "bash_pass": 4, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "array-compat": { + "summary": { + "cases": 12, + "bashkit_pass": 12, + "bashkit_pass_pct": 100.0, + "bash_pass": 12, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 12, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "builtin-echo": { + "summary": { + "cases": 27, + "bashkit_pass": 27, + "bashkit_pass_pct": 100.0, + "bash_pass": 27, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 27, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "builtin-times": { + "summary": { + "cases": 1, + "bashkit_pass": 1, + "bashkit_pass_pct": 100.0, + "bash_pass": 1, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 1, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "case_": { + "summary": { + "cases": 13, + "bashkit_pass": 13, + "bashkit_pass_pct": 100.0, + "bash_pass": 13, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 13, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "command-parsing": { + "summary": { + "cases": 5, + "bashkit_pass": 5, + "bashkit_pass_pct": 100.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "comments": { + "summary": { + "cases": 2, + "bashkit_pass": 2, + "bashkit_pass_pct": 100.0, + "bash_pass": 2, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "empty-bodies": { + "summary": { + "cases": 3, + "bashkit_pass": 3, + "bashkit_pass_pct": 100.0, + "bash_pass": 3, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 3, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "explore-parsing": { + "summary": { + "cases": 5, + "bashkit_pass": 5, + "bashkit_pass_pct": 100.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "globstar": { + "summary": { + "cases": 5, + "bashkit_pass": 5, + "bashkit_pass_pct": 100.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "if_": { + "summary": { + "cases": 5, + "bashkit_pass": 5, + "bashkit_pass_pct": 100.0, + "bash_pass": 5, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 5, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "known-differences": { + "summary": { + "cases": 2, + "bashkit_pass": 2, + "bashkit_pass_pct": 100.0, + "bash_pass": 2, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "let": { + "summary": { + "cases": 2, + "bashkit_pass": 2, + "bashkit_pass_pct": 100.0, + "bash_pass": 2, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "nocasematch-match": { + "summary": { + "cases": 6, + "bashkit_pass": 6, + "bashkit_pass_pct": 100.0, + "bash_pass": 6, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 6, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "posix": { + "summary": { + "cases": 15, + "bashkit_pass": 15, + "bashkit_pass_pct": 100.0, + "bash_pass": 15, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 15, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "print-source-code": { + "summary": { + "cases": 4, + "bashkit_pass": 4, + "bashkit_pass_pct": 100.0, + "bash_pass": 4, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "sh-func": { + "summary": { + "cases": 12, + "bashkit_pass": 12, + "bashkit_pass_pct": 100.0, + "bash_pass": 12, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 12, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "strict-options": { + "summary": { + "cases": 17, + "bashkit_pass": 15, + "bashkit_pass_pct": 88.2, + "bash_pass": 15, + "bash_pass_pct": 88.2, + "bashkit_pass_of_bash": 15, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [ + 3, + 5 + ] + }, + "subshell": { + "summary": { + "cases": 2, + "bashkit_pass": 2, + "bashkit_pass_pct": 100.0, + "bash_pass": 2, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 2, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "temp-binding": { + "summary": { + "cases": 4, + "bashkit_pass": 4, + "bashkit_pass_pct": 100.0, + "bash_pass": 4, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 4, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "type-compat": { + "summary": { + "cases": 7, + "bashkit_pass": 5, + "bashkit_pass_pct": 71.4, + "bash_pass": 5, + "bash_pass_pct": 71.4, + "bashkit_pass_of_bash": 5, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [ + 3, + 5 + ] + }, + "var-num": { + "summary": { + "cases": 7, + "bashkit_pass": 7, + "bashkit_pass_pct": 100.0, + "bash_pass": 7, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 7, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "whitespace": { + "summary": { + "cases": 5, + "bashkit_pass": 0, + "bashkit_pass_pct": 0.0, + "bash_pass": 0, + "bash_pass_pct": 0.0, + "bashkit_pass_of_bash": 0, + "pass_rate": 0.0 + }, + "missed": [], + "bash_fails": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + "word-eval": { + "summary": { + "cases": 8, + "bashkit_pass": 8, + "bashkit_pass_pct": 100.0, + "bash_pass": 8, + "bash_pass_pct": 100.0, + "bashkit_pass_of_bash": 8, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [] + }, + "word-split": { + "summary": { + "cases": 55, + "bashkit_pass": 53, + "bashkit_pass_pct": 96.4, + "bash_pass": 53, + "bash_pass_pct": 96.4, + "bashkit_pass_of_bash": 53, + "pass_rate": 100.0 + }, + "missed": [], + "bash_fails": [ + 24, + 25 + ] + } + } +} diff --git a/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T115238Z.md b/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T115238Z.md new file mode 100644 index 000000000..02415b7b5 --- /dev/null +++ b/scripts/oils-spec/results/oils-spec-linux-x86_64-20261008T115238Z.md @@ -0,0 +1,167 @@ +# Oils spec pass rate + +- Oils revision: [`57d3f0d088c3`](https://github.com/oils-for-unix/oils/tree/57d3f0d088c36340bc4b3038208e58ea02acf15c) +- bashkit commit: `bae540476f21` +- bash: 5.2.21(1)-release +- Spec files: 132 (bash column; 91 without one skipped) +- Runtime: 50.6 s, 4 jobs, 10 s per-case timeout + +## Headline + +**77.3%** — bashkit passes 2080 of the 2692 cases real bash passes. + +| Shell | Pass | Cases | % | +|-------|-----:|------:|--:| +| bash | 2692 | 2759 | 97.6 | +| bashkit | 2086 | 2759 | 75.6 | + +## bashkit failures by class + +Heuristic keyword classes; every case still counts in the rate. + +| Class | Failing cases | +|-------|--------------:| +| behavior | 447 | +| interactive | 110 | +| host | 27 | +| processes | 18 | +| signals | 10 | + +## Per spec file + +Sorted by cases bashkit misses among those bash passes. + +| Spec file | Cases | bash | bashkit | Missed | Rate % | +|-----------|------:|-----:|--------:|-------:|-------:| +| builtin-completion | 51 | 51 | 17 | 34 | 33.3 | +| array | 78 | 78 | 53 | 25 | 67.9 | +| prompt | 33 | 29 | 4 | 25 | 13.8 | +| builtin-trap-bash | 23 | 23 | 4 | 19 | 17.4 | +| var-ref | 31 | 30 | 15 | 15 | 50.0 | +| builtin-fc | 14 | 14 | 2 | 12 | 14.3 | +| builtin-history | 17 | 17 | 5 | 12 | 29.4 | +| builtin-type-bash | 31 | 31 | 19 | 12 | 61.3 | +| interactive | 18 | 18 | 7 | 11 | 38.9 | +| sh-options | 39 | 39 | 28 | 11 | 71.8 | +| vars-special | 42 | 40 | 29 | 11 | 72.5 | +| array-assign | 11 | 11 | 1 | 10 | 9.1 | +| array-sparse | 40 | 40 | 30 | 10 | 75.0 | +| builtin-bind | 9 | 9 | 0 | 9 | 0.0 | +| builtin-read | 64 | 64 | 55 | 9 | 85.9 | +| builtin-trap | 33 | 33 | 24 | 9 | 72.7 | +| introspect | 13 | 12 | 3 | 9 | 25.0 | +| nul-bytes | 16 | 16 | 7 | 9 | 43.8 | +| redirect-multi | 13 | 13 | 4 | 9 | 30.8 | +| tilde | 14 | 14 | 5 | 9 | 35.7 | +| var-op-patsub | 28 | 28 | 19 | 9 | 67.9 | +| ysh-builtin-private | 23 | 22 | 13 | 9 | 59.1 | +| arith | 74 | 74 | 66 | 8 | 89.2 | +| array-assoc | 42 | 36 | 28 | 8 | 77.8 | +| assign-extended | 39 | 35 | 27 | 8 | 77.1 | +| brace-expansion | 55 | 55 | 47 | 8 | 85.5 | +| bugs | 29 | 29 | 21 | 8 | 72.4 | +| var-op-bash | 27 | 26 | 18 | 8 | 69.2 | +| var-op-test | 37 | 37 | 29 | 8 | 78.4 | +| alias | 48 | 48 | 41 | 7 | 85.4 | +| array-literal | 19 | 19 | 12 | 7 | 63.2 | +| ble-idioms | 26 | 26 | 19 | 7 | 73.1 | +| builtin-bracket | 52 | 50 | 43 | 7 | 86.0 | +| builtin-eval-source | 23 | 21 | 14 | 7 | 66.7 | +| command_ | 16 | 16 | 9 | 7 | 56.2 | +| redirect | 41 | 41 | 34 | 7 | 82.9 | +| sh-usage | 24 | 24 | 17 | 7 | 70.8 | +| background | 27 | 26 | 20 | 6 | 76.9 | +| builtin-process | 26 | 24 | 19 | 6 | 75.0 | +| builtin-vars | 41 | 39 | 33 | 6 | 84.6 | +| here-doc | 36 | 36 | 30 | 6 | 83.3 | +| sh-options-bash | 9 | 9 | 3 | 6 | 33.3 | +| xtrace | 19 | 19 | 13 | 6 | 68.4 | +| arith-context | 16 | 16 | 11 | 5 | 68.8 | +| assign | 48 | 45 | 40 | 5 | 88.9 | +| blog1 | 9 | 9 | 4 | 5 | 44.4 | +| builtin-cd | 30 | 28 | 23 | 5 | 82.1 | +| builtin-meta | 18 | 18 | 13 | 5 | 72.2 | +| builtin-meta-assign | 11 | 11 | 6 | 5 | 54.5 | +| builtin-printf | 63 | 58 | 56 | 5 | 91.4 | +| glob | 39 | 37 | 32 | 5 | 86.5 | +| loop | 29 | 29 | 24 | 5 | 82.8 | +| nameref | 32 | 32 | 27 | 5 | 84.4 | +| paren-ambiguity | 9 | 9 | 4 | 5 | 44.4 | +| ble-features | 9 | 9 | 5 | 4 | 55.6 | +| ble-unset | 5 | 5 | 1 | 4 | 20.0 | +| builtin-dirs | 18 | 18 | 14 | 4 | 77.8 | +| builtin-kill | 20 | 20 | 16 | 4 | 80.0 | +| builtin-special | 12 | 12 | 8 | 4 | 66.7 | +| parse-errors | 27 | 25 | 21 | 4 | 84.0 | +| regex | 37 | 37 | 33 | 4 | 89.2 | +| var-sub-quote | 41 | 41 | 37 | 4 | 90.2 | +| assign-dialects | 4 | 4 | 1 | 3 | 25.0 | +| blog2 | 8 | 8 | 5 | 3 | 62.5 | +| bool-parse | 8 | 8 | 5 | 3 | 62.5 | +| builtin-bash | 13 | 13 | 10 | 3 | 76.9 | +| builtin-misc | 7 | 7 | 4 | 3 | 57.1 | +| builtin-set | 24 | 24 | 21 | 3 | 87.5 | +| builtin-trap-err | 23 | 22 | 19 | 3 | 86.4 | +| builtin-umask | 24 | 24 | 21 | 3 | 87.5 | +| extglob-files | 23 | 23 | 20 | 3 | 87.0 | +| fatal-errors | 5 | 5 | 2 | 3 | 40.0 | +| func-parsing | 15 | 15 | 12 | 3 | 80.0 | +| nix-idioms | 6 | 6 | 3 | 3 | 50.0 | +| quote | 35 | 35 | 32 | 3 | 91.4 | +| redirect-command | 23 | 23 | 20 | 3 | 87.0 | +| serialize | 10 | 10 | 7 | 3 | 70.0 | +| var-op-len | 9 | 8 | 6 | 3 | 62.5 | +| arg-parse | 3 | 3 | 1 | 2 | 33.3 | +| array-basic | 5 | 5 | 3 | 2 | 60.0 | +| assign-deferred | 9 | 9 | 7 | 2 | 77.8 | +| builtin-getopts | 31 | 31 | 29 | 2 | 93.5 | +| builtin-type | 6 | 6 | 4 | 2 | 66.7 | +| command-sub | 30 | 28 | 26 | 2 | 92.9 | +| divergence | 4 | 4 | 2 | 2 | 50.0 | +| errexit | 35 | 35 | 33 | 2 | 94.3 | +| exit-status | 11 | 11 | 9 | 2 | 81.8 | +| glob-bash | 8 | 8 | 6 | 2 | 75.0 | +| toysh | 8 | 8 | 6 | 2 | 75.0 | +| var-op-slice | 22 | 22 | 20 | 2 | 90.9 | +| append | 20 | 20 | 19 | 1 | 95.0 | +| dbracket | 49 | 49 | 48 | 1 | 98.0 | +| dparen | 15 | 14 | 13 | 1 | 92.9 | +| errexit-osh | 35 | 35 | 34 | 1 | 97.1 | +| extglob-match | 29 | 29 | 28 | 1 | 96.6 | +| for-expr | 9 | 8 | 7 | 1 | 87.5 | +| globignore | 18 | 18 | 17 | 1 | 94.4 | +| history-expand | 1 | 1 | 0 | 1 | 0.0 | +| pipeline | 26 | 25 | 24 | 1 | 96.0 | +| process-sub | 9 | 8 | 8 | 1 | 87.5 | +| smoke | 18 | 18 | 17 | 1 | 94.4 | +| toysh-posix | 23 | 23 | 22 | 1 | 95.7 | +| unicode | 7 | 2 | 1 | 1 | 50.0 | +| var-op-strip | 29 | 29 | 28 | 1 | 96.6 | +| var-sub | 6 | 6 | 5 | 1 | 83.3 | +| vars-bash | 1 | 1 | 0 | 1 | 0.0 | +| zsh-idioms | 3 | 3 | 2 | 1 | 66.7 | +| arith-dynamic | 4 | 4 | 4 | 0 | 100.0 | +| array-compat | 12 | 12 | 12 | 0 | 100.0 | +| builtin-echo | 27 | 27 | 27 | 0 | 100.0 | +| builtin-times | 1 | 1 | 1 | 0 | 100.0 | +| case_ | 13 | 13 | 13 | 0 | 100.0 | +| command-parsing | 5 | 5 | 5 | 0 | 100.0 | +| comments | 2 | 2 | 2 | 0 | 100.0 | +| empty-bodies | 3 | 3 | 3 | 0 | 100.0 | +| explore-parsing | 5 | 5 | 5 | 0 | 100.0 | +| globstar | 5 | 5 | 5 | 0 | 100.0 | +| if_ | 5 | 5 | 5 | 0 | 100.0 | +| known-differences | 2 | 2 | 2 | 0 | 100.0 | +| let | 2 | 2 | 2 | 0 | 100.0 | +| nocasematch-match | 6 | 6 | 6 | 0 | 100.0 | +| posix | 15 | 15 | 15 | 0 | 100.0 | +| print-source-code | 4 | 4 | 4 | 0 | 100.0 | +| sh-func | 12 | 12 | 12 | 0 | 100.0 | +| strict-options | 17 | 15 | 15 | 0 | 100.0 | +| subshell | 2 | 2 | 2 | 0 | 100.0 | +| temp-binding | 4 | 4 | 4 | 0 | 100.0 | +| type-compat | 7 | 5 | 5 | 0 | 100.0 | +| var-num | 7 | 7 | 7 | 0 | 100.0 | +| whitespace | 5 | 0 | 0 | 0 | 0.0 | +| word-eval | 8 | 8 | 8 | 0 | 100.0 | +| word-split | 55 | 53 | 53 | 0 | 100.0 |