Common/CMake.ps1

#----------------------------------------------------------------------------------------------------------------------
# MIT License
#
# Copyright (c) 2025 Mark Schofield
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#----------------------------------------------------------------------------------------------------------------------
#Requires -PSEdition Core

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

. $PSScriptRoot/Common.ps1

$PEnv = Get-ChildItem env: | ToHashTable

$CMakeCandidates = @(
    (Get-Command 'cmake' -ErrorAction SilentlyContinue)
    if ($IsWindows) {
        (Join-Path -Path $env:ProgramFiles -ChildPath 'CMake/bin/cmake.exe')
    }
)

<#
    .Synopsis
    Finds the path of the CMakePresets file in the current or ancestral folder. This may be a 'CMakePresets.json' file
    or a 'CMakeUserPresets.json' file.
#>

function FindCMakePresets {
    $CurrentLocation = (Get-Location).Path
    GetPathOfFileAbove $CurrentLocation 'CMakeUserPresets.json', 'CMakePresets.json'
}

<#
    .Synopsis
    Finds the root of the CMake build - the current or ancestral folder containing CMake presets.
#>

function FindCMakeRoot {
    $CMakePresetsPath = FindCMakePresets
    [System.IO.Path]::GetDirectoryName($CMakePresetsPath)
}

$script:CMakePresetsPath = $null

<#
    .Synopsis
    Gets the path that the most recently loaded 'CMakePresets.json' or 'CMakeUserPresets.json' was loaded from.
#>

function GetCMakePresetsPath {
    $script:CMakePresetsPath
}

<#
    .Synopsis
    Loads the CMakePresets.json into a PowerShell representation.
#>

function GetCMakePresets {
    param(
        [switch] $Silent
    )
    $script:CMakePresetsPath = FindCMakePresets
    if (-not $script:CMakePresetsPath) {
        if ($Silent) {
            return $null
        }
        Write-Error "Can't find 'CMakePresets.json' or 'CMakeUserPresets.json' in the current or any parent folder."
    }

    $CMakeRoot = [System.IO.Path]::GetDirectoryName($CMakePresetsPath)
    $CMakePresetsJson = $null

    Write-Verbose "Presets = $CMakeRoot"

    # If files were included, load them now and merge them in. Keep track of files that were included to avoid cycles.
    $IncludedFiles = [System.Collections.Generic.HashSet[string]]::new()
    [array] $IncludePaths = @(
        $CMakePresetsPath
        if ([System.IO.Path]::GetFileName($CMakePresetsPath) -ieq 'CMakeUserPresets.json') {
            Join-Path -Path $CMakeRoot -ChildPath 'CMakePresets.json'
        }
    )

    for (; ; ) {
        $IncludePath, $IncludePaths = $IncludePaths
        if (-not $IncludePath) {
            break
        }

        Write-Verbose "Processing CMakePresets: $IncludePath"

        $IncludeJson = Get-Content $IncludePath | ConvertFrom-Json
        if (-not $CMakePresetsJson) {
            $CMakePresetsJson = $IncludeJson
        } else {
            $CMakePresetsJson.buildPresets += Get-MemberValue -InputObject $IncludeJson -Name 'buildPresets' -Or @()
            $CMakePresetsJson.configurePresets += Get-MemberValue -InputObject $IncludeJson -Name 'configurePresets' -Or @()
        }

        $NestedIncludePaths = Get-MemberValue -InputObject $IncludeJson -Name 'include' -Or @()
        $IncludeRoot = [System.IO.Path]::GetDirectoryName($IncludePath)
        foreach ($NestedIncludePath in $NestedIncludePaths) {
            # Macro substiution for include paths
            $NestedIncludePath = MacroReplacement $NestedIncludePath $null

            if (-not [System.IO.Path]::IsPathFullyQualified($NestedIncludePath)) {
                $NestedIncludePath = Join-Path -Path $IncludeRoot -ChildPath $NestedIncludePath
            }

            if (-not (Test-Path -Path $NestedIncludePath -PathType Leaf)) {
                Write-Error "Included CMake presets file '$NestedIncludePath' not found."
            }

            if ($IncludedFiles.Contains($NestedIncludePath)) {
                Write-Error "Cyclic include detected for included CMake presets file '$NestedIncludePath'."
            }

            $IncludedFiles.Add($NestedIncludePath) | Out-Null
            $IncludePaths = $NestedIncludePath
        }
    }

    $CMakePresetsJson
}

<#
    .Synopsis
    Gets 'buildPresets' in the specified CMakePresets.json object.
#>

function GetBuildPresets {
    param(
        $CMakePresetsJson
    )
    if ($CMakePresetsJson) {
        $Presets = $CMakePresetsJson.buildPresets

        # Filter presets that have '"hidden":true'
        $Presets = $Presets | Where-Object { -not (Get-MemberValue -InputObject $_ -Name 'hidden' -Or $false) }

        # Filter presets that have (or their ancestors have) conditions that evaluate to $false
        $Presets = $Presets | Where-Object { EvaluatePresetCondition $_ $CMakePresetsJson.buildPresets }

        # Filter presets that have configure presets that have conditions that evaluate to $false
        $Presets = $Presets | Where-Object {
            $BuildPresetJson = $_
            $ConfigurePresetJson = $CMakePresetsJson.configurePresets | Where-Object { $_.name -eq $BuildPresetJson.configurePreset } | Where-Object { EvaluatePresetCondition $_ $CMakePresetsJson.configurePresets }

            $null -ne $ConfigurePresetJson
        }

        $Presets
    }
}

<#
    .Synopsis
    Gets the build presets matching the given name(s). If no name is given, returns the first available preset.
    Writes an error if a named preset cannot be found.
#>

function GetMatchingBuildPresets {
    param(
        $CMakePresetsJson,
        $Preset
    )
    $BuildPresets = GetBuildPresets $CMakePresetsJson
    if (-not $Preset) {
        if (-not $BuildPresets) {
            Write-Error "No Presets values specified, and one could not be inferred."
        }
        $BuildPresets | Select-Object -First 1
    } else {
        foreach ($CandidatePresetName in $Preset) {
            $MatchingPresets = $BuildPresets |
                Where-Object { ($_.name -like $CandidatePresetName) -or ($_.name -eq $CandidatePresetName) }
            if (-not $MatchingPresets) {
                Write-Error "Unable to find build preset '$CandidatePresetName' in $script:CMakePresetsPath"
            }
            $MatchingPresets
        }
    }
}

<#
    .Synopsis
    Gets the 'configurePresets' in the specified CMakePresets.json object.
#>

function GetConfigurePresets {
    param(
        $CMakePresetsJson
    )
    if ($CMakePresetsJson) {
        $Presets = $CMakePresetsJson.configurePresets

        # Filter presets that have '"hidden":true'
        $Presets = $Presets |
            Where-Object { -not (Get-MemberValue -InputObject $_ -Name 'hidden' -Or $false) }

        # Filter presets that have (or their ancestors have) conditions that evaluate to $false
        $Presets = $Presets |
            Where-Object { EvaluatePresetCondition $_ $CMakePresetsJson.configurePresets }

        $Presets
    }
}

<#
    .Synopsis
    Finds the 'CMake' command.
#>

function GetCMake {
    $CMake = Get-Variable -Name 'CMake' -ValueOnly -Scope script -ErrorAction SilentlyContinue
    if (-not $CMake) {
        foreach ($CMakeCandidate in $CMakeCandidates) {
            $CMake = Get-Command $CMakeCandidate -ErrorAction SilentlyContinue
            if ($CMake) {
                $script:CMake = $CMake
                break
            }
        }
        if (-not $CMake) {
            Write-Error "Unable to find CMake."
        }
    }
    $CMake
}

<#
    .Synopsis
    Returns the configure preset referenced by the given build preset's 'configurePreset' field.
#>

function GetConfigurePresetFor {
    param(
        $CMakePresetsJson,
        $Preset
    )
    $CMakePresetsJson.configurePresets | Where-Object { $_.name -eq $Preset.configurePreset }
}

<#
    .Synopsis
    Searches the specified preset and its ancestors, invoking the specified action for each preset.
 
    .Parameter Preset
    The preset to start searching from.
 
    .Parameter Presets
    The collection of presets to search for 'inherit' references.
 
    .Parameter Action
    A script block invoked for each preset in breadth-first order. Return $null to continue the walk; return
    any non-$null value to stop and return that value to the caller.
 
    .Description
    The action should return $null to continue searching, or a non-$null value to stop searching and return that value.
 
    When searching multiple preset 'inherit' values, the presets will be search in order.
#>

function SearchAncestors {
    param(
        $Preset,
        $Presets,
        [scriptblock] $Action
    )
    if ($null -eq $Preset) {
        return $null
    }
    [array] $PendingPresets = @($Preset)
    for (; ($null -ne $PendingPresets) -and ($PendingPresets.Count -gt 0); ) {
        $Preset, $PendingPresets = $PendingPresets
        $Result = & $Action $Preset
        if ($null -ne $Result) {
            return $Result
        }
        [array] $BasePresets = Get-MemberValue $Preset 'inherits' -Or @() |
            ForEach-Object {
                $BaseParentName = $_
                $Presets | Where-Object { $_.name -eq $BaseParentName } | Select-Object -First 1
            }
        $PendingPresets = $BasePresets + $PendingPresets
    }
}

<#
    .Synopsis
    Walks the preset inheritance chain to find the first ancestor that defines the given property, and returns its value.
#>

function ResolvePresetProperty {
    param(
        $Preset,
        $Presets,
        $PropertyName
    )
    SearchAncestors -Preset $Preset -Presets $Presets {
        param($CurrentPreset)
        Get-MemberValue -InputObject $CurrentPreset -Name $PropertyName
    }
}

<#
    .Synopsis
    Returns $true if the preset (and all its ancestors) have conditions that evaluate to $true, $false otherwise.
#>

function EvaluatePresetCondition {
    param(
        $Preset,
        $Presets
    )
    $Result = SearchAncestors -Preset $Preset -Presets $Presets {
        param($CurrentPreset)
        $PresetConditionJson = Get-MemberValue $CurrentPreset 'condition'
        if (($PresetConditionJson) -and
            (-not (EvaluateCondition $PresetConditionJson $CurrentPreset))) {
            return $false
        }
    }
    $Result -ne $false
}

<#
    .Synopsis
    Evaluates a single CMake preset condition JSON object against the given preset's macro context.
    Supports: equals, notEquals, inList, notInList, matches, notMatches, anyOf, allOf, not.
#>

function EvaluateCondition {
    param(
        $ConditionJson,
        $PresetJson
    )
    switch ($ConditionJson.type) {
        'equals' {
            return (MacroReplacement $ConditionJson.lhs $PresetJson) -eq (MacroReplacement $ConditionJson.rhs $PresetJson)
        }
        'notEquals' {
            return (MacroReplacement $ConditionJson.lhs $PresetJson) -ne (MacroReplacement $ConditionJson.rhs $PresetJson)
        }
        'const' {
            Write-Error "$_ is not yet implemented as a condition type."
            return
        }
        'inList' {
            $ExpandedString = MacroReplacement $ConditionJson.String $PresetJson
            foreach ($String in $ConditionJson.list) {
                if ($ExpandedString -eq (MacroReplacement $String $PresetJson)) {
                    return $true
                }
            }
            return $false
        }
        'notInList' {
            $ExpandedString = MacroReplacement $ConditionJson.String $PresetJson
            foreach ($String in $ConditionJson.list) {
                if ($ExpandedString -eq (MacroReplacement $String $PresetJson)) {
                    return $false
                }
            }
            return $true
        }
        'matches' {
            return (MacroReplacement $ConditionJson.string $PresetJson) -match $ConditionJson.matches
        }
        'notMatches' {
            return -not ((MacroReplacement $ConditionJson.string $PresetJson) -match $ConditionJson.matches)
        }
        'anyOf' {
            foreach ($NestedCondition in $ConditionJson.conditions) {
                if (EvaluateCondition $NestedCondition $PresetJson) {
                    return $true
                }
            }
            return $false
        }
        'allOf' {
            foreach ($NestedCondition in $ConditionJson.conditions) {
                if (-not (EvaluateCondition $NestedCondition $PresetJson)) {
                    return $false
                }
            }
            return $true
        }
        'not' {
            return -not (EvaluateCondition $ConditionJson.condition $PresetJson)
        }
    }
}

<#
    .Synopsis
    Resolves and returns the fully-qualified binary directory for the given configure preset, after performing
    CMake macro substitution on the preset's 'binaryDir' value.
#>

function GetBinaryDirectory {
    param(
        $CMakePresetsJson,
        $ConfigurePreset
    )
    $BinaryDirectory = ResolvePresetProperty -Preset $ConfigurePreset -Presets $CMakePresetsJson.configurePresets -PropertyName 'binaryDir'

    # Perform macro-replacement
    $Result = MacroReplacement $BinaryDirectory $ConfigurePreset

    # Canonicalize
    [System.IO.Path]::GetFullPath($Result)
}

<#
    .Synopsis
    Returns the table of fixed CMake macro values that do not depend on a specific preset (e.g. ${hostSystemName}).
#>

function GetMacroConstants {
    $HostSystemName = if ($IsWindows) {
        'Windows'
    } elseif ($IsMacOS) {
        'Darwin'
    } elseif ($IsLinux) {
        'Linux'
    } else {
        Write-Error "Unsupported `${hostSystemName} value."
    }

    @{
        '${hostSystemName}' = $HostSystemName
        '$vendor{PSCMake}'  = 'true'
    }
}

<#
    .Synopsis
    Performs CMake preset macro substitution on the given string, expanding tokens such as ${sourceDir},
    ${presetName}, ${hostSystemName}, $env{VAR}, $penv{VAR}, and $vendor{...}.
#>

function MacroReplacement {
    param(
        $Value,
        $PresetJson
    )
    $Result = for (; $Value; $Value = $Right) {
        $Left, $Match, $Right = $Value -split '(\$\w*\{\w+\})', 2
        $Left
        switch -regex ($Match) {
            '\$\{sourceDir\}' {
                Split-Path -Path (GetCMakePresetsPath)
                break
            }
            '\$\{sourceParentDir\}' {
                Split-Path -Path (Split-Path -Path (GetCMakePresetsPath))
                break
            }
            '\$\{sourceDirName\}' {
                Split-Path -Leaf -Path (Split-Path -Path (GetCMakePresetsPath))
                break
            }
            '\$\{presetName\}' {
                if ($PresetJson) {
                    $PresetJson.name
                }
                break
            }
            '\$\{generator\}' {
                Write-Error "$_ is not yet implemented as a macro replacement."
                break
            }
            '\$\{hostSystemName\}' {
                (GetMacroConstants).$_
                break
            }
            '\$\{fileDir\}' {
                Write-Error "$_ is not yet implemented as a macro replacement."
                break
            }
            '\$\{dollar\}' {
                '$'
                break
            }
            '\$env\{([^\}]*)\}' {
                [System.Environment]::GetEnvironmentVariable($Matches[1])
            }
            '\$penv\{([^\}]*)\}' {
                $PEnv[$Matches[1]]
            }
            '\$vendor\{\w+\}' {
                Get-MemberValue (GetMacroConstants) $_ -Or $_
                break
            }
            Default {
                $Match
                break
            }
        }
    }
    $Result -join ''
}

<#
    .Synopsis
    Creates the CMake File API query files in the binary directory so that the next cmake configure writes
    code-model, cache, cmakeFiles, and toolchain reply JSON into '.cmake/api/v1/reply/'.
#>

function Enable-CMakeBuildQuery {
    [CmdletBinding()]
    param(
        [string] $BinaryDirectory,

        [ValidateSet('codemodel-v2', 'cache-v2', 'cmakeFiles-v1', 'toolchains-v1')]
        [string[]] $ObjectKinds = @('codemodel-v2', 'cache-v2', 'cmakeFiles-v1', 'toolchains-v1')
    )
    $CMakeQueryApiDirectory = Join-Path -Path $BinaryDirectory -ChildPath '.cmake/api/v1/query'
    $null = New-Item -ItemType Directory -Path $CMakeQueryApiDirectory -Force -ErrorAction SilentlyContinue
    $ObjectKinds |
        ForEach-Object {
            $QueryFile = Join-Path -Path $CMakeQueryApiDirectory -ChildPath $_
            $null = New-Item -Path $QueryFile -ItemType File -ErrorAction SilentlyContinue
        }
}

# For the 'code model' JSON that was found, load the full 'target' JSON to be able to find 'EXECUTABLE' targets.
#
function FilterExecutableTargets {
    param (
        $CodeModelDirectory,
        $TargetTuplesCodeModel
    )
    $TargetJsons = $TargetTuplesCodeModel |
        ForEach-Object {
            $JsonPath = Join-Path -Path $CodeModelDirectory -ChildPath $_.jsonFile
            Get-Item -LiteralPath $JsonPath |
                Get-Content |
                ConvertFrom-Json
            }

    $TargetJsons |
        Where-Object {
            $_.type -eq 'EXECUTABLE'
        }
}

<#
    .Synopsis
    Returns the path to the CMake File API reply directory ('.cmake/api/v1/reply') inside the binary directory.
#>

function Get-CMakeBuildCodeModelDirectory {
    param(
        [string] $BinaryDirectory
    )
    Join-Path -Path $BinaryDirectory -ChildPath '.cmake/api/v1/reply'
}

<#
    .Synopsis
    Gets PowerShell representation of the CodeModel JSON for the given binary directory.
 
    .Outputs
    The PowerShell representation of the CodeModel JSON for the given binary directory, or `$null` if it can't be found.
#>

function Get-CMakeBuildCodeModel {
    param(
        [string] $BinaryDirectory
    )

    # Since BinaryDirectory may contain characters that are valid for the file-system, but are used by PowerShell's
    # wildcard syntax (i.e. '[' and ']'), specify it as the LiteralPath to Get-ChildItem
    Get-ChildItem -LiteralPath (Get-CMakeBuildCodeModelDirectory $BinaryDirectory) -File -Filter 'codemodel-v2-*' -ErrorAction SilentlyContinue |
        Select-Object -First 1 |
        Get-Content |
        ConvertFrom-Json
}

<#
    .Synopsis
    Gets the target with the given name, for the given configuration from the specified code model.
 
    .Outputs
    The PowerShell representation of the target from the CodeModel JSON.
#>

function GetNamedTarget {
    param(
        $CodeModel,
        $Configuration,
        $Name
    )
    $CodeModelConfiguration = if ($Configuration) {
        $CodeModel.configurations | Where-Object { $_.name -eq $Configuration }
    } else {
        $CodeModel.configurations[0]
    }
    $CodeModelConfiguration.targets |
        Where-Object {
            $_.name -eq $Name
        }
}

<#
    .Synopsis
    Gets all targets within the given folder scope, for the given configuration from the specified code model.
 
    .Outputs
    The PowerShell representation of the target(s) from the CodeModel JSON.
#>

function GetScopedTargets {
    param(
        $CodeModel,
        $Configuration,
        $ScopeLocation
    )
    function CanonicalizeDirectoryPath($Path) {
        Resolve-Path -Path (Join-Path -Path $Path -ChildPath '/')
    }
    $ScopeLocation = CanonicalizeDirectoryPath $ScopeLocation
    $CodeModelConfiguration = if ($Configuration) {
        $CodeModel.configurations | Where-Object { $_.name -eq $Configuration }
    } else {
        $CodeModel.configurations[0]
    }
    $SourceDir = $CodeModel.paths.source
    $CodeModelConfiguration.targets |
        Where-Object {
            $Folder = $CodeModelConfiguration.directories[$_.directoryIndex].source
            $Folder = if ($Folder -eq '.') {
                $SourceDir
            } else {
                Join-Path -Path $SourceDir -ChildPath $Folder
            }
            $Folder = CanonicalizeDirectoryPath $Folder
            $Folder.Path.StartsWith($ScopeLocation.Path)
        }
}

<#
    .Synopsis
    Gets the value of a CMake cache entry.
#>

function GetCacheValue {
    param(
        [string] $BinaryDirectory,
        [string] $CacheEntryName
    )
    $CMakeCacheFile = Join-Path -Path $BinaryDirectory -ChildPath 'CMakeCache.txt'
    if (Test-Path -LiteralPath $CMakeCacheFile) {
        Get-Content -LiteralPath $CMakeCacheFile |
            Select-String "^$($CacheEntryName):.*=" |
            ForEach-Object {
                $_.ToString().Split('=', 2) | Select-Object -Last 1
        }
    }
}

<#
    .Synopsis
    Writes the CMake target dependency graph in Graphviz DOT format to the pipeline.
#>

function WriteDot {
    param (
        $Configuration,
        $CodeModel,
        $CodeModelDirectory
    )
    $Targets = @{}
    "digraph CodeModel {"
    ($CodeModel.configurations | Where-Object { $_.name -eq $Configuration }).targets |
        ForEach-Object {
            " `"$($_.id)`" [label=`"$($_.name)`"]"
            $Targets[$_.id] = Get-Content (Join-Path -Path $CodeModelDirectory -ChildPath $_.jsonFile) | ConvertFrom-Json
        }
    $Targets.GetEnumerator() | ForEach-Object {
        $Source = $_.Name
        Get-MemberValue -InputObject $_.Value -Name dependencies -Or @() | ForEach-Object {
            " `"$Source`" -> `"$($_.id)`" "
        }
    }
    "}"
}

<#
    .Synopsis
    Writes the CMake target dependency graph in Visual Studio DGML format to the pipeline.
#>

function WriteDgml {
    param (
        $Configuration,
        $CodeModel,
        $CodeModelDirectory
    )
    $Targets = @{}
    '<?xml version="1.0" encoding="utf-8"?>'
    '<DirectedGraph xmlns="http://schemas.microsoft.com/vs/2009/dgml">'
        '<Properties>'
            '<Property Id="Definition" Label="Definition" DataType="System.String" IsReference="True" />'
            '<Property Id="Type" DataType="System.String" />'
        '</Properties>'
        '<Styles>'
            '<Style TargetType="Node" GroupLabel="Executable" ValueLabel="Executable">'
                '<Condition Expression="Type=''EXECUTABLE''" />'
                '<Setter Property="Background" Value="#FF0000" />'
            '</Style>'
        '</Styles>'
        $SourcePath = $CodeModel.paths.source
        '<Nodes>'
            ($CodeModel.configurations | Where-Object { $_.name -eq $Configuration }).targets |
                ForEach-Object {
                    $TargetJson = Get-Content (Join-Path -Path $CodeModelDirectory -ChildPath $_.jsonFile) |
                        ConvertFrom-Json

                    $ReferenceFileIndex = $TargetJson.backtraceGraph.nodes[0].file
                    $Definition = Join-Path -Path $SourcePath -ChildPath $TargetJson.backtraceGraph.files[$ReferenceFileIndex]

                    '<Node'
                        " Id=`"$($_.id)`""
                        " Label=`"$($_.name)`""
                        " Type=`"$($TargetJson.type)`""
                        " Definition=`"$($Definition)`""
                        '/>'

                    Get-MemberValue -InputObject $TargetJson -Name artifacts -Or @() |
                        ForEach-Object {
                            '<Node'
                                " Id=`"$($_.path)`""
                                '/>'
                        }

                    $Targets[$_.id] = $TargetJson
                }
        '</Nodes>'
        '<Links>'
            $Targets.GetEnumerator() |
                ForEach-Object {
                    $Source = $_.Name
                    Get-MemberValue -InputObject $_.Value -Name dependencies -Or @() |
                        ForEach-Object {
                        '<Link'
                            " Source=`"$Source`""
                            " Target=`"$($_.id)`""
                            '/>'
                    }

                    Get-MemberValue -InputObject $_.Value -Name artifacts -Or @() |
                        ForEach-Object {
                        '<Link'
                            " Source=`"$Source`""
                            " Target=`"$($_.path)`""
                            '/>'
                    }
                }
        '</Links>'
    '</DirectedGraph>'
}