From 0d9767bc77ef728de19538f16dc235d576bf4782 Mon Sep 17 00:00:00 2001 From: Maxime Labelle Date: Wed, 23 Aug 2023 22:54:50 +0200 Subject: [PATCH 1/2] Renamed test file --- test/{TextObjects.Vi.Tests.cs => TextObjects.Vi.diword.Tests.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/{TextObjects.Vi.Tests.cs => TextObjects.Vi.diword.Tests.cs} (100%) diff --git a/test/TextObjects.Vi.Tests.cs b/test/TextObjects.Vi.diword.Tests.cs similarity index 100% rename from test/TextObjects.Vi.Tests.cs rename to test/TextObjects.Vi.diword.Tests.cs From 249538227e9dfbfe3415140c257898a78a0aeb03 Mon Sep 17 00:00:00 2001 From: Maxime Labelle Date: Thu, 24 Aug 2023 01:00:11 +0200 Subject: [PATCH 2/2] Supports di' and di" text objects --- PSReadLine/KeyBindings.vi.cs | 2 + PSReadLine/Position.cs | 30 +--- .../StringBuilderTextObjectExtensions.cs | 157 ++++++++++++++++++ PSReadLine/TextObjects.Vi.cs | 56 ++++++- .../StringBuilderTextObjectExtensionsTests.cs | 46 +++++ test/TextObjects.Vi.diquotes.Tests.cs | 89 ++++++++++ 6 files changed, 347 insertions(+), 33 deletions(-) create mode 100644 test/TextObjects.Vi.diquotes.Tests.cs diff --git a/PSReadLine/KeyBindings.vi.cs b/PSReadLine/KeyBindings.vi.cs index 5a47c6608..7af4a4fb8 100644 --- a/PSReadLine/KeyBindings.vi.cs +++ b/PSReadLine/KeyBindings.vi.cs @@ -301,6 +301,8 @@ private void SetDefaultViBindings() _viChordTextObjectsTable = new Dictionary { + { Keys.DQuote, MakeKeyHandler(ViHandleTextObject, "QuoteTextObject")}, + { Keys.SQuote, MakeKeyHandler(ViHandleTextObject, "QuoteTextObject")}, { Keys.W, MakeKeyHandler(ViHandleTextObject, "WordTextObject")}, }; diff --git a/PSReadLine/Position.cs b/PSReadLine/Position.cs index 2aa32039c..e4f492f49 100644 --- a/PSReadLine/Position.cs +++ b/PSReadLine/Position.cs @@ -10,19 +10,7 @@ public partial class PSConsoleReadLine /// /// The position in the current logical line. private static int GetBeginningOfLinePos(int current) - { - int i = Math.Max(0, current); - while (i > 0) - { - if (_singleton._buffer[--i] == '\n') - { - i += 1; - break; - } - } - - return i; - } + => _singleton._buffer.GetBeginningOfLogicalLinePos(current); /// /// Returns the position of the beginning of line @@ -66,21 +54,7 @@ private static int GetBeginningOfNthLinePos(int lineIndex) /// /// private static int GetEndOfLogicalLinePos(int current) - { - var newCurrent = current; - - for (var position = current; position < _singleton._buffer.Length; position++) - { - if (_singleton._buffer[position] == '\n') - { - break; - } - - newCurrent = position; - } - - return newCurrent; - } + => _singleton._buffer.GetEndOfLogicalLinePos(current); /// /// Returns the position of the end of the logical line diff --git a/PSReadLine/StringBuilderTextObjectExtensions.cs b/PSReadLine/StringBuilderTextObjectExtensions.cs index 421ab3454..00f82eb4f 100644 --- a/PSReadLine/StringBuilderTextObjectExtensions.cs +++ b/PSReadLine/StringBuilderTextObjectExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Management.Automation; using System.Text; namespace Microsoft.PowerShell @@ -109,5 +110,161 @@ public static int ViFindBeginningOfNextWordObjectBoundary(this StringBuilder buf // Make sure end includes the starting position. return Math.Max(i, position); } + + /// + /// Returns the span of text within the quotes relative to the specified position, in the corresponding logical line. + /// If the position refers to the given start delimiter, the method returns the position immediately. + /// If not, it first attempts to look backwards to find the start delimiter and returns its position if found. + /// Otherwise, it look forwards to find the start delimiter and returns its position if found. + /// Otherwise, it returns (-1, -1). + /// + /// If a start delimiter is found, this method then attempts to find the end delimiter within the logical line. + /// Otherwise, it returns (-1, -1). + /// + /// This method supports VI i' and i" text objects. + /// + public static (int Start, int End) ViFindSpanOfInnerQuotedTextObjectBoundary(this StringBuilder buffer, char delimiter, int position, int repeated = 1) + { + // 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 pos = Math.Min(position, buffer.Length - 1); + + // restrict this method to the logical line + // corresponding to the given position + + var startOfLine = buffer.GetBeginningOfLogicalLinePos(pos); + var endOfLine = buffer.GetEndOfLogicalLinePos(pos); + + var start = -1; + var end = -1; + + // if on a quote we may be on a beginning or end quote + // we need to parse the line to find out + + if (buffer[pos] == delimiter) + { + var count = 1; + for (var offset = pos - 1; offset > startOfLine; offset--) + { + if (buffer[offset] == delimiter) + count++; + } + + // if there are an odd number of quotes up to the current position + // the position refers to the beginning a quoted text + + if (count % 2 == 1) + { + start = pos; + } + } + + // else look backwards + + if (start == -1) + { + for (var offset = pos - 1; offset > startOfLine; offset--) + { + if (buffer[offset] == delimiter) + { + start = offset; + break; + } + } + } + + // if not found, look forwards + + if (start == -1) + { + for (var offset = pos; offset < endOfLine; offset++) + { + if (buffer[offset] == delimiter) + { + start = offset; + break; + } + } + } + + // attempts to find the end quote + + if (start != -1 && start < endOfLine) + { + for (var offset = start + 1; offset < buffer.Length; offset++) + { + if (buffer[offset] == delimiter) + { + end = offset; + break; + } + if (buffer[offset] == '\n') + { + break; + } + } + } + + // adjust span boundaries based upon + // the number of repeatitions + + if (start != -1 && end != -1) + { + if (repeated > 1) + { + end++; + } + else + { + start++; + } + } + + return (start, end); + } + + /// + /// Returns the position of the beginning of line + /// starting from the specified "current" position. + /// + /// The position in the current logical line. + internal static int GetBeginningOfLogicalLinePos(this StringBuilder buffer, int current) + { + int i = Math.Max(0, current); + while (i > 0) + { + if (buffer[--i] == '\n') + { + i += 1; + break; + } + } + + return i; + } + + /// + /// Returns the position of the end of the logical line + /// as specified by the "current" position. + /// + /// + /// + internal static int GetEndOfLogicalLinePos(this StringBuilder buffer, int current) + { + var newCurrent = current; + + for (var position = current; position < buffer.Length; position++) + { + if (buffer[position] == '\n') + { + break; + } + + newCurrent = position; + } + + return newCurrent; + } } } diff --git a/PSReadLine/TextObjects.Vi.cs b/PSReadLine/TextObjects.Vi.cs index ea9810fbc..b23c75380 100644 --- a/PSReadLine/TextObjects.Vi.cs +++ b/PSReadLine/TextObjects.Vi.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; - +using System.Runtime.CompilerServices; + namespace Microsoft.PowerShell { public partial class PSConsoleReadLine @@ -22,9 +23,17 @@ internal enum TextObjectSpan private TextObjectOperation _textObjectOperation = TextObjectOperation.None; private TextObjectSpan _textObjectSpan = TextObjectSpan.None; - private readonly Dictionary> _textObjectHandlers = new() + private readonly Dictionary>> _textObjectHandlers = new() { - [TextObjectOperation.Delete] = new() { [TextObjectSpan.Inner] = MakeKeyHandler(ViDeleteInnerWord, "ViDeleteInnerWord") }, + [TextObjectOperation.Delete] = new() + { + [TextObjectSpan.Inner] = new() + { + [Keys.DQuote] = MakeKeyHandler(ViDeleteInnerDQuote, "ViDeleteInnerDQuote"), + [Keys.SQuote] = MakeKeyHandler(ViDeleteInnerSQuote, "ViDeleteInnerSQuote"), + [Keys.W] = MakeKeyHandler(ViDeleteInnerWord, "ViDeleteInnerWord"), + } + }, }; private void ViChordDeleteTextObject(ConsoleKeyInfo? key = null, object arg = null) @@ -75,8 +84,12 @@ 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)) + System.Diagnostics.Debug.Assert(key != null); + var keyInfo = PSKeyInfo.FromConsoleKeyInfo(key.Value); + + if (!_singleton._textObjectHandlers.TryGetValue(_singleton._textObjectOperation, out var textObjectSpanHandlers) || + !textObjectSpanHandlers.TryGetValue(_singleton._textObjectSpan, out var textObjectKeyHandlers) || + !textObjectKeyHandlers.TryGetValue(keyInfo, out var handler)) { ResetTextObjectState(); Ding(); @@ -92,6 +105,39 @@ private static void ResetTextObjectState() _singleton._textObjectSpan = TextObjectSpan.None; } + private static void ViDeleteInnerSQuote(ConsoleKeyInfo? key = null, object arg = null) + => ViDeleteInnerQuotes('\'', key, arg); + private static void ViDeleteInnerDQuote(ConsoleKeyInfo? key = null, object arg = null) + => ViDeleteInnerQuotes('\"', key, arg); + + private static void ViDeleteInnerQuotes(char delimiter, ConsoleKeyInfo? key = null, object arg = null) + { + if (!TryGetArgAsInt(arg, out var numericArg, 1)) + { + return; + } + + if (_singleton._buffer.Length == 0) + { + Ding(); + return; + } + + var (start, end) = _singleton._buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, _singleton._current, repeated: numericArg); + + if (start == -1 || end == -1) + { + Ding(); + return; + } + + var position = start; + + _singleton.RemoveTextToViRegister(position, end - position); + _singleton.AdjustCursorPosition(position); + _singleton.Render(); + } + private static void ViDeleteInnerWord(ConsoleKeyInfo? key = null, object arg = null) { var delimiters = _singleton.Options.WordDelimiters; diff --git a/test/StringBuilderTextObjectExtensionsTests.cs b/test/StringBuilderTextObjectExtensionsTests.cs index 66bd590de..d7ec0dc67 100644 --- a/test/StringBuilderTextObjectExtensionsTests.cs +++ b/test/StringBuilderTextObjectExtensionsTests.cs @@ -73,5 +73,51 @@ public void StringBuilderTextObjectExtensions_ViFindBeginningOfNextWordObjectBou Assert.Equal(46, buffer.ViFindBeginningOfNextWordObjectBoundary(45, wordDelimiters)); Assert.Equal(50, buffer.ViFindBeginningOfNextWordObjectBoundary(46, wordDelimiters)); } + + [Theory] + [InlineData('\'')] + [InlineData('\"')] + public void StringBuilderTextObjectExtensions_ViFindSpanOfInnerQuotedTextObjectBoundary(char delimiter) + { + var buffer = new StringBuilder($"_{delimiter}_{delimiter} {delimiter}_{delimiter} {delimiter}_{delimiter}"); + + // text: _"_" "_" "_" + // position: 012345678901 + // - 1 + // boundary: 111135557888 + + // when invoked once, the span is within the quotes + + Assert.Equal((2, 3), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 0, repeated: 1)); + Assert.Equal((2, 3), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 1, repeated: 1)); + Assert.Equal((2, 3), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 2, repeated: 1)); + Assert.Equal((2, 3), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 3, repeated: 1)); + Assert.Equal((4, 5), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 4, repeated: 1)); + Assert.Equal((6, 7), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 5, repeated: 1)); + Assert.Equal((6, 7), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 6, repeated: 1)); + Assert.Equal((6, 7), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 7, repeated: 1)); + Assert.Equal((8, 9), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 8, repeated: 1)); + Assert.Equal((10, 11), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 9, repeated: 1)); + Assert.Equal((10, 11), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 10, repeated: 1)); + Assert.Equal((10, 11), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 11, repeated: 1)); + Assert.Equal((10, 11), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 12, repeated: 1)); + + // when invoked more than once, the span is around the quotes + + Assert.Equal((1, 4), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 0, repeated: 42)); + Assert.Equal((1, 4), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 1, repeated: 42)); + Assert.Equal((1, 4), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 2, repeated: 42)); + Assert.Equal((1, 4), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 3, repeated: 42)); + Assert.Equal((3, 6), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 4, repeated: 42)); + Assert.Equal((5, 8), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 5, repeated: 42)); + Assert.Equal((5, 8), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 6, repeated: 42)); + Assert.Equal((5, 8), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 7, repeated: 42)); + Assert.Equal((7, 10), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 8, repeated: 42)); + Assert.Equal((9, 12), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 9, repeated: 42)); + Assert.Equal((9, 12), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 10, repeated: 42)); + Assert.Equal((9, 12), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 11, repeated: 42)); + Assert.Equal((9, 12), buffer.ViFindSpanOfInnerQuotedTextObjectBoundary(delimiter, 12, repeated: 42)); + + } } } diff --git a/test/TextObjects.Vi.diquotes.Tests.cs b/test/TextObjects.Vi.diquotes.Tests.cs new file mode 100644 index 000000000..b413ad54e --- /dev/null +++ b/test/TextObjects.Vi.diquotes.Tests.cs @@ -0,0 +1,89 @@ +using Xunit; + +namespace Test +{ + public partial class ReadLine + { + [SkippableTheory] + [InlineData("a 'quoted' text", "di'", "a '' text")] + [InlineData("a \"quoted\" text", "di\"", "a \"\" text")] + public void ViTextObject_diquotes(string input, string motion, string expected) + { + TestSetup(KeyMode.Vi); + + Test(expected, Keys( + input, _.Escape, + "Fo", // move backwards to the letter 'o' inside the quotes + + // delete text object (delete inner quotes) + // this will find the surrounding quotes at the position + + motion, + + CheckThat(() => AssertCursorLeftIs(3)) + )); + + Test(expected, Keys( + input, _.Escape, + "0", // move to beginning of logical line + + // delete text object (delete inner quotes) + // this will look forward and find the quoted text + + motion, + + CheckThat(() => AssertCursorLeftIs(3)) + )); + } + + [SkippableTheory] + [InlineData("a 'quoted' text", "42di'", "a text")] + [InlineData("a \"quoted\" text", "42di\"", "a text")] + public void ViTextObject_diquotes_argument(string input, string motion, string expected) + { + TestSetup(KeyMode.Vi); + + Test(expected, Keys( + input, _.Escape, + "Fo", // move backwards to the letter 'o' inside the quotes + + // delete text object (delete inner quotes) + // no matter how many times, we only care about more than once or not + + motion, + CheckThat(() => AssertCursorLeftIs(2)) + )); + } + + [SkippableTheory] + [InlineData("an 'incorrectly quoted text", "di'", "A'", "an 'incorrectly quoted text'")] + [InlineData("an \"incorrectly quoted text", "di\"", "A\"", "an \"incorrectly quoted text\"")] + public void ViTextObject_diquotes_noop(string input, string motion, string fix, string expected) + { + TestSetup(KeyMode.Vi); + + TestMustDing("a text", Keys( + "a text", _.Escape, + + // attempt to delete non-existent text object must ding + // no matter how many times, we only care about more than once or not + + motion + )); + + TestMustDing(expected, Keys( + input, _.Escape, + + // even though there is a starting delimiter, + // the motion cannot find an ending delimiter + + motion, + + // we must make the input a valid line + // move to the end of the buffer and add the missing quote + + fix + )); + } + } +}