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