Powershellのタブ補完の秘密
本当はタブ補完を自由にコントロールしたかったのだけど、ちょっと面倒になったのでヒントだけ。
Powershellのタブ補完は、TabExpansionという関数が担っているらしい。
その正体は、
(Get-Command TabExpansion).Definition
手元のPowershell v2.0環境では、引数は二つで、
- $line : タブ補完の行全体
- $lastWord : 最後の単語
となっており、補完後の候補を返せば良いらしい。
試しに、「[System.Da」でタブすると、補完するよう苦闘してみた。
結構苦闘
なんかやっぱり便利にするにはいろいろ判断が面倒。
とりあえず、最後の単語が「[」を含んでいて閉じてなければ補完するよう考えてみた。
だいたい各入力でタブを押すと補完してくれる。
- 「[S」->「[System」
- 「[System.Date」->「[System.DateTime]」
大文字小文字は区別します!
System省略不可!
グローバル変数として、$LoadedAssembliesと$LoadedTypesを使います!
スクリプト
修正は、switch分に「# Handle simple namespace」のブロックを追加してみた。
もし、TabExpansionの定義が違うようなら、気を付けてください。
関数を更新すれば、自在に制御できるのでっす!
恒久的に変更するなら、$PROFILE等で毎回更新すべき。
function TabExpansion() { param($line, $lastWord) & { function Write-Members ($sep='.') { Invoke-Expression ('$_val=' + $_expression) $_method = [Management.Automation.PSMemberTypes] ` 'Method,CodeMethod,ScriptMethod,ParameterizedProperty' if ($sep -eq '.') { $params = @{view = 'extended','adapted','base'} } else { $params = @{static=$true} } foreach ($_m in ,$_val | Get-Member @params $_pat | Sort-Object membertype,name) { if ($_m.MemberType -band $_method) { # Return a method... $_base + $_expression + $sep + $_m.name + '(' } else { # Return a property... $_base + $_expression + $sep + $_m.name } } } # If a command name contains any of these chars, it needs to be quoted $_charsRequiringQuotes = ('`&@''#{}()$,;|<> ' + "`t").ToCharArray() # If a variable name contains any of these characters it needs to be in braces $_varsRequiringQuotes = ('-`&@''#{}()$,;|<> .\/' + "`t").ToCharArray() switch -regex ($lastWord) { # Handle property and method expansion rooted at variables... # e.g. $a.b.<tab> '(^.*)(\$(\w|:|\.)+)\.([*\w]*)$' { $_base = $matches[1] $_expression = $matches[2] $_pat = $matches[4] + '*' Write-Members break; } # Handle simple property and method expansion on static members... # e.g. [datetime]::n<tab> '(^.*)(\[(\w|\.|\+)+\])(\:\:|\.){0,1}([*\w]*)$' { $_base = $matches[1] $_expression = $matches[2] $_pat = $matches[5] + '*' Write-Members $(if (! $matches[4]) {'::'} else {$matches[4]}) break; } # Handle complex property and method expansion on static members # where there are intermediate properties... # e.g. [datetime]::now.d<tab> '(^.*)(\[(\w|\.|\+)+\](\:\:|\.)(\w+\.)+)([*\w]*)$' { $_base = $matches[1] # everything before the expression $_expression = $matches[2].TrimEnd('.') # expression less trailing '.' $_pat = $matches[6] + '*' # the member to look for... Write-Members break; } # Handle simple namespace # e.g. [System.Da<tab> '^(.*)\[(([\w.+]*?)(\.?)([\w+]*))$' { $_base = $matches[1] $_name = $matches[2] $_pn = $matches[3] $_lp = $matches[4] $_last = $matches[5] $_sel = $null $_loadn = [AppDomain]::CurrentDomain.GetAssemblies() if($global:TabEx -eq $null) { $global:TabEx = @{} } if($global:TabEx.LoadedAssemblies -ne $null) { if($_loadn.Count -eq $TabEx.LoadedAssemblies.Count) { for($_i = 0; $_i -lt $_loadn.Count; $_i++) { if($_loadn[$_i] -ne $TabEx.LoadedAssemblies[$_i]) { break } } if($_i -ge $_loadn.Count) { $_loadn = $null } } } if($_loadn -ne $null) { $global:TabEx.LoadedTypes = New-Object System.Collections.SortedList foreach($_ass in $_loadn) { foreach($_t in $_ass.GetExportedTypes()) { $_n = $TabEx.LoadedTypes $_np = $_t.FullName.Split('.') for($_i = 0; $_i -lt $_np.Count - 1; $_i++) { if(!$_n.ContainsKey($_np[$_i])) { $_n[$_np[$_i]] = New-Object System.Collections.SortedList } $_n = $_n[$_np[$_i]] } $_n[$_np[$_i]] = $_t } } $global:TabEx.LoadedAssemblies = $_loadn } if(![string]::IsNullOrEmpty($_lp) -and [string]::IsNullOrEmpty($_last)) { $_name = $_pn } if([string]::IsNullOrEmpty($_name)) { $_val = $global:TabEx.LoadedTypes } else { Invoke-Expression ('$_val=$global:TabEx.LoadedTypes.' + $_name) } if($_val -eq $null) { Invoke-Expression ('$_val=$TabEx.LoadedTypes.System.' + $_name) } if($_val -eq $null -and ![string]::IsNullOrEmpty($_last)) { $_sel = $_last $_name = $_pn if([string]::IsNullOrEmpty($_name)) { $_val = $TabEx.LoadedTypes } else { Invoke-Expression ('$_val=$TabEx.LoadedTypes.' + $_name) } if($_val -eq $null) { Invoke-Expression ('$_val=$TabEx.LoadedTypes.System.' + $_name) } } if($_val -ne $null) { if($_val -is [Collections.SortedList]) { foreach($_res in $_val.GetKeyList()) { if($_res.StartsWith($_sel)) { if([string]::IsNullOrEmpty($_name)) { if($_val[$_res] -is [Collections.SortedList]) { '{0}[{1}' -f $_base,$_res } else { '{0}[{1}]' -f $_base,$_res } } else { if($_val[$_res] -is [Collections.SortedList]) { '{0}[{1}.{2}' -f $_base,$_name,$_res } else { '{0}[{1}.{2}]' -f $_base,$_name,$_res } } } } } elseif($_val.Name.StartsWith($_sel)) { '{0}[{1}]' -f $_base,$_val.FullName '{0}{1}' -f $_base,$_val.FullName } } } # Handle variable name expansion... '(^.*\$)([*\w:]+)$' { $_prefix = $matches[1] $_varName = $matches[2] $_colonPos = $_varname.IndexOf(':') if ($_colonPos -eq -1) { $_varName = 'variable:' + $_varName $_provider = '' } else { $_provider = $_varname.Substring(0, $_colonPos+1) } foreach ($_v in Get-ChildItem ($_varName + '*') | sort Name) { $_nameFound = $_v.name $(if ($_nameFound.IndexOfAny($_varsRequiringQuotes) -eq -1) {'{0}{1}{2}'} else {'{0}{{{1}{2}}}'}) -f $_prefix, $_provider, $_nameFound } break; } # Do completion on parameters... '^-([*\w0-9]*)' { $_pat = $matches[1] + '*' # extract the command name from the string # first split the string into statements and pipeline elements # This doesn't handle strings however. $_command = [regex]::Split($line, '[|;=]')[-1] # Extract the trailing unclosed block e.g. ls | foreach { cp if ($_command -match '\{([^\{\}]*)$') { $_command = $matches[1] } # Extract the longest unclosed parenthetical expression... if ($_command -match '\(([^()]*)$') { $_command = $matches[1] } # take the first space separated token of the remaining string # as the command to look up. Trim any leading or trailing spaces # so you don't get leading empty elements. $_command = $_command.TrimEnd('-') $_command,$_arguments = $_command.Trim().Split() # now get the info object for it, -ArgumentList will force aliases to be resolved # it also retrieves dynamic parameters try { $_command = @(Get-Command -type 'Alias,Cmdlet,Function,Filter,ExternalScript' ` -Name $_command -ArgumentList $_arguments)[0] } catch { # see if the command is an alias. If so, resolve it to the real command if(Test-Path alias:\$_command) { $_command = @(Get-Command -Type Alias $_command)[0].Definition } # If we were unsuccessful retrieving the command, try again without the parameters $_command = @(Get-Command -type 'Cmdlet,Function,Filter,ExternalScript' ` -Name $_command)[0] } # remove errors generated by the command not being found, and break if(-not $_command) { $error.RemoveAt(0); break; } # expand the parameter sets and emit the matching elements # need to use psbase.Keys in case 'keys' is one of the parameters # to the cmdlet foreach ($_n in $_command.Parameters.psbase.Keys) { if ($_n -like $_pat) { '-' + $_n } } break; } # Tab complete against history either #<pattern> or #<id> '^#(\w*)' { $_pattern = $matches[1] if ($_pattern -match '^[0-9]+$') { Get-History -ea SilentlyContinue -Id $_pattern | Foreach { $_.CommandLine } } else { $_pattern = '*' + $_pattern + '*' Get-History -Count 32767 | Sort-Object -Descending Id| Foreach { $_.CommandLine } | where { $_ -like $_pattern } } break; } # try to find a matching command... default { # parse the script... $_tokens = [System.Management.Automation.PSParser]::Tokenize($line,[ref] $null) if ($_tokens) { $_lastToken = $_tokens[$_tokens.count - 1] if ($_lastToken.Type -eq 'Command') { $_cmd = $_lastToken.Content # don't look for paths... if ($_cmd.IndexOfAny('/\:') -eq -1) { # handle parsing errors - the last token string should be the last # string in the line... if ($lastword.Length -ge $_cmd.Length -and $lastword.substring($lastword.length-$_cmd.length) -eq $_cmd) { $_pat = $_cmd + '*' $_base = $lastword.substring(0, $lastword.length-$_cmd.length) # get files in current directory first,then look for commands... $( try {Resolve-Path -ea SilentlyContinue -Relative $_pat } catch {} ; try { $ExecutionContext.InvokeCommand.GetCommandName($_pat, $true, $false) | Sort-Object -Unique } catch {} ) | # If the command contains non-word characters (space, ) ] ; ) etc.) # then it needs to be quoted and prefixed with & ForEach-Object { if ($_.IndexOfAny($_charsRequiringQuotes) -eq -1) { $_ } elseif ($_.IndexOf('''') -ge 0) {'& ''{0}''' -f $_.Replace('''','''''') } else { '& ''{0}''' -f $_ }} | ForEach-Object {'{0}{1}' -f $_base,$_ } } } } } } } } }