Private/Path.ps1

# Walking a path through plain objects: maps, lists and objects with properties.

function Test-PsdKeySame {
    param([string] $A, [string] $B, [bool] $CaseSensitive)
    if ($CaseSensitive) { return [string]::Equals($A, $B, [System.StringComparison]::Ordinal) }
    [string]::Equals($A, $B, [System.StringComparison]::OrdinalIgnoreCase)
}

function Find-PsdKeyIn {
    param($Dict, [string] $Key, [bool] $CaseSensitive)
    foreach ($k in $Dict.PSBase.Keys) {
        if (Test-PsdKeySame $k $Key $CaseSensitive) { return @{ Found = $true; Key = $k } }
    }
    @{ Found = $false; Key = $null }
}

function Get-PsdObjectKind {
    param($Value)
    if ($null -eq $Value) { return 'null' }
    if ($Value -is [System.Collections.IDictionary]) { return 'map' }
    if (Test-PsdList $Value) { return 'list' }
    if ($Value -is [string] -or $Value -is [bool] -or (Test-PsdNumber $Value) -or $Value -is [enum] -or $Value -is [datetime] -or $Value -is [guid] -or $Value -is [version]) { return 'scalar' }
    'object'
}

# One step down. Returns @{ Found; Value; Key; Code; Message }.
function Get-PsdObjectChild {
    param($Value, $Seg, [bool] $CaseSensitive, [string] $Under)
    $kind = Get-PsdObjectKind $Value
    $what = if ($Under) { $Under } else { 'the object' }
    $missing = if ($Under) { "'$($Seg.Key)' doesn't exist under $Under." } else { "'$($Seg.Key)' doesn't exist." }
    switch ($kind) {
        'map' {
            if ($Seg.IsIndex) { return @{ Found = $false; Code = 'PSD308'; Message = "$what is a map, so [$($Seg.Index)] can't be used on it." } }
            $r = Find-PsdKeyIn $Value $Seg.Key $CaseSensitive
            if (-not $r.Found) { return @{ Found = $false; Code = 'PSD301'; Message = $missing; Keys = $Value.PSBase.Keys } }
            return @{ Found = $true; Value = $Value[$r.Key]; Key = $r.Key }
        }
        'list' {
            if (-not $Seg.IsIndex) { return @{ Found = $false; Code = 'PSD308'; Message = "$what is a list, so .$($Seg.Key) can't be used on it. Use an index like [0]." } }
            $i = $Seg.Index
            if ($i -lt 0) { $i += $Value.Count }
            if ($i -lt 0 -or $i -ge $Value.Count) { return @{ Found = $false; Code = 'PSD307'; Message = "$what has $($Value.Count) items, so [$($Seg.Index)] doesn't exist." } }
            return @{ Found = $true; Value = $Value[$i]; Key = $i }
        }
        'object' {
            if ($Seg.IsIndex) { return @{ Found = $false; Code = 'PSD308'; Message = "$what isn't a list, so [$($Seg.Index)] can't be used on it." } }
            foreach ($p in $Value.PSObject.Properties) {
                if (Test-PsdKeySame $p.Name $Seg.Key $CaseSensitive) { return @{ Found = $true; Value = $p.Value; Key = $p.Name } }
            }
            return @{ Found = $false; Code = 'PSD301'; Message = $missing; Keys = @($Value.PSObject.Properties | ForEach-Object Name) }
        }
        'null' { return @{ Found = $false; Code = 'PSD301'; Message = "$what is `$null, so nothing can be read from it." } }
    }
    @{ Found = $false; Code = 'PSD304'; Message = "$what is $(Get-PsdKindText $Value), so it has nothing inside it to read." }
}

function Find-PsdObjectPath {
    param($Object, $Segs, [bool] $CaseSensitive = $false)
    $cur = $Object
    for ($i = 0; $i -lt $Segs.Count; $i++) {
        $r = Get-PsdObjectChild $cur $Segs[$i] $CaseSensitive (Format-PsdSegs $Segs $i)
        if (-not $r.Found) { $r.At = $i; return $r }
        $cur = $r.Value
    }
    @{ Found = $true; Value = $cur }
}

function Set-PsdObjectChild {
    param($Container, $Key, $Value)
    if ((Get-PsdObjectKind $Container) -in 'map', 'list') { $Container[$Key] = $Value; return }
    $p = $Container.PSObject.Properties[[string]$Key]
    if ($p) { $p.Value = $Value } else { $Container | Add-Member -NotePropertyName ([string]$Key) -NotePropertyValue $Value }
}

# Sets a value, creating maps on the way. Returns $null or an error @{ Code; Message }.
function Set-PsdObjectPath {
    param($Object, $Segs, $Value)
    $cur = $Object
    for ($i = 0; $i -lt $Segs.Count; $i++) {
        $seg = $Segs[$i]
        $last = ($i -eq $Segs.Count - 1)
        $under = Format-PsdSegs $Segs $i
        $kind = Get-PsdObjectKind $cur
        if ($kind -eq 'map' -and -not $seg.IsIndex) {
            $r = Find-PsdKeyIn $cur $seg.Key $false
            $key = $seg.Key
            if ($r.Found) { $key = $r.Key }
            if ($last) { $cur[$key] = $Value; return $null }
            if (-not $r.Found -or $null -eq $cur[$key]) { $cur[$key] = [ordered]@{} }
            $cur = $cur[$key]
            continue
        }
        $r = Get-PsdObjectChild $cur $seg $false $under
        if (-not $r.Found) {
            if ($kind -eq 'object' -and $r.Code -eq 'PSD301') {
                if ($last) { Set-PsdObjectChild $cur $seg.Key $Value; return $null }
                $new = [ordered]@{}
                Set-PsdObjectChild $cur $seg.Key $new
                $cur = $new
                continue
            }
            return $r
        }
        if ($last) { Set-PsdObjectChild $cur $r.Key $Value; return $null }
        $cur = $r.Value
    }
    $null
}

# Removes a value. Lists that can't shrink are replaced by a shorter copy.
function Remove-PsdObjectPath {
    param($Object, $Segs)
    $parentSegs = $Segs.GetRange(0, $Segs.Count - 1)
    $p = Find-PsdObjectPath $Object $parentSegs
    if (-not $p.Found) { return $p }
    $parent = $p.Value
    $seg = $Segs[$Segs.Count - 1]
    $r = Get-PsdObjectChild $parent $seg $false (Format-PsdSegs $parentSegs)
    if (-not $r.Found) { return $r }
    $kind = Get-PsdObjectKind $parent
    if ($kind -eq 'map') { $parent.Remove($r.Key); return $null }
    if ($kind -eq 'object') { $parent.PSObject.Properties.Remove($r.Key); return $null }
    if (-not $parent.IsFixedSize) { $parent.RemoveAt($r.Key); return $null }
    $items = New-PsdList
    for ($i = 0; $i -lt $parent.Count; $i++) { if ($i -ne $r.Key) { $items.Add($parent[$i]) } }
    if ($parentSegs.Count -eq 0) { return @{ Code = 'PSD308'; Message = "an item can't be removed from the top-level list itself." } }
    Set-PsdObjectPath $Object $parentSegs ($items.ToArray())
}

function Write-PsdPathError {
    param($Cmdlet, $R, [string] $Path)
    $ctx = New-PsdContext
    $hint = @()
    if ($R.ContainsKey('Keys') -and $R['Keys']) {
        $last = $Path
        $m = [regex]::Match($Path, '([^.\[\]"]+)"?$')
        if ($m.Success) { $last = $m.Groups[1].Value }
        $hint = @((Get-PsdDidYouMean $last $R['Keys']))
    }
    Add-PsdError $ctx $R.Code $R.Message -Key $Path -Hint $hint
    Write-PsdResult $Cmdlet $ctx $null
}