Private/Get-HDTRuleBlock.ps1

function Get-HDTRuleBlock {
    <#
        .SYNOPSIS
            Finds the exact lines each rule occupies in a rules document.

        .DESCRIPTION
            THE FOUNDATION OF EVERY EDIT MADE TO RULES.YAML. Add, Set and Remove
            are splices of whole line ranges, and Save is then writing the string
            back unchanged everywhere else. Nothing round-trips through the YAML
            parser, because the parser yields a dictionary and a dictionary has
            no comments in it - a rules.yaml is created with a comment header
            carrying a worked example, and an administrator adds their own
            explanation beside every rule they write. A save that re-serialised a
            model would delete all of it.

            A COMMENT ABOVE A RULE BELONGS TO THAT RULE, so Start reaches back
            over it while Entry stays on the dash line. The comment above a rule
            is what says why the rule is there and why it sits where it does;
            deleting the rule and leaving the explanation behind, now attached to
            whatever took its place, makes the file state something untrue.

            ONLY ENTRIES AT THE RULE COLUMN ARE RULES. A set: value may be a
            list, and a list item is a dash line too - taking those for rules
            would report a document of four rules as a document of seven.

            A TRAILING BLANK LINE IS NOT PART OF A BLOCK, and neither is the
            document header. Taking the blank would collapse the file's spacing a
            little further with every edit; taking the header would let a rule
            removal delete the explanation of the whole file.

        .PARAMETER Line
            The document, already split into lines.

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

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

              Name the rule name
              Indent the column the dash sits at, which an insert must match
              Entry the index of the '- ' line
              Start Entry, or the first line of the comment above it
              End the last line the rule owns, blanks excluded

        .EXAMPLE
            Get-HDTRuleBlock -Line ($text -split "`r?`n")
    #>

    [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
    )

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

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

    # -- the rules: region -------------------------------------------------

    $rulesAt = -1

    for ($i = 0; $i -lt $Line.Count; $i++) {
        if ($Line[$i] -match '^rules:\s*$') {
            $rulesAt = $i
            break
        }
    }

    if ($rulesAt -lt 0) {
        return [pscustomobject[]] @()
    }

    # It runs to the next top-level key, or to the end of the document.
    $regionEnd = $Line.Count - 1

    for ($i = $rulesAt + 1; $i -lt $Line.Count; $i++) {
        $text = $Line[$i]

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

        # A DASH AT COLUMN ZERO IS A RULE, NOT THE NEXT KEY. YAML lets a block
        # sequence sit at its parent's own indentation, and ConvertTo-HDTYaml -
        # what New-HDTWorkspace writes the first rules.yaml with - emits exactly
        # that. Reading '- name: Fallback' as a new top-level key ended the
        # region before the first rule and left the document looking like one
        # with no rules in it, which is a file this editor could not touch at
        # all. Both spellings are valid and the engine's reader takes both, so
        # this has to as well.
        if ($text -match '^-(\s|$)') { continue }

        if ($text -match '^\S') {
            $regionEnd = $i - 1
            break
        }
    }

    # -- the column the rules are written at -------------------------------

    $ruleIndent = -1

    for ($i = $rulesAt + 1; $i -le $regionEnd; $i++) {
        if ($Line[$i] -match '^(\s*)-\s+\S') {
            $ruleIndent = $Matches[1].Length
            break
        }
    }

    if ($ruleIndent -lt 0) {
        return [pscustomobject[]] @()
    }

    # -- every rule in it --------------------------------------------------

    # Only dash lines at the rule column. A deeper one is an item of a list a
    # rule assigns, which the rule owns and which is not a rule.
    $entry = New-Object -TypeName System.Collections.ArrayList

    for ($i = $rulesAt + 1; $i -le $regionEnd; $i++) {
        if ($Line[$i] -match '^(\s*)-\s+\S' -and $Matches[1].Length -eq $ruleIndent) {
            [void] $entry.Add($i)
        }
    }

    # -- each rule's span and name -----------------------------------------

    for ($e = 0; $e -lt $entry.Count; $e++) {
        $at = [int] $entry[$e]

        $end = $regionEnd
        if ($e + 1 -lt $entry.Count) { $end = [int] $entry[$e + 1] - 1 }

        # Blank lines and the next rule's comments are not this rule's.
        while ($end -gt $at -and
            ([string]::IsNullOrWhiteSpace($Line[$end]) -or $Line[$end] -match '^\s*#')) {
            $end--
        }

        # A comment directly above the dash line explains this rule. The guard
        # stops at the first line of the region, so the document header - which
        # explains the file rather than any rule in it - belongs to no rule.
        $start = $at

        while ($start -gt ($rulesAt + 1) -and $Line[$start - 1] -match '^\s*#') {
            $start--
        }

        # -- what it is called

        $name = ''

        for ($s = $at; $s -le $end; $s++) {
            if ($Line[$s] -match '^\s*(?:-\s+)?name:\s*(.+?)\s*$') {
                $name = $Matches[1]

                # A trailing comment is not part of the name, but a '#' inside a
                # quoted name is.
                if ($name -notmatch '^["'']' -and $name -match '^(.*?)\s+#') {
                    $name = $Matches[1]
                }

                $name = $name.Trim().Trim('"', "'")
                break
            }
        }

        [void] $found.Add([pscustomobject] @{
                Name   = $name
                Indent = $ruleIndent
                Entry  = $at
                Start  = $start
                End    = $end
            })
    }

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