From f622dd34c9ebf0c2352c66dc0a1f37b7c2b0a902 Mon Sep 17 00:00:00 2001 From: Andrew Chisholm Date: Mon, 15 Jul 2024 23:14:33 +1000 Subject: [PATCH 1/2] Patch anything, working but somehow broke unit tests... TBD --- README.md | 77 ++++- src/Compare/CompatibilityExtensions.cs | 2 +- src/Compare/Delta.cs | 180 ++++++++++ src/Compare/DiffAlgorithmExtensions.cs | 174 +--------- src/Compare/Operation.cs | 9 +- ...orithmExtensions.cs => PatchExtensions.cs} | 317 +----------------- src/Compare/Patches.cs | 307 +++++++++++++++++ src/Compare/{Patch.cs => Patch{T}.cs} | 45 ++- src/RopeExtensions.cs | 4 +- tests/DiffAnything.cs | 47 ++- tests/DiffMatchPatchTests.cs | 196 +++++------ 11 files changed, 751 insertions(+), 607 deletions(-) create mode 100644 src/Compare/Delta.cs rename src/Compare/{PatchAlgorithmExtensions.cs => PatchExtensions.cs} (66%) create mode 100644 src/Compare/Patches.cs rename src/Compare/{Patch.cs => Patch{T}.cs} (69%) diff --git a/README.md b/README.md index 7ae2263..1287601 100644 --- a/README.md +++ b/README.md @@ -53,16 +53,79 @@ Rope ropeOfPeople = [new Person("Frank"), new Person("Jane")]; // Makes ``` ## In built support for Diffing and Patching -### Example Usage +### Compare two strings . ```csharp -// Compare two ropes using the DiffMatchPatch algorithm (Google). -Rope> diffs = "abcdef".ToRope().Diff("abefg"); -Rope originalText = diffs.ToSource(); -Rope updatedText = diffs.ToTarget(); +Rope sourceText = "abcdef"; +Rope targetText = "abefg"; + +// Create a list of differences. +Rope> diffs = sourceText.Diff(targetText); + +// Recover either side from the list of differences. +Rope recoveredSourceText = diffs.ToSource(); +Rope recoveredTargetText = diffs.ToTarget(); // Create a Patch string (like Git's patch text) -var patches = diffs.ToPatches(); // A rope of patches -var patchText = patches.ToPatchText(); // A single string of patch text. +Rope> patches = diffs.ToPatches(); // A list of patches +Rope patchText = patches.ToPatchString(); // A single string of patch text. +Console.WriteLine(patchText); +/** Outputs: +@@ -1,6 +1,5 @@ + ab +-cd + ef ++g +*/ + +// Parse out the list of patches from the patch text. +Rope> parsedPatches = Patches.Parse(patchText); +``` + +```csharp +// Create diffs of lists of any type +Rope original = +[ + new Person("Stephen", "King"), + new Person("Jane", "Austen"), + new Person("Mary", "Shelley"), + new Person("JRR", "Tokien"), + new Person("James", "Joyce"), +]; + +Rope updated = +[ + new Person("Stephen", "King"), + new Person("Jane", "Austen"), + new Person("JRR", "Tokien"), + new Person("Frank", "Miller"), + new Person("George", "Orwell"), + new Person("James", "Joyce"), +]; + +Rope> changes = original.Diff(updated, DiffOptions.Default); +Assert.AreEqual(2, changes.Count(d => d.Operation != Operation.Equal)); + +// Convert to a Delta string +Rope delta = changes.ToDelta(p => p.ToString()); + +// Rebuild the diff from the original list and a delta. +Rope> fromDelta = Delta.Parse(delta, original, Person.Parse); + +// Get back the original list +Assert.AreEqual(fromDelta.ToSource(), original); + +// Get back the updated list. +Assert.AreEqual(fromDelta.ToTarget(), updated); + +// Make a patch text +Rope> patches = fromDelta.ToPatches(); + +// Convert patches to text +Rope patchText = patches.ToPatchString(p => p.ToString()); + +// Parse the patches back again +Rope> parsedPatches = Patches.Parse(patchText.ToRope(), Person.Parse); +Assert.AreEqual(parsedPatches, patches); ``` ## Comparison with .NET Built in Types diff --git a/src/Compare/CompatibilityExtensions.cs b/src/Compare/CompatibilityExtensions.cs index 19b938e..7e64cce 100644 --- a/src/Compare/CompatibilityExtensions.cs +++ b/src/Compare/CompatibilityExtensions.cs @@ -131,7 +131,7 @@ public static Rope DiffEncode(this Rope text) => /// The function to convert an item to a string. /// The encoded string. [Pure] - public static Rope DiffEncode(this Rope items, Func> itemToString, char separator = '~') where T : IEquatable => + public static Rope DiffEncode(this Rope items, Func> itemToString, char separator = '~') where T : IEquatable => DiffEncoder.Encode(items.Select(i => itemToString(i)).Join(separator).ToString()).Replace("%2B", "+", StringComparison.OrdinalIgnoreCase).ToRope(); /// diff --git a/src/Compare/Delta.cs b/src/Compare/Delta.cs new file mode 100644 index 0000000..4481af1 --- /dev/null +++ b/src/Compare/Delta.cs @@ -0,0 +1,180 @@ + +namespace Rope.Compare; + +using System; +using System.Diagnostics.Contracts; + +public static class Delta +{ + /// + /// Computes the full diff, given an encoded string which describes the operations required + /// to transform source list into destination list (the delta), + /// and the original list the delta was created from. + /// + /// The delta text to be parsed. + /// Original list the delta was created from and the diffs will be applied to. + /// Rope of Diff objects or null if invalid. + /// If invalid input. + [Pure] + public static Rope> Parse(this Rope delta, Rope original) + { + var diffs = Rope>.Empty; + + // Cursor in text1 + int pointer = 0; + var tokens = delta.Split('\t'); + foreach (var token in tokens) + { + if (token.Length == 0) + { + // Blank tokens are ok (from a trailing \t). + continue; + } + + // Each token begins with a one character parameter which specifies the + // operation of this token (delete, insert, equality). + var param = token.Slice(1); + switch (token[0]) + { + case '+': + param = param.DiffDecode(); + diffs = diffs.Add(new Diff(Operation.Insert, param)); + break; + case '-': + // Fall through. + case '=': + int n; + try + { + n = Convert.ToInt32(param.ToString()); + } + catch (FormatException e) + { + throw new ArgumentException(("Invalid number in diff_fromDelta: " + param).ToString(), e); + } + if (n < 0) + { + throw new ArgumentException(("Negative number in diff_fromDelta: " + param).ToString()); + } + + Rope text; + try + { + text = original.Slice(pointer, n); + pointer += n; + } + catch (ArgumentOutOfRangeException e) + { + throw new ArgumentException($"Delta length ({pointer}) larger than source text length ({original.Length}).", e); + } + + if (token[0] == '=') + { + diffs = diffs.Add(new Diff(Operation.Equal, text)); + } + else + { + diffs = diffs.Add(new Diff(Operation.Delete, text)); + } + + break; + default: + // Anything else is an error. + throw new ArgumentException($"Invalid diff operation in diff_fromDelta: {token[0]}"); + } + } + + if (pointer != original.Length) + { + throw new ArgumentException("Delta length (" + pointer + ") smaller than source text length (" + original.Length + ")."); + } + + return diffs; + } + + /// + /// Computes the full diff, given an encoded string which describes the operations required + /// to transform source list into destination list (the delta), + /// and the original list the delta was created from. + /// + /// The delta text to be parsed. + /// Original list the delta was created from and the diffs will be applied to. + /// Rope of Diff objects or null if invalid. + /// If invalid input. + [Pure] + public static Rope> Parse(this Rope delta, Rope original, Func, T> parseItem) where T : IEquatable + { + var diffs = Rope>.Empty; + + // Cursor in text1 + int pointer = 0; + var tokens = delta.Split('\t'); + foreach (var token in tokens) + { + if (token.Length == 0) + { + // Blank tokens are ok (from a trailing \t). + continue; + } + + // Each token begins with a one character parameter which specifies the + // operation of this token (delete, insert, equality). + var param = token.Slice(1); + switch (token[0]) + { + case '+': + var x = param.DiffDecode(parseItem); + diffs = diffs.Add(new Diff(Operation.Insert, x)); + break; + case '-': + // Fall through. + case '=': + int n; + try + { + n = Convert.ToInt32(param.ToString()); + } + catch (FormatException e) + { + throw new ArgumentException(("Invalid number in diff_fromDelta: " + param).ToString(), e); + } + if (n < 0) + { + throw new ArgumentException(("Negative number in diff_fromDelta: " + param).ToString()); + } + + Rope text; + try + { + text = original.Slice(pointer, n); + pointer += n; + } + catch (ArgumentOutOfRangeException e) + { + throw new ArgumentException($"Delta length ({pointer}) larger than source text length ({original.Length}).", e); + } + + if (token[0] == '=') + { + diffs = diffs.Add(new Diff(Operation.Equal, text)); + } + else + { + diffs = diffs.Add(new Diff(Operation.Delete, text)); + } + + break; + default: + // Anything else is an error. + throw new ArgumentException($"Invalid diff operation in diff_fromDelta: {token[0]}"); + } + } + + if (pointer != original.Length) + { + throw new ArgumentException("Delta length (" + pointer + ") smaller than source text length (" + original.Length + ")."); + } + + return diffs; + } +} diff --git a/src/Compare/DiffAlgorithmExtensions.cs b/src/Compare/DiffAlgorithmExtensions.cs index 22ea3f9..2fe9f53 100644 --- a/src/Compare/DiffAlgorithmExtensions.cs +++ b/src/Compare/DiffAlgorithmExtensions.cs @@ -73,7 +73,7 @@ public static Rope> Diff(this string sourceText, string targetText, D /// And what constitutes a maximum edit cost . /// Defaults to use when is , /// otherwise uses . - /// List of Diff objects. + /// List of describing the list of elements that were equal, added or removed. [Pure] public static Rope> Diff(this Rope first, Rope second, DiffOptions? options = null) where T : IEquatable { @@ -140,178 +140,6 @@ public static Rope> Diff(this Rope first, Rope second, DiffOpti return DiffCleanupMerge(diffs); } - /// - /// Computes the full diff, given an encoded string which describes the operations required - /// to transform source list into destination list (the delta), - /// and the original list the delta was created from. - /// - /// The delta text to be parsed. - /// Original list the delta was created from and the diffs will be applied to. - /// Rope of Diff objects or null if invalid. - /// If invalid input. - [Pure] - public static Rope> ParseDelta(this Rope delta, Rope original, Func, T> parseItem) where T : IEquatable - { - var diffs = Rope>.Empty; - - // Cursor in text1 - int pointer = 0; - var tokens = delta.Split('\t'); - foreach (var token in tokens) - { - if (token.Length == 0) - { - // Blank tokens are ok (from a trailing \t). - continue; - } - - // Each token begins with a one character parameter which specifies the - // operation of this token (delete, insert, equality). - var param = token.Slice(1); - switch (token[0]) - { - case '+': - var x = param.DiffDecode(parseItem); - diffs = diffs.Add(new Diff(Operation.Insert, x)); - break; - case '-': - // Fall through. - case '=': - int n; - try - { - n = Convert.ToInt32(param.ToString()); - } - catch (FormatException e) - { - throw new ArgumentException(("Invalid number in diff_fromDelta: " + param).ToString(), e); - } - if (n < 0) - { - throw new ArgumentException(("Negative number in diff_fromDelta: " + param).ToString()); - } - - Rope text; - try - { - text = original.Slice(pointer, n); - pointer += n; - } - catch (ArgumentOutOfRangeException e) - { - throw new ArgumentException($"Delta length ({pointer}) larger than source text length ({original.Length}).", e); - } - - if (token[0] == '=') - { - diffs = diffs.Add(new Diff(Operation.Equal, text)); - } - else - { - diffs = diffs.Add(new Diff(Operation.Delete, text)); - } - - break; - default: - // Anything else is an error. - throw new ArgumentException($"Invalid diff operation in diff_fromDelta: {token[0]}"); - } - } - - if (pointer != original.Length) - { - throw new ArgumentException("Delta length (" + pointer + ") smaller than source text length (" + original.Length + ")."); - } - - return diffs; - } - - /// - /// Computes the full diff, given an encoded string which describes the operations required - /// to transform source list into destination list (the delta), - /// and the original list the delta was created from. - /// - /// The delta text to be parsed. - /// Original list the delta was created from and the diffs will be applied to. - /// Rope of Diff objects or null if invalid. - /// If invalid input. - [Pure] - public static Rope> ParseDelta(this Rope delta, Rope original) - { - var diffs = Rope>.Empty; - - // Cursor in text1 - int pointer = 0; - var tokens = delta.Split('\t'); - foreach (var token in tokens) - { - if (token.Length == 0) - { - // Blank tokens are ok (from a trailing \t). - continue; - } - - // Each token begins with a one character parameter which specifies the - // operation of this token (delete, insert, equality). - var param = token.Slice(1); - switch (token[0]) - { - case '+': - param = param.DiffDecode(); - diffs = diffs.Add(new Diff(Operation.Insert, param)); - break; - case '-': - // Fall through. - case '=': - int n; - try - { - n = Convert.ToInt32(param.ToString()); - } - catch (FormatException e) - { - throw new ArgumentException(("Invalid number in diff_fromDelta: " + param).ToString(), e); - } - if (n < 0) - { - throw new ArgumentException(("Negative number in diff_fromDelta: " + param).ToString()); - } - - Rope text; - try - { - text = original.Slice(pointer, n); - pointer += n; - } - catch (ArgumentOutOfRangeException e) - { - throw new ArgumentException($"Delta length ({pointer}) larger than source text length ({original.Length}).", e); - } - - if (token[0] == '=') - { - diffs = diffs.Add(new Diff(Operation.Equal, text)); - } - else - { - diffs = diffs.Add(new Diff(Operation.Delete, text)); - } - - break; - default: - // Anything else is an error. - throw new ArgumentException($"Invalid diff operation in diff_fromDelta: {token[0]}"); - } - } - - if (pointer != original.Length) - { - throw new ArgumentException("Delta length (" + pointer + ") smaller than source text length (" + original.Length + ")."); - } - - return diffs; - } - /// /// Find the differences between two texts. Assumes that the texts do not /// have any common prefix or suffix. diff --git a/src/Compare/Operation.cs b/src/Compare/Operation.cs index cc0a561..a970edc 100644 --- a/src/Compare/Operation.cs +++ b/src/Compare/Operation.cs @@ -21,12 +21,9 @@ */ namespace Rope.Compare; -/**- - * The data structure representing a diff is a List of Diff objects: - * {Diff(Operation.DELETE, "Hello"), Diff(Operation.INSERT, "Goodbye"), - * Diff(Operation.EQUAL, " world.")} - * which means: delete "Hello", add "Goodbye" and keep " world." - */ +/// +/// Enumeration of the possible diff operations between two lists. +/// public enum Operation { /// diff --git a/src/Compare/PatchAlgorithmExtensions.cs b/src/Compare/PatchExtensions.cs similarity index 66% rename from src/Compare/PatchAlgorithmExtensions.cs rename to src/Compare/PatchExtensions.cs index 4270efa..e8947f6 100644 --- a/src/Compare/PatchAlgorithmExtensions.cs +++ b/src/Compare/PatchExtensions.cs @@ -24,307 +24,25 @@ namespace Rope.Compare; using System; using System.Diagnostics.Contracts; -using System.Text; -using System.Text.RegularExpressions; -using System.Web; -public static partial class PatchAlgorithmExtensions +public static class PatchExtensions { - private static readonly Regex PatchHeaderPattern = CreatePatchHeaderPattern(); - - [GeneratedRegex("^@@ -(\\d+),?(\\d*) \\+(\\d+),?(\\d*) @@$")] - private static partial Regex CreatePatchHeaderPattern(); - - /// - /// Parse a textual representation of patches and return a List of Patch - /// objects. - /// - /// Text representation of patches. - /// List of Patch objects. - /// Thrown if invalid input. - //public static Rope> ParsePatchText(this Rope patchText, Func, T> parseItem, char separator = '~') where T : IEquatable - //{ - // var patches = Rope>.Empty; - // if (patchText.Length == 0) - // { - // return patches; - // } - - // var text = patchText.Split('\n').ToRope(); - // long textPointer = 0; - // Patch patch; - // Match m; - // char sign; - // Rope line = Rope.Empty; - // while (textPointer < text.Length) - // { - // var pointerText = text[textPointer].ToString(); - // m = PatchHeaderPattern.Match(pointerText); - // if (!m.Success) - // { - // throw new ArgumentException("Invalid patch string: " + pointerText); - // } - - // patch = new Patch() - // { - // Start1 = Convert.ToInt32(m.Groups[1].Value) - // }; - - // if (m.Groups[2].Length == 0) - // { - // patch = patch with { Start1 = patch.Start1 - 1, Length1 = 1 }; - // } - // else if (m.Groups[2].Value == "0") - // { - // patch = patch with { Length1 = 0 }; - // } - // else - // { - // patch = patch with { Start1 = patch.Start1 - 1, Length1 = Convert.ToInt32(m.Groups[2].Value) }; - // } - - // patch = patch with { Start2 = Convert.ToInt32(m.Groups[3].Value) }; - // if (m.Groups[4].Length == 0) - // { - // patch = patch with { Start2 = patch.Start2 - 1, Length2 = 1 }; - // } - // else if (m.Groups[4].Value == "0") - // { - // patch = patch with { Length2 = 0 }; - // } - // else - // { - // patch = patch with { Start2 = patch.Start2 - 1, Length2 = Convert.ToInt32(m.Groups[4].Value) }; - // } - - // textPointer++; - // while (textPointer < text.Length) - // { - // try - // { - // sign = text[textPointer][0]; - // } - // catch (IndexOutOfRangeException) - // { - // // Blank line? Whatever. - // textPointer++; - // continue; - // } - - // line = text[textPointer].Slice(1); - // line = line.Replace("+", "%2b"); - // line = HttpUtility.UrlDecode(line.ToString()); - // if (sign == '-') - // { - // // Deletion. - // var items = line.Split(separator).Select(i => parseItem(i)).ToRope(); - // patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Delete, items)) }; - // } - // else if (sign == '+') - // { - // // Insertion. - // var items = line.Split(separator).Select(i => parseItem(i)).ToRope(); - // patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Insert, items)) }; - // } - // else if (sign == ' ') - // { - // // Minor equality. - // var items = line.Split(separator).Select(i => parseItem(i)).ToRope(); - // patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Equal, items)) }; - // } - // else if (sign == '@') - // { - // // Start of next patch. - // break; - // } - // else - // { - // // WTF? - // throw new ArgumentException("Invalid patch mode '" + sign + "' in: " + line.ToString()); - // } - - // textPointer++; - // } - - // patches = patches.Add(patch); - - // } - - // return patches; - //} - - /// - /// Parse a textual representation of patches and return a List of Patch - /// objects. - /// - /// Text representation of patches. - /// List of Patch objects. - /// Thrown if invalid input. - public static Rope> ParsePatchText(this Rope patchText) - { - var patches = Rope>.Empty; - if (patchText.Length == 0) - { - return patches; - } - - var text = patchText.Split('\n').ToRope(); - long textPointer = 0; - Patch patch; - Match m; - char sign; - Rope line = Rope.Empty; - while (textPointer < text.Length) - { - var pointerText = text[textPointer].ToString(); - m = PatchHeaderPattern.Match(pointerText); - if (!m.Success) - { - throw new ArgumentException("Invalid patch string: " + pointerText); - } - - patch = new Patch() - { - Start1 = Convert.ToInt32(m.Groups[1].Value) - }; - - if (m.Groups[2].Length == 0) - { - patch = patch with { Start1 = patch.Start1 - 1, Length1 = 1 }; - } - else if (m.Groups[2].Value == "0") - { - patch = patch with { Length1 = 0 }; - } - else - { - patch = patch with { Start1 = patch.Start1 - 1, Length1 = Convert.ToInt32(m.Groups[2].Value) }; - } - - patch = patch with { Start2 = Convert.ToInt32(m.Groups[3].Value) }; - if (m.Groups[4].Length == 0) - { - patch = patch with { Start2 = patch.Start2 - 1, Length2 = 1 }; - } - else if (m.Groups[4].Value == "0") - { - patch = patch with { Length2 = 0 }; - } - else - { - patch = patch with { Start2 = patch.Start2 - 1, Length2 = Convert.ToInt32(m.Groups[4].Value) }; - } - - textPointer++; - while (textPointer < text.Length) - { - try - { - sign = text[textPointer][0]; - } - catch (IndexOutOfRangeException) - { - // Blank line? Whatever. - textPointer++; - continue; - } - - line = text[textPointer].Slice(1); - line = line.Replace("+", "%2b"); - line = HttpUtility.UrlDecode(line.ToString()); - if (sign == '-') - { - // Deletion. - patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Delete, line)) }; - } - else if (sign == '+') - { - // Insertion. - patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Insert, line)) }; - } - else if (sign == ' ') - { - // Minor equality. - patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Equal, line)) }; - } - else if (sign == '@') - { - // Start of next patch. - break; - } - else - { - // WTF? - throw new ArgumentException("Invalid patch mode '" + sign + "' in: " + line.ToString()); - } - - textPointer++; - } - - patches = patches.Add(patch); - - } - - return patches; - } - /// /// Take a list of patches and return a textual representation. /// /// List of Patch objects. /// Text representation of patches. - //public static string ToPatchText(this IEnumerable> patches, Func> itemToString) where T : IEquatable - //{ - // StringBuilder text = new StringBuilder(); - // foreach (var aPatch in patches) - // { - // text.Append(aPatch.ToCharRope(itemToString).ToString()); - // } - - // return text.ToString(); - //} + public static Rope ToPatchString(this IEnumerable> patches, Func> itemToString, char separator = '~') + where T : IEquatable => + patches.Select(p => p.ToCharRope(itemToString, separator)).Combine(); /// /// Take a list of patches and return a textual representation. /// /// List of Patch objects. /// Text representation of patches. - public static string ToPatchText(this IEnumerable> patches) - { - StringBuilder text = new StringBuilder(); - foreach (var aPatch in patches) - { - text.Append(aPatch); - } - - return text.ToString(); - } - - [Pure] - public static Rope> CreatePatches(this string text1, string text2, PatchOptions? patchOptions = null, DiffOptions? diffOptions = null) => CreatePatches(text1.ToRope(), text2.ToRope(), patchOptions, diffOptions); - - /// - /// Compute a list of patches to turn text1 into text2. - /// A set of diffs will be computed. - /// - /// Old text. - /// New text. - /// List of Patch objects. - [Pure] - public static Rope> CreatePatches(this Rope text1, Rope text2, PatchOptions? patchOptions, DiffOptions? diffOptions = null) where T : IEquatable - { - // No diffs provided, compute our own. - diffOptions = diffOptions ?? DiffOptions.Default; - using var deadline = diffOptions.StartTimer(); - var diffs = text1.Diff(text2, diffOptions, deadline.Cancellation); - if (diffs.Count > 2) - { - diffs = diffs.DiffCleanupSemantic(deadline.Cancellation); - diffs = diffs.DiffCleanupEfficiency(diffOptions); - } - - return text1.ToPatches(diffs, patchOptions); - } + public static Rope ToPatchString(this IEnumerable> patches) => + patches.Select(p => p.ToCharRope()).Combine(); /// /// Compute a list of patches to turn text1 into text2. @@ -333,26 +51,24 @@ public static Rope> CreatePatches(this Rope text1, Rope text2, /// List of Diff objects for text1 to text2. /// List of Patch objects. [Pure] - public static Rope> ToPatches(this Rope> diffs) where T : IEquatable + public static Rope> ToPatches(this Rope> diffs, PatchOptions? options = null) where T : IEquatable { // No origin string provided, compute our own. - var text1 = diffs.ToSource(); - return text1.ToPatches(diffs, PatchOptions.Default); + return diffs.ToPatches(diffs.ToSource(), options); } /// - /// Compute a list of patches to turn text1 into text2. - /// text2 is not provided, diffs are the delta between text1 and text2. + /// Compute a list of patches to turn into the target. + /// The target is not provided, the diffs are the delta between and the target. /// - /// Old text. - /// Sequence of Diff objects for text1 to text2. - /// Options controlling how the patches are created. + /// The original list. + /// Sequence of differences between the provided and the target. + /// Options controlling how the patches are created (optional). /// List of Patch objects. [Pure] - public static Rope> ToPatches(this Rope text1, Rope> diffs, PatchOptions? options) where T : IEquatable + public static Rope> ToPatches(this Rope> diffs, Rope source, PatchOptions? options = null) where T : IEquatable { options ??= PatchOptions.Default; - // Check for null inputs not needed since null can't be passed in C#. if (diffs.Count == 0) { return Rope>.Empty; @@ -365,8 +81,8 @@ public static Rope> ToPatches(this Rope text1, Rope> diff // Start with text1 (prepatch_text) and apply the diffs until we arrive at // text2 (postpatch_text). We recreate the patches one by one to determine // context info. - var prepatch_text = text1; - var postpatch_text = text1; + var prepatch_text = source; + var postpatch_text = source; foreach (var aDiff in diffs) { if (patch.Diffs.Count == 0 && aDiff.Operation != Operation.Equal) @@ -410,6 +126,7 @@ public static Rope> ToPatches(this Rope text1, Rope> diff result += patch; patch = new Patch(); + // Unlike Unidiff, our patch lists have a rolling context. // https://github.com/google/diff-match-patch/wiki/Unidiff // Update prepatch text & pos to reflect the application of the diff --git a/src/Compare/Patches.cs b/src/Compare/Patches.cs new file mode 100644 index 0000000..7dd4089 --- /dev/null +++ b/src/Compare/Patches.cs @@ -0,0 +1,307 @@ +/* +* Diff Match and Patch +* Copyright 2018 The diff-match-patch Authors. +* https://github.com/google/diff-match-patch +* +* Copyright 2024 Andrew Chisholm (FlatlinerDOA). +* https://github.com/FlatlinerDOA/Rope +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +*/ + +namespace Rope.Compare; + +using System; +using System.Diagnostics.Contracts; +using System.Text.RegularExpressions; +using System.Web; + +/// +/// Static functions for creating or parsing out a list of patches. +/// +public static partial class Patches +{ + private static readonly Regex PatchHeaderPattern = CreatePatchHeaderPattern(); + + [GeneratedRegex("^@@ -(\\d+),?(\\d*) \\+(\\d+),?(\\d*) @@$")] + private static partial Regex CreatePatchHeaderPattern(); + + /// + /// Parse a textual representation of patches and return a list of . + /// objects. + /// + /// The item types to be deserialized or parsed. + /// Text representation of patches. + /// List of Patch objects. + /// Thrown if invalid input. + public static Rope> Parse(Rope patchText, Func, T> parseItem, char separator = '~') where T : IEquatable + { + var patches = Rope>.Empty; + if (patchText.Length == 0) + { + return patches; + } + + var text = patchText.Split('\n').ToRope(); + long textPointer = 0; + Patch patch; + Match m; + char sign; + Rope line = Rope.Empty; + while (textPointer < text.Length) + { + var pointerText = text[textPointer].ToString(); + m = PatchHeaderPattern.Match(pointerText); + if (!m.Success) + { + throw new ArgumentException("Invalid patch string: " + pointerText); + } + + patch = new Patch() + { + Start1 = Convert.ToInt32(m.Groups[1].Value) + }; + + if (m.Groups[2].Length == 0) + { + patch = patch with { Start1 = patch.Start1 - 1, Length1 = 1 }; + } + else if (m.Groups[2].Value == "0") + { + patch = patch with { Length1 = 0 }; + } + else + { + patch = patch with { Start1 = patch.Start1 - 1, Length1 = Convert.ToInt32(m.Groups[2].Value) }; + } + + patch = patch with { Start2 = Convert.ToInt32(m.Groups[3].Value) }; + if (m.Groups[4].Length == 0) + { + patch = patch with { Start2 = patch.Start2 - 1, Length2 = 1 }; + } + else if (m.Groups[4].Value == "0") + { + patch = patch with { Length2 = 0 }; + } + else + { + patch = patch with { Start2 = patch.Start2 - 1, Length2 = Convert.ToInt32(m.Groups[4].Value) }; + } + + textPointer++; + while (textPointer < text.Length) + { + try + { + sign = text[textPointer][0]; + } + catch (IndexOutOfRangeException) + { + // Blank line? Whatever. + textPointer++; + continue; + } + + line = text[textPointer].Slice(1); + line = line.Replace("+", "%2b"); + line = HttpUtility.UrlDecode(line.ToString()); + if (sign == '-') + { + // Deletion. + var items = line.Split(separator).Select(i => parseItem(i)).ToRope(); + patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Delete, items)) }; + } + else if (sign == '+') + { + // Insertion. + var items = line.Split(separator).Select(i => parseItem(i)).ToRope(); + patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Insert, items)) }; + } + else if (sign == ' ') + { + // Minor equality. + var items = line.Split(separator).Select(i => parseItem(i)).ToRope(); + patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Equal, items)) }; + } + else if (sign == '@') + { + // Start of next patch. + break; + } + else + { + // WTF? + throw new ArgumentException("Invalid patch mode '" + sign + "' in: " + line.ToString()); + } + + textPointer++; + } + + patches = patches.Add(patch); + + } + + return patches; + } + + /// + /// Parse a textual representation of patches and return a List of Patch + /// objects. + /// + /// Text representation of patches. + /// List of Patch objects. + /// Thrown if invalid input. + public static Rope> Parse(Rope patchText) + { + var patches = Rope>.Empty; + if (patchText.Length == 0) + { + return patches; + } + + var text = patchText.Split('\n').ToRope(); + long textPointer = 0; + Patch patch; + Match m; + char sign; + Rope line = Rope.Empty; + while (textPointer < text.Length) + { + var pointerText = text[textPointer].ToString(); + m = PatchHeaderPattern.Match(pointerText); + if (!m.Success) + { + throw new ArgumentException("Invalid patch string: " + pointerText); + } + + patch = new Patch() + { + Start1 = Convert.ToInt32(m.Groups[1].Value) + }; + + if (m.Groups[2].Length == 0) + { + patch = patch with { Start1 = patch.Start1 - 1, Length1 = 1 }; + } + else if (m.Groups[2].Value == "0") + { + patch = patch with { Length1 = 0 }; + } + else + { + patch = patch with { Start1 = patch.Start1 - 1, Length1 = Convert.ToInt32(m.Groups[2].Value) }; + } + + patch = patch with { Start2 = Convert.ToInt32(m.Groups[3].Value) }; + if (m.Groups[4].Length == 0) + { + patch = patch with { Start2 = patch.Start2 - 1, Length2 = 1 }; + } + else if (m.Groups[4].Value == "0") + { + patch = patch with { Length2 = 0 }; + } + else + { + patch = patch with { Start2 = patch.Start2 - 1, Length2 = Convert.ToInt32(m.Groups[4].Value) }; + } + + textPointer++; + while (textPointer < text.Length) + { + try + { + sign = text[textPointer][0]; + } + catch (IndexOutOfRangeException) + { + // Blank line? Whatever. + textPointer++; + continue; + } + + line = text[textPointer].Slice(1); + line = line.Replace("+", "%2b"); + line = HttpUtility.UrlDecode(line.ToString()); + if (sign == '-') + { + // Deletion. + patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Delete, line)) }; + } + else if (sign == '+') + { + // Insertion. + patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Insert, line)) }; + } + else if (sign == ' ') + { + // Minor equality. + patch = patch with { Diffs = patch.Diffs.Add(new Diff(Operation.Equal, line)) }; + } + else if (sign == '@') + { + // Start of next patch. + break; + } + else + { + // WTF? + throw new ArgumentException("Invalid patch mode '" + sign + "' in: " + line.ToString()); + } + + textPointer++; + } + + patches = patches.Add(patch); + + } + + return patches; + } + + /// + /// Compute a list of patches to turn into . + /// A set of diffs will be computed. + /// + /// The original text. + /// The target text produced by applying the patches to the . + /// Rope of objects. + [Pure] + public static Rope> Create(string source, string target, PatchOptions? patchOptions = null, DiffOptions? diffOptions = null) => + Create(source.ToRope(), target.ToRope(), patchOptions, diffOptions); + + /// + /// Compute a list of patches to turn into . + /// A set of diffs will be computed. + /// + /// The original list of items. + /// The target list of items produced by applying the patches to the . + /// Rope of objects. + [Pure] + public static Rope> Create(Rope source, Rope target, PatchOptions? patchOptions, DiffOptions? diffOptions = null) where T : IEquatable + { + // No diffs provided, compute our own. + diffOptions = diffOptions ?? DiffOptions.Default; + using var deadline = diffOptions.StartTimer(); + var diffs = source.Diff(target, diffOptions, deadline.Cancellation); + if (diffs.Count > 2) + { + diffs = diffs.DiffCleanupSemantic(deadline.Cancellation); + diffs = diffs.DiffCleanupEfficiency(diffOptions); + } + + return diffs.ToPatches(source, patchOptions); + } +} diff --git a/src/Compare/Patch.cs b/src/Compare/Patch{T}.cs similarity index 69% rename from src/Compare/Patch.cs rename to src/Compare/Patch{T}.cs index 41817b7..2203774 100644 --- a/src/Compare/Patch.cs +++ b/src/Compare/Patch{T}.cs @@ -23,16 +23,23 @@ namespace Rope.Compare; using System; +using System.Diagnostics; +using System.Diagnostics.Contracts; -/** - * Class representing one patch operation. - */ +/// +/// Struct representing a single patch operation. +/// +/// The items being patched, typically when patching strings. public readonly record struct Patch() where T : IEquatable { public Rope> Diffs { get; init; } = Rope>.Empty; + public long Start1 { get; init; } - public long Start2 { get; init; } + public long Length1 { get; init; } + + public long Start2 { get; init; } + public long Length2 { get; init; } /** @@ -41,9 +48,16 @@ public readonly record struct Patch() where T : IEquatable * Indices are printed as 1-based, not 0-based. * @return The GNU diff string. */ - public override string ToString() + public override string ToString() => this.ToCharRope().ToString(); + + /// + /// Internal version that constructs a rope, internal as it is only compatible when is of type . + /// + /// The constructed string as a . + internal Rope ToCharRope() { - string coords1, coords2; + Debug.Assert(typeof(T) == typeof(char), "This overload is only compatible with `char` type,\nuse ToCharRope(Func> itemToString) passing a\nfunction to convert each item to a string."); + Rope coords1, coords2; if (this.Length1 == 0) { coords1 = this.Start1 + ",0"; @@ -70,8 +84,8 @@ public override string ToString() } var text = Rope.Empty; - text = text.Append("@@ -").Append(coords1).Append(" +").Append(coords2) - .Append(" @@\n"); + text = text.Append("@@ -").AddRange(coords1).Append(" +").AddRange(coords2).Append(" @@\n"); + // Escape the body of the patch with %xx notation. foreach (var aDiff in this.Diffs) { @@ -91,12 +105,19 @@ public override string ToString() text = text.AddRange(aDiff.Items.ToString().ToRope().DiffEncode()).Append("\n"); } - return text.ToString(); + return text; } - - public Rope ToCharRope(Func> itemToString) + /// + /// Joins items by a separator and then includes them in a text patch. + /// + /// A function to convert a single item into text. + /// The separator between items, defaults to ~ + /// The constructed string as a . + public Rope ToCharRope(Func> itemToString, char separator = '~') { + Contract.Assert(typeof(T) != typeof(char), "This overload is only compatible with non `char` types, use ToString() instead."); + string coords1, coords2; if (this.Length1 == 0) { @@ -142,7 +163,7 @@ public Rope ToCharRope(Func> itemToString) break; } - text = text.AddRange(aDiff.Items.DiffEncode(itemToString)).Append("\n"); + text = text.AddRange(aDiff.Items.DiffEncode(itemToString, separator)).Append("\n"); } return text; diff --git a/src/RopeExtensions.cs b/src/RopeExtensions.cs index 28dc127..9ce1ec7 100644 --- a/src/RopeExtensions.cs +++ b/src/RopeExtensions.cs @@ -87,7 +87,7 @@ public static Rope ToRope(this IEnumerable items) where T : IEquatable< } /// - /// Constructs a new Rope from a series of leaves into a tree. + /// Constructs a new Rope from a series of leaves into a tree (flattens a sequence of sequences). /// /// The leaf nodes to construct into a tree. /// A new rope with the leaves specified. @@ -97,7 +97,7 @@ public static Rope Combine(this Rope> leaves) where T : IEquatable } /// - /// Constructs a new Rope from a series of leaves into a tree. + /// Constructs a new Rope from a series of leaves into a tree (flattens a sequence of sequences). /// /// The leaf nodes to construct into a tree. /// A new rope with the leaves specified. diff --git a/tests/DiffAnything.cs b/tests/DiffAnything.cs index 5a974dc..041d05e 100644 --- a/tests/DiffAnything.cs +++ b/tests/DiffAnything.cs @@ -11,6 +11,35 @@ namespace Rope.UnitTests [TestClass] public class DiffAnything { + [TestMethod] + public void CreateDiffOfStrings() + { + Rope sourceText = "abcdef"; + Rope targetText = "abefg"; + + // Create a list of differences. + Rope> diffs = sourceText.Diff(targetText); + + // Recover either side from the list of differences. + Rope recoveredSourceText = diffs.ToSource(); + Rope recoveredTargetText = diffs.ToTarget(); + + // Create a Patch string (like Git's patch text) + Rope> patches = diffs.ToPatches(); // A list of patches + Rope patchText = patches.ToPatchString(); // A single string of patch text. + Console.WriteLine(patchText); + /** Outputs: + @@ -1,6 +1,5 @@ + ab + -cd + ef + +g + */ + + // Parse out the patches from the patch text. + Rope> parsedPatches = Patches.Parse(patchText); + } + [TestMethod] public void CreateDiffsOfAnything() { @@ -33,14 +62,14 @@ public void CreateDiffsOfAnything() new Person("James", "Joyce"), ]; - var changes = original.Diff(updated, DiffOptions.Default); + Rope> changes = original.Diff(updated, DiffOptions.Default); Assert.AreEqual(2, changes.Count(d => d.Operation != Operation.Equal)); // Convert to a Delta string - var delta = changes.ToDelta(p => p.ToString()); + Rope delta = changes.ToDelta(p => p.ToString()); // Rebuild the diff from the original list and a delta. - var fromDelta = delta.ParseDelta(original, Person.Parse); + Rope> fromDelta = Delta.Parse(delta, original, Person.Parse); // Get back the original list Assert.AreEqual(fromDelta.ToSource(), original); @@ -49,14 +78,14 @@ public void CreateDiffsOfAnything() Assert.AreEqual(fromDelta.ToTarget(), updated); // Make a patch text - var patches = fromDelta.ToPatches(); + Rope> patches = fromDelta.ToPatches(); - // TODO: Convert patches to text - //var patchText = patches.ToPatchText(p => p.ToString()); + // Convert patches to text + Rope patchText = patches.ToPatchString(p => p.ToString()); - // TODO: Parse the patches back again - //var parsedPatches = patchText.ToRope().ParsePatchText(Person.Parse); - //Assert.AreEqual(parsedPatches, patches); + // Parse the patches back again + Rope> parsedPatches = Patches.Parse(patchText.ToRope(), Person.Parse); + Assert.AreEqual(parsedPatches, patches); } private record Person(Rope FirstName, Rope LastName) diff --git a/tests/DiffMatchPatchTests.cs b/tests/DiffMatchPatchTests.cs index 413b7a8..3d7a8b1 100644 --- a/tests/DiffMatchPatchTests.cs +++ b/tests/DiffMatchPatchTests.cs @@ -60,21 +60,21 @@ public void DiffCommonSuffixTest() public void DiffCommonOverlapTest() { // Detect any suffix/prefix overlap. - AssertEquals("diff_commonOverlap: Null case.", 0, "".ToRope().CommonOverlapLength("abcd".ToRope())); + AssertEquals("diff_commonOverlap: Null case.", 0, "".ToRope().CommonOverlapLength("abcd")); - AssertEquals("diff_commonOverlap: Whole case.", 3, "abc".ToRope().CommonOverlapLength("abcd".ToRope())); + AssertEquals("diff_commonOverlap: Whole case.", 3, "abc".ToRope().CommonOverlapLength("abcd")); - AssertEquals("diff_commonOverlap: No overlap.", 0, "123456".ToRope().CommonOverlapLength("abcd".ToRope())); + AssertEquals("diff_commonOverlap: No overlap.", 0, "123456".ToRope().CommonOverlapLength("abcd")); - AssertEquals("diff_commonOverlap: No overlap #2.", 0, "abcdef".ToRope().CommonOverlapLength("cdfg".ToRope())); + AssertEquals("diff_commonOverlap: No overlap #2.", 0, "abcdef".ToRope().CommonOverlapLength("cdfg")); - AssertEquals("diff_commonOverlap: No overlap #3.", 0, "cdfg".ToRope().CommonOverlapLength("abcdef".ToRope())); + AssertEquals("diff_commonOverlap: No overlap #3.", 0, "cdfg".ToRope().CommonOverlapLength("abcdef")); - AssertEquals("diff_commonOverlap: Overlap.", 3, "123456xxx".ToRope().CommonOverlapLength("xxxabcd".ToRope())); + AssertEquals("diff_commonOverlap: Overlap.", 3, "123456xxx".ToRope().CommonOverlapLength("xxxabcd")); // Some overly clever languages (C#) may treat ligatures as equal to their // component letters. E.g. U+FB01 == 'fi' - AssertEquals("diff_commonOverlap: Unicode.", 0, "fi".ToRope().CommonOverlapLength("\ufb01i".ToRope())); + AssertEquals("diff_commonOverlap: Unicode.", 0, "fi".ToRope().CommonOverlapLength("\ufb01i")); } [TestMethod] @@ -85,32 +85,32 @@ public void DiffHalfMatchTest() TimeoutSeconds = 1f }; - AssertNull("diff_halfMatch: No match #1.", "1234567890".ToRope().DiffHalfMatch("abcdef".ToRope(), this.DiffOptions)); + AssertNull("diff_halfMatch: No match #1.", "1234567890".ToRope().DiffHalfMatch("abcdef", this.DiffOptions)); - AssertNull("diff_halfMatch: No match #2.", "12345".ToRope().DiffHalfMatch("23".ToRope(), this.DiffOptions)); + AssertNull("diff_halfMatch: No match #2.", "12345".ToRope().DiffHalfMatch("23", this.DiffOptions)); - AssertEquals("diff_halfMatch: Single Match #1.", new HalfMatch("12", "90", "a", "z", "345678"), "1234567890".ToRope().DiffHalfMatch("a345678z".ToRope(), this.DiffOptions)); + AssertEquals("diff_halfMatch: Single Match #1.", new HalfMatch("12", "90", "a", "z", "345678"), "1234567890".ToRope().DiffHalfMatch("a345678z", this.DiffOptions)); - AssertEquals("diff_halfMatch: Single Match #2.", new HalfMatch("a", "z", "12", "90", "345678"), "a345678z".ToRope().DiffHalfMatch("1234567890".ToRope(), this.DiffOptions)); + AssertEquals("diff_halfMatch: Single Match #2.", new HalfMatch("a", "z", "12", "90", "345678"), "a345678z".ToRope().DiffHalfMatch("1234567890", this.DiffOptions)); - AssertEquals("diff_halfMatch: Single Match #3.", new HalfMatch("abc", "z", "1234", "0", "56789"), "abc56789z".ToRope().DiffHalfMatch("1234567890".ToRope(), this.DiffOptions)); + AssertEquals("diff_halfMatch: Single Match #3.", new HalfMatch("abc", "z", "1234", "0", "56789"), "abc56789z".ToRope().DiffHalfMatch("1234567890", this.DiffOptions)); - AssertEquals("diff_halfMatch: Single Match #4.", new HalfMatch("a", "xyz", "1", "7890", "23456"), "a23456xyz".ToRope().DiffHalfMatch("1234567890".ToRope(), this.DiffOptions)); + AssertEquals("diff_halfMatch: Single Match #4.", new HalfMatch("a", "xyz", "1", "7890", "23456"), "a23456xyz".ToRope().DiffHalfMatch("1234567890", this.DiffOptions)); - AssertEquals("diff_halfMatch: Multiple Matches #1.", new HalfMatch("12123", "123121", "a", "z", "1234123451234"), "121231234123451234123121".ToRope().DiffHalfMatch("a1234123451234z".ToRope(), this.DiffOptions)); + AssertEquals("diff_halfMatch: Multiple Matches #1.", new HalfMatch("12123", "123121", "a", "z", "1234123451234"), "121231234123451234123121".ToRope().DiffHalfMatch("a1234123451234z", this.DiffOptions)); - AssertEquals("diff_halfMatch: Multiple Matches #2.", new HalfMatch("", "-=-=-=-=-=", "x", "", "x-=-=-=-=-=-=-="), "x-=-=-=-=-=-=-=-=-=-=-=-=".ToRope().DiffHalfMatch("xx-=-=-=-=-=-=-=".ToRope(), this.DiffOptions)); + AssertEquals("diff_halfMatch: Multiple Matches #2.", new HalfMatch("", "-=-=-=-=-=", "x", "", "x-=-=-=-=-=-=-="), "x-=-=-=-=-=-=-=-=-=-=-=-=".ToRope().DiffHalfMatch("xx-=-=-=-=-=-=-=", this.DiffOptions)); - AssertEquals("diff_halfMatch: Multiple Matches #3.", new HalfMatch("-=-=-=-=-=", "", "", "y", "-=-=-=-=-=-=-=y"), "-=-=-=-=-=-=-=-=-=-=-=-=y".ToRope().DiffHalfMatch("-=-=-=-=-=-=-=yy".ToRope(), this.DiffOptions)); + AssertEquals("diff_halfMatch: Multiple Matches #3.", new HalfMatch("-=-=-=-=-=", "", "", "y", "-=-=-=-=-=-=-=y"), "-=-=-=-=-=-=-=-=-=-=-=-=y".ToRope().DiffHalfMatch("-=-=-=-=-=-=-=yy", this.DiffOptions)); // Optimal diff would be -q+x=H-i+e=lloHe+Hu=llo-Hew+y not -qHillo+x=HelloHe-w+Hulloy - AssertEquals("diff_halfMatch: Non-optimal halfmatch.", new HalfMatch("qHillo", "w", "x", "Hulloy", "HelloHe"), "qHilloHelloHew".ToRope().DiffHalfMatch("xHelloHeHulloy".ToRope(), this.DiffOptions)); + AssertEquals("diff_halfMatch: Non-optimal halfmatch.", new HalfMatch("qHillo", "w", "x", "Hulloy", "HelloHe"), "qHilloHelloHew".ToRope().DiffHalfMatch("xHelloHeHulloy", this.DiffOptions)); this.DiffOptions = this.DiffOptions with { TimeoutSeconds = 0 }; - AssertNull("diff_halfMatch: Optimal no halfmatch.", "qHilloHelloHew".ToRope().DiffHalfMatch("xHelloHeHulloy".ToRope(), this.DiffOptions)); + AssertNull("diff_halfMatch: Optimal no halfmatch.", "qHilloHelloHew".ToRope().DiffHalfMatch("xHelloHeHulloy", this.DiffOptions)); } [TestMethod] @@ -121,9 +121,9 @@ public void DiffLinesToCharsTest() tmpVector += "".ToRope(); tmpVector += "alpha\n".ToRope(); tmpVector += "beta\n".ToRope(); - var result = "alpha\nbeta\nalpha\n".ToRope().DiffChunksToChars("beta\nalpha\nbeta\n".ToRope(), this.DiffOptions); - AssertEquals("diff_linesToChars: Shared lines #1.", "\u0001\u0002\u0001".ToRope(), result.Item1); - AssertEquals("diff_linesToChars: Shared lines #2.", "\u0002\u0001\u0002".ToRope(), result.Item2); + var result = "alpha\nbeta\nalpha\n".ToRope().DiffChunksToChars("beta\nalpha\nbeta\n", this.DiffOptions); + AssertEquals("diff_linesToChars: Shared lines #1.", "\u0001\u0002\u0001", result.Item1); + AssertEquals("diff_linesToChars: Shared lines #2.", "\u0002\u0001\u0002", result.Item2); AssertEquals("diff_linesToChars: Shared lines #3.", tmpVector, result.Item3); tmpVector = tmpVector.Clear(); @@ -131,7 +131,7 @@ public void DiffLinesToCharsTest() tmpVector += "alpha\r\n".ToRope(); tmpVector += "beta\r\n".ToRope(); tmpVector += "\r\n".ToRope(); - result = "".ToRope().DiffChunksToChars("alpha\r\nbeta\r\n\r\n\r\n".ToRope(), this.DiffOptions); + result = "".ToRope().DiffChunksToChars("alpha\r\nbeta\r\n\r\n\r\n", this.DiffOptions); AssertEquals("diff_linesToChars: Empty string and blank lines #1.", "".ToRope(), result.Item1); AssertEquals("diff_linesToChars: Empty string and blank lines #2.", "\u0001\u0002\u0003\u0003".ToRope(), result.Item2); AssertEquals("diff_linesToChars: Empty string and blank lines #3.", tmpVector, result.Item3); @@ -659,12 +659,12 @@ public void DiffDeltaTest() AssertEquals("diff_toDelta:", "=4\t-1\t+ed\t=6\t-3\t+a\t=5\t+old dog", delta.ToString()); // Convert delta string into a diff. - AssertEquals("diff_fromDelta: Normal.", diffs, delta.ParseDelta(text1)); + AssertEquals("diff_fromDelta: Normal.", diffs, Delta.Parse(delta, text1)); // Generates error (19 < 20). try { - _ = delta.ParseDelta((text1 + "x".ToRope())); + _ = Delta.Parse(delta, text1 + "x"); AssertFail("diff_fromDelta: Too long."); } catch (ArgumentException) @@ -675,7 +675,7 @@ public void DiffDeltaTest() // Generates error (19 > 18). try { - _ = delta.ParseDelta(text1.Slice(1)); + _ = Delta.Parse(delta, text1.Slice(1)); AssertFail("diff_fromDelta: Too short."); } catch (ArgumentException) @@ -686,7 +686,7 @@ public void DiffDeltaTest() // Generates error (%c3%xy invalid Unicode). try { - _ = "+%c3%xy".ToRope().ParseDelta(Rope.Empty); + _ = Delta.Parse("+%c3%xy", Rope.Empty); AssertFail("diff_fromDelta: Invalid character."); } catch (ArgumentException) @@ -709,7 +709,7 @@ public void DiffDeltaTest() // Uppercase, due to UrlEncoder now uses upper. AssertEquals("diff_toDelta: Unicode.", "=7\t-7\t+%DA%82 %02 %5C %7C".ToRope(), delta); - AssertEquals("diff_fromDelta: Unicode.", diffs, delta.ParseDelta(text1)); + AssertEquals("diff_fromDelta: Unicode.", diffs, delta.Parse(text1)); // Verify pool of unchanged characters. diffs = new Rope>(new[] { @@ -721,7 +721,7 @@ public void DiffDeltaTest() AssertEquals("diff_toDelta: Unchanged characters.", "+A-Z a-z 0-9 - _ . ! ~ * \' ( ) ; / ? : @ & = + $ , # ".ToRope(), delta); // Convert delta string into a diff. - AssertEquals("diff_fromDelta: Unchanged characters.", diffs, delta.ParseDelta("".ToRope())); + AssertEquals("diff_fromDelta: Unchanged characters.", diffs, Delta.Parse(delta, "".ToRope())); // 160 kb string. var a = "abcdefghij".ToRope(); @@ -734,7 +734,7 @@ public void DiffDeltaTest() AssertEquals("diff_toDelta: 160kb string.", ("+" + a).ToRope(), delta); // Convert delta string into a diff. - AssertEquals("diff_fromDelta: 160kb string.", diffs, delta.ParseDelta("".ToRope())); + AssertEquals("diff_fromDelta: 160kb string.", diffs, Delta.Parse(delta, "".ToRope())); } [TestMethod] @@ -1005,21 +1005,18 @@ public void PatchObjectTest() [TestMethod] public void PatchFromTextTest() { - AssertTrue("patch_fromText: #0.", "".ToRope().ParsePatchText().Count == 0); + AssertTrue("patch_fromText: #0.", Patches.Parse("").Count == 0); string strp = "@@ -21,18 +22,17 @@\n jump\n-s\n+ed\n over \n-the\n+a\n %0Alaz\n"; - AssertEquals("patch_fromText: #1.", strp, strp.ToRope().ParsePatchText()[0].ToString()); - - AssertEquals("patch_fromText: #2.", "@@ -1 +1 @@\n-a\n+b\n", "@@ -1 +1 @@\n-a\n+b\n".ToRope().ParsePatchText()[0].ToString()); - - AssertEquals("patch_fromText: #3.", "@@ -1,3 +0,0 @@\n-abc\n", "@@ -1,3 +0,0 @@\n-abc\n".ToRope().ParsePatchText()[0].ToString()); - - AssertEquals("patch_fromText: #4.", "@@ -0,0 +1,3 @@\n+abc\n", "@@ -0,0 +1,3 @@\n+abc\n".ToRope().ParsePatchText()[0].ToString()); + AssertEquals("patch_fromText: #1.", strp, Patches.Parse(strp.ToRope())[0].ToString()); + AssertEquals("patch_fromText: #2.", "@@ -1 +1 @@\n-a\n+b\n", Patches.Parse("@@ -1 +1 @@\n-a\n+b\n")[0].ToString()); + AssertEquals("patch_fromText: #3.", "@@ -1,3 +0,0 @@\n-abc\n", Patches.Parse("@@ -1,3 +0,0 @@\n-abc\n")[0].ToString()); + AssertEquals("patch_fromText: #4.", "@@ -0,0 +1,3 @@\n+abc\n", Patches.Parse("@@ -0,0 +1,3 @@\n+abc\n")[0].ToString()); // Generates error. try { - "Bad\nPatch\n".ToRope().ParsePatchText(); + Patches.Parse("Bad\nPatch\n".ToRope()); AssertFail("patch_fromText: #5."); } catch (ArgumentException) @@ -1032,13 +1029,13 @@ public void PatchFromTextTest() public void PatchToTextTest() { var strp = "@@ -21,18 +22,17 @@\n jump\n-s\n+ed\n over \n-the\n+a\n laz\n".ToRope(); - var patches = strp.ParsePatchText(); - string result = patches.ToPatchText(); + var patches = Patches.Parse(strp); + var result = patches.ToPatchString(); AssertEquals("patch_toText: Single.", strp, result); strp = "@@ -1,9 +1,9 @@\n-f\n+F\n oo+fooba\n@@ -7,9 +7,9 @@\n obar\n-,\n+.\n tes\n".ToRope(); - patches = strp.ParsePatchText(); - result = patches.ToPatchText(); + patches = Patches.Parse(strp); + result = patches.ToPatchString(); AssertEquals("patch_toText: Dual.", strp, result); } @@ -1048,20 +1045,20 @@ public void PatchAddContextTest() this.PatchOptions = this.PatchOptions with { Margin = 4 }; Patch p; - p = "@@ -21,4 +21,10 @@\n-jump\n+somersault\n".ToRope().ParsePatchText()[0]; - p = p.PatchAddContext("The quick brown fox jumps over the lazy dog.".ToRope(), this.PatchOptions); + p = Patches.Parse("@@ -21,4 +21,10 @@\n-jump\n+somersault\n")[0]; + p = p.PatchAddContext("The quick brown fox jumps over the lazy dog.", this.PatchOptions); AssertEquals("patch_addContext: Simple case.", "@@ -17,12 +17,18 @@\n fox \n-jump\n+somersault\n s ov\n", p.ToString()); - p = "@@ -21,4 +21,10 @@\n-jump\n+somersault\n".ToRope().ParsePatchText()[0]; - p = p.PatchAddContext("The quick brown fox jumps.".ToRope(), this.PatchOptions); + p = Patches.Parse("@@ -21,4 +21,10 @@\n-jump\n+somersault\n")[0]; + p = p.PatchAddContext("The quick brown fox jumps.", this.PatchOptions); AssertEquals("patch_addContext: Not enough trailing context.", "@@ -17,10 +17,16 @@\n fox \n-jump\n+somersault\n s.\n", p.ToString()); - p = "@@ -3 +3,2 @@\n-e\n+at\n".ToRope().ParsePatchText()[0]; - p = p.PatchAddContext("The quick brown fox jumps.".ToRope(), this.PatchOptions); + p = Patches.Parse("@@ -3 +3,2 @@\n-e\n+at\n")[0]; + p = p.PatchAddContext("The quick brown fox jumps.", this.PatchOptions); AssertEquals("patch_addContext: Not enough leading context.", "@@ -1,7 +1,8 @@\n Th\n-e\n+at\n qui\n", p.ToString()); - p = "@@ -3 +3,2 @@\n-e\n+at\n".ToRope().ParsePatchText()[0]; - p = p.PatchAddContext("The quick brown fox jumps. The quick brown fox crashes.".ToRope(), this.PatchOptions); + p = Patches.Parse("@@ -3 +3,2 @@\n-e\n+at\n".ToRope())[0]; + p = p.PatchAddContext("The quick brown fox jumps. The quick brown fox crashes.", this.PatchOptions); AssertEquals("patch_addContext: Ambiguity.", "@@ -1,27 +1,28 @@\n Th\n-e\n+at\n quick brown fox jumps. \n", p.ToString()); } @@ -1069,39 +1066,44 @@ public void PatchAddContextTest() public void PatchMakeTest() { Rope> patches = Rope>.Empty; - patches = "".CreatePatches(""); - AssertEquals("patch_make: Null case.", "", patches.ToPatchText()); + patches = Patches.Create("", ""); + AssertEquals("patch_make: Null case.", "", patches.ToPatchString()); string text1 = "The quick brown fox jumps over the lazy dog."; string text2 = "That quick brown fox jumped over a lazy dog."; string expectedPatch = "@@ -1,8 +1,7 @@\n Th\n-at\n+e\n qui\n@@ -21,17 +21,18 @@\n jump\n-ed\n+s\n over \n-a\n+the\n laz\n"; // The second patch must be "-21,17 +21,18", not "-22,17 +21,18" due to rolling context. - patches = text2.CreatePatches(text1); - var patchText = patches.ToPatchText(); + patches = Patches.Create(text2, text1); + var patchText = patches.ToPatchString(); AssertEquals("patch_make: Text2+Text1 inputs.", expectedPatch, patchText); expectedPatch = "@@ -1,11 +1,12 @@\n Th\n-e\n+at\n quick b\n@@ -22,18 +22,17 @@\n jump\n-s\n+ed\n over \n-the\n+a\n laz\n"; - patches = text1.CreatePatches(text2); - AssertEquals("patch_make: Text1+Text2 inputs.", expectedPatch, patches.ToPatchText()); + patches = Patches.Create(text1, text2); + AssertEquals("patch_make: Text1+Text2 inputs.", expectedPatch, patches.ToPatchString()); var diffs = text1.Diff(text2, false); patches = diffs.ToPatches(); - AssertEquals("patch_make: Diff input.", expectedPatch, patches.ToPatchText()); + AssertEquals("patch_make: Diff input.", expectedPatch, patches.ToPatchString()); - patches = text1.ToRope().ToPatches(diffs, this.PatchOptions); - AssertEquals("patch_make: Text1+Diff inputs.", expectedPatch, patches.ToPatchText()); + patches = diffs.ToPatches(text1, this.PatchOptions); + AssertEquals("patch_make: Text1+Diff inputs.", expectedPatch, patches.ToPatchString()); - patches = "`1234567890-=[]\\;',./".CreatePatches("~!@#$%^&*()_+{}|:\"<>?"); + patches = Patches.Create("`1234567890-=[]\\;',./", "~!@#$%^&*()_+{}|:\"<>?"); AssertEquals("patch_toText: Character encoding.", "@@ -1,21 +1,21 @@\n-%601234567890-=%5B%5D%5C;',./\n+~!@#$%25%5E&*()_+%7B%7D%7C:%22%3C%3E?\n", - patches.ToPatchText()); + patches.ToPatchString()); + + AssertEquals( + "patch_toText: Character encoding with special separator.", + "@@ -1,21 +1,21 @@\n-%601234567890-=%5B%5D%5C;',./\n+~!@#$%25%5E&*()_+%7B%7D%7C:%22%3C%3E?\n", + patches.ToPatchString(c => "~" + c.ToString() + "~", '~')); diffs = new Rope>(new[] { new Diff(Operation.Delete, "`1234567890-=[]\\;',./"), new Diff(Operation.Insert, "~!@#$%^&*()_+{}|:\"<>?")}); AssertEquals("patch_fromText: Character decoding.", diffs, - "@@ -1,21 +1,21 @@\n-%601234567890-=%5B%5D%5C;',./\n+~!@#$%25%5E&*()_+%7B%7D%7C:%22%3C%3E?\n".ToRope().ParsePatchText()[0].Diffs); + Patches.Parse("@@ -1,21 +1,21 @@\n-%601234567890-=%5B%5D%5C;',./\n+~!@#$%25%5E&*()_+%7B%7D%7C:%22%3C%3E?\n".ToRope())[0].Diffs); text1 = ""; for (int x = 0; x < 100; x++) @@ -1110,8 +1112,8 @@ public void PatchMakeTest() } text2 = text1 + "123"; expectedPatch = "@@ -573,28 +573,31 @@\n cdefabcdefabcdefabcdefabcdef\n+123\n"; - patches = text1.CreatePatches(text2); - AssertEquals("patch_make: Long string with repeats.", expectedPatch, patches.ToPatchText()); + patches = Patches.Create(text1, text2); + AssertEquals("patch_make: Long string with repeats.", expectedPatch, patches.ToPatchString()); // Test null inputs -- not needed because nulls can't be passed in C#. } @@ -1122,53 +1124,53 @@ public void PatchSplitMaxTest() // Assumes that Match_MaxBits is 32. IEnumerable> patches; - patches = "abcdefghijklmnopqrstuvwxyz01234567890".CreatePatches("XabXcdXefXghXijXklXmnXopXqrXstXuvXwxXyzX01X23X45X67X89X0"); + patches = Patches.Create("abcdefghijklmnopqrstuvwxyz01234567890", "XabXcdXefXghXijXklXmnXopXqrXstXuvXwxXyzX01X23X45X67X89X0"); patches = patches.PatchSplitMaxLength(this.PatchOptions); - AssertEquals("patch_splitMax: #1.", "@@ -1,32 +1,46 @@\n+X\n ab\n+X\n cd\n+X\n ef\n+X\n gh\n+X\n ij\n+X\n kl\n+X\n mn\n+X\n op\n+X\n qr\n+X\n st\n+X\n uv\n+X\n wx\n+X\n yz\n+X\n 012345\n@@ -25,13 +39,18 @@\n zX01\n+X\n 23\n+X\n 45\n+X\n 67\n+X\n 89\n+X\n 0\n", patches.ToPatchText()); + AssertEquals("patch_splitMax: #1.", "@@ -1,32 +1,46 @@\n+X\n ab\n+X\n cd\n+X\n ef\n+X\n gh\n+X\n ij\n+X\n kl\n+X\n mn\n+X\n op\n+X\n qr\n+X\n st\n+X\n uv\n+X\n wx\n+X\n yz\n+X\n 012345\n@@ -25,13 +39,18 @@\n zX01\n+X\n 23\n+X\n 45\n+X\n 67\n+X\n 89\n+X\n 0\n", patches.ToPatchString()); - patches = "abcdef1234567890123456789012345678901234567890123456789012345678901234567890uvwxyz".CreatePatches("abcdefuvwxyz"); - string oldToText = patches.ToPatchText(); + patches = Patches.Create("abcdef1234567890123456789012345678901234567890123456789012345678901234567890uvwxyz", "abcdefuvwxyz"); + var oldToText = patches.ToPatchString(); patches = patches.PatchSplitMaxLength(this.PatchOptions); - AssertEquals("patch_splitMax: #2.", oldToText, patches.ToPatchText()); + AssertEquals("patch_splitMax: #2.", oldToText, patches.ToPatchString()); - patches = "1234567890123456789012345678901234567890123456789012345678901234567890".CreatePatches("abc"); + patches = Patches.Create("1234567890123456789012345678901234567890123456789012345678901234567890", "abc"); patches = patches.PatchSplitMaxLength(this.PatchOptions); - AssertEquals("patch_splitMax: #3.", "@@ -1,32 +1,4 @@\n-1234567890123456789012345678\n 9012\n@@ -29,32 +1,4 @@\n-9012345678901234567890123456\n 7890\n@@ -57,14 +1,3 @@\n-78901234567890\n+abc\n", patches.ToPatchText()); + AssertEquals("patch_splitMax: #3.", "@@ -1,32 +1,4 @@\n-1234567890123456789012345678\n 9012\n@@ -29,32 +1,4 @@\n-9012345678901234567890123456\n 7890\n@@ -57,14 +1,3 @@\n-78901234567890\n+abc\n", patches.ToPatchString()); - patches = "abcdefghij , h : 0 , t : 1 abcdefghij , h : 0 , t : 1 abcdefghij , h : 0 , t : 1".CreatePatches("abcdefghij , h : 1 , t : 1 abcdefghij , h : 1 , t : 1 abcdefghij , h : 0 , t : 1"); + patches = Patches.Create("abcdefghij , h : 0 , t : 1 abcdefghij , h : 0 , t : 1 abcdefghij , h : 0 , t : 1", "abcdefghij , h : 1 , t : 1 abcdefghij , h : 1 , t : 1 abcdefghij , h : 0 , t : 1"); patches = patches.PatchSplitMaxLength(this.PatchOptions); - AssertEquals("patch_splitMax: #4.", "@@ -2,32 +2,32 @@\n bcdefghij , h : \n-0\n+1\n , t : 1 abcdef\n@@ -29,32 +29,32 @@\n bcdefghij , h : \n-0\n+1\n , t : 1 abcdef\n", patches.ToPatchText()); + AssertEquals("patch_splitMax: #4.", "@@ -2,32 +2,32 @@\n bcdefghij , h : \n-0\n+1\n , t : 1 abcdef\n@@ -29,32 +29,32 @@\n bcdefghij , h : \n-0\n+1\n , t : 1 abcdef\n", patches.ToPatchString()); } [TestMethod] public void PatchAddPaddingTest() { - Rope> patches = "".CreatePatches("test"); + Rope> patches = Patches.Create("", "test"); AssertEquals("patch_addPadding: Both edges full.", "@@ -0,0 +1,4 @@\n+test\n", - patches.ToPatchText()); + patches.ToPatchString()); (_, patches) = patches.PatchAddPadding(this.PatchOptions); AssertEquals("patch_addPadding: Both edges full.", "@@ -1,8 +1,12 @@\n %01%02%03%04\n+test\n %01%02%03%04\n", - patches.ToPatchText()); + patches.ToPatchString()); - patches = "XY".CreatePatches("XtestY"); + patches = Patches.Create("XY", "XtestY"); AssertEquals("patch_addPadding: Both edges partial.", "@@ -1,2 +1,6 @@\n X\n+test\n Y\n", - patches.ToPatchText()); + patches.ToPatchString()); (_, patches) = patches.PatchAddPadding(this.PatchOptions); AssertEquals("patch_addPadding: Both edges partial.", "@@ -2,8 +2,12 @@\n %02%03%04X\n+test\n Y%01%02%03\n", - patches.ToPatchText()); + patches.ToPatchString()); - patches = "XXXXYYYY".CreatePatches("XXXXtestYYYY"); + patches = Patches.Create("XXXXYYYY", "XXXXtestYYYY"); AssertEquals("patch_addPadding: Both edges none.", "@@ -1,8 +1,12 @@\n XXXX\n+test\n YYYY\n", - patches.ToPatchText()); + patches.ToPatchString()); (_, patches) = patches.PatchAddPadding(this.PatchOptions); AssertEquals("patch_addPadding: Both edges none.", "@@ -5,8 +5,12 @@\n XXXX\n+test\n YYYY\n", - patches.ToPatchText()); + patches.ToPatchString()); } [TestMethod] @@ -1181,13 +1183,13 @@ public void PatchApplyTest() DeleteThreshold = 0.5f }; IEnumerable> patches; - patches = "".CreatePatches("", this.PatchOptions); + patches = Patches.Create("", "", this.PatchOptions); (string Text, bool[] Applied) results = patches.ApplyPatches("Hello world.", this.PatchOptions); bool[] boolArray = results.Applied; string resultStr = results.Text + "\t" + boolArray.Length; AssertEquals("patch_apply: Null case.", "Hello world.\t0", resultStr); - patches = "The quick brown fox jumps over the lazy dog.".CreatePatches("That quick brown fox jumped over a lazy dog.", this.PatchOptions); + patches = Patches.Create("The quick brown fox jumps over the lazy dog.", "That quick brown fox jumped over a lazy dog.", this.PatchOptions); results = patches.ApplyPatches("The quick brown fox jumps over the lazy dog.", this.PatchOptions); boolArray = results.Applied; resultStr = results.Text + "\t" + boolArray[0] + "\t" + boolArray[1]; @@ -1203,20 +1205,20 @@ public void PatchApplyTest() resultStr = results.Text + "\t" + boolArray[0] + "\t" + boolArray[1]; AssertEquals("patch_apply: Failed match.", "I am the very model of a modern major general.\tFalse\tFalse", resultStr); - patches = "x1234567890123456789012345678901234567890123456789012345678901234567890y".CreatePatches("xabcy", this.PatchOptions); + patches = Patches.Create("x1234567890123456789012345678901234567890123456789012345678901234567890y", "xabcy", this.PatchOptions); results = patches.ApplyPatches("x123456789012345678901234567890-----++++++++++-----123456789012345678901234567890y", this.PatchOptions); boolArray = results.Applied; resultStr = results.Text + "\t" + boolArray[0] + "\t" + boolArray[1]; AssertEquals("patch_apply: Big delete, small change.", "xabcy\tTrue\tTrue", resultStr); - patches = "x1234567890123456789012345678901234567890123456789012345678901234567890y".CreatePatches("xabcy", this.PatchOptions); + patches = Patches.Create("x1234567890123456789012345678901234567890123456789012345678901234567890y", "xabcy", this.PatchOptions); results = patches.ApplyPatches("x12345678901234567890---------------++++++++++---------------12345678901234567890y", this.PatchOptions); boolArray = results.Applied; resultStr = results.Text + "\t" + boolArray[0] + "\t" + boolArray[1]; AssertEquals("patch_apply: Big delete, big change 1.", "xabc12345678901234567890---------------++++++++++---------------12345678901234567890y\tFalse\tTrue", resultStr); this.PatchOptions = this.PatchOptions with { DeleteThreshold = 0.6f }; - patches = "x1234567890123456789012345678901234567890123456789012345678901234567890y".CreatePatches("xabcy", this.PatchOptions); + patches = Patches.Create("x1234567890123456789012345678901234567890123456789012345678901234567890y", "xabcy", this.PatchOptions); results = patches.ApplyPatches("x12345678901234567890---------------++++++++++---------------12345678901234567890y", this.PatchOptions); boolArray = results.Applied; resultStr = results.Text + "\t" + boolArray[0] + "\t" + boolArray[1]; @@ -1227,7 +1229,7 @@ public void PatchApplyTest() MatchDistance = 0, MatchThreshold = 0.0f }; - patches = "abcdefghijklmnopqrstuvwxyz--------------------1234567890".CreatePatches("abcXXXXXXXXXXdefghijklmnopqrstuvwxyz--------------------1234567YYYYYYYYYY890", this.PatchOptions); + patches = Patches.Create("abcdefghijklmnopqrstuvwxyz--------------------1234567890", "abcXXXXXXXXXXdefghijklmnopqrstuvwxyz--------------------1234567YYYYYYYYYY890", this.PatchOptions); results = patches.ApplyPatches("ABCDEFGHIJKLMNOPQRSTUVWXYZ--------------------1234567890", this.PatchOptions); boolArray = results.Applied; resultStr = results.Text + "\t" + boolArray[0] + "\t" + boolArray[1]; @@ -1238,29 +1240,29 @@ public void PatchApplyTest() MatchThreshold = 0.5f }; - patches = "".CreatePatches("test", this.PatchOptions); - string patchStr = patches.ToPatchText(); + patches = Patches.Create("", "test", this.PatchOptions); + var patchStr = patches.ToPatchString(); _ = patches.ApplyPatches("", this.PatchOptions); - AssertEquals("patch_apply: No side effects.", patchStr, patches.ToPatchText()); + AssertEquals("patch_apply: No side effects.", patchStr, patches.ToPatchString()); - patches = "The quick brown fox jumps over the lazy dog.".CreatePatches("Woof", this.PatchOptions); - patchStr = patches.ToPatchText(); + patches = Patches.Create("The quick brown fox jumps over the lazy dog.", "Woof", this.PatchOptions); + patchStr = patches.ToPatchString(); _ = patches.ApplyPatches("The quick brown fox jumps over the lazy dog.", this.PatchOptions); - AssertEquals("patch_apply: No side effects with major delete.", patchStr, patches.ToPatchText()); + AssertEquals("patch_apply: No side effects with major delete.", patchStr, patches.ToPatchString()); - patches = "".CreatePatches("test", this.PatchOptions); + patches = Patches.Create("", "test", this.PatchOptions); results = patches.ApplyPatches("", this.PatchOptions); boolArray = results.Applied; resultStr = results.Text + "\t" + boolArray[0]; AssertEquals("patch_apply: Edge exact match.", "test\tTrue", resultStr); - patches = "XY".CreatePatches("XtestY", this.PatchOptions); + patches = Patches.Create("XY", "XtestY", this.PatchOptions); results = patches.ApplyPatches("XY", this.PatchOptions); boolArray = results.Applied; resultStr = results.Text + "\t" + boolArray[0]; AssertEquals("patch_apply: Near edge exact match.", "XtestY\tTrue", resultStr); - patches = "y".CreatePatches("y123", this.PatchOptions); + patches = Patches.Create("y", "y123", this.PatchOptions); results = patches.ApplyPatches("x", this.PatchOptions); boolArray = results.Applied; resultStr = results.Text + "\t" + boolArray[0]; From 9d716b5b98c51af3d67590430409a61c1d582cad Mon Sep 17 00:00:00 2001 From: Andrew Chisholm Date: Sun, 28 Jul 2024 23:29:47 +1000 Subject: [PATCH 2/2] Unit tests fixed, added missing implementations for ImmutableList{T} --- README.md | 75 ++-- src/Compare/CharComparer.cs | 36 ++ src/Compare/CompatibilityExtensions.cs | 9 +- src/Compare/DiffAlgorithmExtensions.cs | 4 +- src/Compare/PatchExtensions.cs | 2 +- src/Compare/Patch{T}.cs | 4 +- src/Rope.cs | 526 ++++++++++++++++++++----- src/RopeExtensions.cs | 108 +++-- src/RopeSplitOptions.cs | 26 ++ tests/DiffAnything.cs | 98 ----- tests/DiffMatchPatchTests.cs | 4 +- tests/HowToUse.cs | 121 ++++++ tests/ImmutableListInterface.cs | 236 +++++++++++ tests/RopeEditingTests.cs | 409 ++++++++++--------- tests/RopeSearchTests.cs | 26 ++ 15 files changed, 1197 insertions(+), 487 deletions(-) create mode 100644 src/Compare/CharComparer.cs create mode 100644 src/RopeSplitOptions.cs delete mode 100644 tests/DiffAnything.cs create mode 100644 tests/HowToUse.cs create mode 100644 tests/ImmutableListInterface.cs diff --git a/README.md b/README.md index 1287601..21579cc 100644 --- a/README.md +++ b/README.md @@ -10,50 +10,69 @@ [![downloads](https://img.shields.io/nuget/dt/FlatlinerDOA.Rope)](https://www.nuget.org/packages/FlatlinerDOA.Rope) ![Size](https://img.shields.io/github/repo-size/FlatlinerDOA/Rope.svg) -C# implementation of a Rope<T> immutable data structure. See the paper [Ropes: an Alternative to Strings: h.-j. boehm, r. atkinson and m. plass](https://www.cs.rit.edu/usr/local/pub/jeh/courses/QUARTERS/FP/Labs/CedarRope/rope-paper.pdf) +## What is this? +A highly efficient and performant immutable list data structure with value-like semantics. + +## Why use this? + +* Get the benefits of functional programming, without the overhead of making copies on edit. +* Replace some or all of the following types (see [Comparison with .NET Built in Types]): + * `string` + * `T[]` + * `List` + * `ImmutableList` + * `StringBuilder` +* Fewer memory allocations and faster edit and search performance (see [Performance]). + +## How does it work? +This is a C# implementation of a Rope<T> data structure. See the paper [Ropes: an Alternative to Strings: h.-j. boehm, r. atkinson and m. plass](https://www.cs.rit.edu/usr/local/pub/jeh/courses/QUARTERS/FP/Labs/CedarRope/rope-paper.pdf) A Rope is an immutable sequence built using a b-tree style data structure that is useful for efficiently applying and storing edits, most commonly used with strings, but any list or sequence can be efficiently edited using the Rope data structure. -Where a b-tree has every node in the tree storing a single entry, a rope contains arrays of elements and only subdivides on the edits. The data structure then decides at edit time whether it is optimal to rebalance the tree using the following heuristic: +Where a b-tree is generally used with every node in the tree storing a single element, a rope contains arrays of elements and only subdivides on the edits. This makes it more CPU-cache coherant when performing operations such as searching / memory comparisons. + +The data structure then decides at edit time whether it is optimal to rebalance the tree using the following heuristic: A rope of depth n is considered balanced if its length is at least Fn+2. **Note:** This implementation of Rope<T> has a hard-coded upper-bound depth of 46 added to the heuristic from the paper. As this seemed to be optimal for my workloads, your mileage may vary. +## How do I use it? +```powershell +dotnet add package FlatlinerDOA.Rope +``` -## License and Acknowledgements -Licensed MIT. -Portions of this code are Apache 2.0 License where nominated. - -## Example Usage ```csharp +using Rope; // Converting to a Rope doesn't allocate any strings (simply points to the original memory). -Rope text = "My favourite text".ToRope(); +Rope myRope1 = "My favourite text".ToRope(); + +// In fact strings are implicitly convertible to Rope, this makes Rope a drop in replacement for `string` in most cases; +Rope myRope2 = "My favourite text"; // With Rope, splits don't allocate any new strings either. -IEnumerable> words = text.Split(' '); +IEnumerable> words = myRope2.Split(' '); // Calling ToString() allocates a new string at the time of conversion. -Console.WriteLine(words.First().ToString()); +Console.WriteLine(words.First().ToString()); -// Warning: Concatenating a string to a Rope converts to a string (allocating memory). -string text2 = text + " My second favourite text"; +// Warning: Assigning a Rope to a string requires calling .ToString() and hence copying memory. +string text2 = (myRope2 + " My second favourite text").ToString(); -// Better: This makes a new rope out of the other two ropes, no string allocations or copies. -Rope text3 = text + " My second favourite text".ToRope(); +// Better: Assigning to another rope makes a new rope out of the other two ropes, no string allocations or copies. +Rope text3 = myRope2 + " My second favourite text"; // Value-like equivalence, no need for SequenceEqual!. -"test".ToRope() == ("te".ToRope() + "st".ToRope()); -"test".ToRope().GetHashCode() == ("te".ToRope() + "st".ToRope()).GetHashCode(); - -// Store a rope of anything, just like List! -Rope ropeOfPeople = [new Person("Frank"), new Person("Jane")]; // Makes an immutable and thread safe list of people. - +Assert.IsTrue("test".ToRope() == ("te".ToRope() + "st".ToRope())); +Assert.IsTrue("test".ToRope().GetHashCode() == ("te".ToRope() + "st".ToRope()).GetHashCode()); +// Store a rope of anything, just like List! +// This makes an immutable and thread safe list of people. +Rope ropeOfPeople = [new Person("Frank", "Stevens"), new Person("Jane", "Seymour")]; ``` ## In built support for Diffing and Patching -### Compare two strings . +### Compare two strings (Rope<char>). ```csharp Rope sourceText = "abcdef"; Rope targetText = "abefg"; @@ -81,8 +100,12 @@ Console.WriteLine(patchText); Rope> parsedPatches = Patches.Parse(patchText); ``` +### Create diffs of any type (Rope<T>). + ```csharp -// Create diffs of lists of any type +// Compare two lists of people +public record class Person(string FirstName, string LastName); + Rope original = [ new Person("Stephen", "King"), @@ -124,7 +147,7 @@ Rope> patches = fromDelta.ToPatches(); Rope patchText = patches.ToPatchString(p => p.ToString()); // Parse the patches back again -Rope> parsedPatches = Patches.Parse(patchText.ToRope(), Person.Parse); +Rope> parsedPatches = Patches.Parse(patchText, Person.Parse); Assert.AreEqual(parsedPatches, patches); ``` @@ -290,3 +313,9 @@ Working with a string of length - 32644 characters. - MaxLeafLength = ~32kb, Max | 'new Rope<char>(array)' | 5.430 ns | 0.0174 ns | 0.0154 ns | 0.0019 | 32 B | | 'new List<char>(array)' | 16.955 ns | 0.1281 ns | 0.1135 ns | 0.0048 | 80 B | | 'new StringBuilder(string)' | 8.575 ns | 0.0361 ns | 0.0338 ns | 0.0062 | 104 B | + + +## License and Acknowledgements +- Licensed MIT. +- Portions of this code are Apache 2.0 License where nominated. +- Includes heavily modified code from https://github.com/google/diff-match-patch \ No newline at end of file diff --git a/src/Compare/CharComparer.cs b/src/Compare/CharComparer.cs new file mode 100644 index 0000000..6808885 --- /dev/null +++ b/src/Compare/CharComparer.cs @@ -0,0 +1,36 @@ +namespace Rope.Compare; + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +/// +/// Culture specific character comparer for performing find operations such as IndexOf etc. +/// +public sealed class CharComparer : IEqualityComparer +{ + public static readonly CharComparer Ordinal = new CharComparer(CultureInfo.InvariantCulture, CompareOptions.Ordinal); + + public static readonly CharComparer OrdinalIgnoreCase = new CharComparer(CultureInfo.InvariantCulture, CompareOptions.OrdinalIgnoreCase); + + public static readonly CharComparer InvariantCulture = new CharComparer(CultureInfo.InvariantCulture, CompareOptions.None); + + public static readonly CharComparer InvariantCultureIgnoreCase = new CharComparer(CultureInfo.InvariantCulture, CompareOptions.IgnoreCase); + + public static readonly CharComparer CurrentCulture = new CharComparer(CultureInfo.CurrentCulture, CompareOptions.None); + + public static readonly CharComparer CurrentCultureIgnoreCase = new CharComparer(CultureInfo.CurrentCulture, CompareOptions.IgnoreCase); + + private readonly CultureInfo culture; + + private readonly CompareOptions options; + + public CharComparer(CultureInfo culture, CompareOptions options) + { + this.culture = culture; + this.options = options; + } + + public bool Equals(char x, char y) => culture.CompareInfo.Compare([x], [y], options) == 0; + + public int GetHashCode([DisallowNull] char obj) => culture.CompareInfo.GetHashCode([obj], options); +} \ No newline at end of file diff --git a/src/Compare/CompatibilityExtensions.cs b/src/Compare/CompatibilityExtensions.cs index 7e64cce..08a1052 100644 --- a/src/Compare/CompatibilityExtensions.cs +++ b/src/Compare/CompatibilityExtensions.cs @@ -24,6 +24,7 @@ namespace Rope.Compare; using System; using System.Buffers; using System.Diagnostics.Contracts; +using System.Linq; using System.Text; using System.Text.Encodings.Web; using System.Web; @@ -131,8 +132,12 @@ public static Rope DiffEncode(this Rope text) => /// The function to convert an item to a string. /// The encoded string. [Pure] - public static Rope DiffEncode(this Rope items, Func> itemToString, char separator = '~') where T : IEquatable => - DiffEncoder.Encode(items.Select(i => itemToString(i)).Join(separator).ToString()).Replace("%2B", "+", StringComparison.OrdinalIgnoreCase).ToRope(); + public static Rope DiffEncode(this Rope items, Func> itemToString, char separator = '~') where T : IEquatable + { + var find = (string.Empty + separator).ToRope(); + var encoded = "%" + Convert.ToHexString(Encoding.UTF8.GetBytes(string.Empty + separator)).ToRope(); + return items.Select(i => itemToString(i).Replace(find, encoded)).Join(separator).DiffEncode().Replace("%2B", "+").DiffEncode(); + } /// /// C# is overzealous in the replacements. Walk back on a few. diff --git a/src/Compare/DiffAlgorithmExtensions.cs b/src/Compare/DiffAlgorithmExtensions.cs index 2fe9f53..6f80b5d 100644 --- a/src/Compare/DiffAlgorithmExtensions.cs +++ b/src/Compare/DiffAlgorithmExtensions.cs @@ -721,8 +721,8 @@ internal static Rope> DiffCleanupSemantic(this Rope> diffs, C { var deletion = diffs[pointer - 1].Items; var insertion = diffs[pointer].Items; - int overlap_length1 = deletion.CommonOverlapLength(insertion); - int overlap_length2 = insertion.CommonOverlapLength(deletion); + var overlap_length1 = deletion.CommonOverlapLength(insertion); + var overlap_length2 = insertion.CommonOverlapLength(deletion); if (overlap_length1 != 0 && overlap_length1 >= overlap_length2) { if (overlap_length1 >= deletion.Length / 2.0 || diff --git a/src/Compare/PatchExtensions.cs b/src/Compare/PatchExtensions.cs index e8947f6..2d1924a 100644 --- a/src/Compare/PatchExtensions.cs +++ b/src/Compare/PatchExtensions.cs @@ -34,7 +34,7 @@ public static class PatchExtensions /// Text representation of patches. public static Rope ToPatchString(this IEnumerable> patches, Func> itemToString, char separator = '~') where T : IEquatable => - patches.Select(p => p.ToCharRope(itemToString, separator)).Combine(); + patches.Select(p => p.ToSeparatedCharRope(itemToString, separator)).Combine(); /// /// Take a list of patches and return a textual representation. diff --git a/src/Compare/Patch{T}.cs b/src/Compare/Patch{T}.cs index 2203774..15489d5 100644 --- a/src/Compare/Patch{T}.cs +++ b/src/Compare/Patch{T}.cs @@ -114,9 +114,9 @@ internal Rope ToCharRope() /// A function to convert a single item into text. /// The separator between items, defaults to ~ /// The constructed string as a . - public Rope ToCharRope(Func> itemToString, char separator = '~') + public Rope ToSeparatedCharRope(Func> itemToString, char separator = '~') { - Contract.Assert(typeof(T) != typeof(char), "This overload is only compatible with non `char` types, use ToString() instead."); + /////Contract.Assert(typeof(T) != typeof(char), "This overload is only compatible with non `char` types, use ToString() instead."); string coords1, coords2; if (this.Length1 == 0) diff --git a/src/Rope.cs b/src/Rope.cs index ea8b7e7..ba109e5 100644 --- a/src/Rope.cs +++ b/src/Rope.cs @@ -14,38 +14,17 @@ namespace Rope; using System.Diagnostics; using System.Text; using System.Buffers; -using Rope.Compare; using System.Runtime.InteropServices; -public enum RopeSplitOptions -{ - /// - /// Excludes the separator from the results, includes empty results. - /// - None = 0, - - /// - /// Excludes the separator and excludes empty results. - /// - RemoveEmpty = 1, - - /// - /// Includes the separator at the end of each result (except the last), empty results are not possible. - /// - SplitAfterSeparator = 2, - - /// - /// Includes the separator at the start of each result (except the first), empty results are not possible. - /// - SplitBeforeSeparator = 3, -} - /// /// A rope is an immutable sequence built using a b-tree style data structure that is useful for efficiently applying and storing edits, most commonly to text, but any list or sequence can be edited. /// [CollectionBuilder(typeof(RopeBuilder), "Create")] public readonly record struct Rope : IEnumerable, IReadOnlyList, IImmutableList, IEquatable> where T : IEquatable { + /// + /// Temporary buffers for performing searches. + /// private static readonly ArrayPool> BufferPool = ArrayPool>.Create(128, 16); private readonly record struct RopeNode(Rope left, Rope right); @@ -247,6 +226,12 @@ internal Rope(Rope left, Rope right) Debug.Assert(this.data is ReadOnlyMemory or RopeNode or ValueTuple, "Bad data type"); } + /// + /// Gets the element at the specified index. + /// + /// The index to get the element from + /// The element at the specified index + /// Thrown if index is larger than or equal to the length or less than 0. public readonly T this[long index] => this.ElementAt(index); /// @@ -680,12 +665,6 @@ public readonly Rope Slice(long start, long length) return this.Slice(start).RemoveRange(length); } - //[Pure] - //public static bool operator ==(Rope a, Rope b) => Rope.Equals(a, b); - - //[Pure] - //public static bool operator !=(Rope a, Rope b) => !Rope.Equals(a, b); - /// /// Concatenates two rope instances together into a single sequence. /// @@ -787,6 +766,9 @@ public readonly Rope Balanced() //// public bool IsBalanced { get; } + /// + /// Gets the integer capped Length of the rope (for interfaces such as . + /// public int Count => (int)this.Length; public T this[int index] => this[(long)index]; @@ -916,6 +898,66 @@ public readonly long CommonSuffixLength(Rope other) return n; } + /// + /// Determine if the suffix of one string is the prefix of another. + /// + /// + /// First string. + /// Second string. + /// The number of characters common to the end of the first + /// string and the start of the second string. + [Pure] + public long CommonOverlapLength(Rope second) + { + // Cache the text lengths to prevent multiple calls. + var first = this; + var firstLength = first.Length; + var secondLength = second.Length; + // Eliminate the null case. + if (firstLength == 0 || secondLength == 0) + { + return 0; + } + // Truncate the longer string. + if (firstLength > secondLength) + { + first = first.Slice(firstLength - secondLength); + } + else if (firstLength < secondLength) + { + second = second.Slice(0, firstLength); + } + + var minLength = Math.Min(firstLength, secondLength); + // Quick check for the worst case. + if (first == second) + { + return minLength; + } + + // Start by looking for a single character match + // and increase length until no match is found. + // Performance analysis: https://neil.fraser.name/news/2010/11/04/ + long best = 0; + long length = 1; + while (true) + { + var pattern = first.Slice(minLength - length); + var found = second.IndexOf(pattern); + if (found == -1) + { + return (int)best; + } + + length += found; + if (found == 0 || first.Slice(minLength - length) == second.Slice(0, length)) + { + best = length; + length++; + } + } + } + /// /// Gets the memory representation of this sequence, may allocate a new array if this instance is a tree. /// @@ -1581,12 +1623,12 @@ public readonly long IndexOf(ReadOnlyMemory find, long offset) } [Pure] - public readonly bool StartsWith(Rope find) => this.Slice(0, Math.Min(this.Length, find.Length)).IndexOf(find) == 0; + public readonly bool StartsWith(Rope find) => this.Length >= find.Length && this.Slice(0, find.Length) == find; [Pure] - public readonly bool StartsWith(ReadOnlyMemory find) => this.StartsWith(new Rope(find)); - + public readonly bool StartsWith(ReadOnlyMemory find) => this.Length >= find.Length && this.StartsWith(new Rope(find)); + [Pure] public readonly long LastIndexOf(Rope find) { if (find.Length == 0) @@ -1609,15 +1651,48 @@ public readonly long LastIndexOf(Rope find) var rentedBuffers = BufferPool.Rent(this.BufferCount); var rentedFindBuffers = BufferPool.Rent(find.BufferCount); - var buffers = rentedBuffers[..this.BufferCount]; - var findBuffers = rentedFindBuffers[..find.BufferCount]; - this.FillBuffers(buffers); - find.FillBuffers(findBuffers); + try + { + var buffers = rentedBuffers[..this.BufferCount]; + var findBuffers = rentedFindBuffers[..find.BufferCount]; + this.FillBuffers(buffers); + find.FillBuffers(findBuffers); + + var i = LastIndexOfDefaultEquality(buffers, findBuffers, find.Length); + return i; + } + finally + { + BufferPool.Return(rentedFindBuffers); + BufferPool.Return(rentedBuffers); + } + } - var i = LastIndexOfDefaultEquality(buffers, findBuffers, find.Length); + [Pure] + public readonly long LastIndexOf(Rope find, TEqualityComparer equalityComparer) where TEqualityComparer : IEqualityComparer + { + if (find.Length == 0) + { + // Adjust the return value to conform with .NET's behavior. + // return the length of 'this' as the next plausible index. + return this.Length; + } - BufferPool.Return(rentedFindBuffers); - BufferPool.Return(rentedBuffers); + if (this.data is ValueTuple value && find is ValueTuple findValue) + { + return equalityComparer.Equals(value.Item1, findValue.Item1) ? 0 : -1; + } + + var lastElement = find[^1]; + var maxIndex = this.LastIndexOf(lastElement, equalityComparer); + if (maxIndex == -1) + { + // Early exit if we can find the last element the sequence at all. + return -1; + } + + // We should be able to start from a good place here as we know the last element matches. + var i = this.Slice(0, maxIndex + 1).LastIndexOfSlow(find, equalityComparer); return i; } @@ -1628,36 +1703,42 @@ public readonly long LastIndexOf(Rope find) /// The starting index to start searching backwards from (Optional). /// The last element index that matches the sub-sequence, skipping the offset elements. [Pure] - public readonly long LastIndexOf(Rope find, long startIndex) => this.Slice(0, Math.Min(startIndex + 1, this.Length)).LastIndexOf(find); + public readonly long LastIndexOf(Rope find, long startIndex) => + this.Slice(0, Math.Min(startIndex + 1, this.Length)).LastIndexOf(find); - private readonly long LastIndexOfSlow(Rope find, long startIndex) + /// + /// Returns the last element index that matches the specified sub-sequence, working backwards from the startIndex (inclusive). + /// + /// The sequence to find, if empty will return the startIndex + 1. + /// The starting index to start searching backwards from (Optional). + /// The comparer used to compare each element. + /// The last element index that matches the sub-sequence, skipping the offset elements. + [Pure] + public readonly long LastIndexOf(Rope find, long startIndex, TEqualityComparer equalityComparer) where TEqualityComparer : IEqualityComparer => + this.Slice(0, Math.Min(startIndex + 1, this.Length)).LastIndexOf(find, equalityComparer); + + private readonly long LastIndexOfSlow(Rope find, TEqualityComparer equalityComparer) where TEqualityComparer : IEqualityComparer { - var comparer = EqualityComparer.Default; - for (var i = startIndex; i >= 0; i--) + for (int i = this.Count - 1; i >= 0; i--) { - if (i + find.Length > this.Length) continue; // Skip if find exceeds bounds from i - - var match = true; - for (var j = find.Length - 1; j >= 0 && match; j--) + if (this.Slice(i).Equals(find, equalityComparer)) { - // This check ensures we don't overshoot the bounds of 'this' - if (i + j < this.Length) - { - match = comparer.Equals(this[i + j], find[j]); - } - else - { - match = false; - break; // No need to continue if we're out of bounds - } - } - - if (match) - { - return i; // Found the last occurrence + return i; } + //if (equalityComparer.Equals(slice[i], item)) + //{ + // return i; + //} } + //for (var i = this.Length + 1 - find.Length; i >= 0; i--) + //{ + // if (this.Slice(i).Equals(find, equalityComparer)) + // { + // return i; + // } + //} + return -1; } @@ -1750,11 +1831,137 @@ private readonly long LastIndexOfDefaultEquality(ReadOnlySpan> return -1; } + private readonly long LastIndexOfCustomEquality(ReadOnlySpan> targetBuffers, ReadOnlySpan> findBuffers, long findLength, TEqualityComparer equalityComparer) where TEqualityComparer : IEqualityComparer + { + // 1. Fast forward to last element in findBuffers + // 2. Reduce find buffer using sequence equal, + // 2.1 If no match, slice target buffers + // 2.2 Else iterate until all sliced sub-sequences matches + var remainingTargetBuffers = targetBuffers; + var currentTargetSpan = remainingTargetBuffers[^1].Span; + var lastElement = findBuffers[^1].Span[^1]; + var endIndex = this.Length; + while (remainingTargetBuffers.Length > 0) + { + // Reset find to the beginning + var remainingFindBuffers = findBuffers; + var currentFindSpan = remainingFindBuffers[^1].Span; + + var index = MoveToLastElement(endIndex, lastElement, ref currentTargetSpan, ref remainingTargetBuffers); + if (index == -1) + { + return -1; + } + + var aligned = new ReverseAlignedBufferEnumerator(currentTargetSpan, currentFindSpan, remainingTargetBuffers, remainingFindBuffers); + var matches = true; + while (aligned.MoveNext()) + { + if (!aligned.CurrentA.SequenceEqual(aligned.CurrentB, equalityComparer)) + { + matches = false; + break; + } + } + + if (matches) + { + // We matched and had nothing left in B. We're done! + if (!aligned.HasRemainderB) + { + return index + 1 - findLength; + } + + // We matched everything so far in A but B had more to search for, so there was no match. + return -1; + } + else + { + // We found an index but it wasn't a match, shift by 1 and do an indexof again. + if (currentTargetSpan.Length > 0) + { + endIndex = index; + currentTargetSpan = currentTargetSpan[..^1]; + } + else + { + remainingTargetBuffers = remainingTargetBuffers[..^1]; + } + } + } + + return -1; + } + [Pure] - public readonly long LastIndexOf(T find, int startIndex) => this.Slice(0, startIndex + 1).LastIndexOf(new Rope(find)); + public readonly long LastIndexOf(T find, int startIndex) => this.Slice(0, startIndex + 1).LastIndexOf(find); [Pure] - public readonly long LastIndexOf(T find) => this.LastIndexOf(new Rope(find)); + public readonly long LastIndexOf(T find) + { + switch (this.data) + { + case ValueTuple value: + return value.Item1.Equals(find) ? 0 : -1; + case ReadOnlyMemory memory: + return memory.Span.LastIndexOf(find); + case RopeNode node: + // Node + var i = node.right.LastIndexOf(find); + if (i != -1) + { + return node.left.Length + i; + } + + i = node.left.LastIndexOf(find); + if (i != -1) + { + return i; + } + + return -1; + default: + return -1; + } + } + + [Pure] + public readonly long LastIndexOf(T find, TEqualityComparer equalityComparer) where TEqualityComparer : IEqualityComparer + { + switch (this.data) + { + case ValueTuple value: + return equalityComparer.Equals(value.Item1, find) ? 0 : -1; + case ReadOnlyMemory memory: + var slice = memory.Span; + for (var x = slice.Length - 1; x >= 0; x--) + { + if (equalityComparer.Equals(slice[x], find)) + { + return x; + } + } + + return -1; + case RopeNode node: + // Node + var i = node.right.LastIndexOf(find, equalityComparer); + if (i != -1) + { + return node.left.Length + i; + } + + i = node.left.LastIndexOf(find, equalityComparer); + if (i != -1) + { + return i; + } + + return -1; + default: + return -1; + } + } [Pure] public readonly bool EndsWith(Rope find) @@ -1773,10 +1980,7 @@ public readonly bool EndsWith(Rope find) /// The element to replace with. /// A new rope with the replacements made. [Pure] - public readonly Rope Replace(T replace, T with) - { - return this.Replace(new Rope(replace), new Rope(with)); - } + public readonly Rope Replace(T replace, T with) => this.Replace(new Rope(replace), new Rope(with)); [Pure] public readonly Rope Replace(Rope replace, Rope with) @@ -1976,6 +2180,28 @@ public readonly bool Equals(Rope other) }; } + /// + /// Gets a value indicating whether these two ropes are equivalent in terms of their content. + /// + /// The other sequence to compare to + /// Element comparer to use. + /// true if both instances hold the same sequence, otherwise false. + [Pure] + public readonly bool Equals(Rope other, IEqualityComparer equalityComparer) + { + return this.data switch + { + ReadOnlyMemory mem => + other.data switch + { + ReadOnlyMemory otherMem => mem.Span.SequenceEqual(otherMem.Span, equalityComparer), + _ => AlignedEquals(other, equalityComparer) + }, + ValueTuple value => other.data is ValueTuple otherValue && equalityComparer.Equals(value.Item1, otherValue.Item1), + _ => AlignedEquals(other, equalityComparer) + }; + } + private bool AlignedEquals(Rope other) { if (this.Length != other.Length) @@ -1991,24 +2217,71 @@ private bool AlignedEquals(Rope other) var rentedBuffers = BufferPool.Rent(this.BufferCount); var rentedFindBuffers = BufferPool.Rent(other.BufferCount); - var buffers = rentedBuffers[..this.BufferCount]; - var findBuffers = rentedFindBuffers[..other.BufferCount]; - this.FillBuffers(buffers); - other.FillBuffers(findBuffers); - var aligned = new AlignedBufferEnumerator(buffers, findBuffers); - var matches = true; - while (aligned.MoveNext()) - { - if (!aligned.CurrentA.SequenceEqual(aligned.CurrentB)) + try + { + var buffers = rentedBuffers[..this.BufferCount]; + var findBuffers = rentedFindBuffers[..other.BufferCount]; + this.FillBuffers(buffers); + other.FillBuffers(findBuffers); + var aligned = new AlignedBufferEnumerator(buffers, findBuffers); + var matches = true; + while (aligned.MoveNext()) { - matches = false; - break; + if (!aligned.CurrentA.SequenceEqual(aligned.CurrentB)) + { + matches = false; + break; + } } + + return matches; } + finally + { + BufferPool.Return(rentedFindBuffers); + BufferPool.Return(rentedBuffers); + } + } + + private bool AlignedEquals(Rope other, IEqualityComparer comparer) + { + if (this.Length != other.Length) + { + return false; + } + + if (this.Length == 0) + { + // Both must be empty if lengths are equal. + return true; + } + + var rentedBuffers = BufferPool.Rent(this.BufferCount); + var rentedFindBuffers = BufferPool.Rent(other.BufferCount); + try + { + var buffers = rentedBuffers[..this.BufferCount]; + var findBuffers = rentedFindBuffers[..other.BufferCount]; + this.FillBuffers(buffers); + other.FillBuffers(findBuffers); + var aligned = new AlignedBufferEnumerator(buffers, findBuffers); + var matches = true; + while (aligned.MoveNext()) + { + if (!aligned.CurrentA.SequenceEqual(aligned.CurrentB, comparer)) + { + matches = false; + break; + } + } - BufferPool.Return(rentedFindBuffers); - BufferPool.Return(rentedBuffers); - return matches; + return matches; + } + finally + { + BufferPool.Return(rentedFindBuffers); + BufferPool.Return(rentedBuffers); + } } /// @@ -2029,16 +2302,24 @@ private bool AlignedEquals(Rope other) _ => 0 }; - public IEnumerator GetEnumerator() - { - return new Enumerator(this); - } + public IEnumerator GetEnumerator() => new Enumerator(this); - IEnumerator IEnumerable.GetEnumerator() - { - return new Enumerator(this); - } + IEnumerator IEnumerable.GetEnumerator() => new Enumerator(this); + /// + /// Calculates based on a given length and depth whether this Rope's Tree is balanced enough, + /// or needs rebalancing. Calculation comes from the Rope paper. + /// https://www.cs.rit.edu/usr/local/pub/jeh/courses/QUARTERS/FP/Labs/CedarRope/rope-paper.pdf + /// + /// | p. 1319 - We define the depth of a leaf to be 0, and the depth of a concatenation to be + /// | one plus the maximum depth of its children. Let Fn be the nth Fibonacci number. + /// | A rope of depth n is balanced if its length is at least Fn+2, e.g. a balanced rope + /// | of depth 1 must have length at least 2. + /// + /// + /// The number of elements in the Rope. + /// The maximum depth of the Rope's tree. + /// true if the Rope is long enough to justify the depth it has. [Pure] private static bool CalculateIsBalanced(long length, int depth) => depth < DepthToFibonnaciPlusTwo.Length && length >= DepthToFibonnaciPlusTwo[depth]; @@ -2210,16 +2491,37 @@ public static Rope FromMemory(ReadOnlyMemory memory) [Pure] IImmutableList IImmutableList.Add(T value) => this.Add(value); + /// + /// Creates a new rope that is the concatentation of all this instance and all elements. + /// + /// Sequence of elements to append. + /// A new rope with the concatanetation of elements. [Pure] IImmutableList IImmutableList.AddRange(IEnumerable items) => this.AddRange(items.ToRope()); + /// + /// Alias for . + /// + /// The static empty instance . [Pure] IImmutableList IImmutableList.Clear() => Rope.Empty; [Pure] int IImmutableList.IndexOf(T item, int index, int count, IEqualityComparer? equalityComparer) { - throw new NotImplementedException(); + if (index < 0 || index > this.Length) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + if ((ulong)count > (ulong)(this.Length - index)) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + equalityComparer ??= EqualityComparer.Default; + var i = this[index..(index + count)].IndexOf(new[] { item }, equalityComparer); + return (int)(i != -1 ? index + i : -1); } [Pure] @@ -2231,19 +2533,50 @@ int IImmutableList.IndexOf(T item, int index, int count, IEqualityComparer [Pure] int IImmutableList.LastIndexOf(T item, int index, int count, IEqualityComparer? equalityComparer) { - throw new NotImplementedException(); + if (this.Count == 0) + { + return (index == -1 && count == 0) ? -1 : throw new ArgumentOutOfRangeException(nameof(index)); + } + + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (index > this.Count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + if (count == 0) + { + return -1; + } + + equalityComparer ??= EqualityComparer.Default; + int endIndex = Math.Min(index, this.Count - 1); + int startIndex = Math.Max(0, endIndex - count + 1); + var i = this.Slice(startIndex, endIndex - startIndex + 1).LastIndexOf(item, equalityComparer); + return i != -1 ? (int)(startIndex + i) : -1; } [Pure] IImmutableList IImmutableList.Remove(T value, IEqualityComparer? equalityComparer) { - throw new NotImplementedException(); + var i = this.IndexOf(value); + return i == -1 ? this : this[..(int)i] + this[(int)(i + 1)..]; } [Pure] IImmutableList IImmutableList.RemoveAll(Predicate match) { - throw new NotImplementedException(); + // TODO: Improve perf and efficiency. + return this.Where(p => !match(p)).ToRope(); } [Pure] @@ -2252,7 +2585,8 @@ IImmutableList IImmutableList.RemoveAll(Predicate match) [Pure] IImmutableList IImmutableList.RemoveRange(IEnumerable items, IEqualityComparer? equalityComparer) { - throw new NotImplementedException(); + // TODO: Improve perf and efficiency. + return this.Where(i => !items.Contains(i)).ToRope(); } [Pure] diff --git a/src/RopeExtensions.cs b/src/RopeExtensions.cs index 9ce1ec7..c4ff676 100644 --- a/src/RopeExtensions.cs +++ b/src/RopeExtensions.cs @@ -1,6 +1,7 @@ +using System; using System.Diagnostics.Contracts; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using Rope.Compare; namespace Rope; @@ -11,6 +12,12 @@ public static class RopeExtensions /// public const int LargeObjectHeapBytes = 85_000 - 24; + /// + /// An attempt to determine a CPU-cache aligned buffer size for the given input type. + /// + /// The element being sized. + /// The default CPU cache line to optimise for. + /// An integer of the number of elements that makes for a CPU aligned buffer. internal static int CalculateAlignedBufferLength(int cacheLineSize = 64) { var elementSize = Unsafe.SizeOf(); @@ -35,6 +42,12 @@ internal static int CalculateAlignedBufferLength(int cacheLineSize = 64) return alignedBufferSize / elementSize; } + /// + /// Math.Pow for integers + /// + /// The integer to multiply to the power of + /// The power to multiply + /// A power of the input integer. internal static int IntPow(this int x, uint pow) { int ret = 1; @@ -107,63 +120,46 @@ public static Rope Combine(this IEnumerable> leaves) where T : IEq } /// - /// Determine if the suffix of one string is the prefix of another. + /// Replaces all occurrences of a specified string within the source with another string. /// - /// - /// First string. - /// Second string. - /// The number of characters common to the end of the first - /// string and the start of the second string. - [Pure] - public static int CommonOverlapLength(this Rope first, Rope second) where T : IEquatable - { - // Cache the text lengths to prevent multiple calls. - var firstLength = first.Length; - var secondLength = second.Length; - // Eliminate the null case. - if (firstLength == 0 || secondLength == 0) - { - return 0; - } - // Truncate the longer string. - if (firstLength > secondLength) - { - first = first.Slice(firstLength - secondLength); - } - else if (firstLength < secondLength) - { - second = second.Slice(0, firstLength); - } - - var minLength = Math.Min(firstLength, secondLength); - // Quick check for the worst case. - if (first == second) + /// The source in which to perform the replacement. + /// The to be replaced. + /// The to replace all occurrences of . + /// The string comparison rule to use when matching. + /// + /// A new that is equivalent to the source except that all instances of + /// are replaced with . If is not found in the source , + /// the method returns the original . + /// + /// + /// Thrown when an unsupported option is provided. + /// + /// + /// This method uses different character comparers based on the specified option: + /// - For Ordinal comparison, it uses the default Replace method. + /// - For OrdinalIgnoreCase, it uses a custom OrdinalIgnoreCaseCharComparer. + /// - For culture-specific comparisons (CurrentCulture, CurrentCultureIgnoreCase, InvariantCulture, InvariantCultureIgnoreCase), + /// it uses the appropriate . + /// The method is case-sensitive or case-insensitive based on the chosen comparison option. + /// + /// + /// + /// Rope source = new Rope("Hello, World!"); + /// Rope result = source.Replace(new Rope("o"), new Rope("0"), StringComparison.OrdinalIgnoreCase); + /// // result will be "Hell0, W0rld!" + /// + /// + public static Rope Replace(this Rope source, Rope replace, Rope with, StringComparison comparison) => + comparison switch { - return (int)minLength; - } - - // Start by looking for a single character match - // and increase length until no match is found. - // Performance analysis: https://neil.fraser.name/news/2010/11/04/ - long best = 0; - long length = 1; - while (true) - { - var pattern = first.Slice(minLength - length); - var found = second.IndexOf(pattern); - if (found == -1) - { - return (int)best; - } - - length += found; - if (found == 0 || first.Slice(minLength - length) == second.Slice(0, length)) - { - best = length; - length++; - } - } - } + StringComparison.Ordinal => source.Replace(replace, with), + StringComparison.OrdinalIgnoreCase => source.Replace(replace, with, CharComparer.OrdinalIgnoreCase), + StringComparison.CurrentCulture => source.Replace(replace, with, CharComparer.CurrentCulture), + StringComparison.CurrentCultureIgnoreCase => source.Replace(replace, with, CharComparer.CurrentCultureIgnoreCase), + StringComparison.InvariantCulture => source.Replace(replace, with, CharComparer.InvariantCulture), + StringComparison.InvariantCultureIgnoreCase => source.Replace(replace, with, CharComparer.InvariantCultureIgnoreCase), + _ => throw new NotSupportedException(), + }; /// /// Joins a sequence of elements with a nominated separator. diff --git a/src/RopeSplitOptions.cs b/src/RopeSplitOptions.cs new file mode 100644 index 0000000..edebfe0 --- /dev/null +++ b/src/RopeSplitOptions.cs @@ -0,0 +1,26 @@ +// Copyright 2024 Andrew Chisholm (https://github.com/FlatlinerDOA) + +namespace Rope; + +public enum RopeSplitOptions +{ + /// + /// Excludes the separator from the results, includes empty results. + /// + None = 0, + + /// + /// Excludes the separator and excludes empty results. + /// + RemoveEmpty = 1, + + /// + /// Includes the separator at the end of each result (except the last), empty results are not possible. + /// + SplitAfterSeparator = 2, + + /// + /// Includes the separator at the start of each result (except the first), empty results are not possible. + /// + SplitBeforeSeparator = 3, +} diff --git a/tests/DiffAnything.cs b/tests/DiffAnything.cs deleted file mode 100644 index 041d05e..0000000 --- a/tests/DiffAnything.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Rope.UnitTests -{ - using Rope.Compare; - - [TestClass] - public class DiffAnything - { - [TestMethod] - public void CreateDiffOfStrings() - { - Rope sourceText = "abcdef"; - Rope targetText = "abefg"; - - // Create a list of differences. - Rope> diffs = sourceText.Diff(targetText); - - // Recover either side from the list of differences. - Rope recoveredSourceText = diffs.ToSource(); - Rope recoveredTargetText = diffs.ToTarget(); - - // Create a Patch string (like Git's patch text) - Rope> patches = diffs.ToPatches(); // A list of patches - Rope patchText = patches.ToPatchString(); // A single string of patch text. - Console.WriteLine(patchText); - /** Outputs: - @@ -1,6 +1,5 @@ - ab - -cd - ef - +g - */ - - // Parse out the patches from the patch text. - Rope> parsedPatches = Patches.Parse(patchText); - } - - [TestMethod] - public void CreateDiffsOfAnything() - { - Rope original = - [ - new Person("Stephen", "King"), - new Person("Jane", "Austen"), - new Person("Mary", "Shelley"), - new Person("JRR", "Tokien"), - new Person("James", "Joyce"), - ]; - - Rope updated = - [ - new Person("Stephen", "King"), - new Person("Jane", "Austen"), - new Person("JRR", "Tokien"), - new Person("Frank", "Miller"), - new Person("George", "Orwell"), - new Person("James", "Joyce"), - ]; - - Rope> changes = original.Diff(updated, DiffOptions.Default); - Assert.AreEqual(2, changes.Count(d => d.Operation != Operation.Equal)); - - // Convert to a Delta string - Rope delta = changes.ToDelta(p => p.ToString()); - - // Rebuild the diff from the original list and a delta. - Rope> fromDelta = Delta.Parse(delta, original, Person.Parse); - - // Get back the original list - Assert.AreEqual(fromDelta.ToSource(), original); - - // Get back the updated list. - Assert.AreEqual(fromDelta.ToTarget(), updated); - - // Make a patch text - Rope> patches = fromDelta.ToPatches(); - - // Convert patches to text - Rope patchText = patches.ToPatchString(p => p.ToString()); - - // Parse the patches back again - Rope> parsedPatches = Patches.Parse(patchText.ToRope(), Person.Parse); - Assert.AreEqual(parsedPatches, patches); - } - - private record Person(Rope FirstName, Rope LastName) - { - public override string ToString() => $"{FirstName} {LastName}"; - - public static Person Parse(Rope source) => new(source.Split(' ').FirstOrDefault(), source.Split(' ').Skip(1).FirstOrDefault()); - } - } -} diff --git a/tests/DiffMatchPatchTests.cs b/tests/DiffMatchPatchTests.cs index 3d7a8b1..cca0b0d 100644 --- a/tests/DiffMatchPatchTests.cs +++ b/tests/DiffMatchPatchTests.cs @@ -1094,8 +1094,8 @@ public void PatchMakeTest() patches.ToPatchString()); AssertEquals( - "patch_toText: Character encoding with special separator.", - "@@ -1,21 +1,21 @@\n-%601234567890-=%5B%5D%5C;',./\n+~!@#$%25%5E&*()_+%7B%7D%7C:%22%3C%3E?\n", + "patch_toText: Object encoding with special separator.", + "@@ -1,21 +1,21 @@\n-%25257E%2560%25257E~%25257E1%25257E~%25257E2%25257E~%25257E3%25257E~%25257E4%25257E~%25257E5%25257E~%25257E6%25257E~%25257E7%25257E~%25257E8%25257E~%25257E9%25257E~%25257E0%25257E~%25257E-%25257E~%25257E=%25257E~%25257E%255B%25257E~%25257E%255D%25257E~%25257E%255C%25257E~%25257E;%25257E~%25257E'%25257E~%25257E,%25257E~%25257E.%25257E~%25257E/%25257E\n+%25257E%25257E%25257E~%25257E!%25257E~%25257E@%25257E~%25257E#%25257E~%25257E$%25257E~%25257E%2525%25257E~%25257E%255E%25257E~%25257E&%25257E~%25257E*%25257E~%25257E(%25257E~%25257E)%25257E~%25257E_%25257E~%25257E+%25257E~%25257E%257B%25257E~%25257E%257D%25257E~%25257E%257C%25257E~%25257E:%25257E~%25257E%2522%25257E~%25257E%253C%25257E~%25257E%253E%25257E~%25257E?%25257E\n", patches.ToPatchString(c => "~" + c.ToString() + "~", '~')); diffs = new Rope>(new[] { diff --git a/tests/HowToUse.cs b/tests/HowToUse.cs new file mode 100644 index 0000000..7ffa535 --- /dev/null +++ b/tests/HowToUse.cs @@ -0,0 +1,121 @@ +namespace Rope.UnitTests; + +using Rope.Compare; + +[TestClass] +public class HowToUse +{ + [TestMethod] + public void Introduction() + { + // Converting to a Rope doesn't allocate any strings (simply points to the original memory). + Rope myRope1 = "My favourite text".ToRope(); + + // In fact strings are implicitly convertible to Rope, this makes Rope a drop in replacement for `string` in most cases; + Rope myRope2 = "My favourite text"; + + // With Rope, splits don't allocate any new strings either. + IEnumerable> words = myRope2.Split(' '); + + // Calling ToString() allocates a new string at the time of conversion. + Console.WriteLine(words.First().ToString()); + + // Warning: Assigning a Rope to a string requires calling .ToString() and hence copying memory. + string text2 = (myRope2 + " My second favourite text").ToString(); + + // Better: Assigning to another rope makes a new rope out of the other two ropes, no string allocations or copies. + Rope text3 = myRope2 + " My second favourite text"; + + // Value-like equivalence, no need for SequenceEqual!. + Assert.IsTrue("test".ToRope() == ("te".ToRope() + "st".ToRope())); + Assert.IsTrue("test".ToRope().GetHashCode() == ("te".ToRope() + "st".ToRope()).GetHashCode()); + + // Store a rope of anything, just like List! + // This makes an immutable and thread safe list of people. + Rope ropeOfPeople = [new Person("Frank", "Stevens"), new Person("Jane", "Seymour")]; + } + + [TestMethod] + public void CreateDiffOfStrings() + { + Rope sourceText = "abcdef"; + Rope targetText = "abefg"; + + // Create a list of differences. + Rope> diffs = sourceText.Diff(targetText); + + // Recover either side from the list of differences. + Rope recoveredSourceText = diffs.ToSource(); + Rope recoveredTargetText = diffs.ToTarget(); + + // Create a Patch string (like Git's patch text) + Rope> patches = diffs.ToPatches(); // A list of patches + Rope patchText = patches.ToPatchString(); // A single string of patch text. + Console.WriteLine(patchText); + /** Outputs: + @@ -1,6 +1,5 @@ + ab + -cd + ef + +g + */ + + // Parse out the patches from the patch text. + Rope> parsedPatches = Patches.Parse(patchText); + } + + [TestMethod] + public void CreateDiffsOfAnything() + { + Rope original = + [ + new Person("Stephen", "King"), + new Person("Jane", "Austen"), + new Person("Mary", "Shelley"), + new Person("JRR", "Tokien"), + new Person("James", "Joyce"), + ]; + + Rope updated = + [ + new Person("Stephen", "King"), + new Person("Jane", "Austen"), + new Person("JRR", "Tokien"), + new Person("Frank", "Miller"), + new Person("George", "Orwell"), + new Person("James", "Joyce"), + ]; + + Rope> changes = original.Diff(updated, DiffOptions.Default); + Assert.AreEqual(2, changes.Count(d => d.Operation != Operation.Equal)); + + // Convert to a Delta string + Rope delta = changes.ToDelta(p => p.ToString()); + + // Rebuild the diff from the original list and a delta. + Rope> fromDelta = Delta.Parse(delta, original, Person.Parse); + + // Get back the original list + Assert.AreEqual(fromDelta.ToSource(), original); + + // Get back the updated list. + Assert.AreEqual(fromDelta.ToTarget(), updated); + + // Make a patch text + Rope> patches = fromDelta.ToPatches(); + + // Convert patches to text + Rope patchText = patches.ToPatchString(p => p.ToString()); + + // Parse the patches back again + Rope> parsedPatches = Patches.Parse(patchText, Person.Parse); + Assert.AreEqual(parsedPatches, patches); + } + + private record Person(Rope FirstName, Rope LastName) + { + public override string ToString() => $"{FirstName} {LastName}"; + + public static Person Parse(Rope source) => new(source.Split(' ').FirstOrDefault(), source.Split(' ').Skip(1).FirstOrDefault()); + } +} diff --git a/tests/ImmutableListInterface.cs b/tests/ImmutableListInterface.cs new file mode 100644 index 0000000..005f195 --- /dev/null +++ b/tests/ImmutableListInterface.cs @@ -0,0 +1,236 @@ +namespace Rope.UnitTests; + +using Rope.Compare; +using System.Collections.Immutable; +using System.Globalization; + +[TestClass] +public class ImmutableListInterface +{ + public const string TestData = "ABC def gHiIİı JkL"; + + public static readonly CultureInfo Turkish = CultureInfo.CreateSpecificCulture("tr-TR"); + + public static IImmutableList Subject = TestData.ToRope(); + + private static readonly CharComparer TurkishCaseInsensitiveComparer = new CharComparer( + Turkish, + CompareOptions.IgnoreCase); + + [TestMethod] + [DataRow('A', 0, 1)] + [DataRow('a', 0, 1)] + [DataRow('B', 0, 1)] + [DataRow('b', 0, 1)] + [DataRow('B', 1, 1)] + [DataRow('b', 1, 1)] + [DataRow('L', 0, 18)] + [DataRow('l', 0, 18)] + [DataRow('Z', 0, 18)] + [DataRow('z', 0, 18)] + [DataRow('I', 0, 18)] + [DataRow('ı', 0, 18)] + [DataRow('i', 0, 18)] + [DataRow('İ', 0, 18)] + [DataRow('A', 1, 1)] + [DataRow('a', 1, 1)] + [DataRow('B', 2, 1)] + [DataRow('b', 2, 1)] + [DataRow('L', 14, 4)] + [DataRow('l', 14, 4)] + [DataRow('Z', 14, 4)] + [DataRow('z', 14, 4)] + public void IndexOfOrdinal(char find, int startIndex, int count) => Assert.AreEqual( + TestData.IndexOf(string.Empty + find, startIndex, count, StringComparison.Ordinal), + Subject.IndexOf(find, startIndex, count, CharComparer.Ordinal)); + + [TestMethod] + [DataRow('A', 0, 1)] + [DataRow('a', 0, 1)] + [DataRow('B', 0, 1)] + [DataRow('b', 0, 1)] + [DataRow('B', 1, 1)] + [DataRow('b', 1, 1)] + [DataRow('L', 0, 18)] + [DataRow('l', 0, 18)] + [DataRow('Z', 0, 18)] + [DataRow('z', 0, 18)] + [DataRow('I', 0, 18)] + [DataRow('ı', 0, 18)] + [DataRow('i', 0, 18)] + [DataRow('İ', 0, 18)] + [DataRow('A', 1, 1)] + [DataRow('a', 1, 1)] + [DataRow('B', 2, 1)] + [DataRow('b', 2, 1)] + [DataRow('L', 14, 4)] + [DataRow('l', 14, 4)] + [DataRow('Z', 14, 4)] + [DataRow('z', 14, 4)] + public void IndexOfOrdinalIgnoreCase(char find, int startIndex, int count) => Assert.AreEqual( + TestData.IndexOf(string.Empty + find, startIndex, count, StringComparison.OrdinalIgnoreCase), + Subject.IndexOf(find, startIndex, count, CharComparer.OrdinalIgnoreCase)); + + [TestMethod] + [DataRow('A', 0, 1)] + [DataRow('a', 0, 1)] + [DataRow('B', 0, 1)] + [DataRow('b', 0, 1)] + [DataRow('B', 1, 1)] + [DataRow('b', 1, 1)] + [DataRow('L', 0, 18)] + [DataRow('l', 0, 18)] + [DataRow('Z', 0, 18)] + [DataRow('z', 0, 18)] + [DataRow('I', 0, 18)] + [DataRow('ı', 0, 18)] + [DataRow('i', 0, 18)] + [DataRow('İ', 0, 18)] + [DataRow('A', 1, 1)] + [DataRow('a', 1, 1)] + [DataRow('B', 2, 1)] + [DataRow('b', 2, 1)] + [DataRow('L', 14, 4)] + [DataRow('l', 14, 4)] + [DataRow('Z', 14, 4)] + [DataRow('z', 14, 4)] + public void IndexOfCultureIgnoreCase(char find, int startIndex, int count) => Assert.AreEqual( + TestData.IndexOf(string.Empty + find, startIndex, count, StringComparison.InvariantCultureIgnoreCase), + Subject.IndexOf(find, startIndex, count, CharComparer.InvariantCultureIgnoreCase)); + + [TestMethod] + [DataRow('A', 0, 1, 0)] + [DataRow('a', 0, 1, 0)] + [DataRow('B', 0, 2, 1)] + [DataRow('b', 0, 2, 1)] + [DataRow('B', 2, 1, -1)] + [DataRow('b', 2, 1, -1)] + [DataRow('L', 0, 18, 17)] + [DataRow('l', 0, 18, 17)] + [DataRow('Z', 0, 18, -1)] + [DataRow('z', 0, 18, -1)] + [DataRow('I', 0, 18, 11)] + [DataRow('ı', 0, 18, 11)] + [DataRow('i', 0, 18, 10)] + [DataRow('İ', 0, 18, 10)] + [DataRow('A', 1, 1, -1)] + [DataRow('a', 1, 1, -1)] + [DataRow('B', 2, 1, -1)] + [DataRow('b', 2, 1, -1)] + [DataRow('L', 18, 0, -1)] + [DataRow('l', 18, 0, -1)] + [DataRow('Z', 18, 0, -1)] + [DataRow('z', 18, 0, -1)] + public void IndexOfTurkishIgnoreCase(char find, int startIndex, int count, int expected) => Assert.AreEqual( + expected, + Subject.IndexOf(find, startIndex, count, TurkishCaseInsensitiveComparer)); + + + [TestMethod] + [DataRow('A', 0, 1)] + [DataRow('a', 0, 1)] + [DataRow('B', 0, 1)] + [DataRow('b', 0, 1)] + [DataRow('B', 1, 1)] + [DataRow('b', 1, 1)] + [DataRow('L', 18, 17)] + [DataRow('l', 18, 17)] + [DataRow('Z', 18, 17)] + [DataRow('z', 18, 17)] + [DataRow('I', 18, 17)] + [DataRow('ı', 18, 17)] + [DataRow('i', 18, 17)] + [DataRow('İ', 18, 17)] + [DataRow('A', 1, 1)] + [DataRow('a', 1, 1)] + [DataRow('B', 2, 1)] + [DataRow('b', 2, 1)] + [DataRow('L', 14, 4)] + [DataRow('l', 14, 4)] + [DataRow('Z', 14, 4)] + [DataRow('z', 14, 4)] + public void LastIndexOfOrdinal(char find, int startIndex, int count) => Assert.AreEqual( + TestData.LastIndexOf(string.Empty + find, startIndex, count, StringComparison.Ordinal), + Subject.LastIndexOf(find, startIndex, count, CharComparer.Ordinal)); + + [TestMethod] + [DataRow('A', 0, 1)] + [DataRow('a', 0, 1)] + [DataRow('B', 0, 1)] + [DataRow('b', 0, 1)] + [DataRow('B', 1, 1)] + [DataRow('b', 1, 1)] + [DataRow('L', 18, 17)] + [DataRow('l', 18, 17)] + [DataRow('Z', 18, 17)] + [DataRow('z', 18, 17)] + [DataRow('I', 18, 17)] + [DataRow('ı', 18, 17)] + [DataRow('i', 18, 17)] + [DataRow('İ', 18, 17)] + [DataRow('A', 1, 1)] + [DataRow('a', 1, 1)] + [DataRow('B', 2, 1)] + [DataRow('b', 2, 1)] + [DataRow('L', 14, 4)] + [DataRow('l', 14, 4)] + [DataRow('Z', 14, 4)] + [DataRow('z', 14, 4)] + public void LastIndexOfOrdinalIgnoreCase(char find, int startIndex, int count) => Assert.AreEqual( + TestData.LastIndexOf(string.Empty + find, startIndex, count, StringComparison.OrdinalIgnoreCase), + Subject.LastIndexOf(find, startIndex, count, CharComparer.OrdinalIgnoreCase)); + + [TestMethod] + [DataRow('A', 0, 1)] + [DataRow('a', 0, 1)] + [DataRow('B', 0, 1)] + [DataRow('b', 0, 1)] + [DataRow('B', 1, 1)] + [DataRow('b', 1, 1)] + [DataRow('L', 18, 17)] + [DataRow('l', 18, 17)] + [DataRow('Z', 18, 17)] + [DataRow('z', 18, 17)] + [DataRow('I', 18, 17)] + [DataRow('ı', 18, 17)] + [DataRow('i', 18, 17)] + [DataRow('İ', 18, 17)] + [DataRow('A', 1, 1)] + [DataRow('a', 1, 1)] + [DataRow('B', 2, 1)] + [DataRow('b', 2, 1)] + [DataRow('L', 14, 4)] + [DataRow('l', 14, 4)] + [DataRow('Z', 14, 4)] + [DataRow('z', 14, 4)] + public void LastIndexOfCultureIgnoreCase(char find, int startIndex, int count) => Assert.AreEqual( + TestData.LastIndexOf(string.Empty + find, startIndex, count, StringComparison.InvariantCultureIgnoreCase), + Subject.LastIndexOf(find, startIndex, count, CharComparer.InvariantCultureIgnoreCase)); + + [TestMethod] + [DataRow('A', 0, 1, 0)] + [DataRow('a', 0, 1, 0)] + [DataRow('B', 0, 1, -1)] + [DataRow('b', 0, 1, -1)] + [DataRow('B', 1, 1, 1)] + [DataRow('b', 1, 1, 1)] + [DataRow('L', 18, 17, 17)] + [DataRow('l', 18, 17, 17)] + [DataRow('Z', 18, 17, -1)] + [DataRow('z', 18, 17, -1)] + [DataRow('I', 18, 17, 13)] + [DataRow('ı', 18, 17, 13)] + [DataRow('i', 18, 17, 12)] + [DataRow('İ', 18, 17, 12)] + [DataRow('A', 1, 1, -1)] + [DataRow('a', 1, 1, -1)] + [DataRow('B', 2, 1, -1)] + [DataRow('b', 2, 1, -1)] + [DataRow('L', 14, 4, -1)] + [DataRow('l', 14, 4, -1)] + [DataRow('Z', 14, 4, -1)] + [DataRow('z', 14, 4, -1)] + public void LastIndexOfTurkishIgnoreCase(char find, int startIndex, int count, int expected) => Assert.AreEqual( + expected, + Subject.LastIndexOf(find, startIndex, count, TurkishCaseInsensitiveComparer)); +} diff --git a/tests/RopeEditingTests.cs b/tests/RopeEditingTests.cs index 6f59331..7b79ac6 100644 --- a/tests/RopeEditingTests.cs +++ b/tests/RopeEditingTests.cs @@ -1,237 +1,236 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Rope.UnitTests +namespace Rope.UnitTests; + +[TestClass] +public class RopeEditingTests { - [TestClass] - public class RopeEditingTests + [TestMethod] + [DataRow(0, 0, 0)] + [DataRow(1, 1, 0)] + [DataRow(1, 1, 1)] + [DataRow(2, 1, 0)] + [DataRow(2, 1, 1)] + [DataRow(2, 1, 2)] + [DataRow(2, 1, 0)] + [DataRow(2, 2, 1)] + [DataRow(2, 2, 2)] + [DataRow(3, 2, 2)] + [DataRow(5, 2, 0)] + [DataRow(5, 2, 2)] + [DataRow(5, 2, 3)] + [DataRow(5, 2, 5)] + [DataRow(5, 5, 5)] + [DataRow(5, 5, 4)] + [DataRow(256, 3, 4)] + [DataRow(256, 24, 3)] + public void SliceStart(int textLength, int chunkSize, int startIndex) { - [TestMethod] - [DataRow(0, 0, 0)] - [DataRow(1, 1, 0)] - [DataRow(1, 1, 1)] - [DataRow(2, 1, 0)] - [DataRow(2, 1, 1)] - [DataRow(2, 1, 2)] - [DataRow(2, 1, 0)] - [DataRow(2, 2, 1)] - [DataRow(2, 2, 2)] - [DataRow(3, 2, 2)] - [DataRow(5, 2, 0)] - [DataRow(5, 2, 2)] - [DataRow(5, 2, 3)] - [DataRow(5, 2, 5)] - [DataRow(5, 5, 5)] - [DataRow(5, 5, 4)] - [DataRow(256, 3, 4)] - [DataRow(256, 24, 3)] - public void SliceStart(int textLength, int chunkSize, int startIndex) - { - var (text, rope) = TestData.Create(textLength, chunkSize); - var expected = new string(text.AsSpan().Slice(startIndex)); - var actual = rope.Slice(startIndex); - Assert.AreEqual(expected, actual.ToString()); - } + var (text, rope) = TestData.Create(textLength, chunkSize); + var expected = new string(text.AsSpan().Slice(startIndex)); + var actual = rope.Slice(startIndex); + Assert.AreEqual(expected, actual.ToString()); + } - [TestMethod] - [DataRow(0, 0, 0, 0)] - [DataRow(1, 1, 0, 1)] - [DataRow(1, 1, 1, 0)] - [DataRow(2, 1, 0, 1)] - [DataRow(2, 1, 1, 1)] - [DataRow(2, 1, 2, 0)] - [DataRow(2, 1, 0, 2)] - [DataRow(2, 2, 1, 1)] - [DataRow(2, 2, 2, 0)] - [DataRow(3, 2, 2, 1)] - [DataRow(5, 2, 0, 5)] - [DataRow(5, 2, 2, 3)] - [DataRow(5, 2, 3, 2)] - [DataRow(5, 2, 4, 1)] - [DataRow(5, 2, 5, 0)] - [DataRow(5, 5, 4, 1)] - [DataRow(256, 3, 4, 200)] - [DataRow(256, 24, 3, 200)] - public void SliceStartAndLength(int textLength, int chunkSize, int startIndex, int length) - { - var (text, rope) = TestData.Create(textLength, chunkSize); - var expected = new string(text.AsSpan().Slice(startIndex, length)); - var actual = rope.Slice(startIndex, length); - Assert.AreEqual(expected, actual.ToString()); - } + [TestMethod] + [DataRow(0, 0, 0, 0)] + [DataRow(1, 1, 0, 1)] + [DataRow(1, 1, 1, 0)] + [DataRow(2, 1, 0, 1)] + [DataRow(2, 1, 1, 1)] + [DataRow(2, 1, 2, 0)] + [DataRow(2, 1, 0, 2)] + [DataRow(2, 2, 1, 1)] + [DataRow(2, 2, 2, 0)] + [DataRow(3, 2, 2, 1)] + [DataRow(5, 2, 0, 5)] + [DataRow(5, 2, 2, 3)] + [DataRow(5, 2, 3, 2)] + [DataRow(5, 2, 4, 1)] + [DataRow(5, 2, 5, 0)] + [DataRow(5, 5, 4, 1)] + [DataRow(256, 3, 4, 200)] + [DataRow(256, 24, 3, 200)] + public void SliceStartAndLength(int textLength, int chunkSize, int startIndex, int length) + { + var (text, rope) = TestData.Create(textLength, chunkSize); + var expected = new string(text.AsSpan().Slice(startIndex, length)); + var actual = rope.Slice(startIndex, length); + Assert.AreEqual(expected, actual.ToString()); + } - [TestMethod] - [DataRow(0, 0, 0)] - [DataRow(1, 1, 0)] - [DataRow(1, 1, 1)] - [DataRow(2, 1, 0)] - [DataRow(2, 1, 1)] - [DataRow(2, 1, 2)] - [DataRow(2, 1, 0)] - [DataRow(2, 2, 1)] - [DataRow(2, 2, 2)] - [DataRow(3, 2, 2)] - [DataRow(5, 2, 0)] - [DataRow(5, 2, 2)] - [DataRow(5, 2, 3)] - [DataRow(5, 2, 5)] - [DataRow(256, 3, 4)] - [DataRow(256, 24, 3)] - public void SplitAt(int textLength, int chunkSize, int startIndex) - { - var (text, rope) = TestData.Create(textLength, chunkSize); - var (expectedLeft, expectedRight) = (text[..startIndex], text[startIndex..]); - var (actualLeft, actualRight) = rope.SplitAt(startIndex); - Assert.AreEqual(expectedLeft, actualLeft.ToString()); - Assert.AreEqual(expectedRight, actualRight.ToString()); - } + [TestMethod] + [DataRow(0, 0, 0)] + [DataRow(1, 1, 0)] + [DataRow(1, 1, 1)] + [DataRow(2, 1, 0)] + [DataRow(2, 1, 1)] + [DataRow(2, 1, 2)] + [DataRow(2, 1, 0)] + [DataRow(2, 2, 1)] + [DataRow(2, 2, 2)] + [DataRow(3, 2, 2)] + [DataRow(5, 2, 0)] + [DataRow(5, 2, 2)] + [DataRow(5, 2, 3)] + [DataRow(5, 2, 5)] + [DataRow(256, 3, 4)] + [DataRow(256, 24, 3)] + public void SplitAt(int textLength, int chunkSize, int startIndex) + { + var (text, rope) = TestData.Create(textLength, chunkSize); + var (expectedLeft, expectedRight) = (text[..startIndex], text[startIndex..]); + var (actualLeft, actualRight) = rope.SplitAt(startIndex); + Assert.AreEqual(expectedLeft, actualLeft.ToString()); + Assert.AreEqual(expectedRight, actualRight.ToString()); + } - [TestMethod] - public void AblationOfSplitAt() + [TestMethod] + public void AblationOfSplitAt() + { + var sequence = Enumerable.Range(0, 1200).Select(c => (char)((int)'9' + (c % 26))).ToList(); + for (int chunkSize = 1; chunkSize < 100; chunkSize += 1) { - var sequence = Enumerable.Range(0, 1200).Select(c => (char)((int)'9' + (c % 26))).ToList(); - for (int chunkSize = 1; chunkSize < 100; chunkSize += 1) + var rope = sequence.Chunk(chunkSize).Select(chunk => new Rope(chunk.ToArray())).Aggregate(Rope.Empty, (prev, next) => prev + next); + for (int splitPoint = 0; splitPoint < rope.Length; splitPoint++) { - var rope = sequence.Chunk(chunkSize).Select(chunk => new Rope(chunk.ToArray())).Aggregate(Rope.Empty, (prev, next) => prev + next); - for (int splitPoint = 0; splitPoint < rope.Length; splitPoint++) - { - var (left, right) = rope.SplitAt(splitPoint); - Assert.AreEqual(splitPoint, left.Length); - Assert.AreEqual(rope.Length - splitPoint, right.Length); - } + var (left, right) = rope.SplitAt(splitPoint); + Assert.AreEqual(splitPoint, left.Length); + Assert.AreEqual(rope.Length - splitPoint, right.Length); } } + } - [TestMethod] - public void ElementIndexerBeforeBreak() => Assert.AreEqual("this is a test"[3], ("this".ToRope() + " is a test.".ToRope())[3]); - - [TestMethod] - public void ElementIndexerAfterBreak() => Assert.AreEqual("this is a test"[4], ("this".ToRope() + " is a test.".ToRope())[4]); + [TestMethod] + public void ElementIndexerBeforeBreak() => Assert.AreEqual("this is a test"[3], ("this".ToRope() + " is a test.".ToRope())[3]); - [TestMethod] - public void RangeIndexer() => Assert.AreEqual("this is a test."[3..8].ToRope(), ("this".ToRope() + " is a test.".ToRope())[3..8]); + [TestMethod] + public void ElementIndexerAfterBreak() => Assert.AreEqual("this is a test"[4], ("this".ToRope() + " is a test.".ToRope())[4]); - [TestMethod] - public void RangeEndIndexer() => Assert.AreEqual("this is a test."[3..^3].ToRope(), ("this".ToRope() + " is a test.".ToRope())[3..^3]); + [TestMethod] + public void RangeIndexer() => Assert.AreEqual("this is a test."[3..8].ToRope(), ("this".ToRope() + " is a test.".ToRope())[3..8]); - [TestMethod] - public void SplitBySingleElement() => Assert.IsTrue("this is a test of the things I split by.".ToRope().Split(' ').Select(c => c.ToString()).SequenceEqual("this is a test of the things I split by.".Split(' '))); + [TestMethod] + public void RangeEndIndexer() => Assert.AreEqual("this is a test."[3..^3].ToRope(), ("this".ToRope() + " is a test.".ToRope())[3..^3]); - [TestMethod] - public void SplitBySingleElementNotFound() => Assert.IsTrue("this is a test of the things I split by.".ToRope().Split('_').Select(c => c.ToString()).SequenceEqual(new[] { "this is a test of the things I split by." })); + [TestMethod] + public void SplitBySingleElement() => Assert.IsTrue("this is a test of the things I split by.".ToRope().Split(' ').Select(c => c.ToString()).SequenceEqual("this is a test of the things I split by.".Split(' '))); - [TestMethod] - public void SplitBySingleElementAtEnd() => Assert.IsTrue("this is a test of the things I split by.".ToRope().Split('.').Select(c => c.ToString()).SequenceEqual("this is a test of the things I split by.".Split('.'))); + [TestMethod] + public void SplitBySingleElementNotFound() => Assert.IsTrue("this is a test of the things I split by.".ToRope().Split('_').Select(c => c.ToString()).SequenceEqual(new[] { "this is a test of the things I split by." })); - [TestMethod] - public void SplitBySequence() => Assert.IsTrue("this is a test of the things I split by.".ToRope().Split(" ".AsMemory()).Select(c => c.ToString()).SequenceEqual("this is a test of the things I split by.".Split(" "))); + [TestMethod] + public void SplitBySingleElementAtEnd() => Assert.IsTrue("this is a test of the things I split by.".ToRope().Split('.').Select(c => c.ToString()).SequenceEqual("this is a test of the things I split by.".Split('.'))); - [TestMethod] - public void Replace() => Assert.AreEqual("The ghosts say boo dee boo", "The ghosts say doo dee doo".ToRope().Replace("doo".AsMemory(), "boo".AsMemory()).ToString()); + [TestMethod] + public void SplitBySequence() => Assert.IsTrue("this is a test of the things I split by.".ToRope().Split(" ".AsMemory()).Select(c => c.ToString()).SequenceEqual("this is a test of the things I split by.".Split(" "))); - [TestMethod] - public void InsertRope() - { - var s = TestData.LargeText; - for (int i = 0; i < 1000; i++) - { - s = s.InsertRange(TestData.LargeText.Length / 2, TestData.LargeText); - } + [TestMethod] + public void Replace() => Assert.AreEqual( + "The ghosts say boo dee boo", + "The ghosts say doo dee doo".ToRope().Replace("doo".AsMemory(), "boo".AsMemory()).ToString()); - Assert.AreEqual(TestData.LargeText.Length * 1001, s.Length); - } + [TestMethod] + public void ReplaceSplit() => Assert.AreEqual( + "this is a test".Replace(" is ", " isn't "), + ("this i".ToRope() + "s a test".ToRope()).Replace(" is ", " isn't ").ToString()); - [TestMethod] - public void InsertMemory() + [TestMethod] + public void InsertRope() + { + var s = TestData.LargeText; + for (int i = 0; i < 1000; i++) { - var memory = TestData.LargeText.ToMemory(); - var s = TestData.LargeText; - for (int i = 0; i < 1000; i++) - { - s = s.InsertRange(memory.Length / 2, memory); - } - - Assert.AreEqual(memory.Length * 1001, s.Length); + s = s.InsertRange(TestData.LargeText.Length / 2, TestData.LargeText); } - [TestMethod] - public void InsertSortedLargeFloatList() - { - var random = new Random(42); - var rope = Rope.Empty; - var comparer = Comparer.Default; - foreach (var rank in Enumerable.Range(0, 65000).Select(s => random.NextSingle())) - { - rope = rope.InsertSorted(rank, comparer); - } - } + Assert.AreEqual(TestData.LargeText.Length * 1001, s.Length); + } - [TestMethod] - [DataRow(0, 0, 0)] - [DataRow(1, 1, 1)] - [DataRow(2, 1, 2)] - [DataRow(2, 2, 2)] - [DataRow(5, 2, 5)] - [DataRow(5, 5, 5)] - [ExpectedException(typeof(ArgumentOutOfRangeException))] - public void RemoveAtOutOfRange(int textLength, int chunkSize, int startIndex) + [TestMethod] + public void InsertMemory() + { + var memory = TestData.LargeText.ToMemory(); + var s = TestData.LargeText; + for (int i = 0; i < 1000; i++) { - var (_, rope) = TestData.Create(textLength, chunkSize); - var _ = rope.RemoveAt(startIndex); + s = s.InsertRange(memory.Length / 2, memory); } - [TestMethod] - [DataRow(1, 1, 0)] - [DataRow(2, 1, 0)] - [DataRow(2, 1, 1)] - [DataRow(2, 2, 1)] - [DataRow(3, 2, 2)] - [DataRow(5, 2, 0)] - [DataRow(5, 2, 2)] - [DataRow(5, 2, 3)] - [DataRow(5, 5, 4)] - [DataRow(256, 3, 4)] - [DataRow(256, 24, 3)] - public void RemoveAt(int textLength, int chunkSize, int startIndex) - { - var (text, rope) = TestData.Create(textLength, chunkSize); - var list = text.ToList(); - list.RemoveAt(startIndex); - var expected = new string(list.ToArray()); - var actual = rope.RemoveAt(startIndex); - Assert.AreEqual(expected, actual.ToString()); - } + Assert.AreEqual(memory.Length * 1001, s.Length); + } - [TestMethod] - [DataRow(0, 0, 0, 0)] - [DataRow(1, 1, 0, 1)] - [DataRow(1, 1, 1, 0)] - [DataRow(2, 1, 0, 1)] - [DataRow(2, 1, 1, 1)] - [DataRow(2, 1, 2, 0)] - [DataRow(2, 1, 0, 2)] - [DataRow(2, 2, 1, 1)] - [DataRow(2, 2, 2, 0)] - [DataRow(3, 2, 2, 1)] - [DataRow(5, 2, 0, 5)] - [DataRow(5, 2, 2, 3)] - [DataRow(5, 2, 3, 2)] - [DataRow(5, 2, 4, 1)] - [DataRow(5, 2, 5, 0)] - [DataRow(5, 5, 4, 1)] - [DataRow(5, 5, 5, 0)] - [DataRow(256, 3, 4, 200)] - [DataRow(256, 24, 3, 200)] - public void RemoveRange(int textLength, int chunkSize, int startIndex, int length) + [TestMethod] + public void InsertSortedLargeFloatList() + { + var random = new Random(42); + var rope = Rope.Empty; + var comparer = Comparer.Default; + foreach (var rank in Enumerable.Range(0, 65000).Select(s => random.NextSingle())) { - var (text, rope) = TestData.Create(textLength, chunkSize); - var expected = text.Remove(startIndex, length); - var actual = rope.RemoveRange(startIndex, length); - Assert.AreEqual(expected, actual.ToString()); + rope = rope.InsertSorted(rank, comparer); } } + + [TestMethod] + [DataRow(0, 0, 0)] + [DataRow(1, 1, 1)] + [DataRow(2, 1, 2)] + [DataRow(2, 2, 2)] + [DataRow(5, 2, 5)] + [DataRow(5, 5, 5)] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void RemoveAtOutOfRange(int textLength, int chunkSize, int startIndex) + { + var (_, rope) = TestData.Create(textLength, chunkSize); + var _ = rope.RemoveAt(startIndex); + } + + [TestMethod] + [DataRow(1, 1, 0)] + [DataRow(2, 1, 0)] + [DataRow(2, 1, 1)] + [DataRow(2, 2, 1)] + [DataRow(3, 2, 2)] + [DataRow(5, 2, 0)] + [DataRow(5, 2, 2)] + [DataRow(5, 2, 3)] + [DataRow(5, 5, 4)] + [DataRow(256, 3, 4)] + [DataRow(256, 24, 3)] + public void RemoveAt(int textLength, int chunkSize, int startIndex) + { + var (text, rope) = TestData.Create(textLength, chunkSize); + var list = text.ToList(); + list.RemoveAt(startIndex); + var expected = new string(list.ToArray()); + var actual = rope.RemoveAt(startIndex); + Assert.AreEqual(expected, actual.ToString()); + } + + [TestMethod] + [DataRow(0, 0, 0, 0)] + [DataRow(1, 1, 0, 1)] + [DataRow(1, 1, 1, 0)] + [DataRow(2, 1, 0, 1)] + [DataRow(2, 1, 1, 1)] + [DataRow(2, 1, 2, 0)] + [DataRow(2, 1, 0, 2)] + [DataRow(2, 2, 1, 1)] + [DataRow(2, 2, 2, 0)] + [DataRow(3, 2, 2, 1)] + [DataRow(5, 2, 0, 5)] + [DataRow(5, 2, 2, 3)] + [DataRow(5, 2, 3, 2)] + [DataRow(5, 2, 4, 1)] + [DataRow(5, 2, 5, 0)] + [DataRow(5, 5, 4, 1)] + [DataRow(5, 5, 5, 0)] + [DataRow(256, 3, 4, 200)] + [DataRow(256, 24, 3, 200)] + public void RemoveRange(int textLength, int chunkSize, int startIndex, int length) + { + var (text, rope) = TestData.Create(textLength, chunkSize); + var expected = text.Remove(startIndex, length); + var actual = rope.RemoveRange(startIndex, length); + Assert.AreEqual(expected, actual.ToString()); + } } diff --git a/tests/RopeSearchTests.cs b/tests/RopeSearchTests.cs index 2e42406..81ca4b0 100644 --- a/tests/RopeSearchTests.cs +++ b/tests/RopeSearchTests.cs @@ -1,4 +1,6 @@ namespace Rope.UnitTests; + +using Rope.Compare; using System; [TestClass] @@ -162,6 +164,30 @@ public void LastIndexOfRopeWithStartIndex() Assert.AreEqual("abc abc".LastIndexOf("bc", 2), ("ab".ToRope() + "c abc".ToRope()).LastIndexOf("bc".AsMemory(), 2)); } + [TestMethod] + public void LastIndexOfRopeWithStartIndexIgnoreCase() + { + var comparer = CharComparer.OrdinalIgnoreCase; + + Assert.AreEqual("abc abC".LastIndexOf("c", 2, StringComparison.OrdinalIgnoreCase), "abc abC".ToRope().LastIndexOf("c".ToRope(), 2, comparer)); + Assert.AreEqual("abc abC".LastIndexOf("c", 6, StringComparison.OrdinalIgnoreCase), "abc abC".ToRope().LastIndexOf("c".ToRope(), 6, comparer)); + Assert.AreEqual("abc abC".LastIndexOf("c", 7, StringComparison.OrdinalIgnoreCase), "abc abC".ToRope().LastIndexOf("c".ToRope(), 7, comparer)); + Assert.AreEqual("ABC".LastIndexOf("B", 0, StringComparison.OrdinalIgnoreCase), "ABC".ToRope().LastIndexOf("B".ToRope(), 0, comparer)); + Assert.AreEqual("ABC".LastIndexOf("B", 1, StringComparison.OrdinalIgnoreCase), "ABC".ToRope().LastIndexOf("B".ToRope(), 1, comparer)); + Assert.AreEqual("ABC".LastIndexOf("C", 2, StringComparison.OrdinalIgnoreCase), "ABC".ToRope().LastIndexOf("C".ToRope(), 2, comparer)); + Assert.AreEqual("C".LastIndexOf("c", 0, StringComparison.OrdinalIgnoreCase), "C".ToRope().LastIndexOf("c".ToRope(), 0, comparer)); + + Assert.AreEqual("ABC".LastIndexOf("B", 0, StringComparison.OrdinalIgnoreCase), new Rope("A".ToRope(), "BC".ToRope()).LastIndexOf("B".ToRope(), 0, comparer)); + Assert.AreEqual("ABC".LastIndexOf("B", 1, StringComparison.OrdinalIgnoreCase), new Rope("A".ToRope(), "BC".ToRope()).LastIndexOf("B".ToRope(), 1, comparer)); + Assert.AreEqual("ABC".LastIndexOf("C", 2, StringComparison.OrdinalIgnoreCase), new Rope("A".ToRope(), "BC".ToRope()).LastIndexOf("C".ToRope(), 2, comparer)); + Assert.AreEqual("ab".LastIndexOf("ab", 1, StringComparison.OrdinalIgnoreCase), new Rope("a".ToRope(), "b".ToRope()).LastIndexOf("ab".ToRope(), 1, comparer)); + Assert.AreEqual("ab".LastIndexOf(string.Empty, 1, StringComparison.OrdinalIgnoreCase), new Rope("a".ToRope(), "b".ToRope()).LastIndexOf(Rope.Empty, 1, comparer)); + Assert.AreEqual("ab".LastIndexOf(string.Empty, 2, StringComparison.OrdinalIgnoreCase), new Rope("a".ToRope(), "b".ToRope()).LastIndexOf(Rope.Empty, 2, comparer)); + Assert.AreEqual("def abcdefgh".LastIndexOf("def", StringComparison.OrdinalIgnoreCase), new Rope("def abcd".ToRope(), "efgh".ToRope()).LastIndexOf("def".ToRope(), comparer)); + Assert.AreEqual("def abcdeFgh".LastIndexOf("fgh", StringComparison.OrdinalIgnoreCase), new Rope("def abcd".ToRope(), "eFgh".ToRope()).LastIndexOf("fgh".ToRope(), comparer)); + Assert.AreEqual("abc abc".LastIndexOf("bc", 2, StringComparison.OrdinalIgnoreCase), ("ab".ToRope() + "c abc".ToRope()).LastIndexOf("bc".AsMemory(), 2, comparer)); + } + [TestMethod] public void CommonPrefixLength() {