Skip to main content

clankerdiff_ratatui/
syntax.rs

1//! Conversion of renderer-neutral syntax spans to Ratatui text.
2
3use crate::color::native_color;
4pub use clankerdiff_syntax::{
5    CacheConfig, CacheKey, CacheUsage, DocumentHighlights, Fingerprint, FontStyle, HighlightSpan,
6    HighlightStats, LanguageHint, Rgba, SyntaxError, SyntaxHighlighter, SyntaxStream,
7    SyntaxStreamUpdate, SyntaxStyle, SyntaxTheme, SyntaxWorkStats, ThemedHighlighter, empty_spans,
8    resolve_language,
9};
10use ratatui::{
11    style::{Color, Modifier, Style},
12    text::{Line, Span},
13};
14
15/// Converts ordered UTF-8 byte spans into a fully-owned Ratatui line.
16///
17/// Unhighlighted gaps retain `base`. Highlight styles patch `base`, so caller
18/// backgrounds and modifiers are preserved rather than replaced.
19#[must_use]
20pub fn highlighted_line(source: &str, spans: &[HighlightSpan], base: Style) -> Line<'static> {
21    let mut output = Vec::with_capacity(spans.len().saturating_mul(2).saturating_add(1));
22    let mut cursor = 0;
23    for span in spans {
24        let start = span.range.start.max(cursor);
25        let end = span.range.end;
26        if start >= end
27            || end > source.len()
28            || !source.is_char_boundary(start)
29            || !source.is_char_boundary(end)
30        {
31            continue;
32        }
33        if cursor < start {
34            output.push(Span::styled(source[cursor..start].to_owned(), base));
35        }
36        let mut modifiers = Modifier::empty();
37        modifiers.set(Modifier::BOLD, span.font_style.bold);
38        modifiers.set(Modifier::ITALIC, span.font_style.italic);
39        modifiers.set(Modifier::UNDERLINED, span.font_style.underline);
40        let syntax = Style::new()
41            .fg(native_color(
42                span.foreground,
43                base.bg.unwrap_or(Color::Black),
44            ))
45            .add_modifier(modifiers);
46        output.push(Span::styled(
47            source[start..end].to_owned(),
48            base.patch(syntax),
49        ));
50        cursor = end;
51    }
52    if cursor < source.len() {
53        output.push(Span::styled(source[cursor..].to_owned(), base));
54    }
55    if output.is_empty() {
56        output.push(Span::styled(source.to_owned(), base));
57    }
58    Line::from(output)
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use clankerdiff_theme::{FontStyle, Rgba};
65
66    #[test]
67    fn preserves_utf8_gaps_and_base_background() {
68        let base = Style::new().bg(Color::Blue).add_modifier(Modifier::DIM);
69        let line = highlighted_line(
70            "aéz",
71            &[HighlightSpan {
72                range: 1..3,
73                foreground: Rgba::new(1, 2, 3, 255),
74                font_style: FontStyle {
75                    bold: true,
76                    italic: false,
77                    underline: false,
78                },
79            }],
80            base,
81        );
82        assert_eq!(line.to_string(), "aéz");
83        assert_eq!(line.spans[1].style.bg, Some(Color::Blue));
84        assert!(line.spans[1].style.add_modifier.contains(Modifier::BOLD));
85        assert!(line.spans[1].style.add_modifier.contains(Modifier::DIM));
86    }
87}