diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index e3db988e4b4..a2ca36f3c7d 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -120,7 +120,10 @@ static ExperimentalFeature() description: "New formatting for ErrorRecord"), new ExperimentalFeature( name: "PSUpdatesNotification", - description: "Print notification message when new releases are available") + description: "Print notification message when new releases are available"), + new ExperimentalFeature( + name: "PSCoalescingOperators", + description: "Support the null coalescing operator and null coalescing assignment operator in PowerShell language") }; EngineExperimentalFeatures = new ReadOnlyCollection(engineFeatures); diff --git a/src/System.Management.Automation/engine/parser/Compiler.cs b/src/System.Management.Automation/engine/parser/Compiler.cs index c06e23c5c5d..2998cdb4067 100644 --- a/src/System.Management.Automation/engine/parser/Compiler.cs +++ b/src/System.Management.Automation/engine/parser/Compiler.cs @@ -221,6 +221,8 @@ internal static class CachedReflectionInfo internal static readonly MethodInfo LanguagePrimitives_GetInvalidCastMessages = typeof(LanguagePrimitives).GetMethod(nameof(LanguagePrimitives.GetInvalidCastMessages), staticFlags); + internal static readonly MethodInfo LanguagePrimitives_IsNullLike = + typeof(LanguagePrimitives).GetMethod(nameof(LanguagePrimitives.IsNullLike), staticPublicFlags); internal static readonly MethodInfo LanguagePrimitives_ThrowInvalidCastException = typeof(LanguagePrimitives).GetMethod(nameof(LanguagePrimitives.ThrowInvalidCastException), staticFlags); @@ -786,6 +788,7 @@ internal Expression ReduceAssignment(ISupportsAssignment left, TokenKind tokenKi { IAssignableValue av = left.GetAssignableValue(); ExpressionType et = ExpressionType.Extension; + switch (tokenKind) { case TokenKind.Equals: return av.SetValue(this, right); @@ -794,15 +797,49 @@ internal Expression ReduceAssignment(ISupportsAssignment left, TokenKind tokenKi case TokenKind.MultiplyEquals: et = ExpressionType.Multiply; break; case TokenKind.DivideEquals: et = ExpressionType.Divide; break; case TokenKind.RemainderEquals: et = ExpressionType.Modulo; break; + case TokenKind.QuestionQuestionEquals when ExperimentalFeature.IsEnabled("PSCoalescingOperators"): et = ExpressionType.Coalesce; break; } var exprs = new List(); var temps = new List(); var getExpr = av.GetValue(this, exprs, temps); - exprs.Add(av.SetValue(this, DynamicExpression.Dynamic(PSBinaryOperationBinder.Get(et), typeof(object), getExpr, right))); + + if(et == ExpressionType.Coalesce) + { + exprs.Add(av.SetValue(this, Coalesce(getExpr, right))); + } + else + { + exprs.Add(av.SetValue(this, DynamicExpression.Dynamic(PSBinaryOperationBinder.Get(et), typeof(object), getExpr, right))); + } + return Expression.Block(temps, exprs); } + private static Expression Coalesce(Expression left, Expression right) + { + Type leftType = left.Type; + + if (leftType.IsValueType) + { + return left; + } + else if(leftType == typeof(DBNull) || leftType == typeof(NullString) || leftType == typeof(AutomationNull)) + { + return right; + } + else + { + Expression lhs = left.Cast(typeof(object)); + Expression rhs = right.Cast(typeof(object)); + + return Expression.Condition( + Expression.Call(CachedReflectionInfo.LanguagePrimitives_IsNullLike, lhs), + rhs, + lhs); + } + } + internal Expression GetLocal(int tupleIndex) { Expression result = LocalVariablesParameter; @@ -5231,6 +5268,8 @@ public object VisitBinaryExpression(BinaryExpressionAst binaryExpressionAst) CachedReflectionInfo.ParserOps_SplitOperator, _executionContextParameter, Expression.Constant(binaryExpressionAst.ErrorPosition), lhs.Cast(typeof(object)), rhs.Cast(typeof(object)), ExpressionCache.Constant(false)); + case TokenKind.QuestionQuestion when ExperimentalFeature.IsEnabled("PSCoalescingOperators"): + return Coalesce(lhs, rhs); } throw new InvalidOperationException("Unknown token in binary operator."); diff --git a/src/System.Management.Automation/engine/parser/Parser.cs b/src/System.Management.Automation/engine/parser/Parser.cs index b65bfb415d9..9c07cbca488 100644 --- a/src/System.Management.Automation/engine/parser/Parser.cs +++ b/src/System.Management.Automation/engine/parser/Parser.cs @@ -6555,8 +6555,12 @@ private ExpressionAst BinaryExpressionRule(bool endNumberOnTernaryOpChars = fals // G bitwise-expression '-bxor' new-lines:opt comparison-expression // G // G comparison-expression: + // G nullcoalesce-expression + // G comparison-expression comparison-operator new-lines:opt nullcoalesce-expression + // G + // G nullcoalesce-expression: // G additive-expression - // G comparison-expression comparison-operator new-lines:opt additive-expression + // G nullcoalesce-expression '??' new-lines:opt additive-expression // G // G additive-expression: // G multiplicative-expression diff --git a/src/System.Management.Automation/engine/parser/token.cs b/src/System.Management.Automation/engine/parser/token.cs index 8ac848dea3a..b9dae289eff 100644 --- a/src/System.Management.Automation/engine/parser/token.cs +++ b/src/System.Management.Automation/engine/parser/token.cs @@ -416,6 +416,12 @@ public enum TokenKind /// The ternary operator '?'. QuestionMark = 100, + /// The null conditional assignment operator '??='. + QuestionQuestionEquals = 101, + + /// The null coalesce operator '??'. + QuestionQuestion = 102, + #endregion Operators #region Keywords @@ -592,46 +598,51 @@ public enum TokenFlags /// /// The precedence of the logical operators '-and', '-or', and '-xor'. /// - BinaryPrecedenceLogical = 1, + BinaryPrecedenceLogical = 0x1, /// /// The precedence of the bitwise operators '-band', '-bor', and '-bxor' /// - BinaryPrecedenceBitwise = 2, + BinaryPrecedenceBitwise = 0x2, /// /// The precedence of comparison operators including: '-eq', '-ne', '-ge', '-gt', '-lt', '-le', '-like', '-notlike', /// '-match', '-notmatch', '-replace', '-contains', '-notcontains', '-in', '-notin', '-split', '-join', '-is', '-isnot', '-as', /// and all of the case sensitive variants of these operators, if they exists. /// - BinaryPrecedenceComparison = 3, + BinaryPrecedenceComparison = 0x5, + + /// + /// The precedence of null coalesce operator '??'. + /// + BinaryPrecedenceCoalesce = 0x7, /// /// The precedence of the binary operators '+' and '-'. /// - BinaryPrecedenceAdd = 4, + BinaryPrecedenceAdd = 0x9, /// /// The precedence of the operators '*', '/', and '%'. /// - BinaryPrecedenceMultiply = 5, + BinaryPrecedenceMultiply = 0xa, /// /// The precedence of the '-f' operator. /// - BinaryPrecedenceFormat = 6, + BinaryPrecedenceFormat = 0xc, /// /// The precedence of the '..' operator. /// - BinaryPrecedenceRange = 7, + BinaryPrecedenceRange = 0xd, #endregion Precedence Values /// /// A bitmask to get the precedence of binary operators. /// - BinaryPrecedenceMask = 0x00000007, + BinaryPrecedenceMask = 0x0000000f, /// /// The token is a keyword. @@ -669,7 +680,7 @@ public enum TokenFlags SpecialOperator = 0x00001000, /// - /// The token is one of the assignment operators: '=', '+=', '-=', '*=', '/=', or '%=' + /// The token is one of the assignment operators: '=', '+=', '-=', '*=', '/=', '%=' or '??=' /// AssignmentOperator = 0x00002000, @@ -854,8 +865,8 @@ public static class TokenTraits /* Shr */ TokenFlags.BinaryOperator | TokenFlags.BinaryPrecedenceComparison | TokenFlags.CanConstantFold, /* Colon */ TokenFlags.SpecialOperator | TokenFlags.DisallowedInRestrictedMode, /* QuestionMark */ TokenFlags.TernaryOperator | TokenFlags.DisallowedInRestrictedMode, - /* Reserved slot 3 */ TokenFlags.None, - /* Reserved slot 4 */ TokenFlags.None, + /* QuestionQuestionEquals */ TokenFlags.AssignmentOperator, + /* QuestionQuestion */ TokenFlags.BinaryOperator | TokenFlags.BinaryPrecedenceCoalesce, /* Reserved slot 5 */ TokenFlags.None, /* Reserved slot 6 */ TokenFlags.None, /* Reserved slot 7 */ TokenFlags.None, @@ -1052,8 +1063,8 @@ public static class TokenTraits /* Shr */ "-shr", /* Colon */ ":", /* QuestionMark */ "?", - /* Reserved slot 3 */ string.Empty, - /* Reserved slot 4 */ string.Empty, + /* QuestionQuestionEquals */ "??=", + /* QuestionQuestion */ "??", /* Reserved slot 5 */ string.Empty, /* Reserved slot 6 */ string.Empty, /* Reserved slot 7 */ string.Empty, diff --git a/src/System.Management.Automation/engine/parser/tokenizer.cs b/src/System.Management.Automation/engine/parser/tokenizer.cs index 6f56afdb9c0..cd613187a5d 100644 --- a/src/System.Management.Automation/engine/parser/tokenizer.cs +++ b/src/System.Management.Automation/engine/parser/tokenizer.cs @@ -4994,6 +4994,25 @@ internal Token NextToken() return this.NewToken(TokenKind.Colon); case '?' when InExpressionMode(): + if (ExperimentalFeature.IsEnabled("PSCoalescingOperators")) + { + c1 = PeekChar(); + + if (c1 == '?') + { + SkipChar(); + c1 = PeekChar(); + + if (c1 == '=') + { + SkipChar(); + return this.NewToken(TokenKind.QuestionQuestionEquals); + } + + return this.NewToken(TokenKind.QuestionQuestion); + } + } + return this.NewToken(TokenKind.QuestionMark); case '\0': diff --git a/test/powershell/Language/Operators/NullConditional.Tests.ps1 b/test/powershell/Language/Operators/NullConditional.Tests.ps1 new file mode 100644 index 00000000000..8f9b86816ec --- /dev/null +++ b/test/powershell/Language/Operators/NullConditional.Tests.ps1 @@ -0,0 +1,266 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe 'NullConditionalOperations' -Tags 'CI' { + BeforeAll { + + $skipTest = -not $EnabledExperimentalFeatures.Contains('PSCoalescingOperators') + + if ($skipTest) { + Write-Verbose "Test Suite Skipped. The test suite requires the experimental feature 'PSCoalescingOperators' to be enabled." -Verbose + $originalDefaultParameterValues = $PSDefaultParameterValues.Clone() + $PSDefaultParameterValues["it:skip"] = $true + } else { + $someGuid = New-Guid + $typesTests = @( + @{ name = 'string'; valueToSet = 'hello' } + @{ name = 'dotnetType'; valueToSet = $someGuid } + @{ name = 'byte'; valueToSet = [byte]0x94 } + @{ name = 'intArray'; valueToSet = 1..2 } + @{ name = 'stringArray'; valueToSet = 'a'..'c' } + @{ name = 'emptyArray'; valueToSet = @(1, 2, 3) } + ) + } + } + + AfterAll { + if ($skipTest) { + $global:PSDefaultParameterValues = $originalDefaultParameterValues + } + } + + Context "Null conditional assignment operator ??=" { + It 'Variable doesnot exist' { + + Remove-Variable variableDoesNotExist -ErrorAction SilentlyContinue -Force + + $variableDoesNotExist ??= 1 + $variableDoesNotExist | Should -Be 1 + + $variableDoesNotExist ??= 2 + $variableDoesNotExist | Should -Be 1 + } + + It 'Variable exists and is null' { + $variableDoesNotExist = $null + + $variableDoesNotExist ??= 2 + $variableDoesNotExist | Should -Be 2 + } + + It 'Validate types - can be set' -TestCases $typesTests { + param ($name, $valueToSet) + + $x = $null + $x ??= $valueToSet + $x | Should -Be $valueToSet + } + + It 'Validate hashtable can be set' { + $x = $null + $x ??= @{ 1 = '1' } + $x.Keys | Should -Be @(1) + } + + It 'Validate lhs is returned' { + $x = 100 + $x ??= 200 + $x | Should -Be 100 + } + + It 'Rhs is a cmdlet' { + $x = $null + $x ??= (Get-Alias -Name 'where') + $x.Definition | Should -BeExactly 'Where-Object' + } + + It 'Lhs is DBNull' { + $x = [System.DBNull]::Value + $x ??= 200 + $x | Should -Be 200 + } + + It 'Lhs is AutomationNull' { + $x = [System.Management.Automation.Internal.AutomationNull]::Value + $x ??= 200 + $x | Should -Be 200 + } + + It 'Lhs is NullString' { + $x = [NullString]::Value + $x ??= 200 + $x | Should -Be 200 + } + + It 'Lhs is empty string' { + $x = '' + $x ??= 20 + $x | Should -BeExactly '' + } + + It 'Error case' { + $e = $null + $null = [System.Management.Automation.Language.Parser]::ParseInput('1 ??= 100', [ref] $null, [ref] $e) + $e[0].ErrorId | Should -BeExactly 'InvalidLeftHandSide' + } + + It 'Variable is non-null' { + $num = 10 + $num ??= 20 + + $num | Should -Be 10 + } + + It 'Lhs is $?' { + { $???=$false} + $? | Should -BeTrue + } + } + + Context 'Null coalesce operator ??' { + BeforeEach { + $x = $null + } + + It 'Variable does not exist' { + Remove-Variable variableDoesNotExist -ErrorAction SilentlyContinue -Force + $variableDoesNotExist ?? 100 | Should -Be 100 + } + + It 'Variable exists but is null' { + $x ?? 100 | Should -Be 100 + } + + It 'Lhs is not null' { + $x = 100 + $x ?? 200 | Should -Be 100 + } + + It 'Lhs is a non-null constant' { + 1 ?? 2 | Should -Be 1 + } + + It 'Lhs is `$null' { + $null ?? 'string value' | Should -BeExactly 'string value' + } + + It 'Check precedence of ?? expression resolution' { + $x ?? $null ?? 100 | Should -Be 100 + $null ?? $null ?? 100 | Should -Be 100 + $null ?? $null ?? $null | Should -Be $null + $x ?? 200 ?? $null | Should -Be 200 + $x ?? 200 ?? 300 | Should -Be 200 + 100 ?? $x ?? 200 | Should -Be 100 + $null ?? 100 ?? $null ?? 200 | Should -Be 100 + } + + It 'Rhs is a cmdlet' { + $result = $x ?? (Get-Alias -Name 'where') + $result.Definition | Should -BeExactly 'Where-Object' + } + + It 'Lhs is DBNull' { + $x = [System.DBNull]::Value + $x ?? 200 | Should -Be 200 + } + + It 'Lhs is AutomationNull' { + $x = [System.Management.Automation.Internal.AutomationNull]::Value + $x ?? 200 | Should -Be 200 + } + + It 'Lhs is NullString' { + $x = [NullString]::Value + $x ?? 200 | Should -Be 200 + } + + It 'Rhs is a get variable expression' { + $x = [System.DBNull]::Value + $y = 2 + $x ?? $y | Should -Be 2 + } + + It 'Lhs is a constant' { + [System.DBNull]::Value ?? 2 | Should -Be 2 + } + + It 'Both are null constants' { + [System.DBNull]::Value ?? [NullString]::Value | Should -Be ([NullString]::Value) + } + + It 'Lhs is $?' { + {$???$false} | Should -BeTrue + } + } + + Context 'Null Coalesce ?? operator precedence' { + It '?? precedence over -and' { + $true -and $null ?? $true | Should -BeTrue + } + + It '?? precedence over -band' { + 1 -band $null ?? 1 | Should -Be 1 + } + + It '?? precedence over -eq' { + 'x' -eq $null ?? 'x' | Should -BeTrue + $null -eq $null ?? 'x' | Should -BeFalse + } + + It '?? precedence over -as' { + 'abc' -as [datetime] ?? 1 | Should -BeNullOrEmpty + } + + It '?? precedence over -replace' { + 'x' -replace 'x',$null ?? 1 | Should -Be ([string]::empty) + } + + It '+ precedence over ??' { + 2 + $null ?? 3 | Should -Be 2 + } + + It '* precedence over ??' { + 2 * $null ?? 3 | Should -Be 0 + } + + It '-f precedence over ??' { + "{0}" -f $null ?? 'b' | Should -Be ([string]::empty) + } + + It '.. precedence ove ??' { + 1..$null ?? 2 | Should -BeIn 1,0 + } + } + + Context 'Combined usage of null conditional operators' { + + BeforeAll { + function GetNull { + return $null + } + + function GetHello { + return "Hello" + } + } + + BeforeEach { + $x = $null + } + + It '?? and ??= used together' { + $x ??= 100 ?? 200 + $x | Should -Be 100 + } + + It '?? and ??= chaining' { + $x ??= $x ?? (GetNull) ?? (GetHello) + $x | Should -BeExactly 'Hello' + } + + It 'First two are null' { + $z ??= $null ?? 100 + $z | Should -Be 100 + } + } +} diff --git a/test/powershell/Language/Parser/Parsing.Tests.ps1 b/test/powershell/Language/Parser/Parsing.Tests.ps1 index bf07d5914b8..bff912090d2 100644 --- a/test/powershell/Language/Parser/Parsing.Tests.ps1 +++ b/test/powershell/Language/Parser/Parsing.Tests.ps1 @@ -262,6 +262,52 @@ Describe 'assignment statement parsing' -Tags "CI" { ShouldBeParseError '$a,$b += 1,2' InvalidLeftHandSide 0 } +Describe 'null coalescing assignment statement parsing' -Tag 'CI' { + BeforeAll { + $skipTest = -not $EnabledExperimentalFeatures.Contains('PSCoalescingOperators') + if ($skipTest) { + Write-Verbose "Test Suite Skipped. The test suite requires the experimental feature 'PSCoalescingOperators' to be enabled." -Verbose + $originalDefaultParameterValues = $PSDefaultParameterValues.Clone() + $PSDefaultParameterValues["it:skip"] = $true + } + } + + AfterAll { + if ($skipTest) { + $global:PSDefaultParameterValues = $originalDefaultParameterValues + } + } + + ShouldBeParseError '1 ??= 1' InvalidLeftHandSide 0 + ShouldBeParseError '@() ??= 1' InvalidLeftHandSide 0 + ShouldBeParseError '@{} ??= 1' InvalidLeftHandSide 0 + ShouldBeParseError '1..2 ??= 1' InvalidLeftHandSide 0 + ShouldBeParseError '[int] ??= 1' InvalidLeftHandSide 0 + ShouldBeParseError '$cricket ?= $soccer' ExpectedValueExpression,InvalidLeftHandSide 10,0 +} + +Describe 'null coalescing statement parsing' -Tag "CI" { + BeforeAll { + $skipTest = -not $EnabledExperimentalFeatures.Contains('PSCoalescingOperators') + if ($skipTest) { + Write-Verbose "Test Suite Skipped. The test suite requires the experimental feature 'PSCoalescingOperators' to be enabled." -Verbose + $originalDefaultParameterValues = $PSDefaultParameterValues.Clone() + $PSDefaultParameterValues["it:skip"] = $true + } + } + + AfterAll { + if ($skipTest) { + $global:PSDefaultParameterValues = $originalDefaultParameterValues + } + } + + ShouldBeParseError '$x??=' ExpectedValueExpression 5 + ShouldBeParseError '$x ??Get-Thing' ExpectedValueExpression,UnexpectedToken 5,5 + ShouldBeParseError '$??=$false' ExpectedValueExpression,InvalidLeftHandSide 3,0 + ShouldBeParseError '$hello ??? $what' ExpectedValueExpression,MissingColonInTernaryExpression 9,17 +} + Describe 'splatting parsing' -Tags "CI" { ShouldBeParseError '@a' SplattingNotPermitted 0 ShouldBeParseError 'foreach (@a in $b) {}' SplattingNotPermitted 9