diff --git a/DiffPlex.Blazor/Components/InteractiveThreeWayMergeViewer.razor b/DiffPlex.Blazor/Components/InteractiveThreeWayMergeViewer.razor new file mode 100644 index 00000000..9f4ff10a --- /dev/null +++ b/DiffPlex.Blazor/Components/InteractiveThreeWayMergeViewer.razor @@ -0,0 +1,1037 @@ +@using DiffPlex +@using DiffPlex.Model +@using DiffPlex.Chunkers + +
+ @if (_diffResult != null && _mergeResult != null) + { + +
+ + + @if (_conflictBlocks.Any()) + { + + } + + +
+ + +
+
+
@YoursHeader
+
+
+
@BaseHeader
+
+
+
@TheirsHeader
+
+
+ + +
+
+ @RenderTextWithBlocks(_diffResult.PiecesOld, _diffResult.DiffBlocks, TextSource.Yours) +
+ +
+ @RenderTextWithBlocks(_diffResult.PiecesBase, _diffResult.DiffBlocks, TextSource.Base) +
+ +
+ @RenderTextWithBlocks(_diffResult.PiecesNew, _diffResult.DiffBlocks, TextSource.Theirs) +
+
+ + + @if (_conflictBlocks.Any() && _currentConflictIndex >= 0 && _currentConflictIndex < _conflictBlocks.Count) + { + var currentConflict = _conflictBlocks[_currentConflictIndex]; +
+
+
Resolve Conflict @(_currentConflictIndex + 1)
+
+ + + + @if (conflictResolutions.ContainsKey(currentConflict.Index)) + { + + } +
+
+ +
+
+ +
+ @foreach (var piece in currentConflict.Conflict.OldPieces) + { +
@piece
+ } +
+
+ +
+ +
+ @foreach (var piece in currentConflict.Conflict.BasePieces) + { +
@piece
+ } +
+
+ +
+ +
+ @foreach (var piece in currentConflict.Conflict.NewPieces) + { +
@piece
+ } +
+
+
+
+ } + + + @if (_mergeResult != null) + { +
+
+
Merge Result @(_mergeResult.IsSuccessful ? "(Clean)" : "(With Conflicts)")
+
+
+ @{ + var actualLineNumber = 1; + foreach (var piece in _mergeResult.MergedPieces) + { + var isConflictMarker = piece.StartsWith("<<<<<<<") || piece.StartsWith("=======") || piece.StartsWith(">>>>>>>") || piece.StartsWith("|||||||"); +
+ @(isConflictMarker ? "" : actualLineNumber.ToString()) + @piece +
+ if (!isConflictMarker) actualLineNumber++; + } + } +
+
+ } + } +
+ +@code { + [Parameter] public string BaseText { get; set; } = ""; + [Parameter] public string YoursText { get; set; } = ""; + [Parameter] public string TheirsText { get; set; } = ""; + [Parameter] public string BaseHeader { get; set; } = "Base"; + [Parameter] public string YoursHeader { get; set; } = "Yours"; + [Parameter] public string TheirsHeader { get; set; } = "Theirs"; + [Parameter] public bool IgnoreWhiteSpace { get; set; } = true; + [Parameter] public bool IgnoreCase { get; set; } = false; + + private ThreeWayDiffResult? _diffResult; + private ThreeWayMergeResult? _mergeResult; + private Dictionary conflictResolutions = new(); + private List<(ThreeWayConflictBlock Conflict, int Index)> _conflictBlocks = new(); + private List _originalConflictBlocks = new(); + private List _originalMergedPieces = new(); + private int _currentConflictIndex = 0; + private ViewMode _viewMode = ViewMode.FullFile; + + private ElementReference contentContainer; + private ElementReference basePanelRef; + private ElementReference yoursPanelRef; + private ElementReference theirsPanelRef; + + private enum ViewMode { FullFile, ChangesOnly, ConflictsOnly } + private enum TextSource { Base, Yours, Theirs } + private enum ConflictResolution { Base, Yours, Theirs } + + protected override void OnParametersSet() + { + UpdateDiff(); + } + + private void UpdateDiff() + { + if (!string.IsNullOrEmpty(BaseText) || !string.IsNullOrEmpty(YoursText) || !string.IsNullOrEmpty(TheirsText)) + { + var chunker = new LineChunker(); + var differ = ThreeWayDiffer.Instance; + + _diffResult = differ.CreateDiffs(BaseText ?? "", YoursText ?? "", TheirsText ?? "", + IgnoreWhiteSpace, IgnoreCase, chunker); + + _mergeResult = differ.CreateMerge(BaseText ?? "", YoursText ?? "", TheirsText ?? "", + IgnoreWhiteSpace, IgnoreCase, chunker); + + conflictResolutions.Clear(); + _conflictBlocks = _mergeResult.ConflictBlocks.Select((c, i) => (c, i)).ToList(); + _originalConflictBlocks = _mergeResult.ConflictBlocks.ToList(); + _originalMergedPieces = _mergeResult.MergedPieces.ToList(); + _currentConflictIndex = _conflictBlocks.Any() ? 0 : -1; + } + } + + private void SetViewMode(ViewMode mode) + { + _viewMode = mode; + StateHasChanged(); + } + + private async Task GoToNextConflict() + { + if (_currentConflictIndex < _conflictBlocks.Count - 1) + { + _currentConflictIndex++; + await ScrollToConflict(_currentConflictIndex); + StateHasChanged(); + } + } + + private async Task GoToPreviousConflict() + { + if (_currentConflictIndex > 0) + { + _currentConflictIndex--; + await ScrollToConflict(_currentConflictIndex); + StateHasChanged(); + } + } + + private async Task ScrollToConflict(int conflictIndex) + { + if (conflictIndex >= 0 && conflictIndex < _conflictBlocks.Count) + { + var conflict = _conflictBlocks[conflictIndex]; + // Find the line number where this conflict starts + var lineNumber = GetConflictStartLine(conflict.Conflict); + + // Scroll to the line in all three panels + await ScrollToLine(lineNumber); + } + } + + private async Task ScrollToLine(int lineNumber) + { + // Calculate the scroll position (assuming 20px per line) + var scrollTop = (lineNumber - 1) * 20; + + // Small delay to ensure rendering + await Task.Delay(50); + StateHasChanged(); + } + + private int GetConflictStartLine(ThreeWayConflictBlock conflict) + { + // Calculate which line the conflict starts on based on the merge result + var lineNumber = 1; + foreach (var piece in _mergeResult!.MergedPieces) + { + if (piece.StartsWith("<<<<<<<")) + { + // Found start of a conflict, check if it matches our conflict + break; + } + lineNumber++; + } + return lineNumber; + } + + private void ResolveCurrentConflict(ConflictResolution resolution) + { + if (_currentConflictIndex >= 0 && _currentConflictIndex < _conflictBlocks.Count) + { + var conflictIndex = _conflictBlocks[_currentConflictIndex].Index; + ResolveConflict(conflictIndex, resolution); + } + } + + private void ResolveConflict(int conflictIndex, ConflictResolution resolution) + { + conflictResolutions[conflictIndex] = resolution; + UpdateMergeResult(); + StateHasChanged(); + } + + private void ClearConflictResolution(int conflictIndex) + { + conflictResolutions.Remove(conflictIndex); + UpdateMergeResult(); + StateHasChanged(); + } + + private bool IsConflictResolved(int conflictIndex, ConflictResolution resolution) + { + return conflictResolutions.TryGetValue(conflictIndex, out var currentResolution) + && currentResolution == resolution; + } + + private void UpdateMergeResult() + { + if (_mergeResult == null || !_originalConflictBlocks.Any()) + return; + + // Rebuild the merge result from scratch, processing each conflict block individually + var mergedPieces = new List(); + var unresolvedConflicts = new List(); + + // Get the original merge pieces and find conflict markers + var originalPieces = _originalMergedPieces.ToList(); + var conflictRanges = new List<(int start, int end, int blockIndex)>(); + + // First pass: identify all conflict ranges and map them to their block indices + var currentBlockIndex = 0; + for (int i = 0; i < originalPieces.Count; i++) + { + if (originalPieces[i].StartsWith("<<<<<<<")) + { + var conflictStart = i; + var conflictEnd = -1; + + // Find the end of this conflict + for (int j = i + 1; j < originalPieces.Count; j++) + { + if (originalPieces[j].StartsWith(">>>>>>>")) + { + conflictEnd = j; + break; + } + } + + if (conflictEnd > conflictStart && currentBlockIndex < _originalConflictBlocks.Count) + { + conflictRanges.Add((conflictStart, conflictEnd, currentBlockIndex)); + currentBlockIndex++; + i = conflictEnd; // Skip past this conflict in the loop + } + } + } + + // Second pass: rebuild the merged content + var processedUpTo = 0; + + foreach (var (start, end, blockIndex) in conflictRanges) + { + // Add any content before this conflict + for (int i = processedUpTo; i < start; i++) + { + mergedPieces.Add(originalPieces[i]); + } + + // Handle this specific conflict + var conflictBlock = _originalConflictBlocks[blockIndex]; + + if (conflictResolutions.TryGetValue(blockIndex, out var resolution) && resolution.HasValue) + { + // Add the resolved content for this specific conflict + var piecesToUse = resolution.Value switch + { + ConflictResolution.Base => conflictBlock.BasePieces, + ConflictResolution.Yours => conflictBlock.OldPieces, + ConflictResolution.Theirs => conflictBlock.NewPieces, + _ => conflictBlock.BasePieces + }; + + mergedPieces.AddRange(piecesToUse); + } + else + { + // Keep the conflict markers for this unresolved conflict + for (int k = start; k <= end; k++) + { + mergedPieces.Add(originalPieces[k]); + } + unresolvedConflicts.Add(conflictBlock); + } + + processedUpTo = end + 1; + } + + // Add any remaining content after the last conflict + for (int i = processedUpTo; i < originalPieces.Count; i++) + { + mergedPieces.Add(originalPieces[i]); + } + + // Update only the merged pieces, keep original conflict blocks unchanged + _mergeResult = new ThreeWayMergeResult( + mergedPieces.ToArray(), + unresolvedConflicts.Count == 0, + _originalConflictBlocks, // Keep original conflict blocks + _mergeResult.DiffResult + ); + } + + private bool ShouldShowLine(ThreeWayDiffBlock block, int lineIndex, TextSource source) + { + if (_viewMode == ViewMode.FullFile) + return true; + + var isConflict = block.ChangeType == ThreeWayChangeType.Conflict; + var isChange = block.ChangeType != ThreeWayChangeType.Unchanged; + + if (_viewMode == ViewMode.ConflictsOnly) + return isConflict; + + if (_viewMode == ViewMode.ChangesOnly) + return isChange; + + return true; + } + + private RenderFragment RenderTextWithBlocks(IReadOnlyList pieces, IList blocks, TextSource source) + { + return builder => + { + var currentIndex = 0; + var fileLineNumber = 1; + + foreach (var block in blocks) + { + var (startIndex, count) = GetBlockIndices(block, source); + + // Render lines before this block (unchanged) + while (currentIndex < startIndex) + { + if (ShouldShowLine(block, currentIndex, source) || _viewMode == ViewMode.FullFile) + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "line unchanged"); + builder.AddAttribute(2, "data-line-number", fileLineNumber); + + builder.OpenElement(3, "span"); + builder.AddAttribute(4, "class", "line-number"); + builder.AddContent(5, fileLineNumber.ToString()); + builder.CloseElement(); + + builder.OpenElement(6, "span"); + builder.AddAttribute(7, "class", "line-content"); + builder.AddContent(8, pieces[currentIndex]); + builder.CloseElement(); + + builder.CloseElement(); + } + currentIndex++; + fileLineNumber++; + } + + // Render the block content with appropriate styling + for (int i = 0; i < count; i++) + { + var blockLineIndex = startIndex + i; + if (blockLineIndex < pieces.Count && ShouldShowLine(block, blockLineIndex, source)) + { + builder.OpenElement(10, "div"); + builder.AddAttribute(11, "class", $"line {GetBlockClass(block.ChangeType, source)}"); + builder.AddAttribute(12, "data-line-number", fileLineNumber); + + builder.OpenElement(13, "span"); + builder.AddAttribute(14, "class", "line-number"); + builder.AddContent(15, fileLineNumber.ToString()); + builder.CloseElement(); + + builder.OpenElement(16, "span"); + builder.AddAttribute(17, "class", "line-content"); + builder.AddContent(18, pieces[blockLineIndex]); + builder.CloseElement(); + + builder.CloseElement(); + } + fileLineNumber++; + } + + currentIndex = startIndex + count; + } + + // Render remaining unchanged lines + while (currentIndex < pieces.Count) + { + if (_viewMode == ViewMode.FullFile) + { + builder.OpenElement(20, "div"); + builder.AddAttribute(21, "class", "line unchanged"); + builder.AddAttribute(22, "data-line-number", fileLineNumber); + + builder.OpenElement(23, "span"); + builder.AddAttribute(24, "class", "line-number"); + builder.AddContent(25, fileLineNumber.ToString()); + builder.CloseElement(); + + builder.OpenElement(26, "span"); + builder.AddAttribute(27, "class", "line-content"); + builder.AddContent(28, pieces[currentIndex]); + builder.CloseElement(); + + builder.CloseElement(); + } + currentIndex++; + fileLineNumber++; + } + }; + } + + private (int startIndex, int count) GetBlockIndices(ThreeWayDiffBlock block, TextSource source) + { + return source switch + { + TextSource.Base => (block.BaseStart, block.BaseCount), + TextSource.Yours => (block.OldStart, block.OldCount), + TextSource.Theirs => (block.NewStart, block.NewCount), + _ => (0, 0) + }; + } + + private string GetBlockClass(ThreeWayChangeType changeType, TextSource source) + { + return changeType switch + { + ThreeWayChangeType.Unchanged => "unchanged", + ThreeWayChangeType.OldOnly => source == TextSource.Yours ? "changed-yours" : "changed-base", + ThreeWayChangeType.NewOnly => source == TextSource.Theirs ? "changed-theirs" : "changed-base", + ThreeWayChangeType.BothSame => source == TextSource.Base ? "changed-base" : "changed-both", + ThreeWayChangeType.Conflict => source switch + { + TextSource.Base => "conflict-base", + TextSource.Yours => "conflict-yours", + TextSource.Theirs => "conflict-theirs", + _ => "conflict" + }, + _ => "unchanged" + }; + } + + private string GetMergeLineClass(string line) + { + if (line.StartsWith("<<<<<<<")) return "conflict-marker-start"; + if (line.StartsWith("|||||||")) return "conflict-marker-base"; + if (line.StartsWith("=======")) return "conflict-marker-separator"; + if (line.StartsWith(">>>>>>>")) return "conflict-marker-end"; + return "merge-line"; + } +} + + diff --git a/DiffPlex.Blazor/Components/Layout/NavMenu.razor b/DiffPlex.Blazor/Components/Layout/NavMenu.razor index 60c32b18..c0d585f4 100644 --- a/DiffPlex.Blazor/Components/Layout/NavMenu.razor +++ b/DiffPlex.Blazor/Components/Layout/NavMenu.razor @@ -1,4 +1,4 @@ - diff --git a/DiffPlex.Blazor/Components/Pages/Examples.razor b/DiffPlex.Blazor/Components/Pages/Examples.razor index 6aab73ea..adb867e5 100644 --- a/DiffPlex.Blazor/Components/Pages/Examples.razor +++ b/DiffPlex.Blazor/Components/Pages/Examples.razor @@ -48,6 +48,13 @@ @onclick='() => SetViewMode("inline")'> Inline + @if (IsThreeWayExample()) + { + + } @@ -62,6 +69,16 @@ NewTextHeader="@currentExample.NewHeader" IgnoreWhiteSpace="true" /> } + else if (viewMode == "three-way") + { + + } else { examples = new() { + ["merge-conflict"] = new ExampleData + { + Name = "Merge Conflict", + Description = "Shows a three-way merge scenario with conflicts that need manual resolution.", + BaseHeader = "Common Base", + YoursHeader = "Your Changes", + TheirsHeader = "Their Changes", + BaseText = @"function calculateTotal(items) { + let total = 0; + for (let item of items) { + total += item.price; + } + return total; +} + +function processOrder(order) { + // Basic processing + return order; +}", + YoursText = @"function calculateTotal(items, tax = 0.08) { + let total = 0; + for (let item of items) { + total += item.price * item.quantity; + } + return total * (1 + tax); +} + +function processOrder(order) { + // Validate order before processing + if (!order.items || order.items.length === 0) { + throw new Error('Order must contain items'); + } + return order; +}", + TheirsText = @"function calculateTotal(items, discountRate = 0) { + let total = 0; + for (let item of items) { + total += item.price; + } + return total * (1 - discountRate); +} + +function processOrder(order) { + // Add logging + console.log('Processing order:', order.id); + return order; +}" + }, + ["merge-clean"] = new ExampleData + { + Name = "Clean Merge", + Description = "Shows a three-way merge scenario where changes can be automatically merged without conflicts.", + BaseHeader = "Original Code", + YoursHeader = "Feature Branch", + TheirsHeader = "Main Branch", + BaseText = @"class UserService { + constructor() { + this.users = []; + } + + addUser(user) { + this.users.push(user); + } + + getUser(id) { + return this.users.find(u => u.id === id); + } +}", + YoursText = @"class UserService { + constructor() { + this.users = []; + this.cache = new Map(); + } + + addUser(user) { + this.users.push(user); + this.cache.set(user.id, user); + } + + getUser(id) { + // Check cache first + if (this.cache.has(id)) { + return this.cache.get(id); + } + return this.users.find(u => u.id === id); + } +}", + TheirsText = @"class UserService { + constructor(database) { + this.users = []; + this.database = database; + } + + addUser(user) { + this.users.push(user); + } + + getUser(id) { + return this.users.find(u => u.id === id); + } + + deleteUser(id) { + this.users = this.users.filter(u => u.id !== id); + } +}" + }, ["code-refactor"] = new ExampleData { Name = "Code Refactor", @@ -284,6 +407,11 @@ var diffModel = differ.BuildDiffModel(oldText, newText); viewMode = mode; } + private bool IsThreeWayExample() + { + return currentExample != null && !string.IsNullOrEmpty(currentExample.BaseText); + } + private class ExampleData { public string Name { get; set; } = ""; @@ -292,5 +420,13 @@ var diffModel = differ.BuildDiffModel(oldText, newText); public string NewHeader { get; set; } = ""; public string OldText { get; set; } = ""; public string NewText { get; set; } = ""; + + // Three-way merge properties + public string BaseText { get; set; } = ""; + public string YoursText { get; set; } = ""; + public string TheirsText { get; set; } = ""; + public string BaseHeader { get; set; } = ""; + public string YoursHeader { get; set; } = ""; + public string TheirsHeader { get; set; } = ""; } } diff --git a/DiffPlex.Blazor/Components/Pages/ThreeWayMerge.razor b/DiffPlex.Blazor/Components/Pages/ThreeWayMerge.razor new file mode 100644 index 00000000..eef9f6a7 --- /dev/null +++ b/DiffPlex.Blazor/Components/Pages/ThreeWayMerge.razor @@ -0,0 +1,280 @@ +@page "/three-way-merge" +@using DiffPlex.Blazor.Components +@rendermode InteractiveServer + +Three-Way Merge - DiffPlex + +
+
+
+

Three-Way Merge

+

Interactive three-way merge tool for resolving conflicts between two branches with a common base. Navigate between conflicts, choose different view modes, and resolve conflicts with a single click.

+
+
+ +
+
+
+
+
Input Texts
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+ +
+
+ +
+
+
+ +@code { + private string baseText = ""; + private string yoursText = ""; + private string theirsText = ""; + + protected override void OnInitialized() + { + LoadConflictExample(); + } + + private void LoadConflictExample() + { + baseText = @"function calculateTotal(items) { + let total = 0; + for (let item of items) { + total += item.price; + } + return total; +} + +function processOrder(order) { + // Basic processing + return order; +}"; + + yoursText = @"function calculateTotal(items, tax = 0.08) { + let total = 0; + for (let item of items) { + total += item.price * item.quantity; + } + return total * (1 + tax); +} + +function processOrder(order) { + // Validate order before processing + if (!order.items || order.items.length === 0) { + throw new Error('Order must contain items'); + } + return order; +}"; + + theirsText = @"function calculateTotal(items, discountRate = 0) { + let total = 0; + for (let item of items) { + total += item.price; + } + return total * (1 - discountRate); +} + +function processOrder(order) { + // Add logging + console.log('Processing order:', order.id); + return order; +}"; + } + + private void LoadCleanMergeExample() + { + baseText = @"class UserService { + constructor() { + this.users = []; + } + + addUser(user) { + this.users.push(user); + } + + getUser(id) { + return this.users.find(u => u.id === id); + } +}"; + + yoursText = @"class UserService { + constructor() { + this.users = []; + this.cache = new Map(); + } + + addUser(user) { + this.users.push(user); + this.cache.set(user.id, user); + } + + getUser(id) { + // Check cache first + if (this.cache.has(id)) { + return this.cache.get(id); + } + return this.users.find(u => u.id === id); + } +}"; + + theirsText = @"class UserService { + constructor(database) { + this.users = []; + this.database = database; + } + + addUser(user) { + this.users.push(user); + } + + getUser(id) { + return this.users.find(u => u.id === id); + } + + deleteUser(id) { + this.users = this.users.filter(u => u.id !== id); + } +}"; + } + + private void ClearAll() + { + baseText = ""; + yoursText = ""; + theirsText = ""; + } +} + + diff --git a/DiffPlex.Blazor/Components/ThreeWayMergeViewer.razor b/DiffPlex.Blazor/Components/ThreeWayMergeViewer.razor new file mode 100644 index 00000000..4c437121 --- /dev/null +++ b/DiffPlex.Blazor/Components/ThreeWayMergeViewer.razor @@ -0,0 +1,651 @@ +@using DiffPlex +@using DiffPlex.Model +@using DiffPlex.Chunkers + +
+
+
+
@BaseHeader
+
+
+
@YoursHeader
+
+
+
@TheirsHeader
+
+
+ +
+ @if (_diffResult != null) + { +
+ @RenderTextWithBlocks(_diffResult.PiecesBase, _diffResult.DiffBlocks, TextSource.Base) +
+ +
+ @RenderTextWithBlocks(_diffResult.PiecesOld, _diffResult.DiffBlocks, TextSource.Yours) +
+ +
+ @RenderTextWithBlocks(_diffResult.PiecesNew, _diffResult.DiffBlocks, TextSource.Theirs) +
+ } +
+ + @if (_mergeResult != null && !_mergeResult.IsSuccessful) + { +
+
+
Conflicts (@_mergeResult.ConflictBlocks.Count)
+
+ + @if (_mergeResult.ConflictBlocks.Any()) + { +
+ @foreach (var conflict in _mergeResult.ConflictBlocks.Select((c, i) => new { Conflict = c, Index = i })) + { +
+
+ Conflict @(conflict.Index + 1) +
+ + + +
+
+
+
+ +
+ @foreach (var piece in conflict.Conflict.OldPieces) + { +
@piece
+ } +
+
+
+ +
+ @foreach (var piece in conflict.Conflict.BasePieces) + { +
@piece
+ } +
+
+
+ +
+ @foreach (var piece in conflict.Conflict.NewPieces) + { +
@piece
+ } +
+
+
+
+ } +
+ } +
+ } + + @if (_mergeResult != null) + { +
+
+
Merge Result @(_mergeResult.IsSuccessful ? "(Clean)" : "(With Conflicts)")
+
+
+ @foreach (var piece in _mergeResult.MergedPieces.Select((p, i) => new { Piece = p, Index = i })) + { +
+ @(piece.Index + 1) + @piece.Piece +
+ } +
+
+ } +
+ +@code { + [Parameter] public string BaseText { get; set; } = ""; + [Parameter] public string YoursText { get; set; } = ""; + [Parameter] public string TheirsText { get; set; } = ""; + [Parameter] public string BaseHeader { get; set; } = "Base"; + [Parameter] public string YoursHeader { get; set; } = "Yours"; + [Parameter] public string TheirsHeader { get; set; } = "Theirs"; + [Parameter] public bool IgnoreWhiteSpace { get; set; } = true; + [Parameter] public bool IgnoreCase { get; set; } = false; + + private ThreeWayDiffResult? _diffResult; + private ThreeWayMergeResult? _mergeResult; + private Dictionary conflictResolutions = new(); + + private enum TextSource { Base, Yours, Theirs } + private enum ConflictResolution { Base, Yours, Theirs } + + protected override void OnParametersSet() + { + UpdateDiff(); + } + + private void UpdateDiff() + { + if (!string.IsNullOrEmpty(BaseText) || !string.IsNullOrEmpty(YoursText) || !string.IsNullOrEmpty(TheirsText)) + { + var chunker = new LineChunker(); + var differ = ThreeWayDiffer.Instance; + + _diffResult = differ.CreateDiffs(BaseText ?? "", YoursText ?? "", TheirsText ?? "", + IgnoreWhiteSpace, IgnoreCase, chunker); + + _mergeResult = differ.CreateMerge(BaseText ?? "", YoursText ?? "", TheirsText ?? "", + IgnoreWhiteSpace, IgnoreCase, chunker); + + conflictResolutions.Clear(); + } + } + + private RenderFragment RenderTextWithBlocks(IReadOnlyList pieces, IList blocks, TextSource source) + { + return builder => + { + var currentIndex = 0; + var lineNumber = 1; + + foreach (var block in blocks) + { + var (startIndex, count) = GetBlockIndices(block, source); + + // Render lines before this block (unchanged) + while (currentIndex < startIndex) + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "line unchanged"); + + builder.OpenElement(2, "span"); + builder.AddAttribute(3, "class", "line-number"); + builder.AddContent(4, lineNumber.ToString()); + builder.CloseElement(); + + builder.OpenElement(5, "span"); + builder.AddAttribute(6, "class", "line-content"); + builder.AddContent(7, pieces[currentIndex]); + builder.CloseElement(); + + builder.CloseElement(); + currentIndex++; + lineNumber++; + } + + // Render the block content with appropriate styling + for (int i = 0; i < count; i++) + { + var blockLineIndex = startIndex + i; + if (blockLineIndex < pieces.Count) + { + builder.OpenElement(10, "div"); + builder.AddAttribute(11, "class", $"line {GetBlockClass(block.ChangeType, source)}"); + + builder.OpenElement(12, "span"); + builder.AddAttribute(13, "class", "line-number"); + builder.AddContent(14, lineNumber.ToString()); + builder.CloseElement(); + + builder.OpenElement(15, "span"); + builder.AddAttribute(16, "class", "line-content"); + builder.AddContent(17, pieces[blockLineIndex]); + builder.CloseElement(); + + builder.CloseElement(); + lineNumber++; + } + } + + currentIndex = startIndex + count; + } + + // Render remaining unchanged lines + while (currentIndex < pieces.Count) + { + builder.OpenElement(20, "div"); + builder.AddAttribute(21, "class", "line unchanged"); + + builder.OpenElement(22, "span"); + builder.AddAttribute(23, "class", "line-number"); + builder.AddContent(24, lineNumber.ToString()); + builder.CloseElement(); + + builder.OpenElement(25, "span"); + builder.AddAttribute(26, "class", "line-content"); + builder.AddContent(27, pieces[currentIndex]); + builder.CloseElement(); + + builder.CloseElement(); + currentIndex++; + lineNumber++; + } + }; + } + + private (int startIndex, int count) GetBlockIndices(ThreeWayDiffBlock block, TextSource source) + { + return source switch + { + TextSource.Base => (block.BaseStart, block.BaseCount), + TextSource.Yours => (block.OldStart, block.OldCount), + TextSource.Theirs => (block.NewStart, block.NewCount), + _ => (0, 0) + }; + } + + private string GetBlockClass(ThreeWayChangeType changeType, TextSource source) + { + return changeType switch + { + ThreeWayChangeType.Unchanged => "unchanged", + ThreeWayChangeType.OldOnly => source == TextSource.Yours ? "changed-yours" : "changed-base", + ThreeWayChangeType.NewOnly => source == TextSource.Theirs ? "changed-theirs" : "changed-base", + ThreeWayChangeType.BothSame => source == TextSource.Base ? "changed-base" : "changed-both", + ThreeWayChangeType.Conflict => source switch + { + TextSource.Base => "conflict-base", + TextSource.Yours => "conflict-yours", + TextSource.Theirs => "conflict-theirs", + _ => "conflict" + }, + _ => "unchanged" + }; + } + + private string GetMergeLineClass(string line) + { + if (line.StartsWith("<<<<<<<")) return "conflict-marker-start"; + if (line.StartsWith("|||||||")) return "conflict-marker-base"; + if (line.StartsWith("=======")) return "conflict-marker-separator"; + if (line.StartsWith(">>>>>>>")) return "conflict-marker-end"; + return "merge-line"; + } + + + + private void ResolveConflict(int conflictIndex, ConflictResolution resolution) + { + conflictResolutions[conflictIndex] = resolution; + UpdateMergeResult(); + StateHasChanged(); + } + + private void UpdateMergeResult() + { + if (_mergeResult == null || !_mergeResult.ConflictBlocks.Any()) + return; + + // Create a new merge result by replacing resolved conflicts + var originalMergedPieces = new List(_mergeResult.MergedPieces); + var unresolvedConflicts = new List(); + + foreach (var conflictBlock in _mergeResult.ConflictBlocks.Select((c, i) => new { Conflict = c, Index = i })) + { + if (conflictResolutions.TryGetValue(conflictBlock.Index, out var resolution) && resolution.HasValue) + { + // Find the conflict markers in the merged pieces and replace them + var piecesToUse = resolution.Value switch + { + ConflictResolution.Base => conflictBlock.Conflict.BasePieces, + ConflictResolution.Yours => conflictBlock.Conflict.OldPieces, + ConflictResolution.Theirs => conflictBlock.Conflict.NewPieces, + _ => conflictBlock.Conflict.BasePieces + }; + + // Replace conflict markers with resolved content + // This is a simplified approach - in practice you'd need to track the exact positions + ReplaceConflictWithResolution(originalMergedPieces, conflictBlock.Conflict, piecesToUse); + } + else + { + unresolvedConflicts.Add(conflictBlock.Conflict); + } + } + + // Update the merge result + _mergeResult = new ThreeWayMergeResult( + originalMergedPieces.ToArray(), + unresolvedConflicts.Count == 0, + unresolvedConflicts, + _mergeResult.DiffResult + ); + } + + private void ReplaceConflictWithResolution(List mergedPieces, ThreeWayConflictBlock conflict, IReadOnlyList resolution) + { + // Find conflict markers and replace the entire conflict section + var startMarkerIndex = -1; + var endMarkerIndex = -1; + + for (int i = 0; i < mergedPieces.Count; i++) + { + if (mergedPieces[i].StartsWith("<<<<<<<")) + { + startMarkerIndex = i; + } + else if (mergedPieces[i].StartsWith(">>>>>>>") && startMarkerIndex != -1) + { + endMarkerIndex = i; + break; + } + } + + if (startMarkerIndex != -1 && endMarkerIndex != -1) + { + // Remove the conflict section + mergedPieces.RemoveRange(startMarkerIndex, endMarkerIndex - startMarkerIndex + 1); + + // Insert the resolved content + mergedPieces.InsertRange(startMarkerIndex, resolution); + } + } +} + + diff --git a/DiffPlex.ConsoleRunner/Program.cs b/DiffPlex.ConsoleRunner/Program.cs index 13f71364..c5a2a7bb 100644 --- a/DiffPlex.ConsoleRunner/Program.cs +++ b/DiffPlex.ConsoleRunner/Program.cs @@ -1,87 +1,307 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using DiffPlex.DiffBuilder; using DiffPlex.DiffBuilder.Model; using DiffPlex.Model; using DiffPlex.Renderer; +using DiffPlex.Chunkers; namespace DiffPlex.ConsoleRunner; -internal static class Program +public static class Program { - private static void Main(string[] args) + public static int Main(string[] args) { if (args.Length < 3) { PrintUsage(); - return; + return 1; } try { - string command = args[0].ToLowerInvariant(); - string result; - - if (command == "file") + var options = ParseCommandLineOptions(args); + + return options.Command switch { - // File comparison mode - string oldFilePath = NormalizePath(args[1]); - string newFilePath = NormalizePath(args[2]); - - if (!File.Exists(oldFilePath)) - { - Console.Error.WriteLine($"Error: File not found: {oldFilePath}"); - return; - } + "file" => HandleFileCommand(options), + "text" => HandleTextCommand(options), + "3way-file" => Handle3WayFileCommand(options), + "3way-text" => Handle3WayTextCommand(options), + "merge-file" => HandleMergeFileCommand(options), + "merge-text" => HandleMergeTextCommand(options), + _ => HandleUnknownCommand(options.Command) + }; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } - if (!File.Exists(newFilePath)) - { - Console.Error.WriteLine($"Error: File not found: {newFilePath}"); - return; - } + private static CommandLineOptions ParseCommandLineOptions(string[] args) + { + var options = new CommandLineOptions + { + Command = args[0].ToLowerInvariant() + }; - string oldText = File.ReadAllText(oldFilePath); - string newText = File.ReadAllText(newFilePath); - result = UnidiffRenderer.GenerateUnidiff(oldText, newText, oldFilePath, newFilePath); + var positionalArgs = new List(); + + for (int i = 1; i < args.Length; i++) + { + string arg = args[i]; + + if (arg == "-i" || arg == "--ignore-case") + { + options.IgnoreCase = true; + } + else if (arg == "-w" || arg == "--ignore-whitespace") + { + options.IgnoreWhitespace = true; } - else if (command == "text") + else if (arg.StartsWith("-")) { - // Text comparison mode - // Process escape sequences like \n in the input text - string oldText = args[1].Replace("\\n", "\n"); - string newText = args[2].Replace("\\n", "\n"); - result = UnidiffRenderer.GenerateUnidiff(oldText, newText); + throw new ArgumentException($"Unknown option: {arg}"); } else { - Console.Error.WriteLine($"Unknown command: {command}"); - PrintUsage(); - return; + positionalArgs.Add(arg); } + } + + options.Arguments = positionalArgs.ToArray(); + return options; + } - Console.WriteLine(result); + private static int HandleFileCommand(CommandLineOptions options) + { + if (options.Arguments.Length < 2) + { + Console.Error.WriteLine("Error: file command requires old and new file paths"); + PrintUsage(); + return 1; } - catch (Exception ex) + + string oldFilePath = NormalizePath(options.Arguments[0]); + string newFilePath = NormalizePath(options.Arguments[1]); + + if (!File.Exists(oldFilePath)) { - Console.Error.WriteLine($"Error: {ex.Message}"); + Console.Error.WriteLine($"Error: File not found: {oldFilePath}"); + return 1; } + + if (!File.Exists(newFilePath)) + { + Console.Error.WriteLine($"Error: File not found: {newFilePath}"); + return 1; + } + + string oldText = File.ReadAllText(oldFilePath); + string newText = File.ReadAllText(newFilePath); + string result = UnidiffRenderer.GenerateUnidiff(oldText, newText, oldFilePath, newFilePath); + + Console.WriteLine(result); + return 0; + } + + private static int HandleTextCommand(CommandLineOptions options) + { + if (options.Arguments.Length < 2) + { + Console.Error.WriteLine("Error: text command requires old and new text"); + PrintUsage(); + return 1; + } + + string oldText = options.Arguments[0].Replace("\\n", "\n"); + string newText = options.Arguments[1].Replace("\\n", "\n"); + string result = UnidiffRenderer.GenerateUnidiff(oldText, newText); + + Console.WriteLine(result); + return 0; + } + + private static int Handle3WayFileCommand(CommandLineOptions options) + { + if (options.Arguments.Length < 3) + { + Console.Error.WriteLine("Error: 3way-file requires base, old, and new file paths"); + PrintUsage(); + return 1; + } + + string result = Handle3WayFileDiff(options.Arguments[0], options.Arguments[1], options.Arguments[2], options); + Console.WriteLine(result); + return 0; + } + + private static int Handle3WayTextCommand(CommandLineOptions options) + { + if (options.Arguments.Length < 3) + { + Console.Error.WriteLine("Error: 3way-text requires base, old, and new text"); + PrintUsage(); + return 1; + } + + string baseText = options.Arguments[0].Replace("\\n", "\n"); + string oldText = options.Arguments[1].Replace("\\n", "\n"); + string newText = options.Arguments[2].Replace("\\n", "\n"); + string result = Handle3WayTextDiff(baseText, oldText, newText, options); + + Console.WriteLine(result); + return 0; + } + + private static int HandleMergeFileCommand(CommandLineOptions options) + { + if (options.Arguments.Length < 3) + { + Console.Error.WriteLine("Error: merge-file requires base, old, and new file paths"); + PrintUsage(); + return 1; + } + + var result = Handle3WayFileMerge(options.Arguments[0], options.Arguments[1], options.Arguments[2], options); + Console.WriteLine(result.Output); + return result.ExitCode; + } + + private static int HandleMergeTextCommand(CommandLineOptions options) + { + if (options.Arguments.Length < 3) + { + Console.Error.WriteLine("Error: merge-text requires base, old, and new text"); + PrintUsage(); + return 1; + } + + string baseText = options.Arguments[0].Replace("\\n", "\n"); + string oldText = options.Arguments[1].Replace("\\n", "\n"); + string newText = options.Arguments[2].Replace("\\n", "\n"); + var result = Handle3WayTextMerge(baseText, oldText, newText, options); + + Console.WriteLine(result.Output); + return result.ExitCode; + } + + private static int HandleUnknownCommand(string command) + { + Console.Error.WriteLine($"Unknown command: {command}"); + PrintUsage(); + return 1; } private static void PrintUsage() { - Console.WriteLine("DiffPlex.ConsoleRunner - Generate unified diff (unidiff) output"); + Console.WriteLine("DiffPlex.ConsoleRunner - Generate unified diff (unidiff) output and three-way diffs/merges"); Console.WriteLine(); Console.WriteLine("Usage:"); - Console.WriteLine(" DiffPlex.ConsoleRunner file "); - Console.WriteLine(" DiffPlex.ConsoleRunner text "); + Console.WriteLine(" DiffPlex.ConsoleRunner file [options]"); + Console.WriteLine(" DiffPlex.ConsoleRunner text [options]"); + Console.WriteLine(" DiffPlex.ConsoleRunner 3way-file [options]"); + Console.WriteLine(" DiffPlex.ConsoleRunner 3way-text [options]"); + Console.WriteLine(" DiffPlex.ConsoleRunner merge-file [options]"); + Console.WriteLine(" DiffPlex.ConsoleRunner merge-text [options]"); Console.WriteLine(); Console.WriteLine("Commands:"); - Console.WriteLine(" file Compare two files and generate a unidiff"); - Console.WriteLine(" text Compare two strings and generate a unidiff"); + Console.WriteLine(" file Compare two files and generate a unidiff"); + Console.WriteLine(" text Compare two strings and generate a unidiff"); + Console.WriteLine(" 3way-file Compare three files and generate three-way diff"); + Console.WriteLine(" 3way-text Compare three strings and generate three-way diff"); + Console.WriteLine(" merge-file Merge three files and generate merged result"); + Console.WriteLine(" merge-text Merge three strings and generate merged result"); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" -i, --ignore-case Ignore case differences"); + Console.WriteLine(" -w, --ignore-whitespace Ignore whitespace differences"); + Console.WriteLine(); + Console.WriteLine("Exit codes:"); + Console.WriteLine(" 0 Success (no conflicts for merge operations)"); + Console.WriteLine(" 1 Error or conflicts found"); Console.WriteLine(); Console.WriteLine("File paths work with both Windows and Unix-style paths."); } + private static string Handle3WayFileDiff(string basePath, string oldPath, string newPath, CommandLineOptions options) + { + basePath = NormalizePath(basePath); + oldPath = NormalizePath(oldPath); + newPath = NormalizePath(newPath); + + if (!File.Exists(basePath)) + { + throw new FileNotFoundException($"Base file not found: {basePath}"); + } + if (!File.Exists(oldPath)) + { + throw new FileNotFoundException($"Old file not found: {oldPath}"); + } + if (!File.Exists(newPath)) + { + throw new FileNotFoundException($"New file not found: {newPath}"); + } + + string baseText = File.ReadAllText(basePath); + string oldText = File.ReadAllText(oldPath); + string newText = File.ReadAllText(newPath); + + return Handle3WayTextDiff(baseText, oldText, newText, options); + } + + private static string Handle3WayTextDiff(string baseText, string oldText, string newText, CommandLineOptions options) + { + var differ = ThreeWayDiffer.Instance; + var result = differ.CreateDiffs(baseText, oldText, newText, options.IgnoreWhitespace, options.IgnoreCase, LineChunker.Instance); + + return ThreeWayDiffRenderer.RenderDiff(result); + } + + private static MergeResult Handle3WayFileMerge(string basePath, string oldPath, string newPath, CommandLineOptions options) + { + basePath = NormalizePath(basePath); + oldPath = NormalizePath(oldPath); + newPath = NormalizePath(newPath); + + if (!File.Exists(basePath)) + { + throw new FileNotFoundException($"Base file not found: {basePath}"); + } + if (!File.Exists(oldPath)) + { + throw new FileNotFoundException($"Old file not found: {oldPath}"); + } + if (!File.Exists(newPath)) + { + throw new FileNotFoundException($"New file not found: {newPath}"); + } + + string baseText = File.ReadAllText(basePath); + string oldText = File.ReadAllText(oldPath); + string newText = File.ReadAllText(newPath); + + return Handle3WayTextMerge(baseText, oldText, newText, options); + } + + private static MergeResult Handle3WayTextMerge(string baseText, string oldText, string newText, CommandLineOptions options) + { + var differ = ThreeWayDiffer.Instance; + var result = differ.CreateMerge(baseText, oldText, newText, options.IgnoreWhitespace, options.IgnoreCase, LineChunker.Instance); + + return new MergeResult + { + Output = result.IsSuccessful + ? string.Join(Environment.NewLine, result.MergedPieces) + : ThreeWayMergeRenderer.RenderMergeWithConflicts(result), + ExitCode = result.IsSuccessful ? 0 : 1 + }; + } + /// /// Normalizes a file path to work correctly on both Windows and Unix-based systems. /// @@ -96,4 +316,98 @@ private static string NormalizePath(string path) return path.Replace('/', Path.DirectorySeparatorChar) .Replace('\\', Path.DirectorySeparatorChar); } + + private class CommandLineOptions + { + public string Command { get; set; } = string.Empty; + public string[] Arguments { get; set; } = Array.Empty(); + public bool IgnoreCase { get; set; } + public bool IgnoreWhitespace { get; set; } + } + + private class MergeResult + { + public string Output { get; set; } = string.Empty; + public int ExitCode { get; set; } + } +} + +public static class ThreeWayDiffRenderer +{ + public static string RenderDiff(ThreeWayDiffResult result) + { + var output = new System.Text.StringBuilder(); + output.AppendLine("=== Three-Way Diff ==="); + output.AppendLine($"Base: {result.PiecesBase.Count} lines"); + output.AppendLine($"Old: {result.PiecesOld.Count} lines"); + output.AppendLine($"New: {result.PiecesNew.Count} lines"); + output.AppendLine($"Blocks: {result.DiffBlocks.Count}"); + output.AppendLine(); + + foreach (var block in result.DiffBlocks) + { + output.AppendLine($"@@@ {block.ChangeType} @@@"); + output.AppendLine($"Base[{block.BaseStart}..{block.BaseStart + block.BaseCount - 1}] ({block.BaseCount})"); + output.AppendLine($"Old [{block.OldStart}..{block.OldStart + block.OldCount - 1}] ({block.OldCount})"); + output.AppendLine($"New [{block.NewStart}..{block.NewStart + block.NewCount - 1}] ({block.NewCount})"); + + // Show content for each section + if (block.BaseCount > 0) + { + output.AppendLine("Base content:"); + for (int i = 0; i < block.BaseCount; i++) + { + output.AppendLine($" = {result.PiecesBase[block.BaseStart + i]}"); + } + } + + if (block.OldCount > 0) + { + output.AppendLine("Old content:"); + for (int i = 0; i < block.OldCount; i++) + { + output.AppendLine($" < {result.PiecesOld[block.OldStart + i]}"); + } + } + + if (block.NewCount > 0) + { + output.AppendLine("New content:"); + for (int i = 0; i < block.NewCount; i++) + { + output.AppendLine($" > {result.PiecesNew[block.NewStart + i]}"); + } + } + + output.AppendLine(); + } + + return output.ToString(); + } +} + +public static class ThreeWayMergeRenderer +{ + public static string RenderMergeWithConflicts(ThreeWayMergeResult result) + { + var output = new System.Text.StringBuilder(); + output.AppendLine($"=== Merge Result (with {result.ConflictBlocks.Count} conflicts) ==="); + output.AppendLine(); + output.AppendLine(string.Join(Environment.NewLine, result.MergedPieces)); + + if (result.ConflictBlocks.Count > 0) + { + output.AppendLine(); + output.AppendLine("=== Conflict Summary ==="); + foreach (var conflict in result.ConflictBlocks) + { + output.AppendLine($"Conflict at merged line {conflict.MergedStart}:"); + output.AppendLine($" Base: {conflict.BasePieces.Count} lines"); + output.AppendLine($" Old: {conflict.OldPieces.Count} lines"); + output.AppendLine($" New: {conflict.NewPieces.Count} lines"); + } + } + + return output.ToString(); + } } diff --git a/DiffPlex.Wpf.Demo/MainWindow.xaml b/DiffPlex.Wpf.Demo/MainWindow.xaml index dce5f3b6..c58424f5 100644 --- a/DiffPlex.Wpf.Demo/MainWindow.xaml +++ b/DiffPlex.Wpf.Demo/MainWindow.xaml @@ -1,4 +1,4 @@ -LinesContext +"; + + const string theirsText = @"
+

Welcome

+

Enhanced content with more details

+
"; + + var result = _differ.CreateMerge(baseText, yoursText, theirsText, false, false, new LineChunker()); + + Assert.True(result.IsSuccessful); + var mergedText = string.Join("\n", result.MergedPieces); + Assert.Contains("responsive", mergedText); + Assert.Contains("Welcome to Our Site", mergedText); + Assert.Contains("Enhanced content", mergedText); + Assert.Contains("showMore()", mergedText); + } + + [Fact] + public void Can_handle_sql_schema_migration_conflict() + { + const string baseText = @"CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name VARCHAR(100), + email VARCHAR(255) +);"; + + const string yoursText = @"CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255) UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +);"; + + const string theirsText = @"CREATE TABLE users ( + id INTEGER PRIMARY KEY, + first_name VARCHAR(50), + last_name VARCHAR(50), + email VARCHAR(255) +);"; + + var result = _differ.CreateMerge(baseText, yoursText, theirsText, false, false, new LineChunker()); + + Assert.False(result.IsSuccessful); + Assert.Single(result.ConflictBlocks); + + // Verify conflict contains both approaches to name field + var conflictText = string.Join(" ", result.ConflictBlocks[0].OldPieces.Concat(result.ConflictBlocks[0].NewPieces)); + Assert.Contains("name VARCHAR(100) NOT NULL", conflictText); + Assert.Contains("first_name", conflictText); + Assert.Contains("last_name", conflictText); + } + + [Fact] + public void Can_merge_different_feature_additions() + { + const string baseText = @"public class Calculator +{ + public double Add(double a, double b) => a + b; + public double Subtract(double a, double b) => a - b; +}"; + + const string yoursText = @"public class Calculator +{ + public double Add(double a, double b) => a + b; + public double Subtract(double a, double b) => a - b; + public double Multiply(double a, double b) => a * b; + public double Power(double baseNum, double exponent) => Math.Pow(baseNum, exponent); +}"; + + const string theirsText = @"public class Calculator +{ + public double Add(double a, double b) => a + b; + public double Subtract(double a, double b) => a - b; + public double Divide(double a, double b) => b != 0 ? a / b : throw new DivideByZeroException(); + public double Modulo(double a, double b) => a % b; +}"; + + var result = _differ.CreateMerge(baseText, yoursText, theirsText, false, false, new LineChunker()); + + // This might conflict if additions are on same lines + var mergedText = string.Join("\n", result.MergedPieces); + Assert.Contains("Multiply", mergedText); + Assert.Contains("Power", mergedText); + Assert.Contains("Divide", mergedText); + Assert.Contains("Modulo", mergedText); + } + + [Fact] + public void Can_handle_comment_and_code_changes() + { + const string baseText = @"// Basic validation +public bool IsValid(string input) +{ + return !string.IsNullOrEmpty(input); +}"; + + const string yoursText = @"/// +/// Validates input string for basic requirements +/// +/// The input string to validate +/// True if valid, false otherwise +public bool IsValid(string input) +{ + return !string.IsNullOrEmpty(input) && input.Length > 2; +}"; + + const string theirsText = @"// Enhanced validation with trim +public bool IsValid(string input) +{ + return !string.IsNullOrWhiteSpace(input?.Trim()); +}"; + + var result = _differ.CreateMerge(baseText, yoursText, theirsText, false, false, new LineChunker()); + + Assert.False(result.IsSuccessful); + Assert.True(result.ConflictBlocks.Count > 0); + + // Both changed the implementation differently + var mergedText = string.Join("\n", result.MergedPieces); + Assert.Contains("summary", mergedText); // XML doc was added by yours + } + + [Fact] + public void Will_handle_consecutive_pure_insertions_at_same_position() + { + // This tests for the potential infinite loop mentioned by the Oracle + // where two consecutive pure insertion blocks at the same BaseStart could cause issues + + var baseText = "line1\nline3\n"; + var oldText = "line1\ninserted_old1\ninserted_old2\nline3\n"; + var newText = "line1\ninserted_new1\ninserted_new2\nline3\n"; + + var differ = ThreeWayDiffer.Instance; + + // This should not hang or throw an exception + var result = differ.CreateDiffs(baseText, oldText, newText, false, false, LineChunker.Instance); + + Assert.NotNull(result); + Assert.True(result.DiffBlocks.Count > 0); + } + + [Fact] + public void Will_handle_empty_base_with_insertions() + { + var baseText = ""; + var oldText = "old_line1\nold_line2\n"; + var newText = "new_line1\nnew_line2\n"; + + var differ = ThreeWayDiffer.Instance; + + var result = differ.CreateDiffs(baseText, oldText, newText, false, false, LineChunker.Instance); + + Assert.NotNull(result); + Assert.True(result.DiffBlocks.Count >= 1); + Assert.Contains(result.DiffBlocks, block => block.ChangeType == ThreeWayChangeType.Conflict); + } + + [Fact] + public void Will_handle_pure_deletions() + { + var baseText = "line1\nline2\nline3\n"; + var oldText = "line1\nline3\n"; + var newText = "line1\nline3\n"; + + var differ = ThreeWayDiffer.Instance; + + var result = differ.CreateDiffs(baseText, oldText, newText, false, false, LineChunker.Instance); + + Assert.NotNull(result); + } + + [Fact] + public void Will_create_merge_with_consecutive_insertions() + { + var baseText = "line1\nline3\n"; + var oldText = "line1\ninserted_old\nline3\n"; + var newText = "line1\ninserted_new\nline3\n"; + + var differ = ThreeWayDiffer.Instance; + + var result = differ.CreateMerge(baseText, oldText, newText, false, false, LineChunker.Instance); + + Assert.NotNull(result); + Assert.False(result.IsSuccessful); // Should be a conflict + Assert.True(result.ConflictBlocks.Count > 0); + } + + [Fact] + public void Will_handle_zero_length_deletions_properly() + { + // Test case where DeleteCountA is 0 (pure insertion) + var baseText = "line1\nline2\n"; + var oldText = "line1\ninserted\nline2\n"; // Pure insertion at position 1 + var newText = "line1\nline2\n"; // No change + + var differ = ThreeWayDiffer.Instance; + + var result = differ.CreateDiffs(baseText, oldText, newText, false, false, LineChunker.Instance); + + Assert.NotNull(result); + Assert.True(result.DiffBlocks.Count > 0); + + // Should have OldOnly change type for the insertion + Assert.Contains(result.DiffBlocks, block => block.ChangeType == ThreeWayChangeType.OldOnly); + } + + [Fact] + public void Will_handle_complex_three_way_scenario() + { + // A more complex scenario that could stress test the loop logic + var baseText = "A\nB\nC\nD\nE\n"; + var oldText = "A\nB_OLD\nNEW_OLD\nC\nD\nE_OLD\n"; + var newText = "A_NEW\nB\nNEW_NEW\nC\nD_NEW\nE\n"; + + var differ = ThreeWayDiffer.Instance; + + var result = differ.CreateDiffs(baseText, oldText, newText, false, false, LineChunker.Instance); + + Assert.NotNull(result); + Assert.True(result.DiffBlocks.Count > 0); + + // Test that merge doesn't hang + var mergeResult = differ.CreateMerge(baseText, oldText, newText, false, false, LineChunker.Instance); + Assert.NotNull(mergeResult); + } + } + } +} diff --git a/README.md b/README.md index 8713c5c6..ecd110f8 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,64 @@ Example output: Line 4 ``` +## IThreeWayDiffer Interface + +The `IThreeWayDiffer` interface provides functionality for three-way diffing and merging, which is essential for merge operations in version control systems or when comparing three versions of text. + +```csharp +/// +/// Responsible for generating three-way differences and merges between texts +/// +public interface IThreeWayDiffer +{ + /// + /// Creates a three-way diff by comparing base, old, and new text line by line. + /// + ThreeWayDiffResult CreateDiffs(string baseText, string oldText, string newText, + bool ignoreWhiteSpace, bool ignoreCase, IChunker chunker); + + /// + /// Creates a three-way merge by comparing base, old, and new text line by line. + /// + ThreeWayMergeResult CreateMerge(string baseText, string oldText, string newText, + bool ignoreWhiteSpace, bool ignoreCase, IChunker chunker); +} +``` + +Three-way diffing compares three versions of text: +- **Base text**: The common ancestor or original version +- **Old text**: One modified version (e.g., your changes) +- **New text**: Another modified version (e.g., incoming changes) + +This enables intelligent merging by identifying: +- Changes unique to the old version +- Changes unique to the new version +- Changes made to both versions (conflicts) +- Unchanged sections + +```csharp +var threeWayDiffer = new ThreeWayDiffer(); + +// Three-way diff +var diffResult = threeWayDiffer.CreateDiffs(baseText, oldText, newText, + ignoreWhiteSpace: false, ignoreCase: false, new LineChunker()); + +// Three-way merge with automatic conflict detection +var mergeResult = threeWayDiffer.CreateMerge(baseText, oldText, newText, + ignoreWhiteSpace: false, ignoreCase: false, new LineChunker()); + +// Check for conflicts +if (mergeResult.HasConflicts) +{ + Console.WriteLine($"Found {mergeResult.ConflictBlocks.Count} conflicts"); +} +else +{ + Console.WriteLine("Merge completed successfully"); + Console.WriteLine(mergeResult.MergedText); +} +``` + ## ISideBySideDifferBuilder Interface ```csharp