private/implicitwindow/Get-UiRepeatedCommand.ps1
|
function Get-UiRepeatedCommand { <# .SYNOPSIS Walks the statements PsUi is about to rerun to build an implicit window, looking for a command that changes something and has already run once. A hit refuses the build, so a Remove-Item sharing a line with the control, or sitting in a loop that already reached it, gets an error rather than a second run. #> [CmdletBinding()] param( [Parameter(Mandatory)] $Chain, [Parameter(Mandatory)] [System.Management.Automation.Language.StatementAst]$Statement, [Parameter(Mandatory)] $Exempt, [Parameter(Mandatory)] $Deferrers, $BlockStorers ) # A fresh block makes FindAll convert a new Func every call, and that costs more than the walk it drives. # One guard each. Get-UiStatementCommandList and Get-UiInvokedBlock write uiCommandAstFilter too, and keying the other three off that one name leaves them null whenever either of those ran first. if (!$script:uiCommandAstFilter) { $script:uiCommandAstFilter = { param($node) $node -is [System.Management.Automation.Language.CommandAst] } } if (!$script:uiWriteAstFilter) { $script:uiWriteAstFilter = { param($node) $node -is [System.Management.Automation.Language.CommandAst] -or $node -is [System.Management.Automation.Language.FileRedirectionAst] } } if (!$script:uiStatementAstFilter) { $script:uiStatementAstFilter = { param($node) $node -is [System.Management.Automation.Language.StatementAst] } } if (!$script:uiLoopCommandFilter) { $script:uiLoopCommandFilter = { param($node) ($node -is [System.Management.Automation.Language.CommandAst] -and (Get-UiPlainCommandName -Command $node) -in 'ForEach-Object', '%', 'foreach') -or ($node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and $node.Member.Value -in 'ForEach', 'Where') } } # A Get or a Test only reads, and so does the rest of those kind of commands. These verbs mess with something, and a second run does it again. $mutating = 'Remove', 'Set', 'New', 'Add', 'Clear', 'Move', 'Copy', 'Rename', 'Update', 'Install', 'Uninstall', 'Start', 'Stop', 'Restart', 'Send', 'Invoke', 'Push', 'Register', 'Unregister', 'Enable', 'Disable', 'Grant', 'Revoke', 'Reset', 'Restore', 'Save', 'Submit', 'Publish', 'Export', 'Import', 'Write' # These here carry one of those verbs and are harmless to run twice. Making an object or reading a file is not the kind of New or Import the list above is looking for. # A loop full of them is the ordinary way to build a row per item. # Add-Member misses the cut because it changes an object the statement did not make, and a second runthrough over the same one is a duplicate member error. $harmless = 'Start-Sleep', 'Import-Module', 'Write-Host', 'Write-Output', 'Write-Verbose', 'Write-Debug', 'Write-Warning', 'Write-Information', 'Write-Progress', 'Write-Error', 'Set-StrictMode', 'New-Object', 'New-TimeSpan', 'New-Guid', 'Import-Csv', 'Import-Clixml', 'Import-PowerShellDataFile', 'Import-LocalizedData' $loops = @( [System.Management.Automation.Language.ForEachStatementAst], [System.Management.Automation.Language.ForStatementAst], [System.Management.Automation.Language.WhileStatementAst], [System.Management.Automation.Language.DoWhileStatementAst], [System.Management.Automation.Language.DoUntilStatementAst] ) $labels = @{ ForEachStatementAst = 'a foreach'; ForStatementAst = 'a for'; WhileStatementAst = 'a while'; DoWhileStatementAst = 'a do loop' DoUntilStatementAst = 'a do loop'; IfStatementAst = 'an if'; SwitchStatementAst = 'a switch'; TryStatementAst = 'a try'; PipelineAst = 'the same pipeline' } # Innermost frame first. Each ran from the top of its body to the call, or through its whole loop when the call sits in one. for ($i = 0; $i -lt $Chain.Count; $i++) { $hop = $Chain[$i] $outermost = $i -eq $Chain.Count - 1 $container = if ($outermost) { $Statement } elseif ($hop.Ast -is [System.Management.Automation.Language.FunctionDefinitionAst]) { $hop.Ast.Body } else { $hop.Ast } if (!$container) { continue } $offset = $hop.Offset $end = $offset $loopStart = $offset $innerLoop = $null # Earlier trips round a loop ran what comes after the call as well. A ForEach-Object block is a loop, and so is a .ForEach() or .Where() in the statement itself. foreach ($candidate in $container.FindAll($script:uiStatementAstFilter, $true)) { $isLoop = $false foreach ($type in $loops) { if ($candidate -is $type) { $isLoop = $true; break } } # $rows = $items.ForEach({ }) has no pipeline around it $holder = $candidate -is [System.Management.Automation.Language.PipelineAst] -or $candidate -is [System.Management.Automation.Language.AssignmentStatementAst] if (!$isLoop -and $holder) { # A .Where() inside a block the statement hands to & or a helper is a filter in there, not a loop round the call $isLoop = @($candidate.FindAll($script:uiLoopCommandFilter, $true) | Where-Object { $up = $_.Parent while ($up -and $up -ne $candidate -and $up -isnot [System.Management.Automation.Language.ScriptBlockExpressionAst]) { $up = $up.Parent } $_ -isnot [System.Management.Automation.Language.InvokeMemberExpressionAst] -or $up -eq $candidate }).Count -gt 0 } if (!$isLoop -or $candidate.Extent.StartOffset -gt $offset -or $offset -ge $candidate.Extent.EndOffset) { continue } if ($candidate.Extent.EndOffset -gt $end) { $end = $candidate.Extent.EndOffset } if ($candidate.Extent.StartOffset -lt $loopStart) { $loopStart = $candidate.Extent.StartOffset } if (!$innerLoop -or $candidate.Extent.Text.Length -lt $innerLoop.Extent.Text.Length) { $innerLoop = $candidate } } foreach ($command in $container.FindAll($script:uiWriteAstFilter, $true)) { if ($command.Extent.EndOffset -gt $end) { continue } # A redirect writes a file and carries no command name, so the verb test below has nothing to read and the file path is all there is to go on. $redirect = $command -as [System.Management.Automation.Language.FileRedirectionAst] if ($redirect) { # 2>$null throws the stream away instead of writing anything, and it is the everyday way to quieten a command, so it is no write. $target = $redirect.Location -as [System.Management.Automation.Language.VariableExpressionAst] if ($target -and $target.VariablePath.UserPath -eq 'null') { continue } $name = "a redirect to $($redirect.Location.Extent.Text)" } else { $name = Get-UiPlainCommandName -Command $command if (!$name) { continue } if ($Exempt.Contains($name) -or $name -in $harmless) { continue } } # A block handed to a PsUi command is PsUi's to run later. The body of a function defined here never ran at all. $skip = $false $node = $command.Parent while ($node -and $node -ne $container) { if ($node -is [System.Management.Automation.Language.FunctionDefinitionAst]) { $skip = $true; break } if ($node -is [System.Management.Automation.Language.ScriptBlockExpressionAst]) { # A block that has not run yet cannot have run a write twice. The deferrers are the PsUi command names, the same set the statement scan asks about. # The exempt set would not do here, since it carries the script's own builders, and a block handed to one of those is judged by the storer set instead. if (Test-UiDeferredBlock -Block $node -Deferrers $Deferrers -BlockStorers $BlockStorers) { $skip = $true; break } } # An if takes one leg of the journey. With the control in another arm of that same if, this one never ran, unless a loop around the pair could have sent an earlier trip down here. # Switch is left out because it runs every clause that matches, and over an array it runs several of them whatever the conditions say. # So the AST cannot show which clause the run skipped. if ($node -is [System.Management.Automation.Language.StatementBlockAst] -and $node.Parent -is [System.Management.Automation.Language.IfStatementAst]) { $branch = $node.Parent $holds = $node.Extent.StartOffset -le $offset -and $offset -lt $node.Extent.EndOffset if (!$holds -and $branch.Extent.StartOffset -lt $loopStart -and $offset -lt $branch.Extent.EndOffset) { $skip = $true; break } } $node = $node.Parent } if ($skip) { continue } # An alias is judged by what it points at, exempt list included. No dash in the name means a native program or a plain word, and those are left alone. if (!$redirect) { $alias = Get-Command -Name $name -CommandType Alias -ErrorAction SilentlyContinue if ($alias) { $name = $alias.Definition } if ($Exempt.Contains($name) -or $name -in $harmless -or $name -notmatch '^([A-Za-z]+)-') { continue } if ($Matches[1] -notin $mutating -and $name -notin 'Out-File', 'Tee-Object') { continue } # Tee-Object -Variable writes no file if ($name -eq 'Tee-Object' -and $command.CommandElements.Where({ $_.ParameterName -like 'va*' })) { continue } } # The error reads better with the loop the control sits in than with the statement around it. $where = if (!$outermost) { $hop.Name } elseif ($innerLoop) { $labels[$innerLoop.GetType().Name] } else { $labels[$Statement.GetType().Name] } if (!$where) { $where = 'the same statement' } return @{ Name = $name; Where = $where } } } return $null } |