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        let marker = match emit.role {
47            Role::Target if self.opts.target_marker => {
48                ansi::paint(self.opts.color, ansi::GREEN, ">") + " "
49            }
50            _ => String::new(),
51        };
52        let prefix = self.opts.prefix(emit.line.no);
53        let content = self.render_content(&emit.line.bytes, &emit.match_info.spans);
54        writeln!(sink, "{marker}{prefix}{content}")
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::{Line, MatchInfo};
62
63    fn opts(color: bool) -> FormatOpts {
64        FormatOpts {
65            show_line_numbers: true,
66            show_filename: false,
67            filename: None,
68            color,
69            target_marker: true,
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    #[allow(clippy::single_range_in_vec_init)]
108    fn spans_highlight_when_color_enabled() {
109        let line = Line::new(1, b"an ERROR today".to_vec());
110        let mi = MatchInfo {
111            hit: true,
112            spans: vec![3..8],
113            ..Default::default()
114        };
115        let emit = Emit {
116            line: &line,
117            role: Role::Target,
118            match_info: &mi,
119        };
120        let mut f = PlainFormatter::new(opts(true));
121        let mut buf: Vec<u8> = Vec::new();
122        f.write(&mut buf, &emit).unwrap();
123        let s = String::from_utf8(buf).unwrap();
124        assert!(s.contains("\x1b[7mERROR\x1b[0m"));
125    }
126}