Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11fad73ef5 | ||
|
|
0b2221d5e9
|
@@ -324,6 +324,9 @@ navigate-right = "right" # accept ghost text
|
||||
filter-active-branch = true # exclude current branch from suggestions
|
||||
deduplicate-branches = true # merge same-name local/remote branches
|
||||
|
||||
[zoxide]
|
||||
extend-cd = false # include zoxide's frecent directories in cd suggestions
|
||||
|
||||
[updater]
|
||||
check-on-startup = true # check for updates on startup
|
||||
channel = "stable" # "stable" or "nightly"
|
||||
|
||||
@@ -3,6 +3,7 @@ package fs
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/versenilvis/iris/internal/config"
|
||||
"github.com/versenilvis/iris/spec"
|
||||
)
|
||||
|
||||
@@ -12,6 +13,11 @@ func init() {
|
||||
Description: "change directory",
|
||||
MaxArgs: 0,
|
||||
Generator: func(tokens []string, prefix string, partial string) []spec.Suggestion {
|
||||
// Extend cd with zoxide's frecency database while keeping local
|
||||
// filesystem suggestions first.
|
||||
if config.Get().Zoxide.ExtendCd {
|
||||
return ZoxideGenerator()(tokens, prefix, partial)
|
||||
}
|
||||
fullQuery := strings.Join(tokens[1:], " ")
|
||||
return spec.FileGenerator("/")(tokens, prefix, fullQuery)
|
||||
},
|
||||
|
||||
+188
-55
@@ -1,20 +1,36 @@
|
||||
// please note that zoxide also shows external suggestions
|
||||
// at the end of the list on command mode
|
||||
// they are the old directories that you have visited
|
||||
// this is a feature, not a bug, and I want to keep it
|
||||
package fs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/versenilvis/fuzzy"
|
||||
"github.com/versenilvis/iris/spec"
|
||||
)
|
||||
|
||||
const (
|
||||
zoxideCacheTTL = 2 * time.Second
|
||||
zoxideQueryTimeout = 500 * time.Millisecond
|
||||
zoxideEmptyLimit = 20
|
||||
zoxideSearchLimit = 10
|
||||
)
|
||||
|
||||
type zoxideQueryFunc func(context.Context) ([]byte, error)
|
||||
|
||||
type zoxideSource struct {
|
||||
mu sync.Mutex
|
||||
query zoxideQueryFunc
|
||||
now func() time.Time
|
||||
dirs []string
|
||||
err error
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
func init() {
|
||||
spec.Register(&spec.Spec{
|
||||
Name: "z",
|
||||
@@ -30,62 +46,179 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
func newZoxideSource(query zoxideQueryFunc) *zoxideSource {
|
||||
return &zoxideSource{query: query, now: time.Now}
|
||||
}
|
||||
|
||||
var defaultZoxideSource = newZoxideSource(func(ctx context.Context) ([]byte, error) {
|
||||
return exec.CommandContext(ctx, "zoxide", "query", "-l").Output()
|
||||
})
|
||||
|
||||
// directories caches failures too, preventing a missing zoxide binary from
|
||||
// spawning a failed process on every keystroke.
|
||||
func (s *zoxideSource) directories() ([]string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := s.now()
|
||||
if !s.fetched.IsZero() && now.Before(s.fetched.Add(zoxideCacheTTL)) {
|
||||
return append([]string(nil), s.dirs...), s.err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), zoxideQueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := s.query(ctx)
|
||||
s.fetched = s.now()
|
||||
s.err = err
|
||||
s.dirs = nil
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for line := range strings.SplitSeq(string(out), "\n") {
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
if line != "" {
|
||||
s.dirs = append(s.dirs, line)
|
||||
}
|
||||
}
|
||||
|
||||
return append([]string(nil), s.dirs...), nil
|
||||
}
|
||||
|
||||
func ZoxideGenerator() spec.GeneratorFunc {
|
||||
return newZoxideGenerator(defaultZoxideSource.directories)
|
||||
}
|
||||
|
||||
func newZoxideGenerator(directorySource func() ([]string, error)) spec.GeneratorFunc {
|
||||
return func(tokens []string, prefix string, partial string) []spec.Suggestion {
|
||||
fullQuery := strings.Join(tokens[1:], " ")
|
||||
localSuggestions := spec.FileGenerator("/")(tokens, prefix, fullQuery)
|
||||
|
||||
var zoxideSuggestions []spec.Suggestion
|
||||
cmd := exec.CommandContext(context.Background(), "zoxide", "query", "-l")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(string(bytes.TrimSpace(out)), "\n")
|
||||
var dirs []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
dirs = append(dirs, line)
|
||||
}
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
if fullQuery == "" {
|
||||
limit := min(len(dirs), 20)
|
||||
for i := range limit {
|
||||
path := dirs[i]
|
||||
display := strings.Replace(path, home, "~", 1)
|
||||
zoxideSuggestions = append(zoxideSuggestions, spec.Suggestion{
|
||||
Cmd: path,
|
||||
Desc: display,
|
||||
})
|
||||
}
|
||||
} else if !strings.Contains(fullQuery, "/") {
|
||||
searcher := fuzzy.NewPlainSearcher(dirs)
|
||||
matches := searcher.SearchWithScores(fullQuery, &fuzzy.SearchOptions{Limit: 10})
|
||||
for _, m := range matches {
|
||||
path := m.Str
|
||||
display := strings.Replace(path, home, "~", 1)
|
||||
zoxideSuggestions = append(zoxideSuggestions, spec.Suggestion{
|
||||
Cmd: path,
|
||||
Desc: display,
|
||||
})
|
||||
}
|
||||
}
|
||||
local := spec.FileGenerator("/")(tokens, prefix, fullQuery)
|
||||
if strings.ContainsAny(fullQuery, `/\`) {
|
||||
return local
|
||||
}
|
||||
|
||||
var finalResults []spec.Suggestion
|
||||
seen := make(map[string]bool)
|
||||
|
||||
finalResults = append(finalResults, localSuggestions...)
|
||||
|
||||
for _, s := range zoxideSuggestions {
|
||||
if !seen[s.Cmd] {
|
||||
finalResults = append(finalResults, s)
|
||||
seen[s.Cmd] = true
|
||||
}
|
||||
dirs, err := directorySource()
|
||||
if err != nil {
|
||||
return local
|
||||
}
|
||||
|
||||
return finalResults
|
||||
external := zoxideSuggestions(dirs, fullQuery)
|
||||
return mergeDirectorySuggestions(local, external, spec.GetCWD())
|
||||
}
|
||||
}
|
||||
|
||||
func zoxideSuggestions(dirs []string, query string) []spec.Suggestion {
|
||||
candidates := dirs
|
||||
limit := zoxideEmptyLimit
|
||||
if query != "" {
|
||||
candidates = matchedZoxideDirectories(dirs, query)
|
||||
limit = zoxideSearchLimit
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
results := make([]spec.Suggestion, 0, min(limit, len(candidates)))
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
path := filepath.Clean(candidate)
|
||||
if !filepath.IsAbs(path) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, spec.Suggestion{
|
||||
Cmd: path,
|
||||
Desc: displayDirectory(path, home),
|
||||
Priority: 40,
|
||||
})
|
||||
if len(results) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func matchedZoxideDirectories(dirs []string, query string) []string {
|
||||
if len(dirs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
results := make([]string, 0, len(dirs))
|
||||
seen := make(map[string]struct{}, len(dirs))
|
||||
lowerQuery := strings.ToLower(query)
|
||||
|
||||
for _, dir := range dirs {
|
||||
if strings.Contains(strings.ToLower(dir), lowerQuery) {
|
||||
results = append(results, dir)
|
||||
seen[dir] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
matches := fuzzy.NewSearcher(dirs).SearchWithScores(query, &fuzzy.SearchOptions{Limit: len(dirs)})
|
||||
for _, match := range matches {
|
||||
if _, ok := seen[match.Str]; ok {
|
||||
continue
|
||||
}
|
||||
results = append(results, match.Str)
|
||||
seen[match.Str] = struct{}{}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func mergeDirectorySuggestions(local, external []spec.Suggestion, cwd string) []spec.Suggestion {
|
||||
results := make([]spec.Suggestion, 0, len(local)+len(external))
|
||||
seen := make(map[string]struct{}, len(local)+len(external))
|
||||
|
||||
add := func(suggestion spec.Suggestion) {
|
||||
key := directoryKey(suggestion.Cmd, cwd)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
results = append(results, suggestion)
|
||||
}
|
||||
for _, suggestion := range local {
|
||||
add(suggestion)
|
||||
}
|
||||
for _, suggestion := range external {
|
||||
add(suggestion)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func directoryKey(path, cwd string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(cwd, path)
|
||||
}
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
|
||||
func displayDirectory(path, home string) string {
|
||||
path = filepath.Clean(path)
|
||||
if home == "" {
|
||||
return path
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(filepath.Clean(home), path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return path
|
||||
}
|
||||
if rel == "." {
|
||||
return "~"
|
||||
}
|
||||
return filepath.Join("~", rel)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/versenilvis/iris/spec"
|
||||
)
|
||||
|
||||
func TestZoxideSourceCachesDirectoriesAndErrors(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC)
|
||||
calls := 0
|
||||
source := newZoxideSource(func(context.Context) ([]byte, error) {
|
||||
calls++
|
||||
return []byte("/tmp/Project One \n/tmp/Проект\r\n"), nil
|
||||
})
|
||||
source.now = func() time.Time { return now }
|
||||
|
||||
want := []string{"/tmp/Project One ", "/tmp/Проект"}
|
||||
for range 2 {
|
||||
got, err := source.directories()
|
||||
if err != nil {
|
||||
t.Fatalf("directories returned an error: %v", err)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("directories = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("query calls = %d, want 1", calls)
|
||||
}
|
||||
|
||||
now = now.Add(zoxideCacheTTL)
|
||||
if _, err := source.directories(); err != nil {
|
||||
t.Fatalf("directories after cache expiry returned an error: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("query calls after cache expiry = %d, want 2", calls)
|
||||
}
|
||||
|
||||
wantErr := errors.New("zoxide unavailable")
|
||||
errorCalls := 0
|
||||
errorSource := newZoxideSource(func(context.Context) ([]byte, error) {
|
||||
errorCalls++
|
||||
return nil, wantErr
|
||||
})
|
||||
if _, err := errorSource.directories(); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("directories error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if _, err := errorSource.directories(); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("cached directories error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if errorCalls != 1 {
|
||||
t.Fatalf("failed query calls = %d, want 1", errorCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoxideGeneratorMergesValidDirectories(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
local := filepath.Join(cwd, "project")
|
||||
external := filepath.Join(t.TempDir(), "My Проект")
|
||||
missing := filepath.Join(t.TempDir(), "deleted")
|
||||
mustMkdirAll(t, local)
|
||||
mustMkdirAll(t, external)
|
||||
|
||||
previousCWD := spec.GetCWD()
|
||||
spec.SetCWD(cwd)
|
||||
t.Cleanup(func() { spec.SetCWD(previousCWD) })
|
||||
|
||||
gen := newZoxideGenerator(func() ([]string, error) {
|
||||
return []string{local, external, external, missing}, nil
|
||||
})
|
||||
results := gen([]string{"z", ""}, "z ", "")
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("suggestion count = %d, want 2: %#v", len(results), results)
|
||||
}
|
||||
if results[0].Cmd != "project/" {
|
||||
t.Fatalf("first suggestion = %q, want local project/", results[0].Cmd)
|
||||
}
|
||||
if results[1].Cmd != external {
|
||||
t.Fatalf("second suggestion = %q, want %q", results[1].Cmd, external)
|
||||
}
|
||||
if results[1].Priority != 40 {
|
||||
t.Fatalf("external priority = %d, want 40", results[1].Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoxideGeneratorFiltersAndFallsBack(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
nested := filepath.Join(cwd, "nested", "child")
|
||||
mustMkdirAll(t, nested)
|
||||
|
||||
previousCWD := spec.GetCWD()
|
||||
spec.SetCWD(cwd)
|
||||
t.Cleanup(func() { spec.SetCWD(previousCWD) })
|
||||
|
||||
calls := 0
|
||||
gen := newZoxideGenerator(func() ([]string, error) {
|
||||
calls++
|
||||
return nil, errors.New("zoxide unavailable")
|
||||
})
|
||||
|
||||
results := gen([]string{"z", "nested/ch"}, "z ", "nested/ch")
|
||||
if calls != 0 {
|
||||
t.Fatalf("source calls for explicit path = %d, want 0", calls)
|
||||
}
|
||||
if len(results) != 1 || results[0].Cmd != "nested/child/" {
|
||||
t.Fatalf("explicit path suggestions = %#v", results)
|
||||
}
|
||||
|
||||
results = gen([]string{"z", "n"}, "z ", "n")
|
||||
if calls != 1 {
|
||||
t.Fatalf("source calls after name query = %d, want 1", calls)
|
||||
}
|
||||
if len(results) != 1 || results[0].Cmd != "nested/" {
|
||||
t.Fatalf("fallback suggestions = %#v", results)
|
||||
}
|
||||
|
||||
empty := newZoxideGenerator(func() ([]string, error) { return nil, nil })
|
||||
if results := empty([]string{"z", "missing"}, "z ", "missing"); len(results) != 0 {
|
||||
t.Fatalf("empty database suggestions = %#v, want none", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoxideGeneratorFuzzyMatchesUnicodeAndLookupQuotesSpaces(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
target := filepath.Join(t.TempDir(), "My Awesome $Проект")
|
||||
mustMkdirAll(t, target)
|
||||
|
||||
previousCWD := spec.GetCWD()
|
||||
spec.SetCWD(cwd)
|
||||
t.Cleanup(func() { spec.SetCWD(previousCWD) })
|
||||
|
||||
const command = "zoxide-test-command"
|
||||
spec.Register(&spec.Spec{
|
||||
Name: command,
|
||||
MaxArgs: 0,
|
||||
Generator: newZoxideGenerator(func() ([]string, error) { return []string{target}, nil }),
|
||||
})
|
||||
t.Cleanup(func() { delete(spec.Registry, command) })
|
||||
|
||||
results := spec.Lookup(command + " My Awe")
|
||||
want := command + ` '` + target + `'`
|
||||
if len(results) != 1 || results[0].Cmd != want {
|
||||
t.Fatalf("lookup suggestions = %#v, want command %q", results, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayDirectoryOnlyShortensHomePrefix(t *testing.T) {
|
||||
home := filepath.Join(string(os.PathSeparator), "Users", "example")
|
||||
inside := filepath.Join(home, "Developer", "Проект")
|
||||
outside := filepath.Join(string(os.PathSeparator), "tmp", "Users", "example", "data")
|
||||
|
||||
if got := displayDirectory(home, home); got != "~" {
|
||||
t.Fatalf("displayDirectory(home) = %q, want ~", got)
|
||||
}
|
||||
if got := displayDirectory(inside, home); got != filepath.Join("~", "Developer", "Проект") {
|
||||
t.Fatalf("displayDirectory(inside) = %q", got)
|
||||
}
|
||||
if got := displayDirectory(outside, home); got != outside {
|
||||
t.Fatalf("displayDirectory(outside) = %q, want %q", got, outside)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdirAll(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatalf("create directory %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,11 @@ type KeybindingsConfig struct {
|
||||
NavigateRight string `toml:"navigate-right"`
|
||||
}
|
||||
|
||||
type ZoxideConfig struct {
|
||||
// ExtendCd adds zoxide's frecency database to cd completions.
|
||||
ExtendCd bool `toml:"extend-cd"`
|
||||
}
|
||||
|
||||
type SuggestOnEmptyConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
DebounceMS int `toml:"debounce_ms"`
|
||||
@@ -127,6 +132,7 @@ type Config struct {
|
||||
Git GitConfig `toml:"git"`
|
||||
Updater UpdaterConfig `toml:"updater"`
|
||||
AI AIConfig `toml:"ai"`
|
||||
Zoxide ZoxideConfig `toml:"zoxide"`
|
||||
Keybindings KeybindingsConfig `toml:"keybindings"`
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ func TestDefaultConfigAndState(t *testing.T) {
|
||||
if !cfg.Core.CobraProbeEnabled {
|
||||
t.Errorf("expected cobra probing to be enabled by default")
|
||||
}
|
||||
if cfg.Zoxide.ExtendCd {
|
||||
t.Errorf("expected zoxide cd extension to be disabled by default")
|
||||
}
|
||||
|
||||
// test manual provider registration
|
||||
cfg.AI.Provider = "custom"
|
||||
|
||||
@@ -47,6 +47,9 @@ func DefaultConfig() *Config {
|
||||
MinIntervalMS: 5000,
|
||||
},
|
||||
},
|
||||
Zoxide: ZoxideConfig{
|
||||
ExtendCd: false,
|
||||
},
|
||||
Keybindings: KeybindingsConfig{
|
||||
ToggleMode: "ctrl+r",
|
||||
ToggleMenu: "shift+tab",
|
||||
|
||||
@@ -97,6 +97,10 @@ filter-active-branch = true
|
||||
# merge remote and local branches with same name
|
||||
deduplicate-branches = true
|
||||
|
||||
[zoxide]
|
||||
# include zoxide directories in cd suggestions
|
||||
extend-cd = false
|
||||
|
||||
[updater]
|
||||
# check for updates on startup
|
||||
check-on-startup = true
|
||||
|
||||
@@ -37,6 +37,9 @@ func TestConfigCommands(t *testing.T) {
|
||||
if !strings.Contains(string(content), "shell-login = false") {
|
||||
t.Error("expected initialized config to include shell-login = false")
|
||||
}
|
||||
if !strings.Contains(string(content), "extend-cd = false") {
|
||||
t.Error("expected initialized config to include extend-cd = false")
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
ConfigShowCmd.SetOut(buf)
|
||||
|
||||
@@ -276,6 +276,10 @@ filter-active-branch = true
|
||||
# merge remote and local branches with same name
|
||||
deduplicate-branches = true
|
||||
|
||||
[zoxide]
|
||||
# include zoxide directories in cd suggestions
|
||||
extend-cd = false
|
||||
|
||||
[updater]
|
||||
# check for updates on startup
|
||||
check-on-startup = true
|
||||
|
||||
+22
-6
@@ -2,6 +2,7 @@ package spec
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -274,15 +275,15 @@ func Lookup(input string) []Suggestion {
|
||||
}
|
||||
|
||||
suggested := g.Cmd
|
||||
if strings.Contains(suggested, " ") && !strings.HasPrefix(suggested, "\"") {
|
||||
suggested = "\"" + suggested + "\""
|
||||
if filepath.IsAbs(g.Cmd) || strings.ContainsAny(g.Cmd, " \t\r\n") {
|
||||
suggested = quoteSuggestionArgument(g.Cmd)
|
||||
}
|
||||
|
||||
// if the suggestion is a full path that includes
|
||||
// words already in the command line (multi-word support), we replace
|
||||
// the entire argument part by using prefix only
|
||||
// Absolute results replace all typed arguments, avoiding duplication
|
||||
// for multi-word directory matches.
|
||||
finalCmd := ""
|
||||
if len(tokens) > depth+1 && strings.HasPrefix(g.Cmd, tokens[depth]) {
|
||||
replaceArguments := strings.HasPrefix(g.Cmd, tokens[depth]) || filepath.IsAbs(g.Cmd)
|
||||
if len(tokens) > depth+1 && replaceArguments {
|
||||
finalCmd = prefix + " " + suggested
|
||||
} else if genLinePrefix != "" {
|
||||
finalCmd = genLinePrefix + " " + suggested
|
||||
@@ -382,6 +383,21 @@ func Lookup(input string) []Suggestion {
|
||||
return results
|
||||
}
|
||||
|
||||
func quoteSuggestionArgument(argument string) string {
|
||||
prefix := ""
|
||||
value := argument
|
||||
if strings.HasPrefix(value, "~/") {
|
||||
prefix = "~/"
|
||||
value = strings.TrimPrefix(value, prefix)
|
||||
}
|
||||
if !strings.ContainsAny(value, " \t\r\n'\"\\$`!;&|<>()*?[]{}#^") {
|
||||
return argument
|
||||
}
|
||||
|
||||
escaped := strings.ReplaceAll(value, "'", `'\''`)
|
||||
return prefix + "'" + escaped + "'"
|
||||
}
|
||||
|
||||
func topLevelSuggestions(query string, aliases map[string]string) []Suggestion {
|
||||
scanExternalCommands()
|
||||
results, seen := []Suggestion{}, make(map[string]bool)
|
||||
|
||||
+17
-1
@@ -173,6 +173,23 @@ func TestLookup_NvimFileGenerator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteSuggestionArgument(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"/tmp/project": "/tmp/project",
|
||||
"/tmp/Проект": "/tmp/Проект",
|
||||
"/tmp/My Project": `'/tmp/My Project'`,
|
||||
"/tmp/$Project`command`": "'/tmp/$Project`command`'",
|
||||
`/tmp/quote"and'apostrophe`: "'/tmp/quote\"and'\\''apostrophe'",
|
||||
"~/My Project": `~/'My Project'`,
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := quoteSuggestionArgument(input); got != want {
|
||||
t.Errorf("quoteSuggestionArgument(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookup_OptionAndFilePriority(t *testing.T) {
|
||||
ResetRegistry()
|
||||
Register(&Spec{
|
||||
@@ -389,4 +406,3 @@ func TestLookup_RealGitProvider(t *testing.T) {
|
||||
t.Errorf("expected 'git recent' in results for real GitProvider, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +1,47 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/versenilvis/iris/commands/fs"
|
||||
)
|
||||
|
||||
func TestZoxideGenerator(t *testing.T) {
|
||||
// Setup: Create a mock zoxide binary
|
||||
tmp := t.TempDir()
|
||||
mockZoxide := filepath.Join(tmp, "zoxide")
|
||||
dataDir := t.TempDir()
|
||||
project := filepath.Join(dataDir, "project1")
|
||||
docs := filepath.Join(dataDir, "docs")
|
||||
iris := filepath.Join(dataDir, "dev", "iris")
|
||||
missing := filepath.Join(dataDir, "deleted")
|
||||
for _, path := range []string{project, docs, iris} {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatalf("create directory %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Script that prints mock directories
|
||||
script := "#!/bin/sh\necho \"/home/verse/project1\n/home/verse/docs\n/home/verse/dev/iris\""
|
||||
_ = os.WriteFile(mockZoxide, []byte(script), 0755)
|
||||
|
||||
t.Setenv("PATH", tmp+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
binDir := t.TempDir()
|
||||
mockZoxide := filepath.Join(binDir, "zoxide")
|
||||
script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' %q %q %q %q\n", project, docs, iris, missing)
|
||||
if err := os.WriteFile(mockZoxide, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("create mock zoxide: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
gen := fs.ZoxideGenerator()
|
||||
|
||||
|
||||
t.Run("Query returns correct result when partial is empty", func(t *testing.T) {
|
||||
results := gen([]string{"z", ""}, "z ", "")
|
||||
if len(results) == 0 {
|
||||
t.Errorf("Expected results from zoxide history, got 0")
|
||||
results := gen([]string{"z", ""}, "z ", "")
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("suggestion count = %d, want 3: %#v", len(results), results)
|
||||
}
|
||||
for _, result := range results {
|
||||
if result.Cmd == missing {
|
||||
t.Fatalf("missing directory was suggested: %#v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
t.Run("Path replaces home dir with ~", func(t *testing.T) {
|
||||
home, _ := os.UserHomeDir()
|
||||
results := gen([]string{"z", ""}, "z ", "")
|
||||
foundHome := false
|
||||
for _, r := range results {
|
||||
if strings.HasPrefix(r.Desc, "~") {
|
||||
foundHome = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundHome && home != "" {
|
||||
t.Logf("Warning: Did not find ~ in descriptions, home is %s", home)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
t.Run("Sort by descending score", func(t *testing.T) {
|
||||
results := gen([]string{"z", "i"}, "z ", "i")
|
||||
if len(results) >= 1 {
|
||||
if results[0].Cmd == "" {
|
||||
t.Errorf("Empty result command")
|
||||
}
|
||||
}
|
||||
})
|
||||
results = gen([]string{"z", "iris"}, "z ", "iris")
|
||||
if len(results) != 1 || results[0].Cmd != iris {
|
||||
t.Fatalf("fuzzy suggestions = %#v, want %q", results, iris)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user