diff --git a/AnyText/AnyText.Core/ChangeTracker.cs b/AnyText/AnyText.Core/ChangeTracker.cs index b4d66be9..57cccf4d 100644 --- a/AnyText/AnyText.Core/ChangeTracker.cs +++ b/AnyText/AnyText.Core/ChangeTracker.cs @@ -10,18 +10,193 @@ namespace NMF.AnyText { internal class ChangeTracker { - private TextEdit[] _edits = Array.Empty(); + private readonly List _edits = new List(); + private readonly List _oldTexts = new List(); + private readonly ItemEqualityComparer _contentComparer = new ItemEqualityComparer(); - public void SetEdits(IEnumerable edits) + public IList CurrentEdits => _edits.AsReadOnly(); + + public void AddEdit(TextEdit edit, string[] input) + { + var editIndex = 0; + var adjacentEdit = GetNextEdit(ref editIndex, edit.Start); + if (adjacentEdit == null) + { + if (editIndex > 0 && _edits[editIndex - 1].EndAfterEdit == edit.Start) + { + var prev = _edits[editIndex - 1]; + var newUpdate = MergeTexts(prev.NewText, edit.NewText); + var newOld = MergeTexts(_oldTexts[editIndex - 1], GetOldText(edit, input)); + _edits[editIndex - 1] = new TextEdit(prev.Start, edit.End, newUpdate); + _oldTexts[editIndex - 1] = newOld; + } + else + { + _edits.Add(edit); + _oldTexts.Add(GetOldText(edit, input)); + } + } + else if (adjacentEdit.Start >= edit.Start) + { + AddEditWithAdjacentAfterStart(edit, input, editIndex, adjacentEdit); + } + else // adj.EndAfterEdit >= Start but adj.Start < Start => overlap + { + if (edit.End <= adjacentEdit.EndAfterEdit) + { + var relativeStart = edit.Start - adjacentEdit.Start; + var relativeEnd = edit.End - adjacentEdit.Start; + var innerEdit = new TextEdit(new ParsePosition(relativeStart.Line, relativeStart.Col), new ParsePosition(relativeEnd.Line, relativeEnd.Col), edit.NewText); + var updatedText = innerEdit.Apply(adjacentEdit.NewText); + if (_contentComparer.Equals(updatedText, _oldTexts[editIndex])) + { + _edits.RemoveAt(editIndex); + _oldTexts.RemoveAt(editIndex); + } + else + { + _edits[editIndex] = new TextEdit(adjacentEdit.Start, adjacentEdit.End, updatedText); + } + } + else + { + var startPosDeltaInAdjacent = edit.Start - adjacentEdit.Start; + var startPosInAdjacent = new ParsePosition(startPosDeltaInAdjacent.Line, startPosDeltaInAdjacent.Col); + var insertion = new TextEdit(startPosInAdjacent, + new ParsePosition(adjacentEdit.NewText.Length - 1, adjacentEdit.NewText[adjacentEdit.NewText.Length - 1].Length), + edit.NewText); + var updatedText = insertion.Apply(adjacentEdit.NewText); + var overlapEnd = UpdateEndPosition(edit.End, adjacentEdit.EndAfterEdit, edit.Start); + _edits[editIndex] = new TextEdit(adjacentEdit.Start, overlapEnd, updatedText); + // TODO: update oldText + } + } + } + + private string[] MergeTexts(string[] text1, string[] text2) + { + var t1Len = Math.Max(text1.Length, 1); + var t2Len = Math.Max(text2.Length, 1); + var ret = new string[t1Len + t2Len - 1]; + Array.Copy(text1, ret, text1.Length); + if (text2.Length > 0) + { + ret[t1Len - 1] += text2[0]; + } + Array.Copy(text2, 1, ret, t1Len, t2Len - 1); + return ret; + } + + private ParsePosition UpdateEndPosition(ParsePosition end, ParsePosition innerEditEnd, ParsePosition innerEditEndAfterEdit) + { + if (innerEditEndAfterEdit.Line < end.Line) + { + return new ParsePosition(end.Line + innerEditEndAfterEdit.Line - innerEditEnd.Line, end.Col); + } + return end + (innerEditEndAfterEdit - innerEditEnd); + } + + private void AddEditWithAdjacentAfterStart(TextEdit edit, string[] input, int editIndex, TextEdit adjacentEdit) + { + var oldText = _oldTexts[editIndex]; + var firstStart = adjacentEdit.Start; + var lastEnd = adjacentEdit.End; + var lastEndAfterEdit = adjacentEdit.EndAfterEdit; + while (adjacentEdit.EndAfterEdit <= edit.End) + { + _edits.RemoveAt(editIndex); + _oldTexts.RemoveAt(editIndex); + lastEnd = adjacentEdit.End; + lastEndAfterEdit = adjacentEdit.EndAfterEdit; + if (editIndex < _edits.Count) + { + adjacentEdit = _edits[editIndex]; + if (adjacentEdit.Start <= edit.End) + { + continue; + } + } + else + { + adjacentEdit = null; + } + break; + } + if (adjacentEdit == null || adjacentEdit.EndAfterEdit < edit.End) + { + var lengthTillStart = firstStart - edit.Start; + var lengthFromEnd = edit.End - lastEndAfterEdit; + if (lengthTillStart != default || lengthFromEnd != default) + { + if (lastEnd == lastEndAfterEdit) + { + InsertEdit(editIndex, edit, input); + } + else + { + InsertEdit(editIndex, new TextEdit(edit.Start, UpdateEndPosition(edit.End, lastEndAfterEdit, lastEnd), edit.NewText), input); + } + } + else if (!_contentComparer.Equals(oldText, edit.NewText)) + { + _edits.Insert(editIndex, new TextEdit(edit.Start, edit.EndAfterEdit, edit.NewText)); + _oldTexts.Insert(editIndex, edit.NewText); + } + } + else + { + InsertEdit(editIndex, edit, input); + } + } + + private void InsertEdit(int editIndex, TextEdit edit, string[] input) { - if (edits.TryGetNonEnumeratedCount(out var count) && count <= 1) + _edits.Insert(editIndex, edit); + _oldTexts.Insert(editIndex, GetOldText(edit, input)); + } + + private string[] GetOldText(TextEdit edit, string[] input) + { + var ret = new string[edit.End.Line - edit.Start.Line + 1]; + if (edit.Start.Line == edit.End.Line) { - _edits = edits as TextEdit[] ?? edits.ToArray(); + if (edit.Start.Line < input.Length) + { + ret[0] = input[edit.Start.Line].Substring(edit.Start.Col, edit.End.Col - edit.Start.Col); + } + else + { + ret[0] = string.Empty; + } } else { - _edits = edits.OrderBy(e => e.Start).ToArray(); + var startLine = edit.Start.Line; + var endLine = edit.End.Line; + ret[0] = input[startLine].Substring(edit.Start.Col); + var index = 1; + while (index + startLine < endLine) + { + if (startLine + index < input.Length) + { + ret[index] = input[startLine + index]; + } + else + { + ret[index] = string.Empty; + } + index++; + } + if (endLine < input.Length) + { + ret[index] = input[endLine].Substring(0, edit.End.Col); + } + else + { + ret[index] = string.Empty; + } } + return ret; } public List CalculateListMigrations(List old, List migrateTo, ParseContext context) @@ -206,7 +381,7 @@ private void RemoveObsoleted(List old, ParseContext context, Li private TextEdit GetNextEdit(ref int editIndex, ParsePosition position) { - while (editIndex < _edits.Length) + while (editIndex < _edits.Count) { var edit = _edits[editIndex]; if (edit.EndAfterEdit >= position) @@ -252,6 +427,10 @@ private bool IsObsoleted(RuleApplication ruleApplication, ParseContext context, ruleApplication.CurrentPosition + ruleApplication.Length <= byEdit.EndAfterEdit; } - internal void Reset() => SetEdits(Array.Empty()); + public void Reset() + { + _edits.Clear(); + _oldTexts.Clear(); + } } } diff --git a/AnyText/AnyText.Core/ItemEqualityComparer.cs b/AnyText/AnyText.Core/ItemEqualityComparer.cs new file mode 100644 index 00000000..4cc24e3c --- /dev/null +++ b/AnyText/AnyText.Core/ItemEqualityComparer.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace NMF.AnyText +{ + internal class ItemEqualityComparer : IEqualityComparer + { + private readonly IEqualityComparer _comparer; + + public ItemEqualityComparer() : this(EqualityComparer.Default) { } + + public ItemEqualityComparer(IEqualityComparer comparer) + { + _comparer = comparer; + } + + public bool Equals(T[] x, T[] y) + { + if (x == null) + { + return y == null; + } + if (y == null || y.Length != x.Length) + { + return false; + } + for (int i = 0; i < x.Length; i++) + { + if (!_comparer.Equals(x[i], y[i])) + { + return false; + } + } + return true; + } + + public int GetHashCode([DisallowNull] T[] obj) + { + unchecked + { + var hash = obj.Length.GetHashCode(); + for (int i = 0; i < obj.Length; i++) + { + hash ^= 23 * hash + obj[i].GetHashCode(); + } + return hash; + } + } + } +} diff --git a/AnyText/AnyText.Core/ParsePosition.cs b/AnyText/AnyText.Core/ParsePosition.cs index d8b2306a..7ebad14f 100644 --- a/AnyText/AnyText.Core/ParsePosition.cs +++ b/AnyText/AnyText.Core/ParsePosition.cs @@ -100,5 +100,11 @@ public int CompareTo(ParsePosition other) } return new ParsePosition(pos.Line + delta.Line, delta.Col); } + + /// + public override string ToString() + { + return $"({Line},{Col})"; + } } } diff --git a/AnyText/AnyText.Core/Parser.cs b/AnyText/AnyText.Core/Parser.cs index 04eea7f3..d89f910c 100644 --- a/AnyText/AnyText.Core/Parser.cs +++ b/AnyText/AnyText.Core/Parser.cs @@ -119,7 +119,6 @@ public void Initialize(object semanticObject, bool skipValidation = false) throw new ArgumentException("no parse tree could be created for this object.", nameof(semanticObject)); } _matcher.Reset(); - _context.ChangeTracker.Reset(); _context.ClearErrors(); var writer = new StringWriter(); @@ -127,6 +126,7 @@ public void Initialize(object semanticObject, bool skipValidation = false) ruleApplication.Write(prettyWriter, _context); _context.Input = writer.ToString().Split(Environment.NewLine); + _context.ChangeTracker.Reset(); try { _context.IsParsing = true; @@ -169,10 +169,9 @@ public object Update(TextEdit edit, bool skipValidation) { var input = _context.Input; _context.RemoveAllErrors(e => e.Source == DiagnosticSources.Parser); - + _context.ChangeTracker.AddEdit(edit, input); input = edit.Apply(input); _matcher.Apply(edit); - _context.ChangeTracker.SetEdits(new[] { edit }); UpdateCore(input, skipValidation); } finally @@ -207,10 +206,10 @@ public object Update(IEnumerable edits, bool skipValidation) _context.RemoveAllErrors(e => e.Source == DiagnosticSources.Parser); foreach (TextEdit edit in edits) { + _context.ChangeTracker.AddEdit(edit, input); input = edit.Apply(input); _matcher.Apply(edit); } - _context.ChangeTracker.SetEdits(edits); UpdateCore(input, skipValidation); return _context.Root; } @@ -345,6 +344,7 @@ private void UpdateCore(string[] input, bool skipValidation) { newRoot = newRoot.ApplyTo(_context.LastSuccessfulRootRuleApplication, _context); } + _context.ChangeTracker.Reset(); _context.RootRuleApplication = newRoot; _context.RefreshRoot(); newRoot.Activate(_context, false); diff --git a/AnyText/AnyText.Core/TextEdit.cs b/AnyText/AnyText.Core/TextEdit.cs index 58bc9f7e..f1333859 100644 --- a/AnyText/AnyText.Core/TextEdit.cs +++ b/AnyText/AnyText.Core/TextEdit.cs @@ -7,7 +7,7 @@ namespace NMF.AnyText /// Denotes an edit for text /// [DebuggerDisplay("edit from {Start} to {End}")] - public class TextEdit + public class TextEdit : IEquatable { private static readonly string[] EmptyString = { string.Empty }; @@ -263,5 +263,46 @@ private static string ChangeLine(string line, int start, int end, string newText } return result; } + + /// + public override bool Equals(object obj) + { + return obj is TextEdit edit && Equals(edit); + } + + /// + public bool Equals(TextEdit other) + { + if (other == null || other.Start != Start || other.End != End || other.NewText.Length != NewText.Length) + { + return false; + } + for (int i = 0; i < NewText.Length; i++) + { + if (other.NewText[i] != NewText[i]) + { + return false; + } + } + return true; + } + + /// + public override int GetHashCode() + { + var hash = Start.GetHashCode() + ^ (17 * End.GetHashCode()) ^ (23 * NewText.Length.GetHashCode()); + for (int i = 0; i < NewText.Length; i++) + { + hash ^= NewText[i].GetHashCode(); + } + return hash; + } + + /// + public override string ToString() + { + return $"{Start} to {End}: {string.Join(Environment.NewLine, NewText)}"; + } } } diff --git a/AnyText/AnyText.history b/AnyText/AnyText.history index 469ef55c..68be57f0 100644 --- a/AnyText/AnyText.history +++ b/AnyText/AnyText.history @@ -41,4 +41,5 @@ patch: generate features as ordered, reset working directory in anytextgen patch: add flag for identifier scopes patch: cache resolve error patch: fix synthesis of negative lookaheads, fix code generation for patterns including alternatives -patch: unification fix \ No newline at end of file +patch: unification fix +patch: carry text edits until successful migration diff --git a/AnyText/Tests/AnyText.Tests/Diffs/ChangeTrackerTests.cs b/AnyText/Tests/AnyText.Tests/Diffs/ChangeTrackerTests.cs new file mode 100644 index 00000000..537c8513 --- /dev/null +++ b/AnyText/Tests/AnyText.Tests/Diffs/ChangeTrackerTests.cs @@ -0,0 +1,171 @@ +using NMF.AnyText; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Text; + +namespace AnyText.Tests.Diffs +{ + [TestFixture] + public class ChangeTrackerTests + { + private ChangeTracker _tracker = new ChangeTracker(); + private string[] _input; + + [SetUp] + public void Setup() + { + _tracker.Reset(); + _input = ["abcdefgh", "abcdefgh"]; + } + + [Test] + public void InsertionOnEmpty_KeepsEdit() + { + var edit = new TextEdit(new ParsePosition(1, 8), new ParsePosition(1, 8), ["a"]); + EditAndApply(edit); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([edit])); + } + + [Test] + public void InsertionAfterExisting_KeepsEdit() + { + var firstEdit = new TextEdit(new ParsePosition(0, 5), new ParsePosition(0, 6), ["a"]); + var edit = new TextEdit(new ParsePosition(1, 8), new ParsePosition(1, 8), ["a"]); + EditAndApply(firstEdit); + EditAndApply(edit); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([firstEdit, edit])); + } + + [Test] + public void InsertionBeforeExisting_KeepsEdit() + { + var firstEdit = new TextEdit(new ParsePosition(0, 5), new ParsePosition(0, 6), ["a"]); + var edit = new TextEdit(new ParsePosition(1, 8), new ParsePosition(1, 8), ["a"]); + EditAndApply(edit); + EditAndApply(firstEdit); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([firstEdit, edit])); + } + + [Test] + public void DeleteInsertion_Empty() + { + var firstEdit = new TextEdit(new ParsePosition(1, 8), new ParsePosition(1, 8), ["a"]); + EditAndApply(firstEdit); + EditAndApply(new TextEdit(new ParsePosition(1, 8), new ParsePosition(1, 9), [""])); + Assert.That(_tracker.CurrentEdits, Is.Empty); + } + + [Test] + public void DeletionOnEmpty_KeepsEdit() + { + var edit = new TextEdit(new ParsePosition(1, 0), new ParsePosition(1, 1), [""]); + EditAndApply(edit); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([edit])); + } + + [Test] + public void InsertDeletion_Empty() + { + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 3), [""])); + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 2), ["c"])); + Assert.That(_tracker.CurrentEdits, Is.Empty); + } + + [Test] + public void InsertDeletionChanged_ChangesEdit() + { + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 3), [""])); + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 2), ["a"])); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([ + new TextEdit(new ParsePosition(1,2), new ParsePosition(1,3), ["a"]) + ])); + } + + [Test] + public void InsertObsoletesDeletes_ChangesEdit() + { + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 3), [""])); + EditAndApply(new TextEdit(new ParsePosition(1, 0), new ParsePosition(1, 4), ["abcdefg"])); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([ + new TextEdit(new ParsePosition(1, 0), new ParsePosition(1, 5), ["abcdefg"]) + ])); + } + + [Test] + public void DeleteAfterInsert_ChangesEdit() + { + EditAndApply(new TextEdit(new ParsePosition(1, 0), new ParsePosition(1, 4), ["abcdefg"])); + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 3), [""])); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([ + new TextEdit(new ParsePosition(1, 0), new ParsePosition(1, 4), ["abdefg"]) + ])); + } + + [Test] + public void InsertAfterInsert_ChangesEdit() + { + EditAndApply(new TextEdit(new ParsePosition(1, 0), new ParsePosition(1, 4), ["abcdefg"])); + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 2), ["h"])); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([ + new TextEdit(new ParsePosition(1, 0), new ParsePosition(1, 4), ["abhcdefg"]) + ])); + } + + [Test] + public void DeletionObsoletesDeletions_ChangesEdit() + { + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 3), [""])); + EditAndApply(new TextEdit(new ParsePosition(1, 0), new ParsePosition(1, 4), [""])); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([ + new TextEdit(new ParsePosition(1, 0), new ParsePosition(1, 5), [""]) + ])); + } + + [Test] + public void InsertAfterInsert_CombinesEdits() + { + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 2), ["a"])); + EditAndApply(new TextEdit(new ParsePosition(1, 3), new ParsePosition(1, 3), ["b"])); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([ + new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 2), ["ab"]) + ])); + } + + [Test] + public void DeleteAfterDelete_CombinesEdits() + { + EditAndApply(new TextEdit(new ParsePosition(1, 3), new ParsePosition(1, 4), [""])); + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 3), [""])); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([ + new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 4), [""]) + ])); + } + + [Test] + public void DeleteAfterDelete2_CombinesEdits() + { + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 3), [""])); + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 3), [""])); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([ + new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 4), [""]) + ])); + } + + [Test] + public void OverlappingInsertions_CombinesEdits() + { + EditAndApply(new TextEdit(new ParsePosition(1, 2), new ParsePosition(1, 2), ["abcdefg"])); + EditAndApply(new TextEdit(new ParsePosition(1, 4), new ParsePosition(1, 14), ["inserted"])); + Assert.That(_tracker.CurrentEdits, Is.EquivalentTo([ + new TextEdit(new ParsePosition(1,2), new ParsePosition(1,6), ["abinserted"]) + ])); + } + + private void EditAndApply(TextEdit edit) + { + _tracker.AddEdit(edit, _input); + _input = edit.Apply(_input); + } + } +} diff --git a/Models/Models.history b/Models/Models.history index fab38122..99d7cc08 100644 --- a/Models/Models.history +++ b/Models/Models.history @@ -16,4 +16,5 @@ patch: fix Utilities dependency patch: fix bug in CompositionList, allow to block deletion events patch: Add support for .NET 10 patch: robustness fix to protect serializable recordings if no Uri can be calculated -patch: store all elements with global name, including conflicts \ No newline at end of file +patch: store all elements with global name, including conflicts +patch: debugging improvements diff --git a/Models/Models/ModelElement.cs b/Models/Models/ModelElement.cs index 13f8168a..ef48ab05 100644 --- a/Models/Models/ModelElement.cs +++ b/Models/Models/ModelElement.cs @@ -67,6 +67,12 @@ public Model Model else { current = current.Parent; +#if DEBUG + if (current == this) + { + throw new InvalidOperationException("You apparently made a mistake in the model hierarchy."); + } +#endif } } return null; @@ -248,6 +254,12 @@ private void SetParent(IModelElement newParent) var newParentME = newParent as ModelElement; if (newParentME != parent) { +#if DEBUG + if (this.Ancestors().Contains(newParent)) + { + throw new InvalidOperationException("Parent relationships must not contain loops."); + } +#endif var oldParent = parent; if (newParentME != null) {