From 7f3d49abf758952377bbfcc45b2df4b9630be520 Mon Sep 17 00:00:00 2001 From: Matthew Manela Date: Thu, 19 Jun 2025 12:31:56 -0400 Subject: [PATCH 1/9] init three way --- .../Components/Layout/NavMenu.razor | 8 +- .../Components/Pages/Examples.razor | 138 +++- .../Components/Pages/ThreeWayMerge.razor | 280 ++++++++ .../Components/ThreeWayMergeViewer.razor | 651 ++++++++++++++++++ DiffPlex.Wpf.Demo/MainWindow.xaml | 3 +- DiffPlex.Wpf.Demo/MainWindow.xaml.cs | 22 +- DiffPlex.Wpf.Demo/ThreeWayMergeWindow.xaml | 39 ++ DiffPlex.Wpf.Demo/ThreeWayMergeWindow.xaml.cs | 146 ++++ DiffPlex.Wpf/Controls/Helper.cs | 35 +- .../Controls/ThreeWayMergeViewer.xaml | 120 ++++ .../Controls/ThreeWayMergeViewer.xaml.cs | 573 +++++++++++++++ DiffPlex/IThreeWayDiffer.cs | 34 + DiffPlex/Model/ThreeWayConflictBlock.cs | 46 ++ DiffPlex/Model/ThreeWayDiffBlock.cs | 86 +++ DiffPlex/Model/ThreeWayDiffResult.cs | 39 ++ DiffPlex/Model/ThreeWayMergeResult.cs | 39 ++ DiffPlex/ThreeWayDiffer.cs | 307 +++++++++ Facts.DiffPlex/ThreeWayDifferFacts.cs | 313 +++++++++ 18 files changed, 2873 insertions(+), 6 deletions(-) create mode 100644 DiffPlex.Blazor/Components/Pages/ThreeWayMerge.razor create mode 100644 DiffPlex.Blazor/Components/ThreeWayMergeViewer.razor create mode 100644 DiffPlex.Wpf.Demo/ThreeWayMergeWindow.xaml create mode 100644 DiffPlex.Wpf.Demo/ThreeWayMergeWindow.xaml.cs create mode 100644 DiffPlex.Wpf/Controls/ThreeWayMergeViewer.xaml create mode 100644 DiffPlex.Wpf/Controls/ThreeWayMergeViewer.xaml.cs create mode 100644 DiffPlex/IThreeWayDiffer.cs create mode 100644 DiffPlex/Model/ThreeWayConflictBlock.cs create mode 100644 DiffPlex/Model/ThreeWayDiffBlock.cs create mode 100644 DiffPlex/Model/ThreeWayDiffResult.cs create mode 100644 DiffPlex/Model/ThreeWayMergeResult.cs create mode 100644 DiffPlex/ThreeWayDiffer.cs create mode 100644 Facts.DiffPlex/ThreeWayDifferFacts.cs 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..2cf0b94e --- /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.

+
+
+ +
+
+
+
+
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.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 + } } } } From b5a57c889289e0491d021b42e88b2ba0209d43fb Mon Sep 17 00:00:00 2001 From: Matthew Manela Date: Thu, 26 Jun 2025 11:54:05 -0400 Subject: [PATCH 3/9] ui --- .../InteractiveThreeWayMergeViewer.razor | 1037 +++++++++++++++++ .../Components/Pages/ThreeWayMerge.razor | 16 +- 2 files changed, 1045 insertions(+), 8 deletions(-) create mode 100644 DiffPlex.Blazor/Components/InteractiveThreeWayMergeViewer.razor 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/Pages/ThreeWayMerge.razor b/DiffPlex.Blazor/Components/Pages/ThreeWayMerge.razor index 2cf0b94e..eef9f6a7 100644 --- a/DiffPlex.Blazor/Components/Pages/ThreeWayMerge.razor +++ b/DiffPlex.Blazor/Components/Pages/ThreeWayMerge.razor @@ -8,7 +8,7 @@

Three-Way Merge

-

Interactive three-way merge tool for resolving conflicts between two branches with a common base.

+

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.

@@ -61,13 +61,13 @@
- +
From 69cf04e7295e7de92f10475b7a6e039d13e459ff Mon Sep 17 00:00:00 2001 From: Matthew Manela Date: Fri, 18 Jul 2025 08:32:44 -0700 Subject: [PATCH 4/9] Update command line and tests --- DiffPlex.ConsoleRunner/Program.cs | 396 +++++++++++++++++++++++--- Facts.DiffPlex/ConsoleRunnerFacts.cs | 305 ++++++++++++++++++++ Facts.DiffPlex/ThreeWayDifferFacts.cs | 138 +++++++-- 3 files changed, 781 insertions(+), 58 deletions(-) create mode 100644 Facts.DiffPlex/ConsoleRunnerFacts.cs diff --git a/DiffPlex.ConsoleRunner/Program.cs b/DiffPlex.ConsoleRunner/Program.cs index 13f71364..8b6f80d2 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 { - private static void Main(string[] args) + private 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/Facts.DiffPlex/ConsoleRunnerFacts.cs b/Facts.DiffPlex/ConsoleRunnerFacts.cs new file mode 100644 index 00000000..ce65d1e5 --- /dev/null +++ b/Facts.DiffPlex/ConsoleRunnerFacts.cs @@ -0,0 +1,305 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Facts.DiffPlex +{ + public class ConsoleRunnerFacts + { + private readonly string _consoleRunnerPath; + + public ConsoleRunnerFacts() + { + // Find the console runner executable + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + var consoleRunnerDir = Path.Combine(baseDir, "..", "..", "..", "..", "DiffPlex.ConsoleRunner", "bin", "Debug", "net6.0"); + _consoleRunnerPath = Path.Combine(consoleRunnerDir, "DiffPlex.ConsoleRunner.dll"); + } + + [Fact] + public async Task Will_show_usage_when_no_arguments_provided() + { + var result = await RunConsoleRunner(); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("DiffPlex.ConsoleRunner", result.Output); + Assert.Contains("Usage:", result.Output); + } + + [Fact] + public async Task Will_show_usage_when_insufficient_arguments_provided() + { + var result = await RunConsoleRunner("file", "onefile.txt"); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("Usage:", result.Output); + } + + [Fact] + public async Task Will_handle_unknown_command() + { + var result = await RunConsoleRunner("unknown", "arg1", "arg2"); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("Unknown command: unknown", result.Output); + } + + [Fact] + public async Task Will_compare_two_text_strings() + { + var result = await RunConsoleRunner("text", "line1\\nline2", "line1\\nline3"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("@@ -1,2 +1,2 @@", result.Output); + Assert.Contains("-line2", result.Output); + Assert.Contains("+line3", result.Output); + } + + [Fact] + public async Task Will_perform_3way_text_diff() + { + var result = await RunConsoleRunner("3way-text", "base\\ncommon", "base\\nold", "base\\nnew"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("Three-Way Diff", result.Output); + Assert.Contains("Base: 2 lines", result.Output); + Assert.Contains("Old: 2 lines", result.Output); + Assert.Contains("New: 2 lines", result.Output); + } + + [Fact] + public async Task Will_perform_3way_text_diff_with_ignore_case() + { + var result = await RunConsoleRunner("3way-text", "-i", "BASE\\ncommon", "base\\nold", "BASE\\nnew"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("Three-Way Diff", result.Output); + } + + [Fact] + public async Task Will_perform_3way_text_diff_with_ignore_whitespace() + { + var result = await RunConsoleRunner("3way-text", "-w", "base \\ncommon", "base\\nold", "base\\nnew"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("Three-Way Diff", result.Output); + } + + [Fact] + public async Task Will_perform_merge_text_without_conflicts() + { + var result = await RunConsoleRunner("merge-text", "base\\ncommon", "base\\nold", "new\\ncommon"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("new", result.Output); + Assert.Contains("old", result.Output); + } + + [Fact] + public async Task Will_perform_merge_text_with_conflicts() + { + var result = await RunConsoleRunner("merge-text", "base\\ncommon", "old\\ncommon", "new\\ncommon"); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("<<<<<<< old", result.Output); + Assert.Contains("=======", result.Output); + Assert.Contains(">>>>>>> new", result.Output); + Assert.Contains("conflicts", result.Output); + } + + [Fact] + public async Task Will_perform_merge_text_with_ignore_case() + { + var result = await RunConsoleRunner("merge-text", "-i", "BASE\\ncommon", "base\\nold", "BASE\\nnew"); + + // This should still be a conflict because the content is different (old vs new) + Assert.Equal(1, result.ExitCode); + } + + [Fact] + public async Task Will_perform_merge_text_with_ignore_whitespace() + { + var result = await RunConsoleRunner("merge-text", "-w", "base \\ncommon", "base\\nold", "base\\nnew"); + + Assert.Equal(0, result.ExitCode); + } + + [Fact] + public async Task Will_handle_file_not_found_for_3way_file() + { + var result = await RunConsoleRunner("3way-file", "nonexistent1.txt", "nonexistent2.txt", "nonexistent3.txt"); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("not found", result.Output); + } + + [Fact] + public async Task Will_handle_file_not_found_for_merge_file() + { + var result = await RunConsoleRunner("merge-file", "nonexistent1.txt", "nonexistent2.txt", "nonexistent3.txt"); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("not found", result.Output); + } + + [Fact] + public async Task Will_handle_insufficient_arguments_for_3way_commands() + { + var result = await RunConsoleRunner("3way-text", "base", "old"); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("requires base, old, and new", result.Output); + } + + [Fact] + public async Task Will_handle_insufficient_arguments_for_merge_commands() + { + var result = await RunConsoleRunner("merge-text", "base", "old"); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("requires base, old, and new", result.Output); + } + + [Fact] + public async Task Will_handle_unknown_option() + { + var result = await RunConsoleRunner("text", "-x", "old", "new"); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("Unknown option: -x", result.Output); + } + + [Fact] + public async Task Will_work_with_file_commands() + { + // Create temporary files + var tempDir = Path.GetTempPath(); + var oldFile = Path.Combine(tempDir, "old_test.txt"); + var newFile = Path.Combine(tempDir, "new_test.txt"); + + try + { + await File.WriteAllTextAsync(oldFile, "line1\nline2\n"); + await File.WriteAllTextAsync(newFile, "line1\nline3\n"); + + var result = await RunConsoleRunner("file", oldFile, newFile); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("@@ -1,", result.Output); + Assert.Contains("-line2", result.Output); + Assert.Contains("+line3", result.Output); + } + finally + { + // Clean up + if (File.Exists(oldFile)) File.Delete(oldFile); + if (File.Exists(newFile)) File.Delete(newFile); + } + } + + [Fact] + public async Task Will_work_with_3way_file_commands() + { + // Create temporary files + var tempDir = Path.GetTempPath(); + var baseFile = Path.Combine(tempDir, "base_test.txt"); + var oldFile = Path.Combine(tempDir, "old_test.txt"); + var newFile = Path.Combine(tempDir, "new_test.txt"); + + try + { + await File.WriteAllTextAsync(baseFile, "common\nbase\n"); + await File.WriteAllTextAsync(oldFile, "common\nold\n"); + await File.WriteAllTextAsync(newFile, "common\nnew\n"); + + var result = await RunConsoleRunner("3way-file", baseFile, oldFile, newFile); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("Three-Way Diff", result.Output); + Assert.Contains("Base:", result.Output); + } + finally + { + // Clean up + if (File.Exists(baseFile)) File.Delete(baseFile); + if (File.Exists(oldFile)) File.Delete(oldFile); + if (File.Exists(newFile)) File.Delete(newFile); + } + } + + [Fact] + public async Task Will_work_with_merge_file_commands() + { + // Create temporary files + var tempDir = Path.GetTempPath(); + var baseFile = Path.Combine(tempDir, "base_merge_test.txt"); + var oldFile = Path.Combine(tempDir, "old_merge_test.txt"); + var newFile = Path.Combine(tempDir, "new_merge_test.txt"); + + try + { + await File.WriteAllTextAsync(baseFile, "common\nbase\n"); + await File.WriteAllTextAsync(oldFile, "common\nold\n"); + await File.WriteAllTextAsync(newFile, "updated\nbase\n"); + + var result = await RunConsoleRunner("merge-file", baseFile, oldFile, newFile); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("updated", result.Output); + Assert.Contains("old", result.Output); + } + finally + { + // Clean up + if (File.Exists(baseFile)) File.Delete(baseFile); + if (File.Exists(oldFile)) File.Delete(oldFile); + if (File.Exists(newFile)) File.Delete(newFile); + } + } + + private async Task RunConsoleRunner(params string[] args) + { + var processInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{_consoleRunnerPath}\" {string.Join(" ", args)}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = processInfo }; + + var outputBuilder = new StringBuilder(); + var errorBuilder = new StringBuilder(); + + process.OutputDataReceived += (sender, e) => { if (e.Data != null) outputBuilder.AppendLine(e.Data); }; + process.ErrorDataReceived += (sender, e) => { if (e.Data != null) errorBuilder.AppendLine(e.Data); }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(); + + var output = outputBuilder.ToString(); + var error = errorBuilder.ToString(); + + return new ProcessResult + { + ExitCode = process.ExitCode, + Output = string.IsNullOrEmpty(error) ? output : output + error + }; + } + + private class ProcessResult + { + public int ExitCode { get; set; } + public string Output { get; set; } = string.Empty; + } + } +} diff --git a/Facts.DiffPlex/ThreeWayDifferFacts.cs b/Facts.DiffPlex/ThreeWayDifferFacts.cs index 6360a92b..a1b662c1 100644 --- a/Facts.DiffPlex/ThreeWayDifferFacts.cs +++ b/Facts.DiffPlex/ThreeWayDifferFacts.cs @@ -285,10 +285,10 @@ public int Subtract(int a, int b) }"; var result = _differ.CreateMerge(baseText, yoursText, theirsText, false, false, new LineChunker()); - + Assert.True(result.IsSuccessful); Assert.Empty(result.ConflictBlocks); - + var mergedText = string.Join("\n", result.MergedPieces); Assert.Contains("// Added validation", mergedText); Assert.Contains("public int Multiply", mergedText); @@ -301,9 +301,9 @@ public void Can_handle_whitespace_conflicts() const string baseText = "line1\n line2\nline3"; const string yoursText = "line1\n\tline2\nline3"; // Tab instead of spaces const string theirsText = "line1\n line2\nline3"; // Different number of spaces - + var result = _differ.CreateMerge(baseText, yoursText, theirsText, true, false, new LineChunker()); - + // With ignoreWhiteSpace=true, this should be treated as BothSame Assert.True(result.IsSuccessful); Assert.Empty(result.ConflictBlocks); @@ -345,7 +345,7 @@ public void Can_merge_config_file_changes() }"; var result = _differ.CreateMerge(baseText, yoursText, theirsText, false, false, new LineChunker()); - + Assert.True(result.IsSuccessful); var mergedText = string.Join("\n", result.MergedPieces); Assert.Contains("\"timeout\": 30", mergedText); @@ -395,17 +395,17 @@ public void DoWork() }"; var result = _differ.CreateMerge(baseText, yoursText, theirsText, false, false, new LineChunker()); - + Assert.False(result.IsSuccessful); // Method signature conflict Assert.True(result.ConflictBlocks.Count > 0); - + var mergedText = string.Join("\n", result.MergedPieces); Assert.Contains("using System.Threading.Tasks", mergedText); Assert.Contains("using System.Linq", mergedText); } [Fact] - public void Can_merge_documentation_changes() + public void Can_merge_documentation_changes() { const string baseText = @"# Project Documentation @@ -448,7 +448,7 @@ Run npm install. Basic usage instructions."; var result = _differ.CreateMerge(baseText, yoursText, theirsText, false, false, new LineChunker()); - + Assert.True(result.IsSuccessful); var mergedText = string.Join("\n", result.MergedPieces); Assert.Contains("for demonstrating features", mergedText); @@ -507,10 +507,10 @@ public async Task UpdateUserAsync(User user) }"; var result = _differ.CreateMerge(baseText, yoursText, theirsText, false, false, new LineChunker()); - + Assert.False(result.IsSuccessful); Assert.True(result.ConflictBlocks.Count > 0); - + // Verify conflicts contain both refactoring attempts var conflictText = string.Join(" ", result.ConflictBlocks.SelectMany(c => c.OldPieces.Concat(c.NewPieces))); Assert.Contains("userRepository", conflictText); @@ -537,7 +537,7 @@ public void Can_merge_html_template_changes() "; 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); @@ -570,10 +570,10 @@ 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); @@ -607,7 +607,7 @@ public void Can_merge_different_feature_additions() }"; 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); @@ -642,14 +642,118 @@ public bool IsValid(string input) }"; 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); + } } } } From 477bde2523bbd9d1229dfe655a9ac93abc7718c8 Mon Sep 17 00:00:00 2001 From: Matthew Manela Date: Fri, 18 Jul 2025 08:39:10 -0700 Subject: [PATCH 5/9] format --- DiffPlex/ThreeWayDiffer.cs | 197 ++++++++++++++++++------------------- 1 file changed, 98 insertions(+), 99 deletions(-) diff --git a/DiffPlex/ThreeWayDiffer.cs b/DiffPlex/ThreeWayDiffer.cs index 043f7e91..9fbf98c6 100644 --- a/DiffPlex/ThreeWayDiffer.cs +++ b/DiffPlex/ThreeWayDiffer.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using DiffPlex.Chunkers; using DiffPlex.Model; namespace DiffPlex @@ -15,33 +14,33 @@ public class ThreeWayDiffer : IThreeWayDiffer private readonly IDiffer _differ = Differ.Instance; - public ThreeWayDiffResult CreateDiffs(string baseText, string oldText, string newText, + public ThreeWayDiffResult CreateDiffs(string baseText, string oldText, string newText, bool ignoreWhiteSpace, bool ignoreCase, IChunker chunker) { - if (baseText == null) throw new ArgumentNullException(nameof(baseText)); - if (oldText == null) throw new ArgumentNullException(nameof(oldText)); - if (newText == null) throw new ArgumentNullException(nameof(newText)); - if (chunker == null) throw new ArgumentNullException(nameof(chunker)); + if (baseText == null) throw new ArgumentNullException(nameof(baseText)); + if (oldText == null) throw new ArgumentNullException(nameof(oldText)); + if (newText == null) throw new ArgumentNullException(nameof(newText)); + if (chunker == null) throw new ArgumentNullException(nameof(chunker)); - var basePieces = chunker.Chunk(baseText); - var oldPieces = chunker.Chunk(oldText); - var newPieces = chunker.Chunk(newText); + var basePieces = chunker.Chunk(baseText); + var oldPieces = chunker.Chunk(oldText); + var newPieces = chunker.Chunk(newText); - // Create two-way diffs: base->old and base->new - var baseToOld = _differ.CreateDiffs(baseText, oldText, ignoreWhiteSpace, ignoreCase, chunker); - var baseToNew = _differ.CreateDiffs(baseText, newText, ignoreWhiteSpace, ignoreCase, chunker); + // Create two-way diffs: base->old and base->new + var baseToOld = _differ.CreateDiffs(baseText, oldText, ignoreWhiteSpace, ignoreCase, chunker); + var baseToNew = _differ.CreateDiffs(baseText, newText, ignoreWhiteSpace, ignoreCase, chunker); - var threeWayBlocks = CreateThreeWayDiffBlocks(basePieces, oldPieces, newPieces, - baseToOld, baseToNew, ignoreWhiteSpace, ignoreCase); + var threeWayBlocks = CreateThreeWayDiffBlocks(basePieces, oldPieces, newPieces, + baseToOld, baseToNew, ignoreWhiteSpace, ignoreCase); - return new ThreeWayDiffResult(basePieces, oldPieces, newPieces, threeWayBlocks); + return new ThreeWayDiffResult(basePieces, oldPieces, newPieces, threeWayBlocks); } - public ThreeWayMergeResult CreateMerge(string baseText, string oldText, string newText, + public ThreeWayMergeResult CreateMerge(string baseText, string oldText, string newText, bool ignoreWhiteSpace, bool ignoreCase, IChunker chunker) { - var diffResult = CreateDiffs(baseText, oldText, newText, ignoreWhiteSpace, ignoreCase, chunker); - + var diffResult = CreateDiffs(baseText, oldText, newText, ignoreWhiteSpace, ignoreCase, chunker); + var mergedPieces = new List(); var conflictBlocks = new List(); var isSuccessful = true; @@ -52,75 +51,75 @@ public ThreeWayMergeResult CreateMerge(string baseText, string oldText, string n foreach (var block in diffResult.DiffBlocks) { - // Add unchanged content before this block - while (baseIndex < block.BaseStart) - { - mergedPieces.Add(diffResult.PiecesBase[baseIndex]); - baseIndex++; - oldIndex++; - newIndex++; - } - - switch (block.ChangeType) - { - case ThreeWayChangeType.Unchanged: - // Add base content (all are the same) - for (int i = 0; i < block.BaseCount; i++) - { - mergedPieces.Add(diffResult.PiecesBase[baseIndex + i]); - } - break; - - case ThreeWayChangeType.OldOnly: - // Take old version - for (int i = 0; i < block.OldCount; i++) - { - mergedPieces.Add(diffResult.PiecesOld[oldIndex + i]); - } - break; - - case ThreeWayChangeType.NewOnly: - // Take new version - for (int i = 0; i < block.NewCount; i++) - { - mergedPieces.Add(diffResult.PiecesNew[newIndex + i]); - } - break; + // Add unchanged content before this block + while (baseIndex < block.BaseStart) + { + mergedPieces.Add(diffResult.PiecesBase[baseIndex]); + baseIndex++; + oldIndex++; + newIndex++; + } - case ThreeWayChangeType.BothSame: - // Both made the same change, take either (we'll take old) - for (int i = 0; i < block.OldCount; i++) - { - mergedPieces.Add(diffResult.PiecesOld[oldIndex + i]); - } - break; - - case ThreeWayChangeType.Conflict: - // Create conflict block - var basePieces = diffResult.PiecesBase.Skip(baseIndex).Take(block.BaseCount).ToList(); - var oldPieces = diffResult.PiecesOld.Skip(oldIndex).Take(block.OldCount).ToList(); - var newPieces = diffResult.PiecesNew.Skip(newIndex).Take(block.NewCount).ToList(); - - var conflictBlock = new ThreeWayConflictBlock(mergedPieces.Count, basePieces, - oldPieces, newPieces, block); - conflictBlocks.Add(conflictBlock); - - // Add conflict markers - mergedPieces.Add("<<<<<<< old"); - mergedPieces.AddRange(oldPieces); - mergedPieces.Add("||||||| base"); - mergedPieces.AddRange(basePieces); - mergedPieces.Add("======="); - mergedPieces.AddRange(newPieces); - mergedPieces.Add(">>>>>>> new"); - - isSuccessful = false; - break; - } + switch (block.ChangeType) + { + case ThreeWayChangeType.Unchanged: + // Add base content (all are the same) + for (int i = 0; i < block.BaseCount; i++) + { + mergedPieces.Add(diffResult.PiecesBase[baseIndex + i]); + } + break; + + case ThreeWayChangeType.OldOnly: + // Take old version + for (int i = 0; i < block.OldCount; i++) + { + mergedPieces.Add(diffResult.PiecesOld[oldIndex + i]); + } + break; + + case ThreeWayChangeType.NewOnly: + // Take new version + for (int i = 0; i < block.NewCount; i++) + { + mergedPieces.Add(diffResult.PiecesNew[newIndex + i]); + } + break; + + case ThreeWayChangeType.BothSame: + // Both made the same change, take either (we'll take old) + for (int i = 0; i < block.OldCount; i++) + { + mergedPieces.Add(diffResult.PiecesOld[oldIndex + i]); + } + break; + + case ThreeWayChangeType.Conflict: + // Create conflict block + var basePieces = diffResult.PiecesBase.Skip(baseIndex).Take(block.BaseCount).ToList(); + var oldPieces = diffResult.PiecesOld.Skip(oldIndex).Take(block.OldCount).ToList(); + var newPieces = diffResult.PiecesNew.Skip(newIndex).Take(block.NewCount).ToList(); + + var conflictBlock = new ThreeWayConflictBlock(mergedPieces.Count, basePieces, + oldPieces, newPieces, block); + conflictBlocks.Add(conflictBlock); + + // Add conflict markers + mergedPieces.Add("<<<<<<< old"); + mergedPieces.AddRange(oldPieces); + mergedPieces.Add("||||||| base"); + mergedPieces.AddRange(basePieces); + mergedPieces.Add("======="); + mergedPieces.AddRange(newPieces); + mergedPieces.Add(">>>>>>> new"); + + isSuccessful = false; + break; + } - baseIndex += block.BaseCount; - oldIndex += block.OldCount; - newIndex += block.NewCount; + baseIndex += block.BaseCount; + oldIndex += block.OldCount; + newIndex += block.NewCount; } // Add remaining unchanged content @@ -133,18 +132,18 @@ public ThreeWayMergeResult CreateMerge(string baseText, string oldText, string n return new ThreeWayMergeResult(mergedPieces, isSuccessful, conflictBlocks, diffResult); } - private List CreateThreeWayDiffBlocks(IReadOnlyList basePieces, + private List CreateThreeWayDiffBlocks(IReadOnlyList basePieces, IReadOnlyList oldPieces, IReadOnlyList newPieces, DiffResult baseToOld, DiffResult baseToNew, bool ignoreWhiteSpace, bool ignoreCase) { var blocks = new List(); - + // If no changes, return single unchanged block if (baseToOld.DiffBlocks.Count == 0 && baseToNew.DiffBlocks.Count == 0) { if (basePieces.Count > 0) { - blocks.Add(new ThreeWayDiffBlock(0, basePieces.Count, 0, basePieces.Count, + blocks.Add(new ThreeWayDiffBlock(0, basePieces.Count, 0, basePieces.Count, 0, basePieces.Count, ThreeWayChangeType.Unchanged)); } return blocks; @@ -159,9 +158,9 @@ private List CreateThreeWayDiffBlocks(IReadOnlyList b while (baseIndex < basePieces.Count) { - var nextOldChange = oldBlockIndex < baseToOld.DiffBlocks.Count + var nextOldChange = oldBlockIndex < baseToOld.DiffBlocks.Count ? baseToOld.DiffBlocks[oldBlockIndex].DeleteStartA : int.MaxValue; - var nextNewChange = newBlockIndex < baseToNew.DiffBlocks.Count + var nextNewChange = newBlockIndex < baseToNew.DiffBlocks.Count ? baseToNew.DiffBlocks[newBlockIndex].DeleteStartA : int.MaxValue; var nextChange = Math.Min(nextOldChange, nextNewChange); @@ -169,9 +168,9 @@ private List CreateThreeWayDiffBlocks(IReadOnlyList b if (baseIndex < nextChange && nextChange != int.MaxValue) { var unchangedCount = nextChange - baseIndex; - blocks.Add(new ThreeWayDiffBlock(baseIndex, unchangedCount, oldIndex, unchangedCount, + blocks.Add(new ThreeWayDiffBlock(baseIndex, unchangedCount, oldIndex, unchangedCount, newIndex, unchangedCount, ThreeWayChangeType.Unchanged)); - + baseIndex += unchangedCount; oldIndex += unchangedCount; newIndex += unchangedCount; @@ -187,7 +186,7 @@ private List CreateThreeWayDiffBlocks(IReadOnlyList b var changeType = DetermineChangeType(basePieces, oldPieces, newPieces, oldBlock, newBlock, ignoreWhiteSpace, ignoreCase); - blocks.Add(new ThreeWayDiffBlock(baseIndex, oldBlock.DeleteCountA, + blocks.Add(new ThreeWayDiffBlock(baseIndex, oldBlock.DeleteCountA, oldIndex, oldBlock.InsertCountB, newIndex, newBlock.InsertCountB, changeType)); @@ -202,7 +201,7 @@ private List CreateThreeWayDiffBlocks(IReadOnlyList b { // Only old has change var oldBlock = baseToOld.DiffBlocks[oldBlockIndex]; - blocks.Add(new ThreeWayDiffBlock(baseIndex, oldBlock.DeleteCountA, + blocks.Add(new ThreeWayDiffBlock(baseIndex, oldBlock.DeleteCountA, oldIndex, oldBlock.InsertCountB, newIndex, oldBlock.DeleteCountA, ThreeWayChangeType.OldOnly)); @@ -216,7 +215,7 @@ private List CreateThreeWayDiffBlocks(IReadOnlyList b { // Only new has change var newBlock = baseToNew.DiffBlocks[newBlockIndex]; - blocks.Add(new ThreeWayDiffBlock(baseIndex, newBlock.DeleteCountA, + blocks.Add(new ThreeWayDiffBlock(baseIndex, newBlock.DeleteCountA, oldIndex, newBlock.DeleteCountA, newIndex, newBlock.InsertCountB, ThreeWayChangeType.NewOnly)); @@ -232,7 +231,7 @@ private List CreateThreeWayDiffBlocks(IReadOnlyList b var remainingCount = basePieces.Count - baseIndex; if (remainingCount > 0) { - blocks.Add(new ThreeWayDiffBlock(baseIndex, remainingCount, oldIndex, remainingCount, + blocks.Add(new ThreeWayDiffBlock(baseIndex, remainingCount, oldIndex, remainingCount, newIndex, remainingCount, ThreeWayChangeType.Unchanged)); } break; @@ -242,7 +241,7 @@ private List CreateThreeWayDiffBlocks(IReadOnlyList b return blocks; } - private ThreeWayChangeType DetermineChangeType(IReadOnlyList basePieces, + private ThreeWayChangeType DetermineChangeType(IReadOnlyList basePieces, IReadOnlyList oldPieces, IReadOnlyList newPieces, DiffBlock oldBlock, DiffBlock newBlock, bool ignoreWhiteSpace, bool ignoreCase) { @@ -257,7 +256,7 @@ private ThreeWayChangeType DetermineChangeType(IReadOnlyList basePieces, // Compare the changes var comparer = CreateStringComparer(ignoreWhiteSpace, ignoreCase); - + if (oldContent.SequenceEqual(newContent, comparer)) { return ThreeWayChangeType.BothSame; @@ -292,7 +291,7 @@ public bool Equals(string x, string y) var stringX = _ignoreWhiteSpace ? x.Trim() : x; var stringY = _ignoreWhiteSpace ? y.Trim() : y; - return string.Equals(stringX, stringY, + return string.Equals(stringX, stringY, _ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } From e6d0db489691861a1bbbb0ed1f1bdb9d71e48d59 Mon Sep 17 00:00:00 2001 From: Matthew Manela Date: Tue, 19 Aug 2025 16:21:13 -0400 Subject: [PATCH 6/9] try to fix test --- Facts.DiffPlex/ConsoleRunnerFacts.cs | 37 ++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/Facts.DiffPlex/ConsoleRunnerFacts.cs b/Facts.DiffPlex/ConsoleRunnerFacts.cs index ce65d1e5..4b2f9f09 100644 --- a/Facts.DiffPlex/ConsoleRunnerFacts.cs +++ b/Facts.DiffPlex/ConsoleRunnerFacts.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Text; using System.Threading.Tasks; using Xunit; @@ -15,8 +17,39 @@ public ConsoleRunnerFacts() { // Find the console runner executable var baseDir = AppDomain.CurrentDomain.BaseDirectory; - var consoleRunnerDir = Path.Combine(baseDir, "..", "..", "..", "..", "DiffPlex.ConsoleRunner", "bin", "Debug", "net6.0"); - _consoleRunnerPath = Path.Combine(consoleRunnerDir, "DiffPlex.ConsoleRunner.dll"); + + // Try multiple possible paths for the console runner + var targetFrameworks = new[] { "net6.0", "net7.0", "net8.0", "net9.0" }; + var buildConfigs = new[] { "Debug", "Release" }; + var relativePaths = new[] + { + Path.Combine("..", "..", "..", "..", "DiffPlex.ConsoleRunner", "bin"), + Path.Combine("..", "..", "DiffPlex.ConsoleRunner", "bin"), + Path.Combine("..", "DiffPlex.ConsoleRunner"), + "" + }; + + var possiblePaths = new List(); + + // Generate all combinations of paths, configs, and frameworks + foreach (var relativePath in relativePaths) + { + foreach (var config in buildConfigs) + { + foreach (var framework in targetFrameworks) + { + var path = Path.Combine(baseDir, relativePath, config, framework, "DiffPlex.ConsoleRunner.dll"); + possiblePaths.Add(path); + } + } + + // Also try without config/framework subfolders (CI scenarios) + var simplePath = Path.Combine(baseDir, relativePath, "DiffPlex.ConsoleRunner.dll"); + possiblePaths.Add(simplePath); + } + + _consoleRunnerPath = possiblePaths.FirstOrDefault(File.Exists) + ?? throw new InvalidOperationException($"Could not find DiffPlex.ConsoleRunner.dll in any of the expected locations. Base directory: {baseDir}"); } [Fact] From 74f6a20c641d5ad261f6f0ac4340d499e37e8fa8 Mon Sep 17 00:00:00 2001 From: Matthew Manela Date: Tue, 19 Aug 2025 16:27:21 -0400 Subject: [PATCH 7/9] more --- Facts.DiffPlex/ConsoleRunnerFacts.cs | 16 +++++++- README.md | 58 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/Facts.DiffPlex/ConsoleRunnerFacts.cs b/Facts.DiffPlex/ConsoleRunnerFacts.cs index 4b2f9f09..4df1bac8 100644 --- a/Facts.DiffPlex/ConsoleRunnerFacts.cs +++ b/Facts.DiffPlex/ConsoleRunnerFacts.cs @@ -14,6 +14,11 @@ public class ConsoleRunnerFacts private readonly string _consoleRunnerPath; public ConsoleRunnerFacts() + { + _consoleRunnerPath = FindConsoleRunnerPath(); + } + + private static string FindConsoleRunnerPath() { // Find the console runner executable var baseDir = AppDomain.CurrentDomain.BaseDirectory; @@ -48,8 +53,15 @@ public ConsoleRunnerFacts() possiblePaths.Add(simplePath); } - _consoleRunnerPath = possiblePaths.FirstOrDefault(File.Exists) - ?? throw new InvalidOperationException($"Could not find DiffPlex.ConsoleRunner.dll in any of the expected locations. Base directory: {baseDir}"); + var foundPath = possiblePaths.FirstOrDefault(File.Exists); + + if (foundPath == null) + { + var searchedPaths = string.Join("\n", possiblePaths.Select((path, i) => $" {i + 1}. {path}")); + throw new InvalidOperationException($"Could not find DiffPlex.ConsoleRunner.dll in any of the expected locations.\nBase directory: {baseDir}\nSearched paths:\n{searchedPaths}"); + } + + return foundPath; } [Fact] 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 From 4d89b3da3ebce706ebe755b4b6d53678e3180219 Mon Sep 17 00:00:00 2001 From: Matthew Manela Date: Tue, 19 Aug 2025 16:43:47 -0400 Subject: [PATCH 8/9] try again --- Facts.DiffPlex/ConsoleRunnerFacts.cs | 29 +++++++++++++--------------- Facts.DiffPlex/Facts.DiffPlex.csproj | 3 ++- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/Facts.DiffPlex/ConsoleRunnerFacts.cs b/Facts.DiffPlex/ConsoleRunnerFacts.cs index 4df1bac8..6eaf51c6 100644 --- a/Facts.DiffPlex/ConsoleRunnerFacts.cs +++ b/Facts.DiffPlex/ConsoleRunnerFacts.cs @@ -20,39 +20,36 @@ public ConsoleRunnerFacts() private static string FindConsoleRunnerPath() { - // Find the console runner executable + // Since we added a project reference, the console runner should be built alongside this project var baseDir = AppDomain.CurrentDomain.BaseDirectory; - // Try multiple possible paths for the console runner + // Try multiple .NET versions and build configurations var targetFrameworks = new[] { "net6.0", "net7.0", "net8.0", "net9.0" }; var buildConfigs = new[] { "Debug", "Release" }; - var relativePaths = new[] + var basePaths = new[] { - Path.Combine("..", "..", "..", "..", "DiffPlex.ConsoleRunner", "bin"), - Path.Combine("..", "..", "DiffPlex.ConsoleRunner", "bin"), - Path.Combine("..", "DiffPlex.ConsoleRunner"), - "" + Path.Combine(baseDir, "..", "..", "..", "..", "DiffPlex.ConsoleRunner", "bin"), + Path.Combine(baseDir, "..", "..", "DiffPlex.ConsoleRunner", "bin") }; var possiblePaths = new List(); - - // Generate all combinations of paths, configs, and frameworks - foreach (var relativePath in relativePaths) + + // Standard build output locations with different frameworks + foreach (var basePath in basePaths) { foreach (var config in buildConfigs) { foreach (var framework in targetFrameworks) { - var path = Path.Combine(baseDir, relativePath, config, framework, "DiffPlex.ConsoleRunner.dll"); - possiblePaths.Add(path); + possiblePaths.Add(Path.Combine(basePath, config, framework, "DiffPlex.ConsoleRunner.dll")); } } - - // Also try without config/framework subfolders (CI scenarios) - var simplePath = Path.Combine(baseDir, relativePath, "DiffPlex.ConsoleRunner.dll"); - possiblePaths.Add(simplePath); } + // CI build patterns (no framework subfolder) + possiblePaths.Add(Path.Combine(baseDir, "DiffPlex.ConsoleRunner.dll")); + possiblePaths.Add(Path.Combine(baseDir, "..", "DiffPlex.ConsoleRunner", "DiffPlex.ConsoleRunner.dll")); + var foundPath = possiblePaths.FirstOrDefault(File.Exists); if (foundPath == null) diff --git a/Facts.DiffPlex/Facts.DiffPlex.csproj b/Facts.DiffPlex/Facts.DiffPlex.csproj index da7a723e..b80e2798 100644 --- a/Facts.DiffPlex/Facts.DiffPlex.csproj +++ b/Facts.DiffPlex/Facts.DiffPlex.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -13,6 +13,7 @@ + From b520e52375435b26ed74c2db11f14b9be436f66c Mon Sep 17 00:00:00 2001 From: Matthew Manela Date: Tue, 19 Aug 2025 17:03:00 -0400 Subject: [PATCH 9/9] fix --- DiffPlex.ConsoleRunner/Program.cs | 4 +- Facts.DiffPlex/ConsoleRunnerFacts.cs | 208 ++++++++++----------------- 2 files changed, 79 insertions(+), 133 deletions(-) diff --git a/DiffPlex.ConsoleRunner/Program.cs b/DiffPlex.ConsoleRunner/Program.cs index 8b6f80d2..c5a2a7bb 100644 --- a/DiffPlex.ConsoleRunner/Program.cs +++ b/DiffPlex.ConsoleRunner/Program.cs @@ -10,9 +10,9 @@ namespace DiffPlex.ConsoleRunner; -internal static class Program +public static class Program { - private static int Main(string[] args) + public static int Main(string[] args) { if (args.Length < 3) { diff --git a/Facts.DiffPlex/ConsoleRunnerFacts.cs b/Facts.DiffPlex/ConsoleRunnerFacts.cs index 6eaf51c6..6bdc1fe0 100644 --- a/Facts.DiffPlex/ConsoleRunnerFacts.cs +++ b/Facts.DiffPlex/ConsoleRunnerFacts.cs @@ -1,70 +1,17 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; using System.IO; -using System.Linq; using System.Text; -using System.Threading.Tasks; using Xunit; namespace Facts.DiffPlex { public class ConsoleRunnerFacts { - private readonly string _consoleRunnerPath; - - public ConsoleRunnerFacts() - { - _consoleRunnerPath = FindConsoleRunnerPath(); - } - - private static string FindConsoleRunnerPath() - { - // Since we added a project reference, the console runner should be built alongside this project - var baseDir = AppDomain.CurrentDomain.BaseDirectory; - - // Try multiple .NET versions and build configurations - var targetFrameworks = new[] { "net6.0", "net7.0", "net8.0", "net9.0" }; - var buildConfigs = new[] { "Debug", "Release" }; - var basePaths = new[] - { - Path.Combine(baseDir, "..", "..", "..", "..", "DiffPlex.ConsoleRunner", "bin"), - Path.Combine(baseDir, "..", "..", "DiffPlex.ConsoleRunner", "bin") - }; - - var possiblePaths = new List(); - - // Standard build output locations with different frameworks - foreach (var basePath in basePaths) - { - foreach (var config in buildConfigs) - { - foreach (var framework in targetFrameworks) - { - possiblePaths.Add(Path.Combine(basePath, config, framework, "DiffPlex.ConsoleRunner.dll")); - } - } - } - - // CI build patterns (no framework subfolder) - possiblePaths.Add(Path.Combine(baseDir, "DiffPlex.ConsoleRunner.dll")); - possiblePaths.Add(Path.Combine(baseDir, "..", "DiffPlex.ConsoleRunner", "DiffPlex.ConsoleRunner.dll")); - - var foundPath = possiblePaths.FirstOrDefault(File.Exists); - - if (foundPath == null) - { - var searchedPaths = string.Join("\n", possiblePaths.Select((path, i) => $" {i + 1}. {path}")); - throw new InvalidOperationException($"Could not find DiffPlex.ConsoleRunner.dll in any of the expected locations.\nBase directory: {baseDir}\nSearched paths:\n{searchedPaths}"); - } - - return foundPath; - } [Fact] - public async Task Will_show_usage_when_no_arguments_provided() + public void Will_show_usage_when_no_arguments_provided() { - var result = await RunConsoleRunner(); + var result = RunConsoleRunner(); Assert.Equal(1, result.ExitCode); Assert.Contains("DiffPlex.ConsoleRunner", result.Output); @@ -72,27 +19,27 @@ public async Task Will_show_usage_when_no_arguments_provided() } [Fact] - public async Task Will_show_usage_when_insufficient_arguments_provided() + public void Will_show_usage_when_insufficient_arguments_provided() { - var result = await RunConsoleRunner("file", "onefile.txt"); + var result = RunConsoleRunner("file", "onefile.txt"); Assert.Equal(1, result.ExitCode); Assert.Contains("Usage:", result.Output); } [Fact] - public async Task Will_handle_unknown_command() + public void Will_handle_unknown_command() { - var result = await RunConsoleRunner("unknown", "arg1", "arg2"); + var result = RunConsoleRunner("unknown", "arg1", "arg2"); Assert.Equal(1, result.ExitCode); Assert.Contains("Unknown command: unknown", result.Output); } [Fact] - public async Task Will_compare_two_text_strings() + public void Will_compare_two_text_strings() { - var result = await RunConsoleRunner("text", "line1\\nline2", "line1\\nline3"); + var result = RunConsoleRunner("text", "line1\\nline2", "line1\\nline3"); Assert.Equal(0, result.ExitCode); Assert.Contains("@@ -1,2 +1,2 @@", result.Output); @@ -101,9 +48,9 @@ public async Task Will_compare_two_text_strings() } [Fact] - public async Task Will_perform_3way_text_diff() + public void Will_perform_3way_text_diff() { - var result = await RunConsoleRunner("3way-text", "base\\ncommon", "base\\nold", "base\\nnew"); + var result = RunConsoleRunner("3way-text", "base\\ncommon", "base\\nold", "base\\nnew"); Assert.Equal(0, result.ExitCode); Assert.Contains("Three-Way Diff", result.Output); @@ -113,27 +60,27 @@ public async Task Will_perform_3way_text_diff() } [Fact] - public async Task Will_perform_3way_text_diff_with_ignore_case() + public void Will_perform_3way_text_diff_with_ignore_case() { - var result = await RunConsoleRunner("3way-text", "-i", "BASE\\ncommon", "base\\nold", "BASE\\nnew"); + var result = RunConsoleRunner("3way-text", "-i", "BASE\\ncommon", "base\\nold", "BASE\\nnew"); Assert.Equal(0, result.ExitCode); Assert.Contains("Three-Way Diff", result.Output); } [Fact] - public async Task Will_perform_3way_text_diff_with_ignore_whitespace() + public void Will_perform_3way_text_diff_with_ignore_whitespace() { - var result = await RunConsoleRunner("3way-text", "-w", "base \\ncommon", "base\\nold", "base\\nnew"); + var result = RunConsoleRunner("3way-text", "-w", "base \\ncommon", "base\\nold", "base\\nnew"); Assert.Equal(0, result.ExitCode); Assert.Contains("Three-Way Diff", result.Output); } [Fact] - public async Task Will_perform_merge_text_without_conflicts() + public void Will_perform_merge_text_without_conflicts() { - var result = await RunConsoleRunner("merge-text", "base\\ncommon", "base\\nold", "new\\ncommon"); + var result = RunConsoleRunner("merge-text", "base\\ncommon", "base\\nold", "new\\ncommon"); Assert.Equal(0, result.ExitCode); Assert.Contains("new", result.Output); @@ -141,9 +88,9 @@ public async Task Will_perform_merge_text_without_conflicts() } [Fact] - public async Task Will_perform_merge_text_with_conflicts() + public void Will_perform_merge_text_with_conflicts() { - var result = await RunConsoleRunner("merge-text", "base\\ncommon", "old\\ncommon", "new\\ncommon"); + var result = RunConsoleRunner("merge-text", "base\\ncommon", "old\\ncommon", "new\\ncommon"); Assert.Equal(1, result.ExitCode); Assert.Contains("<<<<<<< old", result.Output); @@ -153,69 +100,69 @@ public async Task Will_perform_merge_text_with_conflicts() } [Fact] - public async Task Will_perform_merge_text_with_ignore_case() + public void Will_perform_merge_text_with_ignore_case() { - var result = await RunConsoleRunner("merge-text", "-i", "BASE\\ncommon", "base\\nold", "BASE\\nnew"); + var result = RunConsoleRunner("merge-text", "-i", "BASE\\ncommon", "base\\nold", "BASE\\nnew"); // This should still be a conflict because the content is different (old vs new) Assert.Equal(1, result.ExitCode); } [Fact] - public async Task Will_perform_merge_text_with_ignore_whitespace() + public void Will_perform_merge_text_with_ignore_whitespace() { - var result = await RunConsoleRunner("merge-text", "-w", "base \\ncommon", "base\\nold", "base\\nnew"); + var result = RunConsoleRunner("merge-text", "-w", "base \\ncommon", "base\\nold", "new\\ncommon"); Assert.Equal(0, result.ExitCode); } [Fact] - public async Task Will_handle_file_not_found_for_3way_file() + public void Will_handle_file_not_found_for_3way_file() { - var result = await RunConsoleRunner("3way-file", "nonexistent1.txt", "nonexistent2.txt", "nonexistent3.txt"); + var result = RunConsoleRunner("3way-file", "nonexistent1.txt", "nonexistent2.txt", "nonexistent3.txt"); Assert.Equal(1, result.ExitCode); Assert.Contains("not found", result.Output); } [Fact] - public async Task Will_handle_file_not_found_for_merge_file() + public void Will_handle_file_not_found_for_merge_file() { - var result = await RunConsoleRunner("merge-file", "nonexistent1.txt", "nonexistent2.txt", "nonexistent3.txt"); + var result = RunConsoleRunner("merge-file", "nonexistent1.txt", "nonexistent2.txt", "nonexistent3.txt"); Assert.Equal(1, result.ExitCode); Assert.Contains("not found", result.Output); } [Fact] - public async Task Will_handle_insufficient_arguments_for_3way_commands() + public void Will_handle_insufficient_arguments_for_3way_commands() { - var result = await RunConsoleRunner("3way-text", "base", "old"); + var result = RunConsoleRunner("3way-text", "base", "old"); Assert.Equal(1, result.ExitCode); Assert.Contains("requires base, old, and new", result.Output); } [Fact] - public async Task Will_handle_insufficient_arguments_for_merge_commands() + public void Will_handle_insufficient_arguments_for_merge_commands() { - var result = await RunConsoleRunner("merge-text", "base", "old"); + var result = RunConsoleRunner("merge-text", "base", "old"); Assert.Equal(1, result.ExitCode); Assert.Contains("requires base, old, and new", result.Output); } [Fact] - public async Task Will_handle_unknown_option() + public void Will_handle_unknown_option() { - var result = await RunConsoleRunner("text", "-x", "old", "new"); + var result = RunConsoleRunner("text", "-x", "old", "new"); Assert.Equal(1, result.ExitCode); Assert.Contains("Unknown option: -x", result.Output); } [Fact] - public async Task Will_work_with_file_commands() + public void Will_work_with_file_commands() { // Create temporary files var tempDir = Path.GetTempPath(); @@ -224,10 +171,10 @@ public async Task Will_work_with_file_commands() try { - await File.WriteAllTextAsync(oldFile, "line1\nline2\n"); - await File.WriteAllTextAsync(newFile, "line1\nline3\n"); + File.WriteAllText(oldFile, "line1\nline2\n"); + File.WriteAllText(newFile, "line1\nline3\n"); - var result = await RunConsoleRunner("file", oldFile, newFile); + var result = RunConsoleRunner("file", oldFile, newFile); Assert.Equal(0, result.ExitCode); Assert.Contains("@@ -1,", result.Output); @@ -243,7 +190,7 @@ public async Task Will_work_with_file_commands() } [Fact] - public async Task Will_work_with_3way_file_commands() + public void Will_work_with_3way_file_commands() { // Create temporary files var tempDir = Path.GetTempPath(); @@ -253,11 +200,11 @@ public async Task Will_work_with_3way_file_commands() try { - await File.WriteAllTextAsync(baseFile, "common\nbase\n"); - await File.WriteAllTextAsync(oldFile, "common\nold\n"); - await File.WriteAllTextAsync(newFile, "common\nnew\n"); + File.WriteAllText(baseFile, "common\nbase\n"); + File.WriteAllText(oldFile, "common\nold\n"); + File.WriteAllText(newFile, "common\nnew\n"); - var result = await RunConsoleRunner("3way-file", baseFile, oldFile, newFile); + var result = RunConsoleRunner("3way-file", baseFile, oldFile, newFile); Assert.Equal(0, result.ExitCode); Assert.Contains("Three-Way Diff", result.Output); @@ -273,7 +220,7 @@ public async Task Will_work_with_3way_file_commands() } [Fact] - public async Task Will_work_with_merge_file_commands() + public void Will_work_with_merge_file_commands() { // Create temporary files var tempDir = Path.GetTempPath(); @@ -283,11 +230,11 @@ public async Task Will_work_with_merge_file_commands() try { - await File.WriteAllTextAsync(baseFile, "common\nbase\n"); - await File.WriteAllTextAsync(oldFile, "common\nold\n"); - await File.WriteAllTextAsync(newFile, "updated\nbase\n"); + File.WriteAllText(baseFile, "common\nbase\n"); + File.WriteAllText(oldFile, "common\nold\n"); + File.WriteAllText(newFile, "updated\nbase\n"); - var result = await RunConsoleRunner("merge-file", baseFile, oldFile, newFile); + var result = RunConsoleRunner("merge-file", baseFile, oldFile, newFile); Assert.Equal(0, result.ExitCode); Assert.Contains("updated", result.Output); @@ -302,43 +249,42 @@ public async Task Will_work_with_merge_file_commands() } } - private async Task RunConsoleRunner(params string[] args) + private static ConsoleResult RunConsoleRunner(params string[] args) { - var processInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = $"\"{_consoleRunnerPath}\" {string.Join(" ", args)}", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = new Process { StartInfo = processInfo }; + var output = new StringBuilder(); + var originalOut = Console.Out; + var originalError = Console.Error; - var outputBuilder = new StringBuilder(); - var errorBuilder = new StringBuilder(); - - process.OutputDataReceived += (sender, e) => { if (e.Data != null) outputBuilder.AppendLine(e.Data); }; - process.ErrorDataReceived += (sender, e) => { if (e.Data != null) errorBuilder.AppendLine(e.Data); }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - await process.WaitForExitAsync(); - - var output = outputBuilder.ToString(); - var error = errorBuilder.ToString(); - - return new ProcessResult + try + { + using var writer = new StringWriter(output); + Console.SetOut(writer); + Console.SetError(writer); + + var exitCode = global::DiffPlex.ConsoleRunner.Program.Main(args); + + return new ConsoleResult + { + ExitCode = exitCode, + Output = output.ToString() + }; + } + catch (Exception ex) { - ExitCode = process.ExitCode, - Output = string.IsNullOrEmpty(error) ? output : output + error - }; + return new ConsoleResult + { + ExitCode = 1, + Output = $"Error: {ex.Message}" + }; + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + } } - private class ProcessResult + private class ConsoleResult { public int ExitCode { get; set; } public string Output { get; set; } = string.Empty;