diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index 5c47fe73f3a..acc4582c0a4 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -114,6 +114,9 @@ static ExperimentalFeature() new ExperimentalFeature( name: "PSCommandNotFoundSuggestion", description: "Recommend potential commands based on fuzzy search on a CommandNotFoundException"), + new ExperimentalFeature( + name: "PSImplicitLineContinuanceForNamedParameters", + description: "Allow commands to span multiple lines automatically when subsequent lines start with named parameters"), }; EngineExperimentalFeatures = new ReadOnlyCollection(engineFeatures); diff --git a/src/System.Management.Automation/engine/parser/Parser.cs b/src/System.Management.Automation/engine/parser/Parser.cs index d949a826e16..ef88848cc14 100644 --- a/src/System.Management.Automation/engine/parser/Parser.cs +++ b/src/System.Management.Automation/engine/parser/Parser.cs @@ -8,7 +8,6 @@ using System.Globalization; using System.IO; using System.Linq; -using System.Linq.Expressions; using System.Management.Automation.Runspaces; using System.Reflection; using System.Runtime.CompilerServices; @@ -5690,7 +5689,7 @@ private PipelineBaseAst PipelineRule() // G expression assignment-operator statement // G // G pipeline-tail: - // G new-lines:opt '|' new-lines:opt command pipeline-tail:opt + // G new-line:opt '|' new-lines:opt command pipeline-tail:opt var pipelineElements = new List(); IScriptExtent startExtent = null; @@ -5726,6 +5725,8 @@ private PipelineBaseAst PipelineRule() SetTokenizerMode(oldTokenizerMode); } + bool foundImplicitPipeContinuance = false; + if (expr != null) { if (pipelineElements.Count > 0) @@ -5783,7 +5784,7 @@ private PipelineBaseAst PipelineRule() } else { - commandAst = (CommandAst)CommandRule(forDynamicKeyword: false); + commandAst = (CommandAst)CommandRule(forDynamicKeyword: false, out foundImplicitPipeContinuance); } if (commandAst != null) @@ -5811,7 +5812,9 @@ private PipelineBaseAst PipelineRule() // Skip newlines before pipe tokens to support (pipe)line continuance when pipe // tokens start the next line of script - if (nextToken.Kind == TokenKind.NewLine && _tokenizer.IsPipeContinuance(nextToken.Extent)) + if (nextToken.Kind == TokenKind.NewLine && + (foundImplicitPipeContinuance || + _tokenizer.CheckImplicitContinuance(nextToken.Extent, ImplicitContinuance.Pipeline) == ImplicitContinuance.Pipeline)) { SkipNewlines(); nextToken = PeekToken(); @@ -6165,6 +6168,12 @@ private ExpressionAst GetCommandArgument(CommandArgumentContext context, Token t } internal Ast CommandRule(bool forDynamicKeyword) + { + bool foundImplicitPipeContinuance; + return CommandRule(forDynamicKeyword, out foundImplicitPipeContinuance); + } + + internal Ast CommandRule(bool forDynamicKeyword, out bool foundImplicitPipeContinuance) { // G command: // G command-name command-elements:opt @@ -6185,12 +6194,15 @@ internal Ast CommandRule(bool forDynamicKeyword) // G command-element // G command-elements command-element // G command-element: - // G command-parameter + // G new-line:opt command-parameter + // G new-line:opt splatted-variable // G command-argument // G redirection // G command-argument: // G command-name-expr + foundImplicitPipeContinuance = false; + Token firstToken; bool dotSource, ampersand; bool sawDashDash = false; @@ -6230,8 +6242,35 @@ internal Ast CommandRule(bool forDynamicKeyword) } bool scanning = true; + bool checkForImplicitContinuance = true; while (scanning) { + if (ExperimentalFeature.IsEnabled("PSImplicitLineContinuanceForNamedParameters")) + { + // Newlines before named parameters and splatted collections are skipped + // (implicit line continuance) + if (token.Kind == TokenKind.NewLine && checkForImplicitContinuance) + { + switch (_tokenizer.CheckImplicitContinuance(token.Extent, ImplicitContinuance.All)) + { + case ImplicitContinuance.VerbatimArgument: + checkForImplicitContinuance = false; + goto case ImplicitContinuance.NamedParameter; + + case ImplicitContinuance.NamedParameter: + case ImplicitContinuance.SplattedCollection: + UngetToken(token); + SkipNewlines(); + token = NextToken(); + break; + + case ImplicitContinuance.Pipeline: + foundImplicitPipeContinuance = true; + break; + } + } + } + switch (token.Kind) { case TokenKind.Pipe: @@ -6337,7 +6376,12 @@ internal Ast CommandRule(bool forDynamicKeyword) Diagnostics.Assert(elements.Count >= 1, "We should at least have the command name: inlinescript"); endExtent = elements.Last().Extent; - if (!scanning) { continue; } + // If the inline script failed to parse, scanning is set to false, allowing us to use + // continue to break out of the loop statement. + if (!scanning) + { + continue; + } } else { diff --git a/src/System.Management.Automation/engine/parser/tokenizer.cs b/src/System.Management.Automation/engine/parser/tokenizer.cs index 9fcc1bddecb..922cb11165b 100644 --- a/src/System.Management.Automation/engine/parser/tokenizer.cs +++ b/src/System.Management.Automation/engine/parser/tokenizer.cs @@ -569,6 +569,44 @@ internal enum NumberFormat Binary = 0x2 } + /// + /// Indicates the type of implicit line continuance that is being checked (bitwise value) + /// or the type of implicit line continuance that was found (single value). + /// + [Flags] + internal enum ImplicitContinuance : byte + { + /// + /// Indicates no implicit line continuance was found. + /// + None = 0, + + /// + /// Indicates implicit pipeline continuance is being checked or was found. + /// + Pipeline = 1, + + /// + /// Indicates implicit named parameter continuance is being checked or was found. + /// + NamedParameter = 2, + + /// + /// Indicates implicit verbatim argument continuance is being checked or was found. + /// + VerbatimArgument = 4, + + /// + /// Indicates implicit splatted collection continuance is being checked or was found. + /// + SplattedCollection = 8, + + /// + /// Indicates all implicit line continuance possibilities should be checked. + /// + All = byte.MaxValue, + } + // // Class used to do a partial snapshot of the state of the tokenizer. // This is used for nested scans on the same string. @@ -1342,15 +1380,138 @@ private bool OnlyWhitespaceOrCommentsAfterExtent(InternalScriptExtent extent) return true; } - internal bool IsPipeContinuance(IScriptExtent extent) + internal ImplicitContinuance CheckImplicitContinuance(IScriptExtent extent, ImplicitContinuance implicitContinuanceChecks) { - return extent.EndOffset < _script.Length && PipeContinuanceAfterExtent(extent); + if (extent.EndOffset >= _script.Length) + { + return ImplicitContinuance.None; + } + + bool lastNonWhitespaceIsNewline = true; + int i = extent.EndOffset; + + // Since some token pattern matching looks for multiple characters (e.g. newline or block comment) + // we stop searching at _script.Length - 1 and perform one additional check after the while loop. + // This avoids having to compare i + 1 against the script length in multiple locations inside the + // loop. + while (i < _script.Length - 1) + { + char c = _script[i]; + + if (c.IsWhitespace()) + { + i++; + continue; + } + + if (c == '\n') + { + if (lastNonWhitespaceIsNewline) + { + // blank or whitespace-only lines are not allowed in implicit line continuance + return ImplicitContinuance.None; + } + + lastNonWhitespaceIsNewline = true; + i++; + continue; + } + else if (c == '\r') + { + if (lastNonWhitespaceIsNewline) + { + // blank or whitespace-only lines are not allowed in implicit line continuance + return ImplicitContinuance.None; + } + + lastNonWhitespaceIsNewline = true; + i += _script[i + 1] == '\n' ? 2 : 1; + continue; + } + + lastNonWhitespaceIsNewline = false; + + if (c == '#') + { + // SkipLineComment will return the position after the comment end + // which is either at the end of the file, or a cr or lf. + i = SkipLineComment(i + 1); + continue; + } + + if (c == '<' && _script[i + 1] == '#') + { + i = SkipBlockComment(i + 2); + continue; + } + + switch (c) + { + case '|': + if ((implicitContinuanceChecks & ImplicitContinuance.Pipeline) != 0) + { + return ImplicitContinuance.Pipeline; + } + break; + + case '-': + if ((implicitContinuanceChecks & ImplicitContinuance.NamedParameter) != 0 && + CheckForNamedParameter(i)) + { + return ImplicitContinuance.NamedParameter; + } + if ((implicitContinuanceChecks & ImplicitContinuance.VerbatimArgument) != 0 && + CheckForVerbatimArgument(i + 1)) + { + return ImplicitContinuance.VerbatimArgument; + } + break; + + case '@': + if ((implicitContinuanceChecks & ImplicitContinuance.SplattedCollection) != 0 && + CheckForSplattedCollection(i)) + { + return ImplicitContinuance.SplattedCollection; + } + break; + } + + return ImplicitContinuance.None; + } + + switch (_script[_script.Length - 1]) + { + case '|': + if ((implicitContinuanceChecks & ImplicitContinuance.Pipeline) != 0) + { + return ImplicitContinuance.Pipeline; + } + break; + + case '-': + if ((implicitContinuanceChecks & ImplicitContinuance.NamedParameter) != 0) + { + return ImplicitContinuance.NamedParameter; + } + break; + + case '@': + if ((implicitContinuanceChecks & ImplicitContinuance.SplattedCollection) != 0) + { + return ImplicitContinuance.SplattedCollection; + } + break; + } + + return ImplicitContinuance.None; } - private bool PipeContinuanceAfterExtent(IScriptExtent extent) + private bool ImplicitContinuanceAfterExtent(IScriptExtent extent, char characterToLookFor, Func tokenValidator = null) { - // If the first non-comment (regular or block) character following a newline is a pipe, we have - // pipe continuance. + // If the first non-comment (regular or block) character following a newline matches the + // character we are looking for, we have implicit continuance if the token validator is + // null (for single character tokens like a pipe) or if the token validator returns true + // (for multiple character tokens like named parameters or a splatted collection). bool lastNonWhitespaceIsNewline = true; int i = extent.EndOffset; @@ -1409,10 +1570,312 @@ private bool PipeContinuanceAfterExtent(IScriptExtent extent) continue; } - return c == '|'; + return c == characterToLookFor && + (tokenValidator == null || tokenValidator(i)); } - return _script[_script.Length - 1] == '|'; + return _script[_script.Length - 1] == characterToLookFor; + } + + private bool CheckForNamedParameter(int i) + { + for (int j = i + 1; j < _script.Length; j++) + { + char c = _script[j]; + + if (c.IsWhitespace()) + { + return j > i + 1; + } + + switch (c) + { + case 'a': + case 'b': + case 'c': + case 'd': + case 'e': + case 'f': + case 'g': + case 'h': + case 'i': + case 'j': + case 'k': + case 'l': + case 'm': + case 'n': + case 'o': + case 'p': + case 'q': + case 'r': + case 's': + case 't': + case 'u': + case 'v': + case 'w': + case 'x': + case 'y': + case 'z': + case 'A': + case 'B': + case 'C': + case 'D': + case 'E': + case 'F': + case 'G': + case 'H': + case 'I': + case 'J': + case 'K': + case 'L': + case 'M': + case 'N': + case 'O': + case 'P': + case 'Q': + case 'R': + case 'S': + case 'T': + case 'U': + case 'V': + case 'W': + case 'X': + case 'Y': + case 'Z': + continue; + + case '-': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + if (j == i + 1) + { + // Named parameters may not begin with numbers or dashes + return false; + } + continue; + + case ':': + case '{': + case '}': + case '(': + case ')': + case '[': + case ']': + case '.': + case '&': + case ',': + case ';': + case '|': + case '\r': + case '\n': + return j > i + 1; + + case '\0': + // If we get to the end of the statement, return true because + // we may be tokenizing an incomplete named parameter as it is + // typed in. + return true; + + case '\'': + case SpecialChars.QuoteSingleLeft: + case SpecialChars.QuoteSingleRight: + case SpecialChars.QuoteSingleBase: + case SpecialChars.QuoteReversed: + case '"': + case SpecialChars.QuoteDoubleLeft: + case SpecialChars.QuoteDoubleRight: + case SpecialChars.QuoteLowDoubleLeft: + // When quotes are used in a named parameter, PowerShell treats + // the token as an argument. + return false; + + default: + continue; + } + } + + // If we get to this point, the named parameters parsed correctly + return true; + } + + private bool CheckForVerbatimArgument(int i) + { + const string verbatimArgument = "-%"; + char c; + + for (int j = 0; i < _script.Length && j < verbatimArgument.Length; i++, j++) + { + c = _script[i]; + + if (c.IsWhitespace() || c == '\r' || c == '\n' || c == '\0') + { + return true; + } + + if (_script[i] != verbatimArgument[j]) + { + return false; + } + } + + if (i == _script.Length) + { + return true; + } + + c = _script[i]; + + return c.IsWhitespace() || c == '\r' || c == '\n' || c == '\0'; + } + + private bool CheckForSplattedCollection(int i) + { + int maxVarNameLength = int.MaxValue; + + for (int j = i + 1; j < _script.Length; j++) + { + char c = _script[j]; + + if (c.IsWhitespace()) + { + return j > i + 1; + } + + if (j - i > maxVarNameLength) + { + return false; + } + + switch (c) + { + case 'a': + case 'b': + case 'c': + case 'd': + case 'e': + case 'f': + case 'g': + case 'h': + case 'i': + case 'j': + case 'k': + case 'l': + case 'm': + case 'n': + case 'o': + case 'p': + case 'q': + case 'r': + case 's': + case 't': + case 'u': + case 'v': + case 'w': + case 'x': + case 'y': + case 'z': + case 'A': + case 'B': + case 'C': + case 'D': + case 'E': + case 'F': + case 'G': + case 'H': + case 'I': + case 'J': + case 'K': + case 'L': + case 'M': + case 'N': + case 'O': + case 'P': + case 'Q': + case 'R': + case 'S': + case 'T': + case 'U': + case 'V': + case 'W': + case 'X': + case 'Y': + case 'Z': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case '_': + continue; + + case '?': + if (j == i + 1) + { + maxVarNameLength = 1; + } + continue; + + case '$': + case '^': + if (j > i + 1) + { + return false; + } + maxVarNameLength = 1; + continue; + + case ':': + if (j < _script.Length - 1 && _script[j + 1] == ':') + { + return j > i + 1; + } + continue; + + case '\0': + // If we get to the end of the statement, return true because + // we may be tokenizing an incomplete named parameter as it is + // typed in. + return true; + + case '{': + case '}': + case '(': + case ')': + case '[': + case ']': + case '.': + case '&': + case ',': + case ';': + case '|': + case '\r': + case '\n': + return j > i + 1; + + default: + if (!char.IsLetterOrDigit(c)) + { + return false; + } + continue; + } + } + + // If we get to this point, the splatted collection parsed correctly + return true; } private int SkipLineComment(int i) diff --git a/test/powershell/Language/Parser/LineContinuance.Tests.ps1 b/test/powershell/Language/Parser/LineContinuance.Tests.ps1 index 2bb5395119e..6cb089030d5 100644 --- a/test/powershell/Language/Parser/LineContinuance.Tests.ps1 +++ b/test/powershell/Language/Parser/LineContinuance.Tests.ps1 @@ -9,6 +9,8 @@ Describe 'Line Continuance' -Tags 'CI' { } $whitespace = "`t `f`v$([char]0x00a0)$([char]0x0085)" + + $implicitContinuanceWithNamedParametersEnabled = $EnabledExperimentalFeatures.Contains('PSImplicitLineContinuanceForNamedParameters') } Context 'Lines ending with a backtick that parse and execute without error' { @@ -289,4 +291,274 @@ $whitespace } } + + Context 'Parsing and executing without error while using a named parameter at the beginning of a line to continue the previous line' { + BeforeAll { + if (!$implicitContinuanceWithNamedParametersEnabled) { + Write-Verbose 'Tests skipped. This set of tests requires the experimental feature ''PSImplicitLineContinuanceForNamedParameters'' to be enabled.' -Verbose + $originalDefaultParameterValues = $PSDefaultParameterValues.Clone() + $PSDefaultParameterValues["it:skip"] = $true + return + } + } + + AfterAll { + if (!$implicitContinuanceWithNamedParametersEnabled) { + $global:PSDefaultParameterValues = $originalDefaultParameterValues + return + } + } + + It 'Line continuance using named parameters at the start of subsequent lines' { + $script = @' +Get-Date + -Year 2019 + -Month 5 + -Day 15 + -Hour 11 + -Minute 22 + -Second 15 + -Millisecond 0 +'@ + ExecuteCommand $script | Should -Be ([DateTime]'2019-05-15T11:22:15') + } + + It 'Line continuance using named parameters at the start of subsequent lines after a CR (old-style Mac line ending)' { + $script = "Get-Date`r -Year 2019`r -Month 5`r -Day 15`r -Hour 11`r -Minute 22`r -Second 15`r -Millisecond 0" + ExecuteCommand $script | Should -BeExactly ([DateTime]'2019-05-15T11:22:15') + } + + It 'Line continuance using named parameters and pipes at the start of subsequent lines' { + $script = @' +Get-Process + -Id $PID + | ForEach-Object Name +'@ + ExecuteCommand $script | Should -BeExactly 'pwsh' + } + + It 'Line continuance using a comment line followed by named parameter at the start of a subsequent line' { + $script = @' +Get-Process + # You can place comments before named parameters + -Id $pid +'@ + (ExecuteCommand $script).Id | Should -Be $PID + } + + It 'Longer line continuance using named parameters at the start of subsequent lines (with comments)' { + $script = @' +Get-Command + # The command type + -CommandType Cmdlet + # The command name + -Name Get-Date + # Show verbose output + -Verbose +'@ + (ExecuteCommand $script).Name | Should -BeExactly 'Get-Date' + } + + It 'Hiding a command with line continuance using named parameters at the start of subsequent lines' { + $script = @' +function -Syntax {42} +Get-Command + -Name Get-Date + -Syntax +'@ + @(ExecuteCommand $script)[-1] | Should -BeOfType string + } + + It 'Invoking a command hidden by line continuance using named parameters' { + $script = @' +function -Syntax {42} +Get-Command + -Name Get-Date + & -Syntax +'@ + @(ExecuteCommand $script)[-1] | Should -BeOfType int + } + } + + Context 'Lines starting with a named parameter that fail because they are used incorrectly' { + BeforeAll { + if (!$implicitContinuanceWithNamedParametersEnabled) { + Write-Verbose 'Tests skipped. This set of tests requires the experimental feature ''PSImplicitLineContinuanceForNamedParameters'' to be enabled.' -Verbose + $originalDefaultParameterValues = $PSDefaultParameterValues.Clone() + $PSDefaultParameterValues["it:skip"] = $true + return + } + } + + AfterAll { + if (!$implicitContinuanceWithNamedParametersEnabled) { + $global:PSDefaultParameterValues = $originalDefaultParameterValues + return + } + } + + It 'Lines starting with a named parameter that have the value associated with that parameter on a subsequent line' { + $script = @' +try { + Get-Process + -Id + $PID +} catch { + throw +} +'@ + $err = { ExecuteCommand $script } | Should -Throw -ErrorId 'ParameterBindingException' -PassThru + $err.Exception.InnerException.ErrorRecord.FullyQualifiedErrorId | Should -BeExactly 'MissingArgument,Microsoft.PowerShell.Commands.GetProcessCommand' + } + + It 'Lines starting with a named parameter that have a line with whitespace before it' { + $script = @" +try { + Get-Date + $whitespace + -Year 2019 +} catch { + throw +} +"@ + $err = { ExecuteCommand $script } | Should -Throw -ErrorId 'CommandNotFoundException' -PassThru + } + + It 'Lines starting with a named parameter that have a line with nothing but a backtick before it' { + $script = @' +try { + Get-Date + ` + -Year 2019 +} catch { + throw +} +'@ + $err = { ExecuteCommand $script } | Should -Throw -ErrorId 'CommandNotFoundException' -PassThru + } + + } + + Context 'Parsing and executing without error while using splatting at the beginning of a line to continue the previous line' { + BeforeAll { + if (!$implicitContinuanceWithNamedParametersEnabled) { + Write-Verbose 'Tests skipped. This set of tests requires the experimental feature ''PSImplicitLineContinuanceForNamedParameters'' to be enabled.' -Verbose + $originalDefaultParameterValues = $PSDefaultParameterValues.Clone() + $PSDefaultParameterValues["it:skip"] = $true + return + } + } + + AfterAll { + if (!$implicitContinuanceWithNamedParametersEnabled) { + $global:PSDefaultParameterValues = $originalDefaultParameterValues + return + } + } + + It 'Line continuance using splatting at the start a subsequent line' { + $script = @' +$parameters = @{ + Year = 2019 + Month = 5 + Day = 15 + Hour = 11 + Minute = 22 + Second = 15 + Millisecond = 0 +} +Get-Date + @parameters +'@ + ExecuteCommand $script | Should -Be ([DateTime]'2019-05-15T11:22:15') + } + + It 'Line continuance using splatting at the start of a subsequent line after a CR (old-style Mac line ending)' { + $script = "`$parameters = @{`r Year = 2019`r Month = 5`r Day = 15`r Hour = 11`r Minute = 22`r Second = 15`r Millisecond = 0`r}`rGet-Date`r @parameters" + ExecuteCommand $script | Should -BeExactly ([DateTime]'2019-05-15T11:22:15') + } + + It 'Line continuance using named parameters, splatting and pipes at the start of subsequent lines' { + $script = @' +$parameters = @{ + Month = 5 + Day = 15 + Hour = 11 + Minute = 22 + Second = 15 + Millisecond = 0 +} +Get-Date + -Year 2019 + @parameters + | ForEach-Object Year +'@ + ExecuteCommand $script | Should -Be 2019 + } + + It 'Line continuance using a comment line followed by splatting at the start of a subsequent line' { + $script = @' +$parameters = @{ + Id = $PID +} +Get-Process + # You can place comments before splatting + @parameters +'@ + (ExecuteCommand $script).Id | Should -Be $PID + } + } + + Context 'Lines starting with splatting that do not parse' { + BeforeAll { + if (!$implicitContinuanceWithNamedParametersEnabled) { + Write-Verbose 'Tests skipped. This set of tests requires the experimental feature ''PSImplicitLineContinuanceForNamedParameters'' to be enabled.' -Verbose + $originalDefaultParameterValues = $PSDefaultParameterValues.Clone() + $PSDefaultParameterValues["it:skip"] = $true + return + } + } + + AfterAll { + if (!$implicitContinuanceWithNamedParametersEnabled) { + $global:PSDefaultParameterValues = $originalDefaultParameterValues + return + } + } + + It 'Lines starting with splatting that have a line with whitespace before it' { + $script = @" +try { + $parameters = @{ + Year = 2019 + } + Get-Date + $whitespace + @parameters +} catch { + throw +} +"@ + $err = { ExecuteCommand $script } | Should -Throw -ErrorId 'ParseException' -PassThru + $err.Exception.InnerException.ErrorRecord.FullyQualifiedErrorId | Should -BeExactly 'SplattingNotPermitted' + } + + It 'Lines starting with splatting that have a line with nothing but a backtick before it' { + $script = @' +try { + $parameters = @{ + Year = 2019 + } + Get-Date + ` + @parameters +} catch { + throw +} +'@ + $err = { ExecuteCommand $script } | Should -Throw -ErrorId 'ParseException' -PassThru + $err.Exception.InnerException.ErrorRecord.FullyQualifiedErrorId | Should -BeExactly 'SplattingNotPermitted' + } + + } } diff --git a/test/tools/TestMetadata.json b/test/tools/TestMetadata.json index bc172948978..71159858305 100644 --- a/test/tools/TestMetadata.json +++ b/test/tools/TestMetadata.json @@ -1,6 +1,7 @@ { "ExperimentalFeatures": { "Microsoft.PowerShell.Utility.PSDebugRunspaceWithBreakpoints": ["test/powershell/Modules/Microsoft.PowerShell.Utility/New-PSBreakpoint.Tests.ps1"], + "PSImplicitLineContinuanceForNamedParameters": ["test/powershell/Language/Parser/LineContinuance.Tests.ps1"], "ExpTest.FeatureOne": [ "test/powershell/engine/ExperimentalFeature/ExperimentalFeature.Basic.Tests.ps1" ] } }