Skip to content

Commit 2aa7df8

Browse files
fix(spec): catch duplicate registrations; feat(cd): complete from zoxide (#105)
Two fixes, independent of #103 and #104. --- ## 1. `Register` silently overwrites, and `find` was defined twice `Register` is `Registry[s.Name] = s`, and every spec registers from an `init()` spread across the `commands` packages. A name defined twice is therefore resolved by package init order, and nothing anywhere reports it. `find` was registered in both `commands/fs/find.go` and `commands/text/find.go`. The definitions are not equivalent — the `text` one carries `FileGenerator("/")` and 26 options, the `fs` one has four BSD-specific flags and no generator — so which one you got determined whether `find` completed paths at all. Neither file gives any hint that the other exists. Merged into one: the BSD flags move onto the richer `text` definition and the `fs` copy is removed. `Register` now records names it has seen before, and a registry-wide test asserts there are none. Confirmed the guard fails when a duplicate is reintroduced, rather than passing vacuously. ## 2. `cd` could only complete children of the current directory `cd` completes through `FileGenerator`, which reads exactly one directory: the one you are standing in. So `cd proj` resolves only when `proj` happens to be a child of the current directory, and returns nothing at all otherwise — including for directories visited many times a day. That is close to the opposite of what `cd` is normally reached for, and from the prompt it does not read as a scope limit; it reads as completion being broken, because nothing appears. `zoxide.extend-cd` routes `cd` through the generator `z` already uses, so local children are offered first and zoxide's frecency database supplies everything else: ``` # in ~/dotfiles cd Develop -> ~/Development, ~/Developer, ~/Development/cnc-claims, ... cd conf -> config/ (local child, still first), then ~/.config, ~/dotfiles/.config ``` Off by default. It shells out to zoxide, and offering absolute paths from outside the current directory is a real change in what `cd` proposes. ### The query is now cached `zoxide query -l` is a subprocess costing ~11ms on this machine, and generators run on **every keystroke** — that is the entire budget for a suggestion redraw. The result is cached for two seconds; the list only changes when the user changes directory, so the window costs nothing in practice. This applies to `z` and `zi` as well, which were paying that cost per keystroke already. --- ## Testing `go test -race ./...` passes. Behaviour was checked by driving the built binary through a real pty against an unmodified interactive zsh (starship, zsh-autosuggestions, fzf-tab, atuin loaded), reading the byte stream rather than trusting unit tests alone — the `cd Develop` and `cd conf` results above are transcribed from that. No new dependencies. --------- Co-authored-by: verse91 <versedev.store@proton.me>
1 parent 83056ba commit 2aa7df8

11 files changed

Lines changed: 126 additions & 29 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,9 @@ channel = "stable" # "stable" or "nightly"
330330
check-interval = "24h" # min time between update checks
331331
auto-update = 0 # 0 = off, 1 = auto-install, 2 = confirm first
332332
333+
[zoxide]
334+
extend-cd = false # complete `cd` from zoxide's database, not just the current directory
335+
333336
[ai]
334337
enabled = false
335338
provider = "groq" # "groq" or "ollama"

‎commands/fs/cd.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package fs
33
import (
44
"strings"
55

6+
"github.com/versenilvis/iris/internal/config"
67
"github.com/versenilvis/iris/spec"
78
)
89

@@ -12,6 +13,14 @@ func init() {
1213
Description: "change directory",
1314
MaxArgs: 0,
1415
Generator: func(tokens []string, prefix string, partial string) []spec.Suggestion {
16+
// The file generator only reads the current directory, so `cd proj`
17+
// comes back empty unless its parent is where you already are --
18+
// which is not how cd is typically used. zoxide.extend-cd folds in
19+
// the frecency database that `z` already queries, making any
20+
// directory zoxide has seen reachable by name from anywhere.
21+
if config.Get().Zoxide.ExtendCd {
22+
return ZoxideGenerator()(tokens, prefix, partial)
23+
}
1524
fullQuery := strings.Join(tokens[1:], " ")
1625
return spec.FileGenerator("/")(tokens, prefix, fullQuery)
1726
},

‎commands/fs/find.go‎

Lines changed: 0 additions & 18 deletions
This file was deleted.

‎commands/fs/zoxide.go‎

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"os"
1111
"os/exec"
1212
"strings"
13+
"sync"
14+
"time"
1315

1416
"github.com/versenilvis/fuzzy"
1517
"github.com/versenilvis/iris/spec"
@@ -30,24 +32,56 @@ func init() {
3032
})
3133
}
3234

35+
// zoxideCacheTTL bounds how stale the directory list may be. Generators run on
36+
// every keystroke and `zoxide query -l` is a subprocess costing on the order of
37+
// ten milliseconds, which is the entire latency budget for a suggestion redraw.
38+
// The list only changes when the user changes directory, so a short window
39+
// costs nothing in practice.
40+
const zoxideCacheTTL = 2 * time.Second
41+
42+
var zoxideCache struct {
43+
sync.Mutex
44+
dirs []string
45+
err error
46+
fetched time.Time
47+
}
48+
49+
// zoxideDirs returns zoxide's known directories, most frecent first.
50+
func zoxideDirs() ([]string, error) {
51+
zoxideCache.Lock()
52+
defer zoxideCache.Unlock()
53+
54+
if !zoxideCache.fetched.IsZero() && time.Since(zoxideCache.fetched) < zoxideCacheTTL {
55+
return zoxideCache.dirs, zoxideCache.err
56+
}
57+
58+
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
59+
defer cancel()
60+
61+
out, err := exec.CommandContext(ctx, "zoxide", "query", "-l").Output()
62+
zoxideCache.fetched = time.Now()
63+
zoxideCache.err = err
64+
zoxideCache.dirs = nil
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
for line := range strings.SplitSeq(string(bytes.TrimSpace(out)), "\n") {
70+
if line = strings.TrimSpace(line); line != "" {
71+
zoxideCache.dirs = append(zoxideCache.dirs, line)
72+
}
73+
}
74+
return zoxideCache.dirs, nil
75+
}
76+
3377
func ZoxideGenerator() spec.GeneratorFunc {
3478
return func(tokens []string, prefix string, partial string) []spec.Suggestion {
3579
fullQuery := strings.Join(tokens[1:], " ")
3680
localSuggestions := spec.FileGenerator("/")(tokens, prefix, fullQuery)
3781

3882
var zoxideSuggestions []spec.Suggestion
39-
cmd := exec.CommandContext(context.Background(), "zoxide", "query", "-l")
40-
out, err := cmd.Output()
83+
dirs, err := zoxideDirs()
4184
if err == nil {
42-
lines := strings.Split(string(bytes.TrimSpace(out)), "\n")
43-
var dirs []string
44-
for _, line := range lines {
45-
line = strings.TrimSpace(line)
46-
if line != "" {
47-
dirs = append(dirs, line)
48-
}
49-
}
50-
5185
home, _ := os.UserHomeDir()
5286

5387
if fullQuery == "" {

‎commands/text/find.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ func init() {
1010
Description: "search for files",
1111
Generator: spec.FileGenerator("/"),
1212
Options: []spec.Option{
13+
// BSD find; these came from a second `find` spec in commands/fs that
14+
// silently overwrote or was overwritten by this one depending on
15+
// package init order.
16+
{Name: "-E", Description: "Permit find to be safely used in conjunction with xargs"},
17+
{Name: "-d", Description: "Cause find to perform a depth-first traversal"},
18+
{Name: "-f", Description: "Specify a file hierarch for find to traverse"},
19+
{Name: "-s", Description: "Cause find to traverse the file hierarchies in lexicographical order"},
1320
{Name: "-name", Description: "match by name"},
1421
{Name: "-iname", Description: "match by name (case insensitive)"},
1522
{Name: "-type", Description: "match by type (f=file, d=dir, l=link)"},

‎internal/config/config.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,21 @@ type KeybindingsConfig struct {
103103
NavigateRight string `toml:"navigate-right"`
104104
}
105105

106+
type ZoxideConfig struct {
107+
// ExtendCd adds zoxide's frecency database to `cd` completions.
108+
//
109+
// Without it `cd` only offers direct children of the current directory,
110+
// because that is all the file generator walks -- so `cd proj` finds
111+
// nothing unless you are already standing in the parent. With it, any
112+
// directory zoxide has seen is reachable by name from anywhere, which is
113+
// what `cd` is usually wanted for.
114+
//
115+
// Off by default: it shells out to zoxide, and the suggestions are absolute
116+
// paths from outside the current directory, which is a real change in what
117+
// `cd` offers.
118+
ExtendCd bool `toml:"extend-cd"`
119+
}
120+
106121
type SuggestOnEmptyConfig struct {
107122
Enabled bool `toml:"enabled"`
108123
DebounceMS int `toml:"debounce_ms"`
@@ -152,6 +167,7 @@ type Config struct {
152167
Git GitConfig `toml:"git"`
153168
Updater UpdaterConfig `toml:"updater"`
154169
AI AIConfig `toml:"ai"`
170+
Zoxide ZoxideConfig `toml:"zoxide"`
155171
Keybindings KeybindingsConfig `toml:"keybindings"`
156172
}
157173

‎internal/config/defaults.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ func DefaultConfig() *Config {
4747
MinIntervalMS: 5000,
4848
},
4949
},
50+
Zoxide: ZoxideConfig{
51+
ExtendCd: false,
52+
},
5053
Keybindings: KeybindingsConfig{
5154
ToggleMode: "ctrl+r",
5255
ToggleMenu: "shift+tab",

‎root/config_cmd.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ check-interval = "24h"
111111
# 0 = off (default, notify only), 1 = auto-install, 2 = always confirm first
112112
auto-update = 0
113113
114+
[zoxide]
115+
# also complete cd from zoxide's frecency database, not just the
116+
# children of the current directory (requires zoxide on PATH)
117+
extend-cd = false
118+
114119
[keybindings]
115120
toggle-mode = "ctrl+r"
116121
toggle-menu = "shift+tab"

‎root/init.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,11 @@ check-interval = "24h"
296296
# 0 = off (default, notify only), 1 = auto-install, 2 = always confirm first
297297
auto-update = 0
298298
299+
[zoxide]
300+
# also complete cd from zoxide's frecency database, not just the
301+
# children of the current directory (requires zoxide on PATH)
302+
extend-cd = false
303+
299304
[keybindings]
300305
toggle-mode = "ctrl+r"
301306
toggle-menu = "shift+tab"

‎spec/spec.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package spec
22

3+
import "slices"
4+
35
type GeneratorFunc func(tokens []string, prefix string, partial string) []Suggestion
46

57
// Spec defines a top-level command structure
@@ -46,13 +48,33 @@ type Suggestion struct {
4648

4749
var Registry = map[string]*Spec{}
4850

51+
// duplicateNames records specs registered under a name already taken.
52+
//
53+
// Registration is last-one-wins, and every spec is registered from an init()
54+
// spread across the commands packages, so a name defined twice resolves on
55+
// package init order -- and does so silently. `find` was registered in two
56+
// packages, and the one that lost had the file generator, which is exactly the
57+
// kind of difference nobody notices from reading either file.
58+
var duplicateNames []string
59+
4960
// Register adds a new spec to the global Registry
5061
// example: Register(&Spec{Name: "git"})
5162
func Register(s *Spec) {
63+
if _, exists := Registry[s.Name]; exists {
64+
duplicateNames = append(duplicateNames, s.Name)
65+
}
5266
Registry[s.Name] = s
5367
}
5468

69+
// DuplicateNames returns the spec names that were registered more than once.
70+
// A non-empty result is a bug: which definition survives is decided by package
71+
// init order rather than by intent.
72+
func DuplicateNames() []string {
73+
return slices.Clone(duplicateNames)
74+
}
75+
5576
// ResetRegistry clears all registered specs - use in tests only
5677
func ResetRegistry() {
5778
Registry = make(map[string]*Spec)
79+
duplicateNames = nil
5880
}

0 commit comments

Comments
 (0)