Private/TypeGen.ps1

# Classes and enums from a schema, for autocomplete in config scripts.

function ConvertTo-PsdPascal {
    param([string] $Text)
    $out = ''
    foreach ($p in [regex]::Split($Text, '[^A-Za-z0-9]+')) {
        if ($p.Length -eq 0) { continue }
        $out += $p.Substring(0, 1).ToUpperInvariant() + $p.Substring(1)
    }
    $out
}

function Get-PsdRelativePath {
    param([string] $FromDir, [string] $ToFile)
    $from = New-Object System.Uri ($FromDir.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar)
    $to = New-Object System.Uri $ToFile
    $rel = [Uri]::UnescapeDataString($from.MakeRelativeUri($to).ToString()).Replace('\', '/')
    if (-not $rel.StartsWith('../')) { $rel = './' + $rel }
    $rel
}

function Get-PsdTypeName {
    param($G, [string] $Path)
    $name = $G.Name + (ConvertTo-PsdPascal $Path)
    if (-not $script:PsdRxIdent.IsMatch($name)) {
        Add-PsdError $G.Ctx 'PSD512' "$Path would become the class name '$name', which isn't valid." -Hint @('Use -Name with a name that starts with a letter.')
        return $null
    }
    if ($G.Names.ContainsKey($name)) {
        Add-PsdError $G.Ctx 'PSD512' "$Path and $($G.Names[$name]) would both become the class name '$name'." -Hint @('Rename one of the keys.')
        return $null
    }
    $G.Names[$name] = $Path
    $name
}

function Format-PsdPropName {
    param($G, [string] $Key, [string] $Path)
    if ($script:PsdRxIdent.IsMatch($Key)) { return '$' + $Key }
    if ($Key.IndexOf('}') -ge 0 -or $Key.IndexOf('`') -ge 0) {
        Add-PsdError $G.Ctx 'PSD512' "the key '$Key' at $Path can't be a property name, because it contains } or a backtick."
        return $null
    }
    '${' + $Key + '}'
}

function Add-PsdTypeEnum {
    param($G, $Spec)
    if ($G.Enums.Contains($Spec.Name)) { return }
    $rel = Get-PsdRelativePath $G.OutDir $Spec.Source
    $G.Enums[$Spec.Name] = "$($Spec.Def) # copied from $rel"
}

function Get-PsdTypeExpr {
    param($G, $Rule, [string] $Path, [string] $Key, [string] $Owner)
    $specs = New-PsdList
    foreach ($s in $Rule.Types) { if ($s.Name -ne 'null' -or $s.Kind -ne 'base') { $specs.Add($s) } }
    if ($specs.Count -eq 0) { return 'object' }
    if ($specs.Count -gt 1) {
        foreach ($s in $specs) {
            if ($s.Kind -eq 'base' -and $s.Name -eq 'map' -and -not $s.List -and $Rule.Properties) {
                $names = @($specs | ForEach-Object { "'" + $_.Text + "'" }) -join ' or '
                $G.Comments = @("# $Key allows $names, so $Owner.$Key is [object].", '# Cast to this class to get completion for the map form.')
                $null = Add-PsdTypeClass $G $Rule $Path
            }
        }
        return 'object'
    }
    $spec = $specs[0]
    if ($spec.List) {
        $item = $spec.Clone()
        $item.List = $false
        $itemRule = @{ Types = (New-PsdList); Enum = $null; Properties = $null; Items = $null }
        $itemRule.Types.Add($item)
        $t = Get-PsdTypeExpr $G $itemRule $Path $Key $Owner
        return ($t -replace '^Nullable\[(.+)\]$', '$1') + '[]'
    }
    switch ($spec.Kind) {
        'enum' {
            if ($spec.Source) { Add-PsdTypeEnum $G $spec; return "Nullable[$($spec.Name)]" }
            return "Nullable[$($spec.DotNet)]"
        }
        'conv' {
            switch ($spec.Name) { 'version' { return 'version' } 'guid' { return 'Nullable[guid]' } 'datetime' { return 'Nullable[datetime]' } }
        }
    }
    switch ($spec.Name) {
        'string' {
            if ($Rule.Enum -and $Rule.Enum.Count -gt 0) {
                $ok = $true
                foreach ($x in $Rule.Enum) { if ($x -isnot [string] -or -not $script:PsdRxIdent.IsMatch($x)) { $ok = $false } }
                if ($ok) {
                    $en = Get-PsdTypeName $G $Path
                    if ($null -eq $en) { return 'string' }
                    $G.Enums[$en] = "enum $en { $($Rule.Enum -join '; ') }"
                    return "Nullable[$en]"
                }
            }
            return 'string'
        }
        'int' { return 'Nullable[int]' }
        'double' { return 'Nullable[double]' }
        'bool' { return 'Nullable[bool]' }
        'map' {
            if ($Rule.Properties) {
                $cn = Add-PsdTypeClass $G $Rule $Path
                if ($cn) { return $cn }
            }
            return 'hashtable'
        }
        'array' {
            if ($Rule.Items) {
                $t = Get-PsdTypeExpr $G $Rule.Items "$Path.Item" $Key $Owner
                return ($t -replace '^Nullable\[(.+)\]$', '$1') + '[]'
            }
            return 'object[]'
        }
    }
    'object'
}

function Add-PsdTypeClass {
    param($G, $Rule, [string] $Path)
    $comments = $G.Comments
    $G.Comments = $null
    $name = if ($Path) { Get-PsdTypeName $G $Path } else { $G.Name }
    if ($null -eq $name) { return $null }
    if (-not $Path) { $G.Names[$name] = '(top)' }
    $lines = New-PsdList
    $lines.Add(' hidden [System.Collections.IDictionary] $__PsdMap')
    if ($Rule.Properties) {
        foreach ($k in $Rule.Properties.PSBase.Keys) {
            $childPath = if ($Path) { "$Path.$k" } else { $k }
            $t = Get-PsdTypeExpr $G $Rule.Properties[$k] $childPath $k $name
            $pn = Format-PsdPropName $G $k $childPath
            if ($null -eq $pn) { continue }
            $lines.Add(" [$t] $pn")
        }
    }
    $lines.Add('')
    $lines.Add(" $name() { }")
    $lines.Add(" $name([System.Collections.IDictionary] `$map) { [$($G.Helper)]::Apply(`$this, `$map) }")
    $text = ''
    if ($comments) { $text = ($comments -join "`n") + "`n" }
    $text += "class $name {`n" + ($lines -join "`n") + "`n}"
    $G.Classes.Add($text)
    $name
}

# Lets a cast keep what a data file can hold: '!if', merge operators and references.
function Get-PsdTypeHelperText {
    param([string] $Helper)
    @(
        "class $Helper {"
        ' static [void] Apply([object] $target, [System.Collections.IDictionary] $map) {'
        ' $target.__PsdMap = $map'
        ' foreach ($key in $map.PSBase.Keys) {'
        ' $name = [string]$key'
        ' if ($name.StartsWith(''!'') -or $name -match '' (\+=|=\+|\|=|-=|:=)$'') { continue }'
        ' $prop = $target.GetType().GetProperty($name, [System.Reflection.BindingFlags]''Public, Instance, IgnoreCase'')'
        ' if ($null -eq $prop) { throw "$($target.GetType().Name) has no key ''$name''." }'
        ' $value = $map[$key]'
        ' if ($value -is [string] -and $value.Contains(''!<'') -and $prop.PropertyType -ne [string]) { continue }'
        ' $target.$name = $value'
        ' }'
        ' }'
        '}'
    ) -join "`n"
}

# A value as a static property name: a character that can't be in a name becomes _, a leading digit gets _,
# and so do names PowerShell has as variables already: true, false, null, this.
function ConvertTo-PsdConstantName {
    param([string] $Value)
    $n = [regex]::Replace($Value, '[^A-Za-z0-9_]', '_')
    if ($n -match '^[0-9]' -or $n -in 'true', 'false', 'null', 'this') { $n = '_' + $n }
    $n
}

# The type of a static property for a -Constant value.
function Get-PsdConstantType {
    param($Value)
    if ($null -eq $Value) { return 'object' }
    if ($Value -is [bool]) { return 'bool' }
    if ($Value -is [int] -or $Value -is [int16] -or $Value -is [byte] -or $Value -is [sbyte] -or $Value -is [uint16]) { return 'int' }
    if ($Value -is [long] -or $Value -is [uint32]) { return 'long' }
    if ($Value -is [double] -or $Value -is [single]) { return 'double' }
    if ($Value -is [decimal] -or $Value -is [uint64]) { return 'decimal' }
    if (Test-PsdList $Value) { return 'object[]' }
    # text, and what the writer writes as text: dates, versions, guids, enums
    if (Test-PsdScalarOut $Value) { return 'string' }
    # maps and objects are written as @{ }
    'hashtable'
}

# Classes of named values from -Constant, one static property each. A list names each value after itself:
# Region = @('eu-west') gives [Region]::eu_west. A map gives the names: Port = @{ Http = 80 } gives [Port]::Http.
# Values are written as ConvertTo-Psd writes them, exactly as given, and typed by their kind.
function Add-PsdConstantClass {
    param($G, $Constant)
    $S = @{ Ctx = $G.Ctx; Format = (Get-PsdFormat $null $G.Ctx); Depth = 64; Stack = (New-PsdList); Path = ''; NL = "`n"; Raw = $true }
    foreach ($class in @($Constant.PSBase.Keys)) {
        $className = [string]$class
        if (-not $script:PsdRxIdent.IsMatch($className)) {
            Add-PsdError $G.Ctx 'PSD512' "-Constant: '$className' isn't a valid class name." -Hint @('Use a name that starts with a letter and has only letters, digits and _.')
            continue
        }
        if ($className -ieq $G.Name -or $G.Names.ContainsKey($className)) {
            Add-PsdError $G.Ctx 'PSD512' "-Constant: the class name '$className' is already used." -Hint @('Rename the -Constant entry.')
            continue
        }
        $G.Names[$className] = "-Constant $className"
        # name as written -> value
        $entries = New-PsdList
        $given = $Constant[$class]
        if ($given -is [System.Collections.IDictionary]) {
            foreach ($k in @($given.PSBase.Keys)) { $entries.Add(@{ Name = [string]$k; Value = $given[$k] }) }
        }
        else {
            foreach ($value in (Get-PsdItems $given)) {
                if ($null -eq $value -or (Test-PsdContainer $value)) {
                    Add-PsdError $G.Ctx 'PSD512' "-Constant ${className}: a list names each value after itself, so its values can't be $(Get-PsdKindText $value)." -Hint @("To name them yourself, give a map: $className = @{ Name = ... }")
                    continue
                }
                $entries.Add(@{ Name = (Format-PsdScalarText $value); Value = $value })
            }
        }
        $members = [ordered]@{}
        $lines = New-PsdList
        foreach ($entry in $entries) {
            $member = ConvertTo-PsdConstantName $entry.Name
            if ($member -notmatch '[A-Za-z0-9]') {
                Add-PsdError $G.Ctx 'PSD512' "-Constant ${className}: '$($entry.Name)' can't be a property name." -Hint @('A name needs a letter or digit.')
                continue
            }
            if ($members.Contains($member)) {
                Add-PsdError $G.Ctx 'PSD512' "-Constant ${className}: '$($members[$member])' and '$($entry.Name)' would both become the property '$member'."
                continue
            }
            $members[$member] = $entry.Name
            $S.Path = "$className.$member"
            $text = Write-PsdValueText $entry.Value $S 1
            $lines.Add(" static [$(Get-PsdConstantType $entry.Value)] `$$member = $text")
        }
        $G.Constants.Add("class $className {`n$($lines -join "`n")`n}")
    }
}

function New-PsdTypeText {
    param($Rules, [string] $Name, [string] $Command, [string] $OutDir, $Ctx, $Constant = $null)
    if (-not $script:PsdRxIdent.IsMatch($Name)) {
        Add-PsdError $Ctx 'PSD512' "'$Name' isn't a valid class name." -Hint @('Use a name that starts with a letter and has only letters, digits and _.')
        return $null
    }
    $g = @{ Ctx = $Ctx; Name = $Name; OutDir = $OutDir; Enums = [ordered]@{}; Classes = (New-PsdList); Names = @{}; Comments = $null; Helper = "${Name}PsdMap"; Constants = (New-PsdList) }
    $g.Names[$g.Helper] = '(helper)'
    # first, so a class of the schema with the same name is reported
    if ($null -ne $Constant) { Add-PsdConstantClass $g $Constant }
    $null = Add-PsdTypeClass $g $Rules ''
    foreach ($e in @($g.Enums.PSBase.Keys)) {
        if ($null -ne $Constant -and $Constant.Contains($e)) {
            Add-PsdError $Ctx 'PSD512' "-Constant: the class name '$e' is also an enum of the schema." -Hint @('Rename the -Constant entry.')
        }
    }
    $parts = New-PsdList
    $parts.Add("# Generated by: $Command`n# Do not edit. Run it again after changing the schema.")
    if ($g.Enums.PSBase.Count -gt 0) { $parts.Add((@($g.Enums.PSBase.Values) -join "`n")) }
    foreach ($c in $g.Constants) { $parts.Add($c) }
    $parts.Add((Get-PsdTypeHelperText $g.Helper))
    foreach ($c in $g.Classes) { $parts.Add($c) }
    ($parts -join "`n`n") + "`n"
}