/********************************************************************++ 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 { /// /// FNV-1a hashing algorithm: http://www.isthe.com/chongo/tech/comp/fnv/#FNV-1a /// 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 { /// /// History details including the command line, source, and start and approximate execution time. /// [DebuggerDisplay("{" + nameof(CommandLine) + "}")] public class HistoryItem { /// /// The command line, or if multiple lines, the lines joined /// with a newline. /// public string CommandLine { get; internal set; } /// /// The time at which the command was added to history in UTC. /// public DateTime StartTime { get; internal set; } /// /// 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. /// public TimeSpan ApproximateElapsedTime { get; internal set; } /// /// True if the command was from another running session /// (as opposed to read from the history file at startup.) /// public bool FromOtherSession { get; internal set; } /// /// True if the command was read in from the history file at startup. /// public bool FromHistoryFile { get; internal set; } internal bool _saved; internal bool _sensitive; internal List _edits; internal int _undoEditIndex; internal int _editGroupStart; } // History state private HistoryQueue _history; private HistoryQueue _recentHistory; private HistoryItem _previousHistoryItem; private Dictionary _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 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 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 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); } } }); } /// /// 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. /// private List ReadHistoryFileIncrementally() { var fileInfo = new FileInfo(Options.HistorySavePath); if (fileInfo.Exists && fileInfo.Length != _historyFileLastSavedSize) { var historyLines = new List(); 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 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 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 {EditItemInsertString.Create(l, 0)}; MaybeAddToHistory(l, editItems, 1, fromDifferentSession, fromInitialRead); sb.Clear(); } else { var editItems = new List {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 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; } /// /// Add a command to the history - typically used to restore /// history from a previous session. /// public static void AddToHistory(string command) { command = command.Replace("\r\n", "\n"); var editItems = new List {EditItemInsertString.Create(command, 0)}; _singleton.MaybeAddToHistory(command, editItems, 1); } /// /// Clears history in PSReadLine. This does not affect PowerShell history. /// public static void ClearHistory(ConsoleKeyInfo? key = null, object arg = null) { _singleton._history?.Clear(); _singleton._recentHistory?.Clear(); _singleton._currentHistoryIndex = 0; } /// /// Return a collection of history items. /// 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(_savedCurrentLine._edits); _undoEditIndex = _savedCurrentLine._undoEditIndex; _editGroupStart = _savedCurrentLine._editGroupStart; } else { line = _history[_currentHistoryIndex].CommandLine; _edits = new List(_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(); } 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); } } /// /// Replace the current input with the 'previous' item from PSReadLine history. /// 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); } /// /// Replace the current input with the 'next' item from PSReadLine history. /// 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(); } } _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); } } /// /// Move to the first item in the history. /// public static void BeginningOfHistory(ConsoleKeyInfo? key = null, object arg = null) { _singleton.SaveCurrentLine(); _singleton._currentHistoryIndex = 0; _singleton.UpdateFromHistory(HistoryMoveCursor.ToEnd); } /// /// Move to the last item (the current input) in the history. /// 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); } /// /// Replace the current input with the 'previous' item from PSReadLine history /// that matches the characters between the start and the input and the cursor. /// 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); } /// /// Replace the current input with the 'next' item from PSReadLine history /// that matches the characters between the start and the input and the cursor. /// 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(); searchPositions.Push(_currentHistoryIndex); if (Options.HistoryNoDuplicates) { _hashedHistory = new Dictionary(); } 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); } /// /// Perform an incremental forward search through history. /// public static void ForwardSearchHistory(ConsoleKeyInfo? key = null, object arg = null) { _singleton.InteractiveHistorySearch(+1); } /// /// Perform an incremental backward search through history. /// public static void ReverseSearchHistory(ConsoleKeyInfo? key = null, object arg = null) { _singleton.InteractiveHistorySearch(-1); } } }