on main help sign in
feat(diffview): add syntax highlighting
80ad5a3Andrey Nering
internal/exp/diffview/chroma.gonew file+52
@@ -0,0 +1,52 @@
1+package diffview
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "io"
7+
8+ "github.com/alecthomas/chroma/v2"
9+ "github.com/charmbracelet/lipgloss/v2"
10+)
11+
12+var _ chroma.Formatter = chromaFormatter{}
13+
14+// chromaFormatter is a custom formatter for Chroma that uses Lip Gloss for
15+// foreground styling, while keeping a forced background color.
16+type chromaFormatter struct {
17+ bgColor color.Color
18+}
19+
20+// Format implements the chroma.Formatter interface.
21+func (c chromaFormatter) Format(w io.Writer, style *chroma.Style, it chroma.Iterator) error {
22+ for token := it(); token != chroma.EOF; token = it() {
23+ entry := style.Get(token.Type)
24+ if entry.IsZero() {
25+ if _, err := fmt.Fprint(w, token.Value); err != nil {
26+ return err
27+ }
28+ continue
29+ }
30+
31+ s := lipgloss.NewStyle().
32+ Background(c.bgColor)
33+
34+ if entry.Bold == chroma.Yes {
35+ s = s.Bold(true)
36+ }
37+ if entry.Underline == chroma.Yes {
38+ s = s.Underline(true)
39+ }
40+ if entry.Italic == chroma.Yes {
41+ s = s.Italic(true)
42+ }
43+ if entry.Colour.IsSet() {
44+ s = s.Foreground(lipgloss.Color(entry.Colour.String()))
45+ }
46+
47+ if _, err := fmt.Fprint(w, s.Render(token.Value)); err != nil {
48+ return err
49+ }
50+ }
51+ return nil
52+}
internal/exp/diffview/diffview.go+80−22
@@ -2,10 +2,13 @@ package diffview
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "image/color"
5 "os"
6 "os"
6 "strconv"
7 "strconv"
7 "strings"
8 "strings"
8
9
10+ "github.com/alecthomas/chroma/v2"
11+ "github.com/alecthomas/chroma/v2/lexers"
9 "github.com/aymanbagabas/go-udiff"
12 "github.com/aymanbagabas/go-udiff"
10 "github.com/aymanbagabas/go-udiff/myers"
13 "github.com/aymanbagabas/go-udiff/myers"
11 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/lipgloss/v2"
@@ -43,6 +46,7 @@ type DiffView struct {
43 yOffset int
46 yOffset int
44 style Style
47 style Style
45 tabWidth int
48 tabWidth int
49+ chromaStyle *chroma.Style
46
50
47 isComputed bool
51 isComputed bool
48 err error
52 err error
@@ -153,6 +157,13 @@ func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
153 return dv
157 return dv
154}
158}
155
159
160+// ChromaStyle sets the chroma style for syntax highlighting.
161+// If nil, no syntax highlighting will be applied.
162+func (dv *DiffView) ChromaStyle(style *chroma.Style) *DiffView {
163+ dv.chromaStyle = style
164+ return dv
165+}
166+
156// String returns the string representation of the DiffView.
167// String returns the string representation of the DiffView.
157func (dv *DiffView) String() string {
168func (dv *DiffView) String() string {
158 dv.replaceTabs()
169 dv.replaceTabs()
@@ -361,14 +372,19 @@ outer:
361 break outer
372 break outer
362 }
373 }
363
374
375+ getContent := func(ls LineStyle) string {
364− content := strings.TrimSuffix(l.Content, "\n")
376+ content := strings.TrimSuffix(l.Content, "\n")
377+ content = dv.hightlightCode(content, ls.Code.GetBackground())
365− content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
378+ content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
366− content = ansi.Truncate(content, dv.codeWidth, "…")
379+ content = ansi.Truncate(content, dv.codeWidth, "…")
380+ return content
381+ }
367
382
368 leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
383 leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
369
384
370 switch l.Kind {
385 switch l.Kind {
371 case udiff.Equal:
386 case udiff.Equal:
387+ content := getContent(dv.style.EqualLine)
372 if dv.lineNumbers {
388 if dv.lineNumbers {
373 write(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
389 write(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
374 write(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
390 write(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
@@ -379,6 +395,7 @@ outer:
379 beforeLine++
395 beforeLine++
380 afterLine++
396 afterLine++
381 case udiff.Insert:
397 case udiff.Insert:
398+ content := getContent(dv.style.InsertLine)
382 if dv.lineNumbers {
399 if dv.lineNumbers {
383 write(dv.style.InsertLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
400 write(dv.style.InsertLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
384 write(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
401 write(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
@@ -389,6 +406,7 @@ outer:
389 ))
406 ))
390 afterLine++
407 afterLine++
391 case udiff.Delete:
408 case udiff.Delete:
409+ content := getContent(dv.style.DeleteLine)
392 if dv.lineNumbers {
410 if dv.lineNumbers {
393 write(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
411 write(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
394 write(dv.style.DeleteLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
412 write(dv.style.DeleteLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
@@ -479,22 +497,17 @@ outer:
479 break outer
497 break outer
480 }
498 }
481
499
482− var beforeContent string
500+ getContent := func(content string, ls LineStyle) string {
483− var afterContent string
484− if l.before != nil {
485− beforeContent = strings.TrimSuffix(l.before.Content, "\n")
501+ content = strings.TrimSuffix(content, "\n")
502+ content = dv.hightlightCode(content, ls.Code.GetBackground())
486− beforeContent = ansi.GraphemeWidth.Cut(beforeContent, dv.xOffset, len(beforeContent))
503+ content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
487− beforeContent = ansi.Truncate(beforeContent, dv.codeWidth, "…")
504+ content = ansi.Truncate(content, dv.codeWidth, "…")
505+ return content
488 }
506 }
489− if l.after != nil {
507+ getLeadingEllipsis := func(content string) bool {
490− afterContent = strings.TrimSuffix(l.after.Content, "\n")
508+ return dv.xOffset > 0 && strings.TrimSpace(content) != ""
491− afterContent = ansi.GraphemeWidth.Cut(afterContent, dv.xOffset, len(afterContent))
492− afterContent = ansi.Truncate(afterContent, dv.codeWidth+btoi(dv.extraColOnAfter), "…")
493 }
509 }
494
510
495− leadingBeforeEllipsis := dv.xOffset > 0 && strings.TrimSpace(beforeContent) != ""
496− leadingAfterEllipsis := dv.xOffset > 0 && strings.TrimSpace(afterContent) != ""
497−
498 switch {
511 switch {
499 case l.before == nil:
512 case l.before == nil:
500 if dv.lineNumbers {
513 if dv.lineNumbers {
@@ -504,20 +517,24 @@ outer:
504 dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "),
517 dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "),
505 ))
518 ))
506 case l.before.Kind == udiff.Equal:
519 case l.before.Kind == udiff.Equal:
520+ content := getContent(l.before.Content, dv.style.EqualLine)
521+ leadingEllipsis := getLeadingEllipsis(content)
507 if dv.lineNumbers {
522 if dv.lineNumbers {
508 write(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
523 write(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
509 }
524 }
510 write(beforeFullContentStyle.Render(
525 write(beforeFullContentStyle.Render(
511− dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(ternary(leadingBeforeEllipsis, " …", " ") + beforeContent),
526+ dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
512 ))
527 ))
513 beforeLine++
528 beforeLine++
514 case l.before.Kind == udiff.Delete:
529 case l.before.Kind == udiff.Delete:
530+ content := getContent(l.before.Content, dv.style.DeleteLine)
531+ leadingEllipsis := getLeadingEllipsis(content)
515 if dv.lineNumbers {
532 if dv.lineNumbers {
516 write(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
533 write(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
517 }
534 }
518 write(beforeFullContentStyle.Render(
535 write(beforeFullContentStyle.Render(
519− dv.style.DeleteLine.Symbol.Render(ternary(leadingBeforeEllipsis, "-…", "- ")) +
536+ dv.style.DeleteLine.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
520− dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(beforeContent),
537+ dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(content),
521 ))
538 ))
522 beforeLine++
539 beforeLine++
523 }
540 }
@@ -531,20 +548,24 @@ outer:
531 dv.style.MissingLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
548 dv.style.MissingLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
532 ))
549 ))
533 case l.after.Kind == udiff.Equal:
550 case l.after.Kind == udiff.Equal:
551+ content := getContent(l.after.Content, dv.style.EqualLine)
552+ leadingEllipsis := getLeadingEllipsis(content)
534 if dv.lineNumbers {
553 if dv.lineNumbers {
535 write(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
554 write(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
536 }
555 }
537 write(afterFullContentStyle.Render(
556 write(afterFullContentStyle.Render(
538− dv.style.EqualLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingAfterEllipsis, " …", " ") + afterContent),
557+ dv.style.EqualLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
539 ))
558 ))
540 afterLine++
559 afterLine++
541 case l.after.Kind == udiff.Insert:
560 case l.after.Kind == udiff.Insert:
561+ content := getContent(l.after.Content, dv.style.InsertLine)
562+ leadingEllipsis := getLeadingEllipsis(content)
542 if dv.lineNumbers {
563 if dv.lineNumbers {
543 write(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
564 write(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
544 }
565 }
545 write(afterFullContentStyle.Render(
566 write(afterFullContentStyle.Render(
546− dv.style.InsertLine.Symbol.Render(ternary(leadingAfterEllipsis, "+…", "+ ")) +
567+ dv.style.InsertLine.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
547− dv.style.InsertLine.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(afterContent),
568+ dv.style.InsertLine.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
548 ))
569 ))
549 afterLine++
570 afterLine++
550 }
571 }
@@ -613,3 +634,40 @@ func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
613 return dv.style.MissingLine
634 return dv.style.MissingLine
614 }
635 }
615}
636}
637+
638+func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
639+ if dv.chromaStyle == nil {
640+ return source
641+ }
642+
643+ l := dv.getChromaLexer(source)
644+ f := dv.getChromaFormatter(bgColor)
645+
646+ it, err := l.Tokenise(nil, source)
647+ if err != nil {
648+ return source
649+ }
650+
651+ var b strings.Builder
652+ if err := f.Format(&b, dv.chromaStyle, it); err != nil {
653+ return source
654+ }
655+ return b.String()
656+}
657+
658+func (dv *DiffView) getChromaLexer(source string) chroma.Lexer {
659+ l := lexers.Match(dv.before.path)
660+ if l == nil {
661+ l = lexers.Analyse(source)
662+ }
663+ if l == nil {
664+ l = lexers.Fallback
665+ }
666+ return chroma.Coalesce(l)
667+}
668+
669+func (dv *DiffView) getChromaFormatter(gbColor color.Color) chroma.Formatter {
670+ return chromaFormatter{
671+ bgColor: gbColor,
672+ }
673+}
internal/exp/diffview/diffview_test.go+23−6
@@ -6,6 +6,7 @@ import (
6 "strings"
6 "strings"
7 "testing"
7 "testing"
8
8
9+ "github.com/alecthomas/chroma/v2/styles"
9 "github.com/charmbracelet/x/ansi"
10 "github.com/charmbracelet/x/ansi"
10 "github.com/charmbracelet/x/exp/golden"
11 "github.com/charmbracelet/x/exp/golden"
11 "github.com/opencode-ai/opencode/internal/exp/diffview"
12 "github.com/opencode-ai/opencode/internal/exp/diffview"
@@ -87,12 +88,22 @@ var (
87 After("main.go", TestMultipleHunksAfter).
88 After("main.go", TestMultipleHunksAfter).
88 Width(120)
89 Width(120)
89 }
90 }
91+ NoSyntaxHighlightFunc = func(dv *diffview.DiffView) *diffview.DiffView {
92+ return dv.
93+ Before("main.go", TestMultipleHunksBefore).
94+ After("main.go", TestMultipleHunksAfter).
95+ ChromaStyle(nil)
96+ }
90
97
91 LightModeFunc = func(dv *diffview.DiffView) *diffview.DiffView {
98 LightModeFunc = func(dv *diffview.DiffView) *diffview.DiffView {
99+ return dv.
92− return dv.Style(diffview.DefaultLightStyle)
100+ Style(diffview.DefaultLightStyle).
101+ ChromaStyle(styles.Get("catppuccin-latte"))
93 }
102 }
94 DarkModeFunc = func(dv *diffview.DiffView) *diffview.DiffView {
103 DarkModeFunc = func(dv *diffview.DiffView) *diffview.DiffView {
104+ return dv.
95− return dv.Style(diffview.DefaultDarkStyle)
105+ Style(diffview.DefaultDarkStyle).
106+ ChromaStyle(styles.Get("catppuccin-macchiato"))
96 }
107 }
97
108
98 LayoutFuncs = TestFuncs{
109 LayoutFuncs = TestFuncs{
@@ -107,6 +118,7 @@ var (
107 "Narrow": NarrowFunc,
118 "Narrow": NarrowFunc,
108 "SmallWidth": SmallWidthFunc,
119 "SmallWidth": SmallWidthFunc,
109 "LargeWidth": LargeWidthFunc,
120 "LargeWidth": LargeWidthFunc,
121+ "NoSyntaxHighlight": NoSyntaxHighlightFunc,
110 }
122 }
111 ThemeFuncs = TestFuncs{
123 ThemeFuncs = TestFuncs{
112 "LightMode": LightModeFunc,
124 "LightMode": LightModeFunc,
@@ -123,8 +135,8 @@ func TestDiffView(t *testing.T) {
123 t.Run(themeName, func(t *testing.T) {
135 t.Run(themeName, func(t *testing.T) {
124 dv := diffview.New()
136 dv := diffview.New()
125 dv = layoutFunc(dv)
137 dv = layoutFunc(dv)
126− dv = behaviorFunc(dv)
127 dv = themeFunc(dv)
138 dv = themeFunc(dv)
139+ dv = behaviorFunc(dv)
128
140
129 output := dv.String()
141 output := dv.String()
130 golden.RequireEqual(t, []byte(output))
142 golden.RequireEqual(t, []byte(output))
@@ -149,7 +161,8 @@ func TestDiffViewTabs(t *testing.T) {
149 dv := diffview.New().
161 dv := diffview.New().
150 Before("main.go", TestTabsBefore).
162 Before("main.go", TestTabsBefore).
151 After("main.go", TestTabsAfter).
163 After("main.go", TestTabsAfter).
152− Style(diffview.DefaultLightStyle)
164+ Style(diffview.DefaultLightStyle).
165+ ChromaStyle(styles.Get("catppuccin-latte"))
153 dv = layoutFunc(dv)
166 dv = layoutFunc(dv)
154
167
155 output := dv.String()
168 output := dv.String()
@@ -171,7 +184,8 @@ func TestDiffViewWidth(t *testing.T) {
171 Before("main.go", TestMultipleHunksBefore).
184 Before("main.go", TestMultipleHunksBefore).
172 After("main.go", TestMultipleHunksAfter).
185 After("main.go", TestMultipleHunksAfter).
173 Width(width).
186 Width(width).
174− Style(diffview.DefaultLightStyle)
187+ Style(diffview.DefaultLightStyle).
188+ ChromaStyle(styles.Get("catppuccin-latte"))
175 dv = layoutFunc(dv)
189 dv = layoutFunc(dv)
176
190
177 output := dv.String()
191 output := dv.String()
@@ -193,7 +207,8 @@ func TestDiffViewHeight(t *testing.T) {
193 Before("main.go", TestMultipleHunksBefore).
207 Before("main.go", TestMultipleHunksBefore).
194 After("main.go", TestMultipleHunksAfter).
208 After("main.go", TestMultipleHunksAfter).
195 Height(height).
209 Height(height).
196− Style(diffview.DefaultLightStyle)
210+ Style(diffview.DefaultLightStyle).
211+ ChromaStyle(styles.Get("catppuccin-latte"))
197 dv = layoutFunc(dv)
212 dv = layoutFunc(dv)
198
213
199 output := dv.String()
214 output := dv.String()
@@ -215,6 +230,7 @@ func TestDiffViewXOffset(t *testing.T) {
215 Before("main.go", TestDefaultBefore).
230 Before("main.go", TestDefaultBefore).
216 After("main.go", TestDefaultAfter).
231 After("main.go", TestDefaultAfter).
217 Style(diffview.DefaultLightStyle).
232 Style(diffview.DefaultLightStyle).
233+ ChromaStyle(styles.Get("catppuccin-latte")).
218 Width(60).
234 Width(60).
219 XOffset(xOffset)
235 XOffset(xOffset)
220 dv = layoutFunc(dv)
236 dv = layoutFunc(dv)
@@ -238,6 +254,7 @@ func TestDiffViewYOffset(t *testing.T) {
238 Before("main.go", TestMultipleHunksBefore).
254 Before("main.go", TestMultipleHunksBefore).
239 After("main.go", TestMultipleHunksAfter).
255 After("main.go", TestMultipleHunksAfter).
240 Style(diffview.DefaultLightStyle).
256 Style(diffview.DefaultLightStyle).
257+ ChromaStyle(styles.Get("catppuccin-latte")).
241 Height(5).
258 Height(5).
242 YOffset(yOffset)
259 YOffset(yOffset)
243 dv = layoutFunc(dv)
260 dv = layoutFunc(dv)