Wjson.psm1

# Wjson PowerShell module: fast, strict JSON validation, conversion,
# formatting and JSON Pointer queries backed by the native wjson library.
# SPDX-License-Identifier: MIT
Set-StrictMode -Version 3.0

# --- Load the .NET binding and point it at the native library ---------------

$useNet8 = $PSVersionTable.PSEdition -eq 'Core' -and [Environment]::Version.Major -ge 8
$framework = if ($useNet8) { 'net8.0' } else { 'netstandard2.0' }
$assemblyPath = Join-Path $PSScriptRoot "lib/$framework/Imontene.Wjson.dll"
Add-Type -Path $assemblyPath

$isWindowsOS = $PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows
$arch = switch ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture) {
    'X86' { 'x86' }
    'Arm64' { 'arm64' }
    default { 'x64' }
}
$rid, $file = if ($isWindowsOS) { "win-$arch", 'wjson.dll' }
              elseif ($IsMacOS) { "osx-$arch", 'libwjson.dylib' }
              else { "linux-$arch", 'libwjson.so' }
$nativePath = Join-Path $PSScriptRoot "runtimes/$rid/native/$file"
if (Test-Path -LiteralPath $nativePath) {
    [Wjson.WJson]::NativeLibraryPath = $nativePath
}

# --- Helpers ----------------------------------------------------------------

function New-WJsonOptions([bool] $Jsonc, [int] $MaxDepth) {
    $options = if ($Jsonc) { [Wjson.ParseOptions]::Jsonc } else { [Wjson.ParseOptions]::new() }
    if ($MaxDepth -gt 0) { $options.MaxDepth = $MaxDepth }
    $options
}

# Converts JsonObject/JsonArray trees into PSCustomObject/object[] trees, the
# same shapes that ConvertFrom-Json produces.
function ConvertTo-PSValue($Value) {
    if ($Value -is [Wjson.JsonObject]) {
        $properties = [ordered]@{}
        foreach ($entry in $Value.GetEnumerator()) {
            $properties[$entry.Key] = ConvertTo-PSValue $entry.Value
        }
        return [pscustomobject]$properties
    }
    if ($Value -is [Wjson.JsonArray]) {
        $items = [object[]]::new($Value.Count)
        for ($i = 0; $i -lt $Value.Count; $i++) { $items[$i] = ConvertTo-PSValue $Value[$i] }
        return , $items
    }
    $Value
}

function New-WJsonErrorRecord([Wjson.JsonParseError] $ParseError, [string] $Source, $Target) {
    $where = if ($ParseError.Line -gt 0) { "${Source}:$($ParseError.Line):$($ParseError.Column)" } else { $Source }
    $exception = [System.FormatException]::new("${where}: $($ParseError.Message)")
    [System.Management.Automation.ErrorRecord]::new(
        $exception, "WJson.$($ParseError.Code)", [System.Management.Automation.ErrorCategory]::InvalidData, $Target)
}

# Collects pipeline input the way ConvertFrom-Json does: several strings (for
# example lines from Get-Content without -Raw) are joined into one document.
function Join-WJsonInput([System.Collections.Generic.List[string]] $Lines) {
    if ($Lines.Count -eq 1) { return $Lines[0] }
    $Lines -join "`n"
}

# --- Public functions -------------------------------------------------------

function Test-WJson {
    <#
    .SYNOPSIS
    Checks whether text or a file is valid JSON.
    .DESCRIPTION
    Files are validated in streaming mode, so files of any size use constant
    memory. UTF-8 (with or without BOM) and UTF-16 (with BOM) are supported.
    .PARAMETER Json
    JSON text. Accepts pipeline input; several strings are joined with newlines.
    .PARAMETER Path
    Path of a JSON file.
    .PARAMETER Jsonc
    Accept comments and trailing commas (JSON with Comments).
    .PARAMETER MaxDepth
    Maximum nesting depth (default 512).
    .PARAMETER Detailed
    Return an object with the result and the error position instead of a boolean.
    .EXAMPLE
    Test-WJson -Path .\settings.json -Jsonc
    .EXAMPLE
    Get-Content data.json -Raw | Test-WJson -Detailed
    #>

    [CmdletBinding(DefaultParameterSetName = 'Json')]
    [OutputType([bool], [pscustomobject])]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = 'Json')]
        [AllowEmptyString()]
        [string] $Json,

        [Parameter(Mandatory, ParameterSetName = 'Path')]
        [Alias('FullName')]
        [string] $Path,

        [switch] $Jsonc,
        [int] $MaxDepth,
        [switch] $Detailed
    )
    begin { $lines = [System.Collections.Generic.List[string]]::new() }
    process { if ($PSCmdlet.ParameterSetName -eq 'Json') { $lines.Add($Json) } }
    end {
        $options = New-WJsonOptions $Jsonc $MaxDepth
        $parseError = $null
        if ($PSCmdlet.ParameterSetName -eq 'Path') {
            $fullPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path)
            $valid = [Wjson.WJson]::TryValidateFile($fullPath, [ref] $parseError, $options)
        } else {
            $valid = [Wjson.WJson]::TryValidate((Join-WJsonInput $lines), [ref] $parseError, $options)
        }
        if (-not $Detailed) { return $valid }
        [pscustomobject]@{
            Valid   = $valid
            Line    = if ($parseError) { $parseError.Line } else { $null }
            Column  = if ($parseError) { $parseError.Column } else { $null }
            Offset  = if ($parseError) { $parseError.Offset } else { $null }
            Code    = if ($parseError) { $parseError.Code } else { $null }
            Message = if ($parseError) { $parseError.Message } else { $null }
        }
    }
}

function ConvertFrom-WJson {
    <#
    .SYNOPSIS
    Converts JSON text or a file into PowerShell objects.
    .DESCRIPTION
    Like ConvertFrom-Json, but with strict RFC 8259 parsing, precise error
    positions, JSONC support and UTF-16 file support. Objects keep their member
    order. Integers that fit in 64 bits become [long]; other numbers [double].
    .PARAMETER AsHashtable
    Return ordered dictionaries ([Wjson.JsonObject]) instead of PSCustomObject.
    .PARAMETER NoEnumerate
    Output a top-level array as a single object instead of enumerating it.
    .EXAMPLE
    $settings = ConvertFrom-WJson -Path "$env:APPDATA\Code\User\settings.json" -Jsonc
    #>

    [CmdletBinding(DefaultParameterSetName = 'Json')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = 'Json')]
        [AllowEmptyString()]
        [string] $Json,

        [Parameter(Mandatory, ParameterSetName = 'Path')]
        [Alias('FullName')]
        [string] $Path,

        [switch] $Jsonc,
        [int] $MaxDepth,
        [switch] $AsHashtable,
        [switch] $NoEnumerate
    )
    begin { $lines = [System.Collections.Generic.List[string]]::new() }
    process { if ($PSCmdlet.ParameterSetName -eq 'Json') { $lines.Add($Json) } }
    end {
        $options = New-WJsonOptions $Jsonc $MaxDepth
        try {
            if ($PSCmdlet.ParameterSetName -eq 'Path') {
                $source = $Path
                $value = [Wjson.WJson]::ParseFile($PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path), $options)
            } else {
                $source = '<input>'
                $value = [Wjson.WJson]::Parse((Join-WJsonInput $lines), $options)
            }
        } catch [Wjson.JsonParseException] {
            $PSCmdlet.ThrowTerminatingError((New-WJsonErrorRecord $_.Exception.Error $source $null))
        }
        $result = if ($AsHashtable) { $value } else { ConvertTo-PSValue $value }
        Write-Output -InputObject $result -NoEnumerate:$NoEnumerate
    }
}

function Format-WJson {
    <#
    .SYNOPSIS
    Pretty-prints or minifies JSON.
    .PARAMETER Indent
    Spaces per level (default 2).
    .PARAMETER Minify
    Remove all insignificant whitespace (and comments, with -Jsonc).
    .PARAMETER AsciiOnly
    Escape every non-ASCII character as \uXXXX.
    .EXAMPLE
    Format-WJson -Path .\data.json -Indent 4
    .EXAMPLE
    '{"a":[1,2]}' | Format-WJson
    #>

    [CmdletBinding(DefaultParameterSetName = 'Json')]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = 'Json')]
        [AllowEmptyString()]
        [string] $Json,

        [Parameter(Mandatory, ParameterSetName = 'Path')]
        [Alias('FullName')]
        [string] $Path,

        [ValidateRange(1, 16)]
        [int] $Indent = 2,
        [switch] $Minify,
        [switch] $AsciiOnly,
        [switch] $Jsonc
    )
    begin { $lines = [System.Collections.Generic.List[string]]::new() }
    process { if ($PSCmdlet.ParameterSetName -eq 'Json') { $lines.Add($Json) } }
    end {
        $options = New-WJsonOptions $Jsonc 0
        if ($PSCmdlet.ParameterSetName -eq 'Path') {
            $source = $Path
            # ReadAllText detects UTF-8 and UTF-16 byte order marks.
            $text = [System.IO.File]::ReadAllText($PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path))
        } else {
            $source = '<input>'
            $text = Join-WJsonInput $lines
        }
        try {
            if ($Minify) { [Wjson.WJson]::Minify($text, [bool] $AsciiOnly, $options) }
            else { [Wjson.WJson]::Format($text, $Indent, [bool] $AsciiOnly, $options) }
        } catch [Wjson.JsonParseException] {
            $PSCmdlet.ThrowTerminatingError((New-WJsonErrorRecord $_.Exception.Error $source $null))
        }
    }
}

function Get-WJsonValue {
    <#
    .SYNOPSIS
    Gets the value at a JSON Pointer (RFC 6901), e.g. /items/0/name.
    .DESCRIPTION
    Writes a non-terminating error if there is no value at that path.
    .EXAMPLE
    Get-WJsonValue -Pointer /version -Path .\package.json
    .EXAMPLE
    $json | Get-WJsonValue /users/0/email
    #>

    [CmdletBinding(DefaultParameterSetName = 'Json')]
    param(
        [Parameter(Mandatory, Position = 0)]
        [AllowEmptyString()]
        [string] $Pointer,

        [Parameter(Mandatory, Position = 1, ValueFromPipeline, ParameterSetName = 'Json')]
        [AllowEmptyString()]
        [string] $Json,

        [Parameter(Mandatory, ParameterSetName = 'Path')]
        [Alias('FullName')]
        [string] $Path,

        [switch] $Jsonc,
        [switch] $AsHashtable
    )
    begin { $lines = [System.Collections.Generic.List[string]]::new() }
    process { if ($PSCmdlet.ParameterSetName -eq 'Json') { $lines.Add($Json) } }
    end {
        $options = New-WJsonOptions $Jsonc 0
        if ($PSCmdlet.ParameterSetName -eq 'Path') {
            $source = $Path
            $text = [System.IO.File]::ReadAllText($PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path))
        } else {
            $source = '<input>'
            $text = Join-WJsonInput $lines
        }
        $value = $null
        try {
            $found = [Wjson.WJson]::TryQuery($text, $Pointer, [ref] $value, $options)
        } catch [Wjson.JsonParseException] {
            $PSCmdlet.ThrowTerminatingError((New-WJsonErrorRecord $_.Exception.Error $source $null))
        }
        if (-not $found) {
            $exception = [System.Management.Automation.ItemNotFoundException]::new("${source}: no value at '$Pointer'")
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                $exception, 'WJson.PointerNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $Pointer))
            return
        }
        $result = if ($AsHashtable) { $value } else { ConvertTo-PSValue $value }
        Write-Output -InputObject $result -NoEnumerate
    }
}

Export-ModuleMember -Function Test-WJson, ConvertFrom-WJson, Format-WJson, Get-WJsonValue