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