Skip to main content

mdcat/mdless/
highlight.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5//! SGR-aware match highlighting.
6//!
7//! [`write_line`] wraps each match in SGR and re-emits the style
8//! state active at the match start. Current match is yellow; others
9//! use reverse video.
10
11use std::io::{self, Write};
12use std::ops::Range;
13
14/// SGR for any non-current match. ANSI reverse video — terminal-portable.
15pub const MATCH_SGR: &[u8] = b"\x1b[7m";
16/// SGR for the currently-selected match (yellow background, black text).
17pub const CURRENT_MATCH_SGR: &[u8] = b"\x1b[43;30m";
18/// SGR reset after the highlight.
19pub const HIGHLIGHT_OFF: &[u8] = b"\x1b[27;39;49m";
20
21/// Ranges within a styled line that must be highlighted.
22#[derive(Debug, Clone, Default)]
23pub struct Highlight {
24    /// Byte range within the line to emphasise as the current match.
25    pub current: Option<Range<usize>>,
26    /// Byte ranges within the line for all other matches, sorted ascending.
27    pub others: Vec<Range<usize>>,
28}
29
30impl Highlight {
31    /// True when no ranges are set.
32    pub fn is_empty(&self) -> bool {
33        self.current.is_none() && self.others.is_empty()
34    }
35}
36
37/// Emit `line` to `out`, wrapping each highlighted byte range in SGR.
38///
39/// Ranges refer to byte offsets within `line`. Overlapping ranges are
40/// merged on-the-fly (current wins over other). Escape sequences in
41/// `line` are preserved verbatim; the highlight only splices in extra
42/// SGR around the match bytes and restores the SGR state afterward.
43pub fn write_line<W: Write>(out: &mut W, line: &[u8], hl: &Highlight) -> io::Result<()> {
44    if hl.is_empty() {
45        return out.write_all(line);
46    }
47
48    let events = merge_events(line.len(), hl);
49    let mut cursor = 0;
50    for HighlightRun { range, is_current } in events {
51        // Bytes before this run render verbatim.
52        if range.start > cursor {
53            out.write_all(&line[cursor..range.start])?;
54        }
55        // Emit the match with wrapping SGR, then restore the inherent line
56        // style by re-emitting whatever SGR was active at match start.
57        let style_at_start = inherent_style(&line[..range.start]);
58        out.write_all(if is_current {
59            CURRENT_MATCH_SGR
60        } else {
61            MATCH_SGR
62        })?;
63        out.write_all(&line[range.clone()])?;
64        out.write_all(HIGHLIGHT_OFF)?;
65        if !style_at_start.is_empty() {
66            out.write_all(&style_at_start)?;
67        }
68        cursor = range.end;
69    }
70    if cursor < line.len() {
71        out.write_all(&line[cursor..])?;
72    }
73    Ok(())
74}
75
76#[derive(Debug)]
77struct HighlightRun {
78    range: Range<usize>,
79    is_current: bool,
80}
81
82/// Merge current + other highlight ranges into a sorted, non-overlapping
83/// sequence. Current wins when ranges overlap.
84fn merge_events(len: usize, hl: &Highlight) -> Vec<HighlightRun> {
85    let mut events: Vec<HighlightRun> = Vec::with_capacity(hl.others.len() + 1);
86    for r in &hl.others {
87        if r.start < len {
88            events.push(HighlightRun {
89                range: r.start..r.end.min(len),
90                is_current: false,
91            });
92        }
93    }
94    if let Some(r) = hl.current.as_ref() {
95        if r.start < len {
96            events.push(HighlightRun {
97                range: r.start..r.end.min(len),
98                is_current: true,
99            });
100        }
101    }
102    events.sort_by_key(|e| e.range.start);
103
104    // Collapse overlaps: if two runs touch, the later `is_current` wins
105    // (we re-sort by start, not by kind, so "later" is positional).
106    let mut merged: Vec<HighlightRun> = Vec::with_capacity(events.len());
107    for run in events {
108        match merged.last_mut() {
109            Some(prev) if prev.range.end > run.range.start => {
110                prev.range.end = prev.range.end.max(run.range.end);
111                prev.is_current |= run.is_current;
112            }
113            _ => merged.push(run),
114        }
115    }
116    merged
117}
118
119/// Concatenation of every SGR sequence that appears in `prefix`.
120///
121/// When the highlight clears its inverse / bg / fg, we need to restore
122/// whatever the line was already saying. A lossless rebuild would parse
123/// and re-apply parameters; we take a simpler approach that works for
124/// the SGR sequences `push_tty` emits: replay every SGR byte-for-byte in
125/// order. Any redundancy the terminal will collapse on its own.
126fn inherent_style(prefix: &[u8]) -> Vec<u8> {
127    let mut out = Vec::new();
128    let mut i = 0;
129    while i < prefix.len() {
130        if prefix[i] == 0x1b && prefix.get(i + 1) == Some(&b'[') {
131            let mut j = i + 2;
132            while prefix.get(j).is_some_and(|&b| !(0x40..=0x7e).contains(&b)) {
133                j += 1;
134            }
135            if let Some(&final_byte) = prefix.get(j) {
136                if final_byte == b'm' {
137                    out.extend_from_slice(&prefix[i..=j]);
138                }
139                i = j + 1;
140                continue;
141            }
142            break;
143        }
144        i += 1;
145    }
146    out
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn hl(current: Option<Range<usize>>, others: &[Range<usize>]) -> Highlight {
154        Highlight {
155            current,
156            others: others.to_vec(),
157        }
158    }
159
160    #[test]
161    fn no_highlight_passes_through_verbatim() {
162        let mut out = Vec::new();
163        write_line(&mut out, b"hello world\n", &Highlight::default()).unwrap();
164        assert_eq!(out, b"hello world\n");
165    }
166
167    #[test]
168    fn highlights_single_match() {
169        let mut out = Vec::new();
170        write_line(&mut out, b"hello world", &hl(Some(6..11), &[])).unwrap();
171        // Expect: "hello " + CURRENT SGR + "world" + OFF.
172        assert_eq!(out, b"hello \x1b[43;30mworld\x1b[27;39;49m".as_slice());
173    }
174
175    #[test]
176    fn highlights_multiple_other_matches_in_order() {
177        let mut out = Vec::new();
178        write_line(&mut out, b"foo bar baz\n", &hl(None, &[0..3, 8..11])).unwrap();
179        assert_eq!(
180            out,
181            b"\x1b[7mfoo\x1b[27;39;49m bar \x1b[7mbaz\x1b[27;39;49m\n".as_slice()
182        );
183    }
184
185    #[test]
186    fn current_wins_on_overlap() {
187        let mut out = Vec::new();
188        // Lint wants `[2; 8]` for an array of eights; we want a single
189        // overlapping range, which is exactly what `[2..8]` encodes.
190        #[allow(clippy::single_range_in_vec_init)]
191        let others = [2..8];
192        write_line(&mut out, b"aaaaabbbcc", &hl(Some(0..5), &others)).unwrap();
193        // Merged range is 0..8, flagged current.
194        assert_eq!(out, b"\x1b[43;30maaaaabbb\x1b[27;39;49mcc".as_slice());
195    }
196
197    #[test]
198    fn restores_inherent_sgr_after_match() {
199        // Line: "foo <SGR-bold>bar<SGR-reset> baz", highlight "bar".
200        // "foo " = 0..4, "\x1b[1m" = 4..8, "bar" = 8..11.
201        let line = b"foo \x1b[1mbar\x1b[0m baz";
202        let mut out = Vec::new();
203        write_line(&mut out, line, &hl(Some(8..11), &[])).unwrap();
204        let s = String::from_utf8_lossy(&out);
205        // After highlight clear, bold SGR should be re-emitted so any
206        // subsequent text keeps the line's inherent style.
207        assert!(s.contains("\x1b[43;30mbar\x1b[27;39;49m\x1b[1m"));
208    }
209
210    #[test]
211    fn inherent_style_collects_every_sgr_in_prefix() {
212        let prefix = b"\x1b[1m\x1b[34mhello ";
213        let collected = inherent_style(prefix);
214        assert_eq!(collected, b"\x1b[1m\x1b[34m".as_slice());
215    }
216
217    #[test]
218    fn inherent_style_ignores_non_sgr_escapes() {
219        // OSC 8 link start should be ignored (not an `m`-terminated CSI).
220        let prefix = b"\x1b]8;;http://x\x1b\\text";
221        let collected = inherent_style(prefix);
222        assert!(collected.is_empty());
223    }
224}