Private/Json.ps1

# Tolerant JSON (JSONC) reading: the settings.json files of VS Code and Windows
# Terminal contain comments and sometimes trailing commas.

function Remove-TcJsonComments {
    <#
        .SYNOPSIS
        Replaces // and /* */ comments with spaces, preserving positions
        (essential for the surgical editing of settings.json) and ignoring
        anything inside strings.
    #>

    [CmdletBinding()]
    param([string] $Text)

    if ([string]::IsNullOrEmpty($Text)) { return $Text }

    $sb = New-Object System.Text.StringBuilder($Text.Length)
    $i = 0
    $len = $Text.Length
    $inString = $false

    while ($i -lt $len) {
        $c = $Text[$i]

        if ($inString) {
            [void]$sb.Append($c)
            if ($c -eq '\') {
                if ($i + 1 -lt $len) { [void]$sb.Append($Text[$i + 1]); $i += 2; continue }
            } elseif ($c -eq '"') {
                $inString = $false
            }
            $i++
            continue
        }

        if ($c -eq '"') { $inString = $true; [void]$sb.Append($c); $i++; continue }

        if ($c -eq '/' -and $i + 1 -lt $len) {
            $n = $Text[$i + 1]
            if ($n -eq '/') {
                while ($i -lt $len -and $Text[$i] -ne "`n") {
                    if ($Text[$i] -eq "`r") { [void]$sb.Append($Text[$i]) } else { [void]$sb.Append(' ') }
                    $i++
                }
                continue
            }
            if ($n -eq '*') {
                $i += 2
                [void]$sb.Append(' ')
                while ($i -lt $len) {
                    if ($Text[$i] -eq '*' -and $i + 1 -lt $len -and $Text[$i + 1] -eq '/') {
                        [void]$sb.Append(' '); $i += 2; break
                    }
                    if ($Text[$i] -eq "`n" -or $Text[$i] -eq "`r") { [void]$sb.Append($Text[$i]) } else { [void]$sb.Append(' ') }
                    $i++
                }
                continue
            }
        }

        [void]$sb.Append($c)
        $i++
    }

    return $sb.ToString()
}

function ConvertFrom-TcJsonText {
    <#
        .SYNOPSIS
        Parses JSONC. Returns $null on failure (never throws).
    #>

    [CmdletBinding()]
    param([string] $Text)

    if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
    try {
        $clean = Remove-TcJsonComments -Text $Text
        # Trailing commas
        $clean = [regex]::Replace($clean, ',(\s*[}\]])', '$1')
        return $clean | ConvertFrom-Json
    } catch {
        Write-Verbose "TerminalColors: unreadable JSON ($($_.Exception.Message))"
        return $null
    }
}

function ConvertFrom-TcJsonFile {
    [CmdletBinding()]
    param([string] $Path)

    try {
        $raw = [System.IO.File]::ReadAllText($Path)
    } catch {
        Write-Verbose "TerminalColors: could not read [$Path] ($($_.Exception.Message))"
        return $null
    }
    return ConvertFrom-TcJsonText -Text $raw
}

function Get-TcJsonProperty {
    <#
        .SYNOPSIS
        Reads a property whose name contains dots ([peacock.color]) from an object
        produced by ConvertFrom-Json, without raising an error.
    #>

    [CmdletBinding()]
    param(
        # Deliberately not mandatory: callers chain reads over objects that may be
        # absent.
        $InputObject,
        [Parameter(Mandatory)] [string] $Name
    )

    if ($null -eq $InputObject) { return $null }
    $prop = $InputObject.PSObject.Properties[$Name]
    if ($null -eq $prop) { return $null }
    return $prop.Value
}