Private/Get-HDTWorkspaceKey.ps1

function Get-HDTWorkspaceKey {
    <#
        .SYNOPSIS
            Finds the exact lines one key of a workspace document occupies,
            following a path down through the nested blocks.
 
        .DESCRIPTION
            THE FOUNDATION OF EVERY EDIT MADE TO WORKSPACE.YAML, the way
            Get-HDTRuleBlock is for rules.yaml. Every authoring command is a
            splice of a whole line range, so nothing round-trips through the YAML
            parser - the parser yields a dictionary and a dictionary has no
            comments in it. A workspace.yaml is created with a comment header
            explaining deployRoot and the engine defaults, and an administrator
            adds their own notes beside every key they set; a save that
            re-serialised a model would delete all of it.
 
            IT TAKES A PATH, NOT A NAME. workspace.yaml is a mapping of mappings -
            bootImage, extraContent - so 'the key' is only meaningful as
            @('bootImage', 'extraContent'). Each level is searched only inside the
            range the level above owns, which is what stops a top-level name:
            being mistaken for bootImage's own name:.
 
            A KEY OWNS EVERY DEEPER LINE UNDER IT, AND ITS BLOCK SEQUENCE
            WHEREVER YAML LETS IT SIT. A sequence may be indented under its key or
            written at the key's own column, and ConvertTo-HDTYaml - what
            New-HDTWorkspace writes the first workspace.yaml of every share with -
            emits the second. Reading a dash at the parent's column as the next
            key would end the block before its first entry, which is how the rules
            editor once managed to be unable to touch the file the toolkit itself
            writes.
 
            A TRAILING BLANK LINE OR COMMENT IS NOT PART OF A KEY. Taking the
            blank would collapse the file's spacing a little further with every
            edit; taking a comment would let one key's removal delete the sentence
            written above the next.
 
            ChildIndent IS THE COLUMN THE KEY'S CONTENTS ARE WRITTEN AT, and -1
            when it has none - a scalar, or an empty block. It is what an insert
            has to match, and what tells a removal that the block it just emptied
            should go too.
 
        .PARAMETER Line
            The document, already split into lines.
 
        .PARAMETER Path
            The key path, outermost first - @('logLevel') or
            @('bootImage', 'extraContent').
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            $null when the path is not in the document, otherwise
            System.Management.Automation.PSCustomObject:
 
              Name the key, as written
              Index the line the key starts on
              End the last line it owns, blanks and trailing comments
                           excluded
              Indent the column the key itself sits at
              ChildIndent the column its contents sit at, or -1
 
        .EXAMPLE
            Get-HDTWorkspaceKey -Line $line -Path @('bootImage', 'extraContent')
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]   # a blank line IS an empty string, and this is a whole document
        [string[]] $Line,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string[]] $Path
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $from = 0
    $to = $Line.Count - 1
    $indent = 0
    $result = $null

    foreach ($name in @($Path)) {
        # A level whose parent had no contents cannot hold anything.
        if ($indent -lt 0) { return $null }

        $at = -1

        for ($i = $from; $i -le $to; $i++) {
            $text = $Line[$i]

            if ([string]::IsNullOrWhiteSpace($text)) { continue }
            if ($text -match '^ *#') { continue }

            $lead = 0
            if ($text -match '^( *)') { $lead = $Matches[1].Length }

            # Shallower than this level means the parent's region has ended.
            if ($lead -lt $indent) { break }
            if ($lead -ne $indent) { continue }

            # A dash at this column is a sequence entry, never a key.
            if ($text -match '^ *-( |$)') { continue }

            if ($text -match '^ *([A-Za-z][\w-]*):') {
                if ($Matches[1] -eq $name) {
                    $at = $i
                    break
                }
            }
        }

        if ($at -lt 0) { return $null }

        # -- how far the key reaches -----------------------------------------

        $end = $to

        for ($j = $at + 1; $j -le $to; $j++) {
            $text = $Line[$j]

            if ([string]::IsNullOrWhiteSpace($text)) { continue }
            if ($text -match '^ *#') { continue }

            $lead = 0
            if ($text -match '^( *)') { $lead = $Matches[1].Length }

            if ($lead -gt $indent) { continue }

            # THE SEQUENCE AT THE PARENT'S OWN COLUMN. Both spellings are legal
            # and the engine's reader takes both, so this has to as well.
            if ($lead -eq $indent -and $text -match '^ *-( |$)') { continue }

            $end = $j - 1
            break
        }

        while ($end -gt $at -and
            ([string]::IsNullOrWhiteSpace($Line[$end]) -or $Line[$end] -match '^ *#')) {
            $end--
        }

        # -- the column its contents are written at ---------------------------

        $childIndent = -1

        for ($j = $at + 1; $j -le $end; $j++) {
            $text = $Line[$j]

            if ([string]::IsNullOrWhiteSpace($text)) { continue }
            if ($text -match '^ *#') { continue }

            if ($text -match '^( *)') { $childIndent = $Matches[1].Length }
            break
        }

        $result = [pscustomobject] @{
            Name        = [string] $name
            Index       = $at
            End         = $end
            Indent      = $indent
            ChildIndent = $childIndent
        }

        $from = $at + 1
        $to = $end
        $indent = $childIndent
    }

    return $result
}