Skip to content
Merged
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
3 changes: 2 additions & 1 deletion cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
)

func init() {
completionCmd.AddCommand(completionInstallCmd)
completionCmd.AddCommand(completionBashCmd)
completionCmd.AddCommand(completionZshCmd)
completionCmd.AddCommand(completionFishCmd)
Expand All @@ -15,7 +16,7 @@ func init() {
}

var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Use: "completion [install|bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts",
Long: `Generate shell completion scripts for clime.

Expand Down
204 changes: 204 additions & 0 deletions cmd/completion_install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package cmd

import (
"bytes"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/spf13/cobra"
)

const completionProfileMarker = "# clime completion"

var completionInstallCmd = &cobra.Command{
Use: "install [shell]",
Short: "Install TAB completion for your shell",
Long: `Install TAB completion with one command.

This command writes completion scripts generated by Cobra and adds the
required shell profile hook so completion works when you press TAB.`,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return []string{"bash", "zsh", "fish", "powershell"}, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
var (
shell string
err error
)

if len(args) > 0 {
shell, err = detectShellFromEnv(args[0], false)
} else {
shell, err = detectShellFromEnv(os.Getenv("SHELL"), runtime.GOOS == "windows")
}
if err != nil {
return err
}

return installCompletion(shell)
},
}

func installCompletion(shell string) error {
script, err := generateCompletionScript(shell)
if err != nil {
return err
}

home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve home directory: %w", err)
}

if shell == "fish" {
path := filepath.Join(home, ".config", "fish", "completions", "clime.fish")
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("prepare fish completions directory: %w", err)
}
if err := os.WriteFile(path, []byte(script), 0644); err != nil {
return fmt.Errorf("write fish completion file: %w", err)
}
terminal.Successf("Installed fish completion: %s", path)
return nil
}

if shell == "powershell" {
return fmt.Errorf("automatic install for powershell is not supported yet; use: clime completion powershell >> $PROFILE")
}

scriptPath := filepath.Join(home, ".clime", "completions", "clime."+shell)
if err := os.MkdirAll(filepath.Dir(scriptPath), 0755); err != nil {
return fmt.Errorf("prepare completion directory: %w", err)
}
if err := os.WriteFile(scriptPath, []byte(script), 0644); err != nil {
return fmt.Errorf("write completion file: %w", err)
}

rcPath, err := shellProfilePath(shell, home)
if err != nil {
return err
}
line := completionSourceLine(scriptPath)
changed, err := ensureLineInFile(rcPath, completionProfileMarker, line)
if err != nil {
return err
}

if changed {
terminal.Successf("Installed %s completion and updated profile: %s", shell, rcPath)
terminal.Info("Open a new shell or source your profile to enable completion.")
return nil
}
terminal.Successf("%s completion is already configured.", shell)
return nil
}

func generateCompletionScript(shell string) (string, error) {
var buf bytes.Buffer
switch shell {
case "bash":
if err := rootCmd.GenBashCompletionV2(&buf, true); err != nil {
return "", err
}
case "zsh":
if err := rootCmd.GenZshCompletion(&buf); err != nil {
return "", err
}
case "fish":
if err := rootCmd.GenFishCompletion(&buf, true); err != nil {
return "", err
}
case "powershell":
if err := rootCmd.GenPowerShellCompletionWithDesc(&buf); err != nil {
return "", err
}
default:
return "", fmt.Errorf("unsupported shell %q", shell)
}
return buf.String(), nil
}

func detectShellFromEnv(shellValue string, isWindows bool) (string, error) {
if isWindows {
return "powershell", nil
}

shell := normalizeShell(shellValue)
if shell == "" {
return "", fmt.Errorf("could not detect shell; run `clime completion install <bash|zsh|fish|powershell>`")
}
switch shell {
case "bash", "zsh", "fish", "powershell":
return shell, nil
default:
return "", fmt.Errorf("unsupported shell %q", shell)
}
}

func normalizeShell(shellValue string) string {
s := strings.ToLower(strings.TrimSpace(filepath.Base(shellValue)))
switch s {
case "bash":
return "bash"
case "zsh":
return "zsh"
case "fish":
return "fish"
case "powershell", "pwsh":
return "powershell"
default:
return ""
}
}

func shellProfilePath(shell, home string) (string, error) {
switch shell {
case "bash":
return filepath.Join(home, ".bashrc"), nil
case "zsh":
return filepath.Join(home, ".zshrc"), nil
default:
return "", fmt.Errorf("profile setup is unsupported for shell %q", shell)
}
}

func completionSourceLine(scriptPath string) string {
return fmt.Sprintf("[ -f '%s' ] && source '%s'", scriptPath, scriptPath)
}

func ensureLineInFile(path, marker, line string) (bool, error) {
existing, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return false, fmt.Errorf("read profile %s: %w", path, err)
}
if strings.Contains(string(existing), line) {
return false, nil
}

if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return false, fmt.Errorf("prepare profile directory: %w", err)
}

f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return false, fmt.Errorf("open profile %s: %w", path, err)
}
defer f.Close()

if len(existing) > 0 && !strings.HasSuffix(string(existing), "\n") {
if _, err := f.WriteString("\n"); err != nil {
return false, fmt.Errorf("write profile newline: %w", err)
}
}
if _, err := f.WriteString("\n" + marker + "\n" + line + "\n"); err != nil {
return false, fmt.Errorf("write profile hook: %w", err)
}
return true, nil
}
88 changes: 88 additions & 0 deletions cmd/completion_install_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestNormalizeShell(t *testing.T) {
t.Parallel()

tests := []struct {
in string
want string
}{
{in: "/bin/bash", want: "bash"},
{in: "/bin/zsh", want: "zsh"},
{in: "/usr/bin/fish", want: "fish"},
{in: "pwsh", want: "powershell"},
{in: "powershell", want: "powershell"},
{in: "unknown", want: ""},
}

for _, tt := range tests {
if got := normalizeShell(tt.in); got != tt.want {
t.Fatalf("normalizeShell(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}

func TestDetectShellFromEnv(t *testing.T) {
t.Parallel()

got, err := detectShellFromEnv("/bin/zsh", false)
if err != nil {
t.Fatalf("detectShellFromEnv() error = %v", err)
}
if got != "zsh" {
t.Fatalf("shell = %q, want %q", got, "zsh")
}

got, err = detectShellFromEnv("", true)
if err != nil {
t.Fatalf("windows detect should succeed: %v", err)
}
if got != "powershell" {
t.Fatalf("shell = %q, want %q", got, "powershell")
}

if _, err := detectShellFromEnv("unknown", false); err == nil {
t.Fatal("expected error for unknown shell")
}
}

func TestEnsureLineInFileIdempotent(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
path := filepath.Join(tmpDir, ".bashrc")
marker := "# clime completion"
line := "[ -f '/tmp/clime' ] && source '/tmp/clime'"

changed, err := ensureLineInFile(path, marker, line)
if err != nil {
t.Fatalf("first ensureLineInFile() error = %v", err)
}
if !changed {
t.Fatal("first ensureLineInFile() should report changed")
}

data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read profile: %v", err)
}
content := string(data)
if !strings.Contains(content, marker) || !strings.Contains(content, line) {
t.Fatalf("profile content missing marker/line: %q", content)
}

changed, err = ensureLineInFile(path, marker, line)
if err != nil {
t.Fatalf("second ensureLineInFile() error = %v", err)
}
if changed {
t.Fatal("second ensureLineInFile() should be idempotent")
}
}
Loading