Skip to main content

anchor_cli/debugger/
highlight.rs

1//! Syntax highlighting for the source + instruction panes.
2//!
3//! Backed by [`syntect`] (Sublime-syntax engine, used by `bat`, `delta`,
4//! `gitui`) with [`two-face`] for grammar coverage.
5//! No tokenizer or grammar is implemented here — we don't ship any custom
6//! parsing logic.
7//!
8//! `two-face` doesn't yet ship a SBPF grammar. We use its bundled
9//! GAS-assembly syntax for the disassembly pane: it correctly colors
10//! registers (`r0`-`r11`), immediates (`0x...`, decimals), labels,
11//! brackets, and `;` comments. SBPF mnemonics like `mov64` / `lddw` /
12//! `ja` aren't in GAS's keyword set, so they render in the default
13//! foreground — acceptable, and we keep the option open to swap in a
14//! dedicated SBPF syntax later without changing call sites.
15
16use {
17    ratatui::text::{Line, Span},
18    std::sync::OnceLock,
19    syntect::{
20        easy::HighlightLines,
21        highlighting::{Theme, ThemeSet},
22        parsing::{syntax_definition::SyntaxDefinition, SyntaxReference, SyntaxSet},
23    },
24    terminal_colorsaurus::{theme_mode, QueryOptions, ThemeMode},
25};
26
27/// Sublime-syntax grammar for Solana SBPF disassembly. Compiled into
28/// syntect's engine at startup; declarative — no Rust code parses asm.
29const SBPF_SYNTAX_YAML: &str = include_str!("sbpf.sublime-syntax");
30
31/// Terminal background mode detected at startup. `bat` / `delta` both
32/// detect once before any screen takeover — in-TUI detection can race
33/// the OSC 11 reply against ratatui's own output. Set via
34/// [`detect_theme_mode_once`] from the entrypoint, read lazily by [`ctx`].
35static DETECTED_MODE: OnceLock<ThemeMode> = OnceLock::new();
36
37/// Probe the terminal once for its background color and cache the result.
38/// Call this exactly once, before the ratatui terminal guard takes over
39/// stdout. Idempotent: subsequent calls are no-ops.
40///
41/// On detection failure (non-TTY, query unsupported, timeout) the cache
42/// is set to [`ThemeMode::Dark`], which matches the most common dev-terminal
43/// configuration.
44pub fn detect_theme_mode_once() {
45    let _ = DETECTED_MODE.set(theme_mode(QueryOptions::default()).unwrap_or(ThemeMode::Dark));
46}
47
48struct Ctx {
49    syntaxes: SyntaxSet,
50    theme: Theme,
51    rust_syntax: SyntaxReference,
52    asm_syntax: SyntaxReference,
53}
54
55fn ctx() -> &'static Ctx {
56    static C: OnceLock<Ctx> = OnceLock::new();
57    C.get_or_init(|| {
58        // Start from `two-face`'s curated set (Rust + many others) and
59        // augment it with our embedded SBPF grammar so the asm pane
60        // colours real SBPF instead of mis-applied NASM. The grammar is
61        // compiled at startup; failure to parse it is a build-time bug
62        // that we surface by panicking — better than silently rendering
63        // monochrome asm.
64        let mut builder = two_face::syntax::extra_newlines().into_builder();
65        let sbpf_def =
66            SyntaxDefinition::load_from_str(SBPF_SYNTAX_YAML, true, Some("SBPF Assembly"))
67                .expect("compile bundled sbpf.sublime-syntax");
68        builder.add(sbpf_def);
69        let syntaxes = builder.build();
70        let themes = ThemeSet::load_defaults();
71        // Pick a syntect theme that reads against the detected background.
72        // `DETECTED_MODE` is populated by `detect_theme_mode_once()` from
73        // the TUI entrypoint; if the caller forgot, we treat that as dark
74        // (the more common dev-terminal default).
75        let (dark_name, light_name) = ("base16-eighties.dark", "Solarized (light)");
76        let theme_name = match DETECTED_MODE.get() {
77            Some(ThemeMode::Light) => light_name,
78            _ => dark_name,
79        };
80        let theme = themes
81            .themes
82            .get(theme_name)
83            .cloned()
84            .or_else(|| themes.themes.get(dark_name).cloned())
85            .unwrap_or_else(|| {
86                themes
87                    .themes
88                    .values()
89                    .next()
90                    .cloned()
91                    .expect("default theme")
92            });
93
94        let rust_syntax = syntaxes
95            .find_syntax_by_token("rs")
96            .or_else(|| syntaxes.find_syntax_by_name("Rust"))
97            .unwrap_or_else(|| syntaxes.find_syntax_plain_text())
98            .clone();
99
100        // Prefer our SBPF grammar; fall back to NASM only if the embedded
101        // syntax somehow failed to register (would only happen if the
102        // builder change above regressed).
103        let asm_syntax = syntaxes
104            .find_syntax_by_name("SBPF Assembly")
105            .or_else(|| syntaxes.find_syntax_by_name("Assembly x86 (NASM)"))
106            .or_else(|| syntaxes.find_syntax_by_token("asm"))
107            .unwrap_or_else(|| syntaxes.find_syntax_plain_text())
108            .clone();
109
110        Ctx {
111            syntaxes,
112            theme,
113            rust_syntax,
114            asm_syntax,
115        }
116    })
117}
118
119/// Highlight one line of Rust source. Returns an owned [`Line`] of styled
120/// spans suitable for handing to [`ratatui::widgets::Paragraph::new`].
121///
122/// Stateless per-line highlighting — multi-line strings or block comments
123/// may render with the wrong colour past their first line. Acceptable for
124/// the source pane's ~30-line window; `bat` uses the same trade-off when
125/// rendering small slices.
126pub fn highlight_rust(line: &str) -> Line<'static> {
127    highlight_with(&ctx().rust_syntax, line)
128}
129
130/// Highlight one line of disassembly. Falls back to the plain-text syntax
131/// if no asm grammar resolved.
132pub fn highlight_asm(line: &str) -> Line<'static> {
133    highlight_with(&ctx().asm_syntax, line)
134}
135
136/// Convert a [`syntect::highlighting::Style`] and some text to a [`ratatui::style::style`].
137fn syntect_style_to_ratatui(style: syntect::highlighting::Style) -> ratatui::style::Style {
138    fn translate_colour(
139        syntect_color: syntect::highlighting::Color,
140    ) -> Option<ratatui::style::Color> {
141        match syntect_color {
142            syntect::highlighting::Color { r, g, b, a } if a > 0 => {
143                Some(ratatui::style::Color::Rgb(r, g, b))
144            }
145            _ => None,
146        }
147    }
148
149    fn translate_font_style(
150        syntect_font_style: syntect::highlighting::FontStyle,
151    ) -> ratatui::style::Modifier {
152        use {ratatui::style::Modifier, syntect::highlighting::FontStyle};
153        match syntect_font_style {
154            x if x == FontStyle::empty() => Modifier::empty(),
155            x if x == FontStyle::BOLD => Modifier::BOLD,
156            x if x == FontStyle::ITALIC => Modifier::ITALIC,
157            x if x == FontStyle::UNDERLINE => Modifier::UNDERLINED,
158            x if x == FontStyle::BOLD | FontStyle::ITALIC => Modifier::BOLD | Modifier::ITALIC,
159            x if x == FontStyle::BOLD | FontStyle::UNDERLINE => {
160                Modifier::BOLD | Modifier::UNDERLINED
161            }
162            x if x == FontStyle::ITALIC | FontStyle::UNDERLINE => {
163                Modifier::ITALIC | Modifier::UNDERLINED
164            }
165            x if x == FontStyle::BOLD | FontStyle::ITALIC | FontStyle::UNDERLINE => {
166                Modifier::BOLD | Modifier::ITALIC | Modifier::UNDERLINED
167            }
168            _ => Modifier::empty(),
169        }
170    }
171
172    ratatui::style::Style {
173        fg: translate_colour(style.foreground),
174        // Strip the syntect theme's bg color — letting it through
175        // paints a colored rectangle behind every token because the
176        // theme assumes its own background, not the user's
177        // terminal's.
178        bg: None,
179        underline_color: translate_colour(style.foreground),
180        add_modifier: translate_font_style(style.font_style),
181        sub_modifier: ratatui::style::Modifier::empty(),
182    }
183}
184
185fn highlight_with(syntax: &SyntaxReference, line: &str) -> Line<'static> {
186    let c = ctx();
187    let mut hl = HighlightLines::new(syntax, &c.theme);
188    // syntect expects a trailing newline; appending one keeps the highlight
189    // engine from breaking on lines that don't end with `\n`.
190    let owned_line = if line.ends_with('\n') {
191        line.to_owned()
192    } else {
193        format!("{line}\n")
194    };
195    let regions = match hl.highlight_line(&owned_line, &c.syntaxes) {
196        Ok(r) => r,
197        Err(_) => return Line::from(Span::raw(line.to_owned())),
198    };
199    let spans: Vec<Span<'static>> = regions
200        .into_iter()
201        .filter_map(|(style, text)| {
202            // Drop the trailing `\n` we appended so the rendered line
203            // doesn't include a stray newline span.
204            let trimmed = text.trim_end_matches('\n');
205            if trimmed.is_empty() {
206                return None;
207            }
208            let style = syntect_style_to_ratatui(style);
209            Some(Span::styled(trimmed.to_owned(), style))
210        })
211        .collect();
212    if spans.is_empty() {
213        Line::from(Span::raw(line.to_owned()))
214    } else {
215        Line::from(spans)
216    }
217}