diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 6f857655b47..70fbaf019e6 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -1,6 +1,7 @@ const GhosttyResources = @This(); const std = @import("std"); +const builtin = std.builtin; const assert = std.debug.assert; const Config = @import("Config.zig"); const RunStep = std.Build.Step.Run; @@ -183,6 +184,36 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty try steps.append(b.allocator, &install_step.step); } + // PowerShell module + { + const run = b.addRunArtifact(build_data_exe); + run.addArg("+pwsh"); + const wf = b.addWriteFiles(); + _ = wf.addCopyFile(run.captureStdOut(), "ghostty.psm1"); + + // Write the module file. + const module_subdir = b.path("share", "pwsh", "modules"); + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = b.pathJoin(module_subdir, "ghostty"), + }); + try steps.append(b.allocator, &install_step.step); + + // Add the modules path to PSModulePath. + var module_path = cfg.env.get("PSModulePath"); + module_path = if (module_path != null) { + const module_dir = b.pathJoin(b.install_path, module_subdir); + if (builtin.os.tag == .windows) { + module_path + ";" + module_dir; + } else { + module_path + ":" + module_dir; + } + }; + const env_step = RunStep.setEnvironmentVariable(run, "PSModulePath", module_path); + try steps.append(b.allocator, env_step); + } + // Vim and Neovim plugin { const wf = b.addWriteFiles(); diff --git a/src/config/Config.zig b/src/config/Config.zig index 675dbcde3b7..79fa2464834 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2790,7 +2790,7 @@ keybind: Keybinds = .{}, /// /// * `detect` - Detect the shell based on the filename. /// -/// * `bash`, `elvish`, `fish`, `nushell`, `zsh` - Use this specific shell injection scheme. +/// * `bash`, `elvish`, `fish`, `nushell`, `pwsh`, `zsh` - Use this specific shell injection scheme. /// /// The default value is `detect`. @"shell-integration": ShellIntegration = .detect, @@ -8624,6 +8624,7 @@ pub const ShellIntegration = enum { elvish, fish, nushell, + pwsh, zsh, }; diff --git a/src/extra/pwsh.zig b/src/extra/pwsh.zig new file mode 100644 index 00000000000..0fad0f219c3 --- /dev/null +++ b/src/extra/pwsh.zig @@ -0,0 +1,247 @@ +const std = @import("std"); + +const Config = @import("../config/Config.zig"); +const Action = @import("../cli.zig").ghostty.Action; + +/// PowerShell completions that contains all available commands and options. +pub const module = comptimeGeneratePwshCompletions(); + +fn comptimeGeneratePwshCompletions() []const u8 { + comptime { + @setEvalBranchQuota(50000); + var counter: std.Io.Writer.Discarding = .init(&.{}); + try writePwshCompletions(&counter.writer); + + var buf: [counter.count]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try writePwshCompletions(&writer); + const final = buf; + return final[0..writer.end]; + } +} + +fn writePwshCompletions(writer: *std.Io.Writer) !void { + // -- Static header: outer wrapper, Register-ArgumentCompleter, helpers -- + try writer.writeAll( + \\ param($ghostty) + \\ $commandName = (Split-Path -Leaf $ghostty) -replace '\.exe$' + \\ + \\ Register-ArgumentCompleter -Native -CommandName $commandName,"$commandName.exe" -ScriptBlock { + \\ param($wordToComplete, $commandAst, $cursorPosition) + \\ + \\ function Get-Fonts { + \\ & "$ghostty" +list-fonts | Where-Object { $_ -match '^[A-Z]' } + \\ } + \\ + \\ function Get-Themes { + \\ & "$ghostty" +list-themes | ForEach-Object { $_ -replace ' \(.*$' } + \\ } + \\ + \\ $elements = $commandAst.CommandElements + \\ $action = $null + \\ $prev = $null + \\ + \\ for ($i = 1; $i -lt $elements.Count; $i++) { + \\ $t = $elements[$i].ToString() + \\ + \\ if ($t.StartsWith('+') -and -not $action) { $action = $t } + \\ if ($i -lt $elements.Count - 1 -or $wordToComplete -eq '') { + \\ $prev = $t + \\ } + \\ } + \\ + \\ filter Select-Completion { + \\ if ($_ -like "$wordToComplete*") { + \\ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + \\ } + \\ } + \\ + \\ filter Select-ValueCompletion($key) { + \\ $c = "--$key=$_" + \\ if ($c -like "$wordToComplete*") { + \\ [System.Management.Automation.CompletionResult]::new($c, $_, 'ParameterValue', $_) + \\ } + \\ } + \\ + ); + + // -- Section: config --key=value completion (no action context) -- + try writer.writeAll(" if (-not $action -and $wordToComplete -like '--*=*') {\n"); + try writer.writeAll(" $key, $val = $wordToComplete -split '=', 2\n"); + try writer.writeAll(" $key = $key -replace '^--'\n"); + try writer.writeAll(" switch ($key) {\n"); + + for (@typeInfo(Config).@"struct".fields) |field| { + if (field.name[0] == '_') continue; + + if (std.mem.startsWith(u8, field.name, "font-family")) { + try writer.writeAll(" '" ++ field.name ++ "' { Get-Fonts | Select-ValueCompletion $key; return }\n"); + } else if (std.mem.eql(u8, "theme", field.name)) { + try writer.writeAll(" 'theme' { Get-Themes | Select-ValueCompletion $key; return }\n"); + } else if (std.mem.eql(u8, "working-directory", field.name)) { + try writer.writeAll(" 'working-directory' { Get-ChildItem -Directory -Path \"$val*\" | ForEach-Object { $_.FullName } | Select-ValueCompletion $key; return }\n"); + } else if (field.type == Config.RepeatablePath) { + try writer.writeAll(" '" ++ field.name ++ "' { Resolve-Path -Path \"$val*\" -ErrorAction SilentlyContinue | ForEach-Object { $_.Path } | Select-ValueCompletion $key; return }\n"); + } else { + switch (@typeInfo(field.type)) { + .bool => {}, + .@"enum" => |info| { + try writer.writeAll(" '" ++ field.name ++ "' { @("); + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(", "); + try writer.writeAll("'" ++ f.name ++ "'"); + } + try writer.writeAll(") | Select-ValueCompletion $key; return }\n"); + }, + .@"struct" => |info| { + if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") { + try writer.writeAll(" '" ++ field.name ++ "' { @("); + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(", "); + try writer.writeAll("'" ++ f.name ++ "', 'no-" ++ f.name ++ "'"); + } + try writer.writeAll(") | Select-ValueCompletion $key; return }\n"); + } + }, + else => {}, + } + } + } + + try writer.writeAll(" }\n"); + try writer.writeAll(" return\n"); + try writer.writeAll(" }\n"); + + // -- Section: action context -- + try writer.writeAll(" if ($action) {\n"); + + // D1: --opt=value completion for action options + try writer.writeAll(" if ($wordToComplete -like '--*=*') {\n"); + try writer.writeAll(" $key, $val = $wordToComplete -split '=', 2\n"); + try writer.writeAll(" $key = $key -replace '^--'\n"); + try writer.writeAll(" switch ($action) {\n"); + + for (@typeInfo(Action).@"enum".fields) |field| { + const options = @field(Action, field.name).options(); + const opt_fields = @typeInfo(options).@"struct".fields; + if (opt_fields.len == 0) continue; + + // Check if this action has any completable option values + var has_completable = false; + for (opt_fields) |opt| { + if (opt.name[0] == '_') continue; + switch (@typeInfo(opt.type)) { + .@"enum" => { + has_completable = true; + break; + }, + .optional => |optional| { + switch (@typeInfo(optional.child)) { + .@"enum" => { + has_completable = true; + break; + }, + else => {}, + } + }, + else => { + if (std.mem.eql(u8, "config-file", opt.name)) { + has_completable = true; + break; + } + }, + } + } + if (!has_completable) continue; + + try writer.writeAll(" '+" ++ field.name ++ "' {\n"); + try writer.writeAll(" switch ($key) {\n"); + for (opt_fields) |opt| { + if (opt.name[0] == '_') continue; + switch (@typeInfo(opt.type)) { + .@"enum" => |info| { + try writer.writeAll(" '" ++ opt.name ++ "' { @("); + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(", "); + try writer.writeAll("'" ++ f.name ++ "'"); + } + try writer.writeAll(") | Select-ValueCompletion $key; return }\n"); + }, + .optional => |optional| { + switch (@typeInfo(optional.child)) { + .@"enum" => |info| { + try writer.writeAll(" '" ++ opt.name ++ "' { @("); + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(", "); + try writer.writeAll("'" ++ f.name ++ "'"); + } + try writer.writeAll(") | Select-ValueCompletion $key; return }\n"); + }, + else => {}, + } + }, + else => { + if (std.mem.eql(u8, "config-file", opt.name)) { + try writer.writeAll(" 'config-file' { Resolve-Path -Path \"$val*\" -ErrorAction SilentlyContinue | ForEach-Object { $_.Path } | Select-ValueCompletion $key; return }\n"); + } + }, + } + } + try writer.writeAll(" }\n"); + try writer.writeAll(" }\n"); + } + + try writer.writeAll(" }\n"); + try writer.writeAll(" return\n"); + try writer.writeAll(" }\n"); + + // D2: list action-specific option names + try writer.writeAll(" switch ($action) {\n"); + for (@typeInfo(Action).@"enum".fields) |field| { + const options = @field(Action, field.name).options(); + const opt_fields = @typeInfo(options).@"struct".fields; + if (opt_fields.len == 0) continue; + + try writer.writeAll(" '+" ++ field.name ++ "' { @("); + var count: usize = 0; + for (opt_fields) |opt| { + if (opt.name[0] == '_') continue; + if (count > 0) try writer.writeAll(", "); + switch (opt.type) { + bool, ?bool => try writer.writeAll("'--" ++ opt.name ++ "'"), + else => try writer.writeAll("'--" ++ opt.name ++ "='"), + } + count += 1; + } + try writer.writeAll(", '--help') | Select-Completion }\n"); + } + try writer.writeAll(" default { @('--help') | Select-Completion }\n"); + try writer.writeAll(" }\n"); + try writer.writeAll(" return\n"); + try writer.writeAll(" }\n"); + + // -- Section: top-level completions -- + try writer.writeAll(" @('-e', '--help', '--version'"); + + // Actions + for (@typeInfo(Action).@"enum".fields) |field| { + try writer.writeAll(", '+" ++ field.name ++ "'"); + } + + // Config keys + for (@typeInfo(Config).@"struct".fields) |field| { + if (field.name[0] == '_') continue; + switch (field.type) { + bool, ?bool => try writer.writeAll(", '--" ++ field.name ++ "'"), + else => try writer.writeAll(", '--" ++ field.name ++ "='"), + } + } + + try writer.writeAll(") | Select-Completion\n"); + + // -- Static footer -- + try writer.writeAll( + \\ }.GetNewClosure() + \\ + ); +} diff --git a/src/main_build_data.zig b/src/main_build_data.zig index 9dd1da3957d..545f55e82f2 100644 --- a/src/main_build_data.zig +++ b/src/main_build_data.zig @@ -14,6 +14,7 @@ pub const Action = enum { // Shell completions bash, fish, + pwsh, zsh, // Editor syntax files @@ -39,6 +40,7 @@ pub fn main() !void { switch (action) { .bash => try writer.writeAll(@import("extra/bash.zig").completions), .fish => try writer.writeAll(@import("extra/fish.zig").completions), + .pwsh => try writer.writeAll(@import("extra/pwsh.zig").module), .zsh => try writer.writeAll(@import("extra/zsh.zig").completions), .sublime => try writer.writeAll(@import("extra/sublime.zig").syntax), .@"vim-syntax" => try writer.writeAll(@import("extra/vim.zig").syntax), diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 531a0646134..6d96644b43c 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -207,6 +207,7 @@ test { // Extra _ = @import("extra/bash.zig"); _ = @import("extra/fish.zig"); + _ = @import("extra/pwsh.zig"); _ = @import("extra/sublime.zig"); _ = @import("extra/vim.zig"); _ = @import("extra/zsh.zig"); diff --git a/src/shell-integration/pwsh/ghostty.ps1 b/src/shell-integration/pwsh/ghostty.ps1 new file mode 100644 index 00000000000..fad34075ee3 --- /dev/null +++ b/src/shell-integration/pwsh/ghostty.ps1 @@ -0,0 +1,34 @@ +# This script is loaded automatically when shell-integrations are enabled. +# It will load instead of the `$PROFILE` and, unless `-NoProfile` was passed to the process, +# load the `$PROFILE` itself. +# +# To load shell-integrations in other scripts, include the following in your scripts: +# +# if ($env:GHOSTTY_RESOURCES_DIR) { +# source "${env:GHOSTTY_RESOURCES_DIR}/shell-integration/pwsh/ghostty.ps1" +# } + +function Test-Feature([string] $Feature) { + ($env:GHOSTTY_SHELL_FEATURES -split ',') -contains $Feature +} + +function Test-Interactive { + try { + if (-not $Host.UI.RawUI) { + return $false + } + + if (-not [Environment]::UserInteractive) { + return $false + } + + if ([Console]::IsOutputRedirected) { + return $false + } + + $true + } + catch { + $false + } +} diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index af4df3fef8f..46a41efe8e0 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -777,6 +777,7 @@ const Subprocess = struct { .elvish => .elvish, .fish => .fish, .nushell => .nushell, + .pwsh => .pwsh, .zsh => .zsh, }; diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index e5b9eab10f4..85c4caf00f8 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -15,6 +15,7 @@ pub const Shell = enum { elvish, fish, nushell, + pwsh, zsh, }; @@ -65,6 +66,13 @@ pub fn setup( env, ), + .pwsh => try setupPwsh( + alloc_arena, + command, + resource_dir, + env, + ), + .zsh => try setupZsh( alloc_arena, command, @@ -161,6 +169,7 @@ fn detectShell(alloc: Allocator, command: config.Command) !?Shell { if (std.mem.eql(u8, "fish", exe)) return .fish; if (std.mem.eql(u8, "nu", exe)) return .nushell; if (std.mem.eql(u8, "zsh", exe)) return .zsh; + if (std.mem.eql(u8, "pwsh", exe) || std.mem.eql(u8, "powershell", exe)) return .pwsh; return null; } @@ -175,6 +184,8 @@ test detectShell { try testing.expectEqual(.fish, try detectShell(alloc, .{ .shell = "fish" })); try testing.expectEqual(.nushell, try detectShell(alloc, .{ .shell = "nu" })); try testing.expectEqual(.zsh, try detectShell(alloc, .{ .shell = "zsh" })); + try testing.expectEqual(.pwsh, try detectShell(alloc, .{ .shell = "pwsh" })); + try testing.expectEqual(.pwsh, try detectShell(alloc, .{ .shell = "powershell.exe" })); if (comptime builtin.target.os.tag.isDarwin()) { try testing.expect(try detectShell(alloc, .{ .shell = "/bin/bash" }) == null); @@ -885,6 +896,16 @@ test "nushell: missing resources" { try testing.expectEqual(0, env.count()); } +fn setupPwsh( + _: Allocator, + _: config.Command, + _: []const u8, + _: *EnvMap, +) !?config.Command { + // TODO: Don't load profile and instead load shell integrations that load their profile unless -NoProfile was passed. + return undefined; +} + /// Setup the zsh automatic shell integration. This works by setting /// ZDOTDIR to our resources dir so that zsh will load our config. This /// config then loads the true user config.