Files

1179 lines
45 KiB
C#

/********************************************************************++
Copyright (c) Microsoft Corporation. All rights reserved.
--********************************************************************/
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Management.Automation;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Management.Automation.Language;
using Microsoft.PowerShell.PSReadLine;
namespace Microsoft.PowerShell
{
/// <summary>
/// FNV-1a hashing algorithm: http://www.isthe.com/chongo/tech/comp/fnv/#FNV-1a
/// </summary>
internal class FNV1a32Hash
{
// FNV-1a algorithm parameters: http://www.isthe.com/chongo/tech/comp/fnv/#FNV-param
private const uint FNV32_PRIME = 16777619;
private const uint FNV32_OFFSETBASIS = 2166136261;
internal static uint ComputeHash(string input)
{
char ch;
uint hash = FNV32_OFFSETBASIS, lowByte, highByte;
for (int i = 0; i < input.Length; i++)
{
ch = input[i];
lowByte = (uint)(ch & 0x00FF);
hash = unchecked((hash ^ lowByte) * FNV32_PRIME);
highByte = (uint)(ch >> 8);
hash = unchecked((hash ^ highByte) * FNV32_PRIME);
}
return hash;
}
}
public partial class PSConsoleReadLine
{
/// <summary>
/// History details including the command line, source, and start and approximate execution time.
/// </summary>
[DebuggerDisplay("{" + nameof(CommandLine) + "}")]
public class HistoryItem
{
/// <summary>
/// The command line, or if multiple lines, the lines joined
/// with a newline.
/// </summary>
public string CommandLine { get; internal set; }
/// <summary>
/// The time at which the command was added to history in UTC.
/// </summary>
public DateTime StartTime { get; internal set; }
/// <summary>
/// The approximate elapsed time (includes time to invoke Prompt).
/// The value can be 0 ticks if if accessed before PSReadLine
/// gets a chance to set it.
/// </summary>
public TimeSpan ApproximateElapsedTime { get; internal set; }
/// <summary>
/// True if the command was from another running session
/// (as opposed to read from the history file at startup.)
/// </summary>
public bool FromOtherSession { get; internal set; }
/// <summary>
/// True if the command was read in from the history file at startup.
/// </summary>
public bool FromHistoryFile { get; internal set; }
internal bool _saved;
internal bool _sensitive;
internal List<EditItem> _edits;
internal int _undoEditIndex;
internal int _editGroupStart;
}
// History state
private HistoryQueue<HistoryItem> _history;
private HistoryQueue<string> _recentHistory;
private HistoryItem _previousHistoryItem;
private Dictionary<string, int> _hashedHistory;
private int _currentHistoryIndex;
private int _getNextHistoryIndex;
private int _searchHistoryCommandCount;
private int _recallHistoryCommandCount;
private int _anyHistoryCommandCount;
private string _searchHistoryPrefix;
// When cycling through history, the current line (not yet added to history)
// is saved here so it can be restored.
private readonly HistoryItem _savedCurrentLine;
private Mutex _historyFileMutex;
private long _historyFileLastSavedSize;
private const string _forwardISearchPrompt = "fwd-i-search: ";
private const string _backwardISearchPrompt = "bck-i-search: ";
private const string _failedForwardISearchPrompt = "failed-fwd-i-search: ";
private const string _failedBackwardISearchPrompt = "failed-bck-i-search: ";
// Pattern used to check for sensitive inputs.
private static readonly Regex s_sensitivePattern = new Regex(
"password|asplaintext|token|apikey|secret",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly HashSet<string> s_SecretMgmtCommands = new(StringComparer.OrdinalIgnoreCase)
{
"Get-Secret",
"Get-SecretInfo",
"Get-SecretVault",
"Register-SecretVault",
"Remove-Secret",
"Set-SecretInfo",
"Set-SecretVaultDefault",
"Test-SecretVault",
"Unlock-SecretVault",
"Unregister-SecretVault"
};
private void ClearSavedCurrentLine()
{
_savedCurrentLine.CommandLine = null;
_savedCurrentLine._edits = null;
_savedCurrentLine._undoEditIndex = 0;
_savedCurrentLine._editGroupStart = -1;
}
private AddToHistoryOption GetAddToHistoryOption(string line)
{
// Whitespace only is useless, never add.
if (string.IsNullOrWhiteSpace(line))
{
return AddToHistoryOption.SkipAdding;
}
// Under "no dupes" (which is on by default), immediately drop dupes of the previous line.
if (Options.HistoryNoDuplicates && _history.Count > 0 &&
string.Equals(_history[_history.Count - 1].CommandLine, line, StringComparison.Ordinal))
{
return AddToHistoryOption.SkipAdding;
}
if (Options.AddToHistoryHandler != null)
{
if (Options.AddToHistoryHandler == PSConsoleReadLineOptions.DefaultAddToHistoryHandler)
{
// Avoid boxing if it's the default handler.
return GetDefaultAddToHistoryOption(line);
}
object value = Options.AddToHistoryHandler(line);
if (value is PSObject psObj)
{
value = psObj.BaseObject;
}
if (value is bool boolValue)
{
return boolValue ? AddToHistoryOption.MemoryAndFile : AddToHistoryOption.SkipAdding;
}
if (value is AddToHistoryOption enumValue)
{
return enumValue;
}
if (value is string strValue && Enum.TryParse(strValue, out enumValue))
{
return enumValue;
}
// 'TryConvertTo' incurs exception handling when the value cannot be converted to the target type.
// It's expensive, especially when we need to process lots of history items from file during the
// initialization. So do the conversion as the last resort.
if (LanguagePrimitives.TryConvertTo(value, out enumValue))
{
return enumValue;
}
}
// Add to both history queue and file by default.
return AddToHistoryOption.MemoryAndFile;
}
private string MaybeAddToHistory(
string result,
List<EditItem> edits,
int undoEditIndex,
bool fromDifferentSession = false,
bool fromInitialRead = false)
{
var addToHistoryOption = GetAddToHistoryOption(result);
if (addToHistoryOption != AddToHistoryOption.SkipAdding)
{
var fromHistoryFile = fromDifferentSession || fromInitialRead;
_previousHistoryItem = new HistoryItem
{
CommandLine = result,
_edits = edits,
_undoEditIndex = undoEditIndex,
_editGroupStart = -1,
_saved = fromHistoryFile,
FromOtherSession = fromDifferentSession,
FromHistoryFile = fromInitialRead,
};
if (!fromHistoryFile)
{
// Add to the recent history queue, which is used when querying for prediction.
_recentHistory.Enqueue(result);
// 'MemoryOnly' indicates sensitive content in the command line
_previousHistoryItem._sensitive = addToHistoryOption == AddToHistoryOption.MemoryOnly;
_previousHistoryItem.StartTime = DateTime.UtcNow;
}
_history.Enqueue(_previousHistoryItem);
_currentHistoryIndex = _history.Count;
if (_options.HistorySaveStyle == HistorySaveStyle.SaveIncrementally && !fromHistoryFile)
{
IncrementalHistoryWrite();
}
}
else
{
_previousHistoryItem = null;
}
// Clear the saved line unless we used AcceptAndGetNext in which
// case we're really still in middle of history and might want
// to recall the saved line.
if (_getNextHistoryIndex == 0)
{
ClearSavedCurrentLine();
}
return result;
}
private string GetHistorySaveFileMutexName()
{
// Return a reasonably unique name - it's not too important as there will rarely
// be any contention.
uint hashFromPath = FNV1a32Hash.ComputeHash(
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? _options.HistorySavePath.ToLower()
: _options.HistorySavePath);
return "PSReadLineHistoryFile_" + hashFromPath.ToString();
}
private void IncrementalHistoryWrite()
{
var i = _currentHistoryIndex - 1;
while (i >= 0)
{
if (_history[i]._saved)
{
break;
}
i -= 1;
}
WriteHistoryRange(i + 1, _history.Count - 1, overwritten: false);
}
private void SaveHistoryAtExit()
{
WriteHistoryRange(0, _history.Count - 1, overwritten: true);
}
private int historyErrorReportedCount;
private void ReportHistoryFileError(Exception e)
{
if (historyErrorReportedCount == 2)
return;
historyErrorReportedCount += 1;
Console.Write(_options._errorColor);
Console.WriteLine(PSReadLineResources.HistoryFileErrorMessage, Options.HistorySavePath, e.Message);
if (historyErrorReportedCount == 2)
{
Console.WriteLine(PSReadLineResources.HistoryFileErrorFinalMessage);
}
Console.Write("\x1b0m");
}
private bool WithHistoryFileMutexDo(int timeout, Action action)
{
int retryCount = 0;
do
{
try
{
if (_historyFileMutex.WaitOne(timeout))
{
try
{
action();
return true;
}
catch (UnauthorizedAccessException uae)
{
ReportHistoryFileError(uae);
return false;
}
catch (IOException ioe)
{
ReportHistoryFileError(ioe);
return false;
}
finally
{
_historyFileMutex.ReleaseMutex();
}
}
// Consider it a failure if we timed out on the mutex.
return false;
}
catch (AbandonedMutexException)
{
retryCount += 1;
// We acquired the mutex object that was abandoned by another powershell process.
// Now, since we own it, we must release it before retry, otherwise, we will miss
// a release and keep holding the mutex, in which case the 'WaitOne' calls from
// all other powershell processes will time out.
_historyFileMutex.ReleaseMutex();
}
} while (retryCount > 0 && retryCount < 3);
// If we reach here, that means we've done the retries but always got the 'AbandonedMutexException'.
return false;
}
private void WriteHistoryRange(int start, int end, bool overwritten)
{
WithHistoryFileMutexDo(100, () =>
{
bool retry = true;
// Get the new content since the last sync.
List<string> historyLines = overwritten ? null : ReadHistoryFileIncrementally();
try
{
retry_after_creating_directory:
try
{
using (var file = overwritten ? File.CreateText(Options.HistorySavePath) : File.AppendText(Options.HistorySavePath))
{
for (var i = start; i <= end; i++)
{
HistoryItem item = _history[i];
item._saved = true;
// Actually, skip writing sensitive items to file.
if (item._sensitive) { continue; }
var line = item.CommandLine.Replace("\n", "`\n");
file.WriteLine(line);
}
}
var fileInfo = new FileInfo(Options.HistorySavePath);
_historyFileLastSavedSize = fileInfo.Length;
}
catch (DirectoryNotFoundException)
{
// Try making the directory, but just once
if (retry)
{
retry = false;
Directory.CreateDirectory(Path.GetDirectoryName(Options.HistorySavePath));
goto retry_after_creating_directory;
}
}
}
finally
{
if (historyLines != null)
{
// Populate new history from other sessions to the history queue after we are done
// with writing the specified range to the file.
// We do it at this point to make sure the range of history items from 'start' to
// 'end' do not get changed before the writing to the file.
UpdateHistoryFromFile(historyLines, fromDifferentSession: true, fromInitialRead: false);
}
}
});
}
/// <summary>
/// Helper method to read the incremental part of the history file.
/// Note: the call to this method should be guarded by the mutex that protects the history file.
/// </summary>
private List<string> ReadHistoryFileIncrementally()
{
var fileInfo = new FileInfo(Options.HistorySavePath);
if (fileInfo.Exists && fileInfo.Length != _historyFileLastSavedSize)
{
var historyLines = new List<string>();
using (var fs = new FileStream(Options.HistorySavePath, FileMode.Open))
using (var sr = new StreamReader(fs))
{
fs.Seek(_historyFileLastSavedSize, SeekOrigin.Begin);
while (!sr.EndOfStream)
{
historyLines.Add(sr.ReadLine());
}
}
_historyFileLastSavedSize = fileInfo.Length;
return historyLines.Count > 0 ? historyLines : null;
}
return null;
}
private bool MaybeReadHistoryFile()
{
if (Options.HistorySaveStyle == HistorySaveStyle.SaveIncrementally)
{
return WithHistoryFileMutexDo(1000, () =>
{
List<string> historyLines = ReadHistoryFileIncrementally();
if (historyLines != null)
{
UpdateHistoryFromFile(historyLines, fromDifferentSession: true, fromInitialRead: false);
}
});
}
// true means no errors, not that we actually read the file
return true;
}
private void ReadHistoryFile()
{
if (File.Exists(Options.HistorySavePath))
{
WithHistoryFileMutexDo(1000, () =>
{
var historyLines = File.ReadAllLines(Options.HistorySavePath);
UpdateHistoryFromFile(historyLines, fromDifferentSession: false, fromInitialRead: true);
var fileInfo = new FileInfo(Options.HistorySavePath);
_historyFileLastSavedSize = fileInfo.Length;
});
}
}
void UpdateHistoryFromFile(IEnumerable<string> historyLines, bool fromDifferentSession, bool fromInitialRead)
{
var sb = new StringBuilder();
foreach (var line in historyLines)
{
if (line.EndsWith("`", StringComparison.Ordinal))
{
sb.Append(line, 0, line.Length - 1);
sb.Append('\n');
}
else if (sb.Length > 0)
{
sb.Append(line);
var l = sb.ToString();
var editItems = new List<EditItem> {EditItemInsertString.Create(l, 0)};
MaybeAddToHistory(l, editItems, 1, fromDifferentSession, fromInitialRead);
sb.Clear();
}
else
{
var editItems = new List<EditItem> {EditItemInsertString.Create(line, 0)};
MaybeAddToHistory(line, editItems, 1, fromDifferentSession, fromInitialRead);
}
}
}
private static bool IsOnLeftSideOfAnAssignment(Ast ast, out Ast rhs)
{
bool result = false;
rhs = null;
do
{
if (ast.Parent is AssignmentStatementAst assignment)
{
rhs = assignment.Right;
result = ReferenceEquals(assignment.Left, ast);
break;
}
ast = ast.Parent;
}
while (ast.Parent is not null);
return result;
}
private static bool IsSecretMgmtCommand(StringConstantExpressionAst strConst, out CommandAst command)
{
bool result = false;
command = strConst.Parent as CommandAst;
if (command is not null)
{
result = ReferenceEquals(command.CommandElements[0], strConst)
&& s_SecretMgmtCommands.Contains(strConst.Value);
}
return result;
}
private static ExpressionAst GetArgumentForParameter(CommandParameterAst param)
{
if (param.Argument is not null)
{
return param.Argument;
}
var command = (CommandAst)param.Parent;
int index = 1;
for (; index < command.CommandElements.Count; index++)
{
if (ReferenceEquals(command.CommandElements[index], param))
{
break;
}
}
int argIndex = index + 1;
if (argIndex < command.CommandElements.Count
&& command.CommandElements[argIndex] is ExpressionAst arg)
{
return arg;
}
return null;
}
public static AddToHistoryOption GetDefaultAddToHistoryOption(string line)
{
if (string.IsNullOrEmpty(line))
{
return AddToHistoryOption.SkipAdding;
}
Match match = s_sensitivePattern.Match(line);
if (ReferenceEquals(match, Match.Empty))
{
return AddToHistoryOption.MemoryAndFile;
}
// The input contains at least one match of some sensitive patterns, so now we need to further
// analyze the input using the ASTs to see if it should actually be considered sensitive.
bool isSensitive = false;
ParseError[] parseErrors = _singleton._parseErrors;
// We need to compare the text here, instead of simply checking whether or not '_ast' is null.
// This is because we may need to update from history file in the middle of editing an input,
// and in that case, the '_ast' may be not-null, but it was not parsed from 'line'.
Ast ast = string.Equals(_singleton._ast?.Extent.Text, line)
? _singleton._ast
: Parser.ParseInput(line, out _, out parseErrors);
if (parseErrors != null && parseErrors.Length > 0)
{
// If the input has any parsing errors, we cannot reliably analyze the AST. We just consider
// it sensitive in this case, given that it contains matches of our sensitive pattern.
return AddToHistoryOption.MemoryOnly;
}
do
{
int start = match.Index;
int end = start + match.Length;
IEnumerable<Ast> asts = ast.FindAll(
ast => ast.Extent.StartOffset <= start && ast.Extent.EndOffset >= end,
searchNestedScriptBlocks: true);
Ast innerAst = asts.Last();
switch (innerAst)
{
case VariableExpressionAst:
// It's a variable with sensitive name. Using the variable is fine, but assigning to
// the variable could potentially expose sensitive content.
// If it appears on the left-hand-side of an assignment, and the right-hand-side is
// not a command invocation, we consider it sensitive.
// e.g. `$token = Get-Secret` vs. `$token = 'token-text'` or `$token, $url = ...`
isSensitive = IsOnLeftSideOfAnAssignment(innerAst, out Ast rhs)
&& rhs is not PipelineAst;
if (!isSensitive)
{
match = match.NextMatch();
}
break;
case StringConstantExpressionAst strConst:
// If it's not a command name, or it's not one of the secret management commands that
// we can ignore, we consider it sensitive.
isSensitive = !IsSecretMgmtCommand(strConst, out CommandAst command);
if (!isSensitive)
{
// We can safely skip the whole command text.
match = s_sensitivePattern.Match(line, command.Extent.EndOffset);
}
break;
case CommandParameterAst param:
// Special-case the '-AsPlainText' parameter.
if (string.Equals(param.ParameterName, "AsPlainText"))
{
isSensitive = true;
break;
}
ExpressionAst arg = GetArgumentForParameter(param);
if (arg is null)
{
// If no argument is found following the parameter, then it could be a switching parameter
// such as '-UseDefaultPassword' or '-SaveToken', which we assume will not expose sensitive information.
match = match.NextMatch();
}
else if (arg is VariableExpressionAst)
{
// Argument is a variable. It's fine to use a variable for a senstive parameter.
// e.g. `Invoke-WebRequest -Token $token`
match = s_sensitivePattern.Match(line, arg.Extent.EndOffset);
}
else if (arg is ParenExpressionAst paren
&& paren.Pipeline is PipelineAst pipeline
&& pipeline.PipelineElements[0] is not CommandExpressionAst)
{
// Argument is a command invocation, such as `Invoke-WebRequest -Token (Get-Secret)`.
match = match.NextMatch();
}
else
{
// We consider all other arguments sensitive.
isSensitive = true;
}
break;
default:
isSensitive = true;
break;
}
}
while (!isSensitive && !ReferenceEquals(match, Match.Empty));
return isSensitive ? AddToHistoryOption.MemoryOnly : AddToHistoryOption.MemoryAndFile;
}
/// <summary>
/// Add a command to the history - typically used to restore
/// history from a previous session.
/// </summary>
public static void AddToHistory(string command)
{
command = command.Replace("\r\n", "\n");
var editItems = new List<EditItem> {EditItemInsertString.Create(command, 0)};
_singleton.MaybeAddToHistory(command, editItems, 1);
}
/// <summary>
/// Clears history in PSReadLine. This does not affect PowerShell history.
/// </summary>
public static void ClearHistory(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton._history?.Clear();
_singleton._recentHistory?.Clear();
_singleton._currentHistoryIndex = 0;
}
/// <summary>
/// Return a collection of history items.
/// </summary>
public static HistoryItem[] GetHistoryItems()
{
return _singleton._history.ToArray();
}
enum HistoryMoveCursor { ToEnd, ToBeginning, DontMove }
private void UpdateFromHistory(HistoryMoveCursor moveCursor)
{
string line;
if (_currentHistoryIndex == _history.Count)
{
line = _savedCurrentLine.CommandLine;
_edits = new List<EditItem>(_savedCurrentLine._edits);
_undoEditIndex = _savedCurrentLine._undoEditIndex;
_editGroupStart = _savedCurrentLine._editGroupStart;
}
else
{
line = _history[_currentHistoryIndex].CommandLine;
_edits = new List<EditItem>(_history[_currentHistoryIndex]._edits);
_undoEditIndex = _history[_currentHistoryIndex]._undoEditIndex;
_editGroupStart = _history[_currentHistoryIndex]._editGroupStart;
}
_buffer.Clear();
_buffer.Append(line);
switch (moveCursor)
{
case HistoryMoveCursor.ToEnd:
_current = Math.Max(0, _buffer.Length + ViEndOfLineFactor);
break;
case HistoryMoveCursor.ToBeginning:
_current = 0;
break;
default:
if (_current > _buffer.Length)
{
_current = Math.Max(0, _buffer.Length + ViEndOfLineFactor);
}
break;
}
using var _ = _prediction.DisableScoped();
Render();
}
private void SaveCurrentLine()
{
// We're called before any history operation - so it's convenient
// to check if we need to load history from another sessions now.
MaybeReadHistoryFile();
_anyHistoryCommandCount += 1;
if (_savedCurrentLine.CommandLine == null)
{
_savedCurrentLine.CommandLine = _buffer.ToString();
_savedCurrentLine._edits = _edits;
_savedCurrentLine._undoEditIndex = _undoEditIndex;
_savedCurrentLine._editGroupStart = _editGroupStart;
}
}
private void HistoryRecall(int direction)
{
if (_recallHistoryCommandCount == 0 && LineIsMultiLine())
{
MoveToLine(direction);
return;
}
if (Options.HistoryNoDuplicates && _recallHistoryCommandCount == 0)
{
_hashedHistory = new Dictionary<string, int>();
}
int count = Math.Abs(direction);
direction = direction < 0 ? -1 : +1;
int newHistoryIndex = _currentHistoryIndex;
while (count > 0)
{
newHistoryIndex += direction;
if (newHistoryIndex < 0 || newHistoryIndex >= _history.Count)
{
break;
}
if (_history[newHistoryIndex].FromOtherSession)
{
continue;
}
if (Options.HistoryNoDuplicates)
{
var line = _history[newHistoryIndex].CommandLine;
if (!_hashedHistory.TryGetValue(line, out var index))
{
_hashedHistory.Add(line, newHistoryIndex);
--count;
}
else if (newHistoryIndex == index)
{
--count;
}
}
else
{
--count;
}
}
_recallHistoryCommandCount += 1;
if (newHistoryIndex >= 0 && newHistoryIndex <= _history.Count)
{
_currentHistoryIndex = newHistoryIndex;
var moveCursor = InViCommandMode() && !_options.HistorySearchCursorMovesToEnd
? HistoryMoveCursor.ToBeginning
: HistoryMoveCursor.ToEnd;
UpdateFromHistory(moveCursor);
}
}
/// <summary>
/// Replace the current input with the 'previous' item from PSReadLine history.
/// </summary>
public static void PreviousHistory(ConsoleKeyInfo? key = null, object arg = null)
{
TryGetArgAsInt(arg, out var numericArg, -1);
if (numericArg > 0)
{
numericArg = -numericArg;
}
if (UpdateListSelection(numericArg))
{
return;
}
_singleton.SaveCurrentLine();
_singleton.HistoryRecall(numericArg);
}
/// <summary>
/// Replace the current input with the 'next' item from PSReadLine history.
/// </summary>
public static void NextHistory(ConsoleKeyInfo? key = null, object arg = null)
{
TryGetArgAsInt(arg, out var numericArg, +1);
if (UpdateListSelection(numericArg))
{
return;
}
_singleton.SaveCurrentLine();
_singleton.HistoryRecall(numericArg);
}
private void HistorySearch(int direction)
{
if (_searchHistoryCommandCount == 0)
{
if (LineIsMultiLine())
{
MoveToLine(direction);
return;
}
_searchHistoryPrefix = _buffer.ToString(0, _current);
_emphasisStart = 0;
_emphasisLength = _current;
if (Options.HistoryNoDuplicates)
{
_hashedHistory = new Dictionary<string, int>();
}
}
_searchHistoryCommandCount += 1;
int count = Math.Abs(direction);
direction = direction < 0 ? -1 : +1;
int newHistoryIndex = _currentHistoryIndex;
while (count > 0)
{
newHistoryIndex += direction;
if (newHistoryIndex < 0 || newHistoryIndex >= _history.Count)
{
break;
}
if (_history[newHistoryIndex].FromOtherSession && _searchHistoryPrefix.Length == 0)
{
continue;
}
var line = _history[newHistoryIndex].CommandLine;
if (line.StartsWith(_searchHistoryPrefix, Options.HistoryStringComparison))
{
if (Options.HistoryNoDuplicates)
{
if (!_hashedHistory.TryGetValue(line, out var index))
{
_hashedHistory.Add(line, newHistoryIndex);
--count;
}
else if (index == newHistoryIndex)
{
--count;
}
}
else
{
--count;
}
}
}
if (newHistoryIndex >= 0 && newHistoryIndex <= _history.Count)
{
// Set '_current' back to where it was when starting the first search, because
// it might be changed during the rendering of the last matching history command.
_current = _emphasisLength;
_currentHistoryIndex = newHistoryIndex;
var moveCursor = InViCommandMode()
? HistoryMoveCursor.ToBeginning
: Options.HistorySearchCursorMovesToEnd
? HistoryMoveCursor.ToEnd
: HistoryMoveCursor.DontMove;
UpdateFromHistory(moveCursor);
}
}
/// <summary>
/// Move to the first item in the history.
/// </summary>
public static void BeginningOfHistory(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.SaveCurrentLine();
_singleton._currentHistoryIndex = 0;
_singleton.UpdateFromHistory(HistoryMoveCursor.ToEnd);
}
/// <summary>
/// Move to the last item (the current input) in the history.
/// </summary>
public static void EndOfHistory(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.SaveCurrentLine();
GoToEndOfHistory();
}
private static void GoToEndOfHistory()
{
_singleton._currentHistoryIndex = _singleton._history.Count;
_singleton.UpdateFromHistory(HistoryMoveCursor.ToEnd);
}
/// <summary>
/// Replace the current input with the 'previous' item from PSReadLine history
/// that matches the characters between the start and the input and the cursor.
/// </summary>
public static void HistorySearchBackward(ConsoleKeyInfo? key = null, object arg = null)
{
TryGetArgAsInt(arg, out var numericArg, -1);
if (numericArg > 0)
{
numericArg = -numericArg;
}
if (UpdateListSelection(numericArg))
{
return;
}
_singleton.SaveCurrentLine();
_singleton.HistorySearch(numericArg);
}
/// <summary>
/// Replace the current input with the 'next' item from PSReadLine history
/// that matches the characters between the start and the input and the cursor.
/// </summary>
public static void HistorySearchForward(ConsoleKeyInfo? key = null, object arg = null)
{
TryGetArgAsInt(arg, out var numericArg, +1);
if (UpdateListSelection(numericArg))
{
return;
}
_singleton.SaveCurrentLine();
_singleton.HistorySearch(numericArg);
}
private void UpdateHistoryDuringInteractiveSearch(string toMatch, int direction, ref int searchFromPoint)
{
searchFromPoint += direction;
for (; searchFromPoint >= 0 && searchFromPoint < _history.Count; searchFromPoint += direction)
{
var line = _history[searchFromPoint].CommandLine;
var startIndex = line.IndexOf(toMatch, Options.HistoryStringComparison);
if (startIndex >= 0)
{
if (Options.HistoryNoDuplicates)
{
if (!_hashedHistory.TryGetValue(line, out var index))
{
_hashedHistory.Add(line, searchFromPoint);
}
else if (index != searchFromPoint)
{
continue;
}
}
_statusLinePrompt = direction > 0 ? _forwardISearchPrompt : _backwardISearchPrompt;
_current = startIndex;
_emphasisStart = startIndex;
_emphasisLength = toMatch.Length;
_currentHistoryIndex = searchFromPoint;
var moveCursor = Options.HistorySearchCursorMovesToEnd
? HistoryMoveCursor.ToEnd
: HistoryMoveCursor.DontMove;
UpdateFromHistory(moveCursor);
return;
}
}
// Make sure we're never more than 1 away from being in range so if they
// reverse direction, the first time they reverse they are back in range.
if (searchFromPoint < 0)
searchFromPoint = -1;
else if (searchFromPoint >= _history.Count)
searchFromPoint = _history.Count;
_emphasisStart = -1;
_emphasisLength = 0;
_statusLinePrompt = direction > 0 ? _failedForwardISearchPrompt : _failedBackwardISearchPrompt;
Render();
}
private void InteractiveHistorySearchLoop(int direction)
{
var searchFromPoint = _currentHistoryIndex;
var searchPositions = new Stack<int>();
searchPositions.Push(_currentHistoryIndex);
if (Options.HistoryNoDuplicates)
{
_hashedHistory = new Dictionary<string, int>();
}
var toMatch = new StringBuilder(64);
while (true)
{
var key = ReadKey();
_dispatchTable.TryGetValue(key, out var handler);
var function = handler?.Action;
if (function == ReverseSearchHistory)
{
UpdateHistoryDuringInteractiveSearch(toMatch.ToString(), -1, ref searchFromPoint);
}
else if (function == ForwardSearchHistory)
{
UpdateHistoryDuringInteractiveSearch(toMatch.ToString(), +1, ref searchFromPoint);
}
else if (function == BackwardDeleteChar
|| key == Keys.Backspace
|| key == Keys.CtrlH)
{
if (toMatch.Length > 0)
{
toMatch.Remove(toMatch.Length - 1, 1);
_statusBuffer.Remove(_statusBuffer.Length - 2, 1);
searchPositions.Pop();
searchFromPoint = _currentHistoryIndex = searchPositions.Peek();
var moveCursor = Options.HistorySearchCursorMovesToEnd
? HistoryMoveCursor.ToEnd
: HistoryMoveCursor.DontMove;
UpdateFromHistory(moveCursor);
if (_hashedHistory != null)
{
// Remove any entries with index < searchFromPoint because
// we are starting the search from this new index - we always
// want to find the latest entry that matches the search string
foreach (var pair in _hashedHistory.ToArray())
{
if (pair.Value < searchFromPoint)
{
_hashedHistory.Remove(pair.Key);
}
}
}
// Prompt may need to have 'failed-' removed.
var toMatchStr = toMatch.ToString();
var startIndex = _buffer.ToString().IndexOf(toMatchStr, Options.HistoryStringComparison);
if (startIndex >= 0)
{
_statusLinePrompt = direction > 0 ? _forwardISearchPrompt : _backwardISearchPrompt;
_current = startIndex;
_emphasisStart = startIndex;
_emphasisLength = toMatch.Length;
Render();
}
}
else
{
Ding();
}
}
else if (key == Keys.Escape)
{
// End search
break;
}
else if (function == Abort)
{
// Abort search
GoToEndOfHistory();
break;
}
else
{
char toAppend = key.KeyChar;
if (char.IsControl(toAppend))
{
PrependQueuedKeys(key);
break;
}
toMatch.Append(toAppend);
_statusBuffer.Insert(_statusBuffer.Length - 1, toAppend);
var toMatchStr = toMatch.ToString();
var startIndex = _buffer.ToString().IndexOf(toMatchStr, Options.HistoryStringComparison);
if (startIndex < 0)
{
UpdateHistoryDuringInteractiveSearch(toMatchStr, direction, ref searchFromPoint);
}
else
{
_current = startIndex;
_emphasisStart = startIndex;
_emphasisLength = toMatch.Length;
Render();
}
searchPositions.Push(_currentHistoryIndex);
}
}
}
private void InteractiveHistorySearch(int direction)
{
using var _ = _prediction.DisableScoped();
SaveCurrentLine();
// Add a status line that will contain the search prompt and string
_statusLinePrompt = direction > 0 ? _forwardISearchPrompt : _backwardISearchPrompt;
_statusBuffer.Append("_");
Render(); // Render prompt
InteractiveHistorySearchLoop(direction);
_emphasisStart = -1;
_emphasisLength = 0;
// Remove our status line, this will render
ClearStatusMessage(render: true);
}
/// <summary>
/// Perform an incremental forward search through history.
/// </summary>
public static void ForwardSearchHistory(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.InteractiveHistorySearch(+1);
}
/// <summary>
/// Perform an incremental backward search through history.
/// </summary>
public static void ReverseSearchHistory(ConsoleKeyInfo? key = null, object arg = null)
{
_singleton.InteractiveHistorySearch(-1);
}
}
}