Private/Get-HDTRuleKey.ps1

function Get-HDTRuleKey {
    <#
        .SYNOPSIS
            Finds each key a rule declares, and the exact lines it occupies.

        .DESCRIPTION
            THE SHARED HALF OF EVERY EDIT MADE INSIDE A RULE. A rule's when: and
            set: are not single lines - a block mapping runs over as many lines
            as it has entries, and a list value runs over more - so replacing one
            means knowing where it starts and where it stops. Nothing else in the
            file may move.

            IT RETURNS A RANGE PER KEY, NOT A VALUE. The caller does the splicing,
            because the caller is the one that knows whether it is replacing,
            inserting or removing.

            IT HANDLES BOTH SPELLINGS OF A MAPPING. A hand-written when: is
            usually one flow line, { HDTModel: 'Latitude*' }, and a generated one
            is a key per line; both are just a range, so neither is a special
            case and neither is reformatted when the other is edited.

            A KEY IS A LINE AT THE RULE'S OWN COLUMN. Anything deeper belongs to
            the key above it - the entries of a set:, the items of a list - which
            is what makes the range the whole mapping rather than its first line.

        .PARAMETER Line
            The document, already split into lines.

        .PARAMETER Block
            One block from Get-HDTRuleBlock.

        .INPUTS
            None. This command does not accept pipeline input.

        .OUTPUTS
            System.Management.Automation.PSCustomObject[] in document order:

              Name the key, exactly as it is written in the file
              Index the line the key starts on
              End the last line the key owns, blanks and trailing comments
                      excluded
              Indent the column this rule's keys are written at

        .EXAMPLE
            Get-HDTRuleKey -Line $line -Block $block
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]] $Line,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNull()]
        [object] $Block
    )

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

    # A key sits two columns in from the dash that opened the rule, which is what
    # `- name: X` / ` when: ...` means as an indentation.
    $indent = [int] $Block.Indent + 2

    $entry = [int] $Block.Entry
    $last = [int] $Block.End

    $found = New-Object -TypeName System.Collections.ArrayList

    for ($i = $entry; $i -le $last; $i++) {
        $text = $Line[$i]

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

        $name = ''

        if ($i -eq $entry -and $text -match '^\s*-\s+([A-Za-z][\w-]*):') {
            $name = $Matches[1]
        } elseif ($text -match ('^\s{{{0}}}([A-Za-z][\w-]*):' -f $indent)) {
            $name = $Matches[1]
        } else {
            # Deeper than this rule's keys - a mapping's entries or a list's
            # items, which the key above owns and does not name.
            continue
        }

        [void] $found.Add([pscustomobject] @{
                Name   = $name
                Index  = $i
                End    = $i
                Indent = $indent
            })
    }

    # -- each key's span ---------------------------------------------------

    for ($k = 0; $k -lt $found.Count; $k++) {
        $end = $last
        if ($k + 1 -lt $found.Count) { $end = [int] $found[$k + 1].Index - 1 }

        # A blank line or a comment between two keys belongs to neither.
        while ($end -gt [int] $found[$k].Index -and
            ([string]::IsNullOrWhiteSpace($Line[$end]) -or $Line[$end] -match '^\s*#')) {
            $end--
        }

        $found[$k].End = $end
    }

    return [pscustomobject[]] @($found)
}