Repository navigation
Expand file tree
/
Copy pathcompletion_shell.go
More file actions
367 lines (356 loc) · 16.6 KB
/
Copy pathcompletion_shell.go
File metadata and controls
367 lines (356 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
package cli
import (
"context"
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// CompletionShell selects a native adapter, not a shell to start for each query.
type CompletionShell string
const (
CompletionBash CompletionShell = "bash" // Bash 4.4+; bash-completion enables on-demand loading.
CompletionFish CompletionShell = "fish" // Fish 3.6+ with command-specific completion autoloading.
CompletionNushell CompletionShell = "nu" // Nushell 0.114+ with command-wide completers.
CompletionPowerShell CompletionShell = "powershell" // Windows PowerShell 5.1+ and PowerShell 7+.
CompletionZsh CompletionShell = "zsh" // Zsh 5.8+ with its native completion system.
)
const completionScriptHeader = "# Generated by cli4g shell completion protocol v1.\n"
// CompletionScript generates a small, schema-independent adapter for Name.
// It does not run the application at shell startup. Completion requests consult
// the current executable using the captured CompletionRequestCommandName.
// A binary update needs no adapter regeneration while protocol v1 is supported.
// Configure that entry directly or use WithCompletion(true). An empty request
// name returns an error rather than producing an unusable adapter.
//
// Bash users can source the script; bash-completion can load an installed script
// on demand. Nushell users can source it or use its exported extern definition;
// InstallCompletion writes an autoload script. The Nu adapter declares only a
// free-form external signature, so changing flags/commands never leaves a stale
// signature. It preserves native execution and does not replace the user's global
// external completer. Its parameter accepts both legacy span lists and newer
// named place records. It quotes literal candidates and preserves native Nu path
// quoting; Nu controls whitespace insertion. Nu 0.114.1 supplies only the command
// prefix before the cursor: dependency flags must precede the value being completed.
// Later arguments remain in the editor but are unavailable to the provider. The
// adapter does not read commandline/get-cursor, whose REPL state can be stale
// during completion, or change global keybindings to capture a full buffer. This
// shell limitation does not restrict Complete callers supplying all input words.
//
// Bash honors NoSpace and KeepOrder.
// Bash explicitly quotes provider values and native paths as literal arguments;
// Readline does not reinterpret suggestions as filenames or shell expansions.
// Simple unfinished single/double quotes and Readline word breaks are retained.
// Completion inside an ANSI-C quoted word is suppressed rather than treating its
// backslash escapes as simple single-quote syntax.
//
// PowerShell registers a native argument completer, including the .exe spelling.
// Dot-source the script or use InstallCompletion for persistent profile loading.
// Literal AST strings and escapes are decoded without evaluating variables,
// expressions or substitutions. Arguments after the cursor remain available to
// context-aware providers. Candidates are quoted as literal native arguments and
// carry descriptions; path completion uses PowerShell's own filename completer
// (filesystem enumeration for literal Unix colon paths).
// The reserved literal --% uses a fixed singleton-array expression so native
// binding does not drop it or change later arguments. That exact generated AST
// form is decoded as literal data in subsequent requests, without evaluation.
// Returned order is retained; the user's PSReadLine completion binding controls
// space insertion, so NoSpace does not change that binding. Scripts use UTF-8 with
// a BOM for Windows PowerShell 5.1 and an ASCII-only request/response transport.
//
// Zsh uses compinit/fpath autoloading or can source the adapter after compinit.
// Its native tokenizer supplies words and cursor prefixes; simple quoting is
// removed without evaluating expressions. All argument words, including those
// after the cursor, are preserved. compadd quotes candidates, shows descriptions,
// and honors NoSpace/KeepOrder; _files provides native path completion. No global
// widgets, styles, aliases or external completers are replaced.
//
// Fish autoloads Name.fish when the command is first completed, or users can
// source the adapter. It replaces only Name's completion definitions. Each query
// sends the complete current process and its cursor prefix; simple Fish quoting
// and escapes are decoded without evaluating substitutions, variables or globs.
// Empty/multiline arguments and tokens after the cursor are retained. Fish quotes
// literal candidates, displays descriptions, preserves the engine's sorted or
// KeepOrder result, and completes paths natively. Fish controls space insertion:
// punctuation such as "=" or "/" suppresses a space, but its public completion API
// cannot force NoSpace for arbitrary values. Its native suffix matching may reject
// suggestions that conflict with text after the cursor inside the same token.
// Candidate tabs cannot be represented by Fish's value/description interface and
// are omitted; multiline values use NUL-framed records, and descriptions are
// rendered on one line. Other shells' transport formats are independent.
//
// Bash recognizes simple quotes, escapes, and separators but deliberately does
// not evaluate shell substitutions or expansions while collecting dependency
// values. Applications requiring already-expanded values may call Complete with
// their own tokenized CompletionRequest. Names containing NUL/newlines are rejected.
func (this *Application[O]) CompletionScript(shell CompletionShell) (string, error) {
if this == nil || this.Name() == "" || strings.ContainsAny(this.Name(), "\x00\r\n") {
return "", fmt.Errorf("invalid completion command name")
}
if this.CompletionRequestCommandName() == "" {
return "", fmt.Errorf("completion request entry is not configured")
}
hash := sha256.Sum256([]byte(this.Name()))
function := fmt.Sprintf("cli4g_%x", hash[:8])
var template string
switch shell {
case CompletionBash:
template = bashCompletionScript
case CompletionFish:
template = fishCompletionScript
case CompletionNushell:
template = nushellCompletionScript
case CompletionPowerShell:
template = powershellCompletionScript
case CompletionZsh:
template = zshCompletionScript
default:
return "", NewValidationError("supported completion shells are bash, fish, nu, powershell and zsh")
}
header := completionScriptHeader
if shell == CompletionPowerShell {
header = "\ufeff" + header
} else if shell == CompletionZsh {
header = "#compdef " + this.Name() + "\n" + header
}
return header + strings.NewReplacer(
"@FUNCTION@", function,
"@BASHCOMMAND@", "'"+strings.ReplaceAll(this.Name(), "'", "'\\''")+"'",
"@NUCOMMAND@", strconv.Quote(this.Name()),
"@BASHREQUEST@", "'"+strings.ReplaceAll(this.CompletionRequestCommandName(), "'", "'\\''")+"'",
"@NUREQUEST@", strconv.Quote(this.CompletionRequestCommandName()),
"@PSCOMMAND@", quotePowerShell(this.Name()),
"@PSEXE@", quotePowerShell(strings.TrimSuffix(this.Name(), ".exe")+".exe"),
"@PSREQUEST@", quotePowerShell(this.CompletionRequestCommandName()),
"@ZSHAUTOLOAD@", "'"+strings.ReplaceAll("_"+this.Name(), "'", "'\\''")+"'",
"@FISHCOMMAND@", quoteFish(this.Name()),
"@FISHREQUEST@", quoteFish(this.CompletionRequestCommandName()),
).Replace(template), nil
}
// InstallCompletion installs an adapter and returns its path. Empty directory
// chooses Bash's first BASH_COMPLETION_USER_DIR (otherwise XDG_DATA_HOME or
// ~/.local/share, then bash-completion/completions), or asks the installed nu for
// its first user-autoload directory. The Nu discovery process runs only during
// installation. Supply directory explicitly for packaging or custom shell homes.
//
// The operation is idempotent, uses an atomic replacement where supported by the
// OS, and refuses to overwrite a file without cli4g's generated header. It creates
// directories with 0755 subject to the umask and sets scripts to 0644.
// It does not modify .bashrc/config.nu or install bash-completion itself. An
// existing bash-completion loader can discover the file at the next completion;
// Nu autoloads it on its next interactive startup. Source the returned file for
// the current session. Files contain no application schema or user input values.
// Call this again to update the adapter implementation, not for ordinary binary
// updates. The ordinary standard install command uses its invocation's context streams.
//
// For PowerShell, an empty directory discovers the CurrentUserAllHosts profiles
// of installed pwsh and powershell hosts (without loading profiles), writes one
// adapter below the first profile's cli4g-completions directory, and appends an
// idempotent, static dot-source block to each discovered profile. Existing profile
// bytes and UTF-8/UTF-16 encoding are preserved; no application executable runs at
// shell startup.
// Both installed Windows PowerShell and PowerShell hosts are configured. New hosts
// need another installation. A nonempty directory writes only the .ps1 adapter,
// leaving profiles untouched for packaging or manual dot-sourcing. Installation
// respects the host's existing execution policy and requires no administrator rights.
//
// Zsh installs _Name under XDG_DATA_HOME/zsh/site-functions (otherwise
// ~/.local/share/zsh/site-functions). The installed zsh discovers ZDOTDIR/HOME by
// reading its normal noninteractive startup files, including .zshenv, only during
// installation. An idempotent block is appended to .zshrc there: it extends fpath,
// runs compinit only if compdef is unavailable, and registers the autoload function.
// Application and adapter code do not execute during shell startup. Existing user
// content and completion styles are preserved. An explicit directory writes only
// the autoload file, for a packager's fpath or manual setup. Source it after
// compinit to activate it in an existing session.
//
// Fish installs Name.fish in the installed fish's user configuration directory
// (__fish_config_dir/completions, normally XDG_CONFIG_HOME/fish/completions or
// ~/.config/fish/completions). Discovery runs with --no-config only during
// installation. Fish loads the file on demand; config.fish and universal variables
// are not modified. An explicit directory needs no installed fish and supports
// packaging/custom fish_complete_path entries. Source the file for an existing
// session whose command completions were already loaded.
func (this *Application[O]) InstallCompletion(ctx context.Context, shell CompletionShell, directory string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return "", err
}
script, err := this.CompletionScript(shell)
if err != nil {
return "", err
}
if filepath.Base(this.Name()) != this.Name() || this.Name() == "." || this.Name() == ".." || strings.ContainsAny(this.Name(), "/\\") {
return "", fmt.Errorf("completion installation requires a base command name")
}
var profiles []string
var zshProfile string
if directory == "" && shell == CompletionPowerShell {
profiles, err = powershellCompletionProfiles(ctx)
if err != nil {
return "", err
}
directory = filepath.Join(filepath.Dir(profiles[0]), "cli4g-completions")
}
if directory == "" && shell == CompletionZsh {
directory, zshProfile, err = zshCompletionInstallation(ctx)
if err != nil {
return "", err
}
}
if directory == "" {
directory, err = completionDirectory(ctx, shell)
if err != nil {
return "", err
}
}
name := this.Name() + ".bash"
if shell == CompletionNushell {
name = "cli4g-" + this.Name() + ".nu"
} else if shell == CompletionFish {
name = this.Name() + ".fish"
} else if shell == CompletionPowerShell {
name = "cli4g-" + this.Name() + ".ps1"
} else if shell == CompletionZsh {
name = "_" + this.Name()
}
path := filepath.Join(directory, name)
if err := writeCompletionFile(ctx, path, script); err != nil {
return "", err
}
for _, profile := range profiles {
if err := installPowerShellProfile(ctx, profile, path, this.Name()); err != nil {
return "", err
}
}
if zshProfile != "" {
if err := installZshProfile(ctx, zshProfile, directory, this.Name()); err != nil {
return "", err
}
}
return path, nil
}
func writeCompletionFile(ctx context.Context, path, script string) error {
previous, err := os.ReadFile(path)
if err == nil {
if string(previous) == script {
return nil
}
managed := strings.TrimPrefix(string(previous), "\ufeff")
if strings.HasPrefix(managed, "#compdef ") {
_, managed, _ = strings.Cut(managed, "\n")
}
if !strings.HasPrefix(managed, completionScriptHeader) {
return fmt.Errorf("completion file already exists and is not managed by cli4g")
}
} else if !os.IsNotExist(err) {
return err
}
return replaceCompletionFile(ctx, path, []byte(script), 0644)
}
func replaceCompletionFile(ctx context.Context, path string, data []byte, mode os.FileMode) error {
directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0755); err != nil {
return err
}
file, err := os.CreateTemp(directory, ".cli4g-completion-*")
if err != nil {
return err
}
defer func() { _ = os.Remove(file.Name()) }()
_, writeErr := file.Write(data)
closeErr := file.Close()
if writeErr != nil {
return writeErr
}
if closeErr != nil {
return closeErr
}
if err := os.Chmod(file.Name(), mode); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
return os.Rename(file.Name(), path)
}
func completionDirectory(ctx context.Context, shell CompletionShell) (string, error) {
if shell == CompletionFish {
output, err := exec.CommandContext(ctx, "fish", "--no-config", "-c", "printf '%s' $__fish_config_dir").Output()
if err != nil {
return "", fmt.Errorf("could not discover Fish completion directory: %w", err)
}
path := strings.TrimSpace(string(output))
if !filepath.IsAbs(path) || strings.ContainsAny(path, "\r\n\x00") {
return "", fmt.Errorf("fish returned no absolute configuration directory")
}
return filepath.Join(path, "completions"), nil
}
if shell == CompletionNushell {
output, err := exec.CommandContext(ctx, "nu", "-n", "-c", "$nu.user-autoload-dirs | first").Output()
if err != nil {
return "", fmt.Errorf("could not discover Nushell autoload directory: %w", err)
}
path := strings.TrimSpace(string(output))
if path == "" {
return "", fmt.Errorf("nushell returned no autoload directory")
}
return path, nil
}
for _, directory := range filepath.SplitList(os.Getenv("BASH_COMPLETION_USER_DIR")) {
if directory != "" {
return filepath.Join(directory, "completions"), nil
}
}
data := os.Getenv("XDG_DATA_HOME")
if data == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
data = filepath.Join(home, ".local", "share")
}
return filepath.Join(data, "bash-completion", "completions"), nil
}
const nushellCompletionScript = `
module @FUNCTION@ {
def suggest [place] {
let words = if (($place | describe) | str starts-with "record") { $place.command } else { $place }
if ($words | length) < 2 { return [] }
let args = ($words | skip 1)
# Nu 0.114.1 truncates command-wide spans at the cursor, so the last
# supplied word is current. Later dependency flags are not available.
# Do not reconstruct them from commandline: its REPL state can be stale.
let current = ($args | last)
let request = {words: $args, word: (($args | length) - 1), offset: ($current | str length --utf-8-bytes), raw: true}
let response = try {
$request | to json --raw | ^($words.0) @NUREQUEST@ 1 nu | complete
} catch { return [] }
if $response.exit_code != 0 { return [] }
let result = try { $response.stdout | from json } catch { return [] }
mut candidates = ($result.candidates? | default [] | each {|candidate|
if $candidate.value =~ '^[-\p{L}\p{N}_./:=+]+$' {
$candidate
} else {
$candidate | upsert display_override $candidate.value | update value ($candidate.value | to nuon --raw)
}
})
if (($result.files? | default false) or ($result.directories? | default false)) {
let prefix = ($result.filePrefix? | default "")
let search = ($result.fileInput? | default "")
let kind = if ($result.directories? | default false) { "directory" } else { "path" }
let paths = ($search | commandline complete --type $kind | each {|value| {value: ($prefix + $value)} })
$candidates = ($candidates | append $paths)
}
$candidates
}
@complete suggest
export extern @NUCOMMAND@ [...args: string]
}
use @FUNCTION@ *
`