From 3ccb82d1e5c298437977bf2ed1302f0c5091e186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C4=8C=C3=A1bera?= Date: Sun, 28 Jun 2020 16:59:30 +0200 Subject: [PATCH] feat: Add native shell completion (#47) - Closes #24 --- lib/Diagnostic.ps1 | 14 ++ libexec/scoop-alias.ps1 | 2 +- libexec/scoop-bucket.ps1 | 2 +- libexec/scoop-cache.ps1 | 2 +- libexec/scoop-checkup.ps1 | 1 + libexec/scoop-export.ps1 | 1 - supporting/completion/Scoop-Completion.psd1 | 9 + supporting/completion/Scoop-Completion.psm1 | 253 ++++++++++++++++++++ test/00-Project.Tests.ps1 | 9 +- test/Import-File-Tests.ps1 | 6 +- test/Scoop-Config.Tests.ps1 | 2 +- test/Scoop-Core.Tests.ps1 | 2 +- 12 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 supporting/completion/Scoop-Completion.psd1 create mode 100644 supporting/completion/Scoop-Completion.psm1 diff --git a/lib/Diagnostic.ps1 b/lib/Diagnostic.ps1 index e717e0765c..f96d538f46 100644 --- a/lib/Diagnostic.ps1 +++ b/lib/Diagnostic.ps1 @@ -204,3 +204,17 @@ function Test-Config { return $result } + +function Test-CompletionRegistered { + $result = (Get-Content $PROFILE) -like '*\supporting\completion*' + if (!$result) { + $path = Join-Path $PSScriptRoot '..\supporting\completion\Scoop-Completion.psd1' -Resolve + Write-UserMessage -Message 'Automatic completion module is not imported in $PROFILE' -Warning + Write-UserMessage -Message @( + ' Consider importing module for automatic commands/parameters completion' + " Add-Content `$PROFILE 'Import-Module ''$path'' -ErrorAction SilentlyContinue'" + ) + } + + return $result +} diff --git a/libexec/scoop-alias.ps1 b/libexec/scoop-alias.ps1 index bbfa3721e7..eb8870e6c7 100644 --- a/libexec/scoop-alias.ps1 +++ b/libexec/scoop-alias.ps1 @@ -1,4 +1,4 @@ -# Usage: scoop alias add|list|rm [] +# Usage: scoop alias [add|list|rm] [] # Summary: Manage scoop aliases # Help: Add, remove or list Scoop aliases # diff --git a/libexec/scoop-bucket.ps1 b/libexec/scoop-bucket.ps1 index 197d40d0f3..a137ae36fc 100644 --- a/libexec/scoop-bucket.ps1 +++ b/libexec/scoop-bucket.ps1 @@ -1,4 +1,4 @@ -# Usage: scoop bucket add|list|known|rm [] +# Usage: scoop bucket [add|list|known|rm] [] # Summary: Manage Scoop buckets # Help: Add, list or remove buckets. # diff --git a/libexec/scoop-cache.ps1 b/libexec/scoop-cache.ps1 index aff162a7a0..8628609aca 100644 --- a/libexec/scoop-cache.ps1 +++ b/libexec/scoop-cache.ps1 @@ -1,4 +1,4 @@ -# Usage: scoop cache show|rm [app] +# Usage: scoop cache [rm|show] [app] # Summary: Show or clear the download cache # Help: Scoop caches downloads so you don't need to download the same files # when you uninstall and re-install the same version of an app. diff --git a/libexec/scoop-checkup.ps1 b/libexec/scoop-checkup.ps1 index 0c72b395e8..44ae719a54 100644 --- a/libexec/scoop-checkup.ps1 +++ b/libexec/scoop-checkup.ps1 @@ -15,6 +15,7 @@ $issues += !(Test-EnvironmentVariable) $issues += !(Test-HelpersInstalled) $issues += !(Test-Drive) $issues += !(Test-Config) +$issues += !(Test-CompletionRegistered) if ($issues -gt 0) { Write-UserMessage -Message "Found $issues potential $(pluralize $issues 'problem' 'problems')." -Warning diff --git a/libexec/scoop-export.ps1 b/libexec/scoop-export.ps1 index fe9987ee42..93a089bd4f 100644 --- a/libexec/scoop-export.ps1 +++ b/libexec/scoop-export.ps1 @@ -1,6 +1,5 @@ # Usage: scoop export > filename # Summary: Exports (an importable) list of installed apps -# Help: Lists all installed apps. 'core', 'Versions', 'manifest', 'buckets' | ForEach-Object { . (Join-Path $PSScriptRoot "..\lib\$_.ps1") diff --git a/supporting/completion/Scoop-Completion.psd1 b/supporting/completion/Scoop-Completion.psd1 new file mode 100644 index 0000000000..5a1adf21fd --- /dev/null +++ b/supporting/completion/Scoop-Completion.psd1 @@ -0,0 +1,9 @@ +@{ + 'ModuleVersion' = '1.0.0' + 'Description' = 'A Scoop tab completion module for PowerShell' + 'GUID' = '915c2217-407e-430a-b34b-2e1d82dc6511' + 'PowerShellVersion' = '5.0' + 'Author' = 'Jakub Čábera ' + 'RootModule' = 'Scoop-Completion.psm1' + 'FunctionsToExport' = @('TabExpansion') +} diff --git a/supporting/completion/Scoop-Completion.psm1 b/supporting/completion/Scoop-Completion.psm1 new file mode 100644 index 0000000000..f62550b7b3 --- /dev/null +++ b/supporting/completion/Scoop-Completion.psm1 @@ -0,0 +1,253 @@ +if (!((Get-Command 'scoop' -ErrorAction SilentlyContinue) -or (Get-Command 'shovel' -ErrorAction SilentlyContinue))) { + Write-Error 'Scoop is not installed' + exit 1 +} + +$script:SCOOP_CONFIG = scoop config show | ConvertFrom-Json +$script:SCOOP_ALL_ALIASES = $SCOOP_CONFIG.'alias' +$script:SCOOP_DIRECTORY = $env:SCOOP, $SCOOP_CONFIG.'rootPath', "$env:USERPROFILE\scoop" | Where-Object { ![String]::IsNullOrEmpty($_) } | Select-Object -First 1 +$script:SCOOP_COMMANDS = @( + 'alias' + 'bucket' + 'cache' + 'cat' + 'checkup' + 'cleanup' + 'config' + 'depends' + 'download' + 'export' + 'help' + 'hold' + 'home' + 'info' + 'install' + 'list' + 'prefix' + 'reset' + 'search' + 'status' + 'unhold' + 'uninstall' + 'update' + 'virustotal' + 'which' +) +$script:SCOOP_SUB_COMMANDS = @{ + 'alias' = 'add list rm' + 'bucket' = 'add known list rm' + 'cache' = 'rm show' + 'config' = 'rm show' +} +$script:SCOOP_SHORT_PARAMETERS = @{ + 'cleanup' = 'g k' + 'download' = 's u a b' + 'hold' = 'g' + 'install' = 'g i k s a' + 'list' = 'i u r' + 'unhold' = 'g' + 'uninstall' = 'g p' + 'update' = 'f g i k s q' + 'virustotal' = 'a s n' +} +$script:SCOOP_LONG_PARAMETERS = @{ + 'cleanup' = 'global cache' + 'download' = 'skip utility arch all-architectures' + 'hold' = 'global' + 'install' = 'global independent no-cache skip arch' + 'list' = 'installed updated reverse' + 'unhold' = 'global' + 'uninstall' = 'global purge' + 'update' = 'force global independent no-cache skip quiet' + 'virustotal' = 'arch scan no-depends' +} +$script:SCOOP_PARAMETER_VALUES = @{ + 'install' = @{ + 'a' = '32bit 64bit' + 'arch' = '32bit 64bit' + } + 'download' = @{ + 'a' = '32bit 64bit' + 'arch' = '32bit 64bit' + 'u' = 'native aria2' + 'utility' = 'native aria2' + } + 'virustotal' = @{ + 'a' = '32bit 64bit' + 'arch' = '32bit 64bit' + } +} + +$script:REGEX_SHORT_PARAMETERS = $SCOOP_SHORT_PARAMETERS.Keys -join '|' +$script:REGEX_LONG_PARAMETERS = $SCOOP_LONG_PARAMETERS.Keys -join '|' +$script:REGEX_PARAMETERS_VALUES = $SCOOP_PARAMETER_VALUES.Keys -join '|' + +#region Helpers +function script:Expand-ScoopLongParameter($Cmd, $Filter) { + return ($SCOOP_LONG_PARAMETERS[$Cmd] -split ' ') -like "$Filter*" | Sort-Object | ForEach-Object { "--$_" } +} + +function script:Expand-ScoopShortParameter($Cmd, $Filter) { + return ($SCOOP_SHORT_PARAMETERS[$Cmd] -split ' ') -like "$Filter*" | Sort-Object | ForEach-Object { "-$_" } +} + +function script:Expand-ScoopParametersValue($Cmd, $Param, $Filter) { + return ($SCOOP_PARAMETER_VALUES[$Cmd][$Param] -split ' ') -like "$Filter*" | Sort-Object +} + +function script:Get-ScoopAlias($Filter) { + $res = $() + if ($null -ne $SCOOP_ALL_ALIASES) { + $res = @(($SCOOP_ALL_ALIASES.PSObject.Properties.Name) -like "$Filter*") + } + + return $res +} + +function script:New-AllScoopAlias { + $al = @() + + 'scoop', 'shovel' | ForEach-Object { + $al += $_, "$_\.ps1", "$_\.cmd" + $al += @(Get-Alias | Where-Object -Property Definition -EQ -Value $_ | Select-Object -ExpandProperty Name) + } + + return $al -join '|' +} + +function script:Expand-ScoopCommandParameter($Commands, $Command, $Filter) { + return ($Commands.$Command -split ' ') -like "$Filter*" +} + +function script:Expand-ScoopCommand($Filter, [Switch] $IncludeAlias) { + $cmdList = $SCOOP_COMMANDS + if ($IncludeAlias) { $cmdList += Get-ScoopAlias($Filter) } + + return $cmdList -like "$Filter*" | Sort-Object +} + +function script:Get-LocallyInstalledApplicationsByScoop($Filter) { + return (Get-ChildItem $SCOOP_DIRECTORY 'apps\*' -Exclude 'scoop' -Directory -Name) -like "$Filter*" +} + +function script:Get-LocallyAvaialableApplicationsByScoop($Filter) { + $buckets = Get-ChildItem $SCOOP_DIRECTORY 'buckets\*' -Directory + + $manifests = @() + foreach ($buc in $buckets) { + $manifests += Get-ChildItem $buc.FullName 'bucket\*' -File | Select-Object -ExpandProperty BaseName + } + + return ($manifests | Select-Object -Unique) -like "$Filter*" +} + +function script:Get-ScoopCachedFile($Filter) { + $files = Get-ChildItem $SCOOP_DIRECTORY 'cache\*' -File -Name + + $res = @() + foreach ($f in $files) { $res += ($f -split '#')[0] } + + return ($res | Select-Object -Unique) -like "$Filter*" +} + +function script:Get-LocallyAddedBucket($Filter) { + return (Get-ChildItem $SCOOP_DIRECTORY 'buckets\*' -Directory -Name) -like "$Filter*" +} + +function script:Get-AvailableBucket($Filter) { + return @((scoop bucket known) -like "$Filter*") +} +#endregion Helpers + +function script:ScoopTabExpansion($LastBlock) { + switch -Regex ($LastBlock) { + # Handles Scoop -- + "^(?$REGEX_PARAMETERS_VALUES).* --(?.+) (?\w*)$" { + if ($SCOOP_PARAMETER_VALUES[$Matches['cmd']][$Matches['param']]) { + return Expand-ScoopParametersValue $Matches['cmd'] $Matches['param'] $Matches['value'] + } + } + + # Handles Scoop - + "^(?$REGEX_PARAMETERS_VALUES).* -(?.+) (?\w*)$" { + if ($SCOOP_PARAMETER_VALUES[$Matches['cmd']][$Matches['param']]) { + return Expand-ScoopParametersValue $Matches['cmd'] $Matches['param'] $Matches['value'] + } + } + + # Handles uninstall package names + '^(cleanup|hold|prefix|reset|uninstall|update|virustotal|unhold)\s+(?:.+\s+)?(?[\w][\-.\w]*)?$' { + return Get-LocallyInstalledApplicationsByScoop $Matches['package'] + } + + # Handles install package names + '^(cat|depends|download|info|install|home)\s+(?:.+\s+)?(?[\w][\-.\w]*)?$' { + return Get-LocallyAvaialableApplicationsByScoop $Matches['package'] + } + + # Handles cache (rm/show) cache names + '^cache (rm|show)\s+(?:.+\s+)?(?[\w][\-.\w]*)?$' { + return Get-ScoopCachedFile $Matches['cache'] + } + + # Handles bucket rm bucket names + '^bucket rm\s+(?:.+\s+)?(?[\w][\-.\w]*)?$' { + return Get-LocallyAddedBucket $Matches['bucket'] + } + + # Handles bucket add bucket names + '^bucket add\s+(?:.+\s+)?(?[\w][\-.\w]*)?$' { + return Get-AvailableBucket $Matches['bucket'] + } + + # Handles alias rm alias names + '^alias rm\s+(?:.+\s+)?(?[\w][\-\.\w]*)?$' { + return Get-ScoopAlias $Matches['alias'] + } + + # Handles Scoop help + '^help (?\S*)$' { + return Expand-ScoopCommand $Matches['cmd'] + } + + # Handles Scoop + "^(?$($SCOOP_SUB_COMMANDS.Keys -join '|'))\s+(?\S*)$" { + return Expand-ScoopCommandParameter $SCOOP_SUB_COMMANDS $Matches['cmd'] $Matches['op'] + } + + # Handles Scoop + '^(?\S*)$' { + return Expand-ScoopCommand $Matches['cmd'] -IncludeAlias + } + + # Handles Scoop -- + "^(?$REGEX_LONG_PARAMETERS).* --(?\S*)$" { + return Expand-ScoopLongParameter $Matches['cmd'] $Matches['param'] + } + + # Handles Scoop - + "^(?$REGEX_SHORT_PARAMETERS).* -(?\S*)$" { + return Expand-ScoopShortParameter $Matches['cmd'] $Matches['shortparam'] + } + } +} + +# Rename already hooked TabExpansion +if (Test-Path Function:\TabExpansion) { Rename-Item Function:\TabExpansion TabExpansion_Scoop_Backup } + +function TabExpansion($Line, $LastWord) { + <# + .SYNOPSIS + Handle tab completion of all scoop|shovel commands + #> + $lastBlock = [Regex]::Split($Line, '[|;]')[-1].TrimStart() + + switch -Regex ($lastBlock) { + # https://regex101.com/r/COrwSO + "^(sudo\s+)?((\.[\\\/])?bin[\\\/])?(($(New-AllScoopAlias)))\s+(?.*)$" { ScoopTabExpansion $Matches['rest'] } + + default { if (Test-Path Function:\TabExpansion_Scoop_Backup) { TabExpansion_Scoop_Backup $Line $LastWord } } + } +} + +Export-ModuleMember -Function 'TabExpansion' diff --git a/test/00-Project.Tests.ps1 b/test/00-Project.Tests.ps1 index 32783436ce..1137f2443a 100644 --- a/test/00-Project.Tests.ps1 +++ b/test/00-Project.Tests.ps1 @@ -3,11 +3,12 @@ $repo_dir = (Get-Item $MyInvocation.MyCommand.Path).Directory.Parent.FullName $repo_files = @( Get-ChildItem $repo_dir -file -recurse -force ) $project_file_exclusions = @( - $([regex]::Escape($repo_dir) + '(\\|/).git(\\|/).*$'), - '.sublime-workspace$', - '.DS_Store$', - 'supporting(\\|/)validator(\\|/)packages(\\|/)*', + ([Regex]::Escape($repo_dir) + '(\\|/).git(\\|/).*$') + '.sublime-workspace$' + '.DS_Store$' + 'supporting(\\|/)validator(\\|/)packages(\\|/)*' 'supporting(\\|/)shimexe(\\|/)packages(\\|/)*' + 'supporting(\\|/)yaml*' ) Describe 'Project code' { diff --git a/test/Import-File-Tests.ps1 b/test/Import-File-Tests.ps1 index c9a9ee20c9..552271d5a2 100644 --- a/test/Import-File-Tests.ps1 +++ b/test/Import-File-Tests.ps1 @@ -7,9 +7,9 @@ Describe 'Style constraints for non-binary project files' { $files = @( # gather all files except '*.exe', '*.zip', or any .git repository files $repo_files | - Where-Object { $_.fullname -inotmatch $($project_file_exclusions -join '|') } | - Where-Object { $_.fullname -inotmatch '(.exe|.zip|.dll)$' } | - Where-Object { $_.fullname -inotmatch '(unformated)' } + Where-Object { $_.FullName -inotmatch $($project_file_exclusions -join '|') } | + Where-Object { $_.FullName -inotmatch '(.exe|.zip|.dll)$' } | + Where-Object { $_.FullName -inotmatch '(unformated)' } ) $files_exist = ($files.Count -gt 0) diff --git a/test/Scoop-Config.Tests.ps1 b/test/Scoop-Config.Tests.ps1 index 0222d4b0c0..d3a9e9f8f0 100644 --- a/test/Scoop-Config.Tests.ps1 +++ b/test/Scoop-Config.Tests.ps1 @@ -32,7 +32,7 @@ Describe "config" -Tag 'Scoop' { } It "get_config should return exactly the same values" { - $scoopConfig = ConvertFrom-Json $json + $SCOOP_CONFIGURATION = ConvertFrom-Json $json get_config 'does_not_exist' 'default' | Should -Be 'default' get_config 'one' | Should -BeExactly 1 diff --git a/test/Scoop-Core.Tests.ps1 b/test/Scoop-Core.Tests.ps1 index 93a119d944..7e9c1a5cb1 100644 --- a/test/Scoop-Core.Tests.ps1 +++ b/test/Scoop-Core.Tests.ps1 @@ -196,7 +196,7 @@ Describe "shim" -Tag 'Scoop' { } AfterEach { - rm_shim "shim-test" $shimdir + rm_shim "shim-test" $shimdir 6>&1 | Out-Null } }