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: 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..ecc285097 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, @@ -320,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; @@ -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.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/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.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++) { 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); + } } }