1package tui
2
3import (
4 "bytes"
5 "path/filepath"
6 "strings"
7 "sync"
8
9 "github.com/alecthomas/chroma/v2"
10 "github.com/alecthomas/chroma/v2/formatters"
11 "github.com/alecthomas/chroma/v2/lexers"
12 "github.com/alecthomas/chroma/v2/styles"
13)
14
15// HighlightCode syntax-colors src and returns the result split into lines,
16// ready for a line-based diff renderer. If no language is given or
17// chroma has no lexer for it, src is returned as-is (one entry per line).
18// Safe to call from multiple goroutines.
19//
20// Results are memoised by (lang, src) so repeated calls from the view
21// builder (which runs on every redraw) don't re-tokenise. Cache is
22// bounded and evicts oldest entries past its cap.
23func HighlightCode(src, lang string) []string {
24 return Dark.HighlightCode(src, lang)
25}
26
27// HighlightCode syntax-colors src using this theme's syntax palette.
28func (th Theme) HighlightCode(src, lang string) []string {
29 styleKey := th.syntaxKey()
30 if out, ok := highlightCache.lookup(styleKey, lang, src); ok {
31 return out
32 }
33 lexer := chooseLexer(lang)
34 if lexer == nil {
35 out := strings.Split(src, "\n")
36 highlightCache.store(styleKey, lang, src, out)
37 return out
38 }
39 style := th.chromaStyle()
40 formatter := formatters.Get("terminal256")
41 if formatter == nil {
42 return strings.Split(src, "\n")
43 }
44
45 iterator, err := lexer.Tokenise(nil, src)
46 if err != nil {
47 out := strings.Split(src, "\n")
48 highlightCache.store(styleKey, lang, src, out)
49 return out
50 }
51 var buf bytes.Buffer
52 if err := formatter.Format(&buf, style, iterator); err != nil {
53 out := strings.Split(src, "\n")
54 highlightCache.store(styleKey, lang, src, out)
55 return out
56 }
57 out := strings.Split(strings.TrimRight(stripANSIBackgrounds(buf.String()), "\n"), "\n")
58 highlightCache.store(styleKey, lang, src, out)
59 return out
60}
61
62// highlightResultCache is a simple LRU-ish cache: when it exceeds its
63// capacity we drop half of the entries (the oldest, based on insertion
64// order). That's good enough since tool_result text doesn't change once
65// emitted, so cache hits are frequent and evictions rare.
66type highlightResultCache struct {
67 mu sync.Mutex
68 max int
69 data map[string][]string
70 order []string
71}
72
73func (c *highlightResultCache) key(styleKey, lang, src string) string {
74 return styleKey + "\x00" + lang + "\x00" + src
75}
76
77func (c *highlightResultCache) lookup(styleKey, lang, src string) ([]string, bool) {
78 c.mu.Lock()
79 defer c.mu.Unlock()
80 v, ok := c.data[c.key(styleKey, lang, src)]
81 return v, ok
82}
83
84func (c *highlightResultCache) store(styleKey, lang, src string, out []string) {
85 c.mu.Lock()
86 defer c.mu.Unlock()
87 if c.data == nil {
88 c.data = make(map[string][]string)
89 }
90 k := c.key(styleKey, lang, src)
91 if _, ok := c.data[k]; !ok {
92 c.order = append(c.order, k)
93 }
94 c.data[k] = out
95 if c.max > 0 && len(c.order) > c.max {
96 // Evict the oldest half so we amortise the cost.
97 cut := len(c.order) / 2
98 for _, old := range c.order[:cut] {
99 delete(c.data, old)
100 }
101 c.order = append([]string(nil), c.order[cut:]...)
102 }
103}
104
105var highlightCache = &highlightResultCache{max: 512}
106
107// LanguageFromPath maps file extensions to chroma lexer names.
108func LanguageFromPath(p string) string {
109 ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".")
110 base := strings.ToLower(filepath.Base(p))
111 if lang := filenameLang[base]; lang != "" {
112 return lang
113 }
114 return extLang[ext]
115}
116
117// chooseLexer picks the best lexer for a language hint; falls back to
118// nil (no highlighting) if the hint is empty or unknown.
119// stripANSIBackgrounds removes SGR background-color attributes emitted by
120// terminal syntax formatters while preserving foreground colors and styles.
121// Chroma's inherited styles can assign black backgrounds to a few tokens
122// (notably punctuation/error-ish spans), which looks like random black blocks
123// inside zot's dark-gray tool boxes. Foreground color is enough for code.
124func stripANSIBackgrounds(s string) string {
125 var out strings.Builder
126 out.Grow(len(s))
127 for i := 0; i < len(s); {
128 if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
129 out.WriteByte(s[i])
130 i++
131 continue
132 }
133 j := i + 2
134 for j < len(s) && s[j] != 'm' {
135 j++
136 }
137 if j >= len(s) {
138 out.WriteString(s[i:])
139 break
140 }
141 params := strings.Split(s[i+2:j], ";")
142 kept := make([]string, 0, len(params))
143 for p := 0; p < len(params); p++ {
144 param := params[p]
145 switch param {
146 case "48":
147 // 48;5;N or 48;2;R;G;B background color.
148 if p+1 < len(params) && params[p+1] == "5" {
149 p += 2
150 continue
151 }
152 if p+1 < len(params) && params[p+1] == "2" {
153 p += 4
154 continue
155 }
156 continue
157 case "49":
158 continue
159 }
160 // 40-47 and 100-107 are ANSI background colors.
161 if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
162 continue
163 }
164 if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
165 continue
166 }
167 kept = append(kept, param)
168 }
169 if len(kept) > 0 {
170 out.WriteString("\x1b[")
171 out.WriteString(strings.Join(kept, ";"))
172 out.WriteByte('m')
173 }
174 i = j + 1
175 }
176 return out.String()
177}
178
179func chooseLexer(lang string) chroma.Lexer {
180 if lang == "" {
181 return nil
182 }
183 if l := lexers.Get(lang); l != nil {
184 return chroma.Coalesce(l)
185 }
186 // Chroma accepts aliases; try common spellings.
187 if l := lexers.Get(strings.ToLower(lang)); l != nil {
188 return chroma.Coalesce(l)
189 }
190 return nil
191}
192
193// chromaStyle builds a syntax style from the theme. Falls back to
194// chroma's bundled fallback style if the configured base style or
195// overrides cannot be built.
196func (th Theme) chromaStyle() *chroma.Style {
197 base := th.SyntaxBaseStyle
198 if base == "" {
199 base = "monokai"
200 }
201 style := styles.Get(base)
202 if style == nil {
203 style = styles.Fallback
204 }
205 builder := style.Builder()
206 add := func(tt chroma.TokenType, entry string) {
207 if strings.TrimSpace(entry) != "" {
208 builder.Add(tt, entry)
209 }
210 }
211 syn := th.Syntax
212 add(chroma.Keyword, syn.Keyword)
213 add(chroma.KeywordConstant, syn.KeywordConstant)
214 add(chroma.KeywordDeclaration, syn.KeywordDeclaration)
215 add(chroma.KeywordNamespace, syn.KeywordNamespace)
216 add(chroma.KeywordReserved, syn.KeywordReserved)
217 add(chroma.KeywordType, syn.KeywordType)
218 add(chroma.NameBuiltin, syn.NameBuiltin)
219 add(chroma.NameFunction, syn.NameFunction)
220 add(chroma.NameClass, syn.NameClass)
221 add(chroma.NameDecorator, syn.NameDecorator)
222 add(chroma.LiteralString, syn.LiteralString)
223 add(chroma.LiteralStringEscape, syn.LiteralStringEscape)
224 add(chroma.LiteralNumber, syn.LiteralNumber)
225 add(chroma.Comment, syn.Comment)
226 add(chroma.CommentPreproc, syn.CommentPreproc)
227 add(chroma.Operator, syn.Operator)
228 add(chroma.Punctuation, syn.Punctuation)
229 add(chroma.Text, syn.Text)
230 builder.Add(chroma.Background, " bg:")
231
232 s, err := builder.Build()
233 if err != nil {
234 return styles.Fallback
235 }
236 return s
237}
238
239func (th Theme) syntaxKey() string {
240 syn := th.Syntax
241 return strings.Join([]string{
242 th.SyntaxBaseStyle,
243 syn.Keyword,
244 syn.KeywordConstant,
245 syn.KeywordDeclaration,
246 syn.KeywordNamespace,
247 syn.KeywordReserved,
248 syn.KeywordType,
249 syn.NameBuiltin,
250 syn.NameFunction,
251 syn.NameClass,
252 syn.NameDecorator,
253 syn.LiteralString,
254 syn.LiteralStringEscape,
255 syn.LiteralNumber,
256 syn.Comment,
257 syn.CommentPreproc,
258 syn.Operator,
259 syn.Punctuation,
260 syn.Text,
261 }, "\x1f")
262}
263
264// extLang maps file extensions to chroma lexer names. Only
265// extensions that chroma supports are included.
266var extLang = map[string]string{
267 "ts": "typescript",
268 "tsx": "tsx",
269 "js": "javascript",
270 "jsx": "jsx",
271 "mjs": "javascript",
272 "cjs": "javascript",
273 "py": "python",
274 "rb": "ruby",
275 "rs": "rust",
276 "go": "go",
277 "java": "java",
278 "kt": "kotlin",
279 "swift": "swift",
280 "c": "c",
281 "h": "c",
282 "cpp": "cpp",
283 "cc": "cpp",
284 "cxx": "cpp",
285 "hpp": "cpp",
286 "cs": "csharp",
287 "php": "php",
288 "sh": "bash",
289 "bash": "bash",
290 "zsh": "bash",
291 "fish": "fish",
292 "ps1": "powershell",
293 "sql": "sql",
294 "html": "html",
295 "htm": "html",
296 "css": "css",
297 "scss": "scss",
298 "sass": "sass",
299 "less": "less",
300 "json": "json",
301 "yaml": "yaml",
302 "yml": "yaml",
303 "toml": "toml",
304 "xml": "xml",
305 "md": "markdown",
306 "markdown": "markdown",
307 "dockerfile": "docker",
308 "makefile": "makefile",
309 "lua": "lua",
310 "perl": "perl",
311 "pl": "perl",
312 "proto": "protobuf",
313 "tf": "terraform",
314 "hcl": "terraform",
315 "graphql": "graphql",
316 "gql": "graphql",
317 "vue": "vue",
318 "svelte": "svelte",
319 "r": "r",
320 "jl": "julia",
321 "ex": "elixir",
322 "exs": "elixir",
323 "scala": "scala",
324 "nix": "nix",
325}
326
327var filenameLang = map[string]string{
328 "dockerfile": "docker",
329 "makefile": "makefile",
330 "gnumakefile": "makefile",
331 ".gitconfig": "ini",
332 ".gitattributes": "ini",
333 "cargo.toml": "toml",
334 "go.mod": "go-module",
335 "go.sum": "text",
336 "package.json": "json",
337 "tsconfig.json": "json",
338}