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