src/PSMutation.Inline.ps1

# Equivalent-mutant declarations written in the code (#262): a line comment directly above the
# line holding the mutant, naming the mutation and carrying the argument.
#
# The SAME checked declaration as a config entry, and deliberately nothing more: each becomes an
# ordinary `File:Line:Description` key, so every rule a config declaration faces applies to it
# unchanged -- it must match exactly one mutant, and a mutant the suite kills makes it stale. What
# it adds is that the argument sits beside the code and moves with it, where a line-keyed config
# entry goes stale on any edit above it.

# Anywhere in the text; each hit is then checked against the tokens, so a mention inside a string
# or further into a comment is not a declaration.
$script:PSMutationInlinePattern = [regex]::new('#[ \t]*PSMutant-Equivalent[ \t]*:([^\r\n]*)',
    [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::CultureInvariant)

# `<original> -> <mutated>: <reason>`. The description ends at the first ': ' AFTER the arrow, so
# an original holding a colon -- a drive, a scope, a label -- is read whole. A mutated side holding
# ': ' would be cut short, and the key it produced would then match no mutant, which the run
# refuses by name rather than guessing.
$script:PSMutationInlineBody = [regex]::new('^\s*(?<description>.+? -> .*?):\s+(?<reason>.*)$')

function Get-PSMutationTokenIndexAt {
    # The index of the token starting exactly at Offset, or -1, by a binary search over the
    # offsets -- .NET's, so there is no hand-written fencepost to get wrong. Paid once per
    # declaration; 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-PSMutationInlineTarget {
    # The line of the code a declaration stands directly above, or why it stands above none:
    # [pscustomobject]@{ Line; Fault }, one of the two set. Directly above means through any
    # comment lines that follow it, with no blank line and no code before it on its own line.
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [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 mutated line' }
    }
    $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-PSMutationInlineDeclaration {
    # The declarations one file makes in its code, as config keys, and the ones that cannot be.
    # Pure: the text and the tokens one parse produced.
    #
    # A declaration that cannot become a key is RETURNED, never dropped: the run refuses it,
    # because an argument that drifted away from its line -- a blank line inserted, the line moved
    # -- would otherwise sit there believing it excuses a mutant.
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text,
        # Empty for a file that could not be read. A missing mutate file is refused later, by the
        # sandbox check that owns that fault; here it simply declares nothing.
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Token,
        # The file as the config's mutate list spells it, which is how a key names it.
        [Parameter(Mandatory)] [string]$File
    )
    $declared = [System.Collections.Generic.List[object]]::new()
    $faults = [System.Collections.Generic.List[string]]::new()
    foreach ($match in $script:PSMutationInlinePattern.Matches($Text)) {
        $index = Get-PSMutationTokenIndexAt -Token $Token -Offset $match.Index
        # Not the start of a token: the text is inside a string, or further into a comment about
        # something else. A token that DOES start at a `#` is a comment -- nothing else can.
        if ($index -lt 0) { continue }
        $line = $Token[$index].Extent.StartLineNumber
        $fault = Get-PSMutationInlineFault -Body $match.Groups[1].Value -Target (Get-PSMutationInlineTarget -Token $Token -Index $index)
        if ($fault) {
            $faults.Add("${File}:$line declares an equivalent mutant, but $fault")
            continue
        }
        $body = $script:PSMutationInlineBody.Match($match.Groups[1].Value)
        $target = (Get-PSMutationInlineTarget -Token $Token -Index $index).Line
        $declared.Add([pscustomobject]@{
                Key    = "${File}:${target}:$($body.Groups['description'].Value.Trim())"
                Reason = $body.Groups['reason'].Value.Trim()
                File   = $File
                Line   = $line
            })
    }
    return [pscustomobject]@{ Declared = @($declared); Faults = @($faults) }
}

function Get-PSMutationInlineFault {
    # Why one declaration cannot become a key, as text, or nothing when it can.
    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyString()] [string]$Body,
        [Parameter(Mandatory)] $Target
    )
    if ($Target.Fault) { return $Target.Fault }
    $parsed = $script:PSMutationInlineBody.Match($Body)
    if (-not $parsed.Success) {
        return "'$($Body.Trim())' is not '<original> -> <mutated>: <reason>' -- it has to name the mutation it excuses, because a line can hold several"
    }
    if (-not $parsed.Groups['reason'].Value.Trim()) { return 'it gives no reason -- an equivalence is an argument, or it is a mute button' }
}

function Get-PSMutationAllEquivalent {
    # The config's equivalents with every declaration the mutated files make in their code added,
    # as one object of the shape the config holds -- so everything downstream reads one set.
    #
    # Refused, all faults at once: a declaration that cannot become a key, and an inline key that
    # repeats a config key. A mutant declared in both places under DIFFERENT forms -- the config's
    # function-keyed entry and this line-keyed one -- is caught later, where each mutant's keys
    # are known (Get-PSMutationDeclarationCoverageFault).
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Cfg,
        [Parameter(Mandatory)] [string]$SourceRoot
    )
    $all = [ordered]@{}
    # No guard for a config without equivalents: its PSObject is $null there, and a loop over
    # nothing adds nothing.
    foreach ($p in $Cfg.equivalents.PSObject.Properties) { $all[$p.Name] = $p.Value }
    $faults = [System.Collections.Generic.List[string]]::new()
    foreach ($file in @($Cfg.mutate)) {
        $tokens = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $SourceRoot $file), [ref]$tokens, [ref]$null)
        $found = Get-PSMutationInlineDeclaration -Text $ast.Extent.Text -Token @($tokens) -File $file
        $faults.AddRange([string[]]$found.Faults)
        foreach ($d in $found.Declared) {
            if ($all.Contains($d.Key)) {
                $faults.Add("$($d.File):$($d.Line) declares $($d.Key) equivalent, and so does the config -- keep one argument")
                continue
            }
            $all[$d.Key] = $d.Reason
        }
    }
    if ($faults.Count -gt 0) {
        throw ('The equivalent-mutant declarations in the code cannot be used: ' + ($faults -join '; ') + '.')
    }
    return [pscustomobject]$all
}