1use crate::{HtmlFormat, Span};
16use arborium_theme::{
17 Theme, capture_to_slot, slot_to_highlight_index, tag_for_capture, tag_to_name,
18};
19use std::collections::HashMap;
20use std::io::{self, Write};
21
22#[derive(Debug, Clone)]
27pub struct ThemedSpan {
28 pub start: u32,
30 pub end: u32,
32 pub theme_index: usize,
34}
35
36pub fn spans_to_themed(spans: Vec<Span>) -> Vec<ThemedSpan> {
41 if spans.is_empty() {
42 return Vec::new();
43 }
44
45 let mut spans = spans;
47 spans.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end)));
48
49 let mut deduped: HashMap<(u32, u32), Span> = HashMap::new();
52 for span in spans {
53 let key = (span.start, span.end);
54 let new_has_slot = slot_to_highlight_index(capture_to_slot(&span.capture)).is_some();
55
56 if let Some(existing) = deduped.get(&key) {
57 let existing_has_slot =
58 slot_to_highlight_index(capture_to_slot(&existing.capture)).is_some();
59 let should_replace = match (new_has_slot, existing_has_slot) {
62 (true, false) => true, (false, true) => false, _ => span.pattern_index >= existing.pattern_index, };
66 if should_replace {
67 deduped.insert(key, span);
68 }
69 } else {
70 deduped.insert(key, span);
71 }
72 }
73
74 let mut themed: Vec<ThemedSpan> = deduped
76 .into_values()
77 .filter_map(|span| {
78 let slot = capture_to_slot(&span.capture);
79 let theme_index = slot_to_highlight_index(slot)?;
80 Some(ThemedSpan {
81 start: span.start,
82 end: span.end,
83 theme_index,
84 })
85 })
86 .collect();
87
88 themed.sort_by_key(|s| s.start);
90
91 themed
92}
93
94#[cfg(feature = "unicode-width")]
95use unicode_width::UnicodeWidthChar;
96
97fn make_html_tags(short_tag: &str, format: &HtmlFormat) -> (String, String) {
101 match format {
102 HtmlFormat::CustomElements => {
103 let open = format!("<a-{short_tag}>");
104 let close = format!("</a-{short_tag}>");
105 (open, close)
106 }
107 HtmlFormat::CustomElementsWithPrefix(prefix) => {
108 let open = format!("<{prefix}-{short_tag}>");
109 let close = format!("</{prefix}-{short_tag}>");
110 (open, close)
111 }
112 HtmlFormat::ClassNames => {
113 if let Some(name) = tag_to_name(short_tag) {
114 let open = format!("<span class=\"{name}\">");
115 let close = "</span>".to_string();
116 (open, close)
117 } else {
118 ("<span>".to_string(), "</span>".to_string())
120 }
121 }
122 HtmlFormat::ClassNamesWithPrefix(prefix) => {
123 if let Some(name) = tag_to_name(short_tag) {
124 let open = format!("<span class=\"{prefix}-{name}\">");
125 let close = "</span>".to_string();
126 (open, close)
127 } else {
128 ("<span>".to_string(), "</span>".to_string())
130 }
131 }
132 }
133}
134
135#[derive(Debug, Clone)]
137struct NormalizedSpan {
138 start: u32,
139 end: u32,
140 tag: &'static str,
141}
142
143fn normalize_and_coalesce(spans: Vec<Span>) -> Vec<NormalizedSpan> {
145 if spans.is_empty() {
146 return vec![];
147 }
148
149 let mut normalized: Vec<NormalizedSpan> = spans
151 .into_iter()
152 .filter_map(|span| {
153 tag_for_capture(&span.capture).map(|tag| NormalizedSpan {
154 start: span.start,
155 end: span.end,
156 tag,
157 })
158 })
159 .collect();
160
161 if normalized.is_empty() {
162 return vec![];
163 }
164
165 normalized.sort_by_key(|s| (s.start, s.end));
167
168 let mut coalesced: Vec<NormalizedSpan> = Vec::with_capacity(normalized.len());
170
171 for span in normalized {
172 if let Some(last) = coalesced.last_mut() {
173 if span.tag == last.tag && span.start <= last.end {
175 last.end = last.end.max(span.end);
177 continue;
178 }
179 }
180 coalesced.push(span);
181 }
182
183 coalesced
184}
185
186pub fn spans_to_html(source: &str, spans: Vec<Span>, format: &HtmlFormat) -> String {
198 let source = source.trim_end_matches('\n');
200
201 if spans.is_empty() {
202 return html_escape(source);
203 }
204
205 let mut spans = spans;
207 spans.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end)));
208
209 let mut deduped: HashMap<(u32, u32), Span> = HashMap::new();
213 for span in spans {
214 let key = (span.start, span.end);
215 let new_has_styling = tag_for_capture(&span.capture).is_some();
216
217 if let Some(existing) = deduped.get(&key) {
218 let existing_has_styling = tag_for_capture(&existing.capture).is_some();
219 let should_replace = match (new_has_styling, existing_has_styling) {
222 (true, false) => true, (false, true) => false, _ => span.pattern_index >= existing.pattern_index, };
226 if should_replace {
227 deduped.insert(key, span);
228 }
229 } else {
230 deduped.insert(key, span);
231 }
232 }
233
234 let spans: Vec<Span> = deduped.into_values().collect();
236
237 let spans = normalize_and_coalesce(spans);
239
240 if spans.is_empty() {
241 return html_escape(source);
242 }
243
244 let mut spans = spans;
246 spans.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end)));
247
248 let mut events: Vec<(u32, bool, usize)> = Vec::new(); for (i, span) in spans.iter().enumerate() {
251 events.push((span.start, true, i));
252 events.push((span.end, false, i));
253 }
254
255 events.sort_by(|a, b| {
257 a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)) });
259
260 let mut html = String::with_capacity(source.len() * 2);
262 let mut last_pos: usize = 0;
263 let mut stack: Vec<usize> = Vec::new(); for (pos, is_start, span_idx) in events {
266 let pos = pos as usize;
267
268 if pos > last_pos && pos <= source.len() {
270 let text = &source[last_pos..pos];
271 if let Some(&top_idx) = stack.last() {
272 let tag = spans[top_idx].tag;
273 let (open_tag, close_tag) = make_html_tags(tag, format);
274 html.push_str(&open_tag);
275 html.push_str(&html_escape(text));
276 html.push_str(&close_tag);
277 } else {
278 html.push_str(&html_escape(text));
279 }
280 last_pos = pos;
281 }
282
283 if is_start {
285 stack.push(span_idx);
286 } else {
287 if let Some(idx) = stack.iter().rposition(|&x| x == span_idx) {
289 stack.remove(idx);
290 }
291 }
292 }
293
294 if last_pos < source.len() {
296 let text = &source[last_pos..];
297 if let Some(&top_idx) = stack.last() {
298 let tag = spans[top_idx].tag;
299 let (open_tag, close_tag) = make_html_tags(tag, format);
300 html.push_str(&open_tag);
301 html.push_str(&html_escape(text));
302 html.push_str(&close_tag);
303 } else {
304 html.push_str(&html_escape(text));
305 }
306 }
307
308 html
309}
310
311pub fn write_spans_as_html<W: Write>(
315 w: &mut W,
316 source: &str,
317 spans: Vec<Span>,
318 format: &HtmlFormat,
319) -> io::Result<()> {
320 let html = spans_to_html(source, spans, format);
321 w.write_all(html.as_bytes())
322}
323
324pub fn html_escape(text: &str) -> String {
326 let mut result = String::with_capacity(text.len());
327 for c in text.chars() {
328 match c {
329 '<' => result.push_str("<"),
330 '>' => result.push_str(">"),
331 '&' => result.push_str("&"),
332 '"' => result.push_str("""),
333 '\'' => result.push_str("'"),
334 _ => result.push(c),
335 }
336 }
337 result
338}
339
340#[derive(Debug, Clone)]
342pub struct AnsiOptions {
343 pub use_theme_base_style: bool,
346 pub width: Option<usize>,
349 pub pad_to_width: bool,
352 pub tab_width: usize,
354 pub margin_x: usize,
357 pub margin_y: usize,
360 pub padding_x: usize,
363 pub padding_y: usize,
366 pub border: bool,
368}
369
370pub struct BoxChars;
376
377impl BoxChars {
378 pub const TOP: char = '▄';
380 pub const BOTTOM: char = '▀';
382 pub const LEFT: char = '█';
384 pub const RIGHT: char = '█';
386}
387
388fn detect_terminal_width() -> Option<usize> {
389 #[cfg(all(feature = "terminal-size", not(target_arch = "wasm32")))]
390 {
391 use terminal_size::{Width, terminal_size};
392 if let Some((Width(w), _)) = terminal_size() {
393 Some(w as usize)
394 } else {
395 None
396 }
397 }
398 #[cfg(any(not(feature = "terminal-size"), target_arch = "wasm32"))]
399 {
400 None
401 }
402}
403
404impl Default for AnsiOptions {
405 fn default() -> Self {
406 let width = detect_terminal_width();
407 Self {
408 use_theme_base_style: false,
409 width,
410 pad_to_width: width.is_some(),
411 tab_width: 4,
412 margin_x: 0,
413 margin_y: 0,
414 padding_x: 0,
415 padding_y: 0,
416 border: false,
417 }
418 }
419}
420
421#[cfg(feature = "unicode-width")]
422fn char_display_width(c: char, col: usize, tab_width: usize) -> usize {
423 if c == '\t' {
424 let next_tab = ((col / tab_width) + 1) * tab_width;
425 next_tab - col
426 } else {
427 UnicodeWidthChar::width(c).unwrap_or(0)
428 }
429}
430
431#[cfg(not(feature = "unicode-width"))]
432fn char_display_width(c: char, col: usize, tab_width: usize) -> usize {
433 if c == '\t' {
434 let next_tab = ((col / tab_width) + 1) * tab_width;
435 next_tab - col
436 } else {
437 1
438 }
439}
440
441fn write_wrapped_text(
442 out: &mut String,
443 text: &str,
444 options: &AnsiOptions,
445 current_col: &mut usize,
446 base_ansi: &str,
447 active_style: Option<usize>,
448 theme: &Theme,
449 use_base_bg: bool,
450 border_style: &str,
451) {
452 let Some(inner_width) = options.width else {
454 for ch in text.chars() {
455 match ch {
456 '\n' | '\r' => {
457 *current_col = 0;
458 out.push(ch);
459 }
460 other => {
461 let w = char_display_width(other, *current_col, options.tab_width);
462 if other == '\t' {
463 for _ in 0..w {
464 out.push(' ');
465 }
466 } else {
467 out.push(other);
468 }
469 *current_col += w;
470 }
471 }
472 }
473 return;
474 };
475
476 let padding_x = options.padding_x;
477 let margin_x = options.margin_x;
478 let border = options.border;
479 const MIN_CONTENT_WIDTH: usize = 10;
481 let width = if border {
482 inner_width.saturating_sub(2).max(MIN_CONTENT_WIDTH)
483 } else {
484 inner_width.max(MIN_CONTENT_WIDTH)
485 };
486 let content_end = width.saturating_sub(padding_x); let pad_to_width = options.pad_to_width;
488
489 for ch in text.chars() {
490 if *current_col == 0 {
492 for _ in 0..margin_x {
494 out.push(' ');
495 }
496 if border && !border_style.is_empty() {
498 out.push_str(border_style);
499 out.push(BoxChars::LEFT);
500 out.push_str(Theme::ANSI_RESET);
501 if !base_ansi.is_empty() {
502 out.push_str(base_ansi);
503 }
504 }
505 if padding_x > 0 {
507 for _ in 0..padding_x {
508 out.push(' ');
509 }
510 *current_col += padding_x;
511 }
512 }
513
514 if ch == '\n' || ch == '\r' {
515 if pad_to_width && *current_col < width {
517 let pad = width - *current_col;
518 for _ in 0..pad {
519 out.push(' ');
520 }
521 }
522 if border && !border_style.is_empty() {
524 out.push_str(Theme::ANSI_RESET);
525 out.push_str(border_style);
526 out.push(BoxChars::RIGHT);
527 }
528 out.push_str(Theme::ANSI_RESET);
530 out.push('\n');
531 *current_col = 0;
532
533 if !base_ansi.is_empty() {
534 out.push_str(base_ansi);
535 }
536 if let Some(idx) = active_style {
537 let style = if use_base_bg {
538 theme.ansi_style_with_base_bg(idx)
539 } else {
540 theme.ansi_style(idx)
541 };
542 out.push_str(&style);
543 }
544 continue;
545 }
546
547 let w = char_display_width(ch, *current_col, options.tab_width);
548 if w > 0 && *current_col + w > content_end {
550 if pad_to_width && *current_col < width {
552 let pad = width - *current_col;
553 for _ in 0..pad {
554 out.push(' ');
555 }
556 }
557 if border && !border_style.is_empty() {
559 out.push_str(Theme::ANSI_RESET);
560 out.push_str(border_style);
561 out.push(BoxChars::RIGHT);
562 }
563 out.push_str(Theme::ANSI_RESET);
565 out.push('\n');
566 *current_col = 0;
567
568 if !base_ansi.is_empty() {
569 out.push_str(base_ansi);
570 }
571 for _ in 0..margin_x {
574 out.push(' ');
575 }
576 if border && !border_style.is_empty() {
578 out.push_str(border_style);
579 out.push(BoxChars::LEFT);
580 out.push_str(Theme::ANSI_RESET);
581 if !base_ansi.is_empty() {
582 out.push_str(base_ansi);
583 }
584 }
585 if let Some(idx) = active_style {
587 let style = if use_base_bg {
588 theme.ansi_style_with_base_bg(idx)
589 } else {
590 theme.ansi_style(idx)
591 };
592 out.push_str(&style);
593 }
594 if padding_x > 0 {
596 for _ in 0..padding_x {
597 out.push(' ');
598 }
599 *current_col += padding_x;
600 }
601 }
602
603 if ch == '\t' {
604 let w = char_display_width('\t', *current_col, options.tab_width);
605 for _ in 0..w {
606 out.push(' ');
607 }
608 *current_col += w;
609 } else {
610 out.push(ch);
611 *current_col += w;
612 }
613 }
614}
615
616pub fn spans_to_ansi(source: &str, spans: Vec<Span>, theme: &Theme) -> String {
621 spans_to_ansi_with_options(source, spans, theme, &AnsiOptions::default())
622}
623
624pub fn spans_to_ansi_with_options(
626 source: &str,
627 spans: Vec<Span>,
628 theme: &Theme,
629 options: &AnsiOptions,
630) -> String {
631 let source = source.trim_end_matches('\n');
633
634 if spans.is_empty() {
635 return source.to_string();
636 }
637
638 let mut spans = spans;
640 spans.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end)));
641
642 let mut deduped: HashMap<(u32, u32), Span> = HashMap::new();
645 for span in spans {
646 let key = (span.start, span.end);
647 let new_has_slot = slot_to_highlight_index(capture_to_slot(&span.capture)).is_some();
648
649 if let Some(existing) = deduped.get(&key) {
650 let existing_has_slot =
651 slot_to_highlight_index(capture_to_slot(&existing.capture)).is_some();
652 let should_replace = match (new_has_slot, existing_has_slot) {
655 (true, false) => true, (false, true) => false, _ => span.pattern_index >= existing.pattern_index, };
659 if should_replace {
660 deduped.insert(key, span);
661 }
662 } else {
663 deduped.insert(key, span);
664 }
665 }
666
667 let spans: Vec<Span> = deduped.into_values().collect();
668
669 #[derive(Debug, Clone)]
671 struct StyledSpan {
672 start: u32,
673 end: u32,
674 index: usize,
675 }
676
677 let mut normalized: Vec<StyledSpan> = spans
678 .into_iter()
679 .filter_map(|span| {
680 let slot = capture_to_slot(&span.capture);
681 let index = slot_to_highlight_index(slot)?;
682 if options.use_theme_base_style {
684 if let Some(style) = theme.style(index) {
685 if style.is_empty() {
686 return None;
687 }
688 }
689 }
690 Some(StyledSpan {
691 start: span.start,
692 end: span.end,
693 index,
694 })
695 })
696 .collect();
697
698 if normalized.is_empty() {
699 return source.to_string();
700 }
701
702 normalized.sort_by_key(|s| (s.start, s.end));
704
705 let mut coalesced: Vec<StyledSpan> = Vec::with_capacity(normalized.len());
707 for span in normalized {
708 if let Some(last) = coalesced.last_mut() {
709 if span.index == last.index && span.start <= last.end {
710 last.end = last.end.max(span.end);
711 continue;
712 }
713 }
714 coalesced.push(span);
715 }
716
717 if coalesced.is_empty() {
718 return source.to_string();
719 }
720
721 let mut events: Vec<(u32, bool, usize)> = Vec::new();
723 for (i, span) in coalesced.iter().enumerate() {
724 events.push((span.start, true, i));
725 events.push((span.end, false, i));
726 }
727
728 events.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
729
730 let mut out = String::with_capacity(source.len() * 2);
731 let mut last_pos: usize = 0;
732 let mut stack: Vec<usize> = Vec::new();
733 let mut active_style: Option<usize> = None;
734 let mut current_col: usize = 0;
735
736 let base_ansi = if options.use_theme_base_style {
737 theme.ansi_base_style()
738 } else {
739 String::new()
740 };
741 let use_base_bg = options.use_theme_base_style;
742
743 let mut output_started = false;
745
746 let padding_y = options.padding_y;
747 let margin_x = options.margin_x;
748 let margin_y = options.margin_y;
749 let border = options.border;
750 let border_style = if border {
751 theme.ansi_border_style()
752 } else {
753 String::new()
754 };
755
756 const MIN_WIDTH: usize = 10;
758
759 if let Some(width) = options.width.map(|w| w.max(MIN_WIDTH)) {
760 for _ in 0..margin_y {
762 out.push('\n');
763 }
764
765 if border {
767 for _ in 0..margin_x {
769 out.push(' ');
770 }
771 out.push_str(&border_style);
772 for _ in 0..width {
773 out.push(BoxChars::TOP);
774 }
775 out.push_str(Theme::ANSI_RESET);
776 out.push('\n');
777 }
778
779 if padding_y > 0 {
781 for _ in 0..padding_y {
782 for _ in 0..margin_x {
784 out.push(' ');
785 }
786 if border {
788 out.push_str(&border_style);
789 out.push(BoxChars::LEFT);
790 }
791 if !base_ansi.is_empty() {
793 out.push_str(&base_ansi);
794 output_started = true;
795 }
796 let inner = if border {
798 width.saturating_sub(2)
799 } else {
800 width
801 };
802 for _ in 0..inner {
803 out.push(' ');
804 }
805 if border {
807 out.push_str(Theme::ANSI_RESET);
808 out.push_str(&border_style);
809 out.push(BoxChars::RIGHT);
810 }
811 out.push_str(Theme::ANSI_RESET);
812 out.push('\n');
813 if !base_ansi.is_empty() {
815 out.push_str(&base_ansi);
816 }
817 }
818 } else if !base_ansi.is_empty() {
819 out.push_str(&base_ansi);
821 output_started = true;
822 }
823 } else {
824 if !base_ansi.is_empty() {
826 out.push_str(&base_ansi);
827 output_started = true;
828 }
829 }
830
831 for (pos, is_start, span_idx) in events {
832 let pos = pos as usize;
833 if pos > last_pos && pos <= source.len() {
834 let text = &source[last_pos..pos];
835 let desired = stack.last().copied().map(|idx| coalesced[idx].index);
836
837 match (active_style, desired) {
838 (Some(a), Some(d)) if a == d => {
839 write_wrapped_text(
841 &mut out,
842 text,
843 options,
844 &mut current_col,
845 &base_ansi,
846 Some(a),
847 theme,
848 use_base_bg,
849 &border_style,
850 );
851 }
852 (Some(_), Some(d)) => {
853 out.push_str(Theme::ANSI_RESET);
855 let style = if use_base_bg {
856 theme.ansi_style_with_base_bg(d)
857 } else {
858 theme.ansi_style(d)
859 };
860 if use_base_bg {
863 out.push_str(&style);
864 } else {
865 if !base_ansi.is_empty() {
866 out.push_str(&base_ansi);
867 }
868 out.push_str(&style);
869 }
870 write_wrapped_text(
871 &mut out,
872 text,
873 options,
874 &mut current_col,
875 &base_ansi,
876 Some(d),
877 theme,
878 use_base_bg,
879 &border_style,
880 );
881 active_style = Some(d);
882 }
883 (None, Some(d)) => {
884 let style = if use_base_bg {
886 theme.ansi_style_with_base_bg(d)
887 } else {
888 theme.ansi_style(d)
889 };
890
891 if !style.is_empty() && style != base_ansi {
893 out.push_str(&style);
895 output_started = true;
896 } else if !output_started && !base_ansi.is_empty() {
897 out.push_str(&base_ansi);
899 output_started = true;
900 }
901
902 write_wrapped_text(
903 &mut out,
904 text,
905 options,
906 &mut current_col,
907 &base_ansi,
908 Some(d),
909 theme,
910 use_base_bg,
911 &border_style,
912 );
913 active_style = Some(d);
914 }
915 (Some(_), None) => {
916 out.push_str(Theme::ANSI_RESET);
918 if !base_ansi.is_empty() {
919 out.push_str(&base_ansi);
920 }
921 write_wrapped_text(
922 &mut out,
923 text,
924 options,
925 &mut current_col,
926 &base_ansi,
927 None,
928 theme,
929 use_base_bg,
930 &border_style,
931 );
932 active_style = None;
933 }
934 (None, None) => {
935 if !output_started && !base_ansi.is_empty() {
937 out.push_str(&base_ansi);
938 output_started = true;
939 }
940 write_wrapped_text(
941 &mut out,
942 text,
943 options,
944 &mut current_col,
945 &base_ansi,
946 None,
947 theme,
948 use_base_bg,
949 &border_style,
950 );
951 }
952 }
953
954 last_pos = pos;
955 }
956
957 if is_start {
958 stack.push(span_idx);
959 } else if let Some(idx) = stack.iter().rposition(|&x| x == span_idx) {
960 stack.remove(idx);
961 }
962 }
963
964 if last_pos < source.len() {
965 let text = &source[last_pos..];
966 let desired = stack.last().copied().map(|idx| coalesced[idx].index);
967 match (active_style, desired) {
968 (Some(a), Some(d)) if a == d => {
969 write_wrapped_text(
970 &mut out,
971 text,
972 options,
973 &mut current_col,
974 &base_ansi,
975 Some(a),
976 theme,
977 use_base_bg,
978 &border_style,
979 );
980 }
981 (Some(_), Some(d)) => {
982 out.push_str(Theme::ANSI_RESET);
983 let style = if use_base_bg {
984 theme.ansi_style_with_base_bg(d)
985 } else {
986 theme.ansi_style(d)
987 };
988 if use_base_bg {
990 out.push_str(&style);
991 } else {
992 if !base_ansi.is_empty() {
993 out.push_str(&base_ansi);
994 }
995 out.push_str(&style);
996 }
997 write_wrapped_text(
998 &mut out,
999 text,
1000 options,
1001 &mut current_col,
1002 &base_ansi,
1003 Some(d),
1004 theme,
1005 use_base_bg,
1006 &border_style,
1007 );
1008 active_style = Some(d);
1009 }
1010 (None, Some(d)) => {
1011 let style = if use_base_bg {
1012 theme.ansi_style_with_base_bg(d)
1013 } else {
1014 theme.ansi_style(d)
1015 };
1016
1017 if !style.is_empty() && style != base_ansi {
1019 out.push_str(&style);
1020 } else if !output_started && !base_ansi.is_empty() {
1021 out.push_str(&base_ansi);
1022 }
1023
1024 write_wrapped_text(
1025 &mut out,
1026 text,
1027 options,
1028 &mut current_col,
1029 &base_ansi,
1030 Some(d),
1031 theme,
1032 use_base_bg,
1033 &border_style,
1034 );
1035 active_style = Some(d);
1036 }
1037 (Some(_), None) => {
1038 out.push_str(Theme::ANSI_RESET);
1039 if !base_ansi.is_empty() {
1040 out.push_str(&base_ansi);
1041 }
1042 write_wrapped_text(
1043 &mut out,
1044 text,
1045 options,
1046 &mut current_col,
1047 &base_ansi,
1048 None,
1049 theme,
1050 use_base_bg,
1051 &border_style,
1052 );
1053 active_style = None;
1054 }
1055 (None, None) => {
1056 if !output_started && !base_ansi.is_empty() {
1057 out.push_str(&base_ansi);
1058 }
1059 write_wrapped_text(
1060 &mut out,
1061 text,
1062 options,
1063 &mut current_col,
1064 &base_ansi,
1065 None,
1066 theme,
1067 use_base_bg,
1068 &border_style,
1069 );
1070 }
1071 }
1072 }
1073
1074 if let Some(width) = options.width {
1075 let padding_y = options.padding_y;
1076 let pad_to_width = options.pad_to_width;
1077 let inner_width = if border {
1079 width.saturating_sub(2)
1080 } else {
1081 width
1082 };
1083
1084 if pad_to_width && current_col < inner_width {
1086 let pad = inner_width - current_col;
1087 for _ in 0..pad {
1088 out.push(' ');
1089 }
1090 }
1091
1092 if border && !border_style.is_empty() {
1094 out.push_str(Theme::ANSI_RESET);
1095 out.push_str(&border_style);
1096 out.push(BoxChars::RIGHT);
1097 }
1098
1099 out.push_str(Theme::ANSI_RESET);
1101
1102 if padding_y > 0 {
1104 for _ in 0..padding_y {
1105 out.push('\n');
1106 for _ in 0..margin_x {
1108 out.push(' ');
1109 }
1110 if border {
1112 out.push_str(&border_style);
1113 out.push(BoxChars::LEFT);
1114 }
1115 if !base_ansi.is_empty() {
1117 out.push_str(&base_ansi);
1118 }
1119 let inner = if border {
1120 width.saturating_sub(2)
1121 } else {
1122 width
1123 };
1124 for _ in 0..inner {
1125 out.push(' ');
1126 }
1127 if border {
1129 out.push_str(Theme::ANSI_RESET);
1130 out.push_str(&border_style);
1131 out.push(BoxChars::RIGHT);
1132 }
1133 out.push_str(Theme::ANSI_RESET);
1134 }
1135 }
1136
1137 if border {
1139 out.push('\n');
1140 for _ in 0..margin_x {
1142 out.push(' ');
1143 }
1144 out.push_str(&border_style);
1145 for _ in 0..width {
1146 out.push(BoxChars::BOTTOM);
1147 }
1148 out.push_str(Theme::ANSI_RESET);
1149 }
1150
1151 for _ in 0..margin_y {
1153 out.push('\n');
1154 }
1155 } else if active_style.is_some() || !base_ansi.is_empty() {
1156 out.push_str(Theme::ANSI_RESET);
1157 }
1158
1159 out
1160}
1161
1162pub fn write_spans_as_ansi<W: Write>(
1164 w: &mut W,
1165 source: &str,
1166 spans: Vec<Span>,
1167 theme: &Theme,
1168) -> io::Result<()> {
1169 let ansi = spans_to_ansi(source, spans, theme);
1170 w.write_all(ansi.as_bytes())
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use super::*;
1176
1177 #[test]
1178 fn test_simple_highlight() {
1179 let source = "fn main";
1180 let spans = vec![
1181 Span {
1182 start: 0,
1183 end: 2,
1184 capture: "keyword".into(),
1185 pattern_index: 0,
1186 },
1187 Span {
1188 start: 3,
1189 end: 7,
1190 capture: "function".into(),
1191 pattern_index: 0,
1192 },
1193 ];
1194 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1195 assert_eq!(html, "<a-k>fn</a-k> <a-f>main</a-f>");
1196 }
1197
1198 #[test]
1199 fn test_keyword_variants_coalesce() {
1200 let source = "with use import";
1202 let spans = vec![
1203 Span {
1204 start: 0,
1205 end: 4,
1206 capture: "include".into(), pattern_index: 0,
1208 },
1209 Span {
1210 start: 5,
1211 end: 8,
1212 capture: "keyword".into(),
1213 pattern_index: 0,
1214 },
1215 Span {
1216 start: 9,
1217 end: 15,
1218 capture: "keyword.import".into(),
1219 pattern_index: 0,
1220 },
1221 ];
1222 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1223 assert!(html.contains("<a-k>with</a-k>"));
1225 assert!(html.contains("<a-k>use</a-k>"));
1226 assert!(html.contains("<a-k>import</a-k>"));
1227 }
1228
1229 #[test]
1230 fn test_adjacent_same_tag_coalesce() {
1231 let source = "keyword";
1233 let spans = vec![
1234 Span {
1235 start: 0,
1236 end: 3,
1237 capture: "keyword".into(),
1238 pattern_index: 0,
1239 },
1240 Span {
1241 start: 3,
1242 end: 7,
1243 capture: "keyword.function".into(), pattern_index: 0,
1245 },
1246 ];
1247 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1248 assert_eq!(html, "<a-k>keyword</a-k>");
1250 }
1251
1252 #[test]
1253 fn test_overlapping_spans_dedupe() {
1254 let source = "apiVersion";
1255 let spans = vec![
1257 Span {
1258 start: 0,
1259 end: 10,
1260 capture: "property".into(),
1261 pattern_index: 0,
1262 },
1263 Span {
1264 start: 0,
1265 end: 10,
1266 capture: "variable".into(),
1267 pattern_index: 0,
1268 },
1269 ];
1270 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1271 assert!(!html.contains("apiVersionapiVersion"));
1273 assert!(html.contains("apiVersion"));
1274 }
1275
1276 #[test]
1277 fn test_html_escape() {
1278 let source = "<script>";
1279 let spans = vec![];
1280 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1281 assert_eq!(html, "<script>");
1282 }
1283
1284 #[test]
1285 fn test_nospell_filtered() {
1286 let source = "hello world";
1288 let spans = vec![
1289 Span {
1290 start: 0,
1291 end: 5,
1292 capture: "spell".into(),
1293 pattern_index: 0,
1294 },
1295 Span {
1296 start: 6,
1297 end: 11,
1298 capture: "nospell".into(),
1299 pattern_index: 0,
1300 },
1301 ];
1302 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1303 assert_eq!(html, "hello world");
1305 }
1306
1307 #[test]
1308 fn test_simple_ansi_highlight() {
1309 let theme = arborium_theme::theme::builtin::catppuccin_mocha();
1310 let source = "fn main";
1311 let spans = vec![
1312 Span {
1313 start: 0,
1314 end: 2,
1315 capture: "keyword".into(),
1316 pattern_index: 0,
1317 },
1318 Span {
1319 start: 3,
1320 end: 7,
1321 capture: "function".into(),
1322 pattern_index: 0,
1323 },
1324 ];
1325
1326 let kw_idx = slot_to_highlight_index(capture_to_slot("keyword")).unwrap();
1327 let fn_idx = slot_to_highlight_index(capture_to_slot("function")).unwrap();
1328
1329 let ansi = spans_to_ansi(source, spans, &theme);
1330
1331 let expected = format!(
1332 "{}fn{} {}main{}",
1333 theme.ansi_style(kw_idx),
1334 Theme::ANSI_RESET,
1335 theme.ansi_style(fn_idx),
1336 Theme::ANSI_RESET
1337 );
1338 assert_eq!(ansi, expected);
1339 }
1340
1341 #[test]
1342 fn test_ansi_with_base_background() {
1343 let theme = arborium_theme::theme::builtin::tokyo_night();
1344 let source = "fn";
1345 let spans = vec![Span {
1346 start: 0,
1347 end: 2,
1348 capture: "keyword".into(),
1349 pattern_index: 0,
1350 }];
1351
1352 let mut options = AnsiOptions::default();
1353 options.use_theme_base_style = true;
1354
1355 let ansi = spans_to_ansi_with_options(source, spans, &theme, &options);
1356 let base = theme.ansi_base_style();
1357
1358 assert!(ansi.starts_with(&base));
1359 assert!(ansi.ends_with(Theme::ANSI_RESET));
1360 }
1361
1362 #[test]
1363 fn test_ansi_wrapping_inserts_newline() {
1364 let theme = arborium_theme::theme::builtin::dracula();
1365 let source = "abcdefghijklmnop";
1367 let spans = vec![Span {
1368 start: 0,
1369 end: source.len() as u32,
1370 capture: "string".into(),
1371 pattern_index: 0,
1372 }];
1373
1374 let mut options = AnsiOptions::default();
1375 options.use_theme_base_style = true;
1376 options.width = Some(12); options.pad_to_width = false;
1378
1379 let ansi = spans_to_ansi_with_options(source, spans, &theme, &options);
1380
1381 assert!(
1382 ansi.contains('\n'),
1383 "Expected newline for wrapping, got: {:?}",
1384 ansi
1385 );
1386 assert!(ansi.ends_with(Theme::ANSI_RESET));
1387 }
1388
1389 #[test]
1390 fn test_ansi_coalesces_same_style() {
1391 let theme = arborium_theme::theme::builtin::catppuccin_mocha();
1392 let source = "keyword";
1393 let spans = vec![
1394 Span {
1395 start: 0,
1396 end: 3,
1397 capture: "keyword".into(),
1398 pattern_index: 0,
1399 },
1400 Span {
1401 start: 3,
1402 end: 7,
1403 capture: "keyword.function".into(),
1404 pattern_index: 0,
1405 },
1406 ];
1407
1408 let kw_idx = slot_to_highlight_index(capture_to_slot("keyword")).unwrap();
1409 let ansi = spans_to_ansi(source, spans, &theme);
1410
1411 let expected = format!("{}keyword{}", theme.ansi_style(kw_idx), Theme::ANSI_RESET);
1412 assert_eq!(ansi, expected);
1413 }
1414
1415 #[test]
1416 fn test_comment_spell_dedupe() {
1417 let source = "# a comment";
1420 let spans = vec![
1421 Span {
1422 start: 0,
1423 end: 11,
1424 capture: "comment".into(),
1425 pattern_index: 0,
1426 },
1427 Span {
1428 start: 0,
1429 end: 11,
1430 capture: "spell".into(),
1431 pattern_index: 0,
1432 },
1433 ];
1434 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1435 assert_eq!(html, "<a-c># a comment</a-c>");
1437 }
1438
1439 #[test]
1440 fn test_html_format_custom_elements() {
1441 let source = "fn main";
1442 let spans = vec![
1443 Span {
1444 start: 0,
1445 end: 2,
1446 capture: "keyword".into(),
1447 pattern_index: 0,
1448 },
1449 Span {
1450 start: 3,
1451 end: 7,
1452 capture: "function".into(),
1453 pattern_index: 0,
1454 },
1455 ];
1456 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1457 assert_eq!(html, "<a-k>fn</a-k> <a-f>main</a-f>");
1458 }
1459
1460 #[test]
1461 fn test_html_format_custom_elements_with_prefix() {
1462 let source = "fn main";
1463 let spans = vec![
1464 Span {
1465 start: 0,
1466 end: 2,
1467 capture: "keyword".into(),
1468 pattern_index: 0,
1469 },
1470 Span {
1471 start: 3,
1472 end: 7,
1473 capture: "function".into(),
1474 pattern_index: 0,
1475 },
1476 ];
1477 let html = spans_to_html(
1478 source,
1479 spans,
1480 &HtmlFormat::CustomElementsWithPrefix("code".to_string()),
1481 );
1482 assert_eq!(html, "<code-k>fn</code-k> <code-f>main</code-f>");
1483 }
1484
1485 #[test]
1486 fn test_html_format_class_names() {
1487 let source = "fn main";
1488 let spans = vec![
1489 Span {
1490 start: 0,
1491 end: 2,
1492 capture: "keyword".into(),
1493 pattern_index: 0,
1494 },
1495 Span {
1496 start: 3,
1497 end: 7,
1498 capture: "function".into(),
1499 pattern_index: 0,
1500 },
1501 ];
1502 let html = spans_to_html(source, spans, &HtmlFormat::ClassNames);
1503 assert_eq!(
1504 html,
1505 "<span class=\"keyword\">fn</span> <span class=\"function\">main</span>"
1506 );
1507 }
1508
1509 #[test]
1510 fn test_html_format_class_names_with_prefix() {
1511 let source = "fn main";
1512 let spans = vec![
1513 Span {
1514 start: 0,
1515 end: 2,
1516 capture: "keyword".into(),
1517 pattern_index: 0,
1518 },
1519 Span {
1520 start: 3,
1521 end: 7,
1522 capture: "function".into(),
1523 pattern_index: 0,
1524 },
1525 ];
1526 let html = spans_to_html(
1527 source,
1528 spans,
1529 &HtmlFormat::ClassNamesWithPrefix("arb".to_string()),
1530 );
1531 assert_eq!(
1532 html,
1533 "<span class=\"arb-keyword\">fn</span> <span class=\"arb-function\">main</span>"
1534 );
1535 }
1536
1537 #[test]
1538 fn test_html_format_all_tags() {
1539 let source = "kfsctvcopprattgmlnscrttstemdadder";
1541 let mut offset = 0;
1542 let mut spans = vec![];
1543 let tags = [
1544 ("k", "keyword", "keyword"),
1545 ("f", "function", "function"),
1546 ("s", "string", "string"),
1547 ("c", "comment", "comment"),
1548 ("t", "type", "type"),
1549 ("v", "variable", "variable"),
1550 ("co", "constant", "constant"),
1551 ("p", "punctuation", "punctuation"),
1552 ("pr", "property", "property"),
1553 ("at", "attribute", "attribute"),
1554 ("tg", "tag", "tag"),
1555 ("m", "macro", "macro"),
1556 ("l", "label", "label"),
1557 ("ns", "namespace", "namespace"),
1558 ("cr", "constructor", "constructor"),
1559 ("tt", "text.title", "title"),
1560 ("st", "text.strong", "strong"),
1561 ("em", "text.emphasis", "emphasis"),
1562 ("da", "diff.addition", "diff-add"),
1563 ("dd", "diff.deletion", "diff-delete"),
1564 ("er", "error", "error"),
1565 ];
1566
1567 for (tag, capture_name, _class_name) in &tags {
1568 let len = tag.len() as u32;
1569 spans.push(Span {
1570 start: offset,
1571 end: offset + len,
1572 capture: capture_name.to_string(),
1573 pattern_index: 0,
1574 });
1575 offset += len;
1576 }
1577
1578 let html = spans_to_html(source, spans.clone(), &HtmlFormat::ClassNames);
1580 for (_tag, _capture, class_name) in &tags {
1581 assert!(
1582 html.contains(&format!("class=\"{}\"", class_name)),
1583 "Missing class=\"{}\" in output: {}",
1584 class_name,
1585 html
1586 );
1587 }
1588 }
1589}
1590
1591#[cfg(test)]
1592mod html_tests {
1593 use super::*;
1594 use crate::Span;
1595
1596 #[test]
1597 fn test_spans_to_html_cpp_sample() {
1598 let sample = std::fs::read_to_string(concat!(
1599 env!("CARGO_MANIFEST_DIR"),
1600 "/../../demo/samples/cpp.cc"
1601 ))
1602 .expect("Failed to read cpp sample");
1603
1604 let spans = vec![
1606 Span {
1607 start: 0,
1608 end: 10,
1609 capture: "comment".into(),
1610 pattern_index: 0,
1611 },
1612 Span {
1613 start: 100,
1614 end: 110,
1615 capture: "keyword".into(),
1616 pattern_index: 0,
1617 },
1618 ];
1619
1620 let html = spans_to_html(&sample, spans, &HtmlFormat::default());
1622 assert!(!html.is_empty());
1623 }
1624
1625 #[test]
1626 fn test_spans_to_html_real_cpp_grammar() {
1627 use crate::{CompiledGrammar, GrammarConfig, ParseContext};
1628
1629 let sample = std::fs::read_to_string(concat!(
1630 env!("CARGO_MANIFEST_DIR"),
1631 "/../../demo/samples/cpp.cc"
1632 ))
1633 .expect("Failed to read cpp sample");
1634
1635 let config = GrammarConfig {
1637 language: arborium_cpp::language().into(),
1638 highlights_query: &arborium_cpp::HIGHLIGHTS_QUERY,
1639 injections_query: arborium_cpp::INJECTIONS_QUERY,
1640 locals_query: "",
1641 };
1642
1643 let grammar = CompiledGrammar::new(config).expect("Failed to compile grammar");
1644 let mut ctx = ParseContext::for_grammar(&grammar).expect("Failed to create context");
1645
1646 let result = grammar.parse(&mut ctx, &sample);
1648
1649 println!("Got {} spans from parsing", result.spans.len());
1650
1651 for (i, span) in result.spans.iter().enumerate().take(20) {
1653 println!(
1654 "Span {}: {}..{} {:?}",
1655 i, span.start, span.end, span.capture
1656 );
1657 let start = span.start as usize;
1658 let end = span.end as usize;
1659 assert!(
1660 start <= sample.len(),
1661 "Span {} start {} > len {}",
1662 i,
1663 start,
1664 sample.len()
1665 );
1666 assert!(
1667 end <= sample.len(),
1668 "Span {} end {} > len {}",
1669 i,
1670 end,
1671 sample.len()
1672 );
1673 assert!(
1674 sample.is_char_boundary(start),
1675 "Span {} start {} not char boundary",
1676 i,
1677 start
1678 );
1679 assert!(
1680 sample.is_char_boundary(end),
1681 "Span {} end {} not char boundary",
1682 i,
1683 end
1684 );
1685 }
1686
1687 let html = spans_to_html(&sample, result.spans, &HtmlFormat::default());
1689 assert!(!html.is_empty());
1690 println!("Generated {} bytes of HTML", html.len());
1691 }
1692
1693 #[test]
1699 fn test_pattern_index_deduplication() {
1700 let source = "name value";
1701
1702 let spans = vec![
1705 Span {
1706 start: 0,
1707 end: 4,
1708 capture: "string".into(),
1709 pattern_index: 7,
1710 },
1711 Span {
1712 start: 0,
1713 end: 4,
1714 capture: "property".into(),
1715 pattern_index: 11,
1716 },
1717 Span {
1718 start: 5,
1719 end: 10,
1720 capture: "string".into(),
1721 pattern_index: 7,
1722 },
1723 ];
1724
1725 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1726
1727 eprintln!("Generated HTML: {}", html);
1728
1729 assert!(
1732 html.contains("<a-pr>name</a-pr>"),
1733 "Expected 'name' to be rendered as <a-pr> (property), got: {}",
1734 html
1735 );
1736
1737 assert!(
1739 html.contains("<a-s>value</a-s>"),
1740 "Expected 'value' to be rendered as <a-s> (string), got: {}",
1741 html
1742 );
1743 }
1744
1745 #[test]
1748 fn test_pattern_index_deduplication_string_wins() {
1749 let source = "name";
1750
1751 let spans = vec![
1753 Span {
1754 start: 0,
1755 end: 4,
1756 capture: "property".into(),
1757 pattern_index: 7,
1758 },
1759 Span {
1760 start: 0,
1761 end: 4,
1762 capture: "string".into(),
1763 pattern_index: 11,
1764 },
1765 ];
1766
1767 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1768
1769 eprintln!("Generated HTML: {}", html);
1770
1771 assert!(
1773 html.contains("<a-s>name</a-s>"),
1774 "Expected 'name' to be rendered as <a-s> (string), got: {}",
1775 html
1776 );
1777 }
1778
1779 #[test]
1783 fn test_trailing_newlines_trimmed() {
1784 let source = "fn main() {}\n";
1785 let spans = vec![Span {
1786 start: 0,
1787 end: 2,
1788 capture: "keyword".into(),
1789 pattern_index: 0,
1790 }];
1791
1792 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1793
1794 assert!(
1795 !html.ends_with('\n'),
1796 "HTML output should not end with newline, got: {:?}",
1797 html
1798 );
1799 assert_eq!(html, "<a-k>fn</a-k> main() {}");
1800 }
1801
1802 #[test]
1804 fn test_multiple_trailing_newlines_trimmed() {
1805 let source = "let x = 1;\n\n\n";
1806 let spans = vec![];
1807
1808 let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1809
1810 assert!(
1811 !html.ends_with('\n'),
1812 "HTML output should not end with newline, got: {:?}",
1813 html
1814 );
1815 assert_eq!(html, "let x = 1;");
1816 }
1817}