src/AnalyzerRule.ps1

# The editor rule (#163): what the gate would say about one file, as findings a PSScriptAnalyzer
# custom rule hands to an editor. rules/PSComplexity.Rules.psm1 is the rule itself, and stays thin;
# everything it decides is here, under the same coverage and mutation gates as the rest.
#
# The same scoring and the same verdict as Test-PSComplexity, so the editor and the gate cannot
# disagree: the config the file sits under (#164), or 15 and 15; its per-path ceilings and excludes;
# acceptances in the config and in the code (#165); units held by the config's baseline.
#
# What it does NOT do is refuse. The gate throws on a stale acceptance or baseline entry, because a
# run cannot vouch for a policy that has stopped describing it; an editor showing one file has not
# seen the run, so it applies what it can and leaves the refusals to the gate.

function Get-PSCxRuleFinding {
    # The units of one script the gate would fail, each with the message and the extent to show:
    # [pscustomobject]@{ Unit; Message; Extent }. Nothing for a script that does not parse --
    # PSScriptAnalyzer reports that itself -- or for a file the config excludes.
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        # The whole script, as PSScriptAnalyzer passes it. Its text is measured, not the file on
        # disk, so an unsaved edit is judged as it stands. Untyped, because a type named in the
        # code of src/ reads to tests/Vocabulary.Tests.ps1 as a construct the metrics score.
        [Parameter(Mandatory)] $Ast
    )
    $file = [string]$Ast.Extent.File
    $tokens = $null
    $errors = $null
    $parsed = [System.Management.Automation.Language.Parser]::ParseInput($Ast.Extent.Text, $file, [ref]$tokens, [ref]$errors)
    if ($errors.Count -gt 0) { return }
    $root = Get-PSCxWorkingRoot
    $pathComparison = Get-PSCxRunPathComparison -Root $root
    $policy = Get-PSCxRulePolicy -File $file -Root $root -PathComparison $pathComparison
    if ($file -and -not (Test-PSCxSelectedFile -File $file -Root $root -PathComparison $pathComparison `
                -Exclude $policy.Exclude -Excluded ([System.Collections.Generic.List[string]]::new()))) { return }
    # An unsaved buffer has no path, so its units are filed under a name no file can have and no
    # acceptance or baseline entry can match.
    $relative = $file ? (Get-PSCxRelativePath -Path $file -Root $root -PathComparison $pathComparison) : '<untitled>'
    Clear-PSCxAstCache
    $scan = Get-PSCxParsedScan -Ast $parsed -Token @($tokens) -File $relative
    $units = @($scan.Units)
    $fileCeiling = Get-PSCxFileCeiling -Unit $units -Override $policy.Overrides -MaxCyclomatic $policy.MaxCyclomatic `
        -MaxCognitive $policy.MaxCognitive -Root $root -PathComparison $pathComparison
    $map = Get-PSCxRuleBaseline -Path $policy.BaselineFile -PathComparison $pathComparison
    $violations = @(Get-PSCxUnacceptedUnit -Unit $units -Accept (@($policy.Accept) + @($scan.InlineAccept)) `
            -MaxCyclomatic $policy.MaxCyclomatic -MaxCognitive $policy.MaxCognitive -BaselineMap $map `
            -PathComparison $pathComparison -FileCeiling $fileCeiling)
    foreach ($v in $violations) {
        $ceiling = Get-PSCxUnitCeiling -File $v.File -MaxCyclomatic $policy.MaxCyclomatic -MaxCognitive $policy.MaxCognitive -FileCeiling $fileCeiling
        [pscustomobject]@{
            Unit    = $v.Unit
            Message = ('{0} is over the complexity ceiling: cyclomatic {1} (max {2}), cognitive {3} (max {4}).' -f
                $v.Unit, $v.Cyclomatic, $ceiling.Cyclomatic, $v.Cognitive, $ceiling.Cognitive)
            Extent  = Get-PSCxRuleExtent -Ast $Ast -Line $v.Line
        }
    }
}

function Get-PSCxRulePolicy {
    # The policy one file is judged by: the config it sits under, read as the gate reads it with
    # nothing on the command line, or the defaults. An unsaved buffer has no path, so no config.
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyString()] [string]$File,
        [Parameter(Mandatory)] [string]$Root,
        [Parameter(Mandatory)] [System.StringComparison]$PathComparison
    )
    $config = $null
    $found = $File ? (Find-PSCxConfigFile -Path $File -Root $Root) : $null
    if ($found) { $config = Read-PSCxConfig -Path $found -Root $Root -PathComparison $PathComparison }
    return Get-PSCxGatePolicy -Config $config -Bound @{}
}

function Get-PSCxRuleBaseline {
    # The config's baseline as a lookup, or an empty one: no baseline, a file not there yet, or one
    # recorded by another metric version, whose numbers mean something else. The entries are not
    # checked against a run -- the editor has not seen one -- so a stale entry stays the gate's to
    # refuse.
    [OutputType([hashtable])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyString()] [string]$Path,
        [Parameter(Mandatory)] [System.StringComparison]$PathComparison
    )
    if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return @{} }
    $document = Read-PSCxDocument -Path $Path
    if (Get-PSCxBaselineDocumentFault -Document $document -MetricVersion (Get-PSCxMetricVersion) `
            -SchemaVersion (Get-PSCxBaselineSchemaVersion)) { return @{} }
    return Get-PSCxBaselineMap -Entry @($document.units) -PathComparison $PathComparison
}

function Get-PSCxRuleExtent {
    # Where an editor marks a unit: the first line of its declaration, not the whole body -- a
    # squiggle under two hundred lines says nothing the first line does not. The script body's
    # first line when no declaration starts on the line.
    [OutputType([System.Management.Automation.Language.ScriptExtent])]
    [CmdletBinding()]
    param(
        # The whole script; untyped for the reason Get-PSCxRuleFinding gives.
        [Parameter(Mandatory)] $Ast,
        [Parameter(Mandatory)] [int]$Line
    )
    $text = ($Ast.Extent.Text -split '\r?\n')[$Line - 1]
    $start = [System.Management.Automation.Language.ScriptPosition]::new($Ast.Extent.File, $Line, 1, $text)
    $end = [System.Management.Automation.Language.ScriptPosition]::new($Ast.Extent.File, $Line, $text.Length + 1, $text)
    return [System.Management.Automation.Language.ScriptExtent]::new($start, $end)
}