Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add powershell completer (#8939) #10424

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
243 changes: 243 additions & 0 deletions completions/bun.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
<#
.SYNOPSIS
This is a PowerShell script that provides tab completion for the Bun CLI.

.DESCRIPTION
This script will be installed into a users ~/.bun directory as bun.completion.ps1 by `bun completions`.
Users can source it in their PowerShell profile e.g. `. ~/.bun/bun.completion.ps1` to enable tab completion for the Bun CLI.

.NOTES
Subcommands are defined in this script and where possible use `bun getcompletes` to provide dynamic auto-complete.
Subcommand argument completion uses `--help` on subcommands as required, so it's not as full featured as the bash completion script but it will stay in sync with args as bun is updated.
To provide more advanced auto-complete requires re-implementation of the bun arguments parsing in PowerShell, which is not feasible.
Ideally the `bun getcompletes` command could be extended to provide more completions then the shell completers can rely on it.
#>

# Pattern used to extract flags from `bun --help` output
$script:BunHelpFlagPattern = "^\s+(?<Alias>-[\w]+)?,?\s+(?<LongName>--[-\w]+)?\s+(?<Description>.+?)$"
# Global arguments are cached in memory after the first load
$script:BunGlobalArguments = $null
# Subcommands are manually defined because `bun getcompletes` doesn't provide info on them
$script:BunSubCommands = @(
@{
Name = "run"
Description = "Execute a file with Bun or run a package.json script"
Completers = @(
{
# Get scripts runnable from package json via `bun getcompletes z`
param (
[string] $WordToComplete
)
$env:MAX_DESCRIPTION_LEN = 250
return & bun getcompletes z | Where-Object { $_ -like "$WordToComplete*" } | Foreach-Object {
$script = $_.Split("`t")
[System.Management.Automation.CompletionResult]::new($script[0], $script[0], 'ParameterValue', $script[1])
}
},
{
# Get bins runnable via `bun getcompletes b`
param (
[string] $WordToComplete
)
return & bun getcompletes b | Where-Object { $_ -like "$WordToComplete*" } | Foreach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
},
{
# Get javascript files runnable via `bun getcompletes j`
param (
[string] $WordToComplete
)
return & bun getcompletes j | Where-Object { $_ -like "$WordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
}
)
},
@{
Name = "test"
Description = "Run unit tests with Bun"
},
@{
Name = "x"
Description = "Execute a package binary (CLI), installing if needed (bunx)"
},
@{
Name = "repl"
Description = "Start a REPL session with Bun"
},
@{
Name = "exec"
Description = "Run a shell script directly with Bun"
},
@{
Name = "install"
Alias = "i"
Description = "Install dependencies for a package.json (bun i)"
},
@{
Name = "add"
Alias = "a"
Description = "Add a dependency to package.json (bun a)"
Completers = @(
{
# Get frequently installed packages via `bun getcompletes a`
param (
[string] $WordToComplete
)
Write-Debug "Completing package names for $WordToComplete"
return & bun getcompletes a "$WordToComplete" | Foreach-Object {
Write-Debug "Completing package $_"
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
}
)
},
@{
Name = "remove"
Alias = "rm"
Description = "Remove a dependency from package.json (bun rm)"
Completers = @(
{
# Remove dependencies from package.json, this is not available in getcompletes
param (
[string] $WordToComplete
)
if (Test-Path "package.json") {
$packageJson = Get-Content "package.json" -Raw | ConvertFrom-Json
$packageJson.dependencies.PSObject.Properties.Name | Where-Object { $_ -like "$WordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
}
}
)
},
@{
Name = "update"
Description = "Update outdated dependencies"
},
@{
Name = "link"
Description = "Register or link a local npm package"
},
@{
Name = "unlink"
Description = "Unregister a local npm package"
},
@{
Name = "pm"
Description = "Additional package management utilities"
},
@{
Name = "build"
Description = "Bundle TypeScript & JavaScript into a single file"
},
@{
Name = "init"
Description = "Start an empty Bun project from a blank template"
},
@{
Name = "create"
Alias = "c"
Description = "Create a new project from a template (bun c)"
},
@{
Name = "upgrade"
Description = "Upgrade to latest version of Bun."
},
@{
Name = "discord"
Description = "Join the Bun Discord server"
}
)

function Get-BunSubCommandCompletions {
param (
[string] $SubCommandName,
[System.Management.Automation.Language.CommandAst] $CommandAst,
[string] $WordToComplete
)

$subCommandCompletions = @()

$subCommand = $script:BunSubCommands | Where-Object { $_.Name -eq $SubCommandName -or $_.Alias -eq $SubCommandName }

if ($CommandAst.CommandElements.Count -eq 1) {
# Get the subcommand name completions
$script:BunSubCommands | ForEach-Object {
$subCommandCompletions += [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $_.Description)
}
} elseif ($CommandAst.CommandElements.Count -eq 2 -and -not [string]::IsNullOrWhiteSpace($WordToComplete)) {
# Get the subcommand name completions with a partially complete subcommand name
$script:BunSubCommands | Where-Object { $_.Name -like "$WordToComplete*" } | ForEach-Object {
$subCommandCompletions += [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $_.Description)
}
} elseif ($subCommand -and ($CommandAst.CommandElements.Count -gt 2 -or [string]::IsNullOrWhiteSpace($WordToComplete))) {
# Invoke all dynamic completers for the subcommand
if ($subCommand.Completers) {
$subCommandCompletions += $subCommand.Completers | ForEach-Object {
$_.Invoke($WordToComplete)
}
}

# Get all arguments exposed in help with regex capture https://regex101.com/r/lTzfLB/1
& bun $SubCommandName --help *>&1 | Select-String $script:BunHelpFlagPattern | ForEach-Object {

$alias = $_.Matches.Groups | Where-Object { $_.Name -eq 'Alias' } | Select-Object -ExpandProperty Value
$name = $_.Matches.Groups | Where-Object { $_.Name -eq 'LongName' } | Select-Object -ExpandProperty Value
$description = $_.Matches.Groups | Where-Object { $_.Name -eq 'Description' } | Select-Object -ExpandProperty Value

if ($name -like "$WordToComplete*" -or $alias -like "$WordToComplete*") {
$completionName = if (-not [string]::IsNullOrWhiteSpace($name)) { $name } else { $alias }
$subCommandCompletions += [System.Management.Automation.CompletionResult]::new($completionName, $completionName, 'ParameterValue', $description)
}
}
}

return $subCommandCompletions
}

function Get-BunGlobalArgumentCompletions {
param (
[string] $WordToComplete
)

# These don't change often, keep them in memory after the first load
if ($null -eq $script:BunGlobalArguments) {
$script:BunGlobalArguments = @()
& bun --help *>&1 | Select-String $script:BunHelpFlagPattern | ForEach-Object {

$alias = $_.Matches.Groups | Where-Object { $_.Name -eq 'Alias' } | Select-Object -ExpandProperty Value
$name = $_.Matches.Groups | Where-Object { $_.Name -eq 'LongName' } | Select-Object -ExpandProperty Value
$description = $_.Matches.Groups | Where-Object { $_.Name -eq 'Description' } | Select-Object -ExpandProperty Value

if (-not [string]::IsNullOrWhitespace($alias) -or -not [string]::IsNullOrWhiteSpace($name)) {
$script:BunGlobalArguments += @{
Name = $name
Alias = $alias
Description = $description
}
}
}
}

return $script:BunGlobalArguments | Where-Object { $_.Name -like "$WordToComplete*" -or $_.Alias -like "$WordToComplete*" } | ForEach-Object {
$completionName = if (-not [string]::IsNullOrWhiteSpace($_.Name)) { $_.Name } else { $_.Alias }
[System.Management.Automation.CompletionResult]::new($completionName, $completionName, 'ParameterValue', $_.Description)
}
}

Register-ArgumentCompleter -Native -CommandName "bun" -ScriptBlock {
param(
[string] $WordToComplete,
[System.Management.Automation.Language.CommandAst] $CommandAst,
[int] $CursorPosition
)

$subCommandName = if ($CommandAst.CommandElements.Count -ge 2) { $CommandAst.CommandElements[1].Extent.Text.Trim() } else { $null }

$completions = @()
$completions += Get-BunSubCommandCompletions -SubCommandName $subCommandName -CommandAst $CommandAst -WordToComplete $WordToComplete
$completions += Get-BunGlobalArgumentCompletions -WordToComplete $WordToComplete
return $completions | Select-Object * -Unique | Foreach-Object { [System.Management.Automation.CompletionResult]::new($_.CompletionText, $_.ListItemText, $_.ResultType, $_.ToolTip) }
}
1 change: 1 addition & 0 deletions root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub const completions = struct {
pub const bash = @embedFile("./completions/bun.bash");
pub const zsh = @embedFile("./completions/bun.zsh");
pub const fish = @embedFile("./completions/bun.fish");
pub const pwsh = @embedFile("./completions/bun.ps1");
};

pub const JavaScriptCore = @import("./src/jsc.zig");
Expand Down
35 changes: 22 additions & 13 deletions src/cli/install.ps1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env pwsh
param(
# TODO: change this to 'latest' when Bun for Windows is stable.
# Version to install, 'latest' stable version or 'canary' for bleeding edge.
[String]$Version = "latest",
# Forces installing the baseline build regardless of what CPU you are actually using.
[Switch]$ForceBaseline = $false,
Expand Down Expand Up @@ -229,24 +229,33 @@ function Install-Bun {

try {
$env:IS_BUN_AUTO_UPDATE = "1"
# TODO: When powershell completions are added, make this switch actually do something
if ($NoCompletions) {
$env:BUN_NO_INSTALL_COMPLETIONS = "1"
}
# This completions script in general will install some extra stuff, mainly the `bunx` link.
# It also installs completions.
$output = "$(& "${BunBin}\bun.exe" completions 2>&1)"
if ($LASTEXITCODE -ne 0) {
Write-Output $output
Write-Output "Install Failed - could not finalize installation"
Write-Output "The command '${BunBin}\bun.exe completions' exited with code ${LASTEXITCODE}`n"
return 1
Write-Output "Skipping installing completions because NoCompletions was specified."
} else {
# This completions script in general will install some extra stuff, mainly the `bunx` link.
# It also installs completions.
$output = "$(& "${BunBin}\bun.exe" completions 2>&1)"
# Completions are installed in the user's ~/.bun dir by completions and need to be added to the users $PROFILE because powershell does not have a "completions" directory.
# https://github.com/PowerShell/PowerShell/issues/17582
if (Get-Content $PROFILE.CurrentUserCurrentHost -Raw -ErrorAction SilentlyContinue | Select-String -SimpleMatch "bun.completion.ps1" -Quiet) {
Write-Output "Skipping adding completions to your profile, as they are already there."
} else {
# Don't throw errors loading a user's profile if the completions can't load and keep it one line for easy removal.
# This starts with a newline in case the current users profile doesn't end with a newline after the last statement in it.
"`ntry { . ~/.bun/bun.completion.ps1 } catch { Write-Warning 'Failed to load bun autocompletion' }" | Add-Content -Path $PROFILE.CurrentUserCurrentHost -Force
}

if ($LASTEXITCODE -ne 0) {
Write-Output $output
Write-Output "Install Failed - could not finalize installation"
Write-Output "The command '${BunBin}\bun.exe completions' exited with code ${LASTEXITCODE}`n"
return 1
}
}
} catch {
# it is possible on powershell 5 that an error happens, but it is probably fine?
}
$env:IS_BUN_AUTO_UPDATE = $null
$env:BUN_NO_INSTALL_COMPLETIONS = $null

$DisplayVersion = if ($BunRevision -like "*-canary.*") {
"${BunRevision}"
Expand Down
30 changes: 21 additions & 9 deletions src/cli/install_completions_command.zig
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,10 @@ pub const InstallCompletionsCommand = struct {
var stdout = std.io.getStdOut();

var shell = ShellCompletions.Shell.unknown;
if (bun.getenvZ("SHELL")) |shell_name| {
// Powershell doesn't set SHELL so it need to be checked first because it may have inherited it from a parent shell process
if (bun.getenvZ("PSModulePath") != null) {
shell = ShellCompletions.Shell.pwsh;
} else if (bun.getenvZ("SHELL")) |shell_name| {
shell = ShellCompletions.Shell.fromEnv(@TypeOf(shell_name), shell_name);
}

Expand All @@ -196,16 +199,9 @@ pub const InstallCompletionsCommand = struct {
installUninstallerWindows() catch {};
}

// TODO: https://github.com/oven-sh/bun/issues/8939
if (Environment.isWindows) {
Output.errGeneric("PowerShell completions are not yet written for Bun yet.", .{});
Output.printErrorln("See https://github.com/oven-sh/bun/issues/8939", .{});
return;
}

switch (shell) {
.unknown => {
Output.errGeneric("Unknown or unsupported shell. Please set $SHELL to one of zsh, fish, or bash.", .{});
Output.errGeneric("Unknown or unsupported shell. Please set $SHELL to one of zsh, fish, pwsh, or bash.", .{});
Output.note("To manually output completions, run 'bun getcompletes'", .{});
Global.exit(fail_exit_code);
},
Expand Down Expand Up @@ -413,6 +409,21 @@ pub const InstallCompletionsCommand = struct {
break :found std.fs.openDirAbsolute(dir, .{}) catch continue;
}
},
.pwsh => {
// Powershell doesn't have a completions dir, put the completions in the users .bun folder.
// Dot source it in their profile in install.ps1 because the installer ps1 script has access to the user $PROFILE.
// https://github.com/PowerShell/PowerShell/issues/17582
if (bun.getenvZ(bun.DotEnv.home_env)) |home_dir| {
{
outer: {
var paths = [_]string{ home_dir, "./.bun" };
completions_dir = resolve_path.joinAbsString(cwd, &paths, .auto);
break :found std.fs.openDirAbsolute(completions_dir, .{}) catch
break :outer;
}
}
}
},
else => unreachable,
}

Expand All @@ -439,6 +450,7 @@ pub const InstallCompletionsCommand = struct {
.fish => "bun.fish",
.zsh => "_bun",
.bash => "bun.completion.bash",
.pwsh => "bun.completion.ps1",
else => unreachable,
};

Expand Down
3 changes: 0 additions & 3 deletions src/cli/run_command.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1094,9 +1094,6 @@ pub const RunCommand = struct {
bun.copy(u8, path_buf[dir_slice.len..], base);
path_buf[dir_slice.len + base.len] = 0;
const slice = path_buf[0 .. dir_slice.len + base.len :0];
if (Environment.isWindows) {
@panic("TODO");
}
if (!(bun.sys.isExecutableFilePath(slice))) continue;
// we need to dupe because the string pay point to a pointer that only exists in the current scope
_ = try results.getOrPut(this_bundler.fs.filename_store.append(@TypeOf(base), base) catch continue);
Expand Down
2 changes: 2 additions & 0 deletions src/cli/shell_completions.zig
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ pub const Shell = enum {
const bash_completions = @import("root").completions.bash;
const zsh_completions = @import("root").completions.zsh;
const fish_completions = @import("root").completions.fish;
const pwsh_completions = @import("root").completions.pwsh;

pub fn completions(this: Shell) []const u8 {
return switch (this) {
.bash => bun.asByteSlice(bash_completions),
.zsh => bun.asByteSlice(zsh_completions),
.fish => bun.asByteSlice(fish_completions),
.pwsh => bun.asByteSlice(pwsh_completions),
else => "",
};
}
Expand Down