functions/refactor/Export-PSMDString.ps1

function Export-PSMDString
{
<#
    .SYNOPSIS
        Parses a module that uses the PSFramework localization feature for strings and their value.
 
    .DESCRIPTION
        Parses a module that uses the PSFramework localization feature for strings and their value.
        This command can be used to generate and update the language files used by the module.
        It is also used in automatic tests, ensuring no abandoned string has been left behind and no key is unused.
 
    .PARAMETER ModuleRoot
        The root of the module to process.
        Must be the root folder where the psd1 file is stored in.
 
    .EXAMPLE
        PS C:\> Export-PSMDString -ModuleRoot 'C:\Code\Github\MyModuleProject\MyModule'
 
        Generates the strings data for the MyModule module.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('ModuleBase')]
        [string]
        $ModuleRoot
    )

    process
    {
        #region Find Language Files : $languageFiles
        $languageFiles = @{ }
        $languageFolders = Get-ChildItem -Path $ModuleRoot -Directory | Where-Object Name -match '^\w\w-\w\w$'
        foreach ($languageFolder in $languageFolders)
        {
            $languageFiles[$languageFolder.Name] = @{ }
            foreach ($file in (Get-ChildItem -Path $languageFolder.FullName -Filter *.psd1))
            {
                $languageFiles[$languageFolder.Name] += Import-PSFPowerShellDataFile -Path $file.FullName
            }
        }
        #endregion Find Language Files : $languageFiles

        #region Find Keys : $foundKeys
        $foundKeys = foreach ($file in (Get-ChildItem -Path $ModuleRoot -Recurse | Where-Object Extension -match '^\.ps1$|^\.psm1$'))
        {
            $ast = (Read-PSMDScript -Path $file.FullName).Ast
            $commandAsts = $ast.FindAll({
                    if ($args[0] -isnot [System.Management.Automation.Language.CommandAst]) { return $false }
                    if ($args[0].CommandElements[0].Value -notmatch '^Invoke-PSFProtectedCommand$|^Write-PSFMessage$|^Stop-PSFFunction$') { return $false }
                    if (-not ($args[0].CommandElements.ParameterName -match '^String$|^ActionString$')) { return $false }
                    $true
                }, $true)

            foreach ($commandAst in $commandAsts)
            {
                $stringParam = $commandAst.CommandElements | Where-Object ParameterName -match '^String$|^ActionString$'
                $stringParamValue = $commandAst.CommandElements[($commandAst.CommandElements.IndexOf($stringParam) + 1)].Value

                $stringValueParam = $commandAst.CommandElements | Where-Object ParameterName -match '^StringValues$|^ActionStringValues$'
                if ($stringValueParam)
                {
                    $stringValueParamValue = $commandAst.CommandElements[($commandAst.CommandElements.IndexOf($stringValueParam) + 1)].Extent.Text
                }
                else { $stringValueParamValue = '' }
                [PSCustomObject]@{
                    PSTypeName = 'PSModuleDevelopment.String.ParsedItem'
                    File       = $file.FullName
                    Line       = $commandAst.Extent.StartLineNumber
                    CommandName = $commandAst.CommandElements[0].Value
                    String       = $stringParamValue
                    StringValues = $stringValueParamValue
                }
            }

             # Additional checks for splatted commands
            # find all splatted commands
            $splattedVariables = $ast.FindAll( {
                if ($args[0] -isnot [System.Management.Automation.Language.VariableExpressionAst ]) { return $false }
                if (-not ($args[0].Splatted -eq $true)) { return $false }
                $true
            }, $true)

            foreach ($splattedVariable in $splattedVariables)
            {
                #get the variable name
                $splatParamName = $splattedVariable.VariablePath.UserPath
                if ($splatParamName)
                {
                    # match the $param = @{
                    $splatParamNameRegex = "^\s?\`$$($splatParamName)\s?=\s?\@\{"
                    # get all variable assignments where the
                    # left side matches our param
                    # operator is =
                    # matches our assignment regex
                    $splatAssignmentAsts = $ast.FindAll( {
                            if ($args[0] -isnot [System.Management.Automation.Language.AssignmentStatementAst ]) { return $false }
                            if (-not ($args[0].Left -match $splatParamName)) { return $false }
                            if (-not ($args[0].Operator -eq 'Equals')) { return $false }
                            if (-not ($args[0].Extent -match $splatParamNameRegex)) { return $false }
                            $true
                        }, $true)
                    foreach ($splatAssignmentAst in $splatAssignmentAsts)
                    {
                        # get the hashtable
                        $splatHashTable = $splatAssignmentAst.Right.Expression
                        # see if its an empty assignment or null
                        if ($splatHashTable -and $splatHashTable.KeyValuePairs.Count -gt 0)
                        {
                            # find any String or ActionString
                            $splatParam = $splatAssignmentAst.Right.Expression.KeyValuePairs |  Where-Object Item1 -match '^String$|^ActionString$'
                            if ($splatParam)
                            {
                                # The kvp.item.extent.text returns nested quotes where as the commandast.extent.text doesn't so strip them off
                                $splatParamValue = $splatParam.Item2.Extent.Text.Trim('"').Trim("'")
                                # find any StringValue or ActionStringValue
                                $splatValueParam = $splatAssignmentAst.Right.Expression.KeyValuePairs |  Where-Object Item1 -match '^StringValues$|^ActionStringValues$'
                            }
                            if ($splatValueParam)
                            {
                                # The kvp.item.extent.text returns nested quotes whereas the commandast.extent.text doesn't so strip them off
                                $splatValueParamValue = $splatValueParam.Item2.Extent.Text.Trim('"').Trim("'")
                            }
                            else { $splatValueParamValue = '' }

                            [PSCustomObject]@{
                                PSTypeName   = 'PSModuleDevelopment.String.ParsedItem'
                                File         = $file.FullName
                                Line         = $splatHashTable.Extent.StartLineNumber
                                CommandName  = $splattedVariable.Parent.CommandElements[0].Value
                                String       = $splatParamValue
                                StringValues = $splatValueParamValue
                            }
                        }
                    }
                }
            }

            $validateAsts = $ast.FindAll({
                    if ($args[0] -isnot [System.Management.Automation.Language.AttributeAst]) { return $false }
                    if ($args[0].TypeName -notmatch '^PsfValidateScript$|^PsfValidatePattern$') { return $false }
                    if (-not ($args[0].NamedArguments.ArgumentName -eq 'ErrorString')) { return $false }
                    $true
                }, $true)

            foreach ($validateAst in $validateAsts)
            {
                [PSCustomObject]@{
                    PSTypeName = 'PSModuleDevelopment.String.ParsedItem'
                    File       = $file.FullName
                    Line       = $commandAst.Extent.StartLineNumber
                    CommandName = '[{0}]' -f $validateAst.TypeName
                    String       = (($validateAst.NamedArguments | Where-Object ArgumentName -eq 'ErrorString').Argument.Value -split "\.", 2)[1] # The first element is the module element
                    StringValues = '<user input>, <validation item>'
                }
            }
        }
        #endregion Find Keys : $foundKeys

        #region Report Findings
        $totalResults = foreach ($languageFile in $languageFiles.Keys)
        {
            #region Phase 1: Matching parsed strings to language file
            $results = @{ }
            foreach ($foundKey in $foundKeys)
            {
                if ($results[$foundKey.String])
                {
                    $results[$foundKey.String].Entries += $foundKey
                    continue
                }

                $results[$foundKey.String] = [PSCustomObject] @{
                    PSTypeName = 'PSmoduleDevelopment.String.LanguageFinding'
                    Language   = $languageFile
                    Surplus    = $false
                    String       = $foundKey.String
                    StringValues = $foundKey.StringValues
                    Text       = $languageFiles[$languageFile][$foundKey.String]
                    Line       = "'{0}' = '{1}' # {2}" -f $foundKey.String, $languageFiles[$languageFile][$foundKey.String], $foundKey.StringValues
                    Entries    = @($foundKey)
                }
            }
            $results.Values
            #endregion Phase 1: Matching parsed strings to language file

            #region Phase 2: Finding unneeded strings
            foreach ($key in $languageFiles[$languageFile].Keys)
            {
                if ($key -notin $foundKeys.String)
                {
                    [PSCustomObject] @{
                        PSTypeName   = 'PSmoduleDevelopment.String.LanguageFinding'
                        Language     = $languageFile
                        Surplus         = $true
                        String         = $key
                        StringValues = ''
                        Text         = $languageFiles[$languageFile][$key]
                        Line         = ''
                        Entries         = @()
                    }
                }
            }
            #endregion Phase 2: Finding unneeded strings
        }
        $totalResults | Sort-Object String
        #endregion Report Findings
    }
}