Private/Split-AACHclBlock.ps1

function Split-AACHclBlock {
    <#
    .SYNOPSIS
        Splits Terraform (HCL) text into its top-level blocks - terraform,
        provider, resource, import... - each with its type, its labels and
        its text, so exported configurations can be laid out in files and
        merged.
    .DESCRIPTION
        Reads character by character, so braces inside quoted strings,
        heredocs (<<EOT, <<-EOT) and comments (#, //, /* */) don't count.
        Returns, in order, @{ Type; Labels (the quoted labels: 'azurerm',
        or 'azurerm_resource_group', 'res-1'); Text (the block, from its
        type to its closing brace) }. Anything at the top level that isn't
        a block - a comment, a blank line - is left out.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [AllowEmptyString()]
        [string] $Text
    )

    if ([string]::IsNullOrWhiteSpace($Text)) { return }
    $length = $Text.Length
    $i = 0
    # The index just past a string, heredoc or comment starting at $at - or $at when there is none.
    $skip = {
        param([int] $At)
        $c = $Text[$At]
        if ($c -eq '"') {
            $j = $At + 1
            while ($j -lt $length -and $Text[$j] -ne '"') { if ($Text[$j] -eq '\') { $j++ }; $j++ }
            return [Math]::Min($j + 1, $length)
        }
        if ($c -eq '#' -or ($c -eq '/' -and $At + 1 -lt $length -and $Text[$At + 1] -eq '/')) {
            $j = $Text.IndexOf("`n", $At)
            return $(if ($j -lt 0) { $length } else { $j + 1 })
        }
        if ($c -eq '/' -and $At + 1 -lt $length -and $Text[$At + 1] -eq '*') {
            $j = $Text.IndexOf('*/', $At + 2)
            return $(if ($j -lt 0) { $length } else { $j + 2 })
        }
        if ($c -eq '<' -and $At + 1 -lt $length -and $Text[$At + 1] -eq '<') {
            $heredoc = [regex]::Match($Text.Substring($At), '^<<-?([A-Za-z_][A-Za-z0-9_]*)[ \t]*\r?\n')
            if ($heredoc.Success) {
                $marker = [regex]::Match($Text.Substring($At + $heredoc.Length), "(?m)^[ \t]*$([regex]::Escape($heredoc.Groups[1].Value))[ \t]*$")
                return $(if ($marker.Success) { $At + $heredoc.Length + $marker.Index + $marker.Length } else { $length })
            }
        }
        $At
    }

    while ($i -lt $length) {
        $c = $Text[$i]
        if ([char]::IsWhiteSpace($c)) { $i++; continue }
        $after = & $skip $i
        if ($after -ne $i) { $i = $after; continue }

        # A block: its header runs to the first brace outside a string.
        $start = $i
        while ($i -lt $length -and $Text[$i] -ne '{' -and $Text[$i] -ne "`n") {
            $after = & $skip $i
            $i = if ($after -ne $i) { $after } else { $i + 1 }
        }
        if ($i -ge $length -or $Text[$i] -ne '{') { continue }
        $header = $Text.Substring($start, $i - $start).Trim()
        $depth = 0
        while ($i -lt $length) {
            $after = & $skip $i
            if ($after -ne $i) { $i = $after; continue }
            if ($Text[$i] -eq '{') { $depth++ }
            elseif ($Text[$i] -eq '}') { $depth--; if ($depth -eq 0) { $i++; break } }
            $i++
        }
        $type = ($header -split '\s+')[0]
        @{
            Type   = $type
            Labels = @([regex]::Matches($header, '"([^"]*)"') | ForEach-Object { $_.Groups[1].Value })
            Text   = $Text.Substring($start, $i - $start)
        }
    }
}