From bdb3ff3ac93eda0f3aac5cc867f62c6b905ff5f8 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 2 Apr 2026 21:37:50 +0000 Subject: [PATCH 1/2] fix(builtins): read builtin now respects custom IFS for field splitting The read builtin was only checking env variables for IFS, missing shell variables set via IFS=",". Now checks shell variables first, then env. Also fixes the last-variable join to use the IFS delimiter instead of hardcoded space. Closes #968 --- crates/bashkit/src/builtins/read.rs | 71 +++++++++++++++++-- .../spec_cases/bash/read-builtin.test.sh | 14 ++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/crates/bashkit/src/builtins/read.rs b/crates/bashkit/src/builtins/read.rs index aac0f5a2..7bf096b9 100644 --- a/crates/bashkit/src/builtins/read.rs +++ b/crates/bashkit/src/builtins/read.rs @@ -131,7 +131,13 @@ impl Builtin for Read { // Split line by IFS (default: space, tab, newline) // IFS whitespace chars (space, tab, newline) collapse runs and trim. // Non-whitespace IFS chars preserve empty fields between consecutive delimiters. - let ifs = ctx.env.get("IFS").map(|s| s.as_str()).unwrap_or(" \t\n"); + // Check shell variables first (IFS=","), then env, then default. + let ifs = ctx + .variables + .get("IFS") + .or_else(|| ctx.env.get("IFS")) + .map(|s| s.as_str()) + .unwrap_or(" \t\n"); let words: Vec<&str> = if ifs.is_empty() { // Empty IFS means no word splitting vec![&line] @@ -188,9 +194,17 @@ impl Builtin for Read { continue; } let value = if i == var_names.len() - 1 { - // Last variable gets all remaining words + // Last variable gets all remaining words joined by first IFS char let remaining: Vec<&str> = words.iter().skip(i).copied().collect(); - remaining.join(" ") + let ifs_non_ws: Vec = ifs.chars().filter(|c| !" \t\n".contains(*c)).collect(); + let join_sep = if !ifs_non_ws.is_empty() { + // Non-whitespace IFS: join with the first non-whitespace IFS char + ifs_non_ws[0].to_string() + } else { + // Whitespace-only IFS: join with space + " ".to_string() + }; + remaining.join(&join_sep) } else if i < words.len() { words[i].to_string() } else { @@ -548,7 +562,7 @@ mod tests { assert_eq!(result.exit_code, 0); let vars = extract_vars(&result); assert_eq!(vars.get("A").unwrap(), "foo"); - assert_eq!(vars.get("B").unwrap(), "bar baz"); + assert_eq!(vars.get("B").unwrap(), "bar:baz"); } #[tokio::test] @@ -598,4 +612,53 @@ mod tests { let vars = extract_vars(&result); assert_eq!(vars.get("LINE").unwrap(), "no splitting here"); } + + #[tokio::test] + async fn read_ifs_from_shell_variables() { + // IFS set as a shell variable (not env) — the common case (IFS=",") + let (fs, mut cwd, mut variables) = setup().await; + let env = HashMap::new(); + variables.insert("IFS".to_string(), ",".to_string()); + let args = vec!["A".to_string(), "B".to_string(), "C".to_string()]; + let ctx = Context::new_for_test( + &args, + &env, + &mut variables, + &mut cwd, + fs.clone(), + Some("one,two,three"), + ); + let result = Read.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + let vars = extract_vars(&result); + assert_eq!(vars.get("A").unwrap(), "one"); + assert_eq!(vars.get("B").unwrap(), "two"); + assert_eq!(vars.get("C").unwrap(), "three"); + } + + #[tokio::test] + async fn read_ifs_from_shell_variables_array() { + // IFS=: with read -ra should split into array + let (fs, mut cwd, mut variables) = setup().await; + let env = HashMap::new(); + variables.insert("IFS".to_string(), ":".to_string()); + let args = vec!["-ra".to_string(), "parts".to_string()]; + let ctx = Context::new_for_test( + &args, + &env, + &mut variables, + &mut cwd, + fs.clone(), + Some("a:b:c"), + ); + let result = Read.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + match &result.side_effects[0] { + BuiltinSideEffect::SetArray { name, elements } => { + assert_eq!(name, "parts"); + assert_eq!(elements, &["a", "b", "c"]); + } + _ => panic!("Expected SetArray side effect"), + } + } } diff --git a/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh b/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh index 79bf67d2..ec2e0449 100644 --- a/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh @@ -112,3 +112,17 @@ printf "complete\npartial" | { complete partial ### end + +### read_custom_ifs_comma +# read should split on custom IFS +IFS=","; read -r a b c <<< "one,two,three"; echo "$a|$b|$c" +### expect +one|two|three +### end + +### read_custom_ifs_colon +# read -ra should split into array on custom IFS +IFS=":"; read -ra parts <<< "a:b:c"; echo "${#parts[@]} ${parts[1]}" +### expect +3 b +### end From a057947959a66dcc4d9b23ed9de061c3797b875c Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 2 Apr 2026 21:50:26 +0000 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20update=20writeable=200.6.2=20?= =?UTF-8?q?=E2=86=92=200.6.3=20exemption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supply-chain/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 532e567d..804ca15c 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -1799,7 +1799,7 @@ version = "0.244.0" criteria = "safe-to-deploy" [[exemptions.writeable]] -version = "0.6.2" +version = "0.6.3" criteria = "safe-to-deploy" [[exemptions.yansi]]