1package tui
2
3import (
4 "bytes"
5 "path/filepath"
6 "strconv"
7 "strings"
8 "sync"
9
10 "github.com/alecthomas/chroma/v2"
11 "github.com/alecthomas/chroma/v2/formatters"
12 "github.com/alecthomas/chroma/v2/lexers"
13 "github.com/alecthomas/chroma/v2/styles"
14)
15
16// HighlightCode syntax-colors src and returns the result split into lines,
17// ready for a line-based diff renderer. If no language is given or
18// chroma has no lexer for it, src is returned as-is (one entry per line).
19// Safe to call from multiple goroutines.
20//
21// Results are memoised by (lang, src) so repeated calls from the view
22// builder (which runs on every redraw) don't re-tokenise. Cache is
23// bounded and evicts oldest entries past its cap.
24func HighlightCode(src, lang string) []string {
25 return Dark.HighlightCode(src, lang)
26}
27
28// HighlightCode syntax-colors src using this theme's syntax palette.
29func (th Theme) HighlightCode(src, lang string) []string {
30 styleKey := th.syntaxKey()
31 if out, ok := highlightCache.lookup(styleKey, lang, src); ok {
32 return out
33 }
34 lexer := chooseLexer(lang)
35 if lexer == nil {
36 out := strings.Split(src, "\n")
37 highlightCache.store(styleKey, lang, src, out)
38 return out
39 }
40 iterator, err := lexer.Tokenise(nil, src)
41 if err != nil {
42 out := strings.Split(src, "\n")
43 highlightCache.store(styleKey, lang, src, out)
44 return out
45 }
46 if th.UseTerminalPalette {
47 out := formatTerminalTokens(th, iterator)
48 highlightCache.store(styleKey, lang, src, out)
49 return out
50 }
51 style := th.chromaStyle()
52 formatterName := "terminal256"
53 if th.Terminal.ColorDepth() == ColorDepthTrueColor {
54 formatterName = "terminal16m"
55 }
56 formatter := formatters.Get(formatterName)
57 if formatter == nil {
58 return strings.Split(src, "\n")
59 }
60 var buf bytes.Buffer
61 if err := formatter.Format(&buf, style, iterator); err != nil {
62 out := strings.Split(src, "\n")
63 highlightCache.store(styleKey, lang, src, out)
64 return out
65 }
66 out := strings.Split(strings.TrimRight(stripANSIBackgrounds(buf.String()), "\n"), "\n")
67 highlightCache.store(styleKey, lang, src, out)
68 return out
69}
70
71// formatTerminalTokens keeps Chroma's lexer but owns terminal formatting for
72// adaptive themes. Chroma's terminal formatters map duplicate ANSI RGB values
73// through the xterm cube, losing an intended palette slot such as bright blue.
74func formatTerminalTokens(th Theme, iterator chroma.Iterator) []string {
75 var out strings.Builder
76 for token := iterator(); token != chroma.EOF; token = iterator() {
77 color, bold, italic := terminalTokenStyle(th, token.Type)
78 prefix := th.fgPrefix(color)
79 if bold {
80 prefix += "\x1b[1m"
81 }
82 if italic {
83 prefix += "\x1b[3m"
84 }
85 // Coalesced Chroma tokens can span rows. Close the style before each
86 // newline and reopen it for the next segment so every rendered row is
87 // self-contained and cannot bleed into padding or following text.
88 for index, segment := range strings.Split(token.Value, "\n") {
89 if index > 0 {
90 out.WriteByte('\n')
91 }
92 if segment == "" {
93 continue
94 }
95 out.WriteString(prefix)
96 out.WriteString(segment)
97 out.WriteString(reset)
98 }
99 }
100 return strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
101}
102
103func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
104 color, bold, italic := terminalTokenDefault(th, typ)
105 if entry, ok := terminalSyntaxEntry(th, typ); ok {
106 if entry.Colour.IsSet() {
107 color = ColorRGB(int(entry.Colour.Red()), int(entry.Colour.Green()), int(entry.Colour.Blue()))
108 }
109 if entry.Bold != chroma.Pass {
110 bold = entry.Bold == chroma.Yes
111 }
112 if entry.Italic != chroma.Pass {
113 italic = entry.Italic == chroma.Yes
114 }
115 }
116 return color, bold, italic
117}
118
119func terminalTokenDefault(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
120 switch {
121 case typ.InCategory(chroma.Comment):
122 return th.Muted, false, true
123 case typ == chroma.LiteralStringEscape || typ == chroma.Error:
124 return th.Error, false, false
125 case typ.InSubCategory(chroma.LiteralString):
126 return th.Tool, false, false
127 case typ.InSubCategory(chroma.LiteralNumber):
128 return th.Warning, false, false
129 case typ.InCategory(chroma.Keyword):
130 return th.Accent, true, false
131 case typ.InCategory(chroma.Name):
132 return TerminalPaletteSlot(14), false, false
133 default:
134 return TerminalDefault(), false, false
135 }
136}
137
138func terminalSyntaxEntry(th Theme, typ chroma.TokenType) (chroma.StyleEntry, bool) {
139 if entry := terminalSyntaxOverride(th.Syntax, typ); entry != "" {
140 parsed, err := chroma.ParseStyleEntry(entry)
141 return parsed, err == nil
142 }
143 if th.SyntaxBaseStyle != "" {
144 return styles.Get(th.SyntaxBaseStyle).Get(typ), true
145 }
146 return chroma.StyleEntry{}, false
147}
148
149func terminalSyntaxOverride(s SyntaxTheme, typ chroma.TokenType) string {
150 switch {
151 case typ == chroma.KeywordConstant:
152 return firstSyntaxOverride(s.KeywordConstant, s.Keyword)
153 case typ == chroma.KeywordDeclaration:
154 return firstSyntaxOverride(s.KeywordDeclaration, s.Keyword)
155 case typ == chroma.KeywordNamespace:
156 return firstSyntaxOverride(s.KeywordNamespace, s.Keyword)
157 case typ == chroma.KeywordReserved:
158 return firstSyntaxOverride(s.KeywordReserved, s.Keyword)
159 case typ == chroma.KeywordType:
160 return firstSyntaxOverride(s.KeywordType, s.Keyword)
161 case typ.InCategory(chroma.Keyword):
162 return s.Keyword
163 case typ == chroma.NameBuiltin:
164 return firstSyntaxOverride(s.NameBuiltin, s.NameFunction)
165 case typ == chroma.NameFunction:
166 return s.NameFunction
167 case typ == chroma.NameClass:
168 return firstSyntaxOverride(s.NameClass, s.NameFunction)
169 case typ == chroma.NameDecorator:
170 return firstSyntaxOverride(s.NameDecorator, s.NameFunction)
171 case typ == chroma.LiteralStringEscape:
172 return firstSyntaxOverride(s.LiteralStringEscape, s.LiteralString)
173 case typ.InSubCategory(chroma.LiteralString):
174 return s.LiteralString
175 case typ.InSubCategory(chroma.LiteralNumber):
176 return s.LiteralNumber
177 case typ == chroma.CommentPreproc:
178 return firstSyntaxOverride(s.CommentPreproc, s.Comment)
179 case typ.InCategory(chroma.Comment):
180 return s.Comment
181 case typ.InCategory(chroma.Operator):
182 return s.Operator
183 case typ.InCategory(chroma.Punctuation):
184 return s.Punctuation
185 default:
186 return s.Text
187 }
188}
189
190func firstSyntaxOverride(entries ...string) string {
191 for _, entry := range entries {
192 if entry != "" {
193 return entry
194 }
195 }
196 return ""
197}
198
199// highlightResultCache is a simple LRU-ish cache: when it exceeds its
200// capacity we drop half of the entries (the oldest, based on insertion
201// order). That's good enough since tool_result text doesn't change once
202// emitted, so cache hits are frequent and evictions rare.
203type highlightResultCache struct {
204 mu sync.Mutex
205 max int
206 data map[string][]string
207 order []string
208}
209
210func (c *highlightResultCache) key(styleKey, lang, src string) string {
211 return styleKey + "\x00" + lang + "\x00" + src
212}
213
214func (c *highlightResultCache) lookup(styleKey, lang, src string) ([]string, bool) {
215 c.mu.Lock()
216 defer c.mu.Unlock()
217 v, ok := c.data[c.key(styleKey, lang, src)]
218 return v, ok
219}
220
221func (c *highlightResultCache) store(styleKey, lang, src string, out []string) {
222 c.mu.Lock()
223 defer c.mu.Unlock()
224 if c.data == nil {
225 c.data = make(map[string][]string)
226 }
227 k := c.key(styleKey, lang, src)
228 if _, ok := c.data[k]; !ok {
229 c.order = append(c.order, k)
230 }
231 c.data[k] = out
232 if c.max > 0 && len(c.order) > c.max {
233 // Evict the oldest half so we amortise the cost.
234 cut := len(c.order) / 2
235 for _, old := range c.order[:cut] {
236 delete(c.data, old)
237 }
238 c.order = append([]string(nil), c.order[cut:]...)
239 }
240}
241
242var highlightCache = &highlightResultCache{max: 512}
243
244// LanguageFromPath maps file extensions to chroma lexer names.
245func LanguageFromPath(p string) string {
246 ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".")
247 base := strings.ToLower(filepath.Base(p))
248 if lang := filenameLang[base]; lang != "" {
249 return lang
250 }
251 return extLang[ext]
252}
253
254// chooseLexer picks the best lexer for a language hint; falls back to
255// nil (no highlighting) if the hint is empty or unknown.
256// stripANSIBackgrounds removes SGR background-color attributes emitted by
257// terminal syntax formatters while preserving foreground colors and styles.
258// Chroma's inherited styles can assign black backgrounds to a few tokens
259// (notably punctuation/error-ish spans), which looks like random black blocks
260// inside zut's dark-gray tool boxes. Foreground color is enough for code.
261func stripANSIBackgrounds(s string) string {
262 var out strings.Builder
263 out.Grow(len(s))
264 for i := 0; i < len(s); {
265 if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
266 out.WriteByte(s[i])
267 i++
268 continue
269 }
270 j := i + 2
271 for j < len(s) && s[j] != 'm' {
272 j++
273 }
274 if j >= len(s) {
275 out.WriteString(s[i:])
276 break
277 }
278 params := strings.Split(s[i+2:j], ";")
279 kept := make([]string, 0, len(params))
280 for p := 0; p < len(params); p++ {
281 param := params[p]
282 switch param {
283 case "48":
284 // 48;5;N or 48;2;R;G;B background color.
285 if p+1 < len(params) && params[p+1] == "5" {
286 p += 2
287 continue
288 }
289 if p+1 < len(params) && params[p+1] == "2" {
290 p += 4
291 continue
292 }
293 continue
294 case "49":
295 continue
296 }
297 // 40-47 and 100-107 are ANSI background colors.
298 if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
299 continue
300 }
301 if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
302 continue
303 }
304 kept = append(kept, param)
305 }
306 if len(kept) > 0 {
307 out.WriteString("\x1b[")
308 out.WriteString(strings.Join(kept, ";"))
309 out.WriteByte('m')
310 }
311 i = j + 1
312 }
313 return out.String()
314}
315
316func chooseLexer(lang string) chroma.Lexer {
317 if lang == "" {
318 return nil
319 }
320 if l := lexers.Get(lang); l != nil {
321 return chroma.Coalesce(l)
322 }
323 // Chroma accepts aliases; try common spellings.
324 if l := lexers.Get(strings.ToLower(lang)); l != nil {
325 return chroma.Coalesce(l)
326 }
327 return nil
328}
329
330// chromaStyle builds a syntax style from the theme. Falls back to
331// chroma's bundled fallback style if the configured base style or
332// overrides cannot be built.
333func (th Theme) chromaStyle() *chroma.Style {
334 base := th.SyntaxBaseStyle
335 if base == "" {
336 base = "monokai"
337 }
338 style := styles.Get(base)
339 if style == nil {
340 style = styles.Fallback
341 }
342 builder := style.Builder()
343 add := func(tt chroma.TokenType, entry string) {
344 if strings.TrimSpace(entry) != "" {
345 builder.Add(tt, entry)
346 }
347 }
348 syn := th.Syntax
349 add(chroma.Keyword, syn.Keyword)
350 add(chroma.KeywordConstant, syn.KeywordConstant)
351 add(chroma.KeywordDeclaration, syn.KeywordDeclaration)
352 add(chroma.KeywordNamespace, syn.KeywordNamespace)
353 add(chroma.KeywordReserved, syn.KeywordReserved)
354 add(chroma.KeywordType, syn.KeywordType)
355 add(chroma.NameBuiltin, syn.NameBuiltin)
356 add(chroma.NameFunction, syn.NameFunction)
357 add(chroma.NameClass, syn.NameClass)
358 add(chroma.NameDecorator, syn.NameDecorator)
359 add(chroma.LiteralString, syn.LiteralString)
360 add(chroma.LiteralStringEscape, syn.LiteralStringEscape)
361 add(chroma.LiteralNumber, syn.LiteralNumber)
362 add(chroma.Comment, syn.Comment)
363 add(chroma.CommentPreproc, syn.CommentPreproc)
364 add(chroma.Operator, syn.Operator)
365 add(chroma.Punctuation, syn.Punctuation)
366 add(chroma.Text, syn.Text)
367 builder.Add(chroma.Background, " bg:")
368
369 s, err := builder.Build()
370 if err != nil {
371 return styles.Fallback
372 }
373 return s
374}
375
376func (th Theme) syntaxKey() string {
377 syn := th.Syntax
378 return strings.Join([]string{
379 th.terminalSyntaxKeyPart(),
380 th.SyntaxBaseStyle,
381 syn.Keyword,
382 syn.KeywordConstant,
383 syn.KeywordDeclaration,
384 syn.KeywordNamespace,
385 syn.KeywordReserved,
386 syn.KeywordType,
387 syn.NameBuiltin,
388 syn.NameFunction,
389 syn.NameClass,
390 syn.NameDecorator,
391 syn.LiteralString,
392 syn.LiteralStringEscape,
393 syn.LiteralNumber,
394 syn.Comment,
395 syn.CommentPreproc,
396 syn.Operator,
397 syn.Punctuation,
398 syn.Text,
399 }, "\x1f")
400}
401
402func (th Theme) terminalSyntaxKeyPart() string {
403 h := fnv64aInit
404 h = fnv64aWriteByte(h, byte(th.Terminal.ColorDepth()))
405 if th.UseTerminalPalette {
406 h = fnv64aWriteByte(h, 1)
407 } else {
408 h = fnv64aWriteByte(h, 0)
409 }
410 h = fnv64aWriteUint64(h, uint64(th.Terminal.PaletteKnown))
411 for _, color := range th.Terminal.Palette {
412 h = hashThemeColor(h, color)
413 }
414 for _, color := range []TerminalColor{
415 th.Terminal.Foreground,
416 th.Terminal.Background,
417 th.Accent,
418 th.Muted,
419 th.Tool,
420 th.Warning,
421 th.Error,
422 } {
423 h = hashThemeColor(h, color)
424 }
425 for _, value := range []bool{th.Terminal.HasForeground, th.Terminal.HasBackground, th.Terminal.TrueColor, th.Terminal.Light, th.Terminal.SchemeKnown} {
426 if value {
427 h = fnv64aWriteByte(h, 1)
428 } else {
429 h = fnv64aWriteByte(h, 0)
430 }
431 }
432 return strconv.FormatUint(h, 16)
433}
434
435// extLang maps file extensions to chroma lexer names. Only
436// extensions that chroma supports are included.
437var extLang = map[string]string{
438 "ts": "typescript",
439 "tsx": "tsx",
440 "js": "javascript",
441 "jsx": "jsx",
442 "mjs": "javascript",
443 "cjs": "javascript",
444 "py": "python",
445 "rb": "ruby",
446 "rs": "rust",
447 "go": "go",
448 "java": "java",
449 "kt": "kotlin",
450 "swift": "swift",
451 "c": "c",
452 "h": "c",
453 "cpp": "cpp",
454 "cc": "cpp",
455 "cxx": "cpp",
456 "hpp": "cpp",
457 "cs": "csharp",
458 "php": "php",
459 "sh": "bash",
460 "bash": "bash",
461 "zsh": "bash",
462 "fish": "fish",
463 "ps1": "powershell",
464 "sql": "sql",
465 "html": "html",
466 "htm": "html",
467 "css": "css",
468 "scss": "scss",
469 "sass": "sass",
470 "less": "less",
471 "json": "json",
472 "yaml": "yaml",
473 "yml": "yaml",
474 "toml": "toml",
475 "xml": "xml",
476 "md": "markdown",
477 "markdown": "markdown",
478 "dockerfile": "docker",
479 "makefile": "makefile",
480 "lua": "lua",
481 "perl": "perl",
482 "pl": "perl",
483 "proto": "protobuf",
484 "tf": "terraform",
485 "hcl": "terraform",
486 "graphql": "graphql",
487 "gql": "graphql",
488 "vue": "vue",
489 "svelte": "svelte",
490 "r": "r",
491 "jl": "julia",
492 "ex": "elixir",
493 "exs": "elixir",
494 "scala": "scala",
495 "nix": "nix",
496}
497
498var filenameLang = map[string]string{
499 "dockerfile": "docker",
500 "makefile": "makefile",
501 "gnumakefile": "makefile",
502 ".gitconfig": "ini",
503 ".gitattributes": "ini",
504 "cargo.toml": "toml",
505 "go.mod": "go-module",
506 "go.sum": "text",
507 "package.json": "json",
508 "tsconfig.json": "json",
509}