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 root of the CMake build - the current or ancestral folder containing a 'CMakePresets.json' file.
#>

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

$script:CMakePresetsPath = $null

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

function GetCMakePresetsPath {
    $script:CMakePresetsPath
}

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

function GetCMakePresets {
    param(
        [switch] $Silent
    )
    $CMakeRoot = FindCMakeRoot
    if (-not $CMakeRoot) {
        if ($Silent) {
            return $null
        }
        Write-Error "Can't find CMakePresets.json"
    }
    $script:CMakePresetsPath = Join-Path -Path $CMakeRoot -ChildPath 'CMakePresets.json'
    Write-Verbose "Presets = $CMakePresetsPath"
    Get-Content $CMakePresetsPath | ConvertFrom-Json
}

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

function GetBuildPresetNames {
    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.name
    }
}

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

function GetConfigurePresetNames {
    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.name
    }
}

<#
    .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
}

function ResolvePresets {
    param(
        $CMakePresetsJson,

        [ValidateSet('buildPresets', 'testPresets')]
        $PresetType,

        $PresetName
    )
    $PresetJson = $CMakePresetsJson.$PresetType | Where-Object { $_.name -eq $PresetName }
    if (-not $PresetJson) {
        Write-Error "Unable to find $PresetType '$Preset' in $(GetCMakePresetsPath)"
    }

    $ConfigurePresetJson = $CMakePresetsJson.configurePresets | Where-Object { $_.name -eq $PresetJson.configurePreset }
    if (-not $ConfigurePresetJson) {
        Write-Error "Unable to find configure preset '$($PresetJson.configurePreset)' in $(GetCMakePresetsPath)"
    }

    $PresetJson, $ConfigurePresetJson
}

<#
    .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.
 
    .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
    }
}

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

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
}

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)
        }
    }
}

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)
}

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

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

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\}' {
                $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 ''
}

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 {
            Join-Path -Path $CodeModelDirectory -ChildPath $_.jsonFile |
                Get-Item |
                Get-Content |
                ConvertFrom-Json
            }

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

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 ']'), escape the characters before passing to Get-ChildItem.
    $EscapedBinaryDirectory = $BinaryDirectory.Replace('[', '`[').Replace(']', '`]')

    Get-ChildItem -Path (Get-CMakeBuildCodeModelDirectory $EscapedBinaryDirectory) -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].build
            $Folder = if ($Folder -eq '.') {
                $SourceDir
            } else {
                Join-Path -Path $SourceDir -ChildPath $Folder
            }
            $Folder = CanonicalizeDirectoryPath $Folder
            $Folder.Path.StartsWith($ScopeLocation.Path)
        }
}

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)`" "
        }
    }
    "}"
}

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>'
}