1use smallvec::SmallVec;
17
18use crate::{context::Theme, frame::Style};
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub struct SyntaxRun {
26 pub start: usize,
28 pub end: usize,
30 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
58pub 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
73pub 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 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 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 #[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}