diff --git a/cmd/cheatmd/widget_executable_test.go b/cmd/cheatmd/widget_executable_test.go new file mode 100644 index 0000000..7089de6 --- /dev/null +++ b/cmd/cheatmd/widget_executable_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestWidgetUsesCurrentExecutable(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, shell := range []string{"bash", "zsh", "fish"} { + t.Run(shell, func(t *testing.T) { + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + if err := runWidget(cmd, []string{shell}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), executable) { + t.Fatalf("widget does not invoke current executable %q", executable) + } + }) + } +} + +func TestWidgetRejectsUnsupportedShell(t *testing.T) { + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + err := runWidget(cmd, []string{"elvish"}) + const want = "unsupported shell: elvish (supported: bash, zsh, fish)" + if err == nil || err.Error() != want { + t.Fatalf("runWidget error = %v, want %q", err, want) + } + if out.Len() != 0 { + t.Fatalf("unsupported shell produced output: %q", out.String()) + } +} diff --git a/cmd/cheatmd/widgets.go b/cmd/cheatmd/widgets.go index b150349..f80bdf1 100644 --- a/cmd/cheatmd/widgets.go +++ b/cmd/cheatmd/widgets.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "os" "github.com/cheatmd-dev/cheatmd/internal/shellgen" "github.com/spf13/cobra" @@ -22,17 +23,14 @@ Then press Ctrl+G to trigger the cheatmd selector.`, } func runWidget(cmd *cobra.Command, args []string) error { - shell := args[0] - - switch shell { - case "bash": - fmt.Fprint(cmd.OutOrStdout(), shellgen.BashWidget()) - case "zsh": - fmt.Fprint(cmd.OutOrStdout(), shellgen.ZshWidget()) - case "fish": - fmt.Fprint(cmd.OutOrStdout(), shellgen.FishWidget()) - default: - return fmt.Errorf("unsupported shell: %s (supported: bash, zsh, fish)", shell) + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("locate executable: %w", err) + } + script, err := shellgen.Widget(args[0], executable) + if err != nil { + return err } + fmt.Fprint(cmd.OutOrStdout(), script) return nil } diff --git a/internal/shellgen/executable_test.go b/internal/shellgen/executable_test.go new file mode 100644 index 0000000..2b0760c --- /dev/null +++ b/internal/shellgen/executable_test.go @@ -0,0 +1,57 @@ +package shellgen + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestWidgetQuotesExecutable(t *testing.T) { + for _, tc := range []struct{ shell, want string }{ + {"bash", `'/tmp/tool\path'"'"'s name' --print`}, + {"zsh", `'/tmp/tool\path'"'"'s name' --print`}, + {"fish", `'/tmp/tool\\path\'s name' --print`}, + } { + t.Run(tc.shell, func(t *testing.T) { + script, err := Widget(tc.shell, `/tmp/tool\path's name`) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(script, tc.want); got != 2 { + t.Fatalf("script contains quoted executable %d times, want 2:\n%s", got, script) + } + }) + } +} + +func TestWidgetInvokesQuotedExecutable(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is unavailable") + } + executable := filepath.Join(t.TempDir(), "example tool's name") + if err := os.WriteFile(executable, []byte("#!/bin/sh\nprintf '<%s>\\n' \"$0\" \"$@\"\n"), 0o700); err != nil { + t.Fatal(err) + } + script, err := Widget("bash", executable) + if err != nil { + t.Fatal(err) + } + for _, input := range []string{"", "value with spaces; $(printf unwanted) 'quote'"} { + command := script + "\nREADLINE_LINE=$1\n_cheatmd_widget || exit $?\nprintf '%s' \"$READLINE_LINE\"\n" + out, err := exec.Command(bash, "--noprofile", "--norc", "-c", command, "widget-test", input).Output() + if err != nil { + t.Fatal(err) + } + want := fmt.Sprintf("<%s>\n<--print>", executable) + if input != "" { + want += "\n<--match>\n<" + input + ">" + } + if string(out) != want { + t.Fatalf("output = %q, want %q", out, want) + } + } +} diff --git a/internal/shellgen/widgets.go b/internal/shellgen/widgets.go index de0fcaf..0324207 100644 --- a/internal/shellgen/widgets.go +++ b/internal/shellgen/widgets.go @@ -14,6 +14,10 @@ import ( // interactive cheatmd widget, replacing the current command line with the // selected command. func BashWidget() string { + return bashWidget("cheatmd") +} + +func bashWidget(command string) string { keyWidget := config.Get().KeyWidget return fmt.Sprintf(`#!/usr/bin/env bash @@ -22,9 +26,9 @@ _cheatmd_widget() { local output if [ -z "${input}" ]; then - output="$(cheatmd --print)" || return + output="$(%s --print)" || return else - output="$(cheatmd --print --match "$input")" || return + output="$(%s --print --match "$input")" || return fi if [ -n "$output" ]; then @@ -38,13 +42,17 @@ if [ ${BASH_VERSION:0:1} -lt 4 ]; then else bind -x '"%s": _cheatmd_widget' fi -`, keyWidget) +`, command, command, keyWidget) } // ZshWidget returns a zsh script that binds the configured key to an // interactive cheatmd widget, replacing the current command line with the // selected command. func ZshWidget() string { + return zshWidget("cheatmd") +} + +func zshWidget(command string) string { keyWidget := config.Get().KeyWidget // Convert bash-style keybinding to zsh format (e.g., \C-g -> ^g) zshKey := convertToZshKey(keyWidget) @@ -55,9 +63,9 @@ _cheatmd_widget() { local output if [ -z "$input" ]; then - output="$(cheatmd --print)" || return + output="$(%s --print)" || return else - output="$(cheatmd --print --match "$input")" || return + output="$(%s --print --match "$input")" || return fi if [ -n "$output" ]; then @@ -70,13 +78,17 @@ _cheatmd_widget() { zle -N _cheatmd_widget bindkey '%s' _cheatmd_widget -`, zshKey) +`, command, command, zshKey) } // FishWidget returns a fish script that binds the configured key to an // interactive cheatmd widget, replacing the current command line with the // selected command. func FishWidget() string { + return fishWidget("cheatmd") +} + +func fishWidget(command string) string { keyWidget := config.Get().KeyWidget // Convert bash-style keybinding to fish format (e.g., \C-g -> \cg) fishKey := convertToFishKey(keyWidget) @@ -86,10 +98,10 @@ func FishWidget() string { set -l cmd_status 0 if test -z "$input" - set output (cheatmd --print) + set output (%s --print) set cmd_status $status else - set output (cheatmd --print --match "$input") + set output (%s --print --match "$input") set cmd_status $status end @@ -106,7 +118,24 @@ func FishWidget() string { end bind %s _cheatmd_widget -`, fishKey) +`, command, command, fishKey) +} + +// Widget generates an integration bound to a particular executable. Quote its +// path for the chosen shell so spaces and apostrophes remain part of the path. +func Widget(shell, executable string) (string, error) { + quoted := "'" + strings.ReplaceAll(executable, "'", "'\"'\"'") + "'" + switch shell { + case "bash": + return bashWidget(quoted), nil + case "zsh": + return zshWidget(quoted), nil + case "fish": + quoted = "'" + strings.NewReplacer("\\", "\\\\", "'", "\\'").Replace(executable) + "'" + return fishWidget(quoted), nil + default: + return "", fmt.Errorf("unsupported shell: %s (supported: bash, zsh, fish)", shell) + } } // convertToZshKey converts a bash-style keybinding to zsh format