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