src/Diff.ps1
|
# Changed lines from a unified diff (#168): which lines of which files a change touched, and which # units own them. # # The caller produces the diff -- `git diff --unified=0 origin/main...HEAD`, or any tool that writes # the format -- so the module stays free of git, for the reasons recorded beside -ChangedFile. Only # the headers are read: `+++` names a file on the new side, and `@@` gives a hunk's new-side line # range. The lines in between are skipped by the counts the header gives, never interpreted, so an # added line that happens to start `++` cannot be mistaken for a header. With context lines in the # diff, those count as changed too: `--unified=0` gives exactly the changed lines. # # Pure: text in, data out. The scan applies it. $script:PSCxHunkHeader = [regex]::new('^@@ -\d+(?:,(?<oldCount>\d+))? \+(?<start>\d+)(?:,(?<count>\d+))? @@') function Get-PSCxDiffPath { # The file one `---` or `+++` header names, as the diff spelt it, or $null for /dev/null -- the # side of a file that was created or deleted. # # git quotes a path holding unusual characters ("b/my \"x\".ps1") and other tools append a tab # and a timestamp; both are taken off. The a/ and b/ prefixes are left on: only the pair of # headers can say whether they are prefixes or a directory, and Get-PSCxUnifiedDiff decides. [OutputType([string])] [CmdletBinding()] param([Parameter(Mandatory)] [string]$Header) $path = $Header.Substring(4).Split("`t")[0] if ($path.Length -ge 2 -and $path.StartsWith('"') -and $path.EndsWith('"')) { $path = $path.Substring(1, $path.Length - 2) -replace '\\(["\\])', '$1' } if ($path -eq '/dev/null') { return $null } return $path } function Get-PSCxUnifiedDiff { # The new-side hunks of every file a unified diff changes: [pscustomobject]@{ Files; Fault }. # Files maps each file, as the diff names it, to its hunks -- Start and Count on the new side, # Count 0 for a pure deletion, which sits after line Start. Fault says why the text cannot be # read as a diff, and Files is then empty. # # A deleted file is left out: nothing of it is left to measure. A file whose change has no hunk # -- a rename, a mode change, a new empty file -- is listed with none. [OutputType([pscustomobject])] [CmdletBinding()] param( # Lines, or one string holding them, as a pipe or Get-Content -Raw gives it. [Parameter(Mandatory)] [AllowNull()] [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$Text ) $lines = @(($Text -join "`n") -split '\r?\n') if (-not ($lines -match '\S')) { return [pscustomobject]@{ Files = [ordered]@{}; Fault = '-ChangedDiff was given an empty diff. An empty diff would restrict the run to nothing and report a pass over zero units, which is the failure this module exists to find -- and it is what a diff command prints when it fails, matches nothing, or runs against a shallow clone whose base ref is missing. If nothing changed, skip the gate rather than asking it to measure an empty set.' } } # Minus: the last `---` path. Current: the file hunks go to, $null inside a deleted one. # Old, New: body lines still to skip on each side after the last hunk header. $state = [pscustomobject]@{ Files = [ordered]@{}; Minus = $null; Current = $null; InFile = $false; SawFile = $false; Old = 0; New = 0 } for ($i = 0; $i -lt $lines.Count; $i++) { $fault = Read-PSCxDiffLine -State $state -Line $lines[$i] -Number ($i + 1) if ($fault) { return [pscustomobject]@{ Files = [ordered]@{}; Fault = $fault } } } if (-not $state.SawFile) { return [pscustomobject]@{ Files = $state.Files; Fault = '-ChangedDiff was given text with no file header (`diff` or `+++`), so it is not a unified diff. Pass the output of `git diff --unified=0 <base>...HEAD`, not a list of file names: that is what -ChangedFile takes.' } } return [pscustomobject]@{ Files = $state.Files; Fault = $null } } function Read-PSCxDiffLine { # Read one line of a diff into the state Get-PSCxUnifiedDiff keeps, returning why it cannot be # read, or nothing. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] $State, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Line, [Parameter(Mandatory)] [int]$Number ) # A body line, skipped by the counts and never read: it may begin with anything. # A context line counts on both sides, an added or removed one on its own, and git's # `\ No newline at end of file` on neither. if ($State.Old -gt 0 -or $State.New -gt 0) { $note = $Line.StartsWith('\') if (-not $Line.StartsWith('+') -and -not $note) { $State.Old-- } if (-not $Line.StartsWith('-') -and -not $note) { $State.New-- } return } if ($Line.StartsWith('diff ')) { $State.InFile = $false $State.Current = $null $State.SawFile = $true return } if ($Line.StartsWith('--- ')) { $State.Minus = Get-PSCxDiffPath -Header $Line; return } if ($Line.StartsWith('+++ ')) { Read-PSCxDiffFileHeader -State $State -Line $Line; return } if ($Line.StartsWith('@@')) { return Read-PSCxDiffHunk -State $State -Line $Line -Number $Number } } function Read-PSCxDiffFileHeader { # A `+++` header: the file the hunks after it belong to. [OutputType([void])] [CmdletBinding()] param( [Parameter(Mandatory)] $State, [Parameter(Mandatory)] [string]$Line ) $current = Get-PSCxDiffPath -Header $Line # git's prefixes, taken off only when the old side shows them too -- `--no-prefix` writes # neither, and a top-level directory named b/ must keep its name there. if ($current -and $current.StartsWith('b/') -and ($null -eq $State.Minus -or $State.Minus.StartsWith('a/'))) { $current = $current.Substring(2) } if ($current -and -not $State.Files.Contains($current)) { $State.Files[$current] = [System.Collections.Generic.List[object]]::new() } $State.Current = $current $State.InFile = $true $State.SawFile = $true } function Read-PSCxDiffHunk { # A `@@` header: one hunk of the current file, and the body lines to skip after it. Returns why # it cannot be read, or nothing. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] $State, [Parameter(Mandatory)] [string]$Line, [Parameter(Mandatory)] [int]$Number ) $match = $script:PSCxHunkHeader.Match($Line) $fault = Get-PSCxHunkFault -Line $Line -Number $Number -Match $match -InFile $State.InFile if ($fault) { return $fault } # A count left out is one line, as the format defines it. $count = $match.Groups['count'].Success ? [int]$match.Groups['count'].Value : 1 $State.Old = $match.Groups['oldCount'].Success ? [int]$match.Groups['oldCount'].Value : 1 $State.New = $count if ($State.Current) { $State.Files[$State.Current].Add([pscustomobject]@{ Start = [int]$match.Groups['start'].Value; Count = $count }) } } function Get-PSCxHunkFault { # Why one `@@` line cannot be read, as text, or nothing when it can. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Line, [Parameter(Mandatory)] [int]$Number, [Parameter(Mandatory)] [System.Text.RegularExpressions.Match]$Match, [Parameter(Mandatory)] [bool]$InFile ) if (-not $Match.Success) { return "-ChangedDiff line $Number is not a hunk header ('$Line'): expected '@@ -<old> +<start>[,<count>] @@'." } if (-not $InFile) { return "-ChangedDiff line $Number starts a hunk before any '+++' header names its file, so the diff is cut or malformed." } } function Get-PSCxLineOwner { # For each line of a file, the innermost units holding it: an array indexed by line number, each # entry the unit keys or $null where no unit is (the script body's lines). # # Painted outer to inner, so an inner unit replaces its parent on its own lines. A unit sharing # a line with a sibling -- `} function Next {` -- does not replace it: both own the line, as # both count it as a line of code. One pass per unit over its own lines, so a large new file # costs its length times its nesting, not its length times its unit count. [OutputType([object[]])] [CmdletBinding()] param( # Get-PSCxUnitSpan: unit key -> Start, End (offsets), StartLine, EndLine. [Parameter(Mandatory)] [AllowEmptyCollection()] [hashtable]$Span ) # Measure-Object over no units gives no maximum, which [int] makes 0. $last = [int]($Span.Values.EndLine | Measure-Object -Maximum).Maximum $owner = [object[]]::new($last + 1) $order = @($Span.Keys | Sort-Object @{ Expression = { $Span[$_].End - $Span[$_].Start }; Descending = $true }, @{ Expression = { $_ } }) foreach ($key in $order) { $unit = $Span[$key] for ($line = $unit.StartLine; $line -le $unit.EndLine; $line++) { # Keep a sibling sharing the line, whose text does not overlap this unit's. Anything # painted earlier that does overlap is longer, so it is the parent, and it goes. $kept = @(foreach ($k in @($owner[$line])) { if ($null -ne $k -and ($Span[$k].End -le $unit.Start -or $unit.End -le $Span[$k].Start)) { $k } }) $owner[$line] = $kept + $key } } return , $owner } function Get-PSCxChangedUnit { # The keys of the units a file's hunks change, '<script-body>' among them for a line no unit # holds. # # A changed line belongs to the innermost unit holding it, as its line of code does: editing a # nested function does not change its parent. A pure deletion between two lines belongs to the # innermost unit holding both -- removing a branch changes the unit it was removed from -- and # to the script body when no unit holds both, or at the top of the file. [OutputType([string[]])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [hashtable]$Span, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Hunk ) $owner = Get-PSCxLineOwner -Span $Span $changed = [System.Collections.Generic.HashSet[string]]::new() foreach ($h in $Hunk) { if ($h.Count -eq 0) { [void]$changed.Add((Get-PSCxDeletionOwner -Span $Span -After $h.Start)) continue } for ($line = $h.Start; $line -lt $h.Start + $h.Count; $line++) { # Past the last unit's end the array has no entry -- an index past the end reads # $null -- and the line is the script body's. $keys = $owner[$line] if ($null -eq $keys) { [void]$changed.Add('<script-body>'); continue } foreach ($k in $keys) { [void]$changed.Add($k) } } } return [string[]]@($changed) } function Get-PSCxDeletionOwner { # The innermost unit holding both lines a deletion sits between -- After and the line below it # -- or the script body when none does. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [hashtable]$Span, [Parameter(Mandatory)] [int]$After ) # The units holding both lines nest, so the shortest of them is the innermost. $holding = @($Span.Keys | Where-Object { $Span[$_].StartLine -le $After -and $After + 1 -le $Span[$_].EndLine } | Sort-Object { $Span[$_].End - $Span[$_].Start }) if ($holding.Count -eq 0) { return '<script-body>' } return $holding[0] } |