diff --git a/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/FeedbackHub.cs b/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/FeedbackHub.cs index fa12c0ca751..7dfe79a2cd9 100644 --- a/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/FeedbackHub.cs +++ b/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/FeedbackHub.cs @@ -3,10 +3,10 @@ #nullable enable -using System; using System.Collections; using System.Collections.Generic; -using System.Management.Automation.Internal; +using System.Diagnostics.CodeAnalysis; +using System.Management.Automation.Language; using System.Management.Automation.Runspaces; using System.Threading; using System.Threading.Tasks; @@ -62,61 +62,54 @@ public static class FeedbackHub { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(millisecondsTimeout); - var localRunspace = runspace as LocalRunspace; - if (localRunspace is null) + if (runspace is not LocalRunspace localRunspace) { return null; } - // Get the last value of $? - bool questionMarkValue = localRunspace.ExecutionContext.QuestionMarkVariableValue; - if (questionMarkValue) - { - return null; - } - - // Get the last history item - HistoryInfo[] histories = localRunspace.History.GetEntries(id: 0, count: 1, newest: true); - if (histories.Length == 0) + var providers = SubsystemManager.GetSubsystems(); + if (providers.Count is 0) { return null; } - HistoryInfo lastHistory = histories[0]; + ExecutionContext executionContext = localRunspace.ExecutionContext; + bool questionMarkValue = executionContext.QuestionMarkVariableValue; - // Get the last error - ArrayList errorList = (ArrayList)localRunspace.ExecutionContext.DollarErrorVariable; - if (errorList.Count == 0) + // The command line would have run successfully in most cases during an interactive use of the shell. + // So, we do a quick check to see whether we can skip proceeding, so as to avoid unneeded allocations + // from the 'TryGetFeedbackContext' call below. + if (questionMarkValue && CanSkip(providers)) { return null; } - var lastError = errorList[0] as ErrorRecord; - if (lastError is null && errorList[0] is RuntimeException rtEx) - { - lastError = rtEx.ErrorRecord; - } - - if (lastError?.InvocationInfo is null || lastError.InvocationInfo.HistoryId != lastHistory.Id) + // Get the last history item + HistoryInfo[] histories = localRunspace.History.GetEntries(id: 0, count: 1, newest: true); + if (histories.Length is 0) { return null; } - var providers = SubsystemManager.GetSubsystems(); - int count = providers.Count; - if (count == 0) + // Try creating the feedback context object. + if (!TryGetFeedbackContext(executionContext, questionMarkValue, histories[0], out FeedbackContext? feedbackContext)) { return null; } + int count = providers.Count; IFeedbackProvider? generalFeedback = null; List>? tasks = null; CancellationTokenSource? cancellationSource = null; Func? callBack = null; - for (int i = 0; i < providers.Count; i++) + foreach (IFeedbackProvider provider in providers) { - IFeedbackProvider provider = providers[i]; + if (!provider.Trigger.HasFlag(feedbackContext.Trigger)) + { + continue; + } + if (provider is GeneralCommandErrorFeedback) { // This built-in feedback provider needs to run on the target Runspace. @@ -128,7 +121,7 @@ public static class FeedbackHub { tasks = new List>(capacity: count); cancellationSource = new CancellationTokenSource(); - callBack = GetCallBack(lastHistory.CommandLine, lastError, cancellationSource); + callBack = GetCallBack(feedbackContext, cancellationSource); } // Other feedback providers will run on background threads in parallel. @@ -151,33 +144,11 @@ public static class FeedbackHub List? resultList = null; if (generalFeedback is not null) { - bool changedDefault = false; - Runspace? oldDefault = Runspace.DefaultRunspace; - - try - { - if (oldDefault != localRunspace) - { - changedDefault = true; - Runspace.DefaultRunspace = localRunspace; - } - - FeedbackItem? item = generalFeedback.GetFeedback(lastHistory.CommandLine, lastError, CancellationToken.None); - if (item is not null) - { - resultList ??= new List(count); - resultList.Add(new FeedbackResult(generalFeedback.Id, generalFeedback.Name, item)); - } - } - finally + FeedbackResult? builtInResult = GetBuiltInFeedback(generalFeedback, localRunspace, feedbackContext, questionMarkValue); + if (builtInResult is not null) { - if (changedDefault) - { - Runspace.DefaultRunspace = oldDefault; - } - - // Restore $? for the target Runspace. - localRunspace.ExecutionContext.QuestionMarkVariableValue = questionMarkValue; + resultList ??= new List(count); + resultList.Add(builtInResult); } } @@ -210,17 +181,134 @@ public static class FeedbackHub return resultList; } + private static bool CanSkip(IEnumerable providers) + { + const FeedbackTrigger possibleTriggerOnSuccess = FeedbackTrigger.Success | FeedbackTrigger.Comment; + + bool canSkip = true; + foreach (IFeedbackProvider provider in providers) + { + if ((provider.Trigger & possibleTriggerOnSuccess) != 0) + { + canSkip = false; + break; + } + } + + return canSkip; + } + + private static FeedbackResult? GetBuiltInFeedback( + IFeedbackProvider builtInFeedback, + LocalRunspace localRunspace, + FeedbackContext feedbackContext, + bool questionMarkValue) + { + bool changedDefault = false; + Runspace? oldDefault = Runspace.DefaultRunspace; + + try + { + if (oldDefault != localRunspace) + { + changedDefault = true; + Runspace.DefaultRunspace = localRunspace; + } + + FeedbackItem? item = builtInFeedback.GetFeedback(feedbackContext, CancellationToken.None); + if (item is not null) + { + return new FeedbackResult(builtInFeedback.Id, builtInFeedback.Name, item); + } + } + finally + { + if (changedDefault) + { + Runspace.DefaultRunspace = oldDefault; + } + + // Restore $? for the target Runspace. + localRunspace.ExecutionContext.QuestionMarkVariableValue = questionMarkValue; + } + + return null; + } + + private static bool TryGetFeedbackContext( + ExecutionContext executionContext, + bool questionMarkValue, + HistoryInfo lastHistory, + [NotNullWhen(true)] out FeedbackContext? feedbackContext) + { + feedbackContext = null; + Ast ast = Parser.ParseInput(lastHistory.CommandLine, out Token[] tokens, out _); + + FeedbackTrigger trigger; + ErrorRecord? lastError = null; + + if (IsPureComment(tokens)) + { + trigger = FeedbackTrigger.Comment; + } + else if (questionMarkValue) + { + trigger = FeedbackTrigger.Success; + } + else if (TryGetLastError(executionContext, lastHistory, out lastError)) + { + trigger = lastError.FullyQualifiedErrorId is "CommandNotFoundException" + ? FeedbackTrigger.CommandNotFound + : FeedbackTrigger.Error; + } + else + { + return false; + } + + PathInfo cwd = executionContext.SessionState.Path.CurrentLocation; + feedbackContext = new(trigger, ast, tokens, cwd, lastError); + return true; + } + + private static bool IsPureComment(Token[] tokens) + { + return tokens.Length is 2 && tokens[0].Kind is TokenKind.Comment && tokens[1].Kind is TokenKind.EndOfInput; + } + + private static bool TryGetLastError(ExecutionContext context, HistoryInfo lastHistory, [NotNullWhen(true)] out ErrorRecord? lastError) + { + lastError = null; + ArrayList errorList = (ArrayList)context.DollarErrorVariable; + if (errorList.Count == 0) + { + return false; + } + + lastError = errorList[0] as ErrorRecord; + if (lastError is null && errorList[0] is RuntimeException rtEx) + { + lastError = rtEx.ErrorRecord; + } + + if (lastError?.InvocationInfo is null || lastError.InvocationInfo.HistoryId != lastHistory.Id) + { + return false; + } + + return true; + } + // A local helper function to avoid creating an instance of the generated delegate helper class // when no feedback provider is registered. private static Func GetCallBack( - string commandLine, - ErrorRecord lastError, + FeedbackContext feedbackContext, CancellationTokenSource cancellationSource) { return state => { var provider = (IFeedbackProvider)state!; - var item = provider.GetFeedback(commandLine, lastError, cancellationSource.Token); + var item = provider.GetFeedback(feedbackContext, cancellationSource.Token); return item is null ? null : new FeedbackResult(provider.Id, provider.Name, item); }; } diff --git a/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/IFeedbackProvider.cs b/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/IFeedbackProvider.cs index 4a680f30db6..d028e5baa62 100644 --- a/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/IFeedbackProvider.cs +++ b/src/System.Management.Automation/engine/Subsystem/FeedbackSubsystem/IFeedbackProvider.cs @@ -4,15 +4,48 @@ #nullable enable using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Management.Automation.Internal; +using System.Management.Automation.Language; using System.Management.Automation.Runspaces; -using System.Management.Automation.Subsystem.Prediction; using System.Threading; namespace System.Management.Automation.Subsystem.Feedback { + /// + /// Types of trigger for the feedback provider. + /// + [Flags] + public enum FeedbackTrigger + { + /// + /// The last command line is comment only. + /// + Comment = 0x0001, + + /// + /// The last command line executed successfully. + /// + Success = 0x0002, + + /// + /// The last command line failed due to a command-not-found error. + /// This is a special case of . + /// + CommandNotFound = 0x0004, + + /// + /// The last command line failed with an error record. + /// This includes the case of command-not-found error. + /// + Error = CommandNotFound | 0x0008, + + /// + /// All possible triggers. + /// + All = Comment | Success | Error + } + /// /// Layout for displaying the recommended actions. /// @@ -29,6 +62,84 @@ public enum FeedbackDisplayLayout Landscape, } + /// + /// Context information about the last command line. + /// + public sealed class FeedbackContext + { + /// + /// Gets the feedback trigger. + /// + public FeedbackTrigger Trigger { get; } + + /// + /// Gets the last command line that was just executed. + /// + public string CommandLine { get; } + + /// + /// Gets the abstract syntax tree (AST) generated from parsing the last command line. + /// + public Ast CommandLineAst { get; } + + /// + /// Gets the tokens generated from parsing the last command line. + /// + public IReadOnlyList CommandLineTokens { get; } + + /// + /// Gets the current location of the default session. + /// + public PathInfo CurrentLocation { get; } + + /// + /// Gets the last error record generated from executing the last command line. + /// + public ErrorRecord? LastError { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The trigger of this feedback call. + /// The command line that was just executed. + /// The current location of the default session. + /// The error that was triggerd by the last command line. + public FeedbackContext(FeedbackTrigger trigger, string commandLine, PathInfo cwd, ErrorRecord? lastError) + { + ArgumentException.ThrowIfNullOrEmpty(commandLine); + ArgumentNullException.ThrowIfNull(cwd); + + Trigger = trigger; + CommandLine = commandLine; + CommandLineAst = Parser.ParseInput(commandLine, out Token[] tokens, out _); + CommandLineTokens = tokens; + LastError = lastError; + CurrentLocation = cwd; + } + + /// + /// Initializes a new instance of the class. + /// + /// The trigger of this feedback call. + /// The abstract syntax tree (AST) from parsing the last command line. + /// The tokens from parsing the last command line. + /// The current location of the default session. + /// The error that was triggerd by the last command line. + public FeedbackContext(FeedbackTrigger trigger, Ast commandLineAst, Token[] commandLineTokens, PathInfo cwd, ErrorRecord? lastError) + { + ArgumentNullException.ThrowIfNull(commandLineAst); + ArgumentNullException.ThrowIfNull(commandLineTokens); + ArgumentNullException.ThrowIfNull(cwd); + + Trigger = trigger; + CommandLine = commandLineAst.Extent.Text; + CommandLineAst = commandLineAst; + CommandLineTokens = commandLineTokens; + LastError = lastError; + CurrentLocation = cwd; + } + } + /// /// The class represents a feedback item generated by the feedback provider. /// @@ -108,14 +219,21 @@ public interface IFeedbackProvider : ISubsystem /// Dictionary? ISubsystem.FunctionsToDefine => null; + /// + /// Gets the types of trigger for this feedback provider. + /// + /// + /// The default implementation triggers a feedback provider by only. + /// + FeedbackTrigger Trigger => FeedbackTrigger.CommandNotFound; + /// /// Gets feedback based on the given commandline and error record. /// - /// The command line that was just executed. - /// The error that was triggerd by the command line. + /// The context for the feedback call. /// The cancellation token to cancel the operation. /// The feedback item. - FeedbackItem? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token); + FeedbackItem? GetFeedback(FeedbackContext context, CancellationToken token); } internal sealed class GeneralCommandErrorFeedback : IFeedbackProvider @@ -133,7 +251,7 @@ internal GeneralCommandErrorFeedback() public string Description => "The built-in general feedback source for command errors."; - public FeedbackItem? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token) + public FeedbackItem? GetFeedback(FeedbackContext context, CancellationToken token) { var rsToUse = Runspace.DefaultRunspace; if (rsToUse is null) @@ -141,47 +259,47 @@ internal GeneralCommandErrorFeedback() return null; } - if (lastError.FullyQualifiedErrorId == "CommandNotFoundException") - { - EngineIntrinsics context = rsToUse.ExecutionContext.EngineIntrinsics; + // This feedback provider is only triggered by 'CommandNotFound' error, so the + // 'LastError' property is guaranteed to be not null. + ErrorRecord lastError = context.LastError!; + SessionState sessionState = rsToUse.ExecutionContext.SessionState; - var target = (string)lastError.TargetObject; - CommandInvocationIntrinsics invocation = context.SessionState.InvokeCommand; + var target = (string)lastError.TargetObject; + CommandInvocationIntrinsics invocation = sessionState.InvokeCommand; - // See if target is actually an executable file in current directory. - var localTarget = Path.Combine(".", target); - var command = invocation.GetCommand( - localTarget, - CommandTypes.Application | CommandTypes.ExternalScript); + // See if target is actually an executable file in current directory. + var localTarget = Path.Combine(".", target); + var command = invocation.GetCommand( + localTarget, + CommandTypes.Application | CommandTypes.ExternalScript); - if (command is not null) - { - return new FeedbackItem( - StringUtil.Format(SuggestionStrings.Suggestion_CommandExistsInCurrentDirectory, target), - new List { localTarget }); - } + if (command is not null) + { + return new FeedbackItem( + StringUtil.Format(SuggestionStrings.Suggestion_CommandExistsInCurrentDirectory, target), + new List { localTarget }); + } + + // Check fuzzy matching command names. + if (ExperimentalFeature.IsEnabled("PSCommandNotFoundSuggestion")) + { + var pwsh = PowerShell.Create(RunspaceMode.CurrentRunspace); + var results = pwsh.AddCommand("Get-Command") + .AddParameter("UseFuzzyMatching") + .AddParameter("FuzzyMinimumDistance", 1) + .AddParameter("Name", target) + .AddCommand("Select-Object") + .AddParameter("First", 5) + .AddParameter("Unique") + .AddParameter("ExpandProperty", "Name") + .Invoke(); - // Check fuzzy matching command names. - if (ExperimentalFeature.IsEnabled("PSCommandNotFoundSuggestion")) + if (results.Count > 0) { - var pwsh = PowerShell.Create(RunspaceMode.CurrentRunspace); - var results = pwsh.AddCommand("Get-Command") - .AddParameter("UseFuzzyMatching") - .AddParameter("FuzzyMinimumDistance", 1) - .AddParameter("Name", target) - .AddCommand("Select-Object") - .AddParameter("First", 5) - .AddParameter("Unique") - .AddParameter("ExpandProperty", "Name") - .Invoke(); - - if (results.Count > 0) - { - return new FeedbackItem( - SuggestionStrings.Suggestion_CommandNotFound, - new List(results), - FeedbackDisplayLayout.Landscape); - } + return new FeedbackItem( + SuggestionStrings.Suggestion_CommandNotFound, + new List(results), + FeedbackDisplayLayout.Landscape); } } diff --git a/test/xUnit/csharp/test_Feedback.cs b/test/xUnit/csharp/test_Feedback.cs index b3c1fb08ecf..2c90118be55 100644 --- a/test/xUnit/csharp/test_Feedback.cs +++ b/test/xUnit/csharp/test_Feedback.cs @@ -43,7 +43,7 @@ private MyFeedback(Guid id, string name, string description, bool delay) public string Description => _description; - public FeedbackItem GetFeedback(string commandLine, ErrorRecord errorRecord, CancellationToken token) + public FeedbackItem GetFeedback(FeedbackContext context, CancellationToken token) { if (_delay) { @@ -54,7 +54,7 @@ public FeedbackItem GetFeedback(string commandLine, ErrorRecord errorRecord, Can return new FeedbackItem( "slow-feedback-caption", - new List { $"{commandLine}+{errorRecord.FullyQualifiedErrorId}" }); + new List { $"{context.CommandLine}+{context.LastError.FullyQualifiedErrorId}" }); } } diff --git a/test/xUnit/csharp/test_Subsystem.cs b/test/xUnit/csharp/test_Subsystem.cs index 2889bab378b..41f7b6c9e69 100644 --- a/test/xUnit/csharp/test_Subsystem.cs +++ b/test/xUnit/csharp/test_Subsystem.cs @@ -65,7 +65,7 @@ private MyCompositeSubsystem(Guid id) #region IFeedbackProvider - public FeedbackItem GetFeedback(string commandLine, ErrorRecord errorRecord, CancellationToken token) => new FeedbackItem("nothing", null); + public FeedbackItem GetFeedback(FeedbackContext context, CancellationToken token) => new FeedbackItem("nothing", null); #endregion