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(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.
113func chooseLexer(lang string) chroma.Lexer {
114 if lang == "" {
115 return nil
116 }
117 if l := lexers.Get(lang); l != nil {
118 return chroma.Coalesce(l)
119 }
120 // Chroma accepts aliases; try common spellings.
121 if l := lexers.Get(strings.ToLower(lang)); l != nil {
122 return chroma.Coalesce(l)
123 }
124 return nil
125}
126
127// zotChromaStyle is a terminal style tuned for zot's dark palette.
128// Built lazily so the cost is paid once per process.
129var zotChromaStyle = func() *chroma.Style {
130 chromaStyleOnce.Do(func() {
131 chromaStyleCached = buildZotStyle()
132 })
133 return chromaStyleCached
134}()
135
136var (
137 chromaStyleOnce sync.Once
138 chromaStyleCached *chroma.Style
139)
140
141// buildZotStyle returns a chroma style with colors that match zot's
142// theme: accent blue for keywords, green for strings, muted grey for
143// comments, user-tan for numbers. Falls back to chroma's bundled
144// "fruity" style if the builder fails for any reason.
145func buildZotStyle() *chroma.Style {
146 builder := styles.Get("monokai").Builder()
147 // Override the most visible tokens with explicit colors that
148 // read well on a dark terminal.
149 builder.Add(chroma.Keyword, "#81a1c1 bold") // imports, funcs, control flow
150 builder.Add(chroma.KeywordConstant, "#81a1c1") // true, false, null
151 builder.Add(chroma.KeywordDeclaration, "#81a1c1") // const, let, var, type
152 builder.Add(chroma.KeywordNamespace, "#81a1c1") // import, from, package
153 builder.Add(chroma.KeywordReserved, "#81a1c1 bold")
154 builder.Add(chroma.KeywordType, "#88c0d0") // string, int, bool
155 builder.Add(chroma.NameBuiltin, "#88c0d0")
156 builder.Add(chroma.NameFunction, "#8fbcbb")
157 builder.Add(chroma.NameClass, "#a3be8c bold")
158 builder.Add(chroma.NameDecorator, "#b48ead")
159 builder.Add(chroma.LiteralString, "#a3be8c") // strings
160 builder.Add(chroma.LiteralStringEscape, "#bf616a")
161 builder.Add(chroma.LiteralNumber, "#d08770")
162 builder.Add(chroma.Comment, "#616e88 italic")
163 builder.Add(chroma.CommentPreproc, "#b48ead")
164 builder.Add(chroma.Operator, "#eceff4")
165 builder.Add(chroma.Punctuation, "#d8dee9")
166 builder.Add(chroma.Text, "#e5e9f0")
167 builder.Add(chroma.Background, " bg:")
168
169 s, err := builder.Build()
170 if err != nil {
171 return styles.Fallback
172 }
173 return s
174}
175
176// extLang maps file extensions to chroma lexer names. Only
177// extensions that chroma supports are included.
178var extLang = map[string]string{
179 "ts": "typescript",
180 "tsx": "tsx",
181 "js": "javascript",
182 "jsx": "jsx",
183 "mjs": "javascript",
184 "cjs": "javascript",
185 "py": "python",
186 "rb": "ruby",
187 "rs": "rust",
188 "go": "go",
189 "java": "java",
190 "kt": "kotlin",
191 "swift": "swift",
192 "c": "c",
193 "h": "c",
194 "cpp": "cpp",
195 "cc": "cpp",
196 "cxx": "cpp",
197 "hpp": "cpp",
198 "cs": "csharp",
199 "php": "php",
200 "sh": "bash",
201 "bash": "bash",
202 "zsh": "bash",
203 "fish": "fish",
204 "ps1": "powershell",
205 "sql": "sql",
206 "html": "html",
207 "htm": "html",
208 "css": "css",
209 "scss": "scss",
210 "sass": "sass",
211 "less": "less",
212 "json": "json",
213 "yaml": "yaml",
214 "yml": "yaml",
215 "toml": "toml",
216 "xml": "xml",
217 "md": "markdown",
218 "markdown": "markdown",
219 "dockerfile": "docker",
220 "makefile": "makefile",
221 "lua": "lua",
222 "perl": "perl",
223 "pl": "perl",
224 "proto": "protobuf",
225 "tf": "terraform",
226 "hcl": "terraform",
227 "graphql": "graphql",
228 "gql": "graphql",
229 "vue": "vue",
230 "svelte": "svelte",
231 "r": "r",
232 "jl": "julia",
233 "ex": "elixir",
234 "exs": "elixir",
235 "scala": "scala",
236 "nix": "nix",
237}
238
239var filenameLang = map[string]string{
240 "dockerfile": "docker",
241 "makefile": "makefile",
242 "gnumakefile": "makefile",
243 ".gitconfig": "ini",
244 ".gitattributes": "ini",
245 "cargo.toml": "toml",
246 "go.mod": "go-module",
247 "go.sum": "text",
248 "package.json": "json",
249 "tsconfig.json": "json",
250}