Private/LineEndings.ps1

# The line-ending audit behind Test-SLLineEnding and Repair-SLLineEnding.
#
# Three layers are kept apart:
# rules what .gitattributes (effective, as git computes it), .editorconfig
# (local and parent files up to root = true) and VS Code workspace
# settings (.vscode/settings.json, *.code-workspace) ask for
# working copy the endings actually in each file (git ls-files --eol, w/)
# stored what git keeps in the index (i/); CRLF there needs renormalizing
#
# Git decides what a checkout writes, so an explicit .gitattributes eol is the
# expected ending. Without one, an .editorconfig end_of_line is. Without either, a
# file only has to be consistent (not mixed). VS Code settings only choose endings
# for new files, so they are checked for conflicts, never used as the rule.

$script:SLVSCodeLanguages = @{
    '.ps1' = 'powershell'; '.psm1' = 'powershell'; '.psd1' = 'powershell'
    '.md' = 'markdown'; '.markdown' = 'markdown'
    '.json' = 'json'; '.jsonc' = 'jsonc'
    '.yml' = 'yaml'; '.yaml' = 'yaml'
    '.cs' = 'csharp'; '.fs' = 'fsharp'; '.vb' = 'vb'
    '.xml' = 'xml'; '.csproj' = 'xml'; '.props' = 'xml'; '.targets' = 'xml'
    '.bat' = 'bat'; '.cmd' = 'bat'
    '.sh' = 'shellscript'; '.bash' = 'shellscript'
    '.js' = 'javascript'; '.mjs' = 'javascript'; '.cjs' = 'javascript'; '.ts' = 'typescript'
    '.html' = 'html'; '.htm' = 'html'; '.css' = 'css'; '.py' = 'python'
    '.sql' = 'sql'; '.txt' = 'plaintext'
}

# ------------------------------------------------------------- git entries --

# Tracked and (optionally) untracked, not ignored files with git's view of them:
# Path (repo-relative, '/'), Tracked, Index, Worktree, Attr.
function Get-SLGitEolEntry {
    param([Parameter(Mandatory)] [string] $RepoPath, [switch] $IncludeUntracked)
    $sets = @(@{ Tracked = $true; Args = @('ls-files', '--eol', '-z') })
    if ($IncludeUntracked) { $sets += @{ Tracked = $false; Args = @('ls-files', '--others', '--exclude-standard', '--eol', '-z') } }
    foreach ($set in $sets) {
        $r = Invoke-SLGit -RepoPath $RepoPath -Arguments $set.Args
        if ($r.ExitCode -ne 0) { throw "git ls-files failed in $($RepoPath): $($r.Errors)" }
        foreach ($entry in ($r.Output -split "`0")) {
            if (-not $entry) { continue }
            $tab = $entry.IndexOf("`t")
            if ($tab -lt 0) { continue }
            $meta = $entry.Substring(0, $tab)
            if ($meta -notmatch '^i/(?<i>\S*)\s+w/(?<w>\S*)\s+attr/(?<a>.*)$') { continue }
            [pscustomobject]@{
                Path     = $entry.Substring($tab + 1)
                Tracked  = $set.Tracked
                Index    = $Matches['i']
                Worktree = $Matches['w']
                Attr     = $Matches['a'].Trim()
            }
        }
    }
}

# The eol a git attribute string asks for ('lf', 'crlf' or $null), and whether it is binary.
function ConvertFrom-SLGitAttr {
    param([string] $Attr)
    $tokens = @($Attr -split '\s+' | Where-Object { $_ })
    $binary = $tokens -contains '-text'
    $eol = $null
    foreach ($t in $tokens) { if ($t -match '^eol=(lf|crlf)$') { $eol = $Matches[1] } }
    $text = @($tokens | Where-Object { $_ -eq 'text' -or $_ -like 'text=*' -or $_ -like 'eol=*' }).Count -gt 0
    return [pscustomobject]@{ Eol = $eol; Binary = $binary; Text = $text }
}

# -------------------------------------------------------------- editorconfig --

# Converts an EditorConfig section glob to a regex over a '/'-separated path relative
# to the .editorconfig folder. Supports * ** ? [set] [!set] {a,b} {n1..n2} and '\'.
# Number ranges become (?<rN>...) groups, checked by Test-SLEditorConfigMatch.
function ConvertTo-SLEditorConfigRegex {
    param([Parameter(Mandatory)] [string] $Glob)
    $ranges = New-Object System.Collections.Generic.List[object]
    $pattern = $Glob
    $anchored = $pattern.Contains('/')
    if ($pattern.StartsWith('/')) { $pattern = $pattern.Substring(1) }
    $body = ConvertFrom-SLGlobPart -Glob $pattern -Ranges $ranges
    $prefix = '^'
    if (-not $anchored) { $prefix = '^(?:.*/)?' }
    return [pscustomobject]@{ Regex = New-Object regex ($prefix + $body + '$'), ([Text.RegularExpressions.RegexOptions]::CultureInvariant); Ranges = $ranges.ToArray() }
}

function ConvertFrom-SLGlobPart {
    param([string] $Glob, [System.Collections.Generic.List[object]] $Ranges)
    $sb = New-Object Text.StringBuilder
    $i = 0
    while ($i -lt $Glob.Length) {
        $c = $Glob[$i]
        if ($c -eq '\' -and $i + 1 -lt $Glob.Length) { [void] $sb.Append([regex]::Escape([string] $Glob[$i + 1])); $i += 2; continue }
        if ($c -eq '*') {
            if ($i + 1 -lt $Glob.Length -and $Glob[$i + 1] -eq '*') {
                # '**/' also matches no folder at all.
                if ($i + 2 -lt $Glob.Length -and $Glob[$i + 2] -eq '/') { [void] $sb.Append('(?:.*/)?'); $i += 3 }
                else { [void] $sb.Append('.*'); $i += 2 }
            }
            else { [void] $sb.Append('[^/]*'); $i++ }
            continue
        }
        if ($c -eq '?') { [void] $sb.Append('[^/]'); $i++; continue }
        if ($c -eq '[') {
            $close = $Glob.IndexOf(']', $i + 1)
            if ($close -gt $i + 1) {
                $set = $Glob.Substring($i + 1, $close - $i - 1)
                $negate = $set.StartsWith('!')
                if ($negate) { $set = $set.Substring(1) }
                $escaped = ($set.ToCharArray() | ForEach-Object { if ($_ -eq '-') { '-' } else { [regex]::Escape([string] $_).Replace(']', '\]') } }) -join ''
                if ($negate) { [void] $sb.Append("[^/$escaped]") } else { [void] $sb.Append("[$escaped]") }
                $i = $close + 1
                continue
            }
            [void] $sb.Append('\['); $i++; continue
        }
        if ($c -eq '{') {
            # Find the matching close brace, allowing nesting.
            $depth = 0; $close = -1
            for ($j = $i; $j -lt $Glob.Length; $j++) {
                if ($Glob[$j] -eq '\') { $j++; continue }
                if ($Glob[$j] -eq '{') { $depth++ }
                elseif ($Glob[$j] -eq '}') { $depth--; if ($depth -eq 0) { $close = $j; break } }
            }
            if ($close -lt 0) { [void] $sb.Append('\{'); $i++; continue }
            $inner = $Glob.Substring($i + 1, $close - $i - 1)
            if ($inner -match '^([+-]?\d+)\.\.([+-]?\d+)$') {
                $name = "r$($Ranges.Count)"
                $Ranges.Add([pscustomobject]@{ Name = $name; Low = [Math]::Min([int] $Matches[1], [int] $Matches[2]); High = [Math]::Max([int] $Matches[1], [int] $Matches[2]) })
                [void] $sb.Append("(?<$name>[+-]?\d+)")
            }
            else {
                $items = Split-SLBraceItem $inner
                if ($items.Count -lt 2) { [void] $sb.Append('\{' + (ConvertFrom-SLGlobPart -Glob $inner -Ranges $Ranges) + '\}') }
                else { [void] $sb.Append('(?:' + ((@($items | ForEach-Object { ConvertFrom-SLGlobPart -Glob $_ -Ranges $Ranges })) -join '|') + ')') }
            }
            $i = $close + 1
            continue
        }
        [void] $sb.Append([regex]::Escape([string] $c))
        $i++
    }
    return $sb.ToString()
}

# Splits brace contents on top-level commas.
function Split-SLBraceItem {
    param([string] $Text)
    $items = New-Object System.Collections.Generic.List[string]
    $depth = 0; $start = 0
    for ($j = 0; $j -lt $Text.Length; $j++) {
        if ($Text[$j] -eq '\') { $j++; continue }
        if ($Text[$j] -eq '{') { $depth++ }
        elseif ($Text[$j] -eq '}') { $depth-- }
        elseif ($Text[$j] -eq ',' -and $depth -eq 0) { $items.Add($Text.Substring($start, $j - $start)); $start = $j + 1 }
    }
    $items.Add($Text.Substring($start))
    return , $items.ToArray()
}

function Test-SLEditorConfigMatch {
    param($Compiled, [string] $RelativePath)
    $m = $Compiled.Regex.Match($RelativePath)
    if (-not $m.Success) { return $false }
    foreach ($r in $Compiled.Ranges) {
        $n = [int] $m.Groups[$r.Name].Value
        if ($n -lt $r.Low -or $n -gt $r.High) { return $false }
    }
    return $true
}

# Parses one .editorconfig: Root, and Sections with Glob, Compiled, Properties
# (name -> Value, Line). Keys are case-insensitive, values for end_of_line lowercased.
function Read-SLEditorConfig {
    param([Parameter(Mandatory)] [string] $Path)
    $lines = @(Read-SLTextLines -Path $Path)
    $root = $false
    $sections = New-Object System.Collections.Generic.List[object]
    $current = $null
    for ($n = 0; $n -lt $lines.Count; $n++) {
        $t = $lines[$n].Trim()
        if (-not $t -or $t.StartsWith('#') -or $t.StartsWith(';')) { continue }
        if ($t.StartsWith('[') -and $t.EndsWith(']')) {
            $glob = $t.Substring(1, $t.Length - 2)
            $current = [pscustomobject]@{ Glob = $glob; Compiled = (ConvertTo-SLEditorConfigRegex -Glob $glob); Properties = @{}; Line = $n + 1 }
            $sections.Add($current)
            continue
        }
        $eq = $t.IndexOf('=')
        if ($eq -lt 1) { continue }
        $key = $t.Substring(0, $eq).Trim().ToLowerInvariant()
        $value = $t.Substring($eq + 1).Trim()
        if ($null -eq $current) {
            if ($key -eq 'root' -and $value -eq 'true') { $root = $true }
            continue
        }
        $current.Properties[$key] = [pscustomobject]@{ Value = $value.ToLowerInvariant(); Line = $n + 1 }
    }
    return [pscustomobject]@{ Path = $Path; Directory = (Split-Path $Path -Parent); Root = $root; Sections = $sections.ToArray() }
}

# Resolves end_of_line for absolute file paths, caching parsed files and folder chains.
function New-SLEditorConfigResolver {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Returns a new in-memory cache; changes no state.')]
    param()
    return @{ Files = @{}; Chains = @{} }
}

# The applicable .editorconfig files for a folder, farthest first, stopping at root = true.
function Get-SLEditorConfigChain {
    param([Parameter(Mandatory)] [hashtable] $Resolver, [Parameter(Mandatory)] [string] $Directory)
    if ($Resolver.Chains.ContainsKey($Directory)) { return , $Resolver.Chains[$Directory] }
    $chain = @()
    $dir = $Directory
    while ($dir) {
        $file = Join-Path $dir '.editorconfig'
        if (Test-Path -LiteralPath $file -PathType Leaf) {
            if (-not $Resolver.Files.ContainsKey($file)) { $Resolver.Files[$file] = Read-SLEditorConfig -Path $file }
            $parsed = $Resolver.Files[$file]
            $chain = @($parsed) + $chain
            if ($parsed.Root) { break }
        }
        $parent = Split-Path $dir -Parent
        if (-not $parent -or $parent -eq $dir) { break }
        $dir = $parent
    }
    $Resolver.Chains[$Directory] = $chain
    return , $chain
}

# end_of_line for one file: Value (lf | crlf | cr | $null), File, Line, Chain (paths).
# 'unset' clears an earlier value, as the specification says.
function Resolve-SLEditorConfigEol {
    param([Parameter(Mandatory)] [hashtable] $Resolver, [Parameter(Mandatory)] [string] $FullPath)
    $chain = Get-SLEditorConfigChain -Resolver $Resolver -Directory (Split-Path $FullPath -Parent)
    $value = $null; $file = $null; $line = 0
    foreach ($config in $chain) {
        $relative = $FullPath.Substring($config.Directory.TrimEnd('\', '/').Length + 1).Replace('\', '/')
        foreach ($section in $config.Sections) {
            if (-not $section.Properties.ContainsKey('end_of_line')) { continue }
            if (-not (Test-SLEditorConfigMatch -Compiled $section.Compiled -RelativePath $relative)) { continue }
            $p = $section.Properties['end_of_line']
            if ($p.Value -eq 'unset') { $value = $null; $file = $null; $line = 0 }
            else { $value = $p.Value; $file = $config.Path; $line = $p.Line }
        }
    }
    return [pscustomobject]@{ Value = $value; File = $file; Line = $line; Chain = @($chain | ForEach-Object { $_.Path }) }
}

# ------------------------------------------------------------------ vs code --

# Replaces // and /* */ comments outside strings with spaces, keeping every offset.
function Clear-SLJsonComment {
    param([string] $Text)
    $chars = $Text.ToCharArray()
    $i = 0
    while ($i -lt $chars.Length) {
        $c = $chars[$i]
        if ($c -eq '"') {
            $i++
            while ($i -lt $chars.Length -and $chars[$i] -ne '"') { if ($chars[$i] -eq '\') { $i++ }; $i++ }
            $i++
            continue
        }
        if ($c -eq '/' -and $i + 1 -lt $chars.Length -and $chars[$i + 1] -eq '/') {
            while ($i -lt $chars.Length -and $chars[$i] -ne "`n") { if ($chars[$i] -ne "`r") { $chars[$i] = ' ' }; $i++ }
            continue
        }
        if ($c -eq '/' -and $i + 1 -lt $chars.Length -and $chars[$i + 1] -eq '*') {
            while ($i -lt $chars.Length -and -not ($chars[$i] -eq '*' -and $i + 1 -lt $chars.Length -and $chars[$i + 1] -eq '/')) {
                if ($chars[$i] -ne "`r" -and $chars[$i] -ne "`n") { $chars[$i] = ' ' }
                $i++
            }
            if ($i -lt $chars.Length) { $chars[$i] = ' '; if ($i + 1 -lt $chars.Length) { $chars[$i + 1] = ' ' } }
            $i += 2
            continue
        }
        $i++
    }
    return (New-Object string (, $chars))
}

# Every "files.eol" string value in a VS Code settings or workspace file:
# Context (the enclosing object keys), Raw (as written, e.g. \n), Eol (lf | crlf | auto),
# Start and Length (of the raw value, for an in-place edit).
function Find-SLVSCodeEolSetting {
    param([Parameter(Mandatory)] [string] $Text)
    $clean = Clear-SLJsonComment $Text
    $stack = New-Object System.Collections.Generic.List[string]
    $pendingKey = $null
    $i = 0
    while ($i -lt $clean.Length) {
        $c = $clean[$i]
        if ($c -eq '"') {
            $j = $i + 1
            while ($j -lt $clean.Length -and $clean[$j] -ne '"') { if ($clean[$j] -eq '\') { $j++ }; $j++ }
            $raw = $clean.Substring($i + 1, $j - $i - 1)
            $k = $j + 1
            while ($k -lt $clean.Length -and [char]::IsWhiteSpace($clean[$k])) { $k++ }
            if ($k -lt $clean.Length -and $clean[$k] -eq ':') { $pendingKey = $raw; $i = $k + 1; continue }
            if ($pendingKey -eq 'files.eol') {
                $eol = switch ($raw) { '\n' { 'lf' } '\r\n' { 'crlf' } default { 'auto' } }
                [pscustomobject]@{ Context = @($stack.ToArray() | Select-Object -Skip 1); Raw = $raw; Eol = $eol; Start = $i + 1; Length = $raw.Length }
            }
            $pendingKey = $null
            $i = $j + 1
            continue
        }
        if ($c -eq '{') { $key = ''; if ($pendingKey) { $key = $pendingKey }; $stack.Add($key); $pendingKey = $null }
        elseif ($c -eq '[') { $stack.Add(''); $pendingKey = $null }
        elseif ($c -eq '}' -or $c -eq ']') { if ($stack.Count -gt 0) { $stack.RemoveAt($stack.Count - 1) } }
        elseif ($c -eq ',') { $pendingKey = $null }
        $i++
    }
}

# The repo's VS Code sources: .vscode/settings.json and root *.code-workspace files.
# Each: Path, Kind (Folder | Workspace), Settings (from Find-SLVSCodeEolSetting).
function Get-SLVSCodeSource {
    param([Parameter(Mandatory)] [string] $RepoPath)
    $files = @()
    $folder = Join-Path $RepoPath '.vscode\settings.json'
    if (Test-Path -LiteralPath $folder -PathType Leaf) { $files += [pscustomobject]@{ Path = $folder; Kind = 'Folder' } }
    foreach ($w in @(Get-ChildItem -LiteralPath $RepoPath -Filter '*.code-workspace' -File -ErrorAction SilentlyContinue)) {
        $files += [pscustomobject]@{ Path = $w.FullName; Kind = 'Workspace' }
    }
    foreach ($f in $files) {
        $all = @(Find-SLVSCodeEolSetting -Text (Read-SLTextFile -Path $f.Path).Text)
        # A workspace file keeps its settings under "settings"; a folder file at the top.
        $settings = @(foreach ($s in $all) {
                $ctx = @($s.Context)
                if ($f.Kind -eq 'Workspace') {
                    if ($ctx.Count -eq 0 -or $ctx[0] -ne 'settings') { continue }
                    $ctx = @($ctx | Select-Object -Skip 1)
                }
                if ($ctx.Count -gt 1) { continue }
                $language = $null
                if ($ctx.Count -eq 1) {
                    if ($ctx[0] -notmatch '^(\[[^\]]+\])+$') { continue }
                    $language = @([regex]::Matches($ctx[0], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value })
                }
                $s | Add-Member -NotePropertyName Languages -NotePropertyValue $language -PassThru
            })
        [pscustomobject]@{ Path = $f.Path; Kind = $f.Kind; Settings = $settings }
    }
}

# The files.eol that decides new files of this language in one source: language-specific first, then global.
function Resolve-SLVSCodeEol {
    param($Source, [string] $Language)
    if ($Language) {
        $specific = @($Source.Settings | Where-Object { $_.Languages -and $_.Languages -contains $Language })
        if ($specific.Count -gt 0) { return $specific[-1] }
    }
    $global = @($Source.Settings | Where-Object { -not $_.Languages })
    if ($global.Count -gt 0) { return $global[-1] }
    return $null
}

# -------------------------------------------------------------------- audit --

# Audits one repo. Returns Repo, Path, Files (one record per file), Issues, Notes
# and Ok. Kinds of issue:
# Mixed the working copy has both LF and CRLF
# WrongEnding the working copy uses a different ending than the rule
# Renormalize git stores CRLF for a text file (stored normalization)
# EditorConfigConflict .editorconfig end_of_line disagrees with .gitattributes eol
# VSCodeConflict an explicit files.eol disagrees with the effective rule
function Get-SLLineEndingAudit {
    param([Parameter(Mandatory)] [string] $RepoPath, [switch] $IncludeUntracked)
    $resolver = New-SLEditorConfigResolver
    $vscode = @(Get-SLVSCodeSource -RepoPath $RepoPath)
    $records = New-Object System.Collections.Generic.List[object]
    $issues = New-Object System.Collections.Generic.List[object]
    $autoOnly = 0
    foreach ($e in @(Get-SLGitEolEntry -RepoPath $RepoPath -IncludeUntracked:$IncludeUntracked)) {
        $full = Join-Path $RepoPath ($e.Path.Replace('/', [IO.Path]::DirectorySeparatorChar))
        if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { continue }   # deleted, or a submodule
        $git = ConvertFrom-SLGitAttr $e.Attr
        $binary = $git.Binary -or $e.Worktree -eq '-text'
        $record = [pscustomobject]@{
            Path = $e.Path; FullPath = $full; Tracked = $e.Tracked; Index = $e.Index; Worktree = $e.Worktree; Attr = $e.Attr
            Binary = $binary; GitEol = $git.Eol; EditorConfig = $null; VSCode = @(); Expected = $null; ExpectedSource = $null
            Extension = [IO.Path]::GetExtension($e.Path).ToLowerInvariant(); Language = $null
        }
        $records.Add($record)
        if ($binary) { continue }

        $ec = Resolve-SLEditorConfigEol -Resolver $resolver -FullPath $full
        $record.EditorConfig = $ec
        if ($git.Eol) { $record.Expected = $git.Eol; $record.ExpectedSource = ".gitattributes ($($e.Attr))" }
        elseif ($ec.Value -in 'lf', 'crlf') { $record.Expected = $ec.Value; $record.ExpectedSource = "$($ec.File):$($ec.Line)" }
        elseif ($git.Text) { $autoOnly++ }

        if ($git.Eol -and $ec.Value -and $ec.Value -ne $git.Eol) {
            $issues.Add((New-SLLineIssue $record 'EditorConfigConflict' $git.Eol $ec.Value "$($ec.File):$($ec.Line)" "end_of_line = $($ec.Value), but .gitattributes says eol=$($git.Eol)"))
        }
        if ($script:SLVSCodeLanguages.ContainsKey($record.Extension)) { $record.Language = $script:SLVSCodeLanguages[$record.Extension] }
        foreach ($source in $vscode) {
            $setting = Resolve-SLVSCodeEol -Source $source -Language $record.Language
            if (-not $setting) { continue }
            $record.VSCode += [pscustomobject]@{ Source = $source; Setting = $setting }
            if ($record.Expected -and $setting.Eol -ne 'auto' -and $setting.Eol -ne $record.Expected) {
                $key = 'files.eol'
                if ($setting.Languages) { $key = "[$($setting.Languages -join '][')] files.eol" }
                $issues.Add((New-SLLineIssue $record 'VSCodeConflict' $record.Expected $setting.Eol $source.Path "$key = `"$($setting.Raw)`", but the rule is $($record.Expected) ($($record.ExpectedSource))"))
            }
        }

        if ($e.Worktree -eq 'mixed') {
            $issues.Add((New-SLLineIssue $record 'Mixed' $record.Expected 'mixed' $record.ExpectedSource 'both LF and CRLF line endings'))
        }
        elseif ($record.Expected -and $e.Worktree -in 'lf', 'crlf' -and $e.Worktree -ne $record.Expected) {
            $issues.Add((New-SLLineIssue $record 'WrongEnding' $record.Expected $e.Worktree $record.ExpectedSource "working copy is $($e.Worktree.ToUpperInvariant())"))
        }
        if ($e.Tracked -and $git.Text -and $e.Index -in 'crlf', 'mixed') {
            $issues.Add((New-SLLineIssue $record 'Renormalize' 'lf' $e.Index 'git index' "stored with $($e.Index.ToUpperInvariant()); renormalize with Set-SLGitAttributes or 'git add --renormalize'"))
        }
    }
    $notes = @()
    if ($autoOnly -gt 0) { $notes += "$autoOnly text file(s) have no explicit eol rule (text=auto or text); their checkout ending depends on each machine's core.autocrlf / core.eol, so only mixed endings are flagged." }
    if ($vscode.Count -gt 0) { $notes += 'VS Code files.eol applies to new files only; files.associations and user-level settings are not evaluated.' }
    $notes += 'Editor settings cannot control scripts or generators: they must write the declared endings themselves.'
    $repoIssues = $issues.ToArray()
    return [pscustomobject]@{
        PSTypeName   = 'SecondLine.LineEndingCheck'
        Repo         = Split-Path $RepoPath -Leaf
        Path         = $RepoPath
        FilesChecked = @($records | Where-Object { -not $_.Binary }).Count
        Binary       = @($records | Where-Object { $_.Binary }).Count
        Issues       = $repoIssues
        Ok           = $repoIssues.Count -eq 0
        Notes        = $notes
        Files        = $records.ToArray()
        VSCode       = $vscode
        EditorConfig = @($resolver.Files.Values)
    }
}

function New-SLLineIssue {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Returns a new in-memory object; changes no state.')]
    param($Record, [string] $Kind, [string] $Expected, [string] $Actual, [string] $Source, [string] $Detail)
    return [pscustomobject]@{ File = $Record.Path; Kind = $Kind; Expected = $Expected; Actual = $Actual; Source = $Source; Detail = $Detail; Tracked = $Record.Tracked }
}

# ---------------------------------------------------------------- repairs --

# Converts every line ending in Text to Ending ('lf' or 'crlf'). Lone CRs are kept.
function ConvertTo-SLLineEnding {
    param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Text, [Parameter(Mandatory)] [ValidateSet('lf', 'crlf')] [string] $Ending)
    $lfOnly = $Text -replace "`r`n", "`n"
    if ($Ending -eq 'lf') { return $lfOnly }
    return ($lfOnly -replace "`n", "`r`n")
}

# The config edits that make .editorconfig and VS Code settings agree with
# .gitattributes, each with File, Kind, Description and either an in-place edit
# (Start, Length, Replacement in a text-file read) or an Append, or Manual guidance.
function Get-SLConfigRepairPlan {
    param([Parameter(Mandatory)] $Audit)
    $plans = @()
    $text = @($Audit.Files | Where-Object { -not $_.Binary -and $_.EditorConfig })

    # EditorConfig: group conflicts by the line that decided them.
    $conflicts = @($Audit.Issues | Where-Object Kind -eq 'EditorConfigConflict')
    foreach ($group in @($conflicts | Group-Object Source)) {
        $file, $lineText = $group.Name -split ':(?=\d+$)'
        $line = [int] $lineText
        $governed = @($text | Where-Object { $_.EditorConfig.File -eq $file -and $_.EditorConfig.Line -eq $line })
        $wanted = @($governed | ForEach-Object { $_.GitEol } | Sort-Object -Unique)
        $soleRule = @($governed | Where-Object { -not $_.GitEol })
        if ($wanted.Count -eq 1 -and $soleRule.Count -eq 0) {
            $plans += [pscustomobject]@{ File = $file; Kind = 'EditorConfig'; Action = 'Replace'; Line = $line; Value = $wanted[0]
                Description = "$($file):$line end_of_line -> $($wanted[0]) (every file it governs has .gitattributes eol=$($wanted[0]))"
            }
            continue
        }
        # Otherwise add a section per extension, where that is safe: every file the new
        # section would decide must already have that eol in .gitattributes.
        $byExt = @{}
        foreach ($issue in @($group.Group)) {
            $record = $text | Where-Object Path -eq $issue.File | Select-Object -First 1
            $ext = $record.Extension
            if (-not $ext -or $ext -notmatch '^\.[A-Za-z0-9_+-]+$') { continue }
            $byExt[$ext] = $issue.Expected
        }
        $safe = @{}
        foreach ($ext in $byExt.Keys) {
            $affected = @($text | Where-Object { $_.Extension -eq $ext -and $_.EditorConfig.Chain -contains $file -and (-not $_.EditorConfig.File -or $_.EditorConfig.File -eq $file) })
            if (@($affected | Where-Object { $_.GitEol -ne $byExt[$ext] }).Count -eq 0) { $safe[$ext] = $byExt[$ext] }
        }
        foreach ($value in @($safe.Values | Sort-Object -Unique)) {
            $exts = @($safe.Keys | Where-Object { $safe[$_] -eq $value } | Sort-Object | ForEach-Object { $_.Substring(1) })
            $glob = "*.$($exts[0])"
            if ($exts.Count -gt 1) { $glob = "*.{$($exts -join ',')}" }
            $plans += [pscustomobject]@{ File = $file; Kind = 'EditorConfig'; Action = 'Append'; Line = 0; Value = $value
                Section = @('', '# Added by SecondLine: match .gitattributes', "[$glob]", "end_of_line = $value")
                Description = "$file append [$glob] end_of_line = $value (overrides line $line for these files only)"
            }
        }
        $left = @($byExt.Keys | Where-Object { -not $safe.ContainsKey($_) })
        if ($left.Count -gt 0 -or $byExt.Count -eq 0) {
            $plans += [pscustomobject]@{ File = $file; Kind = 'EditorConfig'; Action = 'Manual'; Line = $line; Value = $null
                Description = "$($file):$line governs files with different or no .gitattributes eol rules ($((@($left) -join ', '))); add narrower sections by hand."
            }
        }
    }

    # VS Code: per setting, the value every governed file's rule agrees on, else "auto".
    foreach ($group in @($Audit.Issues | Where-Object Kind -eq 'VSCodeConflict' | Group-Object Source)) {
        $source = $Audit.VSCode | Where-Object Path -eq $group.Name | Select-Object -First 1
        foreach ($setting in @($source.Settings)) {
            $governed = @($Audit.Files | Where-Object { $_.Expected -and @($_.VSCode | Where-Object { $_.Setting -eq $setting }).Count -gt 0 })
            $wanted = @($governed | ForEach-Object { $_.Expected } | Sort-Object -Unique)
            if ($wanted.Count -eq 0 -or ($wanted.Count -eq 1 -and $wanted[0] -eq $setting.Eol)) { continue }
            $value = 'auto'
            if ($wanted.Count -eq 1) { $value = $wanted[0] }
            $raw = @{ lf = '\n'; crlf = '\r\n'; auto = 'auto' }[$value]
            $key = 'files.eol'
            if ($setting.Languages) { $key = "[$($setting.Languages -join '][')] files.eol" }
            $plans += [pscustomobject]@{ File = $source.Path; Kind = 'VSCode'; Action = 'Replace'; Start = $setting.Start; Length = $setting.Length; Value = $raw
                Description = "$($source.Path) $key `"$($setting.Raw)`" -> `"$raw`""
            }
        }
    }
    return $plans
}