Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 113 additions & 46 deletions crates/bashkit/src/builtins/mapfile.rs
Original file line number Diff line number Diff line change
@@ -1,72 +1,139 @@
//! mapfile/readarray builtin — read lines from stdin into an array.
//! mapfile/readarray builtin — read records from stdin into an array.
//!
//! Mutates arrays via [`BuiltinSideEffect::SetIndexedArray`](super::BuiltinSideEffect).
//!
//! Decision: `-u FD` other than 0 and the `-C`/`-c` callback are rejected with
//! status 2 instead of being ignored; reading the wrong input silently would
//! be worse than a clear error (see L-MAPFILE-001 in operations/limitations).

use async_trait::async_trait;

use super::{Builtin, BuiltinSideEffect, Context};
use crate::error::Result;
use crate::interpreter::ExecResult;

/// `mapfile`/`readarray` builtin — read lines from stdin into an indexed array.
/// `mapfile`/`readarray` builtin — read records from stdin into an indexed array.
///
/// Usage: mapfile [-t] [ARRAY]
/// Usage: mapfile [-d DELIM] [-n COUNT] [-O ORIGIN] [-s COUNT] [-t] [ARRAY]
///
/// - `-t` — strip trailing newlines from each line
/// - `-d DELIM` — end records at the first char of DELIM (`''` means NUL)
/// - `-n COUNT` — copy at most COUNT records (0 means all)
/// - `-O ORIGIN` — start at index ORIGIN and keep existing elements
/// - `-s COUNT` — skip the first COUNT records
/// - `-t` — strip the delimiter from each record
/// - Default array name is `MAPFILE`
pub struct Mapfile;

#[async_trait]
impl Builtin for Mapfile {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let mut trim_trailing = false; // -t: strip trailing newlines
let mut array_name = "MAPFILE".to_string();
let mut positional = Vec::new();
struct Opts {
delim: char,
count: usize,
origin: Option<usize>,
skip: usize,
trim: bool,
name: String,
}

for arg in ctx.args {
match arg.as_str() {
"-t" => trim_trailing = true,
a if a.starts_with('-') => {} // skip unknown flags
_ => positional.push(arg.clone()),
}
}
fn parse_count(opt: char, v: &str) -> std::result::Result<usize, String> {
v.trim().parse::<usize>().map_err(|_| {
let what = if opt == 'O' { "origin" } else { "line count" };
format!("{v}: invalid {what}")
})
}

if let Some(name) = positional.first() {
array_name = name.clone();
fn parse_opts(args: &[String]) -> std::result::Result<Opts, String> {
let mut o = Opts {
delim: '\n',
count: 0,
origin: None,
skip: 0,
trim: false,
name: "MAPFILE".to_string(),
};
let mut i = 0;
while i < args.len() {
let arg = &args[i];
i += 1;
if arg == "--" {
break;
}
if !arg.starts_with('-') || arg == "-" {
i -= 1;
break;
}
let mut chars = arg[1..].chars();
while let Some(c) = chars.next() {
match c {
't' => o.trim = true,
'd' | 'n' | 'O' | 's' | 'u' | 'C' | 'c' => {
let rest: String = chars.by_ref().collect();
let value = if !rest.is_empty() {
rest
} else if let Some(v) = args.get(i) {
i += 1;
v.clone()
} else {
return Err(format!("-{c}: option requires an argument"));
};
match c {
'd' => o.delim = value.chars().next().unwrap_or('\0'),
'n' => o.count = parse_count(c, &value)?,
'O' => o.origin = Some(parse_count(c, &value)?),
's' => o.skip = parse_count(c, &value)?,
'u' if value.trim() == "0" => {}
'u' => return Err(format!("-u {value}: only fd 0 is supported")),
_ => return Err(format!("-{c}: callbacks are not supported")),
}
}
_ => return Err(format!("-{c}: invalid option")),
}
}
}
if let Some(name) = args.get(i) {
o.name = name.clone();
}
Ok(o)
}

#[async_trait]
impl Builtin for Mapfile {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let o = match parse_opts(ctx.args) {
Ok(o) => o,
Err(msg) => return Ok(ExecResult::err(format!("mapfile: {msg}\n"), 2)),
};
let input = ctx.stdin.map(|stdin| &**stdin).unwrap_or("");

let mut result = ExecResult::ok(String::new());
// Without -O the array is cleared first.
if o.origin.is_none() {
result
.side_effects
.push(BuiltinSideEffect::RemoveArray(o.name.clone()));
}

// Always remove existing array first
result
.side_effects
.push(BuiltinSideEffect::RemoveArray(array_name.clone()));

// Split into lines and populate array
if !input.is_empty() {
let entries: Vec<(usize, String)> = input
.lines()
.enumerate()
.map(|(idx, line)| {
let value = if trim_trailing {
line.to_string()
} else {
format!("{}\n", line)
};
(idx, value)
})
.collect();
let start = o.origin.unwrap_or(0);
let entries: Vec<(usize, String)> = input
.split_inclusive(o.delim)
.skip(o.skip)
.take(if o.count == 0 { usize::MAX } else { o.count })
.enumerate()
.map(|(idx, rec)| {
let value = if o.trim {
rec.strip_suffix(o.delim).unwrap_or(rec)
} else {
rec
};
(start + idx, value.to_string())
})
.collect();

if !entries.is_empty() {
result
.side_effects
.push(BuiltinSideEffect::SetIndexedArray {
name: array_name,
entries,
});
}
if !entries.is_empty() {
result
.side_effects
.push(BuiltinSideEffect::SetIndexedArray {
name: o.name,
entries,
});
}

Ok(result)
Expand Down
68 changes: 44 additions & 24 deletions crates/bashkit/src/interpreter/expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@

use super::*;

/// Case change for `${v^}`, `${v,}` and `${v~}`.
#[derive(Clone, Copy)]
enum CaseChange {
Upper,
Lower,
Toggle,
}

impl Interpreter {
/// Expand an array access expression (`${arr[index]}`).
pub(super) fn expand_array_access_part(&self, name: &str, index: &str) -> String {
Expand Down Expand Up @@ -698,14 +706,7 @@ impl Interpreter {
.unwrap_or_default();
for (idx, part) in word.parts.iter().enumerate() {
let part_is_quoted = word.part_quoted.get(idx).copied().unwrap_or(word.quoted);
let part_has_expansion = matches!(
part,
WordPart::Variable(_)
| WordPart::CommandSubstitution(_)
| WordPart::ArithmeticExpansion(_)
| WordPart::ParameterExpansion { .. }
| WordPart::ArrayAccess { .. }
);
let part_has_expansion = Self::is_field_split_expansion(part);
let value = if idx > 0
&& let WordPart::Literal(s) = part
{
Expand Down Expand Up @@ -792,16 +793,7 @@ impl Interpreter {
matches!(word.parts.first(), Some(WordPart::Literal(s)) if s.contains('='));
let has_expansion = !word.quoted
&& !is_assignment_word
&& word.parts.iter().any(|p| {
matches!(
p,
WordPart::Variable(_)
| WordPart::CommandSubstitution(_)
| WordPart::ArithmeticExpansion(_)
| WordPart::ParameterExpansion { .. }
| WordPart::ArrayAccess { .. }
)
});
&& word.parts.iter().any(Self::is_field_split_expansion);

if has_expansion {
self.ifs_split(&expanded)
Expand All @@ -811,6 +803,23 @@ impl Interpreter {
})
}

/// Expansion parts whose unquoted result undergoes IFS field splitting.
fn is_field_split_expansion(part: &WordPart) -> bool {
matches!(
part,
WordPart::Variable(_)
| WordPart::CommandSubstitution(_)
| WordPart::ArithmeticExpansion(_)
| WordPart::ParameterExpansion { .. }
| WordPart::ArrayAccess { .. }
| WordPart::IndirectExpansion { .. }
| WordPart::PrefixMatch(_)
| WordPart::Substring { .. }
| WordPart::ArraySlice { .. }
| WordPart::Transformation { .. }
)
}

/// Resolve name for parameter expansion, handling array subscripts and special params.
/// Returns (is_set, expanded_value).
pub(super) fn resolve_param_expansion_name(&self, name: &str) -> (bool, String) {
Expand Down Expand Up @@ -1457,6 +1466,8 @@ impl Interpreter {
| ParameterOp::UpperAll
| ParameterOp::LowerFirst
| ParameterOp::LowerAll
| ParameterOp::ToggleFirst
| ParameterOp::ToggleAll
)
}

Expand Down Expand Up @@ -1596,17 +1607,19 @@ impl Interpreter {
let expanded_pat = self.expand_replace_pattern(pattern);
self.replace_pattern(value, &expanded_pat, &expanded_rep, true)
}
ParameterOp::UpperFirst => self.change_case(value, operand, true, false),
ParameterOp::UpperAll => self.change_case(value, operand, true, true),
ParameterOp::LowerFirst => self.change_case(value, operand, false, false),
ParameterOp::LowerAll => self.change_case(value, operand, false, true),
ParameterOp::UpperFirst => self.change_case(value, operand, CaseChange::Upper, false),
ParameterOp::UpperAll => self.change_case(value, operand, CaseChange::Upper, true),
ParameterOp::LowerFirst => self.change_case(value, operand, CaseChange::Lower, false),
ParameterOp::LowerAll => self.change_case(value, operand, CaseChange::Lower, true),
ParameterOp::ToggleFirst => self.change_case(value, operand, CaseChange::Toggle, false),
ParameterOp::ToggleAll => self.change_case(value, operand, CaseChange::Toggle, true),
}
}

/// `${v^pat}`, `${v^^pat}`, `${v,pat}`, `${v,,pat}`: change the case of
/// the first (or every) character that matches `pat` on its own. An empty
/// pattern matches any character, as in bash.
fn change_case(&mut self, value: &str, operand: &str, upper: bool, all: bool) -> String {
fn change_case(&mut self, value: &str, operand: &str, mode: CaseChange, all: bool) -> String {
let pattern = if operand.is_empty() {
String::new()
} else {
Expand All @@ -1619,7 +1632,14 @@ impl Interpreter {
&& (pattern.is_empty() || self.pattern_matches(ch.encode_utf8(&mut buf), &pattern));
if !selected {
out.push(ch);
} else if upper {
continue;
}
let upper = match mode {
CaseChange::Upper => true,
CaseChange::Lower => false,
CaseChange::Toggle => !ch.is_uppercase(),
};
if upper {
out.extend(ch.to_uppercase());
} else {
out.extend(ch.to_lowercase());
Expand Down
9 changes: 6 additions & 3 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8812,6 +8812,8 @@ impl Interpreter {
ParameterOp::UpperAll => out.push_str(&format!("${{{}^^{}}}", name, operand)),
ParameterOp::LowerFirst => out.push_str(&format!("${{{},{}}}", name, operand)),
ParameterOp::LowerAll => out.push_str(&format!("${{{},,{}}}", name, operand)),
ParameterOp::ToggleFirst => out.push_str(&format!("${{{}~{}}}", name, operand)),
ParameterOp::ToggleAll => out.push_str(&format!("${{{}~~{}}}", name, operand)),
},
WordPart::Length(name) => out.push_str(&format!("${{#{}}}", name)),
WordPart::ArrayAccess { name, index } => {
Expand Down Expand Up @@ -10400,9 +10402,10 @@ impl Interpreter {
self.insert_array_checked(name.clone(), arr);
}
builtins::BuiltinSideEffect::SetIndexedArray { name, entries } => {
let arr: HashMap<usize, String> = entries.iter().cloned().collect();
// Remove existing array first (mirrors mapfile behavior)
self.arrays_mut().remove(name);
// Merges into the existing array; mapfile sends RemoveArray
// first unless `-O` asked to keep the other elements.
let mut arr = self.arrays_mut().remove(name).unwrap_or_default();
arr.extend(entries.iter().cloned());
if !arr.is_empty() {
self.insert_array_checked(name.clone(), arr);
}
Expand Down
3 changes: 2 additions & 1 deletion crates/bashkit/src/interpreter/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ pub enum BuiltinSideEffect {
SetPositional(Vec<String>),
/// Populate an indexed array variable (replaces `_ARRAY_READ_*`).
SetArray { name: String, elements: Vec<String> },
/// Populate an indexed array with index->value pairs (for mapfile).
/// Set index->value pairs in an indexed array, keeping its other elements
/// (for mapfile, which sends `RemoveArray` first unless `-O` is given).
SetIndexedArray {
name: String,
entries: Vec<(usize, String)>,
Expand Down
6 changes: 6 additions & 0 deletions crates/bashkit/src/parser/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,8 @@ impl fmt::Display for Word {
ParameterOp::UpperAll => write!(f, "${{{}^^{}}}", name, operand)?,
ParameterOp::LowerFirst => write!(f, "${{{},{}}}", name, operand)?,
ParameterOp::LowerAll => write!(f, "${{{},,{}}}", name, operand)?,
ParameterOp::ToggleFirst => write!(f, "${{{}~{}}}", name, operand)?,
ParameterOp::ToggleAll => write!(f, "${{{}~~{}}}", name, operand)?,
},
WordPart::Length(name) => write!(f, "${{#{}}}", name)?,
WordPart::ArrayAccess { name, index } => write!(f, "${{{}[{}]}}", name, index)?,
Expand Down Expand Up @@ -511,6 +513,10 @@ pub enum ParameterOp {
LowerFirst,
/// ,, lowercase all chars
LowerAll,
/// ~ toggle case of first char
ToggleFirst,
/// ~~ toggle case of all chars
ToggleAll,
}

/// I/O redirection.
Expand Down
Loading
Loading