From fc4c9cbfd716f975b202afb36afd0176c5af9bbb Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Thu, 20 Aug 2020 17:41:44 -0700 Subject: [PATCH] Initial work of the subsystem plugin model (for minimal powershell) (#13186) --- .../PowerShellCore_format_ps1xml.cs | 22 ++ .../CommandCompletion/CompletionAnalysis.cs | 117 ++++-- .../ExperimentalFeature.cs | 3 + .../engine/InitialSessionState.cs | 7 + .../CommandPrediction/CommandPrediction.cs | 170 +++++++++ .../CommandPrediction/ICommandPredictor.cs | 163 +++++++++ .../Subsystem/Commands/GetSubsystemCommand.cs | 54 +++ .../engine/Subsystem/ISubsystem.cs | 62 ++++ .../engine/Subsystem/SubsystemInfo.cs | 346 ++++++++++++++++++ .../engine/Subsystem/SubsystemManager.cs | 282 ++++++++++++++ .../engine/Utils.cs | 30 ++ .../resources/SubsystemStrings.resx | 156 ++++++++ .../engine/Help/HelpSystem.Tests.ps1 | 3 +- test/xUnit/csharp/test_Prediction.cs | 193 ++++++++++ test/xUnit/csharp/test_Subsystem.cs | 189 ++++++++++ tools/packaging/packaging.psm1 | 35 +- 16 files changed, 1787 insertions(+), 45 deletions(-) create mode 100644 src/System.Management.Automation/engine/Subsystem/CommandPrediction/CommandPrediction.cs create mode 100644 src/System.Management.Automation/engine/Subsystem/CommandPrediction/ICommandPredictor.cs create mode 100644 src/System.Management.Automation/engine/Subsystem/Commands/GetSubsystemCommand.cs create mode 100644 src/System.Management.Automation/engine/Subsystem/ISubsystem.cs create mode 100644 src/System.Management.Automation/engine/Subsystem/SubsystemInfo.cs create mode 100644 src/System.Management.Automation/engine/Subsystem/SubsystemManager.cs create mode 100644 src/System.Management.Automation/resources/SubsystemStrings.resx create mode 100644 test/xUnit/csharp/test_Prediction.cs create mode 100644 test/xUnit/csharp/test_Subsystem.cs diff --git a/src/System.Management.Automation/FormatAndOutput/DefaultFormatters/PowerShellCore_format_ps1xml.cs b/src/System.Management.Automation/FormatAndOutput/DefaultFormatters/PowerShellCore_format_ps1xml.cs index b7d8f46b4b..5729605fe9 100644 --- a/src/System.Management.Automation/FormatAndOutput/DefaultFormatters/PowerShellCore_format_ps1xml.cs +++ b/src/System.Management.Automation/FormatAndOutput/DefaultFormatters/PowerShellCore_format_ps1xml.cs @@ -133,6 +133,10 @@ namespace System.Management.Automation.Runspaces "System.Management.Automation.PSDriveInfo", ViewsOf_System_Management_Automation_PSDriveInfo()); + yield return new ExtendedTypeDefinition( + "System.Management.Automation.Subsystem.SubsystemInfo", + ViewsOf_System_Management_Automation_Subsystem_SubsystemInfo()); + yield return new ExtendedTypeDefinition( "System.Management.Automation.ShellVariable", ViewsOf_System_Management_Automation_ShellVariable()); @@ -728,6 +732,24 @@ namespace System.Management.Automation.Runspaces .EndList()); } + private static IEnumerable ViewsOf_System_Management_Automation_Subsystem_SubsystemInfo() + { + yield return new FormatViewDefinition( + "System.Management.Automation.Subsystem.SubsystemInfo", + TableControl.Create() + .AddHeader(Alignment.Left, width: 17, label: "Kind") + .AddHeader(Alignment.Left, width: 15, label: "SubsystemType") + .AddHeader(Alignment.Right, width: 12, label: "IsRegistered") + .AddHeader(Alignment.Left, label: "Implementations") + .StartRowDefinition() + .AddPropertyColumn("Kind") + .AddScriptBlockColumn("$_.SubsystemType.Name") + .AddPropertyColumn("IsRegistered") + .AddPropertyColumn("Implementations") + .EndRowDefinition() + .EndTable()); + } + private static IEnumerable ViewsOf_System_Management_Automation_ShellVariable() { yield return new FormatViewDefinition("ShellVariable", diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs index ed0c1309cc..db32f5de18 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -94,6 +95,58 @@ namespace System.Management.Automation return cursor.Offset < extent.StartOffset || cursor.Offset > extent.EndOffset; } + internal readonly struct AstAnalysisContext + { + internal AstAnalysisContext(Token tokenAtCursor, Token tokenBeforeCursor, List relatedAsts, int replacementIndex) + { + TokenAtCursor = tokenAtCursor; + TokenBeforeCursor = tokenBeforeCursor; + RelatedAsts = relatedAsts; + ReplacementIndex = replacementIndex; + } + + internal readonly Token TokenAtCursor; + internal readonly Token TokenBeforeCursor; + internal readonly List RelatedAsts; + internal readonly int ReplacementIndex; + } + + internal static AstAnalysisContext ExtractAstContext(Ast inputAst, Token[] inputTokens, IScriptPosition cursor) + { + bool adjustLineAndColumn = false; + IScriptPosition positionForAstSearch = cursor; + + Token tokenBeforeCursor = null; + Token tokenAtCursor = InterstingTokenAtCursorOrDefault(inputTokens, cursor); + if (tokenAtCursor == null) + { + tokenBeforeCursor = InterstingTokenBeforeCursorOrDefault(inputTokens, cursor); + if (tokenBeforeCursor != null) + { + positionForAstSearch = tokenBeforeCursor.Extent.EndScriptPosition; + adjustLineAndColumn = true; + } + } + else + { + var stringExpandableToken = tokenAtCursor as StringExpandableToken; + if (stringExpandableToken?.NestedTokens != null) + { + tokenAtCursor = InterstingTokenAtCursorOrDefault(stringExpandableToken.NestedTokens, cursor) ?? stringExpandableToken; + } + } + + int replacementIndex = adjustLineAndColumn ? cursor.Offset : 0; + List relatedAsts = AstSearcher.FindAll( + inputAst, + ast => IsCursorWithinOrJustAfterExtent(positionForAstSearch, ast.Extent), + searchNestedScriptBlocks: true).ToList(); + + Diagnostics.Assert(tokenAtCursor == null || tokenBeforeCursor == null, "Only one of these tokens can be non-null"); + + return new AstAnalysisContext(tokenAtCursor, tokenBeforeCursor, relatedAsts, replacementIndex); + } + internal CompletionContext CreateCompletionContext(PowerShell powerShell) { var typeInferenceContext = new TypeInferenceContext(powerShell); @@ -107,48 +160,24 @@ namespace System.Management.Automation private CompletionContext InitializeCompletionContext(TypeInferenceContext typeInferenceContext) { - Token tokenBeforeCursor = null; - IScriptPosition positionForAstSearch = _cursorPosition; - var adjustLineAndColumn = false; - var tokenAtCursor = InterstingTokenAtCursorOrDefault(_tokens, _cursorPosition); - if (tokenAtCursor == null) - { - tokenBeforeCursor = InterstingTokenBeforeCursorOrDefault(_tokens, _cursorPosition); - if (tokenBeforeCursor != null) - { - positionForAstSearch = tokenBeforeCursor.Extent.EndScriptPosition; - adjustLineAndColumn = true; - } - } - else - { - var stringExpandableToken = tokenAtCursor as StringExpandableToken; - if (stringExpandableToken?.NestedTokens != null) - { - tokenAtCursor = InterstingTokenAtCursorOrDefault(stringExpandableToken.NestedTokens, _cursorPosition) ?? stringExpandableToken; - } - } - - var asts = AstSearcher.FindAll(_ast, ast => IsCursorWithinOrJustAfterExtent(positionForAstSearch, ast.Extent), searchNestedScriptBlocks: true).ToList(); - - Diagnostics.Assert(tokenAtCursor == null || tokenBeforeCursor == null, "Only one of these tokens can be non-null"); + var astContext = ExtractAstContext(_ast, _tokens, _cursorPosition); if (typeInferenceContext.CurrentTypeDefinitionAst == null) { - typeInferenceContext.CurrentTypeDefinitionAst = Ast.GetAncestorTypeDefinitionAst(asts.Last()); + typeInferenceContext.CurrentTypeDefinitionAst = Ast.GetAncestorTypeDefinitionAst(astContext.RelatedAsts.Last()); } ExecutionContext executionContext = typeInferenceContext.ExecutionContext; return new CompletionContext { - TokenAtCursor = tokenAtCursor, - TokenBeforeCursor = tokenBeforeCursor, - CursorPosition = _cursorPosition, - RelatedAsts = asts, Options = _options, + CursorPosition = _cursorPosition, + TokenAtCursor = astContext.TokenAtCursor, + TokenBeforeCursor = astContext.TokenBeforeCursor, + RelatedAsts = astContext.RelatedAsts, + ReplacementIndex = astContext.ReplacementIndex, ExecutionContext = executionContext, - ReplacementIndex = adjustLineAndColumn ? _cursorPosition.Offset : 0, TypeInferenceContext = typeInferenceContext, Helper = typeInferenceContext.Helper, CustomArgumentCompleters = executionContext.CustomArgumentCompleters, @@ -156,14 +185,32 @@ namespace System.Management.Automation }; } - private static Token InterstingTokenAtCursorOrDefault(IEnumerable tokens, IScriptPosition cursorPosition) + private static Token InterstingTokenAtCursorOrDefault(IReadOnlyList tokens, IScriptPosition cursorPosition) { - return tokens.LastOrDefault(token => IsCursorWithinOrJustAfterExtent(cursorPosition, token.Extent) && IsInterestingToken(token)); + for (int i = tokens.Count - 1; i >= 0; --i) + { + Token token = tokens[i]; + if (IsCursorWithinOrJustAfterExtent(cursorPosition, token.Extent) && IsInterestingToken(token)) + { + return token; + } + } + + return null; } - private static Token InterstingTokenBeforeCursorOrDefault(IEnumerable tokens, IScriptPosition cursorPosition) + private static Token InterstingTokenBeforeCursorOrDefault(IReadOnlyList tokens, IScriptPosition cursorPosition) { - return tokens.LastOrDefault(token => IsCursorAfterExtent(cursorPosition, token.Extent) && IsInterestingToken(token)); + for (int i = tokens.Count - 1; i >= 0; --i) + { + Token token = tokens[i]; + if (IsCursorAfterExtent(cursorPosition, token.Extent) && IsInterestingToken(token)) + { + return token; + } + } + + return null; } private static Ast GetLastAstAtCursor(ScriptBlockAst scriptBlockAst, IScriptPosition cursorPosition) diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index e44ea07962..603ff2f34b 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -126,6 +126,9 @@ namespace System.Management.Automation new ExperimentalFeature( name: "PSNotApplyErrorActionToStderr", description: "Don't have $ErrorActionPreference affect stderr output"), + new ExperimentalFeature( + name: "PSSubsystemPluginModel", + description: "A plugin model for registering and un-registering PowerShell subsystems"), }; EngineExperimentalFeatures = new ReadOnlyCollection(engineFeatures); diff --git a/src/System.Management.Automation/engine/InitialSessionState.cs b/src/System.Management.Automation/engine/InitialSessionState.cs index 0d2e1274aa..2a4f10d9fc 100644 --- a/src/System.Management.Automation/engine/InitialSessionState.cs +++ b/src/System.Management.Automation/engine/InitialSessionState.cs @@ -5392,6 +5392,12 @@ end { { "Out-LineOutput", new SessionStateCmdletEntry("Out-LineOutput", typeof(OutLineOutputCommand), helpFile) }, { "Format-Default", new SessionStateCmdletEntry("Format-Default", typeof(FormatDefaultCommand), helpFile) }, }; + + if (ExperimentalFeature.IsEnabled("PSSubsystemPluginModel")) + { + cmdlets.Add("Get-Subsystem", new SessionStateCmdletEntry("Get-Subsystem", typeof(Subsystem.GetSubsystemCommand), helpFile)); + } + foreach (var val in cmdlets.Values) { val.SetPSSnapIn(psSnapInInfo); @@ -5408,6 +5414,7 @@ end { { "Function", new SessionStateProviderEntry("Function", typeof(FunctionProvider), helpFile) }, { "Variable", new SessionStateProviderEntry("Variable", typeof(VariableProvider), helpFile) }, }; + foreach (var val in providers.Values) { val.SetPSSnapIn(psSnapInInfo); diff --git a/src/System.Management.Automation/engine/Subsystem/CommandPrediction/CommandPrediction.cs b/src/System.Management.Automation/engine/Subsystem/CommandPrediction/CommandPrediction.cs new file mode 100644 index 0000000000..f77072ea37 --- /dev/null +++ b/src/System.Management.Automation/engine/Subsystem/CommandPrediction/CommandPrediction.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Management.Automation.Internal; +using System.Management.Automation.Language; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Management.Automation.Subsystem +{ + /// + /// The class represents the prediction result from a predictor. + /// + public sealed class PredictionResult + { + /// + /// Gets the Id of the predictor. + /// + public Guid Id { get; } + + /// + /// Gets the name of the predictor. + /// + public string Name { get; } + + /// + /// Gets the suggestions. + /// + public IReadOnlyList Suggestions { get; } + + internal PredictionResult(Guid id, string name, List suggestions) + { + Id = id; + Name = name; + Suggestions = suggestions; + } + } + + /// + /// Provides a set of possible predictions for given input. + /// + public static class CommandPrediction + { + /// + /// Collect the predictive suggestions from registered predictors using the default timeout. + /// + /// The object from parsing the current command line input. + /// The objects from parsing the current command line input. + /// A list of objects. + public static Task?> PredictInput(Ast ast, Token[] astTokens) + { + return PredictInput(ast, astTokens, millisecondsTimeout: 20); + } + + /// + /// Collect the predictive suggestions from registered predictors using the specified timeout. + /// + /// The object from parsing the current command line input. + /// The objects from parsing the current command line input. + /// The milliseconds to timeout. + /// A list of objects. + public static async Task?> PredictInput(Ast ast, Token[] astTokens, int millisecondsTimeout) + { + Requires.Condition(millisecondsTimeout > 0, nameof(millisecondsTimeout)); + + var predictors = SubsystemManager.GetSubsystems(); + if (predictors.Count == 0) + { + return null; + } + + var context = new PredictionContext(ast, astTokens); + var tasks = new Task[predictors.Count]; + using var cancellationSource = new CancellationTokenSource(); + + for (int i = 0; i < predictors.Count; i++) + { + ICommandPredictor predictor = predictors[i]; + + tasks[i] = Task.Factory.StartNew( + state => { + var predictor = (ICommandPredictor)state!; + List? texts = predictor.GetSuggestion(context, cancellationSource.Token); + return texts?.Count > 0 ? new PredictionResult(predictor.Id, predictor.Name, texts) : null; + }, + predictor, + cancellationSource.Token, + TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default); + } + + await Task.WhenAny( + Task.WhenAll(tasks), + Task.Delay(millisecondsTimeout, cancellationSource.Token)).ConfigureAwait(false); + cancellationSource.Cancel(); + + var results = new List(predictors.Count); + foreach (Task task in tasks) + { + if (task.IsCompletedSuccessfully) + { + PredictionResult? result = task.Result; + if (result != null) + { + results.Add(result); + } + } + } + + return results; + } + + /// + /// Allow registered predictors to do early processing when a command line is accepted. + /// + /// History command lines provided as references for prediction. + public static void OnCommandLineAccepted(IReadOnlyList history) + { + Requires.NotNull(history, nameof(history)); + + var predictors = SubsystemManager.GetSubsystems(); + if (predictors.Count == 0) + { + return; + } + + foreach (ICommandPredictor predictor in predictors) + { + if (predictor.SupportEarlyProcessing) + { + ThreadPool.QueueUserWorkItem( + state => state.StartEarlyProcessing(history), + predictor, + preferLocal: false); + } + } + } + + /// + /// Send feedback to predictors about their last suggestions. + /// + /// The identifier of the predictor whose prediction result was accepted. + /// The accepted suggestion text. + public static void OnSuggestionAccepted(Guid predictorId, string suggestionText) + { + Requires.NotNullOrEmpty(suggestionText, nameof(suggestionText)); + + var predictors = SubsystemManager.GetSubsystems(); + if (predictors.Count == 0) + { + return; + } + + foreach (ICommandPredictor predictor in predictors) + { + if (predictor.AcceptFeedback && predictor.Id == predictorId) + { + ThreadPool.QueueUserWorkItem( + state => state.OnSuggestionAccepted(suggestionText), + predictor, + preferLocal: false); + } + } + } + } +} diff --git a/src/System.Management.Automation/engine/Subsystem/CommandPrediction/ICommandPredictor.cs b/src/System.Management.Automation/engine/Subsystem/CommandPrediction/ICommandPredictor.cs new file mode 100644 index 0000000000..0edd768cf8 --- /dev/null +++ b/src/System.Management.Automation/engine/Subsystem/CommandPrediction/ICommandPredictor.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Management.Automation.Internal; +using System.Management.Automation.Language; +using System.Threading; + +namespace System.Management.Automation.Subsystem +{ + /// + /// Interface for implementing a predictor plugin. + /// + public interface ICommandPredictor : ISubsystem + { + /// + /// Default implementation. No function is required for a predictor. + /// + Dictionary? ISubsystem.FunctionsToDefine => null; + + /// + /// Default implementation for `ISubsystem.Kind`. + /// + SubsystemKind ISubsystem.Kind => SubsystemKind.CommandPredictor; + + /// + /// Gets a value indicating whether the predictor supports early processing. + /// + bool SupportEarlyProcessing { get; } + + /// + /// Gets a value indicating whether the predictor accepts feedback about the previous suggestion. + /// + bool AcceptFeedback { get; } + + /// + /// A command line was accepted to execute. + /// The predictor can start processing early as needed with the latest history. + /// + /// History command lines provided as references for prediction. + void StartEarlyProcessing(IReadOnlyList history); + + /// + /// The suggestion given by the predictor was accepted. + /// + /// The accepted suggestion text. + void OnSuggestionAccepted(string acceptedSuggestion); + + /// + /// Get the predictive suggestions. + /// + /// The object to be used for prediction. + /// The cancellation token to cancel the prediction. + /// A list of predictive suggestions. + List? GetSuggestion(PredictionContext context, CancellationToken cancellationToken); + } + + /// + /// Context information about the user input. + /// + public class PredictionContext + { + /// + /// Gets the abstract syntax tree (AST) generated from parsing the user input. + /// + public Ast InputAst { get; } + + /// + /// Gets the tokens generated from parsing the user input. + /// + public IReadOnlyList InputTokens { get; } + + /// + /// Gets the cursor position, which is assumed always at the end of the input line. + /// + public IScriptPosition CursorPosition { get; } + + /// + /// Gets the token at the cursor. + /// + public Token? TokenAtCursor { get; } + + /// + /// Gets all ASTs that are related to the cursor position, + /// which is assumed always at the end of the input line. + /// + public IReadOnlyList RelatedAsts { get; } + + /// + /// Initializes a new instance of the class from the AST and tokens that represent the user input. + /// + /// The object from parsing the current command line input. + /// The objects from parsing the current command line input. + public PredictionContext(Ast inputAst, Token[] inputTokens) + { + Requires.NotNull(inputAst, nameof(inputAst)); + Requires.NotNull(inputTokens, nameof(inputTokens)); + + var cursor = inputAst.Extent.EndScriptPosition; + var astContext = CompletionAnalysis.ExtractAstContext(inputAst, inputTokens, cursor); + + InputAst = inputAst; + InputTokens = inputTokens; + CursorPosition = cursor; + TokenAtCursor = astContext.TokenAtCursor; + RelatedAsts = astContext.RelatedAsts; + } + + /// + /// Creates a context instance from the user input line. + /// + /// The user input. + /// A object. + public static PredictionContext Create(string input) + { + Requires.NotNullOrEmpty(input, nameof(input)); + + Ast ast = Parser.ParseInput(input, out Token[] tokens, out _); + return new PredictionContext(ast, tokens); + } + } + + /// + /// The class represents a predictive suggestion generated by a predictor. + /// + public sealed class PredictiveSuggestion + { + /// + /// Gets the suggestion. + /// + public string SuggestionText { get; } + + /// + /// Gets the tooltip of the suggestion. + /// + public string? ToolTip { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The predictive suggestion text. + public PredictiveSuggestion(string suggestion) + : this(suggestion, toolTip: null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The predictive suggestion text. + /// The tooltip of the suggestion. + public PredictiveSuggestion(string suggestion, string? toolTip) + { + Requires.NotNullOrEmpty(suggestion, nameof(suggestion)); + + SuggestionText = suggestion; + ToolTip = toolTip; + } + } +} diff --git a/src/System.Management.Automation/engine/Subsystem/Commands/GetSubsystemCommand.cs b/src/System.Management.Automation/engine/Subsystem/Commands/GetSubsystemCommand.cs new file mode 100644 index 0000000000..666cf9c556 --- /dev/null +++ b/src/System.Management.Automation/engine/Subsystem/Commands/GetSubsystemCommand.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace System.Management.Automation.Subsystem +{ + /// + /// Implementation of 'Get-Subsystem' cmdlet. + /// + [Experimental("PSSubsystemPluginModel", ExperimentAction.Show)] + [Cmdlet(VerbsCommon.Get, "Subsystem", DefaultParameterSetName = AllSet)] + [OutputType(typeof(SubsystemInfo))] + public sealed class GetSubsystemCommand : PSCmdlet + { + private const string AllSet = "GetAllSet"; + private const string TypeSet = "GetByTypeSet"; + private const string KindSet = "GetByKindSet"; + + /// + /// Gets or sets a concrete subsystem kind. + /// + [Parameter(Mandatory = true, ParameterSetName = KindSet, ValueFromPipeline = true)] + public SubsystemKind Kind { get; set; } + + /// + /// Gets or sets the interface or abstract class type of a concrete subsystem. + /// + [Parameter(Mandatory = true, ParameterSetName = TypeSet, ValueFromPipeline = true)] + public Type? SubsystemType { get; set; } + + /// + /// ProcessRecord implementation. + /// + protected override void ProcessRecord() + { + switch (ParameterSetName) + { + case AllSet: + WriteObject(SubsystemManager.GetAllSubsystemInfo()); + break; + case KindSet: + WriteObject(SubsystemManager.GetSubsystemInfo(Kind)); + break; + case TypeSet: + WriteObject(SubsystemManager.GetSubsystemInfo(SubsystemType!)); + break; + + default: + throw new InvalidOperationException("New parameter set is added but the switch statement is not updated."); + } + } + } +} diff --git a/src/System.Management.Automation/engine/Subsystem/ISubsystem.cs b/src/System.Management.Automation/engine/Subsystem/ISubsystem.cs new file mode 100644 index 0000000000..e19f37f06c --- /dev/null +++ b/src/System.Management.Automation/engine/Subsystem/ISubsystem.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; + +namespace System.Management.Automation.Subsystem +{ + /// + /// Define the kinds of subsystems. + /// + public enum SubsystemKind + { + /// + /// Component that provides predictive suggestions to commandline input. + /// + CommandPredictor = 1, + } + + /// + /// Define the base interface to implement a subsystem. + /// The API contracts for specific subsystems are defined within the specific interfaces/abstract classes that implements this interface. + /// + /// + /// There are two purposes to have the internal member `Kind` declared in 'ISubsystem': + /// 1. Make the mapping from an `ISubsystem` implementation to the `SubsystemKind` easy; + /// 2. Make sure a user cannot directly implement 'ISubsystem', but have to derive from one of the concrete subsystem interface or abstract class. + /// + /// The internal member needs to have a default implementation defined by the specific subsystem interfaces or abstract class, + /// because it should be the same for a specific kind of subsystem. + /// + public interface ISubsystem + { + /// + /// Gets the unique identifier for a subsystem implementation. + /// + Guid Id { get; } + + /// + /// Gets the name of a subsystem implementation. + /// + string Name { get; } + + /// + /// Gets the description of a subsystem implementation. + /// + string Description { get; } + + /// + /// Gets a dictionary that contains the functions to be defined at the global scope of a PowerShell session. + /// Key: function name; Value: function script. + /// + Dictionary? FunctionsToDefine { get; } + + /// + /// Gets the subsystem kind. + /// + internal SubsystemKind Kind { get; } + } +} diff --git a/src/System.Management.Automation/engine/Subsystem/SubsystemInfo.cs b/src/System.Management.Automation/engine/Subsystem/SubsystemInfo.cs new file mode 100644 index 0000000000..c12eedde39 --- /dev/null +++ b/src/System.Management.Automation/engine/Subsystem/SubsystemInfo.cs @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Management.Automation.Internal; + +namespace System.Management.Automation.Subsystem +{ + /// + /// Class used to represent the metadata and state of a subsystem. + /// + public abstract class SubsystemInfo + { + #region "Metadata of a Subsystem (public)" + + /// + /// The kind of a concrete subsystem. + /// + public SubsystemKind Kind { get; private set; } + + /// + /// The type of a concrete subsystem. + /// + public Type SubsystemType { get; private set; } + + /// + /// Indicate whether the subsystem allows to unregister an implementation. + /// + public bool AllowUnregistration { get; private set; } + + /// + /// Indicate whether the subsystem allows to have multiple implementations registered. + /// + public bool AllowMultipleRegistration { get; private set; } + + /// + /// Gets the names of the required cmdlets that have to be implemented by the subsystem implementation. + /// + public ReadOnlyCollection RequiredCmdlets { get; private set; } + + /// + /// Gets the names of the required functions that have to be implemented by the subsystem implementation. + /// + public ReadOnlyCollection RequiredFunctions { get; private set; } + + // /// + // /// A subsystem may depend on or more other subsystems. + // /// Maybe add a 'DependsOn' member? + // /// This can be validated when registering a subsystem implementation, + // /// to make sure its prerequisites have already been registered. + // /// + // public ReadOnlyCollection DependsOn { get; private set; } + + #endregion + + #region "State of a Subsystem (public)" + + /// + /// Indicate whether there is any implementation registered to the subsystem. + /// + public bool IsRegistered => _cachedImplInfos.Count > 0; + + /// + /// Get the information about the registered implementations. + /// + public ReadOnlyCollection Implementations => _cachedImplInfos; + + #endregion + + #region "private/internal instance members" + + private protected readonly object _syncObj; + private protected ReadOnlyCollection _cachedImplInfos; + + private protected SubsystemInfo(SubsystemKind kind, Type subsystemType) + { + _syncObj = new object(); + _cachedImplInfos = Utils.EmptyReadOnlyCollection(); + + Kind = kind; + SubsystemType = subsystemType; + AllowUnregistration = false; + AllowMultipleRegistration = false; + RequiredCmdlets = Utils.EmptyReadOnlyCollection(); + RequiredFunctions = Utils.EmptyReadOnlyCollection(); + } + + private protected abstract void AddImplementation(ISubsystem rawImpl); + private protected abstract ISubsystem RemoveImplementation(Guid id); + + internal void RegisterImplementation(ISubsystem impl) + { + AddImplementation(impl); + } + + internal ISubsystem UnregisterImplementation(Guid id) + { + return RemoveImplementation(id); + } + + #endregion + + #region "Static factory overloads" + + internal static SubsystemInfo Create(SubsystemKind kind) + where TConcreteSubsystem : class, ISubsystem + { + return new SubsystemInfoImpl(kind); + } + + internal static SubsystemInfo Create( + SubsystemKind kind, + bool allowUnregistration, + bool allowMultipleRegistration) where TConcreteSubsystem : class, ISubsystem + { + return new SubsystemInfoImpl(kind) + { + AllowUnregistration = allowUnregistration, + AllowMultipleRegistration = allowMultipleRegistration, + }; + } + + internal static SubsystemInfo Create( + SubsystemKind kind, + bool allowUnregistration, + bool allowMultipleRegistration, + ReadOnlyCollection requiredCmdlets, + ReadOnlyCollection requiredFunctions) where TConcreteSubsystem : class, ISubsystem + { + if (allowMultipleRegistration && + (requiredCmdlets.Count > 0 || requiredFunctions.Count > 0)) + { + throw new ArgumentException( + StringUtil.Format( + SubsystemStrings.InvalidSubsystemInfo, + kind.ToString())); + } + + return new SubsystemInfoImpl(kind) + { + AllowUnregistration = allowUnregistration, + AllowMultipleRegistration = allowMultipleRegistration, + RequiredCmdlets = requiredCmdlets, + RequiredFunctions = requiredFunctions, + }; + } + + #endregion + + #region "ImplementationInfo" + + /// + /// Information about an implementation of a subsystem. + /// + public class ImplementationInfo + { + internal ImplementationInfo(ISubsystem implementation) + { + Id = implementation.Id; + Kind = implementation.Kind; + Name = implementation.Name; + Description = implementation.Description; + ImplementationType = implementation.GetType(); + } + + /// + /// Gets the unique identifier for a subsystem implementation. + /// + public Guid Id { get; } + + /// + /// Gets the kind of subsystem. + /// + public SubsystemKind Kind { get; } + + /// + /// Gets the name of a subsystem implementation. + /// + public string Name { get; } + + /// + /// Gets the description of a subsystem implementation. + /// + public string Description { get; } + + /// + /// Gets the implementation type. + /// + public Type ImplementationType { get; } + } + + #endregion + } + + internal sealed class SubsystemInfoImpl : SubsystemInfo + where TConcreteSubsystem : class, ISubsystem + { + private ReadOnlyCollection _registeredImpls; + + internal SubsystemInfoImpl(SubsystemKind kind) + : base(kind, typeof(TConcreteSubsystem)) + { + _registeredImpls = Utils.EmptyReadOnlyCollection(); + } + + /// + /// The 'add' and 'remove' operations are implemented in a way to optimize the 'reading' operation, + /// so that reading is lock-free and allocation-free, at the cost of O(n) copy in 'add' and 'remove' + /// ('n' is the number of registered implementations). + /// + /// + /// In the subsystem scenario, registration operations will be minimum, and in most cases, the registered + /// implementation will never be unregistered, so optimization for reading is more important. + /// + private protected override void AddImplementation(ISubsystem rawImpl) + { + lock (_syncObj) + { + var impl = (TConcreteSubsystem)rawImpl; + + if (_registeredImpls.Count == 0) + { + _registeredImpls = new ReadOnlyCollection(new[] { impl }); + _cachedImplInfos = new ReadOnlyCollection(new[] { new ImplementationInfo(impl) }); + return; + } + + if (!AllowMultipleRegistration) + { + throw new InvalidOperationException( + StringUtil.Format( + SubsystemStrings.MultipleRegistrationNotAllowed, + Kind.ToString())); + } + + foreach (TConcreteSubsystem item in _registeredImpls) + { + if (item.Id == impl.Id) + { + throw new InvalidOperationException( + StringUtil.Format( + SubsystemStrings.ImplementationAlreadyRegistered, + impl.Id, + Kind.ToString())); + } + } + + var list = new List(_registeredImpls.Count + 1); + list.AddRange(_registeredImpls); + list.Add(impl); + + _registeredImpls = new ReadOnlyCollection(list); + _cachedImplInfos = new ReadOnlyCollection(list.ConvertAll(s => new ImplementationInfo(s))); + } + } + + /// + /// The 'add' and 'remove' operations are implemented in a way to optimize the 'reading' operation, + /// so that reading is lock-free and allocation-free, at the cost of O(n) copy in 'add' and 'remove' + /// ('n' is the number of registered implementations). + /// + /// + /// In the subsystem scenario, registration operations will be minimum, and in most cases, the registered + /// implementation will never be unregistered, so optimization for reading is more important. + /// + private protected override ISubsystem RemoveImplementation(Guid id) + { + if (!AllowUnregistration) + { + throw new InvalidOperationException( + StringUtil.Format( + SubsystemStrings.UnregistrationNotAllowed, + Kind.ToString())); + } + + lock (_syncObj) + { + if (_registeredImpls.Count == 0) + { + throw new InvalidOperationException( + StringUtil.Format( + SubsystemStrings.NoImplementationRegistered, + Kind.ToString())); + } + + int index = -1; + for (int i = 0; i < _registeredImpls.Count; i++) + { + if (_registeredImpls[i].Id == id) + { + index = i; + break; + } + } + + if (index == -1) + { + throw new InvalidOperationException( + StringUtil.Format( + SubsystemStrings.ImplementationNotFound, + id.ToString())); + } + + ISubsystem target = _registeredImpls[index]; + if (_registeredImpls.Count == 1) + { + _registeredImpls = Utils.EmptyReadOnlyCollection(); + _cachedImplInfos = Utils.EmptyReadOnlyCollection(); + } + else + { + var list = new List(_registeredImpls.Count - 1); + for (int i = 0; i < _registeredImpls.Count; i++) + { + if (index == i) + { + continue; + } + + list.Add(_registeredImpls[i]); + } + + _registeredImpls = new ReadOnlyCollection(list); + _cachedImplInfos = new ReadOnlyCollection(list.ConvertAll(s => new ImplementationInfo(s))); + } + + return target; + } + } + + internal TConcreteSubsystem? GetImplementation() + { + var localRef = _registeredImpls; + return localRef.Count > 0 ? localRef[localRef.Count - 1] : null; + } + + internal ReadOnlyCollection GetAllImplementations() + { + return _registeredImpls; + } + } +} diff --git a/src/System.Management.Automation/engine/Subsystem/SubsystemManager.cs b/src/System.Management.Automation/engine/Subsystem/SubsystemManager.cs new file mode 100644 index 0000000000..1e32436e6d --- /dev/null +++ b/src/System.Management.Automation/engine/Subsystem/SubsystemManager.cs @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Management.Automation.Internal; + +namespace System.Management.Automation.Subsystem +{ + /// + /// Class used to manage subsystems. + /// + public static class SubsystemManager + { + private static readonly ReadOnlyCollection s_subsystems; + private static readonly ReadOnlyDictionary s_subSystemTypeMap; + private static readonly ReadOnlyDictionary s_subSystemKindMap; + + static SubsystemManager() + { + var subsystems = new SubsystemInfo[] + { + SubsystemInfo.Create( + SubsystemKind.CommandPredictor, + allowUnregistration: true, + allowMultipleRegistration: true), + }; + + var subSystemTypeMap = new Dictionary(subsystems.Length); + var subSystemKindMap = new Dictionary(subsystems.Length); + + foreach (var subsystem in subsystems) + { + subSystemTypeMap.Add(subsystem.SubsystemType, subsystem); + subSystemKindMap.Add(subsystem.Kind, subsystem); + } + + s_subsystems = new ReadOnlyCollection(subsystems); + s_subSystemTypeMap = new ReadOnlyDictionary(subSystemTypeMap); + s_subSystemKindMap = new ReadOnlyDictionary(subSystemKindMap); + } + + #region internal - Retrieve subsystem proxy object + + /// + /// Get the proxy object registered for a specific subsystem. + /// Return null when the given subsystem is not registered. + /// + /// + /// Design point: + /// The implemnentation proxy object is not supposed to expose to users. + /// Users shouldn't depend on a implementation proxy object directly, but instead should depend on PowerShell APIs. + /// + /// Example: if a user want to use prediction functionality, he/she should use the PowerShell prediction API instead of + /// directly interacting with the implementation proxy object of `IPrediction`. + /// + /// The concrete subsystem base type. + /// The most recently registered implmentation object of the concrete subsystem. + internal static TConcreteSubsystem? GetSubsystem() + where TConcreteSubsystem : class, ISubsystem + { + if (s_subSystemTypeMap.TryGetValue(typeof(TConcreteSubsystem), out SubsystemInfo? subsystemInfo)) + { + var subsystemInfoImpl = (SubsystemInfoImpl)subsystemInfo; + return subsystemInfoImpl.GetImplementation(); + } + + throw new ArgumentException( + StringUtil.Format( + SubsystemStrings.SubsystemTypeUnknown, + typeof(TConcreteSubsystem).FullName)); + } + + /// + /// Get all the proxy objects registered for a specific subsystem. + /// Return an empty collection when the given subsystem is not registered. + /// + /// The concrete subsystem base type. + /// A readonly collection of all implmentation objects registered for the concrete subsystem. + internal static ReadOnlyCollection GetSubsystems() + where TConcreteSubsystem : class, ISubsystem + { + if (s_subSystemTypeMap.TryGetValue(typeof(TConcreteSubsystem), out SubsystemInfo? subsystemInfo)) + { + var subsystemInfoImpl = (SubsystemInfoImpl)subsystemInfo; + return subsystemInfoImpl.GetAllImplementations(); + } + + throw new ArgumentException( + StringUtil.Format( + SubsystemStrings.SubsystemTypeUnknown, + typeof(TConcreteSubsystem).FullName)); + } + + #endregion + + #region public - Subsystem metadata + + /// + /// Get the information about all subsystems. + /// + /// A readonly collection of all objects. + public static ReadOnlyCollection GetAllSubsystemInfo() + { + return s_subsystems; + } + + /// + /// Get the information about a subsystem by the subsystem type. + /// + /// The base type of a specific concrete subsystem. + /// The object that represents the concrete subsystem. + public static SubsystemInfo GetSubsystemInfo(Type subsystemType) + { + Requires.NotNull(subsystemType, nameof(subsystemType)); + + if (s_subSystemTypeMap.TryGetValue(subsystemType, out SubsystemInfo? subsystemInfo)) + { + return subsystemInfo; + } + + throw new ArgumentException( + StringUtil.Format( + SubsystemStrings.SubsystemTypeUnknown, + subsystemType.FullName)); + } + + /// + /// Get the information about a subsystem by the subsystem kind. + /// + /// A specific . + /// The object that represents the concrete subsystem. + public static SubsystemInfo GetSubsystemInfo(SubsystemKind kind) + { + if (s_subSystemKindMap.TryGetValue(kind, out SubsystemInfo? subsystemInfo)) + { + return subsystemInfo; + } + + throw new ArgumentException( + StringUtil.Format( + SubsystemStrings.SubsystemKindUnknown, + kind.ToString())); + } + + #endregion + + #region public - Subsystem registration + + /// + /// Subsystem registration. + /// + /// The concrete subsystem base type. + /// The implementation type of that concrete subsystem. + /// An instance of the implementation. + public static void RegisterSubsystem(TImplementation proxy) + where TConcreteSubsystem : class, ISubsystem + where TImplementation : class, TConcreteSubsystem + { + Requires.NotNull(proxy, nameof(proxy)); + + RegisterSubsystem(GetSubsystemInfo(typeof(TConcreteSubsystem)), proxy); + } + + /// + /// Register an implementation for a subsystem. + /// + /// The target of the registration. + /// An instance of the implementation. + public static void RegisterSubsystem(SubsystemKind kind, ISubsystem proxy) + { + Requires.NotNull(proxy, nameof(proxy)); + + if (kind != proxy.Kind) + { + throw new ArgumentException( + StringUtil.Format( + SubsystemStrings.ImplementationMismatch, + proxy.Kind.ToString(), + kind.ToString()), + nameof(proxy)); + } + + RegisterSubsystem(GetSubsystemInfo(kind), proxy); + } + + private static void RegisterSubsystem(SubsystemInfo subsystemInfo, ISubsystem proxy) + { + if (proxy.Id == Guid.Empty) + { + throw new ArgumentException( + StringUtil.Format( + SubsystemStrings.EmptyImplementationId, + subsystemInfo.Kind.ToString()), + nameof(proxy)); + } + + if (string.IsNullOrEmpty(proxy.Name)) + { + throw new ArgumentException( + StringUtil.Format( + SubsystemStrings.NullOrEmptyImplementationName, + subsystemInfo.Kind.ToString()), + nameof(proxy)); + } + + if (string.IsNullOrEmpty(proxy.Description)) + { + throw new ArgumentException( + StringUtil.Format( + SubsystemStrings.NullOrEmptyImplementationDescription, + subsystemInfo.Kind.ToString()), + nameof(proxy)); + } + + if (subsystemInfo.RequiredCmdlets.Any() || subsystemInfo.RequiredFunctions.Any()) + { + // Process 'proxy.CmdletImplementationAssembly' and 'proxy.FunctionsToDefine' + // Functions are added to global scope. + // Cmdlets are loaded in a way like a snapin, making the 'Source' of the cmdlets to be 'Microsoft.PowerShell.Core'. + // + // For example, let's say the Job adapter is made a subsystem, then all `*-Job` cmdlets will be moved out of S.M.A + // into a subsystem implementation DLL. After registration, all `*-Job` cmdlets should be back in the + // 'Microsoft.PowerShell.Core' namespace to keep backward compatibility. + // + // Both cmdlets and functions are added to the default InitialSessionState used for creating a new Runspace, + // so the subsystem works for all subsequent new runspaces after it's registered. + // Take the Job adapter subsystem as an instance again, so when creating another Runspace after the registration, + // all '*-Job' cmdlets should be available in the 'Microsoft.PowerShell.Core' namespace by default. + } + + subsystemInfo.RegisterImplementation(proxy); + } + + #endregion + + #region public - Subsystem unregistration + + /// + /// Subsystem unregistration. + /// Throw 'InvalidOperationException' when called for subsystems that cannot be unregistered. + /// + /// The base type of the target concrete subsystem of the un-registration. + /// The Id of the implementation to be unregistered. + public static void UnregisterSubsystem(Guid id) + where TConcreteSubsystem : class, ISubsystem + { + UnregisterSubsystem(GetSubsystemInfo(typeof(TConcreteSubsystem)), id); + } + + /// + /// Subsystem unregistration. + /// Throw 'InvalidOperationException' when called for subsystems that cannot be unregistered. + /// + /// The target of the un-registration. + /// The Id of the implementation to be unregistered. + public static void UnregisterSubsystem(SubsystemKind kind, Guid id) + { + UnregisterSubsystem(GetSubsystemInfo(kind), id); + } + + private static void UnregisterSubsystem(SubsystemInfo subsystemInfo, Guid id) + { + if (subsystemInfo.RequiredCmdlets.Any() || subsystemInfo.RequiredFunctions.Any()) + { + throw new NotSupportedException("NotSupported yet: unregister subsystem that introduced new cmdlets/functions."); + } + + ISubsystem impl = subsystemInfo.UnregisterImplementation(id); + if (impl is IDisposable disposable) + { + disposable.Dispose(); + } + } + + #endregion + } +} diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index b3d62c4b06..bce78b3543 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -2278,4 +2278,34 @@ namespace System.Management.Automation.Internal /// internal static readonly ReadOnlyBag Empty = new ReadOnlyBag(new HashSet(capacity: 0)); } + + /// + /// Helper class for simple argument validations. + /// + internal static class Requires + { + internal static void NotNull(object value, string paramName) + { + if (value == null) + { + throw new ArgumentNullException(paramName); + } + } + + internal static void NotNullOrEmpty(string value, string paramName) + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentNullException(paramName); + } + } + + internal static void Condition([DoesNotReturnIf(false)] bool precondition, string paramName) + { + if (!precondition) + { + throw new ArgumentException(paramName); + } + } + } } diff --git a/src/System.Management.Automation/resources/SubsystemStrings.resx b/src/System.Management.Automation/resources/SubsystemStrings.resx new file mode 100644 index 0000000000..a2819da868 --- /dev/null +++ b/src/System.Management.Automation/resources/SubsystemStrings.resx @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The subsystem '{0}' does not allow more than one implementation to be registered. + + + The implementation with Id '{0}' was already registered for the subsystem '{1}'. + + + The subsystem '{0}' does not allow the unregistration of an implementation. + + + No implementation was registered for the subsystem '{0}'. + + + A registered implementation with the Id '{0}' was not found. + + + The specified subsystem type '{0}' is unknown. + + + The specified subsystem kind '{0}' is unknown. + + + The specified implementation instance implements the subsystem '{0}', which does not match the target subsystem '{1}'. + + + The declared metadata for subsystem kind '{0}' is invalid. A subsystem that requires cmdlets or functions to be defined cannot allow multiple registrations because that would result in one implementation overwriting the commands defined by another implementation. + + + The 'Id' property of an implementation for the subsystem '{0}' cannot be an empty GUID. + + + The 'Name' property of an implementation for the subsystem '{0}' cannot be null or an empty string. + + + The 'Description' property of an implementation for the subsystem '{0}' cannot be null or an empty string. + + diff --git a/test/powershell/engine/Help/HelpSystem.Tests.ps1 b/test/powershell/engine/Help/HelpSystem.Tests.ps1 index 80d6a46b70..d3c508fe40 100644 --- a/test/powershell/engine/Help/HelpSystem.Tests.ps1 +++ b/test/powershell/engine/Help/HelpSystem.Tests.ps1 @@ -15,7 +15,8 @@ $script:cmdletsToSkip = @( "Enable-PSRemoting", "Get-ExperimentalFeature", "Enable-ExperimentalFeature", - "Disable-ExperimentalFeature" + "Disable-ExperimentalFeature", + "Get-Subsystem" ) function UpdateHelpFromLocalContentPath { diff --git a/test/xUnit/csharp/test_Prediction.cs b/test/xUnit/csharp/test_Prediction.cs new file mode 100644 index 0000000000..4d67c850f6 --- /dev/null +++ b/test/xUnit/csharp/test_Prediction.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Management.Automation.Language; +using System.Management.Automation.Subsystem; +using System.Threading; +using Xunit; + +namespace PSTests.Sequential +{ + public class MyPredictor : ICommandPredictor + { + private readonly Guid _id; + private readonly string _name, _description; + private readonly bool _delay; + + public List History { get; } + + public List AcceptedSuggestions { get; } + + public static readonly MyPredictor SlowPredictor; + + public static readonly MyPredictor FastPredictor; + + static MyPredictor() + { + SlowPredictor = new MyPredictor( + Guid.NewGuid(), + "Test Predictor #1", + "Description for #1 predictor.", + delay: true); + + FastPredictor = new MyPredictor( + Guid.NewGuid(), + "Test Predictor #2", + "Description for #2 predictor.", + delay: false); + } + + private MyPredictor(Guid id, string name, string description, bool delay) + { + _id = id; + _name = name; + _description = description; + _delay = delay; + + History = new List(); + AcceptedSuggestions = new List(); + } + + public Guid Id => _id; + + public string Name => _name; + + public string Description => _description; + + bool ICommandPredictor.SupportEarlyProcessing => true; + + bool ICommandPredictor.AcceptFeedback => true; + + public void StartEarlyProcessing(IReadOnlyList history) + { + History.AddRange(history); + } + + public void OnSuggestionAccepted(string acceptedSuggestion) + { + AcceptedSuggestions.Add(acceptedSuggestion); + } + + public List GetSuggestion(PredictionContext context, CancellationToken cancellationToken) + { + if (_delay) + { + // The delay is exaggerated to make the test reliable. + // xUnit must spin up a lot tasks, which makes the test unreliable when the time difference between 'delay' and 'timeout' is small. + Thread.Sleep(2000); + } + + // You can get the user input from the AST. + var userInput = context.InputAst.Extent.Text; + return new List { + new PredictiveSuggestion($"{userInput} TEST-1 from {Name}"), + new PredictiveSuggestion($"{userInput} TeSt-2 from {Name}"), + }; + } + } + + public static class CommandPredictionTests + { + [Fact] + public static void PredictInput() + { + MyPredictor slow = MyPredictor.SlowPredictor; + MyPredictor fast = MyPredictor.FastPredictor; + Ast ast = Parser.ParseInput("Hello world", out Token[] tokens, out _); + + // Returns null when no predictor implementation registered + List results = CommandPrediction.PredictInput(ast, tokens).Result; + Assert.Null(results); + + try + { + // Register 2 predictor implementations + SubsystemManager.RegisterSubsystem(slow); + SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, fast); + + // Expect the results from 'fast' predictor only b/c the 'slow' one + // cannot finish before the specified timeout. + // The specified timeout is exaggerated to make the test reliable. + // xUnit must spin up a lot tasks, which makes the test unreliable when the time difference between 'delay' and 'timeout' is small. + results = CommandPrediction.PredictInput(ast, tokens, millisecondsTimeout: 1000).Result; + Assert.Single(results); + + PredictionResult res = results[0]; + Assert.Equal(fast.Id, res.Id); + Assert.Equal(2, res.Suggestions.Count); + Assert.Equal($"Hello world TEST-1 from {fast.Name}", res.Suggestions[0].SuggestionText); + Assert.Equal($"Hello world TeSt-2 from {fast.Name}", res.Suggestions[1].SuggestionText); + + // Expect the results from both 'slow' and 'fast' predictors + // Same here -- the specified timeout is exaggerated to make the test reliable. + // xUnit must spin up a lot tasks, which makes the test unreliable when the time difference between 'delay' and 'timeout' is small. + results = CommandPrediction.PredictInput(ast, tokens, millisecondsTimeout: 4000).Result; + Assert.Equal(2, results.Count); + + PredictionResult res1 = results[0]; + Assert.Equal(slow.Id, res1.Id); + Assert.Equal(2, res1.Suggestions.Count); + Assert.Equal($"Hello world TEST-1 from {slow.Name}", res1.Suggestions[0].SuggestionText); + Assert.Equal($"Hello world TeSt-2 from {slow.Name}", res1.Suggestions[1].SuggestionText); + + PredictionResult res2 = results[1]; + Assert.Equal(fast.Id, res2.Id); + Assert.Equal(2, res2.Suggestions.Count); + Assert.Equal($"Hello world TEST-1 from {fast.Name}", res2.Suggestions[0].SuggestionText); + Assert.Equal($"Hello world TeSt-2 from {fast.Name}", res2.Suggestions[1].SuggestionText); + } + finally + { + SubsystemManager.UnregisterSubsystem(slow.Id); + SubsystemManager.UnregisterSubsystem(SubsystemKind.CommandPredictor, fast.Id); + } + } + + [Fact] + public static void Feedback() + { + MyPredictor slow = MyPredictor.SlowPredictor; + MyPredictor fast = MyPredictor.FastPredictor; + + try + { + // Register 2 predictor implementations + SubsystemManager.RegisterSubsystem(slow); + SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, fast); + + var history = new[] { "hello", "world" }; + var ids = new HashSet { slow.Id, fast.Id }; + + CommandPrediction.OnCommandLineAccepted(history); + CommandPrediction.OnSuggestionAccepted(slow.Id, "Yeah"); + + // The calls to 'StartEarlyProcessing' and 'OnSuggestionAccepted' are queued in thread pool, + // so we wait a bit to make sure the calls are done. + while (slow.History.Count == 0 || slow.AcceptedSuggestions.Count == 0) + { + Thread.Sleep(10); + } + + Assert.Equal(2, slow.History.Count); + Assert.Equal(history[0], slow.History[0]); + Assert.Equal(history[1], slow.History[1]); + + Assert.Equal(2, fast.History.Count); + Assert.Equal(history[0], fast.History[0]); + Assert.Equal(history[1], fast.History[1]); + + Assert.Single(slow.AcceptedSuggestions); + Assert.Equal("Yeah", slow.AcceptedSuggestions[0]); + + Assert.Empty(fast.AcceptedSuggestions); + } + finally + { + SubsystemManager.UnregisterSubsystem(slow.Id); + SubsystemManager.UnregisterSubsystem(SubsystemKind.CommandPredictor, fast.Id); + } + } + } +} diff --git a/test/xUnit/csharp/test_Subsystem.cs b/test/xUnit/csharp/test_Subsystem.cs new file mode 100644 index 0000000000..a45ebc116b --- /dev/null +++ b/test/xUnit/csharp/test_Subsystem.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.ObjectModel; +using System.Management.Automation.Subsystem; +using System.Threading; +using Xunit; + +namespace PSTests.Sequential +{ + public static class SubsystemTests + { + private static readonly MyPredictor predictor1, predictor2; + + static SubsystemTests() + { + predictor1 = MyPredictor.FastPredictor; + predictor2 = MyPredictor.SlowPredictor; + } + + // This method needs to be updated when there are more than 1 subsystem defined. + private static void VerifySubsystemMetadata(SubsystemInfo ssInfo) + { + Assert.Equal(SubsystemKind.CommandPredictor, ssInfo.Kind); + Assert.Equal(typeof(ICommandPredictor), ssInfo.SubsystemType); + Assert.True(ssInfo.AllowUnregistration); + Assert.True(ssInfo.AllowMultipleRegistration); + Assert.Empty(ssInfo.RequiredCmdlets); + Assert.Empty(ssInfo.RequiredFunctions); + } + + [Fact] + public static void GetSubsystemInfo() + { + SubsystemInfo ssInfo = SubsystemManager.GetSubsystemInfo(typeof(ICommandPredictor)); + + VerifySubsystemMetadata(ssInfo); + Assert.False(ssInfo.IsRegistered); + Assert.Empty(ssInfo.Implementations); + + SubsystemInfo ssInfo2 = SubsystemManager.GetSubsystemInfo(SubsystemKind.CommandPredictor); + Assert.Same(ssInfo2, ssInfo); + + ReadOnlyCollection ssInfos = SubsystemManager.GetAllSubsystemInfo(); + Assert.Single(ssInfos); + Assert.Same(ssInfos[0], ssInfo); + + ICommandPredictor impl = SubsystemManager.GetSubsystem(); + Assert.Null(impl); + ReadOnlyCollection impls = SubsystemManager.GetSubsystems(); + Assert.Empty(impls); + } + + [Fact] + public static void RegisterSubsystem() + { + try + { + Assert.Throws( + paramName: "proxy", + () => SubsystemManager.RegisterSubsystem(null)); + Assert.Throws( + paramName: "proxy", + () => SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, null)); + Assert.Throws( + paramName: "proxy", + () => SubsystemManager.RegisterSubsystem((SubsystemKind)0, predictor1)); + + // Register 'predictor1' + SubsystemManager.RegisterSubsystem(predictor1); + + // Now validate the SubsystemInfo of the 'ICommandPredictor' subsystem + SubsystemInfo ssInfo = SubsystemManager.GetSubsystemInfo(typeof(ICommandPredictor)); + VerifySubsystemMetadata(ssInfo); + Assert.True(ssInfo.IsRegistered); + Assert.Single(ssInfo.Implementations); + + // Now validate the 'ImplementationInfo' + var implInfo = ssInfo.Implementations[0]; + Assert.Equal(predictor1.Id, implInfo.Id); + Assert.Equal(predictor1.Name, implInfo.Name); + Assert.Equal(predictor1.Description, implInfo.Description); + Assert.Equal(SubsystemKind.CommandPredictor, implInfo.Kind); + Assert.Same(typeof(MyPredictor), implInfo.ImplementationType); + + // Now validate the all-subsystem-info collection. + ReadOnlyCollection ssInfos = SubsystemManager.GetAllSubsystemInfo(); + Assert.Single(ssInfos); + Assert.Same(ssInfos[0], ssInfo); + + // Now validate the subsystem implementation itself. + ICommandPredictor impl = SubsystemManager.GetSubsystem(); + Assert.Same(impl, predictor1); + Assert.Null(impl.FunctionsToDefine); + Assert.Equal(SubsystemKind.CommandPredictor, impl.Kind); + + var predCxt = PredictionContext.Create("Hello world"); + var results = impl.GetSuggestion(predCxt, CancellationToken.None); + Assert.Equal($"Hello world TEST-1 from {impl.Name}", results[0].SuggestionText); + Assert.Equal($"Hello world TeSt-2 from {impl.Name}", results[1].SuggestionText); + + // Now validate the all-subsystem-implementation collection. + ReadOnlyCollection impls = SubsystemManager.GetSubsystems(); + Assert.Single(impls); + Assert.Same(predictor1, impls[0]); + + // Register 'predictor2' + SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, predictor2); + + // Now validate the SubsystemInfo of the 'ICommandPredictor' subsystem + VerifySubsystemMetadata(ssInfo); + Assert.True(ssInfo.IsRegistered); + Assert.Equal(2, ssInfo.Implementations.Count); + + // Now validate the new 'ImplementationInfo' + implInfo = ssInfo.Implementations[1]; + Assert.Equal(predictor2.Id, implInfo.Id); + Assert.Equal(predictor2.Name, implInfo.Name); + Assert.Equal(predictor2.Description, implInfo.Description); + Assert.Equal(SubsystemKind.CommandPredictor, implInfo.Kind); + Assert.Same(typeof(MyPredictor), implInfo.ImplementationType); + + // Now validate the new subsystem implementation. + impl = SubsystemManager.GetSubsystem(); + Assert.Same(impl, predictor2); + + // Now validate the all-subsystem-implementation collection. + impls = SubsystemManager.GetSubsystems(); + Assert.Equal(2, impls.Count); + Assert.Same(predictor1, impls[0]); + Assert.Same(predictor2, impls[1]); + } + finally + { + SubsystemManager.UnregisterSubsystem(predictor1.Id); + SubsystemManager.UnregisterSubsystem(SubsystemKind.CommandPredictor, predictor2.Id); + } + } + + [Fact] + public static void UnregisterSubsystem() + { + // Exception expected when no implementation is registered + Assert.Throws(() => SubsystemManager.UnregisterSubsystem(predictor1.Id)); + + SubsystemManager.RegisterSubsystem(predictor1); + SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, predictor2); + + // Exception is expected when specified id cannot be found + Assert.Throws(() => SubsystemManager.UnregisterSubsystem(Guid.NewGuid())); + + // Unregister 'predictor1' + SubsystemManager.UnregisterSubsystem(predictor1.Id); + + SubsystemInfo ssInfo = SubsystemManager.GetSubsystemInfo(SubsystemKind.CommandPredictor); + VerifySubsystemMetadata(ssInfo); + Assert.True(ssInfo.IsRegistered); + Assert.Single(ssInfo.Implementations); + + var implInfo = ssInfo.Implementations[0]; + Assert.Equal(predictor2.Id, implInfo.Id); + Assert.Equal(predictor2.Name, implInfo.Name); + Assert.Equal(predictor2.Description, implInfo.Description); + Assert.Equal(SubsystemKind.CommandPredictor, implInfo.Kind); + Assert.Same(typeof(MyPredictor), implInfo.ImplementationType); + + ICommandPredictor impl = SubsystemManager.GetSubsystem(); + Assert.Same(impl, predictor2); + + ReadOnlyCollection impls = SubsystemManager.GetSubsystems(); + Assert.Single(impls); + Assert.Same(predictor2, impls[0]); + + // Unregister 'predictor2' + SubsystemManager.UnregisterSubsystem(SubsystemKind.CommandPredictor, predictor2.Id); + + VerifySubsystemMetadata(ssInfo); + Assert.False(ssInfo.IsRegistered); + Assert.Empty(ssInfo.Implementations); + + impl = SubsystemManager.GetSubsystem(); + Assert.Null(impl); + + impls = SubsystemManager.GetSubsystems(); + Assert.Empty(impls); + } + } +} diff --git a/tools/packaging/packaging.psm1 b/tools/packaging/packaging.psm1 index 6740cb0afa..664f545555 100644 --- a/tools/packaging/packaging.psm1 +++ b/tools/packaging/packaging.psm1 @@ -2325,46 +2325,63 @@ function CleanupGeneratedSourceCode '[System.Runtime.CompilerServices.IsReadOnlyAttribute]' '[System.Runtime.CompilerServices.NullableContextAttribute(' '[System.Runtime.CompilerServices.NullableAttribute((byte)0)]' + '[System.Runtime.CompilerServices.NullableAttribute(new byte[]{ (byte)2, (byte)1, (byte)1})]' + '[System.Runtime.CompilerServices.AsyncStateMachineAttribute' ) $patternsToReplace = @( @{ - ApplyTo = "Microsoft.PowerShell.Commands.Utility" + ApplyTo = @("Microsoft.PowerShell.Commands.Utility") Pattern = "[System.Runtime.CompilerServices.IsReadOnlyAttribute]ref Microsoft.PowerShell.Commands.JsonObject.ConvertToJsonContext" Replacement = "in Microsoft.PowerShell.Commands.JsonObject.ConvertToJsonContext" }, @{ - ApplyTo = "Microsoft.PowerShell.Commands.Utility" + ApplyTo = @("Microsoft.PowerShell.Commands.Utility") Pattern = "public partial struct ConvertToJsonContext" Replacement = "public readonly struct ConvertToJsonContext" }, @{ - ApplyTo = "Microsoft.PowerShell.Commands.Utility" + ApplyTo = @("Microsoft.PowerShell.Commands.Utility") Pattern = "Unable to resolve assembly 'Assembly(Name=Newtonsoft.Json" Replacement = "// Unable to resolve assembly 'Assembly(Name=Newtonsoft.Json" }, @{ - ApplyTo = "System.Management.Automation" + ApplyTo = @("System.Management.Automation") Pattern = "Unable to resolve assembly 'Assembly(Name=System.Security.Principal.Windows" Replacement = "// Unable to resolve assembly 'Assembly(Name=System.Security.Principal.Windows" }, @{ - ApplyTo = "System.Management.Automation" + ApplyTo = @("System.Management.Automation") Pattern = "Unable to resolve assembly 'Assembly(Name=Microsoft.Management.Infrastructure" Replacement = "// Unable to resolve assembly 'Assembly(Name=Microsoft.Management.Infrastructure" }, @{ - ApplyTo = "System.Management.Automation" + ApplyTo = @("System.Management.Automation") Pattern = "Unable to resolve assembly 'Assembly(Name=System.Security.AccessControl" Replacement = "// Unable to resolve assembly 'Assembly(Name=System.Security.AccessControl" }, @{ - ApplyTo = "Microsoft.PowerShell.ConsoleHost" + ApplyTo = @("System.Management.Automation") + Pattern = "[System.Runtime.CompilerServices.NullableAttribute(new byte[]{ (byte)1, (byte)2, (byte)1})]" + Replacement = "/* [System.Runtime.CompilerServices.NullableAttribute(new byte[]{ (byte)1, (byte)2, (byte)1})] */ " + }, + @{ + ApplyTo = @("System.Management.Automation") + Pattern = "[System.Runtime.CompilerServices.NullableAttribute(new byte[]{ (byte)2, (byte)1})]" + Replacement = "/* [System.Runtime.CompilerServices.NullableAttribute(new byte[]{ (byte)2, (byte)1})] */ " + }, + @{ + ApplyTo = @("System.Management.Automation") + Pattern = "[System.Runtime.CompilerServices.CompilerGeneratedAttribute, System.Runtime.CompilerServices.NullableContextAttribute((byte)2)]" + Replacement = "/* [System.Runtime.CompilerServices.CompilerGeneratedAttribute, System.Runtime.CompilerServices.NullableContextAttribute((byte)2)] */ " + }, + @{ + ApplyTo = @("System.Management.Automation", "Microsoft.PowerShell.ConsoleHost") Pattern = "[System.Runtime.CompilerServices.NullableAttribute((byte)2)]" Replacement = "/* [System.Runtime.CompilerServices.NullableAttribute((byte)2)] */" }, @{ - ApplyTo = "Microsoft.PowerShell.ConsoleHost" + ApplyTo = @("System.Management.Automation", "Microsoft.PowerShell.ConsoleHost") Pattern = "[System.Runtime.CompilerServices.NullableAttribute((byte)1)]" Replacement = "/* [System.Runtime.CompilerServices.NullableAttribute((byte)1)] */" } @@ -2378,7 +2395,7 @@ function CleanupGeneratedSourceCode $lineWasProcessed = $false foreach ($patternToReplace in $patternsToReplace) { - if ($assemblyName -eq $patternToReplace.ApplyTo -and $line.Contains($patternToReplace.Pattern)) { + if ($assemblyName -in $patternToReplace.ApplyTo -and $line.Contains($patternToReplace.Pattern)) { $line = $line.Replace($patternToReplace.Pattern, $patternToReplace.Replacement) $lineWasProcessed = $true break