use async_trait::async_trait;
use super::{Builtin, Context, MAX_FORMAT_WIDTH, read_text_file};
use crate::error::Result;
use crate::interpreter::ExecResult;
pub struct Nl;
#[derive(Clone, Copy, PartialEq)]
enum BodyType {
All,
NonEmpty,
None,
}
#[derive(Clone, Copy)]
enum NumberFormat {
LeftJustified,
RightJustified,
RightZero,
}
struct NlOptions {
body_type: BodyType,
format: NumberFormat,
separator: String,
increment: usize,
start: usize,
width: usize,
}
impl Default for NlOptions {
fn default() -> Self {
Self {
body_type: BodyType::NonEmpty,
format: NumberFormat::RightJustified,
separator: "\t".to_string(),
increment: 1,
start: 1,
width: 6,
}
}
}
fn parse_nl_args(args: &[String]) -> std::result::Result<(NlOptions, Vec<String>), String> {
let mut opts = NlOptions::default();
let mut files = Vec::new();
let mut p = super::arg_parser::ArgParser::new(args);
while !p.is_done() {
if let Some(val) = p.flag_value("-b", "nl")? {
opts.body_type = match val {
"a" => BodyType::All,
"t" => BodyType::NonEmpty,
"n" => BodyType::None,
other => return Err(format!("nl: invalid body numbering style: '{}'", other)),
};
} else if let Some(val) = p.flag_value("-n", "nl")? {
opts.format = match val {
"ln" => NumberFormat::LeftJustified,
"rn" => NumberFormat::RightJustified,
"rz" => NumberFormat::RightZero,
other => return Err(format!("nl: invalid line numbering format: '{}'", other)),
};
} else if let Some(val) = p.flag_value("-s", "nl")? {
opts.separator = val.to_string();
} else if let Some(val) = p.flag_value("-i", "nl")? {
opts.increment = val
.parse()
.map_err(|_| format!("nl: invalid line number increment: '{}'", val))?;
} else if let Some(val) = p.flag_value("-v", "nl")? {
opts.start = val
.parse()
.map_err(|_| format!("nl: invalid starting line number: '{}'", val))?;
} else if let Some(val) = p.flag_value("-w", "nl")? {
opts.width = val
.parse()
.map_err(|_| format!("nl: invalid line number field width: '{}'", val))?;
if opts.width > MAX_FORMAT_WIDTH {
return Err(format!(
"nl: line number field width {} exceeds maximum ({})",
opts.width, MAX_FORMAT_WIDTH
));
}
} else if let Some(arg) = p.positional() {
files.push(arg.to_string());
}
}
Ok((opts, files))
}
fn format_number(num: usize, format: NumberFormat, width: usize) -> String {
match format {
NumberFormat::LeftJustified => format!("{:<width$}", num, width = width),
NumberFormat::RightJustified => format!("{:>width$}", num, width = width),
NumberFormat::RightZero => format!("{:0>width$}", num, width = width),
}
}
fn number_lines(text: &str, opts: &NlOptions, line_num: &mut usize) -> String {
let mut output = String::new();
for line in text.lines() {
let should_number = match opts.body_type {
BodyType::All => true,
BodyType::NonEmpty => !line.is_empty(),
BodyType::None => false,
};
if should_number {
output.push_str(&format_number(*line_num, opts.format, opts.width));
output.push_str(&opts.separator);
output.push_str(line);
output.push('\n');
*line_num += opts.increment;
} else {
output.push_str(&" ".repeat(opts.width + 1));
output.push_str(line);
output.push('\n');
}
}
output
}
#[async_trait]
impl Builtin for Nl {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: nl [OPTION]... [FILE]...\nNumber lines of files.\n\n -b TYPE\tuse TYPE for numbering body lines (a=all, t=non-empty, n=none)\n -i NUMBER\tline number increment\n -n FORMAT\tinsert line numbers according to FORMAT (ln, rn, rz)\n -s STRING\tadd STRING after line number\n -v NUMBER\tfirst line number\n -w NUMBER\tuse NUMBER columns for line numbers\n --help\t\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("nl (bashkit) 0.1"),
) {
return Ok(r);
}
let (opts, files) = match parse_nl_args(ctx.args) {
Ok(v) => v,
Err(e) => return Ok(ExecResult::err(format!("{}\n", e), 1)),
};
let mut output = String::new();
let mut line_num = opts.start;
if files.is_empty() {
if let Some(stdin) = ctx.stdin {
output.push_str(&number_lines(stdin, &opts, &mut line_num));
}
} else {
for file in &files {
if file == "-" {
if let Some(stdin) = ctx.stdin {
output.push_str(&number_lines(stdin, &opts, &mut line_num));
}
} else {
let path = if file.starts_with('/') {
std::path::PathBuf::from(file)
} else {
ctx.cwd.join(file)
};
let text = match read_text_file(&*ctx.fs, &path, "nl").await {
Ok(t) => t,
Err(e) => return Ok(e),
};
output.push_str(&number_lines(&text, &opts, &mut line_num));
}
}
}
Ok(ExecResult::ok(output))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::fs::{FileSystem, InMemoryFs};
async fn run_nl(args: &[&str], stdin: Option<&str>) -> ExecResult {
let fs = Arc::new(InMemoryFs::new());
let mut variables = HashMap::new();
let env = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
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,
};
Nl.execute(ctx).await.unwrap()
}
async fn run_nl_with_fs(
args: &[&str],
stdin: Option<&str>,
files: &[(&str, &[u8])],
) -> ExecResult {
let fs = Arc::new(InMemoryFs::new());
for (path, content) in files {
fs.write_file(std::path::Path::new(path), content)
.await
.unwrap();
}
let mut variables = HashMap::new();
let env = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
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,
};
Nl.execute(ctx).await.unwrap()
}
#[tokio::test]
async fn test_nl_basic() {
let result = run_nl(&[], Some("hello\nworld\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " 1\thello\n 2\tworld\n");
}
#[tokio::test]
async fn test_nl_default_skips_empty() {
let result = run_nl(&[], Some("hello\n\nworld\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " 1\thello\n \n 2\tworld\n");
}
#[tokio::test]
async fn test_nl_all_lines() {
let result = run_nl(&["-b", "a"], Some("hello\n\nworld\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " 1\thello\n 2\t\n 3\tworld\n");
}
#[tokio::test]
async fn test_nl_no_numbering() {
let result = run_nl(&["-b", "n"], Some("hello\nworld\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " hello\n world\n");
}
#[tokio::test]
async fn test_nl_left_justified() {
let result = run_nl(&["-n", "ln"], Some("hello\nworld\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "1 \thello\n2 \tworld\n");
}
#[tokio::test]
async fn test_nl_right_zero() {
let result = run_nl(&["-n", "rz"], Some("hello\nworld\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "000001\thello\n000002\tworld\n");
}
#[tokio::test]
async fn test_nl_custom_separator() {
let result = run_nl(&["-s", ": "], Some("hello\nworld\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " 1: hello\n 2: world\n");
}
#[tokio::test]
async fn test_nl_custom_increment() {
let result = run_nl(&["-i", "2"], Some("a\nb\nc\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " 1\ta\n 3\tb\n 5\tc\n");
}
#[tokio::test]
async fn test_nl_custom_start() {
let result = run_nl(&["-v", "10"], Some("a\nb\nc\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " 10\ta\n 11\tb\n 12\tc\n");
}
#[tokio::test]
async fn test_nl_custom_width() {
let result = run_nl(&["-w", "3"], Some("a\nb\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " 1\ta\n 2\tb\n");
}
#[tokio::test]
async fn test_nl_rejects_excessive_width() {
let too_wide = (MAX_FORMAT_WIDTH + 1).to_string();
let result = run_nl(&["-w", &too_wide], Some("a\n")).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("exceeds maximum"));
}
#[tokio::test]
async fn test_nl_combined_options() {
let result = run_nl(
&[
"-b", "a", "-n", "rz", "-w", "4", "-s", " ", "-v", "5", "-i", "3",
],
Some("x\n\ny\n"),
)
.await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "0005 x\n0008 \n0011 y\n");
}
#[tokio::test]
async fn test_nl_empty_input() {
let result = run_nl(&[], Some("")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "");
}
#[tokio::test]
async fn test_nl_no_stdin() {
let result = run_nl(&[], None).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "");
}
#[tokio::test]
async fn test_nl_from_file() {
let result =
run_nl_with_fs(&["/test.txt"], None, &[("/test.txt", b"one\ntwo\nthree\n")]).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " 1\tone\n 2\ttwo\n 3\tthree\n");
}
#[tokio::test]
async fn test_nl_file_not_found() {
let result = run_nl(&["/nonexistent"], None).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("nl:"));
}
#[tokio::test]
async fn test_nl_invalid_body_type() {
let result = run_nl(&["-b", "x"], Some("test\n")).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("invalid body numbering style"));
}
#[tokio::test]
async fn test_nl_invalid_format() {
let result = run_nl(&["-n", "xx"], Some("test\n")).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("invalid line numbering format"));
}
#[tokio::test]
async fn test_nl_single_line() {
let result = run_nl(&[], Some("hello\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " 1\thello\n");
}
#[tokio::test]
async fn test_nl_stdin_dash() {
let result = run_nl(&["-"], Some("hello\nworld\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, " 1\thello\n 2\tworld\n");
}
#[tokio::test]
async fn test_nl_multiple_files() {
let result = run_nl_with_fs(
&["/a.txt", "/b.txt"],
None,
&[("/a.txt", b"one\ntwo\n"), ("/b.txt", b"three\nfour\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert_eq!(
result.stdout,
" 1\tone\n 2\ttwo\n 3\tthree\n 4\tfour\n"
);
}
#[tokio::test]
async fn test_nl_attached_args() {
let result = run_nl(&["-ba", "-nrz", "-w4"], Some("x\ny\n")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "0001\tx\n0002\ty\n");
}
}