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
}