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 if out, ok := highlightCache.lookup(lang, src); ok {
25 return out
26 }
27 lexer := chooseLexer(lang)
28 if lexer == nil {
29 out := strings.Split(src, "\n")
30 highlightCache.store(lang, src, out)
31 return out
32 }
33 style := zotChromaStyle
34 formatter := formatters.Get("terminal256")
35 if formatter == nil {
36 return strings.Split(src, "\n")
37 }
38
39 iterator, err := lexer.Tokenise(nil, src)
40 if err != nil {
41 out := strings.Split(src, "\n")
42 highlightCache.store(lang, src, out)
43 return out
44 }
45 var buf bytes.Buffer
46 if err := formatter.Format(&buf, style, iterator); err != nil {
47 out := strings.Split(src, "\n")
48 highlightCache.store(lang, src, out)
49 return out
50 }
51 out := strings.Split(strings.TrimRight(stripANSIBackgrounds(buf.String()), "\n"), "\n")
52 highlightCache.store(lang, src, out)
53 return out
54}
55
56// highlightResultCache is a simple LRU-ish cache: when it exceeds its
57// capacity we drop half of the entries (the oldest, based on insertion
58// order). That's good enough since tool_result text doesn't change once
59// emitted, so cache hits are frequent and evictions rare.
60type highlightResultCache struct {
61 mu sync.Mutex
62 max int
63 data map[string][]string
64 order []string
65}
66
67func (c *highlightResultCache) key(lang, src string) string {
68 return lang + "\x00" + src
69}
70
71func (c *highlightResultCache) lookup(lang, src string) ([]string, bool) {
72 c.mu.Lock()
73 defer c.mu.Unlock()
74 v, ok := c.data[c.key(lang, src)]
75 return v, ok
76}
77
78func (c *highlightResultCache) store(lang, src string, out []string) {
79 c.mu.Lock()
80 defer c.mu.Unlock()
81 if c.data == nil {
82 c.data = make(map[string][]string)
83 }
84 k := c.key(lang, src)
85 if _, ok := c.data[k]; !ok {
86 c.order = append(c.order, k)
87 }
88 c.data[k] = out
89 if c.max > 0 && len(c.order) > c.max {
90 // Evict the oldest half so we amortise the cost.
91 cut := len(c.order) / 2
92 for _, old := range c.order[:cut] {
93 delete(c.data, old)
94 }
95 c.order = append([]string(nil), c.order[cut:]...)
96 }
97}
98
99var highlightCache = &highlightResultCache{max: 512}
100
101// LanguageFromPath maps file extensions to chroma lexer names.
102func LanguageFromPath(p string) string {
103 ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".")
104 base := strings.ToLower(filepath.Base(p))
105 if lang := filenameLang[base]; lang != "" {
106 return lang
107 }
108 return extLang[ext]
109}
110
111// chooseLexer picks the best lexer for a language hint; falls back to
112// nil (no highlighting) if the hint is empty or unknown.
113// stripANSIBackgrounds removes SGR background-color attributes emitted by
114// terminal syntax formatters while preserving foreground colors and styles.
115// Chroma's inherited styles can assign black backgrounds to a few tokens
116// (notably punctuation/error-ish spans), which looks like random black blocks
117// inside zot's dark-gray tool boxes. Foreground color is enough for code.
118func stripANSIBackgrounds(s string) string {
119 var out strings.Builder
120 out.Grow(len(s))
121 for i := 0; i < len(s); {
122 if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
123 out.WriteByte(s[i])
124 i++
125 continue
126 }
127 j := i + 2
128 for j < len(s) && s[j] != 'm' {
129 j++
130 }
131 if j >= len(s) {
132 out.WriteString(s[i:])
133 break
134 }
135 params := strings.Split(s[i+2:j], ";")
136 kept := make([]string, 0, len(params))
137 for p := 0; p < len(params); p++ {
138 param := params[p]
139 switch param {
140 case "48":
141 // 48;5;N or 48;2;R;G;B background color.
142 if p+1 < len(params) && params[p+1] == "5" {
143 p += 2
144 continue
145 }
146 if p+1 < len(params) && params[p+1] == "2" {
147 p += 4
148 continue
149 }
150 continue
151 case "49":
152 continue
153 }
154 // 40-47 and 100-107 are ANSI background colors.
155 if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
156 continue
157 }
158 if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
159 continue
160 }
161 kept = append(kept, param)
162 }
163 if len(kept) > 0 {
164 out.WriteString("\x1b[")
165 out.WriteString(strings.Join(kept, ";"))
166 out.WriteByte('m')
167 }
168 i = j + 1
169 }
170 return out.String()
171}
172
173func chooseLexer(lang string) chroma.Lexer {
174 if lang == "" {
175 return nil
176 }
177 if l := lexers.Get(lang); l != nil {
178 return chroma.Coalesce(l)
179 }
180 // Chroma accepts aliases; try common spellings.
181 if l := lexers.Get(strings.ToLower(lang)); l != nil {
182 return chroma.Coalesce(l)
183 }
184 return nil
185}
186
187// zotChromaStyle is a terminal style tuned for zot's dark palette.
188// Built lazily so the cost is paid once per process.
189var zotChromaStyle = func() *chroma.Style {
190 chromaStyleOnce.Do(func() {
191 chromaStyleCached = buildZotStyle()
192 })
193 return chromaStyleCached
194}()
195
196var (
197 chromaStyleOnce sync.Once
198 chromaStyleCached *chroma.Style
199)
200
201// buildZotStyle returns a chroma style with colors that match zot's
202// theme: accent blue for keywords, green for strings, muted grey for
203// comments, user-tan for numbers. Falls back to chroma's bundled
204// "fruity" style if the builder fails for any reason.
205func buildZotStyle() *chroma.Style {
206 builder := styles.Get("monokai").Builder()
207 // Override the most visible tokens with explicit colors that
208 // read well on a dark terminal.
209 builder.Add(chroma.Keyword, "#81a1c1 bold") // imports, funcs, control flow
210 builder.Add(chroma.KeywordConstant, "#81a1c1") // true, false, null
211 builder.Add(chroma.KeywordDeclaration, "#81a1c1") // const, let, var, type
212 builder.Add(chroma.KeywordNamespace, "#81a1c1") // import, from, package
213 builder.Add(chroma.KeywordReserved, "#81a1c1 bold")
214 builder.Add(chroma.KeywordType, "#88c0d0") // string, int, bool
215 builder.Add(chroma.NameBuiltin, "#88c0d0")
216 builder.Add(chroma.NameFunction, "#8fbcbb")
217 builder.Add(chroma.NameClass, "#a3be8c bold")
218 builder.Add(chroma.NameDecorator, "#b48ead")
219 builder.Add(chroma.LiteralString, "#a3be8c") // strings
220 builder.Add(chroma.LiteralStringEscape, "#bf616a")
221 builder.Add(chroma.LiteralNumber, "#d08770")
222 builder.Add(chroma.Comment, "#616e88 italic")
223 builder.Add(chroma.CommentPreproc, "#b48ead")
224 builder.Add(chroma.Operator, "#eceff4")
225 builder.Add(chroma.Punctuation, "#d8dee9")
226 builder.Add(chroma.Text, "#e5e9f0")
227 builder.Add(chroma.Background, " bg:")
228
229 s, err := builder.Build()
230 if err != nil {
231 return styles.Fallback
232 }
233 return s
234}
235
236// extLang maps file extensions to chroma lexer names. Only
237// extensions that chroma supports are included.
238var extLang = map[string]string{
239 "ts": "typescript",
240 "tsx": "tsx",
241 "js": "javascript",
242 "jsx": "jsx",
243 "mjs": "javascript",
244 "cjs": "javascript",
245 "py": "python",
246 "rb": "ruby",
247 "rs": "rust",
248 "go": "go",
249 "java": "java",
250 "kt": "kotlin",
251 "swift": "swift",
252 "c": "c",
253 "h": "c",
254 "cpp": "cpp",
255 "cc": "cpp",
256 "cxx": "cpp",
257 "hpp": "cpp",
258 "cs": "csharp",
259 "php": "php",
260 "sh": "bash",
261 "bash": "bash",
262 "zsh": "bash",
263 "fish": "fish",
264 "ps1": "powershell",
265 "sql": "sql",
266 "html": "html",
267 "htm": "html",
268 "css": "css",
269 "scss": "scss",
270 "sass": "sass",
271 "less": "less",
272 "json": "json",
273 "yaml": "yaml",
274 "yml": "yaml",
275 "toml": "toml",
276 "xml": "xml",
277 "md": "markdown",
278 "markdown": "markdown",
279 "dockerfile": "docker",
280 "makefile": "makefile",
281 "lua": "lua",
282 "perl": "perl",
283 "pl": "perl",
284 "proto": "protobuf",
285 "tf": "terraform",
286 "hcl": "terraform",
287 "graphql": "graphql",
288 "gql": "graphql",
289 "vue": "vue",
290 "svelte": "svelte",
291 "r": "r",
292 "jl": "julia",
293 "ex": "elixir",
294 "exs": "elixir",
295 "scala": "scala",
296 "nix": "nix",
297}
298
299var filenameLang = map[string]string{
300 "dockerfile": "docker",
301 "makefile": "makefile",
302 "gnumakefile": "makefile",
303 ".gitconfig": "ini",
304 ".gitattributes": "ini",
305 "cargo.toml": "toml",
306 "go.mod": "go-module",
307 "go.sum": "text",
308 "package.json": "json",
309 "tsconfig.json": "json",
310}