diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-26 23:10:50 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-26 23:10:50 +0200 |
| commit | b0392db09b960e70caf73db41cc74c9182733935 (patch) | |
| tree | b570c76c311cc02545ec545620ec87f7da19df39 /internal | |
| parent | 5fd23315835d29f3551db2a1d10218deb95cd3de (diff) | |
release: v0.26.1v0.26.1
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/askcli/command_fish.go | 22 | ||||
| -rw-r--r-- | internal/askcli/completion.go | 26 | ||||
| -rw-r--r-- | internal/askcli/completion_test.go | 40 | ||||
| -rw-r--r-- | internal/askcli/dispatch.go | 3 | ||||
| -rw-r--r-- | internal/askcli/dispatch_test.go | 46 | ||||
| -rw-r--r-- | internal/version.go | 2 |
6 files changed, 111 insertions, 28 deletions
diff --git a/internal/askcli/command_fish.go b/internal/askcli/command_fish.go new file mode 100644 index 0000000..89a8457 --- /dev/null +++ b/internal/askcli/command_fish.go @@ -0,0 +1,22 @@ +package askcli + +import ( + "fmt" + "io" + "os" +) + +func (d Dispatcher) handleFish(args []string, stdout, stderr io.Writer) (int, error) { + if len(args) != 1 { + fmt.Fprintln(stderr, "usage: ask fish") + return 1, nil + } + binaryPath, err := os.Executable() + if err != nil { + binaryPath = "ask" + } + if _, err := io.WriteString(stdout, FishCompletionFor(binaryPath)); err != nil { + return 1, err + } + return 0, nil +} diff --git a/internal/askcli/completion.go b/internal/askcli/completion.go index 6033415..5684e33 100644 --- a/internal/askcli/completion.go +++ b/internal/askcli/completion.go @@ -26,6 +26,7 @@ var askRootCompletionItems = []fishCompletionItem{ {name: "modify", description: "Modify task fields"}, {name: "denotate", description: "Remove an annotation"}, {name: "delete", description: "Delete a task"}, + {name: "fish", description: "Emit Fish shell completion script"}, {name: "help", description: "Show help"}, } @@ -49,10 +50,14 @@ var askUUIDCompletionItems = []fishCompletionItem{ } func FishCompletion() string { + return FishCompletionFor("ask") +} + +func FishCompletionFor(binaryPath string) string { var b strings.Builder writeFishPreamble(&b) writeFishContextFunctions(&b) - writeFishTaskUUIDFunction(&b) + writeFishTaskUUIDFunction(&b, binaryPath) b.WriteString("complete -c ask -f\n") b.WriteString("complete -c ask -s j -l json -d 'Emit JSON output'\n") for _, item := range askRootCompletionItems { @@ -68,8 +73,7 @@ func FishCompletion() string { func writeFishPreamble(b *strings.Builder) { b.WriteString("# Fish completion for ask.\n") - b.WriteString("# Install as ~/.config/fish/completions/ask.fish or") - b.WriteString(" $XDG_CONFIG_HOME/fish/completions/ask.fish.\n\n") + b.WriteString("# Source with: ask fish | source\n\n") } func writeFishContextFunctions(b *strings.Builder) { @@ -177,14 +181,17 @@ func writeFishDepUUIDContextFunction(b *strings.Builder) { b.WriteString("end\n\n") } -func writeFishTaskUUIDFunction(b *strings.Builder) { +func writeFishTaskUUIDFunction(b *strings.Builder, binaryPath string) { b.WriteString("function __ask_task_uuids\n") + b.WriteString(" set -l ask_bin ") + b.WriteString(quoteFishString(binaryPath)) + b.WriteString("\n") b.WriteString(" set -l now (date +%s)\n") b.WriteString(" if set -q __ask_task_uuid_cache_until; and test $__ask_task_uuid_cache_until -ge $now\n") b.WriteString(" printf '%s\\n' $__ask_task_uuid_cache\n") b.WriteString(" return 0\n") b.WriteString(" end\n") - b.WriteString(" set -l uuids (command ask complete-uuids 2>/dev/null)\n") + b.WriteString(" set -l uuids (command $ask_bin complete-uuids 2>/dev/null)\n") b.WriteString(" if test $status -ne 0\n") b.WriteString(" return 1\n") b.WriteString(" end\n") @@ -211,3 +218,12 @@ func writeFishUUIDCompletionLine(b *strings.Builder, condition, description stri b.WriteString(strings.ReplaceAll(description, "'", "\\'")) b.WriteString("'\n") } + +func quoteFishString(value string) string { + replacer := strings.NewReplacer( + "\\", "\\\\", + "\"", "\\\"", + "$", "\\$", + ) + return `"` + replacer.Replace(value) + `"` +} diff --git a/internal/askcli/completion_test.go b/internal/askcli/completion_test.go index 5cab89b..ad25cbd 100644 --- a/internal/askcli/completion_test.go +++ b/internal/askcli/completion_test.go @@ -1,42 +1,25 @@ package askcli import ( - "os" - "path/filepath" - "runtime" "strings" "testing" ) -func TestFishCompletion_MatchesAsset(t *testing.T) { - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("runtime.Caller failed") - } - assetPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "assets", "ask.fish")) - want, err := os.ReadFile(assetPath) - if err != nil { - t.Fatalf("read asset: %v", err) - } - got := FishCompletion() - if got != string(want) { - t.Fatalf("fish completion asset mismatch\n--- got ---\n%s\n--- want ---\n%s", got, string(want)) - } -} - func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { script := FishCompletion() - for _, name := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "help"} { + for _, name := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "fish", "help"} { if !strings.Contains(script, " -a '"+name+"' ") { t.Fatalf("script missing root completion for %q", name) } } for _, line := range []string{ + "# Source with: ask fish | source", "complete -c ask -n '__ask_in_dep_context' -a 'add' -d 'Add a dependency'", "complete -c ask -n '__ask_in_dep_context' -a 'rm' -d 'Remove a dependency'", "complete -c ask -n '__ask_in_dep_context' -a 'list' -d 'List dependencies'", "function __ask_task_uuids", - "set -l uuids (command ask complete-uuids 2>/dev/null)", + `set -l ask_bin "ask"`, + "set -l uuids (command $ask_bin complete-uuids 2>/dev/null)", "complete -c ask -n '__ask_in_uuid_context' -a '(__ask_task_uuids)' -d 'Task UUID'", "complete -c ask -n '__ask_in_dep_uuid_context' -a '(__ask_task_uuids)' -d 'Task UUID'", } { @@ -47,4 +30,19 @@ func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { if strings.Contains(script, "ask export") { t.Fatalf("script should not advertise non-existent export command") } + if strings.Contains(script, "assets/ask.fish") { + t.Fatalf("script should not reference a static asset") + } +} + +func TestFishCompletionFor_EmbedsBinaryPath(t *testing.T) { + script := FishCompletionFor(`/tmp/ask "$HOME"`) + for _, line := range []string{ + `set -l ask_bin "/tmp/ask \"\$HOME\""`, + "set -l uuids (command $ask_bin complete-uuids 2>/dev/null)", + } { + if !strings.Contains(script, line) { + t.Fatalf("script missing %q", line) + } + } } diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index bc4715c..e828989 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -74,6 +74,8 @@ func (d Dispatcher) Dispatch(ctx context.Context, args []string, stdin io.Reader return d.handleDenotate(ctx, args, stdout, stderr) case "delete": return d.handleDelete(ctx, args, stdin, stdout, stderr) + case "fish": + return d.handleFish(args, stdout, stderr) case "help": return d.help(stdout) case "complete-uuids": @@ -104,6 +106,7 @@ func (d Dispatcher) help(w io.Writer) (int, error) { io.WriteString(w, " ask modify <uuid> <args...> Modify task fields\n") io.WriteString(w, " ask denotate <uuid> \"text\" Remove annotation\n") io.WriteString(w, " ask delete <uuid> Delete task\n") + io.WriteString(w, " ask fish Emit Fish shell completion script\n") return 0, nil } diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go index 9a1bf43..040f305 100644 --- a/internal/askcli/dispatch_test.go +++ b/internal/askcli/dispatch_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "io" + "os" "strings" "testing" ) @@ -28,6 +29,9 @@ func TestDispatcher_Help(t *testing.T) { if !strings.Contains(output, "ask all") { t.Fatalf("help missing all subcommand: %s", output) } + if !strings.Contains(output, "ask fish") { + t.Fatalf("help missing fish subcommand: %s", output) + } } func TestDispatcher_UnknownSubcommand(t *testing.T) { @@ -72,13 +76,53 @@ func TestDispatcher_LongHelp(t *testing.T) { var stdout bytes.Buffer d.Dispatch(context.Background(), []string{"help"}, nil, &stdout, io.Discard) output := stdout.String() - for _, sub := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete"} { + for _, sub := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "fish"} { if !strings.Contains(output, "ask "+sub) { t.Errorf("help missing subcommand: ask %s", sub) } } } +func TestDispatcher_FishSubcommand(t *testing.T) { + d := NewDispatcher(nil) + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(context.Background(), []string{"fish"}, nil, &stdout, &stderr) + if err != nil { + t.Fatalf("fish returned error: %v", err) + } + if code != 0 { + t.Fatalf("fish code = %d, want 0", code) + } + exe, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable: %v", err) + } + if got := stdout.String(); got != FishCompletionFor(exe) { + t.Fatalf("fish output mismatch\n--- got ---\n%s\n--- want ---\n%s", got, FishCompletionFor(exe)) + } + if stderr.Len() != 0 { + t.Fatalf("fish wrote unexpected stderr: %q", stderr.String()) + } +} + +func TestDispatcher_FishSubcommandRejectsExtraArgs(t *testing.T) { + d := NewDispatcher(nil) + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(context.Background(), []string{"fish", "extra"}, nil, &stdout, &stderr) + if err != nil { + t.Fatalf("fish extra args returned error: %v", err) + } + if code != 1 { + t.Fatalf("fish extra args code = %d, want 1", code) + } + if stdout.Len() != 0 { + t.Fatalf("fish extra args wrote unexpected stdout: %q", stdout.String()) + } + if got := stderr.String(); !strings.Contains(got, "usage: ask fish") { + t.Fatalf("fish extra args stderr = %q, want usage", got) + } +} + func TestDispatcher_AllSubcommandsReachExecutor(t *testing.T) { subcommands := []string{} subcommandArgs := map[string][]string{ diff --git a/internal/version.go b/internal/version.go index 0056bf1..68ad39a 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ // Package internal provides the Hexai semantic version identifier used by CLI and LSP binaries. package internal -const Version = "0.26.0" +const Version = "0.26.1" |
