src/KeepAChangelog.Core.ps1

Set-StrictMode -Version Latest

function Resolve-KeepAChangelogPath {
    [CmdletBinding()]
    [OutputType([string])]
    param()

    Join-Path (Get-Location) 'CHANGELOG.md'
}

function Remove-KeepAChangelogFooter {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Content
    )

    $separatorMatches = [System.Text.RegularExpressions.Regex]::Matches($Content, '(?m)^---[ \t]*\r?$')
    if ($separatorMatches.Count -eq 0) {
        return $Content
    }

    $separatorMatch = $separatorMatches[$separatorMatches.Count - 1]
    $footerText = $Content.Substring($separatorMatch.Index + $separatorMatch.Length).TrimStart("`r", "`n")
    $hasLinkDefinition = $false

    foreach ($line in ($footerText -split '\r?\n')) {
        if ([string]::IsNullOrWhiteSpace($line)) {
            continue
        }
        if ($line -notmatch '^\[[^\]\r\n]+\]:[ \t]*\S.*$') {
            return $Content
        }

        $hasLinkDefinition = $true
    }

    if ($hasLinkDefinition) {
        $Content.Substring(0, $separatorMatch.Index)
    }
    else {
        $Content
    }
}

function Assert-KeepAChangelogFooterSeparator {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Content
    )

    $lines = $Content -split '\r?\n'
    $hasLinkDefinition = $false

    for ($index = $lines.Count - 1; $index -ge 0; $index--) {
        $line = $lines[$index]
        if ([string]::IsNullOrWhiteSpace($line)) {
            continue
        }

        $linkDefinitionMatch = [System.Text.RegularExpressions.Regex]::Match(
            $line,
            '^\[[^\]\r\n]+\]:[ \t]*\S.*$'
        )
        if (-not $linkDefinitionMatch.Success) {
            if ($line -match '^---[ \t]*$') {
                return
            }

            break
        }

        $hasLinkDefinition = $true
    }

    if ($hasLinkDefinition) {
        throw 'Changelog footer link definitions require a preceding --- separator.'
    }
}

function Get-KeepAChangelogSectionHeaders {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Content
    )

    $linePattern = '(?m)^(?<Text>[^\r\n]*)(?<NewLine>\r?\n|$)'
    $headerPattern = '^## \[(?<Name>[^\]]+)\](?: - .+)?$'
    $fencePattern = '^ {0,3}(?<Marker>`{3,}|~{3,})(?<Tail>.*)$'
    $headers = [System.Collections.Generic.List[object]]::new()
    $versionLines = @{}
    $fenceMarker = $null
    $fenceLength = 0
    $fenceStartLine = 0
    $lineNumber = 0

    foreach ($lineMatch in [System.Text.RegularExpressions.Regex]::Matches($Content, $linePattern)) {
        $lineNumber++
        $line = $lineMatch.Groups['Text'].Value
        $fenceMatch = [System.Text.RegularExpressions.Regex]::Match($line, $fencePattern)

        if ($null -ne $fenceMarker) {
            $marker = $fenceMatch.Groups['Marker'].Value
            $tail = $fenceMatch.Groups['Tail'].Value
            if (
                $fenceMatch.Success -and
                $marker[0] -eq $fenceMarker -and
                $marker.Length -ge $fenceLength -and
                $tail.Trim([char[]] " `t").Length -eq 0
            ) {
                $fenceMarker = $null
            }

            continue
        }

        if ($fenceMatch.Success) {
            $marker = $fenceMatch.Groups['Marker'].Value
            $fenceMarker = $marker[0]
            $fenceLength = $marker.Length
            $fenceStartLine = $lineNumber
            continue
        }

        $headerMatch = [System.Text.RegularExpressions.Regex]::Match($line, $headerPattern)
        if ($headerMatch.Success) {
            $version = $headerMatch.Groups['Name'].Value
            if ($versionLines.ContainsKey($version)) {
                throw "Duplicate changelog version '$version' at lines $($versionLines[$version]) and $lineNumber."
            }
            $versionLines[$version] = $lineNumber

            $headerLength = $line.Length
            if ($lineMatch.Groups['NewLine'].Value.StartsWith("`r")) {
                $headerLength++
            }

            $headers.Add([pscustomobject]@{
                    Index = $lineMatch.Index
                    Length = $headerLength
                    Version = $version
                    Heading = $line
                })
        }
    }

    if ($null -ne $fenceMarker) {
        throw "Unclosed changelog code fence starting at line $fenceStartLine."
    }

    $headers.ToArray()
}

function Read-KeepAChangelogSections {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Path
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "Changelog not found: $Path"
    }

    $rawContent = Get-Content -LiteralPath $Path -Raw
    Assert-KeepAChangelogFooterSeparator -Content $rawContent
    $content = Remove-KeepAChangelogFooter -Content $rawContent
    $headers = @(Get-KeepAChangelogSectionHeaders -Content $content)
    $sections = [System.Collections.Generic.List[object]]::new()

    for ($index = 0; $index -lt $headers.Count; $index++) {
        $header = $headers[$index]
        $bodyStartIndex = $header.Index + $header.Length
        $bodyEndIndex = $content.Length

        if ($index + 1 -lt $headers.Count) {
            $bodyEndIndex = $headers[$index + 1].Index
        }

        $rawBody = $content.Substring($bodyStartIndex, $bodyEndIndex - $bodyStartIndex).TrimStart("`r", "`n")

        $sections.Add([pscustomobject]@{
                Version = $header.Version
                Heading = $header.Heading
                Body = $rawBody.TrimEnd("`r", "`n")
            })
    }

    $sections.ToArray()
}

function Find-KeepAChangelogSection {
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Version
    )

    $section = Read-KeepAChangelogSections -Path $Path |
        Where-Object { $_.Version -eq $Version } |
        Select-Object -First 1

    if (-not $section) {
        throw "Changelog entry not found for version: $Version"
    }

    $section
}

function ConvertFrom-ReleaseTagToVersion {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $ReleaseTag
    )

    $normalizedTag = $ReleaseTag -replace '^refs/tags/', ''
    $match = [System.Text.RegularExpressions.Regex]::Match($normalizedTag, '^v(?<Version>.+)$')
    if (-not $match.Success) {
        throw "Release tag must use the form v<version>: $ReleaseTag"
    }

    $match.Groups['Version'].Value
}

function Get-KeepAChangelogSection {
    [CmdletBinding(DefaultParameterSetName = 'List')]
    [OutputType([object[]])]
    [OutputType([object])]
    param(
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $Path = (Resolve-KeepAChangelogPath),

        [Parameter(Mandatory, ParameterSetName = 'ByVersion')]
        [ValidateNotNullOrEmpty()]
        [string] $Version
    )

    if ($PSCmdlet.ParameterSetName -eq 'List') {
        Read-KeepAChangelogSections -Path $Path
    }
    else {
        Find-KeepAChangelogSection -Path $Path -Version $Version
    }
}

function Get-KeepAChangelogEntry {
    [CmdletBinding(DefaultParameterSetName = 'ByVersion')]
    [OutputType([string])]
    param(
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $Path = (Resolve-KeepAChangelogPath),

        [Parameter(Mandatory, ParameterSetName = 'ByVersion')]
        [ValidateNotNullOrEmpty()]
        [string] $Version,

        [Parameter(Mandatory, ParameterSetName = 'ByReleaseTag')]
        [ValidateNotNullOrEmpty()]
        [string] $ReleaseTag
    )

    $resolvedVersion = if ($PSCmdlet.ParameterSetName -eq 'ByReleaseTag') {
        ConvertFrom-ReleaseTagToVersion -ReleaseTag $ReleaseTag
    }
    else {
        $Version
    }

    (Find-KeepAChangelogSection -Path $Path -Version $resolvedVersion).Body
}

function Assert-KeepAChangelogReleaseMetadata {
    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $Path = (Resolve-KeepAChangelogPath),

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Version,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $ReleaseTag
    )

    Find-KeepAChangelogSection -Path $Path -Version $Version | Out-Null

    if ($PSBoundParameters.ContainsKey('ReleaseTag')) {
        $tagVersion = ConvertFrom-ReleaseTagToVersion -ReleaseTag $ReleaseTag
        if ($tagVersion -ne $Version) {
            throw "Release tag version does not match manifest version. Tag: $tagVersion, Manifest: $Version"
        }
    }
}