From d7b9f82ba07f94d2e3ec008c8be6a00322177364 Mon Sep 17 00:00:00 2001 From: Maxime Labelle Date: Tue, 15 Aug 2023 00:56:46 +0200 Subject: [PATCH] [vi-mode] Supports the text-object command `diw` (#2059) --- PSReadLine/Cmdlets.cs | 2 +- PSReadLine/KeyBindings.vi.cs | 8 + PSReadLine/Position.cs | 13 +- PSReadLine/Prediction.Views.cs | 6 +- .../StringBuilderCharacterExtensions.cs | 78 ++++++++ ....cs => StringBuilderLinewiseExtensions.cs} | 20 ++ .../StringBuilderTextObjectExtensions.cs | 113 +++++++++++ PSReadLine/TextObjects.Vi.cs | 181 ++++++++++++++++++ PSReadLine/Words.cs | 8 +- PSReadLine/Words.vi.cs | 7 +- test/StringBuilderCharacterExtensionsTests.cs | 46 +++++ .../StringBuilderTextObjectExtensionsTests.cs | 77 ++++++++ test/TextObjects.Vi.Tests.cs | 176 +++++++++++++++++ 13 files changed, 709 insertions(+), 26 deletions(-) create mode 100644 PSReadLine/StringBuilderCharacterExtensions.cs rename PSReadLine/{StringBuilderExtensions.cs => StringBuilderLinewiseExtensions.cs} (72%) create mode 100644 PSReadLine/StringBuilderTextObjectExtensions.cs create mode 100644 PSReadLine/TextObjects.Vi.cs create mode 100644 test/StringBuilderCharacterExtensionsTests.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/Position.cs b/PSReadLine/Position.cs index 32068da91..2aa32039c 100644 --- a/PSReadLine/Position.cs +++ b/PSReadLine/Position.cs @@ -102,23 +102,14 @@ private static int GetFirstNonBlankOfLogicalLinePos(int current) var beginningOfLine = GetBeginningOfLinePos(current); var newCurrent = beginningOfLine; + var buffer = _singleton._buffer; - while (newCurrent < _singleton._buffer.Length && IsVisibleBlank(newCurrent)) + while (newCurrent < buffer.Length && 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..ab3faaea3 --- /dev/null +++ b/PSReadLine/StringBuilderCharacterExtensions.cs @@ -0,0 +1,78 @@ +using System.Text; + +namespace Microsoft.PowerShell +{ + internal static class StringBuilderCharacterExtensions + { + /// + /// 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 + /// 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. + /// + /// + /// + /// + 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 72% rename from PSReadLine/StringBuilderExtensions.cs rename to PSReadLine/StringBuilderLinewiseExtensions.cs index 08deef333..40320a97d 100644 --- a/PSReadLine/StringBuilderExtensions.cs +++ b/PSReadLine/StringBuilderLinewiseExtensions.cs @@ -72,6 +72,26 @@ 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) + { + // the cursor is on a logical line considered empty if... + return + // 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, + (cursor == buffer.Length && buffer[cursor - 1] == '\n') || + // or if the cursor is on a newline character. + (cursor > 0 && buffer[cursor] == '\n'); + } } internal static class StringBuilderPredictionExtensions diff --git a/PSReadLine/StringBuilderTextObjectExtensions.cs b/PSReadLine/StringBuilderTextObjectExtensions.cs new file mode 100644 index 000000000..421ab3454 --- /dev/null +++ b/PSReadLine/StringBuilderTextObjectExtensions.cs @@ -0,0 +1,113 @@ +using System; +using System.Text; + +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 : + /// - 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; + var isInWord = buffer.InWord(i, wordDelimiters); + + if (isInWord) + { + // For the purpose of this method, whitespace character is considered a delimiter. + delimiters += WhiteSpace; + } + else + { + 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])) + { + 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) + { + ++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; + var isInWord = buffer.InWord(i, wordDelimiters); + + if (isInWord) + { + delimiters += WhiteSpace; + } + else if (char.IsWhiteSpace(buffer[i])) + { + 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) + { + ++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..ea9810fbc --- /dev/null +++ b/PSReadLine/TextObjects.Vi.cs @@ -0,0 +1,181 @@ +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 Dictionary> _textObjectHandlers = new() + { + [TextObjectOperation.Delete] = new() { [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.RemoveTextToViRegister(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/PSReadLine/Words.cs b/PSReadLine/Words.cs index 5c4c09f67..7bdc34a88 100644 --- a/PSReadLine/Words.cs +++ b/PSReadLine/Words.cs @@ -90,13 +90,7 @@ private Token FindToken(int current, FindTokenMode mode) private bool InWord(int index, string wordDelimiters) { - char c = _buffer[index]; - return InWord(c, wordDelimiters); - } - - private bool InWord(char c, string wordDelimiters) - { - return !char.IsWhiteSpace(c) && wordDelimiters.IndexOf(c) < 0; + return _buffer.InWord(index, 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/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)); + } + } +} 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..f819b3879 --- /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\"")) + )); + } + } +}