DescriptionFile.ps1

$nameRegex = "^[0-9A-Za-z_]+$"
$versionRegex = "^v?(?:(?:(?<epoch>[0-9]+)!)?(?<release>[0-9]*(?:[_\.][0-9]+)*)(?<pre>[_\.]?(?<pre_l>(a|b|c|rc|alpha|beta|pre|preview|sp))[_\.]?(?<pre_n>[0-9]+)?)?(?<post>(?:-(?<post_n1>[0-9]+))|(?:[_\.]?(?<post_l>post|rev|r)[_\.]?(?<post_n2>[0-9]+)?))?(?<dev>[_\.]?(?<dev_l>dev)[_\.]?(?<dev_n>[0-9]+)?)?)(?:\+(?<local>[a-z0-9]+(?:[_\.][a-z0-9]+)*))?$"
$architectureRegex = "^x64|x86$"
$additionalOptionsRegex = "^[0-9A-Za-z.]+$"

function Split-EnvironmentModuleName([String] $ModuleFullName, [switch] $Silent)
{
    <#
    .SYNOPSIS
    Splits the given name into an array with 4 parts (name, version, architecture, additionalOptions).
    .DESCRIPTION
    Split a name string that either has the format 'Name-Version-Architecture' or just 'Name'. The output is
    an anonymous object with the 4 properties (name, version, architecture, additionalOptions). If a value was not specified,
    $null is returned at the according array index.
    .PARAMETER ModuleFullName
    The full name of the module that should be splitted.
    .PARAMETER Silent
    Print a warning in case the module name is not correctly formatted.
    .OUTPUTS
    A string array with 4 parts (name, version, architecture, additionalOptions)
    #>

    $parts = $ModuleFullName.Split("-")
    $nameMatchResult = [System.Text.RegularExpressions.Regex]::Match($parts[0], $nameRegex, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)

    $result = @{}
    $result.Name = $nameMatchResult.Value

    $regexOrder = @(@($versionRegex, "Version"), @($architectureRegex, "Architecture"), @($additionalOptionsRegex, "AdditionalOptions"))

    $currentRegexIndex = 0
    $matchFailed = (-not ($nameMatchResult.Success))
    for($i = 1; $i -lt $parts.Count; $i++) {
        if($currentRegexIndex -ge $regexOrder.Count) {
            # More parts than matching regexes found
            $matchFailed = $true
            break
        }

        $currentRegex = $regexOrder[$currentRegexIndex][0]
        $matchResult = [System.Text.RegularExpressions.Regex]::Match($parts[$i], $currentRegex, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
        if($matchResult.Success) {
            $result.($regexOrder[$currentRegexIndex][1]) = $matchResult.Value
        }
        else {
            $i-- # We have to check the same part again with the next regex in the list
        }
        $currentRegexIndex++
    }

    if($matchFailed) {
        if(-not ($Silent)) {
            Write-Warning "The environment module name '$ModuleFullName' is not correctly formated. It must be 'Name-Version-Architecture-AdditionalOptions'"
        }
        return $null
    }

    return $result
}

function Get-EnvironmentModuleDescriptionFile([string] $ModuleBase, [string] $ModuleFullName)
{
    <#
    .SYNOPSIS
    Get the Environment Module file (*.pse) of the of the given module.
    .DESCRIPTION
    This function will read the environment module info of the given module. If the module does not depend on the environment module, $null is returned. If no
    description file was found, an empty map is returned.
    .OUTPUTS
    The path to the description file.
    #>


    return (Join-Path $ModuleBase "$($ModuleFullName).pse1")
}

function Read-EnvironmentModuleDescriptionFileByPath([string] $Path)
{
    <#
    .SYNOPSIS
    Read the given Environment Module file (*.pse).
    .DESCRIPTION
    This function will read the environment module info. If the description file was not found, an empty map is returned.
    .OUTPUTS
    The map containing the values or $null.
    #>


    if(Test-Path $Path) {
        # Parse the pse1 file
        Write-Verbose "Found desciption file $descriptionFile"
        return Import-PowershellDataFile $Path
    }

    return @{}
}

function New-EnvironmentModuleInfoBase
{
    <#
    .SYNOPSIS
    Create a new EnvironmentModuleInfoBase object from the given parameters.
    .PARAMETER Module
    The module info that contains the base information.
    .OUTPUTS
    The created object of type EnvironmentModuleInfoBase or $null.
    .NOTES
    The given module name must match exactly one module, otherwise $null is returned.
    #>

    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    param (
        [PSModuleInfo] $Module
    )

    $nameParts = Split-EnvironmentModuleName $Module.Name
    if($null -eq $nameParts) {
        return $null
    }

    $descriptionContent = Read-EnvironmentModuleDescriptionFileByPath (Get-EnvironmentModuleDescriptionFile $Module.ModuleBase $Module.Name)

    if(-not $descriptionContent) {
        return $null
    }

    $result = New-Object EnvironmentModuleCore.EnvironmentModuleInfoBase -ArgumentList @($Module.Name, $Module.ModuleBase, $nameParts.Name, $nameParts.Version, $nameParts.Architecture, $nameParts.AdditionalOptions, [EnvironmentModuleCore.EnvironmentModuleType]::Default)
    Set-EnvironmentModuleInfoBaseParameter $result $descriptionContent

    return $result
}

function Set-EnvironmentModuleInfoBaseParameter
{
    <#
    .SYNOPSIS
    Assign the given parameters to the passed module object.
    .PARAMETER Module
    The module to modify.
    .PARAMETER Parameters
    The parameters to set.
    #>

    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    param(
        [EnvironmentModuleCore.EnvironmentModuleInfoBase][ref] $Module,
        [hashtable] $Parameters
    )

    if($Parameters.Contains("ModuleType")) {
        $Module.ModuleType = [Enum]::Parse([EnvironmentModuleCore.EnvironmentModuleType], $descriptionContent.Item("ModuleType"))
        Write-Verbose "Read module type $($Module.ModuleType)"
    }
}

function New-EnvironmentModuleInfoFromDescriptionFile([string] $Path, [EnvironmentModuleCore.EnvironmentModuleInfoBase] $Module = $null)
{
    <#
    .SYNOPSIS
    Read the content of the description file stored in the given path and create an EnvironmentModuleInfo object out of it.
    .PARAMETER Path
    The path to the pse1 file to use.
    .PARAMETER Module
    The module info belonging to the pse1 file.
    .OUTPUTS
    The created EnvironmentModuleInfo object or null if the content could not be read.
    #>

    $descriptionContent = Read-EnvironmentModuleDescriptionFileByPath $Path

    if(-not $descriptionContent) {
        return $null
    }

    $result = $null
    $moduleFullName = $Path
    if($null -ne $Module) {
        $arguments = @($Module, $null, (Join-Path $script:tmpEnvironmentRootSessionPath $Module.Name))
        $result = New-Object EnvironmentModuleCore.EnvironmentModuleInfo -ArgumentList $arguments
        $moduleFullName = $Module.FullName
    }
    else {
        $result = New-Object EnvironmentModuleCore.EnvironmentModuleInfo
    }
    Set-EnvironmentModuleInfoBaseParameter $result $descriptionContent

    $result.DirectUnload = $false
    $customSearchPaths = $script:customSearchPaths[$moduleFullName]
    if ($customSearchPaths) {
        $result.SearchPaths = $result.SearchPaths + $customSearchPaths
    }

    $dependencies = @()
    if($descriptionContent.Contains("RequiredEnvironmentModules")) {
        Write-Warning "The field 'RequiredEnvironmentModules' defined for '$moduleFullName' is deprecated, please use the dependencies field."
        $dependencies = $descriptionContent.Item("RequiredEnvironmentModules") | Foreach-Object { New-Object "EnvironmentModuleCore.DependencyInfo" -ArgumentList $_}
        Write-Verbose "Read module dependencies $($dependencies)"
    }

    if($descriptionContent.Contains("Dependencies")) {
        $dependencies = $dependencies + ($descriptionContent.Item("Dependencies") | Foreach-Object {
            if($_.GetType() -eq [string]) {
                New-Object "EnvironmentModuleCore.DependencyInfo" -ArgumentList $_
            }
            else {
                New-Object "EnvironmentModuleCore.DependencyInfo" -ArgumentList $_.Name, $_.Optional
            }
        })
        Write-Verbose "Read module dependencies $($dependencies)"
    }

    $result.Dependencies = $dependencies

    if($descriptionContent.Contains("DirectUnload")) {
        $result.DirectUnload = $descriptionContent.Item("DirectUnload")
        Write-Verbose "Read module direct unload $($result.DirectUnload)"
    }

    if($descriptionContent.Contains("SwitchDirectoryToModuleRoot")) {
        $result.SwitchDirectoryToModuleRoot = $descriptionContent.Item("SwitchDirectoryToModuleRoot")
        Write-Verbose "Read module switch to directory $($result.SwitchDirectoryToModuleRoot)"
    }

    $requiredItems = @()
    if($descriptionContent.Contains("RequiredFiles")) {
        Write-Warning "The field 'RequiredFiles' defined for '$moduleFullName' is deprecated, please use the RequiredItems field."
        $requiredItems = $result.RequiredItems + ($descriptionContent.Item("RequiredFiles") | ForEach-Object {
            New-Object "EnvironmentModuleCore.RequiredItem" -ArgumentList ([EnvironmentModuleCore.RequiredItem]::TYPE_FILE), $_
        })
        Write-Verbose "Read required files $($descriptionContent.Item('RequiredFiles'))"
    }

    if($descriptionContent.Contains("RequiredItems") -and $descriptionContent.Item("RequiredItems").count -gt 0) {
        $requiredItems = $requiredItems + ($descriptionContent.Item("RequiredItems") | Foreach-Object {
            if($_.GetType() -eq [string]) {
                New-Object "EnvironmentModuleCore.RequiredItem" -ArgumentList ([EnvironmentModuleCore.RequiredItem]::TYPE_FILE), $_
            }
            else {
                New-Object "EnvironmentModuleCore.RequiredItem" -ArgumentList $_.Type, $_.Value
            }
        })
        Write-Verbose "Read module dependencies $($dependencies)"
    }

    $result.RequiredItems = $requiredItems

    if($descriptionContent.Contains("DefaultRegistryPaths") -and $descriptionContent.Item("DefaultRegistryPaths").count -gt 0) {
        Write-Warning "The field 'DefaultRegistryPaths' defined for '$moduleFullName' is deprecated, please use the DefaultSearchPaths field."
        $pathValues = $descriptionContent.Item("DefaultRegistryPaths")
        $searchPathType = "REGISTRY"
        $searchPathPriority = $script:searchPathTypes[$searchPathType].Item2
        Write-Verbose "Read default registry paths $($result.DefaultRegistryPaths)"

        $result.SearchPaths = $result.SearchPaths + ($pathValues | ForEach-Object {
            $parts = $_.Split([IO.Path]::PathSeparator) + @("")
            New-Object "EnvironmentModuleCore.SearchPath" -ArgumentList @($parts[0], $searchPathType, $searchPathPriority, $parts[1], $true)
        })
    }

    if($descriptionContent.Contains("DefaultFolderPaths") -and $descriptionContent.Item("DefaultFolderPaths").count -gt 0) {
        Write-Warning "The field 'DefaultFolderPaths' defined for '$moduleFullName' is deprecated, please use the DefaultSearchPaths field."
        $pathValues = $descriptionContent.Item("DefaultFolderPaths")
        $searchPathType = [EnvironmentModuleCore.SearchPath]::TYPE_DIRECTORY
        $searchPathPriority = $script:searchPathTypes[$searchPathType].Item2
        Write-Verbose "Read default folder paths $($result.DefaultFolderPaths)"

        $result.SearchPaths = $result.SearchPaths + ($pathValues | ForEach-Object {
            $parts = $_.Split([IO.Path]::PathSeparator) + @("")
            New-Object "EnvironmentModuleCore.SearchPath" -ArgumentList @($parts[0], $searchPathType, $searchPathPriority, $parts[1], $true)
        })
    }

    if($descriptionContent.Contains("DefaultEnvironmentPaths") -and $descriptionContent.Item("DefaultEnvironmentPaths").count -gt 0) {
        Write-Warning "The field 'DefaultEnvironmentPaths' defined for '$moduleFullName' is deprecated, please use the DefaultSearchPaths field."
        $pathValues = $descriptionContent.Item("DefaultEnvironmentPaths")
        $searchPathType = [EnvironmentModuleCore.SearchPath]::TYPE_ENVIRONMENT_VARIABLE
        $searchPathPriority = $script:searchPathTypes[$searchPathType].Item2
        Write-Verbose "Read default environment paths $($result.DefaultEnvironmentPaths)"

        $result.SearchPaths = $result.SearchPaths + ($pathValues | ForEach-Object {
            $parts = $_.Split([IO.Path]::PathSeparator) + @("")
            New-Object "EnvironmentModuleCore.SearchPath" -ArgumentList @($parts[0], $searchPathType, $searchPathPriority, $parts[1], $true)
        })
    }

    if($descriptionContent.Contains("DefaultSearchPaths") -and $descriptionContent.Item("DefaultSearchPaths").count -gt 0) {
        $result.SearchPaths = $result.SearchPaths + ($descriptionContent.Item("DefaultSearchPaths") | ForEach-Object {
            if($_.GetType() -eq [string]) {
                $searchPathType = [EnvironmentModuleCore.SearchPath]::TYPE_DIRECTORY
                $searchPathPriority = $script:searchPathTypes[$searchPathType].Item2
                New-Object "EnvironmentModuleCore.SearchPath" -ArgumentList $_, $searchPathType, $searchPathPriority, $null, $true
            }
            else {
                $searchPathType = $_.Type
                $searchPathPriority = $_.Priority
                if($null -eq $searchPathPriority) {
                    $searchPathPriority = $script:searchPathTypes[$searchPathType].Item2
                }

                New-Object "EnvironmentModuleCore.SearchPath" -ArgumentList $_.Key, $searchPathType, $searchPathPriority, $_.SubFolder, $true
            }
        })
        Write-Verbose "Read module default search paths $($result.SearchPaths)"
    }

    if($descriptionContent.Contains("StyleVersion")) {
        $result.StyleVersion = $descriptionContent.Item("StyleVersion")
        Write-Verbose "Read module style version $($result.StyleVersion)"
    }

    if($descriptionContent.Contains("Category")) {
        $result.Category = $descriptionContent.Item("Category")
        Write-Verbose "Read module category $($result.Category)"
    }

    if($descriptionContent.Contains("Parameters")) {
        $parameters = $descriptionContent.Item("Parameters")
        if($parameters -is [array]) {
            # Handle the complex syntax
            $parameters | Foreach-Object {
                $virtualEnvironment = $_.VirtualEnvironment
                if([string]::IsNullOrEmpty($virtualEnvironment)) {
                    $virtualEnvironment = "Default"
                }
                $parameterKey = [System.Tuple[string, string]]::new($_.Name, $virtualEnvironment)
                $result.Parameters[$parameterKey] = (New-Object "EnvironmentModuleCore.ParameterInfoBase" -ArgumentList $_.Name, $_.Value, $_.IsUserDefined, $virtualEnvironment) 
            }
        }
        else {
            # Handle the simple syntax
            $parameters.Keys | Foreach-Object { 
                $virtualEnvironment = "Default"
                $parameterKey = [System.Tuple[string, string]]::new($_, $virtualEnvironment)
                $result.Parameters[$parameterKey] = (New-Object "EnvironmentModuleCore.ParameterInfoBase" -ArgumentList $_, $parameters[$_], $false, $virtualEnvironment)
            }
        }
        Write-Verbose "Read module parameters $($result.Parameters.GetEnumerator() -join ',')"
    }

    if($descriptionContent.Contains("Paths")) {
        $descriptionContent.Item("Paths") | Foreach-Object {
            $mode = [EnvironmentModuleCore.PathType]::UNKNOWN
            [Enum]::TryParse($_.Mode, [ref] $mode) | Out-Null

            if([String]::IsNullOrEmpty($_.Variable)) {
                Write-Error "Path definition without 'Variable' defined in module definition '$moduleFullName'"
                return
            }

            $pathInfo = $null
            $pathDefinition = $_
            $value = Expand-PathSeparators $pathDefinition.Value
            switch ($mode) {
                APPEND {
                    $pathInfo = $result.AddAppendPath($pathDefinition.Variable, $value, $pathDefinition.Key)
                }
                PREPEND {
                    $pathInfo = $result.AddPrependPath($pathDefinition.Variable, $value, $pathDefinition.Key)
                }
                SET {
                    $pathInfo = $result.AddSetPath($pathDefinition.Variable, $value, $pathDefinition.Key)
                }
                Default {
                    Write-Error "Unable to handle of Mode of static path definition of module '$moduleFullName'"
                    return
                }
            }

            Write-Verbose "Added path definition: $($pathInfo.ToString())"
        }
    }

    if($descriptionContent.Contains("MergeModules")) {
        $result.MergeModules = $descriptionContent.Item("MergeModules")
        Write-Verbose "Read merge modules $($descriptionContent.Item('MergeModules'))"
    }

    return $result
}

function New-EnvironmentModuleInfo
{
    <#
    .SYNOPSIS
    Create a new EnvironmentModuleInfo object from the given parameters.
    .PARAMETER Module
    The module info that contains the base information.
    .PARAMETER ModuleFullName
    The full name of the module. Only used if the module parameter is not set.
    .PARAMETER ModuleFile
    The module file (psd1) to load. If this is set, the ModuleFullName is not evaluated.
    .OUTPUTS
    The created object of type EnvironmentModuleInfo or $null.
    .NOTES
    The given module name must match exactly one module, otherwise $null is returned.
    #>

    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    param (
        [EnvironmentModuleCore.EnvironmentModuleInfoBase] $Module = $null,
        [String] $ModuleFullName = $null,
        [String] $ModuleFile = $null
    )

    if($null -eq $Module) {
        if(-not ([string]::IsNullOrEmpty($ModuleFile))) {
            $matchingModules = (Get-Module "$ModuleFile" -ListAvailable)

            if($matchingModules.Length -lt 1) {
                Write-Verbose "Unable to find the module $ModuleFile"
                return $null
            }

            $Module = New-EnvironmentModuleInfoBase $matchingModules[0]
        }
        else {
            $matchingModules = Get-EnvironmentModule -ListAvailable $ModuleFullName

            if($matchingModules.Length -lt 1) {
                Write-Verbose "Unable to find the module $ModuleFullName in the list of all environment modules"
                return $null
            }

            if($matchingModules.Length -gt 1) {
                Write-Warning "More than one environment module matches the given full name '$ModuleFullName'"
            }

            $Module = $matchingModules[0]
        }
    }

    $result = New-EnvironmentModuleInfoFromDescriptionFile -Path (Get-EnvironmentModuleDescriptionFile $Module.ModuleBase $Module.FullName) -Module $Module
    return $result
}

function Compare-EnvironmentModulesByVersion([EnvironmentModuleCore.EnvironmentModuleInfoBase[]] $EnvironmentModules) {
    <#
    .SYNOPSIS
    Compare the given environment modules by its version. If the version is equal, the architecture is compared.
    .PARAMETER EnvironmentModules
    The environment modules to compare.
    .OUTPUTS
    The sorted environment modules.
    #>

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

    $versionMatches = [System.Collections.Generic.Dictionary[String, System.Text.RegularExpressions.Match]]::new()
    foreach($environmentModule in $EnvironmentModules) {
        if([String]::IsNullOrEmpty($environmentModule.Version)) {
            $versionMatches.Add($environmentModule.FullName, $null)
            continue
        }
        $versionMatch = [System.Text.RegularExpressions.Regex]::Match($environmentModule.Version, $versionRegex, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
        if($versionMatch.Success) {
            $versionMatches.Add($environmentModule.FullName, $versionMatch)
            continue
        }

        $versionMatches.Add($environmentModule.FullName, $null)
    }
    
    $moduleList = [System.Collections.Generic.List[EnvironmentModuleCore.EnvironmentModuleInfoBase]]::new($EnvironmentModules)

    class EnvironmentModuleComparator : System.Collections.Generic.IComparer[EnvironmentModuleCore.EnvironmentModuleInfoBase]
    {
        [System.Collections.Generic.Dictionary[String, System.Text.RegularExpressions.Match]] $versionMatches

        EnvironmentModuleComparator([System.Collections.Generic.Dictionary[String, System.Text.RegularExpressions.Match]] $versionMatches)
        {
            $this.versionMatches = $versionMatches
        }

        [bool] ContainsText([string[]] $versionParts) {
            foreach($part in $versionParts) {
                $tmp = 0
                if(-not([int]::TryParse($part, [ref] $tmp))) {
                    return $true;
                }
            }
            return $false;
        }

        [int] Compare([EnvironmentModuleCore.EnvironmentModuleInfoBase] $a, [EnvironmentModuleCore.EnvironmentModuleInfoBase] $b)
        {
            $matchA = $this.versionMatches[$a.FullName]
            $matchB = $this.versionMatches[$b.FullName]
            if($null -eq $matchA) {
                if($null -eq $matchB) {
                    return $a.Architecture.CompareTo($b.Architecture)
                }
                return 1
            }
    
            if($null -eq $matchB){
                return -1
            }

            $versionPartsA = $matchA.Groups[0].Value.Replace("_", ".").Split(".")
            $versionPartsB = $matchB.Groups[0].Value.Replace("_", ".").Split(".")

            # Check if the version numbers contain text like "dev" or "alpha"
            $containsTextA = $this.ContainsText($versionPartsA)
            $containsTextB = $this.ContainsText($versionPartsB)

            if($containsTextA -and (-not $containsTextB)) {
                return 1
            }

            if($containsTextB -and (-not $containsTextA)) {
                return -1
            }
            
            for($i = 0; $i -lt $versionPartsA.Length; $i++) {
                # the Version A has more parts than B -> A wins
                if($i -gt ($versionPartsB.Length - 1)) {
                    return -1
                }
                $partA = $versionPartsA[$i]
                $partB = $versionPartsB[$i]

                try {
                    [int]::TryParse($partA, [ref] $partA) | Out-Null   
                }
                catch {
                }

                try {
                    [int]::TryParse($partB, [ref] $partB) | Out-Null   
                }
                catch {
                }

                if($partA -gt $partB) {
                    return -1
                }

                if($partB -gt $partA) {
                    return 1
                }
            }

            if($versionPartsB.Length -gt $versionPartsA.Length) {
                # The Version B has more parts than A -> B wins
                return 1
            }

            return $a.Architecture.CompareTo($b.Architecture)
        }
    }

    $comparator = [EnvironmentModuleComparator]::new($versionMatches)
    $moduleList.Sort($comparator);
    return $moduleList
}