diff --git a/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs b/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs index 33c2d9524..2cf1ab05f 100644 --- a/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs +++ b/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs @@ -19,6 +19,7 @@ using Microsoft.Extensions.Logging; using Microsoft.PowerShell.EditorServices.Extensions; using Microsoft.PowerShell.EditorServices.Host; +using Microsoft.PowerShell.EditorServices.Templates; using Serilog; namespace Microsoft.PowerShell.EditorServices.Engine @@ -244,6 +245,7 @@ public void StartLanguageService( GetFullyInitializedPowerShellContext( provider.GetService(), profilePaths)) + .AddSingleton() .AddSingleton() .AddSingleton( (provider) => diff --git a/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServer.cs b/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServer.cs index e0e677eec..612aa0aa5 100644 --- a/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServer.cs +++ b/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServer.cs @@ -114,6 +114,7 @@ public async Task StartAsync() .WithHandler() .WithHandler() .WithHandler() + .WithHandler() .OnInitialize( async (languageServer, request) => { diff --git a/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/ITemplateHandlers.cs b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/ITemplateHandlers.cs new file mode 100644 index 000000000..9fefae8d1 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/ITemplateHandlers.cs @@ -0,0 +1,76 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using OmniSharp.Extensions.Embedded.MediatR; +using OmniSharp.Extensions.JsonRpc; + +namespace PowerShellEditorServices.Engine.Services.Handlers +{ + [Serial, Method("powerShell/getProjectTemplates")] + public interface IGetProjectTemplatesHandler : IJsonRpcRequestHandler { } + + [Serial, Method("powerShell/newProjectFromTemplate")] + public interface INewProjectFromTemplateHandler : IJsonRpcRequestHandler { } + + public class GetProjectTemplatesRequest : IRequest + { + public bool IncludeInstalledModules { get; set; } + } + + public class GetProjectTemplatesResponse + { + public bool NeedsModuleInstall { get; set; } + + public TemplateDetails[] Templates { get; set; } + } + + /// + /// Provides details about a file or project template. + /// + public class TemplateDetails + { + /// + /// Gets or sets the title of the template. + /// + public string Title { get; set; } + + /// + /// Gets or sets the author of the template. + /// + public string Author { get; set; } + + /// + /// Gets or sets the version of the template. + /// + public string Version { get; set; } + + /// + /// Gets or sets the description of the template. + /// + public string Description { get; set; } + + /// + /// Gets or sets the template's comma-delimited string of tags. + /// + public string Tags { get; set; } + + /// + /// Gets or sets the template's folder path. + /// + public string TemplatePath { get; set; } + } + + public class NewProjectFromTemplateRequest : IRequest + { + public string DestinationPath { get; set; } + + public string TemplatePath { get; set; } + } + + public class NewProjectFromTemplateResponse + { + public bool CreationSuccessful { get; set; } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/TemplateHandlers.cs b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/TemplateHandlers.cs new file mode 100644 index 000000000..d02bddcc9 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Handlers/TemplateHandlers.cs @@ -0,0 +1,73 @@ +// +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices; +using Microsoft.PowerShell.EditorServices.Templates; + +namespace PowerShellEditorServices.Engine.Services.Handlers +{ + public class TemplateHandlers : IGetProjectTemplatesHandler, INewProjectFromTemplateHandler + { + private readonly ILogger _logger; + private readonly TemplateService _templateService; + + public TemplateHandlers( + ILoggerFactory factory, + TemplateService templateService) + { + _logger = factory.CreateLogger(); + _templateService = templateService; + } + + public async Task Handle(GetProjectTemplatesRequest request, CancellationToken cancellationToken) + { + bool plasterInstalled = await _templateService.ImportPlasterIfInstalledAsync(); + + if (plasterInstalled) + { + var availableTemplates = + await _templateService.GetAvailableTemplatesAsync( + request.IncludeInstalledModules); + + + return new GetProjectTemplatesResponse + { + Templates = availableTemplates + }; + } + + return new GetProjectTemplatesResponse + { + NeedsModuleInstall = true, + Templates = new TemplateDetails[0] + }; + } + + public async Task Handle(NewProjectFromTemplateRequest request, CancellationToken cancellationToken) + { + bool creationSuccessful; + try + { + await _templateService.CreateFromTemplateAsync(request.TemplatePath, request.DestinationPath); + creationSuccessful = true; + } + catch (Exception e) + { + // We don't really care if this worked or not but we report status. + _logger.LogException("New plaster template failed.", e); + creationSuccessful = false; + } + + return new NewProjectFromTemplateResponse + { + CreationSuccessful = creationSuccessful + }; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/PowerShellContext/TemplateService.cs b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/TemplateService.cs new file mode 100644 index 000000000..8ac5cfc8e --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/TemplateService.cs @@ -0,0 +1,210 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Utility; +using PowerShellEditorServices.Engine.Services.Handlers; +using System; +using System.Linq; +using System.Management.Automation; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Templates +{ + /// + /// Provides a service for listing PowerShell project templates and creating + /// new projects from those templates. This service leverages the Plaster + /// module for creating projects from templates. + /// + public class TemplateService + { + #region Private Fields + + private readonly ILogger logger; + private bool isPlasterLoaded; + private bool? isPlasterInstalled; + private readonly PowerShellContextService powerShellContext; + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the TemplateService class. + /// + /// The PowerShellContext to use for this service. + /// An ILoggerFactory implementation used for writing log messages. + public TemplateService(PowerShellContextService powerShellContext, ILoggerFactory factory) + { + Validate.IsNotNull(nameof(powerShellContext), powerShellContext); + + this.logger = factory.CreateLogger(); + this.powerShellContext = powerShellContext; + } + + #endregion + + #region Public Methods + + /// + /// Checks if Plaster is installed on the user's machine. + /// + /// A Task that can be awaited until the check is complete. The result will be true if Plaster is installed. + public async Task ImportPlasterIfInstalledAsync() + { + if (!this.isPlasterInstalled.HasValue) + { + PSCommand psCommand = new PSCommand(); + + psCommand + .AddCommand("Get-Module") + .AddParameter("ListAvailable") + .AddParameter("Name", "Plaster"); + + psCommand + .AddCommand("Sort-Object") + .AddParameter("Descending") + .AddParameter("Property", "Version"); + + psCommand + .AddCommand("Select-Object") + .AddParameter("First", 1); + + this.logger.LogTrace("Checking if Plaster is installed..."); + + var getResult = + await this.powerShellContext.ExecuteCommandAsync( + psCommand, false, false); + + PSObject moduleObject = getResult.First(); + this.isPlasterInstalled = moduleObject != null; + string installedQualifier = + this.isPlasterInstalled.Value + ? string.Empty : "not "; + + this.logger.LogTrace($"Plaster is {installedQualifier}installed!"); + + // Attempt to load plaster + if (this.isPlasterInstalled.Value && this.isPlasterLoaded == false) + { + this.logger.LogTrace("Loading Plaster..."); + + psCommand = new PSCommand(); + psCommand + .AddCommand("Import-Module") + .AddParameter("ModuleInfo", (PSModuleInfo)moduleObject.ImmediateBaseObject) + .AddParameter("PassThru"); + + var importResult = + await this.powerShellContext.ExecuteCommandAsync( + psCommand, false, false); + + this.isPlasterLoaded = importResult.Any(); + string loadedQualifier = + this.isPlasterInstalled.Value + ? "was" : "could not be"; + + this.logger.LogTrace($"Plaster {loadedQualifier} loaded successfully!"); + } + } + + return this.isPlasterInstalled.Value; + } + + /// + /// Gets the available file or project templates on the user's + /// machine. + /// + /// + /// If true, searches the user's installed PowerShell modules for + /// included templates. + /// + /// A Task which can be awaited for the TemplateDetails list to be returned. + public async Task GetAvailableTemplatesAsync( + bool includeInstalledModules) + { + if (!this.isPlasterLoaded) + { + throw new InvalidOperationException("Plaster is not loaded, templates cannot be accessed."); + } + + PSCommand psCommand = new PSCommand(); + psCommand.AddCommand("Get-PlasterTemplate"); + + if (includeInstalledModules) + { + psCommand.AddParameter("IncludeModules"); + } + + var templateObjects = + await this.powerShellContext.ExecuteCommandAsync( + psCommand, false, false); + + this.logger.LogTrace($"Found {templateObjects.Count()} Plaster templates"); + + return + templateObjects + .Select(CreateTemplateDetails) + .ToArray(); + } + + /// + /// Creates a new file or project from a specified template and + /// places it in the destination path. This ultimately calls + /// Invoke-Plaster in PowerShell. + /// + /// The folder path containing the template. + /// The folder path where the files will be created. + /// A boolean-returning Task which communicates success or failure. + public async Task CreateFromTemplateAsync( + string templatePath, + string destinationPath) + { + this.logger.LogTrace( + $"Invoking Plaster...\n\n TemplatePath: {templatePath}\n DestinationPath: {destinationPath}"); + + PSCommand command = new PSCommand(); + command.AddCommand("Invoke-Plaster"); + command.AddParameter("TemplatePath", templatePath); + command.AddParameter("DestinationPath", destinationPath); + + var errorString = new System.Text.StringBuilder(); + await this.powerShellContext.ExecuteCommandAsync( + command, + errorString, + new ExecutionOptions + { + WriteOutputToHost = false, + WriteErrorsToHost = true, + InterruptCommandPrompt = true + }); + + // If any errors were written out, creation was not successful + return errorString.Length == 0; + } + + #endregion + + #region Private Methods + + private static TemplateDetails CreateTemplateDetails(PSObject psObject) + { + return new TemplateDetails + { + Title = psObject.Members["Title"].Value as string, + Author = psObject.Members["Author"].Value as string, + Version = psObject.Members["Version"].Value.ToString(), + Description = psObject.Members["Description"].Value as string, + TemplatePath = psObject.Members["TemplatePath"].Value as string, + Tags = + psObject.Members["Tags"].Value is object[] tags + ? string.Join(", ", tags) + : string.Empty + }; + } + + #endregion + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs index d66b35f38..49b3ea633 100644 --- a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs @@ -728,5 +728,27 @@ await LanguageClient.SendRequest( Assert.Equal(1, locationOrLocationLink.Location.Range.End.Line); Assert.Equal(33, locationOrLocationLink.Location.Range.End.Character); } + + [Fact] + public async Task CanSendGetProjectTemplatesRequest() + { + GetProjectTemplatesResponse getProjectTemplatesResponse = + await LanguageClient.SendRequest( + "powerShell/getProjectTemplates", + new GetProjectTemplatesRequest + { + IncludeInstalledModules = true + }); + + Assert.Collection(getProjectTemplatesResponse.Templates.OrderBy(t => t.Title), + template1 => + { + Assert.Equal("AddPSScriptAnalyzerSettings", template1.Title); + }, + template2 => + { + Assert.Equal("New PowerShell Manifest Module", template2.Title); + }); + } } }