Skip to content
Draft
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
44 changes: 44 additions & 0 deletions cmd/cheatmd/widget_executable_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
20 changes: 9 additions & 11 deletions cmd/cheatmd/widgets.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"fmt"
"os"

"github.com/cheatmd-dev/cheatmd/internal/shellgen"
"github.com/spf13/cobra"
Expand All @@ -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
}
57 changes: 57 additions & 0 deletions internal/shellgen/executable_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
47 changes: 38 additions & 9 deletions internal/shellgen/widgets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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

Expand All @@ -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
Expand Down