diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs index 5bfb5c9697..8d533edc3e 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs @@ -404,6 +404,7 @@ namespace System.Management.Automation case TokenKind.Dot: case TokenKind.ColonColon: + case TokenKind.QuestionDot: replacementIndex += tokenAtCursor.Text.Length; replacementLength = 0; result = CompletionCompleters.CompleteMember(completionContext, @static: tokenAtCursor.Kind == TokenKind.ColonColon); diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index b9f3bb4d16..95824a9992 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -127,6 +127,9 @@ namespace System.Management.Automation new ExperimentalFeature( name: "PSPipelineChainOperators", description: "Allow use of && and || as operators between pipeline invocations"), + new ExperimentalFeature( + name: "PSNullConditionalOperators", + description: "Support the null conditional member access operators 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 6bfc769cc6..06b7ce313a 100644 --- a/src/System.Management.Automation/engine/parser/Compiler.cs +++ b/src/System.Management.Automation/engine/parser/Compiler.cs @@ -6269,15 +6269,14 @@ namespace System.Management.Automation.Language } var target = CompileExpressionOperand(memberExpressionAst.Expression); - var memberNameAst = memberExpressionAst.Member as StringConstantExpressionAst; - if (memberNameAst != null) - { - string name = memberNameAst.Value; - return DynamicExpression.Dynamic(PSGetMemberBinder.Get(name, _memberFunctionType, memberExpressionAst.Static), typeof(object), target); - } - var memberNameExpr = Compile(memberExpressionAst.Member); - return DynamicExpression.Dynamic(PSGetDynamicMemberBinder.Get(_memberFunctionType, memberExpressionAst.Static), typeof(object), target, memberNameExpr); + // If the ?. operator is used for null conditional check, add the null conditional expression. + var memberNameAst = memberExpressionAst.Member as StringConstantExpressionAst; + Expression memberAccessExpr = memberNameAst != null + ? DynamicExpression.Dynamic(PSGetMemberBinder.Get(memberNameAst.Value, _memberFunctionType, memberExpressionAst.Static), typeof(object), target) + : DynamicExpression.Dynamic(PSGetDynamicMemberBinder.Get(_memberFunctionType, memberExpressionAst.Static), typeof(object), target, Compile(memberExpressionAst.Member)); + + return memberExpressionAst.NullConditional ? GetNullConditionalWrappedExpression(target, memberAccessExpr) : memberAccessExpr; } internal static PSMethodInvocationConstraints GetInvokeMemberConstraints(InvokeMemberExpressionAst invokeMemberExpressionAst) @@ -6314,14 +6313,18 @@ namespace System.Management.Automation.Language Expression target, IEnumerable args, bool @static, - bool propertySet) + bool propertySet, + bool nullConditional = false) { var callInfo = new CallInfo(args.Count()); var classScope = _memberFunctionType != null ? _memberFunctionType.Type : null; var binder = name.Equals("new", StringComparison.OrdinalIgnoreCase) && @static ? (CallSiteBinder)PSCreateInstanceBinder.Get(callInfo, constraints, publicTypeOnly: true) : PSInvokeMemberBinder.Get(name, callInfo, @static, propertySet, constraints, classScope); - return DynamicExpression.Dynamic(binder, typeof(object), args.Prepend(target)); + + var dynamicExprFromBinder = DynamicExpression.Dynamic(binder, typeof(object), args.Prepend(target)); + + return nullConditional ? GetNullConditionalWrappedExpression(target, dynamicExprFromBinder) : dynamicExprFromBinder; } private Expression InvokeBaseCtorMethod(PSMethodInvocationConstraints constraints, Expression target, IEnumerable args) @@ -6337,10 +6340,13 @@ namespace System.Management.Automation.Language Expression target, IEnumerable args, bool @static, - bool propertySet) + bool propertySet, + bool nullConditional = false) { var binder = PSInvokeDynamicMemberBinder.Get(new CallInfo(args.Count()), _memberFunctionType, @static, propertySet, constraints); - return DynamicExpression.Dynamic(binder, typeof(object), args.Prepend(memberNameExpr).Prepend(target)); + var dynamicExprFromBinder = DynamicExpression.Dynamic(binder, typeof(object), args.Prepend(memberNameExpr).Prepend(target)); + + return nullConditional ? GetNullConditionalWrappedExpression(target, dynamicExprFromBinder) : dynamicExprFromBinder; } public object VisitInvokeMemberExpression(InvokeMemberExpressionAst invokeMemberExpressionAst) @@ -6353,11 +6359,18 @@ namespace System.Management.Automation.Language var memberNameAst = invokeMemberExpressionAst.Member as StringConstantExpressionAst; if (memberNameAst != null) { - return InvokeMember(memberNameAst.Value, constraints, target, args, invokeMemberExpressionAst.Static, false); + return InvokeMember( + memberNameAst.Value, + constraints, + target, + args, + invokeMemberExpressionAst.Static, + propertySet: false, + invokeMemberExpressionAst.NullConditional); } var memberNameExpr = Compile(invokeMemberExpressionAst.Member); - return InvokeDynamicMember(memberNameExpr, constraints, target, args, invokeMemberExpressionAst.Static, false); + return InvokeDynamicMember(memberNameExpr, constraints, target, args, invokeMemberExpressionAst.Static, propertySet: false, invokeMemberExpressionAst.NullConditional); } public object VisitArrayExpression(ArrayExpressionAst arrayExpressionAst) @@ -6517,15 +6530,26 @@ namespace System.Management.Automation.Language // In the former case, the user is requesting an array slice. In the latter case, they index expression is likely // an array (dynamically determined) and they don't want an array slice, they want to use the array as the index // expression. - if (arrayLiteral != null && arrayLiteral.Elements.Count > 1) - { - return DynamicExpression.Dynamic( - PSGetIndexBinder.Get(arrayLiteral.Elements.Count, constraints), - typeof(object), - arrayLiteral.Elements.Select(CompileExpressionOperand).Prepend(targetExpr)); - } + Expression indexingExpr = arrayLiteral != null && arrayLiteral.Elements.Count > 1 + ? DynamicExpression.Dynamic( + PSGetIndexBinder.Get(arrayLiteral.Elements.Count, constraints), + typeof(object), + arrayLiteral.Elements.Select(CompileExpressionOperand).Prepend(targetExpr)) + : DynamicExpression.Dynamic( + PSGetIndexBinder.Get(argCount: 1, constraints), + typeof(object), + targetExpr, + CompileExpressionOperand(index)); - return DynamicExpression.Dynamic(PSGetIndexBinder.Get(1, constraints), typeof(object), targetExpr, CompileExpressionOperand(index)); + return indexExpressionAst.NullConditional ? GetNullConditionalWrappedExpression(targetExpr, indexingExpr) : indexingExpr; + } + + private static Expression GetNullConditionalWrappedExpression(Expression targetExpr, Expression memberAccessExpression) + { + return Expression.Condition( + Expression.Call(CachedReflectionInfo.LanguagePrimitives_IsNullLike, targetExpr.Cast(typeof(object))), + ExpressionCache.NullConstant, + memberAccessExpression); } public object VisitAttributedExpression(AttributedExpressionAst attributedExpressionAst) diff --git a/src/System.Management.Automation/engine/parser/Parser.cs b/src/System.Management.Automation/engine/parser/Parser.cs index 0acf827c43..c4ddf84c3f 100644 --- a/src/System.Management.Automation/engine/parser/Parser.cs +++ b/src/System.Management.Automation/engine/parser/Parser.cs @@ -7347,11 +7347,11 @@ namespace System.Management.Automation.Language // To support fluent style programming, allow newlines after the member access operator. SkipNewlines(); - if (token.Kind == TokenKind.Dot || token.Kind == TokenKind.ColonColon) + if (token.Kind == TokenKind.Dot || token.Kind == TokenKind.ColonColon || token.Kind == TokenKind.QuestionDot) { expr = MemberAccessRule(expr, token); } - else if (token.Kind == TokenKind.LBracket) + else if (token.Kind == TokenKind.LBracket || token.Kind == TokenKind.QuestionLBracket) { expr = ElementAccessRule(expr, token); } @@ -7772,8 +7772,12 @@ namespace System.Management.Automation.Language } } - return new MemberExpressionAst(ExtentOf(targetExpr, member), - targetExpr, member, operatorToken.Kind == TokenKind.ColonColon); + return new MemberExpressionAst( + ExtentOf(targetExpr, member), + targetExpr, + member, + @static: operatorToken.Kind == TokenKind.ColonColon, + nullConditional: operatorToken.Kind == TokenKind.QuestionDot); } private ExpressionAst MemberInvokeRule(ExpressionAst targetExpr, Token lBracket, Token operatorToken, CommandElementAst member) @@ -7801,7 +7805,13 @@ namespace System.Management.Automation.Language lastExtent = argument.Extent; } - return new InvokeMemberExpressionAst(ExtentOf(targetExpr, lastExtent), targetExpr, member, arguments, operatorToken.Kind == TokenKind.ColonColon); + return new InvokeMemberExpressionAst( + ExtentOf(targetExpr, lastExtent), + targetExpr, + member, + arguments, + operatorToken.Kind == TokenKind.ColonColon, + operatorToken.Kind == TokenKind.QuestionDot); } private List InvokeParamParenListRule(Token lParen, out IScriptExtent lastExtent) @@ -7923,7 +7933,7 @@ namespace System.Management.Automation.Language rBracket = null; } - return new IndexExpressionAst(ExtentOf(primaryExpression, ExtentFromFirstOf(rBracket, indexExpr)), primaryExpression, indexExpr); + return new IndexExpressionAst(ExtentOf(primaryExpression, ExtentFromFirstOf(rBracket, indexExpr)), primaryExpression, indexExpr, lBracket.Kind == TokenKind.QuestionLBracket); } #endregion Expressions diff --git a/src/System.Management.Automation/engine/parser/SemanticChecks.cs b/src/System.Management.Automation/engine/parser/SemanticChecks.cs index 66a6e4f805..5fcd1ab4a0 100644 --- a/src/System.Management.Automation/engine/parser/SemanticChecks.cs +++ b/src/System.Management.Automation/engine/parser/SemanticChecks.cs @@ -762,6 +762,14 @@ namespace System.Management.Automation.Language { errorAst = ast; } + else if (ast is MemberExpressionAst memberExprAst && memberExprAst.NullConditional) + { + errorAst = ast; + } + else if (ast is IndexExpressionAst indexExprAst && indexExprAst.NullConditional) + { + errorAst = ast; + } else if (ast is AttributedExpressionAst) { // Check for multiple types combined with [ref]. diff --git a/src/System.Management.Automation/engine/parser/ast.cs b/src/System.Management.Automation/engine/parser/ast.cs index a334c57d9d..460ef64f8b 100644 --- a/src/System.Management.Automation/engine/parser/ast.cs +++ b/src/System.Management.Automation/engine/parser/ast.cs @@ -7851,6 +7851,26 @@ namespace System.Management.Automation.Language this.Static = @static; } + /// + /// Initializes a new instance of the class. + /// + /// + /// The extent of the expression, starting with the expression before the operator '.', '::' or '?.' and ending after + /// membername or expression naming the member. + /// + /// The expression before the member access operator '.', '::' or '?.'. + /// The name or expression naming the member to access. + /// True if the '::' operator was used, false if '.' or '?.' is used. + /// True if '?.' used. + /// + /// If , , or is null. + /// + public MemberExpressionAst(IScriptExtent extent, ExpressionAst expression, CommandElementAst member, bool @static, bool nullConditional) + : this(extent, expression, member, @static) + { + this.NullConditional = nullConditional; + } + /// /// The expression that produces the value to retrieve the member from. This property is never null. /// @@ -7866,6 +7886,11 @@ namespace System.Management.Automation.Language /// public bool Static { get; private set; } + /// + /// Gets a value indicating true if the operator used is ?. or ?[]. + /// + public bool NullConditional { get; protected set; } + /// /// Copy the MemberExpressionAst instance. /// @@ -7873,7 +7898,7 @@ namespace System.Management.Automation.Language { var newExpression = CopyElement(this.Expression); var newMember = CopyElement(this.Member); - return new MemberExpressionAst(this.Extent, newExpression, newMember, this.Static); + return new MemberExpressionAst(this.Extent, newExpression, newMember, this.Static, this.NullConditional); } #region Visitors @@ -7915,7 +7940,7 @@ namespace System.Management.Automation.Language /// The extent of the expression, starting with the expression before the invocation operator and ending with the /// closing paren after the arguments. /// - /// The expression before the invocation operator ('.' or '::'). + /// The expression before the invocation operator ('.', '::'). /// The method to invoke. /// The arguments to pass to the method. /// @@ -7934,6 +7959,29 @@ namespace System.Management.Automation.Language } } + /// + /// Initializes a new instance of the class. + /// + /// + /// The extent of the expression, starting with the expression before the invocation operator and ending with the + /// closing paren after the arguments. + /// + /// The expression before the invocation operator ('.', '::' or '?.'). + /// The method to invoke. + /// The arguments to pass to the method. + /// + /// True if the invocation is for a static method, using '::', false if invoking a method on an instance using '.' or '?.'. + /// + /// True if the operator used is '?.'. + /// + /// If is null. + /// + public InvokeMemberExpressionAst(IScriptExtent extent, ExpressionAst expression, CommandElementAst method, IEnumerable arguments, bool @static, bool nullConditional) + : this(extent, expression, method, arguments, @static) + { + this.NullConditional = nullConditional; + } + /// /// The non-empty collection of arguments to pass when invoking the method, or null if no arguments were specified. /// @@ -7947,7 +7995,7 @@ namespace System.Management.Automation.Language var newExpression = CopyElement(this.Expression); var newMethod = CopyElement(this.Member); var newArguments = CopyElements(this.Arguments); - return new InvokeMemberExpressionAst(this.Extent, newExpression, newMethod, newArguments, this.Static); + return new InvokeMemberExpressionAst(this.Extent, newExpression, newMethod, newArguments, this.Static, this.NullConditional); } #region Visitors @@ -10220,6 +10268,22 @@ namespace System.Management.Automation.Language SetParent(index); } + /// + /// Initializes a new instance of the class. + /// + /// The extent of the expression. + /// The expression being indexed. + /// The index expression. + /// Access the index only if the target is not null. + /// + /// If , , or is null. + /// + public IndexExpressionAst(IScriptExtent extent, ExpressionAst target, ExpressionAst index, bool nullConditional) + : this(extent, target, index) + { + this.NullConditional = nullConditional; + } + /// /// Return the ast for the expression being indexed. This value is never null. /// @@ -10230,6 +10294,11 @@ namespace System.Management.Automation.Language /// public ExpressionAst Index { get; private set; } + /// + /// Gets a value indicating whether ?[] operator is being used. + /// + public bool NullConditional { get; private set; } + /// /// Copy the IndexExpressionAst instance. /// @@ -10237,7 +10306,7 @@ namespace System.Management.Automation.Language { var newTarget = CopyElement(this.Target); var newIndex = CopyElement(this.Index); - return new IndexExpressionAst(this.Extent, newTarget, newIndex); + return new IndexExpressionAst(this.Extent, newTarget, newIndex, this.NullConditional); } #region Visitors diff --git a/src/System.Management.Automation/engine/parser/token.cs b/src/System.Management.Automation/engine/parser/token.cs index 62d911baa1..e9575ebe72 100644 --- a/src/System.Management.Automation/engine/parser/token.cs +++ b/src/System.Management.Automation/engine/parser/token.cs @@ -422,6 +422,12 @@ namespace System.Management.Automation.Language /// The null coalesce operator '??'. QuestionQuestion = 102, + /// The null conditional member access operator '?.'. + QuestionDot = 103, + + /// The null conditional index access operator '?[]'. + QuestionLBracket = 104, + #endregion Operators #region Keywords @@ -867,8 +873,8 @@ namespace System.Management.Automation.Language /* QuestionMark */ TokenFlags.TernaryOperator | TokenFlags.DisallowedInRestrictedMode, /* QuestionQuestionEquals */ TokenFlags.AssignmentOperator, /* QuestionQuestion */ TokenFlags.BinaryOperator | TokenFlags.BinaryPrecedenceCoalesce, - /* Reserved slot 5 */ TokenFlags.None, - /* Reserved slot 6 */ TokenFlags.None, + /* QuestionDot */ TokenFlags.SpecialOperator | TokenFlags.DisallowedInRestrictedMode, + /* QuestionLBracket */ TokenFlags.None, /* Reserved slot 7 */ TokenFlags.None, /* Reserved slot 8 */ TokenFlags.None, /* Reserved slot 9 */ TokenFlags.None, @@ -1065,8 +1071,8 @@ namespace System.Management.Automation.Language /* QuestionMark */ "?", /* QuestionQuestionEquals */ "??=", /* QuestionQuestion */ "??", - /* Reserved slot 5 */ string.Empty, - /* Reserved slot 6 */ string.Empty, + /* QuestionDot */ "?.", + /* QuestionLBracket */ "?[", /* Reserved slot 7 */ string.Empty, /* Reserved slot 8 */ string.Empty, /* Reserved slot 9 */ string.Empty, diff --git a/src/System.Management.Automation/engine/parser/tokenizer.cs b/src/System.Management.Automation/engine/parser/tokenizer.cs index cd613187a5..44a931dc9f 100644 --- a/src/System.Management.Automation/engine/parser/tokenizer.cs +++ b/src/System.Management.Automation/engine/parser/tokenizer.cs @@ -4217,6 +4217,27 @@ namespace System.Management.Automation.Language return NewToken(TokenKind.LBracket); } + + if (ExperimentalFeature.IsEnabled("PSNullConditionalOperators") && c == '?') + { + _tokenStart = _currentIndex; + SkipChar(); + c = PeekChar(); + if (c == '.') + { + SkipChar(); + return NewToken(TokenKind.QuestionDot); + } + else if (c == '[' && allowLBracket) + { + SkipChar(); + return NewToken(TokenKind.QuestionLBracket); + } + + UngetChar(); + return null; + } + return null; } diff --git a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 index acaadb627c..e1ed4e22c5 100644 --- a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 +++ b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 @@ -32,6 +32,16 @@ Describe "TabCompletion" -Tags CI { $res.CompletionMatches[0].CompletionText | Should -BeExactly 'ToString(' } + It 'Should complete dotnet method with null conditional operator' { + $res = TabExpansion2 -inputScript '(1)?.ToSt' -cursorColumn '(1)?.ToSt'.Length + $res.CompletionMatches[0].CompletionText | Should -BeExactly 'ToString(' + } + + It 'Should complete dotnet method with null conditional operator without first letter' { + $res = TabExpansion2 -inputScript '(1)?.' -cursorColumn '(1)?.'.Length + $res.CompletionMatches[0].CompletionText | Should -BeExactly 'CompareTo(' + } + It 'Should complete Magic foreach' { $res = TabExpansion2 -inputScript '(1..10).Fo' -cursorColumn '(1..10).Fo'.Length $res.CompletionMatches[0].CompletionText | Should -BeExactly 'ForEach(' diff --git a/test/powershell/Language/Operators/NullConditional.Tests.ps1 b/test/powershell/Language/Operators/NullConditional.Tests.ps1 index 8f9b86816e..6d4464c86a 100644 --- a/test/powershell/Language/Operators/NullConditional.Tests.ps1 +++ b/test/powershell/Language/Operators/NullConditional.Tests.ps1 @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -Describe 'NullConditionalOperations' -Tags 'CI' { +Describe 'NullCoalesceOperations' -Tags 'CI' { BeforeAll { $skipTest = -not $EnabledExperimentalFeatures.Contains('PSCoalescingOperators') @@ -264,3 +264,133 @@ Describe 'NullConditionalOperations' -Tags 'CI' { } } } + +Describe 'NullConditionalMemberAccess' -Tag 'CI' { + + BeforeAll { + $skipTest = -not $EnabledExperimentalFeatures.Contains('PSNullConditionalOperators') + + if ($skipTest) { + Write-Verbose "Test Suite Skipped. The test suite requires the experimental feature 'PSNullConditionalOperators' to be enabled." -Verbose + $originalDefaultParameterValues = $PSDefaultParameterValues.Clone() + $PSDefaultParameterValues["it:skip"] = $true + } + } + + AfterAll { + if ($skipTest) { + $global:PSDefaultParameterValues = $originalDefaultParameterValues + } + } + + Context '?. operator tests' { + BeforeAll { + $psObj = [psobject]::new() + $psObj | Add-Member -Name 'name' -Value 'value' -MemberType NoteProperty + $psObj | Add-Member -Name 'nested' -Value @{name = 'valuenested'} -MemberType NoteProperty + + $psobj2 = [psobject]::new() + $psobj2 | Add-Member -Name 'GetHello' -Value { "hello" } -MemberType ScriptMethod + $psObj | Add-Member -Name 'nestedMethod' -Value $psobj2 -MemberType NoteProperty + + $array = 1..3 + $hash = @{ a = 1; b = 2} + + $null = New-Item -ItemType File -Path "$TestDrive/testfile.txt" -Force + } + + It 'Can get member value of a non-null variable' { + ${psObj}?.name | Should -BeExactly 'value' + ${array}?.length | Should -Be 3 + ${hash}?.a | Should -Be 1 + + (Get-Process -Id $pid)?.Name | Should -BeLike "pwsh*" + (Get-Item $TestDrive)?.EnumerateFiles()?.Name | Should -BeExactly 'testfile.txt' + + [int32]::MaxValue?.ToString() | Should -BeExactly '2147483647' + } + + It 'Can get null when variable is null' { + ${nonExistent}?.name | Should -BeNullOrEmpty + ${nonExistent}?.MyMethod() | Should -BeNullOrEmpty + + (get-process -Name doesnotexist -ErrorAction SilentlyContinue)?.Id | Should -BeNullOrEmpty + } + + It 'Use ?. operator multiple times in statement' { + ${psObj}?.name?.nonExistent | Should -BeNullOrEmpty + ${psObj}?.nonExistent?.nonExistent | Should -BeNullOrEmpty + ${nonExistent}?.nonExistent?.nonExistent | Should -BeNullOrEmpty + + ${psObj}?.nested?.name | Should -BeExactly 'valuenested' + ${psObj}?.nestedMethod?.GetHello() | Should -BeExactly 'hello' + } + + It 'Use ?. on a dynamic method name' { + $methodName = 'ToLongDateString' + (Get-Date '11/11/2019')?.$methodName() | Should -BeExactly 'Monday, November 11, 2019' + + ${doesNotExist}?.$methodName() | Should -BeNullOrEmpty + } + + It 'Use ?. on a dynamic method name that does not exist' { + $methodName = 'DoesNotExist' + { (Get-Date '11/11/2019')?.$methodName() } | Should -Throw -ErrorId 'MethodNotFound' + } + + It 'Use ?. on a dynamic method name that does not exist' { + $methodName = $null + { (Get-Date '11/11/2019')?.$methodName() } | Should -Throw -ErrorId 'MethodNotFound' + } + + It 'Use ?. on a dynamic property name' { + $propName = 'Name' + (Get-Process -Id $pid)?.$propName | Should -BeLike 'pwsh*' + + ${doesNotExist}?.$propName() | Should -BeNullOrEmpty + } + + It 'Should throw error when method does not exist' { + { ${psObj}?.nestedMethod?.NonExistent() } | Should -Throw -ErrorId 'MethodNotFound' + } + } + + Context '?[] operator tests' { + BeforeAll { + $array = 1..3 + $hash = @{ a = 1; b = 2} + + $dateArray = @( + (Get-Date '11/1/2019'), + (Get-Date '11/2/2019'), + (Get-Date '11/3/2019')) + } + + It 'Can index can call properties' { + ${array}?[0] | Should -Be 1 + ${array}?[0,1] | Should -Be @(1,2) + ${array}?[0..2] | Should -Be @(1,2,3) + ${array}?[-2] | Should -Be 2 + + ${hash}?['a'] | Should -Be 1 + } + + It 'Indexing in null items should be null' { + ${doesnotExist}?[0] | Should -BeNullOrEmpty + ${doesnotExist}?[0,1] | Should -BeNullOrEmpty + ${doesnotExist}?[0..2] | Should -BeNullOrEmpty + ${doesnotExist}?[-2] | Should -BeNullOrEmpty + + ${doesnotExist}?['a'] | Should -BeNullOrEmpty + } + + It 'Can call methods on indexed items' { + ${dateArray}?[0]?.ToLongDateString() | Should -BeExactly 'Friday, November 1, 2019' + } + + It 'Calling a method on nonexistent item give null' { + ${dateArray}?[1234]?.ToLongDateString() | Should -BeNullOrEmpty + ${doesNotExist}?[0]?.MyGetMethod() | Should -BeNullOrEmpty + } + } +} diff --git a/test/powershell/Language/Parser/Parsing.Tests.ps1 b/test/powershell/Language/Parser/Parsing.Tests.ps1 index bff912090d..7754eded97 100644 --- a/test/powershell/Language/Parser/Parsing.Tests.ps1 +++ b/test/powershell/Language/Parser/Parsing.Tests.ps1 @@ -308,6 +308,37 @@ Describe 'null coalescing statement parsing' -Tag "CI" { ShouldBeParseError '$hello ??? $what' ExpectedValueExpression,MissingColonInTernaryExpression 9,17 } +Describe 'null conditional member access statement parsing' -Tag 'CI' { + BeforeAll { + $skipTest = -not $EnabledExperimentalFeatures.Contains('PSNullConditionalOperators') + + if ($skipTest) { + Write-Verbose "Test Suite Skipped. The test suite requires the experimental feature 'PSNullConditionalOperators' to be enabled." -Verbose + $originalDefaultParameterValues = $PSDefaultParameterValues.Clone() + $PSDefaultParameterValues["it:skip"] = $true + } + } + + AfterAll { + if ($skipTest) { + $global:PSDefaultParameterValues = $originalDefaultParameterValues + } + } + + ShouldBeParseError '[datetime]?::now' ExpectedValueExpression,UnexpectedToken 11,11 + ShouldBeParseError '$x ?.name' ExpectedValueExpression,UnexpectedToken 4,4 + ShouldBeParseError 'Get-Date ?.ToString()' ExpectedExpression 20 + ShouldBeParseError '${x}?.' MissingPropertyName 6 + ShouldBeParseError '${x}?.name = "value"' InvalidLeftHandSide 0 + + ShouldBeParseError '[datetime]?[0]' MissingTypename,ExpectedValueExpression,UnexpectedToken 12,11,11 + ShouldBeParseError '${x} ?[1]' MissingTypename,ExpectedValueExpression,UnexpectedToken 7,6,6 + ShouldBeParseError '${x}?[]' MissingArrayIndexExpression 6 + ShouldBeParseError '${x}?[-]' MissingExpressionAfterOperator 7 + ShouldBeParseError '${x}?[ ]' MissingArrayIndexExpression 6 + ShouldBeParseError '${x}?[0] = 1' InvalidLeftHandSide 0 +} + Describe 'splatting parsing' -Tags "CI" { ShouldBeParseError '@a' SplattingNotPermitted 0 ShouldBeParseError 'foreach (@a in $b) {}' SplattingNotPermitted 9