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,$_ }
						}
					}
				}
			}
		}
	}
}

}