mdcat/mdless/
highlight.rs1use std::io::{self, Write};
12use std::ops::Range;
13
14pub const MATCH_SGR: &[u8] = b"\x1b[7m";
16pub const CURRENT_MATCH_SGR: &[u8] = b"\x1b[43;30m";
18pub const HIGHLIGHT_OFF: &[u8] = b"\x1b[27;39;49m";
20
21#[derive(Debug, Clone, Default)]
23pub struct Highlight {
24 pub current: Option<Range<usize>>,
26 pub others: Vec<Range<usize>>,
28}
29
30impl Highlight {
31 pub fn is_empty(&self) -> bool {
33 self.current.is_none() && self.others.is_empty()
34 }
35}
36
37pub 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 if range.start > cursor {
53 out.write_all(&line[cursor..range.start])?;
54 }
55 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
82fn 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 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
119fn 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 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 #[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 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 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 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 let prefix = b"\x1b]8;;http://x\x1b\\text";
221 let collected = inherent_style(prefix);
222 assert!(collected.is_empty());
223 }
224}