Skip to main content

omp_tui/
syntax.rs

1//! XML-ish syntax highlighting for markup shown in an editor.
2//!
3//! One line at a time, so a caller can highlight only the rows it paints.
4//! Comments are the sole construct that spans lines, so the scan threads an
5//! `in_comment` flag: pass [`xml_comment_state`] over everything above the
6//! first visible row, then chain the flag returned by each
7//! [`highlight_xml`] call into the next.
8//!
9//! ```
10//! # use omp_tui::{Theme, syntax::highlight_xml};
11//! let (runs, in_comment) = highlight_xml("<col bg=\"red\">hi</col>", &Theme::default(), false);
12//! assert!(!in_comment);
13//! assert!(runs.len() > 1, "the line is split into styled runs");
14//! ```
15
16use smallvec::SmallVec;
17
18use crate::{context::Theme, frame::Style};
19
20/// One styled byte range of a highlighted line.
21///
22/// Ranges are non-empty, ordered, and cover the line without gaps, so a
23/// painter can walk them and slice `text` directly.
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub struct SyntaxRun {
26	/// Byte offset where the run starts.
27	pub start: usize,
28	/// Byte offset one past the run's last byte.
29	pub end:   usize,
30	/// Style the run paints with.
31	pub style: Style,
32}
33
34fn push_syntax_run(runs: &mut SmallVec<SyntaxRun, 16>, start: usize, end: usize, style: Style) {
35	if start == end {
36		return;
37	}
38	if let Some(last) = runs.last_mut()
39		&& last.end == start
40		&& last.style == style
41	{
42		last.end = end;
43	} else {
44		runs.push(SyntaxRun { start, end, style });
45	}
46}
47
48fn take_while(text: &str, mut at: usize, keep: impl Fn(char) -> bool) -> usize {
49	while let Some(ch) = text[at..].chars().next() {
50		if !keep(ch) {
51			break;
52		}
53		at += ch.len_utf8();
54	}
55	at
56}
57
58/// Threads comment state across text the caller is not highlighting,
59/// such as the rows scrolled above an editor's viewport.
60pub fn xml_comment_state(text: &str, mut in_comment: bool) -> bool {
61	let mut at = 0;
62	while at < text.len() {
63		let marker = if in_comment { "-->" } else { "<!--" };
64		let Some(found) = text[at..].find(marker) else {
65			break;
66		};
67		at += found + marker.len();
68		in_comment = !in_comment;
69	}
70	in_comment
71}
72
73/// Splits one line into styled runs, returning whether the line ends
74/// inside an unterminated `<!-- ... -->` comment.
75///
76/// Malformed markup is expected: an unterminated tag or a bare `<` styles
77/// what it can and leaves the rest as plain text.
78pub fn highlight_xml(
79	text: &str,
80	theme: &Theme,
81	mut in_comment: bool,
82) -> (SmallVec<SyntaxRun, 16>, bool) {
83	let plain = Style::new().fg(theme.fg);
84	let punctuation = Style::new().fg(theme.muted).dim();
85	let element = Style::new().fg(theme.accent);
86	let attribute = Style::new().fg(theme.info);
87	let value = Style::new().fg(theme.warn);
88	let mut runs = SmallVec::new();
89	let mut at = 0;
90
91	while at < text.len() {
92		if in_comment {
93			if let Some(close) = text[at..].find("-->") {
94				let end = at + close + 3;
95				push_syntax_run(&mut runs, at, end, punctuation);
96				at = end;
97				in_comment = false;
98				continue;
99			}
100			push_syntax_run(&mut runs, at, text.len(), punctuation);
101			return (runs, true);
102		}
103
104		if text[at..].starts_with("<!--") {
105			in_comment = true;
106			continue;
107		}
108		if !text[at..].starts_with('<') {
109			let end = text[at..].find('<').map_or(text.len(), |next| at + next);
110			push_syntax_run(&mut runs, at, end, plain);
111			at = end;
112			continue;
113		}
114
115		let punctuation_end = at + if text[at..].starts_with("</") { 2 } else { 1 };
116		push_syntax_run(&mut runs, at, punctuation_end, punctuation);
117		at = punctuation_end;
118
119		let whitespace_end = take_while(text, at, char::is_whitespace);
120		push_syntax_run(&mut runs, at, whitespace_end, plain);
121		at = whitespace_end;
122		let name_end =
123			take_while(text, at, |ch| !ch.is_whitespace() && !matches!(ch, '/' | '>' | '=' | '<'));
124		push_syntax_run(&mut runs, at, name_end, element);
125		if name_end == at {
126			continue;
127		}
128		at = name_end;
129
130		while at < text.len() {
131			let whitespace_end = take_while(text, at, char::is_whitespace);
132			push_syntax_run(&mut runs, at, whitespace_end, plain);
133			at = whitespace_end;
134			// a tag left open mid-typing (`<box `) ends the line here
135			if at == text.len() {
136				break;
137			}
138			if text[at..].starts_with("/>") {
139				push_syntax_run(&mut runs, at, at + 2, punctuation);
140				at += 2;
141				break;
142			}
143			if text[at..].starts_with('>') {
144				push_syntax_run(&mut runs, at, at + 1, punctuation);
145				at += 1;
146				break;
147			}
148			if text[at..].starts_with('=') {
149				push_syntax_run(&mut runs, at, at + 1, punctuation);
150				at += 1;
151				continue;
152			}
153			if text[at..].starts_with('<') {
154				break;
155			}
156
157			let attribute_end =
158				take_while(text, at, |ch| !ch.is_whitespace() && !matches!(ch, '=' | '/' | '>' | '<'));
159			push_syntax_run(&mut runs, at, attribute_end, attribute);
160			if attribute_end == at {
161				// a lone delimiter (`"`, `'`, …) with no name in front of it
162				let Some(ch) = text[at..].chars().next() else {
163					break;
164				};
165				push_syntax_run(&mut runs, at, at + ch.len_utf8(), plain);
166				at += ch.len_utf8();
167				continue;
168			}
169			at = attribute_end;
170
171			let whitespace_end = take_while(text, at, char::is_whitespace);
172			push_syntax_run(&mut runs, at, whitespace_end, plain);
173			at = whitespace_end;
174			if !text[at..].starts_with('=') {
175				continue;
176			}
177			push_syntax_run(&mut runs, at, at + 1, punctuation);
178			at += 1;
179			let whitespace_end = take_while(text, at, char::is_whitespace);
180			push_syntax_run(&mut runs, at, whitespace_end, plain);
181			at = whitespace_end;
182
183			let Some(first) = text[at..].chars().next() else {
184				break;
185			};
186			if matches!(first, '"' | '\'') {
187				let after_quote = at + first.len_utf8();
188				let end = text[after_quote..]
189					.find(first)
190					.map_or(text.len(), |close| after_quote + close + first.len_utf8());
191				push_syntax_run(&mut runs, at, end, value);
192				at = end;
193			} else {
194				let end =
195					take_while(text, at, |ch| !ch.is_whitespace() && !matches!(ch, '/' | '>' | '<'));
196				push_syntax_run(&mut runs, at, end, value);
197				at = end;
198			}
199		}
200	}
201
202	(runs, in_comment)
203}
204
205#[cfg(test)]
206mod syntax_tests {
207	use super::*;
208	use crate::context::Theme;
209
210	fn style_for<'a>(line: &'a str, needle: &str, runs: &'a [SyntaxRun]) -> Style {
211		let start = line.find(needle).expect("token present");
212		let end = start + needle.len();
213		runs
214			.iter()
215			.find(|run| run.start <= start && run.end >= end)
216			.expect("token has a style")
217			.style
218	}
219
220	#[test]
221	fn xml_editor_highlight_assigns_semantic_style_runs() {
222		let theme = Theme::default();
223		let line = r#"<col bg="red" grow>hi</col>"#;
224		let (runs, in_comment) = highlight_xml(line, &theme, false);
225
226		assert!(!in_comment);
227		assert_eq!(style_for(line, "<", &runs), Style::new().fg(theme.muted).dim());
228		assert_eq!(style_for(line, "col", &runs), Style::new().fg(theme.accent));
229		assert_eq!(style_for(line, "bg", &runs), Style::new().fg(theme.info));
230		assert_eq!(style_for(line, r#""red""#, &runs), Style::new().fg(theme.warn));
231		assert_eq!(style_for(line, "hi", &runs), Style::new().fg(theme.fg));
232	}
233
234	#[test]
235	fn xml_editor_highlight_tolerates_partial_tags() {
236		let theme = Theme::default();
237		for line in ["<foo attr=", "<"] {
238			let (runs, in_comment) = highlight_xml(line, &theme, false);
239			assert!(!in_comment);
240			assert_eq!(runs.first().map(|run| run.start), Some(0));
241			assert_eq!(runs.last().map(|run| run.end), Some(line.len()));
242			assert!(runs.windows(2).all(|pair| pair[0].end == pair[1].start));
243		}
244
245		let line = "λ <foo attr=";
246		let (runs, _) = highlight_xml(line, &theme, false);
247		assert_eq!(style_for(line, "λ ", &runs), Style::new().fg(theme.fg));
248		assert!(
249			runs
250				.iter()
251				.all(|run| line.is_char_boundary(run.start) && line.is_char_boundary(run.end))
252		);
253	}
254
255	#[test]
256	fn xml_editor_comments_continue_across_lines() {
257		let theme = Theme::default();
258		let (first, in_comment) = highlight_xml("a <!-- open", &theme, false);
259		assert!(in_comment);
260		assert_eq!(style_for("a <!-- open", "<!-- open", &first), Style::new().fg(theme.muted).dim());
261		let (second, in_comment) = highlight_xml("close --> tail", &theme, in_comment);
262		assert!(!in_comment);
263		assert_eq!(
264			style_for("close --> tail", "close -->", &second),
265			Style::new().fg(theme.muted).dim()
266		);
267		assert_eq!(style_for("close --> tail", " tail", &second), Style::new().fg(theme.fg));
268	}
269	/// Every keystroke is a partial line, so the scanner has to survive
270	/// every prefix of a realistic one — including `<box ` and `<a t="`.
271	#[test]
272	fn xml_highlighting_survives_every_prefix_of_a_line() {
273		let theme = Theme::default();
274		let line = "<box bg=black><row gap='1'><col bg=\"red\" grow>hi<!-- c --></col></row>";
275		for end in 0..=line.len() {
276			if !line.is_char_boundary(end) {
277				continue;
278			}
279			let head = &line[..end];
280			let (runs, _) = highlight_xml(head, &theme, false);
281			let covered: usize = runs.iter().map(|run| run.end - run.start).sum();
282			assert_eq!(covered, head.len(), "runs must tile the line for {head:?}");
283			assert!(
284				runs.windows(2).all(|pair| pair[0].end == pair[1].start),
285				"runs must be gapless and ordered for {head:?}"
286			);
287		}
288	}
289}