src/InlineAccept.ps1

# Acceptances written in the code (#165): a line comment directly above a unit's declaration,
# carrying the argument for letting that unit exceed a ceiling.
#
# The SAME checked declaration as -Accept, not a suppression comment: the gate puts these through
# the acceptance checks -List and config entries face -- a reason is required, and an acceptance
# whose unit is back within its ceilings fails the run. What it adds is that the argument sits
# next to the code it excuses and moves with it, which a File+Unit list cannot.
#
# Whole units only. A score belongs to a unit, so excusing one construct would mean passing a unit
# on a number nobody measured.
#
# Pure: it reads the text and the tokens one parse already produced, and the unit table.

# Anywhere in the text; each hit is then checked against the tokens, so a mention inside a string
# or a here-string is not a declaration. Case-insensitive, as PowerShell is about its own keywords.
$script:PSCxInlineAcceptPattern = [regex]::new('#[ \t]*PSCx-Accept[ \t]*:([^\r\n]*)',
    [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::CultureInvariant)

function Get-PSCxTokenIndexAt {
    # The index of the token starting exactly at Offset, or -1. Tokens arrive in source order, so
    # a binary search over their offsets -- .NET's, not one written here, whose fencepost
    # variants all give the same answers and so cannot be told apart by any test.
    #
    # The offsets are read per call, about 40ms on a 120,000-token file. That is paid once per
    # declaration, and a file without one never gets here.
    [OutputType([int])]
    [CmdletBinding()]
    param(
        # Never empty: the parser always ends the list with an EndOfInput token.
        [Parameter(Mandatory)] [object[]]$Token,
        [Parameter(Mandatory)] [int]$Offset
    )
    $offsets = [int[]]@($Token.Extent.StartOffset)
    $found = [System.Array]::BinarySearch($offsets, $Offset)
    return ($found -ge 0) ? $found : -1
}

function Get-PSCxInlineTarget {
    # The line of the code a declaration comment stands directly above, or why it stands above
    # none: [pscustomobject]@{ Line; Fault }, one of the two set.
    #
    # Directly above means through the comment lines that follow it -- a help block written as
    # line comments keeps working -- with no blank line, and no code before it on its own line.
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Token,
        [Parameter(Mandatory)] [int]$Index
    )
    $kind = [System.Management.Automation.Language.TokenKind]
    if ($Index -gt 0 -and $Token[$Index - 1].Kind -ne $kind::NewLine) {
        return [pscustomobject]@{ Line = $null; Fault = 'it shares its line with code -- put it on its own line, directly above the unit' }
    }
    $newLines = 0
    for ($i = $Index + 1; $i -lt $Token.Count; $i++) {
        $t = $Token[$i]
        if ($t.Kind -eq $kind::NewLine) {
            $newLines++
            # Two line breaks with nothing between them: a blank line.
            if ($newLines -gt 1) { return [pscustomobject]@{ Line = $null; Fault = 'a blank line separates it from the code below' } }
            continue
        }
        $newLines = 0
        if ($t.Kind -eq $kind::Comment) { continue }
        if ($t.Kind -eq $kind::EndOfInput) { break }
        return [pscustomobject]@{ Line = $t.Extent.StartLineNumber; Fault = $null }
    }
    return [pscustomobject]@{ Line = $null; Fault = 'no code follows it' }
}

function Get-PSCxInlineAccept {
    # The acceptances one file declares in its code, and the declarations that excuse nothing.
    #
    # A declaration that excuses nothing is RETURNED, never dropped: the gate refuses it, because a
    # reason that has drifted away from its function -- a blank line inserted, the function moved
    # -- would otherwise sit there believing it excuses something.
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Token,
        # Unit key -> the line its declaration starts on, as Get-PSCxUnitTable gives it.
        [Parameter(Mandatory)] [AllowEmptyCollection()] [hashtable]$UnitLine,
        # Unit key -> the name a record carries.
        [Parameter(Mandatory)] [AllowEmptyCollection()] [hashtable]$Display,
        # The file as a record names it.
        [Parameter(Mandatory)] [AllowEmptyString()] [string]$File
    )
    $accept = [System.Collections.Generic.List[object]]::new()
    $stray = [System.Collections.Generic.List[object]]::new()
    # Declaration line -> the units starting on it. The script body is in it at line 1, where no
    # declaration can point -- one always stands ABOVE its target -- so it cannot be excused this
    # way, and -Accept is how it is.
    $byLine = @{}
    foreach ($key in $UnitLine.Keys) {
        $at = $UnitLine[$key]
        if (-not $byLine.ContainsKey($at)) { $byLine[$at] = @() }
        $byLine[$at] += $key
    }
    $claimed = @{}
    foreach ($match in $script:PSCxInlineAcceptPattern.Matches($Text)) {
        $index = Get-PSCxTokenIndexAt -Token $Token -Offset $match.Index
        # Not the start of a token: the text sits inside a string, or further into a comment that
        # is about something else. Neither declares anything. A token that DOES start at a `#` is
        # a comment, because nothing else in PowerShell can, so the kind needs no second look.
        if ($index -lt 0) { continue }
        $line = $Token[$index].Extent.StartLineNumber
        $target = Get-PSCxInlineTarget -Token $Token -Index $index
        $fault = Get-PSCxInlineFault -Target $target -ByLine $byLine -Claimed $claimed
        if ($fault) {
            $stray.Add([pscustomobject]@{ File = $File; Line = $line; Fault = $fault })
            continue
        }
        $key = $byLine[$target.Line][0]
        $claimed[$key] = $line
        $accept.Add([pscustomobject]@{ File = $File; Unit = [string]$Display[$key]; Reason = $match.Groups[1].Value.Trim(); Line = $line; Source = 'inline' })
    }
    return [pscustomobject]@{ Accept = @($accept); Stray = @($stray) }
}

function Get-PSCxInlineFault {
    # Why a declaration excuses no unit, as text, or nothing when it names exactly one.
    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Target,
        # Declaration line -> the unit keys starting on it.
        [Parameter(Mandatory)] [AllowEmptyCollection()] [hashtable]$ByLine,
        # Unit key -> the line of the declaration already accepting it.
        [Parameter(Mandatory)] [AllowEmptyCollection()] [hashtable]$Claimed
    )
    if ($Target.Fault) { return $Target.Fault }
    # Filtered, because @() over a missing key is an array of one $null, not an empty one.
    $keys = @($ByLine[$Target.Line] | Where-Object { $null -ne $_ })
    if ($keys.Count -eq 0) { return "the code below it, on line $($Target.Line), starts no function, method or constructor" }
    # Two units starting on one line -- `function A { } function B { }` -- cannot be told apart by
    # where a comment sits, so the declaration names neither rather than guessing.
    if ($keys.Count -gt 1) { return "$($keys.Count) units start on line $($Target.Line), so it cannot say which one it excuses" }
    if ($Claimed.ContainsKey($keys[0])) { return "the unit below it is already accepted by the declaration on line $($Claimed[$keys[0]])" }
}