├── .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 | 
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
--------------------------------------------------------------------------------