diff --git a/PSReadLine/AssemblyInfo.cs b/PSReadLine/AssemblyInfo.cs index a725bc41..d67ca619 100644 --- a/PSReadLine/AssemblyInfo.cs +++ b/PSReadLine/AssemblyInfo.cs @@ -2,21 +2,16 @@ Copyright (c) Microsoft Corporation. All rights reserved. --********************************************************************/ -using System.Reflection; -using System.Runtime.InteropServices; using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. -[assembly: AssemblyTitle("PSReadLine")] -[assembly: AssemblyDescription("Great command line editing in PowerShell")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("PSReadLine")] -[assembly: AssemblyCopyright("Copyright")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] + +// Make it a friend assembly to 'PSReadLine.Tests' for testing. +[assembly:InternalsVisibleTo("PSReadLine.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/PSReadLine/BasicEditing.cs b/PSReadLine/BasicEditing.cs index eaa83a0b..a207c038 100644 --- a/PSReadLine/BasicEditing.cs +++ b/PSReadLine/BasicEditing.cs @@ -121,8 +121,8 @@ public static void BackwardDeleteLine(ConsoleKeyInfo? key = null, object arg = n { if (_singleton._current > 0) { - _singleton._clipboard = _singleton._buffer.ToString(0, _singleton._current); - _singleton.SaveEditItem(EditItemDelete.Create(_singleton._clipboard, 0)); + _clipboard.Record(_singleton._buffer, 0, _singleton._current); + _singleton.SaveEditItem(EditItemDelete.Create(_clipboard, 0)); _singleton._buffer.Remove(0, _singleton._current); _singleton._current = 0; _singleton.Render(); diff --git a/PSReadLine/MultiLineBufferHelper.cs b/PSReadLine/MultiLineBufferHelper.cs new file mode 100644 index 00000000..a2decc3c --- /dev/null +++ b/PSReadLine/MultiLineBufferHelper.cs @@ -0,0 +1,72 @@ +using System; +using System.Text; + +namespace Microsoft.PowerShell +{ + internal static class MultiLineBufferHelper + { + /// + /// Represents a range of text (subset) of the buffer. + /// + public class Range + { + public int Offset { get; set; } + public int Count { get; set; } + } + + /// + /// Determines the offset and the length of the fragment + /// in the specified buffer that corresponds to a + /// given number of lines starting from the specified line index + /// + /// + /// + /// The 0-based number of the logical line for the current cursor position. + /// This argument comes from a call to the + /// method and is thus guaranteed to represent a valid line number. + /// + /// + /// The number of lines to be taken into account. + /// If more lines are taken into account than there are lines available, + /// this method still returns a valid range corresponding to the available + /// lines from the buffer. + /// + public static Range GetRange(StringBuilder buffer, int lineOffset, int lineCount) + { + var length = buffer.Length; + + var startPosition = 0; + var startPositionIdentified = false; + + var endPosition = length - 1; + + var currentLine = 0; + + for (var position = 0; position < length; position++) + { + if (currentLine == lineOffset && !startPositionIdentified) + { + startPosition = position; + startPositionIdentified = true; + } + + if (buffer[position] == '\n') + { + currentLine++; + } + + if (currentLine == lineOffset + lineCount) + { + endPosition = position; + break; + } + } + + return new Range + { + Offset = startPosition, + Count = endPosition - startPosition + 1, + }; + } + } +} \ No newline at end of file diff --git a/PSReadLine/PSReadLine.csproj b/PSReadLine/PSReadLine.csproj index 48f63c45..9dbf2dd2 100644 --- a/PSReadLine/PSReadLine.csproj +++ b/PSReadLine/PSReadLine.csproj @@ -32,7 +32,4 @@ - - - diff --git a/PSReadLine/ReadLine.cs b/PSReadLine/ReadLine.cs index 1cdc9441..3dfc468d 100644 --- a/PSReadLine/ReadLine.cs +++ b/PSReadLine/ReadLine.cs @@ -37,7 +37,10 @@ public partial class PSConsoleReadLine : IPSConsoleReadLineMockableMethods private const int EventProcessingRequested = 3; - private static readonly PSConsoleReadLine _singleton = new PSConsoleReadLine(); + // *must* be initialized in the static ctor + // because the static member _clipboard depends upon it + // for its own initialization + private static readonly PSConsoleReadLine _singleton; private static readonly CancellationToken _defaultCancellationToken = new CancellationTokenSource().Token; @@ -607,6 +610,12 @@ void ProcessOneKey(PSKeyInfo key, Dictionary dispatchTabl } } + static PSConsoleReadLine() + { + _singleton = new PSConsoleReadLine(); + _clipboard = new ViRegister(_singleton); + } + private PSConsoleReadLine() { _mockableMethods = this; diff --git a/PSReadLine/ReadLine.vi.cs b/PSReadLine/ReadLine.vi.cs index f402184d..4b6977be 100644 --- a/PSReadLine/ReadLine.vi.cs +++ b/PSReadLine/ReadLine.vi.cs @@ -221,9 +221,9 @@ public static void DeleteToEnd(ConsoleKeyInfo? key = null, object arg = null) return; } - _singleton._clipboard = _singleton._buffer.ToString(_singleton._current, _singleton._buffer.Length - _singleton._current); + _clipboard.Record(_singleton._buffer, _singleton._current, _singleton._buffer.Length - _singleton._current); _singleton.SaveEditItem(EditItemDelete.Create( - _singleton._clipboard, + _clipboard, _singleton._current, DeleteToEnd, arg @@ -258,7 +258,7 @@ private static void DeleteToEndPoint(object arg, int endPoint, Action public static void DeleteLine(ConsoleKeyInfo? key = null, object arg = null) { - _singleton._clipboard = _singleton._buffer.ToString(); - _singleton.SaveEditItem(EditItemDelete.Create(_singleton._clipboard, 0)); + _clipboard.Record(_singleton._buffer); + _singleton.SaveEditItem(EditItemDelete.Create(_clipboard, 0)); _singleton._current = 0; _singleton._buffer.Remove(0, _singleton._buffer.Length); _singleton.Render(); @@ -746,9 +746,9 @@ public static void BackwardDeleteWord(ConsoleKeyInfo? key = null, object arg = n Ding(); return; } - _singleton._clipboard = _singleton._buffer.ToString(deletePoint, _singleton._current - deletePoint); + _clipboard.Record(_singleton._buffer, deletePoint, _singleton._current - deletePoint); _singleton.SaveEditItem(EditItemDelete.Create( - _singleton._clipboard, + _clipboard, deletePoint, BackwardDeleteWord, arg @@ -779,9 +779,9 @@ public static void ViBackwardDeleteGlob(ConsoleKeyInfo? key = null, object arg = Ding(); return; } - _singleton._clipboard = _singleton._buffer.ToString(deletePoint, _singleton._current - deletePoint); + _clipboard.Record(_singleton._buffer, deletePoint, _singleton._current - deletePoint); _singleton.SaveEditItem(EditItemDelete.Create( - _singleton._clipboard, + _clipboard, deletePoint, BackwardDeleteWord, arg @@ -823,7 +823,7 @@ private static void DeleteRange(int first, int last, Action + /// Represents a named register. + /// + internal sealed class ViRegister + { + private readonly PSConsoleReadLine _singleton; + private string _text; + + /// + /// Initialize a new instance of the class. + /// + /// The object. + /// Used to hook into the undo / redo subsystem as part of + /// pasting the contents of the register into a buffer. + /// + public ViRegister(PSConsoleReadLine singleton) + { + _singleton = singleton; + } + + /// + /// Returns whether this register is empty. + /// + public bool IsEmpty + => String.IsNullOrEmpty(_text); + + /// + /// Returns whether this register contains + /// linewise yanked text. + /// + public bool HasLinewiseText { get; private set; } + + /// + /// Gets the raw text contained in the register + /// + public string RawText + => _text; + + /// + /// Records the entire buffer in the register. + /// + /// + public void Record(StringBuilder buffer) + { + Record(buffer, 0, buffer.Length); + } + + /// + /// Records a piece of text in the register. + /// + /// + /// + /// + public void Record(StringBuilder buffer, int offset, int count) + { + System.Diagnostics.Debug.Assert(offset >= 0 && offset < buffer.Length); + System.Diagnostics.Debug.Assert(offset + count <= buffer.Length); + + HasLinewiseText = false; + _text = buffer.ToString(offset, count); + } + + /// + /// Records a block of lines in the register. + /// + /// + public void LinewiseRecord(string text) + { + HasLinewiseText = true; + _text = text; + } + + // for compatibility reasons, as an interim solution + public static implicit operator string(ViRegister register) + { + return register._text; + } + + public int PasteAfter(StringBuilder buffer, int position) + { + if (IsEmpty) + { + return position; + } + + if (HasLinewiseText) + { + var text = _text; + + // paste text after the next line + + var pastePosition = -1; + var newCursorPosition = position; + + for (var index = position; index < buffer.Length; index++) + { + if (buffer[index] == '\n') + { + pastePosition = index + 1; + newCursorPosition = pastePosition; + break; + } + } + + if (pastePosition == -1) + { + if (text[0] != '\n') + { + text = '\n' + text; + } + + pastePosition = buffer.Length; + newCursorPosition = pastePosition + 1; + } + + InsertAt(buffer, text, pastePosition, position); + + return newCursorPosition; + } + + else + { + if (position < buffer.Length) + { + position += 1; + } + + InsertAt(buffer, _text, position, position); + position += _text.Length - 1; + + return position; + } + } + + public int PasteBefore(StringBuilder buffer, int position) + { + if (HasLinewiseText) + { + // currently, in Vi Edit Mode, the cursor may be positioned + // exactly one character past the end of the buffer. + + // we adjust the current position to prevent a crash + + position = Math.Max(0, Math.Min(position, buffer.Length - 1)); + + var text = _text; + + if (text[text.Length - 1] != '\n') + { + text += '\n'; + } + + // paste text before the current line + + var previousLinePos = -1; + + for (var index = position; index > 0; index--) + { + if (buffer[index] == '\n') + { + previousLinePos = index + 1; + break; + } + } + + if (previousLinePos == -1) + { + previousLinePos = 0; + } + + InsertBefore(buffer, text, previousLinePos, position); + + return previousLinePos; + } + else + { + InsertAt(buffer, _text, position, position); + return position + _text.Length - 1; + } + } + + private void InsertBefore(StringBuilder buffer, string text, int pastePosition, int position) + { + RecordPaste(text, pastePosition, position); + buffer.Insert(pastePosition, text); + } + + private void InsertAt(StringBuilder buffer, string text, int pastePosition, int position) + { + RecordPaste(text, pastePosition, position); + + // Use Append if possible because Insert at end makes StringBuilder quite slow. + if (pastePosition == buffer.Length) + { + buffer.Append(text); + } + else + { + buffer.Insert(pastePosition, text); + } + } + + /// + /// Called to record the paste operation in the undo subsystem. + /// + /// + /// The text being pasted. + /// + /// + /// The position in the buffer at + /// which the pasted text will be inserted. + /// + /// + /// The recorded position in the buffer + /// from which the paste operation originates. + /// This is usually the same as Position, but + /// not always. For instance, when pasting a + /// linewise selection before the current line, + /// the Anchor is the cursor position, whereas + /// the Position is the beginning of the previous line. + /// + private void RecordPaste(string text, int position, int anchor) + { + if (_singleton != null) + { + var editItem = EditItemInsertLines.Create( + text, + position, + anchor + ); + + _singleton.SaveEditItem(editItem); + } + } + +#if DEBUG + public override string ToString() + { + var text = _text.Replace("\n", "\\n"); + return (HasLinewiseText ? "line: " : "") + "\"" + text + "\""; + } +#endif + } + } +} \ No newline at end of file diff --git a/PSReadLine/YankPaste.vi.cs b/PSReadLine/YankPaste.vi.cs index 0d3a86cf..5fe7d553 100644 --- a/PSReadLine/YankPaste.vi.cs +++ b/PSReadLine/YankPaste.vi.cs @@ -1,21 +1,25 @@ -/********************************************************************++ +/********************************************************************++ Copyright (c) Microsoft Corporation. All rights reserved. --********************************************************************/ using System; +using System.Text; namespace Microsoft.PowerShell { public partial class PSConsoleReadLine { - private string _clipboard = string.Empty; + // *must* be initialized in the static ctor + // because it depends on static member _singleton + // being initialized first. + private static readonly ViRegister _clipboard; /// /// Paste the clipboard after the cursor, moving the cursor to the end of the pasted text. /// public static void PasteAfter(ConsoleKeyInfo? key = null, object arg = null) { - if (string.IsNullOrEmpty(_singleton._clipboard)) + if (_clipboard.IsEmpty) { Ding(); return; @@ -29,7 +33,7 @@ public static void PasteAfter(ConsoleKeyInfo? key = null, object arg = null) /// public static void PasteBefore(ConsoleKeyInfo? key = null, object arg = null) { - if (string.IsNullOrEmpty(_singleton._clipboard)) + if (_clipboard.IsEmpty) { Ding(); return; @@ -39,25 +43,31 @@ public static void PasteBefore(ConsoleKeyInfo? key = null, object arg = null) private void PasteAfterImpl() { - if (_current < _buffer.Length) - { - _current++; - } - Insert(_clipboard); - _current--; + _current = _clipboard.PasteAfter(_buffer, _current); Render(); } private void PasteBeforeImpl() { - Insert(_clipboard); - _current--; + _current = _clipboard.PasteBefore(_buffer, _current); Render(); } private void SaveToClipboard(int startIndex, int length) { - _clipboard = _buffer.ToString(startIndex, length); + _clipboard.Record(_buffer, startIndex, length); + } + + /// + /// Saves a number of logical lines in the unnamed register + /// starting at the specified line number and specified count. + /// + /// The logical number of the current line, starting at 0. + /// The number of lines to record to the unnamed register + private void SaveLinesToClipboard(int lineIndex, int lineCount) + { + var range = MultiLineBufferHelper.GetRange(_buffer, lineIndex, lineCount); + _clipboard.LinewiseRecord(_buffer.ToString(range.Offset, range.Count)); } /// @@ -65,7 +75,9 @@ private void SaveToClipboard(int startIndex, int length) /// public static void ViYankLine(ConsoleKeyInfo? key = null, object arg = null) { - _singleton.SaveToClipboard(0, _singleton._buffer.Length); + TryGetArgAsInt(arg, out var lineCount, 1); + var lineIndex = _singleton.GetLogicalLineNumber() - 1; + _singleton.SaveLinesToClipboard(lineIndex, lineCount); } /// diff --git a/test/MultiLineBufferHelperTests.cs b/test/MultiLineBufferHelperTests.cs new file mode 100644 index 00000000..3f6264fb --- /dev/null +++ b/test/MultiLineBufferHelperTests.cs @@ -0,0 +1,39 @@ +using System.Text; +using Microsoft.PowerShell; +using Xunit; + +namespace Test +{ + public sealed class MultiLineBufferHelperTests + { + [Fact] + public void MultiLineBufferHelper_LinewiseYank_Lines() + { + var buffer = new StringBuilder("line1\nline2\nline3\nline4"); + + // system under test + + var range = MultiLineBufferHelper.GetRange(buffer, 1, 2); + + // assert + + Assert.Equal(6, range.Offset); + Assert.Equal(12, range.Count); + } + + [Fact] + public void MultilineBufferHelper_LinewiseYank_MoreLinesThanAvailable() + { + var buffer = new StringBuilder("line1\nline2"); + + // system under test + + var range = MultiLineBufferHelper.GetRange(buffer, 1, 42); + + // assert + + Assert.Equal(6, range.Offset); + Assert.Equal(5, range.Count); + } + } +} diff --git a/test/PSReadLine.Tests.csproj b/test/PSReadLine.Tests.csproj index 27548736..f5cc905d 100644 --- a/test/PSReadLine.Tests.csproj +++ b/test/PSReadLine.Tests.csproj @@ -36,12 +36,6 @@ - - - CharMap.cs - - - {615788cb-1b9a-4b34-97b3-4608686e59ca} diff --git a/test/ViRegisterTests.cs b/test/ViRegisterTests.cs new file mode 100644 index 00000000..f8adfd24 --- /dev/null +++ b/test/ViRegisterTests.cs @@ -0,0 +1,136 @@ +using System.Text; +using Microsoft.PowerShell; +using Xunit; + +namespace Test +{ + public sealed class ViRegisterTests + { + [Fact] + public void ViRegister_Fragment_LinewisePasteBefore() + { + const string yanked = "line1"; + + var register = new PSConsoleReadLine.ViRegister(null); + register.LinewiseRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line2"); + const int position = 2; + + var newPosition = register.PasteBefore(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2", buffer.ToString()); + Assert.Equal(0, newPosition); + } + + [Fact] + public void ViRegister_Lines_LinewisePasteBefore() + { + const string yanked = "line1\n"; + + var register = new PSConsoleReadLine.ViRegister(null); + register.LinewiseRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line2"); + const int position = 2; + + var newPosition = register.PasteBefore(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2", buffer.ToString()); + Assert.Equal(0, newPosition); + } + + + [Fact] + public void ViRegister_Fragment_LinewisePasteAfter_Fragment() + { + const string yanked = "line2"; + + var register = new PSConsoleReadLine.ViRegister(null); + register.LinewiseRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line1"); + const int position = 2; + + var newPosition = register.PasteAfter(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2", buffer.ToString()); + Assert.Equal(6, newPosition); + } + + [Fact] + public void ViRegister_Fragment_LinewisePasteAfter_Lines() + { + const string yanked = "line2"; + + var register = new PSConsoleReadLine.ViRegister(null); + register.LinewiseRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line1\n"); + const int position = 2; + + var newPosition = register.PasteAfter(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2", buffer.ToString()); + Assert.Equal(6, newPosition); + } + + [Fact] + public void ViRegister_Lines_LinewisePasteAfter_Fragment() + { + const string yanked = "line2\nline3\n"; + + var register = new PSConsoleReadLine.ViRegister(null); + register.LinewiseRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line1"); + const int position = 2; + + var newPosition = register.PasteAfter(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2\nline3\n", buffer.ToString()); + Assert.Equal(6, newPosition); + } + + [Fact] + public void ViRegister_Lines_LinewisePasteAfter_Lines() + { + const string yanked = "line2\nline3\n"; + + var register = new PSConsoleReadLine.ViRegister(null); + register.LinewiseRecord(yanked); + + // system under test + + var buffer = new StringBuilder("line1\n"); + const int position = 2; + + var newPosition = register.PasteAfter(buffer, position); + + // assert expectations + + Assert.Equal("line1\nline2\nline3\n", buffer.ToString()); + Assert.Equal(6, newPosition); + } + } +} diff --git a/test/YankPasteTest.VI.cs b/test/YankPasteTest.VI.cs index 0bf02433..4b236ba8 100644 --- a/test/YankPasteTest.VI.cs +++ b/test/YankPasteTest.VI.cs @@ -1,4 +1,4 @@ -using Microsoft.PowerShell; +using Microsoft.PowerShell; using Xunit; namespace Test @@ -209,12 +209,14 @@ public void ViPasteAfterYankLine() { TestSetup(KeyMode.Vi); + var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; + Test("012 456", Keys( "012 456", _.Escape, - "byyP", CheckThat(() => AssertLineIs("012 012 456456")), CheckThat(() => AssertCursorLeftIs(10)), + "byyP", CheckThat(() => AssertLineIs("012 456\n012 456")), CheckThat(() => AssertCursorLeftIs(0)), "u", CheckThat(() => AssertLineIs("012 456")), CheckThat(() => AssertCursorLeftIs(4)), - "p", CheckThat(() => AssertLineIs("012 4012 45656")), CheckThat(() => AssertCursorLeftIs(11)), - "u", CheckThat(() => AssertLineIs("012 456")), CheckThat(() => AssertCursorLeftIs(5)) + "p", CheckThat(() => AssertLineIs("012 456\n012 456")), CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)), + "u", CheckThat(() => AssertLineIs("012 456")), CheckThat(() => AssertCursorLeftIs(4)) )); } @@ -457,5 +459,37 @@ public void ViPasteAfterYankEndOfGlob() "u" )); } + + [SkippableFact] + public void ViYankAndPasteLogicalLines() + { + TestSetup(KeyMode.Vi); + + Test("\"\nline1\nline2\nline1\nline2\n\"", Keys( + _.DQuote, _.Enter, + "line1", _.Enter, + "line2", _.Enter, + _.DQuote, _.Escape, + _.k, _.k, + "2yy", 'P' + )); + } + + [SkippableFact] + public void ViYankAndPasteLogicalLines_LastLine() + { + TestSetup(KeyMode.Vi); + + Test("\"\nHello\nWorld!\nWorld!\n\"", Keys( + _.DQuote, _.Enter, + "Hello", _.Enter, + "World!", _.Enter, + _.DQuote, _.Escape, + _.k, + "yy", + _.j, // move to last line + 'P' + )); + } } }