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
84 changes: 62 additions & 22 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Box<dyn crate::sink::Sink>> {
pub fn make_sink(&self) -> crate::Result<Box<dyn Sink>> {
match self.output.as_deref() {
None | Some("-") => Ok(Box::new(StdoutSink::new())),
Some(path) => {
Expand All @@ -251,30 +251,69 @@ 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<String>,
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.
pub fn into_app_for_file(
&self,
path: &std::path::Path,
show_filename: bool,
) -> crate::Result<App<Seek>> {
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<dyn Sink>,
) -> crate::Result<App<Seek>> {
let source = FileSource::open(path)?;
let filename = if show_filename {
Some(source.label().to_string())
} 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));
Expand Down Expand Up @@ -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<App<NonSeek>> {
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<dyn Sink>,
) -> crate::Result<App<NonSeek>> {
if let Some(raw) = self.get_selector()
&& raw.contains(':')
{
Expand All @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions src/format/fragment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,18 +85,19 @@ 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<u8> = Vec::new();
f.write(&mut buf, &emit).unwrap();
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");
}
}
15 changes: 13 additions & 2 deletions src/format/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
45 changes: 43 additions & 2 deletions src/format/plain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, ">") + " "
Expand All @@ -67,6 +68,7 @@ mod tests {
filename: None,
color,
target_marker: true,
line_number_width: 4,
}
}

Expand All @@ -85,7 +87,7 @@ mod tests {
let mut f = PlainFormatter::new(opts(false));
let mut buf: Vec<u8> = 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]
Expand All @@ -100,7 +102,46 @@ mod tests {
let mut f = PlainFormatter::new(opts(false));
let mut buf: Vec<u8> = 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<u8> = 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<u8> = Vec::new();
f.write(&mut buf, &emit).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "> 10000: wide\n");
}

#[test]
Expand Down
14 changes: 9 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
23 changes: 20 additions & 3 deletions src/matcher/lines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
12 changes: 7 additions & 5 deletions src/matcher/regex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading