diff --git a/crates/bashkit/src/builtins/ls.rs b/crates/bashkit/src/builtins/ls.rs index 7a590dfb..1ef75fc7 100644 --- a/crates/bashkit/src/builtins/ls.rs +++ b/crates/bashkit/src/builtins/ls.rs @@ -20,11 +20,12 @@ struct LsOptions { recursive: bool, sort_by_time: bool, classify: bool, + columns: bool, } /// The ls builtin - list directory contents. /// -/// Usage: ls [-l] [-a] [-h] [-1] [-R] [-t] [-F] [PATH...] +/// Usage: ls [-l] [-a] [-h] [-1] [-R] [-t] [-F] [-C] [PATH...] /// /// Options: /// -l Use long listing format @@ -34,6 +35,7 @@ struct LsOptions { /// -R List subdirectories recursively /// -t Sort by modification time, newest first /// -F Append indicator (/ for dirs, * for executables, @ for symlinks, | for FIFOs) +/// -C List entries in columns (multi-column output) pub struct Ls; #[async_trait] @@ -47,6 +49,7 @@ impl Builtin for Ls { recursive: false, sort_by_time: false, classify: false, + columns: false, }; // Parse flags @@ -64,6 +67,7 @@ impl Builtin for Ls { 'R' => opts.recursive = true, 't' => opts.sort_by_time = true, 'F' => opts.classify = true, + 'C' => opts.columns = true, _ => { return Ok(ExecResult::err( format!("ls: invalid option -- '{}'\n", c), @@ -118,8 +122,8 @@ impl Builtin for Ls { } // Output file arguments first (preserving path as given by user) - for (path_str, metadata) in &file_args { - if opts.long { + if opts.long { + for (path_str, metadata) in &file_args { let mut entry = format_long_entry(path_str, metadata, opts.human); if opts.classify { // Insert suffix before the trailing newline @@ -129,12 +133,25 @@ impl Builtin for Ls { } } output.push_str(&entry); + } + } else if !file_args.is_empty() { + let names: Vec = file_args + .iter() + .map(|(path_str, metadata)| { + let mut name = (*path_str).to_string(); + if opts.classify { + name.push_str(classify_suffix(metadata)); + } + name + }) + .collect(); + if opts.columns && !opts.one_per_line { + output.push_str(&format_columns(&names, 80)); } else { - output.push_str(path_str); - if opts.classify { - output.push_str(classify_suffix(metadata)); + for name in &names { + output.push_str(name); + output.push('\n'); } - output.push('\n'); } } @@ -219,12 +236,14 @@ async fn list_directory( } } } else { + // Collect entry names for potential column formatting + let mut names: Vec = Vec::new(); for entry in &filtered { - output.push_str(&entry.name); + let mut name = entry.name.clone(); if opts.classify { - output.push_str(classify_suffix(&entry.metadata)); + name.push_str(classify_suffix(&entry.metadata)); } - output.push('\n'); + names.push(name); if opts.recursive && entry.metadata.file_type.is_dir() { subdirs.push(( path.join(&entry.name), @@ -232,6 +251,16 @@ async fn list_directory( )); } } + + // Precedence: -l > -1 > -C > default (one-per-line) + if opts.columns && !opts.one_per_line { + output.push_str(&format_columns(&names, 80)); + } else { + for name in &names { + output.push_str(name); + output.push('\n'); + } + } } // Recursive listing @@ -262,6 +291,62 @@ fn classify_suffix(metadata: &crate::fs::Metadata) -> &'static str { } } +/// Format entries in column-major order, like `ls -C`. +/// Uses a fixed terminal width (80) since VFS has no real terminal. +/// Per-column widths match GNU coreutils behavior. +fn format_columns(entries: &[String], terminal_width: usize) -> String { + if entries.is_empty() { + return String::new(); + } + + // Try fitting as many columns as possible, starting from the maximum + let max_width = entries.iter().map(|e| e.len()).max().unwrap_or(0); + let max_possible_cols = (terminal_width / (max_width.min(1) + 2)).max(1); + + let mut num_cols = 1; + let mut col_widths: Vec = vec![0]; + let mut num_rows = entries.len(); + + // Try increasing column counts to find the best fit + for try_cols in 2..=max_possible_cols.min(entries.len()) { + let try_rows = entries.len().div_ceil(try_cols); + // Calculate per-column widths (max entry width in each column) + let mut widths = vec![0usize; try_cols]; + for (i, entry) in entries.iter().enumerate() { + let col = i / try_rows; + if col < try_cols { + widths[col] = widths[col].max(entry.len()); + } + } + // Total width: each column except last gets 2-space padding + let total: usize = widths.iter().sum::() + (try_cols - 1) * 2; + if total <= terminal_width { + num_cols = try_cols; + col_widths = widths; + num_rows = try_rows; + } + } + + let mut output = String::new(); + for row in 0..num_rows { + for (col, col_w) in col_widths.iter().enumerate() { + // Column-major order: fill columns top-to-bottom, left-to-right + let idx = col * num_rows + row; + if idx < entries.len() { + let is_last = col == num_cols - 1 || idx + num_rows >= entries.len(); + if is_last { + output.push_str(&entries[idx]); + } else { + let width = col_w + 2; // entry width + 2 spaces + output.push_str(&format!("{: String { let file_type = match metadata.file_type { FileType::Directory => 'd', diff --git a/crates/bashkit/tests/spec_cases/bash/ls.test.sh b/crates/bashkit/tests/spec_cases/bash/ls.test.sh index 2ee16c14..aa003af6 100644 --- a/crates/bashkit/tests/spec_cases/bash/ls.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/ls.test.sh @@ -75,6 +75,35 @@ ls -F /tmp/lscf/mydir /tmp/lscf/normal.txt /tmp/lscf/mydir: ### end +### ls_columns_basic +# ls -C should produce multi-column output +mkdir -p /tmp/lscol +touch /tmp/lscol/alpha /tmp/lscol/beta /tmp/lscol/delta /tmp/lscol/gamma +ls -C /tmp/lscol +### expect +alpha beta delta gamma +### end + +### ls_columns_with_classify +# ls -CF should combine classify and columns +mkdir -p /tmp/lscf2/subdir +touch /tmp/lscf2/file.txt +ls -CF /tmp/lscf2 +### expect +file.txt subdir/ +### end + +### ls_one_per_line_overrides_columns +# ls -1 should override -C (one per line) +mkdir -p /tmp/ls1c +touch /tmp/ls1c/aaa /tmp/ls1c/bbb /tmp/ls1c/ccc +ls -C1 /tmp/ls1c +### expect +aaa +bbb +ccc +### end + ### ls_classify_long ### bash_diff: bashkit ls -l omits 'total' line # ls -lF should append indicators in long format