sparrow/tui/formatters/
code.rs1use anyhow::{Context, Result};
5use syntect::easy::HighlightLines;
6use syntect::highlighting::ThemeSet;
7use syntect::parsing::SyntaxSet;
8use syntect::util::LinesWithEndings;
9
10const DEFAULT_THEME: &str = "base16-ocean.dark";
12
13pub 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
46pub 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 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 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
91pub(crate) fn language_syntax<'a>(ss: &'a SyntaxSet, language: &str) -> &'a syntect::parsing::SyntaxReference {
94 let lang_lower = language.trim().to_lowercase();
95
96 if !lang_lower.is_empty() {
98 if let Some(s) = ss.find_syntax_by_name(&lang_lower) {
99 return s;
100 }
101 }
102
103 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 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 ss.find_syntax_plain_text()
149}