From 608f792b5f0133cbd5e32ba29089db92dccca47a Mon Sep 17 00:00:00 2001 From: springcomp Date: Fri, 4 Dec 2020 10:40:50 +0100 Subject: [PATCH 1/9] Improve testability using StringBuilder extensions classes. --- PSReadLine/Position.cs | 12 +--- PSReadLine/Prediction.Views.cs | 6 +- .../StringBuilderCharacterExtensions.cs | 66 +++++++++++++++++++ ....cs => StringBuilderLinewiseExtensions.cs} | 19 +++++- PSReadLine/Words.cs | 5 +- PSReadLine/Words.vi.cs | 7 +- PSReadLine/YankPaste.vi.cs | 20 ++++++ test/StringBuilderCharacterExtensionsTests.cs | 46 +++++++++++++ 8 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 PSReadLine/StringBuilderCharacterExtensions.cs rename PSReadLine/{StringBuilderExtensions.cs => StringBuilderLinewiseExtensions.cs} (76%) create mode 100644 test/StringBuilderCharacterExtensionsTests.cs diff --git a/PSReadLine/Position.cs b/PSReadLine/Position.cs index 32068da91..01513a914 100644 --- a/PSReadLine/Position.cs +++ b/PSReadLine/Position.cs @@ -103,22 +103,12 @@ private static int GetFirstNonBlankOfLogicalLinePos(int current) var newCurrent = beginningOfLine; - while (newCurrent < _singleton._buffer.Length && IsVisibleBlank(newCurrent)) + while (newCurrent < _singleton._buffer.Length && _singleton._buffer.IsVisibleBlank(newCurrent)) { newCurrent++; } return newCurrent; } - - private static bool IsVisibleBlank(int newCurrent) - { - var c = _singleton._buffer[newCurrent]; - - // [:blank:] of vim's pattern matching behavior - // defines blanks as SPACE and TAB characters. - - return c == ' ' || c == '\t'; - } } } diff --git a/PSReadLine/Prediction.Views.cs b/PSReadLine/Prediction.Views.cs index a2770eca4..a9145c432 100644 --- a/PSReadLine/Prediction.Views.cs +++ b/PSReadLine/Prediction.Views.cs @@ -1513,12 +1513,12 @@ internal int FindForwardSuggestionWordPoint(int currentIndex, string wordDelimit } int i = currentIndex; - if (!_singleton.InWord(_suggestionText[i], wordDelimiters)) + if (!Character.IsInWord(_suggestionText[i], wordDelimiters)) { // Scan to end of current non-word region while (++i < _suggestionText.Length) { - if (_singleton.InWord(_suggestionText[i], wordDelimiters)) + if (Character.IsInWord(_suggestionText[i], wordDelimiters)) { break; } @@ -1529,7 +1529,7 @@ internal int FindForwardSuggestionWordPoint(int currentIndex, string wordDelimit { while (++i < _suggestionText.Length) { - if (!_singleton.InWord(_suggestionText[i], wordDelimiters)) + if (!Character.IsInWord(_suggestionText[i], wordDelimiters)) { if (_suggestionText[i] == ' ') { diff --git a/PSReadLine/StringBuilderCharacterExtensions.cs b/PSReadLine/StringBuilderCharacterExtensions.cs new file mode 100644 index 000000000..e46071a87 --- /dev/null +++ b/PSReadLine/StringBuilderCharacterExtensions.cs @@ -0,0 +1,66 @@ +using System.Text; + +namespace Microsoft.PowerShell +{ + internal static partial class StringBuilderExtensions + { + /// + /// Returns true if the character at the specified position is a visible whitespace character. + /// A blank character is defined as a SPACE or a TAB. + /// + /// + /// + /// + public static bool IsVisibleBlank(this StringBuilder buffer, int i) + { + var c = buffer[i]; + + // [:blank:] of vim's pattern matching behavior + // defines blanks as SPACE and TAB characters. + + return c == ' ' || c == '\t'; + } + + /// + /// Returns true if the character at the specified position is + /// not present in a list of word-delimiter characters. + /// + /// + /// + /// + /// + public static bool InWord(this StringBuilder buffer, int i, string wordDelimiters) + { + return Character.IsInWord(buffer[i], wordDelimiters); + } + + /// + /// Returns true if the character at the specified position is + /// a unicode whitespace character. + /// + /// + /// + /// + public static bool IsWhiteSpace(this StringBuilder buffer, int i) + { + // Treat just beyond the end of buffer as whitespace because + // it looks like whitespace to the user even though they haven't + // entered a character yet. + return i >= buffer.Length || char.IsWhiteSpace(buffer[i]); + } + } + + public static class Character + { + /// + /// Returns true if the character not present in a list of word-delimiter characters. + /// + /// + /// + /// + public static bool IsInWord(char c, string wordDelimiters) + { + return !char.IsWhiteSpace(c) && wordDelimiters.IndexOf(c) < 0; + } + } +} diff --git a/PSReadLine/StringBuilderExtensions.cs b/PSReadLine/StringBuilderLinewiseExtensions.cs similarity index 76% rename from PSReadLine/StringBuilderExtensions.cs rename to PSReadLine/StringBuilderLinewiseExtensions.cs index 08deef333..d2bf53115 100644 --- a/PSReadLine/StringBuilderExtensions.cs +++ b/PSReadLine/StringBuilderLinewiseExtensions.cs @@ -14,7 +14,7 @@ internal Range(int offset, int count) internal int Count { get; } } - internal static class StringBuilderLinewiseExtensions + internal static partial class StringBuilderLinewiseExtensions { /// /// Determines the offset and the length of the fragment @@ -72,6 +72,23 @@ internal static Range GetRange(this StringBuilder buffer, int lineIndex, int lin endPosition - startPosition + 1 ); } + + /// + /// Returns true if the specified position + /// is on an empty logical line + /// + /// + /// + /// + public static bool IsLogigalLineEmpty(this StringBuilder buffer, int cursor) + { + return + buffer.Length == 0 || + (cursor == buffer.Length && buffer[cursor - 1] == '\n') || + (cursor > 0 && buffer[cursor] == '\n') || + (cursor > 0 && buffer[cursor] == '\n' && cursor < buffer.Length - 1 && buffer[cursor - 1] == '\n') + ; + } } internal static class StringBuilderPredictionExtensions diff --git a/PSReadLine/Words.cs b/PSReadLine/Words.cs index 5c4c09f67..c314dba0a 100644 --- a/PSReadLine/Words.cs +++ b/PSReadLine/Words.cs @@ -90,13 +90,12 @@ private Token FindToken(int current, FindTokenMode mode) private bool InWord(int index, string wordDelimiters) { - char c = _buffer[index]; - return InWord(c, wordDelimiters); + return _buffer.InWord(index, wordDelimiters); } private bool InWord(char c, string wordDelimiters) { - return !char.IsWhiteSpace(c) && wordDelimiters.IndexOf(c) < 0; + return Character.IsInWord(c, wordDelimiters); } /// diff --git a/PSReadLine/Words.vi.cs b/PSReadLine/Words.vi.cs index 8ba987bae..5a475c19f 100644 --- a/PSReadLine/Words.vi.cs +++ b/PSReadLine/Words.vi.cs @@ -2,6 +2,8 @@ Copyright (c) Microsoft Corporation. All rights reserved. --********************************************************************/ +using System; + namespace Microsoft.PowerShell { public partial class PSConsoleReadLine @@ -106,10 +108,7 @@ private int ViFindNextWordFromWord(int i, string wordDelimiters) /// private bool IsWhiteSpace(int i) { - // Treat just beyond the end of buffer as whitespace because - // it looks like whitespace to the user even though they haven't - // entered a character yet. - return i >= _buffer.Length || char.IsWhiteSpace(_buffer[i]); + return _buffer.IsWhiteSpace(i); } /// diff --git a/PSReadLine/YankPaste.vi.cs b/PSReadLine/YankPaste.vi.cs index d59fdd5cc..ec5db502e 100644 --- a/PSReadLine/YankPaste.vi.cs +++ b/PSReadLine/YankPaste.vi.cs @@ -100,6 +100,26 @@ private void RemoveTextToViRegister( _singleton._buffer.Remove(start, count); } + /// + /// Removes a portion of text from the buffer + /// and saves it to the clipboard in order to support undo. + /// + /// + /// + /// + /// + private void RemoveTextToClipboard(int start, int count, Action instigator = null, object arg = null) + { + _singleton.SaveToClipboard(start, count); + _singleton.SaveEditItem(EditItemDelete.Create( + _clipboard, + start, + instigator, + arg + )); + _singleton._buffer.Remove(start, count); + } + /// /// Yank the entire buffer. /// diff --git a/test/StringBuilderCharacterExtensionsTests.cs b/test/StringBuilderCharacterExtensionsTests.cs new file mode 100644 index 000000000..064477a93 --- /dev/null +++ b/test/StringBuilderCharacterExtensionsTests.cs @@ -0,0 +1,46 @@ +using Microsoft.PowerShell; +using System.Text; +using Xunit; + +namespace Test +{ + public sealed class StringBuilderCharacterExtensionsTests + { + [Fact] + public void StringBuilderCharacterExtensions_IsVisibleBlank() + { + var buffer = new StringBuilder(" \tn"); + + // system under test + + Assert.True(buffer.IsVisibleBlank(0)); + Assert.True(buffer.IsVisibleBlank(1)); + Assert.False(buffer.IsVisibleBlank(2)); + } + + [Fact] + public void StringBuilderCharacterExtensions_InWord() + { + var buffer = new StringBuilder("hello, world!"); + const string wordDelimiters = " "; + + // system under test + + Assert.True(buffer.InWord(2, wordDelimiters)); + Assert.True(buffer.InWord(5, wordDelimiters)); + } + + [Fact] + public void StringBuilderCharacterExtensions_IsWhiteSpace() + { + var buffer = new StringBuilder("a c"); + + + // system under test + + Assert.False(buffer.IsWhiteSpace(0)); + Assert.True(buffer.IsWhiteSpace(1)); + Assert.False(buffer.IsWhiteSpace(2)); + } + } +} From 585a0366c18ddaf193b2773206ca220a2bff8bd4 Mon Sep 17 00:00:00 2001 From: springcomp Date: Thu, 31 Dec 2020 10:17:06 +0100 Subject: [PATCH 2/9] Supports 'diw' command. --- PSReadLine/Cmdlets.cs | 2 +- PSReadLine/KeyBindings.vi.cs | 8 + .../StringBuilderCharacterExtensions.cs | 12 ++ .../StringBuilderTextObjectExtensions.cs | 114 ++++++++++ PSReadLine/TextObjects.Vi.cs | 198 ++++++++++++++++++ .../StringBuilderTextObjectExtensionsTests.cs | 77 +++++++ test/TextObjects.Vi.Tests.cs | 176 ++++++++++++++++ 7 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 PSReadLine/StringBuilderTextObjectExtensions.cs create mode 100644 PSReadLine/TextObjects.Vi.cs create mode 100644 test/StringBuilderTextObjectExtensionsTests.cs create mode 100644 test/TextObjects.Vi.Tests.cs diff --git a/PSReadLine/Cmdlets.cs b/PSReadLine/Cmdlets.cs index 7771fa920..222185602 100644 --- a/PSReadLine/Cmdlets.cs +++ b/PSReadLine/Cmdlets.cs @@ -142,7 +142,7 @@ public class PSConsoleReadLineOptions public const int DefaultCompletionQueryItems = 100; // Default includes all characters PowerShell treats like a dash - em dash, en dash, horizontal bar - public const string DefaultWordDelimiters = @";:,.[]{}()/\|^&*-=+'""" + "\u2013\u2014\u2015"; + public const string DefaultWordDelimiters = @";:,.[]{}()/\|!?^&*-=+'""" + "\u2013\u2014\u2015"; /// /// When ringing the bell, what should be done? diff --git a/PSReadLine/KeyBindings.vi.cs b/PSReadLine/KeyBindings.vi.cs index 9d051ec02..5a47c6608 100644 --- a/PSReadLine/KeyBindings.vi.cs +++ b/PSReadLine/KeyBindings.vi.cs @@ -45,6 +45,8 @@ internal static ConsoleColor AlternateBackground(ConsoleColor bg) private static Dictionary _viChordYTable; private static Dictionary _viChordDGTable; + private static Dictionary _viChordTextObjectsTable; + private static Dictionary> _viCmdChordTable; private static Dictionary> _viInsChordTable; @@ -238,6 +240,7 @@ private void SetDefaultViBindings() { Keys.ucG, MakeKeyHandler( DeleteEndOfBuffer, "DeleteEndOfBuffer") }, { Keys.ucE, MakeKeyHandler( ViDeleteEndOfGlob, "ViDeleteEndOfGlob") }, { Keys.H, MakeKeyHandler( BackwardDeleteChar, "BackwardDeleteChar") }, + { Keys.I, MakeKeyHandler( ViChordDeleteTextObject, "ChordViTextObject") }, { Keys.J, MakeKeyHandler( DeleteNextLines, "DeleteNextLines") }, { Keys.K, MakeKeyHandler( DeletePreviousLines, "DeletePreviousLines") }, { Keys.L, MakeKeyHandler( DeleteChar, "DeleteChar") }, @@ -296,6 +299,11 @@ private void SetDefaultViBindings() { Keys.Percent, MakeKeyHandler( ViYankPercent, "ViYankPercent") }, }; + _viChordTextObjectsTable = new Dictionary + { + { Keys.W, MakeKeyHandler(ViHandleTextObject, "WordTextObject")}, + }; + _viChordDGTable = new Dictionary { { Keys.G, MakeKeyHandler( DeleteRelativeLines, "DeleteRelativeLines") }, diff --git a/PSReadLine/StringBuilderCharacterExtensions.cs b/PSReadLine/StringBuilderCharacterExtensions.cs index e46071a87..8a1550266 100644 --- a/PSReadLine/StringBuilderCharacterExtensions.cs +++ b/PSReadLine/StringBuilderCharacterExtensions.cs @@ -34,6 +34,18 @@ public static bool InWord(this StringBuilder buffer, int i, string wordDelimiter return Character.IsInWord(buffer[i], wordDelimiters); } + /// + /// Returns true if the character at the specified position is + /// at the end of the buffer + /// + /// + /// + /// + public static bool IsAtEndOfBuffer(this StringBuilder buffer, int i) + { + return i >= (buffer.Length - 1); + } + /// /// Returns true if the character at the specified position is /// a unicode whitespace character. diff --git a/PSReadLine/StringBuilderTextObjectExtensions.cs b/PSReadLine/StringBuilderTextObjectExtensions.cs new file mode 100644 index 000000000..6c8fc0427 --- /dev/null +++ b/PSReadLine/StringBuilderTextObjectExtensions.cs @@ -0,0 +1,114 @@ +using System; +using System.Text; + +namespace Microsoft.PowerShell +{ + internal static partial class StringBuilderTextObjectExtensions + { + /// + /// Returns the position of the beginning of the current word as delimited by white space and delimiters + /// This method differs from : + /// When the cursor location is on the first character of a word, + /// returns the position of the previous word, whereas this method returns the cursor location. + /// + /// When the cursor location is in a word, both methods return the same result. + /// + /// This method supports VI "iw" text object. + /// + public static int ViFindBeginningOfWordObjectBoundary(this StringBuilder buffer, int position, string wordDelimiters) + { + // cursor may be past the end of the buffer when calling this method + // this may happen if the cursor is at the beginning of a new line + + var i = Math.Min(position, buffer.Length - 1); + + // if starting on a word consider a text object as a sequence of characters excluding the delimiters + // otherwise, consider a word as a sequence of delimiters + + var delimiters = wordDelimiters + '\n'; + if (buffer.InWord(i, wordDelimiters)) + { + delimiters += " \t"; + } + if (delimiters.IndexOf(buffer[i]) == -1 && buffer.IsWhiteSpace(i)) + { + delimiters = " \t"; + } + + var isTextObjectChar = buffer.InWord(i, wordDelimiters) + ? (Func)(c => delimiters.IndexOf(c) == -1) + : c => delimiters.IndexOf(c) != -1 + ; + + var beginning = i; + while (i >= 0 && isTextObjectChar(buffer[i])) + { + beginning = i--; + } + + return beginning; + } + + /// + /// Finds the position of the beginning of the next word object starting from the specified position. + /// If positioned on the last word in the buffer, returns buffer length + 1. + /// This method supports VI "iw" text-object. + /// iw: "inner word", select words. White space between words is counted too. + /// + public static int ViFindBeginningOfNextWordObjectBoundary(this StringBuilder buffer, int position, string wordDelimiters) + { + // cursor may be past the end of the buffer when calling this method + // this may happen if the cursor is at the beginning of a new line + + var i = Math.Min(position, buffer.Length - 1); + + // always skip the first newline character + + if (buffer[i] == '\n' && i < buffer.Length - 1) + { + // try to skip a second newline characters + // to replicate vim behaviour + + ++i; + } + + // if starting on a word consider a text object as a sequence of characters excluding the delimiters + // otherwise, consider a word as a sequence of delimiters + + var delimiters = wordDelimiters; + + if (buffer.InWord(i, wordDelimiters)) + { + delimiters += " \t\n"; + } + if (buffer.IsWhiteSpace(i)) + { + delimiters = " \t"; + } + + var isTextObjectChar = buffer.InWord(i, wordDelimiters) + ? (Func)(c => delimiters.IndexOf(c) == -1) + : c => delimiters.IndexOf(c) != -1 + ; + + // try to skip a second newline characters + // to replicate vim behaviour + + if (buffer[i] == '\n' && i < buffer.Length - 1) + { + ++i; + } + + // skip to next non word characters + + while (i < buffer.Length && isTextObjectChar(buffer[i])) + { + ++i; + } + + // make sure end includes the starting position + + return Math.Max(i, position); + } + } +} diff --git a/PSReadLine/TextObjects.Vi.cs b/PSReadLine/TextObjects.Vi.cs new file mode 100644 index 000000000..712b074a0 --- /dev/null +++ b/PSReadLine/TextObjects.Vi.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.PowerShell +{ + public partial class PSConsoleReadLine + { + internal enum TextObjectOperation + { + None, + Change, + Delete, + } + + internal enum TextObjectSpan + { + None, + Around, + Inner, + } + + private TextObjectOperation _textObjectOperation = TextObjectOperation.None; + private TextObjectSpan _textObjectSpan = TextObjectSpan.None; + + private readonly IDictionary> _textObjectHandlers + = new Dictionary> + { + { + TextObjectOperation.Delete, + new Dictionary + { + {TextObjectSpan.Inner, MakeKeyHandler(ViDeleteInnerWord, "ViDeleteInnerWord")} + } + } + }; + + private void ViChordDeleteTextObject(ConsoleKeyInfo? key = null, object arg = null) + { + _textObjectOperation = TextObjectOperation.Delete; + ViChordTextObject(key, arg); + } + + private void ViChordTextObject(ConsoleKeyInfo? key = null, object arg = null) + { + if (!key.HasValue) + { + ResetTextObjectState(); + throw new ArgumentNullException(nameof(key)); + } + + _textObjectSpan = GetRequestedTextObjectSpan(key.Value); + + // handle text object + + var textObjectKey = ReadKey(); + if (_viChordTextObjectsTable.TryGetValue(textObjectKey, out _)) + { + _singleton.ProcessOneKey(textObjectKey, _viChordTextObjectsTable, ignoreIfNoAction: true, arg: arg); + } + else + { + ResetTextObjectState(); + Ding(); + } + } + + private TextObjectSpan GetRequestedTextObjectSpan(ConsoleKeyInfo key) + { + if (key.KeyChar == 'i') + { + return TextObjectSpan.Inner; + } + else if (key.KeyChar == 'a') + { + return TextObjectSpan.Around; + } + else + { + System.Diagnostics.Debug.Assert(false); + throw new NotSupportedException(); + } + } + + private static void ViHandleTextObject(ConsoleKeyInfo? key = null, object arg = null) + { + if ( + !_singleton._textObjectHandlers.TryGetValue(_singleton._textObjectOperation, out var textObjectHandler) || + !textObjectHandler.TryGetValue(_singleton._textObjectSpan, out var handler) + ) + { + ResetTextObjectState(); + Ding(); + return; + } + + handler.Action(key, arg); + } + + private static void ResetTextObjectState() + { + _singleton._textObjectOperation = TextObjectOperation.None; + _singleton._textObjectSpan = TextObjectSpan.None; + } + + private static void ViDeleteInnerWord(ConsoleKeyInfo? key = null, object arg = null) + { + var delimiters = _singleton.Options.WordDelimiters; + + if (!TryGetArgAsInt(arg, out var numericArg, 1)) + return; + + if (_singleton._buffer.Length == 0) + { + if (numericArg > 1) + { + Ding(); + } + return; + } + + // unless at the end of the buffer a single delete word should not delete backwards + // so if the cursor is on an empty line, do nothing + + if ( + numericArg == 1 && + _singleton._current < _singleton._buffer.Length && + _singleton._buffer.IsLogigalLineEmpty(_singleton._current) + ) + { + return; + } + + var start = _singleton._buffer.ViFindBeginningOfWordObjectBoundary(_singleton._current, delimiters); + var end = _singleton._current; + + // attempting to find a valid position for multiple words + // if no valid position is found, this is a no-op + + { + while (numericArg-- > 0 && end < _singleton._buffer.Length) + { + end = _singleton._buffer.ViFindBeginningOfNextWordObjectBoundary(end, delimiters); + } + + // attempting to delete too many words should ding + + if (numericArg > 0) + { + Ding(); + return; + } + } + + if (end > 0 && _singleton._buffer.IsAtEndOfBuffer(end - 1) && _singleton._buffer.InWord(end - 1, delimiters)) + { + _singleton._shouldAppend = true; + } + + _singleton.RemoveTextToClipboard(start, end - start); + _singleton.AdjustCursorPosition(start); + _singleton.Render(); + } + + /// + /// Attempt to set the cursor at the specified position. + /// + /// + /// + private int AdjustCursorPosition(int position) + { + // this method might prove useful in a more general case + + if (_buffer.Length == 0) + { + _current = 0; + return 0; + } + + var maxPosition = _buffer[_buffer.Length - 1] == '\n' + ? _buffer.Length + : _buffer.Length - 1 + ; + + var newCurrent = Math.Min(position, maxPosition); + + var beginning = GetBeginningOfLinePos(newCurrent); + + if (newCurrent < _buffer.Length && _buffer[newCurrent] == '\n' && (newCurrent + ViEndOfLineFactor > beginning)) + { + newCurrent += ViEndOfLineFactor; + } + + _current = newCurrent; + + return newCurrent; + } + } +} diff --git a/test/StringBuilderTextObjectExtensionsTests.cs b/test/StringBuilderTextObjectExtensionsTests.cs new file mode 100644 index 000000000..66bd590de --- /dev/null +++ b/test/StringBuilderTextObjectExtensionsTests.cs @@ -0,0 +1,77 @@ +using Microsoft.PowerShell; +using System.Text; +using Xunit; + +namespace Test +{ + public sealed class StringBuilderTextObjectExtensionsTests + { + [Fact] + public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary() + { + const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters; + + var buffer = new StringBuilder("Hello, world!\ncruel world.\none\n\n\n\n\ntwo\n three four."); + Assert.Equal(0, buffer.ViFindBeginningOfWordObjectBoundary(1, wordDelimiters)); + } + + [Fact] + public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary_whitespace() + { + const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters; + + var buffer = new StringBuilder("Hello, world!"); + Assert.Equal(6, buffer.ViFindBeginningOfWordObjectBoundary(7, wordDelimiters)); + } + + [Fact] + public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary_backwards() + { + const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters; + + var buffer = new StringBuilder("Hello!\nworld!"); + Assert.Equal(5, buffer.ViFindBeginningOfWordObjectBoundary(6, wordDelimiters)); + } + + [Fact] + public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary_end_of_buffer() + { + const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters; + + var buffer = new StringBuilder("Hello, world!"); + Assert.Equal(12, buffer.ViFindBeginningOfWordObjectBoundary(buffer.Length, wordDelimiters)); + } + + [Fact] + public void StringBuilderTextObjectExtensions_ViFindBeginningOfNextWordObjectBoundary() + { + const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters; + + var buffer = new StringBuilder("Hello, world!\ncruel world.\none\n\n\n\n\ntwo\n three four."); + + // Words |Hello|,| |world|!|\n|cruel |world|.|\n|one\n\n|\n\n|\n|two|\n |three| |four|.| + // Pos 01234 5 6 78901 2 _3 456789 01234 5 _6 789_0_1 _2_3 _4 567 _89 01234 5 6789 0 + // Pos 0 1 2 3 4 5 + + // system under test + + Assert.Equal(5, buffer.ViFindBeginningOfNextWordObjectBoundary(0, wordDelimiters)); + Assert.Equal(6, buffer.ViFindBeginningOfNextWordObjectBoundary(5, wordDelimiters)); + Assert.Equal(7, buffer.ViFindBeginningOfNextWordObjectBoundary(6, wordDelimiters)); + Assert.Equal(12, buffer.ViFindBeginningOfNextWordObjectBoundary(7, wordDelimiters)); + Assert.Equal(13, buffer.ViFindBeginningOfNextWordObjectBoundary(12, wordDelimiters)); + Assert.Equal(19, buffer.ViFindBeginningOfNextWordObjectBoundary(13, wordDelimiters)); + Assert.Equal(20, buffer.ViFindBeginningOfNextWordObjectBoundary(19, wordDelimiters)); + Assert.Equal(25, buffer.ViFindBeginningOfNextWordObjectBoundary(20, wordDelimiters)); + Assert.Equal(26, buffer.ViFindBeginningOfNextWordObjectBoundary(25, wordDelimiters)); + Assert.Equal(30, buffer.ViFindBeginningOfNextWordObjectBoundary(26, wordDelimiters)); + Assert.Equal(32, buffer.ViFindBeginningOfNextWordObjectBoundary(30, wordDelimiters)); + Assert.Equal(34, buffer.ViFindBeginningOfNextWordObjectBoundary(32, wordDelimiters)); + Assert.Equal(38, buffer.ViFindBeginningOfNextWordObjectBoundary(34, wordDelimiters)); + Assert.Equal(40, buffer.ViFindBeginningOfNextWordObjectBoundary(38, wordDelimiters)); + Assert.Equal(45, buffer.ViFindBeginningOfNextWordObjectBoundary(40, wordDelimiters)); + Assert.Equal(46, buffer.ViFindBeginningOfNextWordObjectBoundary(45, wordDelimiters)); + Assert.Equal(50, buffer.ViFindBeginningOfNextWordObjectBoundary(46, wordDelimiters)); + } + } +} diff --git a/test/TextObjects.Vi.Tests.cs b/test/TextObjects.Vi.Tests.cs new file mode 100644 index 000000000..1392c16ee --- /dev/null +++ b/test/TextObjects.Vi.Tests.cs @@ -0,0 +1,176 @@ +using Microsoft.PowerShell; +using Xunit; + +namespace Test +{ + public partial class ReadLine + { + [SkippableFact] + public void ViTextObject_diw() + { + TestSetup(KeyMode.Vi); + + Test("\"hello, \ncruel world!\"", Keys( + _.DQuote, + "hello, world!", _.Enter, + "cruel world!", _.DQuote, + _.Escape, + + // move cursor to the 'o' in 'world' + "gg9l", + + // delete text object + "diw", + CheckThat(() => AssertLineIs("\"hello, !\ncruel world!\"")), + CheckThat(() => AssertCursorLeftIs(8)), + + // delete + "diw", + CheckThat(() => AssertLineIs("\"hello, \ncruel world!\"")), + CheckThat(() => AssertCursorLeftIs(7)) + )); + } + + [SkippableFact] + public void ViTextObject_diw_digit_arguments() + { + TestSetup(KeyMode.Vi); + + Test("\"hello, world!\"", Keys( + _.DQuote, + "hello, world!", _.Enter, + "cruel world!", _.DQuote, + _.Escape, + + // move cursor to the 'o' in 'world' + "gg9l", + + // delete text object + "diw", + CheckThat(() => AssertLineIs("\"hello, !\ncruel world!\"")), + CheckThat(() => AssertCursorLeftIs(8)), + + // delete multiple text objects (spans multiple lines) + "3diw", + CheckThat(() => AssertLineIs("\"hello, world!\"")), + CheckThat(() => AssertCursorLeftIs(8)) + )); + } + + + [SkippableFact] + public void ViTextObject_diw_noop() + { + TestSetup(KeyMode.Vi); + + TestMustDing("\"hello, world!\ncruel world!\"", Keys( + _.DQuote, + "hello, world!", _.Enter, + "cruel world!", _.DQuote, + _.Escape, + + // move cursor to the 'o' in 'world' + "gg9l", + + // attempting to delete too many words must ding + "1274diw" + )); + } + + [SkippableFact] + public void ViTextObject_diw_empty_line() + { + TestSetup(KeyMode.Vi); + + var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; + + Test("\"\nhello, world!\n\noh, bitter world!\n\"", Keys( + _.DQuote, _.Enter, + "hello, world!", _.Enter, + _.Enter, + "oh, bitter world!", _.Enter, + _.DQuote, _.Escape, + + // move cursor to the second line + "ggjj", + + // deleting single word cannot move backwards to previous line (noop) + "diw", + CheckThat(() => AssertLineIs("\"\nhello, world!\n\noh, bitter world!\n\"")) + )); + } + + [SkippableFact] + public void ViTextObject_diw_end_of_buffer() + { + TestSetup(KeyMode.Vi); + + var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; + + Test("", Keys( + _.DQuote, + "hello, world!", _.Enter, + "cruel world!", _.DQuote, + _.Escape, + + // move to end of buffer + "G$", + + // delete text object (deletes backwards) + "diw", CheckThat(() => AssertLineIs("\"hello, world!\ncruel world")), + "diw", CheckThat(() => AssertLineIs("\"hello, world!\ncruel ")), + "diw", CheckThat(() => AssertLineIs("\"hello, world!\ncruel")), + "diw", CheckThat(() => AssertLineIs("\"hello, world!\n")), + "diw", CheckThat(() => AssertLineIs("\"hello, world")), + "diw", CheckThat(() => AssertLineIs("\"hello, ")), + "diw", CheckThat(() => AssertLineIs("\"hello,")), + "diw", CheckThat(() => AssertLineIs("\"hello")), + "diw", CheckThat(() => AssertLineIs("\"")), + "diw", CheckThat(() => AssertLineIs("")) + )); + } + + [SkippableFact] + public void ViTextObject_diw_empty_buffer() + { + TestSetup(KeyMode.Vi); + Test("", Keys(_.Escape, "diw")); + TestMustDing("", Keys(_.Escape, "d2iw")); + } + + [SkippableFact] + public void ViTextObject_diw_new_lines() + { + TestSetup(KeyMode.Vi); + + var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; + + Test("\"\ntwo\n\"", Keys( + _.DQuote, _.Enter, + "one", _.Enter, + _.Enter, _.Enter, + _.Enter, _.Enter, + _.Enter, + "two", _.Enter, _.DQuote, + _.Escape, + + // move to the beginning of 'one' + "gg0j", + + // delete text object + "2diw", + CheckThat(() => AssertLineIs("\"\n\n\n\n\ntwo\n\"")), + + "ugg0j", // currently undo does not move the cursor to the correct position + // delete multiple text objects (spans multiple lines) + "3diw", + CheckThat(() => AssertLineIs("\"\n\n\ntwo\n\"")), + + "ugg0j", // currently undo does not move the cursor to the correct position + // delete multiple text objects (spans multiple lines) + "4diw", + CheckThat(() => AssertLineIs("\"\ntwo\n\"")) + )); + } + } +} \ No newline at end of file From 70e7b54b27978ba1298f2fc52b3d402f85ef99e0 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Wed, 10 Feb 2021 15:15:54 -0800 Subject: [PATCH 3/9] Resolve conflicts and unify line endings to LF --- .../StringBuilderCharacterExtensions.cs | 18 +-- PSReadLine/TextObjects.Vi.cs | 146 +++++++++--------- PSReadLine/YankPaste.vi.cs | 20 --- 3 files changed, 82 insertions(+), 102 deletions(-) diff --git a/PSReadLine/StringBuilderCharacterExtensions.cs b/PSReadLine/StringBuilderCharacterExtensions.cs index 8a1550266..49a7868bb 100644 --- a/PSReadLine/StringBuilderCharacterExtensions.cs +++ b/PSReadLine/StringBuilderCharacterExtensions.cs @@ -34,15 +34,15 @@ public static bool InWord(this StringBuilder buffer, int i, string wordDelimiter return Character.IsInWord(buffer[i], wordDelimiters); } - /// - /// Returns true if the character at the specified position is - /// at the end of the buffer - /// - /// - /// + /// + /// Returns true if the character at the specified position is + /// at the end of the buffer + /// + /// + /// /// - public static bool IsAtEndOfBuffer(this StringBuilder buffer, int i) - { + public static bool IsAtEndOfBuffer(this StringBuilder buffer, int i) + { return i >= (buffer.Length - 1); } @@ -54,7 +54,7 @@ public static bool IsAtEndOfBuffer(this StringBuilder buffer, int i) /// /// public static bool IsWhiteSpace(this StringBuilder buffer, int i) - { + { // Treat just beyond the end of buffer as whitespace because // it looks like whitespace to the user even though they haven't // entered a character yet. diff --git a/PSReadLine/TextObjects.Vi.cs b/PSReadLine/TextObjects.Vi.cs index 712b074a0..7fcb89de6 100644 --- a/PSReadLine/TextObjects.Vi.cs +++ b/PSReadLine/TextObjects.Vi.cs @@ -37,7 +37,7 @@ private readonly IDictionary 1) - { - Ding(); - } - return; - } - - // unless at the end of the buffer a single delete word should not delete backwards - // so if the cursor is on an empty line, do nothing - - if ( - numericArg == 1 && - _singleton._current < _singleton._buffer.Length && - _singleton._buffer.IsLogigalLineEmpty(_singleton._current) - ) - { - return; - } - - var start = _singleton._buffer.ViFindBeginningOfWordObjectBoundary(_singleton._current, delimiters); - var end = _singleton._current; - - // attempting to find a valid position for multiple words - // if no valid position is found, this is a no-op - - { - while (numericArg-- > 0 && end < _singleton._buffer.Length) - { - end = _singleton._buffer.ViFindBeginningOfNextWordObjectBoundary(end, delimiters); - } - - // attempting to delete too many words should ding - - if (numericArg > 0) - { - Ding(); - return; - } - } - - if (end > 0 && _singleton._buffer.IsAtEndOfBuffer(end - 1) && _singleton._buffer.InWord(end - 1, delimiters)) - { - _singleton._shouldAppend = true; - } - - _singleton.RemoveTextToClipboard(start, end - start); - _singleton.AdjustCursorPosition(start); - _singleton.Render(); + return; + + if (_singleton._buffer.Length == 0) + { + if (numericArg > 1) + { + Ding(); + } + return; + } + + // unless at the end of the buffer a single delete word should not delete backwards + // so if the cursor is on an empty line, do nothing + + if ( + numericArg == 1 && + _singleton._current < _singleton._buffer.Length && + _singleton._buffer.IsLogigalLineEmpty(_singleton._current) + ) + { + return; + } + + var start = _singleton._buffer.ViFindBeginningOfWordObjectBoundary(_singleton._current, delimiters); + var end = _singleton._current; + + // attempting to find a valid position for multiple words + // if no valid position is found, this is a no-op + + { + while (numericArg-- > 0 && end < _singleton._buffer.Length) + { + end = _singleton._buffer.ViFindBeginningOfNextWordObjectBoundary(end, delimiters); + } + + // attempting to delete too many words should ding + + if (numericArg > 0) + { + Ding(); + return; + } + } + + if (end > 0 && _singleton._buffer.IsAtEndOfBuffer(end - 1) && _singleton._buffer.InWord(end - 1, delimiters)) + { + _singleton._shouldAppend = true; + } + + _singleton.RemoveTextToViRegister(start, end - start); + _singleton.AdjustCursorPosition(start); + _singleton.Render(); } /// diff --git a/PSReadLine/YankPaste.vi.cs b/PSReadLine/YankPaste.vi.cs index ec5db502e..d59fdd5cc 100644 --- a/PSReadLine/YankPaste.vi.cs +++ b/PSReadLine/YankPaste.vi.cs @@ -100,26 +100,6 @@ private void RemoveTextToViRegister( _singleton._buffer.Remove(start, count); } - /// - /// Removes a portion of text from the buffer - /// and saves it to the clipboard in order to support undo. - /// - /// - /// - /// - /// - private void RemoveTextToClipboard(int start, int count, Action instigator = null, object arg = null) - { - _singleton.SaveToClipboard(start, count); - _singleton.SaveEditItem(EditItemDelete.Create( - _clipboard, - start, - instigator, - arg - )); - _singleton._buffer.Remove(start, count); - } - /// /// Yank the entire buffer. /// From 8ad19d7f2ada304c80c3889be4124f68604c4209 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Wed, 10 Feb 2021 15:23:49 -0800 Subject: [PATCH 4/9] Minor simplification --- PSReadLine/Position.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PSReadLine/Position.cs b/PSReadLine/Position.cs index 01513a914..2aa32039c 100644 --- a/PSReadLine/Position.cs +++ b/PSReadLine/Position.cs @@ -102,8 +102,9 @@ private static int GetFirstNonBlankOfLogicalLinePos(int current) var beginningOfLine = GetBeginningOfLinePos(current); var newCurrent = beginningOfLine; + var buffer = _singleton._buffer; - while (newCurrent < _singleton._buffer.Length && _singleton._buffer.IsVisibleBlank(newCurrent)) + while (newCurrent < buffer.Length && buffer.IsVisibleBlank(newCurrent)) { newCurrent++; } From 944e5ce884895ec9c8a29b0872b8df8ff56cda17 Mon Sep 17 00:00:00 2001 From: springcomp Date: Mon, 15 Feb 2021 22:37:56 +0100 Subject: [PATCH 5/9] Simplified code and added explaining comments. --- PSReadLine/StringBuilderLinewiseExtensions.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/PSReadLine/StringBuilderLinewiseExtensions.cs b/PSReadLine/StringBuilderLinewiseExtensions.cs index d2bf53115..e96428faf 100644 --- a/PSReadLine/StringBuilderLinewiseExtensions.cs +++ b/PSReadLine/StringBuilderLinewiseExtensions.cs @@ -82,11 +82,24 @@ internal static Range GetRange(this StringBuilder buffer, int lineIndex, int lin /// public static bool IsLogigalLineEmpty(this StringBuilder buffer, int cursor) { + // the cursor is on a logical line considered empty if... + return + + // the entire buffer is empty (by definition), or + buffer.Length == 0 || + + // the cursor sits at the start of the empty last line, + // meaning that it is past the end of the buffer and the + // last character in the buffer is a newline character, or + (cursor == buffer.Length && buffer[cursor - 1] == '\n') || - (cursor > 0 && buffer[cursor] == '\n') || - (cursor > 0 && buffer[cursor] == '\n' && cursor < buffer.Length - 1 && buffer[cursor - 1] == '\n') + + // if the cursor is on a newline character, or + + (cursor > 0 && buffer[cursor] == '\n') + ; } } From e89bd178eed1c3f36f88be12f34e52f3dc10933f Mon Sep 17 00:00:00 2001 From: springcomp Date: Mon, 15 Feb 2021 23:29:54 +0100 Subject: [PATCH 6/9] Clarify intent --- PSReadLine/StringBuilderTextObjectExtensions.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/PSReadLine/StringBuilderTextObjectExtensions.cs b/PSReadLine/StringBuilderTextObjectExtensions.cs index 6c8fc0427..0bbb62bf5 100644 --- a/PSReadLine/StringBuilderTextObjectExtensions.cs +++ b/PSReadLine/StringBuilderTextObjectExtensions.cs @@ -24,15 +24,23 @@ public static int ViFindBeginningOfWordObjectBoundary(this StringBuilder buffer, // if starting on a word consider a text object as a sequence of characters excluding the delimiters // otherwise, consider a word as a sequence of delimiters + // for the purpose of this method, a newline (\n) character is considered a delimiter. + + var ws = " \n\t"; + + var delimiters = wordDelimiters; - var delimiters = wordDelimiters + '\n'; if (buffer.InWord(i, wordDelimiters)) { - delimiters += " \t"; + delimiters += ws; } - if (delimiters.IndexOf(buffer[i]) == -1 && buffer.IsWhiteSpace(i)) + if ((wordDelimiters + '\n').IndexOf(buffer[i]) == -1 && buffer.IsWhiteSpace(i)) { - delimiters = " \t"; + delimiters = ws; + } + else + { + delimiters += '\n'; } var isTextObjectChar = buffer.InWord(i, wordDelimiters) From 7f4babdea9b64345d7e65f0d53fb507bad14ad91 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Mon, 14 Aug 2023 11:54:23 -0700 Subject: [PATCH 7/9] Update with minor fixes and style changes --- .../StringBuilderCharacterExtensions.cs | 2 +- PSReadLine/StringBuilderLinewiseExtensions.cs | 28 +-- .../StringBuilderTextObjectExtensions.cs | 215 ++++++++---------- PSReadLine/TextObjects.Vi.cs | 53 ++--- PSReadLine/Words.cs | 5 - test/TextObjects.Vi.Tests.cs | 2 +- 6 files changed, 122 insertions(+), 183 deletions(-) diff --git a/PSReadLine/StringBuilderCharacterExtensions.cs b/PSReadLine/StringBuilderCharacterExtensions.cs index 49a7868bb..ab3faaea3 100644 --- a/PSReadLine/StringBuilderCharacterExtensions.cs +++ b/PSReadLine/StringBuilderCharacterExtensions.cs @@ -2,7 +2,7 @@ namespace Microsoft.PowerShell { - internal static partial class StringBuilderExtensions + internal static class StringBuilderCharacterExtensions { /// /// Returns true if the character at the specified position is a visible whitespace character. diff --git a/PSReadLine/StringBuilderLinewiseExtensions.cs b/PSReadLine/StringBuilderLinewiseExtensions.cs index e96428faf..89b6fe42a 100644 --- a/PSReadLine/StringBuilderLinewiseExtensions.cs +++ b/PSReadLine/StringBuilderLinewiseExtensions.cs @@ -14,7 +14,7 @@ internal Range(int offset, int count) internal int Count { get; } } - internal static partial class StringBuilderLinewiseExtensions + internal static class StringBuilderLinewiseExtensions { /// /// Determines the offset and the length of the fragment @@ -74,8 +74,7 @@ internal static Range GetRange(this StringBuilder buffer, int lineIndex, int lin } /// - /// Returns true if the specified position - /// is on an empty logical line + /// Returns true if the specified position is on an empty logical line. /// /// /// @@ -83,24 +82,15 @@ internal static Range GetRange(this StringBuilder buffer, int lineIndex, int lin public static bool IsLogigalLineEmpty(this StringBuilder buffer, int cursor) { // the cursor is on a logical line considered empty if... - return - - // the entire buffer is empty (by definition), or - - buffer.Length == 0 || - - // the cursor sits at the start of the empty last line, + // the entire buffer is empty (by definition), + buffer.Length == 0 + // or the cursor sits at the start of the empty last line, // meaning that it is past the end of the buffer and the - // last character in the buffer is a newline character, or - - (cursor == buffer.Length && buffer[cursor - 1] == '\n') || - - // if the cursor is on a newline character, or - - (cursor > 0 && buffer[cursor] == '\n') - - ; + // last character in the buffer is a newline character, + || (cursor == buffer.Length && buffer[cursor - 1] == '\n') + // or if the cursor is on a newline character. + || (cursor > 0 && buffer[cursor] == '\n'); } } diff --git a/PSReadLine/StringBuilderTextObjectExtensions.cs b/PSReadLine/StringBuilderTextObjectExtensions.cs index 0bbb62bf5..5f10b1a8f 100644 --- a/PSReadLine/StringBuilderTextObjectExtensions.cs +++ b/PSReadLine/StringBuilderTextObjectExtensions.cs @@ -1,122 +1,93 @@ -using System; -using System.Text; - -namespace Microsoft.PowerShell -{ - internal static partial class StringBuilderTextObjectExtensions - { - /// - /// Returns the position of the beginning of the current word as delimited by white space and delimiters - /// This method differs from : - /// When the cursor location is on the first character of a word, - /// returns the position of the previous word, whereas this method returns the cursor location. - /// - /// When the cursor location is in a word, both methods return the same result. - /// - /// This method supports VI "iw" text object. - /// - public static int ViFindBeginningOfWordObjectBoundary(this StringBuilder buffer, int position, string wordDelimiters) - { - // cursor may be past the end of the buffer when calling this method - // this may happen if the cursor is at the beginning of a new line - - var i = Math.Min(position, buffer.Length - 1); - - // if starting on a word consider a text object as a sequence of characters excluding the delimiters - // otherwise, consider a word as a sequence of delimiters - // for the purpose of this method, a newline (\n) character is considered a delimiter. - - var ws = " \n\t"; - - var delimiters = wordDelimiters; - - if (buffer.InWord(i, wordDelimiters)) - { - delimiters += ws; - } - if ((wordDelimiters + '\n').IndexOf(buffer[i]) == -1 && buffer.IsWhiteSpace(i)) - { - delimiters = ws; - } - else - { - delimiters += '\n'; - } - - var isTextObjectChar = buffer.InWord(i, wordDelimiters) - ? (Func)(c => delimiters.IndexOf(c) == -1) - : c => delimiters.IndexOf(c) != -1 - ; - - var beginning = i; - while (i >= 0 && isTextObjectChar(buffer[i])) - { - beginning = i--; - } - - return beginning; - } - - /// - /// Finds the position of the beginning of the next word object starting from the specified position. - /// If positioned on the last word in the buffer, returns buffer length + 1. - /// This method supports VI "iw" text-object. - /// iw: "inner word", select words. White space between words is counted too. - /// - public static int ViFindBeginningOfNextWordObjectBoundary(this StringBuilder buffer, int position, string wordDelimiters) - { - // cursor may be past the end of the buffer when calling this method - // this may happen if the cursor is at the beginning of a new line - - var i = Math.Min(position, buffer.Length - 1); - - // always skip the first newline character - - if (buffer[i] == '\n' && i < buffer.Length - 1) - { - // try to skip a second newline characters - // to replicate vim behaviour - - ++i; - } - - // if starting on a word consider a text object as a sequence of characters excluding the delimiters - // otherwise, consider a word as a sequence of delimiters - - var delimiters = wordDelimiters; - - if (buffer.InWord(i, wordDelimiters)) - { - delimiters += " \t\n"; - } - if (buffer.IsWhiteSpace(i)) - { - delimiters = " \t"; - } - - var isTextObjectChar = buffer.InWord(i, wordDelimiters) - ? (Func)(c => delimiters.IndexOf(c) == -1) - : c => delimiters.IndexOf(c) != -1 - ; - - // try to skip a second newline characters - // to replicate vim behaviour - - if (buffer[i] == '\n' && i < buffer.Length - 1) - { - ++i; - } - - // skip to next non word characters - - while (i < buffer.Length && isTextObjectChar(buffer[i])) - { - ++i; - } - - // make sure end includes the starting position - - return Math.Max(i, position); - } - } -} +using System; +using System.Text; + +namespace Microsoft.PowerShell +{ + internal static class StringBuilderTextObjectExtensions + { + /// + /// Returns the position of the beginning of the current word as delimited by white space and delimiters + /// This method differs from : + /// - When the cursor location is on the first character of a word, + /// returns the position of the previous word, whereas this method returns the cursor location. + /// - When the cursor location is in a word, both methods return the same result. + /// This method supports VI "iw" text object. + /// + public static int ViFindBeginningOfWordObjectBoundary(this StringBuilder buffer, int position, string wordDelimiters) + { + // Cursor may be past the end of the buffer when calling this method + // this may happen if the cursor is at the beginning of a new line. + var i = Math.Min(position, buffer.Length - 1); + Func isTextObjectChar; + + if (buffer.InWord(i, wordDelimiters)) + { + // If starting on a word consider a text object as a sequence of characters excluding the delimiters. + isTextObjectChar = c => Character.IsInWord(c, wordDelimiters); + } + else + { + // Otherwise, consider a word as a sequence of delimiters. + // For the purpose of this method, a newline (\n), tab (\t), or space is considered delimiter. + var delimiters = wordDelimiters + " \n\t"; + isTextObjectChar = c => delimiters.IndexOf(c) != -1; + } + + var beginning = i; + while (i >= 0 && isTextObjectChar(buffer[i])) + { + beginning = i--; + } + + return beginning; + } + + /// + /// Finds the position of the beginning of the next word object starting from the specified position. + /// If positioned on the last word in the buffer, returns buffer length + 1. + /// This method supports VI "iw" text-object. + /// iw: "inner word", select words. White space between words is counted too. + /// + public static int ViFindBeginningOfNextWordObjectBoundary(this StringBuilder buffer, int position, string wordDelimiters) + { + // Cursor may be past the end of the buffer when calling this method + // this may happen if the cursor is at the beginning of a new line. + var i = Math.Min(position, buffer.Length - 1); + Func isTextObjectChar; + + // Always skip the first newline character. + if (buffer[i] == '\n' && i < buffer.Length - 1) + { + ++i; + } + + if (buffer.InWord(i, wordDelimiters)) + { + // If starting on a word consider a text object as a sequence of characters excluding the delimiters. + isTextObjectChar = c => Character.IsInWord(c, wordDelimiters); + } + else + { + // Otherwise, consider a word as a sequence of delimiters. + // For the purpose of this method, a newline (\n), tab (\t), or space is considered delimiter. + var delimiters = wordDelimiters + " \n\t"; + isTextObjectChar = c => delimiters.IndexOf(c) != -1; + } + + // Try to skip a second newline characters to replicate vim behaviour. + if (buffer[i] == '\n' && i < buffer.Length - 1) + { + ++i; + } + + // Skip to next non-word characters. + while (i < buffer.Length && isTextObjectChar(buffer[i])) + { + ++i; + } + + // Make sure end includes the starting position. + return Math.Max(i, position); + } + } +} diff --git a/PSReadLine/TextObjects.Vi.cs b/PSReadLine/TextObjects.Vi.cs index 7fcb89de6..ea9810fbc 100644 --- a/PSReadLine/TextObjects.Vi.cs +++ b/PSReadLine/TextObjects.Vi.cs @@ -22,17 +22,10 @@ internal enum TextObjectSpan private TextObjectOperation _textObjectOperation = TextObjectOperation.None; private TextObjectSpan _textObjectSpan = TextObjectSpan.None; - private readonly IDictionary> _textObjectHandlers - = new Dictionary> - { - { - TextObjectOperation.Delete, - new Dictionary - { - {TextObjectSpan.Inner, MakeKeyHandler(ViDeleteInnerWord, "ViDeleteInnerWord")} - } - } - }; + private readonly Dictionary> _textObjectHandlers = new() + { + [TextObjectOperation.Delete] = new() { [TextObjectSpan.Inner] = MakeKeyHandler(ViDeleteInnerWord, "ViDeleteInnerWord") }, + }; private void ViChordDeleteTextObject(ConsoleKeyInfo? key = null, object arg = null) { @@ -50,8 +43,7 @@ private void ViChordTextObject(ConsoleKeyInfo? key = null, object arg = null) _textObjectSpan = GetRequestedTextObjectSpan(key.Value); - // handle text object - + // Handle text object var textObjectKey = ReadKey(); if (_viChordTextObjectsTable.TryGetValue(textObjectKey, out _)) { @@ -83,10 +75,8 @@ private TextObjectSpan GetRequestedTextObjectSpan(ConsoleKeyInfo key) private static void ViHandleTextObject(ConsoleKeyInfo? key = null, object arg = null) { - if ( - !_singleton._textObjectHandlers.TryGetValue(_singleton._textObjectOperation, out var textObjectHandler) || - !textObjectHandler.TryGetValue(_singleton._textObjectSpan, out var handler) - ) + if (!_singleton._textObjectHandlers.TryGetValue(_singleton._textObjectOperation, out var textObjectHandler) || + !textObjectHandler.TryGetValue(_singleton._textObjectSpan, out var handler)) { ResetTextObjectState(); Ding(); @@ -107,7 +97,9 @@ private static void ViDeleteInnerWord(ConsoleKeyInfo? key = null, object arg = n var delimiters = _singleton.Options.WordDelimiters; if (!TryGetArgAsInt(arg, out var numericArg, 1)) + { return; + } if (_singleton._buffer.Length == 0) { @@ -118,14 +110,11 @@ private static void ViDeleteInnerWord(ConsoleKeyInfo? key = null, object arg = n return; } - // unless at the end of the buffer a single delete word should not delete backwards - // so if the cursor is on an empty line, do nothing - - if ( - numericArg == 1 && + // Unless at the end of the buffer a single delete word should not delete backwards + // so if the cursor is on an empty line, do nothing. + if (numericArg == 1 && _singleton._current < _singleton._buffer.Length && - _singleton._buffer.IsLogigalLineEmpty(_singleton._current) - ) + _singleton._buffer.IsLogigalLineEmpty(_singleton._current)) { return; } @@ -133,17 +122,15 @@ private static void ViDeleteInnerWord(ConsoleKeyInfo? key = null, object arg = n var start = _singleton._buffer.ViFindBeginningOfWordObjectBoundary(_singleton._current, delimiters); var end = _singleton._current; - // attempting to find a valid position for multiple words - // if no valid position is found, this is a no-op - + // Attempting to find a valid position for multiple words. + // If no valid position is found, this is a no-op { while (numericArg-- > 0 && end < _singleton._buffer.Length) { end = _singleton._buffer.ViFindBeginningOfNextWordObjectBoundary(end, delimiters); } - // attempting to delete too many words should ding - + // Attempting to delete too many words should ding. if (numericArg > 0) { Ding(); @@ -168,8 +155,7 @@ private static void ViDeleteInnerWord(ConsoleKeyInfo? key = null, object arg = n /// private int AdjustCursorPosition(int position) { - // this method might prove useful in a more general case - + // This method might prove useful in a more general case. if (_buffer.Length == 0) { _current = 0; @@ -178,11 +164,9 @@ private int AdjustCursorPosition(int position) var maxPosition = _buffer[_buffer.Length - 1] == '\n' ? _buffer.Length - : _buffer.Length - 1 - ; + : _buffer.Length - 1; var newCurrent = Math.Min(position, maxPosition); - var beginning = GetBeginningOfLinePos(newCurrent); if (newCurrent < _buffer.Length && _buffer[newCurrent] == '\n' && (newCurrent + ViEndOfLineFactor > beginning)) @@ -191,7 +175,6 @@ private int AdjustCursorPosition(int position) } _current = newCurrent; - return newCurrent; } } diff --git a/PSReadLine/Words.cs b/PSReadLine/Words.cs index c314dba0a..7bdc34a88 100644 --- a/PSReadLine/Words.cs +++ b/PSReadLine/Words.cs @@ -93,11 +93,6 @@ private bool InWord(int index, string wordDelimiters) return _buffer.InWord(index, wordDelimiters); } - private bool InWord(char c, string wordDelimiters) - { - return Character.IsInWord(c, wordDelimiters); - } - /// /// Find the end of the current/next word as defined by wordDelimiters and whitespace. /// diff --git a/test/TextObjects.Vi.Tests.cs b/test/TextObjects.Vi.Tests.cs index 1392c16ee..f819b3879 100644 --- a/test/TextObjects.Vi.Tests.cs +++ b/test/TextObjects.Vi.Tests.cs @@ -173,4 +173,4 @@ public void ViTextObject_diw_new_lines() )); } } -} \ No newline at end of file +} From abb17a8093dd69835fe3adb68845cfe0d0b9ee8e Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Mon, 14 Aug 2023 12:00:01 -0700 Subject: [PATCH 8/9] Minor style changes --- PSReadLine/StringBuilderLinewiseExtensions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PSReadLine/StringBuilderLinewiseExtensions.cs b/PSReadLine/StringBuilderLinewiseExtensions.cs index 89b6fe42a..40320a97d 100644 --- a/PSReadLine/StringBuilderLinewiseExtensions.cs +++ b/PSReadLine/StringBuilderLinewiseExtensions.cs @@ -84,13 +84,13 @@ public static bool IsLogigalLineEmpty(this StringBuilder buffer, int cursor) // the cursor is on a logical line considered empty if... return // the entire buffer is empty (by definition), - buffer.Length == 0 + buffer.Length == 0 || // or the cursor sits at the start of the empty last line, // meaning that it is past the end of the buffer and the // last character in the buffer is a newline character, - || (cursor == buffer.Length && buffer[cursor - 1] == '\n') + (cursor == buffer.Length && buffer[cursor - 1] == '\n') || // or if the cursor is on a newline character. - || (cursor > 0 && buffer[cursor] == '\n'); + (cursor > 0 && buffer[cursor] == '\n'); } } From c6fb8e0785f2b0ba1321b3389f568c0610867827 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Mon, 14 Aug 2023 15:44:42 -0700 Subject: [PATCH 9/9] Revert some changes that caused test failure --- .../StringBuilderTextObjectExtensions.cs | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/PSReadLine/StringBuilderTextObjectExtensions.cs b/PSReadLine/StringBuilderTextObjectExtensions.cs index 5f10b1a8f..421ab3454 100644 --- a/PSReadLine/StringBuilderTextObjectExtensions.cs +++ b/PSReadLine/StringBuilderTextObjectExtensions.cs @@ -5,6 +5,8 @@ namespace Microsoft.PowerShell { internal static class StringBuilderTextObjectExtensions { + private const string WhiteSpace = " \n\t"; + /// /// Returns the position of the beginning of the current word as delimited by white space and delimiters /// This method differs from : @@ -18,21 +20,35 @@ public static int ViFindBeginningOfWordObjectBoundary(this StringBuilder buffer, // Cursor may be past the end of the buffer when calling this method // this may happen if the cursor is at the beginning of a new line. var i = Math.Min(position, buffer.Length - 1); - Func isTextObjectChar; - if (buffer.InWord(i, wordDelimiters)) + // If starting on a word consider a text object as a sequence of characters excluding the delimiters, + // otherwise, consider a word as a sequence of delimiters. + var delimiters = wordDelimiters; + var isInWord = buffer.InWord(i, wordDelimiters); + + if (isInWord) { - // If starting on a word consider a text object as a sequence of characters excluding the delimiters. - isTextObjectChar = c => Character.IsInWord(c, wordDelimiters); + // For the purpose of this method, whitespace character is considered a delimiter. + delimiters += WhiteSpace; } else { - // Otherwise, consider a word as a sequence of delimiters. - // For the purpose of this method, a newline (\n), tab (\t), or space is considered delimiter. - var delimiters = wordDelimiters + " \n\t"; - isTextObjectChar = c => delimiters.IndexOf(c) != -1; + char c = buffer[i]; + if ((wordDelimiters + '\n').IndexOf(c) == -1 && char.IsWhiteSpace(c)) + { + // Current position points to a whitespace that is not a newline. + delimiters = WhiteSpace; + } + else + { + delimiters += '\n'; + } } + var isTextObjectChar = isInWord + ? (Func)(c => delimiters.IndexOf(c) == -1) + : c => delimiters.IndexOf(c) != -1; + var beginning = i; while (i >= 0 && isTextObjectChar(buffer[i])) { @@ -53,7 +69,6 @@ public static int ViFindBeginningOfNextWordObjectBoundary(this StringBuilder buf // Cursor may be past the end of the buffer when calling this method // this may happen if the cursor is at the beginning of a new line. var i = Math.Min(position, buffer.Length - 1); - Func isTextObjectChar; // Always skip the first newline character. if (buffer[i] == '\n' && i < buffer.Length - 1) @@ -61,19 +76,24 @@ public static int ViFindBeginningOfNextWordObjectBoundary(this StringBuilder buf ++i; } - if (buffer.InWord(i, wordDelimiters)) + // If starting on a word consider a text object as a sequence of characters excluding the delimiters, + // otherwise, consider a word as a sequence of delimiters. + var delimiters = wordDelimiters; + var isInWord = buffer.InWord(i, wordDelimiters); + + if (isInWord) { - // If starting on a word consider a text object as a sequence of characters excluding the delimiters. - isTextObjectChar = c => Character.IsInWord(c, wordDelimiters); + delimiters += WhiteSpace; } - else + else if (char.IsWhiteSpace(buffer[i])) { - // Otherwise, consider a word as a sequence of delimiters. - // For the purpose of this method, a newline (\n), tab (\t), or space is considered delimiter. - var delimiters = wordDelimiters + " \n\t"; - isTextObjectChar = c => delimiters.IndexOf(c) != -1; + delimiters = " \t"; } + var isTextObjectChar = isInWord + ? (Func)(c => delimiters.IndexOf(c) == -1) + : c => delimiters.IndexOf(c) != -1; + // Try to skip a second newline characters to replicate vim behaviour. if (buffer[i] == '\n' && i < buffer.Length - 1) {