diff.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047
  1. package diff
  2. import (
  3. "bytes"
  4. "fmt"
  5. "io"
  6. "os"
  7. "path/filepath"
  8. "regexp"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "github.com/alecthomas/chroma/v2"
  13. "github.com/alecthomas/chroma/v2/formatters"
  14. "github.com/alecthomas/chroma/v2/lexers"
  15. "github.com/alecthomas/chroma/v2/styles"
  16. "github.com/charmbracelet/lipgloss"
  17. "github.com/charmbracelet/x/ansi"
  18. "github.com/go-git/go-git/v5"
  19. "github.com/go-git/go-git/v5/plumbing/object"
  20. "github.com/kujtimiihoxha/opencode/internal/config"
  21. "github.com/kujtimiihoxha/opencode/internal/logging"
  22. "github.com/sergi/go-diff/diffmatchpatch"
  23. )
  24. // -------------------------------------------------------------------------
  25. // Core Types
  26. // -------------------------------------------------------------------------
  27. // LineType represents the kind of line in a diff.
  28. type LineType int
  29. const (
  30. LineContext LineType = iota // Line exists in both files
  31. LineAdded // Line added in the new file
  32. LineRemoved // Line removed from the old file
  33. )
  34. // Segment represents a portion of a line for intra-line highlighting
  35. type Segment struct {
  36. Start int
  37. End int
  38. Type LineType
  39. Text string
  40. }
  41. // DiffLine represents a single line in a diff
  42. type DiffLine struct {
  43. OldLineNo int // Line number in old file (0 for added lines)
  44. NewLineNo int // Line number in new file (0 for removed lines)
  45. Kind LineType // Type of line (added, removed, context)
  46. Content string // Content of the line
  47. Segments []Segment // Segments for intraline highlighting
  48. }
  49. // Hunk represents a section of changes in a diff
  50. type Hunk struct {
  51. Header string
  52. Lines []DiffLine
  53. }
  54. // DiffResult contains the parsed result of a diff
  55. type DiffResult struct {
  56. OldFile string
  57. NewFile string
  58. Hunks []Hunk
  59. }
  60. // linePair represents a pair of lines for side-by-side display
  61. type linePair struct {
  62. left *DiffLine
  63. right *DiffLine
  64. }
  65. // -------------------------------------------------------------------------
  66. // Style Configuration
  67. // -------------------------------------------------------------------------
  68. // StyleConfig defines styling for diff rendering
  69. type StyleConfig struct {
  70. ShowHeader bool
  71. ShowHunkHeader bool
  72. FileNameFg lipgloss.Color
  73. // Background colors
  74. RemovedLineBg lipgloss.Color
  75. AddedLineBg lipgloss.Color
  76. ContextLineBg lipgloss.Color
  77. HunkLineBg lipgloss.Color
  78. RemovedLineNumberBg lipgloss.Color
  79. AddedLineNamerBg lipgloss.Color
  80. // Foreground colors
  81. HunkLineFg lipgloss.Color
  82. RemovedFg lipgloss.Color
  83. AddedFg lipgloss.Color
  84. LineNumberFg lipgloss.Color
  85. RemovedHighlightFg lipgloss.Color
  86. AddedHighlightFg lipgloss.Color
  87. // Highlight settings
  88. HighlightStyle string
  89. RemovedHighlightBg lipgloss.Color
  90. AddedHighlightBg lipgloss.Color
  91. }
  92. // StyleOption is a function that modifies a StyleConfig
  93. type StyleOption func(*StyleConfig)
  94. // NewStyleConfig creates a StyleConfig with default values
  95. func NewStyleConfig(opts ...StyleOption) StyleConfig {
  96. // Default color scheme
  97. config := StyleConfig{
  98. ShowHeader: true,
  99. ShowHunkHeader: true,
  100. FileNameFg: lipgloss.Color("#a0a0a0"),
  101. RemovedLineBg: lipgloss.Color("#3A3030"),
  102. AddedLineBg: lipgloss.Color("#303A30"),
  103. ContextLineBg: lipgloss.Color("#212121"),
  104. HunkLineBg: lipgloss.Color("#212121"),
  105. HunkLineFg: lipgloss.Color("#a0a0a0"),
  106. RemovedFg: lipgloss.Color("#7C4444"),
  107. AddedFg: lipgloss.Color("#478247"),
  108. LineNumberFg: lipgloss.Color("#888888"),
  109. HighlightStyle: "dracula",
  110. RemovedHighlightBg: lipgloss.Color("#612726"),
  111. AddedHighlightBg: lipgloss.Color("#256125"),
  112. RemovedLineNumberBg: lipgloss.Color("#332929"),
  113. AddedLineNamerBg: lipgloss.Color("#293229"),
  114. RemovedHighlightFg: lipgloss.Color("#FADADD"),
  115. AddedHighlightFg: lipgloss.Color("#DAFADA"),
  116. }
  117. // Apply all provided options
  118. for _, opt := range opts {
  119. opt(&config)
  120. }
  121. return config
  122. }
  123. // Style option functions
  124. func WithFileNameFg(color lipgloss.Color) StyleOption {
  125. return func(s *StyleConfig) { s.FileNameFg = color }
  126. }
  127. func WithRemovedLineBg(color lipgloss.Color) StyleOption {
  128. return func(s *StyleConfig) { s.RemovedLineBg = color }
  129. }
  130. func WithAddedLineBg(color lipgloss.Color) StyleOption {
  131. return func(s *StyleConfig) { s.AddedLineBg = color }
  132. }
  133. func WithContextLineBg(color lipgloss.Color) StyleOption {
  134. return func(s *StyleConfig) { s.ContextLineBg = color }
  135. }
  136. func WithRemovedFg(color lipgloss.Color) StyleOption {
  137. return func(s *StyleConfig) { s.RemovedFg = color }
  138. }
  139. func WithAddedFg(color lipgloss.Color) StyleOption {
  140. return func(s *StyleConfig) { s.AddedFg = color }
  141. }
  142. func WithLineNumberFg(color lipgloss.Color) StyleOption {
  143. return func(s *StyleConfig) { s.LineNumberFg = color }
  144. }
  145. func WithHighlightStyle(style string) StyleOption {
  146. return func(s *StyleConfig) { s.HighlightStyle = style }
  147. }
  148. func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
  149. return func(s *StyleConfig) {
  150. s.RemovedHighlightBg = bg
  151. s.RemovedHighlightFg = fg
  152. }
  153. }
  154. func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
  155. return func(s *StyleConfig) {
  156. s.AddedHighlightBg = bg
  157. s.AddedHighlightFg = fg
  158. }
  159. }
  160. func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
  161. return func(s *StyleConfig) { s.RemovedLineNumberBg = color }
  162. }
  163. func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
  164. return func(s *StyleConfig) { s.AddedLineNamerBg = color }
  165. }
  166. func WithHunkLineBg(color lipgloss.Color) StyleOption {
  167. return func(s *StyleConfig) { s.HunkLineBg = color }
  168. }
  169. func WithHunkLineFg(color lipgloss.Color) StyleOption {
  170. return func(s *StyleConfig) { s.HunkLineFg = color }
  171. }
  172. func WithShowHeader(show bool) StyleOption {
  173. return func(s *StyleConfig) { s.ShowHeader = show }
  174. }
  175. func WithShowHunkHeader(show bool) StyleOption {
  176. return func(s *StyleConfig) { s.ShowHunkHeader = show }
  177. }
  178. // -------------------------------------------------------------------------
  179. // Parse Configuration
  180. // -------------------------------------------------------------------------
  181. // ParseConfig configures the behavior of diff parsing
  182. type ParseConfig struct {
  183. ContextSize int // Number of context lines to include
  184. }
  185. // ParseOption modifies a ParseConfig
  186. type ParseOption func(*ParseConfig)
  187. // WithContextSize sets the number of context lines to include
  188. func WithContextSize(size int) ParseOption {
  189. return func(p *ParseConfig) {
  190. if size >= 0 {
  191. p.ContextSize = size
  192. }
  193. }
  194. }
  195. // -------------------------------------------------------------------------
  196. // Side-by-Side Configuration
  197. // -------------------------------------------------------------------------
  198. // SideBySideConfig configures the rendering of side-by-side diffs
  199. type SideBySideConfig struct {
  200. TotalWidth int
  201. Style StyleConfig
  202. }
  203. // SideBySideOption modifies a SideBySideConfig
  204. type SideBySideOption func(*SideBySideConfig)
  205. // NewSideBySideConfig creates a SideBySideConfig with default values
  206. func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
  207. config := SideBySideConfig{
  208. TotalWidth: 160, // Default width for side-by-side view
  209. Style: NewStyleConfig(),
  210. }
  211. for _, opt := range opts {
  212. opt(&config)
  213. }
  214. return config
  215. }
  216. // WithTotalWidth sets the total width for side-by-side view
  217. func WithTotalWidth(width int) SideBySideOption {
  218. return func(s *SideBySideConfig) {
  219. if width > 0 {
  220. s.TotalWidth = width
  221. }
  222. }
  223. }
  224. // WithStyle sets the styling configuration
  225. func WithStyle(style StyleConfig) SideBySideOption {
  226. return func(s *SideBySideConfig) {
  227. s.Style = style
  228. }
  229. }
  230. // WithStyleOptions applies the specified style options
  231. func WithStyleOptions(opts ...StyleOption) SideBySideOption {
  232. return func(s *SideBySideConfig) {
  233. s.Style = NewStyleConfig(opts...)
  234. }
  235. }
  236. // -------------------------------------------------------------------------
  237. // Diff Parsing
  238. // -------------------------------------------------------------------------
  239. // ParseUnifiedDiff parses a unified diff format string into structured data
  240. func ParseUnifiedDiff(diff string) (DiffResult, error) {
  241. var result DiffResult
  242. var currentHunk *Hunk
  243. hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
  244. lines := strings.Split(diff, "\n")
  245. var oldLine, newLine int
  246. inFileHeader := true
  247. for _, line := range lines {
  248. // Parse file headers
  249. if inFileHeader {
  250. if strings.HasPrefix(line, "--- a/") {
  251. result.OldFile = strings.TrimPrefix(line, "--- a/")
  252. continue
  253. }
  254. if strings.HasPrefix(line, "+++ b/") {
  255. result.NewFile = strings.TrimPrefix(line, "+++ b/")
  256. inFileHeader = false
  257. continue
  258. }
  259. }
  260. // Parse hunk headers
  261. if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
  262. if currentHunk != nil {
  263. result.Hunks = append(result.Hunks, *currentHunk)
  264. }
  265. currentHunk = &Hunk{
  266. Header: line,
  267. Lines: []DiffLine{},
  268. }
  269. oldStart, _ := strconv.Atoi(matches[1])
  270. newStart, _ := strconv.Atoi(matches[3])
  271. oldLine = oldStart
  272. newLine = newStart
  273. continue
  274. }
  275. // Ignore "No newline at end of file" markers
  276. if strings.HasPrefix(line, "\\ No newline at end of file") {
  277. continue
  278. }
  279. if currentHunk == nil {
  280. continue
  281. }
  282. // Process the line based on its prefix
  283. if len(line) > 0 {
  284. switch line[0] {
  285. case '+':
  286. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  287. OldLineNo: 0,
  288. NewLineNo: newLine,
  289. Kind: LineAdded,
  290. Content: line[1:],
  291. })
  292. newLine++
  293. case '-':
  294. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  295. OldLineNo: oldLine,
  296. NewLineNo: 0,
  297. Kind: LineRemoved,
  298. Content: line[1:],
  299. })
  300. oldLine++
  301. default:
  302. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  303. OldLineNo: oldLine,
  304. NewLineNo: newLine,
  305. Kind: LineContext,
  306. Content: line,
  307. })
  308. oldLine++
  309. newLine++
  310. }
  311. } else {
  312. // Handle empty lines
  313. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  314. OldLineNo: oldLine,
  315. NewLineNo: newLine,
  316. Kind: LineContext,
  317. Content: "",
  318. })
  319. oldLine++
  320. newLine++
  321. }
  322. }
  323. // Add the last hunk if there is one
  324. if currentHunk != nil {
  325. result.Hunks = append(result.Hunks, *currentHunk)
  326. }
  327. return result, nil
  328. }
  329. // HighlightIntralineChanges updates lines in a hunk to show character-level differences
  330. func HighlightIntralineChanges(h *Hunk, style StyleConfig) {
  331. var updated []DiffLine
  332. dmp := diffmatchpatch.New()
  333. for i := 0; i < len(h.Lines); i++ {
  334. // Look for removed line followed by added line
  335. if i+1 < len(h.Lines) &&
  336. h.Lines[i].Kind == LineRemoved &&
  337. h.Lines[i+1].Kind == LineAdded {
  338. oldLine := h.Lines[i]
  339. newLine := h.Lines[i+1]
  340. // Find character-level differences
  341. patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
  342. patches = dmp.DiffCleanupSemantic(patches)
  343. patches = dmp.DiffCleanupMerge(patches)
  344. patches = dmp.DiffCleanupEfficiency(patches)
  345. segments := make([]Segment, 0)
  346. removeStart := 0
  347. addStart := 0
  348. for _, patch := range patches {
  349. switch patch.Type {
  350. case diffmatchpatch.DiffDelete:
  351. segments = append(segments, Segment{
  352. Start: removeStart,
  353. End: removeStart + len(patch.Text),
  354. Type: LineRemoved,
  355. Text: patch.Text,
  356. })
  357. removeStart += len(patch.Text)
  358. case diffmatchpatch.DiffInsert:
  359. segments = append(segments, Segment{
  360. Start: addStart,
  361. End: addStart + len(patch.Text),
  362. Type: LineAdded,
  363. Text: patch.Text,
  364. })
  365. addStart += len(patch.Text)
  366. default:
  367. // Context text, no highlighting needed
  368. removeStart += len(patch.Text)
  369. addStart += len(patch.Text)
  370. }
  371. }
  372. oldLine.Segments = segments
  373. newLine.Segments = segments
  374. updated = append(updated, oldLine, newLine)
  375. i++ // Skip the next line as we've already processed it
  376. } else {
  377. updated = append(updated, h.Lines[i])
  378. }
  379. }
  380. h.Lines = updated
  381. }
  382. // pairLines converts a flat list of diff lines to pairs for side-by-side display
  383. func pairLines(lines []DiffLine) []linePair {
  384. var pairs []linePair
  385. i := 0
  386. for i < len(lines) {
  387. switch lines[i].Kind {
  388. case LineRemoved:
  389. // Check if the next line is an addition, if so pair them
  390. if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
  391. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
  392. i += 2
  393. } else {
  394. pairs = append(pairs, linePair{left: &lines[i], right: nil})
  395. i++
  396. }
  397. case LineAdded:
  398. pairs = append(pairs, linePair{left: nil, right: &lines[i]})
  399. i++
  400. case LineContext:
  401. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
  402. i++
  403. }
  404. }
  405. return pairs
  406. }
  407. // -------------------------------------------------------------------------
  408. // Syntax Highlighting
  409. // -------------------------------------------------------------------------
  410. // SyntaxHighlight applies syntax highlighting to text based on file extension
  411. func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
  412. // Determine the language lexer to use
  413. l := lexers.Match(fileName)
  414. if l == nil {
  415. l = lexers.Analyse(source)
  416. }
  417. if l == nil {
  418. l = lexers.Fallback
  419. }
  420. l = chroma.Coalesce(l)
  421. // Get the formatter
  422. f := formatters.Get(formatter)
  423. if f == nil {
  424. f = formatters.Fallback
  425. }
  426. theme := `
  427. <style name="vscode-dark-plus">
  428. <!-- Base colors -->
  429. <entry type="Background" style="bg:#1E1E1E"/>
  430. <entry type="Text" style="#D4D4D4"/>
  431. <entry type="Other" style="#D4D4D4"/>
  432. <entry type="Error" style="#F44747"/>
  433. <!-- Keywords - using the Control flow / Special keywords color -->
  434. <entry type="Keyword" style="#C586C0"/>
  435. <entry type="KeywordConstant" style="#4FC1FF"/>
  436. <entry type="KeywordDeclaration" style="#C586C0"/>
  437. <entry type="KeywordNamespace" style="#C586C0"/>
  438. <entry type="KeywordPseudo" style="#C586C0"/>
  439. <entry type="KeywordReserved" style="#C586C0"/>
  440. <entry type="KeywordType" style="#4EC9B0"/>
  441. <!-- Names -->
  442. <entry type="Name" style="#D4D4D4"/>
  443. <entry type="NameAttribute" style="#9CDCFE"/>
  444. <entry type="NameBuiltin" style="#4EC9B0"/>
  445. <entry type="NameBuiltinPseudo" style="#9CDCFE"/>
  446. <entry type="NameClass" style="#4EC9B0"/>
  447. <entry type="NameConstant" style="#4FC1FF"/>
  448. <entry type="NameDecorator" style="#DCDCAA"/>
  449. <entry type="NameEntity" style="#9CDCFE"/>
  450. <entry type="NameException" style="#4EC9B0"/>
  451. <entry type="NameFunction" style="#DCDCAA"/>
  452. <entry type="NameLabel" style="#C8C8C8"/>
  453. <entry type="NameNamespace" style="#4EC9B0"/>
  454. <entry type="NameOther" style="#9CDCFE"/>
  455. <entry type="NameTag" style="#569CD6"/>
  456. <entry type="NameVariable" style="#9CDCFE"/>
  457. <entry type="NameVariableClass" style="#9CDCFE"/>
  458. <entry type="NameVariableGlobal" style="#9CDCFE"/>
  459. <entry type="NameVariableInstance" style="#9CDCFE"/>
  460. <!-- Literals -->
  461. <entry type="Literal" style="#CE9178"/>
  462. <entry type="LiteralDate" style="#CE9178"/>
  463. <entry type="LiteralString" style="#CE9178"/>
  464. <entry type="LiteralStringBacktick" style="#CE9178"/>
  465. <entry type="LiteralStringChar" style="#CE9178"/>
  466. <entry type="LiteralStringDoc" style="#CE9178"/>
  467. <entry type="LiteralStringDouble" style="#CE9178"/>
  468. <entry type="LiteralStringEscape" style="#d7ba7d"/>
  469. <entry type="LiteralStringHeredoc" style="#CE9178"/>
  470. <entry type="LiteralStringInterpol" style="#CE9178"/>
  471. <entry type="LiteralStringOther" style="#CE9178"/>
  472. <entry type="LiteralStringRegex" style="#d16969"/>
  473. <entry type="LiteralStringSingle" style="#CE9178"/>
  474. <entry type="LiteralStringSymbol" style="#CE9178"/>
  475. <!-- Numbers - using the numberLiteral color -->
  476. <entry type="LiteralNumber" style="#b5cea8"/>
  477. <entry type="LiteralNumberBin" style="#b5cea8"/>
  478. <entry type="LiteralNumberFloat" style="#b5cea8"/>
  479. <entry type="LiteralNumberHex" style="#b5cea8"/>
  480. <entry type="LiteralNumberInteger" style="#b5cea8"/>
  481. <entry type="LiteralNumberIntegerLong" style="#b5cea8"/>
  482. <entry type="LiteralNumberOct" style="#b5cea8"/>
  483. <!-- Operators -->
  484. <entry type="Operator" style="#D4D4D4"/>
  485. <entry type="OperatorWord" style="#C586C0"/>
  486. <entry type="Punctuation" style="#D4D4D4"/>
  487. <!-- Comments - standard VSCode Dark+ comment color -->
  488. <entry type="Comment" style="#6A9955"/>
  489. <entry type="CommentHashbang" style="#6A9955"/>
  490. <entry type="CommentMultiline" style="#6A9955"/>
  491. <entry type="CommentSingle" style="#6A9955"/>
  492. <entry type="CommentSpecial" style="#6A9955"/>
  493. <entry type="CommentPreproc" style="#C586C0"/>
  494. <!-- Generic styles -->
  495. <entry type="Generic" style="#D4D4D4"/>
  496. <entry type="GenericDeleted" style="#F44747"/>
  497. <entry type="GenericEmph" style="italic #D4D4D4"/>
  498. <entry type="GenericError" style="#F44747"/>
  499. <entry type="GenericHeading" style="bold #D4D4D4"/>
  500. <entry type="GenericInserted" style="#b5cea8"/>
  501. <entry type="GenericOutput" style="#808080"/>
  502. <entry type="GenericPrompt" style="#D4D4D4"/>
  503. <entry type="GenericStrong" style="bold #D4D4D4"/>
  504. <entry type="GenericSubheading" style="bold #D4D4D4"/>
  505. <entry type="GenericTraceback" style="#F44747"/>
  506. <entry type="GenericUnderline" style="underline"/>
  507. <entry type="TextWhitespace" style="#D4D4D4"/>
  508. </style>
  509. `
  510. r := strings.NewReader(theme)
  511. style := chroma.MustNewXMLStyle(r)
  512. // Modify the style to use the provided background
  513. s, err := style.Builder().Transform(
  514. func(t chroma.StyleEntry) chroma.StyleEntry {
  515. r, g, b, _ := bg.RGBA()
  516. t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
  517. return t
  518. },
  519. ).Build()
  520. if err != nil {
  521. s = styles.Fallback
  522. }
  523. // Tokenize and format
  524. it, err := l.Tokenise(nil, source)
  525. if err != nil {
  526. return err
  527. }
  528. return f.Format(w, s, it)
  529. }
  530. // highlightLine applies syntax highlighting to a single line
  531. func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
  532. var buf bytes.Buffer
  533. err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
  534. if err != nil {
  535. return line
  536. }
  537. return buf.String()
  538. }
  539. // createStyles generates the lipgloss styles needed for rendering diffs
  540. func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
  541. removedLineStyle = lipgloss.NewStyle().Background(config.RemovedLineBg)
  542. addedLineStyle = lipgloss.NewStyle().Background(config.AddedLineBg)
  543. contextLineStyle = lipgloss.NewStyle().Background(config.ContextLineBg)
  544. lineNumberStyle = lipgloss.NewStyle().Foreground(config.LineNumberFg)
  545. return
  546. }
  547. // -------------------------------------------------------------------------
  548. // Rendering Functions
  549. // -------------------------------------------------------------------------
  550. // applyHighlighting applies intra-line highlighting to a piece of text
  551. func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.Color,
  552. ) string {
  553. // Find all ANSI sequences in the content
  554. ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
  555. ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
  556. // Build a mapping of visible character positions to their actual indices
  557. visibleIdx := 0
  558. ansiSequences := make(map[int]string)
  559. lastAnsiSeq := "\x1b[0m" // Default reset sequence
  560. for i := 0; i < len(content); {
  561. isAnsi := false
  562. for _, match := range ansiMatches {
  563. if match[0] == i {
  564. ansiSequences[visibleIdx] = content[match[0]:match[1]]
  565. lastAnsiSeq = content[match[0]:match[1]]
  566. i = match[1]
  567. isAnsi = true
  568. break
  569. }
  570. }
  571. if isAnsi {
  572. continue
  573. }
  574. // For non-ANSI positions, store the last ANSI sequence
  575. if _, exists := ansiSequences[visibleIdx]; !exists {
  576. ansiSequences[visibleIdx] = lastAnsiSeq
  577. }
  578. visibleIdx++
  579. i++
  580. }
  581. // Apply highlighting
  582. var sb strings.Builder
  583. inSelection := false
  584. currentPos := 0
  585. for i := 0; i < len(content); {
  586. // Check if we're at an ANSI sequence
  587. isAnsi := false
  588. for _, match := range ansiMatches {
  589. if match[0] == i {
  590. sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
  591. i = match[1]
  592. isAnsi = true
  593. break
  594. }
  595. }
  596. if isAnsi {
  597. continue
  598. }
  599. // Check for segment boundaries
  600. for _, seg := range segments {
  601. if seg.Type == segmentType {
  602. if currentPos == seg.Start {
  603. inSelection = true
  604. }
  605. if currentPos == seg.End {
  606. inSelection = false
  607. }
  608. }
  609. }
  610. // Get current character
  611. char := string(content[i])
  612. if inSelection {
  613. // Get the current styling
  614. currentStyle := ansiSequences[currentPos]
  615. // Apply background highlight
  616. sb.WriteString("\x1b[48;2;")
  617. r, g, b, _ := highlightBg.RGBA()
  618. sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
  619. sb.WriteString(char)
  620. sb.WriteString("\x1b[49m") // Reset only background
  621. // Reapply the original ANSI sequence
  622. sb.WriteString(currentStyle)
  623. } else {
  624. // Not in selection, just copy the character
  625. sb.WriteString(char)
  626. }
  627. currentPos++
  628. i++
  629. }
  630. return sb.String()
  631. }
  632. // renderLeftColumn formats the left side of a side-by-side diff
  633. func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
  634. if dl == nil {
  635. contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
  636. return contextLineStyle.Width(colWidth).Render("")
  637. }
  638. removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
  639. // Determine line style based on line type
  640. var marker string
  641. var bgStyle lipgloss.Style
  642. switch dl.Kind {
  643. case LineRemoved:
  644. marker = removedLineStyle.Foreground(styles.RemovedFg).Render("-")
  645. bgStyle = removedLineStyle
  646. lineNumberStyle = lineNumberStyle.Foreground(styles.RemovedFg).Background(styles.RemovedLineNumberBg)
  647. case LineAdded:
  648. marker = "?"
  649. bgStyle = contextLineStyle
  650. case LineContext:
  651. marker = contextLineStyle.Render(" ")
  652. bgStyle = contextLineStyle
  653. }
  654. // Format line number
  655. lineNum := ""
  656. if dl.OldLineNo > 0 {
  657. lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
  658. }
  659. // Create the line prefix
  660. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  661. // Apply syntax highlighting
  662. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  663. // Apply intra-line highlighting for removed lines
  664. if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
  665. content = applyHighlighting(content, dl.Segments, LineRemoved, styles.RemovedHighlightBg)
  666. }
  667. // Add a padding space for removed lines
  668. if dl.Kind == LineRemoved {
  669. content = bgStyle.Render(" ") + content
  670. }
  671. // Create the final line and truncate if needed
  672. lineText := prefix + content
  673. return bgStyle.MaxHeight(1).Width(colWidth).Render(
  674. ansi.Truncate(
  675. lineText,
  676. colWidth,
  677. lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
  678. ),
  679. )
  680. }
  681. // renderRightColumn formats the right side of a side-by-side diff
  682. func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
  683. if dl == nil {
  684. contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
  685. return contextLineStyle.Width(colWidth).Render("")
  686. }
  687. _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
  688. // Determine line style based on line type
  689. var marker string
  690. var bgStyle lipgloss.Style
  691. switch dl.Kind {
  692. case LineAdded:
  693. marker = addedLineStyle.Foreground(styles.AddedFg).Render("+")
  694. bgStyle = addedLineStyle
  695. lineNumberStyle = lineNumberStyle.Foreground(styles.AddedFg).Background(styles.AddedLineNamerBg)
  696. case LineRemoved:
  697. marker = "?"
  698. bgStyle = contextLineStyle
  699. case LineContext:
  700. marker = contextLineStyle.Render(" ")
  701. bgStyle = contextLineStyle
  702. }
  703. // Format line number
  704. lineNum := ""
  705. if dl.NewLineNo > 0 {
  706. lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
  707. }
  708. // Create the line prefix
  709. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  710. // Apply syntax highlighting
  711. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  712. // Apply intra-line highlighting for added lines
  713. if dl.Kind == LineAdded && len(dl.Segments) > 0 {
  714. content = applyHighlighting(content, dl.Segments, LineAdded, styles.AddedHighlightBg)
  715. }
  716. // Add a padding space for added lines
  717. if dl.Kind == LineAdded {
  718. content = bgStyle.Render(" ") + content
  719. }
  720. // Create the final line and truncate if needed
  721. lineText := prefix + content
  722. return bgStyle.MaxHeight(1).Width(colWidth).Render(
  723. ansi.Truncate(
  724. lineText,
  725. colWidth,
  726. lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
  727. ),
  728. )
  729. }
  730. // -------------------------------------------------------------------------
  731. // Public API
  732. // -------------------------------------------------------------------------
  733. // RenderSideBySideHunk formats a hunk for side-by-side display
  734. func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
  735. // Apply options to create the configuration
  736. config := NewSideBySideConfig(opts...)
  737. // Make a copy of the hunk so we don't modify the original
  738. hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
  739. copy(hunkCopy.Lines, h.Lines)
  740. // Highlight changes within lines
  741. HighlightIntralineChanges(&hunkCopy, config.Style)
  742. // Pair lines for side-by-side display
  743. pairs := pairLines(hunkCopy.Lines)
  744. // Calculate column width
  745. colWidth := config.TotalWidth / 2
  746. leftWidth := colWidth
  747. rightWidth := config.TotalWidth - colWidth
  748. var sb strings.Builder
  749. for _, p := range pairs {
  750. leftStr := renderLeftColumn(fileName, p.left, leftWidth, config.Style)
  751. rightStr := renderRightColumn(fileName, p.right, rightWidth, config.Style)
  752. sb.WriteString(leftStr + rightStr + "\n")
  753. }
  754. return sb.String()
  755. }
  756. // FormatDiff creates a side-by-side formatted view of a diff
  757. func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
  758. diffResult, err := ParseUnifiedDiff(diffText)
  759. if err != nil {
  760. return "", err
  761. }
  762. var sb strings.Builder
  763. config := NewSideBySideConfig(opts...)
  764. if config.Style.ShowHeader {
  765. removeIcon := lipgloss.NewStyle().
  766. Background(config.Style.RemovedLineBg).
  767. Foreground(config.Style.RemovedFg).
  768. Render("⏹")
  769. addIcon := lipgloss.NewStyle().
  770. Background(config.Style.AddedLineBg).
  771. Foreground(config.Style.AddedFg).
  772. Render("⏹")
  773. fileName := lipgloss.NewStyle().
  774. Background(config.Style.ContextLineBg).
  775. Foreground(config.Style.FileNameFg).
  776. Render(" " + diffResult.OldFile)
  777. sb.WriteString(
  778. lipgloss.NewStyle().
  779. Background(config.Style.ContextLineBg).
  780. Padding(0, 1, 0, 1).
  781. Foreground(config.Style.FileNameFg).
  782. BorderStyle(lipgloss.NormalBorder()).
  783. BorderTop(true).
  784. BorderBottom(true).
  785. BorderForeground(config.Style.FileNameFg).
  786. BorderBackground(config.Style.ContextLineBg).
  787. Width(config.TotalWidth).
  788. Render(
  789. lipgloss.JoinHorizontal(lipgloss.Top,
  790. removeIcon,
  791. addIcon,
  792. fileName,
  793. ),
  794. ) + "\n",
  795. )
  796. }
  797. for _, h := range diffResult.Hunks {
  798. // Render hunk header
  799. if config.Style.ShowHunkHeader {
  800. sb.WriteString(
  801. lipgloss.NewStyle().
  802. Background(config.Style.HunkLineBg).
  803. Foreground(config.Style.HunkLineFg).
  804. Width(config.TotalWidth).
  805. Render(h.Header) + "\n",
  806. )
  807. }
  808. sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
  809. }
  810. return sb.String(), nil
  811. }
  812. // GenerateDiff creates a unified diff from two file contents
  813. func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
  814. // remove the cwd prefix and ensure consistent path format
  815. // this prevents issues with absolute paths in different environments
  816. cwd := config.WorkingDirectory()
  817. fileName = strings.TrimPrefix(fileName, cwd)
  818. fileName = strings.TrimPrefix(fileName, "/")
  819. // Create temporary directory for git operations
  820. tempDir, err := os.MkdirTemp("", fmt.Sprintf("git-diff-%d", time.Now().UnixNano()))
  821. if err != nil {
  822. logging.Error("Failed to create temp directory for git diff", "error", err)
  823. return "", 0, 0
  824. }
  825. defer os.RemoveAll(tempDir)
  826. // Initialize git repo
  827. repo, err := git.PlainInit(tempDir, false)
  828. if err != nil {
  829. logging.Error("Failed to initialize git repository", "error", err)
  830. return "", 0, 0
  831. }
  832. wt, err := repo.Worktree()
  833. if err != nil {
  834. logging.Error("Failed to get git worktree", "error", err)
  835. return "", 0, 0
  836. }
  837. // Write the "before" content and commit it
  838. fullPath := filepath.Join(tempDir, fileName)
  839. if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
  840. logging.Error("Failed to create directory for file", "error", err)
  841. return "", 0, 0
  842. }
  843. if err = os.WriteFile(fullPath, []byte(beforeContent), 0o644); err != nil {
  844. logging.Error("Failed to write before content to file", "error", err)
  845. return "", 0, 0
  846. }
  847. _, err = wt.Add(fileName)
  848. if err != nil {
  849. logging.Error("Failed to add file to git", "error", err)
  850. return "", 0, 0
  851. }
  852. beforeCommit, err := wt.Commit("Before", &git.CommitOptions{
  853. Author: &object.Signature{
  854. Name: "OpenCode",
  855. Email: "[email protected]",
  856. When: time.Now(),
  857. },
  858. })
  859. if err != nil {
  860. logging.Error("Failed to commit before content", "error", err)
  861. return "", 0, 0
  862. }
  863. // Write the "after" content and commit it
  864. if err = os.WriteFile(fullPath, []byte(afterContent), 0o644); err != nil {
  865. logging.Error("Failed to write after content to file", "error", err)
  866. return "", 0, 0
  867. }
  868. _, err = wt.Add(fileName)
  869. if err != nil {
  870. logging.Error("Failed to add file to git", "error", err)
  871. return "", 0, 0
  872. }
  873. afterCommit, err := wt.Commit("After", &git.CommitOptions{
  874. Author: &object.Signature{
  875. Name: "OpenCode",
  876. Email: "[email protected]",
  877. When: time.Now(),
  878. },
  879. })
  880. if err != nil {
  881. logging.Error("Failed to commit after content", "error", err)
  882. return "", 0, 0
  883. }
  884. // Get the diff between the two commits
  885. beforeCommitObj, err := repo.CommitObject(beforeCommit)
  886. if err != nil {
  887. logging.Error("Failed to get before commit object", "error", err)
  888. return "", 0, 0
  889. }
  890. afterCommitObj, err := repo.CommitObject(afterCommit)
  891. if err != nil {
  892. logging.Error("Failed to get after commit object", "error", err)
  893. return "", 0, 0
  894. }
  895. patch, err := beforeCommitObj.Patch(afterCommitObj)
  896. if err != nil {
  897. logging.Error("Failed to create git diff patch", "error", err)
  898. return "", 0, 0
  899. }
  900. // Count additions and removals
  901. additions := 0
  902. removals := 0
  903. for _, fileStat := range patch.Stats() {
  904. additions += fileStat.Addition
  905. removals += fileStat.Deletion
  906. }
  907. return patch.String(), additions, removals
  908. }