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