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
    Google
    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