diff.go 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995
  1. package diff
  2. import (
  3. "bytes"
  4. "fmt"
  5. "io"
  6. "regexp"
  7. "strconv"
  8. "strings"
  9. "time"
  10. "github.com/alecthomas/chroma/v2"
  11. "github.com/alecthomas/chroma/v2/formatters"
  12. "github.com/alecthomas/chroma/v2/lexers"
  13. "github.com/alecthomas/chroma/v2/styles"
  14. "github.com/charmbracelet/lipgloss"
  15. "github.com/charmbracelet/x/ansi"
  16. "github.com/sergi/go-diff/diffmatchpatch"
  17. )
  18. // LineType represents the kind of line in a diff.
  19. type LineType int
  20. const (
  21. // LineContext represents a line that exists in both the old and new file.
  22. LineContext LineType = iota
  23. // LineAdded represents a line added in the new file.
  24. LineAdded
  25. // LineRemoved represents a line removed from the old file.
  26. LineRemoved
  27. )
  28. // DiffLine represents a single line in a diff, either from the old file,
  29. // the new file, or a context line.
  30. type DiffLine struct {
  31. OldLineNo int // Line number in the old file (0 for added lines)
  32. NewLineNo int // Line number in the new file (0 for removed lines)
  33. Kind LineType // Type of line (added, removed, context)
  34. Content string // Content of the line
  35. }
  36. // Hunk represents a section of changes in a diff.
  37. type Hunk struct {
  38. Header string
  39. Lines []DiffLine
  40. }
  41. // DiffResult contains the parsed result of a diff.
  42. type DiffResult struct {
  43. OldFile string
  44. NewFile string
  45. Hunks []Hunk
  46. }
  47. // HunkDelta represents the change statistics for a hunk.
  48. type HunkDelta struct {
  49. StartLine1 int
  50. LineCount1 int
  51. StartLine2 int
  52. LineCount2 int
  53. }
  54. // linePair represents a pair of lines to be displayed side by side.
  55. type linePair struct {
  56. left *DiffLine
  57. right *DiffLine
  58. }
  59. // -------------------------------------------------------------------------
  60. // Style Configuration with Option Pattern
  61. // -------------------------------------------------------------------------
  62. // StyleConfig defines styling for diff rendering.
  63. type StyleConfig struct {
  64. RemovedLineBg lipgloss.Color
  65. AddedLineBg lipgloss.Color
  66. ContextLineBg lipgloss.Color
  67. HunkLineBg lipgloss.Color
  68. HunkLineFg lipgloss.Color
  69. RemovedFg lipgloss.Color
  70. AddedFg lipgloss.Color
  71. LineNumberFg lipgloss.Color
  72. HighlightStyle string
  73. RemovedHighlightBg lipgloss.Color
  74. AddedHighlightBg lipgloss.Color
  75. RemovedLineNumberBg lipgloss.Color
  76. AddedLineNamerBg lipgloss.Color
  77. RemovedHighlightFg lipgloss.Color
  78. AddedHighlightFg lipgloss.Color
  79. }
  80. // StyleOption defines a function that modifies a StyleConfig.
  81. type StyleOption func(*StyleConfig)
  82. // NewStyleConfig creates a StyleConfig with default values and applies any provided options.
  83. func NewStyleConfig(opts ...StyleOption) StyleConfig {
  84. // Set default values
  85. config := StyleConfig{
  86. RemovedLineBg: lipgloss.Color("#3A3030"),
  87. AddedLineBg: lipgloss.Color("#303A30"),
  88. ContextLineBg: lipgloss.Color("#212121"),
  89. HunkLineBg: lipgloss.Color("#2A2822"),
  90. HunkLineFg: lipgloss.Color("#D4AF37"),
  91. RemovedFg: lipgloss.Color("#7C4444"),
  92. AddedFg: lipgloss.Color("#478247"),
  93. LineNumberFg: lipgloss.Color("#888888"),
  94. HighlightStyle: "dracula",
  95. RemovedHighlightBg: lipgloss.Color("#612726"),
  96. AddedHighlightBg: lipgloss.Color("#256125"),
  97. RemovedLineNumberBg: lipgloss.Color("#332929"),
  98. AddedLineNamerBg: lipgloss.Color("#293229"),
  99. RemovedHighlightFg: lipgloss.Color("#FADADD"),
  100. AddedHighlightFg: lipgloss.Color("#DAFADA"),
  101. }
  102. // Apply all provided options
  103. for _, opt := range opts {
  104. opt(&config)
  105. }
  106. return config
  107. }
  108. // WithRemovedLineBg sets the background color for removed lines.
  109. func WithRemovedLineBg(color lipgloss.Color) StyleOption {
  110. return func(s *StyleConfig) {
  111. s.RemovedLineBg = color
  112. }
  113. }
  114. // WithAddedLineBg sets the background color for added lines.
  115. func WithAddedLineBg(color lipgloss.Color) StyleOption {
  116. return func(s *StyleConfig) {
  117. s.AddedLineBg = color
  118. }
  119. }
  120. // WithContextLineBg sets the background color for context lines.
  121. func WithContextLineBg(color lipgloss.Color) StyleOption {
  122. return func(s *StyleConfig) {
  123. s.ContextLineBg = color
  124. }
  125. }
  126. // WithRemovedFg sets the foreground color for removed line markers.
  127. func WithRemovedFg(color lipgloss.Color) StyleOption {
  128. return func(s *StyleConfig) {
  129. s.RemovedFg = color
  130. }
  131. }
  132. // WithAddedFg sets the foreground color for added line markers.
  133. func WithAddedFg(color lipgloss.Color) StyleOption {
  134. return func(s *StyleConfig) {
  135. s.AddedFg = color
  136. }
  137. }
  138. // WithLineNumberFg sets the foreground color for line numbers.
  139. func WithLineNumberFg(color lipgloss.Color) StyleOption {
  140. return func(s *StyleConfig) {
  141. s.LineNumberFg = color
  142. }
  143. }
  144. // WithHighlightStyle sets the syntax highlighting style.
  145. func WithHighlightStyle(style string) StyleOption {
  146. return func(s *StyleConfig) {
  147. s.HighlightStyle = style
  148. }
  149. }
  150. // WithRemovedHighlightColors sets the colors for highlighted parts in removed text.
  151. func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
  152. return func(s *StyleConfig) {
  153. s.RemovedHighlightBg = bg
  154. s.RemovedHighlightFg = fg
  155. }
  156. }
  157. // WithAddedHighlightColors sets the colors for highlighted parts in added text.
  158. func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
  159. return func(s *StyleConfig) {
  160. s.AddedHighlightBg = bg
  161. s.AddedHighlightFg = fg
  162. }
  163. }
  164. // WithRemovedLineNumberBg sets the background color for removed line numbers.
  165. func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
  166. return func(s *StyleConfig) {
  167. s.RemovedLineNumberBg = color
  168. }
  169. }
  170. // WithAddedLineNumberBg sets the background color for added line numbers.
  171. func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
  172. return func(s *StyleConfig) {
  173. s.AddedLineNamerBg = color
  174. }
  175. }
  176. func WithHunkLineBg(color lipgloss.Color) StyleOption {
  177. return func(s *StyleConfig) {
  178. s.HunkLineBg = color
  179. }
  180. }
  181. func WithHunkLineFg(color lipgloss.Color) StyleOption {
  182. return func(s *StyleConfig) {
  183. s.HunkLineFg = color
  184. }
  185. }
  186. // -------------------------------------------------------------------------
  187. // Parse Options with Option Pattern
  188. // -------------------------------------------------------------------------
  189. // ParseConfig configures the behavior of diff parsing.
  190. type ParseConfig struct {
  191. ContextSize int // Number of context lines to include
  192. }
  193. // ParseOption defines a function that modifies a ParseConfig.
  194. type ParseOption func(*ParseConfig)
  195. // NewParseConfig creates a ParseConfig with default values and applies any provided options.
  196. func NewParseConfig(opts ...ParseOption) ParseConfig {
  197. // Set default values
  198. config := ParseConfig{
  199. ContextSize: 3,
  200. }
  201. // Apply all provided options
  202. for _, opt := range opts {
  203. opt(&config)
  204. }
  205. return config
  206. }
  207. // WithContextSize sets the number of context lines to include.
  208. func WithContextSize(size int) ParseOption {
  209. return func(p *ParseConfig) {
  210. if size >= 0 {
  211. p.ContextSize = size
  212. }
  213. }
  214. }
  215. // -------------------------------------------------------------------------
  216. // Side-by-Side Options with Option Pattern
  217. // -------------------------------------------------------------------------
  218. // SideBySideConfig configures the rendering of side-by-side diffs.
  219. type SideBySideConfig struct {
  220. TotalWidth int
  221. Style StyleConfig
  222. }
  223. // SideBySideOption defines a function that modifies a SideBySideConfig.
  224. type SideBySideOption func(*SideBySideConfig)
  225. // NewSideBySideConfig creates a SideBySideConfig with default values and applies any provided options.
  226. func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
  227. // Set default values
  228. config := SideBySideConfig{
  229. TotalWidth: 160, // Default width for side-by-side view
  230. Style: NewStyleConfig(),
  231. }
  232. // Apply all provided options
  233. for _, opt := range opts {
  234. opt(&config)
  235. }
  236. return config
  237. }
  238. // WithTotalWidth sets the total width for side-by-side view.
  239. func WithTotalWidth(width int) SideBySideOption {
  240. return func(s *SideBySideConfig) {
  241. if width > 0 {
  242. s.TotalWidth = width
  243. }
  244. }
  245. }
  246. // WithStyle sets the styling configuration.
  247. func WithStyle(style StyleConfig) SideBySideOption {
  248. return func(s *SideBySideConfig) {
  249. s.Style = style
  250. }
  251. }
  252. // WithStyleOptions applies the specified style options.
  253. func WithStyleOptions(opts ...StyleOption) SideBySideOption {
  254. return func(s *SideBySideConfig) {
  255. s.Style = NewStyleConfig(opts...)
  256. }
  257. }
  258. // -------------------------------------------------------------------------
  259. // Diff Parsing and Generation
  260. // -------------------------------------------------------------------------
  261. // ParseUnifiedDiff parses a unified diff format string into structured data.
  262. func ParseUnifiedDiff(diff string) (DiffResult, error) {
  263. var result DiffResult
  264. var currentHunk *Hunk
  265. hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
  266. lines := strings.Split(diff, "\n")
  267. var oldLine, newLine int
  268. inFileHeader := true
  269. for _, line := range lines {
  270. // Parse the file headers
  271. if inFileHeader {
  272. if strings.HasPrefix(line, "--- a/") {
  273. result.OldFile = strings.TrimPrefix(line, "--- a/")
  274. continue
  275. }
  276. if strings.HasPrefix(line, "+++ b/") {
  277. result.NewFile = strings.TrimPrefix(line, "+++ b/")
  278. inFileHeader = false
  279. continue
  280. }
  281. }
  282. // Parse hunk headers
  283. if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
  284. if currentHunk != nil {
  285. result.Hunks = append(result.Hunks, *currentHunk)
  286. }
  287. currentHunk = &Hunk{
  288. Header: line,
  289. Lines: []DiffLine{},
  290. }
  291. oldStart, _ := strconv.Atoi(matches[1])
  292. newStart, _ := strconv.Atoi(matches[3])
  293. oldLine = oldStart
  294. newLine = newStart
  295. continue
  296. }
  297. if currentHunk == nil {
  298. continue
  299. }
  300. if len(line) > 0 {
  301. // Process the line based on its prefix
  302. switch line[0] {
  303. case '+':
  304. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  305. OldLineNo: 0,
  306. NewLineNo: newLine,
  307. Kind: LineAdded,
  308. Content: line[1:], // skip '+'
  309. })
  310. newLine++
  311. case '-':
  312. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  313. OldLineNo: oldLine,
  314. NewLineNo: 0,
  315. Kind: LineRemoved,
  316. Content: line[1:], // skip '-'
  317. })
  318. oldLine++
  319. default:
  320. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  321. OldLineNo: oldLine,
  322. NewLineNo: newLine,
  323. Kind: LineContext,
  324. Content: line,
  325. })
  326. oldLine++
  327. newLine++
  328. }
  329. } else {
  330. // Handle empty lines
  331. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  332. OldLineNo: oldLine,
  333. NewLineNo: newLine,
  334. Kind: LineContext,
  335. Content: "",
  336. })
  337. oldLine++
  338. newLine++
  339. }
  340. }
  341. // Add the last hunk if there is one
  342. if currentHunk != nil {
  343. result.Hunks = append(result.Hunks, *currentHunk)
  344. }
  345. return result, nil
  346. }
  347. // HighlightIntralineChanges updates the content of lines in a hunk to show
  348. // character-level differences within lines.
  349. func HighlightIntralineChanges(h *Hunk, style StyleConfig) {
  350. var updated []DiffLine
  351. dmp := diffmatchpatch.New()
  352. for i := 0; i < len(h.Lines); i++ {
  353. // Look for removed line followed by added line, which might have similar content
  354. if i+1 < len(h.Lines) &&
  355. h.Lines[i].Kind == LineRemoved &&
  356. h.Lines[i+1].Kind == LineAdded {
  357. oldLine := h.Lines[i]
  358. newLine := h.Lines[i+1]
  359. // Find character-level differences
  360. patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
  361. patches = dmp.DiffCleanupEfficiency(patches)
  362. patches = dmp.DiffCleanupSemantic(patches)
  363. // Apply highlighting to the differences
  364. oldLine.Content = colorizeSegments(patches, true, style)
  365. newLine.Content = colorizeSegments(patches, false, style)
  366. updated = append(updated, oldLine, newLine)
  367. i++ // Skip the next line as we've already processed it
  368. } else {
  369. updated = append(updated, h.Lines[i])
  370. }
  371. }
  372. h.Lines = updated
  373. }
  374. // colorizeSegments applies styles to the character-level diff segments.
  375. func colorizeSegments(diffs []diffmatchpatch.Diff, isOld bool, style StyleConfig) string {
  376. var buf strings.Builder
  377. removeBg := lipgloss.NewStyle().
  378. Background(style.RemovedHighlightBg).
  379. Foreground(style.RemovedHighlightFg)
  380. addBg := lipgloss.NewStyle().
  381. Background(style.AddedHighlightBg).
  382. Foreground(style.AddedHighlightFg)
  383. removedLineStyle := lipgloss.NewStyle().Background(style.RemovedLineBg)
  384. addedLineStyle := lipgloss.NewStyle().Background(style.AddedLineBg)
  385. afterBg := false
  386. for _, d := range diffs {
  387. switch d.Type {
  388. case diffmatchpatch.DiffEqual:
  389. // Handle text that's the same in both versions
  390. if afterBg {
  391. if isOld {
  392. buf.WriteString(removedLineStyle.Render(d.Text))
  393. } else {
  394. buf.WriteString(addedLineStyle.Render(d.Text))
  395. }
  396. } else {
  397. buf.WriteString(d.Text)
  398. }
  399. case diffmatchpatch.DiffDelete:
  400. // Handle deleted text (only show in old version)
  401. if isOld {
  402. buf.WriteString(removeBg.Render(d.Text))
  403. afterBg = true
  404. }
  405. case diffmatchpatch.DiffInsert:
  406. // Handle inserted text (only show in new version)
  407. if !isOld {
  408. buf.WriteString(addBg.Render(d.Text))
  409. afterBg = true
  410. }
  411. }
  412. }
  413. return buf.String()
  414. }
  415. // pairLines converts a flat list of diff lines to pairs for side-by-side display.
  416. func pairLines(lines []DiffLine) []linePair {
  417. var pairs []linePair
  418. i := 0
  419. for i < len(lines) {
  420. switch lines[i].Kind {
  421. case LineRemoved:
  422. // Check if the next line is an addition, if so pair them
  423. if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
  424. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
  425. i += 2
  426. } else {
  427. pairs = append(pairs, linePair{left: &lines[i], right: nil})
  428. i++
  429. }
  430. case LineAdded:
  431. pairs = append(pairs, linePair{left: nil, right: &lines[i]})
  432. i++
  433. case LineContext:
  434. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
  435. i++
  436. }
  437. }
  438. return pairs
  439. }
  440. // -------------------------------------------------------------------------
  441. // Syntax Highlighting
  442. // -------------------------------------------------------------------------
  443. // SyntaxHighlight applies syntax highlighting to a string based on the file extension.
  444. func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
  445. // Determine the language lexer to use
  446. l := lexers.Match(fileName)
  447. if l == nil {
  448. l = lexers.Analyse(source)
  449. }
  450. if l == nil {
  451. l = lexers.Fallback
  452. }
  453. l = chroma.Coalesce(l)
  454. // Get the formatter
  455. f := formatters.Get(formatter)
  456. if f == nil {
  457. f = formatters.Fallback
  458. }
  459. // Get the style
  460. s := styles.Get("dracula")
  461. if s == nil {
  462. s = styles.Fallback
  463. }
  464. // Modify the style to use the provided background
  465. s, err := s.Builder().Transform(
  466. func(t chroma.StyleEntry) chroma.StyleEntry {
  467. r, g, b, _ := bg.RGBA()
  468. ru8 := uint8(r >> 8)
  469. gu8 := uint8(g >> 8)
  470. bu8 := uint8(b >> 8)
  471. t.Background = chroma.NewColour(ru8, gu8, bu8)
  472. return t
  473. },
  474. ).Build()
  475. if err != nil {
  476. s = styles.Fallback
  477. }
  478. // Tokenize and format
  479. it, err := l.Tokenise(nil, source)
  480. if err != nil {
  481. return err
  482. }
  483. return f.Format(w, s, it)
  484. }
  485. // highlightLine applies syntax highlighting to a single line.
  486. func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
  487. var buf bytes.Buffer
  488. err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
  489. if err != nil {
  490. return line
  491. }
  492. return buf.String()
  493. }
  494. // createStyles generates the lipgloss styles needed for rendering diffs.
  495. func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
  496. removedLineStyle = lipgloss.NewStyle().Background(config.RemovedLineBg)
  497. addedLineStyle = lipgloss.NewStyle().Background(config.AddedLineBg)
  498. contextLineStyle = lipgloss.NewStyle().Background(config.ContextLineBg)
  499. lineNumberStyle = lipgloss.NewStyle().Foreground(config.LineNumberFg)
  500. return
  501. }
  502. // renderLeftColumn formats the left side of a side-by-side diff.
  503. func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
  504. if dl == nil {
  505. contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
  506. return contextLineStyle.Width(colWidth).Render("")
  507. }
  508. removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
  509. var marker string
  510. var bgStyle lipgloss.Style
  511. switch dl.Kind {
  512. case LineRemoved:
  513. marker = removedLineStyle.Foreground(styles.RemovedFg).Render("-")
  514. bgStyle = removedLineStyle
  515. lineNumberStyle = lineNumberStyle.Foreground(styles.RemovedFg).Background(styles.RemovedLineNumberBg)
  516. case LineAdded:
  517. marker = "?"
  518. bgStyle = contextLineStyle
  519. case LineContext:
  520. marker = contextLineStyle.Render(" ")
  521. bgStyle = contextLineStyle
  522. }
  523. lineNum := ""
  524. if dl.OldLineNo > 0 {
  525. lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
  526. }
  527. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  528. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  529. if dl.Kind == LineRemoved {
  530. content = bgStyle.Render(" ") + content
  531. }
  532. lineText := prefix + content
  533. return bgStyle.MaxHeight(1).Width(colWidth).Render(ansi.Truncate(lineText, colWidth, "..."))
  534. }
  535. // renderRightColumn formats the right side of a side-by-side diff.
  536. func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
  537. if dl == nil {
  538. contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
  539. return contextLineStyle.Width(colWidth).Render("")
  540. }
  541. _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
  542. var marker string
  543. var bgStyle lipgloss.Style
  544. switch dl.Kind {
  545. case LineAdded:
  546. marker = addedLineStyle.Foreground(styles.AddedFg).Render("+")
  547. bgStyle = addedLineStyle
  548. lineNumberStyle = lineNumberStyle.Foreground(styles.AddedFg).Background(styles.AddedLineNamerBg)
  549. case LineRemoved:
  550. marker = "?"
  551. bgStyle = contextLineStyle
  552. case LineContext:
  553. marker = contextLineStyle.Render(" ")
  554. bgStyle = contextLineStyle
  555. }
  556. lineNum := ""
  557. if dl.NewLineNo > 0 {
  558. lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
  559. }
  560. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  561. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  562. if dl.Kind == LineAdded {
  563. content = bgStyle.Render(" ") + content
  564. }
  565. lineText := prefix + content
  566. return bgStyle.MaxHeight(1).Width(colWidth).Render(ansi.Truncate(lineText, colWidth, "..."))
  567. }
  568. // -------------------------------------------------------------------------
  569. // Public API Methods
  570. // -------------------------------------------------------------------------
  571. // RenderSideBySideHunk formats a hunk for side-by-side display.
  572. func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
  573. // Apply options to create the configuration
  574. config := NewSideBySideConfig(opts...)
  575. // Make a copy of the hunk so we don't modify the original
  576. hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
  577. copy(hunkCopy.Lines, h.Lines)
  578. // Highlight changes within lines
  579. HighlightIntralineChanges(&hunkCopy, config.Style)
  580. // Pair lines for side-by-side display
  581. pairs := pairLines(hunkCopy.Lines)
  582. // Calculate column width
  583. colWidth := config.TotalWidth / 2
  584. var sb strings.Builder
  585. for _, p := range pairs {
  586. leftStr := renderLeftColumn(fileName, p.left, colWidth, config.Style)
  587. rightStr := renderRightColumn(fileName, p.right, colWidth, config.Style)
  588. sb.WriteString(leftStr + rightStr + "\n")
  589. }
  590. return sb.String()
  591. }
  592. // FormatDiff creates a side-by-side formatted view of a diff.
  593. func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
  594. diffResult, err := ParseUnifiedDiff(diffText)
  595. if err != nil {
  596. return "", err
  597. }
  598. var sb strings.Builder
  599. config := NewSideBySideConfig(opts...)
  600. for i, h := range diffResult.Hunks {
  601. if i > 0 {
  602. sb.WriteString(lipgloss.NewStyle().Background(config.Style.HunkLineBg).Foreground(config.Style.HunkLineFg).Width(config.TotalWidth).Render(h.Header) + "\n")
  603. }
  604. sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
  605. }
  606. return sb.String(), nil
  607. }
  608. // GenerateDiff creates a unified diff from two file contents.
  609. func GenerateDiff(beforeContent, afterContent, beforeFilename, afterFilename string, opts ...ParseOption) (string, int, int) {
  610. config := NewParseConfig(opts...)
  611. var output strings.Builder
  612. // Ensure we handle newlines correctly
  613. beforeHasNewline := len(beforeContent) > 0 && beforeContent[len(beforeContent)-1] == '\n'
  614. afterHasNewline := len(afterContent) > 0 && afterContent[len(afterContent)-1] == '\n'
  615. // Split into lines
  616. beforeLines := strings.Split(beforeContent, "\n")
  617. afterLines := strings.Split(afterContent, "\n")
  618. // Remove empty trailing element from the split if the content ended with a newline
  619. if beforeHasNewline && len(beforeLines) > 0 {
  620. beforeLines = beforeLines[:len(beforeLines)-1]
  621. }
  622. if afterHasNewline && len(afterLines) > 0 {
  623. afterLines = afterLines[:len(afterLines)-1]
  624. }
  625. dmp := diffmatchpatch.New()
  626. dmp.DiffTimeout = 5 * time.Second
  627. // Convert lines to characters for efficient diffing
  628. lineArray1, lineArray2, lineArrays := dmp.DiffLinesToChars(beforeContent, afterContent)
  629. diffs := dmp.DiffMain(lineArray1, lineArray2, false)
  630. diffs = dmp.DiffCharsToLines(diffs, lineArrays)
  631. // Default filenames if not provided
  632. if beforeFilename == "" {
  633. beforeFilename = "a"
  634. }
  635. if afterFilename == "" {
  636. afterFilename = "b"
  637. }
  638. // Write diff header
  639. output.WriteString(fmt.Sprintf("diff --git a/%s b/%s\n", beforeFilename, afterFilename))
  640. output.WriteString(fmt.Sprintf("--- a/%s\n", beforeFilename))
  641. output.WriteString(fmt.Sprintf("+++ b/%s\n", afterFilename))
  642. line1 := 0 // Line numbers start from 0 internally
  643. line2 := 0
  644. additions := 0
  645. deletions := 0
  646. var hunks []string
  647. var currentHunk strings.Builder
  648. var hunkStartLine1, hunkStartLine2 int
  649. var hunkLines1, hunkLines2 int
  650. inHunk := false
  651. contextSize := config.ContextSize
  652. // startHunk begins recording a new hunk
  653. startHunk := func(startLine1, startLine2 int) {
  654. inHunk = true
  655. hunkStartLine1 = startLine1
  656. hunkStartLine2 = startLine2
  657. hunkLines1 = 0
  658. hunkLines2 = 0
  659. currentHunk.Reset()
  660. }
  661. // writeHunk adds the current hunk to the hunks slice
  662. writeHunk := func() {
  663. if inHunk {
  664. hunkHeader := fmt.Sprintf("@@ -%d,%d +%d,%d @@\n",
  665. hunkStartLine1+1, hunkLines1,
  666. hunkStartLine2+1, hunkLines2)
  667. hunks = append(hunks, hunkHeader+currentHunk.String())
  668. inHunk = false
  669. }
  670. }
  671. // Process diffs to create hunks
  672. pendingContext := make([]string, 0, contextSize*2)
  673. var contextLines1, contextLines2 int
  674. // Helper function to add context lines to the hunk
  675. addContextToHunk := func(lines []string, count int) {
  676. for i := 0; i < count; i++ {
  677. if i < len(lines) {
  678. currentHunk.WriteString(" " + lines[i] + "\n")
  679. hunkLines1++
  680. hunkLines2++
  681. }
  682. }
  683. }
  684. // Process diffs
  685. for _, diff := range diffs {
  686. lines := strings.Split(diff.Text, "\n")
  687. // Remove empty trailing line that comes from splitting a string that ends with \n
  688. if len(lines) > 0 && lines[len(lines)-1] == "" && diff.Text[len(diff.Text)-1] == '\n' {
  689. lines = lines[:len(lines)-1]
  690. }
  691. switch diff.Type {
  692. case diffmatchpatch.DiffEqual:
  693. // If we have enough equal lines to serve as context, add them to pending
  694. pendingContext = append(pendingContext, lines...)
  695. // If pending context grows too large, trim it
  696. if len(pendingContext) > contextSize*2 {
  697. pendingContext = pendingContext[len(pendingContext)-contextSize*2:]
  698. }
  699. // If we're in a hunk, add the necessary context
  700. if inHunk {
  701. // Only add the first contextSize lines as trailing context
  702. numContextLines := min(contextSize, len(lines))
  703. addContextToHunk(lines[:numContextLines], numContextLines)
  704. // If we've added enough trailing context, close the hunk
  705. if numContextLines >= contextSize {
  706. writeHunk()
  707. }
  708. }
  709. line1 += len(lines)
  710. line2 += len(lines)
  711. contextLines1 += len(lines)
  712. contextLines2 += len(lines)
  713. case diffmatchpatch.DiffDelete, diffmatchpatch.DiffInsert:
  714. // Start a new hunk if needed
  715. if !inHunk {
  716. // Determine how many context lines we can add before
  717. contextBefore := min(contextSize, len(pendingContext))
  718. ctxStartIdx := len(pendingContext) - contextBefore
  719. // Calculate the correct start lines
  720. startLine1 := line1 - contextLines1 + ctxStartIdx
  721. startLine2 := line2 - contextLines2 + ctxStartIdx
  722. startHunk(startLine1, startLine2)
  723. // Add the context lines before
  724. addContextToHunk(pendingContext[ctxStartIdx:], contextBefore)
  725. }
  726. // Reset context tracking when we see a diff
  727. pendingContext = pendingContext[:0]
  728. contextLines1 = 0
  729. contextLines2 = 0
  730. // Add the changes
  731. if diff.Type == diffmatchpatch.DiffDelete {
  732. for _, line := range lines {
  733. currentHunk.WriteString("-" + line + "\n")
  734. hunkLines1++
  735. deletions++
  736. }
  737. line1 += len(lines)
  738. } else { // DiffInsert
  739. for _, line := range lines {
  740. currentHunk.WriteString("+" + line + "\n")
  741. hunkLines2++
  742. additions++
  743. }
  744. line2 += len(lines)
  745. }
  746. }
  747. }
  748. // Write the final hunk if there's one pending
  749. if inHunk {
  750. writeHunk()
  751. }
  752. // Merge hunks that are close to each other (within 2*contextSize lines)
  753. var mergedHunks []string
  754. if len(hunks) > 0 {
  755. mergedHunks = append(mergedHunks, hunks[0])
  756. for i := 1; i < len(hunks); i++ {
  757. prevHunk := mergedHunks[len(mergedHunks)-1]
  758. currHunk := hunks[i]
  759. // Extract line numbers to check proximity
  760. var prevStart, prevLen, currStart, currLen int
  761. fmt.Sscanf(prevHunk, "@@ -%d,%d", &prevStart, &prevLen)
  762. fmt.Sscanf(currHunk, "@@ -%d,%d", &currStart, &currLen)
  763. prevEnd := prevStart + prevLen - 1
  764. // If hunks are close, merge them
  765. if currStart-prevEnd <= contextSize*2 {
  766. // Create a merged hunk - this is a simplification, real git has more complex merging logic
  767. merged := mergeHunks(prevHunk, currHunk)
  768. mergedHunks[len(mergedHunks)-1] = merged
  769. } else {
  770. mergedHunks = append(mergedHunks, currHunk)
  771. }
  772. }
  773. }
  774. // Write all hunks to output
  775. for _, hunk := range mergedHunks {
  776. output.WriteString(hunk)
  777. }
  778. // Handle "No newline at end of file" notifications
  779. if !beforeHasNewline && len(beforeLines) > 0 {
  780. // Find the last deletion in the diff and add the notification after it
  781. lastPos := strings.LastIndex(output.String(), "\n-")
  782. if lastPos != -1 {
  783. // Insert the notification after the line
  784. str := output.String()
  785. output.Reset()
  786. output.WriteString(str[:lastPos+1])
  787. output.WriteString("\\ No newline at end of file\n")
  788. output.WriteString(str[lastPos+1:])
  789. }
  790. }
  791. if !afterHasNewline && len(afterLines) > 0 {
  792. // Find the last insertion in the diff and add the notification after it
  793. lastPos := strings.LastIndex(output.String(), "\n+")
  794. if lastPos != -1 {
  795. // Insert the notification after the line
  796. str := output.String()
  797. output.Reset()
  798. output.WriteString(str[:lastPos+1])
  799. output.WriteString("\\ No newline at end of file\n")
  800. output.WriteString(str[lastPos+1:])
  801. }
  802. }
  803. // Return the diff without the summary line
  804. return output.String(), additions, deletions
  805. }
  806. // Helper function to merge two hunks
  807. func mergeHunks(hunk1, hunk2 string) string {
  808. // This is a simplified implementation
  809. // A full implementation would need to properly recalculate the hunk header
  810. // and remove redundant context lines
  811. // Extract header info from both hunks
  812. var start1, len1, start2, len2 int
  813. var startB1, lenB1, startB2, lenB2 int
  814. fmt.Sscanf(hunk1, "@@ -%d,%d +%d,%d @@", &start1, &len1, &startB1, &lenB1)
  815. fmt.Sscanf(hunk2, "@@ -%d,%d +%d,%d @@", &start2, &len2, &startB2, &lenB2)
  816. // Split the hunks to get content
  817. parts1 := strings.SplitN(hunk1, "\n", 2)
  818. parts2 := strings.SplitN(hunk2, "\n", 2)
  819. content1 := ""
  820. content2 := ""
  821. if len(parts1) > 1 {
  822. content1 = parts1[1]
  823. }
  824. if len(parts2) > 1 {
  825. content2 = parts2[1]
  826. }
  827. // Calculate the new header
  828. newEnd := max(start1+len1-1, start2+len2-1)
  829. newEndB := max(startB1+lenB1-1, startB2+lenB2-1)
  830. newLen := newEnd - start1 + 1
  831. newLenB := newEndB - startB1 + 1
  832. newHeader := fmt.Sprintf("@@ -%d,%d +%d,%d @@", start1, newLen, startB1, newLenB)
  833. // Combine the content, potentially with some overlap handling
  834. return newHeader + "\n" + content1 + content2
  835. }