octorus 0.5.5

A TUI tool for GitHub PR review, designed for Helix editor users
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
//! Syntax highlighting module using tree-sitter and syntect.
//!
//! This module provides syntax highlighting for diff content using:
//! - **tree-sitter**: For supported languages (Rust, TypeScript, JavaScript, Go, Python)
//! - **syntect**: As a fallback for other languages (Vue, YAML, Markdown, etc.)
//!
//! ## Supported Languages (tree-sitter)
//!
//! - Rust (.rs)
//! - TypeScript (.ts, .tsx)
//! - JavaScript (.js, .jsx)
//! - Go (.go)
//! - Python (.py)
//!
//! ## Fallback Languages (syntect via two-face)
//!
//! - Vue (.vue)
//! - YAML (.yaml, .yml)
//! - TOML (.toml)
//! - Markdown (.md)
//! - SCSS (.scss)
//! - Svelte (.svelte)
//! - And many more...
//!
//! ## Theme Loading
//!
//! Themes are loaded from two sources:
//! 1. **Bundled themes**: two-face extras + Dracula (compiled into binary)
//! 2. **User themes**: `~/.config/octorus/themes/*.tmTheme` files
//!
//! User themes override bundled themes if they have the same name.

pub mod highlighter;
pub mod injection;
pub mod parser_pool;
pub mod themes;

pub use highlighter::{
    apply_line_highlights, collect_line_highlights, collect_line_highlights_with_injections,
    CstParseResult, Highlighter, LineHighlights,
};
pub use parser_pool::ParserPool;
pub use themes::ThemeStyleCache;

use std::io::Cursor;
use std::sync::OnceLock;

use lasso::Rodeo;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use xdg::BaseDirectories;

use crate::app::InternedSpan;

static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();

/// Bundled Dracula theme (compiled into binary)
const DRACULA_THEME: &[u8] = include_bytes!("../../themes/Dracula.tmTheme");

/// Get the global SyntaxSet instance.
/// This is lazily initialized on first access.
/// Uses two-face's extended syntax definitions for broader language support.
pub fn syntax_set() -> &'static SyntaxSet {
    SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines)
}

/// Get the global ThemeSet instance.
/// This is lazily initialized on first access.
///
/// Loads themes in the following order:
/// 1. Syntect default themes
/// 2. Bundled themes (Dracula)
/// 3. User themes from ~/.config/octorus/themes/
pub fn theme_set() -> &'static ThemeSet {
    THEME_SET.get_or_init(load_all_themes)
}

/// Load all themes from syntect defaults, bundled, and user sources.
///
/// Note: We use syntect's ThemeSet for flexibility (custom themes, string-based lookup).
/// two-face's EmbeddedLazyThemeSet is more limited but we benefit from its extended syntax set.
fn load_all_themes() -> ThemeSet {
    let mut themes = ThemeSet::load_defaults();

    // Load bundled Dracula theme
    if let Ok(theme) = ThemeSet::load_from_reader(&mut Cursor::new(DRACULA_THEME)) {
        themes.themes.insert("Dracula".to_string(), theme);
    }

    // Load user themes from ~/.config/octorus/themes/
    if let Ok(base_dirs) = BaseDirectories::with_prefix("octorus") {
        let user_themes_dir = base_dirs.get_config_home().join("themes");
        if user_themes_dir.is_dir() {
            if let Ok(entries) = std::fs::read_dir(&user_themes_dir) {
                for entry in entries.flatten() {
                    let path = entry.path();
                    if path.extension().is_some_and(|e| e == "tmTheme") {
                        if let Ok(theme) = ThemeSet::get_theme(&path) {
                            // Use filename without extension as theme name
                            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
                                themes.themes.insert(name.to_string(), theme);
                            }
                        }
                    }
                }
            }
        }
    }

    themes
}

/// List all available theme names.
pub fn available_themes() -> Vec<&'static str> {
    theme_set().themes.keys().map(|s| s.as_str()).collect()
}

/// Get the SyntaxReference for a file based on its extension.
///
/// # Arguments
/// * `filename` - The filename to get syntax for (e.g., "main.rs", "index.ts")
///
/// # Returns
/// * `Some(SyntaxReference)` - If a matching syntax was found
/// * `None` - If the extension is unknown or the file has no extension
pub fn syntax_for_file(filename: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
    let ext = std::path::Path::new(filename)
        .extension()
        .and_then(|e| e.to_str())?;
    syntax_set().find_syntax_by_extension(ext)
}

/// Get a theme by name with fallback to default themes.
///
/// Theme matching is case-insensitive. Falls back to "base16-ocean.dark"
/// if the specified theme is not found, and falls back to any available
/// theme if that also fails.
///
/// # Arguments
/// * `name` - The name of the theme to get
///
/// # Returns
/// A reference to the theme
pub fn get_theme(name: &str) -> &'static syntect::highlighting::Theme {
    let themes = &theme_set().themes;

    // Try exact match first
    if let Some(theme) = themes.get(name) {
        return theme;
    }

    // Try case-insensitive match
    let name_lower = name.to_lowercase();
    for (key, theme) in themes.iter() {
        if key.to_lowercase() == name_lower {
            return theme;
        }
    }

    // Fallback to default themes
    themes
        .get("base16-ocean.dark")
        .or_else(|| themes.values().next())
        .expect("syntect default themes should never be empty")
}

/// Highlight a code line and return a vector of InternedSpans.
///
/// # Arguments
/// * `code` - The code line to highlight
/// * `highlighter` - A mutable reference to the HighlightLines instance
/// * `interner` - A mutable reference to the string interner
///
/// # Returns
/// A vector of `InternedSpan` with syntax highlighting applied.
/// If highlighting fails, returns plain text with no styling.
pub fn highlight_code_line(
    code: &str,
    highlighter: &mut HighlightLines<'_>,
    interner: &mut Rodeo,
) -> Vec<InternedSpan> {
    match highlighter.highlight_line(code, syntax_set()) {
        Ok(ranges) => ranges
            .into_iter()
            .map(|(style, text)| {
                // Intern the text to avoid allocations for duplicate tokens
                InternedSpan {
                    content: interner.get_or_intern(text),
                    style: convert_syntect_style(&style),
                }
            })
            .collect(),
        Err(_e) => {
            #[cfg(debug_assertions)]
            eprintln!("Highlight error: {_e:?}");
            vec![InternedSpan {
                content: interner.get_or_intern(code),
                style: Style::default(),
            }]
        }
    }
}

/// Highlight a code line and return a vector of owned Spans (legacy API).
///
/// This function is kept for backward compatibility with tests.
/// For production code, prefer `highlight_code_line` with interner.
pub fn highlight_code_line_legacy(
    code: &str,
    highlighter: &mut HighlightLines<'_>,
) -> Vec<Span<'static>> {
    match highlighter.highlight_line(code, syntax_set()) {
        Ok(ranges) => ranges
            .into_iter()
            .map(|(style, text)| Span::styled(text.to_string(), convert_syntect_style(&style)))
            .collect(),
        Err(_e) => {
            #[cfg(debug_assertions)]
            eprintln!("Highlight error: {_e:?}");
            vec![Span::raw(code.to_string())]
        }
    }
}

/// Convert syntect Style to ratatui Style.
///
/// Note: Background color is intentionally NOT applied. Syntect themes define
/// a background color for the entire editor, but in a TUI diff viewer, we want
/// to preserve the terminal's background color for better visual consistency.
pub fn convert_syntect_style(style: &syntect::highlighting::Style) -> Style {
    let mut ratatui_style = Style::default();

    // Convert foreground color
    if style.foreground.a > 0 {
        ratatui_style = ratatui_style.fg(Color::Rgb(
            style.foreground.r,
            style.foreground.g,
            style.foreground.b,
        ));
    }

    // Background color is NOT applied - we want to keep the terminal's background
    // The theme's background is meant for the whole editor, not per-token

    // Convert font style
    if style
        .font_style
        .contains(syntect::highlighting::FontStyle::BOLD)
    {
        ratatui_style = ratatui_style.add_modifier(Modifier::BOLD);
    }
    if style
        .font_style
        .contains(syntect::highlighting::FontStyle::ITALIC)
    {
        ratatui_style = ratatui_style.add_modifier(Modifier::ITALIC);
    }
    if style
        .font_style
        .contains(syntect::highlighting::FontStyle::UNDERLINE)
    {
        ratatui_style = ratatui_style.add_modifier(Modifier::UNDERLINED);
    }

    ratatui_style
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_syntax_for_file_known_extension() {
        // Common extensions from syntect defaults
        assert!(syntax_for_file("main.rs").is_some());
        assert!(syntax_for_file("script.py").is_some());
        assert!(syntax_for_file("main.go").is_some());
        assert!(syntax_for_file("index.js").is_some());
        assert!(syntax_for_file("style.css").is_some());
        assert!(syntax_for_file("index.html").is_some());

        // Extensions added by two-face (bat syntax definitions)
        assert!(
            syntax_for_file("index.ts").is_some(),
            "TypeScript should be supported"
        );
        assert!(
            syntax_for_file("app.tsx").is_some(),
            "TSX should be supported"
        );
        assert!(
            syntax_for_file("app.vue").is_some(),
            "Vue should be supported"
        );
        assert!(
            syntax_for_file("config.toml").is_some(),
            "TOML should be supported"
        );
        assert!(
            syntax_for_file("style.scss").is_some(),
            "SCSS should be supported"
        );
        assert!(
            syntax_for_file("App.svelte").is_some(),
            "Svelte should be supported"
        );
        // Note: .jsx is NOT supported by two-face/bat, use .tsx instead

        // Test with path-like filenames (as returned by GitHub API)
        assert!(
            syntax_for_file("src/app.rs").is_some(),
            "src/app.rs should have syntax"
        );
        assert!(syntax_for_file("src/ui/diff_view.rs").is_some());
        assert!(syntax_for_file("src/components/Button.vue").is_some());
    }

    #[test]
    fn test_syntax_for_file_unknown_extension() {
        assert!(syntax_for_file("file.unknown_ext_xyz").is_none());
    }

    #[test]
    fn test_syntax_for_file_no_extension() {
        assert!(syntax_for_file("Makefile").is_none());
        assert!(syntax_for_file("README").is_none());
    }

    #[test]
    fn test_get_theme_existing() {
        let theme = get_theme("base16-ocean.dark");
        // Should not panic
        assert!(!theme.scopes.is_empty() || theme.settings.background.is_some());
    }

    #[test]
    fn test_get_theme_fallback() {
        // Non-existent theme should fall back without panic
        let theme = get_theme("non_existent_theme_xyz");
        assert!(!theme.scopes.is_empty() || theme.settings.background.is_some());
    }

    #[test]
    fn test_highlight_code_line_rust() {
        let syntax = syntax_for_file("test.rs").unwrap();
        let theme = get_theme("base16-ocean.dark");
        let mut highlighter = HighlightLines::new(syntax, theme);

        let spans = highlight_code_line_legacy("let app = Self {", &mut highlighter);
        assert!(!spans.is_empty());

        // Verify that "let" keyword has a foreground color (syntax highlighting applied)
        let let_span = spans.iter().find(|s| s.content.as_ref() == "let");
        assert!(let_span.is_some(), "Should have a span for 'let'");
        let let_style = let_span.unwrap().style;
        assert!(let_style.fg.is_some(), "'let' should have foreground color");

        // Verify that background color is NOT applied (we preserve terminal background)
        assert!(
            let_style.bg.is_none(),
            "'let' should NOT have background color"
        );
    }

    #[test]
    fn test_highlight_code_line_empty() {
        let syntax = syntax_for_file("test.rs").unwrap();
        let theme = get_theme("base16-ocean.dark");
        let mut highlighter = HighlightLines::new(syntax, theme);

        let spans = highlight_code_line_legacy("", &mut highlighter);
        // Empty line should produce empty or single empty span
        assert!(spans.is_empty() || (spans.len() == 1 && spans[0].content.is_empty()));
    }

    #[test]
    fn test_bundled_dracula_theme() {
        // Dracula theme should be available from bundled themes
        let theme = get_theme("Dracula");
        assert!(!theme.scopes.is_empty(), "Dracula should have scopes");
    }

    #[test]
    fn test_available_themes_includes_defaults_and_bundled() {
        let themes = available_themes();
        // Should include syntect defaults
        assert!(
            themes.contains(&"base16-ocean.dark"),
            "Should include base16-ocean.dark"
        );
        // Should include bundled Dracula
        assert!(themes.contains(&"Dracula"), "Should include Dracula");
    }

    #[test]
    fn test_highlight_with_dracula() {
        let syntax = syntax_for_file("test.rs").unwrap();
        let theme = get_theme("Dracula");
        let mut highlighter = HighlightLines::new(syntax, theme);

        let spans = highlight_code_line_legacy("fn main() {", &mut highlighter);
        assert!(!spans.is_empty());

        // fn keyword should be highlighted
        let fn_span = spans.iter().find(|s| s.content.as_ref() == "fn");
        assert!(fn_span.is_some(), "Should have a span for 'fn'");
        assert!(
            fn_span.unwrap().style.fg.is_some(),
            "'fn' should have foreground color"
        );
    }

    #[test]
    fn test_get_theme_case_insensitive() {
        // Theme names should match case-insensitively
        let theme1 = get_theme("Dracula");
        let theme2 = get_theme("dracula");
        let theme3 = get_theme("DRACULA");

        // All should return the same Dracula theme (not fallback)
        assert!(!theme1.scopes.is_empty());
        assert!(!theme2.scopes.is_empty());
        assert!(!theme3.scopes.is_empty());

        // Verify they have the same number of scopes (same theme)
        assert_eq!(theme1.scopes.len(), theme2.scopes.len());
        assert_eq!(theme1.scopes.len(), theme3.scopes.len());
    }

    #[test]
    fn test_highlight_code_line_typescript() {
        let syntax = syntax_for_file("test.ts").unwrap();
        let theme = get_theme("base16-ocean.dark");
        let mut highlighter = HighlightLines::new(syntax, theme);

        let spans = highlight_code_line_legacy("const foo: string = 'bar';", &mut highlighter);
        assert!(!spans.is_empty());

        // Verify that "const" keyword has a foreground color (syntax highlighting applied)
        let const_span = spans.iter().find(|s| s.content.as_ref() == "const");
        assert!(const_span.is_some(), "Should have a span for 'const'");
        assert!(
            const_span.unwrap().style.fg.is_some(),
            "'const' should have foreground color"
        );
    }

    #[test]
    fn test_highlight_code_line_vue() {
        let syntax = syntax_for_file("test.vue").unwrap();
        let theme = get_theme("Dracula");
        let mut highlighter = HighlightLines::new(syntax, theme);

        let spans = highlight_code_line_legacy("<template>", &mut highlighter);
        assert!(!spans.is_empty());
    }

    #[test]
    fn test_highlight_code_line_with_interner() {
        let syntax = syntax_for_file("test.rs").unwrap();
        let theme = get_theme("base16-ocean.dark");
        let mut highlighter = HighlightLines::new(syntax, theme);
        let mut interner = Rodeo::default();

        let spans = highlight_code_line("let app = Self {", &mut highlighter, &mut interner);
        assert!(!spans.is_empty());

        // Verify that the interner contains the expected tokens
        for span in &spans {
            let text = interner.resolve(&span.content);
            assert!(!text.is_empty() || spans.len() == 1);
        }
    }

    #[test]
    fn test_interner_deduplication() {
        let syntax = syntax_for_file("test.rs").unwrap();
        let theme = get_theme("base16-ocean.dark");
        let mut highlighter = HighlightLines::new(syntax, theme);
        let mut interner = Rodeo::default();

        // Highlight two lines with the same keyword
        let spans1 = highlight_code_line("let x = 1;", &mut highlighter, &mut interner);
        let spans2 = highlight_code_line("let y = 2;", &mut highlighter, &mut interner);

        // Find "let" in both spans - they should have the same Spur
        let let_spur1 = spans1
            .iter()
            .find(|s| interner.resolve(&s.content) == "let")
            .map(|s| s.content);
        let let_spur2 = spans2
            .iter()
            .find(|s| interner.resolve(&s.content) == "let")
            .map(|s| s.content);

        assert!(let_spur1.is_some(), "First line should contain 'let'");
        assert!(let_spur2.is_some(), "Second line should contain 'let'");
        assert_eq!(
            let_spur1, let_spur2,
            "Both 'let' tokens should have the same Spur (interned)"
        );
    }
}