Utils/diff.ps1

Register-GnuUtil @{
    Name          = 'diff'
    Category      = 'Searching and comparing'
    Synopsis      = 'Compare files line by line.'
    Usage         = @('diff [OPTION]... FILES')
    Description   = @('Compare FILES line by line.')
    UsageExitCode = 2
    UsageHintHasName = $true
    FileOperands  = $true
    Options       = @(
        @{ Long = 'normal'; Help = 'output a normal diff (the default)' }
        @{ Short = 'q'; Long = 'brief'; Help = 'report only when files differ' }
        @{ Short = 's'; Long = 'report-identical-files'; Help = 'report when two files are the same' }
        @{ Short = 'c'; Help = 'output 3 lines of copied context' }
        @{ Short = 'C'; Key = 'context'; Arg = 'NUM'; Help = 'output NUM lines of copied context' }
        @{ Long = 'context'; Key = 'context'; Arg = 'NUM'; OptionalArg = $true; Help = 'output NUM (default 3) lines of copied context' }
        @{ Short = 'u'; Help = 'output 3 lines of unified context' }
        @{ Short = 'U'; Key = 'unified'; Arg = 'NUM'; Help = 'output NUM lines of unified context' }
        @{ Long = 'unified'; Key = 'unified'; Arg = 'NUM'; OptionalArg = $true; Help = 'output NUM (default 3) lines of unified context' }
        @{ Short = 'e'; Long = 'ed'; Help = 'output an ed script' }
        @{ Short = 'p'; Long = 'show-c-function'; Help = 'show which C function each change is in' }
        @{ Short = 'F'; Long = 'show-function-line'; Arg = 'RE'; Help = 'show the most recent line matching RE' }
        @{ Long = 'label'; Short = 'L'; Arg = 'LABEL'; Multi = $true; Help = 'use LABEL instead of file name and timestamp (can be repeated)' }
        @{ Short = 't'; Long = 'expand-tabs'; Help = 'expand tabs to spaces in output' }
        @{ Short = 'T'; Long = 'initial-tab'; Help = 'make tabs line up by prepending a tab' }
        @{ Long = 'tabsize'; Arg = 'NUM'; Help = 'tab stops every NUM (default 8) print columns' }
        @{ Short = 'r'; Long = 'recursive'; Help = 'recursively compare any subdirectories found' }
        @{ Short = 'N'; Long = 'new-file'; Help = 'treat absent files as empty' }
        @{ Long = 'unidirectional-new-file'; Help = 'treat absent first files as empty' }
        @{ Short = 'x'; Long = 'exclude'; Arg = 'PAT'; Multi = $true; Help = 'exclude files that match PAT' }
        @{ Short = 'X'; Long = 'exclude-from'; Arg = 'FILE'; Multi = $true; Help = 'exclude files that match any pattern in FILE' }
        @{ Short = 'i'; Long = 'ignore-case'; Help = 'ignore case differences in file contents' }
        @{ Short = 'E'; Long = 'ignore-tab-expansion'; Help = 'ignore changes due to tab expansion' }
        @{ Short = 'Z'; Long = 'ignore-trailing-space'; Help = 'ignore white space at line end' }
        @{ Short = 'b'; Long = 'ignore-space-change'; Help = 'ignore changes in the amount of white space' }
        @{ Short = 'w'; Long = 'ignore-all-space'; Help = 'ignore all white space' }
        @{ Short = 'B'; Long = 'ignore-blank-lines'; Help = 'ignore changes where lines are all blank' }
        @{ Long = 'strip-trailing-cr'; Help = 'strip trailing carriage return on input' }
        @{ Short = 'a'; Long = 'text'; Help = 'treat all files as text' }
        @{ Short = 'd'; Long = 'minimal'; Help = 'try hard to find a smaller set of changes' }
        @{ Long = 'speed-large-files'; Help = 'accepted; has no effect' }
        @{ Long = 'horizon-lines'; Arg = 'NUM'; Help = 'keep NUM lines of the common prefix and suffix' }
    )
    Notes         = @(
        'FILES are ''FILE1 FILE2'' or ''DIR1 DIR2'' or ''DIR FILE'' or ''FILE DIR''.',
        'If a FILE is ''-'', read standard input.',
        'Exit status is 0 if inputs are the same, 1 if different, 2 if trouble.',
        '',
        'PowerShell notes: -y (side by side), -I, -S, --from-file and format options are not supported.',
        'File names in directories are compared in ordinal order. Timestamps have 100 ns resolution.'
    )
}



function script:Remove-GnuDiffConfusingLine {
    <#
    .SYNOPSIS
    Port of GNU's discard_confusing_lines: marks lines that cannot match, and which only confuse the search, as changed.
    #>

    param([Parameter(Mandatory)][hashtable[]]$Files, [Parameter(Mandatory)][int]$ClassCount)

    $counts = @((New-Object int[] ($ClassCount + 1)), (New-Object int[] ($ClassCount + 1)))
    foreach ($f in 0, 1) { foreach ($class in $Files[$f].Equivs) { $counts[$f][$class]++ } }

    $discards = @((New-Object byte[] ($Files[0].Equivs.Length + 1)), (New-Object byte[] ($Files[1].Equivs.Length + 1)))
    foreach ($f in 0, 1) {
        $equivs = $Files[$f].Equivs
        $other = $counts[1 - $f]
        $flags = $discards[$f]
        $many = 5
        $tem = [int][Math]::Floor($equivs.Length / 64)
        while (($tem = $tem -shr 2) -gt 0) { $many *= 2 }
        for ($i = 0; $i -lt $equivs.Length; $i++) {
            $matches2 = $other[$equivs[$i]]
            if ($matches2 -eq 0) { $flags[$i] = 1 } elseif ($matches2 -gt $many) { $flags[$i] = 2 }
        }
    }

    foreach ($f in 0, 1) {
        $flags = $discards[$f]
        $end = $Files[$f].Equivs.Length
        for ($i = 0; $i -lt $end; $i++) {
            if ($flags[$i] -eq 2) { $flags[$i] = 0; continue }
            if ($flags[$i] -eq 0) { continue }
            # A nonprovisional discard starts a run of discardable lines.
            $provisional = 0
            $j = $i
            while ($j -lt $end -and $flags[$j] -ne 0) {
                if ($flags[$j] -eq 2) { $provisional++ }
                $j++
            }
            while ($j -gt $i -and $flags[$j - 1] -eq 2) {
                $j--
                $flags[$j] = 0
                $provisional--
            }
            $length = $j - $i
            if ($provisional * 4 -gt $length) {
                while ($j -gt $i) {
                    $j--
                    if ($flags[$j] -eq 2) { $flags[$j] = 0 }
                }
                continue
            }
            $minimum = 1
            $tem = $length -shr 2
            while (($tem = $tem -shr 2) -gt 0) { $minimum = $minimum -shl 1 }
            $minimum++

            $consec = 0
            for ($j = 0; $j -lt $length; $j++) {
                if ($flags[$i + $j] -ne 2) { $consec = 0; continue }
                $consec++
                if ($minimum -eq $consec) { $j -= $consec }
                elseif ($minimum -lt $consec) { $flags[$i + $j] = 0 }
            }
            $consec = 0
            for ($j = 0; $j -lt $length; $j++) {
                if ($j -ge 8 -and $flags[$i + $j] -eq 1) { break }
                if ($flags[$i + $j] -eq 2) { $consec = 0; $flags[$i + $j] = 0 }
                elseif ($flags[$i + $j] -eq 0) { $consec = 0 }
                else { $consec++ }
                if ($consec -eq 3) { break }
            }
            $i += $length - 1
            $consec = 0
            for ($j = 0; $j -lt $length; $j++) {
                if ($j -ge 8 -and $flags[$i - $j] -eq 1) { break }
                if ($flags[$i - $j] -eq 2) { $consec = 0; $flags[$i - $j] = 0 }
                elseif ($flags[$i - $j] -eq 0) { $consec = 0 }
                else { $consec++ }
                if ($consec -eq 3) { break }
            }
        }
    }

    foreach ($f in 0, 1) {
        $kept = New-Object 'System.Collections.Generic.List[int]'
        $real = New-Object 'System.Collections.Generic.List[int]'
        for ($i = 0; $i -lt $Files[$f].Equivs.Length; $i++) {
            if ($discards[$f][$i] -eq 0) {
                $kept.Add($Files[$f].Equivs[$i])
                $real.Add($i)
            }
            else { $Files[$f].Changed[$i + 1] = 1 }
        }
        $Files[$f].Kept = $kept.ToArray()
        $Files[$f].Real = $real.ToArray()
    }
}

function script:Find-GnuDiffMiddle {
    <#
    .SYNOPSIS
    Port of GNU's diag: finds the midpoint of a shortest edit script by searching from both ends. Returns @(x, y).
    #>

    param([Parameter(Mandatory)][hashtable]$Search, [int]$XOff, [int]$XLim, [int]$YOff, [int]$YLim)

    $xv = $Search.X
    $yv = $Search.Y
    $fd = $Search.Fd
    $bd = $Search.Bd
    $o = $Search.Offset
    $dmin = $XOff - $YLim
    $dmax = $XLim - $YOff
    $fmid = $XOff - $YOff
    $bmid = $XLim - $YLim
    $fmin = $fmid
    $fmax = $fmid
    $bmin = $bmid
    $bmax = $bmid
    $odd = (($fmid - $bmid) -band 1) -ne 0
    $fd[$o + $fmid] = $XOff
    $bd[$o + $bmid] = $XLim

    while ($true) {
        if ($fmin -gt $dmin) { $fmin--; $fd[$o + $fmin - 1] = -1 } else { $fmin++ }
        if ($fmax -lt $dmax) { $fmax++; $fd[$o + $fmax + 1] = -1 } else { $fmax-- }
        for ($d = $fmax; $d -ge $fmin; $d -= 2) {
            $tlo = $fd[$o + $d - 1]
            $thi = $fd[$o + $d + 1]
            if ($tlo -lt $thi) { $x = $thi } else { $x = $tlo + 1 }
            $y = $x - $d
            while ($x -lt $XLim -and $y -lt $YLim -and $xv[$x] -eq $yv[$y]) { $x++; $y++ }
            $fd[$o + $d] = $x
            if ($odd -and $bmin -le $d -and $d -le $bmax -and $bd[$o + $d] -le $x) { return @($x, $y) }
        }
        if ($bmin -gt $dmin) { $bmin--; $bd[$o + $bmin - 1] = [int]::MaxValue } else { $bmin++ }
        if ($bmax -lt $dmax) { $bmax++; $bd[$o + $bmax + 1] = [int]::MaxValue } else { $bmax-- }
        for ($d = $bmax; $d -ge $bmin; $d -= 2) {
            $tlo = $bd[$o + $d - 1]
            $thi = $bd[$o + $d + 1]
            if ($tlo -lt $thi) { $x = $tlo } else { $x = $thi - 1 }
            $y = $x - $d
            while ($XOff -lt $x -and $YOff -lt $y -and $xv[$x - 1] -eq $yv[$y - 1]) { $x--; $y-- }
            $bd[$o + $d] = $x
            if (-not $odd -and $fmin -le $d -and $d -le $fmax -and $x -le $fd[$o + $d]) { return @($x, $y) }
        }
    }
}

function script:Compare-GnuDiffSequence {
    <#
    .SYNOPSIS
    Port of GNU's compareseq: marks the changed lines of both files.
    #>

    param([Parameter(Mandatory)][hashtable]$Search, [int]$XOff, [int]$XLim, [int]$YOff, [int]$YLim)

    $xv = $Search.X
    $yv = $Search.Y
    while ($XOff -lt $XLim -and $YOff -lt $YLim -and $xv[$XOff] -eq $yv[$YOff]) { $XOff++; $YOff++ }
    while ($XOff -lt $XLim -and $YOff -lt $YLim -and $xv[$XLim - 1] -eq $yv[$YLim - 1]) { $XLim--; $YLim-- }
    if ($XOff -eq $XLim) {
        while ($YOff -lt $YLim) { $Search.Changed1[$Search.Real1[$YOff] + 1] = 1; $YOff++ }
        return
    }
    if ($YOff -eq $YLim) {
        while ($XOff -lt $XLim) { $Search.Changed0[$Search.Real0[$XOff] + 1] = 1; $XOff++ }
        return
    }
    $middle = Find-GnuDiffMiddle -Search $Search -XOff $XOff -XLim $XLim -YOff $YOff -YLim $YLim
    Compare-GnuDiffSequence -Search $Search -XOff $XOff -XLim $middle[0] -YOff $YOff -YLim $middle[1]
    Compare-GnuDiffSequence -Search $Search -XOff $middle[0] -XLim $XLim -YOff $middle[1] -YLim $YLim
}

function script:Move-GnuDiffBoundary {
    <#
    .SYNOPSIS
    Port of GNU's shift_boundaries: moves runs of changes to merge them and to end them at natural places.
    #>

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

    foreach ($f in 0, 1) {
        $changed = $Files[$f].Changed
        $other = $Files[1 - $f].Changed
        $equivs = $Files[$f].Equivs
        $i = 0
        $j = 0
        $end = $equivs.Length
        while ($true) {
            while ($i -lt $end -and $changed[$i + 1] -eq 0) {
                while ($other[$j + 1] -ne 0) { $j++ }
                $j++
                $i++
            }
            if ($i -eq $end) { break }
            $start = $i
            $i++
            while ($changed[$i + 1] -ne 0) { $i++ }
            while ($other[$j + 1] -ne 0) { $j++ }
            do {
                $run = $i - $start
                while ($start -gt 0 -and $equivs[$start - 1] -eq $equivs[$i - 1]) {
                    $start--
                    $changed[$start + 1] = 1
                    $i--
                    $changed[$i + 1] = 0
                    while ($changed[$start] -ne 0) { $start-- }
                    $j--
                    while ($other[$j + 1] -ne 0) { $j-- }
                }
                if ($other[$j] -ne 0) { $corresponding = $i } else { $corresponding = $end }
                while ($i -ne $end -and $equivs[$start] -eq $equivs[$i]) {
                    $changed[$start + 1] = 0
                    $start++
                    $changed[$i + 1] = 1
                    $i++
                    while ($changed[$i + 1] -ne 0) { $i++ }
                    $j++
                    while ($other[$j + 1] -ne 0) { $corresponding = $i; $j++ }
                }
            } while ($run -ne $i - $start)
            while ($corresponding -lt $i) {
                $start--
                $changed[$start + 1] = 1
                $i--
                $changed[$i + 1] = 0
                do { $j-- } while ($other[$j + 1] -ne 0)
            }
        }
    }
}

function script:Get-GnuDiffLineKey {
    <#
    .SYNOPSIS
    The text two lines are compared by, after the ignore options.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [AllowEmptyString()][string]$Line)

    $key = $Line
    if ($State.IgnoreAllSpace) { $key = [regex]::Replace($key, '\s+', '') }
    elseif ($State.IgnoreSpaceChange) { $key = [regex]::Replace([regex]::Replace($key, '\s+$', ''), '\s+', ' ') }
    else {
        if ($State.IgnoreTrailing) { $key = [regex]::Replace($key, '\s+$', '') }
        if ($State.IgnoreTabs) { $key = Expand-GnuDiffTab -Text $key -Size $State.TabSize }
    }
    if ($State.IgnoreCase) { $key = $key.ToLowerInvariant() }
    $key
}

function script:Expand-GnuDiffTab {
    <#
    .SYNOPSIS
    Replaces tabs by spaces up to the next tab stop.
    #>

    param([AllowEmptyString()][string]$Text, [int]$Size)
    if (-not $Text.Contains("`t")) { return $Text }
    $builder = New-Object System.Text.StringBuilder
    foreach ($char in $Text.ToCharArray()) {
        if ($char -eq "`t") { [void]$builder.Append(' ', $Size - ($builder.Length % $Size)) }
        else { [void]$builder.Append($char) }
    }
    $builder.ToString()
}

function script:Get-GnuDiffScript {
    <#
    .SYNOPSIS
    The changes between two files: a list of @{ Line0; Line1; Deleted; Inserted; Ignore } with 0-based line numbers.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$First, [Parameter(Mandatory)][hashtable]$Second)

    $lines0 = $First.Lines
    $lines1 = $Second.Lines
    $robust = -not $State.Ed -and -not $State.IgnoreSpaceChange -and -not $State.IgnoreAllSpace
    $classes = New-Object 'System.Collections.Generic.Dictionary[string,int]' ([System.StringComparer]::Ordinal)
    $keysOf = {
        param($File)
        $keys = New-Object string[] $File.Lines.Length
        for ($i = 0; $i -lt $File.Lines.Length; $i++) { $keys[$i] = Get-GnuDiffLineKey -State $State -Line $File.Lines[$i] }
        # An incomplete last line only equals another incomplete one, unless space changes are ignored.
        if ($robust -and $File.Missing -and $keys.Length -gt 0) { $keys[$keys.Length - 1] += [string][char]0 + 'missing' }
        , $keys
    }
    $keys0 = & $keysOf $First
    $keys1 = & $keysOf $Second

    # Identical ends are left out of the search, except for the lines kept as a horizon (GNU's find_identical_ends).
    $common = 0
    $limit = [Math]::Min($keys0.Length, $keys1.Length)
    while ($common -lt $limit -and [string]::Equals($lines0[$common], $lines1[$common], [System.StringComparison]::Ordinal) -and [string]::Equals($keys0[$common], $keys1[$common], [System.StringComparison]::Ordinal)) { $common++ }
    $prefix = [Math]::Max(0, $common - $State.Horizon)
    $tail = 0
    while ($tail -lt $limit - $common -and [string]::Equals($lines0[$keys0.Length - 1 - $tail], $lines1[$keys1.Length - 1 - $tail], [System.StringComparison]::Ordinal) -and [string]::Equals($keys0[$keys0.Length - 1 - $tail], $keys1[$keys1.Length - 1 - $tail], [System.StringComparison]::Ordinal)) { $tail++ }
    $suffix = [Math]::Max(0, $tail - $State.Horizon)

    $count0 = $keys0.Length - $prefix - $suffix
    $count1 = $keys1.Length - $prefix - $suffix
    $equivs0 = New-Object int[] $count0
    $equivs1 = New-Object int[] $count1
    foreach ($pair in @(, @($keys0, $equivs0)) + @(, @($keys1, $equivs1))) {
        for ($i = 0; $i -lt $pair[1].Length; $i++) {
            $key = $pair[0][$prefix + $i]
            $class = 0
            if (-not $classes.TryGetValue($key, [ref]$class)) {
                $class = $classes.Count + 1
                $classes[$key] = $class
            }
            $pair[1][$i] = $class
        }
    }

    # Changed arrays have a sentinel on both sides: index i of the file is element i + 1.
    $files = @(
        @{ Equivs = $equivs0; Changed = (New-Object byte[] ($count0 + 2)); Kept = $null; Real = $null }
        @{ Equivs = $equivs1; Changed = (New-Object byte[] ($count1 + 2)); Kept = $null; Real = $null }
    )
    if ($State.Minimal) {
        foreach ($file in $files) {
            $file.Kept = $file.Equivs
            $file.Real = [int[]](0..[Math]::Max(0, $file.Equivs.Length))
        }
    }
    else { Remove-GnuDiffConfusingLine -Files $files -ClassCount $classes.Count }
    $size = $files[0].Kept.Length + $files[1].Kept.Length + 3
    $search = @{
        X = $files[0].Kept; Y = $files[1].Kept; Real0 = $files[0].Real; Real1 = $files[1].Real; Changed0 = $files[0].Changed; Changed1 = $files[1].Changed
        Fd = New-Object int[] $size; Bd = New-Object int[] $size; Offset = $files[1].Kept.Length + 1
    }
    Compare-GnuDiffSequence -Search $search -XOff 0 -XLim $search.X.Length -YOff 0 -YLim $search.Y.Length
    Move-GnuDiffBoundary -Files $files

    $changes = New-Object 'System.Collections.Generic.List[hashtable]'
    $i0 = 0
    $i1 = 0
    $changed0 = $files[0].Changed
    $changed1 = $files[1].Changed
    while ($i0 -lt $count0 -or $i1 -lt $count1) {
        if ($changed0[$i0 + 1] -ne 0 -or $changed1[$i1 + 1] -ne 0) {
            $start0 = $i0
            $start1 = $i1
            while ($changed0[$i0 + 1] -ne 0) { $i0++ }
            while ($changed1[$i1 + 1] -ne 0) { $i1++ }
            $changes.Add(@{ Line0 = $prefix + $start0; Line1 = $prefix + $start1; Deleted = $i0 - $start0; Inserted = $i1 - $start1; Ignore = $false })
        }
        $i0++
        $i1++
    }
    foreach ($change in $changes) {
        $trivial = $State.IgnoreBlank
        if ($trivial) {
            foreach ($row in @(@($First.Lines, $change.Line0, $change.Deleted), @($Second.Lines, $change.Line1, $change.Inserted))) {
                for ($k = 0; $k -lt $row[2]; $k++) {
                    if (-not [string]::IsNullOrEmpty((Get-GnuDiffLineKey -State $State -Line $row[0][$row[1] + $k]))) { $trivial = $false }
                }
            }
        }
        $change.Ignore = $trivial
    }
    , $changes.ToArray()
}

function script:Get-GnuDiffHunk {
    <#
    .SYNOPSIS
    Groups changes into hunks (GNU's find_hunk): changes closer than the context distance stay together.
    #>

    param([AllowEmptyCollection()][hashtable[]]$Changes, [int]$Context)

    $start = 0
    while ($start -lt $Changes.Count) {
        $last = $start
        while ($last + 1 -lt $Changes.Count) {
            $next = $Changes[$last + 1]
            $threshold = 2 * $Context + 1
            if ($next.Ignore) { $threshold = $Context }
            if ($threshold -le $next.Line0 - ($Changes[$last].Line0 + $Changes[$last].Deleted)) { break }
            $last++
        }
        , @($Changes[$start..$last])
        $start = $last + 1
    }
}

function script:Get-GnuDiffHunkRange {
    <#
    .SYNOPSIS
    What a hunk touches (GNU's analyze_hunk): @{ First0; Last0; First1; Last1; Old; New }, or $null if it is all ignorable.
    #>

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

    if (@($Hunk | Where-Object { -not $_.Ignore }).Count -eq 0) { return $null }
    $last = $Hunk[$Hunk.Count - 1]
    $old = $false
    $new = $false
    foreach ($change in $Hunk) {
        if ($change.Deleted -gt 0) { $old = $true }
        if ($change.Inserted -gt 0) { $new = $true }
    }
    @{ First0 = $Hunk[0].Line0; Last0 = $last.Line0 + $last.Deleted - 1; First1 = $Hunk[0].Line1; Last1 = $last.Line1 + $last.Inserted - 1; Old = $old; New = $new }
}

function script:Format-GnuDiffRange {
    <#
    .SYNOPSIS
    A line range for normal, context and ed output: 'a,b', or one number when it covers at most one line.
    #>

    param([int]$A, [int]$B)
    if ($B -gt $A) { return "$($A + 1),$($B + 1)" }
    [string]($B + 1)
}

function script:Format-GnuDiffUnifiedRange {
    <#
    .SYNOPSIS
    A line range for unified output.
    #>

    param([int]$A, [int]$B)
    $from = $A + 1
    $to = $B + 1
    if ($to -lt $from) { return "$to,0" }
    if ($to -eq $from) { return [string]$to }
    "$from,$($to - $from + 1)"
}

function script:Add-GnuDiffLine {
    <#
    .SYNOPSIS
    Writes one line of a file with its prefix, and GNU's note when the file does not end in a newline.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$File, [int]$Index, [AllowEmptyString()][string]$Prefix)

    $text = $File.Lines[$Index]
    if ($State.ExpandTabs) { $text = Expand-GnuDiffTab -Text $text -Size $State.TabSize }
    $State.Output.Add($Prefix + $text)
    if ($File.Missing -and $Index -eq $File.Lines.Length - 1 -and -not $State.Ed) { $State.Output.Add('\ No newline at end of file') }
}

function script:Find-GnuDiffFunctionLine {
    <#
    .SYNOPSIS
    The last line before the hunk that matches the -p / -F pattern, cut to 40 characters; '' if there is none.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$File, [int]$Before)

    if ($null -eq $State.FunctionRegex) { return '' }
    for ($i = $Before - 1; $i -ge 0; $i--) {
        if ($State.FunctionRegex.IsMatch($File.Lines[$i])) {
            $text = $File.Lines[$i]
            if ($text.Length -gt 40) { $text = $text.Substring(0, 40) }
            return ' ' + $text.TrimEnd()
        }
    }
    ''
}

function script:Add-GnuDiffNormalHunk {
    <#
    .SYNOPSIS
    One hunk of the normal format.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$First, [Parameter(Mandatory)][hashtable]$Second, [Parameter(Mandatory)][hashtable]$Change)

    $letter = 'c'
    if ($Change.Deleted -eq 0) { $letter = 'a' } elseif ($Change.Inserted -eq 0) { $letter = 'd' }
    $last0 = $Change.Line0 + $Change.Deleted - 1
    $last1 = $Change.Line1 + $Change.Inserted - 1
    $State.Output.Add((Format-GnuDiffRange -A $Change.Line0 -B $last0) + $letter + (Format-GnuDiffRange -A $Change.Line1 -B $last1))
    $mark = ' '
    if ($State.InitialTab) { $mark = "`t" }
    for ($i = $Change.Line0; $i -le $last0; $i++) { Add-GnuDiffLine -State $State -File $First -Index $i -Prefix "<$mark" }
    if ($Change.Deleted -gt 0 -and $Change.Inserted -gt 0) { $State.Output.Add('---') }
    for ($i = $Change.Line1; $i -le $last1; $i++) { Add-GnuDiffLine -State $State -File $Second -Index $i -Prefix ">$mark" }
}

function script:Add-GnuDiffEdHunk {
    <#
    .SYNOPSIS
    One hunk of an ed script.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$Second, [Parameter(Mandatory)][hashtable]$Change)

    $letter = 'c'
    if ($Change.Deleted -eq 0) { $letter = 'a' } elseif ($Change.Inserted -eq 0) { $letter = 'd' }
    $State.Output.Add((Format-GnuDiffRange -A $Change.Line0 -B ($Change.Line0 + $Change.Deleted - 1)) + $letter)
    if ($letter -ceq 'd') { return }
    $inserting = $true
    for ($i = $Change.Line1; $i -lt $Change.Line1 + $Change.Inserted; $i++) {
        if (-not $inserting) { $State.Output.Add('a'); $inserting = $true }
        if ($Second.Lines[$i] -ceq '.') {
            # A lone dot would end the insertion: write two dots, end it, and remove the extra dot.
            foreach ($row in '..', '.', 's/.//') { $State.Output.Add($row) }
            $inserting = $false
        }
        else { Add-GnuDiffLine -State $State -File $Second -Index $i -Prefix '' }
    }
    if ($inserting) { $State.Output.Add('.') }
}

function script:Add-GnuDiffContextHunk {
    <#
    .SYNOPSIS
    One hunk of the context format.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$First, [Parameter(Mandatory)][hashtable]$Second, [Parameter(Mandatory)][hashtable[]]$Hunk, [Parameter(Mandatory)][hashtable]$Range)

    $from0 = [Math]::Max(0, $Range.First0 - $State.ContextLines)
    $from1 = [Math]::Max(0, $Range.First1 - $State.ContextLines)
    $to0 = [Math]::Min($First.Lines.Length - 1, $Range.Last0 + $State.ContextLines)
    $to1 = [Math]::Min($Second.Lines.Length - 1, $Range.Last1 + $State.ContextLines)
    $State.Output.Add('***************' + (Find-GnuDiffFunctionLine -State $State -File $First -Before $Range.First0))
    $State.Output.Add('*** ' + (Format-GnuDiffRange -A $from0 -B $to0) + ' ****')
    $mark = ' '
    if ($State.InitialTab) { $mark = "`t" }
    if ($Range.Old) {
        $k = 0
        for ($i = $from0; $i -le $to0; $i++) {
            while ($k -lt $Hunk.Count -and $Hunk[$k].Line0 + $Hunk[$k].Deleted -le $i) { $k++ }
            $flag = ' '
            if ($k -lt $Hunk.Count -and $Hunk[$k].Line0 -le $i) { if ($Hunk[$k].Inserted -gt 0) { $flag = '!' } else { $flag = '-' } }
            Add-GnuDiffLine -State $State -File $First -Index $i -Prefix ($flag + $mark)
        }
    }
    $State.Output.Add('--- ' + (Format-GnuDiffRange -A $from1 -B $to1) + ' ----')
    if ($Range.New) {
        $k = 0
        for ($i = $from1; $i -le $to1; $i++) {
            while ($k -lt $Hunk.Count -and $Hunk[$k].Line1 + $Hunk[$k].Inserted -le $i) { $k++ }
            $flag = ' '
            if ($k -lt $Hunk.Count -and $Hunk[$k].Line1 -le $i) { if ($Hunk[$k].Deleted -gt 0) { $flag = '!' } else { $flag = '+' } }
            Add-GnuDiffLine -State $State -File $Second -Index $i -Prefix ($flag + $mark)
        }
    }
}

function script:Add-GnuDiffUnifiedHunk {
    <#
    .SYNOPSIS
    One hunk of the unified format.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$First, [Parameter(Mandatory)][hashtable]$Second, [Parameter(Mandatory)][hashtable[]]$Hunk, [Parameter(Mandatory)][hashtable]$Range)

    $from0 = [Math]::Max(0, $Range.First0 - $State.ContextLines)
    $from1 = [Math]::Max(0, $Range.First1 - $State.ContextLines)
    $to0 = [Math]::Min($First.Lines.Length - 1, $Range.Last0 + $State.ContextLines)
    $to1 = [Math]::Min($Second.Lines.Length - 1, $Range.Last1 + $State.ContextLines)
    $State.Output.Add('@@ -' + (Format-GnuDiffUnifiedRange -A $from0 -B $to0) + ' +' + (Format-GnuDiffUnifiedRange -A $from1 -B $to1) + ' @@' + (Find-GnuDiffFunctionLine -State $State -File $First -Before $Range.First0))
    $tab = ''
    if ($State.InitialTab) { $tab = "`t" }
    $i = $from0
    $j = $from1
    $k = 0
    while ($i -le $to0 -or $j -le $to1) {
        if ($k -ge $Hunk.Count -or $i -lt $Hunk[$k].Line0) {
            Add-GnuDiffLine -State $State -File $First -Index $i -Prefix (' ' + $tab)
            $i++
            $j++
            continue
        }
        for ($n = 0; $n -lt $Hunk[$k].Deleted; $n++) { Add-GnuDiffLine -State $State -File $First -Index $i -Prefix ('-' + $tab); $i++ }
        for ($n = 0; $n -lt $Hunk[$k].Inserted; $n++) { Add-GnuDiffLine -State $State -File $Second -Index $j -Prefix ('+' + $tab); $j++ }
        $k++
    }
}

function script:Format-GnuDiffFileHeader {
    <#
    .SYNOPSIS
    The name and time on a header line, or the label given with -L.
    #>

    param([Parameter(Mandatory)][hashtable]$File, [AllowNull()][string]$Label)

    if (-not [string]::IsNullOrEmpty($Label)) { return $Label }
    $time = $File.Time
    $invariant = [System.Globalization.CultureInfo]::InvariantCulture
    $offset = $time.ToString('zzz', $invariant).Replace(':', '')
    $File.Name + "`t" + $time.ToString('yyyy-MM-dd HH:mm:ss.fffffff', $invariant) + '00 ' + $offset
}

function script:Read-GnuDiffFile {
    <#
    .SYNOPSIS
    Loads one side of a comparison: @{ Name; Lines; Missing; Time; Binary; Bytes }. An empty side stands for an absent file with -N.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Name, [switch]$Absent)

    $file = @{ Name = $Name; Lines = @(); Missing = $false; Time = [System.DateTimeOffset]::new([datetime]'1970-01-01Z'); Binary = $false; Bytes = (New-Object byte[] 0) }
    if ($Absent) { return $file }
    if ($Name -ceq '-') {
        $text = [string](Get-GnuInputText -Context $State.Context -Operand '-')
        $file.Bytes = $script:GnuUtf8.GetBytes($text)
        $file.Time = [System.DateTimeOffset]::Now
    }
    else {
        $path = Resolve-GnuPath -Path $Name
        $file.Bytes = [System.IO.File]::ReadAllBytes($path)
        $file.Time = [System.DateTimeOffset]::new([System.IO.File]::GetLastWriteTime($path))
        $text = (Read-GnuTextFile -Path $path).Text
    }
    if (-not $State.Text) { $file.Binary = [array]::IndexOf($file.Bytes, [byte]0, 0, [Math]::Min($file.Bytes.Length, 32768)) -ge 0 }
    $file.Missing = $text.Length -gt 0 -and -not $text.EndsWith("`n", [System.StringComparison]::Ordinal)
    if ($text.Length -gt 0) {
        $body = $text
        if (-not $file.Missing) { $body = $text.Substring(0, $text.Length - 1) }
        $file.Lines = $body.Split("`n")
        if ($State.StripCr) {
            for ($i = 0; $i -lt $file.Lines.Length; $i++) {
                if ($file.Lines[$i].EndsWith("`r", [System.StringComparison]::Ordinal)) { $file.Lines[$i] = $file.Lines[$i].Substring(0, $file.Lines[$i].Length - 1) }
            }
        }
    }
    $file
}

function script:Compare-GnuDiffContent {
    <#
    .SYNOPSIS
    Compares two regular files and writes the result.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$First, [Parameter(Mandatory)][hashtable]$Second, [switch]$InDirectory)

    $sameBytes = $First.Bytes.Length -eq $Second.Bytes.Length -and ([System.Linq.Enumerable]::SequenceEqual([byte[]]$First.Bytes, [byte[]]$Second.Bytes))
    $differs = -not $sameBytes
    $changes = @()
    if ($differs -and $State.Ed) {
        # An ed script cannot express an incomplete last line.
        foreach ($file in $First, $Second) {
            if ($file.Missing) {
                Write-GnuError -Context $State.Context -Message "$($file.Name): No newline at end of file`n" -ExitCode 2
                return
            }
        }
    }
    if ($differs -and -not $First.Binary -and -not $Second.Binary) {
        $changes = Get-GnuDiffScript -State $State -First $First -Second $Second
        $differs = @($changes | Where-Object { -not $_.Ignore }).Count -gt 0
    }
    if (-not $differs) {
        if ($State.ReportSame) { $State.Output.Add("Files $($First.Name) and $($Second.Name) are identical") }
        return
    }
    $State.Context.ExitCode = [Math]::Max($State.Context.ExitCode, 1)
    if ($State.Brief) { $State.Output.Add("Files $($First.Name) and $($Second.Name) differ"); return }
    if ($First.Binary -or $Second.Binary) { $State.Output.Add("Binary files $($First.Name) and $($Second.Name) differ"); return }
    if ($InDirectory) { $State.Output.Add("diff$($State.Switches) $($First.Name) $($Second.Name)") }
    if ($State.Style -ceq 'unified' -or $State.Style -ceq 'context') {
        $first0 = '***'
        $first1 = '---'
        if ($State.Style -ceq 'unified') { $first0 = '---'; $first1 = '+++' }
        $State.Output.Add("$first0 " + (Format-GnuDiffFileHeader -File $First -Label $State.Label0))
        $State.Output.Add("$first1 " + (Format-GnuDiffFileHeader -File $Second -Label $State.Label1))
    }
    if ($State.Style -ceq 'ed') {
        for ($i = $changes.Count - 1; $i -ge 0; $i--) { if (-not $changes[$i].Ignore) { Add-GnuDiffEdHunk -State $State -Second $Second -Change $changes[$i] } }
        return
    }
    if ($State.Style -ceq 'normal') {
        foreach ($change in $changes) { if (-not $change.Ignore) { Add-GnuDiffNormalHunk -State $State -First $First -Second $Second -Change $change } }
        return
    }
    foreach ($hunk in @(Get-GnuDiffHunk -Changes $changes -Context $State.ContextLines)) {
        $range = Get-GnuDiffHunkRange -Hunk $hunk
        if ($null -eq $range) { continue }
        if ($State.Style -ceq 'unified') { Add-GnuDiffUnifiedHunk -State $State -First $First -Second $Second -Hunk $hunk -Range $range }
        else { Add-GnuDiffContextHunk -State $State -First $First -Second $Second -Hunk $hunk -Range $range }
    }
}

function script:Test-GnuDiffExcluded {
    <#
    .SYNOPSIS
    $true if a name matches an --exclude pattern.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Name)
    foreach ($pattern in $State.Exclude) { if ($pattern.IsMatch($Name)) { return $true } }
    $false
}

function script:Compare-GnuDiffDirectory {
    <#
    .SYNOPSIS
    Compares two directories entry by entry in name order.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Left, [Parameter(Mandatory)][string]$Right)

    $names = New-Object 'System.Collections.Generic.SortedSet[string]' ([System.StringComparer]::Ordinal)
    foreach ($directory in $Left, $Right) {
        # With -N a directory can exist on one side only.
        $path = Resolve-GnuPath -Path $directory
        if (-not [System.IO.Directory]::Exists($path)) { continue }
        foreach ($entry in [System.IO.Directory]::GetFileSystemEntries($path)) { $null = $names.Add([System.IO.Path]::GetFileName($entry)) }
    }
    foreach ($name in $names) {
        if (Test-GnuDiffExcluded -State $State -Name $name) { continue }
        Compare-GnuDiffEntry -State $State -Left (Join-GnuDisplayPath -Directory $Left -Leaf $name) -Right (Join-GnuDisplayPath -Directory $Right -Leaf $name) -LeftParent $Left -RightParent $Right -Leaf $name
    }
}

function script:Compare-GnuDiffEntry {
    <#
    .SYNOPSIS
    Compares one name found in a directory on either side.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Left, [Parameter(Mandatory)][string]$Right, [Parameter(Mandatory)][string]$LeftParent, [Parameter(Mandatory)][string]$RightParent, [Parameter(Mandatory)][string]$Leaf)

    $leftPath = Resolve-GnuPath -Path $Left
    $rightPath = Resolve-GnuPath -Path $Right
    $leftDir = [System.IO.Directory]::Exists($leftPath)
    $rightDir = [System.IO.Directory]::Exists($rightPath)
    $leftFile = [System.IO.File]::Exists($leftPath)
    $rightFile = [System.IO.File]::Exists($rightPath)
    $leftMissing = -not $leftDir -and -not $leftFile
    $rightMissing = -not $rightDir -and -not $rightFile

    $treatLeftEmpty = $leftMissing -and ($State.NewFile -or $State.UniNewFile) -and ($rightFile -or ($rightDir -and $State.Recursive))
    $treatRightEmpty = $rightMissing -and $State.NewFile -and ($leftFile -or ($leftDir -and $State.Recursive))
    if (($leftMissing -and -not $treatLeftEmpty) -or ($rightMissing -and -not $treatRightEmpty)) {
        $parent = $LeftParent
        if ($leftMissing) { $parent = $RightParent }
        $State.Output.Add("Only in $($parent.TrimEnd('/')): $Leaf")
        $State.Context.ExitCode = [Math]::Max($State.Context.ExitCode, 1)
        return
    }
    if (($leftDir -and $rightDir) -or ($leftDir -and $treatRightEmpty) -or ($rightDir -and $treatLeftEmpty)) {
        if ($State.Recursive) { Compare-GnuDiffDirectory -State $State -Left $Left -Right $Right }
        else { $State.Output.Add("Common subdirectories: $Left and $Right") }
        return
    }
    if ($leftDir -ne $rightDir -and -not $leftMissing -and -not $rightMissing) {
        $kinds = @('regular file', 'directory')
        $State.Output.Add("File $Left is a $($kinds[[int]$leftDir]) while file $Right is a $($kinds[[int]$rightDir])")
        $State.Context.ExitCode = [Math]::Max($State.Context.ExitCode, 1)
        return
    }
    $first = Read-GnuDiffFile -State $State -Name $Left -Absent:$treatLeftEmpty
    $second = Read-GnuDiffFile -State $State -Name $Right -Absent:$treatRightEmpty
    Compare-GnuDiffContent -State $State -First $first -Second $second -InDirectory
}

function script:Invoke-GnuDiffReport {
    <#
    .SYNOPSIS
    Implementation of diff.
    #>

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

    $operands = @($Context.Operands)
    if ($operands.Count -eq 0) { Write-GnuUsageError -Context $Context -Message 'missing operand after ''diff'''; return }
    if ($operands.Count -eq 1) { Write-GnuUsageError -Context $Context -Message "missing operand after '$($operands[0])'"; return }
    if ($operands.Count -gt 2) { Write-GnuUsageError -Context $Context -Message "extra operand '$($operands[2])'"; return }

    $styles = @()
    if (Test-GnuOption -Context $Context -Key 'normal') { $styles += 'normal' }
    if (Test-GnuOption -Context $Context -Key 'ed') { $styles += 'ed' }
    if ((Test-GnuOption -Context $Context -Key 'c') -or (Test-GnuOption -Context $Context -Key 'context')) { $styles += 'context' }
    if ((Test-GnuOption -Context $Context -Key 'u') -or (Test-GnuOption -Context $Context -Key 'unified')) { $styles += 'unified' }
    if ($styles.Count -gt 1) { Write-GnuUsageError -Context $Context -Message 'conflicting output style options'; return }

    $contextLines = 3
    foreach ($key in 'context', 'unified') {
        if (-not (Test-GnuOption -Context $Context -Key $key)) { continue }
        $text = [string](Get-GnuOptionValue -Context $Context -Key $key)
        if ($text.Length -eq 0) { continue }
        if ($text -notmatch '^\d+$') { Write-GnuUsageError -Context $Context -Message "invalid context length '$text'"; return }
        $contextLines = [int]$text
    }
    $tabSize = 8
    if (Test-GnuOption -Context $Context -Key 'tabsize') {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'tabsize')
        if ($text -notmatch '^[1-9]\d*$') { Write-GnuUsageError -Context $Context -Message "invalid tabsize '$text'"; return }
        $tabSize = [int]$text
    }
    $horizon = 0
    if (Test-GnuOption -Context $Context -Key 'horizon-lines') {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'horizon-lines')
        if ($text -notmatch '^\d+$') { Write-GnuUsageError -Context $Context -Message "invalid horizon length '$text'"; return }
        $horizon = [int]$text
    }
    $style = 'normal'
    if ($styles.Count -eq 1) { $style = $styles[0] }
    # -p and -F only make sense with context, so on their own they select it.
    elseif ((Test-GnuOption -Context $Context -Key 'show-c-function') -or (Test-GnuOption -Context $Context -Key 'show-function-line')) { $style = 'context' }

    $functionPattern = $null
    if (Test-GnuOption -Context $Context -Key 'show-c-function') { $functionPattern = '^[A-Za-z$_]' }
    if (Test-GnuOption -Context $Context -Key 'show-function-line') { $functionPattern = (ConvertTo-GnuDotNetRegex -Pattern ([string](Get-GnuOptionValue -Context $Context -Key 'show-function-line'))).Regex }
    $functionRegex = $null
    if ($null -ne $functionPattern) { $functionRegex = New-Object System.Text.RegularExpressions.Regex -ArgumentList $functionPattern }

    $labels = @(Get-GnuOptionValue -Context $Context -Key 'label' -Default @())
    if ($labels.Count -gt 2) { Write-GnuError -Context $Context -Message 'too many file label options' -ExitCode 2; return }
    $label0 = $null
    $label1 = $null
    if ($labels.Count -gt 0) { $label0 = $labels[0] }
    if ($labels.Count -gt 1) { $label1 = $labels[1] }

    $exclude = New-Object 'System.Collections.Generic.List[System.Management.Automation.WildcardPattern]'
    foreach ($pattern in @(Get-GnuOptionValue -Context $Context -Key 'exclude' -Default @())) { $exclude.Add((New-Object System.Management.Automation.WildcardPattern -ArgumentList $pattern)) }
    foreach ($file in @(Get-GnuOptionValue -Context $Context -Key 'exclude-from' -Default @())) {
        foreach ($line in @(Get-GnuInputLine -Context $Context -Operand $file)) { if ($line.Length -gt 0) { $exclude.Add((New-Object System.Management.Automation.WildcardPattern -ArgumentList $line)) } }
    }

    # GNU repeats the options on its 'diff -r a/x b/x' lines, in the order given.
    $switches = New-Object System.Text.StringBuilder
    $remaining = New-Object 'System.Collections.Generic.List[string]'
    foreach ($operand in @($Context.RawOperands)) { $remaining.Add($operand) }
    foreach ($argument in @($Context.Argv)) {
        if ($argument -ceq '--') { continue }
        if ($remaining.Remove($argument)) { continue }
        $shown = $argument
        if ($shown -notmatch '^[-A-Za-z0-9_+,./:@%^]+$') { $shown = "'" + $shown.Replace("'", "'\''") + "'" }
        [void]$switches.Append(' ').Append($shown)
    }

    $state = @{
        Context = $Context; Output = New-Object 'System.Collections.Generic.List[string]'; Style = $style; ContextLines = $contextLines; TabSize = $tabSize; Horizon = [Math]::Max($horizon, $(if ($style -ceq 'normal' -or $style -ceq 'ed') { 0 } else { $contextLines }))
        Brief = Test-GnuOption -Context $Context -Key 'brief'; ReportSame = Test-GnuOption -Context $Context -Key 'report-identical-files'; Ed = $style -ceq 'ed'
        IgnoreCase = Test-GnuOption -Context $Context -Key 'ignore-case'; IgnoreTabs = Test-GnuOption -Context $Context -Key 'ignore-tab-expansion'; IgnoreTrailing = Test-GnuOption -Context $Context -Key 'ignore-trailing-space'
        IgnoreSpaceChange = Test-GnuOption -Context $Context -Key 'ignore-space-change'; IgnoreAllSpace = Test-GnuOption -Context $Context -Key 'ignore-all-space'; IgnoreBlank = Test-GnuOption -Context $Context -Key 'ignore-blank-lines'
        StripCr = Test-GnuOption -Context $Context -Key 'strip-trailing-cr'; Text = Test-GnuOption -Context $Context -Key 'text'; Minimal = Test-GnuOption -Context $Context -Key 'minimal'
        ExpandTabs = Test-GnuOption -Context $Context -Key 'expand-tabs'; InitialTab = Test-GnuOption -Context $Context -Key 'initial-tab'; FunctionRegex = $functionRegex
        Label0 = $label0; Label1 = $label1; Recursive = Test-GnuOption -Context $Context -Key 'recursive'; NewFile = Test-GnuOption -Context $Context -Key 'new-file'; UniNewFile = Test-GnuOption -Context $Context -Key 'unidirectional-new-file'
        Exclude = $exclude; Switches = $switches.ToString()
    }

    $left = $operands[0]
    $right = $operands[1]
    $leftPath = ''
    $rightPath = ''
    if ($left -cne '-') { $leftPath = Resolve-GnuPath -Path $left }
    if ($right -cne '-') { $rightPath = Resolve-GnuPath -Path $right }
    $leftDir = $leftPath.Length -gt 0 -and [System.IO.Directory]::Exists($leftPath)
    $rightDir = $rightPath.Length -gt 0 -and [System.IO.Directory]::Exists($rightPath)
    if (($leftDir -and $right -ceq '-') -or ($rightDir -and $left -ceq '-')) {
        Write-GnuError -Context $Context -Message 'cannot compare ''-'' to a directory' -ExitCode 2
        return
    }
    # A directory against a file means the file of that name in the directory.
    if ($leftDir -and -not $rightDir) { $left = Join-GnuDisplayPath -Directory $left -Leaf ([System.IO.Path]::GetFileName($rightPath)); $leftPath = Resolve-GnuPath -Path $left; $leftDir = $false }
    elseif ($rightDir -and -not $leftDir) { $right = Join-GnuDisplayPath -Directory $right -Leaf ([System.IO.Path]::GetFileName($leftPath)); $rightPath = Resolve-GnuPath -Path $right; $rightDir = $false }

    $absentLeft = $false
    $absentRight = $false
    foreach ($side in @(@($left, $leftPath, 0), @($right, $rightPath, 1))) {
        if ($side[0] -ceq '-' -or [System.IO.File]::Exists($side[1]) -or [System.IO.Directory]::Exists($side[1])) { continue }
        $other = $rightPath
        if ($side[2] -eq 1) { $other = $leftPath }
        $otherExists = $other.Length -eq 0 -or [System.IO.File]::Exists($other) -or [System.IO.Directory]::Exists($other)
        $allowed = $state.NewFile -or ($state.UniNewFile -and $side[2] -eq 0)
        if ($allowed -and $otherExists) {
            if ($side[2] -eq 0) { $absentLeft = $true } else { $absentRight = $true }
            continue
        }
        Write-GnuError -Context $Context -Message "$($side[0]): No such file or directory" -ExitCode 2
    }
    if ($Context.ExitCode -ge 2) { return }

    try {
        if ($leftDir -and $rightDir) { Compare-GnuDiffDirectory -State $state -Left $left -Right $right }
        else {
            $first = Read-GnuDiffFile -State $state -Name $left -Absent:$absentLeft
            $second = Read-GnuDiffFile -State $state -Name $right -Absent:$absentRight
            Compare-GnuDiffContent -State $state -First $first -Second $second
        }
    }
    finally { $state.Output.ToArray() }
}