diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Var.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Var.cs index 85bdd2b02d..12cfb784c7 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Var.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Var.cs @@ -693,6 +693,13 @@ namespace Microsoft.PowerShell.Commands private bool _passThru; + /// + /// Gets whether we will append to the variable if it exists. + /// + [Parameter] + [Experimental(ExperimentalFeature.PSRedirectToVariable, ExperimentAction.Show)] + public SwitchParameter Append { get; set; } + private bool _nameIsFormalParameter; private bool _valueIsFormalParameter; #endregion parameters @@ -711,6 +718,33 @@ namespace Microsoft.PowerShell.Commands { _valueIsFormalParameter = true; } + + if (Append) + { + // create the list here and add to it if it has a value + // but if they have more than one name, produce an error + if (Name.Length != 1) + { + ErrorRecord appendVariableError = new ErrorRecord(new InvalidOperationException(), "SetVariableAppend", ErrorCategory.InvalidOperation, Name); + appendVariableError.ErrorDetails = new ErrorDetails("SetVariableAppend"); + appendVariableError.ErrorDetails.RecommendedAction = VariableCommandStrings.UseSingleVariable; + ThrowTerminatingError(appendVariableError); + } + + _valueList = new List(); + var currentValue = Context.SessionState.PSVariable.Get(Name[0]); + if (currentValue is not null) + { + if (currentValue.Value is IList ilist) + { + _valueList.AddRange(ilist); + } + else + { + _valueList.Add(currentValue.Value); + } + } + } } /// @@ -726,6 +760,16 @@ namespace Microsoft.PowerShell.Commands { if (_nameIsFormalParameter && _valueIsFormalParameter) { + if (Append) + { + if (Value != AutomationNull.Value) + { + _valueList ??= new List(); + + _valueList.Add(Value); + } + } + return; } @@ -756,7 +800,14 @@ namespace Microsoft.PowerShell.Commands { if (_valueIsFormalParameter) { - SetVariable(Name, Value); + if (Append) + { + SetVariable(Name, _valueList); + } + else + { + SetVariable(Name, Value); + } } else { diff --git a/src/Microsoft.PowerShell.Commands.Utility/resources/VariableCommandStrings.resx b/src/Microsoft.PowerShell.Commands.Utility/resources/VariableCommandStrings.resx index be5d3bbe47..d385adda03 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/resources/VariableCommandStrings.resx +++ b/src/Microsoft.PowerShell.Commands.Utility/resources/VariableCommandStrings.resx @@ -123,6 +123,9 @@ Name: {0} Value: {1} + + Use a single variable rather than a collection + New variable diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index 1ddc7adca2..176089d29c 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -25,6 +25,7 @@ namespace System.Management.Automation internal const string PSFeedbackProvider = "PSFeedbackProvider"; internal const string PSCommandWithArgs = "PSCommandWithArgs"; internal const string PSNativeWindowsTildeExpansion = nameof(PSNativeWindowsTildeExpansion); + internal const string PSRedirectToVariable = "PSRedirectToVariable"; #endregion @@ -127,8 +128,10 @@ namespace System.Management.Automation description: "Enable `-CommandWithArgs` parameter for pwsh"), new ExperimentalFeature( name: PSNativeWindowsTildeExpansion, - description: "On windows, expand unquoted tilde (`~`) with the user's current home folder." - ) + description: "On windows, expand unquoted tilde (`~`) with the user's current home folder."), + new ExperimentalFeature( + name: PSRedirectToVariable, + description: "Add support for redirecting to the variable drive"), }; EngineExperimentalFeatures = new ReadOnlyCollection(engineFeatures); diff --git a/src/System.Management.Automation/engine/runtime/Operations/MiscOps.cs b/src/System.Management.Automation/engine/runtime/Operations/MiscOps.cs index 8452c27b98..ddc70fabd5 100644 --- a/src/System.Management.Automation/engine/runtime/Operations/MiscOps.cs +++ b/src/System.Management.Automation/engine/runtime/Operations/MiscOps.cs @@ -1090,10 +1090,23 @@ namespace System.Management.Automation // dir > out internal override void Bind(PipelineProcessor pipelineProcessor, CommandProcessorBase commandProcessor, ExecutionContext context) { + // Check first to see if File is a variable path. If so, we'll not create the FileBytePipe + bool redirectToVariable = false; + if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSRedirectToVariable)) + { + ProviderInfo p; + context.SessionState.Path.GetUnresolvedProviderPathFromPSPath(File, out p, out _); + if (p != null && p.NameEquals(context.ProviderNames.Variable)) + { + redirectToVariable = true; + } + } + if (commandProcessor is NativeCommandProcessor nativeCommand && nativeCommand.CommandRuntime.ErrorMergeTo is not MshCommandRuntime.MergeDataStream.Output && FromStream is RedirectionStream.Output - && !string.IsNullOrWhiteSpace(File)) + && !string.IsNullOrWhiteSpace(File) + && !redirectToVariable) { nativeCommand.StdOutDestination = FileBytePipe.Create(File, Appending); return; @@ -1208,26 +1221,51 @@ namespace System.Management.Automation return new Pipe { NullPipe = true }; } - CommandProcessorBase commandProcessor = context.CreateCommand("out-file", false); - Diagnostics.Assert(commandProcessor != null, "CreateCommand returned null"); + // determine whether we're trying to set a variable by inspecting the file path + // if we can determine that it's a variable, we'll use Set-Variable rather than Out-File + ProviderInfo p; + PSDriveInfo d; + CommandProcessorBase commandProcessor; + var name = context.SessionState.Path.GetUnresolvedProviderPathFromPSPath(File, out p, out d); - // Previously, we mandated Unicode encoding here - // Now, We can take what ever has been set if PSDefaultParameterValues - // Unicode is still the default, but now may be overridden - - var cpi = CommandParameterInternal.CreateParameterWithArgument( - /*parameterAst*/null, "Filepath", "-Filepath:", - /*argumentAst*/null, File, - false); - commandProcessor.AddParameter(cpi); - - if (this.Appending) + if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSRedirectToVariable) && p != null && p.NameEquals(context.ProviderNames.Variable)) { - cpi = CommandParameterInternal.CreateParameterWithArgument( - /*parameterAst*/null, "Append", "-Append:", - /*argumentAst*/null, true, + commandProcessor = context.CreateCommand("Set-Variable", false); + Diagnostics.Assert(commandProcessor != null, "CreateCommand returned null"); + var cpi = CommandParameterInternal.CreateParameterWithArgument( + /*parameterAst*/null, "Name", "-Name:", + /*argumentAst*/null, name, false); commandProcessor.AddParameter(cpi); + + if (this.Appending) + { + commandProcessor.AddParameter(CommandParameterInternal.CreateParameter("Append", "-Append", null)); + } + } + else + { + commandProcessor = context.CreateCommand("out-file", false); + Diagnostics.Assert(commandProcessor != null, "CreateCommand returned null"); + + // Previously, we mandated Unicode encoding here + // Now, We can take what ever has been set if PSDefaultParameterValues + // Unicode is still the default, but now may be overridden + + var cpi = CommandParameterInternal.CreateParameterWithArgument( + /*parameterAst*/null, "Filepath", "-Filepath:", + /*argumentAst*/null, File, + false); + commandProcessor.AddParameter(cpi); + + if (this.Appending) + { + cpi = CommandParameterInternal.CreateParameterWithArgument( + /*parameterAst*/null, "Append", "-Append:", + /*argumentAst*/null, true, + false); + commandProcessor.AddParameter(cpi); + } } PipelineProcessor = new PipelineProcessor(); @@ -1243,9 +1281,15 @@ namespace System.Management.Automation // is more specific tp the redirection operation... if (rte.ErrorRecord.Exception is System.ArgumentException) { - throw InterpreterError.NewInterpreterExceptionWithInnerException(null, - typeof(RuntimeException), null, "RedirectionFailed", ParserStrings.RedirectionFailed, - rte.ErrorRecord.Exception, File, rte.ErrorRecord.Exception.Message); + throw InterpreterError.NewInterpreterExceptionWithInnerException( + null, + typeof(RuntimeException), + null, + "RedirectionFailed", + ParserStrings.RedirectionFailed, + rte.ErrorRecord.Exception, + File, + rte.ErrorRecord.Exception.Message); } throw; diff --git a/test/powershell/Language/Parser/RedirectionOperator.Tests.ps1 b/test/powershell/Language/Parser/RedirectionOperator.Tests.ps1 index 0c9ef0f0b9..d948881f1d 100644 --- a/test/powershell/Language/Parser/RedirectionOperator.Tests.ps1 +++ b/test/powershell/Language/Parser/RedirectionOperator.Tests.ps1 @@ -123,3 +123,125 @@ Describe "File redirection should have 'DoComplete' called on the underlying pip $errorContent | Should -Match "CommandNotFoundException,Microsoft.PowerShell.Commands.GetCommandCommand" } } + +Describe "Redirection and Set-Variable -append tests" -tags CI { + Context "variable redirection should work" { + BeforeAll { + if ( $EnabledExperimentalFeatures -contains "PSRedirectToVariable" ) { + $skipTest = $false + } + else { + $skipTest = $true + } + $testCases = @{ Name = "Variable should be created"; scriptBlock = { 1..3>variable:a }; Validation = { ($a -join "") | Should -Be ((1..3) -join "") } }, + @{ Name = "variable should be appended"; scriptBlock = {1..3>variable:a; 4..6>>variable:a}; Validation = { ($a -join "") | Should -Be ((1..6) -join "")}}, + @{ Name = "variable should maintain type"; scriptBlock = {@{one=1}>variable:a};Validation = {$a | Should -BeOfType [hashtable]}}, + @{ + Name = "variable should maintain type for multiple objects" + scriptBlock = {@{one=1}>variable:a;@{two=2}>>variable:a;1>>variable:a;"string">>variable:a} + Validation = { + $a.count | Should -Be 4 + ,$a | Should -BeOfType [array] + $a[0] | Should -BeOfType [hashtable] + $a[1] | Should -BeOfType [hashtable] + $a[2] | Should -BeOfType [int] + $a[3] | Should -BeOfType [string] + $a[0].one | Should -Be 1 + $a[1].two | Should -Be 2 + $a[2] | Should -Be 1 + $a[3] | Should -Be "string" + } + }, + @{ Name = "Error stream should be redirectable" + scriptBlock = { write-error bad 2>variable:a} + validation = { + $a |Should -BeOfType [System.Management.Automation.ErrorRecord] + $a.Exception.Message | Should -BeExactly "bad" + } + }, + @{ Name = "Warning stream should be redirectable" + scriptBlock = { write-warning warn 3>variable:a} + validation = { + $a |Should -BeOfType [System.Management.Automation.WarningRecord] + $a.Message | Should -BeExactly "warn" + } + }, + @{ Name = "Verbose stream should be redirectable" + scriptBlock = { write-verbose -verbose verb 4>variable:a} + validation = { + $a |Should -BeOfType [System.Management.Automation.VerboseRecord] + $a.Message | Should -BeExactly "verb" + } + }, + @{ Name = "Debug stream should be redirectable" + scriptBlock = { write-debug -debug deb 5>variable:a} + validation = { + $a |Should -BeOfType [System.Management.Automation.DebugRecord] + $a.Message | Should -BeExactly "deb" + } + }, + @{ Name = "Information stream should be redirectable" + scriptBlock = { write-information info 6>variable:a} + validation = { + $a |Should -BeOfType [System.Management.Automation.InformationRecord] + $a.MessageData | Should -BeExactly "info" + } + }, + @{ Name = "Complex redirection should be supported" + scriptBlock = { + . { + write-error bad + write-information info + } *>variable:a + } + validation = { + $a.Count | Should -Be 2 + $a[0] | Should -BeOfType [System.Management.Automation.ErrorRecord] + $a[0].Exception.Message | Should -Be "bad" + $a[1] |Should -BeOfType [System.Management.Automation.InformationRecord] + $a[1].MessageData | Should -BeExactly "info" + } + }, + @{ + Name = "multiple redirections should work" + scriptBlock = { + . { + write-error bad + write-information info + } 2>variable:e 6>variable:i + } + validation = { + $e | Should -BeOfType [System.Management.Automation.ErrorRecord] + $e.Exception.Message | Should -Be "bad" + $i | Should -BeOfType [System.Management.Automation.InformationRecord] + $i.MessageData | Should -BeExactly "info" + } + } + + + } + It "" -TestCases $testCases -skip:$skipTest { + param ( $scriptBlock, $validation ) + . $scriptBlock + . $validation + } + + It 'Redirection of a native application is correct' { + $expected = @('Arg 0 is ','Arg 1 is ') + testexe -echoargs hi bye > variable:observed + $observed | Should -Be $expected + } + + It 'Redirection while in variable provider is correct' { + $expected = @('Arg 0 is ','Arg 1 is ') + try { + Push-Location variable: + testexe -echoargs hi bye > observed + } + finally { + Pop-Location + } + $observed | Should -Be $expected + } + } +} diff --git a/test/powershell/Modules/Microsoft.PowerShell.Security/ConstrainedLanguageRestriction.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Security/ConstrainedLanguageRestriction.Tests.ps1 index 398bc49271..6fb97a46ca 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Security/ConstrainedLanguageRestriction.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Security/ConstrainedLanguageRestriction.Tests.ps1 @@ -460,7 +460,7 @@ try $rs.Open() $pl = $rs.CreatePipeline('"Hello" > c:\temp\foo.txt') - $e = { $pl.Invoke() } | Should -Throw -ErrorId "CmdletInvocationException" + $e = { $pl.Invoke() } | Should -Throw -ErrorId "DriveNotFoundException" $rs.Dispose() } diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Set-Variable.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Set-Variable.Tests.ps1 index 0b6b9c59df..d11fd5cfbb 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/Set-Variable.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Set-Variable.Tests.ps1 @@ -241,4 +241,47 @@ Describe "Set-Variable" -Tags "CI" { { Set-Variable globalVar -Value 1 -Scope local -Force } | Should -Not -Throw } } + + Context "Set-Variable -Append tests" { + BeforeAll { + if (! (Get-ExperimentalFeature PSRedirectToVariable).Enabled) { + $skipTest = $true + } + + $testCases = @{ value = 2; Count = 2 }, + @{ value = @(2,3,4); Count = 2}, + @{ value = "abc",(Get-Process -Id $PID) ; count = 2} + } + + It "Can append values to a variable" -testCases $testCases { + param ($value, $count) + + if ($skipTest) { + Set-ItResult -skip -because "Experimental Feature PSRedirectToVariable not enabled" + return + } + + $variableName = "testVar" + Set-Variable -Name $variableName -Value 1 + Set-Variable -Name $variableName -Value $value -Append + + $observedValues = Get-Variable $variableName -Value + + $observedValues.Count | Should -Be $count + $observedValues[0] | Should -Be 1 + + $observedValues[1] | Should -Be $value + } + + It "Can use set-variable via streaming and append values" { + if ($skipTest) { + Set-ItResult -skip -because "Experimental Feature PSRedirectToVariable not enabled" + return + } + + $testVar = 1 + 4..6 | Set-Variable -Name testVar -Append + $testVar | Should -Be @(1,4,5,6) + } + } }