Datum.InvokeCommand.psm1

#Region './Prefix.ps1' -1

$here = $PSScriptRoot
$modulePath = Join-Path -Path $here -ChildPath 'Modules'

Import-Module -Name (Join-Path -Path $modulePath -ChildPath 'DscResource.Common')

$script:localizedData = Get-LocalizedData -DefaultUICulture en-US

# Load the handler configuration (header/footer delimiters) and compile the regular expression
# used to match embedded commands in Datum values. The regex captures the content between the
# configurable header (default: [x=) and footer (default: =]) delimiters.
$config = Import-PowerShellDataFile -Path $here\Config\Datum.InvokeCommand.Config.psd1
$regExString = '{0}(?<Content>((.|\s)+)?){1}' -f [regex]::Escape($config.Header), [regex]::Escape($config.Footer)
$global:datumInvokeCommandRegEx = New-Object Text.RegularExpressions.Regex($regExString, ('IgnoreCase', 'Compiled'))
#EndRegion './Prefix.ps1' 14
#Region './Private/Get-DatumCurrentNode.ps1' -1

function Get-DatumCurrentNode
{
    <#
    .SYNOPSIS
    Resolves the current Datum node from a YAML configuration file.

    .DESCRIPTION
    This internal function determines the current node context when processing a Datum
    configuration file. It reads the YAML file content, converts it, and then attempts
    to resolve the full RSOP (Resultant Set of Policy) for the node using `Get-DatumRsop`.

    If the RSOP resolution succeeds, the resolved node data is returned. Otherwise, the
    raw file content is returned as a fallback.

    This function is used by `Invoke-InvokeCommandAction` to automatically determine the
    `$Node` context when it is not explicitly provided and the value originates from a
    node-specific file (not `Datum.yml`).

    .PARAMETER File
    The YAML configuration file (`System.IO.FileInfo`) to read and resolve the node from.

    .NOTES
    This is a private function and is not exported by the module.

    .LINK
    Invoke-InvokeCommandAction
    #>

    param (
        [Parameter(Mandatory = $true)]
        [System.IO.FileInfo]
        $File
    )

    $fileNode = $File | Get-Content | ConvertFrom-Yaml
    $rsopNode = Get-DatumRsop -Datum $datum -AllNodes $currentNode

    if ($rsopNode)
    {
        $rsopNode
    }
    else
    {
        $fileNode
    }
}
#EndRegion './Private/Get-DatumCurrentNode.ps1' 46
#Region './Private/Get-RelativeNodeFileName.ps1' -1

function Get-RelativeNodeFileName
{
    <#
    .SYNOPSIS
    Converts an absolute file path to a relative Datum node path.

    .DESCRIPTION
    This internal function takes an absolute file path and converts it to a relative path
    suitable for identifying a Datum node within the configuration hierarchy. The function:

    1. Resolves the path relative to the current location.
    2. Splits the path by backslash separators.
    3. Removes the file extension from the last segment.
    4. Returns the segments starting from the third element onward (skipping `.\` prefix
       and the root data folder name), joined with backslashes.

    For example, given a current location of `C:\Config` and a path of
    `C:\Config\DscConfigData\AllNodes\Dev\DSCFile01.yml`, the function returns
    `AllNodes\Dev\DSCFile01`.

    .PARAMETER Path
    The absolute path to the configuration file. Accepts empty strings, in which case
    an empty string is returned.

    .NOTES
    This is a private function and is not exported by the module.

    .LINK
    Invoke-InvokeCommandAction
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]
        $Path
    )

    if (-not $Path)
    {
        return [string]::Empty
    }

    try
    {
        $p = Resolve-Path -Path $Path -Relative -ErrorAction Stop
        $p = $p -split '\\'
        $p[-1] = [System.IO.Path]::GetFileNameWithoutExtension($p[-1])
        $p[2..($p.Length - 1)] -join '\'
    }
    catch
    {
        Write-Verbose 'Get-RelativeNodeFileName: nothing to catch'
    }
}
#EndRegion './Private/Get-RelativeNodeFileName.ps1' 56
#Region './Private/Get-ValueKind.ps1' -1

function Get-ValueKind
{
    <#
    .SYNOPSIS
    Determines the type of a parsed Datum command content string.

    .DESCRIPTION
    This internal function uses the PowerShell parser to analyze the input string extracted
    from a Datum embedded command and determines its kind. The input is the content between
    the header and footer delimiters (e.g., the part between `[x=` and `=]`).

    The function returns a hashtable with `Kind` and `Value` keys. The possible kinds are:

    - **ScriptBlock**: The content is wrapped in curly braces `{ ... }`. The script block
      will be invoked by the caller. Example: `{ Get-Date }`
    - **ExpandableString**: The content is a double-quoted string `"..."` that may contain
      variable references or sub-expressions. It will be expanded using
      `$ExecutionContext.InvokeCommand.ExpandString()`. Example: `"$($Node.Name)"`
    - **LiteralString**: The content is a single-quoted string `'...'`. It cannot be expanded
      and is returned as-is with a warning. Example: `'literal value'`

    If the input does not match any recognized pattern, an error is written.

    .PARAMETER InputObject
    The raw content string extracted from the embedded command (the text between header and
    footer delimiters). Must not be null or empty.

    .EXAMPLE
    Get-ValueKind -InputObject '{ Get-Date }'

    Returns `@{ Kind = 'ScriptBlock'; Value = '{ Get-Date }' }`.

    .EXAMPLE
    Get-ValueKind -InputObject '"Hello $Name"'

    Returns `@{ Kind = 'ExpandableString'; Value = 'Hello $Name' }`.

    .EXAMPLE
    Get-ValueKind -InputObject "'literal'"

    Returns `@{ Kind = 'LiteralString'; Value = 'literal' }` and writes a warning.

    .NOTES
    This is a private function and is not exported by the module.

    .LINK
    Invoke-InvokeCommandAction
    #>

    [OutputType([hashtable])]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $InputObject
    )

    $errors = $null
    $tokens = $null

    $ast = [System.Management.Automation.Language.Parser]::ParseInput(
        $InputObject,
        [ref]$tokens,
        [ref]$errors
    )

    $InputObject = $InputObject.Trim()

    if ($InputObject -notmatch '^{[\s\S]+}$|^"[\s\S]+"$|^''[\s\S]+''$')
    {
        Write-Error 'Get-ValueKind: The input object is not an ExpandableString, nor a literal string or ScriptBlock'
    }
    elseif (($tokens[0].Kind -eq 'LCurly' -and $tokens[-2].Kind -eq 'RCurly' -and $tokens[-1].Kind -eq 'EndOfInput') -or
        ($tokens[0].Kind -eq 'LCurly' -and $tokens[-3].Kind -eq 'RCurly' -and $tokens[-2].Kind -eq 'NewLine' -and $tokens[-1].Kind -eq 'EndOfInput'))
    {
        @{
            Kind  = 'ScriptBlock'
            Value = $InputObject
        }
    }
    elseif ($tokens.Where({ $_.Kind -eq 'StringExpandable' }).Count -eq 1)
    {
        @{
            Kind  = 'ExpandableString'
            Value = $tokens.Where({ $_.Kind -eq 'StringExpandable' }).Value
        }
    }
    elseif ($tokens.Where({ $_.Kind -eq 'StringLiteral' }).Count -eq 1)
    {
        Write-Warning "Get-ValueKind: The value '$InputObject' is a literal string and cannot be expanded."
        @{
            Kind  = 'LiteralString'
            Value = $tokens.Where({ $_.Kind -eq 'StringLiteral' }).Value
        }
    }
    else
    {
        Write-Error "Get-ValueKind: The value '$InputObject' could not be parsed. It is not a scriptblock nor a string."
    }
}
#EndRegion './Private/Get-ValueKind.ps1' 100
#Region './Private/Invoke-InvokeCommandActionInternal.ps1' -1

function Invoke-InvokeCommandActionInternal
{
    <#
    .SYNOPSIS
    Executes the parsed command content extracted from an embedded Datum command string.

    .DESCRIPTION
    This internal function is called by `Invoke-InvokeCommandAction` after the input has been parsed
    and its type (script block, expandable string, or literal string) has been determined by
    `Get-ValueKind`.

    Based on the `DatumType.Kind`, the function performs one of the following:
    - **ScriptBlock**: Creates and invokes the script block using `[scriptblock]::Create()`.
    - **ExpandableString**: Expands the string using `$ExecutionContext.InvokeCommand.ExpandString()`.
    - **Other** (e.g., LiteralString): Returns the value as-is.

    The function also:
    - Sets `$global:CurrentDatumNode` and `$global:CurrentDatumFile` for use within script blocks.
    - Detects and prevents self-referencing loops when `Get-DatumRsop` is involved.
    - Recursively resolves nested embedded commands by calling `Test-InvokeCommandFilter` and
      `Invoke-InvokeCommandAction` on the result.
    - Logs invocation timing for performance diagnostics via `Write-Verbose`.

    .PARAMETER DatumType
    A hashtable containing the parsed command content with the following keys:
    - `Kind`: The type of content (`ScriptBlock`, `ExpandableString`, or `LiteralString`).
    - `Value`: The raw content string to evaluate.

    .PARAMETER Datum
    The Datum structure (hashtable) providing access to the full configuration data hierarchy.
    Falls back to `$DatumTree` if not provided.

    .NOTES
    This is a private function and is not exported by the module. It is called exclusively by
    `Invoke-InvokeCommandAction`.

    .LINK
    https://github.com/raandree/Datum.InvokeCommand

    .LINK
    Invoke-InvokeCommandAction
    #>

    param (
        [Parameter(Mandatory = $true)]
        [hashtable]
        $DatumType,

        [Parameter()]
        [hashtable]
        $Datum
    )

    if (-not $datum -and $DatumTree)
    {
        $datum = $DatumTree
    }

    #Prevent self-referencing loop
    if (($DatumType.Value.Contains('Get-DatumRsop')) -and ((Get-PSCallStack).Command | Where-Object { $_ -eq 'Get-DatumRsop' }).Count -gt 1)
    {
        return $DatumType.Value
    }

    try
    {
        $callId = New-Guid
        $start = Get-Date
        $global:CurrentDatumNode = $Node
        $global:CurrentDatumFile = $file

        Write-Verbose "Invoking command '$($DatumType.Value)'. CallId is '$callId'"

        $result = if ($DatumType.Kind -eq 'ScriptBlock')
        {
            $command = [scriptblock]::Create($DatumType.Value)
            & (& $command)
        }
        elseif ($DatumType.Kind -eq 'ExpandableString')
        {
            $ExecutionContext.InvokeCommand.ExpandString($DatumType.Value)
        }
        else
        {
            $DatumType.Value
        }

        $dynamicPart = $true
        while ($dynamicPart)
        {
            if ($dynamicPart = Test-InvokeCommandFilter -InputObject $result -ReturnValue)
            {
                $innerResult = Invoke-InvokeCommandAction -InputObject $result -Datum $Datum -Node $node
                $result = $result.Replace($dynamicPart, $innerResult)
            }
        }
        $duration = (Get-Date) - $start
        Write-Verbose "Invoke with CallId '$callId' has taken $([System.Math]::Round($duration.TotalSeconds, 2)) seconds"

        if ($result -is [string])
        {
            $ExecutionContext.InvokeCommand.ExpandString($result)
        }
        else
        {
            $result
        }
    }
    catch
    {
        Write-Error -Message ($script:localizedData.CannotCreateScriptBlock -f $DatumType.Value, $_.Exception.Message) -Exception $_.Exception
        return $DatumType.Value
    }
}
#EndRegion './Private/Invoke-InvokeCommandActionInternal.ps1' 114
#Region './Public/Invoke-InvokeCommandAction.ps1' -1

function Invoke-InvokeCommandAction
{
    <#
    .SYNOPSIS
    Invokes a Datum handler action that evaluates embedded commands within Datum configuration data.

    .DESCRIPTION
    This function is the primary action handler for the Datum.InvokeCommand module. It is called by
    the Datum framework when `Test-InvokeCommandFilter` returns `$true` for a given value. The function
    processes input objects that contain embedded commands wrapped in the configurable header/footer
    delimiters (default: `[x=` and `=]`).

    The function supports three types of embedded content:
    - **ScriptBlocks**: Code wrapped in curly braces, e.g., `[x={ Get-Date }=]`
    - **Expandable strings**: Double-quoted strings with variable expansion, e.g., `[x="$($Node.Name)"=]`
    - **Literal strings**: Single-quoted strings (returned as-is with a warning), e.g., `[x='literal'=]`

    When a Datum value contains an embedded command, the handler extracts the content using the configured
    regular expression, determines whether it is a script block or expandable string via `Get-ValueKind`,
    and then invokes or expands it accordingly via `Invoke-InvokeCommandActionInternal`.

    The function provides access to `$Node`, `$Datum`, and `$File` variables within the embedded commands,
    enabling dynamic lookups across the Datum hierarchy.

    Error handling behavior is controlled by the `DatumHandlersThrowOnError` property in the Datum
    definition. When set to `$true`, errors terminate execution; otherwise, warnings are emitted and
    the original input value is returned.

    For more information about Datum and Datum handlers, see https://github.com/gaelcolas/datum/.

    .PARAMETER InputObject
    The input object containing the embedded command string(s) to evaluate. This parameter
    accepts pipeline input. The value should contain a string wrapped with the configured
    header and footer delimiters (default: `[x=` ... `=]`).

    .PARAMETER Datum
    The Datum structure (hashtable) providing access to the full configuration data hierarchy.
    This is made available as `$Datum` within embedded script blocks and expandable strings,
    enabling lookups like `$Datum.Global.Adds.DomainName`.

    .PARAMETER Node
    The current node context (hashtable or object) being processed. If not provided, the function
    attempts to resolve the node from the file path using `Get-DatumCurrentNode`. This is made
    available as `$Node` within embedded commands.

    .PARAMETER ProjectPath
    The root path of the DSC configuration project. Used to resolve relative paths within
    embedded commands.

    .EXAMPLE
    $value = '[x={ Get-Date }=]'
    Invoke-InvokeCommandAction -InputObject $value

    Evaluates the embedded script block `{ Get-Date }` and returns the current date/time.

    .EXAMPLE
    $value = '[x="$($Node.Name) in $($Node.Environment)"=]'
    Invoke-InvokeCommandAction -InputObject $value -Datum $datum -Node $node

    Expands the embedded string using the `$Node` variable, returning something like
    'DSCFile01 in Dev'.

    .EXAMPLE
    $value = '[x={ $Datum.Global.Adds.DomainFqdn }=]'
    Invoke-InvokeCommandAction -InputObject $value -Datum $datum

    Evaluates the script block to return the domain FQDN from Datum's global configuration,
    e.g., 'contoso.com'.

    .EXAMPLE
    $value = '[x={ @{ Name = "Server1"; Value = "Config1" } }=]'
    Invoke-InvokeCommandAction -InputObject $value

    Returns a hashtable from the evaluated script block. Script blocks can return any
    PowerShell object type including hashtables, arrays, and custom objects.

    .EXAMPLE
    '[x={ Get-Date }=]' | Invoke-InvokeCommandAction

    Demonstrates pipeline input support.

    .NOTES
    This function is registered as a Datum handler in the `Datum.yml` configuration file under
    the `DatumHandlers` section:

        DatumHandlers:
          Datum.InvokeCommand::InvokeCommand:
            SkipDuringLoad: true

    The `SkipDuringLoad: true` flag prevents the handler from being invoked during the initial
    Datum structure loading. Commands are evaluated only during value resolution (e.g., RSOP
    computation).

    The header and footer delimiters can be customized in the module configuration file
    `Config\Datum.InvokeCommand.Config.psd1`.

    .LINK
    https://github.com/raandree/Datum.InvokeCommand

    .LINK
    https://github.com/gaelcolas/datum/

    .LINK
    Test-InvokeCommandFilter
    #>

    param (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [object]
        $InputObject,

        [Parameter()]
        [hashtable]
        $Datum,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [object]
        $Node,

        [Parameter()]
        [string]
        $ProjectPath

    )

    $throwOnError = [bool]$datum.__Definition.DatumHandlersThrowOnError

    if ($InputObject -is [array])
    {
        $returnValue = @()
    }
    else
    {
        $returnValue = $null
    }

    foreach ($value in $InputObject)
    {
        $regexResult = ($datumInvokeCommandRegEx.Match($value).Groups['Content'].Value)
        if (-not $regexResult -and $throwOnError)
        {
            Write-Error "Could not get the content for the Datum.InvokeCommand handler, RegEx '$($datumInvokeCommandRegEx.ToString())' did not succeed." -ErrorAction Stop
        }
        elseif (-not $regexResult -and -not $throwOnError)
        {
            Write-Warning "Could not get the content for the Datum.InvokeCommand handler, RegEx '$($datumInvokeCommandRegEx.ToString())' did not succeed."
            $returnValue += $value
            continue
        }

        $datumType = Get-ValueKind -InputObject $regexResult -ErrorAction (& { if ($throwOnError)
                {
                    'Stop'
                }
                else
                {
                    'Continue'
                } })

        if ($datumType)
        {
            try
            {
                $file = $null

                # avoid TerminatingError in log if $value is an attribute of node.yml
                # -> in this case $value.__File is $null
                if( $value.__File )
                {
                    $file = Get-Item -Path $value.__File -ErrorAction Ignore
                }
            }
            catch
            {
                Write-Verbose 'Invoke-InvokeCommandAction: Nothing to catch'
            }

            if (-not $Node -and $file)
            {
                if ($file.Name -ne 'Datum.yml')
                {
                    $Node = Get-DatumCurrentNode -File $file

                    if (-not $Node)
                    {
                        return $value
                    }
                }
            }

            try
            {
                $returnValue += (Invoke-InvokeCommandActionInternal -DatumType $datumType -Datum $Datum -ErrorAction Stop).ForEach({
                        $_ | Add-Member -Name __File -MemberType NoteProperty -Value "$file" -PassThru -Force
                    })

            }
            catch
            {
                $throwOnError = [bool]$datum.__Definition.DatumHandlersThrowOnError

                if ($throwOnError)
                {
                    Write-Error -Message "Error using Datum Handler $Handler, the error was: '$($_.Exception.Message)'. Returning InputObject ($InputObject)." -Exception $_.Exception -ErrorAction Stop
                }
                else
                {
                    Write-Warning "Error using Datum Handler $Handler, the error was: '$($_.Exception.Message)'. Returning InputObject ($InputObject)."
                    $returnValue += $value
                    continue
                }
            }
        }
        else
        {
            $returnValue += $value
        }
    }

    if ($InputObject -is [array])
    {
        , $returnValue
    }
    else
    {
        $returnValue
    }
}
#EndRegion './Public/Invoke-InvokeCommandAction.ps1' 229
#Region './Public/Test-InvokeCommandFilter.ps1' -1

function Test-InvokeCommandFilter
{
    <#
    .SYNOPSIS
    Tests whether a Datum value contains an embedded command that should be processed by the
    Datum.InvokeCommand handler.

    .DESCRIPTION
    This filter function is invoked by the Datum framework's `ConvertTo-Datum` function on every
    value during data resolution. When this function returns `$true`, the Datum framework calls
    the corresponding action function `Invoke-InvokeCommandAction` to evaluate the embedded command.

    The function checks whether the input object is a string that matches the configured regular
    expression pattern for embedded commands (default pattern: `[x=` ... `=]`). The header and
    footer delimiters are defined in `Config\Datum.InvokeCommand.Config.psd1` and compiled into
    a regex at module load time.

    When the `-ReturnValue` switch is specified, the function returns the full matched string
    instead of a boolean. This is used internally by `Invoke-InvokeCommandActionInternal` to
    detect and resolve nested embedded commands.

    For more information about Datum handlers and their filter/action pattern, see
    https://github.com/gaelcolas/datum/.

    .PARAMETER InputObject
    The object to test. Only string values are evaluated against the embedded command pattern.
    Non-string objects always return `$false`. Accepts pipeline input.

    .PARAMETER ReturnValue
    When specified, returns the full matched string (including header and footer) instead of
    a boolean. Used internally for nested command resolution.

    .EXAMPLE
    Test-InvokeCommandFilter -InputObject '[x={ Get-Date }=]'

    Returns `$true` because the input string matches the embedded command pattern.

    .EXAMPLE
    Test-InvokeCommandFilter -InputObject 'Just a regular string'

    Returns `$false` because the input string does not contain an embedded command.

    .EXAMPLE
    '[x={ Get-Date }=]' | Test-InvokeCommandFilter

    Demonstrates pipeline input. Returns `$true`.

    .EXAMPLE
    Test-InvokeCommandFilter -InputObject '[x={ Get-Date }=]' -ReturnValue

    Returns the full matched string `[x={ Get-Date }=]` instead of `$true`.

    .EXAMPLE
    42 | Test-InvokeCommandFilter

    Returns `$false` because the input is not a string.

    .NOTES
    Datum handler modules follow a convention of exposing a filter function (`Test-*`) and an
    action function (`Invoke-*`). The filter function is called first to determine if the action
    should be invoked for a given value. This pattern is described in the Datum documentation.

    .LINK
    https://github.com/raandree/Datum.InvokeCommand

    .LINK
    https://github.com/gaelcolas/datum/

    .LINK
    Invoke-InvokeCommandAction
    #>

    param (
        [Parameter(ValueFromPipeline = $true)]
        [object]
        $InputObject,

        [Parameter()]
        [switch]
        $ReturnValue
    )

    if ($InputObject -is [string])
    {
        $all = $datumInvokeCommandRegEx.Match($InputObject.Trim()).Groups['0'].Value
        $content = $datumInvokeCommandRegEx.Match($InputObject.Trim()).Groups['Content'].Value

        if ($ReturnValue -and $content)
        {
            $all
        }
        elseif ($content)
        {
            return $true
        }
        else
        {
            return $false
        }
    }
    else
    {
        return $false
    }
}
#EndRegion './Public/Test-InvokeCommandFilter.ps1' 105