ConvertFrom-JsonAsHashtable.psm1
|
<#
.SYNOPSIS JSON to Hashtable Conversion for Windows PowerShell 5+, providing functionality similar to 'ConvertFrom-Json -AsHashtable' in PowerShell 7+. .DESCRIPTION Converts JSON text into PowerShell Hashtable. Provides functionality similar to 'ConvertFrom-Json -AsHashtable' in PowerShell 7+. On PowerShell 7+, this simply delegates to the built-in 'ConvertFrom-Json -AsHashtable'. On PowerShell 5.1, this uses a hand-written, ITERATIVE JSON parser. .PARAMETER InputObject The JSON input to convert. Can be a string, an array of strings, or input from the pipeline. .EXAMPLE # Convert JSON from a file into a hashtable Get-Content "data.json" | ConvertFrom-JsonAsHashtable .EXAMPLE # Convert a JSON string directly '{"name":"Tom","age":25}' | ConvertFrom-JsonAsHashtable .EXAMPLE # Assign JSON from a file to a variable as hashtable $ht = Get-Content "data.json" | ConvertFrom-JsonAsHashtable .NOTES Number handling: integers that fit in Int32/Int64 are returned as such; anything with a decimal point/exponent, or too large for Int64, is returned as Double. This matches the general behavior of 'ConvertFrom-Json -AsHashtable' and shares the same precision limits for numbers larger than Int64.MaxValue (they become Double and may lose precision). Trailing commas and other lenient JSON features are tolerated to match PS7 behavior. .LINK GitHub : https://github.com/abgox/ConvertFrom-JsonAsHashtable Gitee : https://gitee.com/abgox/ConvertFrom-JsonAsHashtable #> # Frame type constants — integer comparison is faster than string $_FT_OBJECT = 0 $_FT_ARRAY = 1 class JsonParserState { [string]$Text [int]$Pos [int]$Len JsonParserState([string]$text) { $this.Text = $text $this.Pos = 0 $this.Len = $text.Length } [void] SkipWs() { while ($this.Pos -lt $this.Len) { $c = $this.Text[$this.Pos] if ($c -eq ' ' -or $c -eq "`t" -or $c -eq "`r" -or $c -eq "`n") { $this.Pos++ } else { break } } } [char] Peek() { if ($this.Pos -ge $this.Len) { throw 'Unexpected end of JSON input.' } return $this.Text[$this.Pos] } [void] Expect([char]$Char) { $this.SkipWs() if ($this.Pos -ge $this.Len -or $this.Text[$this.Pos] -ne $Char) { throw "Expected '$Char' at position $($this.Pos)." } $this.Pos++ } [string] ParseString() { $this.Expect('"') $sb = [System.Text.StringBuilder]::new(32) while ($true) { if ($this.Pos -ge $this.Len) { throw 'Unterminated string in JSON input.' } $c = $this.Text[$this.Pos] if ($c -eq '"') { $this.Pos++; break } elseif ($c -eq '\') { $this.Pos++ if ($this.Pos -ge $this.Len) { throw 'Unterminated escape sequence in JSON string.' } $esc = $this.Text[$this.Pos] switch ($esc) { '"' { [void]$sb.Append('"'); $this.Pos++ } '\' { [void]$sb.Append('\'); $this.Pos++ } '/' { [void]$sb.Append('/'); $this.Pos++ } 'b' { [void]$sb.Append([char]8); $this.Pos++ } 'f' { [void]$sb.Append([char]12); $this.Pos++ } 'n' { [void]$sb.Append("`n"); $this.Pos++ } 'r' { [void]$sb.Append("`r"); $this.Pos++ } 't' { [void]$sb.Append("`t"); $this.Pos++ } 'u' { if ($this.Pos + 4 -ge $this.Len) { throw 'Invalid unicode escape in JSON string.' } $hex = $this.Text.Substring($this.Pos + 1, 4) $code = [Convert]::ToInt32($hex, 16) [void]$sb.Append([char]$code) $this.Pos += 5 } default { throw "Invalid escape sequence '\$esc' in JSON string." } } } else { [void]$sb.Append($c); $this.Pos++ } } return $sb.ToString() } [object] ParseNumber() { $start = $this.Pos $isFloat = $false $txt = $this.Text $end = $this.Len if ($this.Pos -lt $end -and $txt[$this.Pos] -eq '-') { $this.Pos++ } if ($this.Pos -lt $end -and $txt[$this.Pos] -eq '.') { $isFloat = $true; $this.Pos++ while ($this.Pos -lt $end -and $txt[$this.Pos] -ge '0' -and $txt[$this.Pos] -le '9') { $this.Pos++ } } while ($this.Pos -lt $end -and $txt[$this.Pos] -ge '0' -and $txt[$this.Pos] -le '9') { $this.Pos++ } if ($this.Pos -lt $end -and $txt[$this.Pos] -eq '.') { $isFloat = $true; $this.Pos++ while ($this.Pos -lt $end -and $txt[$this.Pos] -ge '0' -and $txt[$this.Pos] -le '9') { $this.Pos++ } } if ($this.Pos -lt $end -and ($txt[$this.Pos] -eq 'e' -or $txt[$this.Pos] -eq 'E')) { $isFloat = $true; $this.Pos++ if ($this.Pos -lt $end -and ($txt[$this.Pos] -eq '+' -or $txt[$this.Pos] -eq '-')) { $this.Pos++ } while ($this.Pos -lt $end -and $txt[$this.Pos] -ge '0' -and $txt[$this.Pos] -le '9') { $this.Pos++ } } $numStr = $txt.Substring($start, $this.Pos - $start) if ($numStr.Length -eq 0 -or ($numStr.Length -eq 1 -and $numStr[0] -eq '-')) { throw "Invalid number in JSON input near position $start." } if ($isFloat) { return [double]::Parse($numStr, [System.Globalization.CultureInfo]::InvariantCulture) } $intVal = 0 if ([int]::TryParse($numStr, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$intVal)) { return $intVal } $longVal = 0L if ([long]::TryParse($numStr, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$longVal)) { return $longVal } return [double]::Parse($numStr, [System.Globalization.CultureInfo]::InvariantCulture) } [object] ParseLiteral([string]$Literal, [object]$Value) { if ($this.Pos + $Literal.Length -gt $this.Len -or $this.Text.Substring($this.Pos, $Literal.Length) -cne $Literal) { throw "Invalid literal near position $($this.Pos), expected '$Literal'." } $this.Pos += $Literal.Length return $Value } } function ConvertFrom-JsonAsHashtable { [CmdletBinding()] param( [Parameter(ValueFromPipeline = $true)] $InputObject ) begin { $buffer = [System.Text.StringBuilder]::new() } process { if ($InputObject -is [array]) { [void]$buffer.AppendLine(($InputObject -join "`n")) } else { [void]$buffer.AppendLine($InputObject) } } end { $jsonString = $buffer.ToString().Trim() if (-not $jsonString) { return $null } if ($PSVersionTable.PSVersion.Major -ge 7) { return ConvertFrom-Json $jsonString -AsHashtable } # ---- PowerShell 5.1 path: iterative JSON parser ---- if ($jsonString.Length -gt 0 -and $jsonString[0] -eq [char]0xFEFF) { $jsonString = $jsonString.Substring(1).Trim() if (-not $jsonString) { return $null } } $parser = [JsonParserState]::new($jsonString) # Stack: three parallel lists instead of list of hashtable (faster access) $stkType = [System.Collections.Generic.List[int]]::new() $stkCont = [System.Collections.Generic.List[object]]::new() $stkKey = [System.Collections.Generic.List[object]]::new() $root = $null $haveRoot = $false $pLen = $parser.Len while (-not $haveRoot) { $parser.SkipWs() $top = $stkType.Count - 1 # ---- EOF inside structures (lenient) ---- if ($parser.Pos -ge $pLen -and $top -ge 0) { $topType = $stkType[$top] $topCont = $stkCont[$top] $hasContent = $topCont.Count -gt 0 $lastIsComplex = $false if ($topType -eq $_FT_ARRAY -and $hasContent) { $le = $topCont[$topCont.Count - 1] $lastIsComplex = $le -is [array] -or $le -is [System.Collections.IList] -or $le -is [hashtable] -or $le -is [System.Collections.IDictionary] } if ($hasContent -and $lastIsComplex) { while ($stkType.Count -gt 0) { $si = $stkType.Count - 1 $sType = $stkType[$si] $sCont = $stkCont[$si] if ($sType -eq $_FT_OBJECT -and $null -ne $stkKey[$si]) { $sCont[$stkKey[$si]] = $null } $stkType.RemoveAt($si); $stkCont.RemoveAt($si); $stkKey.RemoveAt($si) if ($sType -eq $_FT_ARRAY) { $completed = $sCont.ToArray() } else { $completed = $sCont } if ($stkType.Count -gt 0) { $psi = $stkType.Count - 1 if ($stkType[$psi] -eq $_FT_ARRAY) { $stkCont[$psi].Add($completed) } else { $stkCont[$psi][$stkKey[$psi]] = $completed; $stkKey[$psi] = $null } } else { $root = $completed; $haveRoot = $true } } break } else { throw 'Unterminated JSON structure.' } } # ---- Close bracket/brace or key parsing ---- if ($top -ge 0) { $sType = $stkType[$top] $sCont = $stkCont[$top] if ($sType -eq $_FT_OBJECT -and $null -eq $stkKey[$top]) { $ch = $parser.Peek() if ($ch -eq '}') { $parser.Pos++ $completed = $sCont $stkType.RemoveAt($top); $stkCont.RemoveAt($top); $stkKey.RemoveAt($top) # Inline attach $attaching = $true while ($attaching) { $ai = $stkType.Count - 1 if ($ai -lt 0) { $root = $completed; $haveRoot = $true; break } $aType = $stkType[$ai] if ($aType -eq $_FT_ARRAY) { $stkCont[$ai].Add($completed) } else { $stkCont[$ai][$stkKey[$ai]] = $completed; $stkKey[$ai] = $null } $parser.SkipWs() if ($parser.Pos -ge $pLen) { if ($completed -is [array] -or $completed -is [System.Collections.IList] -or $completed -is [hashtable] -or $completed -is [System.Collections.IDictionary]) { $root = $completed; $haveRoot = $true; break } throw 'Unterminated JSON structure.' } $cc = if ($aType -eq $_FT_ARRAY) { [char]']' } else { [char]'}' } $pch = $parser.Text[$parser.Pos] if ($pch -eq $cc) { $parser.Pos++ $savedCont = $stkCont[$ai] $stkType.RemoveAt($ai); $stkCont.RemoveAt($ai); $stkKey.RemoveAt($ai) if ($aType -eq $_FT_ARRAY) { $completed = $savedCont.ToArray() } else { $completed = $savedCont } } elseif ($pch -eq ',') { $parser.Pos++ $parser.SkipWs() if ($parser.Pos -lt $pLen -and $parser.Text[$parser.Pos] -eq $cc) { $parser.Pos++ $savedCont = $stkCont[$ai] $stkType.RemoveAt($ai); $stkCont.RemoveAt($ai); $stkKey.RemoveAt($ai) if ($aType -eq $_FT_ARRAY) { $completed = $savedCont.ToArray() } else { $completed = $savedCont } } else { $attaching = $false } } else { throw "Expected ',' or '$cc' at position $($parser.Pos)." } } continue } else { if ($ch -eq '"') { $key = $parser.ParseString() } elseif ($ch -eq '-' -or ($ch -ge '0' -and $ch -le '9')) { $key = [string]$parser.ParseNumber() } elseif ($ch -eq 't') { $parser.ParseLiteral('true', $true); $key = 'true' } elseif ($ch -eq 'f') { $parser.ParseLiteral('false', $false); $key = 'false' } elseif ($ch -eq 'n') { $parser.ParseLiteral('null', $null); $key = 'null' } else { throw "Expected string or number key at position $($parser.Pos)." } $parser.SkipWs() $parser.Expect(':') $stkKey[$top] = $key } continue } if ($sType -eq $_FT_ARRAY) { $ch = $parser.Peek() if ($ch -eq ']') { $parser.Pos++ $completed = $sCont.ToArray() $stkType.RemoveAt($top); $stkCont.RemoveAt($top); $stkKey.RemoveAt($top) # Inline attach $attaching = $true while ($attaching) { $ai = $stkType.Count - 1 if ($ai -lt 0) { $root = $completed; $haveRoot = $true; break } $aType = $stkType[$ai] if ($aType -eq $_FT_ARRAY) { $stkCont[$ai].Add($completed) } else { $stkCont[$ai][$stkKey[$ai]] = $completed; $stkKey[$ai] = $null } $parser.SkipWs() if ($parser.Pos -ge $pLen) { if ($completed -is [array] -or $completed -is [System.Collections.IList] -or $completed -is [hashtable] -or $completed -is [System.Collections.IDictionary]) { $root = $completed; $haveRoot = $true; break } throw 'Unterminated JSON structure.' } $cc = if ($aType -eq $_FT_ARRAY) { [char]']' } else { [char]'}' } $pch = $parser.Text[$parser.Pos] if ($pch -eq $cc) { $parser.Pos++ $savedCont = $stkCont[$ai] $stkType.RemoveAt($ai); $stkCont.RemoveAt($ai); $stkKey.RemoveAt($ai) if ($aType -eq $_FT_ARRAY) { $completed = $savedCont.ToArray() } else { $completed = $savedCont } } elseif ($pch -eq ',') { $parser.Pos++ $parser.SkipWs() if ($parser.Pos -lt $pLen -and $parser.Text[$parser.Pos] -eq $cc) { $parser.Pos++ $savedCont = $stkCont[$ai] $stkType.RemoveAt($ai); $stkCont.RemoveAt($ai); $stkKey.RemoveAt($ai) if ($aType -eq $_FT_ARRAY) { $completed = $savedCont.ToArray() } else { $completed = $savedCont } } else { $attaching = $false } } else { throw "Expected ',' or '$cc' at position $($parser.Pos)." } } continue } } } # ---- Parse new value ---- $ch = $parser.Peek() if ($ch -eq '{') { $parser.Pos++ $ht = [System.Collections.Hashtable]::new([System.StringComparer]::Ordinal) $stkType.Add($_FT_OBJECT); $stkCont.Add($ht); $stkKey.Add($null) continue } elseif ($ch -eq '[') { $parser.Pos++ $list = [System.Collections.Generic.List[object]]::new() $stkType.Add($_FT_ARRAY); $stkCont.Add($list); $stkKey.Add($null) continue } else { $inContainer = $top -ge 0 $isComma = $ch -eq ',' $isArrayClose = $ch -eq ']' -and $inContainer -and $stkType[$top] -eq $_FT_ARRAY if ($isComma -or $isArrayClose) { $value = $null } else { $value = switch ($ch) { '"' { $parser.ParseString() } 't' { $parser.ParseLiteral('true', $true) } 'f' { $parser.ParseLiteral('false', $false) } 'n' { $parser.ParseLiteral('null', $null) } default { if ($ch -eq '-' -or $ch -eq '.' -or ($ch -ge '0' -and $ch -le '9')) { $parser.ParseNumber() } else { throw "Unexpected character '$ch' at position $($parser.Pos)." } } } } if (-not $inContainer) { $root = $value $haveRoot = $true } else { # Inline _JsonAttach $current = $value $attaching = $true while ($attaching) { $ai = $stkType.Count - 1 if ($ai -lt 0) { $root = $current; $haveRoot = $true; break } $aType = $stkType[$ai] if ($aType -eq $_FT_ARRAY) { $stkCont[$ai].Add($current) } else { $stkCont[$ai][$stkKey[$ai]] = $current; $stkKey[$ai] = $null } $parser.SkipWs() if ($parser.Pos -ge $pLen) { if ($current -is [array] -or $current -is [System.Collections.IList] -or $current -is [hashtable] -or $current -is [System.Collections.IDictionary]) { break } throw 'Unterminated JSON structure.' } $cc = if ($aType -eq $_FT_ARRAY) { [char]']' } else { [char]'}' } $pch = $parser.Text[$parser.Pos] if ($pch -eq $cc) { $parser.Pos++ $savedCont = $stkCont[$ai] $stkType.RemoveAt($ai); $stkCont.RemoveAt($ai); $stkKey.RemoveAt($ai) if ($aType -eq $_FT_ARRAY) { $current = $savedCont.ToArray() } else { $current = $savedCont } } elseif ($pch -eq ',') { $parser.Pos++ $parser.SkipWs() if ($parser.Pos -lt $pLen -and $parser.Text[$parser.Pos] -eq $cc) { $parser.Pos++ $savedCont = $stkCont[$ai] $stkType.RemoveAt($ai); $stkCont.RemoveAt($ai); $stkKey.RemoveAt($ai) if ($aType -eq $_FT_ARRAY) { $current = $savedCont.ToArray() } else { $current = $savedCont } } else { $attaching = $false } } else { throw "Expected ',' or '$cc' at position $($parser.Pos)." } } } } } if ($stkType.Count -eq 0) { $parser.SkipWs() if ($parser.Pos -ne $pLen) { throw "Unexpected trailing content in JSON input at position $($parser.Pos)." } } return $root } } Export-ModuleMember -Function ConvertFrom-JsonAsHashtable |