Documentarian.Vale.psm1
# Copyright (c) Microsoft Corporation. # Licensed under the MIT License. using namespace System.Management.Automation #region Enums.Public enum ValeAlertLevel { Suggestion Warning Error } enum ValeInstallScope { User Workspace } enum ValeKnownStylePackage { Vale Alex Hugo JobLint Microsoft PowerShellDocs ProseLint Readability RedHat WriteGood } enum ValeReadabilityRule { AutomatedReadability ColemanLiau FleschKincaid FleschReadingEase GunningFog LIX SMOG } #endregion Enums.Public #region Classes.Public class ValeStylePackageTransformAttribute : ArgumentTransformationAttribute { [object] Transform([EngineIntrinsics]$engineIntrinsics, [System.Object]$inputData) { $ValidEnums = [ValeKnownStylePackage].GetEnumNames() $outputData = switch ($inputData) { { $_ -in $ValidEnums } { switch ([ValeKnownStylePackage]$_) { Alex { 'alex' continue } JobLint { 'Joblint' continue } PowerShellDocs { 'https://microsoft.github.io/Documentarian/packages/vale/PowerShell-Docs.zip' continue } ProseLint { 'proselint' continue } WriteGood { 'write-good' continue } default { $_.ToString() } } continue } { $_ -is [string] } { $_ } default { $Message = @( "Could not convert input ($_) of type '$($_.GetType().FullName)' to a string." ) -join ' ' throw [ArgumentTransformationMetadataException]::New( $Message ) } } return $outputData } } class ValeStyleNameTransformAttribute : ArgumentTransformationAttribute { [object] Transform([EngineIntrinsics]$engineIntrinsics, [System.Object]$inputData) { $ValidEnums = [ValeKnownStylePackage].GetEnumNames() $outputData = switch ($inputData) { { $_ -in $ValidEnums } { switch ([ValeKnownStylePackage]$_) { Alex { 'alex' continue } JobLint { 'Joblint' continue } PowerShellDocs { 'PowerShell-Docs' continue } ProseLint { 'proselint' continue } WriteGood { 'write-good' continue } default { $_.ToString() } } continue } { $_ -is [string] } { $_ } default { $Message = @( "Could not convert input ($_) of type '$($_.GetType().FullName)' to a string." ) -join ' ' throw [ArgumentTransformationMetadataException]::New( $Message ) } } return $outputData } } class ValeApplicationInfo { [System.Management.Automation.CommandTypes] $CommandType [string] $Definition [string] $Extension [psmoduleinfo] $Module [string] $ModuleName [string] $Name [System.Collections.ObjectModel.ReadOnlyCollection[System.Management.Automation.PSTypeName]] $OutputType [System.Collections.Generic.Dictionary[string, System.Management.Automation.ParameterMetadata]] $Parameters [System.Collections.ObjectModel.ReadOnlyCollection[System.Management.Automation.CommandParameterSetInfo]] $ParameterSets [string] $Path [System.Management.Automation.RemotingCapability] $RemotingCapability [string] $Source [version] $Version [System.Management.Automation.SessionStateEntryVisibility] $Visibility ValeApplicationInfo() {} ValeApplicationInfo([System.Management.Automation.ApplicationInfo]$Info) { $InfoProperties = $Info | Get-Member -MemberType Property | Select-Object -ExpandProperty Name foreach ($Property in $InfoProperties) { $this.$Property = $Info.$Property } if (($Output = & $Info --version) -match 'vale version (?<VersionString>\S+)') { $this.Version = [version]($Matches.VersionString) } else { throw "Unable to find version string from 'vale --version' output: $Output" } } [string] ToString() { return $this.Source } } class ValeConfigurationIgnore { [string]$GlobPattern [string[]]$IgnorePatterns } class ValeConfigurationFormatTypeAssociation { [string]$ActualFormat [string]$EffectiveFormat } class ValeConfigurationFormatLanguageAssociation { [string]$GlobPattern [string]$LanguageID } class ValeConfigurationFormatTransform { [string]$GlobPattern [string]$Path } class ValeConfigurationEffective { [ValeConfigurationIgnore[]] $BlockIgnores [string[]] $Checks [ValeConfigurationFormatTypeAssociation[]] $FormatTypeAssociations [hashtable] $AsciidoctorAttributes # A set of key-value pairs for `Asciidoctor` attributes [ValeConfigurationFormatLanguageAssociation[]] $FormatLanguageAssociations [string[]] $GlobalBaseStyles # Maps to `GBaseStyles` [hashtable] $GlobalChecks # Maps to `GChecks` [string[]] $IgnoredClasses [string[]] $IgnoredScopes [ValeAlertLevel] $MinimumAlertLevel # Maps to `MinAlertLevel` [string[]] $Vocabularies [hashtable] $RuleToLevel [hashtable] $SyntaxBaseStyles # Maps to `SBaseStyles` [hashtable] $SyntaxChecks # Maps to `SChecks` [string[]] $SkippedScopes [ValeConfigurationFormatTransform[]] $FormatTransformationStylesheets [string] $StylesPath [ValeConfigurationIgnore[]] $TokenIgnores [string] $WordTemplate [string] $RootIniPath [string] $DictionaryPath [string] $NlpEndpoint ValeConfigurationEffective() {} ValeConfigurationEffective([hashtable]$Info) { $Info.BlockIgnores.GetEnumerator() | ForEach-Object -Process { $this.BlockIgnores += [ValeConfigurationIgnore]@{ GlobPattern = $_.Key IgnorePatterns = $_.Value } } $this.Checks = $Info.Checks $Info.Formats.GetEnumerator() | ForEach-Object -Process { $this.FormatTypeAssociations += [ValeConfigurationFormatTypeAssociation]@{ ActualFormat = $_.Key EffectiveFormat = $_.Value } } $this.AsciidoctorAttributes = $Info.Asciidoctor $Info.FormatToLang.GetEnumerator() | ForEach-Object -Process { $this.FormatLanguageAssociations += [ValeConfigurationFormatLanguageAssociation]@{ GlobPattern = $_.Key LanguageID = $_.Value } } $this.GlobalBaseStyles = $Info.GBaseStyles $this.GlobalChecks = $Info.GChecks $this.IgnoredClasses = $Info.IgnoredClasses $this.IgnoredScopes = $Info.IgnoredScopes $this.MinimumAlertLevel = [ValeAlertLevel]($Info.MinAlertLevel) $this.Vocabularies = $Info.Vocab $this.SyntaxBaseStyles = $Info.SBaseStyles $this.SyntaxChecks = $Info.SChecks $this.SkippedScopes = $Info.SkippedScopes $Info.Stylesheets.GetEnumerator() | ForEach-Object -Process { $this.FormatTransformationStylesheets += [ValeConfigurationFormatTransform]@{ GlobPattern = $_.Key Path = $_.Value } } $this.StylesPath = $Info.StylesPath $Info.TokenIgnores.GetEnumerator() | ForEach-Object -Process { $this.TokenIgnores += [ValeConfigurationIgnore]@{ GlobPattern = $_.Key IgnorePatterns = $_.Value } } $this.WordTemplate = $Info.WordTemplate $this.RootIniPath = $Info.RootINI $this.DictionaryPath = $Info.DictionaryPath $this.NlpEndpoint = $Info.NLPEndpoint } } class ValeMetricsHeadingCount { [int] $H1 [int] $H2 [int] $H3 [int] $H4 [int] $H5 [int] $H6 } class ValeMetricsInfo { [System.IO.FileInfo] $FileInfo [int] $CharacterCount [int] $ComplexWordCount [ValeMetricsHeadingCount] $HeadingCounts [int] $ListBlockCount [int] $LongWordCount [int] $ParagraphCount [int] $PolysyllabicWordCount [int] $SentenceCount [int] $SyllableCount [int] $WordCount # Default Constructor ValeMetricsInfo() { $this.HeadingCounts = [ValeMetricsHeadingCount]::new() } # From PSCustomObject, as with Invoke-Vale ValeMetricsInfo([hashtable]$Info) { $this.SetFromMetricInfo($Info) } # From PSCustomObject with known file info ValeMetricsInfo([hashtable]$Info, [System.IO.FileInfo]$File) { $this.SetFromMetricInfo($Info) $this.FileInfo = $File } # Reusable method for converting from JSON properties to class properties hidden [void] SetFromMetricInfo([hashtable]$Info) { $this.HeadingCounts = [ValeMetricsHeadingCount]::new() $this.CharacterCount = $Info.characters $this.ComplexWordCount = $info.complex_words $this.HeadingCounts.H1 = $info.heading_h1 $this.HeadingCounts.H2 = $info.heading_h2 $this.HeadingCounts.H3 = $info.heading_h3 $this.HeadingCounts.H4 = $info.heading_h4 $this.HeadingCounts.H5 = $info.heading_h5 $this.HeadingCounts.H6 = $info.heading_h6 $this.ListBlockCount = $info.list $this.LongWordCount = $info.long_words $this.ParagraphCount = $info.paragraphs $this.PolysyllabicWordCount = $info.polysyllabic_words $this.SentenceCount = $info.sentences $this.SyllableCount = $info.syllables $this.WordCount = $info.words } } class ValeReadability { [ValeReadabilityRule] $Rule [float] $Score [string] $File [float] $Threshold [string] $ProblemMessage [ValeMetricsInfo] $Metrics hidden static [hashtable] $ScoreMapping = @{ 1 = @{ AgeRange = '5-6' GradeLevel = 'Kindergarten' } 2 = @{ AgeRange = '6-7' GradeLevel = '1st Grade' } 3 = @{ AgeRange = '7-8' GradeLevel = '2nd Grade' } 4 = @{ AgeRange = '8-9' GradeLevel = '3rd Grade' } 5 = @{ AgeRange = '9-10' GradeLevel = '4th Grade' } 6 = @{ AgeRange = '10-11' GradeLevel = '5th Grade' } 7 = @{ AgeRange = '11-12' GradeLevel = '6th Grade' } 8 = @{ AgeRange = '12-13' GradeLevel = '7th Grade' } 9 = @{ AgeRange = '13-14' GradeLevel = '8th Grade' } 10 = @{ AgeRange = '14-15' GradeLevel = '9th Grade' } 11 = @{ AgeRange = '15-16' GradeLevel = '10th Grade' } 12 = @{ AgeRange = '16-17' GradeLevel = '11th Grade' } 13 = @{ AgeRange = '17-18' GradeLevel = '12th Grade' } 14 = @{ AgeRange = '18-22' GradeLevel = 'College student' } } hidden static [string] GetAgeRange([int]$Score) { $Index = [Math]::Clamp($Score, 1, 14) return [ValeReadability]::ScoreMapping[$Index].AgeRange } hidden static [string] GetGradeLevel([int]$Score) { $Index = [Math]::Clamp($Score, 1, 14) return [ValeReadability]::ScoreMapping[$Index].GradeLevel } hidden static [string] GetMappedScore($Score) { return @( $Score "(Age Range: $([ValeReadability]::GetAgeRange($Score))," "Grade Level: $([ValeReadability]::GetGradeLevel($Score)))" ) -join ' ' } static [ValeReadabilityRule[]] GetGradeLevelRules() { return @( [ValeReadabilityRule]::AutomatedReadability [ValeReadabilityRule]::ColemanLiau [ValeReadabilityRule]::FleschKincaid [ValeReadabilityRule]::SMOG ) } static [ValeReadabilityRule[]] GetNumericalRules() { return @( [ValeReadabilityRule]::FleschReadingEase [ValeReadabilityRule]::GunningFog [ValeReadabilityRule]::LIX ) } # Default Constructor ValeReadability() { $this.AddBaseDynamicMembers() } ValeReadability([ValeMetricsInfo]$Metrics) { $this.Metrics = $Metrics $this.AddBaseDynamicMembers() } [bool] TestThreshold() { return $this.Score -le $this.Threshold } hidden [void] AddBaseDynamicMembers() { $this | Add-Member -Force -MemberType ScriptProperty -Name File -Value { $this.Metrics.FileInfo.FullName } } hidden static [string] GetRoundedScore($Score) { return ('{0:n2}' -f $Score) } } class ValeMetricsAutomatedReadability : ValeReadability { [string] $AgeRange [string] $GradeLevel hidden [void] AddDynamicMembers() { $this | Add-Member -Force -MemberType ScriptProperty -Name AgeRange -Value { return [ValeReadability]::GetAgeRange($this.Score) } $this | Add-Member -Force -MemberType ScriptProperty -Name GradeLevel -Value { return [ValeReadability]::GetGradeLevel($this.Score) } $this | Add-Member -Force -MemberType ScriptProperty -Name ProblemMessage -Value { if (-not $this.TestThreshold()) { $Target = [ValeReadability]::GetGradeLevel($this.Threshold) return @( "Automated Readability Index grade level is $($this.GradeLevel)" "try to target $Target" ) -join ' - ' } } } ValeMetricsAutomatedReadability() : base() { $this.Rule = [ValeReadabilityRule]::AutomatedReadability $this.Threshold = 8 } ValeMetricsAutomatedReadability([int]$Score) : base() { $this.Rule = [ValeReadabilityRule]::AutomatedReadability $this.Threshold = 8 $this.Score = $Score $this.AddDynamicMembers() } ValeMetricsAutomatedReadability([ValeMetricsInfo]$Metrics) : base([ValeMetricsInfo]$Metrics) { $this.Rule = [ValeReadabilityRule]::AutomatedReadability $this.Threshold = 8 $this.Score = [ValeMetricsAutomatedReadability]::GetScore($Metrics) $this.AddDynamicMembers() } static [int] GetScore([ValeMetricsInfo]$Metrics) { $Result = 4.71 * ($Metrics.CharacterCount / $Metrics.WordCount) $Result += 0.5 * ($Metrics.WordCount / $Metrics.SentenceCount) $Result -= 21.43 $Result = [Math]::Ceiling($Result) $Result = [Math]::Clamp($Result, 1, 14) return $Result } [string] ToString() { return @( "Automated Readability Index for '$($this.Name)':" [ValeReadability]::GetMappedScore($this.Score) ) -join ' ' } } class ValeMetricsColemanLiau : ValeReadability { [string] $AgeRange [string] $GradeLevel hidden [void] AddDynamicMembers() { $this | Add-Member -Force -MemberType ScriptProperty -Name AgeRange -Value { return [ValeReadability]::GetAgeRange($this.Score + 1) } $this | Add-Member -Force -MemberType ScriptProperty -Name GradeLevel -Value { return [ValeReadability]::GetGradeLevel($this.Score + 1) } $this | Add-Member -Force -MemberType ScriptProperty -Name ProblemMessage -Value { if (-not $this.TestThreshold()) { $Target = [ValeReadability]::GetGradeLevel($this.Threshold + 1) return @( "Coleman-Liau Index grade level is $($this.GradeLevel)" "try to keep below $Target" ) -join ' - ' } } } ValeMetricsColemanLiau() : base() { $this.Rule = [ValeReadabilityRule]::ColemanLiau $this.Threshold = 9 } ValeMetricsColemanLiau([int]$Score) : base() { $this.Rule = [ValeReadabilityRule]::ColemanLiau $this.Threshold = 9 $this.Score = $Score $this.AddDynamicMembers() } ValeMetricsColemanLiau([ValeMetricsInfo]$Metrics) : base([ValeMetricsInfo]$Metrics) { $this.Rule = [ValeReadabilityRule]::ColemanLiau $this.Threshold = 9 $this.Score = [ValeMetricsColemanLiau]::GetScore($Metrics) $this.AddDynamicMembers() } static [float] GetScore([ValeMetricsInfo]$Metrics) { $Result = 0.0588 * ($Metrics.CharacterCount / $Metrics.WordCount) * 100 $Result -= 0.296 * ($Metrics.SentenceCount / $Metrics.WordCount) * 100 $Result -= 15.8 return $Result } [string] ToString() { return @( "Coleman-Liau Index for '$($this.Name)':" $this.Score "(Ages $($this.AgeRange), Grade Level $($this.GradeLevel))" ) -join ' ' } } class ValeMetricsFleschKincaid : ValeReadability { [string] $AgeRange [string] $GradeLevel hidden [void] AddDynamicMembers() { $this | Add-Member -Force -MemberType ScriptProperty -Name AgeRange -Value { $MapIndex = [int]($this.Score) + 1 return [ValeReadability]::GetAgeRange($MapIndex) } $this | Add-Member -Force -MemberType ScriptProperty -Name GradeLevel -Value { $MapIndex = [int]($this.Score) + 1 return [ValeReadability]::GetGradeLevel($MapIndex) } $this | Add-Member -Force -MemberType ScriptProperty -Name ProblemMessage -Value { if (-not $this.TestThreshold()) { $Target = [ValeReadability]::GetGradeLevel($this.Threshold + 1) return @( "Flesch-Kincaid grade level is $($this.GradeLevel)" "try to keep below $Target" ) -join ' - ' } } } ValeMetricsFleschKincaid() : base() { $this.Rule = [ValeReadabilityRule]::FleschKincaid $this.Threshold = 8 } ValeMetricsFleschKincaid([int]$Score) : base() { $this.Rule = [ValeReadabilityRule]::FleschKincaid $this.Threshold = 8 $this.Score = $Score $this.AddDynamicMembers() } ValeMetricsFleschKincaid([ValeMetricsInfo]$Metrics) : base([ValeMetricsInfo]$Metrics) { $this.Rule = [ValeReadabilityRule]::FleschKincaid $this.Threshold = 8 $this.Score = [ValeMetricsFleschKincaid]::GetScore($Metrics) $this.AddDynamicMembers() } static [float] GetScore([ValeMetricsInfo]$Metrics) { $Result = 0.39 * ($Metrics.WordCount / $Metrics.SentenceCount) $Result += 11.8 * ($Metrics.SyllableCount / $Metrics.WordCount) $Result -= 15.59 return $Result } [string] ToString() { return @( "Flesch-Kincaid score for '$($this.Name)':" $this.Score "(Ages $($this.AgeRange), Grade Level $($this.GradeLevel))" ) -join ' ' } } class ValeMetricsFleschReadingEase : ValeReadability { hidden [void] AddDynamicMembers() { $this | Add-Member -Force -MemberType ScriptProperty -Name ProblemMessage -Value { if (-not $this.TestThreshold()) { $Ease = [ValeReadability]::GetRoundedScore($Result.Score) return @( "Flesch reading ease score is $Ease" "try to keep above $($this.Threshold)." ) -join ' - ' } } } [bool] TestThreshold() { return $this.Score -ge $this.Threshold } ValeMetricsFleschReadingEase() : base() { $this.Rule = [ValeReadabilityRule]::FleschReadingEase $this.Threshold = 70 } ValeMetricsFleschReadingEase([int]$Score) : base() { $this.Rule = [ValeReadabilityRule]::FleschReadingEase $this.Threshold = 70 $this.Score = $Score $this.AddDynamicMembers() } ValeMetricsFleschReadingEase([ValeMetricsInfo]$Metrics) : base([ValeMetricsInfo]$Metrics) { $this.Rule = [ValeReadabilityRule]::FleschReadingEase $this.Threshold = 70 $this.Score = [ValeMetricsFleschReadingEase]::GetScore($Metrics) $this.AddDynamicMembers() } static [float] GetScore([ValeMetricsInfo]$Metrics) { $Result = 206.835 $Result -= 1.015 * ($Metrics.WordCount / $Metrics.SentenceCount) $Result -= 84.6 * ($Metrics.SyllableCount / $Metrics.WordCount) return $Result } [string] ToString() { return @( "Flesch reading ease score for '$($this.Name)':" $this.Score ) -join ' ' } } class ValeMetricsGunningFog : ValeReadability { hidden [void] AddDynamicMembers() { $this | Add-Member -Force -MemberType ScriptProperty -Name ProblemMessage -Value { if (-not $this.TestThreshold()) { $Index = [ValeReadability]::GetRoundedScore($this.Score) return @( "Gunning fog index is $Index" "try to keep below $($this.Threshold)." ) -join ' - ' } } } ValeMetricsGunningFog() : base() { $this.Rule = [ValeReadabilityRule]::GunningFog $this.Threshold = 10 } ValeMetricsGunningFog([int]$Score) : base() { $this.Rule = [ValeReadabilityRule]::GunningFog $this.Threshold = 10 $this.Score = $Score $this.AddDynamicMembers() } ValeMetricsGunningFog([ValeMetricsInfo]$Metrics) : base([ValeMetricsInfo]$Metrics) { $this.Rule = [ValeReadabilityRule]::GunningFog $this.Threshold = 10 $this.Score = [ValeMetricsGunningFog]::GetScore($Metrics) $this.AddDynamicMembers() } static [float] GetScore([ValeMetricsInfo]$Metrics) { $Result = 0.4 * ($Metrics.WordCount / $Metrics.SentenceCount) $Result += 100 * ($Metrics.ComplexWordCount / $Metrics.WordCount) $Result -= 15.59 return $Result } [string] ToString() { return @( "Gunning fog index for '$($this.Name)':" $this.Score ) -join ' ' } } class ValeMetricsLIX : ValeReadability { hidden [void] AddDynamicMembers() { $this | Add-Member -Force -MemberType ScriptProperty -Name ProblemMessage -Value { if (-not $this.TestThreshold()) { $Index = [ValeReadability]::GetRoundedScore($this.Score) return @( "LIX readability index is $Index" "try to keep below $($this.Threshold)." ) -join ' - ' } } } ValeMetricsLIX() : base() { $this.Rule = [ValeReadabilityRule]::LIX $this.Threshold = 35 } ValeMetricsLIX([int]$Score) : base() { $this.Rule = [ValeReadabilityRule]::LIX $this.Threshold = 35 $this.Score = $Score $this.AddDynamicMembers() } ValeMetricsLIX([ValeMetricsInfo]$Metrics) : base([ValeMetricsInfo]$Metrics) { $this.Rule = [ValeReadabilityRule]::LIX $this.Threshold = 35 $this.Score = [ValeMetricsLIX]::GetScore($Metrics) $this.AddDynamicMembers() } static [float] GetScore([ValeMetricsInfo]$Metrics) { $Result = $Metrics.WordCount / $Metrics.SentenceCount $Result += $Metrics.LongWordCount * 100 / $Metrics.WordCount return $Result } [string] ToString() { return @( "LIX readability index for '$($this.Name)':" $this.Score ) -join ' ' } } class ValeMetricsSMOG : ValeReadability { [string] $AgeRange [string] $GradeLevel hidden [void] AddDynamicMembers() { $this | Add-Member -Force -MemberType ScriptProperty -Name AgeRange -Value { $MapIndex = [int]($this.Score) + 1 return [ValeReadability]::GetAgeRange($MapIndex) } $this | Add-Member -Force -MemberType ScriptProperty -Name GradeLevel -Value { $MapIndex = [int]($this.Score) + 1 return [ValeReadability]::GetGradeLevel($MapIndex) } $this | Add-Member -Force -MemberType ScriptProperty -Name ProblemMessage -Value { if (-not $this.TestThreshold()) { $Target = [ValeReadability]::GetGradeLevel($this.Threshold + 1) return @( "SMOG grade level is $($this.GradeLevel)" "try to keep below $Target" ) -join ' - ' } } } ValeMetricsSMOG() : base() { $this.Rule = [ValeReadabilityRule]::SMOG $this.Threshold = 10 } ValeMetricsSMOG([int]$Score) : base() { $this.Rule = [ValeReadabilityRule]::SMOG $this.Threshold = 10 $this.Score = $Score $this.AddDynamicMembers() } ValeMetricsSMOG([ValeMetricsInfo]$Metrics) : base([ValeMetricsInfo]$Metrics) { $this.Rule = [ValeReadabilityRule]::SMOG $this.Threshold = 10 $this.Score = [ValeMetricsSMOG]::GetScore($Metrics) $this.AddDynamicMembers() } static [float] GetScore([ValeMetricsInfo]$Metrics) { $Result = 1.043 * [Math]::Sqrt($Metrics.ComplexWordCount * 30 / $Metrics.SentenceCount) $Result += 3.1291 return $Result } [string] ToString() { return @( "SMOG score for '$($this.Name)':" $this.Score "(Ages $($this.AgeRange), Grade Level $($this.GradeLevel))" ) -join ' ' } } class ValeRule { [string]$Style [string]$Name [string]$Path [hashtable]$Properties ValeRule([string]$Style, [string]$Name, [string]$Path) { $this.Style = $Style $this.Name = $Name $this.Path = $Path $this.Properties = $null } } class ValeStyle { [string]$Name [string]$Path [ValeRule[]]$Rules ValeStyle([string]$Name, [string]$Path) { $this.Name = $Name $this.Path = $Path $this.Rules = @() } } class ValeViolationAction { [string]$Name [string[]]$Parameters [string] ToString() { if ([string]::IsNullOrEmpty($this.Name)) { return '' } return "$($this.Name) ($($this.Parameters -join ', '))" } } class ValeViolationPosition { [System.IO.FileInfo]$FileInfo [int]$Line [int]$StartColumn [int]$EndColumn [string] ToString() { if ($null -ne $this.FileInfo) { return @( @( $this.FileInfo.FullName $this.Line $this.StartColumn ) -join ':' $this.EndColumn ) -join '-' } return @( @( $this.Line $this.StartColumn ) -join ':' $this.EndColumn ) -join '-' } [string] ToRelativeString() { if ($null -ne $this.FileInfo) { return @( @( (Resolve-Path -Path $this.FileInfo.FullName -Relative) $this.Line $this.StartColumn ) -join ':' $this.EndColumn ) -join '-' } return @( @( $this.Line $this.StartColumn ) -join ':' $this.EndColumn ) -join '-' } } class ValeViolationInfo { [ValeViolationPosition] $Position [string] $RuleName [ValeAlertLevel] $AlertLevel [string] $Message [string] $MatchingText [ValeViolationAction] $Action [string] $RuleLink [string] $Description ValeViolationInfo() {} ValeViolationInfo([hashtable]$Info) { $this.SetInfo($Info, $null) } ValeViolationInfo([hashtable]$Info, [System.IO.FileInfo]$File) { $this.SetInfo($Info, $File) } [void] SetInfo([hashtable]$Info, [System.IO.FileInfo]$File) { $this.Position = [ValeViolationPosition]@{ FileInfo = $File Line = $Info.Line StartColumn = $Info.Span[0] EndColumn = $Info.Span[1] } if ($null -ne $Info.Action) { $this.Action = [ValeViolationAction]@{ Name = $Info.Action.Name Parameters = $Info.Action.Params } } $this.Description = $Info.Description $this.MatchingText = $Info.Match $this.Message = $Info.Message $this.RuleLink = $Info.Link $this.RuleName = $Info.Check } } #endregion Classes.Public #region Functions.Public function Get-ProseMetric { [CmdletBinding()] [OutputType([ValeMetricsInfo])] param( [SupportsWildcards()] [Parameter(Mandatory, Position = 0)] [string[]]$Path, [switch]$Recurse ) begin { $MetricsParameters = @( 'ls-metrics' '--output', 'JSON' ) } process { Get-ChildItem -Path $Path -File -Recurse:$Recurse | ForEach-Object -Process { if ($_.Extension -ne '.md') { return } $Info = Invoke-Vale -ArgumentList ($MetricsParameters + $_.FullName) [ValeMetricsInfo]::new($Info, $_.FullName) } } } function Get-ProseReadability { [CmdletBinding(DefaultParameterSetName = 'ByRule')] [OutputType( [ValeReadability], [ValeMetricsAutomatedReadability], [ValeMetricsColemanLiau], [ValeMetricsFleschKincaid], [ValeMetricsFleschReadingEase], [ValeMetricsGunningFog], [ValeMetricsLIX], [ValeMetricsSMOG] )] param( [Parameter(Mandatory, Position = 0)] [SupportsWildcards()] [string[]]$Path, [Parameter(Position = 1, ParameterSetName = 'ByRule')] [ValeReadabilityRule[]]$ReadabilityRule = [ValeReadabilityRule]::AutomatedReadability, [Parameter(Position = 2, ParameterSetName = 'ByRule')] [float]$Threshold, [Parameter(ParameterSetName = 'Preset')] [ValidateSet('GradeLevels', 'Numericals')] [string]$Preset, [Parameter(ParameterSetName = 'AllRules')] [switch]$All, [switch]$ProblemsOnly, [switch]$Recurse ) begin { if ($ProblemsOnly) { $DecoratingType = 'ProblemMessage' } switch ($Preset) { 'GradeLevels' { $ReadabilityRule = [ValeReadability]::GetGradeLevelRules() } 'Numericals' { $ReadabilityRule = [ValeReadability]::GetNumericalRules() } default { if ($All) { $ReadabilityRule = [ValeReadabilityRule].GetEnumNames() } } } } process { Get-ProseMetric -Path $Path -Recurse:$Recurse | ForEach-Object { $Metrics = $_ if ($Metrics.WordCount -eq 0) { $Message = @( "Skipping readability analysis for '$($Metrics.FileInfo)'" 'Word count is 0.' ) -join ' - ' Write-Verbose $Message return } foreach ($Rule in $ReadabilityRule) { $Result = New-Object -TypeName "ValeMetrics$Rule" -ArgumentList $Metrics if ($Threshold -gt 0) { $Result.Threshold = $Threshold } if ($ProblemsOnly -and [string]::IsNullOrEmpty($Result.ProblemMessage)) { continue } if (-not [string]::IsNullOrEmpty($DecoratingType)) { $Result.psobject.TypeNames.Insert(0, "ValeReadability#$DecoratingType") } $Result } } } } function Get-Vale { [CmdletBinding()] [OutputType([ValeApplicationInfo])] param() process { Write-Verbose 'Checking for vale in workspace...' $Folder = Get-Location do { $ValeCommand = Get-Command "$Folder/.vale/vale" -ErrorAction Ignore $Folder = Split-Path -Path $Folder -Parent } until (($null -ne $ValeCommand) -or ([string]::IsNullOrEmpty($Folder))) if ($null -eq $ValeCommand) { Write-Verbose 'Vale not found in workspace. Checking user scope...' $ValeCommand = Get-Command '~/.vale/vale' -ErrorAction Ignore } if ($null -eq $ValeCommand) { Write-Verbose 'Vale not found in user scope. Checking PATH...' $ValeCommand = Get-Command -Name vale -ErrorAction Ignore } if ($null -eq $ValeCommand) { $Message = @( "Can't find vale installed in workspace, home directory, or PATH." 'You can use the Install-Vale command or use your package manager to install it.' ) -join ' ' throw $Message } return [ValeApplicationInfo]::new($ValeCommand) } } function Get-ValeConfiguration { [CmdletBinding()] [OutputType([ValeConfigurationEffective])] param( [string]$Path ) begin { $ConfigParameters = @( 'ls-config' '--output', 'JSON' ) } process { if ($Path) { $ConfigParameters += '--config', $Path } $ConfigurationJson = Invoke-Vale -ArgumentList $ConfigParameters return [ValeConfigurationEffective]::new($ConfigurationJson) } } function Get-ValeStyle { [CmdletBinding()] param( [Parameter(ParameterSetName = 'ByName', Position = 0)] [SupportsWildcards()] [string]$Name, [Parameter(ParameterSetName = 'ByName')] [string]$Configuration = ${env:VALE.INI} ) begin { $Styles = @() try { $ValeConfiguration = Get-ValeConfiguration -Path $Configuration if ($Name -eq '') { $Name = '*' } Get-ChildItem -Directory $ValeConfiguration.StylesPath -Exclude Vocab | Where-Object Name -Like $Name | ForEach-Object { $Styles += [ValeStyle]::new($_.Name, $_.FullName) } } catch { Write-Error -Message $_ } } process { foreach ($style in $Styles) { $rulenames = Get-ChildItem -Path $style.Path -Filter '*.yml' $rules = @() foreach ($rulename in $rulenames) { $rule = [ValeRule]::new($style.Name, $rulename.BaseName, $rulename.FullName) $rule.Properties = Get-Content -Path $rulename.FullName | ConvertFrom-Yaml -Ordered $rules += $rule } $style.Rules = $rules $style } } } function Install-Vale { [CmdletBinding()] [OutputType([System.IO.FileInfo])] param( [string]$Version = 'latest', [ValeInstallScope]$Scope = [ValeInstallScope]::Workspace, [switch]$PassThru ) begin { $ApiUrlBase = 'https://api.github.com/repos/errata-ai/vale/releases' $OSArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture $Architecture = switch ($OSArchitecture) { X64 { '64-bit' } Arm64 { 'arm64' } default { $Message = @( "No Vale release available for this CPU architecture ($OSArchitecture)." 'Vale is packaged for x64 and ARM64 only.' ) -join ' ' throw $Message } } $OS = if ($IsLinux) { 'Linux' } elseif ($IsMacOS) { 'macOS' } elseif ($Architecture -eq 'arm64') { throw 'Detected ARM64 architecture for Windows; Vale does not release for this platform.' } else { 'Windows' } $Extension = $OS -eq 'Windows' ? '.zip' : '.tar.gz' $PackageNamePattern = "vale_\d+\.\d+\.\d+_${OS}_${Architecture}${Extension}" $ChecksumNamePattern = '_checksums.txt$' $BaseInstallPath = if ($Scope -eq 'User') { Get-Item -Path ~ } else { Get-Location } $InstallFolderPath = Join-Path -Path $BaseInstallPath -ChildPath '.vale' $TempFolderPath = Join-Path -Path 'Temp:' -ChildPath (New-Guid).Guid $ArchiveFilePath = Join-Path -Path $TempFolderPath -ChildPath "vale${Extension}" } process { Write-Verbose "Detected Operating System: $OS" Write-Verbose "Detected CPU Architecture: $Architecture" $ApiUrl = if ($Version -eq 'latest') { Write-Verbose 'Checking for latest version...' $ApiUrlBase, $Version.ToLowerInvariant() | Join-String -Separator '/' } else { $ApiUrlBase, 'tags', $Version | Join-String -Separator '/' } try { $Release = Invoke-RestMethod -Uri $ApiUrl -Verbose:$false } catch { throw "Unable to retrieve vale from GitHub at version: $Version" } Write-Verbose "Downloading vale at version $($Release.name)..." $PackageAsset = $Release.Assets | Where-Object -Property name -Match $PackageNamePattern $ChecksumAsset = $Release.Assets | Where-Object -Property name -Match $ChecksumNamePattern $null = New-Item -Path $TempFolderPath -ItemType Directory Invoke-WebRequest -Uri $PackageAsset.browser_download_url -OutFile $ArchiveFilePath -Verbose:$false # Now that the archive exists, we need to grab the real path to it, as Temp:\ is a PSDrive. $ArchiveFilePath = Get-Item -Path $ArchiveFilePath | Select-Object -ExpandProperty FullName Write-Verbose 'Verifying package checksum...' $PackageChecksum = (Get-FileHash -Path $ArchiveFilePath).Hash.Trim() $ExpectedChecksum = Invoke-WebRequest -Uri $ChecksumAsset.browser_download_url -ContentType $ChecksumAsset.content_type -Verbose:$false | Select-Object -ExpandProperty RawContent | ForEach-Object { $ChecksumLine = ($_ -split '\r?\n') -match $PackageNamePattern $ChecksumLine -split '\s' | Select-Object -First 1 } Write-Verbose "Expected checksum: $ExpectedChecksum" Write-Verbose "Package checksum: $PackageChecksum" if ($ExpectedChecksum -ne $PackageChecksum) { throw "Downloaded package checksum '$PackageChecksum' did not match expected checksum '$ExpectedChecksum'" } if (Test-Path -Path $InstallFolderPath) { Write-Verbose "Overriding existing Vale install in '$InstallFolderPath'..." } else { $null = New-Item -ItemType Directory -Path $InstallFolderPath } Write-Verbose "Expanding archive '$ArchiveFilePath'..." if ($Extension -eq '.zip') { Expand-Archive -Path $ArchiveFilePath -DestinationPath $InstallFolderPath -Force } else { tar -xvf $ArchiveFilePath -C $InstallFolderPath } $InstalledBinary = Get-Item -Path "$InstallFolderPath\vale*" Write-Verbose "Installed vale to '$($InstalledBinary.FullName)'" Write-Verbose "Cleaning up temp folder '$TempFolderPath'" Remove-Item -Path $TempFolderPath -Recurse -Force if ($PassThru) { $InstalledBinary } } } function Invoke-Vale { [CmdletBinding()] [OutputType([hashtable])] [OutputType([String])] param( [parameter(Mandatory, ValueFromRemainingArguments)] [string[]]$ArgumentList ) begin { $PriorPreference = $PSNativeCommandUseErrorActionPreference $Vale = Get-Vale $Patterns = @{ SpecifiedConfigFileNotFound = "\[--config\] Runtime error\s+path '(?<ConfigPath>[^']+)' does not exist" DefaultConfigFileNotFound = 'vale\.ini not found' MissingStyleFolder = "The path '(?<StylePath>[^']+)' does not exist" InvalidFlag = 'unknown flag:\s(?<FlagName>.+)$' InvalidGlobalOption = 'is a syntax-specific option' } } process { if ($ArgumentList.Count -eq 0) { Write-Verbose 'No arguments specified for Vale, returning null' return $null } if ("$ArgumentList" -notmatch '--output json') { $ArgumentList += @('--output', 'JSON') } $PSNativeCommandUseErrorActionPreference = $false $RawResult = & $Vale @ArgumentList 2>&1 $PSNativeCommandUseErrorActionPreference = $PriorPreference $ValeErrors = $RawResult | Where-Object -FilterScript { $_ -is [System.Management.Automation.ErrorRecord] } | ForEach-Object -Process { $_.Exception.Message } | Join-String -Separator "`n" $ValeOutput = $RawResult | Where-Object -FilterScript { $_ -is [String] } | Join-String -Separator "`n" if ($ValeErrors) { try { $Result = $ValeErrors | ConvertFrom-Json -ErrorAction Stop switch ($Result.Code) { # E201 is the code for "well-known" error types from Vale 'E201' { if ($Result.Text -match $Patterns.MissingStyleFolder) { $StylePath = $Matches.StylePath $Message = @( "Vale styles not synced to '$StylePath'" "as expected by the configuration in '$($Result.Path)';" 'run Sync-Vale to download them.' ) -join ' ' $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.IO.DirectoryNotFoundException]$Message), 'Vale.StylePathNotFound', [System.Management.Automation.ErrorCategory]::ResourceUnavailable, ($ArgumentList -join ' ') ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } elseif ($Result.Text -match $Patterns.InvalidGlobalOption) { $Message = @( "Invalid value at '$($Result.Path):$($Result.Line):$($Result.Span)'." $Result.Text ) -join ' ' $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.Runtime.Serialization.SerializationException]$Message), 'Vale.InvalidConfigurationValue', [System.Management.Automation.ErrorCategory]::InvalidData, ($ArgumentList -join ' ') ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } else { $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.Exception]$Result.Text), 'Vale.UnhandledError', [System.Management.Automation.ErrorCategory]::FromStdErr, ($ArgumentList -join ' ') ) $PSCmdlet.WriteError($ErrorRecord) } } # E100 is the code for unexpected errors in Vale 'E100' { if ($Result.Text -match $Patterns.SpecifiedConfigFileNotFound) { $Message = @( 'Specified Vale configuration file' "'$($Matches.ConfigPath)'" "doesn't exist." ) -join ' ' $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.IO.FileNotFoundException]$Message), 'Vale.ConfigurationFileNotFound', [System.Management.Automation.ErrorCategory]::ResourceUnavailable, ($ArgumentList -join ' ') ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } if ($Result.Text -match $Patterns.DefaultConfigFileNotFound) { $Message = @( 'Default Vale configuration file' "'.vale.ini' or '_vale.ini' not found in the current directory, parent directories," "or '$HOME' folder." 'Make sure you have a configuration file in one of these locations.' ) -join ' ' $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.IO.FileNotFoundException]$Message), 'Vale.ConfigurationFileNotFound', [System.Management.Automation.ErrorCategory]::ResourceUnavailable, ($ArgumentList -join ' ') ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.Exception]$Result.Text), "Vale.$($Result.Code)", [System.Management.Automation.ErrorCategory]::FromStdErr, ($ArgumentList -join ' ') ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } # Indicates there was a completely unhandled error. default { $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.Exception]$Result.Text), 'Vale.UnhandledError', [System.Management.Automation.ErrorCategory]::FromStdErr, ($ArgumentList -join ' ') ) $PSCmdlet.WriteError($ErrorRecord) } } } catch [System.ArgumentException] { # If we're in this catch, it's because there were non-JSON errors. # These can only be checked by parsing the stderr strings. if ($ValeErrors -match $Patterns.InvalidFlag) { $FlagName = $Matches.FlagName $Message = "Invalid flag '$FlagName' passed to Vale." $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.ArgumentException]$Message), 'Vale.InvalidFlag', [System.Management.Automation.ErrorCategory]::InvalidArgument, ($ArgumentList -join ' ') ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.Exception]$ValeErrors), 'Vale.UnhandledError', [System.Management.Automation.ErrorCategory]::FromStdErr, ($ArgumentList -join ' ') ) $PSCmdlet.WriteError($ErrorRecord) } } # At this point, any errors have been handled and we can try converting the # stdout from json to object. If this fails, it's because Vale hasn't # implemented a JSON output for the command or subcommand. try { $Result = $ValeOutput | ConvertFrom-Json -Depth 99 -AsHashtable } catch [System.ArgumentException] { # Version is a basic string, even with '--output JSON' if ($ArgumentList -contains '-v' -or $ArgumentList -contains '--version') { return $ValeOutput } # Help is a basic string, even with '--output JSON' if ($ArgumentList -contains '-h' -or $ArgumentList -contains '--help') { return $ValeOutput } # Sync doesn't return any stdout, only progress that we can't capture # usefully anyway. This check is naive, but should work as long as there # is no other bare-argument 'sync' included. if ('sync' -in $ArgumentList) { return '' } # If something else happened, we need to throw an error on invalid result $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.Exception]$_), 'Vale.InvalidResult', [System.Management.Automation.ErrorCategory]::InvalidResult, ($ArgumentList -join ' ') ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } return $Result } } function New-ValeConfiguration { [cmdletbinding()] [OutputType([System.IO.FileSystemInfo])] param( [Parameter(Position = 0)] [string]$FilePath = './.vale.ini', [string]$StylesPath, [ValeAlertLevel]$MinimumAlertLevel, [ValeStylePackageTransform()] [string[]] $StylePackage, [switch]$Force, [switch]$PassThru, [switch]$NoSpelling, [switch]$NoSync ) begin { # The configuration needs to be ordered and the root section must be defined first. # The implementation for PsIni skips writing a section when adding sectionless keys, # so if they come after the '*' section, the configuration file is malformed. $Configuration = New-Object -TypeName System.Collections.Specialized.OrderedDictionary $Configuration.Add('_', @{ StylesPath = 'styles' MinAlertLevel = 'suggestion' Packages = @() } ) $Configuration.Add('*', @{ BasedOnStyles = @() } ) $ConfigExists = Test-Path -Path $FilePath } process { if ($ConfigExists -and -not $Force) { $ResolvedPath = Resolve-Path $FilePath $Message = "Specified Vale configuration file already exists at '$ResolvedPath'." $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.ArgumentException]$Message), 'Vale.ConfigurationFileAlreadyExists', [System.Management.Automation.ErrorCategory]::InvalidArgument, $FilePath ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } if (![string]::IsNullOrEmpty($StylesPath)) { $Configuration._.StylesPath = $StylesPath } if ($null -ne $MinimumAlertLevel) { $Configuration._.MinAlertLevel = $MinimumAlertLevel.ToString().ToLowerInvariant() } if (!$NoSpelling) { $Configuration['*'].BasedOnStyles += 'Vale' } elseif ($StylePackage.Count -eq 0) { $Message = @( 'Must specify at least one style for the configuration when specifying NoSpelling.' 'Run the command again with at least one package specified for StylePackage' 'or without NoSpelling.' ) -join ' ' $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.ArgumentException]$Message), 'Vale.NoStylePackagesSpecified', [System.Management.Automation.ErrorCategory]::InvalidArgument, $PSBoundParameters ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } foreach ($Package in $StylePackage) { # Skip duplicates if ($Package -in $Configuration._.Packages) { Write-Warning "Skipping duplicate package '$Package'" continue } # For built-in packages, the package name and style name are the same. $Style = $Package # .zip packages are local or remote styles not built into Vale if ($Package -match '.zip$') { # Split for the last path segment, trim the '.zip' from the end. # Definitionally, vale style packages must have the same name # as their zip file. $Style = (Split-Path $Package -Leaf) | ForEach-Object { $_.Substring(0, $_.Length - 4) } } $Configuration._.Packages += $Package $Configuration['*'].BasedOnStyles += $Style } # These need to convert to comma-separated strings for Vale to be happy; # PsIni adds arrays as repeated entries by default. $Configuration._.Packages = $Configuration._.Packages -join ', ' $Configuration['*'].BasedOnStyles = $Configuration['*'].BasedOnStyles -join ', ' try { $OutputParameters = @{ InputObject = $Configuration FilePath = $FilePath Force = $Force Passthru = $PassThru ErrorAction = 'Stop' } Out-IniFile @OutputParameters if (!$NoSync) { Sync-Vale -Path $FilePath } } catch { $PSCmdlet.ThrowTerminatingError($_) } } } function Sync-Vale { [CmdletBinding()] param( [Parameter(Position = 0)] [string]$Path ) begin { $SyncParameters = @( 'sync' ) } process { if (![string]::IsNullOrEmpty($Path)) { $SyncParameters += @('--config', $Path) } $null = Invoke-Vale -ArgumentList $SyncParameters } } function Test-Prose { [CmdletBinding()] [OutputType([ValeViolationInfo])] param( [string[]]$Path, [string]$ConfigurationPath, [ValeAlertLevel]$MinimumAlertLevel ) begin { $TestParameters = @( '--output', 'JSON' ) if ($ConfigurationPath) { $TestParameters += '--config', $ConfigurationPath } } process { foreach ($TestPath in $Path) { $Result = Invoke-Vale -ArgumentList @($TestParameters + $TestPath) foreach ($FilePath in $Result.Keys) { $FileInfo = Get-Item -Path $FilePath $Result.$FilePath | ForEach-Object { [ValeViolationInfo]::new($_, $FileInfo) } } } } } #endregion Functions.Public $ExportableFunctions = @( 'Get-ProseMetric' 'Get-ProseReadability' 'Get-Vale' 'Get-ValeConfiguration' 'Get-ValeStyle' 'Install-Vale' 'Invoke-Vale' 'New-ValeConfiguration' 'Sync-Vale' 'Test-Prose' ) Export-ModuleMember -Alias * -Function $ExportableFunctions |