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.
124//
125// Extended color parameters ("38;5;N", "38;2;R;G;B" and their "48;..."
126// background twins) are consumed as whole units. The color index of an
127// extended foreground is a value, not a standalone ANSI code: stripping a
128// "45" or "101" out of "\x1b[38;5;45m" leaves the malformed "\x1b[38;5m",
129// whose leftover "5" terminals read as blink (SGR 5), so the token loses its
130// color and blinks. A truncated extended color cannot be rendered anyway,
131// so it is dropped rather than forwarded.
132func stripANSIBackgrounds(s string) string {
133 var out strings.Builder
134 out.Grow(len(s))
135 for i := 0; i < len(s); {
136 if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
137 out.WriteByte(s[i])
138 i++
139 continue
140 }
141 j := i + 2
142 for j < len(s) && s[j] != 'm' {
143 j++
144 }
145 if j >= len(s) {
146 out.WriteString(s[i:])
147 break
148 }
149 params := strings.Split(s[i+2:j], ";")
150 kept := make([]string, 0, len(params))
151 for p := 0; p < len(params); p++ {
152 param := params[p]
153 switch param {
154 case "38", "48":
155 span, complete := extendedColorSpan(params, p)
156 if complete && param == "38" {
157 kept = append(kept, span...)
158 }
159 // Consume the whole unit either way: a truncated
160 // "38;5" must not leave its "5" to be read as blink.
161 p += len(span) - 1
162 continue
163 case "49":
164 continue
165 }
166 // 40-47 and 100-107 are ANSI background colors. Bare codes only:
167 // extended colors were consumed above, so a color index in those
168 // ranges cannot be mistaken for one here.
169 if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
170 continue
171 }
172 if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
173 continue
174 }
175 kept = append(kept, param)
176 }
177 if len(kept) > 0 {
178 out.WriteString("\x1b[")
179 out.WriteString(strings.Join(kept, ";"))
180 out.WriteByte('m')
181 }
182 i = j + 1
183 }
184 return out.String()
185}
186
187// extendedColorSpan returns the parameter run that belongs to the extended
188// color starting at params[i] ("38" or "48"): "5;N" or "2;R;G;B". complete
189// reports whether the run can be rendered; a truncated run is still returned
190// so its parameters are consumed instead of being reprocessed as standalone
191// codes (a bare "5" would mean blink).
192func extendedColorSpan(params []string, i int) (span []string, complete bool) {
193 if i+1 >= len(params) {
194 return params[i : i+1], false
195 }
196 switch params[i+1] {
197 case "5":
198 if i+2 >= len(params) {
199 return params[i : i+2], false
200 }
201 return params[i : i+3], true
202 case "2":
203 if i+5 > len(params) {
204 return params[i:], false
205 }
206 return params[i : i+5], true
207 }
208 return params[i : i+1], false
209}
210
211func chooseLexer(lang string) chroma.Lexer {
212 if lang == "" {
213 return nil
214 }
215 if l := lexers.Get(lang); l != nil {
216 return chroma.Coalesce(l)
217 }
218 // Chroma accepts aliases; try common spellings.
219 if l := lexers.Get(strings.ToLower(lang)); l != nil {
220 return chroma.Coalesce(l)
221 }
222 return nil
223}
224
225// chromaStyle builds a syntax style from the theme. Falls back to
226// chroma's bundled fallback style if the configured base style or
227// overrides cannot be built.
228func (th Theme) chromaStyle() *chroma.Style {
229 base := th.SyntaxBaseStyle
230 if base == "" {
231 base = "monokai"
232 }
233 style := styles.Get(base)
234 if style == nil {
235 style = styles.Fallback
236 }
237 builder := style.Builder()
238 add := func(tt chroma.TokenType, entry string) {
239 if strings.TrimSpace(entry) != "" {
240 builder.Add(tt, entry)
241 }
242 }
243 syn := th.Syntax
244 add(chroma.Keyword, syn.Keyword)
245 add(chroma.KeywordConstant, syn.KeywordConstant)
246 add(chroma.KeywordDeclaration, syn.KeywordDeclaration)
247 add(chroma.KeywordNamespace, syn.KeywordNamespace)
248 add(chroma.KeywordReserved, syn.KeywordReserved)
249 add(chroma.KeywordType, syn.KeywordType)
250 add(chroma.NameBuiltin, syn.NameBuiltin)
251 add(chroma.NameFunction, syn.NameFunction)
252 add(chroma.NameClass, syn.NameClass)
253 add(chroma.NameDecorator, syn.NameDecorator)
254 add(chroma.LiteralString, syn.LiteralString)
255 add(chroma.LiteralStringEscape, syn.LiteralStringEscape)
256 add(chroma.LiteralNumber, syn.LiteralNumber)
257 add(chroma.Comment, syn.Comment)
258 add(chroma.CommentPreproc, syn.CommentPreproc)
259 add(chroma.Operator, syn.Operator)
260 add(chroma.Punctuation, syn.Punctuation)
261 add(chroma.Text, syn.Text)
262 builder.Add(chroma.Background, " bg:")
263
264 s, err := builder.Build()
265 if err != nil {
266 return styles.Fallback
267 }
268 return s
269}
270
271func (th Theme) syntaxKey() string {
272 syn := th.Syntax
273 return strings.Join([]string{
274 th.SyntaxBaseStyle,
275 syn.Keyword,
276 syn.KeywordConstant,
277 syn.KeywordDeclaration,
278 syn.KeywordNamespace,
279 syn.KeywordReserved,
280 syn.KeywordType,
281 syn.NameBuiltin,
282 syn.NameFunction,
283 syn.NameClass,
284 syn.NameDecorator,
285 syn.LiteralString,
286 syn.LiteralStringEscape,
287 syn.LiteralNumber,
288 syn.Comment,
289 syn.CommentPreproc,
290 syn.Operator,
291 syn.Punctuation,
292 syn.Text,
293 }, "\x1f")
294}
295
296// extLang maps file extensions to chroma lexer names. Only
297// extensions that chroma supports are included.
298var extLang = map[string]string{
299 "ts": "typescript",
300 "tsx": "tsx",
301 "js": "javascript",
302 "jsx": "jsx",
303 "mjs": "javascript",
304 "cjs": "javascript",
305 "py": "python",
306 "rb": "ruby",
307 "rs": "rust",
308 "go": "go",
309 "java": "java",
310 "kt": "kotlin",
311 "swift": "swift",
312 "c": "c",
313 "h": "c",
314 "cpp": "cpp",
315 "cc": "cpp",
316 "cxx": "cpp",
317 "hpp": "cpp",
318 "cs": "csharp",
319 "php": "php",
320 "sh": "bash",
321 "bash": "bash",
322 "zsh": "bash",
323 "fish": "fish",
324 "ps1": "powershell",
325 "sql": "sql",
326 "html": "html",
327 "htm": "html",
328 "css": "css",
329 "scss": "scss",
330 "sass": "sass",
331 "less": "less",
332 "json": "json",
333 "yaml": "yaml",
334 "yml": "yaml",
335 "toml": "toml",
336 "xml": "xml",
337 "md": "markdown",
338 "markdown": "markdown",
339 "dockerfile": "docker",
340 "makefile": "makefile",
341 "lua": "lua",
342 "perl": "perl",
343 "pl": "perl",
344 "proto": "protobuf",
345 "tf": "terraform",
346 "hcl": "terraform",
347 "graphql": "graphql",
348 "gql": "graphql",
349 "vue": "vue",
350 "svelte": "svelte",
351 "r": "r",
352 "jl": "julia",
353 "ex": "elixir",
354 "exs": "elixir",
355 "scala": "scala",
356 "nix": "nix",
357}
358
359var filenameLang = map[string]string{
360 "dockerfile": "docker",
361 "makefile": "makefile",
362 "gnumakefile": "makefile",
363 ".gitconfig": "ini",
364 ".gitattributes": "ini",
365 "cargo.toml": "toml",
366 "go.mod": "go-module",
367 "go.sum": "text",
368 "package.json": "json",
369 "tsconfig.json": "json",
370}