#![allow(clippy::unwrap_used)]
use crate::builtins::clap_cache::cached_command;
use async_trait::async_trait;
use std::ffi::OsString;
use std::path::Path;
use crate::builtins::clap_env::apply_env_defaults;
use crate::builtins::generated::ls_args::LS_ENV_DEFAULTS;
use crate::builtins::{Builtin, Context, resolve_path};
use crate::error::Result;
use crate::fs::FileType;
use crate::fs::vfs_join;
use crate::interpreter::ExecResult;
use super::quoting::{QuotingStyle, quote_name};
const LS_SUPPORTED_IDS: &[&str] = &[
"long", "all", "almost-all", "human-readable", "1", "recursive", "t", "classify", "C", "directory", "quoting-style", "literal", "escape", "quote-name", "paths",
"help",
];
pub(super) struct LsOptions {
pub(super) long: bool,
pub(super) all: bool,
pub(super) human: bool,
pub(super) one_per_line: bool,
pub(super) recursive: bool,
pub(super) sort_by_time: bool,
pub(super) classify: bool,
pub(super) columns: bool,
pub(super) directory: bool,
pub(super) quoting: QuotingStyle,
}
pub struct Ls;
cached_command!(ls_cmd, super::super::generated::ls_args::ls_command());
#[async_trait]
impl Builtin for Ls {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let argv: Vec<OsString> = std::iter::once(OsString::from("ls"))
.chain(ctx.args.iter().map(OsString::from))
.collect();
let argv = apply_env_defaults(argv, LS_ENV_DEFAULTS, ctx.env);
let matches = match ls_cmd().try_get_matches_from(argv) {
Ok(m) => m,
Err(e) => {
let kind = e.kind();
let rendered = e.render().to_string();
if matches!(
kind,
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
) {
return Ok(ExecResult::ok(rendered));
}
return Ok(ExecResult::err(rendered, 2));
}
};
let unsupported: Vec<String> = matches
.ids()
.filter(|id| {
let name = id.as_str();
!LS_SUPPORTED_IDS.contains(&name)
&& matches.value_source(name) != Some(clap::parser::ValueSource::DefaultValue)
})
.map(|id| id.as_str().to_string())
.collect();
if !unsupported.is_empty() {
return Ok(ExecResult::err(
format!(
"ls: option(s) not yet implemented in bashkit: {}\n",
unsupported.join(", ")
),
2,
));
}
let classify = matches.contains_id("classify")
&& matches
.get_one::<String>("classify")
.map(|v| v != "never")
.unwrap_or(true);
let quoting = if matches.get_flag("escape") {
QuotingStyle::Escape
} else if matches.get_flag("quote-name") {
QuotingStyle::C
} else if let Some(style) = matches.get_one::<String>("quoting-style") {
match QuotingStyle::from_name(style) {
Some(q) => q,
None => {
return Ok(ExecResult::err(
format!("ls: quoting style '{style}' not yet implemented in bashkit\n"),
2,
));
}
}
} else {
QuotingStyle::Literal
};
let opts = LsOptions {
long: matches.get_flag("long"),
all: matches.get_flag("all") || matches.get_flag("almost-all"),
human: matches.get_flag("human-readable"),
one_per_line: matches.get_flag("1"),
recursive: matches.get_flag("recursive"),
sort_by_time: matches.get_flag("t"),
classify,
columns: matches.get_flag("C"),
directory: matches.get_flag("directory"),
quoting,
};
let paths_owned: Vec<String> = matches
.get_many::<OsString>("paths")
.map(|vs| vs.map(|v| v.to_string_lossy().into_owned()).collect())
.unwrap_or_default();
let mut paths: Vec<&str> = paths_owned.iter().map(String::as_str).collect();
if paths.is_empty() {
paths.push(".");
}
let mut output = String::new();
let multiple_paths = paths.len() > 1 || opts.recursive;
let mut file_args: Vec<(&str, std::path::PathBuf, crate::fs::Metadata)> = Vec::new();
let mut dir_args: Vec<(usize, &str, std::path::PathBuf)> = Vec::new();
for (i, path_str) in paths.iter().enumerate() {
let path = resolve_path(ctx.cwd, path_str);
let lmeta = match ctx.fs.lstat(&path).await {
Ok(m) => m,
Err(e) => {
let reason = match &e {
crate::Error::Io(io) if io.kind() == std::io::ErrorKind::NotADirectory => {
"Not a directory"
}
_ => "No such file or directory",
};
return Ok(ExecResult::err(
format!("ls: cannot access '{}': {reason}\n", path_str),
2,
));
}
};
let metadata = if lmeta.file_type.is_symlink()
&& !(opts.long || opts.directory || opts.classify)
{
ctx.fs.stat(&path).await.unwrap_or(lmeta)
} else {
lmeta
};
if !metadata.file_type.is_dir() || opts.directory {
file_args.push((path_str, path, metadata));
} else {
dir_args.push((i, path_str, path));
}
}
if opts.sort_by_time {
file_args.sort_by_key(|entry| std::cmp::Reverse(entry.2.modified));
}
if opts.long {
for (path_str, path, metadata) in &file_args {
let mut entry =
format_long_entry("e_name(path_str, opts.quoting), metadata, opts.human);
push_link_target(&ctx, path, metadata, &mut entry).await;
if opts.classify {
let suffix = classify_suffix(metadata);
if !suffix.is_empty() {
entry.insert_str(entry.len() - 1, suffix);
}
}
output.push_str(&entry);
}
} else if !file_args.is_empty() {
let names: Vec<String> = file_args
.iter()
.map(|(path_str, _, metadata)| {
let mut name = quote_name(path_str, opts.quoting).into_owned();
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 {
for name in &names {
output.push_str(name);
output.push('\n');
}
}
}
for (i, path_str, path) in &dir_args {
if let Err(e) = list_directory(
&ctx,
path,
path_str,
&mut output,
&opts,
multiple_paths,
*i > 0 || !file_args.is_empty(),
)
.await
{
return Ok(ExecResult::err(format!("ls: {}\n", e), 2));
}
}
Ok(ExecResult::ok(output))
}
}
async fn list_directory(
ctx: &Context<'_>,
path: &Path,
display_path: &str,
output: &mut String,
opts: &LsOptions,
show_header: bool,
add_newline: bool,
) -> std::result::Result<(), String> {
if add_newline {
output.push('\n');
}
if show_header {
let header = if opts.quoting == QuotingStyle::Escape {
std::borrow::Cow::Borrowed(display_path)
} else {
quote_name(display_path, opts.quoting)
};
output.push_str(&format!("{}:\n", header));
}
let entries = ctx
.fs
.read_dir(path)
.await
.map_err(|e| format!("cannot open directory '{}': {}", display_path, e))?;
let mut sorted_entries = entries;
if opts.sort_by_time {
sorted_entries.sort_by_key(|entry| std::cmp::Reverse(entry.metadata.modified));
} else {
sorted_entries.sort_by(|a, b| a.name.cmp(&b.name));
}
let filtered: Vec<_> = sorted_entries
.iter()
.filter(|e| opts.all || !e.name.starts_with('.'))
.collect();
let mut subdirs: Vec<(std::path::PathBuf, String)> = Vec::new();
if opts.long {
for entry in &filtered {
let mut line = format_long_entry(
"e_name(&entry.name, opts.quoting),
&entry.metadata,
opts.human,
);
push_link_target(
ctx,
&vfs_join(path, &entry.name),
&entry.metadata,
&mut line,
)
.await;
if opts.classify {
let suffix = classify_suffix(&entry.metadata);
if !suffix.is_empty() {
line.insert_str(line.len() - 1, suffix);
}
}
output.push_str(&line);
if opts.recursive && entry.metadata.file_type.is_dir() {
subdirs.push((
vfs_join(path, &entry.name),
format!("{}/{}", display_path, entry.name),
));
}
}
} else {
let mut names: Vec<String> = Vec::new();
for entry in &filtered {
let mut name = quote_name(&entry.name, opts.quoting).into_owned();
if opts.classify {
name.push_str(classify_suffix(&entry.metadata));
}
names.push(name);
if opts.recursive && entry.metadata.file_type.is_dir() {
subdirs.push((
vfs_join(path, &entry.name),
format!("{}/{}", display_path, entry.name),
));
}
}
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');
}
}
}
if opts.recursive {
for (subpath, display) in subdirs {
Box::pin(list_directory(
ctx, &subpath, &display, output, opts, true, true,
))
.await?;
}
}
Ok(())
}
async fn push_link_target(
ctx: &Context<'_>,
path: &Path,
metadata: &crate::fs::Metadata,
line: &mut String,
) {
if metadata.file_type.is_symlink()
&& let Ok(target) = ctx.fs.read_link(path).await
{
let at = line.len().saturating_sub(1);
line.insert_str(at, &format!(" -> {}", target.display()));
}
}
pub(super) fn classify_suffix(metadata: &crate::fs::Metadata) -> &'static str {
match metadata.file_type {
FileType::Directory => "/",
FileType::Symlink => "@",
FileType::Fifo => "|",
FileType::File => {
if metadata.mode & 0o111 != 0 { "*" } else { "" }
}
}
}
pub(super) fn format_columns(entries: &[String], terminal_width: usize) -> String {
if entries.is_empty() {
return String::new();
}
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();
for try_cols in 2..=max_possible_cols.min(entries.len()) {
let try_rows = entries.len().div_ceil(try_cols);
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());
}
}
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() {
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; output.push_str(&format!("{:<width$}", entries[idx], width = width));
}
}
}
output.push('\n');
}
output
}
pub(super) fn format_long_entry(name: &str, metadata: &crate::fs::Metadata, human: bool) -> String {
let file_type = match metadata.file_type {
FileType::Directory => 'd',
FileType::Symlink => 'l',
FileType::Fifo => 'p',
FileType::File => '-',
};
let mode = metadata.mode;
let perms = format!(
"{}{}{}{}{}{}{}{}{}",
if mode & 0o400 != 0 { 'r' } else { '-' },
if mode & 0o200 != 0 { 'w' } else { '-' },
if mode & 0o100 != 0 { 'x' } else { '-' },
if mode & 0o040 != 0 { 'r' } else { '-' },
if mode & 0o020 != 0 { 'w' } else { '-' },
if mode & 0o010 != 0 { 'x' } else { '-' },
if mode & 0o004 != 0 { 'r' } else { '-' },
if mode & 0o002 != 0 { 'w' } else { '-' },
if mode & 0o001 != 0 { 'x' } else { '-' },
);
let size = if human {
human_readable_size(metadata.size)
} else {
format!("{:>8}", metadata.size)
};
let modified = crate::time_compat::to_chrono_utc(metadata.modified)
.format("%Y-%m-%d %H:%M")
.to_string();
format!("{}{} {} {} {}\n", file_type, perms, size, modified, name)
}
fn human_readable_size(size: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if size >= GB {
format!("{:>5.1}G", size as f64 / GB as f64)
} else if size >= MB {
format!("{:>5.1}M", size as f64 / MB as f64)
} else if size >= KB {
format!("{:>5.1}K", size as f64 / KB as f64)
} else {
format!("{:>6}", size)
}
}