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