Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,17 @@ func detectCompletionsState() initStepState {
return state
}
shellType := common.DetectShellType()
rc := common.DetectShellRC()
if shellType == "" {
state.status = "unsupported shell — skipping"
state.configured = true
return state
}
if pkgPath := common.PackageInstalledCompletionPath(shellType); pkgPath != "" {
state.configured = true
state.status = "already installed at " + util.DisplayPath(pkgPath)
return state
}
rc := common.DetectShellRC()
mentioned, err := common.ShellRCMentionsGhostCompletion(rc)
if err != nil {
state.status = fmt.Sprintf("could not read %s", rc)
Expand Down Expand Up @@ -352,6 +357,10 @@ func runInitCompletions(cmd *cobra.Command) (bool, error) {
cmd.PrintErrln("Could not detect your shell from $SHELL; skipping completions.")
return false, nil
}
if pkgPath := common.PackageInstalledCompletionPath(shellType); pkgPath != "" {
cmd.PrintErrf("Completions already installed at %s.\n", pkgPath)
return false, nil
}
rc := common.DetectShellRC()
mentioned, err := common.ShellRCMentionsGhostCompletion(rc)
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions internal/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ func TestRunSelectedInitSteps_ConfiguresPathBeforeCompletions(t *testing.T) {
t.Setenv("PATH", filepath.Join(home, "not-in-path"))
t.Setenv("ZDOTDIR", "")
t.Setenv("XDG_CONFIG_HOME", "")
// Point HOMEBREW_PREFIX at an empty dir so PackageInstalledCompletionPath
// doesn't pick up a real brew-installed completion on the dev machine
// and skip writing the snippet.
t.Setenv("HOMEBREW_PREFIX", t.TempDir())

executablePath, err := getGhostExecutablePath()
if err != nil {
Expand Down
54 changes: 54 additions & 0 deletions internal/common/shell_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,60 @@ func DetectShellRC() string {
return filepath.Join(home, ".bashrc")
}

// PackageInstalledCompletionPath returns the path of a Ghost shell
// completion file installed by a package manager (Homebrew on macOS,
// deb/rpm on Linux) for the given shell. Returns "" if shell is empty,
// unsupported, or no such file exists. When found, the caller should treat
// completions as already configured and skip writing to the user's rc file.
//
// Homebrew's prefix is taken from $HOMEBREW_PREFIX when set; otherwise the
// well-known prefixes (/opt/homebrew, /usr/local, /home/linuxbrew/.linuxbrew)
// are probed. The deb/rpm system paths under /usr/share are checked
// unconditionally.
func PackageInstalledCompletionPath(shell string) string {
for _, path := range packageCompletionCandidates(shell) {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}

func packageCompletionCandidates(shell string) []string {
prefixes := homebrewPrefixCandidates()
switch shell {
case "bash":
paths := make([]string, 0, len(prefixes)+1)
for _, p := range prefixes {
paths = append(paths, filepath.Join(p, "etc", "bash_completion.d", "ghost"))
}
return append(paths, "/usr/share/bash-completion/completions/ghost")
case "zsh":
paths := make([]string, 0, len(prefixes)+2)
for _, p := range prefixes {
paths = append(paths, filepath.Join(p, "share", "zsh", "site-functions", "_ghost"))
}
return append(paths,
"/usr/share/zsh/vendor-completions/_ghost",
"/usr/share/zsh/site-functions/_ghost",
)
case "fish":
paths := make([]string, 0, len(prefixes)+1)
for _, p := range prefixes {
paths = append(paths, filepath.Join(p, "share", "fish", "vendor_completions.d", "ghost.fish"))
}
return append(paths, "/usr/share/fish/vendor_completions.d/ghost.fish")
}
return nil
}

func homebrewPrefixCandidates() []string {
if prefix := os.Getenv("HOMEBREW_PREFIX"); prefix != "" {
return []string{prefix}
}
return []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"}
}

// IsInPath reports whether dir is an element of $PATH.
func IsInPath(dir string) bool {
if dir == "" {
Expand Down
50 changes: 50 additions & 0 deletions internal/common/shell_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,56 @@ func TestDetectShellRC(t *testing.T) {
}
}

func TestPackageInstalledCompletionPath(t *testing.T) {
tests := []struct {
name string
shell string
// relPath is created (with an empty file) under a fresh
// HOMEBREW_PREFIX temp dir before invoking the function. Empty
// means "no file created".
relPath string
wantFile bool
}{
{name: "bash with no file", shell: "bash"},
{name: "bash homebrew", shell: "bash", relPath: "etc/bash_completion.d/ghost", wantFile: true},
{name: "zsh homebrew", shell: "zsh", relPath: "share/zsh/site-functions/_ghost", wantFile: true},
{name: "fish homebrew", shell: "fish", relPath: "share/fish/vendor_completions.d/ghost.fish", wantFile: true},
{name: "unsupported shell", shell: "ksh"},
{name: "empty shell", shell: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prefix := t.TempDir()
t.Setenv("HOMEBREW_PREFIX", prefix)

var want string
if tt.relPath != "" {
want = filepath.Join(prefix, tt.relPath)
if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(want, nil, 0o644); err != nil {
t.Fatal(err)
}
}

got := PackageInstalledCompletionPath(tt.shell)
if tt.wantFile {
if got != want {
t.Errorf("got %q, want %q", got, want)
}
return
}
// Without a fake file we expect "" — but a real package install
// on the dev machine (e.g. /usr/share/...) could match. Treat
// that as a skip rather than a failure.
if got != "" {
t.Skipf("real package completion present at %q; can't validate empty case", got)
}
})
}
}

func TestIsInPath(t *testing.T) {
t.Setenv("PATH", "/foo:/bar:/baz")
if !IsInPath("/bar") {
Expand Down