diff --git a/src/GitTabExpansion.ps1 b/src/GitTabExpansion.ps1 index 6e0606738..5d46fc446 100644 --- a/src/GitTabExpansion.ps1 +++ b/src/GitTabExpansion.ps1 @@ -69,6 +69,22 @@ $script:gitCommandsWithParamValues = $gitParamValues.Keys -join '|' $script:vstsCommandsWithShortParams = $shortVstsParams.Keys -join '|' $script:vstsCommandsWithLongParams = $longVstsParams.Keys -join '|' +# The regular expression here is roughly follows this pattern: +# +# *()*+<$args>* +# +# The delimiters inside the parameter list and between some of the elements are non-newline whitespace characters ([^\S\r\n]). +# In those instances, newlines are only allowed if they preceded by a non-newline whitespace character. +# +# Begin anchor (^|[;`n]) +# Whitespace (\s*) +# Git Command (?$(GetAliasPattern git)) +# Parameters (?(([^\S\r\n]|[^\S\r\n]``\r?\n)+\S+)*) +# $args Anchor (([^\S\r\n]|[^\S\r\n]``\r?\n)+\`$args) +# Whitespace (\s|``\r?\n)* +# End Anchor ($|[|;`n]) +$script:GitProxyFunctionRegex = "(^|[;`n])(\s*)(?$(Get-AliasPattern git))(?(([^\S\r\n]|[^\S\r\n]``\r?\n)+\S+)*)(([^\S\r\n]|[^\S\r\n]``\r?\n)+\`$args)(\s|``\r?\n)*($|[|;`n])" + try { if ($null -ne (git help -a 2>&1 | Select-String flow)) { $script:someCommands += 'flow' @@ -472,6 +488,34 @@ function GitTabExpansionInternal($lastBlock, $GitStatus = $null) { } } +function Expand-GitProxyFunction($command) { + # Make sure the incoming command matches: , so we can extract the alias/command + # name and the arguments being passed in. + if ($command -notmatch '^(?\S+)([^\S\r\n]|[^\S\r\n]`\r?\n)+(?([^\S\r\n]|[^\S\r\n]`\r?\n|\S)*)$') { + return $command + } + + # Store arguments for replacement later + $arguments = $matches['args'] + + # Get the command name; if an alias exists, get the actual command name + $commandName = $matches['command'] + if (Test-Path -Path Alias:\$commandName) { + $commandName = Get-Item -Path Alias:\$commandName | Select-Object -ExpandProperty 'ResolvedCommandName' + } + + # Extract definition of git usage + if (Test-Path -Path Function:\$commandName) { + $definition = Get-Item -Path Function:\$commandName | Select-Object -ExpandProperty 'Definition' + if ($definition -match $script:GitProxyFunctionRegex) { + # Clean up the command by removing extra delimiting whitespace and backtick preceding newlines + return (("$($matches['cmd'].TrimStart()) $($matches['params']) $arguments") -replace '`\r?\n', ' ' -replace '\s+', ' ') + } + } + + return $command +} + function WriteTabExpLog([string] $Message) { if (!$global:GitTabSettings.EnableLogging) { return } @@ -481,6 +525,9 @@ function WriteTabExpLog([string] $Message) { if (!$UseLegacyTabExpansion -and ($PSVersionTable.PSVersion.Major -ge 6)) { $cmdNames = "git","tgit","gitk" + if ($EnableProxyFunctionExpansion) { + $cmdNames += Get-ChildItem -Path Function:\ | Where-Object { $_.Definition -match $script:GitProxyFunctionRegex } | Select-Object -ExpandProperty 'Name' + } $cmdNames += Get-Alias -Definition $cmdNames -ErrorAction Ignore | ForEach-Object Name Microsoft.PowerShell.Core\Register-ArgumentCompleter -CommandName $cmdNames -Native -ScriptBlock { @@ -491,6 +538,9 @@ if (!$UseLegacyTabExpansion -and ($PSVersionTable.PSVersion.Major -ge 6)) { # The Expand-GitCommand expects this trailing space, so pad with a space if necessary. $padLength = $cursorPosition - $commandAst.Extent.StartOffset $textToComplete = $commandAst.ToString().PadRight($padLength, ' ').Substring(0, $padLength) + if ($EnableProxyFunctionExpansion) { + $textToComplete = Expand-GitProxyFunction($textToComplete) + } WriteTabExpLog "Expand: command: '$($commandAst.Extent.Text)', padded: '$textToComplete', padlen: $padLength" Expand-GitCommand $textToComplete @@ -504,6 +554,9 @@ else { $line = $Context.Line $lastBlock = [regex]::Split($line, '[|;]')[-1].TrimStart() + if ($EnableProxyFunctionExpansion) { + $lastBlock = Expand-GitProxyFunction($lastBlock) + } $TabExpansionHasOutput.Value = $true WriteTabExpLog "PowerTab expand: '$lastBlock'" Expand-GitCommand $lastBlock @@ -514,6 +567,9 @@ else { function TabExpansion($line, $lastWord) { $lastBlock = [regex]::Split($line, '[|;]')[-1].TrimStart() + if ($EnableProxyFunctionExpansion) { + $lastBlock = Expand-GitProxyFunction($lastBlock) + } $msg = "Legacy expand: '$lastBlock'" switch -regex ($lastBlock) { diff --git a/src/posh-git.psm1 b/src/posh-git.psm1 index df90b0903..900a502f5 100644 --- a/src/posh-git.psm1 +++ b/src/posh-git.psm1 @@ -1,4 +1,4 @@ -param([bool]$ForcePoshGitPrompt, [bool]$UseLegacyTabExpansion) +param([bool]$ForcePoshGitPrompt, [bool]$UseLegacyTabExpansion, [bool]$EnableProxyFunctionExpansion) . $PSScriptRoot\CheckRequirements.ps1 > $null diff --git a/test/GitProxyFunctionExpansion.Tests.ps1 b/test/GitProxyFunctionExpansion.Tests.ps1 new file mode 100644 index 000000000..9ef606db7 --- /dev/null +++ b/test/GitProxyFunctionExpansion.Tests.ps1 @@ -0,0 +1,363 @@ +BeforeAll { + . $PSScriptRoot\Shared.ps1 +} + +Describe 'Proxy Function Expansion Tests' { + Context 'Proxy Function Name TabExpansion Tests' { + BeforeEach { + if(Test-Path -Path Function:\Invoke-GitFunction) { + Rename-Item -Path Function:\Invoke-GitFunction -NewName Invoke-GitFunctionBackup + } + if(Test-Path -Path Alias:\igf) { + Rename-Item -Path Alias:\igf -NewName igfbackup + } + New-Alias -Name 'igf' -Value Invoke-GitFunction -Scope 'Script' + } + AfterEach { + if(Test-Path -Path Function:\Invoke-GitFunction) { + Remove-Item -Path Function:\Invoke-GitFunction + } + if(Test-Path -Path Function:\Invoke-GitFunctionBackup) { + Rename-Item Function:\Invoke-GitFunctionBackup Invoke-GitFunction + } + if(Test-Path -Path Alias:\igf) { + Remove-Item -Path Alias:\igf + } + if(Test-Path -Path Alias:\igfbackup) { + Rename-Item -Path Alias:\igfbackup -NewName igf + } + } + It 'Expands a proxy function with parameters' { + function script:Invoke-GitFunction { git checkout $args } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction -b newbranch' + $result | Should -Be 'git checkout -b newbranch' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf -b newbranch') + } + It 'Expands a multiline proxy function' { + function script:Invoke-GitFunction { git checkout $args } + $result = & $module Expand-GitProxyFunction "Invoke-GitFunction ```r`n-b ```r`nnewbranch" + $result | Should -Be 'git checkout -b newbranch' + $result | Should -Be (& $module Expand-GitProxyFunction "igf ```r`n-b ```r`nnewbranch") + } + It 'Does not expand the proxy function name if there is no preceding whitespace before backtick newlines' { + function script:Invoke-GitFunction { git checkout $args } + & $module Expand-GitProxyFunction "Invoke-GitFunction```r`n-b```r`nnewbranch" | Should -Be "Invoke-GitFunction```r`n-b```r`nnewbranch" + & $module Expand-GitProxyFunction "igf```r`n-b```r`nnewbranch" | Should -Be "igf```r`n-b```r`nnewbranch" + } + It 'Does not expand the proxy function name if there is no preceding non-newline whitespace before any backtick newlines' { + function script:Invoke-GitFunction { git checkout $args } + & $module Expand-GitProxyFunction "Invoke-GitFunction ```r`n-b```r`nnewbranch" | Should -Be "Invoke-GitFunction ```r`n-b```r`nnewbranch" + & $module Expand-GitProxyFunction "igf ```r`n-b```r`nnewbranch" | Should -Be "igf ```r`n-b```r`nnewbranch" + } + It 'Does not expand the proxy function name if the preceding whitespace before backtick newlines are newlines' { + function script:Invoke-GitFunction { git checkout $args } + & $module Expand-GitProxyFunction "Invoke-GitFunction`r`n```r`n-b`r`n```r`nnewbranch" | Should -Be "Invoke-GitFunction`r`n```r`n-b`r`n```r`nnewbranch" + & $module Expand-GitProxyFunction "igf`r`n```r`n-b`r`n```r`nnewbranch" | Should -Be "igf`r`n```r`n-b`r`n```r`nnewbranch" + } + It 'Does not expand the proxy function if there is no trailing space' { + function script:Invoke-GitFunction { git checkout $args } + & $module Expand-GitProxyFunction 'Invoke-GitFunction' | Should -Be 'Invoke-GitFunction' + & $module Expand-GitProxyFunction 'igf' | Should -Be 'igf' + } + } + Context 'Proxy Function Definition Expansion Tests' { + BeforeEach { + if(Test-Path -Path Function:\Invoke-GitFunction) { + Rename-Item -Path Function:\Invoke-GitFunction -NewName Invoke-GitFunctionBackup + } + if(Test-Path -Path Alias:\igf) { + Rename-Item -Path Alias:\igf -NewName igfbackup + } + New-Alias -Name 'igf' -Value Invoke-GitFunction -Scope 'Script' + } + AfterEach { + if(Test-Path -Path Function:\Invoke-GitFunction) { + Remove-Item -Path Function:\Invoke-GitFunction + } + if(Test-Path -Path Function:\Invoke-GitFunctionBackup) { + Rename-Item Function:\Invoke-GitFunctionBackup Invoke-GitFunction + } + if(Test-Path -Path Alias:\igf) { + Remove-Item -Path Alias:\igf + } + if(Test-Path -Path Alias:\igfbackup) { + Rename-Item -Path Alias:\igfbackup -NewName igf + } + } + It 'Expands a single line function' { + function script:Invoke-GitFunction { + git checkout $args + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands a single line function with short parameter' { + function script:Invoke-GitFunction { + git checkout -b $args + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout -b ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands a single line function with long parameter' { + function script:Invoke-GitFunction { + git checkout --detach $args + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout --detach ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands a single line with piped function suffix' { + function script:Invoke-GitFunction { + git checkout --detach $args | Write-Host + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout --detach ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands the first line in function' { + function script:Invoke-GitFunction { + git checkout $args + $a = 5 + Write-Host $null + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands the middle line in function' { + function script:Invoke-GitFunction { + $a = 5 + git checkout $args + Write-Host $null + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands the last line in function' { + function script:Invoke-GitFunction { + $a = 5 + Write-Host $null + git checkout $args + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands semicolon delimited functions' { + function script:Invoke-GitFunction { + $a = 5; git checkout $args; Write-Host $null; + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands mixed semicolon delimited and newline functions' { + function script:Invoke-GitFunction { + $a = 5; Write-Host $null + git checkout $args; Write-Host $null; + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands mixed semicolon delimited and newline multiline functions' { + function script:Invoke-GitFunction { + $a = 5; Write-Host $null + git ` + checkout ` + $args; Write-Host $null; + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands simultaneously semicolon delimited and newline functions' { + function script:Invoke-GitFunction { + $a = 5; + Write-Host $null; + git checkout $args; + Write-Host $null; + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands multiline function' { + function script:Invoke-GitFunction { + git ` + checkout ` + $args + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands multiline function that terminates with semicolon on new line' { + function script:Invoke-GitFunction { + git ` + checkout ` + $args ` + ; + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands multiline function with short parameter' { + function script:Invoke-GitFunction { + git ` + checkout ` + -b ` + $args + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout -b ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Expands multiline function with long parameter' { + function script:Invoke-GitFunction { + git ` + checkout ` + --detach ` + $args + } + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result | Should -Be 'git checkout --detach ' + $result | Should -Be (& $module Expand-GitProxyFunction 'igf ' ) + } + It 'Does not expand a single line with piped function prefix' { + function script:Invoke-GitFunction { + "master" | git checkout --detach $args + } + & $module Expand-GitProxyFunction 'Invoke-GitFunction ' | Should -Be 'Invoke-GitFunction ' + & $module Expand-GitProxyFunction 'igf ' | Should -Be 'igf ' + } + It 'Does not expand function if $args is not present' { + function script:Invoke-GitFunction { + git checkout + } + & $module Expand-GitProxyFunction 'Invoke-GitFunction ' | Should -Be 'Invoke-GitFunction ' + & $module Expand-GitProxyFunction 'igf ' | Should -Be 'igf ' + } + It 'Does not expand function if $args is not attached to the git function' { + function script:Invoke-GitFunction { + $a = 5 + git checkout + Write-Host $args + } + & $module Expand-GitProxyFunction 'Invoke-GitFunction ' | Should -Be 'Invoke-GitFunction ' + & $module Expand-GitProxyFunction 'igf ' | Should -Be 'igf ' + } + It 'Does not expand multiline function if $args is not attached to the git function' { + function script:Invoke-GitFunction { + $a = 5 + git ` + checkout + Write-Host $args + } + & $module Expand-GitProxyFunction 'Invoke-GitFunction ' | Should -Be 'Invoke-GitFunction ' + & $module Expand-GitProxyFunction 'igf ' | Should -Be 'igf ' + } + It 'Does not expand multiline function backtick newlines are not preceded with whitespace' { + function script:Invoke-GitFunction { + $a = 5 + git` + checkout` + $args + Write-Host $null + } + & $module Expand-GitProxyFunction 'Invoke-GitFunction ' | Should -Be 'Invoke-GitFunction ' + & $module Expand-GitProxyFunction 'igf ' | Should -Be 'igf ' + } + } + Context 'Proxy Function Parameter Replacement Tests' { + BeforeEach { + if(Test-Path -Path Function:\Invoke-GitFunction) { + Rename-Item -Path Function:\Invoke-GitFunction -NewName Invoke-GitFunctionBackup + } + function script:Invoke-GitFunction { git checkout $args } + } + AfterEach { + if(Test-Path -Path Function:\Invoke-GitFunction) { + Remove-Item -Path Function:\Invoke-GitFunction + } + if(Test-Path -Path Function:\Invoke-GitFunctionBackup) { + Rename-Item Function:\Invoke-GitFunctionBackup Invoke-GitFunction + } + } + It 'Replaces parameter in $args' { + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction master' + $result | Should -Be 'git checkout master' + } + It 'Replaces short parameter in $args' { + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction -b master' + $result | Should -Be 'git checkout -b master' + } + It 'Replaces long parameter in $args' { + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction --detach master' + $result | Should -Be 'git checkout --detach master' + } + It 'Replaces mixed parameters in $args' { + $result = & $module Expand-GitProxyFunction 'Invoke-GitFunction -q -f -m --detach master' + $result | Should -Be 'git checkout -q -f -m --detach master' + } + } + Context 'Proxy Subcommand TabExpansion Tests' { + BeforeEach { + if(Test-Path -Path Function:\Invoke-GitFunction) { + Rename-Item -Path Function:\Invoke-GitFunction -NewName Invoke-GitFunctionBackup + } + } + AfterEach { + if(Test-Path -Path Function:\Invoke-GitFunction) { + Remove-Item -Path Function:\Invoke-GitFunction + } + if(Test-Path -Path Function:\Invoke-GitFunctionBackup) { + Rename-Item -Path Function:\Invoke-GitFunctionBackup -NewName Invoke-GitFunction + } + } + It 'Tab completes without subcommands' { + function script:Invoke-GitFunction { git whatever $args } + $functionText = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result = & $module GitTabExpansionInternal $functionText + + $result | Should -Be @() + } + It 'Tab completes bisect subcommands' { + function script:Invoke-GitFunction { git bisect $args } + $functionText = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result = & $module GitTabExpansionInternal $functionText + + $result -contains '' | Should -Be $false + $result -contains 'start' | Should -Be $true + $result -contains 'run' | Should -Be $true + + $functionText = & $module Expand-GitProxyFunction 'Invoke-GitFunction s' + $result2 = & $module GitTabExpansionInternal $functionText + + $result2 -contains 'start' | Should -Be $true + $result2 -contains 'skip' | Should -Be $true + } + It 'Tab completes remote subcommands' { + function script:Invoke-GitFunction { git remote $args } + $functionText = & $module Expand-GitProxyFunction 'Invoke-GitFunction ' + $result = & $module GitTabExpansionInternal $functionText + + $result -contains '' | Should -Be $false + $result -contains 'add' | Should -Be $true + $result -contains 'set-branches' | Should -Be $true + $result -contains 'get-url' | Should -Be $true + $result -contains 'update' | Should -Be $true + + $functionText = & $module Expand-GitProxyFunction 'Invoke-GitFunction s' + $result2 = & $module GitTabExpansionInternal $functionText + + $result2 -contains 'set-branches' | Should -Be $true + $result2 -contains 'set-head' | Should -Be $true + $result2 -contains 'set-url' | Should -Be $true + } + } +}