on main help sign in
feat(diffview): add syntax highlighting
commit80ad5a3a859f424cc167074db9cebb0903de2bd3 authorAndrey Nering <andreynering@users.noreply.github.com> dateThu 12 Jun 2025, 15:52 (1 year ago) parentbaa94c1
3 files changed+155−28
internal/exp/diffview/chroma.go+52 internal/exp/diffview/diffview.go+80−22 internal/exp/diffview/diffview_test.go+23−6
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
22
33import (
44 "fmt"
5+ "image/color"
56 "os"
67 "strconv"
78 "strings"
89
10+ "github.com/alecthomas/chroma/v2"
11+ "github.com/alecthomas/chroma/v2/lexers"
912 "github.com/aymanbagabas/go-udiff"
1013 "github.com/aymanbagabas/go-udiff/myers"
1114 "github.com/charmbracelet/lipgloss/v2"
@@ -43,6 +46,7 @@ type DiffView struct {
4346 yOffset int
4447 style Style
4548 tabWidth int
49+ chromaStyle *chroma.Style
4650
4751 isComputed bool
4852 err error
@@ -153,6 +157,13 @@ func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
153157 return dv
154158}
155159
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+
156167// String returns the string representation of the DiffView.
157168func (dv *DiffView) String() string {
158169 dv.replaceTabs()
@@ -361,14 +372,19 @@ outer:
361372 break outer
362373 }
363374
364− content := strings.TrimSuffix(l.Content, "\n")
365− content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
366− content = ansi.Truncate(content, dv.codeWidth, "…")
375+ getContent := func(ls LineStyle) string {
376+ content := strings.TrimSuffix(l.Content, "\n")
377+ content = dv.hightlightCode(content, ls.Code.GetBackground())
378+ content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
379+ content = ansi.Truncate(content, dv.codeWidth, "…")
380+ return content
381+ }
367382
368383 leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
369384
370385 switch l.Kind {
371386 case udiff.Equal:
387+ content := getContent(dv.style.EqualLine)
372388 if dv.lineNumbers {
373389 write(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
374390 write(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
@@ -379,6 +395,7 @@ outer:
379395 beforeLine++
380396 afterLine++
381397 case udiff.Insert:
398+ content := getContent(dv.style.InsertLine)
382399 if dv.lineNumbers {
383400 write(dv.style.InsertLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
384401 write(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
@@ -389,6 +406,7 @@ outer:
389406 ))
390407 afterLine++
391408 case udiff.Delete:
409+ content := getContent(dv.style.DeleteLine)
392410 if dv.lineNumbers {
393411 write(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
394412 write(dv.style.DeleteLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
@@ -479,22 +497,17 @@ outer:
479497 break outer
480498 }
481499
482− var beforeContent string
483− var afterContent string
484− if l.before != nil {
485− beforeContent = strings.TrimSuffix(l.before.Content, "\n")
486− beforeContent = ansi.GraphemeWidth.Cut(beforeContent, dv.xOffset, len(beforeContent))
487− beforeContent = ansi.Truncate(beforeContent, dv.codeWidth, "…")
500+ getContent := func(content string, ls LineStyle) string {
501+ content = strings.TrimSuffix(content, "\n")
502+ content = dv.hightlightCode(content, ls.Code.GetBackground())
503+ content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
504+ content = ansi.Truncate(content, dv.codeWidth, "…")
505+ return content
488506 }
489− if l.after != nil {
490− afterContent = strings.TrimSuffix(l.after.Content, "\n")
491− afterContent = ansi.GraphemeWidth.Cut(afterContent, dv.xOffset, len(afterContent))
492− afterContent = ansi.Truncate(afterContent, dv.codeWidth+btoi(dv.extraColOnAfter), "…")
507+ getLeadingEllipsis := func(content string) bool {
508+ return dv.xOffset > 0 && strings.TrimSpace(content) != ""
493509 }
494510
495− leadingBeforeEllipsis := dv.xOffset > 0 && strings.TrimSpace(beforeContent) != ""
496− leadingAfterEllipsis := dv.xOffset > 0 && strings.TrimSpace(afterContent) != ""
497−
498511 switch {
499512 case l.before == nil:
500513 if dv.lineNumbers {
@@ -504,20 +517,24 @@ outer:
504517 dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "),
505518 ))
506519 case l.before.Kind == udiff.Equal:
520+ content := getContent(l.before.Content, dv.style.EqualLine)
521+ leadingEllipsis := getLeadingEllipsis(content)
507522 if dv.lineNumbers {
508523 write(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
509524 }
510525 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),
512527 ))
513528 beforeLine++
514529 case l.before.Kind == udiff.Delete:
530+ content := getContent(l.before.Content, dv.style.DeleteLine)
531+ leadingEllipsis := getLeadingEllipsis(content)
515532 if dv.lineNumbers {
516533 write(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
517534 }
518535 write(beforeFullContentStyle.Render(
519− dv.style.DeleteLine.Symbol.Render(ternary(leadingBeforeEllipsis, "-…", "- ")) +
520− dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(beforeContent),
536+ dv.style.DeleteLine.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
537+ dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(content),
521538 ))
522539 beforeLine++
523540 }
@@ -531,20 +548,24 @@ outer:
531548 dv.style.MissingLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
532549 ))
533550 case l.after.Kind == udiff.Equal:
551+ content := getContent(l.after.Content, dv.style.EqualLine)
552+ leadingEllipsis := getLeadingEllipsis(content)
534553 if dv.lineNumbers {
535554 write(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
536555 }
537556 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),
539558 ))
540559 afterLine++
541560 case l.after.Kind == udiff.Insert:
561+ content := getContent(l.after.Content, dv.style.InsertLine)
562+ leadingEllipsis := getLeadingEllipsis(content)
542563 if dv.lineNumbers {
543564 write(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
544565 }
545566 write(afterFullContentStyle.Render(
546− dv.style.InsertLine.Symbol.Render(ternary(leadingAfterEllipsis, "+…", "+ ")) +
547− dv.style.InsertLine.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(afterContent),
567+ dv.style.InsertLine.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
568+ dv.style.InsertLine.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
548569 ))
549570 afterLine++
550571 }
@@ -613,3 +634,40 @@ func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
613634 return dv.style.MissingLine
614635 }
615636}
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 (
66 "strings"
77 "testing"
88
9+ "github.com/alecthomas/chroma/v2/styles"
910 "github.com/charmbracelet/x/ansi"
1011 "github.com/charmbracelet/x/exp/golden"
1112 "github.com/opencode-ai/opencode/internal/exp/diffview"
@@ -87,12 +88,22 @@ var (
8788 After("main.go", TestMultipleHunksAfter).
8889 Width(120)
8990 }
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+ }
9097
9198 LightModeFunc = func(dv *diffview.DiffView) *diffview.DiffView {
92− return dv.Style(diffview.DefaultLightStyle)
99+ return dv.
100+ Style(diffview.DefaultLightStyle).
101+ ChromaStyle(styles.Get("catppuccin-latte"))
93102 }
94103 DarkModeFunc = func(dv *diffview.DiffView) *diffview.DiffView {
95− return dv.Style(diffview.DefaultDarkStyle)
104+ return dv.
105+ Style(diffview.DefaultDarkStyle).
106+ ChromaStyle(styles.Get("catppuccin-macchiato"))
96107 }
97108
98109 LayoutFuncs = TestFuncs{
@@ -107,6 +118,7 @@ var (
107118 "Narrow": NarrowFunc,
108119 "SmallWidth": SmallWidthFunc,
109120 "LargeWidth": LargeWidthFunc,
121+ "NoSyntaxHighlight": NoSyntaxHighlightFunc,
110122 }
111123 ThemeFuncs = TestFuncs{
112124 "LightMode": LightModeFunc,
@@ -123,8 +135,8 @@ func TestDiffView(t *testing.T) {
123135 t.Run(themeName, func(t *testing.T) {
124136 dv := diffview.New()
125137 dv = layoutFunc(dv)
126− dv = behaviorFunc(dv)
127138 dv = themeFunc(dv)
139+ dv = behaviorFunc(dv)
128140
129141 output := dv.String()
130142 golden.RequireEqual(t, []byte(output))
@@ -149,7 +161,8 @@ func TestDiffViewTabs(t *testing.T) {
149161 dv := diffview.New().
150162 Before("main.go", TestTabsBefore).
151163 After("main.go", TestTabsAfter).
152− Style(diffview.DefaultLightStyle)
164+ Style(diffview.DefaultLightStyle).
165+ ChromaStyle(styles.Get("catppuccin-latte"))
153166 dv = layoutFunc(dv)
154167
155168 output := dv.String()
@@ -171,7 +184,8 @@ func TestDiffViewWidth(t *testing.T) {
171184 Before("main.go", TestMultipleHunksBefore).
172185 After("main.go", TestMultipleHunksAfter).
173186 Width(width).
174− Style(diffview.DefaultLightStyle)
187+ Style(diffview.DefaultLightStyle).
188+ ChromaStyle(styles.Get("catppuccin-latte"))
175189 dv = layoutFunc(dv)
176190
177191 output := dv.String()
@@ -193,7 +207,8 @@ func TestDiffViewHeight(t *testing.T) {
193207 Before("main.go", TestMultipleHunksBefore).
194208 After("main.go", TestMultipleHunksAfter).
195209 Height(height).
196− Style(diffview.DefaultLightStyle)
210+ Style(diffview.DefaultLightStyle).
211+ ChromaStyle(styles.Get("catppuccin-latte"))
197212 dv = layoutFunc(dv)
198213
199214 output := dv.String()
@@ -215,6 +230,7 @@ func TestDiffViewXOffset(t *testing.T) {
215230 Before("main.go", TestDefaultBefore).
216231 After("main.go", TestDefaultAfter).
217232 Style(diffview.DefaultLightStyle).
233+ ChromaStyle(styles.Get("catppuccin-latte")).
218234 Width(60).
219235 XOffset(xOffset)
220236 dv = layoutFunc(dv)
@@ -238,6 +254,7 @@ func TestDiffViewYOffset(t *testing.T) {
238254 Before("main.go", TestMultipleHunksBefore).
239255 After("main.go", TestMultipleHunksAfter).
240256 Style(diffview.DefaultLightStyle).
257+ ChromaStyle(styles.Get("catppuccin-latte")).
241258 Height(5).
242259 YOffset(yOffset)
243260 dv = layoutFunc(dv)