diff.go 27 KB

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