private/Invoke-TerraformArgumentCompleter.ps1

function Invoke-TerraformArgumentCompleter
{
    param(
        $WordToComplete,
        $CommandAst,
        $CursorPosition
    )

    #region Helpers

    function Get-CommandCompletionResult
    {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            $CommandTreeNode,

            [Parameter(Mandatory)]
            [AllowEmptyString()]
            [string] $WordToComplete
        )

        if(-not $CommandTreeNode.Commands -or $WordToComplete.StartsWith('-'))
        {
            return @() 
        }

        return $CommandTreeNode.Commands |
            Where-Object { $_.Name.StartsWith($WordToComplete) } |
            ForEach-Object {
                [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'Method', ($_.Description -join ''))
            }
    }

    function Get-OptionCompletionResult
    {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            $CommandTreeNode,

            [Parameter(Mandatory)]
            [AllowEmptyString()]
            [string] $WordToComplete,

            [Parameter()]
            [object[]] $CompletedOptions = @()
        )

        if(-not $CommandTreeNode.Options -or `
            (-not [string]::IsNullOrEmpty($WordToComplete) -and -not $WordToComplete.StartsWith('-'))
        )
        {
            return @()
        }

        $CommandTreeNode.Options | 
            Where-Object { $_.Name.StartsWith($WordToComplete.TrimStart('-')) } |
            Where-Object { $_.Name -notin $CompletedOptions -or $_.Repeatable } |
            ForEach-Object {
                $Name = "-$($_.Name)"
                $TextToInsert = $Name

                if(-not $_.Flag)
                {
                    $TextToInsert += '='
                }

                [System.Management.Automation.CompletionResult]::new($TextToInsert, $Name, 'ParameterName', ($_.Description -join ''))
            }
    }

    function Get-OptionValueCompletionResult
    {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            $CommandTreeNode,

            [Parameter(Mandatory)]
            [AllowEmptyString()]
            [string] $WordToComplete,

            [Parameter(Mandatory)]
            [System.Management.Automation.Language.CommandElementAst[]] $CommandElements
        )

        if(-not $WordToComplete.StartsWith('-') -or -not $WordToComplete.Contains('='))
        {
            return 
        }

        $Parameter = $WordToComplete.TrimStart('-')
        $ParameterName, $ParameterValue = $Parameter.Split('=', 2)

        $Option = $CommandTreeNode.Options | Where-Object { $_.Name -eq $ParameterName }

        if($null -eq $Option)
        {
            return
        }

        if($Option.AllowedValues)
        {
            # If value is already complete, we don't need to provide completion
            if($Option.AllowedValues -contains $ParameterValue)
            {
                return
            }

            return $Option.AllowedValues | 
                Where-Object { $_.StartsWith($ParameterValue) } |
                ForEach-Object {
                    [System.Management.Automation.CompletionResult]::new(
                        "$($WordToComplete)$($_)", 
                        $_, 
                        'ParameterValue',
                        $_
                    )
                }
        }
        elseif($Option.PathValue)
        {
            Get-OptionPathValueCompletionResult `
                -Option $Option `
                -WordToComplete $WordToComplete `
                -CommandElements $CommandElements
        }
    }

    function Get-OptionPathValueCompletionResult
    {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [object] $Option,

            [Parameter(Mandatory)]
            [AllowEmptyString()]
            [string] $WordToComplete,

            [Parameter(Mandatory)]
            [System.Management.Automation.Language.CommandElementAst[]] $CommandElements
        )

        # In case of the path, we need raw value with quotes if provided
        $RawWordToComplete = $CommandElements[-1].Extent.Text
        $RawParameter = $RawWordToComplete.TrimStart("-")
        $RawParameterValue = $RawParameter.Split('=')[1]
        $PathValue = $RawParameterValue.Trim(@("'", '"')).TrimEnd(@('/', '\'))

        # Explicitely type to use the correct Split override in .NET
        $PathSeparators = [char[]]@('/', '\')

        if([string]::IsNullOrEmpty($PathValue))
        {
            $ParameterValue = "."

            # Special case if completion is trigger after a quote to fix the path
            $RawWordToComplete += "."
        }

        if((Test-Path -Path $ParameterValue -PathType Leaf))
        {
            return @()
        }

        # If the path is a full path (not a partial match / search filter)
        if((Test-Path -Path $ParameterValue -PathType Container))
        {
            $LookupPath = "$ParameterValue/*"

            # We need to complete after the complete path (we remove any path separator at then end)
            # We will add them manually later
            $RawWordToCompleteWithPath = $RawWordToComplete.TrimEnd($PathSeparators)
        }
        else
        {
            $LookupPath = "$ParameterValue*" 

            # For a non-completed folder/file Name
            # We need to complete the path up to the last separator and we will add the folder/file name later
            $RawWordToCompleteWithPath = ($RawWordToComplete.Split($PathSeparators) | Select-Object -SkipLast 1) -join [System.IO.Path]::DirectorySeparatorChar
        }

        # If the beginning of a file/folder was already provided, we need to use it as a filter
        $SearchFilter = $LookupPath.Split(@('/'))[-1]

        return Get-ChildItem -Path $LookupPath | ForEach-Object {
            if($_.Name -like $SearchFilter)        
            {
                $Completion = @($RawWordToCompleteWithPath, $_.Name) -join [System.IO.Path]::DirectorySeparatorChar 

                if(-not ($_.Attributes -band [System.IO.FileAttributes]::Directory))
                {
                    # QoL, add closing quote at the end for files
                    $Completion += "$($RawParameterValue[0])"
                }

                [System.Management.Automation.CompletionResult]::new(
                    $Completion, 
                    $_.Name, 
                    'ParameterValue',
                    $_.FullName
                )
            }
        }
    }

    function Get-CompletionResult
    {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            $CommandTreeNode,

            [Parameter(Mandatory)]
            [AllowEmptyString()]
            [string] $WordToComplete,

            [Parameter(Mandatory)]
            [AllowEmptyCollection()]
            [System.Management.Automation.Language.CommandElementAst[]] $CommandElements,

            [Parameter()]
            [object[]] $CompletedOptions = @()
        )

        # ParameterValue completion
        if($WordToComplete.StartsWith('-') -and $WordToComplete.Contains('='))
        {
            Get-OptionValueCompletionResult -CommandTreeNode $CommandTreeNode -WordToComplete $WordToComplete -CommandElements $CommandElements
        }
        else
        {
            @(
                Get-CommandCompletionResult -CommandTreeNode $CommandTreeNode -WordToComplete $WordToComplete
            ) + @(
                Get-OptionCompletionresult `
                    -CommandTreeNode $CommandTreeNode `
                    -WordToComplete $WordToComplete `
                    -CompletedOptions $CompletedOptions
            )
        }
    }

    function Get-CompletedOptions
    {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [AllowEmptyCollection()]
            [System.Management.Automation.Language.CommandElementAst[]] $CommandElements,

            [Parameter(Mandatory)]
            [AllowEmptyString()]
            [string] $WordToComplete
        )

        $CommandElements | Foreach-Object {
            $OptionString = $_.ToString()

            if($OptionString -ne $WordToComplete)
            {
                $OptionString.TrimStart('-').Split('=')[0]
            }
        }
    }

    #endregion Helpers

    $GrammarFile = Join-Path $script:TerraformCompleter.Paths.Grammar -ChildPath $script:TerraformCompleter.GrammarFileName

    if(-not (Test-Path -Path $GrammarFile -PathType Leaf))
    {
        Write-Error -Category NotInstalled "Terraform completer grammar file not found at '$GrammarFile'."
        return
    }

    $CommandTree = Get-Content -Path $GrammarFile | ConvertFrom-Json -AsHashtable

    try
    {
        # Skip the first element (always 'terraform')
        $CommandElements = [System.Collections.ArrayList]::new(@($CommandAst.CommandElements | Select-Object -Skip 1))

        # Naked terraform (without any command/options)
        if($CommandElements.Count -eq 0)
        {
            return Get-CompletionResult `
                -CommandTreeNode $CommandTree `
                -WordToComplete $WordToComplete `
                -CommandElements $CommandElements
        }

        # We walk the tree with already completed commands
        $CurrentNode = $CommandTree

        foreach($CurrentElement in $CommandElements)
        {
            # Command/Subcommand
            if($CurrentElement -is [System.Management.Automation.Language.StringConstantExpressionAst])
            {
                $Command = $CurrentNode.Commands | Where-Object { $_.Name -eq $CurrentElement.Value }

                if($null -ne $Command)
                {
                    $CurrentNode = $Command
                }
                elseif($CommandElementsRemaining.Count -gt 0)
                {
                    # If we have a non matching items and we have more elements to process, we can't complete
                    return
                }
            }
        }

        $CompletedOptions = Get-CompletedOptions -CommandElements $CommandElements -WordToComplete $WordToComplete

        return Get-CompletionResult `
            -CommandTreeNode $CurrentNode `
            -WordToComplete $WordToComplete `
            -CompletedOptions $CompletedOptions `
            -CommandElements $CommandElements
    }
    catch
    {
        throw $_
    }

    return @()

}