Skip to main content

sel/format/
plain.rs

1//! Plain-line formatter with optional line number, filename, and highlight.
2
3use super::{FormatOpts, Formatter, ansi};
4use crate::{Emit, Role};
5use std::io::{self, Write};
6use std::ops::Range;
7
8pub struct PlainFormatter {
9    pub opts: FormatOpts,
10}
11
12impl PlainFormatter {
13    pub fn new(opts: FormatOpts) -> Self {
14        Self { opts }
15    }
16
17    fn render_content(&self, bytes: &[u8], spans: &[Range<usize>]) -> String {
18        let text = String::from_utf8_lossy(bytes);
19        if !self.opts.color || spans.is_empty() {
20            return text.to_string();
21        }
22        let mut sorted = spans.to_vec();
23        sorted.sort_by_key(|r| r.start);
24        let mut out = String::new();
25        let mut cursor = 0usize;
26        let t: &str = text.as_ref();
27        for r in sorted {
28            let s = r.start.min(t.len());
29            let e = r.end.min(t.len());
30            if s < cursor {
31                continue;
32            }
33            out.push_str(&t[cursor..s]);
34            out.push_str(ansi::INVERSE);
35            out.push_str(&t[s..e]);
36            out.push_str(ansi::RESET);
37            cursor = e;
38        }
39        out.push_str(&t[cursor..]);
40        out
41    }
42}
43
44impl Formatter for PlainFormatter {
45    fn write(&mut self, sink: &mut dyn Write, emit: &Emit) -> io::Result<()> {
46        self.opts.widen_for_line(emit.line.no);
47        let marker = match emit.role {
48            Role::Target if self.opts.target_marker => {
49                ansi::paint(self.opts.color, ansi::GREEN, ">") + " "
50            }
51            _ => String::new(),
52        };
53        let prefix = self.opts.prefix(emit.line.no);
54        let content = self.render_content(&emit.line.bytes, &emit.match_info.spans);
55        writeln!(sink, "{marker}{prefix}{content}")
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::{Line, MatchInfo};
63
64    fn opts(color: bool) -> FormatOpts {
65        FormatOpts {
66            show_line_numbers: true,
67            show_filename: false,
68            filename: None,
69            color,
70            target_marker: true,
71            line_number_width: 4,
72        }
73    }
74
75    #[test]
76    fn target_gets_marker_and_prefix() {
77        let line = Line::new(7, b"hello".to_vec());
78        let mi = MatchInfo {
79            hit: true,
80            ..Default::default()
81        };
82        let emit = Emit {
83            line: &line,
84            role: Role::Target,
85            match_info: &mi,
86        };
87        let mut f = PlainFormatter::new(opts(false));
88        let mut buf: Vec<u8> = Vec::new();
89        f.write(&mut buf, &emit).unwrap();
90        assert_eq!(String::from_utf8(buf).unwrap(), ">    7: hello\n");
91    }
92
93    #[test]
94    fn context_has_no_marker() {
95        let line = Line::new(3, b"ctx".to_vec());
96        let mi = MatchInfo::default();
97        let emit = Emit {
98            line: &line,
99            role: Role::Context,
100            match_info: &mi,
101        };
102        let mut f = PlainFormatter::new(opts(false));
103        let mut buf: Vec<u8> = Vec::new();
104        f.write(&mut buf, &emit).unwrap();
105        assert_eq!(String::from_utf8(buf).unwrap(), "   3: ctx\n");
106    }
107
108    #[test]
109    fn filename_prefix_keeps_padded_line_number() {
110        let line = Line::new(7, b"hello".to_vec());
111        let mi = MatchInfo {
112            hit: true,
113            ..Default::default()
114        };
115        let emit = Emit {
116            line: &line,
117            role: Role::Target,
118            match_info: &mi,
119        };
120        let mut opts = opts(false);
121        opts.show_filename = true;
122        opts.filename = Some("input.txt".to_string());
123        let mut f = PlainFormatter::new(opts);
124        let mut buf: Vec<u8> = Vec::new();
125        f.write(&mut buf, &emit).unwrap();
126        assert_eq!(String::from_utf8(buf).unwrap(), "> input.txt:   7: hello\n");
127    }
128
129    #[test]
130    fn width_grows_for_large_line_numbers() {
131        let line = Line::new(10000, b"wide".to_vec());
132        let mi = MatchInfo {
133            hit: true,
134            ..Default::default()
135        };
136        let emit = Emit {
137            line: &line,
138            role: Role::Target,
139            match_info: &mi,
140        };
141        let mut f = PlainFormatter::new(opts(false));
142        let mut buf: Vec<u8> = Vec::new();
143        f.write(&mut buf, &emit).unwrap();
144        assert_eq!(String::from_utf8(buf).unwrap(), "> 10000: wide\n");
145    }
146
147    #[test]
148    #[allow(clippy::single_range_in_vec_init)]
149    fn spans_highlight_when_color_enabled() {
150        let line = Line::new(1, b"an ERROR today".to_vec());
151        let mi = MatchInfo {
152            hit: true,
153            spans: vec![3..8],
154            ..Default::default()
155        };
156        let emit = Emit {
157            line: &line,
158            role: Role::Target,
159            match_info: &mi,
160        };
161        let mut f = PlainFormatter::new(opts(true));
162        let mut buf: Vec<u8> = Vec::new();
163        f.write(&mut buf, &emit).unwrap();
164        let s = String::from_utf8(buf).unwrap();
165        assert!(s.contains("\x1b[7mERROR\x1b[0m"));
166    }
167}