Skip to main content

arborium_highlight/
render.rs

1//! HTML rendering from highlight spans.
2//!
3//! This module converts raw spans from grammar parsers into HTML with proper
4//! handling of overlapping spans (deduplication) and span coalescing.
5//!
6//! # Span Coalescing
7//!
8//! Adjacent spans that map to the same theme slot are merged into a single HTML element.
9//! For example, if we have:
10//! - `keyword.function` at bytes 0-4
11//! - `keyword` at bytes 5-8
12//!
13//! Both map to the "keyword" slot (`k` tag), so they become a single `<a-k>` element.
14
15use 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/// A span with a theme style index for rendering.
23///
24/// This is the output of processing raw `Span` objects through the theme system.
25/// The `theme_index` can be used with `Theme::style()` to get colors and modifiers.
26#[derive(Debug, Clone)]
27pub struct ThemedSpan {
28    /// Byte offset where the span starts (inclusive).
29    pub start: u32,
30    /// Byte offset where the span ends (exclusive).
31    pub end: u32,
32    /// Index into the theme's style array.
33    pub theme_index: usize,
34}
35
36/// Convert raw spans to themed spans by resolving capture names to theme indices.
37///
38/// This performs deduplication and returns spans with theme style indices that can
39/// be used with `Theme::style()` to get colors and modifiers.
40pub fn spans_to_themed(spans: Vec<Span>) -> Vec<ThemedSpan> {
41    if spans.is_empty() {
42        return Vec::new();
43    }
44
45    // Sort spans by (start, -end) so longer spans come first at same start
46    let mut spans = spans;
47    spans.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end)));
48
49    // Deduplicate ranges - prefer spans with higher pattern_index (later in highlights.scm wins)
50    // This matches tree-sitter convention: later patterns override earlier ones
51    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            // Prefer spans with styling over unstyled spans
60            // Among equally-styled spans, prefer higher pattern_index (later in query)
61            let should_replace = match (new_has_slot, existing_has_slot) {
62                (true, false) => true,  // New has styling, existing doesn't
63                (false, true) => false, // Existing has styling, new doesn't
64                _ => span.pattern_index >= existing.pattern_index, // Both same styling status: higher pattern_index wins
65            };
66            if should_replace {
67                deduped.insert(key, span);
68            }
69        } else {
70            deduped.insert(key, span);
71        }
72    }
73
74    // Convert to themed spans
75    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    // Sort by start position
89    themed.sort_by_key(|s| s.start);
90
91    themed
92}
93
94#[cfg(feature = "unicode-width")]
95use unicode_width::UnicodeWidthChar;
96
97/// Generate opening and closing HTML tags based on the configured format.
98///
99/// Returns (opening_tag, closing_tag) for the given short tag and format.
100fn 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                // Fallback for unknown tags
119                ("<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                // Fallback for unknown tags
129                ("<span>".to_string(), "</span>".to_string())
130            }
131        }
132    }
133}
134
135/// A normalized span with theme slot tag.
136#[derive(Debug, Clone)]
137struct NormalizedSpan {
138    start: u32,
139    end: u32,
140    tag: &'static str,
141}
142
143/// Normalize spans: map captures to theme slots and merge adjacent spans with same tag.
144fn normalize_and_coalesce(spans: Vec<Span>) -> Vec<NormalizedSpan> {
145    if spans.is_empty() {
146        return vec![];
147    }
148
149    // First, normalize all spans to their theme slot tags
150    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    // Sort by start position
166    normalized.sort_by_key(|s| (s.start, s.end));
167
168    // Coalesce adjacent spans with the same tag
169    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 this span is adjacent (or overlapping) and has the same tag, merge
174            if span.tag == last.tag && span.start <= last.end {
175                // Extend the last span to cover this one
176                last.end = last.end.max(span.end);
177                continue;
178            }
179        }
180        coalesced.push(span);
181    }
182
183    coalesced
184}
185
186/// Deduplicate spans and convert to HTML.
187///
188/// This handles:
189/// 1. Mapping captures to theme slots (many -> few)
190/// 2. Coalescing adjacent spans with the same tag
191/// 3. Handling overlapping spans
192///
193/// The `format` parameter controls the HTML output style.
194///
195/// Note: Trailing newlines are trimmed from the source to avoid extra whitespace
196/// when the output is embedded in `<pre><code>` tags.
197pub fn spans_to_html(source: &str, spans: Vec<Span>, format: &HtmlFormat) -> String {
198    // Trim trailing newlines from source to avoid extra whitespace in code blocks
199    let source = source.trim_end_matches('\n');
200
201    if spans.is_empty() {
202        return html_escape(source);
203    }
204
205    // Sort spans by (start, -end) so longer spans come first at same start
206    let mut spans = spans;
207    spans.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end)));
208
209    // Deduplicate: for spans with the exact same (start, end), prefer spans with higher pattern_index
210    // This matches tree-sitter convention: later patterns in highlights.scm override earlier ones.
211    // We also prefer styled spans over unstyled (e.g., @comment over @spell).
212    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            // Prefer spans with styling over unstyled spans
220            // Among equally-styled spans, prefer higher pattern_index (later in query)
221            let should_replace = match (new_has_styling, existing_has_styling) {
222                (true, false) => true,  // New has styling, existing doesn't
223                (false, true) => false, // Existing has styling, new doesn't
224                _ => span.pattern_index >= existing.pattern_index, // Both same styling status: higher pattern_index wins
225            };
226            if should_replace {
227                deduped.insert(key, span);
228            }
229        } else {
230            deduped.insert(key, span);
231        }
232    }
233
234    // Convert back to vec
235    let spans: Vec<Span> = deduped.into_values().collect();
236
237    // Normalize to theme slots and coalesce adjacent same-tag spans
238    let spans = normalize_and_coalesce(spans);
239
240    if spans.is_empty() {
241        return html_escape(source);
242    }
243
244    // Re-sort after coalescing
245    let mut spans = spans;
246    spans.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end)));
247
248    // Build events from spans
249    let mut events: Vec<(u32, bool, usize)> = Vec::new(); // (pos, is_start, span_index)
250    for (i, span) in spans.iter().enumerate() {
251        events.push((span.start, true, i));
252        events.push((span.end, false, i));
253    }
254
255    // Sort events: by position, then ends before starts at same position
256    events.sort_by(|a, b| {
257        a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)) // false (end) < true (start)
258    });
259
260    // Process events with a stack
261    let mut html = String::with_capacity(source.len() * 2);
262    let mut last_pos: usize = 0;
263    let mut stack: Vec<usize> = Vec::new(); // indices into spans
264
265    for (pos, is_start, span_idx) in events {
266        let pos = pos as usize;
267
268        // Emit any source text before this position
269        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        // Update the stack
284        if is_start {
285            stack.push(span_idx);
286        } else {
287            // Remove this span from stack
288            if let Some(idx) = stack.iter().rposition(|&x| x == span_idx) {
289                stack.remove(idx);
290            }
291        }
292    }
293
294    // Emit remaining text
295    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
311/// Write spans as HTML to a writer.
312///
313/// This is more efficient than `spans_to_html` for streaming output.
314pub 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
324/// Escape HTML special characters.
325pub 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("&lt;"),
330            '>' => result.push_str("&gt;"),
331            '&' => result.push_str("&amp;"),
332            '"' => result.push_str("&quot;"),
333            '\'' => result.push_str("&#39;"),
334            _ => result.push(c),
335        }
336    }
337    result
338}
339
340/// Options controlling ANSI rendering behavior.
341#[derive(Debug, Clone)]
342pub struct AnsiOptions {
343    /// If true, apply the theme's foreground/background as a base style
344    /// for all text (including un-highlighted regions).
345    pub use_theme_base_style: bool,
346    /// Optional hard wrap width (in columns). When None, no wrapping is
347    /// performed and the original line structure is preserved.
348    pub width: Option<usize>,
349    /// If true and `width` is set, pad each visual line with spaces up
350    /// to exactly `width` columns.
351    pub pad_to_width: bool,
352    /// Tab width (in columns) used when computing display width.
353    pub tab_width: usize,
354    /// Horizontal margin (in columns) outside the border/background.
355    /// This is empty space with no styling.
356    pub margin_x: usize,
357    /// Vertical margin (in rows) outside the border/background.
358    /// This is empty space with no styling.
359    pub margin_y: usize,
360    /// Horizontal padding (in columns) on left and right sides.
361    /// Inside the background, between border and content.
362    pub padding_x: usize,
363    /// Vertical padding (in rows) on top and bottom.
364    /// Inside the background, between border and content.
365    pub padding_y: usize,
366    /// If true, draw a border around the code block using half-block characters.
367    pub border: bool,
368}
369
370/// Unicode block drawing characters used to create visual borders around ANSI output.
371///
372/// These characters create a "half-block" border style that works well in terminals:
373/// - `TOP`/`BOTTOM`: half blocks that create smooth edges
374/// - `LEFT`/`RIGHT`: full blocks for solid vertical borders
375pub struct BoxChars;
376
377impl BoxChars {
378    /// Unicode lower half block (`▄`) used for the top border.
379    pub const TOP: char = '▄';
380    /// Unicode upper half block (`▀`) used for the bottom border.
381    pub const BOTTOM: char = '▀';
382    /// Unicode full block (`█`) used for the left border.
383    pub const LEFT: char = '█';
384    /// Unicode full block (`█`) used for the right border.
385    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    // No wrapping requested: just track column and append text.
453    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    // Inner width excludes border characters, with a minimum to handle narrow terminals
480    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); // where content should stop (before right padding)
487    let pad_to_width = options.pad_to_width;
488
489    for ch in text.chars() {
490        // At the start of a visual line, emit margin + left border + left padding
491        if *current_col == 0 {
492            // Left margin
493            for _ in 0..margin_x {
494                out.push(' ');
495            }
496            // Left border (full block)
497            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            // Left padding
506            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            // Pad to full width (including right padding)
516            if pad_to_width && *current_col < width {
517                let pad = width - *current_col;
518                for _ in 0..pad {
519                    out.push(' ');
520                }
521            }
522            // Right border (full block)
523            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            // Reset before newline so background doesn't extend to terminal edge
529            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        // Wrap when we would exceed the content area (before right padding)
549        if w > 0 && *current_col + w > content_end {
550            // Pad to full width (including right padding)
551            if pad_to_width && *current_col < width {
552                let pad = width - *current_col;
553                for _ in 0..pad {
554                    out.push(' ');
555                }
556            }
557            // Right border (full block)
558            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            // Reset before newline so background doesn't extend to terminal edge
564            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            // New visual line after wrap: emit left margin + border + padding
572            // Left margin
573            for _ in 0..margin_x {
574                out.push(' ');
575            }
576            // Left border (full block)
577            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            // Re-apply active style after border
586            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            // Left padding
595            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
616/// Deduplicate spans and convert to ANSI-colored text using a theme.
617///
618/// This mirrors the HTML rendering logic but emits ANSI escape sequences
619/// instead of `<a-*>` tags, using `Theme::ansi_style` for each slot.
620pub 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
624/// ANSI rendering with additional configuration options.
625pub fn spans_to_ansi_with_options(
626    source: &str,
627    spans: Vec<Span>,
628    theme: &Theme,
629    options: &AnsiOptions,
630) -> String {
631    // Trim trailing newlines from source
632    let source = source.trim_end_matches('\n');
633
634    if spans.is_empty() {
635        return source.to_string();
636    }
637
638    // Sort spans by (start, -end) so longer spans come first at same start
639    let mut spans = spans;
640    spans.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.end.cmp(&a.end)));
641
642    // Deduplicate ranges - prefer spans with higher pattern_index (later in highlights.scm wins)
643    // This matches tree-sitter convention: later patterns override earlier ones
644    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            // Prefer spans with styling over unstyled spans
653            // Among equally-styled spans, prefer higher pattern_index (later in query)
654            let should_replace = match (new_has_slot, existing_has_slot) {
655                (true, false) => true,  // New has styling, existing doesn't
656                (false, true) => false, // Existing has styling, new doesn't
657                _ => span.pattern_index >= existing.pattern_index, // Both same styling status: higher pattern_index wins
658            };
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    // Normalize to highlight indices and coalesce adjacent spans with same style
670    #[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            // Filter out empty styles when using base style - they'll just use the base
683            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    // Sort by start
703    normalized.sort_by_key(|s| (s.start, s.end));
704
705    // Coalesce adjacent/overlapping spans with the same style index
706    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    // Build events from spans
722    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    // Track if we've output anything yet to avoid duplicate base style at start
744    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    // Minimum width to ensure usable output on narrow terminals
757    const MIN_WIDTH: usize = 10;
758
759    if let Some(width) = options.width.map(|w| w.max(MIN_WIDTH)) {
760        // Top margin (empty lines)
761        for _ in 0..margin_y {
762            out.push('\n');
763        }
764
765        // Top border row
766        if border {
767            // Left margin spaces
768            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        // Top padding rows (inside the background)
780        if padding_y > 0 {
781            for _ in 0..padding_y {
782                // Left margin
783                for _ in 0..margin_x {
784                    out.push(' ');
785                }
786                // Left border (full block)
787                if border {
788                    out.push_str(&border_style);
789                    out.push(BoxChars::LEFT);
790                }
791                // Apply base style for the padding content
792                if !base_ansi.is_empty() {
793                    out.push_str(&base_ansi);
794                    output_started = true;
795                }
796                // Inner width (minus border chars if present)
797                let inner = if border {
798                    width.saturating_sub(2)
799                } else {
800                    width
801                };
802                for _ in 0..inner {
803                    out.push(' ');
804                }
805                // Right border (full block)
806                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                // Reapply base style for next line
814                if !base_ansi.is_empty() {
815                    out.push_str(&base_ansi);
816                }
817            }
818        } else if !base_ansi.is_empty() {
819            // No top padding but we need base style for content
820            out.push_str(&base_ansi);
821            output_started = true;
822        }
823    } else {
824        // No width specified, just apply base style if needed
825        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                    // Style hasn't changed, just write text
840                    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                    // Style change: reset and apply new style
854                    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 using base_bg, the style already includes base colors, so don't emit base_ansi separately
861                    // If the style is identical to base, just emit base once
862                    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                    // First styled span or transitioning from unstyled to styled
885                    let style = if use_base_bg {
886                        theme.ansi_style_with_base_bg(d)
887                    } else {
888                        theme.ansi_style(d)
889                    };
890
891                    // When using base_bg, if the style is identical to base_ansi, don't emit it
892                    if !style.is_empty() && style != base_ansi {
893                        // Emit the style code
894                        out.push_str(&style);
895                        output_started = true;
896                    } else if !output_started && !base_ansi.is_empty() {
897                        // No distinct style, just ensure base is active
898                        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                    // Transitioning from styled to unstyled
917                    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                    // No styling, just plain text
936                    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 using base_bg, the style already includes base colors
989                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                // When using base_bg, if the style is identical to base_ansi, don't emit it
1018                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        // Inner width excludes border characters
1078        let inner_width = if border {
1079            width.saturating_sub(2)
1080        } else {
1081            width
1082        };
1083
1084        // Pad the final content line out to the full width.
1085        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        // Right border on final content line
1093        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        // Reset before newline so background doesn't extend to terminal edge
1100        out.push_str(Theme::ANSI_RESET);
1101
1102        // Bottom padding rows.
1103        if padding_y > 0 {
1104            for _ in 0..padding_y {
1105                out.push('\n');
1106                // Left margin
1107                for _ in 0..margin_x {
1108                    out.push(' ');
1109                }
1110                // Left border
1111                if border {
1112                    out.push_str(&border_style);
1113                    out.push(BoxChars::LEFT);
1114                }
1115                // Background fill
1116                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                // Right border
1128                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        // Bottom border row
1138        if border {
1139            out.push('\n');
1140            // Left margin spaces
1141            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        // Bottom margin (empty lines)
1152        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
1162/// Write spans as ANSI-colored text to a writer.
1163pub 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        // Different keyword captures should all map to "k" and coalesce
1201        let source = "with use import";
1202        let spans = vec![
1203            Span {
1204                start: 0,
1205                end: 4,
1206                capture: "include".into(), // nvim-treesitter name
1207                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        // All should use "k" tag - but they're not adjacent so still separate
1224        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        // Adjacent spans with same tag should merge
1232        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(), // Maps to same slot
1244                pattern_index: 0,
1245            },
1246        ];
1247        let html = spans_to_html(source, spans, &HtmlFormat::CustomElements);
1248        // Should be one tag, not two
1249        assert_eq!(html, "<a-k>keyword</a-k>");
1250    }
1251
1252    #[test]
1253    fn test_overlapping_spans_dedupe() {
1254        let source = "apiVersion";
1255        // Two spans for the same range - should keep only one
1256        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        // Should only have one tag, not two
1272        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, "&lt;script&gt;");
1282    }
1283
1284    #[test]
1285    fn test_nospell_filtered() {
1286        // Captures like "spell" and "nospell" should produce no output
1287        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        // No tags should be emitted
1304        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        // Source must be longer than MIN_CONTENT_WIDTH (10) to trigger wrapping
1366        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); // Must be > MIN_CONTENT_WIDTH (10) for wrapping to occur
1377        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        // When a node has @comment @spell, both produce spans with the same range.
1418        // The @spell should NOT overwrite @comment - we should keep @comment.
1419        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        // Should have comment styling, not be unstyled
1436        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        // Test a variety of different tags to ensure mapping works
1540        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        // Test ClassNames format
1579        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        // Create some fake spans that cover the whole file
1605        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        // This should not panic
1621        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        // Load the actual cpp grammar
1636        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        // Parse the sample
1647        let result = grammar.parse(&mut ctx, &sample);
1648
1649        println!("Got {} spans from parsing", result.spans.len());
1650
1651        // Check some spans for validity
1652        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        // Now try to render - this should not panic
1688        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 that pattern_index deduplication works correctly.
1694    ///
1695    /// This simulates what the plugin runtime returns: two spans covering the same
1696    /// text ("name") with different captures (@string and @property) and different
1697    /// pattern indices. The higher pattern_index should win.
1698    #[test]
1699    fn test_pattern_index_deduplication() {
1700        let source = "name value";
1701
1702        // Simulate plugin runtime output: both @string and @property cover "name"
1703        // @property has higher pattern_index (11) than @string (7)
1704        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        // "name" should be rendered as property (a-pr), not string (a-s)
1730        // because property has higher pattern_index
1731        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        // "value" should be rendered as string (a-s)
1738        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 that pattern_index deduplication works when @string has higher index.
1746    /// This is the opposite case - verifying the logic works both ways.
1747    #[test]
1748    fn test_pattern_index_deduplication_string_wins() {
1749        let source = "name";
1750
1751        // @string has higher pattern_index (11) than @property (7)
1752        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        // "name" should be rendered as string (a-s) because it has higher pattern_index
1772        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 that trailing newlines are trimmed from HTML output.
1780    /// This prevents extra whitespace at the bottom of code blocks
1781    /// when embedded in `<pre><code>` tags.
1782    #[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 that multiple trailing newlines are all trimmed.
1803    #[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}