diff --git a/PSModuleDevelopment/PSModuleDevelopment.psd1 b/PSModuleDevelopment/PSModuleDevelopment.psd1 index e74a912..0c1c7e4 100644 --- a/PSModuleDevelopment/PSModuleDevelopment.psd1 +++ b/PSModuleDevelopment/PSModuleDevelopment.psd1 @@ -4,7 +4,7 @@ RootModule = 'PSModuleDevelopment.psm1' # Version number of this module. - ModuleVersion = '2.2.8.104' + ModuleVersion = '2.2.9.106' # ID used to uniquely identify this module GUID = '37dd5fce-e7b5-4d57-ac37-832055ce49d6' @@ -27,7 +27,8 @@ # Modules that must be imported into the global environment prior to importing # this module RequiredModules = @( - @{ ModuleName = 'PSFramework'; ModuleVersion = '1.1.59' } + @{ ModuleName = 'PSFramework'; ModuleVersion = '1.4.149' } + @{ ModuleName = 'string'; ModuleVersion = '0.6.1' } ) # Assemblies that must be loaded prior to importing this module @@ -48,7 +49,8 @@ NestedModules = @() # Functions to export from this module - FunctionsToExport = @( + FunctionsToExport = @( + 'Convert-PSMDMessage', 'Expand-PSMDTypeName', 'Export-PSMDString', 'Find-PSMDFileContent', diff --git a/PSModuleDevelopment/changelog.md b/PSModuleDevelopment/changelog.md index 62a0045..7b3b449 100644 --- a/PSModuleDevelopment/changelog.md +++ b/PSModuleDevelopment/changelog.md @@ -1,5 +1,10 @@ # Changelog +## 2.2.9.106 (September 10th, 2020) + +- New: Convert-PSMDMessage - Converts a file's use of PSFramework messages to strings. +- Fix: Export-PSMDString - Failed with splatting detection + ## 2.2.8.104 (July 26th, 2020) - Fix: Various bugs in the new functions diff --git a/PSModuleDevelopment/en-us/strings.psd1 b/PSModuleDevelopment/en-us/strings.psd1 index 8955739..65c294e 100644 --- a/PSModuleDevelopment/en-us/strings.psd1 +++ b/PSModuleDevelopment/en-us/strings.psd1 @@ -1,4 +1,7 @@ @{ + 'Convert-PSMDMessage.Parameter.NonAffected' = 'No commands found that should be switched to strings in {0}' # $Path + 'Convert-PSMDMessage.SyntaxError' = 'Syntax error in result after converting the file {0}. Please validate your file and if it is valid, file an issue with the source file it failed to convert' # $Path + 'Get-PSMDFileCommand.SyntaxError' = 'Syntax error in file: {0}' # $pathItem 'MeasurePSMDLinesOfCode.Processing' = 'Processing Path: {0}' # $fileItem diff --git a/PSModuleDevelopment/functions/refactor/Convert-PSMDMessage.ps1 b/PSModuleDevelopment/functions/refactor/Convert-PSMDMessage.ps1 new file mode 100644 index 0000000..4e1bce6 --- /dev/null +++ b/PSModuleDevelopment/functions/refactor/Convert-PSMDMessage.ps1 @@ -0,0 +1,199 @@ +function Convert-PSMDMessage +{ +<# + .SYNOPSIS + Converts a file's use of PSFramework messages to strings. + + .DESCRIPTION + Converts a file's use of PSFramework messages to strings. + + .PARAMETER Path + Path to the file to convert. + + .PARAMETER OutPath + Folder in which to generate the output ps1 and psd1 file. + + .PARAMETER EnableException + Replaces user friendly yellow warnings with bloody red exceptions of doom! + Use this if you want the function to throw terminating errors you want to catch. + + .EXAMPLE + PS C:\> Convert-PSMDMessage -Path 'C:\Scripts\logrotate.ps1' -OutPath 'C:\output' + + Converts all instances of writing messages in logrotate.ps1 to use strings instead. +#> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, Position = 0)] + [PsfValidateScript('PSFramework.Validate.FSPath.File', ErrorString = 'PSFramework.Validate.FSPath.File')] + [string] + $Path, + + [Parameter(Mandatory = $true, Position = 1)] + [PsfValidateScript('PSFramework.Validate.FSPath.Folder', ErrorString = 'PSFramework.Validate.FSPath.Folder')] + [string] + $OutPath, + + [switch] + $EnableException + ) + + begin + { + #region Utility Functions + function Get-Text + { + [OutputType([string])] + [CmdletBinding()] + param ( + $Value + ) + + if (-not $Value.NestedExpressions) { return $Value.Extent.Text } + + $expressions = @{ } + $expIndex = 0 + + $builder = [System.Text.StringBuilder]::new() + $baseIndex = $Value.Extent.StartOffset + $astIndex = 0 + + foreach ($nestedExpression in $Value.NestedExpressions) + { + $null = $builder.Append($Value.Extent.Text.SubString($astIndex, ($nestedExpression.Extent.StartOffset - $baseIndex - $astIndex)).Replace("{", "{{").Replace('}', '}}')) + $astIndex = $nestedExpression.Extent.EndOffset - $baseIndex + + if ($expressions.ContainsKey($nestedExpression.Extent.Text)) { $effectiveIndex = $expressions[$nestedExpression.Extent.Text] } + else + { + $expressions[$nestedExpression.Extent.Text] = $expIndex + $effectiveIndex = $expIndex + $expIndex++ + } + + $null = $builder.Append("{$effectiveIndex}") + } + + $null = $builder.Append($Value.Extent.Text.SubString($astIndex).Replace("{", "{{").Replace('}', '}}')) + $builder.ToString() + } + + function Get-Insert + { + [OutputType([string])] + [CmdletBinding()] + param ( + $Value + ) + + if (-not $Value.NestedExpressions) { return "" } + + $processed = @{ } + $elements = foreach ($nestedExpression in $Value.NestedExpressions) + { + if ($processed[$nestedExpression.Extent.Text]) { continue } + else { $processed[$nestedExpression.Extent.Text] = $true } + + if ($nestedExpression -is [System.Management.Automation.Language.SubExpressionAst]) + { + if ( + ($nestedExpression.SubExpression.Statements.Count -eq 1) -and + ($nestedExpression.SubExpression.Statements[0].PipelineElements.Count -eq 1) -and + ($nestedExpression.SubExpression.Statements[0].PipelineElements[0].Expression -is [System.Management.Automation.Language.MemberExpressionAst]) + ) { $nestedExpression.SubExpression.Extent.Text } + else { $nestedExpression.Extent.Text.SubString(1) } + } + else { $nestedExpression.Extent.Text } + } + $elements -join ", " + } + #endregion Utility Functions + + $parameterMapping = @{ + 'Message' = 'String' + 'Action' = 'ActionString' + } + $insertMapping = @{ + 'String' = '-StringValues' + 'Action' = '-ActionStringValues' + } + } + process + { + $ast = (Read-PSMDScript -Path $Path).Ast + + #region Parse Input + $functionName = (Get-Item $Path).BaseName + + $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 '^Message$|^Action$')) { return $false } + $true + }, $true) + if (-not $commandAsts) + { + Write-PSFMessage -Level Host -String 'Convert-PSMDMessage.Parameter.NonAffected' -StringValues $Path + return + } + #endregion Parse Input + + #region Build Replacements table + $currentCount = 1 + $replacements = foreach ($command in $commandAsts) + { + $parameter = $command.CommandElements | Where-Object ParameterName -in 'Message', 'Action' + $paramIndex = $command.CommandElements.IndexOf($parameter) + $parameterValue = $command.CommandElements[$paramIndex + 1] + + [PSCustomObject]@{ + OriginalText = $parameterValue.Value + Text = Get-Text -Value $parameterValue + Inserts = Get-Insert -Value $parameterValue + String = "$($functionName).Message$($currentCount)" + StartOffset = $parameter.Extent.StartOffset + EndOffset = $parameterValue.Extent.EndOffset + OldParameterName = $parameter.ParameterName + NewParameterName = $parameterMapping[$parameter.ParameterName] + Parameter = $parameter + ParameterValue = $parameterValue + } + $currentCount++ + } + #endregion Build Replacements table + + #region Calculate new text body + $fileText = [System.IO.File]::ReadAllText((Resolve-PSFPath -Path $Path)) + $builder = [System.Text.StringBuilder]::new() + $index = 0 + foreach ($replacement in $replacements) + { + $null = $builder.Append($fileText.Substring($index, ($replacement.StartOffset - $index))) + $null = $builder.Append("-$($replacement.NewParameterName) '$($replacement.String)'") + if ($replacement.Inserts) { $null = $builder.Append(" $($insertMapping[$replacement.NewParameterName]) $($replacement.Inserts)") } + $index = $replacement.EndOffset + } + $null = $builder.Append($fileText.Substring($index)) + $newDefinition = $builder.ToString() + $testResult = Read-PSMDScript -ScriptCode ([Scriptblock]::create($newDefinition)) + + if ($testResult.Errors) + { + Stop-PSFFunction -String 'Convert-PSMDMessage.SyntaxError' -StringValues $Path -Target $Path -EnableException $EnableException + return + } + #endregion Calculate new text body + + $resolvedOutPath = Resolve-PSFPath -Path $OutPath + $encoding = [System.Text.UTF8Encoding]::new($true) + $filePath = Join-Path -Path $resolvedOutPath -ChildPath "$functionName.ps1" + [System.IO.File]::WriteAllText($filePath, $newDefinition, $encoding) + $stringsPath = Join-Path -Path $resolvedOutPath -ChildPath "$functionName.psd1" + $stringsText = @" +@{ +$($replacements | Format-String "`t'{0}' = {1} # {2}" -Property String, Text, Inserts | Join-String -Separator "`n") +} +"@ + [System.IO.File]::WriteAllText($stringsPath, $stringsText, $encoding) + } +} \ No newline at end of file diff --git a/PSModuleDevelopment/functions/refactor/Export-PSMDString.ps1 b/PSModuleDevelopment/functions/refactor/Export-PSMDString.ps1 index 1581f70..fbf020a 100644 --- a/PSModuleDevelopment/functions/refactor/Export-PSMDString.ps1 +++ b/PSModuleDevelopment/functions/refactor/Export-PSMDString.ps1 @@ -25,7 +25,7 @@ [string] $ModuleRoot ) - + process { #region Find Language Files : $languageFiles @@ -40,23 +40,24 @@ } } #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 + #region Command Parameters $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) { @@ -72,77 +73,64 @@ 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 - } - } - } - } - } - + #endregion Command Parameters + + #region Splatted Variables + $splattedVariables = $ast.FindAll({ + if ($args[0] -isnot [System.Management.Automation.Language.VariableExpressionAst]) { return $false } + if (-not ($args[0].Splatted -eq $true)) { return $false } + try { if ($args[0].Parent.CommandElements[0].Value -notmatch '^Invoke-PSFProtectedCommand$|^Write-PSFMessage$|^Stop-PSFFunction$') { return $false } } + catch { return $false } + $true + }, $true) + + foreach ($splattedVariable in $splattedVariables) + { + $splatParamName = $splattedVariable.VariablePath.UserPath + + $splatAssignmentAsts = $ast.FindAll({ + if ($args[0] -isnot [System.Management.Automation.Language.AssignmentStatementAst]) { return $false } + if ($args[0].Left.VariablePath.userPath -ne $splatParamName) { return $false } + if ($args[0].Operator -ne 'Equals') { return $false } + if ($args[0].Right.Expression -isnot [System.Management.Automation.Language.HashtableAst]) { return $false } + $keys = $args[0].Right.Expression.KeyValuePairs.Item1.Value + if (($keys -notcontains 'String') -and ($keys -notcontains 'ActionString')) { return $false } + + $true + }, $true) + + foreach ($splatAssignmentAst in $splatAssignmentAsts) + { + $splatHashTable = $splatAssignmentAst.Right.Expression + + $splatParam = $splathashTable.KeyValuePairs | Where-Object Item1 -in 'String', 'ActionString' + $splatValueParam = $splathashTable.KeyValuePairs | Where-Object Item1 -in 'StringValues', 'ActionStringValues' + if ($splatValueParam) + { + $splatValueParamValue = $splatValueParam.Item2.Extent.Text + } + else { $splatValueParamValue = '' } + + [PSCustomObject]@{ + PSTypeName = 'PSModuleDevelopment.String.ParsedItem' + File = $file.FullName + Line = $splatHashTable.Extent.StartLineNumber + CommandName = $splattedVariable.Parent.CommandElements[0].Value + String = $splatParam.Item2.Extent.Text.Trim("'").Trim('"') + StringValues = $splatValueParamValue + } + } + } + #endregion Splatted Variables + + #region Attributes $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]@{ @@ -154,9 +142,10 @@ StringValues = ', ' } } + #endregion Attributes } #endregion Find Keys : $foundKeys - + #region Report Findings $totalResults = foreach ($languageFile in $languageFiles.Keys) { @@ -169,7 +158,7 @@ $results[$foundKey.String].Entries += $foundKey continue } - + $results[$foundKey.String] = [PSCustomObject] @{ PSTypeName = 'PSmoduleDevelopment.String.LanguageFinding' Language = $languageFile @@ -183,7 +172,7 @@ } $results.Values #endregion Phase 1: Matching parsed strings to language file - + #region Phase 2: Finding unneeded strings foreach ($key in $languageFiles[$languageFile].Keys) { diff --git a/build/vsts-prerequisites.ps1 b/build/vsts-prerequisites.ps1 index f4d94e0..7f3af50 100644 --- a/build/vsts-prerequisites.ps1 +++ b/build/vsts-prerequisites.ps1 @@ -3,4 +3,6 @@ Install-Module Pester -Force -SkipPublisherCheck Write-Host "Installing PSScriptAnalyzer" -ForegroundColor Cyan Install-Module PSScriptAnalyzer -Force -SkipPublisherCheck Write-Host "Installing PSFramework" -ForegroundColor Cyan -Install-Module PSFramework -Force -SkipPublisherCheck \ No newline at end of file +Install-Module PSFramework -Force -SkipPublisherCheck +Write-Host "Installing String" -ForegroundColor Cyan +Install-Module String -Force -SkipPublisherCheck \ No newline at end of file