Skip to main content

sel/format/
fragment.rs

1//! Fragment formatter — char-context window with caret.
2//!
3//! Used when `-n` is set (positional selectors or `-e` + `-n`).
4
5use super::{FormatOpts, Formatter, ansi};
6use crate::Emit;
7use std::io::{self, Write};
8
9pub struct FragmentFormatter {
10    pub opts: FormatOpts,
11    pub char_context: usize,
12}
13
14impl FragmentFormatter {
15    pub fn new(opts: FormatOpts, char_context: usize) -> Self {
16        Self { opts, char_context }
17    }
18}
19
20impl Formatter for FragmentFormatter {
21    fn write(&mut self, sink: &mut dyn Write, emit: &Emit) -> io::Result<()> {
22        // Target column: from position matcher (`col`), else start of first regex span.
23        let target_col_1 = emit
24            .match_info
25            .col
26            .or_else(|| emit.match_info.spans.first().map(|r| r.start + 1))
27            .unwrap_or(1);
28
29        let bytes = &emit.line.bytes;
30        let col_idx = target_col_1
31            .saturating_sub(1)
32            .min(bytes.len().saturating_sub(1));
33        let start = col_idx.saturating_sub(self.char_context);
34        let end = bytes.len().min(col_idx + self.char_context + 1);
35
36        let frag = String::from_utf8_lossy(&bytes[start..end]).to_string();
37        let prefix = self.opts.prefix(emit.line.no);
38
39        // Highlight the target span within the fragment if regex spans exist.
40        let rendered = if let Some(span) = emit.match_info.spans.first() {
41            if self.opts.color {
42                let hs = span.start.saturating_sub(start).min(frag.len());
43                let he = (span.end.saturating_sub(start)).min(frag.len());
44                if hs < he {
45                    let (a, rest) = frag.split_at(hs);
46                    let (b, c) = rest.split_at(he - hs);
47                    format!("{a}{}{b}{}{c}", ansi::INVERSE, ansi::RESET)
48                } else {
49                    frag.clone()
50                }
51            } else {
52                frag.clone()
53            }
54        } else {
55            frag.clone()
56        };
57
58        writeln!(sink, "{prefix}{rendered}")?;
59
60        // Caret line, aligned under the target column within the fragment.
61        let caret_offset = col_idx - start + prefix.len();
62        let spaces = " ".repeat(caret_offset);
63        let caret = ansi::paint(self.opts.color, ansi::GREEN, "^");
64        writeln!(sink, "{spaces}{caret}")
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::{Line, MatchInfo, Role};
72
73    #[test]
74    fn renders_fragment_with_caret_under_col() {
75        let line = Line::new(1, b"abcdefghij".to_vec());
76        let mi = MatchInfo {
77            hit: true,
78            col: Some(5),
79            ..Default::default()
80        };
81        let emit = Emit {
82            line: &line,
83            role: Role::Target,
84            match_info: &mi,
85        };
86        let opts = FormatOpts {
87            show_line_numbers: false,
88            show_filename: false,
89            filename: None,
90            color: false,
91            target_marker: false,
92        };
93        let mut f = FragmentFormatter::new(opts, 2);
94        let mut buf: Vec<u8> = Vec::new();
95        f.write(&mut buf, &emit).unwrap();
96        let s = String::from_utf8(buf).unwrap();
97        // Fragment: col=5 with context=2 → bytes [2..7] = "cdefg"
98        // Caret at col 5 → offset 2 in fragment
99        assert_eq!(s, "cdefg\n  ^\n");
100    }
101}