Utils/cmp.ps1

Register-GnuUtil @{
    Name          = 'cmp'
    Category      = 'Searching and comparing'
    Synopsis      = 'Compare two files byte by byte.'
    Usage         = @('cmp [OPTION]... FILE1 [FILE2 [SKIP1 [SKIP2]]]')
    Description   = @(
        'Compare two files byte by byte.',
        '',
        'The optional SKIP1 and SKIP2 specify the number of bytes to skip',
        'at the beginning of each file (zero by default).'
    )
    FileOperands  = $true
    UsageExitCode = 2
    UsageHintHasName = $true
    Options       = @(
        @{ Short = 'b'; Long = 'print-bytes'; Help = 'print differing bytes' }
        @{ Short = 'i'; Long = 'ignore-initial'; Arg = 'SKIP'; Help = 'skip first SKIP bytes of both inputs; or SKIP1:SKIP2 for each input' }
        @{ Short = 'l'; Long = 'verbose'; Help = 'output byte numbers and differing byte values' }
        @{ Short = 'n'; Long = 'bytes'; Arg = 'LIMIT'; Help = 'compare at most LIMIT bytes' }
        @{ Short = 's'; Long = 'quiet'; Key = 'quiet'; Help = 'suppress all normal output' }
        @{ Long = 'silent'; Key = 'quiet'; Help = 'suppress all normal output' }
    )
    Notes         = @(
        'SKIP values may be followed by the following multiplicative suffixes:',
        'kB 1000, K 1024, MB 1,000,000, M 1,048,576,',
        'GB 1,000,000,000, G 1,073,741,824, and so on for T, P, E, Z, Y.',
        '',
        'If a FILE is ''-'' or missing, read standard input.',
        'Exit status is 0 if inputs are the same, 1 if different, 2 if trouble.'
    )
}



function script:ConvertTo-GnuCmpCharacter {
    <#
    .SYNOPSIS
    A byte as cat -v shows it (^A, M-x, M-^?).
    #>

    param([Parameter(Mandatory)][int]$Byte)
    $prefix = ''
    if ($Byte -ge 128) { $prefix = 'M-'; $Byte -= 128 }
    if ($Byte -lt 32) { return "$prefix^$([char]($Byte + 64))" }
    if ($Byte -eq 127) { return "$prefix^?" }
    "$prefix$([char]$Byte)"
}

function script:ConvertFrom-GnuCmpSkip {
    <#
    .SYNOPSIS
    A SKIP or LIMIT value (decimal, 0x hex, size suffixes), or $null if invalid.
    #>

    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)
    if ($Text -match '^0[xX]([0-9a-fA-F]+)$') { return [System.Convert]::ToInt64($Matches[1], 16) }
    $value = ConvertFrom-GnuSize -Text $Text
    if ($null -eq $value) { return $null }
    ConvertTo-GnuInt64 -Value $value
}

function script:Invoke-GnuCmpCompare {
    <#
    .SYNOPSIS
    Implementation of cmp.
    #>

    param([Parameter(Mandatory)][hashtable]$Context)

    $verbose = Test-GnuOption -Context $Context -Key 'verbose'
    $quiet = Test-GnuOption -Context $Context -Key 'quiet'
    $printBytes = Test-GnuOption -Context $Context -Key 'print-bytes'
    if ($verbose -and $quiet) { Write-GnuUsageError -Context $Context -Message 'options -l and -s are incompatible'; return }
    $operands = @($Context.Operands)
    if ($operands.Count -eq 0) { Write-GnuUsageError -Context $Context -Message "missing operand after 'cmp'"; return }

    $skips = @([long]0, [long]0)
    if (Test-GnuOption -Context $Context -Key 'ignore-initial') {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'ignore-initial')
        $parts = @($text.Split(':'))
        if ($parts.Count -gt 2) { $parts = @('') }
        for ($k = 0; $k -lt 2; $k++) {
            $value = ConvertFrom-GnuCmpSkip -Text $parts[[Math]::Min($k, $parts.Count - 1)]
            if ($null -eq $value) { Write-GnuUsageError -Context $Context -Message "invalid --ignore-initial value '$text'"; return }
            $skips[$k] = $value
        }
    }
    for ($k = 2; $k -lt [Math]::Min(4, $operands.Count); $k++) {
        $value = ConvertFrom-GnuCmpSkip -Text $operands[$k]
        if ($null -eq $value) { Write-GnuUsageError -Context $Context -Message "invalid --ignore-initial value '$($operands[$k])'"; return }
        $skips[$k - 2] = $value
    }
    if ($operands.Count -gt 4) { Write-GnuUsageError -Context $Context -Message "extra operand '$($operands[4])'"; return }
    $limit = [long]::MaxValue
    if (Test-GnuOption -Context $Context -Key 'bytes') {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'bytes')
        $value = ConvertFrom-GnuCmpSkip -Text $text
        if ($null -eq $value) { Write-GnuUsageError -Context $Context -Message "invalid --bytes value '$text'"; return }
        $limit = $value
    }

    $names = @($operands[0], '-')
    if ($operands.Count -ge 2) { $names[1] = $operands[1] }
    $data = @()
    foreach ($name in $names) {
        $bytes = Get-GnuInputBytes -Context $Context -Operand $name
        if ($null -eq $bytes) { $Context.ExitCode = 2; return }
        $data += , ([byte[]]$bytes)
    }
    $starts = @([Math]::Min($skips[0], [long]$data[0].Length), [Math]::Min($skips[1], [long]$data[1].Length))
    $lengths = @(($data[0].Length - $starts[0]), ($data[1].Length - $starts[1]))
    $common = [long][Math]::Min([Math]::Min($lengths[0], $lengths[1]), $limit)
    $offsetWidth = ([string][Math]::Min([Math]::Min($lengths[0], $lengths[1]), $limit)).Length

    $line = 1
    $different = $false
    for ($i = [long]0; $i -lt $common; $i++) {
        $a = $data[0][$starts[0] + $i]
        $b = $data[1][$starts[1] + $i]
        if ($a -ne $b) {
            $different = $true
            if ($quiet) { break }
            if (-not $verbose) {
                $message = "$($names[0]) $($names[1]) differ: "
                if ($printBytes) {
                    $message += "byte $($i + 1), line $line is " + ('{0,3} {1} {2,3} {3}' -f [System.Convert]::ToString([int]$a, 8), (ConvertTo-GnuCmpCharacter -Byte $a), [System.Convert]::ToString([int]$b, 8), (ConvertTo-GnuCmpCharacter -Byte $b))
                }
                else { $message += "char $($i + 1), line $line" }
                $message
                break
            }
            $entry = ([string]($i + 1)).PadLeft($offsetWidth) + ' ' + [System.Convert]::ToString([int]$a, 8).PadLeft(3)
            if ($printBytes) { $entry += ' ' + (ConvertTo-GnuCmpCharacter -Byte $a).PadRight(4) }
            $entry += ' ' + [System.Convert]::ToString([int]$b, 8).PadLeft(3)
            if ($printBytes) { $entry += ' ' + (ConvertTo-GnuCmpCharacter -Byte $b) }
            $entry
        }
        if ($a -eq 10) { $line++ }
    }

    if ($different) { $Context.ExitCode = 1 }
    if ((-not $different -or $verbose) -and $lengths[0] -ne $lengths[1] -and $common -lt $limit) {
        # One input ended first.
        $short = 0
        if ($lengths[1] -lt $lengths[0]) { $short = 1 }
        $Context.ExitCode = 1
        if ($quiet) { return }
        if ($common -eq 0) { Write-GnuError -Context $Context -Message "EOF on '$($names[$short])' which is empty"; return }
        $where = ''
        if (-not $verbose) {
            $lastByte = $data[$short][$starts[$short] + $common - 1]
            if ($lastByte -eq 10) { $where = ", line $($line - 1)" } else { $where = ", in line $line" }
        }
        Write-GnuError -Context $Context -Message "EOF on '$($names[$short])' after byte $common$where"
    }
}