Private/ConvertFrom-BasicYaml.ps1
|
function ConvertFrom-BasicYaml { <# .SYNOPSIS Built-in YAML parser for VM-AutoTagger tag profiles. .DESCRIPTION Provides a lightweight YAML parser that handles the subset of YAML used by VM-AutoTagger tag profiles. Supports: - Scalar key-value pairs (string, number, boolean) - Sequences (arrays) denoted by "- " prefix - Nested mappings (objects) via indentation - Quoted strings (single and double) - Comments (lines starting with #) - Blank lines and inline comments This parser serves as a zero-dependency fallback when the powershell-yaml module is not installed. It is intentionally limited to profile YAML and does not implement the full YAML specification (no anchors, aliases, flow style, multi-line scalars, etc.). .PARAMETER YamlContent The raw YAML string content to parse. .EXAMPLE $yaml = Get-Content .\profile.yml -Raw $parsed = ConvertFrom-BasicYaml -YamlContent $yaml $parsed.name $parsed.categories | ForEach-Object { $_.name } .NOTES Author: Larry Roberts Part of the VM-AutoTagger module. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$YamlContent ) process { # Split content into lines, preserving empty lines for structure $rawLines = $YamlContent -split "`n" # Pre-process: strip carriage returns and remove pure comment / blank lines $lines = [System.Collections.ArrayList]::new() foreach ($rawLine in $rawLines) { $line = $rawLine.TrimEnd("`r") # Skip blank lines if ([string]::IsNullOrWhiteSpace($line)) { continue } # Skip pure comment lines (leading whitespace + #) $trimmed = $line.TrimStart() if ($trimmed.StartsWith('#')) { continue } [void]$lines.Add($line) } if ($lines.Count -eq 0) { Write-Verbose "YAML content is empty after stripping comments." return @{} } # Parse from the top level $index = 0 $result = ParseYamlBlock -Lines $lines -Index ([ref]$index) -BaseIndent 0 return $result } } function ParseYamlBlock { <# .SYNOPSIS Recursively parses a block of YAML lines into a hashtable or array. .DESCRIPTION Internal recursive descent parser. Determines whether the current block represents a mapping (hashtable) or sequence (array) based on whether lines start with "- ". #> [CmdletBinding()] param( [System.Collections.ArrayList]$Lines, [ref]$Index, [int]$BaseIndent ) if ($Index.Value -ge $Lines.Count) { return @{} } $firstLine = $Lines[$Index.Value] $firstTrimmed = $firstLine.TrimStart() # Determine if this block is a sequence or a mapping if ($firstTrimmed.StartsWith('- ') -or $firstTrimmed -eq '-') { return ParseYamlSequence -Lines $Lines -Index $Index -BaseIndent $BaseIndent } else { return ParseYamlMapping -Lines $Lines -Index $Index -BaseIndent $BaseIndent } } function ParseYamlMapping { <# .SYNOPSIS Parses a YAML mapping (key-value pairs) into an ordered hashtable. #> [CmdletBinding()] param( [System.Collections.ArrayList]$Lines, [ref]$Index, [int]$BaseIndent ) $mapping = [ordered]@{} while ($Index.Value -lt $Lines.Count) { $line = $Lines[$Index.Value] $indent = Get-YamlIndent -Line $line # If indent is less than our base, this block is done if ($indent -lt $BaseIndent) { break } # If indent is exactly our base, parse this key-value pair if ($indent -ne $BaseIndent) { break } $trimmed = $line.TrimStart() # Skip if this looks like a sequence item (handled by parent) if ($trimmed.StartsWith('- ')) { break } # Strip inline comments (but not inside quoted strings) $trimmed = Remove-InlineComment -Text $trimmed # Parse key: value $colonIdx = Find-KeyColonIndex -Text $trimmed if ($colonIdx -lt 0) { # Not a valid key-value line, skip it $Index.Value++ continue } $key = $trimmed.Substring(0, $colonIdx).Trim() $valueStr = '' if ($colonIdx + 1 -lt $trimmed.Length) { $valueStr = $trimmed.Substring($colonIdx + 1).Trim() } if ([string]::IsNullOrWhiteSpace($valueStr)) { # Value is on subsequent indented lines (nested block) $Index.Value++ if ($Index.Value -lt $Lines.Count) { $nextIndent = Get-YamlIndent -Line $Lines[$Index.Value] if ($nextIndent -gt $BaseIndent) { $mapping[$key] = ParseYamlBlock -Lines $Lines -Index $Index -BaseIndent $nextIndent } else { $mapping[$key] = $null } } else { $mapping[$key] = $null } } else { # Inline scalar value $mapping[$key] = Convert-YamlScalar -Value $valueStr $Index.Value++ } } return $mapping } function ParseYamlSequence { <# .SYNOPSIS Parses a YAML sequence (array of items) into an ArrayList. #> [CmdletBinding()] param( [System.Collections.ArrayList]$Lines, [ref]$Index, [int]$BaseIndent ) $sequence = [System.Collections.ArrayList]::new() while ($Index.Value -lt $Lines.Count) { $line = $Lines[$Index.Value] $indent = Get-YamlIndent -Line $line # If indent is less than our base, this block is done if ($indent -lt $BaseIndent) { break } $trimmed = $line.TrimStart() if (-not ($trimmed.StartsWith('- ') -or $trimmed -eq '-')) { # Not a sequence item; we may have exited the sequence break } # Remove the "- " prefix $afterDash = '' if ($trimmed.Length -gt 2) { $afterDash = $trimmed.Substring(2) } # Strip inline comments $afterDash = Remove-InlineComment -Text $afterDash # Determine if this sequence item is a scalar, inline mapping, or nested block if ([string]::IsNullOrWhiteSpace($afterDash)) { # Empty dash -- nested block follows $Index.Value++ if ($Index.Value -lt $Lines.Count) { $nextIndent = Get-YamlIndent -Line $Lines[$Index.Value] if ($nextIndent -gt $BaseIndent) { $item = ParseYamlBlock -Lines $Lines -Index $Index -BaseIndent $nextIndent [void]$sequence.Add($item) } else { [void]$sequence.Add($null) } } } else { # Check if "- key: value" (inline mapping start) $colonIdx = Find-KeyColonIndex -Text $afterDash if ($colonIdx -ge 0) { # This is the start of an inline mapping on the same line as the dash # Parse the first key-value pair, then continue with indented lines $key = $afterDash.Substring(0, $colonIdx).Trim() $valStr = '' if ($colonIdx + 1 -lt $afterDash.Length) { $valStr = $afterDash.Substring($colonIdx + 1).Trim() } $itemMapping = [ordered]@{} if ([string]::IsNullOrWhiteSpace($valStr)) { # Value is a nested block $Index.Value++ if ($Index.Value -lt $Lines.Count) { $nextIndent = Get-YamlIndent -Line $Lines[$Index.Value] if ($nextIndent -gt $BaseIndent) { $itemMapping[$key] = ParseYamlBlock -Lines $Lines -Index $Index -BaseIndent $nextIndent } else { $itemMapping[$key] = $null } } else { $itemMapping[$key] = $null } } else { $itemMapping[$key] = Convert-YamlScalar -Value $valStr $Index.Value++ } # Continue reading additional keys at the indented level for this mapping item # The continuation keys are indented more than the dash $continuationIndent = $BaseIndent + 2 # Some YAML uses varying indent, so detect from next line if ($Index.Value -lt $Lines.Count) { $peekIndent = Get-YamlIndent -Line $Lines[$Index.Value] $peekTrimmed = ($Lines[$Index.Value]).TrimStart() # Only continue if the next line is at the continuation level # and is NOT a new sequence item if ($peekIndent -gt $BaseIndent -and -not $peekTrimmed.StartsWith('- ')) { $continuationIndent = $peekIndent # Parse remaining keys into the same mapping $subMapping = ParseYamlMapping -Lines $Lines -Index $Index -BaseIndent $continuationIndent foreach ($sk in $subMapping.Keys) { $itemMapping[$sk] = $subMapping[$sk] } } } [void]$sequence.Add($itemMapping) } else { # Simple scalar value after the dash [void]$sequence.Add((Convert-YamlScalar -Value $afterDash)) $Index.Value++ } } } return $sequence } function Get-YamlIndent { <# .SYNOPSIS Returns the number of leading spaces on a line. #> [CmdletBinding()] param( [string]$Line ) $count = 0 foreach ($ch in $Line.ToCharArray()) { if ($ch -eq ' ') { $count++ } elseif ($ch -eq "`t") { $count += 2 } # treat tab as 2 spaces else { break } } return $count } function Find-KeyColonIndex { <# .SYNOPSIS Finds the index of the first colon that acts as a key-value separator. .DESCRIPTION Skips colons that are inside quoted strings. The colon must be followed by a space, end-of-string, or be at the end of the text to count as a separator. #> [CmdletBinding()] param( [string]$Text ) $inSingleQuote = $false $inDoubleQuote = $false for ($i = 0; $i -lt $Text.Length; $i++) { $ch = $Text[$i] if ($ch -eq "'" -and -not $inDoubleQuote) { $inSingleQuote = -not $inSingleQuote } elseif ($ch -eq '"' -and -not $inSingleQuote) { $inDoubleQuote = -not $inDoubleQuote } elseif ($ch -eq ':' -and -not $inSingleQuote -and -not $inDoubleQuote) { # Colon must be followed by space or end-of-string to be a separator if ($i + 1 -ge $Text.Length -or $Text[$i + 1] -eq ' ') { return $i } } } return -1 } function Remove-InlineComment { <# .SYNOPSIS Removes inline comments (# ...) from a YAML value string. .DESCRIPTION Strips text after a # that is preceded by whitespace and not inside a quoted string. #> [CmdletBinding()] param( [string]$Text ) if ([string]::IsNullOrEmpty($Text)) { return $Text } $inSingleQuote = $false $inDoubleQuote = $false for ($i = 0; $i -lt $Text.Length; $i++) { $ch = $Text[$i] if ($ch -eq "'" -and -not $inDoubleQuote) { $inSingleQuote = -not $inSingleQuote } elseif ($ch -eq '"' -and -not $inSingleQuote) { $inDoubleQuote = -not $inDoubleQuote } elseif ($ch -eq '#' -and -not $inSingleQuote -and -not $inDoubleQuote) { # Must be preceded by whitespace to be a comment if ($i -gt 0 -and $Text[$i - 1] -eq ' ') { return $Text.Substring(0, $i).TrimEnd() } } } return $Text } function Convert-YamlScalar { <# .SYNOPSIS Converts a raw YAML scalar string to a typed PowerShell value. .DESCRIPTION Handles: - Quoted strings (removes surrounding quotes) - Booleans: true/false, yes/no, on/off - Null: null, ~ - Integers and floating-point numbers - Unquoted strings (returned as-is) #> [CmdletBinding()] param( [string]$Value ) if ([string]::IsNullOrWhiteSpace($Value)) { return '' } $v = $Value.Trim() # Quoted strings if (($v.StartsWith('"') -and $v.EndsWith('"')) -or ($v.StartsWith("'") -and $v.EndsWith("'"))) { # Remove surrounding quotes $inner = $v.Substring(1, $v.Length - 2) # Handle escaped characters in double-quoted strings if ($v.StartsWith('"')) { $inner = $inner.Replace('\"', '"') $inner = $inner.Replace('\\', '\') $inner = $inner.Replace('\n', "`n") $inner = $inner.Replace('\t', "`t") } return $inner } # Null if ($v -eq 'null' -or $v -eq '~') { return $null } # Boolean switch ($v.ToLower()) { 'true' { return $true } 'false' { return $false } 'yes' { return $true } 'no' { return $false } 'on' { return $true } 'off' { return $false } } # Integer $intVal = 0 if ([int]::TryParse($v, [ref]$intVal)) { return $intVal } # Long integer $longVal = [long]0 if ([long]::TryParse($v, [ref]$longVal)) { return $longVal } # Double / float $dblVal = [double]0 if ([double]::TryParse($v, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$dblVal)) { return $dblVal } # Return as plain string return $v } |