src/Jsonc.ps1
|
# Jsonc.ps1 - JSONC (JSON-with-comments) reading and *surgical* editing. # # Windows Terminal's settings.json is JSONC: // line comments, /* */ block # comments, and tolerated trailing commas. ConvertFrom-Json can't parse it, and # a naive parse->reserialize round-trip throws away every comment the user wrote. # # Remove-JsoncComments handles reading (strip comments, then parse). # Edit-Jsonc* handle writing: they mutate only the spans that change and leave # the rest of the file - comments, formatting, key order - byte-for-byte intact. # --- Reading ------------------------------------------------------------------ # Strip comments while respecting string contents, so a "https://" inside a # value or a "," inside a string is never mistaken for a comment/separator. function Remove-JsoncComments { param([string] $Text) $sb = [System.Text.StringBuilder]::new() $inString = $false; $escaped = $false $i = 0; $n = $Text.Length while ($i -lt $n) { $c = $Text[$i] $next = if ($i + 1 -lt $n) { $Text[$i + 1] } else { [char]0 } if ($inString) { [void]$sb.Append($c) if ($escaped) { $escaped = $false } elseif ($c -eq '\') { $escaped = $true } elseif ($c -eq '"') { $inString = $false } $i++; continue } if ($c -eq '"') { $inString = $true; [void]$sb.Append($c); $i++; continue } if ($c -eq '/' -and $next -eq '/') { while ($i -lt $n -and $Text[$i] -ne "`n") { $i++ }; continue } if ($c -eq '/' -and $next -eq '*') { $i += 2 while ($i -lt $n -and -not ($Text[$i] -eq '*' -and $i + 1 -lt $n -and $Text[$i + 1] -eq '/')) { $i++ } $i += 2; continue } [void]$sb.Append($c); $i++ } $sb.ToString() } function ConvertFrom-Jsonc { param([Parameter(Mandatory)][string] $Text, [switch] $AsHashtable) $clean = Remove-JsoncComments $Text $clean = [regex]::Replace($clean, ',(\s*[}\]])', '$1') # tolerate trailing commas $clean | ConvertFrom-Json -AsHashtable:$AsHashtable } # --- Structural scanning (comment- and string-aware) -------------------------- # Given $Open pointing at a '{' or '[', return the index of its matching close. function Find-JsoncBracketMatch { param([string] $Text, [int] $Open) $openCh = $Text[$Open] $closeCh = if ($openCh -eq '{') { '}' } else { ']' } $depth = 0; $i = $Open; $n = $Text.Length; $inStr = $false; $esc = $false while ($i -lt $n) { $c = $Text[$i] $next = if ($i + 1 -lt $n) { $Text[$i + 1] } else { [char]0 } if ($inStr) { if ($esc) { $esc = $false } elseif ($c -eq '\') { $esc = $true } elseif ($c -eq '"') { $inStr = $false } $i++; continue } if ($c -eq '"') { $inStr = $true; $i++; continue } if ($c -eq '/' -and $next -eq '/') { while ($i -lt $n -and $Text[$i] -ne "`n") { $i++ }; continue } if ($c -eq '/' -and $next -eq '*') { $i += 2; while ($i -lt $n -and -not ($Text[$i] -eq '*' -and $Text[$i + 1] -eq '/')) { $i++ }; $i += 2; continue } if ($c -eq $openCh) { $depth++ } elseif ($c -eq $closeCh) { $depth--; if ($depth -eq 0) { return $i } } $i++ } return -1 } # End index (inclusive) of the JSON value that starts at $Start (first non-ws char). function Get-JsoncValueEnd { param([string] $Text, [int] $Start) $c = $Text[$Start] if ($c -eq '{' -or $c -eq '[') { return (Find-JsoncBracketMatch $Text $Start) } if ($c -eq '"') { $i = $Start + 1; $n = $Text.Length; $esc = $false while ($i -lt $n) { if ($esc) { $esc = $false } elseif ($Text[$i] -eq '\') { $esc = $true } elseif ($Text[$i] -eq '"') { return $i } $i++ } return -1 } # primitive: read until a structural delimiter $i = $Start; $n = $Text.Length while ($i -lt $n -and $Text[$i] -notin @(',', '}', ']') -and $Text[$i] -ne "`n" -and $Text[$i] -ne "`r") { $i++ } return ($i - 1) } # Find a depth-1 member inside the object whose '{' is at $ObjOpen. # Returns @{ ValueStart; ValueEnd } for the member's value, or $null. function Find-JsoncMember { param([string] $Text, [int] $ObjOpen, [string] $Key) $close = Find-JsoncBracketMatch $Text $ObjOpen $i = $ObjOpen + 1; $depth = 0; $inStr = $false; $esc = $false $target = '"' + $Key + '"' while ($i -lt $close) { $c = $Text[$i] $next = if ($i + 1 -lt $close) { $Text[$i + 1] } else { [char]0 } if ($inStr) { if ($esc) { $esc = $false } elseif ($c -eq '\') { $esc = $true } elseif ($c -eq '"') { $inStr = $false } $i++; continue } if ($c -eq '/' -and $next -eq '/') { while ($i -lt $close -and $Text[$i] -ne "`n") { $i++ }; continue } if ($c -eq '/' -and $next -eq '*') { $i += 2; while ($i -lt $close -and -not ($Text[$i] -eq '*' -and $Text[$i + 1] -eq '/')) { $i++ }; $i += 2; continue } if ($c -eq '{' -or $c -eq '[') { $depth++; $i++; continue } if ($c -eq '}' -or $c -eq ']') { $depth--; $i++; continue } if ($c -eq '"') { if ($depth -eq 0 -and ($i + $target.Length -le $close) -and $Text.Substring($i, $target.Length) -eq $target) { $j = $i + $target.Length while ($j -lt $close -and $Text[$j] -ne ':') { $j++ } $j++ # past ':' while ($j -lt $close -and [char]::IsWhiteSpace($Text[$j])) { $j++ } $end = Get-JsoncValueEnd $Text $j return @{ ValueStart = $j; ValueEnd = $end } } $inStr = $true; $i++; continue } $i++ } return $null } # Is the object/array delimited by ($Open..matching) empty (only ws/comments)? function Test-JsoncEmpty { param([string] $Text, [int] $Open) $close = Find-JsoncBracketMatch $Text $Open $inner = $Text.Substring($Open + 1, $close - $Open - 1) return [string]::IsNullOrWhiteSpace((Remove-JsoncComments $inner)) } # Indentation (leading whitespace of the line) at byte offset $At. function Get-JsoncIndent { param([string] $Text, [int] $At) $ls = $Text.LastIndexOf("`n", [Math]::Min($At, $Text.Length - 1)) $i = $ls + 1; $sb = '' while ($i -lt $Text.Length -and ($Text[$i] -eq ' ' -or $Text[$i] -eq "`t")) { $sb += $Text[$i]; $i++ } return $sb } # Set (or insert) a depth-1 member of the object at $ObjOpen to $RawValue # (already-serialized JSON text). Returns the new full text. function Set-JsoncMember { param([string] $Text, [int] $ObjOpen, [string] $Key, [string] $RawValue) $m = Find-JsoncMember $Text $ObjOpen $Key if ($m) { return $Text.Substring(0, $m.ValueStart) + $RawValue + $Text.Substring($m.ValueEnd + 1) } # insert just after the opening brace, matching sibling indentation $indent = (Get-JsoncIndent $Text $ObjOpen) + ' ' $empty = Test-JsoncEmpty $Text $ObjOpen $sep = if ($empty) { '' } else { ',' } $insert = "`n$indent`"$Key`": $RawValue$sep" return $Text.Substring(0, $ObjOpen + 1) + $insert + $Text.Substring($ObjOpen + 1) } # Upsert an object into the array at $ArrOpen, replacing any element whose # "name" equals $Name. $RawValue is the serialized object. Returns new text. function Set-JsoncArrayItemByName { param([string] $Text, [int] $ArrOpen, [string] $Name, [string] $RawValue) $close = Find-JsoncBracketMatch $Text $ArrOpen # find & remove an existing element with matching "name" $i = $ArrOpen + 1; $inStr = $false; $esc = $false while ($i -lt $close) { $c = $Text[$i] $next = if ($i + 1 -lt $close) { $Text[$i + 1] } else { [char]0 } if ($inStr) { if ($esc) { $esc = $false } elseif ($c -eq '\') { $esc = $true } elseif ($c -eq '"') { $inStr = $false } $i++; continue } if ($c -eq '/' -and $next -eq '/') { while ($i -lt $close -and $Text[$i] -ne "`n") { $i++ }; continue } if ($c -eq '/' -and $next -eq '*') { $i += 2; while ($i -lt $close -and -not ($Text[$i] -eq '*' -and $Text[$i + 1] -eq '/')) { $i++ }; $i += 2; continue } if ($c -eq '"') { $inStr = $true; $i++; continue } if ($c -eq '{') { $objEnd = Find-JsoncBracketMatch $Text $i $nm = Find-JsoncMember $Text $i 'name' if ($nm) { $val = $Text.Substring($nm.ValueStart, $nm.ValueEnd - $nm.ValueStart + 1).Trim().Trim('"') if ($val -eq $Name) { # remove this element plus one adjacent comma $s = $i; $e = $objEnd $k = $e + 1 while ($k -lt $close -and [char]::IsWhiteSpace($Text[$k])) { $k++ } if ($k -lt $close -and $Text[$k] -eq ',') { $e = $k } else { $p = $s - 1 while ($p -gt $ArrOpen -and [char]::IsWhiteSpace($Text[$p])) { $p-- } if ($p -gt $ArrOpen -and $Text[$p] -eq ',') { $s = $p } } $Text = $Text.Substring(0, $s) + $Text.Substring($e + 1) $close = Find-JsoncBracketMatch $Text $ArrOpen break } } $i = $objEnd + 1; continue } $i++ } # insert the new element right after '[' $indent = (Get-JsoncIndent $Text $ArrOpen) + ' ' $empty = Test-JsoncEmpty $Text $ArrOpen $body = ($RawValue -split "`n") -join "`n$indent" $sep = if ($empty) { '' } else { ',' } $insert = "`n$indent$body$sep" return $Text.Substring(0, $ArrOpen + 1) + $insert + $Text.Substring($ArrOpen + 1) } |