diff --git a/.runsettings b/.runsettings index b74957bb..2c8a5243 100644 --- a/.runsettings +++ b/.runsettings @@ -6,7 +6,8 @@ - .*\.dll$ + avaloniaedit.textmate.dll$ + avaloniaedit.dll$ .*Tests\.dll$ diff --git a/AvaloniaEdit.slnx b/AvaloniaEdit.slnx index 3e6273b0..687c8a0a 100644 --- a/AvaloniaEdit.slnx +++ b/AvaloniaEdit.slnx @@ -1,5 +1,6 @@ + diff --git a/src/AvaloniaEdit.TextMate/TextMate.cs b/src/AvaloniaEdit.TextMate/TextMate.cs index d4482cda..b0ef64a0 100644 --- a/src/AvaloniaEdit.TextMate/TextMate.cs +++ b/src/AvaloniaEdit.TextMate/TextMate.cs @@ -44,6 +44,8 @@ public class Installation : IDisposable private readonly Registry _textMateRegistry; private readonly TextEditor _editor; private Action _exceptionHandler; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", + Justification = "Disposed in Dispose(bool) and DisposeEditorModel methods")] private TextEditorModel _editorModel; private IGrammar _grammar; private TMModel _tmModel; @@ -51,7 +53,7 @@ public class Installation : IDisposable private readonly bool _ownsTransformer; private ReadOnlyDictionary _themeColorsDictionary; public IRegistryOptions RegistryOptions { get; } - public TextEditorModel EditorModel { get { return Volatile.Read(ref _editorModel); } } + public TextEditorModel EditorModel => Volatile.Read(ref _editorModel); public event EventHandler AppliedTheme; diff --git a/src/AvaloniaEdit/Editing/EditingCommandHandler.cs b/src/AvaloniaEdit/Editing/EditingCommandHandler.cs index 19ea3a1e..acf190ec 100644 --- a/src/AvaloniaEdit/Editing/EditingCommandHandler.cs +++ b/src/AvaloniaEdit/Editing/EditingCommandHandler.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team // // Permission is hereby granted, free of charge, to any person obtaining a copy of this // software and associated documentation files (the "Software"), to deal in the Software @@ -197,6 +197,8 @@ private static void TransformSelectedSegments(Action transfo { foreach (var segment in segments.Reverse()) { + // Use Enumerable.Reverse explicitly to avoid a breaking change in C# 14 where Reverse() now resolves to MemoryExtensions.Reverse instead of Enumerable.Reverse + // see https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/breaking-changes/compiler%20breaking%20changes%20-%20dotnet%2010#enumerablereverse foreach (var writableSegment in System.Linq.Enumerable.Reverse(textArea.GetDeletableSegments(segment))) { transformSegment(textArea, writableSegment); @@ -416,7 +418,7 @@ private static bool CopySelectedText(TextArea textArea) text = TextUtilities.NormalizeNewLines(text, Environment.NewLine); - var df = new DataTransfer(); + using var df = new DataTransfer(); var item = new DataTransferItem(); item.Set(DataFormat.Text, text); df.Add(item); @@ -482,7 +484,7 @@ private static bool CopyWholeLine(TextArea textArea, DocumentLine line) //textArea.RaiseEvent(copyingEventArgs); //if (copyingEventArgs.CommandCancelled) // return false; - var df = new DataTransfer(); + using var df = new DataTransfer(); var item = new DataTransferItem(); item.Set(DataFormat.Text, text); df.Add(item); diff --git a/src/AvaloniaEdit/Highlighting/HighlightingManager.cs b/src/AvaloniaEdit/Highlighting/HighlightingManager.cs index 08d3e05f..f39caba2 100644 --- a/src/AvaloniaEdit/Highlighting/HighlightingManager.cs +++ b/src/AvaloniaEdit/Highlighting/HighlightingManager.cs @@ -126,7 +126,7 @@ public IHighlightingDefinition GetDefinition(string name) { lock (_lockObj) { - return _highlightingsByName.TryGetValue(name, out var rh) ? rh : null; + return _highlightingsByName.GetValueOrDefault(name); } } @@ -152,7 +152,7 @@ public IHighlightingDefinition GetDefinitionByExtension(string extension) { lock (_lockObj) { - return _highlightingsByExtension.TryGetValue(extension, out var rh) ? rh : null; + return _highlightingsByExtension.GetValueOrDefault(extension); } } diff --git a/src/AvaloniaEdit/Highlighting/Xshd/V2Loader.cs b/src/AvaloniaEdit/Highlighting/Xshd/V2Loader.cs index 805d5467..7a43db08 100644 --- a/src/AvaloniaEdit/Highlighting/Xshd/V2Loader.cs +++ b/src/AvaloniaEdit/Highlighting/Xshd/V2Loader.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team // // Permission is hereby granted, free of charge, to any person obtaining a copy of this // software and associated documentation files (the "Software"), to deal in the Software @@ -48,6 +48,7 @@ internal static class V2Loader // } //} + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "XmlReader is disposed by caller")] public static XshdSyntaxDefinition LoadDefinition(XmlReader reader, bool skipValidation) { reader = HighlightingLoader.GetValidatingReader(reader, true); diff --git a/src/AvaloniaEdit/Highlighting/Xshd/XmlHighlightingDefinition.cs b/src/AvaloniaEdit/Highlighting/Xshd/XmlHighlightingDefinition.cs index 3acbd42f..040c24d8 100644 --- a/src/AvaloniaEdit/Highlighting/Xshd/XmlHighlightingDefinition.cs +++ b/src/AvaloniaEdit/Highlighting/Xshd/XmlHighlightingDefinition.cs @@ -406,12 +406,12 @@ public HighlightingRuleSet GetNamedRuleSet(string name) { if (string.IsNullOrEmpty(name)) return MainRuleSet; - return _ruleSetDict.TryGetValue(name, out var r) ? r : null; + return _ruleSetDict.GetValueOrDefault(name); } public HighlightingColor GetNamedColor(string name) { - return _colorDict.TryGetValue(name, out var c) ? c : null; + return _colorDict.GetValueOrDefault(name); } public IEnumerable NamedHighlightingColors => _colorDict.Values; diff --git a/src/AvaloniaEdit/Snippets/InsertionContext.cs b/src/AvaloniaEdit/Snippets/InsertionContext.cs index be4d0353..37531ef0 100644 --- a/src/AvaloniaEdit/Snippets/InsertionContext.cs +++ b/src/AvaloniaEdit/Snippets/InsertionContext.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team // // Permission is hereby granted, free of charge, to any person obtaining a copy of this // software and associated documentation files (the "Software"), to deal in the Software @@ -17,10 +17,12 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Buffers; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using AvaloniaEdit.Document; using AvaloniaEdit.Editing; +using AvaloniaEdit.Utils; namespace AvaloniaEdit.Snippets { @@ -38,6 +40,12 @@ private enum Status Deactivated } + /// + /// Pre-compiled, SIMD-accelerated search values for newline characters + /// used during snippet text insertion. + /// + private static readonly SearchValues _newlineChars = SearchValues.Create("\r\n"); + private Status _currentStatus = Status.Insertion; /// @@ -116,28 +124,161 @@ public int StartPosition /// This method will add the current indentation to every line in and will /// replace newlines with the expected newline for the document. /// + /// + /// + /// This method uses a two-phase algorithm that is semantically equivalent to the + /// original implementation: + /// + /// + /// + /// + /// Phase 1 - Tab expansion: Replaces tab characters with the configured + /// indentation string using , which + /// leverages the runtime's native SIMD-optimized implementation. When + /// equals "\t" (identity replacement), this phase is + /// skipped entirely. + /// + /// + /// + /// + /// Phase 2 - Newline normalization: Scans the fully tab-expanded text for + /// newline characters using (SIMD-accelerated) and + /// replaces them with + . Uses + /// a stack-allocated that falls back to + /// for large inputs, minimizing heap allocations. + /// + /// + /// + /// + /// The two-phase approach ensures that if contains newline characters + /// (possible via a custom subclass), those newlines are + /// correctly processed by the newline normalization phase - including CRLF pairs that + /// span the boundary between expanded tab content and adjacent source text. + /// + /// + /// The entire transformed result is inserted into the document with a single + /// call within a + /// scope, eliminating per-line substring + /// allocations and reducing document change, anchor update, and undo-grouping overhead. + /// + /// + /// A fast path is provided for text that contains no special characters, avoiding + /// all intermediate allocations entirely. + /// + /// public void InsertText(string text) { if (_currentStatus != Status.Insertion) throw new InvalidOperationException(); - text = text?.Replace("\t", Tab) ?? throw new ArgumentNullException(nameof(text)); + text = text ?? throw new ArgumentNullException(nameof(text)); - using (Document.RunUpdate()) + // Fast path: if Tab is identity ("\t") and text has no newlines, + // or if Tab is not identity and text has no tabs or newlines, + // insert directly with zero extra allocations + bool isTabIdentity = Tab == "\t"; + + ReadOnlySpan span = text.AsSpan(); + bool hasNewlines = span.IndexOfAny(_newlineChars) >= 0; + bool hasTabs = !isTabIdentity && span.Contains('\t'); + + if (!hasNewlines && !hasTabs) + { + using (Document.RunUpdate()) + { + Document.Insert(InsertionPosition, text); + InsertionPosition += text.Length; + } + return; + } + + // Phase 1: Tab expansion + // Equivalent to the original's: text = text.Replace("\t", Tab) + // + // Uses the runtime's native string.Replace which is SIMD-optimized + // and significantly faster than a managed StringBuilder loop for + // this operation. When Tab == "\t" (identity), this is skipped. + // + // This must happen before newline normalization so that any newline + // characters within Tab are visible to Phase 2. This is what makes + // CRLF boundary merging work correctly - Phase 2 sees the fully + // expanded text as one contiguous stream. + string expanded = hasTabs ? text.Replace("\t", Tab) : text; + ReadOnlySpan expandedSpan = expanded.AsSpan(); + + // Check if newlines exist in the expanded text (tab expansion may + // have introduced newlines if Tab contains \r or \n) + if (expandedSpan.IndexOfAny(_newlineChars) < 0) { - int textOffset = 0; - SimpleSegment segment; - while ((segment = NewLineFinder.NextNewLine(text, textOffset)) != SimpleSegment.Invalid) + // Tab expansion produced text with no newlines - insert directly + using (Document.RunUpdate()) { - string insertString = text.Substring(textOffset, segment.Offset - textOffset) - + LineTerminator + Indentation; - Document.Insert(InsertionPosition, insertString); - InsertionPosition += insertString.Length; - textOffset = segment.EndOffset; + Document.Insert(InsertionPosition, expanded); + InsertionPosition += expanded.Length; } - string remainingInsertString = text.Substring(textOffset); - Document.Insert(InsertionPosition, remainingInsertString); - InsertionPosition += remainingInsertString.Length; + return; + } + + // Phase 2: Newline normalization + // Equivalent to the original's NewLineFinder.NextNewLine loop + // + // Uses a ValueStringBuilder backed by stackalloc for small inputs + // (typical snippets < 512 chars) with ArrayPool fallback for + // larger inputs. This eliminates the StringBuilder object allocation + // and its internal char[] buffer allocation on the heap for the + // common case. + // + // Because this operates on the fully tab-expanded text, CRLF pairs + // that span tab-expansion boundaries are correctly handled as single + // units - identical to the original implementation. + string result; + const int optimizedStackBufferSize = 512; + using (var vsb = new ValueStringBuilder(stackalloc char[optimizedStackBufferSize])) + { + int pos = 0; + while (pos < expandedSpan.Length) + { + int index = expandedSpan[pos..].IndexOfAny(_newlineChars); + if (index < 0) + { + // No more newlines - append remaining text + vsb.Append(expandedSpan[pos..]); + break; + } + + // Append literal text before the newline + if (index > 0) + { + vsb.Append(expandedSpan.Slice(pos, index)); + } + + pos += index; + char c = expandedSpan[pos]; + + // Handle \r\n as a single unit, or \r / \n individually + if (c == '\r' && pos + 1 < expandedSpan.Length && expandedSpan[pos + 1] == '\n') + { + pos += 2; // skip \r\n + } + else + { + pos++; // skip \r or \n + } + + // Replace with document's line terminator + indentation + vsb.Append(LineTerminator); + vsb.Append(Indentation); + } + + // Single document insert wrapped in RunUpdate() for update-scope + // and undo-grouping parity with the original implementation + result = vsb.ToString(); + } + + using (Document.RunUpdate()) + { + Document.Insert(InsertionPosition, result); + InsertionPosition += result.Length; } } @@ -158,6 +299,7 @@ public void RegisterActiveElement(SnippetElement owner, IActiveElement element) throw new ArgumentNullException(nameof(element)); if (_currentStatus != Status.Insertion) throw new InvalidOperationException(); + _elementMap.Add(owner, element); _registeredElements.Add(element); } @@ -169,7 +311,8 @@ public IActiveElement GetActiveElement(SnippetElement owner) { if (owner == null) throw new ArgumentNullException(nameof(owner)); - return _elementMap.TryGetValue(owner, out var element) ? element : null; + + return _elementMap.GetValueOrDefault(owner); } /// @@ -188,8 +331,8 @@ public void RaiseInsertionCompleted(EventArgs e) { if (_currentStatus != Status.Insertion) throw new InvalidOperationException(); - if (e == null) - e = EventArgs.Empty; + + e ??= EventArgs.Empty; _currentStatus = Status.RaisingInsertionCompleted; int endPosition = InsertionPosition; @@ -238,8 +381,8 @@ public void Deactivate(SnippetEventArgs e) return; if (_currentStatus != Status.Interactive) throw new InvalidOperationException("Cannot call Deactivate() until RaiseInsertionCompleted() has finished."); - if (e == null) - e = new SnippetEventArgs(DeactivateReason.Unknown); + + e ??= new SnippetEventArgs(DeactivateReason.Unknown); TextDocumentWeakEventManager.UpdateFinished.RemoveHandler(Document, OnUpdateFinished); _currentStatus = Status.RaisingDeactivated; diff --git a/src/AvaloniaEdit/TextEditor.cs b/src/AvaloniaEdit/TextEditor.cs index 2df935ff..aac323a1 100644 --- a/src/AvaloniaEdit/TextEditor.cs +++ b/src/AvaloniaEdit/TextEditor.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team // // Permission is hereby granted, free of charge, to any person obtaining a copy of this // software and associated documentation files (the "Software"), to deal in the Software @@ -18,15 +18,11 @@ using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text; using Avalonia; -using AvaloniaEdit.Document; -using AvaloniaEdit.Editing; -using AvaloniaEdit.Highlighting; -using AvaloniaEdit.Rendering; -using AvaloniaEdit.Utils; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Controls.Shapes; @@ -34,7 +30,12 @@ using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Highlighting; +using AvaloniaEdit.Rendering; using AvaloniaEdit.Search; +using AvaloniaEdit.Utils; namespace AvaloniaEdit { @@ -75,7 +76,7 @@ public TextEditor() : this(new TextArea()) /// protected TextEditor(TextArea textArea) : this(textArea, new TextDocument()) { - + } protected TextEditor(TextArea textArea, TextDocument document) @@ -619,7 +620,7 @@ private static void SearchResultsBrushChangedCallback(AvaloniaPropertyChangedEve } #endregion - + #region LineNumbersMargin /// /// LineNumbersMargin dependency property. @@ -691,7 +692,7 @@ public IDisposable DeclareChangeBlock() /// public void Delete() { - if(CanDelete) + if (CanDelete) { ApplicationCommands.Delete.Execute(null, TextArea); } @@ -870,7 +871,7 @@ public bool Undo() /// public bool CanRedo { - get { return ApplicationCommands.Redo.CanExecute(null, TextArea); } + get { return ApplicationCommands.Redo.CanExecute(null, TextArea); } } /// @@ -1130,6 +1131,8 @@ public Encoding Encoding /// /// This method sets to false. /// + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Method does not own the stream and should not dispose it.")] public void Save(Stream stream) { if (stream == null) diff --git a/src/AvaloniaEdit/Utils/ValueStringBuilder.AppendSpanFormattable.cs b/src/AvaloniaEdit/Utils/ValueStringBuilder.AppendSpanFormattable.cs new file mode 100644 index 00000000..d9ffeec3 --- /dev/null +++ b/src/AvaloniaEdit/Utils/ValueStringBuilder.AppendSpanFormattable.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Source taken from https://github.com/dotnet/runtime/blob/v11.0.100/src/libraries/Common/src/System/Text/ValueStringBuilder.AppendSpanFormattable.cs + +using System; + +#nullable enable + +namespace AvaloniaEdit.Utils +{ + internal ref partial struct ValueStringBuilder + { + internal void AppendSpanFormattable(T value, string? format = null, IFormatProvider? provider = null) where T : ISpanFormattable + { + if (value.TryFormat(_chars[_pos..], out int charsWritten, format, provider)) + { + _pos += charsWritten; + } + else + { + Append(value.ToString(format, provider)); + } + } + } +} diff --git a/src/AvaloniaEdit/Utils/ValueStringBuilder.cs b/src/AvaloniaEdit/Utils/ValueStringBuilder.cs new file mode 100644 index 00000000..7ed32a20 --- /dev/null +++ b/src/AvaloniaEdit/Utils/ValueStringBuilder.cs @@ -0,0 +1,284 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Source taken from: https://github.com/dotnet/runtime/blob/v11.0.100/src/libraries/Common/src/System/Text/ValueStringBuilder.cs + +using System; +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +#nullable enable + +namespace AvaloniaEdit.Utils +{ + [DebuggerDisplay("{DebuggerDisplay,nq}")] + internal ref partial struct ValueStringBuilder + { + private char[]? _arrayToReturnToPool; + private Span _chars; + private int _pos; + + public ValueStringBuilder(Span initialBuffer) + { + _arrayToReturnToPool = null; + _chars = initialBuffer; + _pos = 0; + } + + public ValueStringBuilder(int initialCapacity) + { + _arrayToReturnToPool = ArrayPool.Shared.Rent(initialCapacity); + _chars = _arrayToReturnToPool; + _pos = 0; + } + + public int Length + { + readonly get => _pos; + set + { + Debug.Assert(value >= 0); + Debug.Assert(value <= _chars.Length); + _pos = value; + } + } + + public readonly int Capacity => _chars.Length; + + public void EnsureCapacity(int capacity) + { + // This is not expected to be called this with negative capacity + Debug.Assert(capacity >= 0); + + // If the caller has a bug and calls this with negative capacity, make sure to call Grow to throw an exception. + if ((uint)capacity > (uint)_chars.Length) + Grow(capacity - _pos); + } + + /// + /// Ensures that the builder is terminated with a NUL character. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void NullTerminate() + { + EnsureCapacity(_pos + 1); + _chars[_pos] = '\0'; + } + + /// + /// Get a pinnable reference to the builder. + /// Does not ensure there is a null char after + /// This overload is pattern matched in the C# 7.3+ compiler so you can omit + /// the explicit method call, and write eg "fixed (char* c = builder)" + /// + public readonly ref char GetPinnableReference() + { + return ref MemoryMarshal.GetReference(_chars); + } + + public ref char this[int index] + { + get + { + Debug.Assert(index < _pos); + return ref _chars[index]; + } + } + + // ToString() clears the builder, so we need a side-effect free debugger display. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private readonly string DebuggerDisplay => AsSpan().ToString(); + + public override string ToString() + { + string s = _chars[.._pos].ToString(); + Dispose(); + return s; + } + + /// Returns the underlying storage of the builder. + public readonly Span RawChars => _chars; + + public readonly ReadOnlySpan AsSpan() => _chars[.._pos]; + public readonly ReadOnlySpan AsSpan(int start) => _chars[start.._pos]; + public readonly ReadOnlySpan AsSpan(int start, int length) => _chars.Slice(start, length); + + public void Insert(int index, char value, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + int remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars[(index + count)..]); + _chars.Slice(index, count).Fill(value); + _pos += count; + } + + public void Insert(int index, string? s) + { + if (s == null) + { + return; + } + + int count = s.Length; + + if (_pos > (_chars.Length - count)) + { + Grow(count); + } + + int remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars[(index + count)..]); + s.CopyTo(_chars[index..]); + _pos += count; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(char c) + { + int pos = _pos; + Span chars = _chars; + if ((uint)pos < (uint)chars.Length) + { + chars[pos] = c; + _pos = pos + 1; + } + else + { + GrowAndAppend(c); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(string? s) + { + if (s == null) + { + return; + } + + int pos = _pos; + if (s.Length == 1 && (uint)pos < (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc. + { + _chars[pos] = s[0]; + _pos = pos + 1; + } + else + { + AppendSlow(s); + } + } + + private void AppendSlow(string s) + { + int pos = _pos; + if (pos > _chars.Length - s.Length) + { + Grow(s.Length); + } + + s.CopyTo(_chars[pos..]); + _pos += s.Length; + } + + public void Append(char c, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + Span dst = _chars.Slice(_pos, count); + //for (int i = 0; i < dst.Length; i++) + //{ + // dst[i] = c; + //} + dst.Fill(c); // optimized (diff from .NET source) + _pos += count; + } + + public void Append(scoped ReadOnlySpan value) + { + int pos = _pos; + if (pos > _chars.Length - value.Length) + { + Grow(value.Length); + } + + value.CopyTo(_chars[_pos..]); + _pos += value.Length; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Span AppendSpan(int length) + { + int origPos = _pos; + if (origPos > _chars.Length - length) + { + Grow(length); + } + + _pos = origPos + length; + return _chars.Slice(origPos, length); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowAndAppend(char c) + { + Grow(1); + Append(c); + } + + /// + /// Resize the internal buffer either by doubling current buffer size or + /// by adding to + /// whichever is greater. + /// + /// + /// Number of chars requested beyond current position. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void Grow(int additionalCapacityBeyondPos) + { + Debug.Assert(additionalCapacityBeyondPos > 0); + Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed."); + + const uint arrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength + + // Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try + // to double the size if possible, bounding the doubling to not go beyond the max array length. + int newCapacity = (int)Math.Max( + (uint)(_pos + additionalCapacityBeyondPos), + Math.Min((uint)_chars.Length * 2, arrayMaxLength)); + + // Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative. + // This could also go negative if the actual required length wraps around. + char[] poolArray = ArrayPool.Shared.Rent(newCapacity); + + _chars[.._pos].CopyTo(poolArray); + + char[]? toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = poolArray; + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + char[]? toReturn = _arrayToReturnToPool; + this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + } +} diff --git a/src/AvaloniaEdit/Utils/ValueStringBuilder_1.cs b/src/AvaloniaEdit/Utils/ValueStringBuilder_1.cs new file mode 100644 index 00000000..c33c0222 --- /dev/null +++ b/src/AvaloniaEdit/Utils/ValueStringBuilder_1.cs @@ -0,0 +1,332 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Source taken from: https://github.com/dotnet/runtime/blob/v11.0.100/src/libraries/Common/src/System/Text/ValueStringBuilder_1.cs + +#nullable enable + +// TODO: this version is only compatible with .NET 11.0 or greater +#if NET11_0_OR_GREATER + +using System; +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace AvaloniaEdit.Utils +{ + using System.Text; + + [DebuggerDisplay("{DebuggerDisplay,nq}")] + internal ref partial struct ValueStringBuilder + where TChar : unmanaged + { + private TChar[]? _arrayToReturnToPool; + private Span _chars; + private int _pos; + + public ValueStringBuilder(Span initialBuffer) + { + Debug.Assert((typeof(TChar) == typeof(Utf8Char)) || (typeof(TChar) == typeof(Utf16Char))); + + _arrayToReturnToPool = null; + _chars = initialBuffer; + _pos = 0; + } + + public ValueStringBuilder(int initialCapacity) + { + Debug.Assert((typeof(TChar) == typeof(Utf8Char)) || (typeof(TChar) == typeof(Utf16Char))); + + _arrayToReturnToPool = ArrayPool.Shared.Rent(initialCapacity); + _chars = _arrayToReturnToPool; + _pos = 0; + } + + public int Length + { + readonly get => _pos; + set + { + Debug.Assert(value >= 0); + Debug.Assert(value <= _chars.Length); + _pos = value; + } + } + + public readonly int Capacity => _chars.Length; + + public void EnsureCapacity(int capacity) + { + // This is not expected to be called this with negative capacity + Debug.Assert(capacity >= 0); + + // If the caller has a bug and calls this with negative capacity, make sure to call Grow to throw an exception. + if ((uint)capacity > (uint)_chars.Length) + Grow(capacity - _pos); + } + + /// + /// Get a pinnable reference to the builder. + /// Does not ensure there is a null TChar after + /// This overload is pattern matched in the C# 7.3+ compiler so you can omit + /// the explicit method call, and write eg "fixed (TChar* c = builder)" + /// + public readonly ref TChar GetPinnableReference() + { + return ref MemoryMarshal.GetReference(_chars); + } + + /// + /// Get a pinnable reference to the builder. + /// + /// Ensures that the builder has a null TChar after + public ref TChar GetPinnableReference(bool terminate) + { + if (terminate) + { + EnsureCapacity(Length + 1); + _chars[Length] = default; + } + return ref MemoryMarshal.GetReference(_chars); + } + + public readonly ref TChar this[int index] + { + get + { + Debug.Assert(index < _pos); + return ref _chars[index]; + } + } + + // ToString() clears the builder, so we need a side-effect free debugger display. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private readonly string DebuggerDisplay => GetString(); + + private readonly string GetString() + { + Span slice = _chars.Slice(0, _pos); + + if (typeof(TChar) == typeof(Utf8Char)) + { + return Encoding.UTF8.GetString(Unsafe.BitCast, ReadOnlySpan>(slice)); + } + else + { + Debug.Assert(typeof(TChar) == typeof(Utf16Char)); + return Unsafe.BitCast, ReadOnlySpan>(slice).ToString(); + } + } + + public override string ToString() + { + string result = GetString(); + Dispose(); + return result; + } + + /// Returns the underlying storage of the builder. + public readonly Span RawChars => _chars; + + /// + /// Returns a span around the contents of the builder. + /// + /// Ensures that the builder has a null TChar after + public ReadOnlySpan AsSpan(bool terminate) + { + if (terminate) + { + EnsureCapacity(Length + 1); + _chars[Length] = default; + } + return _chars[.._pos]; + } + + public readonly ReadOnlySpan AsSpan() => _chars[.._pos]; + public readonly ReadOnlySpan AsSpan(int start) => _chars[start.._pos]; + public readonly ReadOnlySpan AsSpan(int start, int length) => _chars.Slice(start, length); + + public bool TryCopyTo(Span destination, out int charsWritten) + { + if (_chars[.._pos].TryCopyTo(destination)) + { + charsWritten = _pos; + Dispose(); + return true; + } + else + { + charsWritten = 0; + Dispose(); + return false; + } + } + + public void Insert(int index, TChar value, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + int remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars[(index + count)..]); + _chars.Slice(index, count).Fill(value); + _pos += count; + } + + public void Insert(int index, ReadOnlySpan text) + { + if (text.IsEmpty) + { + return; + } + + int count = text.Length; + + if (_pos > (_chars.Length - count)) + { + Grow(count); + } + + int remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars[(index + count)..]); + text.CopyTo(_chars[index..]); + _pos += count; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(TChar c) + { + int pos = _pos; + Span chars = _chars; + if ((uint)pos < (uint)chars.Length) + { + chars[pos] = c; + _pos = pos + 1; + } + else + { + GrowAndAppend(c); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(ReadOnlySpan text) + { + if (text.IsEmpty) + { + return; + } + + int pos = _pos; + if (text.Length == 1 && (uint)pos < (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc. + { + _chars[pos] = text[0]; + _pos = pos + 1; + } + else + { + AppendSlow(text); + } + } + + private void AppendSlow(ReadOnlySpan text) + { + int pos = _pos; + if (pos > _chars.Length - text.Length) + { + Grow(text.Length); + } + + text.CopyTo(_chars[pos..]); + _pos += text.Length; + } + + public void Append(TChar c, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + Span dst = _chars.Slice(_pos, count); + //for (int i = 0; i < dst.Length; i++) + //{ + // dst[i] = c; + //} + dst.Fill(c); // optimized (diff from .NET source) + _pos += count; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Span AppendSpan(int length) + { + int origPos = _pos; + if (origPos > _chars.Length - length) + { + Grow(length); + } + + _pos = origPos + length; + return _chars.Slice(origPos, length); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowAndAppend(TChar c) + { + Grow(1); + Append(c); + } + + /// + /// Resize the internal buffer either by doubling current buffer size or + /// by adding to + /// whichever is greater. + /// + /// + /// Number of chars requested beyond current position. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void Grow(int additionalCapacityBeyondPos) + { + Debug.Assert(additionalCapacityBeyondPos > 0); + Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed."); + + const uint arrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength + + // Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try + // to double the size if possible, bounding the doubling to not go beyond the max array length. + int newCapacity = (int)Math.Max( + (uint)(_pos + additionalCapacityBeyondPos), + Math.Min((uint)_chars.Length * 2, arrayMaxLength)); + + // Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative. + // This could also go negative if the actual required length wraps around. + TChar[] poolArray = ArrayPool.Shared.Rent(newCapacity); + + _chars[.._pos].CopyTo(poolArray); + + TChar[]? toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = poolArray; + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + TChar[]? toReturn = _arrayToReturnToPool; + this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + } +} +#endif diff --git a/test/AvaloniaEdit.Tests/Document/RandomizedLineManagerTest.cs b/test/AvaloniaEdit.Tests/Document/RandomizedLineManagerTest.cs index 035ecb85..b6f17932 100644 --- a/test/AvaloniaEdit.Tests/Document/RandomizedLineManagerTest.cs +++ b/test/AvaloniaEdit.Tests/Document/RandomizedLineManagerTest.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team // // Permission is hereby granted, free of charge, to any person obtaining a copy of this // software and associated documentation files (the "Software"), to deal in the Software @@ -18,167 +18,184 @@ using System; using System.Collections.Generic; +using AvaloniaEdit.Document; using AvaloniaEdit.Rendering; using NUnit.Framework; using Assert = NUnit.Framework.Legacy.ClassicAssert; -namespace AvaloniaEdit.Document +namespace AvaloniaEdit.Tests.Document { - /// - /// A randomized test for the line manager. - /// - [TestFixture] - public class RandomizedLineManagerTest - { - TextDocument document; - Random rnd; - - [OneTimeSetUp] - public void FixtureSetup() - { - int seed = Environment.TickCount; - Console.WriteLine("RandomizedLineManagerTest Seed: " + seed); - rnd = new Random(seed); - } - - [SetUp] - public void Setup() - { - document = new TextDocument(); - } - - [Test] - public void ShortReplacements() - { - char[] chars = { 'a', 'b', '\r', '\n' }; - char[] buffer = new char[20]; - for (int i = 0; i < 2500; i++) { - int offset = rnd.Next(0, document.TextLength); - int length = rnd.Next(0, document.TextLength - offset); - int newTextLength = rnd.Next(0, 20); - for (int j = 0; j < newTextLength; j++) { - buffer[j] = chars[rnd.Next(0, chars.Length)]; - } - - document.Replace(offset, length, new string(buffer, 0, newTextLength)); - CheckLines(); - } - } - - [Test] - public void LargeReplacements() - { - char[] chars = { 'a', 'b', 'c', 'd', 'e', 'f', 'g', '\r', '\n' }; - char[] buffer = new char[1000]; - for (int i = 0; i < 20; i++) { - int offset = rnd.Next(0, document.TextLength); - int length = rnd.Next(0, (document.TextLength - offset) / 4); - int newTextLength = rnd.Next(0, 1000); - for (int j = 0; j < newTextLength; j++) { - buffer[j] = chars[rnd.Next(0, chars.Length)]; - } - - string newText = new string(buffer, 0, newTextLength); - string expectedText = document.Text.Remove(offset, length).Insert(offset, newText); - document.Replace(offset, length, newText); - Assert.AreEqual(expectedText, document.Text); - CheckLines(); - } - } - - void CheckLines() - { - string text = document.Text; - int lineNumber = 1; - int lineStart = 0; - for (int i = 0; i < text.Length; i++) { - char c = text[i]; - if (c == '\r' && i + 1 < text.Length && text[i + 1] == '\n') { - DocumentLine line = document.GetLineByNumber(lineNumber); - Assert.AreEqual(lineNumber, line.LineNumber); - Assert.AreEqual(2, line.DelimiterLength); - Assert.AreEqual(lineStart, line.Offset); - Assert.AreEqual(i - lineStart, line.Length); - i++; // consume \n - lineNumber++; - lineStart = i+1; - } else if (c == '\r' || c == '\n') { - DocumentLine line = document.GetLineByNumber(lineNumber); - Assert.AreEqual(lineNumber, line.LineNumber); - Assert.AreEqual(1, line.DelimiterLength); - Assert.AreEqual(lineStart, line.Offset); - Assert.AreEqual(i - lineStart, line.Length); - lineNumber++; - lineStart = i+1; - } - } - Assert.AreEqual(lineNumber, document.LineCount); - } - - [Test] - public void CollapsingTest() - { - char[] chars = { 'a', 'b', '\r', '\n' }; - char[] buffer = new char[20]; - HeightTree heightTree = new HeightTree(document, 10); - List collapsedSections = new List(); - for (int i = 0; i < 2500; i++) { -// Console.WriteLine("Iteration " + i); -// Console.WriteLine(heightTree.GetTreeAsString()); -// foreach (CollapsedLineSection cs in collapsedSections) { -// Console.WriteLine(cs); -// } - - switch (rnd.Next(0, 10)) { - case 0: - case 1: - case 2: - case 3: - case 4: - case 5: - int offset = rnd.Next(0, document.TextLength); - int length = rnd.Next(0, document.TextLength - offset); - int newTextLength = rnd.Next(0, 20); - for (int j = 0; j < newTextLength; j++) { - buffer[j] = chars[rnd.Next(0, chars.Length)]; - } - - document.Replace(offset, length, new string(buffer, 0, newTextLength)); - break; - case 6: - case 7: - int startLine = rnd.Next(1, document.LineCount + 1); - int endLine = rnd.Next(startLine, document.LineCount + 1); - collapsedSections.Add(heightTree.CollapseText(document.GetLineByNumber(startLine), document.GetLineByNumber(endLine))); - break; - case 8: - if (collapsedSections.Count > 0) { - CollapsedLineSection cs = collapsedSections[rnd.Next(0, collapsedSections.Count)]; - // unless the text section containing the CollapsedSection was deleted: - if (cs.Start != null) { - cs.Uncollapse(); - } - collapsedSections.Remove(cs); - } - break; - case 9: - foreach (DocumentLine ls in document.Lines) { - heightTree.SetHeight(ls, ls.LineNumber); - } - break; - } - var treeSections = new HashSet(heightTree.GetAllCollapsedSections()); - int expectedCount = 0; - foreach (CollapsedLineSection cs in collapsedSections) { - if (cs.Start != null) { - expectedCount++; - Assert.IsTrue(treeSections.Contains(cs)); - } - } - Assert.AreEqual(expectedCount, treeSections.Count); - CheckLines(); - HeightTests.CheckHeights(document, heightTree); - } - } - } + /// + /// A randomized test for the line manager. + /// + [TestFixture] + public class RandomizedLineManagerTest + { + TextDocument document; + Random rnd; + + [OneTimeSetUp] + public void FixtureSetup() + { + int seed = Environment.TickCount; + Console.WriteLine("RandomizedLineManagerTest Seed: " + seed); + rnd = new Random(seed); + } + + [SetUp] + public void Setup() + { + document = new TextDocument(); + } + + [Test] + public void ShortReplacements() + { + char[] chars = { 'a', 'b', '\r', '\n' }; + char[] buffer = new char[20]; + for (int i = 0; i < 2500; i++) + { + int offset = rnd.Next(0, document.TextLength); + int length = rnd.Next(0, document.TextLength - offset); + int newTextLength = rnd.Next(0, 20); + for (int j = 0; j < newTextLength; j++) + { + buffer[j] = chars[rnd.Next(0, chars.Length)]; + } + + document.Replace(offset, length, new string(buffer, 0, newTextLength)); + CheckLines(); + } + } + + [Test] + public void LargeReplacements() + { + char[] chars = { 'a', 'b', 'c', 'd', 'e', 'f', 'g', '\r', '\n' }; + char[] buffer = new char[1000]; + for (int i = 0; i < 20; i++) + { + int offset = rnd.Next(0, document.TextLength); + int length = rnd.Next(0, (document.TextLength - offset) / 4); + int newTextLength = rnd.Next(0, 1000); + for (int j = 0; j < newTextLength; j++) + { + buffer[j] = chars[rnd.Next(0, chars.Length)]; + } + + string newText = new string(buffer, 0, newTextLength); + string expectedText = document.Text.Remove(offset, length).Insert(offset, newText); + document.Replace(offset, length, newText); + Assert.AreEqual(expectedText, document.Text); + CheckLines(); + } + } + + void CheckLines() + { + string text = document.Text; + int lineNumber = 1; + int lineStart = 0; + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (c == '\r' && i + 1 < text.Length && text[i + 1] == '\n') + { + DocumentLine line = document.GetLineByNumber(lineNumber); + Assert.AreEqual(lineNumber, line.LineNumber); + Assert.AreEqual(2, line.DelimiterLength); + Assert.AreEqual(lineStart, line.Offset); + Assert.AreEqual(i - lineStart, line.Length); + i++; // consume \n + lineNumber++; + lineStart = i + 1; + } + else if (c == '\r' || c == '\n') + { + DocumentLine line = document.GetLineByNumber(lineNumber); + Assert.AreEqual(lineNumber, line.LineNumber); + Assert.AreEqual(1, line.DelimiterLength); + Assert.AreEqual(lineStart, line.Offset); + Assert.AreEqual(i - lineStart, line.Length); + lineNumber++; + lineStart = i + 1; + } + } + Assert.AreEqual(lineNumber, document.LineCount); + } + + [Test] + public void CollapsingTest() + { + char[] chars = { 'a', 'b', '\r', '\n' }; + char[] buffer = new char[20]; + using HeightTree heightTree = new HeightTree(document, 10); + List collapsedSections = new List(); + for (int i = 0; i < 2500; i++) + { + // Console.WriteLine("Iteration " + i); + // Console.WriteLine(heightTree.GetTreeAsString()); + // foreach (CollapsedLineSection cs in collapsedSections) { + // Console.WriteLine(cs); + // } + + switch (rnd.Next(0, 10)) + { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + int offset = rnd.Next(0, document.TextLength); + int length = rnd.Next(0, document.TextLength - offset); + int newTextLength = rnd.Next(0, 20); + for (int j = 0; j < newTextLength; j++) + { + buffer[j] = chars[rnd.Next(0, chars.Length)]; + } + + document.Replace(offset, length, new string(buffer, 0, newTextLength)); + break; + case 6: + case 7: + int startLine = rnd.Next(1, document.LineCount + 1); + int endLine = rnd.Next(startLine, document.LineCount + 1); + collapsedSections.Add(heightTree.CollapseText(document.GetLineByNumber(startLine), document.GetLineByNumber(endLine))); + break; + case 8: + if (collapsedSections.Count > 0) + { + CollapsedLineSection cs = collapsedSections[rnd.Next(0, collapsedSections.Count)]; + // unless the text section containing the CollapsedSection was deleted: + if (cs.Start != null) + { + cs.Uncollapse(); + } + collapsedSections.Remove(cs); + } + break; + case 9: + foreach (DocumentLine ls in document.Lines) + { + heightTree.SetHeight(ls, ls.LineNumber); + } + break; + } + var treeSections = new HashSet(heightTree.GetAllCollapsedSections()); + int expectedCount = 0; + foreach (CollapsedLineSection cs in collapsedSections) + { + if (cs.Start != null) + { + expectedCount++; + Assert.IsTrue(treeSections.Contains(cs)); + } + } + Assert.AreEqual(expectedCount, treeSections.Count); + CheckLines(); + HeightTests.CheckHeights(document, heightTree); + } + } + } } diff --git a/test/AvaloniaEdit.Tests/Snippets/InsertionContextTests.cs b/test/AvaloniaEdit.Tests/Snippets/InsertionContextTests.cs new file mode 100644 index 00000000..b306eb46 --- /dev/null +++ b/test/AvaloniaEdit.Tests/Snippets/InsertionContextTests.cs @@ -0,0 +1,1833 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Linq; +using Avalonia.Headless.NUnit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Snippets; +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace AvaloniaEdit.Tests.Snippets +{ + [TestFixture] + public class InsertionContextTests + { + #region Constructor tests + + [AvaloniaTest] + public void Constructor_ShouldThrow_When_TextAreaIsNull() + { + // act & assert + Assert.Throws(() => new InsertionContext(null, 0)); + } + + [AvaloniaTest] + public void Constructor_ShouldSetTextAreaProperty() + { + // arrange + var textArea = CreateTextArea("hello"); + + // act + var context = new InsertionContext(textArea, 0); + + // assert + Assert.AreSame(textArea, context.TextArea); + } + + [AvaloniaTest] + public void Constructor_ShouldSetDocumentProperty() + { + // arrange + var textArea = CreateTextArea("hello"); + + // act + var context = new InsertionContext(textArea, 0); + + // assert + Assert.AreSame(textArea.Document, context.Document); + } + + [AvaloniaTest] + public void Constructor_ShouldSetInsertionPosition() + { + // arrange + var textArea = CreateTextArea("hello"); + + // act + var context = new InsertionContext(textArea, 3); + + // assert + Assert.AreEqual(3, context.InsertionPosition); + } + + [AvaloniaTest] + public void Constructor_ShouldSetStartPosition() + { + // arrange + var textArea = CreateTextArea("hello"); + + // act + var context = new InsertionContext(textArea, 3); + + // assert + Assert.AreEqual(3, context.StartPosition); + } + + [AvaloniaTest] + public void Constructor_ShouldCaptureSelectedText() + { + // arrange + var textArea = CreateTextArea("hello world"); + + // act - no selection, should be empty + var context = new InsertionContext(textArea, 0); + + // assert + Assert.AreEqual("", context.SelectedText); + } + + [AvaloniaTest] + public void Constructor_ShouldSetTabFromOptions() + { + // arrange - default: ConvertTabsToSpaces = false, so Tab = "\t" + var textArea = CreateTextArea("hello"); + var context = new InsertionContext(textArea, 0); + + // assert + Assert.AreEqual("\t", context.Tab); + } + + [AvaloniaTest] + public void Constructor_ShouldSetTabToSpaces_When_ConvertTabsToSpaces() + { + // arrange + var textArea = CreateTextAreaWithSpaces("hello", 4); + var context = new InsertionContext(textArea, 0); + + // assert + Assert.AreEqual(" ", context.Tab); + } + + [AvaloniaTest] + public void Constructor_ShouldCaptureIndentation_When_InsertedInIndentedLine() + { + // arrange - " hello\n" with insertion at offset 4 (after the whitespace) + var textArea = CreateTextArea(" hello\n"); + var context = new InsertionContext(textArea, 4); + + // assert - captures the whitespace before the insertion position on the same line + Assert.AreEqual(" ", context.Indentation); + } + + [AvaloniaTest] + public void Constructor_ShouldCaptureEmptyIndentation_When_NoLeadingWhitespace() + { + // arrange + var textArea = CreateTextArea("hello\n"); + var context = new InsertionContext(textArea, 0); + + // assert + Assert.AreEqual("", context.Indentation); + } + + [AvaloniaTest] + public void Constructor_ShouldSetLineTerminator_CrLf() + { + // arrange - document with \r\n line endings + var textArea = CreateTextArea("hello\r\nworld"); + var context = new InsertionContext(textArea, 0); + + // assert + Assert.AreEqual("\r\n", context.LineTerminator); + } + + [AvaloniaTest] + public void Constructor_ShouldSetLineTerminator_Lf() + { + // arrange - document with \n line endings + var textArea = CreateTextArea("hello\nworld"); + var context = new InsertionContext(textArea, 0); + + // assert + Assert.AreEqual("\n", context.LineTerminator); + } + + #endregion Constructor tests + + #region InsertText - exception ordering tests + + [AvaloniaTest] + public void InsertText_ShouldThrowInvalidOperationException_Before_ArgumentNullException() + { + // This test verifies the original exception precedence: + // _currentStatus is checked BEFORE text nullability. + // If both conditions would throw, InvalidOperationException must win. + + // arrange + var textArea = CreateTextArea("hello"); + var context = CreateContext(textArea, 5); + // Transition away from Insertion status + context.RaiseInsertionCompleted(EventArgs.Empty); + + // act & assert - passing null text when not in Insertion status + // must throw InvalidOperationException, NOT ArgumentNullException + Assert.Throws(() => context.InsertText(null)); + } + + [AvaloniaTest] + public void InsertText_ShouldThrow_When_TextIsNull_And_StatusIsInsertion() + { + // arrange + var textArea = CreateTextArea("hello"); + var context = CreateContext(textArea, 5); + + // act & assert - status IS Insertion, so null check fires + Assert.Throws(() => context.InsertText(null)); + } + + [AvaloniaTest] + public void InsertText_ShouldThrow_When_StatusIsNotInsertion() + { + // arrange + var textArea = CreateTextArea("hello"); + var context = CreateContext(textArea, 5); + + // Transition to Deactivated status (no elements -> auto-deactivates) + context.RaiseInsertionCompleted(EventArgs.Empty); + + // act & assert - context is now in Deactivated state + Assert.Throws(() => context.InsertText("world")); + } + + #endregion InsertText - exception ordering tests + + #region InsertText - fast path tests (no special characters) + + [AvaloniaTest] + public void InsertText_PlainText_ShouldInsertDirectly() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + + // act + context.InsertText("hello world"); + + // assert + Assert.AreEqual("hello world", textArea.Document.Text); + Assert.AreEqual(11, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_PlainText_ShouldInsertAtMiddleOfDocument() + { + // arrange + var textArea = CreateTextArea("helloworld"); + var context = CreateContext(textArea, 5); + + // act + context.InsertText(" "); + + // assert + Assert.AreEqual("hello world", textArea.Document.Text); + Assert.AreEqual(6, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_EmptyString_ShouldNotModifyDocument() + { + // arrange + var textArea = CreateTextArea("hello"); + var context = CreateContext(textArea, 5); + + // act + context.InsertText(""); + + // assert + Assert.AreEqual("hello", textArea.Document.Text); + Assert.AreEqual(5, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_PlainText_ShouldInsertAtEndOfDocument() + { + // arrange + var textArea = CreateTextArea("hello"); + var context = CreateContext(textArea, 5); + + // act + context.InsertText(" world"); + + // assert + Assert.AreEqual("hello world", textArea.Document.Text); + Assert.AreEqual(11, context.InsertionPosition); + } + + #endregion InsertText - fast path tests (no special characters) + + #region InsertText - tab replacement tests + + [AvaloniaTest] + public void InsertText_SingleTab_ShouldReplaceWithTabString() + { + // arrange - default options: ConvertTabsToSpaces = false, so Tab = "\t" + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + + // act + context.InsertText("\t"); + + // assert - Tab property is "\t" when ConvertTabsToSpaces is false + Assert.AreEqual("\t", textArea.Document.Text); + Assert.AreEqual(1, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_Tab_ShouldReplaceWithSpaces_When_ConvertTabsToSpaces() + { + // arrange + var textArea = CreateTextAreaWithSpaces("", 4); + var context = CreateContext(textArea, 0); + + // act + context.InsertText("\t"); + + // assert - Tab property should be " " (4 spaces) + Assert.AreEqual(" ", textArea.Document.Text); + Assert.AreEqual(4, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_MultipleTabs_ShouldReplaceEachWithTabString() + { + // arrange + var textArea = CreateTextAreaWithSpaces("", 2); + var context = CreateContext(textArea, 0); + + // act + context.InsertText("\t\t"); + + // assert - each tab becomes " " (2 spaces) + Assert.AreEqual(" ", textArea.Document.Text); + Assert.AreEqual(4, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_TabSurroundedByText_ShouldReplaceTabOnly() + { + // arrange + var textArea = CreateTextAreaWithSpaces("", 4); + var context = CreateContext(textArea, 0); + + // act + context.InsertText("if\t(true)"); + + // assert + Assert.AreEqual("if (true)", textArea.Document.Text); + Assert.AreEqual(12, context.InsertionPosition); + } + + #endregion InsertText - tab replacement tests + + #region InsertText - tab with newline characters tests + + [AvaloniaTest] + public void InsertText_Tab_ShouldNormalizeNewlines_When_TabContainsLineFeed() + { + // arrange - create a TextArea with custom options that returns Tab with newline + var textArea = CreateTextAreaWithCustomIndentation(" code\n", " \n "); + var context = CreateContext(textArea, 4); + string term = context.LineTerminator; + string indent = context.Indentation; + + // Verify Tab contains newline + Assert.IsTrue(context.Tab.Contains('\n')); + Assert.AreEqual(" \n ", context.Tab); + + // act - input text contains a tab character + context.InsertText("x\ty"); + + // assert - tab should be replaced with Tab string, and its embedded newline normalized + // Expected: " " + "x" + " " + term + indent + " " + "y" + "code\n" + string expected = " x " + term + indent + " y" + "code\n"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_Tab_ShouldNormalizeNewlines_When_TabContainsCarriageReturn() + { + // arrange + var textArea = CreateTextAreaWithCustomIndentation("start\n", "\t\r\t"); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // Verify Tab contains newline + Assert.IsTrue(context.Tab.Contains('\r')); + Assert.AreEqual("\t\r\t", context.Tab); + + // act + context.InsertText("a\tb"); + + // assert - the \r in Tab should be normalized to term + indent + string expected = "a\t" + term + indent + "\tb" + "start\n"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_Tab_ShouldNormalizeNewlines_When_TabContainsCrLf() + { + // arrange + var textArea = CreateTextAreaWithCustomIndentation(" \n", "--\r\n++"); + var context = CreateContext(textArea, 4); + string term = context.LineTerminator; + string indent = context.Indentation; + + // Verify Tab contains newline + Assert.IsTrue(context.Tab.Contains("\r\n")); + Assert.AreEqual("--\r\n++", context.Tab); + + // act + context.InsertText("\t"); + + // assert - the \r\n in Tab should be normalized to term + indent + string expected = " --" + term + indent + "++" + "\n"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_MultipleTabs_ShouldNormalizeNewlines_When_TabContainsNewline() + { + // arrange + var textArea = CreateTextAreaWithCustomIndentation("", ">\n<"); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + Assert.AreEqual(">\n<", context.Tab); + + // act - input contains multiple tabs + context.InsertText("\t\t"); + + // assert - each tab's embedded newline should be normalized + string expected = ">" + term + indent + "<" + + ">" + term + indent + "<"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_TabWithSurroundingText_ShouldNormalizeEmbeddedNewlines() + { + // arrange + var textArea = CreateTextAreaWithCustomIndentation(" \n", " [\n] "); + var context = CreateContext(textArea, 4); + string term = context.LineTerminator; + string indent = context.Indentation; + + Assert.AreEqual(" [\n] ", context.Tab); + + // act + context.InsertText("before\tmiddle\tafter"); + + // assert + string expected = " before [" + + term + indent + "] middle [" + + term + indent + "] after" + + "\n"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_Tab_ShouldNormalizeMixedNewlines_When_TabContainsMultipleNewlineTypes() + { + // arrange + var textArea = CreateTextAreaWithCustomIndentation("", "a\rb\nc\r\nd"); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + Assert.IsTrue(context.Tab.Contains('\r') || context.Tab.Contains('\n')); + + // act + context.InsertText("\t"); + + // assert - all newlines in Tab should be normalized + string expected = "a" + term + indent + + "b" + term + indent + + "c" + term + indent + + "d"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_TabAndInputNewline_ShouldNormalizeBoth_When_TabContainsNewline() + { + // arrange + var textArea = CreateTextAreaWithCustomIndentation(" \n", ">>>\n<<<"); + var context = CreateContext(textArea, 4); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act - input contains both tab and explicit newline + context.InsertText("x\t\ny"); + + // assert - both the tab's embedded newline and the explicit \n should be normalized + string expected = " x>>>" + + term + indent + "<<<" + + term + indent + "y" + + "\n"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_CrLfBoundary_TabEndsWithCr_FollowedByLfInSource() + { + // This is the exact counterexample identified during code review: + // Tab = "\r", text = "\t\n" + // + // Original behavior: + // text.Replace("\t", "\r") produces "\r\n" + // NewLineFinder sees \r\n as ONE CRLF unit -> one LineTerminator + Indentation + // + // A naive single-pass approach would process \r from Tab expansion in isolation + // (one newline), then see the \n from source text (second newline) = TWO newlines. + // The two-phase approach avoids this because Phase 2 sees "\r\n" as one unit. + + // arrange + var textArea = CreateTextAreaWithCustomIndentation("", "\r"); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + Assert.AreEqual("\r", context.Tab); + + // act + context.InsertText("\t\n"); + + // assert - the \r from Tab expansion + \n from source text must form ONE CRLF, + // producing exactly one LineTerminator + Indentation, not two + string expected = term + indent; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_CrLfBoundary_SourceEndsWithCr_FollowedByTabStartingWithLf() + { + // Another boundary case: source text ends with \r, Tab starts with \n. + // After tab expansion, the \r from source and \n from Tab should merge + // into one CRLF unit. + + // arrange - Tab = "\nX" so expanded text becomes "A\r\nXB" + var textArea = CreateTextAreaWithCustomIndentation("", "\nX"); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + Assert.AreEqual("\nX", context.Tab); + + // act - "A\r" from source + "\nX" from Tab expansion + "B" from source + context.InsertText("A\r\tB"); + + // assert - \r from source and \n from Tab expansion form one CRLF + // Then "X" is literal, then "B" + string expected = "A" + term + indent + "XB"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_CrLfBoundary_TabEndsWithCr_FollowedByTabStartingWithCr() + { + // Boundary case between two consecutive tab expansions: + // Tab = "\r", text = "\t\t" -> expanded = "\r\r" + // Each \r is a standalone newline, so two newlines. + // (No CRLF merging because both tab expansions contribute \r, not \r\n) + + // arrange + var textArea = CreateTextAreaWithCustomIndentation("", "\r"); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("\t\t"); + + // assert - two standalone \r, each producing a newline + string expected = term + indent + term + indent; + Assert.AreEqual(expected, textArea.Document.Text); + } + + #endregion InsertText - tab with newline characters tests + + #region InsertText - newline normalization tests (\n) + + [AvaloniaTest] + public void InsertText_LineFeed_ShouldReplaceWithLineTerminatorAndIndentation() + { + // arrange - document with \n line endings, insert at position after indentation + var textArea = CreateTextArea(" code\n"); + // Insert at offset 4 (after the leading whitespace " ") + var context = CreateContext(textArea, 4); + string indent = context.Indentation; + string term = context.LineTerminator; + + // act + context.InsertText("line1\nline2"); + + // assert - the existing "code\n" remains after the insertion point + string expected = " " + "line1" + term + indent + "line2" + "code\n"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_MultipleLineFeeds_ShouldReplaceEach() + { + // arrange - insert at offset 0 of "start\n" + var textArea = CreateTextArea("start\n"); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("a\nb\nc"); + + // assert - "start\n" remains after the insertion + string inserted = "a" + term + indent + + "b" + term + indent + + "c"; + Assert.AreEqual(inserted + "start\n", textArea.Document.Text); + Assert.AreEqual(inserted.Length, context.InsertionPosition); + } + + #endregion InsertText - newline normalization tests (\n) + + #region InsertText - newline normalization tests (\r) + + [AvaloniaTest] + public void InsertText_CarriageReturn_ShouldReplaceWithLineTerminatorAndIndentation() + { + // arrange + var textArea = CreateTextArea("start\n"); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("line1\rline2"); + + // assert - "start\n" remains after the insertion + string inserted = "line1" + term + indent + "line2"; + Assert.AreEqual(inserted + "start\n", textArea.Document.Text); + } + + #endregion InsertText - newline normalization tests (\r) + + #region InsertText - newline normalization tests (\r\n) + + [AvaloniaTest] + public void InsertText_CrLf_ShouldReplaceWithLineTerminatorAndIndentation() + { + // arrange + var textArea = CreateTextArea("start\n"); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("line1\r\nline2"); + + // assert - "start\n" remains after the insertion + string inserted = "line1" + term + indent + "line2"; + Assert.AreEqual(inserted + "start\n", textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_MultipleCrLf_ShouldReplaceEach() + { + // arrange + var textArea = CreateTextArea("start\n"); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("a\r\nb\r\nc"); + + // assert - "start\n" remains after the insertion + string inserted = "a" + term + indent + + "b" + term + indent + + "c"; + Assert.AreEqual(inserted + "start\n", textArea.Document.Text); + } + + #endregion InsertText - newline normalization tests (\r\n) + + #region InsertText - mixed newline tests + + [AvaloniaTest] + public void InsertText_MixedNewlines_ShouldNormalizeAll() + { + // arrange + var textArea = CreateTextArea("start\n"); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act - mix of \n, \r, and \r\n + context.InsertText("a\nb\rc\r\nd"); + + // assert - all newlines normalized, "start\n" remains after insertion + string inserted = "a" + term + indent + + "b" + term + indent + + "c" + term + indent + + "d"; + Assert.AreEqual(inserted + "start\n", textArea.Document.Text); + } + + #endregion InsertText - mixed newline tests + + #region InsertText - mixed tabs and newlines tests + + [AvaloniaTest] + public void InsertText_TabsAndNewlines_ShouldReplaceAll() + { + // arrange - use spaces for tabs so we can verify expansion + var textArea = CreateTextAreaWithSpaces("start\n", 4); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + string tab = context.Tab; + + // act + context.InsertText("\tif (true)\n\t\treturn;"); + + // assert - "start\n" remains after insertion + string inserted = tab + "if (true)" + + term + indent + + tab + tab + "return;"; + Assert.AreEqual(inserted + "start\n", textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_TabImmediatelyBeforeNewline_ShouldReplaceBoth() + { + // arrange + var textArea = CreateTextAreaWithSpaces("", 2); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + string tab = context.Tab; + + // act + context.InsertText("x\t\ny"); + + // assert + string expected = "x" + tab + term + indent + "y"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_NewlineImmediatelyBeforeTab_ShouldReplaceBoth() + { + // arrange + var textArea = CreateTextAreaWithSpaces("", 2); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + string tab = context.Tab; + + // act + context.InsertText("x\n\ty"); + + // assert + string expected = "x" + term + indent + tab + "y"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + #endregion InsertText - mixed tabs and newlines tests + + #region InsertText - indentation preservation tests + + [AvaloniaTest] + public void InsertText_ShouldPreserveIndentation_When_InsertedInIndentedLine() + { + // arrange - insert at end of " hello" (offset 9), before the \n + var textArea = CreateTextArea(" hello\n"); + var context = CreateContext(textArea, 9); + + // The indentation should be " " (the leading whitespace of the line) + Assert.AreEqual(" ", context.Indentation); + + string term = context.LineTerminator; + + // act + context.InsertText("\nworld"); + + // assert - newline should include the indentation, then rest of document follows + Assert.AreEqual(" hello" + term + " world\n", textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_ShouldUseEmptyIndentation_When_InsertedAtLineStart() + { + // arrange - insertion at the very start of a non-indented line + var textArea = CreateTextArea("hello\n"); + var context = CreateContext(textArea, 0); + + // No leading whitespace before position 0 + Assert.AreEqual("", context.Indentation); + + string term = context.LineTerminator; + + // act + context.InsertText("a\nb"); + + // assert - no indentation added after the newline, "hello\n" remains + Assert.AreEqual("a" + term + "b" + "hello\n", textArea.Document.Text); + } + + #endregion InsertText - indentation preservation tests + + #region InsertText - consecutive calls tests + + [AvaloniaTest] + public void InsertText_MultipleConsecutiveCalls_ShouldAppendCorrectly() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + + // act + context.InsertText("hello"); + context.InsertText(" "); + context.InsertText("world"); + + // assert + Assert.AreEqual("hello world", textArea.Document.Text); + Assert.AreEqual(11, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_MultipleCallsWithNewlines_ShouldMaintainInsertionPosition() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("line1\n"); + int posAfterFirst = context.InsertionPosition; + context.InsertText("line2"); + + // assert + string expectedFirstPart = "line1" + term + indent; + Assert.AreEqual(expectedFirstPart.Length, posAfterFirst); + Assert.AreEqual(expectedFirstPart + "line2", textArea.Document.Text); + } + + #endregion InsertText - consecutive calls tests + + #region InsertText - InsertionPosition tracking tests + + [AvaloniaTest] + public void InsertText_InsertionPosition_ShouldAdvanceByInsertedLength_PlainText() + { + // arrange + var textArea = CreateTextArea("existing"); + var context = CreateContext(textArea, 8); + int startPos = context.InsertionPosition; + + // act + context.InsertText("added"); + + // assert + Assert.AreEqual(startPos + 5, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_InsertionPosition_ShouldAccountForExpandedTabs() + { + // arrange + var textArea = CreateTextAreaWithSpaces("", 4); + var context = CreateContext(textArea, 0); + + // act + context.InsertText("\t"); + + // assert - tab expanded to 4 spaces + Assert.AreEqual(4, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_InsertionPosition_ShouldAccountForExpandedNewlines() + { + // arrange + var textArea = CreateTextArea(" code\n"); + var context = CreateContext(textArea, 4); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("a\nb"); + + // assert + int expectedLength = ("a" + term + indent + "b").Length; + Assert.AreEqual(4 + expectedLength, context.InsertionPosition); + } + + #endregion InsertText - InsertionPosition tracking tests + + #region InsertText - edge case tests + + [AvaloniaTest] + public void InsertText_OnlyNewline_ShouldInsertTerminatorAndIndentation() + { + // arrange + var textArea = CreateTextArea(" code\n"); + var context = CreateContext(textArea, 4); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("\n"); + + // assert - "code\n" remains after the insertion + string expected = " " + term + indent + "code\n"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_OnlyTab_ShouldInsertTabString() + { + // arrange + var textArea = CreateTextAreaWithSpaces("", 3); + var context = CreateContext(textArea, 0); + + // act + context.InsertText("\t"); + + // assert + Assert.AreEqual(" ", textArea.Document.Text); + Assert.AreEqual(3, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_TrailingNewline_ShouldAppendTerminatorAndIndentation() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("hello\n"); + + // assert + string expected = "hello" + term + indent; + Assert.AreEqual(expected, textArea.Document.Text); + Assert.AreEqual(expected.Length, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_LeadingNewline_ShouldPrependTerminatorAndIndentation() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("\nhello"); + + // assert + string expected = term + indent + "hello"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_ConsecutiveNewlines_ShouldInsertMultipleTerminators() + { + // arrange - insert into empty document + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("a\n\nb"); + + // assert - two newlines should produce two terminator+indentation pairs + string expected = "a" + term + indent + + term + indent + + "b"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_ConsecutiveTabs_ShouldReplaceEachIndividually() + { + // arrange + var textArea = CreateTextAreaWithSpaces("", 2); + var context = CreateContext(textArea, 0); + + // act + context.InsertText("\t\t\t"); + + // assert - three tabs, each expanded to 2 spaces + Assert.AreEqual(" ", textArea.Document.Text); + Assert.AreEqual(6, context.InsertionPosition); + } + + [AvaloniaTest] + public void InsertText_CrWithoutLf_ShouldTreatAsSingleNewline() + { + // arrange - insert into empty document to avoid stale text confusion + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act - lone \r should be treated as a newline, not ignored + context.InsertText("a\rb"); + + // assert + string expected = "a" + term + indent + "b"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_CrLfAsUnitNotTwoNewlines() + { + // arrange - verify that \r\n produces exactly ONE newline, not two + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + + // act + context.InsertText("a\r\nb"); + + // assert - should be exactly one line break + string expected = "a" + term + indent + "b"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + #endregion InsertText - edge case tests + + #region InsertText - document line terminator preservation tests + + [AvaloniaTest] + public void InsertText_ShouldUseDocumentLineTerminator_CrLf() + { + // arrange - document with \r\n line endings + var textArea = CreateTextArea("hello\r\nworld"); + var context = CreateContext(textArea, 0); + + // The LineTerminator should be detected from the document + Assert.AreEqual("\r\n", context.LineTerminator); + + // act + context.InsertText("a\nb"); + + // assert - the \n in the input should become \r\n in the output, + // and the existing "hello\r\nworld" remains after the insertion + Assert.AreEqual("a\r\nb" + "hello\r\nworld", textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_ShouldUseDocumentLineTerminator_Lf() + { + // arrange - document with \n line endings + var textArea = CreateTextArea("hello\nworld"); + var context = CreateContext(textArea, 0); + + Assert.AreEqual("\n", context.LineTerminator); + + // act + context.InsertText("a\r\nb"); + + // assert - the \r\n in the input should become \n in the output, + // and the existing "hello\nworld" remains after the insertion + Assert.AreEqual("a\nb" + "hello\nworld", textArea.Document.Text); + } + + #endregion InsertText - document line terminator preservation tests + + #region InsertText - large input tests + + [AvaloniaTest] + public void InsertText_LargeInput_ShouldHandleCorrectly() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + + // Build a large input with many lines + var inputBuilder = new System.Text.StringBuilder(); + for (int i = 0; i < 100; i++) + { + if (i > 0) + { + inputBuilder.Append('\n'); + } + + inputBuilder.Append($"line{i}"); + } + string input = inputBuilder.ToString(); + + // act + context.InsertText(input); + + // assert - verify the document contains the expected text + string documentText = textArea.Document.Text; + + // Verify first and last lines are present + Assert.IsTrue(documentText.StartsWith("line0")); + Assert.IsTrue(documentText.EndsWith("line99")); + + // Verify line count: 100 lines of text means 99 newlines + int terminatorCount = 0; + int searchPos = 0; + while ((searchPos = documentText.IndexOf(term, searchPos, StringComparison.Ordinal)) >= 0) + { + terminatorCount++; + searchPos += term.Length; + } + Assert.AreEqual(99, terminatorCount); + } + + #endregion InsertText - large input tests + + #region InsertText - tab identity tests + + [AvaloniaTest] + public void InsertText_TabEqualsLiteralTab_ShouldProduceCorrectResult() + { + // arrange - when ConvertTabsToSpaces is false, Tab == "\t" + // So replacing \t with \t is a no-op. The optimized version skips tab + // processing when Tab == "\t" and only scans for newlines. + var textArea = CreateTextArea(""); + textArea.Options.ConvertTabsToSpaces = false; + var context = CreateContext(textArea, 0); + + // act + context.InsertText("hello\tworld"); + + // assert - tab is preserved as-is (identity replacement) + Assert.AreEqual("hello\tworld", textArea.Document.Text); + Assert.AreEqual(11, context.InsertionPosition); + } + + #endregion InsertText - tab identity tests + + #region InsertText - RunUpdate event observation tests + + [AvaloniaTest] + public void InsertText_ShouldFireUpdateStartedAndFinished_ForPlainText() + { + // This test verifies that InsertText wraps its document operation + // in RunUpdate(), which fires UpdateStarted and UpdateFinished events. + // The original code always uses RunUpdate(); the optimized version must too. + + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + int updateStartedCount = 0; + int updateFinishedCount = 0; + textArea.Document.UpdateStarted += (_, _) => updateStartedCount++; + textArea.Document.UpdateFinished += (_, _) => updateFinishedCount++; + + // act + context.InsertText("hello"); + + // assert - should have fired exactly one UpdateStarted/UpdateFinished pair + Assert.AreEqual(1, updateStartedCount); + Assert.AreEqual(1, updateFinishedCount); + } + + [AvaloniaTest] + public void InsertText_ShouldFireUpdateStartedAndFinished_ForTextWithNewlines() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + int updateStartedCount = 0; + int updateFinishedCount = 0; + textArea.Document.UpdateStarted += (_, _) => updateStartedCount++; + textArea.Document.UpdateFinished += (_, _) => updateFinishedCount++; + + // act + context.InsertText("hello\nworld"); + + // assert + Assert.AreEqual(1, updateStartedCount); + Assert.AreEqual(1, updateFinishedCount); + } + + [AvaloniaTest] + public void InsertText_ShouldGroupAsOneUndoAction() + { + // Verifies the RunUpdate scope groups all changes into a single undo action. + + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + + // act + context.InsertText("line1\nline2\nline3"); + + // assert - one undo should revert the entire insertion + textArea.Document.UndoStack.Undo(); + Assert.AreEqual("", textArea.Document.Text); + } + + #endregion InsertText - RunUpdate event observation tests + + #region InsertText - regression parity tests + + [AvaloniaTest] + public void InsertText_ShouldProduceSameResult_AsOriginalForSimpleSnippet() + { + // This test verifies a realistic snippet insertion scenario: + // inserting "if (condition)\n{\n\tstatement;\n}" into an indented context. + + // arrange + var textArea = CreateTextAreaWithSpaces(" \n", 4); + // Insert at offset 4, which is after " " indentation on line 1 + var context = CreateContext(textArea, 4); + + Assert.AreEqual(" ", context.Indentation); + string term = context.LineTerminator; + string indent = context.Indentation; + string tab = context.Tab; + + // act + context.InsertText("if (condition)\n{\n\tstatement;\n}"); + + // assert - "\n" at end of original document remains after insertion + string expected = " " + + "if (condition)" + term + indent + + "{" + term + indent + + tab + "statement;" + term + indent + + "}" + + "\n"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + [AvaloniaTest] + public void InsertText_ShouldProduceSameResult_ForMultipleTabsInLine() + { + // Simulates: "\t\tint x = 0;\n\t\tint y = 0;" + + // arrange + var textArea = CreateTextAreaWithSpaces("", 4); + var context = CreateContext(textArea, 0); + string term = context.LineTerminator; + string indent = context.Indentation; + string tab = context.Tab; + + // act + context.InsertText("\t\tint x = 0;\n\t\tint y = 0;"); + + // assert + string expected = tab + tab + "int x = 0;" + + term + indent + + tab + tab + "int y = 0;"; + Assert.AreEqual(expected, textArea.Document.Text); + } + + #endregion InsertText - regression parity tests + + #region InsertText - StartPosition and anchor tests + + [AvaloniaTest] + public void InsertText_StartPosition_ShouldRemainAtOriginalOffset() + { + // arrange + var textArea = CreateTextArea("prefix"); + var context = CreateContext(textArea, 6); + + // act + context.InsertText("inserted text"); + + // assert - StartPosition should still point to the beginning of the snippet + Assert.AreEqual(6, context.StartPosition); + } + + #endregion InsertText - StartPosition and anchor tests + + #region RegisterActiveElement tests + + [AvaloniaTest] + public void RegisterActiveElement_ShouldThrow_When_OwnerIsNull() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var element = new TestActiveElement(); + + // act & assert + Assert.Throws(() => context.RegisterActiveElement(null, element)); + } + + [AvaloniaTest] + public void RegisterActiveElement_ShouldThrow_When_ElementIsNull() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var owner = new TestSnippetElement(); + + // act & assert + Assert.Throws(() => context.RegisterActiveElement(owner, null)); + } + + [AvaloniaTest] + public void RegisterActiveElement_ShouldThrow_When_NotInInsertionStatus() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + // Transition away from Insertion status + context.RaiseInsertionCompleted(EventArgs.Empty); + + var owner = new TestSnippetElement(); + var element = new TestActiveElement(); + + // act & assert + Assert.Throws(() => context.RegisterActiveElement(owner, element)); + } + + [AvaloniaTest] + public void RegisterActiveElement_ShouldSucceed_DuringInsertion() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var owner = new TestSnippetElement(); + var element = new TestActiveElement(); + + // act - should not throw + context.RegisterActiveElement(owner, element); + + // assert - element should be retrievable + Assert.AreSame(element, context.GetActiveElement(owner)); + } + + [AvaloniaTest] + public void RegisterActiveElement_ShouldThrow_When_DuplicateOwner() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var owner = new TestSnippetElement(); + var element1 = new TestActiveElement(); + var element2 = new TestActiveElement(); + + context.RegisterActiveElement(owner, element1); + + // act & assert - same owner registered twice should throw (Dictionary.Add behavior) + Assert.Throws(() => context.RegisterActiveElement(owner, element2)); + } + + #endregion RegisterActiveElement tests + + #region GetActiveElement tests + + [AvaloniaTest] + public void GetActiveElement_ShouldThrow_When_OwnerIsNull() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + + // act & assert + Assert.Throws(() => context.GetActiveElement(null)); + } + + [AvaloniaTest] + public void GetActiveElement_ShouldReturnNull_When_OwnerNotRegistered() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var owner = new TestSnippetElement(); + + // act + var result = context.GetActiveElement(owner); + + // assert + Assert.IsNull(result); + } + + [AvaloniaTest] + public void GetActiveElement_ShouldReturnRegisteredElement() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var owner = new TestSnippetElement(); + var element = new TestActiveElement(); + context.RegisterActiveElement(owner, element); + + // act + var result = context.GetActiveElement(owner); + + // assert + Assert.AreSame(element, result); + } + + [AvaloniaTest] + public void GetActiveElement_ShouldReturnCorrectElement_When_MultipleRegistered() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var owner1 = new TestSnippetElement(); + var owner2 = new TestSnippetElement(); + var element1 = new TestActiveElement(); + var element2 = new TestActiveElement(); + context.RegisterActiveElement(owner1, element1); + context.RegisterActiveElement(owner2, element2); + + // act & assert + Assert.AreSame(element1, context.GetActiveElement(owner1)); + Assert.AreSame(element2, context.GetActiveElement(owner2)); + } + + #endregion GetActiveElement tests + + #region ActiveElements property tests + + [AvaloniaTest] + public void ActiveElements_ShouldBeEmpty_Initially() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + + // act & assert + Assert.IsFalse(context.ActiveElements.Any()); + } + + [AvaloniaTest] + public void ActiveElements_ShouldReturnRegisteredElements_InOrder() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var owner1 = new TestSnippetElement(); + var owner2 = new TestSnippetElement(); + var element1 = new TestActiveElement(); + var element2 = new TestActiveElement(); + context.RegisterActiveElement(owner1, element1); + context.RegisterActiveElement(owner2, element2); + + // act + var elements = context.ActiveElements.ToList(); + + // assert - should preserve insertion order + Assert.AreEqual(2, elements.Count); + Assert.AreSame(element1, elements[0]); + Assert.AreSame(element2, elements[1]); + } + + #endregion ActiveElements property tests + + #region RaiseInsertionCompleted tests + + [AvaloniaTest] + public void RaiseInsertionCompleted_ShouldThrow_When_NotInInsertionStatus() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + // First call transitions away from Insertion + context.RaiseInsertionCompleted(EventArgs.Empty); + + // act & assert - second call should throw + Assert.Throws(() => context.RaiseInsertionCompleted(EventArgs.Empty)); + } + + [AvaloniaTest] + public void RaiseInsertionCompleted_ShouldCallOnInsertionCompleted_OnAllElements() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var owner1 = new TestSnippetElement(); + var owner2 = new TestSnippetElement(); + var element1 = new TestActiveElement { IsEditable = true, Segment = new SimpleSegment(0, 0) }; + var element2 = new TestActiveElement { IsEditable = true, Segment = new SimpleSegment(0, 0) }; + context.RegisterActiveElement(owner1, element1); + context.RegisterActiveElement(owner2, element2); + + // act + context.RaiseInsertionCompleted(EventArgs.Empty); + + // assert + Assert.IsTrue(element1.InsertionCompletedCalled); + Assert.IsTrue(element2.InsertionCompletedCalled); + } + + [AvaloniaTest] + public void RaiseInsertionCompleted_ShouldRaiseInsertionCompletedEvent() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + bool eventRaised = false; + context.InsertionCompleted += (_, _) => eventRaised = true; + + // act + context.RaiseInsertionCompleted(EventArgs.Empty); + + // assert + Assert.IsTrue(eventRaised); + } + + [AvaloniaTest] + public void RaiseInsertionCompleted_ShouldAutoDeactivate_When_NoActiveElements() + { + // arrange - no elements registered + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + bool deactivatedRaised = false; + DeactivateReason? reason = null; + context.Deactivated += (_, e) => + { + deactivatedRaised = true; + reason = e.Reason; + }; + + // act + context.RaiseInsertionCompleted(EventArgs.Empty); + + // assert - should auto-deactivate with NoActiveElements reason + Assert.IsTrue(deactivatedRaised); + Assert.AreEqual(DeactivateReason.NoActiveElements, reason); + } + + [AvaloniaTest] + public void RaiseInsertionCompleted_ShouldSetStartPosition_ViaWholeSnippetAnchor() + { + // arrange + var textArea = CreateTextArea("prefix"); + var context = CreateContext(textArea, 6); + context.InsertText("body"); + + // act + context.RaiseInsertionCompleted(EventArgs.Empty); + + // assert - StartPosition should now be backed by the AnchorSegment + Assert.AreEqual(6, context.StartPosition); + } + + [AvaloniaTest] + public void RaiseInsertionCompleted_ShouldAcceptNullEventArgs() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + bool eventRaised = false; + EventArgs receivedArgs = null; + context.InsertionCompleted += (_, e) => + { + eventRaised = true; + receivedArgs = e; + }; + + // act - null should be replaced with EventArgs.Empty + context.RaiseInsertionCompleted(null); + + // assert + Assert.IsTrue(eventRaised); + Assert.AreSame(EventArgs.Empty, receivedArgs); + } + + #endregion RaiseInsertionCompleted tests + + #region Deactivate tests + + [AvaloniaTest] + public void Deactivate_ShouldThrow_When_NotInInteractiveStatus() + { + // arrange - context is still in Insertion status + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + + // act & assert + Assert.Throws(() => + context.Deactivate(new SnippetEventArgs(DeactivateReason.Unknown))); + } + + [AvaloniaTest] + public void Deactivate_ShouldCallDeactivate_OnAllElements() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var owner1 = new TestSnippetElement(); + var owner2 = new TestSnippetElement(); + var element1 = new TestActiveElement { IsEditable = true, Segment = new SimpleSegment(0, 0) }; + var element2 = new TestActiveElement { IsEditable = true, Segment = new SimpleSegment(0, 0) }; + context.RegisterActiveElement(owner1, element1); + context.RegisterActiveElement(owner2, element2); + context.RaiseInsertionCompleted(EventArgs.Empty); + + // act + context.Deactivate(new SnippetEventArgs(DeactivateReason.EscapePressed)); + + // assert + Assert.IsTrue(element1.DeactivateCalled); + Assert.IsTrue(element2.DeactivateCalled); + Assert.AreEqual(DeactivateReason.EscapePressed, element1.DeactivateArgs.Reason); + Assert.AreEqual(DeactivateReason.EscapePressed, element2.DeactivateArgs.Reason); + } + + [AvaloniaTest] + public void Deactivate_ShouldRaiseDeactivatedEvent() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var owner = new TestSnippetElement(); + var element = new TestActiveElement { IsEditable = true, Segment = new SimpleSegment(0, 0) }; + context.RegisterActiveElement(owner, element); + context.RaiseInsertionCompleted(EventArgs.Empty); + + bool deactivatedRaised = false; + DeactivateReason? reason = null; + context.Deactivated += (_, e) => + { + deactivatedRaised = true; + reason = e.Reason; + }; + + // act + context.Deactivate(new SnippetEventArgs(DeactivateReason.ReturnPressed)); + + // assert + Assert.IsTrue(deactivatedRaised); + Assert.AreEqual(DeactivateReason.ReturnPressed, reason); + } + + [AvaloniaTest] + public void Deactivate_ShouldBeIdempotent_WhenCalledMultipleTimes() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + // No elements -> auto-deactivates on RaiseInsertionCompleted + context.RaiseInsertionCompleted(EventArgs.Empty); + + // act & assert - second deactivate should be a no-op (not throw) + Assert.DoesNotThrow(() => context.Deactivate(new SnippetEventArgs(DeactivateReason.Unknown))); + } + + [AvaloniaTest] + public void Deactivate_ShouldUseUnknownReason_When_EventArgsIsNull() + { + // arrange + var textArea = CreateTextArea(""); + var context = CreateContext(textArea, 0); + var owner = new TestSnippetElement(); + var element = new TestActiveElement { IsEditable = true, Segment = new SimpleSegment(0, 0) }; + context.RegisterActiveElement(owner, element); + context.RaiseInsertionCompleted(EventArgs.Empty); + + // act - null should be replaced with Unknown reason + context.Deactivate(null); + + // assert + Assert.IsTrue(element.DeactivateCalled); + Assert.AreEqual(DeactivateReason.Unknown, element.DeactivateArgs.Reason); + } + + #endregion Deactivate tests + + #region Link tests + + [AvaloniaTest] + public void Link_ShouldRegisterActiveElements_ForMainAndBound() + { + // arrange - create a document with text that has identifiable segments + var textArea = CreateTextArea("var name = name;"); + var context = CreateContext(textArea, 0); + + // Main element: "name" at offset 4, length 4 + var mainSegment = new SimpleSegment(4, 4); + // Bound element: "name" at offset 11, length 4 + var boundSegments = new ISegment[] { new SimpleSegment(11, 4) }; + + // act + context.Link(mainSegment, boundSegments); + + // assert - should have registered 2 elements (1 main + 1 bound) + var elements = context.ActiveElements.ToList(); + Assert.AreEqual(2, elements.Count); + } + + [AvaloniaTest] + public void Link_ShouldRegisterMultipleBoundElements() + { + // arrange + var textArea = CreateTextArea("var x = x + x;"); + var context = CreateContext(textArea, 0); + + var mainSegment = new SimpleSegment(4, 1); + var boundSegments = new ISegment[] + { + new SimpleSegment(8, 1), + new SimpleSegment(12, 1) + }; + + // act + context.Link(mainSegment, boundSegments); + + // assert - should have 3 elements (1 main + 2 bound) + var elements = context.ActiveElements.ToList(); + Assert.AreEqual(3, elements.Count); + } + + #endregion Link tests + + #region Test helpers + + /// + /// Creates a with the specified document text and + /// default options (tabs = "\t", indentation size = 4, ConvertTabsToSpaces = false). + /// + private static TextArea CreateTextArea(string documentText) + { + var textArea = new TextArea + { + Document = new TextDocument(documentText), + Options = + { + // Ensure default tab behavior: tabs stay as literal "\t" replacement + ConvertTabsToSpaces = false, + IndentationSize = 4 + } + }; + return textArea; + } + + /// + /// Creates a configured to convert tabs to spaces. + /// + private static TextArea CreateTextAreaWithSpaces(string documentText, int indentationSize = 4) + { + var textArea = new TextArea + { + Document = new TextDocument(documentText), + Options = + { + ConvertTabsToSpaces = true, + IndentationSize = indentationSize + } + }; + return textArea; + } + + /// + /// Creates an at the specified offset. + /// + private static InsertionContext CreateContext(TextArea textArea, int insertionPosition) + { + return new InsertionContext(textArea, insertionPosition); + } + + /// + /// Creates a with custom IndentationString for testing + /// scenarios where Tab property contains newline characters. + /// + private static TextArea CreateTextAreaWithCustomIndentation(string documentText, string customIndentation) + { + var textArea = new TextArea + { + Document = new TextDocument(documentText), + Options = new TestEditorOptionsWithCustomIndentation(customIndentation) + }; + + return textArea; + } + + /// + /// Custom that overrides GetIndentationString + /// to return a custom indentation string, used for testing edge cases where + /// the indentation string contains newline characters. + /// + private sealed class TestEditorOptionsWithCustomIndentation : TextEditorOptions + { + private readonly string _customIndentation; + + public TestEditorOptionsWithCustomIndentation(string customIndentation) + { + _customIndentation = customIndentation ?? throw new ArgumentNullException(nameof(customIndentation)); + } + + public override string GetIndentationString(int column) => _customIndentation; + } + + /// + /// Simple stub implementation of for testing. + /// Tracks whether OnInsertionCompleted and Deactivate were called. + /// + private sealed class TestActiveElement : IActiveElement + { + public bool InsertionCompletedCalled { get; private set; } + public bool DeactivateCalled { get; private set; } + public SnippetEventArgs DeactivateArgs { get; private set; } + public bool IsEditable { get; init; } + public ISegment Segment { get; init; } = new TextSegment(); + + public void OnInsertionCompleted() + { + InsertionCompletedCalled = true; + } + + public void Deactivate(SnippetEventArgs e) + { + DeactivateCalled = true; + DeactivateArgs = e; + } + } + + /// + /// Simple concrete subclass for use as a dictionary key + /// in RegisterActiveElement/GetActiveElement tests. + /// + private sealed class TestSnippetElement : SnippetElement + { + public override void Insert(InsertionContext context) + { + // no-op for testing + } + } + + #endregion Test helpers + } +} diff --git a/test/AvaloniaEdit.Tests/TestUtils/ReflectionTestHelper.cs b/test/AvaloniaEdit.Tests/TestUtils/ReflectionTestHelper.cs new file mode 100644 index 00000000..5233b738 --- /dev/null +++ b/test/AvaloniaEdit.Tests/TestUtils/ReflectionTestHelper.cs @@ -0,0 +1,338 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Reflection; + +namespace AvaloniaEdit.Tests.TestUtils +{ + /// + /// Provides reflection-based helper methods for invoking private and internal members + /// in unit tests. This utility enables precise testing of private algorithms (e.g., + /// match quality scoring, CamelCase matching) without exposing implementation details + /// in the public API. + /// + /// + /// Think of this class as a "backstage pass" - it lets tests reach behind the curtain + /// to verify the internal machinery of a class, while the class itself maintains its + /// proper encapsulation for consumers. + /// + internal static class ReflectionTestHelper + { + private const BindingFlags InstanceNonPublic = BindingFlags.Instance | BindingFlags.NonPublic; + private const BindingFlags StaticNonPublic = BindingFlags.Static | BindingFlags.NonPublic; + private const BindingFlags InstanceAll = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + private const BindingFlags StaticAll = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + + /// + /// Invokes a private instance method on the specified target object. + /// + /// The expected return type of the method. + /// The object instance on which to invoke the method. + /// The name of the private method to invoke. + /// The arguments to pass to the method. + /// The return value of the invoked method, cast to . + /// + /// Thrown when or is null. + /// + /// + /// Thrown when the specified method cannot be found on the target's type. + /// + public static TResult InvokePrivateMethod(object target, string methodName, params object[] args) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(methodName); + + // Use parameter types from args to disambiguate overloads. + // Null arguments cannot be used to infer parameter types reliably, so reject them. + if (args != null) + { + for (int i = 0; i < args.Length; i++) + { + if (args[i] is null) + { + throw new ArgumentException("Null arguments are not supported for overload resolution. " + + "Provide non-null arguments or use a different helper that allows specifying parameter types explicitly.", + nameof(args)); + } + } + } + + Type[] parameterTypes = (args is null) ? Type.EmptyTypes : Array.ConvertAll(args, a => a.GetType()); + MethodInfo method = target.GetType().GetMethod(methodName, InstanceNonPublic, binder: null, types: parameterTypes, modifiers: null) + ?? throw new InvalidOperationException($"Method '{methodName}' not found on type '{target.GetType().FullName}'. " + + $"Ensure the method exists and is a non-public instance method with the specified signature."); + + object result = method.Invoke(target, args); + + if (result is null) + { + var resultType = typeof(TResult); + bool isNonNullableValueType = resultType.IsValueType && Nullable.GetUnderlyingType(resultType) is null; + + if (isNonNullableValueType) + { + throw new InvalidOperationException($"Method '{methodName}' on type '{target.GetType().FullName}' returned null, " + + $"but the requested result type '{resultType.FullName}' is a non-nullable value type."); + } + + return default; + } + return (TResult)result; + } + + /// + /// Invokes a private instance method that returns void on the specified target object. + /// + /// The object instance on which to invoke the method. + /// The name of the private method to invoke. + /// The arguments to pass to the method. + /// + /// Thrown when or is null. + /// + /// + /// Thrown when the specified method cannot be found on the target's type. + /// + public static void InvokePrivateMethod(object target, string methodName, params object[] args) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(methodName); + + MethodInfo method = target.GetType().GetMethod(methodName, InstanceNonPublic) + ?? throw new InvalidOperationException($"Method '{methodName}' not found on type '{target.GetType().FullName}'. " + + $"Ensure the method exists and is a non-public instance method."); + + method.Invoke(target, args); + } + + /// + /// Invokes a private static method on the specified type. + /// + /// The expected return type of the method. + /// The type that declares the static method. + /// The name of the private static method to invoke. + /// The arguments to pass to the method. + /// The return value of the invoked method, cast to . + /// + /// Thrown when or is null. + /// + /// + /// Thrown when the specified method cannot be found on the given type. + /// + public static TResult InvokePrivateStaticMethod(Type type, string methodName, params object[] args) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(methodName); + + object[] actualArgs = args ?? Array.Empty(); + Type[] parameterTypes = new Type[actualArgs.Length]; + for (int i = 0; i < actualArgs.Length; i++) + { + if (actualArgs[i] is null) + { + throw new ArgumentException($"Cannot resolve overload for static method '{methodName}' on type '{type.FullName}' " + + "because one of the arguments is null. Provide a non-null argument or adjust the helper.", + nameof(args)); + } + + parameterTypes[i] = actualArgs[i].GetType(); + } + + MethodInfo method = type.GetMethod(methodName, StaticNonPublic, binder: null, types: parameterTypes, modifiers: null) + ?? throw new InvalidOperationException($"Static method '{methodName}' not found on type '{type.FullName}'. " + + $"Ensure the method exists and is a non-public static method with the specified signature."); + + object result = method.Invoke(null, actualArgs); + if (result is null) + { + // Provide a clearer error when a null result is incompatible with TResult. + Type resultType = typeof(TResult); + if (resultType.IsValueType && Nullable.GetUnderlyingType(resultType) is null) + { + throw new InvalidOperationException($"Static method '{methodName}' on type '{type.FullName}' returned null, " + + $"but the requested result type '{typeof(TResult).FullName}' is a non-nullable value type."); + } + + return default; + } + return (TResult)result; + } + + /// + /// Gets the value of a private instance field on the specified target object. + /// + /// The expected type of the field value. + /// The object instance from which to read the field. + /// The name of the private field to read. + /// The value of the field, cast to . + /// + /// Thrown when or is null. + /// + /// + /// Thrown when the specified field cannot be found on the target's type. + /// + public static TResult GetPrivateField(object target, string fieldName) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(fieldName); + + FieldInfo field = target.GetType().GetField(fieldName, InstanceAll) + ?? throw new InvalidOperationException($"Field '{fieldName}' not found on type '{target.GetType().FullName}'."); + + object value = field.GetValue(target); + if (value is null) + { + var resultType = typeof(TResult); + bool isNonNullableValueType = resultType.IsValueType && Nullable.GetUnderlyingType(resultType) is null; + if (isNonNullableValueType) + { + throw new InvalidOperationException($"Field '{fieldName}' on type '{target.GetType().FullName}' contains null, " + + $"but the requested result type '{resultType.FullName}' is a non-nullable value type."); + } + + return default; + } + return (TResult)value; + } + + /// + /// Sets the value of a private instance field on the specified target object. + /// + /// The object instance on which to set the field. + /// The name of the private field to set. + /// The value to assign to the field. + /// + /// Thrown when or is null. + /// + /// + /// Thrown when the specified field cannot be found on the target's type. + /// + public static void SetPrivateField(object target, string fieldName, object value) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(fieldName); + + FieldInfo field = target.GetType().GetField(fieldName, InstanceAll) + ?? throw new InvalidOperationException($"Field '{fieldName}' not found on type '{target.GetType().FullName}'."); + + field.SetValue(target, value); + } + + /// + /// Gets the value of a private static field on the specified type. + /// + /// The expected type of the field value. + /// The type that declares the static field. + /// The name of the private static field to read. + /// The value of the field, cast to . + /// + /// Thrown when or is null. + /// + /// + /// Thrown when the specified field cannot be found on the given type. + /// + public static TResult GetPrivateStaticField(Type type, string fieldName) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(fieldName); + + FieldInfo field = type.GetField(fieldName, StaticAll) + ?? throw new InvalidOperationException($"Static field '{fieldName}' not found on type '{type.FullName}'."); + + object value = field.GetValue(null); + Type resultType = typeof(TResult); + if (value is null) + { + // For non-nullable value types, a null field value is an invalid state for the requested result type. + if (resultType.IsValueType && Nullable.GetUnderlyingType(resultType) is null) + { + throw new InvalidOperationException($"Static field '{fieldName}' on type '{type.FullName}' contains null, " + + $"which cannot be assigned to non-nullable value type '{resultType.FullName}'."); + } + + return default!; + } + + if (!resultType.IsInstanceOfType(value)) + { + throw new InvalidOperationException($"Static field '{fieldName}' on type '{type.FullName}' has value of type '{value.GetType().FullName}', " + + $"which is not compatible with requested result type '{resultType.FullName}'."); + } + + return (TResult)value; + } + + /// + /// Sets the value of a property on the specified target object, using either the property setter + /// or the backing field if the setter is not accessible. + /// + /// The object instance on which to set the property. + /// The name of the property to set. + /// The value to assign to the property. + /// + /// Thrown when or is null. + /// + /// + /// Thrown when neither the property setter nor backing field can be found or accessed. + /// + public static void SetProperty(object target, string propertyName, object value) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(propertyName); + + Type targetType = target.GetType(); + PropertyInfo property = targetType.GetProperty(propertyName, InstanceAll); + + // Try to use the property setter if available + if (property != null && property.CanWrite) + { + property.SetValue(target, value); + return; + } + + // If property exists but is read-only, try to find the backing field + if (property != null) + { + // Try common backing field patterns + string camelCaseName = propertyName.Length > 1 + ? $"{char.ToLowerInvariant(propertyName[0])}{propertyName[1..]}" + : char.ToLowerInvariant(propertyName[0]).ToString(); + + string[] backingFieldPatterns = new[] + { + $"<{propertyName}>k__BackingField", // Auto-property backing field + $"_{camelCaseName}", // _camelCase + $"_{propertyName}", // _PropertyName + }; + + foreach (string fieldName in backingFieldPatterns) + { + FieldInfo field = targetType.GetField(fieldName, InstanceAll); + if (field != null) + { + field.SetValue(target, value); + return; + } + } + } + + throw new InvalidOperationException($"Property '{propertyName}' not found or cannot be set on type '{targetType.FullName}'. " + + $"Ensure the property exists with a setter or has an accessible backing field."); + } + } +} \ No newline at end of file diff --git a/test/AvaloniaEdit.Tests/Utils/CompressingTreeListTests.cs b/test/AvaloniaEdit.Tests/Utils/CompressingTreeListTests.cs index 857bf62e..58ee6a9b 100644 --- a/test/AvaloniaEdit.Tests/Utils/CompressingTreeListTests.cs +++ b/test/AvaloniaEdit.Tests/Utils/CompressingTreeListTests.cs @@ -23,125 +23,131 @@ namespace AvaloniaEdit.Utils { - [TestFixture] - public class CompressingTreeListTests - { - [Test] - public void EmptyTreeList() - { - CompressingTreeList list = new CompressingTreeList(string.Equals); - Assert.AreEqual(0, list.Count); - foreach (string v in list) { - Assert.Fail(); - } - string[] arr = new string[0]; - list.CopyTo(arr, 0); - } - - [Test] - public void CheckAdd10BillionElements() - { - const int billion = 1000000000; - CompressingTreeList list = new CompressingTreeList(string.Equals); - list.InsertRange(0, billion, "A"); - list.InsertRange(1, billion, "B"); - Assert.AreEqual(2 * billion, list.Count); - Assert.Throws(delegate { list.InsertRange(2, billion, "C"); }); - } - - [Test] - public void AddRepeated() - { - CompressingTreeList list = new CompressingTreeList((a, b) => a == b); - list.Add(42); - list.Add(42); - list.Add(42); - list.Insert(0, 42); - list.Insert(1, 42); - Assert.AreEqual(new[] { 42, 42, 42, 42, 42 }, list.ToArray()); - } - - [Test] - public void RemoveRange() - { - CompressingTreeList list = new CompressingTreeList((a, b) => a == b); - for (int i = 1; i <= 3; i++) { - list.InsertRange(list.Count, 2, i); - } - Assert.AreEqual(new[] { 1, 1, 2, 2, 3, 3 }, list.ToArray()); - list.RemoveRange(1, 4); - Assert.AreEqual(new[] { 1, 3 }, list.ToArray()); - list.Insert(1, 1); - list.InsertRange(2, 2, 2); - list.Insert(4, 1); - Assert.AreEqual(new[] { 1, 1, 2, 2, 1, 3 }, list.ToArray()); - list.RemoveRange(2, 2); - Assert.AreEqual(new[] { 1, 1, 1, 3 }, list.ToArray()); - } - - [Test] - public void RemoveAtEnd() - { - CompressingTreeList list = new CompressingTreeList((a, b) => a == b); - for (int i = 1; i <= 3; i++) { - list.InsertRange(list.Count, 2, i); - } - Assert.AreEqual(new[] { 1, 1, 2, 2, 3, 3 }, list.ToArray()); - list.RemoveRange(3, 3); - Assert.AreEqual(new[] { 1, 1, 2 }, list.ToArray()); - } - - [Test] - public void RemoveAtStart() - { - CompressingTreeList list = new CompressingTreeList((a, b) => a == b); - for (int i = 1; i <= 3; i++) { - list.InsertRange(list.Count, 2, i); - } - Assert.AreEqual(new[] { 1, 1, 2, 2, 3, 3 }, list.ToArray()); - list.RemoveRange(0, 1); - Assert.AreEqual(new[] { 1, 2, 2, 3, 3 }, list.ToArray()); - } - - [Test] - public void RemoveAtStart2() - { - CompressingTreeList list = new CompressingTreeList((a, b) => a == b); - for (int i = 1; i <= 3; i++) { - list.InsertRange(list.Count, 2, i); - } - Assert.AreEqual(new[] { 1, 1, 2, 2, 3, 3 }, list.ToArray()); - list.RemoveRange(0, 3); - Assert.AreEqual(new[] { 2, 3, 3 }, list.ToArray()); - } - - [Test] - public void Transform() - { - CompressingTreeList list = new CompressingTreeList((a, b) => a == b); - list.AddRange(new[] { 0, 1, 1, 0 }); - int calls = 0; - list.Transform(i => { calls++; return i + 1; }); - Assert.AreEqual(3, calls); - Assert.AreEqual(new[] { 1, 2, 2, 1 }, list.ToArray()); - } - - [Test] - public void TransformToZero() - { - CompressingTreeList list = new CompressingTreeList((a, b) => a == b); - list.AddRange(new[] { 0, 1, 1, 0 }); - list.Transform(i => 0); - Assert.AreEqual(new[] { 0, 0, 0, 0 }, list.ToArray()); - } - - [Test] - public void TransformRange() - { - CompressingTreeList list = new CompressingTreeList((a, b) => a == b); - list.AddRange(new[] { 0, 1, 1, 1, 0, 0 }); - list.TransformRange(2, 3, i => 0); - Assert.AreEqual(new[] { 0, 1, 0, 0, 0, 0 }, list.ToArray()); - } - } + [TestFixture] + public class CompressingTreeListTests + { + [Test] + public void EmptyTreeList() + { + CompressingTreeList list = new CompressingTreeList(string.Equals); + Assert.AreEqual(0, list.Count); + foreach (string v in list) + { + Assert.Fail(); + } + string[] arr = Array.Empty(); + list.CopyTo(arr, 0); + } + + [Test] + public void CheckAdd10BillionElements() + { + const int billion = 1000000000; + CompressingTreeList list = new CompressingTreeList(string.Equals); + list.InsertRange(0, billion, "A"); + list.InsertRange(1, billion, "B"); + Assert.AreEqual(2 * billion, list.Count); + Assert.Throws(delegate + { list.InsertRange(2, billion, "C"); }); + } + + [Test] + public void AddRepeated() + { + CompressingTreeList list = new CompressingTreeList((a, b) => a == b); + list.Add(42); + list.Add(42); + list.Add(42); + list.Insert(0, 42); + list.Insert(1, 42); + Assert.AreEqual(new[] { 42, 42, 42, 42, 42 }, list.ToArray()); + } + + [Test] + public void RemoveRange() + { + CompressingTreeList list = new CompressingTreeList((a, b) => a == b); + for (int i = 1; i <= 3; i++) + { + list.InsertRange(list.Count, 2, i); + } + Assert.AreEqual(new[] { 1, 1, 2, 2, 3, 3 }, list.ToArray()); + list.RemoveRange(1, 4); + Assert.AreEqual(new[] { 1, 3 }, list.ToArray()); + list.Insert(1, 1); + list.InsertRange(2, 2, 2); + list.Insert(4, 1); + Assert.AreEqual(new[] { 1, 1, 2, 2, 1, 3 }, list.ToArray()); + list.RemoveRange(2, 2); + Assert.AreEqual(new[] { 1, 1, 1, 3 }, list.ToArray()); + } + + [Test] + public void RemoveAtEnd() + { + CompressingTreeList list = new CompressingTreeList((a, b) => a == b); + for (int i = 1; i <= 3; i++) + { + list.InsertRange(list.Count, 2, i); + } + Assert.AreEqual(new[] { 1, 1, 2, 2, 3, 3 }, list.ToArray()); + list.RemoveRange(3, 3); + Assert.AreEqual(new[] { 1, 1, 2 }, list.ToArray()); + } + + [Test] + public void RemoveAtStart() + { + CompressingTreeList list = new CompressingTreeList((a, b) => a == b); + for (int i = 1; i <= 3; i++) + { + list.InsertRange(list.Count, 2, i); + } + Assert.AreEqual(new[] { 1, 1, 2, 2, 3, 3 }, list.ToArray()); + list.RemoveRange(0, 1); + Assert.AreEqual(new[] { 1, 2, 2, 3, 3 }, list.ToArray()); + } + + [Test] + public void RemoveAtStart2() + { + CompressingTreeList list = new CompressingTreeList((a, b) => a == b); + for (int i = 1; i <= 3; i++) + { + list.InsertRange(list.Count, 2, i); + } + Assert.AreEqual(new[] { 1, 1, 2, 2, 3, 3 }, list.ToArray()); + list.RemoveRange(0, 3); + Assert.AreEqual(new[] { 2, 3, 3 }, list.ToArray()); + } + + [Test] + public void Transform() + { + CompressingTreeList list = new CompressingTreeList((a, b) => a == b); + list.AddRange(new[] { 0, 1, 1, 0 }); + int calls = 0; + list.Transform(i => { calls++; return i + 1; }); + Assert.AreEqual(3, calls); + Assert.AreEqual(new[] { 1, 2, 2, 1 }, list.ToArray()); + } + + [Test] + public void TransformToZero() + { + CompressingTreeList list = new CompressingTreeList((a, b) => a == b); + list.AddRange(new[] { 0, 1, 1, 0 }); + list.Transform(i => 0); + Assert.AreEqual(new[] { 0, 0, 0, 0 }, list.ToArray()); + } + + [Test] + public void TransformRange() + { + CompressingTreeList list = new CompressingTreeList((a, b) => a == b); + list.AddRange(new[] { 0, 1, 1, 1, 0, 0 }); + list.TransformRange(2, 3, i => 0); + Assert.AreEqual(new[] { 0, 1, 0, 0, 0, 0 }, list.ToArray()); + } + } } diff --git a/test/AvaloniaEdit.Tests/Utils/RopeTests.cs b/test/AvaloniaEdit.Tests/Utils/RopeTests.cs index 9013739d..3e6c4d23 100644 --- a/test/AvaloniaEdit.Tests/Utils/RopeTests.cs +++ b/test/AvaloniaEdit.Tests/Utils/RopeTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team // // Permission is hereby granted, free of charge, to any person obtaining a copy of this // software and associated documentation files (the "Software"), to deal in the Software @@ -17,179 +17,191 @@ // DEALINGS IN THE SOFTWARE. using System.IO; -using NUnit.Framework; using System.Text; +using AvaloniaEdit.Utils; +using NUnit.Framework; using Assert = NUnit.Framework.Legacy.ClassicAssert; -namespace AvaloniaEdit.Utils +namespace AvaloniaEdit.Tests.Utils { - [TestFixture] - public class RopeTests - { - [Test] - public void EmptyRope() - { - Rope empty = new Rope(); - Assert.AreEqual(0, empty.Length); - Assert.AreEqual("", empty.ToString()); - } - - [Test] - public void EmptyRopeFromString() - { - Rope empty = new Rope(string.Empty); - Assert.AreEqual(0, empty.Length); - Assert.AreEqual("", empty.ToString()); - } - - [Test] - public void InitializeRopeFromShortString() - { - Rope rope = new Rope("Hello, World"); - Assert.AreEqual(12, rope.Length); - Assert.AreEqual("Hello, World", rope.ToString()); - } - - string BuildLongString(int lines) - { - StringWriter w = new StringWriter(); - w.NewLine = "\n"; - for (int i = 1; i <= lines; i++) { - w.WriteLine(i.ToString()); - } - return w.ToString(); - } - - [Test] - public void InitializeRopeFromLongString() - { - string text = BuildLongString(1000); - Rope rope = new Rope(text); - Assert.AreEqual(text.Length, rope.Length); - Assert.AreEqual(text, rope.ToString()); - Assert.AreEqual(text.ToCharArray(), rope.ToArray()); - } - - [Test] - public void TestToArrayAndToStringWithParts() - { - string text = BuildLongString(1000); - Rope rope = new Rope(text); - - string textPart = text.Substring(1200, 600); - char[] arrayPart = textPart.ToCharArray(); - Assert.AreEqual(textPart, rope.ToString(1200, 600)); - Assert.AreEqual(arrayPart, rope.ToArray(1200, 600)); - - Rope partialRope = rope.GetRange(1200, 600); - Assert.AreEqual(textPart, partialRope.ToString()); - Assert.AreEqual(arrayPart, partialRope.ToArray()); - } - - [Test] - public void ConcatenateStringToRope() - { - StringBuilder b = new StringBuilder(); - Rope rope = new Rope(); - for (int i = 1; i <= 1000; i++) { - b.Append(i.ToString()); - rope.AddText(i.ToString()); - b.Append(' '); - rope.Add(' '); - } - Assert.AreEqual(b.ToString(), rope.ToString()); - } - - [Test] - public void ConcatenateSmallRopesToRope() - { - StringBuilder b = new StringBuilder(); - Rope rope = new Rope(); - for (int i = 1; i <= 1000; i++) { - b.Append(i.ToString()); - b.Append(' '); - rope.AddRange(CharRope.Create(i.ToString() + " ")); - } - Assert.AreEqual(b.ToString(), rope.ToString()); - } - - [Test] - public void AppendLongTextToEmptyRope() - { - string text = BuildLongString(1000); - Rope rope = new Rope(); - rope.AddText(text); - Assert.AreEqual(text, rope.ToString()); - } - - [Test] - public void ConcatenateStringToRopeBackwards() - { - StringBuilder b = new StringBuilder(); - Rope rope = new Rope(); - for (int i = 1; i <= 1000; i++) { - b.Append(i.ToString()); - b.Append(' '); - } - for (int i = 1000; i >= 1; i--) { - rope.Insert(0, ' '); - rope.InsertText(0, i.ToString()); - } - Assert.AreEqual(b.ToString(), rope.ToString()); - } - - [Test] - public void ConcatenateSmallRopesToRopeBackwards() - { - StringBuilder b = new StringBuilder(); - Rope rope = new Rope(); - for (int i = 1; i <= 1000; i++) { - b.Append(i.ToString()); - b.Append(' '); - } - for (int i = 1000; i >= 1; i--) { - rope.InsertRange(0, CharRope.Create(i.ToString() + " ")); - } - Assert.AreEqual(b.ToString(), rope.ToString()); - } - - [Test] - public void ConcatenateStringToRopeByInsertionInMiddle() - { - StringBuilder b = new StringBuilder(); - Rope rope = new Rope(); - for (int i = 1; i <= 998; i++) { - b.Append(i.ToString("d3")); - b.Append(' '); - } - int middle = 0; - for (int i = 1; i <= 499; i++) { - rope.InsertText(middle, i.ToString("d3")); - middle += 3; - rope.Insert(middle, ' '); - middle++; - rope.InsertText(middle, (999-i).ToString("d3")); - rope.Insert(middle + 3, ' '); - } - Assert.AreEqual(b.ToString(), rope.ToString()); - } - - [Test] - public void ConcatenateSmallRopesByInsertionInMiddle() - { - StringBuilder b = new StringBuilder(); - Rope rope = new Rope(); - for (int i = 1; i <= 1000; i++) { - b.Append(i.ToString("d3")); - b.Append(' '); - } - int middle = 0; - for (int i = 1; i <= 500; i++) { - rope.InsertRange(middle, CharRope.Create(i.ToString("d3") + " ")); - middle += 4; - rope.InsertRange(middle, CharRope.Create((1001-i).ToString("d3") + " ")); - } - Assert.AreEqual(b.ToString(), rope.ToString()); - } - } + [TestFixture] + public class RopeTests + { + [Test] + public void EmptyRope() + { + Rope empty = new Rope(); + Assert.AreEqual(0, empty.Length); + Assert.AreEqual("", empty.ToString()); + } + + [Test] + public void EmptyRopeFromString() + { + Rope empty = new Rope(string.Empty); + Assert.AreEqual(0, empty.Length); + Assert.AreEqual("", empty.ToString()); + } + + [Test] + public void InitializeRopeFromShortString() + { + Rope rope = new Rope("Hello, World"); + Assert.AreEqual(12, rope.Length); + Assert.AreEqual("Hello, World", rope.ToString()); + } + + private static string BuildLongString(int lines) + { + using StringWriter w = new StringWriter(); + w.NewLine = "\n"; + for (int i = 1; i <= lines; i++) + { + w.WriteLine(i.ToString()); + } + return w.ToString(); + } + + [Test] + public void InitializeRopeFromLongString() + { + string text = BuildLongString(1000); + Rope rope = new Rope(text); + Assert.AreEqual(text.Length, rope.Length); + Assert.AreEqual(text, rope.ToString()); + Assert.AreEqual(text.ToCharArray(), rope.ToArray()); + } + + [Test] + public void TestToArrayAndToStringWithParts() + { + string text = BuildLongString(1000); + Rope rope = new Rope(text); + + string textPart = text.Substring(1200, 600); + char[] arrayPart = textPart.ToCharArray(); + Assert.AreEqual(textPart, rope.ToString(1200, 600)); + Assert.AreEqual(arrayPart, rope.ToArray(1200, 600)); + + Rope partialRope = rope.GetRange(1200, 600); + Assert.AreEqual(textPart, partialRope.ToString()); + Assert.AreEqual(arrayPart, partialRope.ToArray()); + } + + [Test] + public void ConcatenateStringToRope() + { + StringBuilder b = new StringBuilder(); + Rope rope = new Rope(); + for (int i = 1; i <= 1000; i++) + { + b.Append(i.ToString()); + rope.AddText(i.ToString()); + b.Append(' '); + rope.Add(' '); + } + Assert.AreEqual(b.ToString(), rope.ToString()); + } + + [Test] + public void ConcatenateSmallRopesToRope() + { + StringBuilder b = new StringBuilder(); + Rope rope = new Rope(); + for (int i = 1; i <= 1000; i++) + { + b.Append(i.ToString()); + b.Append(' '); + rope.AddRange(CharRope.Create(i.ToString() + " ")); + } + Assert.AreEqual(b.ToString(), rope.ToString()); + } + + [Test] + public void AppendLongTextToEmptyRope() + { + string text = BuildLongString(1000); + Rope rope = new Rope(); + rope.AddText(text); + Assert.AreEqual(text, rope.ToString()); + } + + [Test] + public void ConcatenateStringToRopeBackwards() + { + StringBuilder b = new StringBuilder(); + Rope rope = new Rope(); + for (int i = 1; i <= 1000; i++) + { + b.Append(i.ToString()); + b.Append(' '); + } + for (int i = 1000; i >= 1; i--) + { + rope.Insert(0, ' '); + rope.InsertText(0, i.ToString()); + } + Assert.AreEqual(b.ToString(), rope.ToString()); + } + + [Test] + public void ConcatenateSmallRopesToRopeBackwards() + { + StringBuilder b = new StringBuilder(); + Rope rope = new Rope(); + for (int i = 1; i <= 1000; i++) + { + b.Append(i.ToString()); + b.Append(' '); + } + for (int i = 1000; i >= 1; i--) + { + rope.InsertRange(0, CharRope.Create(i.ToString() + " ")); + } + Assert.AreEqual(b.ToString(), rope.ToString()); + } + + [Test] + public void ConcatenateStringToRopeByInsertionInMiddle() + { + StringBuilder b = new StringBuilder(); + Rope rope = new Rope(); + for (int i = 1; i <= 998; i++) + { + b.Append(i.ToString("d3")); + b.Append(' '); + } + int middle = 0; + for (int i = 1; i <= 499; i++) + { + rope.InsertText(middle, i.ToString("d3")); + middle += 3; + rope.Insert(middle, ' '); + middle++; + rope.InsertText(middle, (999 - i).ToString("d3")); + rope.Insert(middle + 3, ' '); + } + Assert.AreEqual(b.ToString(), rope.ToString()); + } + + [Test] + public void ConcatenateSmallRopesByInsertionInMiddle() + { + StringBuilder b = new StringBuilder(); + Rope rope = new Rope(); + for (int i = 1; i <= 1000; i++) + { + b.Append(i.ToString("d3")); + b.Append(' '); + } + int middle = 0; + for (int i = 1; i <= 500; i++) + { + rope.InsertRange(middle, CharRope.Create(i.ToString("d3") + " ")); + middle += 4; + rope.InsertRange(middle, CharRope.Create((1001 - i).ToString("d3") + " ")); + } + Assert.AreEqual(b.ToString(), rope.ToString()); + } + } } diff --git a/test/AvaloniaEdit.Tests/Utils/ValueStringBuilderTests.cs b/test/AvaloniaEdit.Tests/Utils/ValueStringBuilderTests.cs new file mode 100644 index 00000000..9be8f6ca --- /dev/null +++ b/test/AvaloniaEdit.Tests/Utils/ValueStringBuilderTests.cs @@ -0,0 +1,445 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Source taken from: https://github.com/dotnet/runtime/blob/v10.0.105/src/libraries/Common/tests/Tests/System/Text/ValueStringBuilderTests.cs +// and converted from XUnit to NUnit tests. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using AvaloniaEdit.Utils; +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace AvaloniaEdit.Tests.Utils +{ + [SuppressMessage("Reliability", "S2930: IDisposables should be disposed", Justification = "Just unit tests")] + [TestFixture] + public class ValueStringBuilderTests + { + [Test] + public void Ctor_Default_CanAppend() + { + // Arrange + ValueStringBuilder vsb = default; + + // Act + int initialLength = vsb.Length; + vsb.Append('a'); + int finalLength = vsb.Length; + string result = vsb.ToString(); + + // Assert + Assert.AreEqual(0, initialLength); + Assert.AreEqual(1, finalLength); + Assert.AreEqual("a", result); + } + + [Test] + public void Ctor_Span_CanAppend() + { + // Arrange + ValueStringBuilder vsb = new ValueStringBuilder(new char[1]); + + // Act + int initialLength = vsb.Length; + vsb.Append('a'); + int finalLength = vsb.Length; + string result = vsb.ToString(); + + // Assert + Assert.AreEqual(0, initialLength); + Assert.AreEqual(1, finalLength); + Assert.AreEqual("a", result); + } + + [Test] + public void Ctor_InitialCapacity_CanAppend() + { + // Arrange + ValueStringBuilder vsb = new ValueStringBuilder(1); + + // Act + int initialLength = vsb.Length; + vsb.Append('a'); + int finalLength = vsb.Length; + string result = vsb.ToString(); + + // Assert + Assert.AreEqual(0, initialLength); + Assert.AreEqual(1, finalLength); + Assert.AreEqual("a", result); + } + + [Test] + public void Append_Char_MatchesStringBuilder() + { + // Arrange + StringBuilder sb = new StringBuilder(); + ValueStringBuilder vsb = new ValueStringBuilder(); + + // Act + for (int i = 1; i <= 100; i++) + { + sb.Append((char)i); + vsb.Append((char)i); + } + + int actualLength = vsb.Length; + int expectedLength = sb.Length; + string actual = vsb.ToString(); + string expected = sb.ToString(); + + // Assert + Assert.AreEqual(expectedLength, actualLength); + Assert.AreEqual(expected, actual); + } + + [Test] + public void Append_String_MatchesStringBuilder() + { + // Arrange + StringBuilder sb = new StringBuilder(); + ValueStringBuilder vsb = new ValueStringBuilder(); + + // Act + for (int i = 1; i <= 100; i++) + { + string s = i.ToString(); + sb.Append(s); + vsb.Append(s); + } + + int actualLength = vsb.Length; + int expectedLength = sb.Length; + string actual = vsb.ToString(); + string expected = sb.ToString(); + + // Assert + Assert.AreEqual(expectedLength, actualLength); + Assert.AreEqual(expected, actual); + } + + [TestCase(0, 4 * 1024 * 1024)] + [TestCase(1025, 4 * 1024 * 1024)] + [TestCase(3 * 1024 * 1024, 6 * 1024 * 1024)] + public void Append_String_Large_MatchesStringBuilder(int initialLength, int stringLength) + { + // Arrange + StringBuilder sb = new StringBuilder(initialLength); + ValueStringBuilder vsb = new ValueStringBuilder(new char[initialLength]); + string s = new string('a', stringLength); + + // Act + sb.Append(s); + vsb.Append(s); + + int actualLength = vsb.Length; + int expectedLength = sb.Length; + string actual = vsb.ToString(); + string expected = sb.ToString(); + + // Assert + Assert.AreEqual(expectedLength, actualLength); + Assert.AreEqual(expected, actual); + } + + [Test] + public void Append_CharInt_MatchesStringBuilder() + { + // Arrange + StringBuilder sb = new StringBuilder(); + ValueStringBuilder vsb = new ValueStringBuilder(); + + // Act + for (int i = 1; i <= 100; i++) + { + sb.Append((char)i, i); + vsb.Append((char)i, i); + } + + int actualLength = vsb.Length; + int expectedLength = sb.Length; + string actual = vsb.ToString(); + string expected = sb.ToString(); + + // Assert + Assert.AreEqual(expectedLength, actualLength); + Assert.AreEqual(expected, actual); + } + + [Test] + public void AppendSpan_Capacity() + { + // Arrange + ValueStringBuilder vsb = new ValueStringBuilder(); + + // Act + vsb.AppendSpan(17); + int capacityAfterFirstAppendSpan = vsb.Capacity; + + vsb.AppendSpan(100); + int capacityAfterSecondAppendSpan = vsb.Capacity; + + // Assert + Assert.AreEqual(32, capacityAfterFirstAppendSpan); + Assert.AreEqual(128, capacityAfterSecondAppendSpan); + } + + [Test] + public void AppendSpan_DataAppendedCorrectly() + { + // Arrange + StringBuilder sb = new StringBuilder(); + ValueStringBuilder vsb = new ValueStringBuilder(); + + // Act + for (int i = 1; i <= 1000; i++) + { + string s = i.ToString(); + + sb.Append(s); + + Span span = vsb.AppendSpan(s.Length); + s.AsSpan().CopyTo(span); + } + + int actualLength = vsb.Length; + int expectedLength = sb.Length; + string actual = vsb.ToString(); + string expected = sb.ToString(); + + // Assert + Assert.AreEqual(expectedLength, actualLength); + Assert.AreEqual(expected, actual); + } + + [Test] + public void Insert_IntCharInt_MatchesStringBuilder() + { + // Arrange + StringBuilder sb = new StringBuilder(); + ValueStringBuilder vsb = new ValueStringBuilder(); + Random rand = new Random(42); + + // Act + for (int i = 1; i <= 100; i++) + { + int index = rand.Next(sb.Length); + sb.Insert(index, new string((char)i, 1), i); + vsb.Insert(index, (char)i, i); + } + + int actualLength = vsb.Length; + int expectedLength = sb.Length; + string actual = vsb.ToString(); + string expected = sb.ToString(); + + // Assert + Assert.AreEqual(expectedLength, actualLength); + Assert.AreEqual(expected, actual); + } + + [Test] + public void Insert_IntString_MatchesStringBuilder() + { + // Arrange + StringBuilder sb = new StringBuilder(); + ValueStringBuilder vsb = new ValueStringBuilder(); + + // Act + sb.Insert(0, new string('a', 6)); + vsb.Insert(0, new string('a', 6)); + int lengthAfterFirstInsert = vsb.Length; + int capacityAfterFirstInsert = vsb.Capacity; + + sb.Insert(0, new string('b', 11)); + vsb.Insert(0, new string('b', 11)); + int lengthAfterSecondInsert = vsb.Length; + int capacityAfterSecondInsert = vsb.Capacity; + + sb.Insert(0, new string('c', 15)); + vsb.Insert(0, new string('c', 15)); + int lengthAfterThirdInsert = vsb.Length; + int capacityAfterThirdInsert = vsb.Capacity; + + sb.Length = 24; + vsb.Length = 24; + + sb.Insert(0, new string('d', 40)); + vsb.Insert(0, new string('d', 40)); + int finalLength = vsb.Length; + int finalCapacity = vsb.Capacity; + string actual = vsb.ToString(); + string expected = sb.ToString(); + + // Assert + Assert.AreEqual(6, lengthAfterFirstInsert); + Assert.AreEqual(16, capacityAfterFirstInsert); + + Assert.AreEqual(17, lengthAfterSecondInsert); + Assert.AreEqual(32, capacityAfterSecondInsert); + + Assert.AreEqual(32, lengthAfterThirdInsert); + Assert.AreEqual(32, capacityAfterThirdInsert); + + Assert.AreEqual(64, finalLength); + Assert.AreEqual(64, finalCapacity); + + Assert.AreEqual(sb.Length, finalLength); + Assert.AreEqual(expected, actual); + } + + [Test] + public void AsSpan_ReturnsCorrectValue_DoesntClearBuilder() + { + // Arrange + StringBuilder sb = new StringBuilder(); + ValueStringBuilder vsb = new ValueStringBuilder(); + + for (int i = 1; i <= 100; i++) + { + string s = i.ToString(); + sb.Append(s); + vsb.Append(s); + } + + // Act + string resultString = new string(vsb.AsSpan()); + int stringBuilderLength = sb.Length; + int valueStringBuilderLength = vsb.Length; + string valueStringBuilderString = vsb.ToString(); + string stringBuilderString = sb.ToString(); + + // Assert + Assert.AreEqual(stringBuilderString, resultString); + Assert.AreNotEqual(0, stringBuilderLength); + Assert.AreEqual(stringBuilderLength, valueStringBuilderLength); + Assert.AreEqual(stringBuilderString, valueStringBuilderString); + } + + [Test] + public void ToString_ClearsBuilder_ThenReusable() + { + // Arrange + const string text1 = "test"; + const string text2 = "another test"; + ValueStringBuilder vsb = new ValueStringBuilder(); + + vsb.Append(text1); + + // Act + int lengthBeforeToString = vsb.Length; + string firstResult = vsb.ToString(); + int lengthAfterToString = vsb.Length; + string secondResult = vsb.ToString(); + + vsb.Append(text2); + int finalLength = vsb.Length; + string finalResult = vsb.ToString(); + + // Assert + Assert.AreEqual(text1.Length, lengthBeforeToString); + Assert.AreEqual(text1, firstResult); + Assert.AreEqual(0, lengthAfterToString); + Assert.AreEqual(string.Empty, secondResult); + Assert.AreEqual(text2.Length, finalLength); + Assert.AreEqual(text2, finalResult); + } + + [Test] + public void Dispose_ClearsBuilder_ThenReusable() + { + // Arrange + const string text1 = "test"; + const string text2 = "another test"; + ValueStringBuilder vsb = new ValueStringBuilder(); + + vsb.Append(text1); + + // Act + int lengthBeforeDispose = vsb.Length; + vsb.Dispose(); + int lengthAfterDispose = vsb.Length; + string resultAfterDispose = vsb.ToString(); + + vsb.Append(text2); + int finalLength = vsb.Length; + string finalResult = vsb.ToString(); + + // Assert + Assert.AreEqual(text1.Length, lengthBeforeDispose); + Assert.AreEqual(0, lengthAfterDispose); + Assert.AreEqual(string.Empty, resultAfterDispose); + Assert.AreEqual(text2.Length, finalLength); + Assert.AreEqual(text2, finalResult); + } + + [Test] + public void Indexer() + { + // Arrange + const string text1 = "foobar"; + ValueStringBuilder vsb = new ValueStringBuilder(); + vsb.Append(text1); + + // Act + char originalCharacter = vsb[3]; + vsb[3] = 'c'; + char updatedCharacter = vsb[3]; + vsb.Dispose(); + + // Assert + Assert.AreEqual('b', originalCharacter); + Assert.AreEqual('c', updatedCharacter); + } + + [Test] + public void EnsureCapacity_IfRequestedCapacityWins() + { + // Arrange + // Note: constants used here may be dependent on minimal buffer size + // the ArrayPool is able to return. + ValueStringBuilder builder = new ValueStringBuilder(stackalloc char[32]); + + // Act + builder.EnsureCapacity(65); + int capacity = builder.Capacity; + + // Assert + Assert.AreEqual(128, capacity); + } + + [Test] + public void EnsureCapacity_IfBufferTimesTwoWins() + { + // Arrange + ValueStringBuilder builder = new ValueStringBuilder(stackalloc char[32]); + + // Act + builder.EnsureCapacity(33); + int capacity = builder.Capacity; + builder.Dispose(); + + // Assert + Assert.AreEqual(64, capacity); + } + + [Test] + public void EnsureCapacity_NoAllocIfNotNeeded() + { + // Arrange + // Note: constants used here may be dependent on minimal buffer size + // the ArrayPool is able to return. + ValueStringBuilder builder = new ValueStringBuilder(stackalloc char[64]); + + // Act + builder.EnsureCapacity(16); + int capacity = builder.Capacity; + builder.Dispose(); + + // Assert + Assert.AreEqual(64, capacity); + } + } +}