TerraformAST.psm1

$dllPath = Join-Path $PSScriptRoot "lib\TerraformAST.dll"
if (-not (Test-Path $dllPath)) {
    throw "DLL not found: $dllPath"
}

$signature = @"
    [DllImport(@"$dllPath", EntryPoint = "ParseHCL", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
    public static extern IntPtr ParseHCL(string filePath);
 
    [DllImport(@"$dllPath", EntryPoint = "FreeString", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
    public static extern void FreeString(IntPtr str);
"@


$typeName = 'TerraformAST.HCLParser'
$alreadyLoaded = [AppDomain]::CurrentDomain.GetAssemblies() |
    ForEach-Object { try { $_.GetType($typeName, $false, $false) } catch { $null } } |
    Where-Object { $_ }

if (-not $alreadyLoaded) {
    Add-Type -MemberDefinition $signature -Name HCLParser -Namespace TerraformAST
}

# Default view: Type, Name, Line, Column, File.
# Everything else is still on the object — use Format-List * or Select-Object *.
$blockTypeName = 'TerraformAST.Block'
Update-TypeData -TypeName $blockTypeName -DefaultDisplayPropertySet Type, Name, Line, Column, File -Force
Update-TypeData -TypeName $blockTypeName -MemberType ScriptProperty -MemberName Name -Value {
    if ($this.Labels) { $this.Labels -join '.' }
} -Force
Update-TypeData -TypeName $blockTypeName -MemberType ScriptProperty -MemberName Line -Value {
    $this.TypeRange.Start.Line
} -Force
Update-TypeData -TypeName $blockTypeName -MemberType ScriptProperty -MemberName Column -Value {
    $this.TypeRange.Start.Column
} -Force
Update-TypeData -TypeName $blockTypeName -MemberType ScriptProperty -MemberName File -Value {
    if ($this.TypeRange.Filename) {
        Split-Path -Path $this.TypeRange.Filename -Leaf
    }
} -Force

function ConvertTo-TerraformAstBlock {
    param($Block)
    $Block.PSObject.TypeNames.Insert(0, 'TerraformAST.Block')
    $Block
}

function ConvertFrom-TerraformHclFile {
    <#
    .SYNOPSIS
        Parses one Terraform .tf file through the native HCL DLL and emits its blocks.
 
    .DESCRIPTION
        ConvertFrom-TerraformHclFile is the single-file parser used by Get-TerraformAST.
        It resolves a literal path, calls ParseHCL on TerraformAST.dll (HashiCorp HCL v2),
        converts the JSON payload to objects, and writes each top-level block from
        Body.Blocks to the pipeline.
 
        Prefer Get-TerraformAST for directories, recursion, and validation. This function
        assumes the path already exists.
 
    .PARAMETER LiteralPath
        Full or relative path to a single .tf file. Wildcards are not expanded.
 
    .EXAMPLE
        ConvertFrom-TerraformHclFile -LiteralPath .\infra\variables.tf
 
        Parse one file and emit its HCL blocks.
 
    .EXAMPLE
        Get-TerraformAST -FilePath .\infra\main.tf
 
        Public wrapper that validates the path, then calls this function.
 
    .OUTPUTS
        TerraformAST.Block. Default view is Type, Name, Line, Column, File.
        TypeRange, LabelRanges, Body, and brace ranges remain on the object.
 
    .NOTES
        Not exported. Get-TerraformAST is the supported entry point.
        Parse failures throw so a 7.4+ agent with ErrorAction Stop can correct them.
 
    .LINK
        Get-TerraformAST
 
    .LINK
        https://github.com/JerryBalmer1/TerraformAST
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]
        $LiteralPath
    )

    $absPath = (Resolve-Path -LiteralPath $LiteralPath -ErrorAction Stop).Path
    Write-Verbose "Parsing $absPath"

    $astJsonPtr = [TerraformAST.HCLParser]::ParseHCL($absPath)
    if ($astJsonPtr -eq [IntPtr]::Zero) {
        throw "Failed to parse HCL file: Null pointer returned ($absPath)"
    }

    try {
        $astJson = [System.Runtime.InteropServices.Marshal]::PtrToStringAnsi($astJsonPtr)
    }
    finally {
        [TerraformAST.HCLParser]::FreeString($astJsonPtr)
    }

    if (-not $astJson) {
        throw "Failed to convert AST JSON to string ($absPath)"
    }

    if ($astJson.StartsWith("Error ")) {
        throw "$astJson ($absPath)"
    }

    if ($PSVersionTable.PSVersion.Major -gt 5) {
        $ast = $astJson | ConvertFrom-Json -Depth 99 -ErrorAction Stop
    }
    else {
        $ast = $astJson | ConvertFrom-Json -ErrorAction Stop
    }

    $ast.Body.Blocks | ForEach-Object { ConvertTo-TerraformAstBlock -Block $_ }
}

function Get-TerraformAST {
    <#
    .SYNOPSIS
        Parses Terraform .tf files into an HCL abstract syntax tree.
 
    .DESCRIPTION
        Get-TerraformAST walks one file or a directory of Terraform configuration and
        returns the HCL blocks produced by HashiCorp HCL v2 (the language library
        Terraform uses).
 
        Use -FilePath for a single .tf file. Use -Path for a directory. Add -Recurse
        to include .tf files in subdirectories.
 
        Default display is Type, Name, Line, Column, and File. Source ranges and Body
        stay on the object; use Format-List * when you need the full tree.
 
    .PARAMETER Path
        Directory that contains Terraform .tf files.
 
    .PARAMETER Recurse
        When -Path is used, include .tf files in child directories.
 
    .PARAMETER FilePath
        A single Terraform .tf file.
 
    .EXAMPLE
        Get-TerraformAST -Path .\infra
 
        Parse every .tf file in the infra directory (not recursive).
 
    .EXAMPLE
        Get-TerraformAST -Path .\infra -Recurse
 
        Parse every .tf file under infra, including nested modules.
 
    .EXAMPLE
        Get-TerraformAST -FilePath .\infra\main.tf
 
        Parse one Terraform file.
 
    .EXAMPLE
        Get-ChildItem .\infra -Filter *.tf | Get-TerraformAST
 
        Pipeline input binds to -FilePath via the FullName alias.
 
    .OUTPUTS
        TerraformAST.Block
 
    .LINK
        https://github.com/JerryBalmer1/TerraformAST
 
    .LINK
        https://www.powershellgallery.com/packages/TerraformAST/1.0.1
    #>

    [CmdletBinding(DefaultParameterSetName = 'Directory')]
    param(
        [Parameter(
            Mandatory,
            ParameterSetName = 'Directory',
            Position = 0,
            HelpMessage = 'Directory that contains .tf files.'
        )]
        [ValidateScript({
            if (-not (Test-Path -LiteralPath $_ -PathType Container)) {
                throw "Path '$_' is not an existing directory."
            }
            $true
        })]
        [string]
        $Path,

        [Parameter(ParameterSetName = 'Directory')]
        [switch]
        $Recurse,

        [Parameter(
            Mandatory,
            ParameterSetName = 'File',
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            HelpMessage = 'Path to a single .tf file.'
        )]
        [Alias('FullName')]
        [ValidateScript({
            if (-not (Test-Path -LiteralPath $_ -PathType Leaf)) {
                throw "FilePath '$_' is not an existing file."
            }
            if (-not ([System.IO.FileInfo]$_).Extension.Equals('.tf', [System.StringComparison]::OrdinalIgnoreCase)) {
                throw "FilePath '$_' must have a .tf extension."
            }
            $true
        })]
        [string]
        $FilePath
    )

    begin {
        Write-Verbose "[ $($MyInvocation.InvocationName) ] $($PSCmdlet.ParameterSetName)"
    }

    process {
        $files = switch ($PSCmdlet.ParameterSetName) {
            'File' {
                Get-Item -LiteralPath $FilePath
            }
            'Directory' {
                $gci = @{
                    LiteralPath = $Path
                    Filter      = '*.tf'
                    File        = $true
                    ErrorAction = 'Stop'
                }
                if ($Recurse) {
                    $gci.Recurse = $true
                }
                Get-ChildItem @gci
            }
        }

        if (-not $files) {
            Write-Error "No .tf files found."
            return
        }

        foreach ($file in $files) {
            try {
                ConvertFrom-TerraformHclFile -LiteralPath $file.FullName
            }
            catch {
                Write-Error -ErrorRecord $_
            }
        }
    }
}