1use lattice_cells::{Style, StyledSpan};
44use lattice_theme::{Color, ElementId, NamedColor};
45use lattice_theme::{ColorRef, ElementName, ElementOwner, StyleSpec, ThemeRegistry};
46
47const ANSI_NAMES: [&str; 16] = [
51 "black",
52 "red",
53 "green",
54 "yellow",
55 "blue",
56 "magenta",
57 "cyan",
58 "white",
59 "bright-black",
60 "bright-red",
61 "bright-green",
62 "bright-yellow",
63 "bright-blue",
64 "bright-magenta",
65 "bright-cyan",
66 "bright-white",
67];
68
69const ANSI_CHANNELS: [NamedColor; 16] = [
77 NamedColor::Black,
78 NamedColor::Red,
79 NamedColor::Green,
80 NamedColor::Yellow,
81 NamedColor::Blue,
82 NamedColor::Magenta,
83 NamedColor::Cyan,
84 NamedColor::Gray,
85 NamedColor::DarkGray,
86 NamedColor::LightRed,
87 NamedColor::LightGreen,
88 NamedColor::LightYellow,
89 NamedColor::LightBlue,
90 NamedColor::LightMagenta,
91 NamedColor::LightCyan,
92 NamedColor::White,
93];
94
95#[derive(Copy, Clone, Debug, PartialEq, Eq)]
100pub struct AnsiPalette {
101 pub colors: [ElementId; 16],
103 pub bold: ElementId,
105}
106
107impl AnsiPalette {
108 pub fn register(registry: &dyn ThemeRegistry) -> Self {
126 let owner = ElementOwner::Mode(std::borrow::Cow::Borrowed("compilation-mode"));
127 let mut colors = [ElementId::INVALID; 16];
128 for (slot, name) in ANSI_NAMES.iter().enumerate() {
129 colors[slot] = registry.register(
130 ElementName::from(format!("compilation.ansi.{name}")),
131 owner.clone(),
132 StyleSpec::new().fg(ColorRef::Literal(Color::Named(ANSI_CHANNELS[slot]))),
133 "ANSI colour in captured compilation output.",
134 );
135 }
136 let bold = registry.register(
137 ElementName::from_static("compilation.ansi.bold"),
138 owner,
139 StyleSpec::new().bold(),
140 "Bold (SGR 1) with no colour in captured compilation output.",
141 );
142 Self { colors, bold }
143 }
144
145 fn style(&self, state: SgrState) -> Option<Style> {
148 match (state.fg, state.bold) {
149 (Some(slot), true) if slot < 8 => Some(Style::Element(self.colors[slot as usize + 8])),
151 (Some(slot), _) => Some(Style::Element(self.colors[slot as usize])),
152 (None, true) => Some(Style::Element(self.bold)),
153 (None, false) => None,
154 }
155 }
156}
157
158#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
166pub struct SgrState {
167 fg: Option<u8>,
169 bold: bool,
170}
171
172impl SgrState {
173 fn apply(&mut self, params: &str) {
176 let mut it = params.split(';').peekable();
177 if params.is_empty() {
178 *self = Self::default();
179 return;
180 }
181 while let Some(raw) = it.next() {
182 let code: u16 = if raw.is_empty() {
184 0
185 } else {
186 match raw.parse() {
187 Ok(c) => c,
188 Err(_) => {
189 tracing::debug!(param = raw, "compilation: unparsable SGR parameter");
190 continue;
191 }
192 }
193 };
194 match code {
195 0 => *self = Self::default(),
196 1 => self.bold = true,
197 21 | 22 => self.bold = false,
202 30..=37 => self.fg = Some((code - 30) as u8),
203 90..=97 => self.fg = Some((code - 90) as u8 + 8),
204 39 => self.fg = None,
205 38 => match it.next() {
208 Some("5") => {
209 let n = it.next().and_then(|v| v.parse::<u16>().ok());
210 self.fg = match n {
211 Some(n) if n < 16 => Some(n as u8),
212 _ => None,
213 };
214 }
215 Some("2") => {
216 for _ in 0..3 {
217 it.next();
218 }
219 self.fg = None;
220 }
221 _ => {}
222 },
223 48 => match it.next() {
225 Some("5") => {
226 it.next();
227 }
228 Some("2") => {
229 for _ in 0..3 {
230 it.next();
231 }
232 }
233 _ => {}
234 },
235 _ => {}
239 }
240 }
241 }
242}
243
244#[derive(Debug, Clone, Default, PartialEq, Eq)]
247pub struct CleanLine {
248 pub text: String,
249 pub spans: Vec<StyledSpan>,
250}
251
252pub fn clean_line(raw: &str, state: &mut SgrState, palette: Option<&AnsiPalette>) -> CleanLine {
265 let mut out = CleanLine::default();
266 let bytes = raw.as_bytes();
267 let mut i = 0;
268 let mut run_start = 0usize;
270 let mut run_style = palette.and_then(|p| p.style(*state));
271
272 fn close(out: &mut CleanLine, run_start: usize, style: Option<Style>) {
274 if let Some(style) = style
275 && out.text.len() > run_start
276 {
277 out.spans.push(StyledSpan {
278 start: run_start,
279 end: out.text.len(),
280 style,
281 });
282 }
283 }
284
285 while i < bytes.len() {
286 let run_end = bytes[i..]
292 .iter()
293 .position(|&b| b == 0x1b || b == b'\r')
294 .map_or(bytes.len(), |off| i + off);
295 if run_end > i {
296 match std::str::from_utf8(&bytes[i..run_end]) {
297 Ok(s) => out.text.push_str(s),
298 Err(_) => {
299 tracing::debug!("compilation: skipping malformed UTF-8 in output");
302 }
303 }
304 i = run_end;
305 continue;
306 }
307 match bytes[i] {
308 0x1b => {
309 let (consumed, sgr) = scan_escape(&bytes[i..]);
310 if consumed == 0 {
311 break;
314 }
315 if let Some(params) = sgr {
316 let next = {
317 let mut probe = *state;
318 probe.apply(params);
319 probe
320 };
321 if next != *state {
322 close(&mut out, run_start, run_style);
323 *state = next;
324 run_start = out.text.len();
325 run_style = palette.and_then(|p| p.style(*state));
326 }
327 }
328 i += consumed;
329 }
330 b'\r' => {
331 out.text.clear();
335 out.spans.clear();
336 run_start = 0;
337 i += 1;
338 }
339 _ => i += 1,
343 }
344 }
345 close(&mut out, run_start, run_style);
346 out
347}
348
349fn scan_escape(bytes: &[u8]) -> (usize, Option<&str>) {
356 if bytes.len() < 2 {
357 return (0, None);
358 }
359 match bytes[1] {
360 b'[' => {
362 let mut j = 2;
363 while j < bytes.len() && (0x30..=0x3f).contains(&bytes[j]) {
364 j += 1;
365 }
366 let params_end = j;
367 while j < bytes.len() && (0x20..=0x2f).contains(&bytes[j]) {
368 j += 1;
369 }
370 if j >= bytes.len() {
371 return (0, None);
372 }
373 let final_byte = bytes[j];
374 let consumed = j + 1;
375 if final_byte == b'm' {
376 let params = &bytes[2..params_end];
378 if params.first().is_some_and(|b| (0x3c..=0x3f).contains(b)) {
379 return (consumed, None);
380 }
381 match std::str::from_utf8(params) {
382 Ok(s) => (consumed, Some(s)),
383 Err(_) => (consumed, None),
384 }
385 } else {
386 (consumed, None)
387 }
388 }
389 b']' => {
391 let mut j = 2;
392 while j < bytes.len() {
393 if bytes[j] == 0x07 {
394 return (j + 1, None);
395 }
396 if bytes[j] == 0x1b && bytes.get(j + 1) == Some(&b'\\') {
397 return (j + 2, None);
398 }
399 j += 1;
400 }
401 (bytes.len(), None)
404 }
405 _ => (2, None),
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 #![allow(clippy::unwrap_used, clippy::panic)]
413 use super::*;
414
415 fn palette() -> AnsiPalette {
417 let mut colors = [ElementId::INVALID; 16];
418 for (slot, c) in colors.iter_mut().enumerate() {
419 *c = ElementId(slot as u32);
420 }
421 AnsiPalette {
422 colors,
423 bold: ElementId(100),
424 }
425 }
426
427 fn clean(raw: &str) -> CleanLine {
428 let p = palette();
429 let mut st = SgrState::default();
430 clean_line(raw, &mut st, Some(&p))
431 }
432
433 fn slot(span: &StyledSpan) -> u32 {
434 match span.style {
435 Style::Element(id) => id.0,
436 other => panic!("expected an element style, got {other:?}"),
437 }
438 }
439
440 #[test]
441 fn plain_text_is_untouched_and_unspanned() {
442 let out = clean("error: something broke");
443 assert_eq!(out.text, "error: something broke");
444 assert!(out.spans.is_empty());
445 }
446
447 #[test]
448 fn sgr_is_stripped_from_the_text() {
449 let out = clean("\u{1b}[31merror\u{1b}[0m: broke");
450 assert_eq!(out.text, "error: broke");
451 }
452
453 #[test]
454 fn sgr_colour_becomes_a_span_over_the_clean_offsets() {
455 let out = clean("\u{1b}[31merror\u{1b}[0m: broke");
456 assert_eq!(out.spans.len(), 1);
457 assert_eq!((out.spans[0].start, out.spans[0].end), (0, 5));
458 assert_eq!(slot(&out.spans[0]), 1, "slot 1 is red");
459 }
460
461 #[test]
462 fn bold_plus_normal_colour_is_the_bright_slot() {
463 let out = clean("\u{1b}[1m\u{1b}[31merror\u{1b}[0m");
465 assert_eq!(out.text, "error");
466 assert_eq!(out.spans.len(), 1);
467 assert_eq!(slot(&out.spans[0]), 9, "bold red promotes to bright red");
468 }
469
470 #[test]
471 fn bold_plus_a_bright_colour_stays_put() {
472 let out = clean("\u{1b}[1;91mx\u{1b}[0m");
473 assert_eq!(slot(&out.spans[0]), 9);
474 }
475
476 #[test]
477 fn bold_without_colour_uses_the_bold_element() {
478 let out = clean("\u{1b}[1mwarning\u{1b}[0m");
479 assert_eq!(out.spans.len(), 1);
480 assert_eq!(slot(&out.spans[0]), 100);
481 }
482
483 #[test]
484 fn combined_parameters_in_one_sequence() {
485 let out = clean("\u{1b}[1;32mok\u{1b}[m done");
486 assert_eq!(out.text, "ok done");
487 assert_eq!(out.spans.len(), 1);
488 assert_eq!((out.spans[0].start, out.spans[0].end), (0, 2));
489 assert_eq!(slot(&out.spans[0]), 10, "bold green → bright green");
490 }
491
492 #[test]
493 fn bare_sgr_m_is_a_reset() {
494 let out = clean("\u{1b}[31mred\u{1b}[mplain");
495 assert_eq!(out.text, "redplain");
496 assert_eq!(out.spans.len(), 1);
497 assert_eq!((out.spans[0].start, out.spans[0].end), (0, 3));
498 }
499
500 #[test]
501 fn adjacent_runs_produce_adjacent_spans() {
502 let out = clean("\u{1b}[31ma\u{1b}[32mb\u{1b}[0mc");
503 assert_eq!(out.text, "abc");
504 assert_eq!(out.spans.len(), 2);
505 assert_eq!((out.spans[0].start, out.spans[0].end), (0, 1));
506 assert_eq!((out.spans[1].start, out.spans[1].end), (1, 2));
507 assert_eq!(slot(&out.spans[1]), 2);
508 }
509
510 #[test]
511 fn state_carries_across_lines() {
512 let p = palette();
513 let mut st = SgrState::default();
514 let first = clean_line("\u{1b}[33mopen", &mut st, Some(&p));
515 assert_eq!(first.spans.len(), 1);
516 let second = clean_line("still yellow\u{1b}[0m", &mut st, Some(&p));
518 assert_eq!(second.text, "still yellow");
519 assert_eq!(second.spans.len(), 1);
520 assert_eq!((second.spans[0].start, second.spans[0].end), (0, 12));
521 assert_eq!(slot(&second.spans[0]), 3);
522 assert_eq!(st, SgrState::default(), "the trailing reset landed");
523 }
524
525 #[test]
526 fn non_sgr_csi_is_stripped_without_affecting_style() {
527 let out = clean("\u{1b}[31ma\u{1b}[2K\u{1b}[1Ab\u{1b}[0m");
529 assert_eq!(out.text, "ab");
530 assert_eq!(out.spans.len(), 1);
531 assert_eq!((out.spans[0].start, out.spans[0].end), (0, 2));
532 }
533
534 #[test]
535 fn osc_hyperlink_payload_does_not_leak_into_the_text() {
536 let out = clean("see \u{1b}]8;;https://example.com\u{7}link\u{1b}]8;;\u{7} end");
537 assert_eq!(out.text, "see link end");
538 }
539
540 #[test]
541 fn osc_terminated_by_string_terminator() {
542 let out = clean("a\u{1b}]0;title\u{1b}\\b");
543 assert_eq!(out.text, "ab");
544 }
545
546 #[test]
547 fn private_csi_ending_in_m_is_not_treated_as_sgr() {
548 let out = clean("\u{1b}[?1049mx");
549 assert_eq!(out.text, "x");
550 assert!(out.spans.is_empty());
551 }
552
553 #[test]
554 fn carriage_return_restarts_the_line() {
555 let out = clean(" Compiling a\r Compiling b\r Compiling c");
556 assert_eq!(out.text, " Compiling c");
557 }
558
559 #[test]
560 fn carriage_return_drops_the_spans_it_discards() {
561 let out = clean("\u{1b}[31mgone\r\u{1b}[32mkept\u{1b}[0m");
562 assert_eq!(out.text, "kept");
563 assert_eq!(out.spans.len(), 1);
564 assert_eq!((out.spans[0].start, out.spans[0].end), (0, 4));
565 assert_eq!(slot(&out.spans[0]), 2);
566 }
567
568 #[test]
569 fn extended_256_colour_below_16_maps_to_its_slot() {
570 let out = clean("\u{1b}[38;5;4mx\u{1b}[0m");
571 assert_eq!(out.text, "x");
572 assert_eq!(slot(&out.spans[0]), 4);
573 }
574
575 #[test]
576 fn extended_256_colour_above_16_is_dropped_without_desyncing() {
577 let out = clean("\u{1b}[38;5;208;1mx\u{1b}[0m");
579 assert_eq!(out.text, "x");
580 assert_eq!(out.spans.len(), 1);
581 assert_eq!(slot(&out.spans[0]), 100, "colour dropped, bold survives");
582 }
583
584 #[test]
585 fn truecolour_is_dropped_without_desyncing() {
586 let out = clean("\u{1b}[38;2;255;128;0;1mx\u{1b}[0m");
587 assert_eq!(out.spans.len(), 1);
588 assert_eq!(slot(&out.spans[0]), 100);
589 }
590
591 #[test]
592 fn background_parameters_are_consumed_not_painted() {
593 let out = clean("\u{1b}[48;5;22;31mx\u{1b}[0m");
594 assert_eq!(out.text, "x");
595 assert_eq!(out.spans.len(), 1);
596 assert_eq!(slot(&out.spans[0]), 1, "the fg after the bg still applies");
597 }
598
599 #[test]
600 fn unparsable_parameter_is_skipped_and_the_rest_still_applies() {
601 let out = clean("\u{1b}[1:2;31mx\u{1b}[0m");
605 assert_eq!(out.text, "x");
606 assert_eq!(out.spans.len(), 1);
607 assert_eq!(slot(&out.spans[0]), 1);
608 }
609
610 #[test]
611 fn a_csi_with_a_non_m_final_byte_ends_at_that_byte() {
612 let out = clean("\u{1b}[3zkept");
617 assert_eq!(out.text, "kept");
618 assert!(out.spans.is_empty());
619 }
620
621 #[test]
622 fn trailing_lone_escape_is_dropped() {
623 let out = clean("tail\u{1b}");
624 assert_eq!(out.text, "tail");
625 }
626
627 #[test]
628 fn incomplete_csi_at_end_of_line_is_dropped() {
629 let out = clean("tail\u{1b}[31");
630 assert_eq!(out.text, "tail");
631 }
632
633 #[test]
634 fn multibyte_text_keeps_byte_offsets_aligned() {
635 let out = clean("\u{1b}[31mré\u{1b}[0m→");
636 assert_eq!(out.text, "ré→");
637 assert_eq!(out.spans.len(), 1);
638 assert_eq!((out.spans[0].start, out.spans[0].end), (0, 3));
640 }
641
642 #[test]
643 fn no_palette_strips_but_does_not_span() {
644 let mut st = SgrState::default();
645 let out = clean_line("\u{1b}[31merror\u{1b}[0m", &mut st, None);
646 assert_eq!(out.text, "error");
647 assert!(
648 out.spans.is_empty(),
649 "stripping is unconditional; colouring needs a palette"
650 );
651 }
652
653 #[test]
654 fn reset_with_no_open_run_emits_nothing() {
655 let out = clean("\u{1b}[0mplain\u{1b}[0m");
656 assert_eq!(out.text, "plain");
657 assert!(out.spans.is_empty());
658 }
659
660 #[test]
661 fn empty_line_is_empty() {
662 let out = clean("");
663 assert_eq!(out.text, "");
664 assert!(out.spans.is_empty());
665 }
666
667 #[test]
668 fn a_line_that_is_only_escapes_is_empty_text() {
669 let out = clean("\u{1b}[31m\u{1b}[0m");
670 assert_eq!(out.text, "");
671 assert!(out.spans.is_empty());
672 }
673}