use async_trait::async_trait;
use super::{Builtin, Context, read_text_file, resolve_path};
use crate::error::Result;
use crate::interpreter::ExecResult;
pub struct Expand;
#[async_trait]
impl Builtin for Expand {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: expand [OPTION]... [FILE]...\nConvert tabs to spaces.\n\n -t N\tuse N characters as tab size (default 8)\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("expand (bashkit) 0.1"),
) {
return Ok(r);
}
let mut tab_stops: Vec<usize> = vec![8];
let mut files: Vec<&str> = Vec::new();
let mut i = 0;
while i < ctx.args.len() {
match ctx.args[i].as_str() {
"-t" => {
i += 1;
if i >= ctx.args.len() {
return Ok(ExecResult::err(
"expand: option requires an argument -- 't'\n".to_string(),
1,
));
}
tab_stops = parse_tab_stops(&ctx.args[i]);
}
s if s.starts_with("-t") && s.len() > 2 => {
tab_stops = parse_tab_stops(&s[2..]);
}
_ => files.push(&ctx.args[i]),
}
i += 1;
}
let input = if files.is_empty() {
ctx.stdin.unwrap_or("").to_string()
} else {
let mut buf = String::new();
for file in &files {
let path = resolve_path(ctx.cwd, file);
match read_text_file(ctx.fs.as_ref(), &path, "expand").await {
Ok(text) => buf.push_str(&text),
Err(_) => {
return Ok(ExecResult::err(
format!("expand: {}: No such file or directory\n", file),
1,
));
}
}
}
buf
};
let mut output = String::new();
for line in input.split('\n') {
let mut col = 0;
for ch in line.chars() {
if ch == '\t' {
let next_stop = next_tab_stop(col, &tab_stops);
let spaces = next_stop - col;
for _ in 0..spaces {
output.push(' ');
}
col = next_stop;
} else {
output.push(ch);
col += 1;
}
}
output.push('\n');
}
if !input.ends_with('\n') && output.ends_with('\n') {
output.pop();
}
Ok(ExecResult::ok(output))
}
}
pub struct Unexpand;
#[async_trait]
impl Builtin for Unexpand {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: unexpand [OPTION]... [FILE]...\nConvert spaces to tabs.\n\n -a, --all\tconvert all blanks, instead of just initial blanks\n -t N\t\tuse N characters as tab size (default 8)\n --help\t\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("unexpand (bashkit) 0.1"),
) {
return Ok(r);
}
let mut tab_stops: Vec<usize> = vec![8];
let mut all = false;
let mut files: Vec<&str> = Vec::new();
let mut i = 0;
while i < ctx.args.len() {
match ctx.args[i].as_str() {
"-a" | "--all" => all = true,
"-t" => {
i += 1;
if i >= ctx.args.len() {
return Ok(ExecResult::err(
"unexpand: option requires an argument -- 't'\n".to_string(),
1,
));
}
let parsed_tab_stops = parse_tab_stops(&ctx.args[i]);
if parsed_tab_stops.is_empty() {
return Ok(ExecResult::err(
format!("unexpand: invalid tab size: '{}'\n", ctx.args[i]),
1,
));
}
tab_stops = parsed_tab_stops;
all = true; }
_ => files.push(&ctx.args[i]),
}
i += 1;
}
let input = if files.is_empty() {
ctx.stdin.unwrap_or("").to_string()
} else {
let mut buf = String::new();
for file in &files {
let path = resolve_path(ctx.cwd, file);
match read_text_file(ctx.fs.as_ref(), &path, "unexpand").await {
Ok(text) => buf.push_str(&text),
Err(_) => {
return Ok(ExecResult::err(
format!("unexpand: {}: No such file or directory\n", file),
1,
));
}
}
}
buf
};
let tab_size = tab_stops[0];
let mut output = String::new();
for line in input.split('\n') {
if all {
let mut col = 0;
let mut space_count = 0;
let mut result = String::new();
for ch in line.chars() {
if ch == ' ' {
space_count += 1;
col += 1;
if col % tab_size == 0 && space_count > 1 {
result.push('\t');
space_count = 0;
}
} else {
for _ in 0..space_count {
result.push(' ');
}
space_count = 0;
result.push(ch);
col += 1;
}
}
for _ in 0..space_count {
result.push(' ');
}
output.push_str(&result);
} else {
let mut col = 0;
let chars: Vec<char> = line.chars().collect();
let mut pos = 0;
let mut result = String::new();
while pos < chars.len() && chars[pos] == ' ' {
col += 1;
pos += 1;
if col % tab_size == 0 {
result.push('\t');
}
}
let remainder = col % tab_size;
if remainder > 0 && pos < chars.len() {
let tabs_written = col / tab_size;
let spaces_accounted = tabs_written * tab_size;
for _ in 0..(col - spaces_accounted) {
}
}
for ch in &chars[pos..] {
result.push(*ch);
}
output.push_str(&result);
}
output.push('\n');
}
if !input.ends_with('\n') && output.ends_with('\n') {
output.pop();
}
Ok(ExecResult::ok(output))
}
}
fn parse_tab_stops(s: &str) -> Vec<usize> {
s.split(',')
.filter_map(|p| p.trim().parse::<usize>().ok())
.filter(|&n| n > 0)
.collect::<Vec<_>>()
.into_iter()
.collect()
}
fn next_tab_stop(col: usize, tab_stops: &[usize]) -> usize {
if tab_stops.len() == 1 {
let ts = tab_stops[0];
((col / ts) + 1) * ts
} else {
for &ts in tab_stops {
if ts > col {
return ts;
}
}
col + 1
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::InMemoryFs;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
async fn run_expand(args: &[&str], stdin: Option<&str>) -> ExecResult {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let env = HashMap::new();
let mut variables = HashMap::new();
let mut cwd = PathBuf::from("/");
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn crate::fs::FileSystem>;
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs,
stdin,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
Expand.execute(ctx).await.expect("expand failed")
}
async fn run_unexpand(args: &[&str], stdin: Option<&str>) -> ExecResult {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let env = HashMap::new();
let mut variables = HashMap::new();
let mut cwd = PathBuf::from("/");
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn crate::fs::FileSystem>;
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs,
stdin,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
Unexpand.execute(ctx).await.expect("unexpand failed")
}
#[tokio::test]
async fn test_expand_default_tab() {
let result = run_expand(&[], Some("\thello")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " hello");
}
#[tokio::test]
async fn test_expand_custom_tab() {
let result = run_expand(&["-t", "4"], Some("\thello")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " hello");
}
#[tokio::test]
async fn test_expand_no_tabs() {
let result = run_expand(&[], Some("no tabs here")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "no tabs here");
}
#[tokio::test]
async fn test_expand_multiple_tabs() {
let result = run_expand(&["-t", "4"], Some("a\tb\tc")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "a b c");
}
#[tokio::test]
async fn test_unexpand_leading_spaces() {
let result = run_unexpand(&[], Some(" hello")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "\thello");
}
#[tokio::test]
async fn test_unexpand_all() {
let result = run_unexpand(&["-a"], Some("hello world")).await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello"));
}
#[tokio::test]
async fn test_expand_empty() {
let result = run_expand(&[], Some("")).await;
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_unexpand_invalid_zero_tab_stop() {
let result = run_unexpand(&["-t", "0"], Some(" hello")).await;
assert_eq!(result.exit_code, 1);
assert_eq!(result.stderr, "unexpand: invalid tab size: '0'\n");
}
#[tokio::test]
async fn test_unexpand_invalid_non_numeric_tab_stop() {
let result = run_unexpand(&["-t", "foo"], Some(" hello")).await;
assert_eq!(result.exit_code, 1);
assert_eq!(result.stderr, "unexpand: invalid tab size: 'foo'\n");
}
}