lib/jsonctext.ps1

# jsonctext.ps1 -- editing JSONC as TEXT, so comments and formatting survive.
#
# WHY THIS EXISTS. lib/vscode.ps1 merges a style into VS Code's settings.json
# by parsing it, mutating the object and serialising it back. That round trip
# destroys every comment and all formatting in a file people hand-annotate.
# lib/wtsettings.ps1 does the same to Windows Terminal's settings.json and has
# always done, which is a smaller sin only because fewer people comment it.
#
# Everything here is pure: text in, text out. No file is read or written, no
# environment is consulted, and nothing depends on the host -- which is what
# lets the tests drive the hard cases as strings.
#
# THE OWNERSHIP RULE, which is the whole design. Four independent designs for
# this were reviewed and every one of them was broken in the same place: what
# text does a member OWN when you delete it? The clever answers -- own the
# comment above you, own your whole line -- each destroyed something in a real
# file: a `// --- terminal ---` section header deleted along with the key under
# it; a trailing `// keep` belonging to the NEXT member eaten; a compact
# `{ "a": 1, "b": 2 }` mangled by a whole-line delete.
#
# So the rule here is the dull one:
#
# A member owns its key, its value, the text between them, and the one
# comma that separates it. It owns NO comment, ever.
#
# An orphaned comment is litter the user can delete in two seconds. A deleted
# comment is their writing, gone, with no undo. Those are not comparable, so
# the rule never has to weigh them. Test-JsoncCommentsIntact turns it into an
# invariant that is CHECKED rather than intended.
#
# The other trap the review found: a separating comma must be written at the
# value's end offset, never at the end of the line. A line ending in a comment
# puts the comma inside the comment, which silently removes the separator and
# breaks the file at the NEXT member.

function Get-JsoncToken {
    <#
    .SYNOPSIS
    Split JSONC text into tokens carrying their source offsets.

    .DESCRIPTION
    Pure. Returns an array of @{ Kind; Start; Length }, covering the text with
    no gaps: joining every token's source text reproduces the input exactly.
    That invariant is what makes splicing safe, and it is asserted directly by
    the tests rather than assumed.

    Kinds: Whitespace, LineComment, BlockComment, String, Colon, Comma,
    BraceOpen, BraceClose, BracketOpen, BracketClose, Other.

    The string state machine is the one lib/wtsettings.ps1's Remove-JsonComment
    already uses -- an $escaped flag, so a backslash-escaped quote does not end
    the string and `\\` before a quote does. That is what keeps braces, commas
    and `//` inside a string value structurally invisible.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)

    $tokens = [System.Collections.Generic.List[object]]::new()
    $n = $Text.Length
    $i = 0
    while ($i -lt $n) {
        $c = $Text[$i]

        if ([char]::IsWhiteSpace($c)) {
            $s = $i
            while ($i -lt $n -and [char]::IsWhiteSpace($Text[$i])) { $i++ }
            $tokens.Add(@{ Kind = 'Whitespace'; Start = $s; Length = $i - $s })
            continue
        }

        if ($c -eq '"') {
            $s = $i
            $i++
            $escaped = $false
            while ($i -lt $n) {
                $ch = $Text[$i]
                if ($escaped)        { $escaped = $false }
                elseif ($ch -eq '\') { $escaped = $true }
                elseif ($ch -eq '"') { $i++; break }
                $i++
            }
            $tokens.Add(@{ Kind = 'String'; Start = $s; Length = $i - $s })
            continue
        }

        if ($c -eq '/' -and ($i + 1) -lt $n -and $Text[$i + 1] -eq '/') {
            $s = $i
            # The newline is NOT part of the comment: it belongs to the
            # whitespace that follows, so a line comment never owns the line
            # break that ends it.
            while ($i -lt $n -and $Text[$i] -ne "`n") { $i++ }
            $tokens.Add(@{ Kind = 'LineComment'; Start = $s; Length = $i - $s })
            continue
        }

        if ($c -eq '/' -and ($i + 1) -lt $n -and $Text[$i + 1] -eq '*') {
            $s = $i
            $i += 2
            while ($i -lt $n -and -not ($Text[$i] -eq '*' -and ($i + 1) -lt $n -and $Text[$i + 1] -eq '/')) { $i++ }
            if ($i -lt $n) { $i += 2 } else { $i = $n }   # unterminated: to EOF
            $tokens.Add(@{ Kind = 'BlockComment'; Start = $s; Length = $i - $s })
            continue
        }

        $kind = switch ($c) {
            ':' { 'Colon' }
            ',' { 'Comma' }
            '{' { 'BraceOpen' }
            '}' { 'BraceClose' }
            '[' { 'BracketOpen' }
            ']' { 'BracketClose' }
            default { $null }
        }
        if ($kind) {
            $tokens.Add(@{ Kind = $kind; Start = $i; Length = 1 })
            $i++
            continue
        }

        # A run of anything else: a number, true/false/null, or junk. The
        # editor never needs to tell those apart -- it only ever replaces a
        # value span wholesale -- so one kind is enough.
        $s = $i
        while ($i -lt $n) {
            $ch = $Text[$i]
            if ([char]::IsWhiteSpace($ch) -or $ch -eq '"' -or $ch -eq ':' -or $ch -eq ',' -or
                $ch -eq '{' -or $ch -eq '}' -or $ch -eq '[' -or $ch -eq ']') { break }
            if ($ch -eq '/' -and ($i + 1) -lt $n -and ($Text[$i + 1] -eq '/' -or $Text[$i + 1] -eq '*')) { break }
            $i++
        }
        $tokens.Add(@{ Kind = 'Other'; Start = $s; Length = $i - $s })
    }
    return ,$tokens.ToArray()
}

function ConvertFrom-JsoncString {
    # The decoded text of a JSON string token, quotes stripped and escapes
    # resolved, so a key written with unicode escapes still matches the plain
    # name a caller asks for. Returns $null if the token is not a well-formed
    # string.
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Raw)

    if ($Raw.Length -lt 2 -or $Raw[0] -ne '"' -or $Raw[$Raw.Length - 1] -ne '"') { return $null }
    $body = $Raw.Substring(1, $Raw.Length - 2)
    $sb = [System.Text.StringBuilder]::new($body.Length)
    $i = 0
    while ($i -lt $body.Length) {
        $c = $body[$i]
        if ($c -ne '\') { [void]$sb.Append($c); $i++; continue }
        $i++
        if ($i -ge $body.Length) { break }
        $e = $body[$i]
        switch ($e) {
            'n' { [void]$sb.Append("`n") }
            't' { [void]$sb.Append("`t") }
            'r' { [void]$sb.Append("`r") }
            'b' { [void]$sb.Append([char]8) }
            'f' { [void]$sb.Append([char]12) }
            'u' {
                if (($i + 4) -lt $body.Length + 1 -and ($i + 5) -le $body.Length) {
                    $hex = $body.Substring($i + 1, 4)
                    $code = 0
                    if ([int]::TryParse($hex, [System.Globalization.NumberStyles]::HexNumber,
                            [cultureinfo]::InvariantCulture, [ref]$code)) {
                        [void]$sb.Append([char]$code)
                        $i += 4
                    } else { [void]$sb.Append($e) }
                } else { [void]$sb.Append($e) }
            }
            default { [void]$sb.Append($e) }   # \" \\ \/ and anything else
        }
        $i++
    }
    return $sb.ToString()
}

function ConvertTo-JsoncString {
    # A string as a JSON string literal. Only the escapes JSON requires.
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Value)

    $sb = [System.Text.StringBuilder]::new($Value.Length + 2)
    [void]$sb.Append('"')
    foreach ($c in $Value.ToCharArray()) {
        $code = [int]$c
        if     ($c -eq '"')  { [void]$sb.Append('\"') }
        elseif ($c -eq '\')  { [void]$sb.Append('\\') }
        elseif ($code -eq 8) { [void]$sb.Append('\b') }
        elseif ($code -eq 9) { [void]$sb.Append('\t') }
        elseif ($code -eq 10){ [void]$sb.Append('\n') }
        elseif ($code -eq 12){ [void]$sb.Append('\f') }
        elseif ($code -eq 13){ [void]$sb.Append('\r') }
        elseif ($code -lt 32) { [void]$sb.Append(('\u{0:x4}' -f $code)) }
        else { [void]$sb.Append($c) }
    }
    [void]$sb.Append('"')
    return $sb.ToString()
}

function ConvertTo-JsoncLiteral {
    # A PowerShell value as JSONC source text. Strings quote; numbers and
    # booleans do not. Anything else is refused rather than guessed at -- a
    # value this does not understand must not reach a user's file.
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowNull()]$Value)

    if ($null -eq $Value) { return 'null' }
    if ($Value -is [bool]) { if ($Value) { return 'true' } else { return 'false' } }
    if ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or
        $Value -is [decimal] -or $Value -is [single]) {
        return [string]::Format([cultureinfo]::InvariantCulture, '{0}', $Value)
    }
    if ($Value -is [string]) { return (ConvertTo-JsoncString -Value $Value) }
    throw "Cannot write a $($Value.GetType().Name) into JSONC text."
}

function Get-JsoncObjectMember {
    <#
    .SYNOPSIS
    The members of the object whose '{' is the token at -OpenIndex.

    .DESCRIPTION
    Pure. Returns an array of member records:

      Name decoded key
      KeyStart offset of the opening quote of the key
      ValueStart offset of the first character of the value
      ValueEnd offset just past the last character of the value
      ValueOpen for an object/array value, the token index of its '{' / '['
      CommaOffset offset of the comma that follows, or -1
      Index, IsLast

    Only the members of THIS object -- a nested object's members belong to that
    object and are found by asking again with its own open token.

    Comments are legal between any two of these pieces and are simply skipped,
    so `"a" /* why */ : 1` parses and its comment is not part of any span.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Tokens,
        [Parameter(Mandatory)][int]$OpenIndex
    )

    $members = [System.Collections.Generic.List[object]]::new()
    $significant = { param($k) $k -ne 'Whitespace' -and $k -ne 'LineComment' -and $k -ne 'BlockComment' }

    $i = $OpenIndex + 1
    $depth = 0
    while ($i -lt $Tokens.Count) {
        $t = $Tokens[$i]
        if (-not (& $significant $t.Kind)) { $i++; continue }
        if ($t.Kind -eq 'BraceClose') { break }
        if ($t.Kind -eq 'Comma') { $i++; continue }
        if ($t.Kind -ne 'String') { $i++; continue }   # junk: skip, do not guess

        $keyToken = $t
        $keyIndex = $i

        # colon
        $i++
        while ($i -lt $Tokens.Count -and -not (& $significant $Tokens[$i].Kind)) { $i++ }
        if ($i -ge $Tokens.Count -or $Tokens[$i].Kind -ne 'Colon') { continue }

        # value
        $i++
        while ($i -lt $Tokens.Count -and -not (& $significant $Tokens[$i].Kind)) { $i++ }
        if ($i -ge $Tokens.Count) { break }

        $valueStartToken = $Tokens[$i]
        $valueOpen = -1
        if ($valueStartToken.Kind -eq 'BraceOpen' -or $valueStartToken.Kind -eq 'BracketOpen') {
            $valueOpen = $i
            $close = if ($valueStartToken.Kind -eq 'BraceOpen') { 'BraceClose' } else { 'BracketClose' }
            $open  = $valueStartToken.Kind
            $depth = 0
            while ($i -lt $Tokens.Count) {
                if ($Tokens[$i].Kind -eq $open)  { $depth++ }
                if ($Tokens[$i].Kind -eq $close) { $depth--; if ($depth -eq 0) { break } }
                $i++
            }
        }
        $valueEndToken = $Tokens[[Math]::Min($i, $Tokens.Count - 1)]
        $valueEnd = $valueEndToken.Start + $valueEndToken.Length

        # the comma that separates this member, if there is one
        $j = $i + 1
        while ($j -lt $Tokens.Count -and -not (& $significant $Tokens[$j].Kind)) { $j++ }
        $commaOffset = -1
        if ($j -lt $Tokens.Count -and $Tokens[$j].Kind -eq 'Comma') {
            $commaOffset = $Tokens[$j].Start
            $i = $j
        }

        $members.Add(@{
            Name        = (ConvertFrom-JsoncString -Raw $Text.Substring($keyToken.Start, $keyToken.Length))
            KeyStart    = $keyToken.Start
            KeyIndex    = $keyIndex
            ValueStart  = $valueStartToken.Start
            ValueEnd    = $valueEnd
            ValueOpen   = $valueOpen
            CommaOffset = $commaOffset
            Index       = $members.Count
            IsLast      = $false
        })
        $i++
    }

    $arr = $members.ToArray()
    if ($arr.Count -gt 0) { $arr[$arr.Count - 1].IsLast = $true }
    # The leading comma stops PowerShell unrolling a ONE-element array into a
    # bare hashtable on the way out. A hashtable's .Count is its KEY count, so
    # a caller doing $m[$m.Count - 1] would index past the end and get $null --
    # silently, on exactly the commonest shape there is: an object with one
    # member.
    return ,$arr
}

function Find-JsoncRootObject {
    # The token index of the document's root '{'. -1 when the text has no
    # object at the top level, which every caller treats as "refuse".
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Tokens)

    for ($i = 0; $i -lt $Tokens.Count; $i++) {
        $k = $Tokens[$i].Kind
        if ($k -eq 'Whitespace' -or $k -eq 'LineComment' -or $k -eq 'BlockComment') { continue }
        if ($k -eq 'BraceOpen') { return $i }
        return -1
    }
    return -1
}

function Get-JsoncEol {
    # The line ending the text already uses. Never assumed: a file written with
    # CRLF must come back with CRLF, or every line shows as changed.
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)
    if ($Text -match "`r`n") { return "`r`n" }
    return "`n"
}

function Get-JsoncIndentUnit {
    # The indentation the file already uses, as a string: the leading
    # whitespace of the first line that has any. Tabs stay tabs.
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)

    foreach ($line in ($Text -split "`n")) {
        $m = [regex]::Match($line, '^([ \t]+)\S')
        if ($m.Success) { return $m.Groups[1].Value }
    }
    return ' '
}

function Invoke-JsoncSplice {
    <#
    .SYNOPSIS
    Apply a list of @{ Start; Length; Text } edits to text. Returns the result.

    .DESCRIPTION
    Pure. Edits are sorted by Start and applied with a single cursor, so the
    offsets in every edit refer to the ORIGINAL text and no edit has to know
    about any other.

    It THROWS on overlap. That is the point of the function: a planning bug
    becomes a loud failure here instead of a quietly mangled settings.json.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Edits
    )

    $sorted = @($Edits | Sort-Object { $_.Start })
    $sb = [System.Text.StringBuilder]::new($Text.Length)
    $cursor = 0
    foreach ($e in $sorted) {
        if ($e.Start -lt $cursor) {
            throw "Overlapping JSONC edits at offset $($e.Start): refusing to write."
        }
        if ($e.Start -gt $Text.Length -or ($e.Start + $e.Length) -gt $Text.Length) {
            throw "JSONC edit runs past the end of the text: refusing to write."
        }
        [void]$sb.Append($Text.Substring($cursor, $e.Start - $cursor))
        if ($e.Text) { [void]$sb.Append($e.Text) }
        $cursor = $e.Start + $e.Length
    }
    [void]$sb.Append($Text.Substring($cursor))
    return $sb.ToString()
}

function Get-JsoncCommentText {
    # Every comment in the text, in order, as source strings. The evidence for
    # the ownership rule: an edit that loses one of these is rejected.
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)

    $out = [System.Collections.Generic.List[string]]::new()
    foreach ($t in (Get-JsoncToken -Text $Text)) {
        if ($t.Kind -eq 'LineComment' -or $t.Kind -eq 'BlockComment') {
            $out.Add($Text.Substring($t.Start, $t.Length))
        }
    }
    return ,$out.ToArray()
}

function Test-JsoncCommentsIntact {
    <#
    .SYNOPSIS
    Did every comment in -Before survive into -After?

    .DESCRIPTION
    Pure, and the invariant this whole file exists to keep. Compares the two
    comment lists as a multiset: every comment present before must still be
    present after, the same number of times. Adding one is fine; losing one is
    not.

    This is a CHECK, not a convention. The four designs reviewed for this each
    intended to preserve comments and each destroyed one in a case its author
    had not thought of, so intent is not the thing to rely on.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Before,
        [Parameter(Mandatory)][AllowEmptyString()][string]$After
    )

    $b = Get-JsoncCommentText -Text $Before
    $a = Get-JsoncCommentText -Text $After
    # An ORDINAL dictionary, not @{}. A PowerShell hashtable compares string
    # keys case-insensitively, so '// mine' and '// MINE' would be the same
    # comment and an edit that rewrote one into the other would pass this
    # check. Comments are the user's text; case is part of it.
    $counts = [System.Collections.Generic.Dictionary[string, int]]::new([System.StringComparer]::Ordinal)
    foreach ($c in $a) {
        if ($counts.ContainsKey($c)) { $counts[$c] = $counts[$c] + 1 } else { $counts[$c] = 1 }
    }
    foreach ($c in $b) {
        if (-not $counts.ContainsKey($c) -or $counts[$c] -le 0) { return $false }
        $counts[$c] = $counts[$c] - 1
    }
    return $true
}

function Resolve-JsoncPath {
    <#
    .SYNOPSIS
    Walk a path of key names and return the member it names, plus its parent.

    .DESCRIPTION
    Pure. -Path is an ARRAY of key names and is never split on anything. That
    is this file's answer to the dotted-key problem: VS Code settings keys
    contain dots ("terminal.integrated.fontSize" is ONE key, and
    "terminal.background" is one key nested inside "workbench.colorCustomizations"),
    so any design that splits a string on '.' cannot tell those apart. The
    caller knows the shape and says so.

    Returns @{ Member; ParentOpenIndex; Found; Reason }. Member is $null when
    the leaf is absent but its parent object exists -- which is what an
    insertion needs. ParentOpenIndex is -1 when the parent itself is missing or
    is not an object, and Reason says which.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Tokens,
        [Parameter(Mandatory)][string[]]$Path
    )

    $rootOpen = Find-JsoncRootObject -Tokens $Tokens
    if ($rootOpen -lt 0) {
        return @{ Member = $null; ParentOpenIndex = -1; Found = $false; Reason = 'rootnotobject' }
    }

    $open = $rootOpen
    for ($p = 0; $p -lt $Path.Count; $p++) {
        $members = Get-JsoncObjectMember -Text $Text -Tokens $Tokens -OpenIndex $open
        $hit = $null
        # Duplicate keys are legal JSONC and the LAST one wins, which is what
        # every JSON reader does -- so take the last match, not the first.
        foreach ($m in $members) { if ($m.Name -eq $Path[$p]) { $hit = $m } }

        if ($p -eq $Path.Count - 1) {
            return @{ Member = $hit; ParentOpenIndex = $open; Found = ($null -ne $hit); Reason = 'ok' }
        }
        if (-not $hit) {
            return @{ Member = $null; ParentOpenIndex = -1; Found = $false; Reason = 'parentmissing' }
        }
        if ($hit.ValueOpen -lt 0 -or $Tokens[$hit.ValueOpen].Kind -ne 'BraceOpen') {
            return @{ Member = $null; ParentOpenIndex = -1; Found = $false; Reason = 'parentnotobject' }
        }
        $open = $hit.ValueOpen
    }
    return @{ Member = $null; ParentOpenIndex = -1; Found = $false; Reason = 'emptypath' }
}

function Get-JsoncLineBound {
    # The half-open span of the line containing -Offset: Start at the first
    # character of the line, ContentEnd before its terminator, End past it.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][int]$Offset
    )

    $start = $Offset
    while ($start -gt 0 -and $Text[$start - 1] -ne "`n") { $start-- }
    $end = $Offset
    while ($end -lt $Text.Length -and $Text[$end] -ne "`n") { $end++ }
    $contentEnd = $end
    if ($contentEnd -gt $start -and $Text[$contentEnd - 1] -eq "`r") { $contentEnd-- }
    if ($end -lt $Text.Length) { $end++ }   # past the newline
    return @{ Start = $start; ContentEnd = $contentEnd; End = $end }
}

function Test-JsoncRangeBlank {
    # Is everything in [Start, End) whitespace? Used to decide whether a line
    # held anything besides the member being removed. A comment is NOT
    # whitespace, which is exactly why a line carrying one is never dropped.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][int]$Start,
        [Parameter(Mandatory)][int]$End
    )
    if ($End -le $Start) { return $true }
    for ($i = $Start; $i -lt $End; $i++) {
        if (-not [char]::IsWhiteSpace($Text[$i])) { return $false }
    }
    return $true
}

function Set-JsoncValue {
    <#
    .SYNOPSIS
    Set a key to a value in JSONC text. Returns @{ Text; Changed; Status }.

    .DESCRIPTION
    Pure. Updates the value in place when the key exists, inserts the member
    when it does not, and refuses when the path's parent is missing or is not
    an object -- a Status of 'rootnotobject', 'parentmissing' or
    'parentnotobject' rather than a guess.

    An inserted member follows the file: its indentation is the indentation of
    the member above it, and an object written on ONE line stays on one line
    rather than being helpfully expanded.

    The separating comma is written at the previous value's END OFFSET, never
    at the end of its line. A line that ends in a comment would otherwise get
    the comma written inside the comment, which deletes the separator and
    breaks the member after it. That is a real defect found in one of the
    designs this replaced, and it is silent: the file still looks right.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][string[]]$Path,
        [Parameter(Mandatory)][AllowNull()]$Value
    )
    return (Set-JsoncLiteral -Text $Text -Path $Path -Literal (ConvertTo-JsoncLiteral -Value $Value))
}

function Get-JsoncValueLiteral {
    <#
    .SYNOPSIS
    The SOURCE TEXT of the value at -Path, or $null when the key is absent.

    .DESCRIPTION
    Pure. Source text, not a parsed value, because the caller comparing this
    wants to know whether the bytes are still the ones it wrote. Parsed
    equality would call '#F5F5F5' and '#f5f5f5' the same value; they are the
    same colour but they are not the same text, and only one of them is ours.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][string[]]$Path
    )

    $tokens = Get-JsoncToken -Text $Text
    $found = Resolve-JsoncPath -Text $Text -Tokens $tokens -Path $Path
    if (-not $found.Member) { return $null }
    return $Text.Substring($found.Member.ValueStart, $found.Member.ValueEnd - $found.Member.ValueStart)
}

function Set-JsoncLiteral {
    # Set a key to a value given as JSONC SOURCE TEXT, with no encoding step.
    # Writing back a value exactly as it was found is what a restore needs, and
    # re-encoding a parsed value cannot promise that.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][string[]]$Path,
        [Parameter(Mandatory)][string]$Literal
    )

    $tokens = Get-JsoncToken -Text $Text
    $found = Resolve-JsoncPath -Text $Text -Tokens $tokens -Path $Path
    $literal = $Literal

    if ($found.ParentOpenIndex -lt 0) {
        return @{ Text = $Text; Changed = $false; Status = $found.Reason }
    }

    # Present: replace just the value span.
    if ($found.Member) {
        $m = $found.Member
        # -cne, not -ne. PowerShell's -eq is case-insensitive, so writing
        # "#F8F8F8" over "#f8f8f8" would report 'same' and change nothing --
        # a silent no-op on an edit the caller asked for.
        if ($Text.Substring($m.ValueStart, $m.ValueEnd - $m.ValueStart) -ceq $literal) {
            return @{ Text = $Text; Changed = $false; Status = 'same' }
        }
        $new = Invoke-JsoncSplice -Text $Text -Edits @(
            @{ Start = $m.ValueStart; Length = ($m.ValueEnd - $m.ValueStart); Text = $literal }
        )
        return @{ Text = $new; Changed = $true; Status = 'updated' }
    }

    # Absent: insert.
    $openTok  = $tokens[$found.ParentOpenIndex]
    $members  = Get-JsoncObjectMember -Text $Text -Tokens $tokens -OpenIndex $found.ParentOpenIndex
    $closeOff = Find-JsoncCloseBrace -Tokens $tokens -OpenIndex $found.ParentOpenIndex
    if ($closeOff -lt 0) { return @{ Text = $Text; Changed = $false; Status = 'unterminated' } }

    $eol  = Get-JsoncEol -Text $Text
    $unit = Get-JsoncIndentUnit -Text $Text
    $keyText = (ConvertTo-JsoncString -Value $Path[$Path.Count - 1]) + ': ' + $literal
    $singleLine = -not ($Text.Substring($openTok.Start, $closeOff - $openTok.Start).Contains("`n"))
    $edits = @()

    if ($singleLine) {
        # Keep a compact object compact. Expanding it would rewrite lines the
        # user never asked us to touch.
        if ($members.Count -eq 0) {
            $edits += @{ Start = ($openTok.Start + 1); Length = 0; Text = $keyText }
        } else {
            $last = $members[$members.Count - 1]
            if ($last.CommaOffset -ge 0) {
                $edits += @{ Start = ($last.CommaOffset + 1); Length = 0; Text = ' ' + $keyText + ',' }
            } else {
                $edits += @{ Start = $last.ValueEnd; Length = 0; Text = ', ' + $keyText }
            }
        }
    } else {
        if ($members.Count -eq 0) {
            $anchorLine = Get-JsoncLineBound -Text $Text -Offset $openTok.Start
            $anchor = [Math]::Min($anchorLine.ContentEnd, $closeOff)
            $braceIndent = [regex]::Match($Text.Substring($anchorLine.Start), '^[ \t]*').Value
            $edits += @{ Start = $anchor; Length = 0; Text = $eol + $braceIndent + $unit + $keyText }
        } else {
            $last = $members[$members.Count - 1]
            $tailOffset = if ($last.CommaOffset -ge 0) { $last.CommaOffset } else { $last.ValueEnd }
            $lastLine = Get-JsoncLineBound -Text $Text -Offset $tailOffset
            $anchor = [Math]::Min($lastLine.ContentEnd, $closeOff)
            $indent = [regex]::Match($Text.Substring($lastLine.Start), '^[ \t]*').Value
            $needComma = ($last.CommaOffset -lt 0)

            if ($needComma -and $last.ValueEnd -eq $anchor) {
                # Nothing between the value and the end of the line, so one
                # edit carries both and there is no ordering question.
                $edits += @{ Start = $anchor; Length = 0; Text = ',' + $eol + $indent + $keyText }
            } else {
                if ($needComma) {
                    $edits += @{ Start = $last.ValueEnd; Length = 0; Text = ',' }
                }
                $edits += @{ Start = $anchor; Length = 0; Text = $eol + $indent + $keyText }
            }
        }
    }

    $new = Invoke-JsoncSplice -Text $Text -Edits $edits
    return @{ Text = $new; Changed = $true; Status = 'inserted' }
}

function Find-JsoncCloseBrace {
    # Offset of the '}' matching the '{' at -OpenIndex, or -1 if unterminated.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Tokens,
        [Parameter(Mandatory)][int]$OpenIndex
    )
    $depth = 0
    for ($i = $OpenIndex; $i -lt $Tokens.Count; $i++) {
        if ($Tokens[$i].Kind -eq 'BraceOpen')  { $depth++ }
        if ($Tokens[$i].Kind -eq 'BraceClose') {
            $depth--
            if ($depth -eq 0) { return $Tokens[$i].Start }
        }
    }
    return -1
}

function Remove-JsoncValue {
    <#
    .SYNOPSIS
    Remove a key from JSONC text. Returns @{ Text; Changed; Status }.

    .DESCRIPTION
    Pure, and the half where the ownership rule bites.

    The member's own span is its key through its value. Exactly one comma goes
    with it -- its own if it has one, otherwise the PREVIOUS member's, since
    removing the last member is what leaves a dangling separator. Those are
    separate edits, so anything sitting between a value and its comma (legal,
    and occasionally a comment) is not inside either.

    The line the member sat on is removed as well ONLY when the line held
    nothing else at all. A line carrying a comment is not blank, so it stays --
    the member goes and the comment remains, orphaned. That is deliberate: an
    orphan comment is two seconds of tidying, and a deleted comment is the
    user's writing with no undo.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][string[]]$Path
    )

    $tokens = Get-JsoncToken -Text $Text
    $found = Resolve-JsoncPath -Text $Text -Tokens $tokens -Path $Path
    if ($found.ParentOpenIndex -lt 0) {
        return @{ Text = $Text; Changed = $false; Status = $found.Reason }
    }
    if (-not $found.Member) {
        return @{ Text = $Text; Changed = $false; Status = 'absent' }
    }

    $m = $found.Member
    $members = Get-JsoncObjectMember -Text $Text -Tokens $tokens -OpenIndex $found.ParentOpenIndex
    $edits = @()

    $delStart = $m.KeyStart
    $delEnd   = $m.ValueEnd
    $ownComma = $m.CommaOffset

    # Its own comma, when that comma is on the same line and nothing but
    # whitespace separates them, folds into the one span.
    if ($ownComma -ge 0 -and (Test-JsoncRangeBlank -Text $Text -Start $delEnd -End $ownComma)) {
        $delEnd = $ownComma + 1
        $ownComma = -1
    }

    # No comma of its own: the previous member's separator is now dangling.
    if ($m.CommaOffset -lt 0) {
        $prev = $null
        foreach ($x in $members) { if ($x.Index -eq ($m.Index - 1)) { $prev = $x } }
        if ($prev -and $prev.CommaOffset -ge 0) {
            $edits += @{ Start = $prev.CommaOffset; Length = 1; Text = '' }
        }
    } elseif ($ownComma -ge 0) {
        $edits += @{ Start = $ownComma; Length = 1; Text = '' }
    }

    # Whole-line removal, but only when the line held nothing else.
    $line = Get-JsoncLineBound -Text $Text -Offset $delStart
    $endLine = Get-JsoncLineBound -Text $Text -Offset ([Math]::Max($delStart, $delEnd - 1))
    if ($line.Start -eq $endLine.Start -and
        (Test-JsoncRangeBlank -Text $Text -Start $line.Start -End $delStart) -and
        (Test-JsoncRangeBlank -Text $Text -Start $delEnd -End $line.ContentEnd)) {
        $delStart = $line.Start
        $delEnd   = $line.End
    } else {
        # Staying on a shared line: take the spacing that belonged to the
        # separator too, or '{ "a": 1, "b": 2 }' loses a member and keeps its
        # gap. Which side depends on which comma went: a member with its own
        # comma leaves a hole after it, a last member leaves one before it.
        # Spaces and tabs only -- never across a line break, and never a
        # comment, which is not whitespace.
        if ($m.CommaOffset -ge 0) {
            while ($delEnd -lt $Text.Length -and ($Text[$delEnd] -eq ' ' -or $Text[$delEnd] -eq "`t")) { $delEnd++ }
        } else {
            while ($delStart -gt 0 -and ($Text[$delStart - 1] -eq ' ' -or $Text[$delStart - 1] -eq "`t")) { $delStart-- }
        }
    }

    $edits += @{ Start = $delStart; Length = ($delEnd - $delStart); Text = '' }
    $new = Invoke-JsoncSplice -Text $Text -Edits $edits
    return @{ Text = $new; Changed = $true; Status = 'removed' }
}