Skip to main content

margin_term/
lib.rs

1//! Terminal rendering for `margin` layout plans.
2//!
3//! d[impl package.primary-split]
4
5use std::fmt::Write;
6
7use arborium_theme::{Theme as ArboriumTheme, ThemeSlot, slot_to_highlight_index};
8use margin::{
9    AnnotationRole, Diagnostics, LayoutError, LayoutOptions, LayoutPlan, Note, NoteKind,
10    PlacementMode, ResolvedSpan, Severity, SourceWindow, SyntaxClass, WindowAnnotation, plan,
11};
12use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13
14/// d[impl test.fixture-first]
15/// d[impl test.width-matrix]
16/// d[impl test.capability-matrix]
17/// d[impl test.unicode]
18#[cfg(test)]
19mod tests;
20
21/// d[impl glyph.unicode-ascii]
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum GlyphMode {
24    Unicode,
25    Ascii,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ColorLevel {
30    None,
31    Ansi16,
32    Rgb24,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum HyperlinkMode {
37    None,
38    Osc8,
39}
40
41/// d[impl api.renderer-options]
42/// d[impl term.explicit-capabilities]
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct TerminalCapabilities {
45    pub width: usize,
46    pub glyph_mode: GlyphMode,
47    pub color_level: ColorLevel,
48    pub hyperlink_mode: HyperlinkMode,
49    pub tab_width: usize,
50}
51
52/// d[impl theme.roles]
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Theme {
55    pub severity_error: Style,
56    pub severity_warning: Style,
57    pub severity_advice: Style,
58    pub primary_label: Style,
59    pub secondary_label: Style,
60    pub syntax_token: Style,
61    pub note: Style,
62    pub help: Style,
63    pub gutter: Style,
64    pub connector: Style,
65    pub emphasis: Style,
66    syntax_styles: [Style; SYNTAX_CLASS_COUNT],
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct Style {
71    ansi16: Option<u8>,
72    fg_rgb24: Option<Rgb24>,
73    bg_rgb24: Option<Rgb24>,
74    modifiers: TextModifiers,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78struct Rgb24 {
79    r: u8,
80    g: u8,
81    b: u8,
82}
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
85struct TextModifiers {
86    bold: bool,
87    italic: bool,
88    underline: bool,
89    strikethrough: bool,
90}
91
92const SYNTAX_CLASS_COUNT: usize = 27;
93
94impl Default for TerminalCapabilities {
95    fn default() -> Self {
96        Self {
97            width: 80,
98            glyph_mode: GlyphMode::Unicode,
99            color_level: ColorLevel::None,
100            hyperlink_mode: HyperlinkMode::None,
101            tab_width: 4,
102        }
103    }
104}
105
106impl Default for Theme {
107    fn default() -> Self {
108        Self {
109            severity_error: Style::ansi16(31),
110            severity_warning: Style::ansi16(33),
111            severity_advice: Style::ansi16(36),
112            primary_label: Style::ansi16(31),
113            secondary_label: Style::ansi16(33),
114            syntax_token: Style::ansi16(36),
115            note: Style::ansi16(36),
116            help: Style::ansi16(32),
117            gutter: Style::ansi16(90),
118            connector: Style::ansi16(90),
119            emphasis: Style::ansi16(35),
120            syntax_styles: std::array::from_fn(|index| {
121                fallback_syntax_style(syntax_class_from_index(index))
122            }),
123        }
124    }
125}
126
127impl Style {
128    const fn ansi16(code: u8) -> Self {
129        Self {
130            ansi16: Some(code),
131            fg_rgb24: None,
132            bg_rgb24: None,
133            modifiers: TextModifiers {
134                bold: false,
135                italic: false,
136                underline: false,
137                strikethrough: false,
138            },
139        }
140    }
141
142    const fn plain() -> Self {
143        Self {
144            ansi16: None,
145            fg_rgb24: None,
146            bg_rgb24: None,
147            modifiers: TextModifiers {
148                bold: false,
149                italic: false,
150                underline: false,
151                strikethrough: false,
152            },
153        }
154    }
155
156    fn from_arborium(theme: &ArboriumTheme, slot: ThemeSlot) -> Option<Self> {
157        let index = slot_to_highlight_index(slot)?;
158        let style = theme.style(index)?;
159        if style.is_empty() {
160            return None;
161        }
162
163        Some(Self {
164            ansi16: None,
165            fg_rgb24: style.fg.map(Rgb24::from),
166            bg_rgb24: style.bg.map(Rgb24::from),
167            modifiers: TextModifiers {
168                bold: style.modifiers.bold,
169                italic: style.modifiers.italic,
170                underline: style.modifiers.underline,
171                strikethrough: style.modifiers.strikethrough,
172            },
173        })
174    }
175
176    /// d[impl theme.capability-fallback]
177    fn has_effect(self, color_level: ColorLevel) -> bool {
178        match color_level {
179            ColorLevel::None => false,
180            ColorLevel::Ansi16 => {
181                self.ansi16.is_some()
182                    || self.modifiers.bold
183                    || self.modifiers.italic
184                    || self.modifiers.underline
185                    || self.modifiers.strikethrough
186            }
187            ColorLevel::Rgb24 => {
188                self.ansi16.is_some()
189                    || self.fg_rgb24.is_some()
190                    || self.bg_rgb24.is_some()
191                    || self.modifiers.bold
192                    || self.modifiers.italic
193                    || self.modifiers.underline
194                    || self.modifiers.strikethrough
195            }
196        }
197    }
198}
199
200pub fn layout(
201    diagnostics: &Diagnostics,
202    capabilities: TerminalCapabilities,
203) -> Result<LayoutPlan, LayoutError> {
204    plan(
205        diagnostics,
206        &LayoutOptions {
207            width: capabilities.width,
208            tab_width: capabilities.tab_width,
209            ..LayoutOptions::default()
210        },
211    )
212}
213
214pub fn render(
215    diagnostics: &Diagnostics,
216    capabilities: TerminalCapabilities,
217) -> Result<String, LayoutError> {
218    let plan = layout(diagnostics, capabilities)?;
219    Ok(render_plan(&plan, capabilities))
220}
221
222/// d[impl api.layout-render-separation]
223pub fn render_plan(plan: &LayoutPlan, capabilities: TerminalCapabilities) -> String {
224    render_plan_with_theme(plan, capabilities, Theme::default())
225}
226
227pub fn render_plan_with_theme(
228    plan: &LayoutPlan,
229    capabilities: TerminalCapabilities,
230    theme: Theme,
231) -> String {
232    let mut output = String::new();
233
234    for (report_index, report) in plan.reports.iter().enumerate() {
235        if report_index > 0 {
236            output.push('\n');
237        }
238
239        let severity = colorize(
240            severity_label(report.severity),
241            theme.style_for_severity(report.severity),
242            capabilities.color_level,
243        );
244        match &report.code {
245            Some(code) => {
246                let _ = writeln!(output, "{severity}[{code}]: {}", report.title);
247            }
248            None => {
249                let _ = writeln!(output, "{severity}: {}", report.title);
250            }
251        }
252
253        for window in &report.windows {
254            render_window(&mut output, window, capabilities, theme);
255        }
256
257        for note in &report.notes {
258            render_note(&mut output, note, capabilities, theme);
259        }
260
261        for section in &report.sections {
262            let _ = writeln!(
263                output,
264                "{} {}",
265                colorize(
266                    glyphs(capabilities.glyph_mode).branch,
267                    theme.connector,
268                    capabilities.color_level
269                ),
270                section.title
271            );
272            for note in &section.notes {
273                render_note(&mut output, note, capabilities, theme);
274            }
275        }
276    }
277
278    output
279}
280
281/// d[impl layout.ellipsis]
282fn render_window(
283    output: &mut String,
284    window: &SourceWindow,
285    capabilities: TerminalCapabilities,
286    theme: Theme,
287) {
288    let glyphs = glyphs(capabilities.glyph_mode);
289    let primary_position = window
290        .annotations
291        .iter()
292        .filter(|annotation| annotation.role == AnnotationRole::PrimaryLabel)
293        .flat_map(|annotation| &annotation.segments)
294        .min_by_key(|segment| (segment.line_number, segment.start_column));
295    let source_label = match primary_position {
296        Some(position) => format!(
297            "{}:{}:{}",
298            window.source_name,
299            position.line_number,
300            position.start_column + 1
301        ),
302        None => window.source_name.clone(),
303    };
304    let source_label = hyperlink_text(
305        source_label.as_str(),
306        window.source_hyperlink.as_deref(),
307        capabilities.hyperlink_mode,
308    );
309    let _ = writeln!(
310        output,
311        "{} {}",
312        colorize(glyphs.source, theme.connector, capabilities.color_level),
313        source_label
314    );
315    if window.omitted_before {
316        let _ = writeln!(
317            output,
318            "{} ...",
319            colorize(glyphs.separator, theme.gutter, capabilities.color_level)
320        );
321    }
322
323    for line in &window.lines {
324        let expanded = expand_tabs(&line.text, capabilities.tab_width);
325        let clipped = clip_to_width(&expanded, window.geometry.source_columns);
326        let styled = style_source_line(
327            clipped.as_str(),
328            line.line_number,
329            &window.annotations,
330            capabilities,
331            theme,
332        );
333        let separator = line_separator(&window.annotations, line.line_number, capabilities, theme);
334        let omission = if line.clipped { "..." } else { "" };
335        let _ = writeln!(
336            output,
337            "{:>width$} {} {}{}",
338            line.line_number,
339            separator,
340            styled,
341            omission,
342            width = window.geometry.line_number_width
343        );
344
345        for annotation in annotations_for_line(&window.annotations, line.line_number)
346            .into_iter()
347            .filter(|annotation| annotation.role != AnnotationRole::SyntaxToken)
348        {
349            render_annotation(output, annotation, window, capabilities, theme);
350        }
351    }
352
353    if window.omitted_after {
354        let _ = writeln!(
355            output,
356            "{} ...",
357            colorize(glyphs.separator, theme.gutter, capabilities.color_level)
358        );
359    }
360}
361
362fn style_source_line(
363    text: &str,
364    line_number: usize,
365    annotations: &[WindowAnnotation],
366    capabilities: TerminalCapabilities,
367    theme: Theme,
368) -> String {
369    if capabilities.color_level == ColorLevel::None {
370        return text.to_string();
371    }
372
373    let syntax_segments = annotations
374        .iter()
375        .filter(|annotation| annotation.role == AnnotationRole::SyntaxToken)
376        .flat_map(|annotation| {
377            annotation
378                .segments
379                .iter()
380                .copied()
381                .map(move |segment| (segment, annotation.syntax_class))
382        })
383        .filter(|(segment, _)| segment.line_number == line_number)
384        .collect::<Vec<_>>();
385
386    if syntax_segments.is_empty() {
387        return text.to_string();
388    }
389
390    let mut styled = String::new();
391    let mut run = String::new();
392    let mut current_style = None;
393    let mut column = 0;
394    for ch in text.chars() {
395        let width = UnicodeWidthChar::width(ch).unwrap_or(0);
396        let style = syntax_segments
397            .iter()
398            .find_map(|(segment, syntax_class)| {
399                let overlaps = if width == 0 {
400                    segment.start_column <= column && column < segment.end_column
401                } else {
402                    segment.start_column < column + width && column < segment.end_column
403                };
404                overlaps.then(|| theme.style_for_syntax_class(*syntax_class))
405            })
406            .filter(|style| style.has_effect(capabilities.color_level));
407
408        if style != current_style {
409            flush_styled_run(
410                &mut styled,
411                &mut run,
412                current_style,
413                capabilities.color_level,
414            );
415            current_style = style;
416        }
417        run.push(ch);
418
419        column += width;
420    }
421    flush_styled_run(
422        &mut styled,
423        &mut run,
424        current_style,
425        capabilities.color_level,
426    );
427
428    styled
429}
430
431fn flush_styled_run(
432    output: &mut String,
433    run: &mut String,
434    style: Option<Style>,
435    color_level: ColorLevel,
436) {
437    if run.is_empty() {
438        return;
439    }
440
441    match style {
442        Some(style) => output.push_str(&colorize(run.as_str(), style, color_level)),
443        None => output.push_str(run),
444    }
445    run.clear();
446}
447
448fn hyperlink_text(text: &str, target: Option<&str>, mode: HyperlinkMode) -> String {
449    match (mode, target) {
450        (HyperlinkMode::Osc8, Some(target)) => {
451            format!("\u{1b}]8;;{target}\u{1b}\\{text}\u{1b}]8;;\u{1b}\\")
452        }
453        _ => text.to_string(),
454    }
455}
456
457/// d[impl layout.notes-wrap]
458fn render_note(output: &mut String, note: &Note, capabilities: TerminalCapabilities, theme: Theme) {
459    let prefix = match note.kind {
460        NoteKind::Note => "note",
461        NoteKind::Help => "help",
462    };
463    let indent = format!("  = {prefix}: ");
464    let wrapped = wrap_text(
465        &note.text,
466        capabilities.width.saturating_sub(indent_width(&indent)),
467    );
468
469    for (index, line) in wrapped.iter().enumerate() {
470        if index == 0 {
471            let styled_indent = colorize(
472                indent.as_str(),
473                theme.style_for_note_kind(note.kind),
474                capabilities.color_level,
475            );
476            let _ = writeln!(output, "{styled_indent}{line}");
477        } else {
478            let _ = writeln!(output, "{}{line}", " ".repeat(indent_width(&indent)));
479        }
480    }
481}
482
483/// d[impl layout.multiline-labels]
484/// d[impl label.multiline-message-alignment]
485fn render_annotation(
486    output: &mut String,
487    annotation: RenderableAnnotation<'_>,
488    window: &SourceWindow,
489    capabilities: TerminalCapabilities,
490    theme: Theme,
491) {
492    let glyphs = glyphs(capabilities.glyph_mode);
493    let annotation_style = theme.style_for_annotation_role(annotation.role, annotation.severity);
494    if annotation.block {
495        if let Some(message) = annotation.message {
496            let gutter = format!(
497                "{:>width$} {} ",
498                "",
499                colorize(glyphs.block, annotation_style, capabilities.color_level),
500                width = window.geometry.line_number_width
501            );
502            let continuation_padding = " ".repeat(indent_width(glyphs.branch) + 1);
503            let available = window
504                .geometry
505                .source_columns
506                .saturating_sub(indent_width(glyphs.branch) + 1);
507            for (index, line) in wrap_text(message, available).into_iter().enumerate() {
508                let _ = writeln!(
509                    output,
510                    "{gutter}{}{line}",
511                    if index == 0 {
512                        format!(
513                            "{} ",
514                            colorize(glyphs.branch, annotation_style, capabilities.color_level)
515                        )
516                    } else {
517                        continuation_padding.clone()
518                    }
519                );
520            }
521        }
522        return;
523    }
524
525    let marker_line = marker_line(
526        annotation.segment,
527        annotation.placement,
528        capabilities.glyph_mode,
529    );
530    let colored = colorize(
531        marker_line.as_str(),
532        annotation_style,
533        capabilities.color_level,
534    );
535    let gutter = format!(
536        "{:>width$} {} ",
537        "",
538        colorize(glyphs.separator, theme.gutter, capabilities.color_level),
539        width = window.geometry.line_number_width
540    );
541
542    match annotation.placement {
543        PlacementMode::Side => {
544            let message = annotation
545                .message
546                .map(|message| format!(" {}", message))
547                .unwrap_or_default();
548            let _ = writeln!(output, "{gutter}{colored}{message}");
549        }
550        PlacementMode::BelowSpan => {
551            let _ = writeln!(output, "{gutter}{colored}");
552            if let Some(message) = annotation.message {
553                let anchor_padding = " ".repeat(annotation.segment.start_column);
554                let continuation_padding = " ".repeat(indent_width(glyphs.branch) + 1);
555                let available = window
556                    .geometry
557                    .source_columns
558                    .saturating_sub(annotation.segment.start_column + 2);
559                for (index, line) in wrap_text(message, available).into_iter().enumerate() {
560                    let _ = writeln!(
561                        output,
562                        "{gutter}{anchor_padding}{}{line}",
563                        if index == 0 {
564                            format!(
565                                "{} ",
566                                colorize(glyphs.branch, annotation_style, capabilities.color_level)
567                            )
568                        } else {
569                            continuation_padding.clone()
570                        }
571                    );
572                }
573            }
574        }
575        PlacementMode::Stacked => {
576            let _ = writeln!(output, "{gutter}{colored}");
577            if let Some(message) = annotation.message {
578                for line in wrap_text(message, window.geometry.source_columns.saturating_sub(4)) {
579                    let _ = writeln!(
580                        output,
581                        "{gutter}{} = {line}",
582                        colorize(glyphs.branch, annotation_style, capabilities.color_level)
583                    );
584                }
585            }
586        }
587    }
588}
589
590fn annotations_for_line<'a>(
591    annotations: &'a [WindowAnnotation],
592    line_number: usize,
593) -> Vec<RenderableAnnotation<'a>> {
594    let mut renderable = annotations
595        .iter()
596        .flat_map(|annotation| {
597            let block = is_gutter_block_annotation(annotation);
598            let message_owner = if block {
599                annotation.segments.last().copied()
600            } else {
601                annotation.segments.first().copied()
602            };
603            annotation
604                .segments
605                .iter()
606                .copied()
607                .filter(move |segment| {
608                    if block {
609                        message_owner == Some(*segment) && segment.line_number == line_number
610                    } else {
611                        segment.line_number == line_number
612                    }
613                })
614                .map(move |segment| RenderableAnnotation {
615                    block,
616                    message: (message_owner == Some(segment))
617                        .then_some(annotation.message.as_deref())
618                        .flatten(),
619                    placement: annotation.placement,
620                    role: annotation.role,
621                    severity: severity_for_role(annotation.role),
622                    priority: annotation.priority,
623                    segment,
624                })
625        })
626        .collect::<Vec<_>>();
627    renderable.sort_by_key(|item| std::cmp::Reverse(item.priority));
628    renderable
629}
630
631fn line_separator(
632    annotations: &[WindowAnnotation],
633    line_number: usize,
634    capabilities: TerminalCapabilities,
635    theme: Theme,
636) -> String {
637    let glyphs = glyphs(capabilities.glyph_mode);
638    if let Some(annotation) = annotations
639        .iter()
640        .filter(|annotation| is_gutter_block_annotation(annotation))
641        .filter(|annotation| {
642            annotation
643                .segments
644                .iter()
645                .any(|segment| segment.line_number == line_number)
646        })
647        .max_by_key(|annotation| annotation.priority)
648    {
649        let style =
650            theme.style_for_annotation_role(annotation.role, severity_for_role(annotation.role));
651        colorize(glyphs.block, style, capabilities.color_level)
652    } else {
653        colorize(glyphs.separator, theme.gutter, capabilities.color_level)
654    }
655}
656
657fn is_gutter_block_annotation(annotation: &WindowAnnotation) -> bool {
658    annotation.message.is_some()
659        && annotation.role != AnnotationRole::SyntaxToken
660        && annotation.segments.len() >= 3
661}
662
663fn marker_line(span: ResolvedSpan, placement: PlacementMode, glyph_mode: GlyphMode) -> String {
664    let (start, fill) = match (placement, glyph_mode) {
665        (PlacementMode::BelowSpan, GlyphMode::Unicode) => ('┬', '─'),
666        (_, GlyphMode::Unicode) => ('─', '─'),
667        (_, GlyphMode::Ascii) => ('^', '^'),
668    };
669    let width = span.end_column.saturating_sub(span.start_column).max(1);
670    let mut marker = String::with_capacity(span.start_column + width);
671    marker.push_str(&" ".repeat(span.start_column));
672    marker.push(start);
673    if width > 1 {
674        marker.push_str(&fill.to_string().repeat(width - 1));
675    }
676    marker
677}
678
679fn expand_tabs(text: &str, tab_width: usize) -> String {
680    let mut expanded = String::new();
681    let tab_width = tab_width.max(1);
682    let mut col = 0;
683
684    for ch in text.chars() {
685        if ch == '\t' {
686            let spaces = tab_width - (col % tab_width);
687            expanded.push_str(&" ".repeat(spaces));
688            col += spaces;
689        } else {
690            expanded.push(ch);
691            col += UnicodeWidthChar::width(ch).unwrap_or(0);
692        }
693    }
694
695    expanded
696}
697
698fn clip_to_width(text: &str, width: usize) -> String {
699    if width == 0 {
700        return String::new();
701    }
702
703    let mut clipped = String::new();
704    let mut used = 0;
705    for ch in text.chars() {
706        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
707        if used + ch_width > width {
708            break;
709        }
710        clipped.push(ch);
711        used += ch_width;
712    }
713    clipped
714}
715
716/// d[impl layout.no-terminal-wrap]
717fn wrap_text(text: &str, width: usize) -> Vec<String> {
718    if width == 0 {
719        return vec![String::new()];
720    }
721
722    let mut lines = Vec::new();
723    for paragraph in text.split('\n') {
724        let mut current = String::new();
725        let mut saw_content = false;
726        for word in paragraph.split_whitespace() {
727            saw_content = true;
728            push_wrapped_word(&mut lines, &mut current, word, width);
729        }
730        if current.is_empty() && !saw_content {
731            lines.push(String::new());
732        } else if !current.is_empty() {
733            lines.push(current);
734        }
735    }
736    lines
737}
738
739fn indent_width(text: &str) -> usize {
740    UnicodeWidthStr::width(text)
741}
742
743fn push_wrapped_word(lines: &mut Vec<String>, current: &mut String, word: &str, width: usize) {
744    let mut remaining = word;
745
746    loop {
747        let separator = if current.is_empty() { "" } else { " " };
748        let candidate = format!("{current}{separator}{remaining}");
749        if UnicodeWidthStr::width(candidate.as_str()) <= width {
750            if !current.is_empty() {
751                current.push(' ');
752            }
753            current.push_str(remaining);
754            return;
755        }
756
757        if !current.is_empty() {
758            lines.push(std::mem::take(current));
759            continue;
760        }
761
762        let (head, tail) = split_long_token(remaining, width);
763        lines.push(head.to_string());
764        if tail.is_empty() {
765            return;
766        }
767        remaining = tail;
768    }
769}
770
771fn split_long_token(token: &str, width: usize) -> (&str, &str) {
772    let mut used = 0;
773    let mut furthest_end = 0;
774    let mut preferred_end = None;
775
776    for (index, ch) in token.char_indices() {
777        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
778        if used + ch_width > width {
779            break;
780        }
781
782        used += ch_width;
783        furthest_end = index + ch.len_utf8();
784        if is_path_wrap_boundary(ch) {
785            preferred_end = Some(furthest_end);
786        }
787    }
788
789    let split_at = preferred_end.or_else(|| {
790        if furthest_end > 0 {
791            Some(furthest_end)
792        } else {
793            token.chars().next().map(char::len_utf8)
794        }
795    });
796    let split_at = split_at.unwrap_or(0);
797    (&token[..split_at], &token[split_at..])
798}
799
800fn is_path_wrap_boundary(ch: char) -> bool {
801    matches!(ch, '/' | '\\' | ':' | '.' | '-' | '_')
802}
803
804fn severity_label(severity: Severity) -> &'static str {
805    match severity {
806        Severity::Error => "error",
807        Severity::Warning => "warning",
808        Severity::Advice => "advice",
809    }
810}
811
812fn severity_for_role(role: AnnotationRole) -> Severity {
813    match role {
814        AnnotationRole::PrimaryLabel => Severity::Error,
815        AnnotationRole::SecondaryLabel => Severity::Warning,
816        AnnotationRole::RelatedLabel
817        | AnnotationRole::SyntaxToken
818        | AnnotationRole::SearchHighlight
819        | AnnotationRole::Selection
820        | AnnotationRole::Emphasis => Severity::Advice,
821    }
822}
823
824/// d[impl term.ansi-discipline]
825/// d[impl term.plaintext-mode]
826fn colorize(text: &str, style: Style, color_level: ColorLevel) -> String {
827    if color_level == ColorLevel::None {
828        return text.to_string();
829    }
830
831    let mut codes = Vec::<String>::new();
832    if style.modifiers.bold {
833        codes.push("1".to_string());
834    }
835    if style.modifiers.italic {
836        codes.push("3".to_string());
837    }
838    if style.modifiers.underline {
839        codes.push("4".to_string());
840    }
841    if style.modifiers.strikethrough {
842        codes.push("9".to_string());
843    }
844
845    match color_level {
846        ColorLevel::None => {}
847        ColorLevel::Ansi16 => {
848            if let Some(code) = style.ansi16 {
849                codes.push(code.to_string());
850            }
851        }
852        ColorLevel::Rgb24 => {
853            if let Some(fg) = style.fg_rgb24 {
854                codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
855            } else if let Some(code) = style.ansi16 {
856                codes.push(code.to_string());
857            }
858            if let Some(bg) = style.bg_rgb24 {
859                codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
860            }
861        }
862    }
863
864    if codes.is_empty() {
865        return text.to_string();
866    }
867    format!("\u{1b}[{}m{text}\u{1b}[0m", codes.join(";"))
868}
869
870fn glyphs(mode: GlyphMode) -> Glyphs {
871    match mode {
872        GlyphMode::Unicode => Glyphs {
873            source: "╭─>",
874            separator: "│",
875            branch: "╰─",
876            block: "▌",
877        },
878        GlyphMode::Ascii => Glyphs {
879            source: "-->",
880            separator: "|",
881            branch: "\\-",
882            block: "|",
883        },
884    }
885}
886
887#[derive(Debug, Clone, Copy)]
888struct Glyphs {
889    source: &'static str,
890    separator: &'static str,
891    branch: &'static str,
892    block: &'static str,
893}
894
895#[derive(Debug, Clone, Copy)]
896struct RenderableAnnotation<'a> {
897    block: bool,
898    message: Option<&'a str>,
899    placement: PlacementMode,
900    role: AnnotationRole,
901    severity: Severity,
902    priority: u16,
903    segment: ResolvedSpan,
904}
905
906impl Theme {
907    pub fn from_arborium(theme: &ArboriumTheme) -> Self {
908        let default = Self::default();
909
910        Self {
911            severity_error: Style::from_arborium(theme, ThemeSlot::Error)
912                .unwrap_or(default.severity_error),
913            severity_warning: Style::from_arborium(theme, ThemeSlot::Type)
914                .unwrap_or(default.secondary_label),
915            severity_advice: Style::from_arborium(theme, ThemeSlot::Function)
916                .unwrap_or(default.severity_advice),
917            primary_label: Style::from_arborium(theme, ThemeSlot::Error)
918                .unwrap_or(default.primary_label),
919            secondary_label: Style::from_arborium(theme, ThemeSlot::Type)
920                .unwrap_or(default.secondary_label),
921            syntax_token: Style::from_arborium(theme, ThemeSlot::Function)
922                .unwrap_or(default.syntax_token),
923            note: Style::from_arborium(theme, ThemeSlot::Comment).unwrap_or(default.note),
924            help: Style::from_arborium(theme, ThemeSlot::String).unwrap_or(default.help),
925            gutter: Style::from_arborium(theme, ThemeSlot::Comment).unwrap_or(default.gutter),
926            connector: Style::from_arborium(theme, ThemeSlot::Comment).unwrap_or(default.connector),
927            emphasis: Style::from_arborium(theme, ThemeSlot::Keyword).unwrap_or(default.emphasis),
928            syntax_styles: std::array::from_fn(|index| {
929                let class = syntax_class_from_index(index);
930                Style::from_arborium(theme, theme_slot_for_syntax_class(class))
931                    .unwrap_or(fallback_syntax_style(class))
932            }),
933        }
934    }
935
936    fn style_for_severity(self, severity: Severity) -> Style {
937        match severity {
938            Severity::Error => self.severity_error,
939            Severity::Warning => self.severity_warning,
940            Severity::Advice => self.severity_advice,
941        }
942    }
943
944    fn style_for_note_kind(self, kind: NoteKind) -> Style {
945        match kind {
946            NoteKind::Note => self.note,
947            NoteKind::Help => self.help,
948        }
949    }
950
951    fn style_for_annotation_role(self, role: AnnotationRole, severity: Severity) -> Style {
952        match role {
953            AnnotationRole::PrimaryLabel => self.primary_label,
954            AnnotationRole::SecondaryLabel => self.secondary_label,
955            AnnotationRole::SyntaxToken => self.syntax_token,
956            AnnotationRole::Emphasis => self.emphasis,
957            AnnotationRole::RelatedLabel
958            | AnnotationRole::SearchHighlight
959            | AnnotationRole::Selection => self.style_for_severity(severity),
960        }
961    }
962
963    fn style_for_syntax_class(self, syntax_class: Option<SyntaxClass>) -> Style {
964        syntax_class
965            .map(|class| self.syntax_styles[syntax_class_index(class)])
966            .unwrap_or(Style::plain())
967    }
968}
969
970impl From<arborium_theme::Color> for Rgb24 {
971    fn from(value: arborium_theme::Color) -> Self {
972        Self {
973            r: value.r,
974            g: value.g,
975            b: value.b,
976        }
977    }
978}
979
980fn fallback_syntax_style(class: SyntaxClass) -> Style {
981    match class {
982        SyntaxClass::Keyword
983        | SyntaxClass::Operator
984        | SyntaxClass::Macro
985        | SyntaxClass::Namespace
986        | SyntaxClass::Tag
987        | SyntaxClass::Title
988        | SyntaxClass::Strong
989        | SyntaxClass::Emphasis
990        | SyntaxClass::Link
991        | SyntaxClass::Literal
992        | SyntaxClass::Strikethrough => Style::ansi16(35),
993        SyntaxClass::Function | SyntaxClass::Constructor => Style::ansi16(36),
994        SyntaxClass::String | SyntaxClass::DiffAdd => Style::ansi16(32),
995        SyntaxClass::Comment | SyntaxClass::Punctuation => Style::ansi16(90),
996        SyntaxClass::Type | SyntaxClass::Attribute => Style::ansi16(33),
997        SyntaxClass::Constant
998        | SyntaxClass::Number
999        | SyntaxClass::Property
1000        | SyntaxClass::Label
1001        | SyntaxClass::Embedded => Style::ansi16(36),
1002        SyntaxClass::DiffDelete | SyntaxClass::Error => Style::ansi16(31),
1003        SyntaxClass::Variable => Style::plain(),
1004    }
1005}
1006
1007const fn syntax_class_index(class: SyntaxClass) -> usize {
1008    match class {
1009        SyntaxClass::Keyword => 0,
1010        SyntaxClass::Function => 1,
1011        SyntaxClass::String => 2,
1012        SyntaxClass::Comment => 3,
1013        SyntaxClass::Type => 4,
1014        SyntaxClass::Variable => 5,
1015        SyntaxClass::Constant => 6,
1016        SyntaxClass::Number => 7,
1017        SyntaxClass::Operator => 8,
1018        SyntaxClass::Punctuation => 9,
1019        SyntaxClass::Property => 10,
1020        SyntaxClass::Attribute => 11,
1021        SyntaxClass::Tag => 12,
1022        SyntaxClass::Macro => 13,
1023        SyntaxClass::Label => 14,
1024        SyntaxClass::Namespace => 15,
1025        SyntaxClass::Constructor => 16,
1026        SyntaxClass::Title => 17,
1027        SyntaxClass::Strong => 18,
1028        SyntaxClass::Emphasis => 19,
1029        SyntaxClass::Link => 20,
1030        SyntaxClass::Literal => 21,
1031        SyntaxClass::Strikethrough => 22,
1032        SyntaxClass::DiffAdd => 23,
1033        SyntaxClass::DiffDelete => 24,
1034        SyntaxClass::Embedded => 25,
1035        SyntaxClass::Error => 26,
1036    }
1037}
1038
1039const fn syntax_class_from_index(index: usize) -> SyntaxClass {
1040    match index {
1041        0 => SyntaxClass::Keyword,
1042        1 => SyntaxClass::Function,
1043        2 => SyntaxClass::String,
1044        3 => SyntaxClass::Comment,
1045        4 => SyntaxClass::Type,
1046        5 => SyntaxClass::Variable,
1047        6 => SyntaxClass::Constant,
1048        7 => SyntaxClass::Number,
1049        8 => SyntaxClass::Operator,
1050        9 => SyntaxClass::Punctuation,
1051        10 => SyntaxClass::Property,
1052        11 => SyntaxClass::Attribute,
1053        12 => SyntaxClass::Tag,
1054        13 => SyntaxClass::Macro,
1055        14 => SyntaxClass::Label,
1056        15 => SyntaxClass::Namespace,
1057        16 => SyntaxClass::Constructor,
1058        17 => SyntaxClass::Title,
1059        18 => SyntaxClass::Strong,
1060        19 => SyntaxClass::Emphasis,
1061        20 => SyntaxClass::Link,
1062        21 => SyntaxClass::Literal,
1063        22 => SyntaxClass::Strikethrough,
1064        23 => SyntaxClass::DiffAdd,
1065        24 => SyntaxClass::DiffDelete,
1066        25 => SyntaxClass::Embedded,
1067        _ => SyntaxClass::Error,
1068    }
1069}
1070
1071const fn theme_slot_for_syntax_class(class: SyntaxClass) -> ThemeSlot {
1072    match class {
1073        SyntaxClass::Keyword => ThemeSlot::Keyword,
1074        SyntaxClass::Function => ThemeSlot::Function,
1075        SyntaxClass::String => ThemeSlot::String,
1076        SyntaxClass::Comment => ThemeSlot::Comment,
1077        SyntaxClass::Type => ThemeSlot::Type,
1078        SyntaxClass::Variable => ThemeSlot::Variable,
1079        SyntaxClass::Constant => ThemeSlot::Constant,
1080        SyntaxClass::Number => ThemeSlot::Number,
1081        SyntaxClass::Operator => ThemeSlot::Operator,
1082        SyntaxClass::Punctuation => ThemeSlot::Punctuation,
1083        SyntaxClass::Property => ThemeSlot::Property,
1084        SyntaxClass::Attribute => ThemeSlot::Attribute,
1085        SyntaxClass::Tag => ThemeSlot::Tag,
1086        SyntaxClass::Macro => ThemeSlot::Macro,
1087        SyntaxClass::Label => ThemeSlot::Label,
1088        SyntaxClass::Namespace => ThemeSlot::Namespace,
1089        SyntaxClass::Constructor => ThemeSlot::Constructor,
1090        SyntaxClass::Title => ThemeSlot::Title,
1091        SyntaxClass::Strong => ThemeSlot::Strong,
1092        SyntaxClass::Emphasis => ThemeSlot::Emphasis,
1093        SyntaxClass::Link => ThemeSlot::Link,
1094        SyntaxClass::Literal => ThemeSlot::Literal,
1095        SyntaxClass::Strikethrough => ThemeSlot::Strikethrough,
1096        SyntaxClass::DiffAdd => ThemeSlot::DiffAdd,
1097        SyntaxClass::DiffDelete => ThemeSlot::DiffDelete,
1098        SyntaxClass::Embedded => ThemeSlot::Embedded,
1099        SyntaxClass::Error => ThemeSlot::Error,
1100    }
1101}