diff --git a/src/PowerShellEditorServices.Hosting/Internal/EditorServicesRunner.cs b/src/PowerShellEditorServices.Hosting/Internal/EditorServicesRunner.cs index 1c9de268c..5d8c368c9 100644 --- a/src/PowerShellEditorServices.Hosting/Internal/EditorServicesRunner.cs +++ b/src/PowerShellEditorServices.Hosting/Internal/EditorServicesRunner.cs @@ -267,18 +267,12 @@ private HostStartupInfo CreateHostStartupInfo() { _logger.Log(PsesLogLevel.Debug, "Creating startup info object"); - ProfilePathInfo profilePaths = null; - if (_config.ProfilePaths.AllUsersAllHosts != null - || _config.ProfilePaths.AllUsersCurrentHost != null - || _config.ProfilePaths.CurrentUserAllHosts != null - || _config.ProfilePaths.CurrentUserCurrentHost != null) - { - profilePaths = new ProfilePathInfo( - _config.ProfilePaths.CurrentUserAllHosts, - _config.ProfilePaths.CurrentUserCurrentHost, - _config.ProfilePaths.AllUsersAllHosts, - _config.ProfilePaths.AllUsersCurrentHost); - } + ProfilePathInfo profilePaths = new( + _config.ProfilePaths.CurrentUserAllHosts, + _config.ProfilePaths.CurrentUserCurrentHost, + _config.ProfilePaths.AllUsersAllHosts, + _config.ProfilePaths.AllUsersCurrentHost + ); return new HostStartupInfo( _config.HostInfo.Name, diff --git a/src/PowerShellEditorServices/Hosting/HostStartupInfo.cs b/src/PowerShellEditorServices/Hosting/HostStartupInfo.cs index d1f1b27db..3f82990cd 100644 --- a/src/PowerShellEditorServices/Hosting/HostStartupInfo.cs +++ b/src/PowerShellEditorServices/Hosting/HostStartupInfo.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#nullable enable using System; using System.Collections.Generic; @@ -190,32 +191,13 @@ public HostStartupInfo( } /// - /// This is a strange class that is generally null or otherwise just has a single path - /// set. It is eventually parsed one-by-one when setting up the PowerShell runspace. + /// Stores profile information passed from Start-EditorServices to be used for loading profiles if configured + /// and for the $PROFILE variable in the initial session state. /// - /// - /// TODO: Simplify this as a . - /// - public sealed class ProfilePathInfo - { - public ProfilePathInfo( - string currentUserAllHosts, - string currentUserCurrentHost, - string allUsersAllHosts, - string allUsersCurrentHost) - { - CurrentUserAllHosts = currentUserAllHosts; - CurrentUserCurrentHost = currentUserCurrentHost; - AllUsersAllHosts = allUsersAllHosts; - AllUsersCurrentHost = allUsersCurrentHost; - } - - public string CurrentUserAllHosts { get; } - - public string CurrentUserCurrentHost { get; } - - public string AllUsersAllHosts { get; } - - public string AllUsersCurrentHost { get; } - } + public readonly record struct ProfilePathInfo( + string CurrentUserAllHosts, + string CurrentUserCurrentHost, + string AllUsersAllHosts, + string AllUsersCurrentHost + ); } diff --git a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs index 517c4825d..48ba1c27c 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs @@ -306,12 +306,20 @@ public async Task TryStartAsync(HostStartOptions startOptions, Cancellatio _logger.LogDebug("InitialWorkingDirectory set!"); } + _logger.LogDebug("Setting profile variable..."); + await SetProfileVariableAsync(cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Profile variable set!"); + if (startOptions.LoadProfiles) { _logger.LogDebug("Loading profiles..."); await LoadHostProfilesAsync(cancellationToken).ConfigureAwait(false); _logger.LogDebug("Profiles loaded!"); } + else + { + _logger.LogDebug("Profile loading skipped per configuration!"); + } if (!string.IsNullOrEmpty(startOptions.ShellIntegrationScript)) { @@ -583,13 +591,35 @@ internal void DisableTranscribeOnly() } } + internal Task SetProfileVariableAsync(CancellationToken cancellationToken) + { + // If the CurrentUserCurrentHost profile is null then we cannot create the profile variable + if (_hostInfo.ProfilePaths.CurrentUserCurrentHost is null) + { + return Task.CompletedTask; + } + + // NOTE: This is a special task run on startup! + return ExecuteDelegateAsync( + "SetProfileVariable", + executionOptions: null, + (pwsh, _) => pwsh.SetProfileVariable(_hostInfo.ProfilePaths), + cancellationToken); + } + internal Task LoadHostProfilesAsync(CancellationToken cancellationToken) { + // If the CurrentUserCurrentHost profile is null then we cannot instantiate + if (_hostInfo.ProfilePaths.CurrentUserCurrentHost is null) + { + return Task.CompletedTask; + } + // NOTE: This is a special task run on startup! return ExecuteDelegateAsync( "LoadProfiles", executionOptions: null, - (pwsh, _) => pwsh.LoadProfiles(_hostInfo.ProfilePaths), + (pwsh, _) => pwsh.LoadProfileScripts(_hostInfo.ProfilePaths), cancellationToken); } @@ -812,8 +842,9 @@ private void RunTopLevelExecutionLoop() { // Make sure we execute any startup tasks first. These should be, in order: // 1. Delegate to register psEditor variable - // 2. LoadProfiles delegate - // 3. Delegate to import PSEditModule + // 2. SetProfileVariable delegate + // 3. Optional LoadProfiles delegate + // 4. Delegate to import PSEditModule while (_taskQueue.TryTake(out ISynchronousTask task)) { task.ExecuteSynchronously(CancellationToken.None); diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellExtensions.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellExtensions.cs index f7ef51ab9..f24ae98bc 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellExtensions.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/PowerShellExtensions.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. - +#nullable enable using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -69,7 +69,7 @@ and not PSInvocationState.Failed pwsh.InvocationStateChanged += handler; } - public static Collection InvokeAndClear(this PowerShell pwsh, PSInvocationSettings invocationSettings = null) + public static Collection InvokeAndClear(this PowerShell pwsh, PSInvocationSettings? invocationSettings = null) { try { @@ -81,7 +81,7 @@ public static Collection InvokeAndClear(this PowerShell pwsh, } } - public static void InvokeAndClear(this PowerShell pwsh, PSInvocationSettings invocationSettings = null) + public static void InvokeAndClear(this PowerShell pwsh, PSInvocationSettings? invocationSettings = null) { try { @@ -93,13 +93,13 @@ public static void InvokeAndClear(this PowerShell pwsh, PSInvocationSettings inv } } - public static Collection InvokeCommand(this PowerShell pwsh, PSCommand psCommand, PSInvocationSettings invocationSettings = null) + public static Collection InvokeCommand(this PowerShell pwsh, PSCommand psCommand, PSInvocationSettings? invocationSettings = null) { pwsh.Commands = psCommand; return pwsh.InvokeAndClear(invocationSettings); } - public static void InvokeCommand(this PowerShell pwsh, PSCommand psCommand, PSInvocationSettings invocationSettings = null) + public static void InvokeCommand(this PowerShell pwsh, PSCommand psCommand, PSInvocationSettings? invocationSettings = null) { pwsh.Commands = psCommand; pwsh.InvokeAndClear(invocationSettings); @@ -193,7 +193,7 @@ public static void SetCorrectExecutionPolicy(this PowerShell pwsh, ILogger logge } } - public static void LoadProfiles(this PowerShell pwsh, ProfilePathInfo profilePaths) + public static void SetProfileVariable(this PowerShell pwsh, ProfilePathInfo profilePaths) { // Per the documentation, "the `$PROFILE` variable stores the path to the 'Current User, // Current Host' profile. The other profiles are saved in note properties of the @@ -202,15 +202,24 @@ public static void LoadProfiles(this PowerShell pwsh, ProfilePathInfo profilePat // https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles?view=powershell-7.1#the-profile-variable PSObject profileVariable = PSObject.AsPSObject(profilePaths.CurrentUserCurrentHost); + profileVariable.Members.Add(new PSNoteProperty(nameof(profilePaths.AllUsersAllHosts), profilePaths.AllUsersAllHosts)); + profileVariable.Members.Add(new PSNoteProperty(nameof(profilePaths.AllUsersCurrentHost), profilePaths.AllUsersCurrentHost)); + profileVariable.Members.Add(new PSNoteProperty(nameof(profilePaths.CurrentUserAllHosts), profilePaths.CurrentUserAllHosts)); + profileVariable.Members.Add(new PSNoteProperty(nameof(profilePaths.CurrentUserCurrentHost), profilePaths.CurrentUserCurrentHost)); + + pwsh.Runspace.SessionStateProxy.SetVariable("PROFILE", profileVariable); + } + + public static void LoadProfileScripts(this PowerShell pwsh, ProfilePathInfo profilePaths) + { + PSObject profileVariable = PSObject.AsPSObject(profilePaths.CurrentUserCurrentHost); + PSCommand psCommand = new PSCommand() .AddProfileLoadIfExists(profileVariable, nameof(profilePaths.AllUsersAllHosts), profilePaths.AllUsersAllHosts) .AddProfileLoadIfExists(profileVariable, nameof(profilePaths.AllUsersCurrentHost), profilePaths.AllUsersCurrentHost) .AddProfileLoadIfExists(profileVariable, nameof(profilePaths.CurrentUserAllHosts), profilePaths.CurrentUserAllHosts) .AddProfileLoadIfExists(profileVariable, nameof(profilePaths.CurrentUserCurrentHost), profilePaths.CurrentUserCurrentHost); - // NOTE: This must be set before the profiles are loaded. - pwsh.Runspace.SessionStateProxy.SetVariable("PROFILE", profileVariable); - // NOTE: Because it's possible there are no profiles defined, we might have an empty // command. Since this is being executed directly, we can't rely on `ThrowOnError = // false` to avoid an exception here. Instead, we must just not execute it. @@ -253,7 +262,7 @@ private static StringBuilder AddErrorString(this StringBuilder sb, ErrorRecord e .AppendLine("Exception:") .Append(" ").Append(error.Exception.ToString() ?? ""); - Exception innerException = error.Exception?.InnerException; + Exception? innerException = error.Exception?.InnerException; while (innerException != null) { sb.AppendLine("InnerException:") diff --git a/test/PowerShellEditorServices.Test/Services/Symbols/PSScriptAnalyzerTests.cs b/test/PowerShellEditorServices.Test/Services/Symbols/PSScriptAnalyzerTests.cs index 1155db102..273e5ecec 100644 --- a/test/PowerShellEditorServices.Test/Services/Symbols/PSScriptAnalyzerTests.cs +++ b/test/PowerShellEditorServices.Test/Services/Symbols/PSScriptAnalyzerTests.cs @@ -29,7 +29,7 @@ public class PSScriptAnalyzerTests profileId: "", version: null, psHost: null, - profilePaths: null, + profilePaths: default, featureFlags: null, additionalModules: null, initialSessionState: null, diff --git a/test/PowerShellEditorServices.Test/Session/PsesInternalHostTests.cs b/test/PowerShellEditorServices.Test/Session/PsesInternalHostTests.cs index 617b610ed..e9b5cf37c 100644 --- a/test/PowerShellEditorServices.Test/Session/PsesInternalHostTests.cs +++ b/test/PowerShellEditorServices.Test/Session/PsesInternalHostTests.cs @@ -105,23 +105,45 @@ public async Task CanCancelExecutionWithMethod() } [Fact] - public async Task CanHandleNoProfiles() + public async Task CanHandleMissingProfilePaths() { - // Call LoadProfiles with profile paths that won't exist, and assert that it does not - // throw PSInvalidOperationException (which it previously did when it tried to invoke an - // empty command). + // Call LoadProfileScripts with profile paths that won't exist, and assert that it does + // not throw PSInvalidOperationException (which it previously did when it tried to + // invoke an empty command). ProfilePathInfo emptyProfilePaths = new("", "", "", ""); await psesHost.ExecuteDelegateAsync( - "LoadProfiles", + "SetProfileVariableAndLoadProfileScripts", executionOptions: null, (pwsh, _) => { - pwsh.LoadProfiles(emptyProfilePaths); + pwsh.SetProfileVariable(emptyProfilePaths); + pwsh.LoadProfileScripts(emptyProfilePaths); + + Assert.Equal(emptyProfilePaths.CurrentUserCurrentHost, pwsh.Runspace.SessionStateProxy.GetVariable("PROFILE")?.ToString()); Assert.Empty(pwsh.Commands.Commands); }, CancellationToken.None); } + [Fact] + public async Task SetsProfileVariableWhenProfilesAreNotLoaded() + { + // This host fixture starts with LoadProfiles = false. Ensure $PROFILE is still set. + IReadOnlyList profileVariable = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("$PROFILE"), + CancellationToken.None); + + Assert.Collection(profileVariable, + (p) => Assert.Equal(PsesHostFactory.TestProfilePaths.CurrentUserCurrentHost, p)); + + // Ensure profile scripts were not loaded as part of startup. + IReadOnlyList profileLoadedCommand = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("Get-Command Assert-ProfileLoaded -ErrorAction Ignore"), + CancellationToken.None); + + Assert.Empty(profileLoadedCommand); + } + // NOTE: Tests where we call functions that use PowerShell runspaces are slightly more // complicated than one would expect because we explicitly need the methods to run on the // pipeline thread, otherwise Windows complains about the the thread's apartment state not