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// stripANSIBackgrounds removes SGR background-color attributes emitted by
255// terminal syntax formatters while preserving foreground colors and styles.
256// Chroma's inherited styles can assign black backgrounds to a few tokens
257// (notably punctuation/error-ish spans), which looks like random black blocks
258// inside zut's dark-gray tool boxes. Foreground color is enough for code.
259//
260// Extended color parameters ("38;5;N", "38;2;R;G;B" and their "48;..."
261// background twins) are consumed as whole units. The color index of an
262// extended foreground is a value, not a standalone ANSI code: stripping a
263// "45" or "101" out of "\x1b[38;5;45m" leaves the malformed "\x1b[38;5m",
264// whose leftover "5" terminals read as blink (SGR 5). A truncated extended
265// color cannot be rendered anyway, so it is dropped rather than forwarded.
266func stripANSIBackgrounds(s string) string {
267 var out strings.Builder
268 out.Grow(len(s))
269 for i := 0; i < len(s); {
270 if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
271 out.WriteByte(s[i])
272 i++
273 continue
274 }
275 j := i + 2
276 for j < len(s) && s[j] != 'm' {
277 j++
278 }
279 if j >= len(s) {
280 out.WriteString(s[i:])
281 break
282 }
283 params := strings.Split(s[i+2:j], ";")
284 kept := make([]string, 0, len(params))
285 for p := 0; p < len(params); p++ {
286 param := params[p]
287 switch param {
288 case "38", "48":
289 span, complete := extendedColorSpan(params, p)
290 if complete && param == "38" {
291 kept = append(kept, span...)
292 }
293 // Consume the whole unit either way: a truncated
294 // "38;5" must not leave its "5" to be read as blink.
295 p += len(span) - 1
296 continue
297 case "49":
298 continue
299 }
300 // 40-47 and 100-107 are ANSI background colors.
301 if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
302 continue
303 }
304 if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
305 continue
306 }
307 kept = append(kept, param)
308 }
309 if len(kept) > 0 {
310 out.WriteString("\x1b[")
311 out.WriteString(strings.Join(kept, ";"))
312 out.WriteByte('m')
313 }
314 i = j + 1
315 }
316 return out.String()
317}
318
319// extendedColorSpan returns the parameter run that belongs to the extended
320// color starting at params[i] ("38" or "48"): "5;N" or "2;R;G;B". complete
321// reports whether the run can be rendered; a truncated run is still returned
322// so its parameters are consumed instead of being reprocessed as standalone
323// codes (a bare "5" would mean blink).
324func extendedColorSpan(params []string, i int) (span []string, complete bool) {
325 if i+1 >= len(params) {
326 return params[i : i+1], false
327 }
328 switch params[i+1] {
329 case "5":
330 if i+2 >= len(params) {
331 return params[i : i+2], false
332 }
333 return params[i : i+3], true
334 case "2":
335 if i+5 > len(params) {
336 return params[i:], false
337 }
338 return params[i : i+5], true
339 }
340 return params[i : i+1], false
341}
342
343// chooseLexer picks the best lexer for a language hint; falls back to
344// nil (no highlighting) if the hint is empty or unknown.
345func chooseLexer(lang string) chroma.Lexer {
346 if lang == "" {
347 return nil
348 }
349 if l := lexers.Get(lang); l != nil {
350 return chroma.Coalesce(l)
351 }
352 // Chroma accepts aliases; try common spellings.
353 if l := lexers.Get(strings.ToLower(lang)); l != nil {
354 return chroma.Coalesce(l)
355 }
356 return nil
357}
358
359// chromaStyle builds a syntax style from the theme. Falls back to
360// chroma's bundled fallback style if the configured base style or
361// overrides cannot be built.
362func (th Theme) chromaStyle() *chroma.Style {
363 base := th.SyntaxBaseStyle
364 if base == "" {
365 base = "monokai"
366 }
367 style := styles.Get(base)
368 if style == nil {
369 style = styles.Fallback
370 }
371 builder := style.Builder()
372 add := func(tt chroma.TokenType, entry string) {
373 if strings.TrimSpace(entry) != "" {
374 builder.Add(tt, entry)
375 }
376 }
377 syn := th.Syntax
378 add(chroma.Keyword, syn.Keyword)
379 add(chroma.KeywordConstant, syn.KeywordConstant)
380 add(chroma.KeywordDeclaration, syn.KeywordDeclaration)
381 add(chroma.KeywordNamespace, syn.KeywordNamespace)
382 add(chroma.KeywordReserved, syn.KeywordReserved)
383 add(chroma.KeywordType, syn.KeywordType)
384 add(chroma.NameBuiltin, syn.NameBuiltin)
385 add(chroma.NameFunction, syn.NameFunction)
386 add(chroma.NameClass, syn.NameClass)
387 add(chroma.NameDecorator, syn.NameDecorator)
388 add(chroma.LiteralString, syn.LiteralString)
389 add(chroma.LiteralStringEscape, syn.LiteralStringEscape)
390 add(chroma.LiteralNumber, syn.LiteralNumber)
391 add(chroma.Comment, syn.Comment)
392 add(chroma.CommentPreproc, syn.CommentPreproc)
393 add(chroma.Operator, syn.Operator)
394 add(chroma.Punctuation, syn.Punctuation)
395 add(chroma.Text, syn.Text)
396 builder.Add(chroma.Background, " bg:")
397
398 s, err := builder.Build()
399 if err != nil {
400 return styles.Fallback
401 }
402 return s
403}
404
405func (th Theme) syntaxKey() string {
406 syn := th.Syntax
407 return strings.Join([]string{
408 th.terminalSyntaxKeyPart(),
409 th.SyntaxBaseStyle,
410 syn.Keyword,
411 syn.KeywordConstant,
412 syn.KeywordDeclaration,
413 syn.KeywordNamespace,
414 syn.KeywordReserved,
415 syn.KeywordType,
416 syn.NameBuiltin,
417 syn.NameFunction,
418 syn.NameClass,
419 syn.NameDecorator,
420 syn.LiteralString,
421 syn.LiteralStringEscape,
422 syn.LiteralNumber,
423 syn.Comment,
424 syn.CommentPreproc,
425 syn.Operator,
426 syn.Punctuation,
427 syn.Text,
428 }, "\x1f")
429}
430
431func (th Theme) terminalSyntaxKeyPart() string {
432 h := fnv64aInit
433 h = fnv64aWriteByte(h, byte(th.Terminal.ColorDepth()))
434 if th.UseTerminalPalette {
435 h = fnv64aWriteByte(h, 1)
436 } else {
437 h = fnv64aWriteByte(h, 0)
438 }
439 h = fnv64aWriteUint64(h, uint64(th.Terminal.PaletteKnown))
440 for _, color := range th.Terminal.Palette {
441 h = hashThemeColor(h, color)
442 }
443 for _, color := range []TerminalColor{
444 th.Terminal.Foreground,
445 th.Terminal.Background,
446 th.Accent,
447 th.Muted,
448 th.Tool,
449 th.Warning,
450 th.Error,
451 } {
452 h = hashThemeColor(h, color)
453 }
454 for _, value := range []bool{th.Terminal.HasForeground, th.Terminal.HasBackground, th.Terminal.TrueColor, th.Terminal.Light, th.Terminal.SchemeKnown} {
455 if value {
456 h = fnv64aWriteByte(h, 1)
457 } else {
458 h = fnv64aWriteByte(h, 0)
459 }
460 }
461 return strconv.FormatUint(h, 16)
462}
463
464// extLang maps file extensions to chroma lexer names. Only
465// extensions that chroma supports are included.
466var extLang = map[string]string{
467 "ts": "typescript",
468 "tsx": "tsx",
469 "js": "javascript",
470 "jsx": "jsx",
471 "mjs": "javascript",
472 "cjs": "javascript",
473 "py": "python",
474 "rb": "ruby",
475 "rs": "rust",
476 "go": "go",
477 "java": "java",
478 "kt": "kotlin",
479 "swift": "swift",
480 "c": "c",
481 "h": "c",
482 "cpp": "cpp",
483 "cc": "cpp",
484 "cxx": "cpp",
485 "hpp": "cpp",
486 "cs": "csharp",
487 "php": "php",
488 "sh": "bash",
489 "bash": "bash",
490 "zsh": "bash",
491 "fish": "fish",
492 "ps1": "powershell",
493 "sql": "sql",
494 "html": "html",
495 "htm": "html",
496 "css": "css",
497 "scss": "scss",
498 "sass": "sass",
499 "less": "less",
500 "json": "json",
501 "yaml": "yaml",
502 "yml": "yaml",
503 "toml": "toml",
504 "xml": "xml",
505 "md": "markdown",
506 "markdown": "markdown",
507 "dockerfile": "docker",
508 "makefile": "makefile",
509 "lua": "lua",
510 "perl": "perl",
511 "pl": "perl",
512 "proto": "protobuf",
513 "tf": "terraform",
514 "hcl": "terraform",
515 "graphql": "graphql",
516 "gql": "graphql",
517 "vue": "vue",
518 "svelte": "svelte",
519 "r": "r",
520 "jl": "julia",
521 "ex": "elixir",
522 "exs": "elixir",
523 "scala": "scala",
524 "nix": "nix",
525}
526
527var filenameLang = map[string]string{
528 "dockerfile": "docker",
529 "makefile": "makefile",
530 "gnumakefile": "makefile",
531 ".gitconfig": "ini",
532 ".gitattributes": "ini",
533 "cargo.toml": "toml",
534 "go.mod": "go-module",
535 "go.sum": "text",
536 "package.json": "json",
537 "tsconfig.json": "json",
538}