├── .github ├── CODE_OF_CONDUCT.md └── SECURITY.md ├── .gitignore ├── .vsts-ci └── releaseBuild.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build.ps1 ├── src ├── CompletionPredictor.cs ├── CompletionPredictor.psd1 ├── CompletionPredictorStateSync.cs ├── CustomHandlers │ └── git │ │ ├── GitHandler.cs │ │ ├── GitNode.cs │ │ └── GitRepoInfo.cs ├── Init.cs └── PowerShell.Predictor.csproj └── tools ├── helper.psm1 └── images └── CompletionPredictor.gif /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | - Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) 11 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin) and [PowerShell](https://github.com/PowerShell). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | .ionide/ 4 | project.lock.json 5 | *-tests.xml 6 | /debug/ 7 | /staging/ 8 | /Packages/ 9 | *.nuget.props 10 | 11 | # VSCode directories that are not at the repository root 12 | /**/.vscode/ 13 | 14 | # Ignore binaries and symbols 15 | *.pdb 16 | *.dll 17 | 18 | -------------------------------------------------------------------------------- /.vsts-ci/releaseBuild.yml: -------------------------------------------------------------------------------- 1 | name: CompletionPredictor-ModuleBuild-$(Build.BuildId) 2 | trigger: none 3 | pr: none 4 | 5 | variables: 6 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 7 | POWERSHELL_TELEMETRY_OPTOUT: 1 8 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 9 | SBOMGenerator_Formats: 'spdx:2.2' 10 | 11 | resources: 12 | repositories: 13 | - repository: ComplianceRepo 14 | type: github 15 | endpoint: ComplianceGHRepo 16 | name: PowerShell/compliance 17 | 18 | stages: 19 | - stage: Build 20 | displayName: Build and Sign 21 | pool: 22 | name: 1ES 23 | demands: 24 | - ImageOverride -equals PSMMS2019-Secure 25 | jobs: 26 | - job: build_windows 27 | displayName: Build CompletionPredictor 28 | variables: 29 | - group: ESRP 30 | 31 | steps: 32 | 33 | - checkout: self 34 | clean: true 35 | persistCredentials: true 36 | 37 | - pwsh: | 38 | function Send-VstsCommand ($vstsCommandString) { 39 | Write-Host ("sending: " + $vstsCommandString) 40 | Write-Host "##$vstsCommandString" 41 | } 42 | Write-Host "PS Version: $($($PSVersionTable.PSVersion))" 43 | Set-Location -Path '$(Build.SourcesDirectory)\CompletionPredictor' 44 | .\build.ps1 -Bootstrap 45 | .\build.ps1 -Configuration Release 46 | 47 | # Set target folder paths 48 | New-Item -Path .\bin\NuGetPackage -ItemType Directory > $null 49 | Send-VstsCommand "vso[task.setvariable variable=NuGetPackage]$(Build.SourcesDirectory)\CompletionPredictor\bin\NuGetPackage" 50 | Send-VstsCommand "vso[task.setvariable variable=Module]$(Build.SourcesDirectory)\CompletionPredictor\bin\CompletionPredictor" 51 | Send-VstsCommand "vso[task.setvariable variable=Signed]$(Build.SourcesDirectory)\CompletionPredictor\bin\Signed" 52 | displayName: Bootstrap & Build 53 | 54 | - task: ms.vss-governance-buildtask.governance-build-task-component-detection.ComponentGovernanceComponentDetection@0 55 | displayName: 'Component Governance Detection' 56 | inputs: 57 | sourceScanPath: '$(Build.SourcesDirectory)\CompletionPredictor' 58 | snapshotForceEnabled: true 59 | scanType: 'Register' 60 | failOnAlert: true 61 | 62 | - checkout: ComplianceRepo 63 | 64 | # Sign the module files 65 | - template: EsrpSign.yml@ComplianceRepo 66 | parameters: 67 | # the folder which contains the binaries to sign 68 | buildOutputPath: $(Module) 69 | # the location to put the signed output 70 | signOutputPath: $(Signed) 71 | # the certificate ID to use 72 | certificateId: "CP-230012" 73 | pattern: | 74 | *.psd1 75 | *.psm1 76 | *.ps1 77 | *.ps1xml 78 | **\*.dll 79 | useMinimatch: true 80 | 81 | # Replace the *.psm1, *.ps1, *.psd1, *.dll files with the signed ones 82 | - pwsh: | 83 | # Show the signed files 84 | Get-ChildItem -Path $(Signed) 85 | Copy-Item -Path $(Signed)\* -Destination $(Module) -Recurse -Force 86 | displayName: 'Replace unsigned files with signed ones' 87 | 88 | # Verify the signatures 89 | - pwsh: | 90 | $HasInvalidFiles = $false 91 | $WrongCert = @{} 92 | Get-ChildItem -Path $(Module) -Recurse -Include "*.dll","*.ps*1*" | ` 93 | Get-AuthenticodeSignature | ForEach-Object { 94 | $_ | Select-Object Path, Status 95 | if ($_.Status -ne 'Valid') { $HasInvalidFiles = $true } 96 | if ($_.SignerCertificate.Subject -notmatch 'CN=Microsoft Corporation.*') { 97 | $WrongCert.Add($_.Path, $_.SignerCertificate.Subject) 98 | } 99 | } 100 | 101 | if ($HasInvalidFiles) { throw "Authenticode verification failed. There is one or more invalid files." } 102 | if ($WrongCert.Count -gt 0) { 103 | $WrongCert 104 | throw "Certificate should have the subject starts with 'Microsoft Corporation'" 105 | } 106 | displayName: 'Verify the signed files' 107 | 108 | # Generate a Software Bill of Materials (SBOM) 109 | - template: Sbom.yml@ComplianceRepo 110 | parameters: 111 | BuildDropPath: '$(Module)' 112 | Build_Repository_Uri: 'https://github.com/PowerShell/CompletionPredictor.git' 113 | displayName: Generate SBOM 114 | 115 | - pwsh: | 116 | try { 117 | $RepoName = "LocalRepo" 118 | Register-PSRepository -Name $RepoName -SourceLocation $(NuGetPackage) -PublishLocation $(NuGetPackage) -InstallationPolicy Trusted 119 | Publish-Module -Repository $RepoName -Path $(Module) 120 | } finally { 121 | Unregister-PSRepository -Name $RepoName -ErrorAction SilentlyContinue 122 | } 123 | Get-ChildItem -Path $(NuGetPackage) 124 | displayName: 'Create the NuGet package' 125 | 126 | - pwsh: | 127 | Get-ChildItem -Path $(Module), $(NuGetPackage) 128 | Write-Host "##vso[artifact.upload containerfolder=CompletionPredictor;artifactname=CompletionPredictor]$(Module)" 129 | Write-Host "##vso[artifact.upload containerfolder=NuGetPackage;artifactname=NuGetPackage]$(NuGetPackage)" 130 | displayName: 'Upload artifacts' 131 | 132 | - stage: compliance 133 | displayName: Compliance 134 | dependsOn: Build 135 | pool: 136 | name: 1ES 137 | demands: 138 | - ImageOverride -equals PSMMS2019-Secure 139 | jobs: 140 | - job: Compliance_Job 141 | displayName: CompletionPredictor Compliance 142 | variables: 143 | - group: APIScan 144 | # APIScan can take a long time 145 | timeoutInMinutes: 240 146 | 147 | steps: 148 | - checkout: self 149 | - checkout: ComplianceRepo 150 | - download: current 151 | artifact: CompletionPredictor 152 | 153 | - pwsh: | 154 | Get-ChildItem -Path "$(Pipeline.Workspace)\CompletionPredictor" -Recurse 155 | displayName: Capture downloaded artifacts 156 | 157 | - pwsh: | 158 | function Send-VstsCommand ($vstsCommandString) { 159 | Write-Host ("sending: " + $vstsCommandString) 160 | Write-Host "##$vstsCommandString" 161 | } 162 | 163 | # Get module version 164 | $psd1Data = Import-PowerShellDataFile -Path "$(Pipeline.Workspace)\CompletionPredictor\CompletionPredictor.psd1" 165 | $moduleVersion = $psd1Data.ModuleVersion 166 | $prerelease = $psd1Data.PrivateData.PSData.Prerelease 167 | if ($prerelease) { $moduleVersion = "$moduleVersion-$prerelease" } 168 | Send-VstsCommand "vso[task.setvariable variable=ModuleVersion]$moduleVersion" 169 | displayName: Get Module Version 170 | 171 | - template: assembly-module-compliance.yml@ComplianceRepo 172 | parameters: 173 | # binskim 174 | AnalyzeTarget: '$(Pipeline.Workspace)\CompletionPredictor\*.dll' 175 | AnalyzeSymPath: 'SRV*' 176 | # component-governance 177 | sourceScanPath: '' 178 | # credscan 179 | suppressionsFile: '' 180 | # TermCheck 181 | optionsRulesDBPath: '' 182 | optionsFTPath: '' 183 | # tsa-upload 184 | codeBaseName: 'CompletionPredictor_20220322' 185 | # apiscan 186 | softwareFolder: '$(Pipeline.Workspace)\CompletionPredictor' 187 | softwareName: 'CompletionPredictor' 188 | softwareVersion: '$(ModuleVersion)' 189 | connectionString: 'RunAs=App;AppId=$(APIScanClient);TenantId=$(APIScanTenant);AppKey=$(APIScanSecret)' 190 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### [0.1.1] - 2023-05-03 4 | 5 | - Set `tooltip` only if it's different from the completion text (#26) 6 | - Move the git handler into its own folder (#25) 7 | - Make ad-hoc fixes for issues found out while using the module (#23) 8 | - Add argument prediction for some basic git command (#21) 9 | - Allow argument completion on `cd` and `dir` (#20, #27) 10 | - Set the predictor Runspace name (#16) (Thanks @ThomasNieto!) 11 | - Update .psd1 file to include the project uri (#18) 12 | 13 | [0.1.1]: https://github.com/PowerShell/PSReadLine/compare/v0.1.0...v0.1.1 14 | 15 | ## [0.1.0] - 2022-04-06 16 | 17 | Initial release of the `CompletionPredictor` module: 18 | 1. Provides prediction results based on tab completion of the user's input using a separate Runspace. 19 | 1. Enable syncing some states between the PowerShell console default Runspace and the separate Runspace, including the current working directory, variables, and loaded modules. 20 | 21 | Known limitations: 22 | 1. Prediction on command names is currently disabled because tab completion on command names usually exceeds the timeout limit set by PSReadLine for the predictive intellisense feature, which is 20ms. Different approaches will need to be explored for this, such as 23 | - building an index for available commands like the module analysis cache, or 24 | - reusing tab completion results in certain cases, so further user input will be used to filter the existing tab completion results instead of always triggering new tab completion requests. 25 | 1. Prediction on command arguments is currently disabled due to the same reason. 26 | - the default argument completion action is to enumerate file system items, which is slow in our current implementation. But this can be improved by special case the file system provider, so as to call .NET APIs directly when operating in the `FileSystemProvider`. 27 | - some custom argument completers are slow, especially those for native commands as they usually have to start an external process. This can potentially be improved by building index for common native commands. 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 PowerShell Team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CompletionPredictor 2 | 3 | > **NOTE:** This is an experimental project with no target date for a 1.0 release. 4 | 5 | The `CompletionPredictor` is a PowerShell command line auto-completion plugin for the PSReadLine 6 | [Predictive Intellisense](https://devblogs.microsoft.com/powershell/announcing-psreadline-2-1-with-predictive-intellisense/) feature: 7 | 8 | ![CompletionPredictor](./tools/images/CompletionPredictor.gif) 9 | 10 | This predictor is relatively simple and also serves as an example for you to build your own predictor. 11 | 12 | We welcome your feedback and suggestions. Please file issues and submit pull requests in this repository. 13 | 14 | ## Requirements 15 | 16 | The `CompletionPredictor` plugin is built on the [Subsystem Plugin Model][subsystem-plugin-model], 17 | which is available with PowerShell 7.2 or above. 18 | To display prediction suggestions from the `CompletionPredictor`, 19 | you need [PSReadLine 2.2.2](https://www.powershellgallery.com/packages/PSReadLine/2.2.2) or above. 20 | 21 | - PowerShell 7.2 or above 22 | - PSReadLine 2.2.2 or above 23 | 24 | ## Predictor documentation 25 | 26 | PowerShell predictors are written in C# and registered with the PowerShell [Subsystem Plugin Model][subsystem-plugin-model]. 27 | To learn more, see ["How to create a command-line predictor"]( https://docs.microsoft.com/powershell/scripting/dev-cross-plat/create-cmdline-predictor). 28 | 29 | ## Build 30 | 31 | Make sure the [latest .NET 6 SDK](https://dotnet.microsoft.com/download/dotnet/6.0) is installed and 32 | available in your `PATH` environment variable. 33 | Run `.\build.ps1` from PowerShell to build the project. 34 | The module will be published to `.\bin\CompletionPredictor` by a successful build. 35 | 36 | ## Use the predictor 37 | 38 | > NOTE: Make sure you use PowerShell 7.2 with PSReadLine 2.2.2. 39 | 40 | 1. Install the module by `Install-Module -Name CompletionPredictor -Repository PSGallery` 41 | 1. Import the module to register the plugin with the PSSubsystem: `Import-Module -Name CompletionPredictor` 42 | 1. Enable prediction from the plugin source for PSReadLine: `Set-PSReadLineOption -PredictionSource HistoryAndPlugin` 43 | 1. Switch between the `Inline` and `List` prediction views, by pressing F2 44 | 45 | [subsystem-plugin-model]: https://docs.microsoft.com/powershell/scripting/learn/experimental-features#pssubsystempluginmodel 46 | 47 | ## Code of Conduct 48 | 49 | Please see our [Code of Conduct](.github/CODE_OF_CONDUCT.md) before participating in this project. 50 | 51 | ## Security Policy 52 | 53 | For any security issues, please see our [Security Policy](.github/SECURITY.md). 54 | -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding(DefaultParameterSetName = 'Build')] 2 | param( 3 | [Parameter(ParameterSetName = 'Build')] 4 | [ValidateSet('Debug', 'Release')] 5 | [string] $Configuration = 'Debug', 6 | 7 | [Parameter(ParameterSetName = 'Bootstrap')] 8 | [switch] $Bootstrap 9 | ) 10 | 11 | Import-Module "$PSScriptRoot/tools/helper.psm1" 12 | 13 | if ($Bootstrap) { 14 | Write-Log "Validate and install missing prerequisits for building ..." 15 | Install-Dotnet 16 | return 17 | } 18 | 19 | $srcDir = Join-Path $PSScriptRoot 'src' 20 | dotnet publish -c $Configuration $srcDir 21 | 22 | Write-Host "`nThe module 'CompleterPredictor' is published to 'bin\CompleterPredictor'`n" -ForegroundColor Green 23 | -------------------------------------------------------------------------------- /src/CompletionPredictor.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Management.Automation.Language; 3 | using System.Management.Automation.Runspaces; 4 | using System.Management.Automation.Subsystem.Prediction; 5 | 6 | namespace Microsoft.PowerShell.Predictor; 7 | 8 | using System.Management.Automation; 9 | 10 | public partial class CompletionPredictor : ICommandPredictor, IDisposable 11 | { 12 | private readonly Guid _guid; 13 | private readonly Runspace _runspace; 14 | private readonly GitHandler _gitHandler; 15 | private string? _cwd; 16 | private int _lock = 1; 17 | 18 | private static HashSet s_cmdList = new(StringComparer.OrdinalIgnoreCase) 19 | { 20 | "%", "foreach", "ForEach-Object", 21 | "?", "where", "Where-Object", 22 | "cd", "dir", 23 | "git", 24 | }; 25 | 26 | internal CompletionPredictor(string guid) 27 | { 28 | _guid = new Guid(guid); 29 | _gitHandler = new GitHandler(); 30 | _runspace = RunspaceFactory.CreateRunspace(InitialSessionState.CreateDefault()); 31 | _runspace.Name = nameof(CompletionPredictor); 32 | _runspace.Open(); 33 | 34 | PopulateInitialState(); 35 | RegisterEvents(); 36 | } 37 | 38 | public Guid Id => _guid; 39 | public string Name => "Completion"; 40 | public string Description => "Predictive intellisense based on PowerShell completion."; 41 | 42 | public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContext context, CancellationToken cancellationToken) 43 | { 44 | Token tokenAtCursor = context.TokenAtCursor; 45 | IReadOnlyList relatedAsts = context.RelatedAsts; 46 | 47 | if (tokenAtCursor is null) 48 | { 49 | // When it ends at a white space, it would likely trigger argument completion which in most cases would be file-operation 50 | // intensive. That's not only slow but also undesirable in most cases, so we skip it. 51 | // But, there are exceptions for some commands, where completion on member names is quite useful. 52 | if (!IsCommandAstWithLiteralName(context, out var cmdAst, out var nameAst) 53 | || !s_cmdList.TryGetValue(nameAst.Value, out string? cmd)) 54 | { 55 | // Stop processing if the cursor is not at the end of an allowed command. 56 | return default; 57 | } 58 | 59 | if (cmd is "git") 60 | { 61 | // Process 'git' command. 62 | return _gitHandler.GetGitResult(cmdAst, _cwd, context, cancellationToken); 63 | } 64 | 65 | if (cmdAst.CommandElements.Count != 1) 66 | { 67 | // For commands other than 'git', we only do argument completion if the cursor is right after the command name. 68 | return default; 69 | } 70 | } 71 | else 72 | { 73 | if (tokenAtCursor.TokenFlags.HasFlag(TokenFlags.CommandName)) 74 | { 75 | // When it's a command, it would likely take too much time because the command discovery is usually expensive, so we skip it. 76 | return default; 77 | } 78 | 79 | if (IsCommandAstWithLiteralName(context, out var cmdAst, out var nameAst) 80 | && string.Equals(nameAst.Value, "git", StringComparison.OrdinalIgnoreCase)) 81 | { 82 | return _gitHandler.GetGitResult(cmdAst, _cwd, context, cancellationToken); 83 | } 84 | } 85 | 86 | return GetFromTabCompletion(context, cancellationToken); 87 | } 88 | 89 | private bool IsCommandAstWithLiteralName( 90 | PredictionContext context, 91 | [NotNullWhen(true)] out CommandAst? cmdAst, 92 | [NotNullWhen(true)] out StringConstantExpressionAst? nameAst) 93 | { 94 | Ast lastAst = context.RelatedAsts[^1]; 95 | cmdAst = lastAst.Parent as CommandAst; 96 | nameAst = cmdAst?.CommandElements[0] as StringConstantExpressionAst; 97 | return nameAst is not null; 98 | } 99 | 100 | private SuggestionPackage GetFromTabCompletion(PredictionContext context, CancellationToken cancellationToken) 101 | { 102 | // Call into PowerShell tab completion to get completion results. 103 | // The runspace may be held by another call, or the call may take too long and exceed the timeout. 104 | CommandCompletion? result = GetCompletionResults(context.InputAst, context.InputTokens, context.CursorPosition); 105 | if (result is null || result.CompletionMatches.Count == 0 || cancellationToken.IsCancellationRequested) 106 | { 107 | return default; 108 | } 109 | 110 | int count = result.CompletionMatches.Count > 30 ? 30 : result.CompletionMatches.Count; 111 | List? list = null; 112 | 113 | int replaceIndex = result.ReplacementIndex; 114 | string input = context.InputAst.Extent.Text; 115 | 116 | ReadOnlySpan head = replaceIndex == 0 ? ReadOnlySpan.Empty : input.AsSpan(0, replaceIndex); 117 | ReadOnlySpan diff = input.AsSpan(replaceIndex); 118 | 119 | for (int i = 0; i < count; i++) 120 | { 121 | CompletionResult completion = result.CompletionMatches[i]; 122 | ReadOnlySpan text = completion.CompletionText.AsSpan(); 123 | string? tooltip = completion.CompletionText == completion.ToolTip ? null : completion.ToolTip; 124 | string? suggestion = null; 125 | 126 | switch (completion.ResultType) 127 | { 128 | case CompletionResultType.ProviderItem or CompletionResultType.ProviderContainer when !diff.IsEmpty: 129 | // For local paths, if the input doesn't contain the prefix, then we stripe it from the suggestion. 130 | bool removeLocalPathPrefix = 131 | text.IndexOf(diff, StringComparison.OrdinalIgnoreCase) == 2 && 132 | text[0] == '.' && text[1] == Path.DirectorySeparatorChar; 133 | ReadOnlySpan newPart = removeLocalPathPrefix ? text.Slice(2) : text; 134 | suggestion = string.Concat(head, newPart); 135 | 136 | break; 137 | 138 | default: 139 | break; 140 | } 141 | 142 | suggestion ??= string.Concat(head, text); 143 | if (!string.Equals(input, suggestion, StringComparison.OrdinalIgnoreCase)) 144 | { 145 | list ??= new List(count); 146 | list.Add(new PredictiveSuggestion(suggestion, tooltip)); 147 | } 148 | } 149 | 150 | return list is null ? default : new SuggestionPackage(list); 151 | } 152 | 153 | private CommandCompletion? GetCompletionResults(Ast inputAst, IReadOnlyCollection inputTokens, IScriptPosition cursorPosition) 154 | { 155 | // A simple way that denies reentrancy. We need this because this method could be called in parallel 156 | // (each keystroke triggers a call to this method), but the Runspace cannot handle that. So, if the 157 | // Runspace is busy, the call is ignored. 158 | // Value 1 indicates the runspace is available for use. 159 | if (Interlocked.Exchange(ref _lock, 0) != 1) 160 | { 161 | return null; 162 | } 163 | 164 | Runspace oldRunspace = Runspace.DefaultRunspace; 165 | 166 | try 167 | { 168 | Runspace.DefaultRunspace = _runspace; 169 | Token[] tokens = (Token[])inputTokens ?? inputTokens!.ToArray(); 170 | return CommandCompletion.CompleteInput(inputAst, tokens, cursorPosition, options: null); 171 | } 172 | finally 173 | { 174 | Interlocked.Exchange(ref _lock, 1); 175 | Runspace.DefaultRunspace = oldRunspace; 176 | } 177 | } 178 | 179 | /// 180 | /// This will be called when the module is being unloaded, from the pipeline thread. 181 | /// 182 | public void Dispose() 183 | { 184 | UnregisterEvents(); 185 | _runspace.Dispose(); 186 | } 187 | 188 | #region "Unused interface members because this predictor doesn't process feedback" 189 | 190 | public bool CanAcceptFeedback(PredictionClient client, PredictorFeedbackKind feedback) 191 | { 192 | return feedback == PredictorFeedbackKind.CommandLineAccepted ? true : false; 193 | } 194 | 195 | public void OnSuggestionDisplayed(PredictionClient client, uint session, int countOrIndex) { } 196 | public void OnSuggestionAccepted(PredictionClient client, uint session, string acceptedSuggestion) { } 197 | public void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) { } 198 | public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList history) 199 | { 200 | _gitHandler.SignalCheckForRepoUpdate(); 201 | } 202 | 203 | #endregion; 204 | } 205 | -------------------------------------------------------------------------------- /src/CompletionPredictor.psd1: -------------------------------------------------------------------------------- 1 | # 2 | # Module manifest for module 'CompletionPredictor' 3 | # 4 | 5 | @{ 6 | ModuleVersion = '0.1.1' 7 | GUID = 'dab36133-7065-440d-ac9a-821187afc400' 8 | Author = 'PowerShell' 9 | CompanyName = "Microsoft Corporation" 10 | Copyright = "Copyright (c) Microsoft Corporation." 11 | Description = 'Command-line intellisense based on PowerShell auto-completion' 12 | PowerShellVersion = '7.2' 13 | 14 | NestedModules = @('PowerShell.Predictor.dll') 15 | FunctionsToExport = @() 16 | CmdletsToExport = @() 17 | VariablesToExport = '*' 18 | AliasesToExport = @() 19 | 20 | PrivateData = @{ 21 | PSData = @{ 22 | Tags = @('PSEdition_Core') 23 | ProjectUri = 'https://github.com/PowerShell/CompletionPredictor' 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/CompletionPredictorStateSync.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using System.Management.Automation.Runspaces; 3 | using Microsoft.PowerShell.Commands; 4 | 5 | namespace Microsoft.PowerShell.Predictor; 6 | 7 | using System.Management.Automation; 8 | 9 | public partial class CompletionPredictor 10 | { 11 | private readonly HashSet _loadedModules = new(StringComparer.Ordinal); 12 | private readonly List _modulesToImport = new(); 13 | 14 | private readonly HashSet _builtInVariables = new(StringComparer.OrdinalIgnoreCase); 15 | private readonly List _varsToSet = new(); 16 | private Dictionary _userVariables = new(StringComparer.OrdinalIgnoreCase); 17 | private Dictionary _newUserVars = new(StringComparer.OrdinalIgnoreCase); 18 | 19 | private long _lastHistoryId = -1; 20 | 21 | private static readonly CmdletInfo s_getHistoryCommand = new("Get-History", typeof(GetHistoryCommand)); 22 | private static readonly CmdletInfo s_getModuleCommand = new("Get-Module", typeof(GetModuleCommand)); 23 | private static readonly CmdletInfo s_importModuleCommand = new("Import-Module", typeof(ImportModuleCommand)); 24 | 25 | private void PopulateInitialState() 26 | { 27 | PSObject value = _runspace.SessionStateProxy.InvokeProvider.Item.Get(@"Variable:\")[0]; 28 | var builtInVars = (IEnumerable)value.BaseObject; 29 | foreach (PSVariable variable in builtInVars) 30 | { 31 | _builtInVariables.Add(variable.Name); 32 | } 33 | 34 | using var ps = PowerShell.Create(); 35 | ps.Runspace = _runspace; 36 | Collection modules = ps.AddCommand(s_getModuleCommand).InvokeAndCleanup(); 37 | foreach (PSModuleInfo module in modules) 38 | { 39 | _loadedModules.Add(module.Path); 40 | } 41 | } 42 | 43 | /// 44 | /// Sync Runspace states between the default Runspace and predictor Runspace. 45 | /// 46 | private void SyncRunspaceState(object? sender, RunspaceAvailabilityEventArgs e) 47 | { 48 | if (sender is null || e.RunspaceAvailability != RunspaceAvailability.Available) 49 | { 50 | return; 51 | } 52 | 53 | // It's safe to get states of the PowerShell Runspace now because it's available and this event 54 | // is handled synchronously. 55 | // We may want to invoke command or script here, and we have to unregister ourself before doing 56 | // that, because the invocation would change the availability of the Runspace, which will cause 57 | // the 'AvailabilityChanged' to be fired again and re-enter our handler. 58 | // We register ourself back after we are done with the processing. 59 | var pwshRunspace = (Runspace)sender; 60 | pwshRunspace.AvailabilityChanged -= SyncRunspaceState; 61 | 62 | try 63 | { 64 | using var ps = PowerShell.Create(); 65 | ps.Runspace = pwshRunspace; 66 | 67 | HistoryInfo? lastHistory = ps 68 | .AddCommand(s_getHistoryCommand) 69 | .AddParameter("Count", 1) 70 | .InvokeAndCleanup() 71 | .FirstOrDefault(); 72 | 73 | if (lastHistory is not null) 74 | { 75 | if (lastHistory.Id == _lastHistoryId) 76 | { 77 | return; 78 | } 79 | 80 | _lastHistoryId = lastHistory.Id; 81 | } 82 | 83 | SyncCurrentPath(pwshRunspace); 84 | SyncVariables(pwshRunspace); 85 | SyncModules(ps); 86 | } 87 | finally 88 | { 89 | pwshRunspace.AvailabilityChanged += SyncRunspaceState; 90 | } 91 | } 92 | 93 | private void SyncModules(PowerShell pwsh) 94 | { 95 | Collection sourceModules = pwsh.AddCommand(s_getModuleCommand).InvokeAndCleanup(); 96 | 97 | foreach (PSModuleInfo module in sourceModules) 98 | { 99 | if (!_loadedModules.Contains(module.Path)) 100 | { 101 | _loadedModules.Add(module.Path); 102 | 103 | if (!module.Name.Contains("predictor", StringComparison.OrdinalIgnoreCase)) 104 | { 105 | // The completion predictor should not be imported in more than 1 Runspace, 106 | // due to the same 'Id'. I assume that's the same for all other predictors, 107 | // so we skip all modules with 'predictor' in the name. 108 | _modulesToImport.Add(module); 109 | } 110 | } 111 | } 112 | 113 | if (_modulesToImport.Count > 0) 114 | { 115 | string[] name = new string[1]; 116 | using var target = PowerShell.Create(); 117 | target.Runspace = _runspace; 118 | 119 | foreach (PSModuleInfo module in _modulesToImport) 120 | { 121 | name[0] = module.Path; 122 | if (module.ModuleType != ModuleType.Manifest && module.RootModule is not null) 123 | { 124 | string manifest = Path.Combine(module.ModuleBase, $"{module.Name}.psd1"); 125 | if (File.Exists(manifest)) 126 | { 127 | name[0] = manifest; 128 | } 129 | } 130 | 131 | try 132 | { 133 | target.AddCommand(s_importModuleCommand) 134 | .AddParameter("Name", name) 135 | .InvokeAndCleanup(); 136 | } 137 | catch 138 | { 139 | // It's possible a module cannot be imported in more than 1 Runspace. 140 | // Ignore any failures in such case. 141 | } 142 | } 143 | 144 | _modulesToImport.Clear(); 145 | } 146 | } 147 | 148 | private void SyncCurrentPath(Runspace source) 149 | { 150 | PathInfo currentPath = source.SessionStateProxy.Path.CurrentLocation; 151 | _runspace.SessionStateProxy.Path.SetLocation(currentPath.Path); 152 | _cwd = source.SessionStateProxy.Path.CurrentFileSystemLocation.ProviderPath; 153 | } 154 | 155 | private void SyncVariables(Runspace source) 156 | { 157 | var globalVars = (ICollection)source 158 | .SessionStateProxy 159 | .InvokeProvider 160 | .Item.Get(@"Variable:\")[0].BaseObject; 161 | 162 | // Figure out which ones require a change in the predictor Runspace. 163 | foreach (PSVariable variable in globalVars) 164 | { 165 | if (_builtInVariables.Contains(variable.Name)) 166 | { 167 | // Ignore built-in variables. 168 | continue; 169 | } 170 | 171 | // Get the hashcode of the value and add to the new-user-var dictionary. 172 | int newHashCode = variable.Value is null ? 0 : variable.Value.GetHashCode(); 173 | _newUserVars.Add(variable.Name, newHashCode); 174 | 175 | if (_userVariables.TryGetValue(variable.Name, out int oldHashCode)) 176 | { 177 | if (newHashCode != oldHashCode) 178 | { 179 | // Value of the variable changed, so we will re-set the variable. 180 | _varsToSet.Add(variable); 181 | } 182 | 183 | // Remove the found variable. 184 | // After the loop, all remaining ones would be those already removed from the PowerShell session. 185 | _userVariables.Remove(variable.Name); 186 | } 187 | else 188 | { 189 | // Newly added variable. 190 | _varsToSet.Add(variable); 191 | } 192 | } 193 | 194 | // Now we will be updating the variables in the predictor runspace. 195 | // The 'AvailabilityChanged' event is handled synchronously and hence it's safe to change state of the 196 | // predictor Runspace, because the PowerShell hasn't call back to PSReadLine yet. 197 | PSVariableIntrinsics predictorPSVariable = _runspace.SessionStateProxy.PSVariable; 198 | 199 | foreach (string varName in _userVariables.Keys) 200 | { 201 | predictorPSVariable.Remove(varName); 202 | } 203 | 204 | foreach (PSVariable var in _varsToSet) 205 | { 206 | predictorPSVariable.Set(var.Name, var.Value); 207 | } 208 | 209 | _varsToSet.Clear(); 210 | _userVariables.Clear(); 211 | 212 | var temp = _userVariables; 213 | _userVariables = _newUserVars; 214 | _newUserVars = temp; 215 | } 216 | 217 | /// 218 | /// Register the 'AvailabilityChanged' event. 219 | /// 220 | private void RegisterEvents() 221 | { 222 | Runspace.DefaultRunspace.AvailabilityChanged += SyncRunspaceState; 223 | } 224 | 225 | /// 226 | /// Un-register the 'AvailabilityChanged' event. 227 | /// 228 | private void UnregisterEvents() 229 | { 230 | Runspace.DefaultRunspace.AvailabilityChanged -= SyncRunspaceState; 231 | } 232 | } 233 | 234 | internal static class PowerShellExtensions 235 | { 236 | internal static Collection InvokeAndCleanup(this PowerShell ps) 237 | { 238 | var results = ps.Invoke(); 239 | ps.Commands.Clear(); 240 | 241 | return results; 242 | } 243 | 244 | internal static void InvokeAndCleanup(this PowerShell ps) 245 | { 246 | ps.Invoke(); 247 | ps.Commands.Clear(); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/CustomHandlers/git/GitHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Collections.ObjectModel; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Management.Automation.Language; 5 | using System.Management.Automation.Subsystem.Prediction; 6 | 7 | namespace Microsoft.PowerShell.Predictor; 8 | 9 | internal partial class GitHandler 10 | { 11 | private readonly ConcurrentDictionary _repos; 12 | private readonly Dictionary _gitCmds; 13 | 14 | internal GitHandler() 15 | { 16 | _repos = new(StringComparer.Ordinal); 17 | _gitCmds = new(StringComparer.Ordinal) 18 | { 19 | { "merge", new Merge() }, 20 | { "branch", new Branch() }, 21 | { "checkout", new Checkout() }, 22 | { "push", new Push() }, 23 | }; 24 | } 25 | 26 | internal void SignalCheckForRepoUpdate() 27 | { 28 | foreach (var repoInfo in _repos.Values) 29 | { 30 | repoInfo.NeedCheckForUpdate(); 31 | } 32 | } 33 | 34 | internal SuggestionPackage GetGitResult(CommandAst gitAst, string? cwd, PredictionContext context, CancellationToken token) 35 | { 36 | var elements = gitAst.CommandElements; 37 | if (cwd is null || elements.Count is 1 || !TryConvertToText(elements, out List? textElements)) 38 | { 39 | return default; 40 | } 41 | 42 | RepoInfo? repoInfo = GetRepoInfo(cwd); 43 | if (repoInfo is null || token.IsCancellationRequested) 44 | { 45 | return default; 46 | } 47 | 48 | string gitCmd = textElements[1]; 49 | string? textAtCursor = context.TokenAtCursor?.Text; 50 | bool cursorAtGitCmd = textElements.Count is 2 && textAtCursor is not null; 51 | 52 | if (!_gitCmds.TryGetValue(gitCmd, out GitNode? node)) 53 | { 54 | if (cursorAtGitCmd) 55 | { 56 | foreach (var entry in _gitCmds) 57 | { 58 | if (entry.Key.StartsWith(textAtCursor!)) 59 | { 60 | node = entry.Value; 61 | break; 62 | } 63 | } 64 | } 65 | } 66 | 67 | if (node is not null) 68 | { 69 | return node.Predict(textElements, textAtCursor, context.InputAst.Extent.Text, repoInfo, cursorAtGitCmd); 70 | } 71 | 72 | return default; 73 | } 74 | 75 | private bool TryConvertToText( 76 | ReadOnlyCollection elements, 77 | [NotNullWhen(true)] out List? textElements) 78 | { 79 | textElements = new(elements.Count); 80 | foreach (var e in elements) 81 | { 82 | switch (e) 83 | { 84 | case StringConstantExpressionAst str: 85 | textElements.Add(str.Value); 86 | break; 87 | case CommandParameterAst param: 88 | textElements.Add(param.Extent.Text); 89 | break; 90 | default: 91 | textElements = null; 92 | return false; 93 | } 94 | } 95 | 96 | return true; 97 | } 98 | 99 | private RepoInfo? GetRepoInfo(string cwd) 100 | { 101 | if (_repos.TryGetValue(cwd, out RepoInfo? repoInfo)) 102 | { 103 | return repoInfo; 104 | } 105 | 106 | foreach (var entry in _repos) 107 | { 108 | string root = entry.Key; 109 | if (cwd.StartsWith(root) && cwd[root.Length] == Path.DirectorySeparatorChar) 110 | { 111 | repoInfo = entry.Value; 112 | break; 113 | } 114 | } 115 | 116 | if (repoInfo is null) 117 | { 118 | string? repoRoot = FindRepoRoot(cwd); 119 | if (repoRoot is not null) 120 | { 121 | repoInfo = _repos.GetOrAdd(repoRoot, new RepoInfo(repoRoot)); 122 | } 123 | } 124 | 125 | return repoInfo; 126 | } 127 | 128 | private string? FindRepoRoot(string currentLocation) 129 | { 130 | string? root = currentLocation; 131 | while (root is not null) 132 | { 133 | string gitDir = Path.Join(root, ".git", "refs"); 134 | if (Directory.Exists(gitDir)) 135 | { 136 | return root; 137 | } 138 | 139 | root = Path.GetDirectoryName(root); 140 | } 141 | 142 | return null; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/CustomHandlers/git/GitNode.cs: -------------------------------------------------------------------------------- 1 | using System.Management.Automation.Subsystem.Prediction; 2 | 3 | namespace Microsoft.PowerShell.Predictor; 4 | 5 | internal abstract class GitNode 6 | { 7 | internal readonly string Name; 8 | 9 | protected GitNode(string name) 10 | { 11 | Name = name; 12 | } 13 | 14 | internal abstract SuggestionPackage Predict(List textElements, string? textAtCursor, string origInput, RepoInfo repoInfo, bool cursorAtGitCmd); 15 | } 16 | 17 | internal sealed class Merge : GitNode 18 | { 19 | internal Merge() : base("merge") { } 20 | 21 | internal override SuggestionPackage Predict( 22 | List textElements, 23 | string? textAtCursor, 24 | string origInput, 25 | RepoInfo repoInfo, 26 | bool cursorAtGitCmd) 27 | { 28 | if (textAtCursor is not null && textAtCursor.StartsWith('-')) 29 | { 30 | // We don't predict flag/option today, but may support it in future. 31 | return default; 32 | } 33 | 34 | bool predictArg = true; 35 | for (int i = 2; i < textElements.Count; i++) 36 | { 37 | if (textElements[i] is "--continue" or "--abort" or "--quit") 38 | { 39 | predictArg = false; 40 | break; 41 | } 42 | } 43 | 44 | if (predictArg) 45 | { 46 | string filter = (cursorAtGitCmd ? null : textAtCursor) ?? string.Empty; 47 | List? args = PredictArgument(filter, repoInfo); 48 | if (args is not null) 49 | { 50 | List list = new(args.Count); 51 | foreach (string arg in args) 52 | { 53 | if (textAtCursor is null) 54 | { 55 | list.Add(new PredictiveSuggestion($"{origInput}{arg}")); 56 | } 57 | else if (cursorAtGitCmd) 58 | { 59 | var remainingPortionInCmd = Name.AsSpan(textAtCursor.Length); 60 | list.Add(new PredictiveSuggestion($"{origInput}{remainingPortionInCmd} {arg}")); 61 | } 62 | else 63 | { 64 | var remainingPortionInArg = arg.AsSpan(textAtCursor.Length); 65 | list.Add(new PredictiveSuggestion($"{origInput}{remainingPortionInArg}")); 66 | } 67 | } 68 | 69 | return new SuggestionPackage(list); 70 | } 71 | } 72 | 73 | return default; 74 | } 75 | 76 | private List? PredictArgument(string filter, RepoInfo repoInfo) 77 | { 78 | List? ret = null; 79 | string activeBranch = repoInfo.ActiveBranch; 80 | 81 | if (filter.Length is 0 || !filter.Contains('/')) 82 | { 83 | foreach (RemoteInfo remote in repoInfo.Remotes) 84 | { 85 | string remoteName = remote.Name; 86 | if (remote.Branches is null || !remoteName.StartsWith(filter, StringComparison.Ordinal)) 87 | { 88 | continue; 89 | } 90 | 91 | foreach (string branch in remote.Branches) 92 | { 93 | if (branch != activeBranch) 94 | { 95 | continue; 96 | } 97 | 98 | ret ??= new List(); 99 | string candidate = $"{remoteName}/{branch}"; 100 | if (remoteName == "upstream") 101 | { 102 | ret.Insert(index: 0, candidate); 103 | } 104 | else 105 | { 106 | ret.Add(candidate); 107 | } 108 | 109 | break; 110 | } 111 | } 112 | 113 | foreach (string localBranch in repoInfo.Branches) 114 | { 115 | if (localBranch != activeBranch && localBranch.StartsWith(filter, StringComparison.Ordinal)) 116 | { 117 | ret ??= new List(); 118 | ret.Add(localBranch); 119 | } 120 | } 121 | } 122 | else 123 | { 124 | int slashIndex = filter.IndexOf('/'); 125 | if (slashIndex > 0) 126 | { 127 | var remoteName = filter.AsSpan(0, slashIndex); 128 | var branchName = filter.AsSpan(slashIndex + 1); 129 | 130 | foreach (RemoteInfo remote in repoInfo.Remotes) 131 | { 132 | if (remote.Branches is null || !MemoryExtensions.Equals(remote.Name, remoteName, StringComparison.Ordinal)) 133 | { 134 | continue; 135 | } 136 | 137 | foreach (string branch in remote.Branches) 138 | { 139 | if (branch.AsSpan().StartsWith(branchName, StringComparison.Ordinal) && branch.Length > branchName.Length) 140 | { 141 | ret ??= new List(); 142 | string candidate = $"{remoteName}/{branch}"; 143 | if (branch == activeBranch) 144 | { 145 | ret.Insert(0, candidate); 146 | } 147 | else 148 | { 149 | ret.Add(candidate); 150 | } 151 | } 152 | } 153 | 154 | break; 155 | } 156 | } 157 | 158 | if (ret is null) 159 | { 160 | foreach (string localBranch in repoInfo.Branches) 161 | { 162 | if (localBranch == activeBranch) 163 | { 164 | continue; 165 | } 166 | 167 | if (localBranch.StartsWith(filter, StringComparison.Ordinal) && localBranch.Length > filter.Length) 168 | { 169 | ret ??= new List(); 170 | ret.Add(localBranch); 171 | } 172 | } 173 | } 174 | } 175 | 176 | return ret; 177 | } 178 | } 179 | 180 | internal sealed class Branch : GitNode 181 | { 182 | internal Branch() : base("branch") { } 183 | 184 | internal override SuggestionPackage Predict( 185 | List textElements, 186 | string? textAtCursor, 187 | string origInput, 188 | RepoInfo repoInfo, 189 | bool cursorAtGitCmd) 190 | { 191 | ReadOnlySpan autoFill = null; 192 | bool predictArg = false; 193 | 194 | if (cursorAtGitCmd) 195 | { 196 | return default; 197 | } 198 | 199 | if (textElements.Count is 2 && textAtCursor is null) 200 | { 201 | autoFill = "-D ".AsSpan(); 202 | predictArg = true; 203 | } 204 | 205 | if (textAtCursor is not null && textAtCursor.StartsWith('-')) 206 | { 207 | if (textAtCursor is "-" or "-d" or "-D") 208 | { 209 | autoFill = "-D ".AsSpan(textAtCursor.Length); 210 | predictArg = true; 211 | textAtCursor = null; 212 | } 213 | else 214 | { 215 | // We don't predict flag/option today, but may support it in future. 216 | return default; 217 | } 218 | } 219 | 220 | if (!predictArg) 221 | { 222 | for (int i = 2; i < textElements.Count; i++) 223 | { 224 | if (textElements[i] is "-d" or "-D") 225 | { 226 | predictArg = true; 227 | break; 228 | } 229 | } 230 | } 231 | 232 | if (predictArg) 233 | { 234 | List? args = PredictArgument(textAtCursor ?? string.Empty, repoInfo); 235 | if (args is not null) 236 | { 237 | List list = new(args.Count); 238 | foreach (string arg in args) 239 | { 240 | if (textAtCursor is null) 241 | { 242 | list.Add(new PredictiveSuggestion($"{origInput}{autoFill}{arg}")); 243 | } 244 | else 245 | { 246 | var remainingPortionInArg = arg.AsSpan(textAtCursor.Length); 247 | list.Add(new PredictiveSuggestion($"{origInput}{remainingPortionInArg}")); 248 | } 249 | } 250 | 251 | return new SuggestionPackage(list); 252 | } 253 | } 254 | 255 | return default; 256 | } 257 | 258 | private List? PredictArgument(string filter, RepoInfo repoInfo) 259 | { 260 | List? ret = null; 261 | List? originBranches = null; 262 | 263 | foreach (var remote in repoInfo.Remotes) 264 | { 265 | if (remote.Name is "origin") 266 | { 267 | originBranches = remote.Branches; 268 | break; 269 | } 270 | } 271 | 272 | string activeBranch = repoInfo.ActiveBranch; 273 | string defaultBranch = repoInfo.DefaultBranch; 274 | 275 | if (originBranches is not null) 276 | { 277 | // The 'origin' remote exists, so do a smart check to find those local branches 278 | // that are not available in the 'origin' remote branches. 279 | HashSet localBranches = new(repoInfo.Branches); 280 | localBranches.ExceptWith(originBranches); 281 | 282 | if (localBranches.Count > 0) 283 | { 284 | foreach (string branch in localBranches) 285 | { 286 | if (branch.StartsWith(filter, StringComparison.Ordinal) && 287 | branch != activeBranch) 288 | { 289 | ret ??= new List(); 290 | ret.Add(branch); 291 | } 292 | } 293 | } 294 | } 295 | else 296 | { 297 | // No 'origin' remote, so just list the local branches, except for the default branch 298 | // and the current active branch. 299 | foreach (string branch in repoInfo.Branches) 300 | { 301 | if (branch.StartsWith(filter, StringComparison.Ordinal) && 302 | branch != activeBranch && 303 | branch != defaultBranch) 304 | { 305 | ret ??= new List(); 306 | ret.Add(branch); 307 | } 308 | } 309 | } 310 | 311 | return ret; 312 | } 313 | } 314 | 315 | internal sealed class Checkout : GitNode 316 | { 317 | internal Checkout() : base("checkout") { } 318 | 319 | internal override SuggestionPackage Predict( 320 | List textElements, 321 | string? textAtCursor, 322 | string origInput, 323 | RepoInfo repoInfo, 324 | bool cursorAtGitCmd) 325 | { 326 | if (textAtCursor is not null && textAtCursor.StartsWith('-')) 327 | { 328 | // We don't predict flag/option today, but may support it in future. 329 | return default; 330 | } 331 | 332 | int argCount = 0; 333 | bool predictArg = true; 334 | bool hasDashB = false; 335 | 336 | for (int i = 2; i < textElements.Count; i++) 337 | { 338 | if (textElements[i] is "-b" or "-B") 339 | { 340 | hasDashB = true; 341 | continue; 342 | } 343 | 344 | if (hasDashB && !textElements[i].StartsWith('-')) 345 | { 346 | argCount += 1; 347 | } 348 | } 349 | 350 | if (hasDashB) 351 | { 352 | predictArg = (argCount is 1 && textAtCursor is null) 353 | || (argCount is 2 && textAtCursor is not null); 354 | } 355 | 356 | if (predictArg) 357 | { 358 | string filter = (cursorAtGitCmd ? null : textAtCursor) ?? string.Empty; 359 | List? args = PredictArgument(filter, repoInfo, hasDashB ? false : true); 360 | if (args is not null) 361 | { 362 | List list = new(args.Count); 363 | foreach (string arg in args) 364 | { 365 | if (textAtCursor is null) 366 | { 367 | list.Add(new PredictiveSuggestion($"{origInput}{arg}")); 368 | } 369 | else if (cursorAtGitCmd) 370 | { 371 | var remainingPortionInCmd = Name.AsSpan(textAtCursor!.Length); 372 | list.Add(new PredictiveSuggestion($"{origInput}{remainingPortionInCmd} {arg}")); 373 | } 374 | else 375 | { 376 | var remainingPortionInArg = arg.AsSpan(textAtCursor.Length); 377 | list.Add(new PredictiveSuggestion($"{origInput}{remainingPortionInArg}")); 378 | } 379 | } 380 | 381 | return new SuggestionPackage(list); 382 | } 383 | } 384 | 385 | return default; 386 | } 387 | 388 | private List? PredictArgument(string filter, RepoInfo repoInfo, bool excludeActiveBranch) 389 | { 390 | List? ret = null; 391 | string activeBranch = repoInfo.ActiveBranch; 392 | 393 | foreach (string localBranch in repoInfo.Branches) 394 | { 395 | if (excludeActiveBranch && localBranch == activeBranch) 396 | { 397 | continue; 398 | } 399 | 400 | if (localBranch.StartsWith(filter, StringComparison.Ordinal) && 401 | localBranch.Length > filter.Length) 402 | { 403 | ret ??= new List(); 404 | ret.Add(localBranch); 405 | } 406 | } 407 | 408 | return ret; 409 | } 410 | } 411 | 412 | internal sealed class Push : GitNode 413 | { 414 | internal Push() : base("push") { } 415 | 416 | internal override SuggestionPackage Predict( 417 | List textElements, 418 | string? textAtCursor, 419 | string origInput, 420 | RepoInfo repoInfo, 421 | bool cursorAtGitCmd) 422 | { 423 | ReadOnlySpan autoFill = null; 424 | bool hasAutoFill = false; 425 | 426 | if (cursorAtGitCmd) 427 | { 428 | hasAutoFill = true; 429 | autoFill = Name.AsSpan(textAtCursor!.Length); 430 | textAtCursor = null; 431 | } 432 | 433 | if (textAtCursor is not null && textAtCursor.StartsWith('-')) 434 | { 435 | const string forceWithLease = "--force-with-lease"; 436 | if (forceWithLease.StartsWith(textAtCursor, StringComparison.Ordinal)) 437 | { 438 | hasAutoFill = true; 439 | autoFill = forceWithLease.AsSpan(textAtCursor.Length); 440 | textAtCursor = null; 441 | } 442 | else 443 | { 444 | // We don't predict flag/option today, but may support it in future. 445 | return default; 446 | } 447 | } 448 | 449 | int argCount = 0; 450 | for (int i = 2; i < textElements.Count; i++) 451 | { 452 | if (!textElements[i].StartsWith('-')) 453 | { 454 | argCount += 1; 455 | } 456 | } 457 | 458 | int pos = -1; 459 | if ((argCount is 0 && textAtCursor is null) 460 | || (argCount is 1 && textAtCursor is not null)) 461 | { 462 | pos = 0; 463 | } 464 | else if ((argCount is 1 && textAtCursor is null) 465 | || (argCount is 2 && textAtCursor is not null)) 466 | { 467 | pos = 1; 468 | } 469 | 470 | string filter = textAtCursor ?? string.Empty; 471 | string activeBranch = repoInfo.ActiveBranch; 472 | List? list = null; 473 | 474 | if (pos is 0) 475 | { 476 | foreach (RemoteInfo remote in repoInfo.Remotes) 477 | { 478 | string remoteName = remote.Name; 479 | if (!remoteName.StartsWith(filter, StringComparison.Ordinal)) 480 | { 481 | continue; 482 | } 483 | 484 | string candidate; 485 | list ??= new List(); 486 | 487 | if (textAtCursor is null) 488 | { 489 | candidate = hasAutoFill 490 | ? $"{origInput}{autoFill} {remoteName} {activeBranch}" 491 | : $"{origInput}{remoteName} {activeBranch}"; 492 | } 493 | else 494 | { 495 | candidate = $"{origInput}{remoteName.AsSpan(textAtCursor.Length)} {activeBranch}"; 496 | } 497 | 498 | if (remoteName is "origin") 499 | { 500 | list.Insert(0, new PredictiveSuggestion(candidate)); 501 | } 502 | else 503 | { 504 | list.Add(new PredictiveSuggestion(candidate)); 505 | } 506 | } 507 | } 508 | else if (pos is 2) 509 | { 510 | if (textAtCursor is null || (activeBranch.StartsWith(textAtCursor) && activeBranch.Length > textAtCursor.Length)) 511 | { 512 | list ??= new List(); 513 | list.Add(new PredictiveSuggestion($"{origInput}{activeBranch}")); 514 | } 515 | } 516 | 517 | return list is null ? default : new SuggestionPackage(list); 518 | } 519 | } 520 | -------------------------------------------------------------------------------- /src/CustomHandlers/git/GitRepoInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace Microsoft.PowerShell.Predictor; 4 | 5 | internal class RepoInfo 6 | { 7 | private readonly object _syncObj; 8 | private readonly string _root; 9 | private readonly string _git; 10 | private readonly string _head; 11 | private readonly string _ref_remotes; 12 | private readonly string _ref_heads; 13 | 14 | private bool _checkForUpdate; 15 | private string? _defaultBranch; 16 | private string? _activeBranch; 17 | private List? _branches; 18 | private List? _remotes; 19 | private DateTime? _ref_remote_LastWrittenTimeUtc; 20 | private DateTime? _ref_heads_LastWrittenTimeUtc; 21 | private DateTime? _head_LastWrittenTimeUtc; 22 | 23 | internal RepoInfo(string root) 24 | { 25 | _syncObj = new(); 26 | _checkForUpdate = true; 27 | _root = root; 28 | _git = Path.Join(root, ".git"); 29 | _head = Path.Join(_git, "HEAD"); 30 | _ref_heads = Path.Join(_git, "refs", "heads"); 31 | _ref_remotes = Path.Join(_git, "refs", "remotes"); 32 | } 33 | 34 | internal string RepoRoot => _root; 35 | 36 | internal string DefaultBranch 37 | { 38 | get 39 | { 40 | if (_defaultBranch is null) 41 | { 42 | Refresh(); 43 | } 44 | return _defaultBranch!; 45 | } 46 | } 47 | 48 | internal string ActiveBranch 49 | { 50 | get 51 | { 52 | Refresh(); 53 | return _activeBranch!; 54 | } 55 | } 56 | 57 | internal List Branches 58 | { 59 | get 60 | { 61 | Refresh(); 62 | return _branches!; 63 | } 64 | } 65 | 66 | internal List Remotes 67 | { 68 | get 69 | { 70 | Refresh(); 71 | return _remotes!; 72 | } 73 | } 74 | 75 | internal void NeedCheckForUpdate() 76 | { 77 | _checkForUpdate = true; 78 | if (_remotes is null) 79 | { 80 | return; 81 | } 82 | 83 | foreach (var remote in _remotes) 84 | { 85 | remote.NeedCheckForUpdate(); 86 | } 87 | } 88 | 89 | private void Refresh() 90 | { 91 | if (_checkForUpdate) 92 | { 93 | lock(_syncObj) 94 | { 95 | if (_checkForUpdate) 96 | { 97 | if (_head_LastWrittenTimeUtc == null || File.GetLastWriteTimeUtc(_head) > _head_LastWrittenTimeUtc) 98 | { 99 | (_activeBranch, _head_LastWrittenTimeUtc) = GetActiveBranch(); 100 | } 101 | 102 | if (_ref_heads_LastWrittenTimeUtc == null || Directory.GetLastWriteTimeUtc(_ref_heads) > _ref_heads_LastWrittenTimeUtc) 103 | { 104 | (_branches, _ref_heads_LastWrittenTimeUtc) = GetBranches(); 105 | } 106 | 107 | if (_ref_remote_LastWrittenTimeUtc == null || Directory.GetLastWriteTimeUtc(_ref_remotes) > _ref_remote_LastWrittenTimeUtc) 108 | { 109 | (_remotes, _ref_remote_LastWrittenTimeUtc) = GetRemotes(); 110 | } 111 | 112 | if (_defaultBranch is null) 113 | { 114 | bool hasMaster = false, hasMain = false; 115 | foreach (var branch in _branches!) 116 | { 117 | if (branch == "master") 118 | { 119 | hasMaster = true; 120 | break; 121 | } 122 | 123 | if (branch == "main") 124 | { 125 | hasMain = true; 126 | } 127 | } 128 | 129 | _defaultBranch = hasMaster ? "master" : hasMain ? "main" : string.Empty; 130 | } 131 | 132 | _checkForUpdate = false; 133 | } 134 | } 135 | } 136 | } 137 | 138 | private (string, DateTime) GetActiveBranch() 139 | { 140 | var head = new FileInfo(_head); 141 | using var reader = head.OpenText(); 142 | string content = reader.ReadLine()!; 143 | return (content.Substring("ref: refs/heads/".Length), head.LastWriteTimeUtc); 144 | } 145 | 146 | private (List, DateTime) GetBranches() 147 | { 148 | var ret = new List(); 149 | var dirInfo = new DirectoryInfo(_ref_heads); 150 | 151 | if (dirInfo.Exists) 152 | { 153 | RemoteInfo.ReadBranches(dirInfo, ret); 154 | return (ret, dirInfo.LastWriteTimeUtc); 155 | } 156 | 157 | return (ret, DateTime.UtcNow); 158 | } 159 | 160 | private (List, DateTime) GetRemotes() 161 | { 162 | var ret = new List(); 163 | var dirInfo = new DirectoryInfo(_ref_remotes); 164 | 165 | if (dirInfo.Exists) 166 | { 167 | foreach (DirectoryInfo dir in dirInfo.EnumerateDirectories()) 168 | { 169 | ret.Add(new RemoteInfo(dir.Name, dir.FullName)); 170 | } 171 | 172 | return (ret, dirInfo.LastWriteTimeUtc); 173 | } 174 | 175 | return (ret, DateTime.UtcNow); 176 | } 177 | } 178 | 179 | internal class RemoteInfo 180 | { 181 | private static readonly bool s_isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); 182 | private static readonly EnumerationOptions s_enumOption = new() { RecurseSubdirectories = true, IgnoreInaccessible = true, }; 183 | 184 | private readonly object _syncObj; 185 | private readonly string _path; 186 | 187 | private bool _checkForUpdate; 188 | private List? _branches; 189 | private DateTime? _lastWrittenTimeUtc; 190 | 191 | internal readonly string Name; 192 | 193 | internal RemoteInfo(string name, string path) 194 | { 195 | _syncObj = new(); 196 | _path = path; 197 | 198 | Name = name; 199 | } 200 | 201 | internal List? Branches 202 | { 203 | get 204 | { 205 | Refresh(); 206 | return _branches; 207 | } 208 | } 209 | 210 | internal void NeedCheckForUpdate() 211 | { 212 | _checkForUpdate = true; 213 | } 214 | 215 | internal static void ReadBranches(DirectoryInfo dirInfo, List branches) 216 | { 217 | foreach (FileInfo file in dirInfo.EnumerateFiles("*", s_enumOption)) 218 | { 219 | string name = Path.GetRelativePath(dirInfo.FullName, file.FullName); 220 | if (name == "HEAD") 221 | { 222 | using var reader = file.OpenText(); 223 | string? content = reader.ReadLine(); 224 | if (string.IsNullOrEmpty(content)) 225 | { 226 | continue; 227 | } 228 | 229 | name = content.Substring("ref: refs/remotes/".Length + dirInfo.Name.Length + 1); 230 | } 231 | 232 | branches.Add(s_isWindows ? name.Replace('\\', '/') : name); 233 | } 234 | } 235 | 236 | private void Refresh() 237 | { 238 | if (ShouldUpdate()) 239 | { 240 | lock(_syncObj) 241 | { 242 | if (ShouldUpdate()) 243 | { 244 | var dirInfo = new DirectoryInfo(_path); 245 | var option = new EnumerationOptions() 246 | { 247 | RecurseSubdirectories = true, 248 | IgnoreInaccessible = true, 249 | }; 250 | 251 | var branches = new List(); 252 | ReadBranches(dirInfo, branches); 253 | 254 | // Reference assignment is an atomic operation. 255 | _branches = branches; 256 | _checkForUpdate = false; 257 | _lastWrittenTimeUtc = dirInfo.LastWriteTimeUtc; 258 | } 259 | } 260 | } 261 | } 262 | 263 | private bool ShouldUpdate() 264 | { 265 | if (_lastWrittenTimeUtc is null) 266 | { 267 | return true; 268 | } 269 | 270 | return _checkForUpdate && Directory.GetLastWriteTimeUtc(_path) > _lastWrittenTimeUtc; 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/Init.cs: -------------------------------------------------------------------------------- 1 | using System.Management.Automation; 2 | using System.Management.Automation.Subsystem; 3 | using System.Management.Automation.Subsystem.Prediction; 4 | 5 | namespace Microsoft.PowerShell.Predictor; 6 | 7 | public class Init : IModuleAssemblyInitializer, IModuleAssemblyCleanup 8 | { 9 | private const string Id = "77bb0bd8-2d8b-4210-ad14-79fb91a75eab"; 10 | 11 | public void OnImport() 12 | { 13 | var predictor = new CompletionPredictor(Id); 14 | SubsystemManager.RegisterSubsystem(predictor); 15 | } 16 | 17 | public void OnRemove(PSModuleInfo psModuleInfo) 18 | { 19 | SubsystemManager.UnregisterSubsystem(new Guid(Id)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/PowerShell.Predictor.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | false 10 | 11 | 12 | ..\bin\CompletionPredictor 13 | 14 | 15 | 16 | 17 | false 18 | None 19 | 20 | 21 | 22 | 23 | contentFiles 24 | All 25 | 26 | 27 | PreserveNewest 28 | PreserveNewest 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /tools/helper.psm1: -------------------------------------------------------------------------------- 1 | $MinimalSDKVersion = '6.0.201' 2 | $IsWindowsEnv = [System.Environment]::OSVersion.Platform -eq "Win32NT" 3 | $LocalDotnetDirPath = if ($IsWindowsEnv) { "$env:LocalAppData\Microsoft\dotnet" } else { "$env:HOME/.dotnet" } 4 | 5 | <# 6 | .SYNOPSIS 7 | Find the dotnet SDK that meets the minimal version requirement. 8 | #> 9 | function Find-Dotnet 10 | { 11 | $dotnetFile = if ($IsWindowsEnv) { "dotnet.exe" } else { "dotnet" } 12 | $dotnetExePath = Join-Path -Path $LocalDotnetDirPath -ChildPath $dotnetFile 13 | 14 | # If dotnet is already in the PATH, check to see if that version of dotnet can find the required SDK. 15 | # This is "typically" the globally installed dotnet. 16 | $foundDotnetWithRightVersion = $false 17 | $dotnetInPath = Get-Command 'dotnet' -ErrorAction Ignore 18 | if ($dotnetInPath) { 19 | $foundDotnetWithRightVersion = Test-DotnetSDK $dotnetInPath.Source 20 | } 21 | 22 | if (-not $foundDotnetWithRightVersion) { 23 | if (Test-DotnetSDK $dotnetExePath) { 24 | Write-Warning "Can't find the dotnet SDK version $MinimalSDKVersion or higher, prepending '$LocalDotnetDirPath' to PATH." 25 | $env:PATH = $LocalDotnetDirPath + [IO.Path]::PathSeparator + $env:PATH 26 | } 27 | else { 28 | throw "Cannot find the dotnet SDK with the version $MinimalSDKVersion or higher. Please specify '-Bootstrap' to install build dependencies." 29 | } 30 | } 31 | } 32 | 33 | <# 34 | .SYNOPSIS 35 | Check if the dotnet SDK meets the minimal version requirement. 36 | #> 37 | function Test-DotnetSDK 38 | { 39 | param($dotnetExePath) 40 | 41 | if (Test-Path $dotnetExePath) { 42 | $installedVersion = & $dotnetExePath --version 43 | return $installedVersion -ge $MinimalSDKVersion 44 | } 45 | return $false 46 | } 47 | 48 | <# 49 | .SYNOPSIS 50 | Install the dotnet SDK if we cannot find an existing one. 51 | #> 52 | function Install-Dotnet 53 | { 54 | [CmdletBinding()] 55 | param( 56 | [string]$Channel = 'release', 57 | [string]$Version = $MinimalSDKVersion 58 | ) 59 | 60 | try { 61 | Find-Dotnet 62 | return # Simply return if we find dotnet SDk with the correct version 63 | } catch { } 64 | 65 | $logMsg = if (Get-Command 'dotnet' -ErrorAction Ignore) { 66 | "dotnet SDK out of date. Require '$MinimalSDKVersion' but found '$dotnetSDKVersion'. Updating dotnet." 67 | } else { 68 | "dotent SDK is not present. Installing dotnet SDK." 69 | } 70 | Write-Log $logMsg -Warning 71 | 72 | $obtainUrl = "https://raw.githubusercontent.com/dotnet/cli/master/scripts/obtain" 73 | 74 | try { 75 | Remove-Item $LocalDotnetDirPath -Recurse -Force -ErrorAction Ignore 76 | $installScript = if ($IsWindowsEnv) { "dotnet-install.ps1" } else { "dotnet-install.sh" } 77 | Invoke-WebRequest -Uri $obtainUrl/$installScript -OutFile $installScript 78 | 79 | if ($IsWindowsEnv) { 80 | & .\$installScript -Channel $Channel -Version $Version 81 | } else { 82 | bash ./$installScript -c $Channel -v $Version 83 | } 84 | } 85 | finally { 86 | Remove-Item $installScript -Force -ErrorAction Ignore 87 | } 88 | } 89 | 90 | <# 91 | .SYNOPSIS 92 | Write log message for the build. 93 | #> 94 | function Write-Log 95 | { 96 | param( 97 | [string] $Message, 98 | [switch] $Warning, 99 | [switch] $Indent 100 | ) 101 | 102 | $foregroundColor = if ($Warning) { "Yellow" } else { "Green" } 103 | $indentPrefix = if ($Indent) { " " } else { "" } 104 | Write-Host -ForegroundColor $foregroundColor "${indentPrefix}${Message}" 105 | } 106 | -------------------------------------------------------------------------------- /tools/images/CompletionPredictor.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PowerShell/CompletionPredictor/b6115bcd3a7853e73d7768c4d49eb7f4dd2ed792/tools/images/CompletionPredictor.gif --------------------------------------------------------------------------------