summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-26 23:10:50 +0200
committerPaul Buetow <paul@buetow.org>2026-03-26 23:10:50 +0200
commitb0392db09b960e70caf73db41cc74c9182733935 (patch)
treeb570c76c311cc02545ec545620ec87f7da19df39 /internal
parent5fd23315835d29f3551db2a1d10218deb95cd3de (diff)
release: v0.26.1v0.26.1
Diffstat (limited to 'internal')
-rw-r--r--internal/askcli/command_fish.go22
-rw-r--r--internal/askcli/completion.go26
-rw-r--r--internal/askcli/completion_test.go40
-rw-r--r--internal/askcli/dispatch.go3
-rw-r--r--internal/askcli/dispatch_test.go46
-rw-r--r--internal/version.go2
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"