# encoding: ascii
# api: powershell
# title: Select-EnumeratedType
# description: UPDATED 2010/10/28: Visually create an instance of an enum with an easy to use menu system. Supports both single value enumerated types and bitmask (flags) enums. Also supports dynamic inline help for enumerated values (help works with powershell.exe host only – ISE is buggy.) PowerShell 2.0 required. This uses uses the new to v2 IHostUISupportsMultipleChoiceSelection interface.
# version: 0.1
# type: script
# author: Oisin Grehan
# license: CC0
# function: Select-EnumeratedType
# x-poshcode-id: 2327
# x-archived: 2010-11-02T18:10:51
#
#
#requires -version 2

if (-not(test-path variable:script:_helpcache))
{
    $SCRIPT:_helpCache = @{}
}

function Select-EnumeratedType
{
<#
    .SYNOPSIS
        Visually create an instance of an enum.
        
    .DESCRIPTION
        Visually create an instance of an enum with an easy to use menu system.
        Supports both single value enumerated types and bitmask (flags) enums.
        Also supports inline help for enumerated values (powershell.exe only)
    
    .PARAMETER EnumeratedType
        The enum type to provide choices for. Pass as a string or Type.
    
    .PARAMETER IncludeHelp
        Allows the caller to view help for the enumerated type, including all values.
        This help is pulled from the local .NET installation and does not require online
        access.
        
    .PARAMETER DefaultValue 
        A dynamic parameter that is added and typed to the provided enum. Use this to
        provide default values should the caller press <enter> without choosing a value
        or value(s). This parameter will absorb all trailing arguments.
        (ValueFromRemainingArguments = $true)
        
    .EXAMPLE
        $rights = Select-EnumeratedType System.Security.AccessControl.CryptoKeyRights Delete,Synchronize
        
    .EXAMPLE
        $access = Select-EnumeratedType System.IO.FileAccess Read -IncludeHelp
#>
    [cmdletbinding()]
    param(
        [parameter(position=0, mandatory=$true)]
        [validatescript({ $_.isenum })]
        [validatenotnull()]
        [type]$EnumeratedType,
        
        [parameter()]
        [switch]$IncludeHelp
    )
    
    dynamicparam {
    
        # only create dynamic parameter if enum provided
        if ($PSCmdlet.MyInvocation.BoundParameters["EnumeratedType"]) {
        
            write-verbose "Adding DefaultValue ($enumeratedType) parameter."
            
            # dynamically create -Default parameter with the correct enum type for easy validation
            $dict = new-object management.automation.RuntimeDefinedParameterDictionary
            $defaultParam = new-object System.Management.Automation.RuntimeDefinedParameter
            $defaultParam.Name = "DefaultValue"
            $defaultParam.ParameterType = $enumeratedType
            
            # create parameter attribute for positional info etc.
            $p = new-object System.Management.Automation.ParameterAttribute
            $p.ParameterSetName = $PSCmdlet.ParameterSetName
            $p.ValueFromRemainingArguments = $true
            $p.Position = 1
            $p.Mandatory = $false
            $defaultParam.Attributes.Add($p)
            
            $dict.Add("DefaultValue", $defaultParam)
            $dict
        }
    }
    
    end {                
        # ugly. why doesn't $defaultvalue just work?
        $default = $pscmdlet.MyInvocation.BoundParameters["DefaultValue"]               
        

        $help = @{}

        if ($IncludeHelp) {

            $xmldoc = Get-XmlDocumentation -type $enumeratedType        

            if ($xmlDoc) {

                # use readallines over get-content - 10x faster.
                $f = [xml][io.file]::readAllLines($xmlDoc)

                $selector = "F:$($enumeratedType.fullName)"

                foreach ($node in $f.doc.members.selectnodes("member[starts-with(@name,'$selector')]")) {
                    if ($node.summary) {
                        $help[$node.name] = $node.summary.trim()
                    }
                }
            }
        }

        $choices = new-object collections.generic.list[System.Management.Automation.Host.ChoiceDescription]

        $names = [enum]::getnames($enumeratedType)
        [double[]]$values = [enum]::getvalues($enumeratedType)|%{[double]$_}
        
        # compute hotkeys for enum choices
        $hot = new-hotkeytable $names

        # insert ampersand (&) for hotkey hints
        $names | % {
            
            $i = $_.indexof([string]$hot[$_], [stringcomparison]::ordinalignorecase)
            
            if ($i -ne -1) {
            
                # hotkey exists in word
                $name = $_.insert($i, "&") 
            
            } else {
            
                # hotkey is not part of word - need to append (blech)
                $name = "$_ (&$($hot[$_]))"
            }

            $helpKey = "F:$($enumeratedType.fullname).$_"
            
            if ($includehelp -and $help[$helpKey]) {
            
                # doesn't work in ISE - never renders the '?'
                $choiceParams =  @($name, $help[$helpKey])
            
            } else {
            
                $choiceParams = $name
            }
            
            $choices.add((new-object Management.Automation.Host.ChoiceDescription -Args $choiceParams))
        }

        # are we a flags enum?        
        $isFlags = $enumeratedType.GetCustomAttributes([FlagsAttribute], $false)
        
        # does this host support multiple choice?
        $supportsMultipleChoice = $host.ui -is [Management.Automation.Host.IHostUISupportsMultipleChoiceSelection]
        
        if ($isFlags -and (-not $supportsMultipleChoice)) {
            
            throw ("{0} enum is flags decorated and this host ({1}) does not support multiple choice selections. Sorry!" -f $enumeratedType.name, $host.name)
        
        }
        
        $title = $enumeratedType.name               

        if ($isFlags) {
        
            if (-not $default) {
        
                # no default provided
                $default = [int[]]@()
        
            } else {
        
                # need to parse default
                [int[]]$defaults = @()
                $limit = [math]::log([enum]::GetUnderlyingType($enumeratedType)::maxvalue + 1, 2)
                
                # cast to [int] required or else we get non-zero result
                # as we approach limit (double trouble)
                for ($index = [int]$limit; $index -ge 0; $index--) {
        
                    $bit = [math]::pow(2,$index) # double
        
                    if (([int]$default) -band $bit) {
                        $defaults += [array]::indexof($values, $bit)
                    }
                }
                $default = $defaults
            }
            
            $message = "Choose one or more values for mask:"
            $title += " (Flags)"

        } else {
        
            if (-not $default) {
        
                # this is menu position, not enum value
                $default = -1
        
            } else {
        
                # convert to index
                $default = [array]::indexof($values, [double]$default)
            }
            $message = "Choose single value:"
        }
        
        $result = @()
        
        # invoke host support for multiple choice
        $host.ui.promptforchoice($title, $message, $choices, $default) | % {
            $result += $names[$_]
        }
        
        # cast back to enum
        $result -as $enumeratedtype
    }
}

function Get-HelpSummary
{
        [CmdletBinding()]
        param
        (        
            [string]$file,
            [reflection.assembly]$assembly,
            [string]$selector
        )
        
        if ($helpCache.ContainsKey($assembly))
        {
            $xml = $helpCache[$assembly]            
        }
        else
        {
            # cache it
            Write-Progress -id 1 "Caching Help Documentation" $assembly.getname().name

            # cache this for future lookups. It's a giant pig. Oink.
            $xml = [xml](gc $file)
            
            $helpCache.Add($assembly, $xml)
            
            Write-Progress -id 1 "Caching Help Documentation" $assembly.getname().name -completed
        }

        # TODO: support overloads
        $summary = $xml.doc.members.SelectSingleNode("member[@name='$selector' or starts-with(@name,'$selector(')]").summary
        
        $summary
}

function New-HotKeyTable {
    param([string[]]$names)
    
    $hot = @{}

    # assign hot keys
    $names | % {
        
        $c = [char]::toupper($_[0])
        
        # prioritize first letter
        if (-not $hot.containsvalue($c)) {
            $hot[$_] = $c
            write-debug "1) assigned $c to $_"
        }
    }

    $names | ? {

        # unallocated?
        -not $hot.containskey($_)

    } | % {
        
        # try camel humps
        for ($i=1; $i -lt $_.length; $i++) {
            
            $c = $_[$i]
            
            if ([char]::IsUpper($c) -and (-not $hot.containsvalue($c))) {
                $hot[$_] = $c
                write-debug "2) assigned $c to $_"
                break
            }
        }
    }

    $names | ? {

        # unallocated?
        -not $hot.containskey($_)

    } | % {

        # try sequential from pos 1
        for ($i=1; $i -lt $_.length; $i++) {
            
            $c = [char]::toupper($_[$i])
            
            # available?
            if (-not $hot.containsvalue($c)) {
                $hot[$_] = $c
                write-debug "3) assigned $c to $_"
                break
            }
        }
    }

    # alphabetic search
    $names | ? {

        # unallocated?
        -not $hot.containskey($_)

    } | % { 

        $word = $_

        $s = [int]"A"[0]
        
        for ($i = $s; $i -lt ($s + 26); $i++) {

            $c = [char]$i
            
            if (-not $hot.containsvalue($c)) {
                $hot[$word] = $c
                write-debug "4) assigned $c to $word"
                break
            }
        }
    }
    
    # todo: if the above fails, use 0-9?
    $hot
}

function Get-XmlDocumentation {
    [cmdletbinding()]
    param([type]$type)
    
    $docFilename = [io.path]::changeextension([io.path]::getfilename($type.assembly.location), ".xml")
    $location = [io.path]::getdirectoryname($type.assembly.location)
    $codebase = (new-object uri $type.assembly.codebase).localpath
            
    # try localized location (typically newer than base framework dir)
    $frameworkDir = "${env:windir}\Microsoft.NET\framework\v2.0.50727"
    $lang = [system.globalization.cultureinfo]::CurrentUICulture.parent.name

    switch
        (
        "${frameworkdir}\${lang}\$docFilename",
        "${frameworkdir}\$docFilename",
        "$location\$docFilename",
        "$codebase\$docFilename"
        )
    {
        { test-path $_ } { $_; return; }
        
        default
        {
            # try next path
            continue;
        }        
    }
}
