Public/Get-DiffAlignment.ps1

#Requires -Version 5.1

function Get-DiffAlignment {
    <#
    .SYNOPSIS
        変更前と変更後の行を対応付け、行ごとの差分の種類を返す。
    .DESCRIPTION
        Myers 法で共通行を確定し、隣接する削除行と追加行のうち、類似度が SimilarityThreshold 以上の組を
        1 行の変更(Changed)として対応付けます。
    .PARAMETER BeforeLines
        変更前の行の配列。空の配列と空行を含められます。
    .PARAMETER AfterLines
        変更後の行の配列。空の配列と空行を含められます。
    .PARAMETER SimilarityThreshold
        削除行と追加行を Changed として対応付ける類似度の下限(0.0〜1.0)。
        既定の 0.5 は、行の半分以上が共通なら同じ行の変更とみなすことを表します。
    .OUTPUTS
        TextDiff.DiffRow。次の項目を持つ [PSCustomObject] を、変更前・変更後の行の順序で返します。
            Kind = 'Same' | 'Changed' | 'Deleted' | 'Added'
            LeftNo = 変更前の行番号(1 始まり)。Added のときは $null
            RightNo = 変更後の行番号(1 始まり)。Deleted のときは $null
            Left = 変更前の行の内容。Added のときは $null
            Right = 変更後の行の内容。Deleted のときは $null
        ConvertTo-DiffText と ConvertTo-DiffHtml は、この型のものだけを受け付けます。
    .EXAMPLE
        PS> $rows = Get-DiffAlignment -BeforeLines @('id bigint,', 'name varchar(100),') -AfterLines @('id bigint,', 'name varchar(20),')
        PS> $rows | ForEach-Object -Process { $_.Kind }
        Same
        Changed

        2 行目は同じ行の変更として、1 つの Changed になります。
    #>

    [CmdletBinding()]
    [OutputType('TextDiff.DiffRow')]
    param(
        # NOTE: AllowEmptyString が必須。Mandatory な [string[]] は、空文字を含む配列を
        # 「引数が空の文字列である」として拒否する。比較する行には空行が含まれる。
        [Parameter(Mandatory)] [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$BeforeLines,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$AfterLines,
        [ValidateRange(0.0, 1.0)] [double]$SimilarityThreshold = 0.5
    )

    # Compare-Object ではなく Myers 法を使うのは、行の順序を保って比較するため。Compare-Object は集合の比較で、
    # どの行がどの行に変更されたかを示せない。
    # 動的計画法(O(N×M))ではなく Myers 法(O((N+M)×D))にするのは、変更が少ないほど速いため。
    # 1000 行のうち 5 行の変更で、動的計画法の 2,801 ミリ秒に対して 14 ミリ秒だった(Windows PowerShell 5.1 での実測)。
    # 共通の先頭・末尾を除去してから動的計画法を実行する方法も試したが、変更行が全体に散らばる場合は
    # ほとんど除去できず、効果が無かった。
    $ids = ConvertTo-LineId -Left $BeforeLines -Right $AfterLines
    # 空のリストは $null で返り、StrictMode では $ops.Count が例外になる。
    $ops = @(Get-MyersOperation -Left $ids.Left -Right $ids.Right)

    $result = [System.Collections.Generic.List[object]]::new()
    $index = 0
    while ($index -lt $ops.Count) {
        $op = $ops[$index]

        if ($op.Kind -ceq 'Same') {
            $result.Add([PSCustomObject]@{
                    PSTypeName = 'TextDiff.DiffRow'
                    Kind       = 'Same'
                    LeftNo     = $op.LeftIndex + 1
                    RightNo    = $op.RightIndex + 1
                    Left       = $BeforeLines[$op.LeftIndex]
                    Right      = $AfterLines[$op.RightIndex]
                })
            $index++
            continue
        }

        # 連続する削除と追加を 1 つのブロックとして抽出する。Myers 法の出力では削除が追加より先に出現するとは
        # 限らないため、ブロック内で分類してから対応付ける。
        $deleted = [System.Collections.Generic.List[int]]::new()
        $added = [System.Collections.Generic.List[int]]::new()
        while ($index -lt $ops.Count -and $ops[$index].Kind -cne 'Same') {
            if ($ops[$index].Kind -ceq 'Deleted') { $deleted.Add($ops[$index].LeftIndex) }
            else { $added.Add($ops[$index].RightIndex) }
            $index++
        }

        # ブロック内で、削除行と追加行を先頭から順に 1 対 1 で比較する。
        # 隣接しているだけで対応付けると、別々の変更(列の削除と別の列の追加など)を 1 行の変更と誤認し、
        # 行内の強調が行のほぼ全体に付いて読めなくなる。そのため類似度で判定する。
        $pairCount = [Math]::Min($deleted.Count, $added.Count)
        $usedLeft = New-Object -TypeName 'bool[]' -ArgumentList $deleted.Count
        $usedRight = New-Object -TypeName 'bool[]' -ArgumentList $added.Count
        $pending = [System.Collections.Generic.List[hashtable]]::new()

        for ($pairIndex = 0; $pairIndex -lt $pairCount; $pairIndex++) {
            $leftIndex = $deleted[$pairIndex]
            $rightIndex = $added[$pairIndex]
            $similarity = Get-LineSimilarity -Left $BeforeLines[$leftIndex] -Right $AfterLines[$rightIndex]
            if ($similarity -ge $SimilarityThreshold) {
                $usedLeft[$pairIndex] = $true
                $usedRight[$pairIndex] = $true
                $pending.Add(@{
                        Kind    = 'Changed'
                        LeftNo  = $leftIndex + 1
                        RightNo = $rightIndex + 1
                        Left    = $BeforeLines[$leftIndex]
                        Right   = $AfterLines[$rightIndex]
                        Order   = $pairIndex
                    })
            }
        }

        # 対応付けなかった行を、元の順序のまま並べる。
        # 削除行を追加行より先に配置するのは、diff の慣習(- が先、+ が後)に合わせるため。
        for ($deletedIndex = 0; $deletedIndex -lt $deleted.Count; $deletedIndex++) {
            if ($usedLeft[$deletedIndex]) { continue }
            $pending.Add(@{
                    Kind    = 'Deleted'
                    LeftNo  = $deleted[$deletedIndex] + 1
                    RightNo = $null
                    Left    = $BeforeLines[$deleted[$deletedIndex]]
                    Right   = $null
                    Order   = $deletedIndex
                })
        }
        for ($addedIndex = 0; $addedIndex -lt $added.Count; $addedIndex++) {
            if ($usedRight[$addedIndex]) { continue }
            $pending.Add(@{
                    Kind    = 'Added'
                    LeftNo  = $null
                    RightNo = $added[$addedIndex] + 1
                    Left    = $null
                    Right   = $AfterLines[$added[$addedIndex]]
                    Order   = $addedIndex + 0.5
                })
        }

        # 並べ替え用の Order は、戻り値に含めない。
        foreach ($entry in ($pending | Sort-Object -CaseSensitive -Property { $_.Order })) {
            $result.Add([PSCustomObject]@{
                    PSTypeName = 'TextDiff.DiffRow'
                    Kind       = $entry.Kind
                    LeftNo     = $entry.LeftNo
                    RightNo    = $entry.RightNo
                    Left       = $entry.Left
                    Right      = $entry.Right
                })
        }
    }

    return $result.ToArray()
}