PS.Utils.psm1
|
#region PS.Utils.AST function Get-PSUtilAST { [CmdletBinding( DefaultParameterSetName = 'File' )] Param( [Parameter( ParameterSetName = 'File', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName )] [Alias('FullName')] [String] $FilePath, [Parameter( ParameterSetName = 'Code', Mandatory, ValueFromPipeline )] [string] $Code, [Parameter(ParameterSetName = 'Code')] [ArgumentCompleter({ Param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $collection = [PSObject].Assembly.GetTypes().Where{$_.Name.EndsWith('Ast')}.Name | Sort-Object | Where-Object { $_ -like "$wordToComplete*" } $collection | Foreach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_) } })] $AstType = '*', [Switch] $NoRecursion ) BEGIN { $predicate = { param($astObject) $astObject.GetType().Name -like $AstType } } PROCESS { $errors = $null $ast = switch ($PSCmdlet.ParameterSetName) { 'File' { [System.Management.Automation.Language.Parser]::ParseFile( $FilePath, [ref]$null, [ref]$errors) } 'Code' { [System.Management.Automation.Language.Parser]::ParseInput($Code, [ref]$null, [ref]$errors) } } if ($errors) { throw [System.InvalidCastException]::new("Submitted text could not be converted to PowerShell because it contains syntax errors: $($errors | Out-String)") } $ast.FindAll($predicate, !$NoRecursion) | Add-Member -MemberType ScriptProperty -Name Type -Value { $this.GetType().Name } -PassThru -Force } } function Get-PSUtilASTClass { [CmdletBinding( DefaultParameterSetName = 'File' )] Param( [Parameter( ParameterSetName = 'File', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName )] [Alias('FullName')] [String] $FilePath, [Parameter( ParameterSetName = 'Code', Mandatory, ValueFromPipeline )] [string] $Code, [Switch] $NoRecursion ) BEGIN { $predicate = { param($astObject) $astObject -is [System.Management.Automation.Language.TypeDefinitionAst] -and $astObject.IsClass } } PROCESS { $errors = $null $ast = switch ($PSCmdlet.ParameterSetName) { 'File' { [System.Management.Automation.Language.Parser]::ParseFile( $FilePath, [ref]$null, [ref]$errors) } 'Code' { [System.Management.Automation.Language.Parser]::ParseInput($Code, [ref]$null, [ref]$errors) } } if ($errors) { throw [System.InvalidCastException]::new("Submitted text could not be converted to PowerShell because it contains syntax errors: $($errors | Out-String)") } $ast.FindAll($predicate, !$NoRecursion) | Add-Member -MemberType ScriptProperty -Name Type -Value { $this.GetType().Name } -PassThru -Force } } function Get-PSUtilASTEnum { [CmdletBinding( DefaultParameterSetName = 'File' )] Param( [Parameter( ParameterSetName = 'File', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName )] [Alias('FullName')] [String] $FilePath, [Parameter( ParameterSetName = 'Code', Mandatory, ValueFromPipeline )] [string] $Code, [Switch] $NoRecursion ) BEGIN { $predicate = { param($astObject) $astObject -is [System.Management.Automation.Language.TypeDefinitionAst] -and $astObject.IsEnum } } PROCESS { $errors = $null $ast = switch ($PSCmdlet.ParameterSetName) { 'File' { [System.Management.Automation.Language.Parser]::ParseFile( $FilePath, [ref]$null, [ref]$errors) } 'Code' { [System.Management.Automation.Language.Parser]::ParseInput($Code, [ref]$null, [ref]$errors) } } if ($errors) { throw [System.InvalidCastException]::new("Submitted text could not be converted to PowerShell because it contains syntax errors: $($errors | Out-String)") } $ast.FindAll($predicate, !$NoRecursion) | Add-Member -MemberType ScriptProperty -Name Type -Value { $this.GetType().Name } -PassThru -Force } } function Get-PSUtilASTFunctionDefinition { [CmdletBinding( DefaultParameterSetName = 'File' )] Param( [Parameter( ParameterSetName = 'File', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName )] [Alias('FullName')] [String] $FilePath, [Parameter( ParameterSetName = 'Code', Mandatory, ValueFromPipeline )] [string] $Code, [Switch] $NoRecursion ) BEGIN { $predicate = { param($astObject) $astObject.GetType().Name -eq 'FunctionDefinitionAst' } } PROCESS { $errors = $null $ast = switch ($PSCmdlet.ParameterSetName) { 'File' { [System.Management.Automation.Language.Parser]::ParseFile( $FilePath, [ref]$null, [ref]$errors) } 'Code' { [System.Management.Automation.Language.Parser]::ParseInput($Code, [ref]$null, [ref]$errors) } } if ($errors) { throw [System.InvalidCastException]::new("Submitted text could not be converted to PowerShell because it contains syntax errors: $($errors | Out-String)") } $ast.FindAll($predicate, !$NoRecursion) | Add-Member -MemberType ScriptProperty -Name Type -Value { $this.GetType().Name } -PassThru -Force } } function Get-PSUtilASTTypeNameList { [CmdletBinding()] Param() PROCESS { [PSObject].Assembly.GetTypes().Where{$_.Name.EndsWith('Ast')}.Name | Sort-Object } } #endregion PS.Utils.AST #region PS.Utils.Build function Get-PSUtilFunctionInformation { <# .SYNOPSIS Finds function definitions in a PowerShell file and lists unique commands used in each. .DESCRIPTION Parses a PowerShell script or module file, locates every FunctionDefinitionAst, and for each function collects the unique command/cmdlet names referenced inside it. .PARAMETER Path Path to the PowerShell file to analyze. .PARAMETER Exclude One or more wildcard patterns matched against the file name. Matching files are skipped. Defaults to '*.Tests.ps1' so Pester test files are ignored. .EXAMPLE Get-FunctionData -Path '.\src\PS.Utils\PS.Utils.psm1' Returns one object per function with Name and Commands properties. .EXAMPLE Get-ChildItem -Path . -Recurse -Filter *.ps*1 | Get-FunctionData Processes script/module files while skipping *.Tests.ps1 by default. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('FullName', 'PSPath', 'LiteralPath')] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter()] [Alias('ExcludePattern')] [string[]] $Exclude = @('*.Tests.ps1') ) begin { $commandAstType = [System.Management.Automation.Language.CommandAst] $functionAstType = [System.Management.Automation.Language.FunctionDefinitionAst] } process { $resolvedPath = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) $fileName = [System.IO.Path]::GetFileName($resolvedPath) foreach ($pattern in $Exclude) { if ([string]::IsNullOrWhiteSpace($pattern)) { continue } if ($fileName -like $pattern) { Write-Verbose "Skipping excluded file '$resolvedPath' (matched '$pattern')." return } } if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) { $PSCmdlet.WriteError( [System.Management.Automation.ErrorRecord]::new( [System.IO.FileNotFoundException]::new("File not found: $resolvedPath"), 'FileNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $resolvedPath ) ) return } $tokens = $null $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile( $resolvedPath, [ref]$tokens, [ref]$errors ) if ($errors -and $errors.Count -gt 0) { foreach ($parseError in $errors) { $PSCmdlet.WriteWarning("Parse error in '${resolvedPath}': $($parseError.Message)") } } $functionAsts = $ast.FindAll( { param($node) $node -is $functionAstType }, $true ) foreach ($functionAst in $functionAsts) { $commandAsts = $functionAst.FindAll( { param($node) $node -is $commandAstType }, $true ) $commands = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) foreach ($commandAst in $commandAsts) { $commandName = $commandAst.GetCommandName() if (-not [string]::IsNullOrWhiteSpace($commandName)) { [void]$commands.Add($commandName) } } [pscustomobject]@{ PSTypeName = 'PS.Utils.FunctionData' Name = $functionAst.Name Path = $resolvedPath StartLine = $functionAst.Extent.StartLineNumber EndLine = $functionAst.Extent.EndLineNumber IsFilter = [bool]$functionAst.IsFilter IsWorkflow = [bool]$functionAst.IsWorkflow Commands = @($commands | Sort-Object) } } } } #endregion PS.Utils.Build #region PS.Utils.CommentHelp function Convert-PSUtilCommentHelpToInside { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [string]$FunctionName ) # 1. Locate targets $Target = Get-AstHelpPosition -FilePath $FilePath | Where-Object { $_.FunctionName -eq $FunctionName } if ($null -eq $Target -or $Target.HelpPosition -ne 'Outside') { Write-Warning "Function '$FunctionName' help is already Inside or does not exist." return } $RawContent = [System.IO.File]::ReadAllText($FilePath) $CommentText = $Target.CommentToken.Text # 2. Extract bounds $CommentStart = $Target.CommentToken.Extent.StartOffset $CommentLength = $Target.CommentToken.Extent.EndOffset - $CommentStart # Find the precise opening brace position inside the body AST block $BodyStartOffset = $Target.FuncAst.Body.Extent.StartOffset # 3. Surgical String Manipulation # Remove the comment text completely from outside the header $StrippedContent = $RawContent.Remove($CommentStart, $CommentLength) # Re-calculate index alignment since deleting elements shifted the remaining text backwards $ShiftedBodyStart = $BodyStartOffset - $CommentLength # Safely insert the help comment inside the open brace block (adding a clean indentation spacing newline) $FinalContent = $StrippedContent.Insert($ShiftedBodyStart + 1, "`n $CommentText") [System.IO.File]::WriteAllText($FilePath, $FinalContent) Write-Verbose "Moved comment-based help for '$FunctionName' to the INSIDE." } function Convert-PSUtilCommentHelpToOutside { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [string]$FunctionName ) # 1. Locate the targets $Target = Get-AstHelpPosition -FilePath $FilePath | Where-Object { $_.FunctionName -eq $FunctionName } if ($null -eq $Target -or $Target.HelpPosition -ne 'Inside') { Write-Warning "Function '$FunctionName' help is already Outside or does not exist." return } $RawContent = [System.IO.File]::ReadAllText($FilePath) $CommentText = $Target.CommentToken.Text # 2. Extract bounds $CommentStart = $Target.CommentToken.Extent.StartOffset $CommentLength = $Target.CommentToken.Extent.EndOffset - $CommentStart $FuncStart = $Target.FuncAst.Extent.StartOffset # 3. Surgical String Manipulation # Remove the comment from its original inside location $StrippedContent = $RawContent.Remove($CommentStart, $CommentLength) # Re-insert the comment cleanly directly above the function keyword header # Adjusting position slightly if stripping changed the index layout $FinalIndex = if ($CommentStart -lt $FuncStart) { $FuncStart - $CommentLength } else { $FuncStart } $FinalContent = $StrippedContent.Insert($FinalIndex, "$CommentText`n") [System.IO.File]::WriteAllText($FilePath, $FinalContent) Write-Verbose "Moved comment-based help for '$FunctionName' to the OUTSIDE." } function Get-PSUtilCommentHelpInfo { [CmdletBinding()] Param( [Parameter( Mandatory, ValueFromPipeline )] [System.Management.Automation.Language.FunctionDefinitionAst] $FuntionDefinitionAst ) PROCESS { $predicate = { param($astObject) $astObject.GetType().Name -like 'FunctionDefinitionAst' } $commentHelp = $FuntionDefinitionAst.GetHelpContent() $commentHelpType = if ($commentHelp) { $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput($FuntionDefinitionAst.Extent.Text, [ref]$null, [ref]$errors) $result = $ast.FindAll($predicate, $false) if ($result.GetHelpContent()) { 'Internal' } else { 'External' } } else { "None" } [PSCustomObject]@{ FunctionName = $function.Name CommentHelpType = $commentHelpType CommentHelp = $commentHelp } } } #endregion PS.Utils.CommentHelp |