src/KeepAChangelog.ManifestReleaseNotes.ps1

Set-StrictMode -Version Latest

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

    $bytes = [System.IO.File]::ReadAllBytes($Path)
    $encoding = [System.Text.UTF8Encoding]::new($false, $true)
    $byteOrderMarkLength = 0

    if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
        $encoding = [System.Text.UTF8Encoding]::new($false, $true)
        $byteOrderMarkLength = 3
    }
    elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
        $encoding = [System.Text.UnicodeEncoding]::new($false, $false, $true)
        $byteOrderMarkLength = 2
    }
    elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
        $encoding = [System.Text.UnicodeEncoding]::new($true, $false, $true)
        $byteOrderMarkLength = 2
    }

    try {
        $content = $encoding.GetString($bytes, $byteOrderMarkLength, $bytes.Length - $byteOrderMarkLength)
    }
    catch [System.Text.DecoderFallbackException] {
        throw "Could not decode manifest using a supported encoding: $Path"
    }

    [byte[]] $byteOrderMark = [byte[]]::new(0)
    if ($byteOrderMarkLength -gt 0) {
        $byteOrderMark = [byte[]] $bytes[0..($byteOrderMarkLength - 1)]
    }

    [pscustomobject]@{
        Content = $content
        Encoding = $encoding
        ByteOrderMark = $byteOrderMark
    }
}

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

    $match = [System.Text.RegularExpressions.Regex]::Match($Content, "`r`n|`n|`r")
    if ($match.Success) {
        return $match.Value
    }

    $script:KeepAChangelogNewLine
}

function Write-KeepAChangelogManifestFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Content,

        [Parameter(Mandatory)]
        [System.Text.Encoding] $Encoding,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [byte[]] $ByteOrderMark
    )

    $contentBytes = $Encoding.GetBytes($Content)
    $fileBytes = [byte[]]::new($ByteOrderMark.Length + $contentBytes.Length)
    if ($ByteOrderMark.Length -gt 0) {
        [System.Buffer]::BlockCopy($ByteOrderMark, 0, $fileBytes, 0, $ByteOrderMark.Length)
    }
    [System.Buffer]::BlockCopy($contentBytes, 0, $fileBytes, $ByteOrderMark.Length, $contentBytes.Length)
    [System.IO.File]::WriteAllBytes($Path, $fileBytes)
}

function Assert-KeepAChangelogManifestReleaseNotesValue {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.Language.ScriptBlockAst] $ManifestAst,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $ReleaseNotes,

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

    $rootHashtable = $ManifestAst.Find(
        { param($ast) $ast -is [System.Management.Automation.Language.HashtableAst] },
        $false
    )
    $pathResolution = if ($rootHashtable -is [System.Management.Automation.Language.HashtableAst]) {
        Resolve-KeepAChangelogManifestReleaseNotesPath -HashtableAst $rootHashtable
    }
    if ($null -eq $pathResolution -or $null -eq $pathResolution.ValueAst) {
        throw "Could not validate PrivateData.PSData.ReleaseNotes in updated manifest: $Path"
    }

    try {
        $validatedReleaseNotes = $pathResolution.ValueAst.SafeGetValue()
    }
    catch {
        throw "Could not evaluate PrivateData.PSData.ReleaseNotes in updated manifest: $Path. $($_.Exception.Message)"
    }
    if ($validatedReleaseNotes -isnot [string] -or $validatedReleaseNotes -cne $ReleaseNotes) {
        throw "Updated manifest ReleaseNotes did not match the requested value: $Path"
    }
}

function Resolve-KeepAChangelogManifestReleaseNotesPath {
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.Language.HashtableAst] $HashtableAst,

        [int] $Depth = 0
    )

    $path = @('PrivateData', 'PSData', 'ReleaseNotes')
    foreach ($pair in $HashtableAst.KeyValuePairs) {
        if (
            $pair.Item1 -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
            $pair.Item1.Value -eq $path[$Depth] -and
            $pair.Item2.PipelineElements.Count -eq 1 -and
            $pair.Item2.PipelineElements[0] -is [System.Management.Automation.Language.CommandExpressionAst]
        ) {
            $valueAst = $pair.Item2.PipelineElements[0].Expression
            if ($Depth -eq 2) {
                return [pscustomobject]@{
                    HashtableAst = $HashtableAst
                    ValueAst = $valueAst
                    MissingDepth = 0
                }
            }
            if ($valueAst -isnot [System.Management.Automation.Language.HashtableAst]) {
                return $null
            }

            return Resolve-KeepAChangelogManifestReleaseNotesPath `
                -HashtableAst $valueAst `
                -Depth ($Depth + 1)
        }
    }

    [pscustomobject]@{
        HashtableAst = $HashtableAst
        ValueAst = $null
        MissingDepth = 3 - $Depth
    }
}

function Get-KeepAChangelogManifestReleaseNotesTarget {
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Content
    )

    $tokens = $null
    $parseErrors = $null
    $manifestAst = [System.Management.Automation.Language.Parser]::ParseInput(
        $Content,
        [ref] $tokens,
        [ref] $parseErrors
    )
    if ($parseErrors.Count -gt 0) {
        throw "Manifest contains syntax errors: $($parseErrors[0].Message)"
    }
    $rootHashtable = $manifestAst.Find(
        { param($ast) $ast -is [System.Management.Automation.Language.HashtableAst] },
        $false
    )
    if ($rootHashtable -isnot [System.Management.Automation.Language.HashtableAst]) {
        return $null
    }

    $pathResolution = Resolve-KeepAChangelogManifestReleaseNotesPath -HashtableAst $rootHashtable
    if ($null -eq $pathResolution) {
        return $null
    }

    $target = $pathResolution.ValueAst
    if ($null -eq $target -and $pathResolution.MissingDepth -eq 1) {
        $releaseNotesComments = @($tokens | Where-Object {
                $_.Kind -eq [System.Management.Automation.Language.TokenKind]::Comment -and
                $_.Extent.StartOffset -ge $pathResolution.HashtableAst.Extent.StartOffset -and
                $_.Extent.EndOffset -le $pathResolution.HashtableAst.Extent.EndOffset -and
                $_.Text -match '^#\s*ReleaseNotes\s*='
            })
        if ($releaseNotesComments.Count -gt 1) {
            return $null
        }
        if ($releaseNotesComments.Count -eq 1) {
            $target = $releaseNotesComments[0]
        }
    }

    $missingDepth = if ($null -ne $target) { 0 } else { $pathResolution.MissingDepth }
    [pscustomobject]@{
        HashtableAst = $pathResolution.HashtableAst
        MissingDepth = $missingDepth
        Target = $target
    }
}

function Get-KeepAChangelogIndentAtOffset {
    param(
        [Parameter(Mandatory)]
        [string] $Content,

        [Parameter(Mandatory)]
        [int] $Offset
    )

    $lineStart = $Content.LastIndexOf("`n", [Math]::Max(0, $Offset - 1)) + 1
    $linePrefix = $Content.Substring($lineStart, $Offset - $lineStart)
    [regex]::Match($linePrefix, '^[ \t]*').Value
}

function New-KeepAChangelogMissingReleaseNotesContent {
    param(
        [Parameter(Mandatory)]
        [int] $MissingDepth,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $ReleaseNotes,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $HashtableIndent,

        [Parameter(Mandatory)]
        [string] $IndentUnit,

        [Parameter(Mandatory)]
        [string] $NewLine
    )

    $valueIndent = $HashtableIndent + $IndentUnit
    for ($depth = 1; $depth -lt $MissingDepth; $depth++) {
        $valueIndent += $IndentUnit
    }
    $content = @("${valueIndent}ReleaseNotes = @'", $ReleaseNotes, "'@") -join $NewLine

    if ($MissingDepth -ge 2) {
        $valueIndent = $valueIndent.Substring(0, $valueIndent.Length - $IndentUnit.Length)
        $content = @("${valueIndent}PSData = @{", $content, "$valueIndent}") -join $NewLine
    }
    if ($MissingDepth -eq 3) {
        $valueIndent = $valueIndent.Substring(0, $valueIndent.Length - $IndentUnit.Length)
        $content = @("${valueIndent}PrivateData = @{", $content, "$valueIndent}") -join $NewLine
    }

    $content
}

function Get-KeepAChangelogManifestReleaseNotesEdit {
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Content,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $ReleaseNotes,

        [Parameter(Mandatory)]
        [string] $NewLine
    )

    $targetInfo = Get-KeepAChangelogManifestReleaseNotesTarget -Content $Content
    if ($null -eq $targetInfo) {
        return $null
    }
    if ($null -ne $targetInfo.Target) {
        $replacementStart = if ($targetInfo.Target -is [System.Management.Automation.Language.Token]) {
            "ReleaseNotes = @'"
        }
        else {
            "@'"
        }
        return [pscustomobject]@{
            StartOffset = $targetInfo.Target.Extent.StartOffset
            EndOffset = $targetInfo.Target.Extent.EndOffset
            Replacement = @($replacementStart, $ReleaseNotes, "'@") -join $NewLine
        }
    }

    $hashtableAst = $targetInfo.HashtableAst
    $hashtableIndent = Get-KeepAChangelogIndentAtOffset -Content $Content -Offset $hashtableAst.Extent.StartOffset
    $indentUnit = ' '
    if ($hashtableAst.KeyValuePairs.Count -gt 0) {
        $firstKeyIndent = Get-KeepAChangelogIndentAtOffset `
            -Content $Content `
            -Offset $hashtableAst.KeyValuePairs[0].Item1.Extent.StartOffset
        if ($firstKeyIndent.StartsWith($hashtableIndent) -and $firstKeyIndent.Length -gt $hashtableIndent.Length) {
            $indentUnit = $firstKeyIndent.Substring($hashtableIndent.Length)
        }
    }
    $pathContent = New-KeepAChangelogMissingReleaseNotesContent `
        -MissingDepth $targetInfo.MissingDepth `
        -ReleaseNotes $ReleaseNotes `
        -HashtableIndent $hashtableIndent `
        -IndentUnit $indentUnit `
        -NewLine $NewLine

    $closingBraceOffset = $hashtableAst.Extent.EndOffset - 1
    $closingLineStart = $Content.LastIndexOf("`n", [Math]::Max(0, $closingBraceOffset - 1)) + 1
    $closingLinePrefix = $Content.Substring($closingLineStart, $closingBraceOffset - $closingLineStart)
    if ($closingLineStart -gt $hashtableAst.Extent.StartOffset -and $closingLinePrefix -match '^[ \t]*$') {
        $startOffset = $closingLineStart
        $replacement = $pathContent + $NewLine
    }
    else {
        $startOffset = $closingBraceOffset
        $replacement = $NewLine + $pathContent + $NewLine + $hashtableIndent
    }

    [pscustomobject]@{
        StartOffset = $startOffset
        EndOffset = $startOffset
        Replacement = $replacement
    }
}

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

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

        [Parameter()]
        [ValidateRange(1, 20)]
        [int] $RecentCount = 3,

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

    $sections = Read-KeepAChangelogSections -Path $Path
    $startIndex = -1
    for ($index = 0; $index -lt $sections.Count; $index++) {
        if ($sections[$index].Version -eq $Version) {
            $startIndex = $index
            break
        }
    }

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

    $newLine = $script:KeepAChangelogNewLine
    $selectedSections = $sections | Select-Object -Skip $startIndex -First $RecentCount
    $sectionTexts = foreach ($section in $selectedSections) {
        @(
            $section.Heading
            ''
            $section.Body
        ) -join $newLine
    }

    (@(
        ($sectionTexts -join ($newLine + $newLine))
        ''
        "Full CHANGELOG: $FullChangelogUrl"
    ) -join $newLine).TrimEnd("`r", "`n")
}

function Set-KeepAChangelogManifestReleaseNotes {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $ManifestPath,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $ReleaseNotes
    )

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

    $manifestFile = Read-KeepAChangelogManifestFile -Path $ManifestPath
    $content = $manifestFile.Content
    $newLine = Get-KeepAChangelogManifestNewLine -Content $content
    $normalizedReleaseNotes = ($ReleaseNotes -replace "`r?`n", $newLine).TrimEnd("`r", "`n")
    $edit = Get-KeepAChangelogManifestReleaseNotesEdit `
        -Content $content `
        -ReleaseNotes $normalizedReleaseNotes `
        -NewLine $newLine
    if ($null -eq $edit) {
        throw "Could not resolve PrivateData.PSData.ReleaseNotes in manifest: $ManifestPath"
    }

    $updatedContent = $content.Substring(0, $edit.StartOffset) + $edit.Replacement + $content.Substring($edit.EndOffset)
    $parseTokens = $null
    $parseErrors = $null
    $updatedAst = [System.Management.Automation.Language.Parser]::ParseInput(
        $updatedContent,
        [ref] $parseTokens,
        [ref] $parseErrors
    )
    if ($parseErrors.Count -gt 0) {
        throw "Could not create a valid manifest from ReleaseNotes: $ManifestPath. $($parseErrors[0].Message)"
    }
    Assert-KeepAChangelogManifestReleaseNotesValue `
        -ManifestAst $updatedAst `
        -ReleaseNotes $normalizedReleaseNotes `
        -Path $ManifestPath

    if ($PSCmdlet.ShouldProcess($ManifestPath, 'Update manifest ReleaseNotes')) {
        Write-KeepAChangelogManifestFile `
            -Path $ManifestPath `
            -Content $updatedContent `
            -Encoding $manifestFile.Encoding `
            -ByteOrderMark $manifestFile.ByteOrderMark
    }
}