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
105 changes: 95 additions & 10 deletions crates/bashkit/src/builtins/ls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand All @@ -47,6 +49,7 @@ impl Builtin for Ls {
recursive: false,
sort_by_time: false,
classify: false,
columns: false,
};

// Parse flags
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand All @@ -129,12 +133,25 @@ impl Builtin for Ls {
}
}
output.push_str(&entry);
}
} else if !file_args.is_empty() {
let names: Vec<String> = 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');
}
}

Expand Down Expand Up @@ -219,19 +236,31 @@ async fn list_directory(
}
}
} else {
// Collect entry names for potential column formatting
let mut names: Vec<String> = 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),
format!("{}/{}", display_path, entry.name),
));
}
}

// 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
Expand Down Expand Up @@ -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<usize> = 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::<usize>() + (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!("{:<width$}", entries[idx], width = width));
}
}
}
output.push('\n');
}
output
}

fn format_long_entry(name: &str, metadata: &crate::fs::Metadata, human: bool) -> String {
let file_type = match metadata.file_type {
FileType::Directory => 'd',
Expand Down
29 changes: 29 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/ls.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading