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() {
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
50pub 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 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 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
103pub(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 if !lang_lower.is_empty() {
113 if let Some(s) = ss.find_syntax_by_name(&lang_lower) {
114 return s;
115 }
116 }
117
118 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 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 ss.find_syntax_plain_text()
164}