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}