Functions/GenXdev.Helpers/Get-GenXDevCmdlets.ps1

###############################################################################
<#
.SYNOPSIS
Retrieves and lists all GenXdev cmdlets and their details.
 
.DESCRIPTION
Searches through installed GenXdev modules and script files to find cmdlets,
their aliases, and descriptions. Can filter by name pattern and module name.
Supports filtering by cmdlet definitions and provides flexible search options
across both local and published module paths.
 
.PARAMETER CmdletName
Search pattern to filter cmdlets. Supports wildcards (*) and exact matching.
When ExactMatch is false, automatically wraps simple strings with wildcards.
 
.PARAMETER DefinitionMatches
Regular expression to match cmdlet definitions. Used to filter cmdlets based
on their function content or implementation details.
 
.PARAMETER ModuleName
One or more GenXdev module names to search. Can omit GenXdev prefix. Supports
wildcards and validates module name patterns for GenXdev modules.
 
.PARAMETER NoLocal
Skip searching in local module paths. When specified, only searches in
published or system module locations.
 
.PARAMETER OnlyPublished
Limit search to published module paths only. Excludes local development
modules and focuses on released versions.
 
.PARAMETER FromScripts
Search in script files instead of module files. Changes the search target
from PowerShell modules to standalone script files.
 
.PARAMETER IncludeScripts
Includes the scripts directory in addition to regular modules. Expands the
search scope to cover both modules and scripts simultaneously.
 
.PARAMETER OnlyReturnModuleNames
Only return unique module names instead of full cmdlet details. Provides a
summary view of available modules rather than detailed cmdlet information.
 
.PARAMETER ExactMatch
Perform exact matching instead of wildcard matching. When specified, disables
automatic wildcard wrapping for simple search patterns.
 
.EXAMPLE
Get-GenXDevCmdlets -CmdletName "Get-*" -ModuleName "Console" -NoLocal
 
.EXAMPLE
gcmds Get-*
 
.EXAMPLE
Get-GenXDevCmdlets -OnlyReturnModuleNames
#>

function Get-GenXDevCmdlets {

    [CmdletBinding()]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', 'Get-GenXDevCmdlets')]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidTrailingWhitespace', '')]
    [Alias('gcmds')]

    param(
        ###############################################################################
        [Parameter(
            Position = 0,
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'Search pattern to filter cmdlets'
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('Filter', 'CmdLet', 'Cmd', 'FunctionName', 'Name')]
        [SupportsWildcards()]
        [string] $CmdletName,
        ###############################################################################
        [Parameter(
            Position = 1,
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'Regular expression to match cmdlet definitions'
        )]
        [ValidateNotNullOrEmpty()]
        [string] $DefinitionMatches,
        ###############################################################################
        [Parameter(
            Position = 2,
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'GenXdev module names to search'
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('Module', 'BaseModuleName', 'SubModuleName')]
        [ValidatePattern('^(GenXdev|GenXde[v]\*|GenXdev(\.\w+)+)+$')]
        [SupportsWildcards()]
        [string[]] $ModuleName,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Skip searching in local module paths'
        )]
        [switch] $NoLocal,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Only search in published module paths'
        )]
        [switch] $OnlyPublished,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Search in script files instead of modules'
        )]
        [switch] $FromScripts,
        ###############################################################################
        [Parameter(
            ParameterSetName = "ModuleName",
            Mandatory = $false,
            HelpMessage = ('Includes the scripts directory in addition to ' +
                          'regular modules')
        )]
        [switch] $IncludeScripts,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Only return unique module names'
        )]
        [switch] $OnlyReturnModuleNames,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Perform exact matching instead of wildcard matching'
        )]
        [switch] $ExactMatch
    )

    begin {

        # store current location to restore later and prevent side effects
        $originalLocation = Microsoft.PowerShell.Management\Get-Location

        # output verbose information about search parameters
        Microsoft.PowerShell.Utility\Write-Verbose (
            "Starting cmdlet search with CmdletName: ${CmdletName}, " +
            "ModuleName: ${ModuleName}, ExactMatch: ${ExactMatch}"
        )
    }

    process {

        # copy identical parameter values to pass to the module iteration function
        $params = GenXdev.Helpers\Copy-IdenticalParamValues `
            -BoundParameters $PSBoundParameters `
            -FunctionName 'GenXdev.Helpers\Invoke-OnEachGenXdevModule' `
            -DefaultValues (Microsoft.PowerShell.Utility\Get-Variable `
                -Scope Local `
                -ErrorAction SilentlyContinue
        )

        # output verbose information about parameter copying
        Microsoft.PowerShell.Utility\Write-Verbose (
            "Copied parameters for module iteration: " +
            "$($params.Keys -join ', ')"
        )

        # iterate through each genxdev module to find cmdlets
        GenXdev.Helpers\Invoke-OnEachGenXdevModule @params {

            param(
                $Module,
                $isScriptsFolder,
                $isSubModule,
                $subModuleName
            )

            # determine which files to search based on folder type
            @(
                if ($isScriptsFolder) {

                    # search all powershell scripts recursively in scripts folder
                    Microsoft.PowerShell.Management\Get-ChildItem .\*.ps1 -Recurse

                } elseif ($isSubModule) {

                    # get all subdirectories under Functions to process each submodule
                    Microsoft.PowerShell.Management\Get-ChildItem .\Functions\* -Directory |
                    Microsoft.PowerShell.Core\ForEach-Object {
                        $subModuleDir = $_

                        # temporarily change to submodule directory
                        Microsoft.PowerShell.Management\Push-Location $subModuleDir.FullName

                        # get all powershell files in this submodule directory
                        Microsoft.PowerShell.Management\Get-ChildItem .\*.ps1 |
                        Microsoft.PowerShell.Core\ForEach-Object {
                            # add a property to track the actual submodule name
                            $_ | Microsoft.PowerShell.Utility\Add-Member -NotePropertyName 'ActualSubModuleName' -NotePropertyValue $subModuleDir.Name -Force
                            $_
                        }

                        # restore previous location
                        Microsoft.PowerShell.Management\Pop-Location
                    }
                }
                else {

                    # search all function files recursively, excluding private ones
                    Microsoft.PowerShell.Management\Get-ChildItem .\Functions\*.ps1 -Recurse |
                    Microsoft.PowerShell.Core\Where-Object {

                        # exclude files starting with underscore (private functions)
                        $_.Name -notlike "_*"
                    }
                }
             ) |
            Microsoft.PowerShell.Core\ForEach-Object {

                try {
                    ###############################################################################
                    <#
                    .SYNOPSIS
                    Helper function to extract function descriptions from sourcecode.
 
                    .DESCRIPTION
                    Parses PowerShell script content to extract the SYNOPSIS section from
                    comment-based help. Handles both module functions and script files with
                    different regex patterns based on file location.
 
                    .PARAMETER FileName
                    The path to the script file to analyze for function descriptions.
 
                    .PARAMETER FunctionContent
                    The content of the function to parse for extracting the synopsis.
                    #>

                    function Get-FunctionDescription {

                        [CmdletBinding()]
                        [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', 'Get-FunctionDescription')]
                        [OutputType([string])]

                        param(
                            [Parameter(
                                Position = 0,
                                Mandatory = $true
                            )]
                            [string]$FileName,

                            [Parameter(
                                Position = 1,
                                Mandatory = $true
                            )]
                            [AllowEmptyString()]
                            [AllowNull()]
                            [string]$FunctionContent
                        )

                        # ensure function content is never null for string operations
                        $FunctionContent = ($null -eq $FunctionContent) ? '' : $FunctionContent

                        # check if file is in scripts folder to determine parsing strategy
                        $fromScripts = $FileName.StartsWith((GenXdev.FileSystem\Expand-Path `
                                    -FilePath "$PSScriptRoot\..\..\..\..\..\Scripts\"))

                        try {

                            # build regex pattern based on file location and structure
                            $pattern = $fromScripts ?
                            "\.SYNOPSIS\s*`r`n([^\r\n]*[\r\n]*)\r\n\r\n([^#][^>])*#>" :
                                ("\.SYNOPSIS\s*`r`n([^\r\n]*[\r\n]*)\r\n\r\n([^#][^>])*#>\s*" +
                            "function\s+$([IO.Path]::GetFileNameWithoutExtension($FileName))")

                            # extract synopsis using regex matching
                            $match = [regex]::Match(
                                $FunctionContent,
                                $pattern,
                                ([System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor
                                [System.Text.RegularExpressions.RegexOptions]::IgnorePatternWhitespace)
                            )

                            # return the first capture group if match was successful
                            if ($match.Success) {
                                return $match.Groups[1].Value.Trim()
                            }
                        }
                        catch {

                            # log verbose message on parsing failure
                            Microsoft.PowerShell.Utility\Write-Verbose (
                                "Failed to get description: $($_.Exception.Message)"
                            )
                        }

                        # return empty string if no description found
                        return ''
                    }

                    ###############################################################################
                    <#
                    .SYNOPSIS
                    Helper function to extract function aliases from sourcecode.
 
                    .DESCRIPTION
                    Parses PowerShell function content to locate and extract alias
                    definitions. Searches for [Alias()] attributes within function
                    declarations and returns them as a clean array of strings.
 
                    .PARAMETER FileName
                    The path to the script file containing the function to analyze.
 
                    .PARAMETER FunctionContent
                    The content of the function to parse for alias extraction.
                    #>

                    function Get-FunctionAliases {

                        [CmdletBinding()]
                        [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', 'Get-FunctionAliases')]
                        [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', 'Get-FunctionAliases')]
                        [OutputType([string])]

                        param(
                            [Parameter(
                                Position = 0,
                                Mandatory = $true
                            )]
                            [string]$FileName,

                            [Parameter(
                                Position = 1,
                                Mandatory = $true
                            )]
                            [AllowEmptyString()]
                            [AllowNull()]
                            [string]$FunctionContent
                        )

                        # ensure function content is never null for string operations
                        $FunctionContent = $null -eq $FunctionContent ? '' : $FunctionContent

                        try {

                            # convert content to lowercase for case-insensitive searching
                            $content = $FunctionContent.ToLowerInvariant();

                            # find the end of comment-based help section
                            [int] $i = $content.IndexOf('#>');

                            # locate the function declaration after help section
                            $i = $content.IndexOf(
                                "function $([IO.Path]::GetFileNameWithoutExtension($FileName).ToLowerInvariant())", [Math]::Max(0, $i))

                            # fallback to cmdletbinding if function declaration not found
                            if ($i -lt 0) {
                                $i = $content.IndexOf('[cmdletbinding(', [Math]::Max(0, $i))
                                if ($i -lt 0) {
                                    $i = 0
                                }
                            }

                            # find the parameter section start
                            $i2 = $content.IndexOf('param(', [Math]::Max(0, $i))
                            if ($i2 -lt 0) {

                                # fallback to first parameter attribute
                                $i2 = $content.ToLowerInvariant().IndexOf('[parameter', [Math]::Max(0, $i))
                            }

                            # look for alias attribute between function and parameters
                            $i = $content.IndexOf('[alias(', [Math]::Max(0, $i))

                            # extract aliases if found in correct position
                            if ($i -ge 0 -and $i2 -gt $i) {

                                # extract the alias definition content
                                $aliases = $content.Substring($i + 7)

                                # get content until closing parenthesis
                                $aliases = $aliases.Substring(0, $aliases.IndexOf(')')).Replace(
                                    "'", "`"")

                                # clean up brackets and quotes from alias string
                                $aliases = $aliases -replace "[\)\[\]\`"]", ''

                                # split aliases and trim whitespace
                                return (@($aliases -split ',') |
                                Microsoft.PowerShell.Core\ForEach-Object { $_.Trim() })
                            }
                        }
                        catch {

                            # log verbose message on parsing failure
                            Microsoft.PowerShell.Utility\Write-Verbose (
                                "Failed to get aliases: $($_.Exception.Message)"
                            )
                        }

                        # return empty string if no aliases found
                        return [string]::Empty
                    }

                    ###############################################################################
                    <#
                    .SYNOPSIS
                    Helper function to find the starting line number of a function.
 
                    .DESCRIPTION
                    Analyzes function content to determine the approximate starting line
                    number by searching for key markers like comment-based help closure,
                    cmdletbinding attributes, or function blocks.
 
                    .PARAMETER Content
                    The content to search for the function start position markers.
                    #>

                    function Get-FunctionStartLine {

                        [CmdletBinding()]
                        [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', 'Get-FunctionStartLine')]
                        [OutputType([int])]

                        param(
                            [Parameter(
                                Position = 0,
                                Mandatory = $true
                            )]
                            [AllowEmptyString()]
                            [AllowNull()]
                            [string]$Content
                        )

                        # ensure content is never null for string operations
                        $Content = ($null -eq $Content) ? '' : $Content

                        # search for function start markers in order of preference
                        $lineNo = $Content.IndexOf('#>')
                        if ($lineNo -lt 0) { $lineNo = $Content.IndexOf('[CmdletBinding') }
                        if ($lineNo -lt 0) { $lineNo = $Content.IndexOf('params') }
                        if ($lineNo -lt 0) { $lineNo = $Content.IndexOf('begin {') }
                        if ($lineNo -lt 0) {
                            $lineNo = $Content.IndexOf('process {') }
                        if ($lineNo -lt 0) { $lineNo = 0 }
                        else {
                            # calculate line number by counting newlines before marker
                            $lineNo = $Content.Substring(0, $lineNo).Split("`n").Count + 1
                        }

                        return $lineNo
                    }

                    # extract the cmdlet name from the current file being processed
                    $cName = [IO.Path]::GetFileNameWithoutExtension($_.Name)

                    # get command help information for the cmdlet
                    $help = @(@($isScriptsFolder ?
                        (Microsoft.PowerShell.Core\Get-Command -Name $_.FullName  -ErrorAction SilentlyContinue) :
                        (Microsoft.PowerShell.Core\Get-Command -Name "$($Module.Name)\$($cName)"  -ErrorAction SilentlyContinue)) |
                        Microsoft.PowerShell.Utility\Select-Object -first 1 -ErrorAction SilentlyContinue)

                    # determine description from help or extract from source code
                    $description = (
                        $help -and (-not [String]::IsNullOrWhiteSpace($help.ResolvedCommand.Description)) ?
                        $help.ResolvedCommand.Description :
                        (
                            Get-FunctionDescription -FileName $_.FullName `
                                -FunctionContent (
                                    [IO.File]::ReadAllText($_.FullName )
                                )
                        )
                    )

                    # collect all aliases for this cmdlet from different sources
                    $aliases = @(
                        @(
                            Microsoft.PowerShell.Utility\Get-Alias -Definition $_.FullName -ErrorAction SilentlyContinue
                        ) + @(
                            Microsoft.PowerShell.Utility\Get-Alias -Definition "$($cName)" -ErrorAction SilentlyContinue
                        )
                    ) |
                    Microsoft.PowerShell.Core\Where-Object {

                        # filter aliases to only those from the current module
                        $_.Source -eq $Module.Name
                    } |
                    Microsoft.PowerShell.Core\ForEach-Object "Name";

                    # determine if cmdlet matches search criteria
                    $found = (-not $CmdletName) -or ($CmdletName.Length -eq 0)

                    # check if cmdlet name matches any provided search patterns
                    if (-not $found) {

                        foreach ($c in $CmdletName) {

                            $n = $c

                            # automatically add wildcards for non-exact matches
                            if ((-not $ExactMatch) -and ($n.IndexOfAny('*?[]'.ToCharArray()) -lt 0)) {

                                $n = "*$n*"
                            }

                            # check for module-qualified name match
                            if ($n.Contains('\')) {

                                if ("$($Module.Name)\$($cName)" -Like $n) {
                                    $found = $true
                                    break;
                                }
                            }
                            else {
                                # check for simple cmdlet name match
                                if ("$($cName)" -Like $n) {
                                    $found = $true
                                    break;
                                }
                            }
                        }
                    }

                    # check alias matches if cmdlet name didn't match
                    if (-not $found) {

                        foreach ($a in $aliases) {

                            if ($a -Like $CmdletName) {
                                $found = $true
                                break;
                            }
                        }
                    }

                    # apply definition matching filter if specified
                    if ($found -and $DefinitionMatches) {

                        $found = ($help ? $help.ResolvedCommand.Definition : [IO.File]::ReadAllText($_.FullName)) -match $DefinitionMatches
                    }

                    # skip this cmdlet if it doesn't match search criteria
                    if (-not $found) { return }

                    $moduleN = (
                            $isScriptsFolder ?
                                "GenXdev.Scripts" :
                                [IO.Path]::GetFileName([IO.Path]::GetDirectoryName($_.FullName))
                        )
                    # create and return cmdlet information object
                    [GenXdev.Helpers.GenXdevCmdletInfo]@{
                        Name               = $cName
                        ModuleName         = $moduleN
                        BaseModule         = $($Module.Name)
                        LineNo             = 0
                        Description        = $description
                        Aliases            = $aliases -join ', '
                        ScriptFilePath     = $_.FullName
                        ScriptTestFilePath = (
                            $isScriptsFolder ?
                                (GenXdev.FileSystem\Expand-Path "$([IO.Path]::GetDirectoryName($_.FullName))\$($cName).Tests.ps1") :
                                (GenXdev.FileSystem\Expand-Path "$([IO.Path]::GetDirectoryName($_.FullName))\..\..\Tests\$moduleN\$($cName).Tests.ps1")
                        )
                    }
                }
                catch {

                    # log warning for any file processing errors
                    Microsoft.PowerShell.Utility\Write-Warning (
                        "Error processing file '$($_.FullName)': $($_.Exception.Message)"
                    )
                }
            }
        }
    }

    end {

        # restore the original location to prevent side effects
        Microsoft.PowerShell.Management\Set-Location -LiteralPath $originalLocation

        # output verbose completion message
        Microsoft.PowerShell.Utility\Write-Verbose (
            "Completed cmdlet search and restored original location"
        )
    }
}
###############################################################################