Files
PowerShell-PSReadLine/PSReadLine/KillYank.cs
T

739 lines
28 KiB
C#

/********************************************************************++
Copyright (c) Microsoft Corporation. All rights reserved.
--********************************************************************/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Management.Automation.Language;
using System.Text.RegularExpressions;
using Microsoft.PowerShell.Internal;
namespace Microsoft.PowerShell
{
public partial class PSConsoleReadLine
{
// Yank/Kill state
private List<string> _killRing;
private int _killIndex;
private int _killCommandCount;
private int _yankCommandCount;
private int _yankStartPoint;
private int _yankLastArgCommandCount;
class YankLastArgState
{
internal int argument;
internal int historyIndex;
internal int historyIncrement;
internal int startPoint = -1;
}
private YankLastArgState _yankLastArgState;
private int _visualSelectionCommandCount;
// Pattern to check for CLI parameters like '--json'.
// Valid characters are 'a-z', 'A-Z', '0-9', '_' (all covered by '\w'), and '-'.
private static readonly Regex s_cliOptionPattern = new(@"^--[\w-]+$", RegexOptions.Compiled);
/// <summary>
/// Mark the current location of the cursor for use in a subsequent editing command.
/// </summary>
public static void SetMark(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton._mark = _singleton._current;
}
/// <summary>
/// The cursor is placed at the location of the mark and the mark is moved
/// to the location of the cursor.
/// </summary>
public static void ExchangePointAndMark(ConsoleKeyInfo? key = null, object arg = null)
{
var tmp = _singleton._mark;
_singleton._mark = _singleton._current;
_singleton.MoveCursor(Math.Min(tmp, _singleton._buffer.Length));
}
/// <summary>
/// The contents of the kill ring are cleared.
/// </summary>
public static void ClearKillRing()
{
_singleton._killRing?.Clear();
_singleton._killIndex = -1; // So first add indexes 0.
}
private void Kill(int start, int length, bool prepend)
{
if (length <= 0)
{
// if we're already in the middle of some kills,
// change _killCommandCount so it isn't zeroed out.
// If, OTOH, _killCommandCount was 0 to begin with,
// we won't append to something we're not supposed to.
if (_killCommandCount > 0)
_killCommandCount++;
return;
}
var killText = _buffer.ToString(start, length);
SaveEditItem(EditItemDelete.Create(killText, start));
_buffer.Remove(start, length);
_current = start;
Render();
if (_killCommandCount > 0)
{
if (prepend)
{
_killRing[_killIndex] = killText + _killRing[_killIndex];
}
else
{
_killRing[_killIndex] += killText;
}
}
else
{
if (_killRing.Count < Options.MaximumKillRingCount)
{
_killRing.Add(killText);
_killIndex = _killRing.Count - 1;
}
else
{
_killIndex += 1;
if (_killIndex == _killRing.Count)
{
_killIndex = 0;
}
_killRing[_killIndex] = killText;
}
}
_killCommandCount += 1;
}
/// <summary>
/// Clear the input from the cursor to the end of the input. The cleared text is placed
/// in the kill ring.
/// </summary>
public static void KillLine(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.Kill(_singleton._current, _singleton._buffer.Length - _singleton._current, false);
}
/// <summary>
/// Clear the input from the start of the input to the cursor. The cleared text is placed
/// in the kill ring.
/// </summary>
public static void BackwardKillInput(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.Kill(0, _singleton._current, true);
}
/// <summary>
/// Clear the input from the start of the current logical line to the cursor. The cleared text is placed
/// in the kill ring.
/// </summary>
public static void BackwardKillLine(ConsoleKeyInfo? key = null, object arg = null)
{
var start = GetBeginningOfLinePos(_singleton._current);
_singleton.Kill(start, _singleton._current, true);
}
/// <summary>
/// Clear the input from the cursor to the end of the current word. If the cursor
/// is between words, the input is cleared from the cursor to the end of the next word.
/// The cleared text is placed in the kill ring.
/// </summary>
public static void KillWord(ConsoleKeyInfo? key = null, object arg = null)
{
int i = _singleton.FindForwardWordPoint(_singleton.Options.WordDelimiters);
_singleton.Kill(_singleton._current, i - _singleton._current, false);
}
/// <summary>
/// Clear the input from the cursor to the end of the current word. If the cursor
/// is between words, the input is cleared from the cursor to the end of the next word.
/// The cleared text is placed in the kill ring.
/// </summary>
public static void ShellKillWord(ConsoleKeyInfo? key = null, object arg = null)
{
var token = _singleton.FindToken(_singleton._current, FindTokenMode.CurrentOrNext);
var end = (token.Kind == TokenKind.EndOfInput)
? _singleton._buffer.Length
: token.Extent.EndOffset;
_singleton.Kill(_singleton._current, end - _singleton._current, false);
}
/// <summary>
/// Clear the input from the start of the current word to the cursor. If the cursor
/// is between words, the input is cleared from the start of the previous word to the
/// cursor. The cleared text is placed in the kill ring.
/// </summary>
public static void BackwardKillWord(ConsoleKeyInfo? key = null, object arg = null)
{
int i = _singleton.FindBackwardWordPoint(_singleton.Options.WordDelimiters);
_singleton.Kill(i, _singleton._current - i, true);
}
/// <summary>
/// Clear the input from the start of the current word to the cursor. If the cursor
/// is between words, the input is cleared from the start of the previous word to the
/// cursor. The cleared text is placed in the kill ring.
/// </summary>
public static void UnixWordRubout(ConsoleKeyInfo? key = null, object arg = null)
{
int i = _singleton.FindBackwardWordPoint("");
_singleton.Kill(i, _singleton._current - i, true);
}
/// <summary>
/// Clear the input from the start of the current word to the cursor. If the cursor
/// is between words, the input is cleared from the start of the previous word to the
/// cursor. The cleared text is placed in the kill ring.
/// </summary>
public static void ShellBackwardKillWord(ConsoleKeyInfo? key = null, object arg = null)
{
var token = _singleton.FindToken(_singleton._current, FindTokenMode.Previous);
var start = token?.Extent.StartOffset ?? 0;
_singleton.Kill(start, _singleton._current - start, true);
}
/// <summary>
/// Kill the text between the cursor and the mark.
/// </summary>
public static void KillRegion(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.GetRegion(out var start, out var length);
_singleton.Kill(start, length, true);
}
private void YankImpl()
{
if (_killRing.Count == 0)
return;
// Starting a yank session, yank the last thing killed and
// remember where we started.
_mark = _yankStartPoint = _current;
Insert(_killRing[_killIndex]);
_yankCommandCount += 1;
}
/// <summary>
/// Add the most recently killed text to the input.
/// </summary>
public static void Yank(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.YankImpl();
}
private void YankPopImpl()
{
if (_yankCommandCount == 0)
return;
_killIndex -= 1;
if (_killIndex < 0)
{
_killIndex = _killRing.Count - 1;
}
var yankText = _killRing[_killIndex];
Replace(_yankStartPoint, _current - _yankStartPoint, yankText);
_yankCommandCount += 1;
}
/// <summary>
/// If the previous operation was Yank or YankPop, replace the previously yanked
/// text with the next killed text from the kill ring.
/// </summary>
public static void YankPop(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.YankPopImpl();
}
void YankArgImpl(YankLastArgState yankLastArgState)
{
if (yankLastArgState.historyIndex < 0 || yankLastArgState.historyIndex >= _history.Count)
{
Ding();
return;
}
var buffer = _history[yankLastArgState.historyIndex];
Parser.ParseInput(buffer.CommandLine, out var tokens, out var unused);
int arg = (yankLastArgState.argument < 0)
? tokens.Length + yankLastArgState.argument - 1
: yankLastArgState.argument;
if (arg < 0 || arg >= tokens.Length)
{
Ding();
return;
}
var argText = tokens[arg].Text;
if (yankLastArgState.startPoint < 0)
{
yankLastArgState.startPoint = _current;
Insert(argText);
}
else
{
Replace(yankLastArgState.startPoint, _current - yankLastArgState.startPoint, argText);
}
}
/// <summary>
/// Yank the first argument (after the command) from the previous history line.
/// With an argument, yank the nth argument (starting from 0), if the argument
/// is negative, start from the last argument.
/// </summary>
public static void YankNthArg(ConsoleKeyInfo? key = null, object arg = null)
{
var yankLastArgState = new YankLastArgState
{
argument = arg as int? ?? 1,
historyIndex = _singleton._currentHistoryIndex - 1,
};
_singleton.YankArgImpl(yankLastArgState);
}
/// <summary>
/// Yank the last argument from the previous history line. With an argument,
/// the first time it is invoked, behaves just like YankNthArg. If invoked
/// multiple times, instead it iterates through history and arg sets the direction
/// (negative reverses the direction.)
/// </summary>
public static void YankLastArg(ConsoleKeyInfo? key = null, object arg = null)
{
if (arg != null && !(arg is int))
{
Ding();
return;
}
_singleton._yankLastArgCommandCount += 1;
if (_singleton._yankLastArgCommandCount == 1)
{
_singleton._yankLastArgState = new YankLastArgState
{
argument = (int?) arg ?? -1,
historyIncrement = -1,
historyIndex = _singleton._currentHistoryIndex - 1
};
_singleton.YankArgImpl(_singleton._yankLastArgState);
return;
}
var yankLastArgState = _singleton._yankLastArgState;
if ((int?) arg < 0)
{
yankLastArgState.historyIncrement = -yankLastArgState.historyIncrement;
}
yankLastArgState.historyIndex += yankLastArgState.historyIncrement;
// Don't increment more than 1 out of range so it's quick to get back to being in range.
if (yankLastArgState.historyIndex < 0)
{
Ding();
yankLastArgState.historyIndex = 0;
}
else if (yankLastArgState.historyIndex >= _singleton._history.Count)
{
Ding();
yankLastArgState.historyIndex = _singleton._history.Count - 1;
}
else
{
_singleton.YankArgImpl(yankLastArgState);
}
}
private void VisualSelectionCommon(Action action, bool forceSetMark = false)
{
if (_singleton._visualSelectionCommandCount == 0 || forceSetMark)
{
SetMark();
}
_singleton._visualSelectionCommandCount += 1;
action();
_singleton.RenderWithPredictionQueryPaused();
}
/// <summary>
/// Adjust the current selection to include the previous character.
/// </summary>
public static void SelectBackwardChar(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.VisualSelectionCommon(() => BackwardChar(key, arg));
}
/// <summary>
/// Adjust the current selection to include the next character.
/// </summary>
public static void SelectForwardChar(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.VisualSelectionCommon(() => ForwardChar(key, arg));
}
/// <summary>
/// Adjust the current selection to include the previous word.
/// </summary>
public static void SelectBackwardWord(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.VisualSelectionCommon(() => BackwardWord(key, arg));
}
/// <summary>
/// Adjust the current selection to include the next word.
/// </summary>
public static void SelectNextWord(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.VisualSelectionCommon(() => NextWord(key, arg));
}
/// <summary>
/// Adjust the current selection to include the next word using ForwardWord.
/// </summary>
public static void SelectForwardWord(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.VisualSelectionCommon(() => ForwardWord(key, arg));
}
/// <summary>
/// Adjust the current selection to include the next word using ShellForwardWord.
/// </summary>
public static void SelectShellForwardWord(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.VisualSelectionCommon(() => ShellForwardWord(key, arg));
}
/// <summary>
/// Adjust the current selection to include the next word using ShellNextWord.
/// </summary>
public static void SelectShellNextWord(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.VisualSelectionCommon(() => ShellNextWord(key, arg));
}
/// <summary>
/// Adjust the current selection to include the previous word using ShellBackwardWord.
/// </summary>
public static void SelectShellBackwardWord(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.VisualSelectionCommon(() => ShellBackwardWord(key, arg));
}
/// <summary>
/// Select the entire line.
/// </summary>
public static void SelectAll(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton._visualSelectionCommandCount += 1;
_singleton._mark = 0;
_singleton._current = _singleton._buffer.Length;
_singleton.RenderWithPredictionQueryPaused();
}
/// <summary>
/// Adjust the current selection to include from the cursor to the end of the line.
/// </summary>
public static void SelectLine(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.VisualSelectionCommon(() => EndOfLine(key, arg));
}
/// <summary>
/// Adjust the current selection to include from the cursor to the start of the line.
/// </summary>
public static void SelectBackwardsLine(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.VisualSelectionCommon(() => BeginningOfLine(key, arg));
}
/// <summary>
/// Select the command argument that the cursor is at, or the previous/next Nth command arguments from the current cursor position.
/// </summary>
public static void SelectCommandArgument(ConsoleKeyInfo? key = null, object arg = null)
{
if (!TryGetArgAsInt(arg, out var numericArg, 0))
{
return;
}
_singleton.MaybeParseInput();
int cursor = _singleton._current;
int prev = -1, curr = -1, next = -1;
var sbAsts = _singleton._ast.FindAll(GetScriptBlockAst, searchNestedScriptBlocks: true).ToList();
var arguments = new List<ExpressionAst>();
// We start searching for command arguments from the most nested script block.
for (int i = sbAsts.Count - 1; i >= 0; i --)
{
var sbAst = sbAsts[i];
var cmdAsts = sbAst.FindAll(ast => ast is CommandAst, searchNestedScriptBlocks: false);
foreach (CommandAst cmdAst in cmdAsts)
{
for (int j = 1; j < cmdAst.CommandElements.Count; j++)
{
var argument = cmdAst.CommandElements[j] switch
{
CommandParameterAst paramAst => paramAst.Argument,
ExpressionAst exprAst => ProcessExpressionAst(exprAst),
_ => null,
};
if (argument is not null)
{
arguments.Add(argument);
int start = argument.Extent.StartOffset;
int end = argument.Extent.EndOffset;
if (end <= cursor)
{
prev = arguments.Count - 1;
}
if (curr == -1 && start <= cursor && end > cursor)
{
curr = arguments.Count - 1;
}
else if (next == -1 && start > cursor)
{
next = arguments.Count - 1;
}
}
}
}
// Stop searching the outer script blocks if we find any command arguments within the current script block.
if (arguments.Count > 0)
{
break;
}
}
// Simply return if we didn't find any command arguments.
int count = arguments.Count;
if (count == 0)
{
return;
}
if (prev == -1) { prev = count - 1; }
if (next == -1) { next = 0; }
if (curr == -1) { curr = numericArg > 0 ? prev : next; }
int newStartCursor, newEndCursor;
int selectCount = _singleton._visualSelectionCommandCount;
// When an argument is already visually selected by the previous run of this function, the cursor would have past the selected argument.
// In this case, if a user wants to move backward to an argument that is before the currently selected argument by having numericArg < 0,
// we will need to adjust 'numericArg' to move to the expected argument.
// Scenario:
// 1) 'Alt+a' to select an argument;
// 2) 'Alt+-' to make 'numericArg = -1';
// 3) 'Alt+a' to select the argument that is right before the currently selected argument.
if (count > 1 && numericArg < 0 && curr == next && selectCount > 0)
{
var prevArg = arguments[prev];
if (_singleton._mark == prevArg.Extent.StartOffset && cursor == prevArg.Extent.EndOffset)
{
numericArg--;
}
}
while (true)
{
ExpressionAst targetAst = null;
if (numericArg == 0)
{
targetAst = arguments[curr];
}
else
{
int index = curr + numericArg;
index = index >= 0 ? index % count : (count + index % count) % count;
targetAst = arguments[index];
}
// Handle quoted-string arguments specially, by leaving the quotes out of the visual selection.
StringConstantType? constantType = null;
if (targetAst is StringConstantExpressionAst conString)
{
constantType = conString.StringConstantType;
}
else if (targetAst is ExpandableStringExpressionAst expString)
{
constantType = expString.StringConstantType;
}
int startOffsetAdjustment = 0, endOffsetAdjustment = 0;
switch (constantType)
{
case StringConstantType.DoubleQuoted:
case StringConstantType.SingleQuoted:
startOffsetAdjustment = endOffsetAdjustment = 1;
break;
case StringConstantType.DoubleQuotedHereString:
case StringConstantType.SingleQuotedHereString:
startOffsetAdjustment = 2;
endOffsetAdjustment = 3;
break;
default: break;
}
newStartCursor = targetAst.Extent.StartOffset + startOffsetAdjustment;
newEndCursor = targetAst.Extent.EndOffset - endOffsetAdjustment;
// For quoted-string arguments, due to the special handling above, the cursor would always be
// within the selected argument (cursor is placed at the ending quote), and thus when running
// the 'SelectCommandArgument' action again, the same argument would be chosen.
//
// Below is how we detect this and move to the next argument when there is one:
// * the previous action was a visual selection command and the visual range was exactly
// what we are going to make. AND
// * count > 1, meaning that there are other arguments. AND
// * numericArg == 0. When 'numericArg' is not 0, the user is leaping among the available
// arguments, so it's possible that the same argument gets chosen.
// In this case, we should select the next argument.
if (numericArg == 0 && count > 1 && selectCount > 0 &&
_singleton._mark == newStartCursor && cursor == newEndCursor)
{
curr = next;
continue;
}
break;
}
// Move cursor to the start of the argument.
SetCursorPosition(newStartCursor);
// Make the intended range visually selected.
_singleton.VisualSelectionCommon(() => SetCursorPosition(newEndCursor), forceSetMark: true);
// ===== Local Functions =====
// Get the script block AST's whose extent contains the cursor.
bool GetScriptBlockAst(Ast ast)
{
if (ast is not ScriptBlockAst)
{
return false;
}
if (ast.Parent is null)
{
return true;
}
if (ast.Extent.StartOffset >= cursor)
{
return false;
}
// If the script block is closed, then we want the script block only if the cursor is within the script block.
// Otherwise, if the script block is not completed, then we want the script block even if the cursor is at the end.
int textLength = ast.Extent.Text.Length;
return ast.Extent.Text[textLength - 1] == '}'
? ast.Extent.EndOffset - 1 > cursor
: ast.Extent.EndOffset >= cursor;
}
// Process an expression AST to check if it's a CLI posix style option.
static ExpressionAst ProcessExpressionAst(ExpressionAst exprAst)
{
if (exprAst is StringConstantExpressionAst strAst
&& strAst.StringConstantType is StringConstantType.BareWord
&& strAst.Value.StartsWith("--")
&& s_cliOptionPattern.IsMatch(strAst.Value))
{
// It's a CLI posix style option, like '--json' or '--machine-type',
// so we treat it as a parameter.
return null;
}
return exprAst;
}
}
/// <summary>
/// Paste text from the system clipboard.
/// </summary>
public static void Paste(ConsoleKeyInfo? key = null, object arg = null)
{
string textToPaste = Clipboard.GetText();
if (textToPaste != null)
{
textToPaste = textToPaste.Replace("\r", "");
textToPaste = textToPaste.Replace("\t", " ");
if (_singleton._visualSelectionCommandCount > 0)
{
_singleton.GetRegion(out var start, out var length);
Replace(start, length, textToPaste);
}
else
{
Insert(textToPaste);
}
}
}
/// <summary>
/// Copy selected region to the system clipboard. If no region is selected, copy the whole line.
/// </summary>
public static void Copy(ConsoleKeyInfo? key = null, object arg = null)
{
string textToSet;
if (_singleton._visualSelectionCommandCount > 0)
{
_singleton.GetRegion(out var start, out var length);
textToSet = _singleton._buffer.ToString(start, length);
}
else
{
textToSet = _singleton._buffer.ToString();
}
if (!string.IsNullOrEmpty(textToSet))
{
Clipboard.SetText(textToSet);
}
}
/// <summary>
/// If text is selected, copy to the clipboard, otherwise cancel the line.
/// </summary>
public static void CopyOrCancelLine(ConsoleKeyInfo? key = null, object arg = null)
{
if (_singleton._visualSelectionCommandCount > 0)
{
Copy(key, arg);
}
else
{
CancelLine(key, arg);
}
}
/// <summary>
/// Delete selected region placing deleted text in the system clipboard.
/// </summary>
public static void Cut(ConsoleKeyInfo? key = null, object arg = null)
{
if (_singleton._visualSelectionCommandCount > 0)
{
_singleton.GetRegion(out var start, out var length);
Clipboard.SetText(_singleton._buffer.ToString(start, length));
Delete(start, length);
}
}
}
}