From b7cf5942f3939cf7126cf16e362ce3d556f60e27 Mon Sep 17 00:00:00 2001 From: David Wilson Date: Sun, 27 Mar 2016 14:50:18 -0700 Subject: [PATCH 1/3] Introduce new editor extensibility API This change introduces a new PowerShell-based API that allows scripts and modules to extend the behavior of the host editor. Scripts can either automate the editor directly or register commands that can later be invoked by the user. The ExtensionService provides this behavior for any PowerShellContext by injecting a new variable '$psEditor' into the session. This version of the API is very early and will be expanded upon in future releases. --- docs/extensions.md | 112 +++++++ .../LanguageServer/EditorCommands.cs | 111 +++++++ .../PowerShellEditorServices.Protocol.csproj | 2 + .../Server/LanguageServer.cs | 74 ++++- .../Server/LanguageServerEditorOperations.cs | 113 +++++++ .../Extensions/CmdletInterface.ps1 | 140 +++++++++ .../Extensions/EditorCommand.cs | 89 ++++++ .../Extensions/EditorContext.cs | 115 +++++++ .../Extensions/EditorObject.cs | 86 ++++++ .../Extensions/EditorWorkspace.cs | 44 +++ .../Extensions/ExtensionService.cs | 241 +++++++++++++++ .../Extensions/FileContext.cs | 220 ++++++++++++++ .../Extensions/IEditorOperations.cs | 48 +++ .../Language/CompletionResults.cs | 18 +- .../PowerShellEditorServices.csproj | 12 +- .../Session/EditorSession.cs | 7 + .../Session/PowerShellContext.cs | 14 +- .../Session/SessionPSHost.cs | 23 +- .../Workspace/BufferPosition.cs | 80 ++++- .../Workspace/BufferRange.cs | 89 +++++- .../Workspace/FilePosition.cs | 109 +++++++ .../Workspace/ScriptFile.cs | 132 +++++++- .../Extensions/ExtensionServiceTests.cs | 192 ++++++++++++ .../PowerShellEditorServices.Test.csproj | 1 + .../Session/ScriptFileTests.cs | 282 +++++++++++++++++- 25 files changed, 2331 insertions(+), 23 deletions(-) create mode 100644 docs/extensions.md create mode 100644 src/PowerShellEditorServices.Protocol/LanguageServer/EditorCommands.cs create mode 100644 src/PowerShellEditorServices.Protocol/Server/LanguageServerEditorOperations.cs create mode 100644 src/PowerShellEditorServices/Extensions/CmdletInterface.ps1 create mode 100644 src/PowerShellEditorServices/Extensions/EditorCommand.cs create mode 100644 src/PowerShellEditorServices/Extensions/EditorContext.cs create mode 100644 src/PowerShellEditorServices/Extensions/EditorObject.cs create mode 100644 src/PowerShellEditorServices/Extensions/EditorWorkspace.cs create mode 100644 src/PowerShellEditorServices/Extensions/ExtensionService.cs create mode 100644 src/PowerShellEditorServices/Extensions/FileContext.cs create mode 100644 src/PowerShellEditorServices/Extensions/IEditorOperations.cs create mode 100644 src/PowerShellEditorServices/Workspace/FilePosition.cs create mode 100644 test/PowerShellEditorServices.Test/Extensions/ExtensionServiceTests.cs diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 000000000..13c0a9153 --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,112 @@ +# PowerShell Editor Services Extensibility Model + +PowerShell Editor Services exposes a common extensibility model which allows +a user to write extension code in PowerShell that works across any editor that +uses PowerShell Editor Services. + +## Using Extensions + +**TODO** + +- Enable-EditorExtension -Name "SomeExtension.CustomAnalyzer" +- Disable-EditorExtension -Name "SomeExtension.CustomAnalyzer" + +## Writing Extensions + +Here are some examples of writing editor extensions: + +### Command Extensions + +#### Executing a cmdlet or function + +```powershell +function MyExtensionFunction { + Write-Output "My extension function was invoked!" +} + +Register-EditorExtension ` + -Command + -Name "MyExt.MyExtensionFunction" ` + -DisplayName "My extension function" ` + -Function MyExtensionFunction +``` + +#### Executing a script block + +```powershell +Register-EditorExtension ` + -Command + -Name "MyExt.MyExtensionScriptBlock" ` + -DisplayName "My extension script block" ` + -ScriptBlock { Write-Output "My extension script block was invoked!" } +``` + +#### Additional Parameters + +##### ExecuteInSession [switch] + +Causes the command to be executed in the user's current session. By default, +commands are executed in a global session that isn't affected by script +execution. Adding this parameter will cause the command to be executed in the +context of the user's session. + +### Analyzer Extensions + +```powershell +function Invoke-MyAnalyzer { + param( + $FilePath, + $Ast, + $StartLine, + $StartColumn, + $EndLine, + $EndColumn + ) +} + +Register-EditorExtension ` + -Analyzer + -Name "MyExt.MyAnalyzer" ` + -DisplayName "My analyzer extension" ` + -Function Invoke-MyAnalyzer +``` + +#### Additional Parameters + +##### DelayInterval [int] + +Specifies the interval after which this analyzer will be run when the +user finishes typing in the script editor. + +### Formatter Extensions + +```powershell +function Invoke-MyFormatter { + param( + $FilePath, + $ScriptText, + $StartLine, + $StartColumn, + $EndLine, + $EndColumn + ) +} + +Register-EditorExtension ` + -Formatter + -Name "MyExt.MyFormatter" ` + -DisplayName "My formatter extension" ` + -Function Invoke-MyFormatter +``` + +#### Additional Parameters + +##### SupportsSelections [switch] + +Indicates that this formatter extension can format selections in a larger +file rather than formatting the entire file. If this parameter is not +specified then the entire file will be sent to the extension for every +call. + +## Examples + diff --git a/src/PowerShellEditorServices.Protocol/LanguageServer/EditorCommands.cs b/src/PowerShellEditorServices.Protocol/LanguageServer/EditorCommands.cs new file mode 100644 index 000000000..cdd128135 --- /dev/null +++ b/src/PowerShellEditorServices.Protocol/LanguageServer/EditorCommands.cs @@ -0,0 +1,111 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol; + +namespace Microsoft.PowerShell.EditorServices.Protocol.LanguageServer +{ + public class ExtensionCommandAddedNotification + { + public static readonly + EventType Type = + EventType.Create("powerShell/extensionCommandAdded"); + + public string Name { get; set; } + + public string DisplayName { get; set; } + } + + public class ExtensionCommandUpdatedNotification + { + public static readonly + EventType Type = + EventType.Create("powerShell/extensionCommandUpdated"); + + public string Name { get; set; } + } + + public class ExtensionCommandRemovedNotification + { + public static readonly + EventType Type = + EventType.Create("powerShell/extensionCommandRemoved"); + + public string Name { get; set; } + } + + public class ClientEditorContext + { + public string CurrentFilePath { get; set; } + + public Position CursorPosition { get; set; } + + public Range SelectionRange { get; set; } + + } + + public class InvokeExtensionCommandRequest + { + public static readonly + RequestType Type = + RequestType.Create("powerShell/invokeExtensionCommand"); + + public string Name { get; set; } + + public ClientEditorContext Context { get; set; } + } + + public class GetEditorContextRequest + { + public static readonly + RequestType Type = + RequestType.Create("editor/getEditorContext"); + } + + public enum EditorCommandResponse + { + Unsupported, + OK + } + + public class InsertTextRequest + { + public static readonly + RequestType Type = + RequestType.Create("editor/insertText"); + + public string FilePath { get; set; } + + public string InsertText { get; set; } + + public Range InsertRange { get; set; } + } + + public class SetSelectionRequest + { + public static readonly + RequestType Type = + RequestType.Create("editor/setSelection"); + + public Range SelectionRange { get; set; } + } + + public class SetCursorPositionRequest + { + public static readonly + RequestType Type = + RequestType.Create("editor/setCursorPosition"); + + public Position CursorPosition { get; set; } + } + + public class OpenFileRequest + { + public static readonly + RequestType Type = + RequestType.Create("editor/openFile"); + } +} + diff --git a/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj b/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj index 20b57d50d..2c3368e42 100644 --- a/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj +++ b/src/PowerShellEditorServices.Protocol/PowerShellEditorServices.Protocol.csproj @@ -53,6 +53,7 @@ + @@ -125,6 +126,7 @@ + diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs index 916c802ef..209315ed7 100644 --- a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using Microsoft.PowerShell.EditorServices.Extensions; using Microsoft.PowerShell.EditorServices.Protocol.LanguageServer; using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol; using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol.Channel; @@ -27,6 +28,7 @@ public class LanguageServer : LanguageServerBase private bool profilesLoaded; private EditorSession editorSession; private OutputDebouncer outputDebouncer; + private LanguageServerEditorOperations editorOperations; private LanguageServerSettings currentSettings = new LanguageServerSettings(); /// @@ -47,6 +49,17 @@ public LanguageServer(HostDetails hostDetails, ChannelBase serverChannel) this.editorSession.StartSession(hostDetails); this.editorSession.ConsoleService.OutputWritten += this.powerShellContext_OutputWritten; + // Attach to ExtensionService events + this.editorSession.ExtensionService.CommandAdded += ExtensionService_ExtensionAdded; + this.editorSession.ExtensionService.CommandUpdated += ExtensionService_ExtensionUpdated; + this.editorSession.ExtensionService.CommandRemoved += ExtensionService_ExtensionRemoved; + + // Create the IEditorOperations implementation + this.editorOperations = + new LanguageServerEditorOperations( + this.editorSession, + this); + // Always send console prompts through the UI in the language service // TODO: This will change later once we have a general REPL available // in VS Code. @@ -61,6 +74,11 @@ public LanguageServer(HostDetails hostDetails, ChannelBase serverChannel) protected override void Initialize() { + // Initialize the extension service + // TODO: This should be made awaited once Initialize is async! + this.editorSession.ExtensionService.Initialize( + this.editorOperations).Wait(); + // Register all supported message types this.SetRequestHandler(InitializeRequest.Type, this.HandleInitializeRequest); @@ -86,6 +104,8 @@ protected override void Initialize() this.SetRequestHandler(FindModuleRequest.Type, this.HandleFindModuleRequest); this.SetRequestHandler(InstallModuleRequest.Type, this.HandleInstallModuleRequest); + this.SetRequestHandler(InvokeExtensionCommandRequest.Type, this.HandleInvokeExtensionCommandRequest); + this.SetRequestHandler(DebugAdapterMessages.EvaluateRequest.Type, this.HandleEvaluateRequest); } @@ -169,6 +189,26 @@ RequestContext requestContext await requestContext.SendResult(null); } + private Task HandleInvokeExtensionCommandRequest( + InvokeExtensionCommandRequest commandDetails, + RequestContext requestContext) + { + EditorContext editorContext = + this.editorOperations.ConvertClientEditorContext( + commandDetails.Context); + + Task commandTask = + this.editorSession.ExtensionService.InvokeCommand( + commandDetails.Name, + editorContext); + + commandTask.ContinueWith(t => + { + return requestContext.SendResult(null); + }); + + return commandTask; + } private async Task HandleExpandAliasRequest( string content, @@ -802,12 +842,44 @@ protected Task HandleEvaluateRequest( #region Event Handlers - async void powerShellContext_OutputWritten(object sender, OutputWrittenEventArgs e) + private async void powerShellContext_OutputWritten(object sender, OutputWrittenEventArgs e) { // Queue the output for writing await this.outputDebouncer.Invoke(e); } + private async void ExtensionService_ExtensionAdded(object sender, EditorCommand e) + { + await this.SendEvent( + ExtensionCommandAddedNotification.Type, + new ExtensionCommandAddedNotification + { + Name = e.Name, + DisplayName = e.DisplayName + }); + } + + private async void ExtensionService_ExtensionUpdated(object sender, EditorCommand e) + { + await this.SendEvent( + ExtensionCommandUpdatedNotification.Type, + new ExtensionCommandUpdatedNotification + { + Name = e.Name, + }); + } + + private async void ExtensionService_ExtensionRemoved(object sender, EditorCommand e) + { + await this.SendEvent( + ExtensionCommandRemovedNotification.Type, + new ExtensionCommandRemovedNotification + { + Name = e.Name, + }); + } + + #endregion #region Helper Methods diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServerEditorOperations.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServerEditorOperations.cs new file mode 100644 index 000000000..11f9089a6 --- /dev/null +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServerEditorOperations.cs @@ -0,0 +1,113 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.PowerShell.EditorServices.Extensions; +using Microsoft.PowerShell.EditorServices.Protocol.LanguageServer; +using Microsoft.PowerShell.EditorServices.Protocol.MessageProtocol; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Protocol.Server +{ + internal class LanguageServerEditorOperations : IEditorOperations + { + private EditorSession editorSession; + private IMessageSender messageSender; + + public LanguageServerEditorOperations( + EditorSession editorSession, + IMessageSender messageSender) + { + this.editorSession = editorSession; + this.messageSender = messageSender; + } + + public async Task GetEditorContext() + { + ClientEditorContext clientContext = + await this.messageSender.SendRequest( + GetEditorContextRequest.Type, + new GetEditorContextRequest(), + true); + + return this.ConvertClientEditorContext(clientContext); + } + + public async Task InsertText(string filePath, string text, BufferRange insertRange) + { + await this.messageSender.SendRequest( + InsertTextRequest.Type, + new InsertTextRequest + { + FilePath = filePath, + InsertText = text, + InsertRange = + new Range + { + Start = new Position + { + Line = insertRange.Start.Line - 1, + Character = insertRange.Start.Column - 1 + }, + End = new Position + { + Line = insertRange.End.Line - 1, + Character = insertRange.End.Column - 1 + } + } + }, false); + + // TODO: Set the last param back to true! + } + + public Task SetSelection(BufferRange selectionRange) + { + return this.messageSender.SendRequest( + SetSelectionRequest.Type, + new SetSelectionRequest + { + SelectionRange = + new Range + { + Start = new Position + { + Line = selectionRange.Start.Line - 1, + Character = selectionRange.Start.Column - 1 + }, + End = new Position + { + Line = selectionRange.End.Line - 1, + Character = selectionRange.End.Column - 1 + } + } + }, true); + } + + public EditorContext ConvertClientEditorContext( + ClientEditorContext clientContext) + { + return + new EditorContext( + this, + this.editorSession.Workspace.GetFile(clientContext.CurrentFilePath), + new BufferPosition( + clientContext.CursorPosition.Line + 1, + clientContext.CursorPosition.Character + 1), + new BufferRange( + clientContext.SelectionRange.Start.Line + 1, + clientContext.SelectionRange.Start.Character + 1, + clientContext.SelectionRange.End.Line + 1, + clientContext.SelectionRange.End.Character + 1)); + } + + public Task OpenFile(string filePath) + { + return + this.messageSender.SendRequest( + OpenFileRequest.Type, + filePath, + true); + } + } +} diff --git a/src/PowerShellEditorServices/Extensions/CmdletInterface.ps1 b/src/PowerShellEditorServices/Extensions/CmdletInterface.ps1 new file mode 100644 index 000000000..1ebe07b81 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/CmdletInterface.ps1 @@ -0,0 +1,140 @@ +<# + .SYNOPSIS + Registers a command which can be executed in the host editor. + + .DESCRIPTION + Registers a command which can be executed in the host editor. This + command will be shown to the user either in a menu or command palette. + Upon invoking this command, either a function/cmdlet or ScriptBlock will + be executed depending on whether the -Function or -ScriptBlock parameter + was used when the command was registered. + + This command can be run multiple times for the same command so that its + details can be updated. However, re-registration of commands should only + be used for development purposes, not for dynamic behavior. + + .PARAMETER Name + Specifies a unique name which can be used to identify this command. + This name is not displayed to the user. + + .PARAMETER DisplayName + Specifies a display name which is displayed to the user. + + .PARAMETER Function + Specifies a function or cmdlet name which will be executed when the user + invokes this command. This function may take a parameter called $context + which will be populated with an EditorContext object containing information + about the host editor's state at the time the command was executed. + + .PARAMETER ScriptBlock + Specifies a ScriptBlock which will be executed when the user invokes this + command. This ScriptBlock may take a parameter called $context + which will be populated with an EditorContext object containing information + about the host editor's state at the time the command was executed. + + .PARAMETER SuppressOutput + If provided, causes the output of the editor command to be suppressed when + it is run. Errors that occur while running this command will still be + written to the host. + + .EXAMPLE + PS> Register-EditorCommand -Name "MyModule.MyFunctionCommand" -DisplayName "My function command" -Function Invoke-MyCommand -SuppressOutput + + .EXAMPLE + PS> Register-EditorCommand -Name "MyModule.MyScriptBlockCommand" -DisplayName "My ScriptBlock command" -ScriptBlock { Write-Output "Hello from my command!" } + + .LINK + Unregister-EditorCommand +#> +function Register-EditorCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string]$Name, + + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string]$DisplayName, + + [Parameter( + Mandatory=$true, + ParameterSetName="Function")] + [ValidateNotNullOrEmpty()] + [string]$Function, + + [Parameter( + Mandatory=$true, + ParameterSetName="ScriptBlock")] + [ValidateNotNullOrEmpty()] + [ScriptBlock]$ScriptBlock, + + [switch]$SuppressOutput + ) + + Process + { + $commandArgs = @($Name, $DisplayName, $SuppressOutput.IsPresent) + + if ($ScriptBlock -ne $null) + { + Write-Verbose "Registering command '$Name' which executes a ScriptBlock" + $commandArgs += $ScriptBlock + } + else + { + Write-Verbose "Registering command '$Name' which executes a function" + $commandArgs += $Function + } + + $editorCommand = New-Object Microsoft.PowerShell.EditorServices.Extensions.EditorCommand -ArgumentList $commandArgs + if ($psEditor.RegisterCommand($editorCommand)) + { + Write-Verbose "Registered new command '$Name'" + } + else + { + Write-Verbose "Updated existing command '$Name'" + } + } +} + +<# + .SYNOPSIS + Unregisters a command which has already been registered in the host editor. + + .DESCRIPTION + Unregisters a command which has already been registered in the host editor. + An error will be thrown if the specified Name is unknown. + + .PARAMETER Name + Specifies a unique name which identifies a command which has already been registered. + + .EXAMPLE + PS> Unregister-EditorCommand -Name "MyModule.MyFunctionCommand" + + .LINK + Register-EditorCommand +#> +function Unregister-EditorCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string]$Name + ) + + Process + { + Write-Verbose "Unregistering command '$Name'" + $psEditor.UnregisterCommand($Name); + } +} + +function psedit { + param([Parameter(Mandatory=$true)]$FilePaths) + + dir $FilePaths | where { !$_.PSIsContainer } | % { + $psEditor.Workspace.OpenFile($_.FullName) + } +} diff --git a/src/PowerShellEditorServices/Extensions/EditorCommand.cs b/src/PowerShellEditorServices/Extensions/EditorCommand.cs new file mode 100644 index 000000000..8a7b80cef --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorCommand.cs @@ -0,0 +1,89 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides details about a command that has been registered + /// with the editor. + /// + public class EditorCommand + { + #region Properties + + /// + /// Gets the name which uniquely identifies the command. + /// + public string Name { get; private set; } + + /// + /// Gets the display name for the command. + /// + public string DisplayName { get; private set; } + + /// + /// Gets the boolean which determines whether this command's + /// output should be suppressed. + /// + public bool SuppressOutput { get; private set; } + + /// + /// Gets the ScriptBlock which can be used to execute the command. + /// + public ScriptBlock ScriptBlock { get; private set; } + + #endregion + + #region Constructors + + /// + /// Creates a new EditorCommand instance that invokes a cmdlet or + /// function by name. + /// + /// The unique identifier name for the command. + /// The display name for the command. + /// If true, causes output to be suppressed for this command. + /// The name of the cmdlet or function which will be invoked by this command. + public EditorCommand( + string commandName, + string displayName, + bool suppressOutput, + string cmdletName) + : this( + commandName, + displayName, + suppressOutput, + ScriptBlock.Create( + string.Format( + "param($context) {0} $context", + cmdletName))) + { + } + + /// + /// Creates a new EditorCommand instance that invokes a ScriptBlock. + /// + /// The unique identifier name for the command. + /// The display name for the command. + /// If true, causes output to be suppressed for this command. + /// The ScriptBlock which will be invoked by this command. + public EditorCommand( + string commandName, + string displayName, + bool suppressOutput, + ScriptBlock scriptBlock) + { + this.Name = commandName; + this.DisplayName = displayName; + this.SuppressOutput = suppressOutput; + this.ScriptBlock = scriptBlock; + } + + #endregion + } +} + diff --git a/src/PowerShellEditorServices/Extensions/EditorContext.cs b/src/PowerShellEditorServices/Extensions/EditorContext.cs new file mode 100644 index 000000000..cfccc516e --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorContext.cs @@ -0,0 +1,115 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Linq; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides context for the host editor at the time of creation. + /// + public class EditorContext + { + #region Private Fields + + private IEditorOperations editorOperations; + + #endregion + + #region Properties + + /// + /// Gets the FileContext for the active file. + /// + public FileContext CurrentFile { get; private set; } + + /// + /// Gets the BufferRange representing the current selection in the file. + /// + public BufferRange SelectedRange { get; private set; } + + /// + /// Gets the FilePosition representing the current cursor position. + /// + public FilePosition CursorPosition { get; private set; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the EditorContext class. + /// + /// An IEditorOperations implementation which performs operations in the editor. + /// The ScriptFile that is in the active editor buffer. + /// The position of the user's cursor in the active editor buffer. + /// The range of the user's selection in the active editor buffer. + public EditorContext( + IEditorOperations editorOperations, + ScriptFile currentFile, + BufferPosition cursorPosition, + BufferRange selectedRange) + { + this.editorOperations = editorOperations; + this.CurrentFile = new FileContext(currentFile, this, editorOperations); + this.SelectedRange = selectedRange; + this.CursorPosition = new FilePosition(currentFile, cursorPosition); + } + + #endregion + + #region Public Methods + + /// + /// Sets a selection in the host editor's active buffer. + /// + /// The 1-based starting line of the selection. + /// The 1-based starting column of the selection. + /// The 1-based ending line of the selection. + /// The 1-based ending column of the selection. + public void SetSelection( + int startLine, + int startColumn, + int endLine, + int endColumn) + { + this.SetSelection( + new BufferRange( + startLine, startColumn, + endLine, endColumn)); + } + + /// + /// Sets a selection in the host editor's active buffer. + /// + /// The starting position of the selection. + /// The ending position of the selection. + public void SetSelection( + BufferPosition startPosition, + BufferPosition endPosition) + { + this.SetSelection( + new BufferRange( + startPosition, + endPosition)); + } + + /// + /// Sets a selection in the host editor's active buffer. + /// + /// The range of the selection. + public void SetSelection(BufferRange selectionRange) + { + this.editorOperations + .SetSelection(selectionRange) + .Wait(); + } + + #endregion + } +} + diff --git a/src/PowerShellEditorServices/Extensions/EditorObject.cs b/src/PowerShellEditorServices/Extensions/EditorObject.cs new file mode 100644 index 000000000..b5947ae6e --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorObject.cs @@ -0,0 +1,86 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides the entry point of the extensibility API, inserted into + /// the PowerShell session as the "$psEditor" variable. + /// + public class EditorObject + { + #region Private Fields + + private ExtensionService extensionService; + private IEditorOperations editorOperations; + + #endregion + + #region Properties + + /// + /// Gets the version of PowerShell Editor Services. + /// + public Version EditorServicesVersion + { + get { return this.GetType().Assembly.GetName().Version; } + } + + /// + /// Gets the workspace interface for the editor API. + /// + public EditorWorkspace Workspace { get; private set; } + + #endregion + + /// + /// Creates a new instance of the EditorObject class. + /// + /// An ExtensionService which handles command registration. + /// An IEditorOperations implementation which handles operations in the host editor. + public EditorObject( + ExtensionService extensionService, + IEditorOperations editorOperations) + { + this.extensionService = extensionService; + this.editorOperations = editorOperations; + + // Create API area objects + this.Workspace = new EditorWorkspace(this.editorOperations); + } + + /// + /// Registers a new command in the editor. + /// + /// The EditorCommand to be registered. + public void RegisterCommand(EditorCommand editorCommand) + { + this.extensionService.RegisterCommand(editorCommand); + } + + /// + /// Unregisters an existing EditorCommand based on its registered name. + /// + /// The name of the command to be unregistered. + public void UnregisterCommand(string commandName) + { + this.extensionService.UnregisterCommand(commandName); + } + + /// + /// Gets the EditorContext which contains the state of the editor + /// at the time this method is invoked. + /// + /// A instance of the EditorContext class. + public EditorContext GetEditorContext() + { + return this.editorOperations.GetEditorContext().Result; + } + } +} + diff --git a/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs b/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs new file mode 100644 index 000000000..a9598f09c --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/EditorWorkspace.cs @@ -0,0 +1,44 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides a PowerShell-facing API which allows scripts to + /// interact with the editor's workspace. + /// + public class EditorWorkspace + { + #region Private Fields + + private IEditorOperations editorOperations; + + #endregion + + #region Constructors + + internal EditorWorkspace(IEditorOperations editorOperations) + { + this.editorOperations = editorOperations; + } + + #endregion + + #region Public Methods + + /// + /// Opens a file in the workspace. If the file is already open + /// its buffer will be made active. + /// + /// The path to the file to be opened. + public void OpenFile(string filePath) + { + this.editorOperations.OpenFile(filePath).Wait(); + } + + #endregion + } +} + diff --git a/src/PowerShellEditorServices/Extensions/ExtensionService.cs b/src/PowerShellEditorServices/Extensions/ExtensionService.cs new file mode 100644 index 000000000..c0fee37a9 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/ExtensionService.cs @@ -0,0 +1,241 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.PowerShell.EditorServices.Utility; +using System; +using System.Collections.Generic; +using System.IO; +using System.Management.Automation; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides a high-level service which enables PowerShell scripts + /// and modules to extend the behavior of the host editor. + /// + public class ExtensionService + { + #region Fields + + private Dictionary editorCommands = + new Dictionary(); + + #endregion + + #region Properties + + /// + /// Gets the IEditorOperations implementation used to invoke operations + /// in the host editor. + /// + public IEditorOperations EditorOperations { get; private set; } + + /// + /// Gets the EditorObject which exists in the PowerShell session as the + /// '$psEditor' variable. + /// + public EditorObject EditorObject { get; private set; } + + /// + /// Gets the PowerShellContext in which extension code will be executed. + /// + public PowerShellContext PowerShellContext { get; private set; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the ExtensionService which uses the provided + /// PowerShellContext for loading and executing extension code. + /// + /// A PowerShellContext used to execute extension code. + public ExtensionService(PowerShellContext powerShellContext) + { + this.PowerShellContext = powerShellContext; + } + + #endregion + + #region Public Methods + + /// + /// Initializes this ExtensionService using the provided IEditorOperations + /// implementation for future interaction with the host editor. + /// + /// An IEditorOperations implementation. + /// A Task that can be awaited for completion. + public async Task Initialize(IEditorOperations editorOperations) + { + this.EditorObject = new EditorObject(this, editorOperations); + + // Register the editor object in the runspace + PSCommand variableCommand = new PSCommand(); + using (RunspaceHandle handle = await this.PowerShellContext.GetRunspaceHandle()) + { + handle.Runspace.SessionStateProxy.PSVariable.Set( + "psEditor", + this.EditorObject); + } + + // Load the cmdlet interface + Type thisType = this.GetType(); + Stream resourceStream = + thisType.Assembly.GetManifestResourceStream( + thisType.Namespace + ".CmdletInterface.ps1"); + + using (StreamReader reader = new StreamReader(resourceStream)) + { + // Create a temporary folder path + string randomFileNamePart = + Path.GetFileNameWithoutExtension( + Path.GetRandomFileName()); + + string tempScriptPath = + Path.Combine( + Path.GetTempPath(), + "PSES_ExtensionCmdlets_" + randomFileNamePart + ".ps1"); + + Logger.Write( + LogLevel.Verbose, + "Executing extension API cmdlet script at path: " + tempScriptPath); + + // Read the cmdlet interface script and write it to a temporary + // file so that we don't have to execute the full file contents + // directly. This keeps the script execution from creating a + // lot of noise in the verbose logs. + string cmdletInterfaceScript = reader.ReadToEnd(); + File.WriteAllText( + tempScriptPath, + cmdletInterfaceScript); + + await this.PowerShellContext.ExecuteScriptString( + ". " + tempScriptPath, + writeInputToHost: false, + writeOutputToHost: false); + + // Delete the temporary file + File.Delete(tempScriptPath); + } + } + + /// + /// Invokes the specified editor command against the provided EditorContext. + /// + /// The unique name of the command to be invoked. + /// The context in which the command is being invoked. + /// A Task that can be awaited for completion. + public async Task InvokeCommand(string commandName, EditorContext editorContext) + { + EditorCommand editorCommand; + + if (this.editorCommands.TryGetValue(commandName, out editorCommand)) + { + PSCommand executeCommand = new PSCommand(); + executeCommand.AddCommand("Invoke-Command"); + executeCommand.AddParameter("ScriptBlock", editorCommand.ScriptBlock); + executeCommand.AddParameter("ArgumentList", new object[] { editorContext }); + + await this.PowerShellContext.ExecuteCommand( + executeCommand, + !editorCommand.SuppressOutput, + true); + } + else + { + throw new KeyNotFoundException( + string.Format( + "Editor command not found: '{0}'", + commandName)); + } + } + + /// + /// Registers a new EditorCommand with the ExtensionService and + /// causes its details to be sent to the host editor. + /// + /// The details about the editor command to be registered. + /// True if the command is newly registered, false if the command already exists. + public bool RegisterCommand(EditorCommand editorCommand) + { + bool commandExists = + this.editorCommands.ContainsKey( + editorCommand.Name); + + // Add or replace the editor command + this.editorCommands[editorCommand.Name] = editorCommand; + + if (!commandExists) + { + this.OnCommandAdded(editorCommand); + } + else + { + this.OnCommandUpdated(editorCommand); + } + + return !commandExists; + } + + /// + /// Unregisters an existing EditorCommand based on its registered name. + /// + /// The name of the command to be unregistered. + public void UnregisterCommand(string commandName) + { + EditorCommand existingCommand = null; + if (this.editorCommands.TryGetValue(commandName, out existingCommand)) + { + this.editorCommands.Remove(commandName); + this.OnCommandRemoved(existingCommand); + } + else + { + throw new KeyNotFoundException( + string.Format( + "Command '{0}' is not registered", + commandName)); + } + } + + #endregion + + #region Events + + /// + /// Raised when a new editor command is added. + /// + public event EventHandler CommandAdded; + + private void OnCommandAdded(EditorCommand command) + { + this.CommandAdded?.Invoke(this, command); + } + + /// + /// Raised when an existing editor command is updated. + /// + public event EventHandler CommandUpdated; + + private void OnCommandUpdated(EditorCommand command) + { + this.CommandUpdated?.Invoke(this, command); + } + + /// + /// Raised when an existing editor command is removed. + /// + public event EventHandler CommandRemoved; + + private void OnCommandRemoved(EditorCommand command) + { + this.CommandRemoved?.Invoke(this, command); + } + + #endregion + } +} + diff --git a/src/PowerShellEditorServices/Extensions/FileContext.cs b/src/PowerShellEditorServices/Extensions/FileContext.cs new file mode 100644 index 000000000..9b7f7f8d7 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/FileContext.cs @@ -0,0 +1,220 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Linq; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides context for a file that is open in the editor. + /// + public class FileContext + { + #region Private Fields + + private ScriptFile scriptFile; + private EditorContext editorContext; + private IEditorOperations editorOperations; + + #endregion + + #region Properties + + /// + /// Gets the filesystem path of the file. + /// + public string Path + { + get { return this.scriptFile.FilePath; } + } + + /// + /// Gets the parsed abstract syntax tree for the file. + /// + public Ast Ast + { + get { return this.scriptFile.ScriptAst; } + } + + /// + /// Gets the parsed token list for the file. + /// + public Token[] Tokens + { + get { return this.scriptFile.ScriptTokens; } + } + + /// + /// Gets a BufferRange which represents the entire content + /// range of the file. + /// + public BufferRange FileRange + { + get { return this.scriptFile.FileRange; } + } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the FileContext class. + /// + /// The ScriptFile to which this file refers. + /// The EditorContext to which this file relates. + /// An IEditorOperations implementation which performs operations in the editor. + public FileContext( + ScriptFile scriptFile, + EditorContext editorContext, + IEditorOperations editorOperations) + { + this.scriptFile = scriptFile; + this.editorContext = editorContext; + this.editorOperations = editorOperations; + } + + #endregion + + #region Text Accessors + + /// + /// Gets the complete file content as a string. + /// + /// A string containing the complete file content. + public string GetText() + { + return this.scriptFile.Contents; + } + + /// + /// Gets the file content in the specified range as a string. + /// + /// The buffer range for which content will be extracted. + /// A string with the specified range of content. + public string GetText(BufferRange bufferRange) + { + return + string.Join( + Environment.NewLine, + this.GetTextLines(bufferRange)); + } + + /// + /// Gets the complete file content as an array of strings. + /// + /// An array of strings, each representing a line in the file. + public string[] GetTextLines() + { + return this.scriptFile.FileLines.ToArray(); + } + + /// + /// Gets the file content in the specified range as an array of strings. + /// + /// The buffer range for which content will be extracted. + /// An array of strings, each representing a line in the file within the specified range. + public string[] GetTextLines(BufferRange bufferRange) + { + return this.scriptFile.GetLinesInRange(bufferRange); + } + + #endregion + + #region Text Manipulation + + /// + /// Inserts a text string at the current cursor position represented by + /// the parent EditorContext's CursorPosition property. + /// + /// The text string to insert. + public void InsertText(string textToInsert) + { + // Is there a selection? + if (this.editorContext.SelectedRange.HasRange) + { + this.InsertText( + textToInsert, + this.editorContext.SelectedRange); + } + else + { + this.InsertText( + textToInsert, + this.editorContext.CursorPosition); + } + } + + /// + /// Inserts a text string at the specified buffer position. + /// + /// The text string to insert. + /// The position at which the text will be inserted. + public void InsertText(string textToInsert, BufferPosition insertPosition) + { + this.InsertText( + textToInsert, + new BufferRange(insertPosition, insertPosition)); + } + + /// + /// Inserts a text string at the specified line and column numbers. + /// + /// The text string to insert. + /// The 1-based line number at which the text will be inserted. + /// The 1-based column number at which the text will be inserted. + public void InsertText(string textToInsert, int insertLine, int insertColumn) + { + this.InsertText( + textToInsert, + new BufferPosition(insertLine, insertColumn)); + } + + /// + /// Inserts a text string to replace the specified range, represented + /// by starting and ending line and column numbers. Can be used to + /// insert, replace, or delete text depending on the specified range + /// and text to insert. + /// + /// The text string to insert. + /// The 1-based starting line number where text will be replaced. + /// The 1-based starting column number where text will be replaced. + /// The 1-based ending line number where text will be replaced. + /// The 1-based ending column number where text will be replaced. + public void InsertText( + string textToInsert, + int startLine, + int startColumn, + int endLine, + int endColumn) + { + this.InsertText( + textToInsert, + new BufferRange( + startLine, + startColumn, + endLine, + endColumn)); + } + + /// + /// Inserts a text string to replace the specified range. Can be + /// used to insert, replace, or delete text depending on the specified + /// range and text to insert. + /// + /// The text string to insert. + /// The buffer range which will be replaced by the string. + public void InsertText(string textToInsert, BufferRange insertRange) + { + this.editorOperations + .InsertText(this.scriptFile.ClientFilePath, textToInsert, insertRange) + .Wait(); + } + + #endregion + } +} + diff --git a/src/PowerShellEditorServices/Extensions/IEditorOperations.cs b/src/PowerShellEditorServices/Extensions/IEditorOperations.cs new file mode 100644 index 000000000..80da54774 --- /dev/null +++ b/src/PowerShellEditorServices/Extensions/IEditorOperations.cs @@ -0,0 +1,48 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Extensions +{ + /// + /// Provides an interface that must be implemented by an editor + /// host to perform operations invoked by extensions written in + /// PowerShell. + /// + public interface IEditorOperations + { + /// + /// Gets the EditorContext for the editor's current state. + /// + /// A new EditorContext object. + Task GetEditorContext(); + + /// + /// Causes a file to be opened in the editor. If the file is + /// already open, the editor must switch to the file. + /// + /// The path of the file to be opened. + /// A Task that can be tracked for completion. + Task OpenFile(string filePath); + + /// + /// Inserts text into the specified range for the file at the specified path. + /// + /// The path of the file which will have text inserted. + /// The text to insert into the file. + /// The range in the file to be replaced. + /// A Task that can be tracked for completion. + Task InsertText(string filePath, string insertText, BufferRange insertRange); + + /// + /// Causes the selection to be changed in the editor's active file buffer. + /// + /// The range over which the selection will be made. + /// A Task that can be tracked for completion. + Task SetSelection(BufferRange selectionRange); + } +} + diff --git a/src/PowerShellEditorServices/Language/CompletionResults.cs b/src/PowerShellEditorServices/Language/CompletionResults.cs index 9b95f1f83..1b60c6360 100644 --- a/src/PowerShellEditorServices/Language/CompletionResults.cs +++ b/src/PowerShellEditorServices/Language/CompletionResults.cs @@ -41,20 +41,28 @@ public sealed class CompletionResults public CompletionResults() { this.Completions = new CompletionDetails[0]; - this.ReplacedRange = new BufferRange(); + this.ReplacedRange = new BufferRange(0, 0, 0, 0); } internal static CompletionResults Create( ScriptFile scriptFile, CommandCompletion commandCompletion) { - return new CompletionResults + BufferRange replacedRange = null; + + // Only calculate the replacement range if there are completion results + if (commandCompletion.CompletionMatches.Count > 0) { - Completions = GetCompletionsArray(commandCompletion), - ReplacedRange = + replacedRange = scriptFile.GetRangeBetweenOffsets( commandCompletion.ReplacementIndex, - commandCompletion.ReplacementIndex + commandCompletion.ReplacementLength) + commandCompletion.ReplacementIndex + commandCompletion.ReplacementLength); + } + + return new CompletionResults + { + Completions = GetCompletionsArray(commandCompletion), + ReplacedRange = replacedRange }; } diff --git a/src/PowerShellEditorServices/PowerShellEditorServices.csproj b/src/PowerShellEditorServices/PowerShellEditorServices.csproj index 684ad5050..ef60c428d 100644 --- a/src/PowerShellEditorServices/PowerShellEditorServices.csproj +++ b/src/PowerShellEditorServices/PowerShellEditorServices.csproj @@ -75,6 +75,13 @@ + + + + + + + @@ -127,6 +134,7 @@ + @@ -138,7 +146,9 @@ ScriptAnalyzerEngine - + + + diff --git a/src/PowerShellEditorServices/Session/EditorSession.cs b/src/PowerShellEditorServices/Session/EditorSession.cs index d66179e5e..080e52c81 100644 --- a/src/PowerShellEditorServices/Session/EditorSession.cs +++ b/src/PowerShellEditorServices/Session/EditorSession.cs @@ -4,6 +4,7 @@ // using Microsoft.PowerShell.EditorServices.Console; +using Microsoft.PowerShell.EditorServices.Extensions; using Microsoft.PowerShell.EditorServices.Session; using Microsoft.PowerShell.EditorServices.Utility; using System.IO; @@ -48,6 +49,11 @@ public class EditorSession /// public ConsoleService ConsoleService { get; private set; } + /// + /// Gets the ExtensionService instance for this session. + /// + public ExtensionService ExtensionService { get; private set; } + #endregion #region Public Methods @@ -75,6 +81,7 @@ public void StartSession(HostDetails hostDetails) this.LanguageService = new LanguageService(this.PowerShellContext); this.DebugService = new DebugService(this.PowerShellContext); this.ConsoleService = new ConsoleService(this.PowerShellContext); + this.ExtensionService = new ExtensionService(this.PowerShellContext); this.InstantiateAnalysisService(); diff --git a/src/PowerShellEditorServices/Session/PowerShellContext.cs b/src/PowerShellEditorServices/Session/PowerShellContext.cs index 6771183b1..5a1602cda 100644 --- a/src/PowerShellEditorServices/Session/PowerShellContext.cs +++ b/src/PowerShellEditorServices/Session/PowerShellContext.cs @@ -168,6 +168,7 @@ private void Initialize(HostDetails hostDetails, Runspace initialRunspace) this.initialRunspace = initialRunspace; this.currentRunspace = initialRunspace; + this.psHost.Runspace = initialRunspace; this.currentRunspace.Debugger.BreakpointUpdated += OnBreakpointUpdated; this.currentRunspace.Debugger.DebuggerStop += OnDebuggerStop; @@ -720,13 +721,24 @@ private IEnumerable ExecuteCommandInDebugger(PSCommand psComma } internal void WriteOutput(string outputString, bool includeNewLine) + { + this.WriteOutput( + outputString, + includeNewLine, + OutputType.Normal); + } + + internal void WriteOutput( + string outputString, + bool includeNewLine, + OutputType outputType) { if (this.ConsoleHost != null) { this.ConsoleHost.WriteOutput( outputString, includeNewLine, - OutputType.Normal); + outputType); } } diff --git a/src/PowerShellEditorServices/Session/SessionPSHost.cs b/src/PowerShellEditorServices/Session/SessionPSHost.cs index fa5e49e7e..eb09d2e67 100644 --- a/src/PowerShellEditorServices/Session/SessionPSHost.cs +++ b/src/PowerShellEditorServices/Session/SessionPSHost.cs @@ -8,6 +8,7 @@ using Microsoft.PowerShell.EditorServices.Utility; using System; using System.Management.Automation.Host; +using System.Management.Automation.Runspaces; namespace Microsoft.PowerShell.EditorServices { @@ -16,7 +17,7 @@ namespace Microsoft.PowerShell.EditorServices /// ConsoleService and routes its calls to an IConsoleHost /// implementation. /// - internal class ConsoleServicePSHost : PSHost + internal class ConsoleServicePSHost : PSHost, IHostSupportsInteractiveSession { #region Private Fields @@ -92,6 +93,16 @@ public override PSHostUserInterface UI get { return this.hostUserInterface; } } + public bool IsRunspacePushed + { + get { return false; } + } + + public Runspace Runspace + { + get; internal set; + } + public override void EnterNestedPrompt() { Logger.Write(LogLevel.Verbose, "EnterNestedPrompt() called."); @@ -120,6 +131,16 @@ public override void SetShouldExit(int exitCode) } } + public void PushRunspace(Runspace runspace) + { + throw new NotImplementedException(); + } + + public void PopRunspace() + { + throw new NotImplementedException(); + } + #endregion } } diff --git a/src/PowerShellEditorServices/Workspace/BufferPosition.cs b/src/PowerShellEditorServices/Workspace/BufferPosition.cs index c77bf4a97..effdd7660 100644 --- a/src/PowerShellEditorServices/Workspace/BufferPosition.cs +++ b/src/PowerShellEditorServices/Workspace/BufferPosition.cs @@ -3,13 +3,25 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using System.Diagnostics; + namespace Microsoft.PowerShell.EditorServices { /// - /// Provides details about a position in a file buffer. + /// Provides details about a position in a file buffer. All + /// positions are expressed in 1-based positions (i.e. the + /// first line and column in the file is position 1,1). /// - public struct BufferPosition + [DebuggerDisplay("Position = {Line}:{Column}")] + public class BufferPosition { + #region Properties + + /// + /// Provides an instance that represents a position that has not been set. + /// + public static readonly BufferPosition None = new BufferPosition(-1, -1); + /// /// Gets the line number of the position in the buffer. /// @@ -20,6 +32,10 @@ public struct BufferPosition /// public int Column { get; private set; } + #endregion + + #region Constructors + /// /// Creates a new instance of the BufferPosition class. /// @@ -30,6 +46,66 @@ public BufferPosition(int line, int column) this.Line = line; this.Column = column; } + + #endregion + + #region Public Methods + + /// + /// Compares two instances of the BufferPosition class. + /// + /// The object to which this instance will be compared. + /// True if the positions are equal, false otherwise. + public override bool Equals(object obj) + { + if (!(obj is BufferPosition)) + { + return false; + } + + BufferPosition other = (BufferPosition)obj; + + return + this.Line == other.Line && + this.Column == other.Column; + } + + /// + /// Calculates a unique hash code that represents this instance. + /// + /// A hash code representing this instance. + public override int GetHashCode() + { + return this.Line.GetHashCode() ^ this.Column.GetHashCode(); + } + + /// + /// Compares two positions to check if one is greater than the other. + /// + /// The first position to compare. + /// The second position to compare. + /// True if positionOne is greater than positionTwo. + public static bool operator >(BufferPosition positionOne, BufferPosition positionTwo) + { + return + (positionOne != null && positionTwo == null) || + (positionOne.Line > positionTwo.Line) || + (positionOne.Line == positionTwo.Line && + positionOne.Column > positionTwo.Column); + } + + /// + /// Compares two positions to check if one is less than the other. + /// + /// The first position to compare. + /// The second position to compare. + /// True if positionOne is less than positionTwo. + public static bool operator <(BufferPosition positionOne, BufferPosition positionTwo) + { + return positionTwo > positionOne; + } + + #endregion } } diff --git a/src/PowerShellEditorServices/Workspace/BufferRange.cs b/src/PowerShellEditorServices/Workspace/BufferRange.cs index 83b0767cc..147eed042 100644 --- a/src/PowerShellEditorServices/Workspace/BufferRange.cs +++ b/src/PowerShellEditorServices/Workspace/BufferRange.cs @@ -3,14 +3,25 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using System; +using System.Diagnostics; + namespace Microsoft.PowerShell.EditorServices { /// /// Provides details about a range between two positions in /// a file buffer. /// - public struct BufferRange + [DebuggerDisplay("Start = {Start.Line}:{Start.Column}, End = {End.Line}:{End.Column}")] + public class BufferRange { + #region Properties + + /// + /// Provides an instance that represents a range that has not been set. + /// + public static readonly BufferRange None = new BufferRange(0, 0, 0, 0); + /// /// Gets the start position of the range in the buffer. /// @@ -21,6 +32,22 @@ public struct BufferRange /// public BufferPosition End { get; private set; } + /// + /// Returns true if the current range is non-zero, i.e. + /// contains valid start and end positions. + /// + public bool HasRange + { + get + { + return this.Equals(BufferRange.None); + } + } + + #endregion + + #region Constructors + /// /// Creates a new instance of the BufferRange class. /// @@ -28,9 +55,69 @@ public struct BufferRange /// The end position of the range. public BufferRange(BufferPosition start, BufferPosition end) { + if (start > end) + { + throw new ArgumentException( + string.Format( + "Start position ({0}, {1}) must come before or be equal to the end position ({2}, {3}).", + start.Line, start.Column, + end.Line, end.Column)); + } + this.Start = start; this.End = end; } + + /// + /// Creates a new instance of the BufferRange class. + /// + /// The 1-based starting line number of the range. + /// The 1-based starting column number of the range. + /// The 1-based ending line number of the range. + /// The 1-based ending column number of the range. + public BufferRange( + int startLine, + int startColumn, + int endLine, + int endColumn) + { + this.Start = new BufferPosition(startLine, startColumn); + this.End = new BufferPosition(endLine, endColumn); + } + + #endregion + + #region Public Methods + + /// + /// Compares two instances of the BufferRange class. + /// + /// The object to which this instance will be compared. + /// True if the ranges are equal, false otherwise. + public override bool Equals(object obj) + { + if (!(obj is BufferRange)) + { + return false; + } + + BufferRange other = (BufferRange)obj; + + return + this.Start.Equals(other.Start) && + this.End.Equals(other.End); + } + + /// + /// Calculates a unique hash code that represents this instance. + /// + /// A hash code representing this instance. + public override int GetHashCode() + { + return this.Start.GetHashCode() ^ this.End.GetHashCode(); + } + + #endregion } } diff --git a/src/PowerShellEditorServices/Workspace/FilePosition.cs b/src/PowerShellEditorServices/Workspace/FilePosition.cs new file mode 100644 index 000000000..a7c9036c7 --- /dev/null +++ b/src/PowerShellEditorServices/Workspace/FilePosition.cs @@ -0,0 +1,109 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.PowerShell.EditorServices +{ + /// + /// Provides details and operations for a buffer position in a + /// specific file. + /// + public class FilePosition : BufferPosition + { + #region Private Fields + + private ScriptFile scriptFile; + + #endregion + + #region Constructors + + /// + /// Creates a new FilePosition instance for the 1-based line and + /// column numbers in the specified file. + /// + /// The ScriptFile in which the position is located. + /// The 1-based line number in the file. + /// The 1-based column number in the file. + public FilePosition( + ScriptFile scriptFile, + int line, + int column) + : base(line, column) + { + this.scriptFile = scriptFile; + } + + /// + /// Creates a new FilePosition instance for the specified file by + /// copying the specified BufferPosition + /// + /// The ScriptFile in which the position is located. + /// The original BufferPosition from which the line and column will be copied. + public FilePosition( + ScriptFile scriptFile, + BufferPosition copiedPosition) + : this(scriptFile, copiedPosition.Line, copiedPosition.Column) + { + scriptFile.ValidatePosition(copiedPosition); + } + + #endregion + + #region Public Methods + + /// + /// Gets a FilePosition relative to this position by adding the + /// provided line and column offset relative to the contents of + /// the current file. + /// + /// The line offset to add to this position. + /// The column offset to add to this position. + /// A new FilePosition instance for the calculated position. + public FilePosition AddOffset(int lineOffset, int columnOffset) + { + return this.scriptFile.CalculatePosition( + this, + lineOffset, + columnOffset); + } + + /// + /// Gets a FilePosition for the line and column position + /// of the beginning of the current line after any initial + /// whitespace for indentation. + /// + /// A new FilePosition instance for the calculated position. + public FilePosition GetLineStart() + { + string scriptLine = scriptFile.FileLines[this.Line - 1]; + + int lineStartColumn = 1; + for (int i = 0; i < scriptLine.Length; i++) + { + if (!char.IsWhiteSpace(scriptLine[i])) + { + lineStartColumn = i + 1; + break; + } + } + + return new FilePosition(this.scriptFile, this.Line, lineStartColumn); + } + + /// + /// Gets a FilePosition for the line and column position + /// of the end of the current line. + /// + /// A new FilePosition instance for the calculated position. + public FilePosition GetLineEnd() + { + string scriptLine = scriptFile.FileLines[this.Line - 1]; + return new FilePosition(this.scriptFile, this.Line, scriptLine.Length + 1); + } + + #endregion + } +} + diff --git a/src/PowerShellEditorServices/Workspace/ScriptFile.cs b/src/PowerShellEditorServices/Workspace/ScriptFile.cs index 730a7b3f4..307611b76 100644 --- a/src/PowerShellEditorServices/Workspace/ScriptFile.cs +++ b/src/PowerShellEditorServices/Workspace/ScriptFile.cs @@ -71,6 +71,12 @@ public string Contents } } + /// + /// Gets a BufferRange that represents the entire content + /// range of the file. + /// + public BufferRange FileRange { get; private set; } + /// /// Gets the list of syntax markers found by parsing this /// file's contents. @@ -175,18 +181,98 @@ public ScriptFile( /// The complete line at the given line number. public string GetLine(int lineNumber) { - // TODO: Validate range + Validate.IsWithinRange( + "lineNumber", lineNumber, + 1, this.FileLines.Count + 1); return this.FileLines[lineNumber - 1]; } + /// + /// Gets a range of lines from the file's contents. + /// + /// The buffer range from which lines will be extracted. + /// An array of strings from the specified range of the file. + public string[] GetLinesInRange(BufferRange bufferRange) + { + this.ValidatePosition(bufferRange.Start); + this.ValidatePosition(bufferRange.End); + + List linesInRange = new List(); + + int startLine = bufferRange.Start.Line, + endLine = bufferRange.End.Line; + + for (int line = startLine; line <= endLine; line++) + { + string currentLine = this.FileLines[line - 1]; + int startColumn = + line == startLine + ? bufferRange.Start.Column + : 1; + int endColumn = + line == endLine + ? bufferRange.End.Column + : currentLine.Length + 1; + + currentLine = + currentLine.Substring( + startColumn - 1, + endColumn - startColumn); + + linesInRange.Add(currentLine); + } + + return linesInRange.ToArray(); + } + + /// + /// Throws ArgumentOutOfRangeException if the given position is outside + /// of the file's buffer extents. + /// + /// The position in the buffer to be validated. + public void ValidatePosition(BufferPosition bufferPosition) + { + this.ValidatePosition( + bufferPosition.Line, + bufferPosition.Column); + } + + /// + /// Throws ArgumentOutOfRangeException if the given position is outside + /// of the file's buffer extents. + /// + /// The 1-based line to be validated. + /// The 1-based column to be validated. + public void ValidatePosition(int line, int column) + { + if (line < 1 || line > this.FileLines.Count + 1) + { + throw new ArgumentOutOfRangeException("Position is outside of file line range."); + } + + // The maximum column is either one past the length of the string + // or 1 if the string is empty. + string lineString = this.FileLines[line - 1]; + int maxColumn = lineString.Length > 0 ? lineString.Length + 1 : 1; + + if (column < 1 || column > maxColumn) + { + throw new ArgumentOutOfRangeException( + string.Format( + "Position is outside of column range for line {0}.", + line)); + } + } + /// /// Applies the provided FileChange to the file's contents /// /// The FileChange to apply to the file's contents. public void ApplyChange(FileChange fileChange) { - // TODO: Verify offsets are in range + this.ValidatePosition(fileChange.Line, fileChange.Offset); + this.ValidatePosition(fileChange.EndLine, fileChange.EndOffset); // Break up the change lines string[] changeLines = fileChange.InsertString.Split('\n'); @@ -268,6 +354,30 @@ public int GetOffsetAtPosition(int lineNumber, int columnNumber) return offset; } + /// + /// Calculates a FilePosition relative to a starting BufferPosition + /// using the given 1-based line and column offset. + /// + /// The original BufferPosition from which an new position should be calculated. + /// The 1-based line offset added to the original position in this file. + /// The 1-based column offset added to the original position in this file. + /// A new FilePosition instance with the resulting line and column number. + public FilePosition CalculatePosition( + BufferPosition originalPosition, + int lineOffset, + int columnOffset) + { + int newLine = originalPosition.Line + lineOffset, + newColumn = originalPosition.Column + columnOffset; + + this.ValidatePosition(newLine, newColumn); + + string scriptLine = this.FileLines[newLine - 1]; + newColumn = Math.Min(scriptLine.Length + 1, newColumn); + + return new FilePosition(this, newLine, newColumn); + } + /// /// Calculates the 1-based line and column number position based /// on the given buffer offset. @@ -296,7 +406,7 @@ public BufferRange GetRangeBetweenOffsets(int startOffset, int endOffset) int currentOffset = 0; int searchedOffset = startOffset; - BufferPosition startPosition = new BufferPosition(); + BufferPosition startPosition = new BufferPosition(0, 0); BufferPosition endPosition = startPosition; int line = 0; @@ -369,6 +479,22 @@ private void ParseFileContents() { ParseError[] parseErrors = null; + // First, get the updated file range + int lineCount = this.FileLines.Count; + if (lineCount > 0) + { + this.FileRange = + new BufferRange( + new BufferPosition(1, 1), + new BufferPosition( + lineCount + 1, + this.FileLines[lineCount - 1].Length + 1)); + } + else + { + this.FileRange = BufferRange.None; + } + try { #if PowerShellv5r2 diff --git a/test/PowerShellEditorServices.Test/Extensions/ExtensionServiceTests.cs b/test/PowerShellEditorServices.Test/Extensions/ExtensionServiceTests.cs new file mode 100644 index 000000000..d967dfff8 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Extensions/ExtensionServiceTests.cs @@ -0,0 +1,192 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.PowerShell.EditorServices.Extensions; +using Microsoft.PowerShell.EditorServices.Utility; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.PowerShell.EditorServices.Test.Extensions +{ + public class ExtensionServiceTests : IAsyncLifetime + { + private ScriptFile currentFile; + private EditorContext commandContext; + private ExtensionService extensionService; + private PowerShellContext powerShellContext; + private TestEditorOperations editorOperations; + + private AsyncQueue> extensionEventQueue = + new AsyncQueue>(); + + private enum EventType + { + Add, + Update, + Remove + } + + public async Task InitializeAsync() + { + this.powerShellContext = new PowerShellContext(); + this.extensionService = new ExtensionService(this.powerShellContext); + this.editorOperations = new TestEditorOperations(); + + this.extensionService.CommandAdded += ExtensionService_ExtensionAdded; + this.extensionService.CommandUpdated += ExtensionService_ExtensionUpdated; + this.extensionService.CommandRemoved += ExtensionService_ExtensionRemoved; + + await this.extensionService.Initialize(this.editorOperations); + + var filePath = @"c:\Test\Test.ps1"; + this.currentFile = new ScriptFile(filePath, filePath, "This is a test file", new Version("5.0")); + this.commandContext = + new EditorContext( + this.editorOperations, + currentFile, + new BufferPosition(1, 1), + BufferRange.None); + } + + public Task DisposeAsync() + { + this.powerShellContext.Dispose(); + return Task.FromResult(true); + } + + [Fact] + public async Task CanRegisterAndInvokeCommandWithCmdletName() + { + await extensionService.PowerShellContext.ExecuteScriptString( + "function Invoke-Extension { $global:extensionValue = 5 }\r\n" + + "Register-EditorCommand -Name \"test.function\" -DisplayName \"Function extension\" -Function \"Invoke-Extension\""); + + // Wait for the add event + EditorCommand command = await this.AssertExtensionEvent(EventType.Add, "test.function"); + + // Invoke the command + await extensionService.InvokeCommand("test.function", this.commandContext); + + // Assert the expected value + PSCommand psCommand = new PSCommand(); + psCommand.AddScript("$global:extensionValue"); + var results = await powerShellContext.ExecuteCommand(psCommand); + Assert.Equal(5, results.FirstOrDefault()); + } + + [Fact] + public async Task CanRegisterAndInvokeCommandWithScriptBlock() + { + await extensionService.PowerShellContext.ExecuteScriptString( + "Register-EditorCommand -Name \"test.scriptblock\" -DisplayName \"ScriptBlock extension\" -ScriptBlock { $global:extensionValue = 10 }"); + + // Wait for the add event + EditorCommand command = await this.AssertExtensionEvent(EventType.Add, "test.scriptblock"); + + // Invoke the command + await extensionService.InvokeCommand("test.scriptblock", this.commandContext); + + // Assert the expected value + PSCommand psCommand = new PSCommand(); + psCommand.AddScript("$global:extensionValue"); + var results = await powerShellContext.ExecuteCommand(psCommand); + Assert.Equal(10, results.FirstOrDefault()); + } + + [Fact] + public async Task CanUpdateRegisteredCommand() + { + // Register a command and then update it + await extensionService.PowerShellContext.ExecuteScriptString( + "function Invoke-Extension { Write-Output \"Extension output!\" }\r\n" + + "Register-EditorCommand -Name \"test.function\" -DisplayName \"Function extension\" -Function \"Invoke-Extension\"\r\n" + + "Register-EditorCommand -Name \"test.function\" -DisplayName \"Updated Function extension\" -Function \"Invoke-Extension\""); + + // Wait for the add and update events + await this.AssertExtensionEvent(EventType.Add, "test.function"); + EditorCommand updatedCommand = await this.AssertExtensionEvent(EventType.Update, "test.function"); + + Assert.Equal("Updated Function extension", updatedCommand.DisplayName); + } + + [Fact] + public async Task CanUnregisterCommand() + { + // Add the command and wait for the add event + await extensionService.PowerShellContext.ExecuteScriptString( + "Register-EditorCommand -Name \"test.scriptblock\" -DisplayName \"ScriptBlock extension\" -ScriptBlock { Write-Output \"Extension output!\" }"); + await this.AssertExtensionEvent(EventType.Add, "test.scriptblock"); + + // Remove the command and wait for the remove event + await extensionService.PowerShellContext.ExecuteScriptString( + "Unregister-EditorCommand -Name \"test.scriptblock\""); + await this.AssertExtensionEvent(EventType.Remove, "test.scriptblock"); + + // Ensure that the command has been unregistered + await Assert.ThrowsAsync( + typeof(KeyNotFoundException), + () => extensionService.InvokeCommand("test.scriptblock", this.commandContext)); + } + + private async Task AssertExtensionEvent(EventType expectedEventType, string expectedExtensionName) + { + var eventExtensionTuple = + await this.extensionEventQueue.DequeueAsync( + new CancellationTokenSource(5000).Token); + + Assert.Equal(expectedEventType, eventExtensionTuple.Item1); + Assert.Equal(expectedExtensionName, eventExtensionTuple.Item2.Name); + + return eventExtensionTuple.Item2; + } + + private async void ExtensionService_ExtensionAdded(object sender, EditorCommand e) + { + await this.extensionEventQueue.EnqueueAsync( + new Tuple(EventType.Add, e)); + } + + private async void ExtensionService_ExtensionUpdated(object sender, EditorCommand e) + { + await this.extensionEventQueue.EnqueueAsync( + new Tuple(EventType.Update, e)); + } + + private async void ExtensionService_ExtensionRemoved(object sender, EditorCommand e) + { + await this.extensionEventQueue.EnqueueAsync( + new Tuple(EventType.Remove, e)); + } + } + + public class TestEditorOperations : IEditorOperations + { + public Task OpenFile(string filePath) + { + throw new NotImplementedException(); + } + + public Task InsertText(string filePath, string text, BufferRange insertRange) + { + throw new NotImplementedException(); + } + + public Task SetSelection(BufferRange selectionRange) + { + throw new NotImplementedException(); + } + + public Task GetEditorContext() + { + throw new NotImplementedException(); + } + } +} + diff --git a/test/PowerShellEditorServices.Test/PowerShellEditorServices.Test.csproj b/test/PowerShellEditorServices.Test/PowerShellEditorServices.Test.csproj index 3f7e03dc6..5a9a43cd9 100644 --- a/test/PowerShellEditorServices.Test/PowerShellEditorServices.Test.csproj +++ b/test/PowerShellEditorServices.Test/PowerShellEditorServices.Test.csproj @@ -64,6 +64,7 @@ + diff --git a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs index 2f488cfe1..2045f6fd5 100644 --- a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs +++ b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs @@ -6,11 +6,12 @@ using Microsoft.PowerShell.EditorServices; using System; using System.IO; +using System.Linq; using Xunit; namespace PSLanguageService.Test { - public class FileChangeTests + public class ScriptFileChangeTests { private static readonly Version PowerShellVersion = new Version("5.0"); @@ -151,10 +152,28 @@ public void FindsDotSourcedFiles() } } - private void AssertFileChange( - string initialString, - string expectedString, - FileChange fileChange) + [Fact] + public void ThrowsExceptionWithEditOutsideOfRange() + { + Assert.Throws( + typeof(ArgumentOutOfRangeException), + () => + { + this.AssertFileChange( + "first\r\nsecond\r\nREMOVE\r\nTHESE\r\nLINES\r\nthird", + "first\r\nsecond\r\nthird", + new FileChange + { + Line = 3, + EndLine = 7, + Offset = 1, + EndOffset = 1, + InsertString = "" + }); + }); + } + + internal static ScriptFile CreateScriptFile(string initialString) { using (StringReader stringReader = new StringReader(initialString)) { @@ -166,10 +185,257 @@ private void AssertFileChange( stringReader, PowerShellVersion); - // Apply the FileChange and assert the resulting contents - fileToChange.ApplyChange(fileChange); - Assert.Equal(expectedString, fileToChange.Contents); + return fileToChange; } } + + private void AssertFileChange( + string initialString, + string expectedString, + FileChange fileChange) + { + // Create an in-memory file from the StringReader + ScriptFile fileToChange = CreateScriptFile(initialString); + + // Apply the FileChange and assert the resulting contents + fileToChange.ApplyChange(fileChange); + Assert.Equal(expectedString, fileToChange.Contents); + } + } + + public class ScriptFileGetLinesTests + { + private ScriptFile scriptFile; + + private const string TestString = "Line One\r\nLine Two\r\nLine Three\r\nLine Four\r\nLine Five"; + private readonly string[] TestStringLines = + TestString.Split( + new string[] { "\r\n" }, + StringSplitOptions.None); + + public ScriptFileGetLinesTests() + { + this.scriptFile = + ScriptFileChangeTests.CreateScriptFile( + "Line One\r\nLine Two\r\nLine Three\r\nLine Four\r\nLine Five\r\n"); + } + + [Fact] + public void CanGetWholeLine() + { + string[] lines = + this.scriptFile.GetLinesInRange( + new BufferRange(5, 1, 5, 10)); + + Assert.Equal(1, lines.Length); + Assert.Equal("Line Five", lines[0]); + } + + [Fact] + public void CanGetMultipleWholeLines() + { + string[] lines = + this.scriptFile.GetLinesInRange( + new BufferRange(2, 1, 4, 10)); + + Assert.Equal(TestStringLines.Skip(1).Take(3), lines); + } + + [Fact] + public void CanGetSubstringInSingleLine() + { + string[] lines = + this.scriptFile.GetLinesInRange( + new BufferRange(4, 3, 4, 8)); + + Assert.Equal(1, lines.Length); + Assert.Equal("ne Fo", lines[0]); + } + + [Fact] + public void CanGetEmptySubstringRange() + { + string[] lines = + this.scriptFile.GetLinesInRange( + new BufferRange(4, 3, 4, 3)); + + Assert.Equal(1, lines.Length); + Assert.Equal("", lines[0]); + } + + [Fact] + public void CanGetSubstringInMultipleLines() + { + string[] expectedLines = new string[] + { + "Two", + "Line Three", + "Line Fou" + }; + + string[] lines = + this.scriptFile.GetLinesInRange( + new BufferRange(2, 6, 4, 9)); + + Assert.Equal(expectedLines, lines); + } + + [Fact] + public void CanGetRangeAtLineBoundaries() + { + string[] expectedLines = new string[] + { + "", + "Line Three", + "" + }; + + string[] lines = + this.scriptFile.GetLinesInRange( + new BufferRange(2, 9, 4, 1)); + + Assert.Equal(expectedLines, lines); + } + } + + public class ScriptFilePositionTests + { + private ScriptFile scriptFile; + + public ScriptFilePositionTests() + { + this.scriptFile = + ScriptFileChangeTests.CreateScriptFile(@" +First line + Second line is longer + Third line +"); + } + + [Fact] + public void CanOffsetByLine() + { + this.AssertNewPosition( + 1, 1, + 2, 0, + 3, 1); + + this.AssertNewPosition( + 3, 1, + -2, 0, + 1, 1); + } + + [Fact] + public void CanOffsetByColumn() + { + this.AssertNewPosition( + 2, 1, + 0, 2, + 2, 3); + + this.AssertNewPosition( + 2, 5, + 0, -3, + 2, 2); + } + + [Fact] + public void ThrowsWhenPositionOutOfRange() + { + // Less than line range + Assert.Throws( + typeof(ArgumentOutOfRangeException), + () => + { + scriptFile.CalculatePosition( + new BufferPosition(1, 1), + -10, 0); + }); + + // Greater than line range + Assert.Throws( + typeof(ArgumentOutOfRangeException), + () => + { + scriptFile.CalculatePosition( + new BufferPosition(1, 1), + 10, 0); + }); + + // Less than column range + Assert.Throws( + typeof(ArgumentOutOfRangeException), + () => + { + scriptFile.CalculatePosition( + new BufferPosition(1, 1), + 0, -10); + }); + + // Greater than column range + Assert.Throws( + typeof(ArgumentOutOfRangeException), + () => + { + scriptFile.CalculatePosition( + new BufferPosition(1, 1), + 0, 10); + }); + } + + [Fact] + public void CanFindBeginningOfLine() + { + this.AssertNewPosition( + 4, 12, + pos => pos.GetLineStart(), + 4, 5); + } + + [Fact] + public void CanFindEndOfLine() + { + this.AssertNewPosition( + 4, 12, + pos => pos.GetLineEnd(), + 4, 15); + } + + [Fact] + public void CanComposePositionOperations() + { + this.AssertNewPosition( + 4, 12, + pos => pos.AddOffset(-1, 1).GetLineStart(), + 3, 3); + } + + private void AssertNewPosition( + int originalLine, int originalColumn, + int lineOffset, int columnOffset, + int expectedLine, int expectedColumn) + { + this.AssertNewPosition( + originalLine, originalColumn, + pos => pos.AddOffset(lineOffset, columnOffset), + expectedLine, expectedColumn); + } + + private void AssertNewPosition( + int originalLine, int originalColumn, + Func positionOperation, + int expectedLine, int expectedColumn) + { + var newPosition = + positionOperation( + new FilePosition( + this.scriptFile, + originalLine, + originalColumn)); + + Assert.Equal(expectedLine, newPosition.Line); + Assert.Equal(expectedColumn, newPosition.Column); + } } } From 0c8bdf44df77b0d6ab9ac5dadbe9e348689cfd7e Mon Sep 17 00:00:00 2001 From: David Wilson Date: Fri, 29 Apr 2016 08:28:58 -0700 Subject: [PATCH 2/3] Fix broken profile loading test This change fixes the LanguageServerTests.ServiceLoadsProfilesOnDemand test which was broken by recent changes to our setting loading code. It removes a potential condition where sending a null string for the Script Analyzer settings path causes the AnalysisService to be restarted. This restart resulted in a load failure which polluted the console output, causing the expectations for the profile loading test to fail. --- .../Server/LanguageServer.cs | 2 +- .../Server/LanguageServerSettings.cs | 19 +++++++-- .../LanguageServerTests.cs | 39 +++++++++++-------- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs index 209315ed7..ecc285097 100644 --- a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs @@ -360,7 +360,7 @@ protected async Task HandleDidChangeConfigurationNotification( // If there is a new settings file path, restart the analyzer with the new settigs. bool settingsPathChanged = false; string newSettingsPath = this.currentSettings.ScriptAnalysis.SettingsPath; - if (!(oldScriptAnalysisSettingsPath?.Equals(newSettingsPath, StringComparison.OrdinalIgnoreCase) ?? false)) + if (!string.Equals(oldScriptAnalysisSettingsPath, newSettingsPath, StringComparison.OrdinalIgnoreCase)) { this.editorSession.RestartAnalysisService(newSettingsPath); settingsPathChanged = true; diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServerSettings.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServerSettings.cs index 281334c13..cb90268cb 100644 --- a/src/PowerShellEditorServices.Protocol/Server/LanguageServerSettings.cs +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServerSettings.cs @@ -44,8 +44,6 @@ public void Update(ScriptAnalysisSettings settings, string workspaceRootPath) { if (settings != null) { - Validate.IsNotNullOrEmptyString(nameof(workspaceRootPath), workspaceRootPath); - this.Enable = settings.Enable; string settingsPath = settings.SettingsPath; @@ -56,7 +54,22 @@ public void Update(ScriptAnalysisSettings settings, string workspaceRootPath) } else if (!Path.IsPathRooted(settingsPath)) { - settingsPath = Path.GetFullPath(Path.Combine(workspaceRootPath, settingsPath)); + if (string.IsNullOrEmpty(workspaceRootPath)) + { + // The workspace root path could be an empty string + // when the user has opened a PowerShell script file + // without opening an entire folder (workspace) first. + // In this case we should just log an error and let + // the specified settings path go through even though + // it will fail to load. + Logger.Write( + LogLevel.Error, + "Could not resolve Script Analyzer settings path due to null or empty workspaceRootPath."); + } + else + { + settingsPath = Path.GetFullPath(Path.Combine(workspaceRootPath, settingsPath)); + } } this.SettingsPath = settingsPath; diff --git a/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs b/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs index ef816869a..e21e7e221 100644 --- a/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs +++ b/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs @@ -619,21 +619,6 @@ public async Task ServiceExecutesNativeCommandAndReceivesCommand() [Fact] public async Task ServiceLoadsProfilesOnDemand() { - // Send the configuration change to cause profiles to be loaded - await this.languageServiceClient.SendEvent( - DidChangeConfigurationNotification.Type, - new DidChangeConfigurationParams - { - Settings = new LanguageServerSettingsWrapper - { - Powershell = new LanguageServerSettings - { - EnableProfileLoading = true, - ScriptAnalysis = null - } - } - }); - string testProfilePath = Path.GetFullPath( @"..\..\..\PowerShellEditorServices.Test.Shared\Profile\Profile.ps1"); @@ -654,6 +639,28 @@ await this.languageServiceClient.SendEvent( // Copy the test profile to the current user's host profile path File.Copy(testProfilePath, currentUserCurrentHostPath, true); + Assert.True( + File.Exists(currentUserCurrentHostPath), + "Copied profile path does not exist!"); + + // Send the configuration change to cause profiles to be loaded + await this.languageServiceClient.SendEvent( + DidChangeConfigurationNotification.Type, + new DidChangeConfigurationParams + { + Settings = new LanguageServerSettingsWrapper + { + Powershell = new LanguageServerSettings + { + EnableProfileLoading = true, + ScriptAnalysis = new ScriptAnalysisSettings + { + Enable = false + } + } + } + }); + OutputReader outputReader = new OutputReader(this.protocolClient); Task evaluateTask = @@ -665,7 +672,7 @@ await this.languageServiceClient.SendEvent( Context = "repl" }); - // Try reading up to 10 lines to find the + // Try reading up to 10 lines to find the expected output line string outputString = null; for (int i = 0; i < 10; i++) { From 4816885f7d90db111c6660ab1806b088d225ed08 Mon Sep 17 00:00:00 2001 From: David Wilson Date: Tue, 10 May 2016 10:34:16 -0700 Subject: [PATCH 3/3] Revert to NuGet 3.3 to fix broken build --- appveyor.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/appveyor.yml b/appveyor.yml index 2a8a5daf3..80710a34c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -30,6 +30,7 @@ install: - git submodule -q update --init before_build: +- ps: if (Test-Path 'C:\Tools\NuGet3') { $nugetDir = 'C:\Tools\NuGet3' } else { $nugetDir = 'C:\Tools\NuGet' }; (New-Object Net.WebClient).DownloadFile('https://dist.nuget.org/win-x86-commandline/v3.3.0/nuget.exe', "$nugetDir\NuGet.exe") - nuget restore build: