Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 186 additions & 7 deletions AnyText/AnyText.Core/ChangeTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,193 @@ namespace NMF.AnyText
{
internal class ChangeTracker
{
private TextEdit[] _edits = Array.Empty<TextEdit>();
private readonly List<TextEdit> _edits = new List<TextEdit>();
private readonly List<string[]> _oldTexts = new List<string[]>();
private readonly ItemEqualityComparer<string> _contentComparer = new ItemEqualityComparer<string>();

public void SetEdits(IEnumerable<TextEdit> edits)
public IList<TextEdit> 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overlapping edit merge leaves stale original text behind

In the overlap branch, the tracker rewrites _edits[editIndex] but never updates _oldTexts[editIndex]. That breaks the whole "carry edits until a successful migration" flow: a later edit can compare against the wrong pre-edit content and either drop a real change or keep an obsolete one.

Update _oldTexts[editIndex] when collapsing overlapping edits. Easiest fix is to recompute the merged old text for the new combined span, or explicitly carry forward the correct original slice before replacing _edits[editIndex].

}
}
}

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<RuleApplicationListMigrationEntry> CalculateListMigrations(List<RuleApplication> old, List<RuleApplication> migrateTo, ParseContext context)
Expand Down Expand Up @@ -206,7 +381,7 @@ private void RemoveObsoleted(List<RuleApplication> 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)
Expand Down Expand Up @@ -252,6 +427,10 @@ private bool IsObsoleted(RuleApplication ruleApplication, ParseContext context,
ruleApplication.CurrentPosition + ruleApplication.Length <= byEdit.EndAfterEdit;
}

internal void Reset() => SetEdits(Array.Empty<TextEdit>());
public void Reset()
{
_edits.Clear();
_oldTexts.Clear();
}
}
}
54 changes: 54 additions & 0 deletions AnyText/AnyText.Core/ItemEqualityComparer.cs
Original file line number Diff line number Diff line change
@@ -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<T> : IEqualityComparer<T[]>
{
private readonly IEqualityComparer<T> _comparer;

public ItemEqualityComparer() : this(EqualityComparer<T>.Default) { }

public ItemEqualityComparer(IEqualityComparer<T> 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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Array comparer crashes on null entries

GetHashCode assumes every array element is non-null and calls obj[i].GetHashCode() directly. This comparer is generic and used for string arrays, so a single null element will throw during hashing instead of behaving like Equals, which already tolerates null arrays.

Use a null-safe hash, e.g. hash = (hash * 23) ^ (obj[i]?.GetHashCode() ?? 0); and mirror that in TextEdit.GetHashCode() if you want the new equality implementation to stay robust too.

}
return hash;
}
}
}
}
6 changes: 6 additions & 0 deletions AnyText/AnyText.Core/ParsePosition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,11 @@ public int CompareTo(ParsePosition other)
}
return new ParsePosition(pos.Line + delta.Line, delta.Col);
}

/// <inheritdoc/>
public override string ToString()
{
return $"({Line},{Col})";
}
}
}
8 changes: 4 additions & 4 deletions AnyText/AnyText.Core/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,14 @@ 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();
var prettyWriter = new PrettyPrintWriter(writer, " ");
ruleApplication.Write(prettyWriter, _context);

_context.Input = writer.ToString().Split(Environment.NewLine);
_context.ChangeTracker.Reset();
try
{
_context.IsParsing = true;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -207,10 +206,10 @@ public object Update(IEnumerable<TextEdit> 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;
}
Expand Down Expand Up @@ -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);
Expand Down
43 changes: 42 additions & 1 deletion AnyText/AnyText.Core/TextEdit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace NMF.AnyText
/// Denotes an edit for text
/// </summary>
[DebuggerDisplay("edit from {Start} to {End}")]
public class TextEdit
public class TextEdit : IEquatable<TextEdit>
{
private static readonly string[] EmptyString = { string.Empty };

Expand Down Expand Up @@ -263,5 +263,46 @@ private static string ChangeLine(string line, int start, int end, string newText
}
return result;
}

/// <inheritdoc />
public override bool Equals(object obj)
{
return obj is TextEdit edit && Equals(edit);
}

/// <inheritdoc />
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;
}

/// <inheritdoc />
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;
}

/// <inheritdoc />
public override string ToString()
{
return $"{Start} to {End}: {string.Join(Environment.NewLine, NewText)}";
}
}
}
3 changes: 2 additions & 1 deletion AnyText/AnyText.history
Original file line number Diff line number Diff line change
Expand Up @@ -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
patch: unification fix
patch: carry text edits until successful migration
Loading
Loading