diff.go

  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}