From 1acc11c94692692bd9df6e90d65ef5db6be204c7 Mon Sep 17 00:00:00 2001 From: Inky Quill Date: Sun, 10 May 2026 23:36:55 +0300 Subject: [PATCH] fix: align line numbers and reuse output sink --- src/cli.rs | 84 ++++++++++++++++------ src/format/fragment.rs | 6 +- src/format/mod.rs | 15 +++- src/format/plain.rs | 45 +++++++++++- src/main.rs | 14 ++-- src/matcher/lines.rs | 23 +++++- src/matcher/regex.rs | 12 ++-- src/pipeline.rs | 139 +++++++++++++++++++++++++++++++++--- tests/line_number_format.rs | 85 ++++++++++++++++++++++ tests/multi_file.rs | 4 +- tests/output_file.rs | 36 +++++++++- tests/positions.rs | 18 ++--- tests/regex.rs | 10 +-- tests/selectors.rs | 120 +++++++++++++++---------------- tests/stdin.rs | 4 +- 15 files changed, 482 insertions(+), 133 deletions(-) create mode 100644 tests/line_number_format.rs diff --git a/src/cli.rs b/src/cli.rs index b104c10..af5d570 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -224,15 +224,15 @@ impl Cli { use crate::app::{NonSeek, Seek, Stage1}; use crate::context::{LineContext, NoContext}; -use crate::format::{FormatOpts, FragmentFormatter, PlainFormatter}; +use crate::format::{FormatOpts, FragmentFormatter, PlainFormatter, digits}; use crate::matcher::{AllMatcher, LineMatcher, PositionMatcher, RegexMatcher}; -use crate::sink::{FileSink, StdoutSink}; +use crate::sink::{FileSink, Sink, StdoutSink}; use crate::source::{FileSource, Source, StdinSource}; -use crate::{App, Selector}; +use crate::{App, LineSpec, Selector}; impl Cli { /// Construct the output sink based on `--output`/`--force` flags. - fn make_sink(&self) -> crate::Result> { + pub fn make_sink(&self) -> crate::Result> { match self.output.as_deref() { None | Some("-") => Ok(Box::new(StdoutSink::new())), Some(path) => { @@ -251,6 +251,43 @@ impl Cli { } } + fn line_number_width(&self) -> usize { + let Some(raw) = self.get_selector() else { + return 4; + }; + let Ok(selector) = Selector::parse(&raw).map(|sel| sel.normalize()) else { + return 4; + }; + let max_line = match selector { + Selector::All => None, + Selector::LineNumbers(specs) => specs + .into_iter() + .map(|spec| match spec { + LineSpec::Single(n) | LineSpec::Range(_, n) => n, + }) + .max(), + Selector::Positions(positions) => positions.into_iter().map(|pos| pos.line).max(), + }; + max_line.map_or(4, |line| 4.max(digits(line as u64))) + } + + fn format_opts( + &self, + show_filename: bool, + filename: Option, + color: bool, + ) -> FormatOpts { + FormatOpts { + show_line_numbers: !self.no_line_numbers, + show_filename, + filename, + color, + // Target marker (`> `) only appears in context-aware output. + target_marker: matches!(self.context, Some(n) if n > 0), + line_number_width: self.line_number_width(), + } + } + /// Build a ready-to-run `App` for a single file. /// /// Callers iterate over `get_files()` and build one `App` per file. @@ -258,6 +295,16 @@ impl Cli { &self, path: &std::path::Path, show_filename: bool, + ) -> crate::Result> { + let sink = self.make_sink()?; + self.into_app_for_file_with_sink(path, show_filename, sink) + } + + pub fn into_app_for_file_with_sink( + &self, + path: &std::path::Path, + show_filename: bool, + sink: Box, ) -> crate::Result> { let source = FileSource::open(path)?; let filename = if show_filename { @@ -265,16 +312,8 @@ impl Cli { } else { None }; - let sink = self.make_sink()?; let color = self.resolve_color(sink.is_terminal()); - let opts = FormatOpts { - show_line_numbers: !self.no_line_numbers, - show_filename, - filename, - color, - // Target marker (`> `) only appears in context-aware output. - target_marker: matches!(self.context, Some(n) if n > 0), - }; + let opts = self.format_opts(show_filename, filename, color); // Matcher + seek stage. let stage2 = Stage1::with_seekable_source(Box::new(source)); @@ -316,6 +355,15 @@ impl Cli { /// Returns `PositionalWithStdin` when paired with a positional selector /// (line:column), which requires a seekable source. pub fn into_app_for_stdin(&self, show_filename: bool) -> crate::Result> { + let sink = self.make_sink()?; + self.into_app_for_stdin_with_sink(show_filename, sink) + } + + pub fn into_app_for_stdin_with_sink( + &self, + show_filename: bool, + sink: Box, + ) -> crate::Result> { if let Some(raw) = self.get_selector() && raw.contains(':') { @@ -327,16 +375,8 @@ impl Cli { } else { None }; - let sink = self.make_sink()?; let color = self.resolve_color(sink.is_terminal()); - let opts = FormatOpts { - show_line_numbers: !self.no_line_numbers, - show_filename, - filename, - color, - // Target marker (`> `) only appears in context-aware output. - target_marker: matches!(self.context, Some(n) if n > 0), - }; + let opts = self.format_opts(show_filename, filename, color); let stage2 = Stage1::with_nonseekable_source(Box::new(source)); let stage3 = if let Some(pat) = &self.regex { diff --git a/src/format/fragment.rs b/src/format/fragment.rs index 86baa8a..0a43872 100644 --- a/src/format/fragment.rs +++ b/src/format/fragment.rs @@ -19,6 +19,7 @@ impl FragmentFormatter { impl Formatter for FragmentFormatter { fn write(&mut self, sink: &mut dyn Write, emit: &Emit) -> io::Result<()> { + self.opts.widen_for_line(emit.line.no); // Target column: from position matcher (`col`), else start of first regex span. let target_col_1 = emit .match_info @@ -84,11 +85,12 @@ mod tests { match_info: &mi, }; let opts = FormatOpts { - show_line_numbers: false, + show_line_numbers: true, show_filename: false, filename: None, color: false, target_marker: false, + line_number_width: 4, }; let mut f = FragmentFormatter::new(opts, 2); let mut buf: Vec = Vec::new(); @@ -96,6 +98,6 @@ mod tests { let s = String::from_utf8(buf).unwrap(); // Fragment: col=5 with context=2 → bytes [2..7] = "cdefg" // Caret at col 5 → offset 2 in fragment - assert_eq!(s, "cdefg\n ^\n"); + assert_eq!(s, " 1: cdefg\n ^\n"); } } diff --git a/src/format/mod.rs b/src/format/mod.rs index cdb8837..f14de7c 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -25,9 +25,14 @@ pub struct FormatOpts { /// Prepend `"> "` (colorized green) before target lines. /// Set `true` only when mixing target and context lines (i.e. `-c N`). pub target_marker: bool, + pub line_number_width: usize, } impl FormatOpts { + pub fn widen_for_line(&mut self, line_no: u64) { + self.line_number_width = self.line_number_width.max(digits(line_no)); + } + pub fn prefix(&self, line_no: u64) -> String { let mut p = String::new(); if self.show_filename @@ -37,9 +42,15 @@ impl FormatOpts { p.push(':'); } if self.show_line_numbers { - p.push_str(&line_no.to_string()); - p.push(':'); + p.push_str(&format!( + "{line_no:>width$}: ", + width = self.line_number_width + )); } p } } + +pub fn digits(n: u64) -> usize { + n.to_string().len() +} diff --git a/src/format/plain.rs b/src/format/plain.rs index 2820dd2..c1b93dd 100644 --- a/src/format/plain.rs +++ b/src/format/plain.rs @@ -43,6 +43,7 @@ impl PlainFormatter { impl Formatter for PlainFormatter { fn write(&mut self, sink: &mut dyn Write, emit: &Emit) -> io::Result<()> { + self.opts.widen_for_line(emit.line.no); let marker = match emit.role { Role::Target if self.opts.target_marker => { ansi::paint(self.opts.color, ansi::GREEN, ">") + " " @@ -67,6 +68,7 @@ mod tests { filename: None, color, target_marker: true, + line_number_width: 4, } } @@ -85,7 +87,7 @@ mod tests { let mut f = PlainFormatter::new(opts(false)); let mut buf: Vec = Vec::new(); f.write(&mut buf, &emit).unwrap(); - assert_eq!(String::from_utf8(buf).unwrap(), "> 7:hello\n"); + assert_eq!(String::from_utf8(buf).unwrap(), "> 7: hello\n"); } #[test] @@ -100,7 +102,46 @@ mod tests { let mut f = PlainFormatter::new(opts(false)); let mut buf: Vec = Vec::new(); f.write(&mut buf, &emit).unwrap(); - assert_eq!(String::from_utf8(buf).unwrap(), "3:ctx\n"); + assert_eq!(String::from_utf8(buf).unwrap(), " 3: ctx\n"); + } + + #[test] + fn filename_prefix_keeps_padded_line_number() { + let line = Line::new(7, b"hello".to_vec()); + let mi = MatchInfo { + hit: true, + ..Default::default() + }; + let emit = Emit { + line: &line, + role: Role::Target, + match_info: &mi, + }; + let mut opts = opts(false); + opts.show_filename = true; + opts.filename = Some("input.txt".to_string()); + let mut f = PlainFormatter::new(opts); + let mut buf: Vec = Vec::new(); + f.write(&mut buf, &emit).unwrap(); + assert_eq!(String::from_utf8(buf).unwrap(), "> input.txt: 7: hello\n"); + } + + #[test] + fn width_grows_for_large_line_numbers() { + let line = Line::new(10000, b"wide".to_vec()); + let mi = MatchInfo { + hit: true, + ..Default::default() + }; + let emit = Emit { + line: &line, + role: Role::Target, + match_info: &mi, + }; + let mut f = PlainFormatter::new(opts(false)); + let mut buf: Vec = Vec::new(); + f.write(&mut buf, &emit).unwrap(); + assert_eq!(String::from_utf8(buf).unwrap(), "> 10000: wide\n"); } #[test] diff --git a/src/main.rs b/src/main.rs index 224881d..ad567ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,12 +19,16 @@ fn main() { fn run(cli: Cli) -> sel::Result<()> { let files = cli.get_files(); let show_filename = cli.with_filename || files.len() > 1; + let mut sink = cli.make_sink()?; for path in &files { - if path.as_os_str() == "-" { - sel::pipeline::run(cli.into_app_for_stdin(show_filename)?)?; + let app_sink = sink; + sink = if path.as_os_str() == "-" { + let app = cli.into_app_for_stdin_with_sink(show_filename, app_sink)?; + sel::pipeline::run_unfinished(app)? } else { - sel::pipeline::run(cli.into_app_for_file(path, show_filename)?)?; - } + let app = cli.into_app_for_file_with_sink(path, show_filename, app_sink)?; + sel::pipeline::run_unfinished(app)? + }; } - Ok(()) + sel::pipeline::finish_sink(sink) } diff --git a/src/matcher/lines.rs b/src/matcher/lines.rs index 4a559ec..e44255a 100644 --- a/src/matcher/lines.rs +++ b/src/matcher/lines.rs @@ -8,6 +8,8 @@ use crate::{Line, MatchInfo}; pub struct LineMatcher { /// Sorted, non-overlapping, inclusive `(start, end)` ranges (1-indexed). ranges: Vec<(u64, u64)>, + next_range: usize, + last_line_no: u64, } impl LineMatcher { @@ -28,16 +30,31 @@ impl LineMatcher { LineSpec::Range(a, b) => (*a as u64, *b as u64), }) .collect(); - Self { ranges } + Self { + ranges, + next_range: 0, + last_line_no: 0, + } } } impl Matcher for LineMatcher { fn match_line(&mut self, line: &Line) -> MatchInfo { + if line.no < self.last_line_no { + self.next_range = 0; + } + self.last_line_no = line.no; + while self + .ranges + .get(self.next_range) + .is_some_and(|&(_, end)| line.no > end) + { + self.next_range += 1; + } let hit = self .ranges - .iter() - .any(|&(a, b)| line.no >= a && line.no <= b); + .get(self.next_range) + .is_some_and(|&(start, end)| line.no >= start && line.no <= end); MatchInfo { hit, ..MatchInfo::default() diff --git a/src/matcher/regex.rs b/src/matcher/regex.rs index 4cf49e2..9cf5694 100644 --- a/src/matcher/regex.rs +++ b/src/matcher/regex.rs @@ -20,16 +20,18 @@ impl RegexMatcher { impl Matcher for RegexMatcher { fn match_line(&mut self, line: &Line) -> MatchInfo { - let is_match = self.regex.is_match(&line.bytes); - let hit = is_match ^ self.invert; - // Inverted hits have nothing to highlight. - let spans = if hit && !self.invert { + let spans = if self.invert { + Vec::new() + } else { self.regex .find_iter(&line.bytes) .map(|m| m.start()..m.end()) .collect() + }; + let hit = if self.invert { + !self.regex.is_match(&line.bytes) } else { - Vec::new() + !spans.is_empty() }; MatchInfo { hit, diff --git a/src/pipeline.rs b/src/pipeline.rs index ad37c51..3431fb9 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -4,36 +4,153 @@ use crate::Emit; use crate::Result; use crate::app::{App, SourceKind}; use crate::context::EmitOwned; +use crate::sink::Sink; +use std::io; pub fn run(mut app: App) -> Result<()> { + process(&mut app)?; + // Destructure and finalize the sink. + let App { sink, .. } = app; + finish_sink(sink) +} + +pub fn run_unfinished(mut app: App) -> Result> { + process(&mut app)?; + Ok(app.sink) +} + +fn process(app: &mut App) -> Result<()> { // Read lines, run matcher, feed expander, write via formatter. + let mut write_error: Option = None; while let Some(line) = app.source.next_line()? { let info = app.matcher.match_line(&line); let formatter = &mut app.formatter; let sink = &mut app.sink; app.expander.push(line, info, &mut |emit: EmitOwned| { + if write_error.is_some() { + return; + } let borrowed = Emit { line: &emit.line, role: emit.role, match_info: &emit.match_info, }; - let _ = formatter.write(sink.as_mut(), &borrowed); + if let Err(err) = formatter.write(sink.as_mut(), &borrowed) { + write_error = Some(err); + } }); + if write_error.is_some() { + break; + } } let formatter = &mut app.formatter; let sink = &mut app.sink; - app.expander.drain(&mut |emit: EmitOwned| { - let borrowed = Emit { - line: &emit.line, - role: emit.role, - match_info: &emit.match_info, - }; - let _ = formatter.write(sink.as_mut(), &borrowed); - }); - // Destructure and finalize the sink. - let App { sink, .. } = app; + if write_error.is_none() { + app.expander.drain(&mut |emit: EmitOwned| { + if write_error.is_some() { + return; + } + let borrowed = Emit { + line: &emit.line, + role: emit.role, + match_info: &emit.match_info, + }; + if let Err(err) = formatter.write(sink.as_mut(), &borrowed) { + write_error = Some(err); + } + }); + } + if let Some(source) = write_error { + return Err(crate::SelError::Io { + path: "".to_string(), + source, + }); + } + Ok(()) +} + +pub fn finish_sink(sink: Box) -> Result<()> { sink.finish().map_err(|source| crate::SelError::Io { path: "".to_string(), source, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::Stage1; + use crate::context::NoContext; + use crate::format::{FormatOpts, PlainFormatter}; + use crate::matcher::AllMatcher; + use crate::sink::Sink; + use crate::source::Source; + use crate::{Line, SelError}; + use std::io::{self, Write}; + + struct OneLineSource(Option); + + impl Source for OneLineSource { + fn next_line(&mut self) -> crate::Result> { + Ok(self.0.take()) + } + + fn label(&self) -> &str { + "one-line" + } + + fn is_seekable(&self) -> bool { + false + } + } + + struct FailingSink; + + impl Write for FailingSink { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "sink failed")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + impl Sink for FailingSink { + fn is_terminal(&self) -> bool { + false + } + + fn finish(self: Box) -> io::Result<()> { + panic!("finish should not run after a write error"); + } + } + + #[test] + fn formatter_write_errors_are_returned() { + let opts = FormatOpts { + show_line_numbers: true, + show_filename: false, + filename: None, + color: false, + target_marker: false, + line_number_width: 4, + }; + let app = Stage1::with_nonseekable_source(Box::new(OneLineSource(Some(Line::new( + 1, + b"line".to_vec(), + ))))) + .with_matcher(Box::new(AllMatcher)) + .with_expander(Box::new(NoContext)) + .with_formatter(Box::new(PlainFormatter::new(opts))) + .with_sink(Box::new(FailingSink)); + + let err = run(app).unwrap_err(); + match err { + SelError::Io { source, .. } => { + assert_eq!(source.kind(), io::ErrorKind::BrokenPipe); + } + other => panic!("expected io error, got {other:?}"), + } + } +} diff --git a/tests/line_number_format.rs b/tests/line_number_format.rs new file mode 100644 index 0000000..c6d7ece --- /dev/null +++ b/tests/line_number_format.rs @@ -0,0 +1,85 @@ +use std::process::Command; +use tempfile::tempdir; + +fn sel_bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_sel")) +} + +fn run_sel(args: &[&str]) -> String { + let output = sel_bin().args(args).output().unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() +} + +#[test] +fn normal_output_uses_minimum_width_four_with_separator_space() { + let dir = tempdir().unwrap(); + let input = dir.path().join("input.txt"); + std::fs::write(&input, "alpha\nbeta\n").unwrap(); + + let output = run_sel(&["2", input.to_str().unwrap()]); + + assert_eq!(output, " 2: beta\n"); +} + +#[test] +fn context_output_keeps_marker_outside_padded_number_field() { + let dir = tempdir().unwrap(); + let input = dir.path().join("input.txt"); + std::fs::write(&input, "alpha\nbeta\ngamma\n").unwrap(); + + let output = run_sel(&["-c", "1", "2", input.to_str().unwrap()]); + + assert_eq!(output, " 1: alpha\n> 2: beta\n 3: gamma\n"); +} + +#[test] +fn regex_output_uses_padded_prefix() { + let dir = tempdir().unwrap(); + let input = dir.path().join("input.txt"); + std::fs::write(&input, "INFO ok\nERROR bad\n").unwrap(); + + let output = run_sel(&["-e", "ERROR", input.to_str().unwrap()]); + + assert_eq!(output, " 2: ERROR bad\n"); +} + +#[test] +fn fragment_output_aligns_caret_after_padded_prefix() { + let dir = tempdir().unwrap(); + let input = dir.path().join("input.txt"); + std::fs::write(&input, "abcdef\n").unwrap(); + + let output = run_sel(&["-n", "2", "1:5", input.to_str().unwrap()]); + + assert_eq!(output, " 1: cdef\n ^\n"); +} + +#[test] +fn selector_estimates_width_from_largest_selected_line() { + let dir = tempdir().unwrap(); + let input = dir.path().join("input.txt"); + let mut contents = "\n".repeat(9999); + contents.push_str("target\n"); + std::fs::write(&input, contents).unwrap(); + + let output = run_sel(&["10000", input.to_str().unwrap()]); + + assert_eq!(output, "10000: target\n"); +} + +#[test] +fn whole_file_output_grows_width_while_streaming() { + let dir = tempdir().unwrap(); + let input = dir.path().join("input.txt"); + let contents = (1..=10000) + .map(|line| format!("line{line}\n")) + .collect::(); + std::fs::write(&input, contents).unwrap(); + + let output = run_sel(&[input.to_str().unwrap()]); + + assert!(output.contains(" 1: line1\n")); + assert!(output.contains("9999: line9999\n")); + assert!(output.contains("10000: line10000\n")); +} diff --git a/tests/multi_file.rs b/tests/multi_file.rs index cbaf170..16ef4e7 100644 --- a/tests/multi_file.rs +++ b/tests/multi_file.rs @@ -111,8 +111,8 @@ fn test_no_filename_prefix_single_file() { // Single file should not show filename by default let name = temp_file_name(&file1); - // Output should be "1:content1", not "filename:1:content1" - assert!(output.contains("1:content1")); + // Output should include the line prefix, not a filename prefix. + assert!(output.contains("1: content1")); // Unless filename is part of content if !output.contains(&name) { // Good - no filename prefix diff --git a/tests/output_file.rs b/tests/output_file.rs index 1324902..eb1c500 100644 --- a/tests/output_file.rs +++ b/tests/output_file.rs @@ -17,7 +17,7 @@ fn writes_to_output_file() { .status() .unwrap(); assert!(status.success()); - assert_eq!(std::fs::read_to_string(&output).unwrap(), "2:b\n"); + assert_eq!(std::fs::read_to_string(&output).unwrap(), " 2: b\n"); } #[test] @@ -59,7 +59,7 @@ fn force_overwrites() { .status() .unwrap(); assert!(status.success()); - assert_eq!(std::fs::read_to_string(&output).unwrap(), "1:new\n"); + assert_eq!(std::fs::read_to_string(&output).unwrap(), " 1: new\n"); } #[test] @@ -73,5 +73,35 @@ fn dash_output_is_stdout() { .output() .unwrap(); assert!(out.status.success()); - assert_eq!(String::from_utf8(out.stdout).unwrap(), "1:stdout\n"); + assert_eq!(String::from_utf8(out.stdout).unwrap(), " 1: stdout\n"); +} + +#[test] +fn output_file_collects_multiple_inputs_without_recreating_sink() { + let dir = tempdir().unwrap(); + let input1 = dir.path().join("one.txt"); + let input2 = dir.path().join("two.txt"); + let output = dir.path().join("out.txt"); + std::fs::write(&input1, "a1\na2\n").unwrap(); + std::fs::write(&input2, "b1\nb2\n").unwrap(); + + let status = sel_bin() + .args([ + "2", + input1.to_str().unwrap(), + input2.to_str().unwrap(), + "-o", + output.to_str().unwrap(), + ]) + .status() + .unwrap(); + assert!(status.success()); + assert_eq!( + std::fs::read_to_string(&output).unwrap(), + format!( + "{}: 2: a2\n{}: 2: b2\n", + input1.display(), + input2.display() + ) + ); } diff --git a/tests/positions.rs b/tests/positions.rs index 1837ef1..72bc716 100644 --- a/tests/positions.rs +++ b/tests/positions.rs @@ -46,7 +46,7 @@ fn test_simple_position() { let output = run_sel(&["2:5", file.path().to_str().unwrap()]); // Without -n, should output the full line - assert!(output.contains("2:line2")); + assert!(output.contains("2: line2")); } #[test] @@ -54,7 +54,7 @@ fn test_position_first_column() { let file = create_test_file(&["hello world", "foo bar"]); let output = run_sel(&["1:1", file.path().to_str().unwrap()]); - assert!(output.contains("1:hello world")); + assert!(output.contains("1: hello world")); } #[test] @@ -62,7 +62,7 @@ fn test_position_last_column() { let file = create_test_file(&["abc"]); let output = run_sel(&["1:3", file.path().to_str().unwrap()]); - assert!(output.contains("1:abc")); + assert!(output.contains("1: abc")); } #[test] @@ -71,7 +71,7 @@ fn test_position_beyond_line_length() { let output = run_sel(&["1:100", file.path().to_str().unwrap()]); // Should still output the line - assert!(output.contains("1:short")); + assert!(output.contains("1: short")); } #[test] @@ -79,7 +79,7 @@ fn test_position_at_line_start() { let file = create_test_file(&[" indented line", "normal"]); let output = run_sel(&["1:3", file.path().to_str().unwrap()]); - assert!(output.contains("1: indented line")); + assert!(output.contains("1: indented line")); } #[test] @@ -87,9 +87,9 @@ fn test_multiple_positions() { let file = create_test_file(&["line one", "line two", "line three"]); let output = run_sel(&["1:1,3:1", file.path().to_str().unwrap()]); - assert!(output.contains("1:line one")); - assert!(output.contains("3:line three")); - assert!(!output.contains("2:line two")); + assert!(output.contains("1: line one")); + assert!(output.contains("3: line three")); + assert!(!output.contains("2: line two")); } #[test] @@ -98,7 +98,7 @@ fn test_multiple_positions_same_line() { let output = run_sel(&["1:1,1:5,1:10", file.path().to_str().unwrap()]); // Should output the line (without -n, full lines are shown) - assert!(output.contains("1:a very long line here")); + assert!(output.contains("1: a very long line here")); } #[test] diff --git a/tests/regex.rs b/tests/regex.rs index 6ec5e69..e0362bc 100644 --- a/tests/regex.rs +++ b/tests/regex.rs @@ -216,7 +216,7 @@ fn test_regex_with_no_line_numbers() { let output = run_sel(&["-e", "ERROR", "-l", file.path().to_str().unwrap()]); assert!(output.contains("ERROR: bad")); - assert!(!output.contains("1:ERROR")); + assert!(!output.contains("1: ERROR")); assert!(!output.contains("INFO: good")); } @@ -268,10 +268,10 @@ fn test_regex_repetition_operators() { assert!(output.contains("aaa")); assert!(output.contains("aaaa")); // Note: "aaa" contains "aa" as substring, so we check line prefixes - assert!(output.contains("3:aaa") || output.contains("4:aaaa")); - assert!(!output.contains("1:a")); - assert!(!output.contains("2:aa")); - assert!(!output.contains("5:b")); + assert!(output.contains("3: aaa") || output.contains("4: aaaa")); + assert!(!output.contains("1: a")); + assert!(!output.contains("2: aa")); + assert!(!output.contains("5: b")); } #[test] diff --git a/tests/selectors.rs b/tests/selectors.rs index 9b145a7..4ae45fa 100644 --- a/tests/selectors.rs +++ b/tests/selectors.rs @@ -53,9 +53,9 @@ fn test_single_line() { let file = create_test_file(&["line1", "line2", "line3"]); let output = run_sel(&["2", file.path().to_str().unwrap()]); - assert!(output.contains("2:line2")); - assert!(!output.contains("1:line1")); - assert!(!output.contains("3:line3")); + assert!(output.contains("2: line2")); + assert!(!output.contains("1: line1")); + assert!(!output.contains("3: line3")); } #[test] @@ -63,7 +63,7 @@ fn test_first_line() { let file = create_test_file(&["alpha", "beta", "gamma"]); let output = run_sel(&["1", file.path().to_str().unwrap()]); - assert!(output.contains("1:alpha")); + assert!(output.contains("1: alpha")); assert!(!output.contains("beta")); } @@ -72,7 +72,7 @@ fn test_last_line() { let file = create_test_file(&["first", "second", "third"]); let output = run_sel(&["3", file.path().to_str().unwrap()]); - assert!(output.contains("3:third")); + assert!(output.contains("3: third")); assert!(!output.contains("first")); } @@ -84,11 +84,11 @@ fn test_simple_range() { let lines: Vec<&str> = output.lines().collect(); assert!(lines.len() >= 3); - assert!(output.contains("2:l2")); - assert!(output.contains("3:l3")); - assert!(output.contains("4:l4")); - assert!(!output.contains("1:l1")); - assert!(!output.contains("5:l5")); + assert!(output.contains("2: l2")); + assert!(output.contains("3: l3")); + assert!(output.contains("4: l4")); + assert!(!output.contains("1: l1")); + assert!(!output.contains("5: l5")); } #[test] @@ -96,11 +96,11 @@ fn test_full_range() { let file = create_test_file(&["a", "b", "c", "d", "e"]); let output = run_sel(&["1-5", file.path().to_str().unwrap()]); - assert!(output.contains("1:a")); - assert!(output.contains("2:b")); - assert!(output.contains("3:c")); - assert!(output.contains("4:d")); - assert!(output.contains("5:e")); + assert!(output.contains("1: a")); + assert!(output.contains("2: b")); + assert!(output.contains("3: c")); + assert!(output.contains("4: d")); + assert!(output.contains("5: e")); } #[test] @@ -108,11 +108,11 @@ fn test_multiple_single_lines() { let file = create_test_file(&["line1", "line2", "line3", "line4", "line5"]); let output = run_sel(&["1,3,5", file.path().to_str().unwrap()]); - assert!(output.contains("1:line1")); - assert!(output.contains("3:line3")); - assert!(output.contains("5:line5")); - assert!(!output.contains("2:line2")); - assert!(!output.contains("4:line4")); + assert!(output.contains("1: line1")); + assert!(output.contains("3: line3")); + assert!(output.contains("5: line5")); + assert!(!output.contains("2: line2")); + assert!(!output.contains("4: line4")); } #[test] @@ -120,15 +120,15 @@ fn test_mixed_selector() { let file = create_test_file(&["l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8", "l9", "l10"]); let output = run_sel(&["1,3-5,8", file.path().to_str().unwrap()]); - assert!(output.contains("1:l1")); - assert!(output.contains("3:l3")); - assert!(output.contains("4:l4")); - assert!(output.contains("5:l5")); - assert!(output.contains("8:l8")); + assert!(output.contains("1: l1")); + assert!(output.contains("3: l3")); + assert!(output.contains("4: l4")); + assert!(output.contains("5: l5")); + assert!(output.contains("8: l8")); - assert!(!output.contains("2:l2")); - assert!(!output.contains("6:l6")); - assert!(!output.contains("7:l7")); + assert!(!output.contains("2: l2")); + assert!(!output.contains("6: l6")); + assert!(!output.contains("7: l7")); } #[test] @@ -136,13 +136,13 @@ fn test_multiple_ranges() { let file = create_test_file(&["l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8"]); let output = run_sel(&["1-2,5-6", file.path().to_str().unwrap()]); - assert!(output.contains("1:l1")); - assert!(output.contains("2:l2")); - assert!(output.contains("5:l5")); - assert!(output.contains("6:l6")); + assert!(output.contains("1: l1")); + assert!(output.contains("2: l2")); + assert!(output.contains("5: l5")); + assert!(output.contains("6: l6")); - assert!(!output.contains("3:l3")); - assert!(!output.contains("4:l4")); + assert!(!output.contains("3: l3")); + assert!(!output.contains("4: l4")); } #[test] @@ -153,13 +153,13 @@ fn test_complex_comma_selector() { ]); let output = run_sel(&["1,3-5,10,12-15", file.path().to_str().unwrap()]); - assert!(output.contains("1:l1")); - assert!(output.contains("3:l3")); - assert!(output.contains("4:l4")); - assert!(output.contains("5:l5")); - assert!(output.contains("10:l10")); - assert!(output.contains("12:l12")); - assert!(output.contains("15:l15")); + assert!(output.contains("1: l1")); + assert!(output.contains("3: l3")); + assert!(output.contains("4: l4")); + assert!(output.contains("5: l5")); + assert!(output.contains("10: l10")); + assert!(output.contains("12: l12")); + assert!(output.contains("15: l15")); } #[test] @@ -167,10 +167,10 @@ fn test_no_line_numbers_flag() { let file = create_test_file(&["line1", "line2", "line3"]); let output = run_sel(&["-l", "2", file.path().to_str().unwrap()]); - // Without -l, output would be "2:line2" + // Without -l, output would include a line-number prefix. // With -l, output should be just "line2" assert!(output.contains("line2")); - assert!(!output.contains("2:line2")); + assert!(!output.contains("2: line2")); assert!(!output.contains(":line2")); } @@ -191,9 +191,9 @@ fn test_all_lines_no_selector() { let output = run_sel(&[file.path().to_str().unwrap()]); // When no selector is provided, all lines are output - assert!(output.contains("1:first")); - assert!(output.contains("2:second")); - assert!(output.contains("3:third")); + assert!(output.contains("1: first")); + assert!(output.contains("2: second")); + assert!(output.contains("3: third")); } #[test] @@ -201,7 +201,7 @@ fn test_single_line_range() { let file = create_test_file(&["x", "y", "z"]); let output = run_sel(&["2-2", file.path().to_str().unwrap()]); - assert!(output.contains("2:y")); + assert!(output.contains("2: y")); assert!(!output.contains("x")); assert!(!output.contains("z")); } @@ -229,8 +229,8 @@ fn test_duplicate_line_numbers() { let output = run_sel(&["2,2,2", file.path().to_str().unwrap()]); // Should only show line 2 once - assert!(output.contains("2:b")); - let count = output.matches("2:b").count(); + assert!(output.contains("2: b")); + let count = output.matches("2: b").count(); assert_eq!(count, 1); } @@ -240,16 +240,16 @@ fn test_overlapping_ranges() { let output = run_sel(&["1-4,3-6", file.path().to_str().unwrap()]); // All lines should appear, but without duplicates - assert!(output.contains("1:l1")); - assert!(output.contains("2:l2")); - assert!(output.contains("3:l3")); - assert!(output.contains("4:l4")); - assert!(output.contains("5:l5")); - assert!(output.contains("6:l6")); + assert!(output.contains("1: l1")); + assert!(output.contains("2: l2")); + assert!(output.contains("3: l3")); + assert!(output.contains("4: l4")); + assert!(output.contains("5: l5")); + assert!(output.contains("6: l6")); // Each line should appear only once for i in 1..=6 { - assert_eq!(output.matches(&format!("{}:l{}", i, i)).count(), 1); + assert_eq!(output.matches(&format!("{}: l{}", i, i)).count(), 1); } } @@ -259,10 +259,10 @@ fn test_long_file() { let file = create_test_file(&lines.iter().map(|s| s.as_str()).collect::>()); let output = run_sel(&["50-55", file.path().to_str().unwrap()]); - assert!(output.contains("50:line50")); - assert!(output.contains("55:line55")); - assert!(!output.contains("49:line49")); - assert!(!output.contains("56:line56")); + assert!(output.contains("50: line50")); + assert!(output.contains("55: line55")); + assert!(!output.contains("49: line49")); + assert!(!output.contains("56: line56")); } #[test] diff --git a/tests/stdin.rs b/tests/stdin.rs index 07e1843..6812178 100644 --- a/tests/stdin.rs +++ b/tests/stdin.rs @@ -31,14 +31,14 @@ fn run_with_stdin(args: &[&str], stdin: &str) -> (String, String, i32) { fn no_args_reads_stdin_as_cat_n() { let (stdout, _, code) = run_with_stdin(&[], "alpha\nbeta\ngamma\n"); assert_eq!(code, 0); - assert_eq!(stdout, "1:alpha\n2:beta\n3:gamma\n"); + assert_eq!(stdout, " 1: alpha\n 2: beta\n 3: gamma\n"); } #[test] fn dash_is_stdin() { let (stdout, _, code) = run_with_stdin(&["2", "-"], "one\ntwo\nthree\n"); assert_eq!(code, 0); - assert_eq!(stdout, "2:two\n"); + assert_eq!(stdout, " 2: two\n"); } #[test]