Skip to main content

sparrow/tui/formatters/
code.rs

1// ─── Code syntax highlighting via syntect ─────────────────────────────────────
2// Uses syntect for syntax highlighting and emits 24-bit ANSI terminal escapes.
3
4use anyhow::{Context, Result};
5use syntect::easy::HighlightLines;
6use syntect::highlighting::ThemeSet;
7use syntect::parsing::SyntaxSet;
8use syntect::util::LinesWithEndings;
9
10/// Default syntax theme used when none is specified.
11const DEFAULT_THEME: &str = "base16-ocean.dark";
12
13/// Highlight a code string with syntax highlighting, producing an ANSI-coloured
14/// String suitable for terminal output.
15///
16/// If `language` is empty or unrecognised, falls back to plain text.
17/// If `theme` is empty, uses the built-in `base16-ocean.dark` theme.
18pub fn highlight(code: &str, language: &str, theme: &str) -> Result<String> {
19    let ss = SyntaxSet::load_defaults_newlines();
20    let ts = ThemeSet::load_defaults();
21
22    let syntax = language_syntax(&ss, language);
23    let theme = ts
24        .themes
25        .get(if theme.is_empty() { DEFAULT_THEME } else { theme })
26        .unwrap_or_else(|| {
27            ts.themes
28                .get(DEFAULT_THEME)
29                .expect("built-in theme should exist")
30        });
31
32    let mut h = HighlightLines::new(syntax, theme);
33    let mut out = String::with_capacity(code.len() * 2);
34
35    for line in LinesWithEndings::from(code) {
36        let ranges = h
37            .highlight_line(line, &ss)
38            .context("syntax highlight failed")?;
39        let escaped = syntect::util::as_24_bit_terminal_escaped(&ranges[..], false);
40        out.push_str(&escaped);
41    }
42
43    Ok(out)
44}
45
46/// Highlight a code string with line numbers prepended on the left.
47///
48/// Line numbers are dimmed grey so they don't distract from the code itself.
49pub fn highlight_with_line_numbers(code: &str, language: &str, theme: &str) -> Result<String> {
50    let ss = SyntaxSet::load_defaults_newlines();
51    let ts = ThemeSet::load_defaults();
52
53    let syntax = language_syntax(&ss, language);
54    let theme = ts
55        .themes
56        .get(if theme.is_empty() { DEFAULT_THEME } else { theme })
57        .unwrap_or_else(|| {
58            ts.themes
59                .get(DEFAULT_THEME)
60                .expect("built-in theme should exist")
61        });
62
63    let mut h = HighlightLines::new(syntax, theme);
64    let lines: Vec<&str> = LinesWithEndings::from(code).collect();
65    let total = lines.len();
66    let gutter_width = if total == 0 { 1 } else { total.to_string().len() };
67
68    let dim_reset = "\x1b[0m";
69    // Dim grey for line numbers: \x1b[38;2;100;100;100m
70    let dim_prefix = "\x1b[38;2;100;100;100m";
71
72    let mut out = String::new();
73    for (i, line) in lines.into_iter().enumerate() {
74        // Line number
75        out.push_str(&format!(
76            "{dim_prefix}{:>gutter_width$} │ {dim_reset}",
77            i + 1,
78            gutter_width = gutter_width
79        ));
80
81        let ranges = h
82            .highlight_line(line, &ss)
83            .context("syntax highlight failed")?;
84        let escaped = syntect::util::as_24_bit_terminal_escaped(&ranges[..], false);
85        out.push_str(&escaped);
86    }
87
88    Ok(out)
89}
90
91/// Auto-detect the language from a file extension or common name.
92/// Maps language names / extensions to syntect syntax names.
93pub(crate) fn language_syntax<'a>(ss: &'a SyntaxSet, language: &str) -> &'a syntect::parsing::SyntaxReference {
94    let lang_lower = language.trim().to_lowercase();
95
96    // Try by direct syntax name
97    if !lang_lower.is_empty() {
98        if let Some(s) = ss.find_syntax_by_name(&lang_lower) {
99            return s;
100        }
101    }
102
103    // Try by extension (strip leading '.' if present, or treat as extension)
104    let ext = lang_lower.strip_prefix('.').unwrap_or(&lang_lower);
105    if !ext.is_empty() {
106        if let Some(s) = ss.find_syntax_by_extension(ext) {
107            return s;
108        }
109    }
110
111    // Common aliases
112    let alias = match ext {
113        "js" | "javascript" => "JavaScript",
114        "ts" | "typescript" => "TypeScript",
115        "py" | "python" => "Python",
116        "rs" | "rust" => "Rust",
117        "go" | "golang" => "Go",
118        "rb" | "ruby" => "Ruby",
119        "java" => "Java",
120        "c" => "C",
121        "cpp" | "c++" | "cxx" => "C++",
122        "cs" | "csharp" | "c#" => "C#",
123        "sh" | "bash" | "shell" => "Bash",
124        "zsh" => "Bash",
125        "fish" => "Fish",
126        "ps1" | "powershell" => "PowerShell",
127        "sql" => "SQL",
128        "html" => "HTML",
129        "css" => "CSS",
130        "json" => "JSON",
131        "xml" => "XML",
132        "yaml" | "yml" => "YAML",
133        "toml" => "TOML",
134        "ini" | "cfg" | "conf" => "INI",
135        "md" | "markdown" => "Markdown",
136        "dockerfile" | "docker" => "Dockerfile",
137        "makefile" | "make" => "Makefile",
138        _ => "",
139    };
140
141    if !alias.is_empty() {
142        if let Some(s) = ss.find_syntax_by_name(alias) {
143            return s;
144        }
145    }
146
147    // Fallback to plain text
148    ss.find_syntax_plain_text()
149}