diff --git a/src/functions/assertions/Be.ps1 b/src/functions/assertions/Be.ps1 index 272fffc65..06dee457f 100644 --- a/src/functions/assertions/Be.ps1 +++ b/src/functions/assertions/Be.ps1 @@ -393,6 +393,27 @@ function ArraysAreEqual { throw "Reached the recursion depth limit of $RecursionLimit when comparing arrays $First and $Second. Is one of your arrays cyclic?" } + # Fast path for the overwhelmingly common case: each side is a single non-null value that + # PowerShell does not enumerate (numbers, strings, dates, hashtables, scriptblocks, ...), + # either bare or wrapped in a one-element object[] - which is what `1 | Should -Be 1` produces, + # because Should always hands the Be operator the collected pipeline array. Unwrapping a + # one-element object[] matches the full path exactly: @() leaves such an array as-is and the + # element loop below compares its single element. LanguagePrimitives.GetEnumerator is the same + # enumerability test @() uses, so anything it enumerates (arrays, lists, Keys/Values collections, + # enumerators) - including a one-element array wrapping another collection - still takes the full + # path. For two single values the full path always reaches the element comparison: the null/empty + # checks cannot match (one non-null element), the counts are both 1, and IsArray is false for + # both elements, ending in $Second -eq $First (-ceq when case sensitive) - which is what we do + # here without the @() allocations and four helper-function calls. + $singleFirst = if ($First -is [object[]] -and 1 -eq $First.Length) { $First[0] } else { $First } + $singleSecond = if ($Second -is [object[]] -and 1 -eq $Second.Length) { $Second[0] } else { $Second } + if ($null -ne $singleFirst -and $null -ne $singleSecond -and + $null -eq [System.Management.Automation.LanguagePrimitives]::GetEnumerator($singleFirst) -and + $null -eq [System.Management.Automation.LanguagePrimitives]::GetEnumerator($singleSecond)) { + if ($CaseSensitive) { return $singleSecond -ceq $singleFirst } + return $singleSecond -eq $singleFirst + } + # Enumerate the inputs ourselves with @(). PowerShell's [object[]] parameter binding wraps a non-IList # enumerable - such as the collection returned by [hashtable].Keys/.Values - into a single element instead # of enumerating it, which made Should -Be report two equal collections as different (#1200). @() uses the @@ -422,14 +443,11 @@ function ArraysAreEqual { } } else { - if ($CaseSensitive) { - $comparer = { param($Actual, $Expected) $Expected -ceq $Actual } - } - else { - $comparer = { param($Actual, $Expected) $Expected -eq $Actual } - } - - if (-not (& $comparer $First[$i] $Second[$i])) { + # Inline the element comparison. This previously built a fresh comparer scriptblock and + # invoked it (& $comparer) on every iteration even though $CaseSensitive is loop-invariant; + # the comparison itself is identical ($Expected -eq/-ceq $Actual, i.e. $Second[$i] vs $First[$i]). + $itemsEqual = if ($CaseSensitive) { $Second[$i] -ceq $First[$i] } else { $Second[$i] -eq $First[$i] } + if (-not $itemsEqual) { return $false } } diff --git a/src/functions/assertions/Should.ps1 b/src/functions/assertions/Should.ps1 index a4fd47391..88a006ac0 100644 --- a/src/functions/assertions/Should.ps1 +++ b/src/functions/assertions/Should.ps1 @@ -71,6 +71,9 @@ function Should { .LINK https://pester.dev/docs/assertions #> + # $lineNumber, $lineText, $file and $addErrorCallback are consumed by Test-AssertionResult + # through dynamic scoping, which the analyzer cannot see. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true, ValueFromRemainingArguments = $true)] @@ -78,7 +81,9 @@ function Should { ) dynamicparam { - Get-AssertionDynamicParams + # Inlined Get-AssertionDynamicParams (whose whole body is `return $script:AssertionDynamicParams`) + # to save a function-call frame in the dynamicparam block, which PowerShell evaluates on every Should call. + $script:AssertionDynamicParams } begin { @@ -90,9 +95,12 @@ function Should { } end { - $lineNumber = $MyInvocation.ScriptLineNumber - $lineText = $MyInvocation.Line.TrimEnd([System.Environment]::NewLine) - $file = $MyInvocation.ScriptName + # [int]/[string] typed to mirror the parameter types of Invoke-Assertion, whose body is inlined + # below; notably a $null ScriptName becomes '' exactly like binding $null to a [string] parameter, + # which keeps the "$null -eq $currentFile" fallback in Test-AssertionResult unreachable, as before. + [int] $lineNumber = $MyInvocation.ScriptLineNumber + [string] $lineText = $MyInvocation.Line.TrimEnd([System.Environment]::NewLine) + [string] $file = $MyInvocation.ScriptName $negate = $false if ($PSBoundParameters.ContainsKey('Not')) { @@ -103,9 +111,15 @@ function Should { $null = $PSBoundParameters.Remove($PSCmdlet.ParameterSetName) $null = $PSBoundParameters.Remove('Not') - $entry = Get-AssertionOperatorEntry -Name $PSCmdlet.ParameterSetName + # Inlined Get-AssertionOperatorEntry (whose whole body is `return $script:AssertionOperators[$Name]`) + # to save a function-call frame per assertion. Should is a module function, so $script: resolves to + # the same module scope the helper reads. + $entry = $script:AssertionOperators[$PSCmdlet.ParameterSetName] $shouldThrow = $null + # always defined so the dynamic-scope read in Test-AssertionResult (called below without the + # former Invoke-Assertion parameter layer) finds it even when no callback is set up + $addErrorCallback = $null $errorActionIsDefined = $PSBoundParameters.ContainsKey("ErrorAction") if ($errorActionIsDefined) { $shouldThrow = 'Stop' -eq $PSBoundParameters["ErrorAction"] @@ -153,39 +167,38 @@ function Should { } } - $assertionParams = @{ - AssertionEntry = $entry - BoundParameters = $PSBoundParameters - File = $file - LineNumber = $lineNumber - LineText = $lineText - Negate = $negate - CallerSessionState = $PSCmdlet.SessionState - ShouldThrow = $shouldThrow - AddErrorCallback = $addErrorCallback - } - if (-not $entry) { return } + # The Invoke-Assertion body is inlined below: its 12-parameter binding (Mandatory + validation + # attributes) plus the splat hashtable cost ~100us per assertion, roughly half of a passing + # `Should -Be`. Test-AssertionResult keeps reading $file/$lineNumber/$lineText/$shouldThrow/ + # $addErrorCallback from this scope via dynamic scoping, exactly as it read the equally-named + # Invoke-Assertion parameters before. Invoke-Assertion itself stays defined for other callers. + $callerSessionState = $PSCmdlet.SessionState + if ($inputArray.Count -eq 0) { - Invoke-Assertion @assertionParams -ValueToTest $null + $testResult = & $entry.Test -ActualValue $null -Negate:$negate -CallerSessionState $callerSessionState @PSBoundParameters + Test-AssertionResult $testResult } elseif ($entry.SupportsArrayInput) { if ($MyInvocation.ExpectingInput) { # Pipeline input is collected item-by-item in the process block, so pass the collected array. - Invoke-Assertion @assertionParams -ValueToTest $inputArray.ToArray() + $testResult = & $entry.Test -ActualValue ($inputArray.ToArray()) -Negate:$negate -CallerSessionState $callerSessionState @PSBoundParameters + Test-AssertionResult $testResult } else { # The value was supplied by parameter instead (Should -ActualValue @(1,2,3), which is also what # splatting does). The process block ran once and $inputArray wrapped the whole value into a # single element; enumerate the original $ActualValue with @() so it matches what the pipeline # would have produced, rather than being wrapped one level too deep. (#2314) - Invoke-Assertion @assertionParams -ValueToTest @($ActualValue) + $testResult = & $entry.Test -ActualValue (@($ActualValue)) -Negate:$negate -CallerSessionState $callerSessionState @PSBoundParameters + Test-AssertionResult $testResult } } else { foreach ($object in $inputArray) { - Invoke-Assertion @assertionParams -ValueToTest $object + $testResult = & $entry.Test -ActualValue $object -Negate:$negate -CallerSessionState $callerSessionState @PSBoundParameters + Test-AssertionResult $testResult } } }