feat(spec): support tool aliases (#107)
closes #57 IRIS now automatically read and suggest internal configuration aliases for Git (`.gitconfig`, `.git/config`) and Cargo (`.cargo/config.toml`) - Added the `Provider` interface and extended registry for CLI tools that define aliases - Git: parse aliases via `git config --show-scope`, supporting raw tab/space separation and scope differentiation - Cargo: traverse the directory tree upwards to root to read the `.cargo/config.toml` configuration - 0ms hot-path caching: key caching is calculated entirely via `os.Stat` mtime (no subprocess spawn) combined with a 2-second TTL window to ensure 0ms latency when typing - Lookup integration: display alias suggestions at the subcommand level and automatically expand aliases as the user enters further arguments (ignore shell pipeline commands starting with `!`)
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
package root
|
||||||
|
|
||||||
|
import "github.com/versenilvis/iris/spec/alias"
|
||||||
|
|
||||||
|
type mockGitProvider struct{}
|
||||||
|
|
||||||
|
func (m *mockGitProvider) ToolName() string { return "git" }
|
||||||
|
func (m *mockGitProvider) GetAliases(cwd string) []alias.AliasEntry {
|
||||||
|
return []alias.AliasEntry{{Name: "co", Expansion: "checkout", Scope: "local"}}
|
||||||
|
}
|
||||||
+4
-1
@@ -25,7 +25,10 @@ func MergeResults(query string, mode string) []spec.Suggestion {
|
|||||||
// add suggestion helper to deduplicate
|
// add suggestion helper to deduplicate
|
||||||
addSuggestion := func(s spec.Suggestion) {
|
addSuggestion := func(s spec.Suggestion) {
|
||||||
normalizedCmd := strings.TrimSpace(s.Cmd)
|
normalizedCmd := strings.TrimSpace(s.Cmd)
|
||||||
if normalizedCmd == "" || normalizedCmd == normalizedQuery {
|
if normalizedCmd == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.Source != "alias" && normalizedCmd == normalizedQuery {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.Source == "" {
|
if s.Source == "" {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/versenilvis/iris/spec"
|
"github.com/versenilvis/iris/spec"
|
||||||
|
"github.com/versenilvis/iris/spec/alias"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMergeResults(t *testing.T) {
|
func TestMergeResults(t *testing.T) {
|
||||||
@@ -48,6 +49,22 @@ func TestMergeResults(t *testing.T) {
|
|||||||
t.Errorf("Expected promoted source 'ai', got %q", res[0].Source)
|
t.Errorf("Expected promoted source 'ai', got %q", res[0].Source)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Tool Alias Preserved When Matching Query", func(t *testing.T) {
|
||||||
|
alias.Register(&mockGitProvider{})
|
||||||
|
defer alias.Reset()
|
||||||
|
res := MergeResults("git co", "default")
|
||||||
|
foundCo := false
|
||||||
|
for _, r := range res {
|
||||||
|
if r.Cmd == "git co" {
|
||||||
|
foundCo = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundCo {
|
||||||
|
t.Errorf("expected 'git co' alias suggestion in results for query 'git co', got %v", res)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPrevRecordedCommandState(t *testing.T) {
|
func TestPrevRecordedCommandState(t *testing.T) {
|
||||||
|
|||||||
@@ -821,6 +821,7 @@ func runWrapper() {
|
|||||||
overlay.ClearGhostTextState()
|
overlay.ClearGhostTextState()
|
||||||
userNavigated.Store(false)
|
userNavigated.Store(false)
|
||||||
writeStdout([]byte(overlay.Render()))
|
writeStdout([]byte(overlay.Render()))
|
||||||
|
renderOverlay()
|
||||||
}
|
}
|
||||||
i += consumed - 1
|
i += consumed - 1
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package alias
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CargoProvider struct {
|
||||||
|
cacheKey string
|
||||||
|
cached []AliasEntry
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CargoProvider) ToolName() string {
|
||||||
|
return "cargo"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CargoProvider) GetAliases(cwd string) []AliasEntry {
|
||||||
|
key := p.buildCacheKey(cwd)
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if p.cacheKey != "" && p.cacheKey == key {
|
||||||
|
return p.cached
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cached = p.parse(cwd)
|
||||||
|
p.cacheKey = key
|
||||||
|
return p.cached
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CargoProvider) buildCacheKey(cwd string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(cwd)
|
||||||
|
|
||||||
|
dir := cwd
|
||||||
|
for {
|
||||||
|
localConfig := filepath.Join(dir, ".cargo", "config.toml")
|
||||||
|
if info, err := os.Stat(localConfig); err == nil {
|
||||||
|
fmt.Fprintf(&sb, "|local:%s", info.ModTime().String())
|
||||||
|
}
|
||||||
|
localConfig2 := filepath.Join(dir, ".cargo", "config")
|
||||||
|
if info, err := os.Stat(localConfig2); err == nil {
|
||||||
|
fmt.Fprintf(&sb, "|local:%s", info.ModTime().String())
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
if cargoHome := os.Getenv("CARGO_HOME"); cargoHome != "" {
|
||||||
|
globalConfig := filepath.Join(cargoHome, "config.toml")
|
||||||
|
if info, err := os.Stat(globalConfig); err == nil {
|
||||||
|
fmt.Fprintf(&sb, "|global:%s", info.ModTime().String())
|
||||||
|
}
|
||||||
|
} else if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
globalConfig := filepath.Join(home, ".cargo", "config.toml")
|
||||||
|
if info, err := os.Stat(globalConfig); err == nil {
|
||||||
|
fmt.Fprintf(&sb, "|global:%s", info.ModTime().String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CargoProvider) parse(cwd string) []AliasEntry {
|
||||||
|
var rawEntries []AliasEntry
|
||||||
|
|
||||||
|
dir := cwd
|
||||||
|
for {
|
||||||
|
rawEntries = append(rawEntries, p.parseFile(filepath.Join(dir, ".cargo", "config.toml"), "local")...)
|
||||||
|
rawEntries = append(rawEntries, p.parseFile(filepath.Join(dir, ".cargo", "config"), "local")...)
|
||||||
|
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
if cargoHome := os.Getenv("CARGO_HOME"); cargoHome != "" {
|
||||||
|
rawEntries = append(rawEntries, p.parseFile(filepath.Join(cargoHome, "config.toml"), "global")...)
|
||||||
|
rawEntries = append(rawEntries, p.parseFile(filepath.Join(cargoHome, "config"), "global")...)
|
||||||
|
} else if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
rawEntries = append(rawEntries, p.parseFile(filepath.Join(home, ".cargo", "config.toml"), "global")...)
|
||||||
|
rawEntries = append(rawEntries, p.parseFile(filepath.Join(home, ".cargo", "config"), "global")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var entries []AliasEntry
|
||||||
|
for _, e := range rawEntries {
|
||||||
|
if !seen[e.Name] {
|
||||||
|
seen[e.Name] = true
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CargoProvider) parseFile(path, scope string) []AliasEntry {
|
||||||
|
var config struct {
|
||||||
|
Alias map[string]interface{} `toml:"alias"`
|
||||||
|
}
|
||||||
|
if _, err := toml.DecodeFile(path, &config); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []AliasEntry
|
||||||
|
for k, v := range config.Alias {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
entries = append(entries, AliasEntry{Name: k, Expansion: val, Scope: scope})
|
||||||
|
case []interface{}:
|
||||||
|
var parts []string
|
||||||
|
for _, item := range val {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
parts = append(parts, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries = append(entries, AliasEntry{Name: k, Expansion: strings.Join(parts, " "), Scope: scope})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&CargoProvider{})
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package alias
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GitProvider struct {
|
||||||
|
cacheKey string
|
||||||
|
cached []AliasEntry
|
||||||
|
lastCheck time.Time
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GitProvider) ToolName() string {
|
||||||
|
return "git"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GitProvider) GetAliases(cwd string) []AliasEntry {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if p.cached != nil && now.Sub(p.lastCheck) < 2*time.Second {
|
||||||
|
return p.cached
|
||||||
|
}
|
||||||
|
|
||||||
|
key := p.buildCacheKey(cwd)
|
||||||
|
p.lastCheck = now
|
||||||
|
|
||||||
|
if p.cacheKey != "" && p.cacheKey == key {
|
||||||
|
return p.cached
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cached = p.parse(cwd)
|
||||||
|
p.cacheKey = key
|
||||||
|
return p.cached
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GitProvider) buildCacheKey(cwd string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(cwd)
|
||||||
|
|
||||||
|
// Check local config via fast os.Stat traversal
|
||||||
|
if configPath := resolveGitConfigPath(cwd); configPath != "" {
|
||||||
|
if info, err := os.Stat(configPath); err == nil {
|
||||||
|
fmt.Fprintf(&sb, "|local:%s", info.ModTime().String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check global config
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
globalConfig := filepath.Join(home, ".gitconfig")
|
||||||
|
if info, err := os.Stat(globalConfig); err == nil {
|
||||||
|
fmt.Fprintf(&sb, "|global:%s", info.ModTime().String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGitConfigPath(cwd string) string {
|
||||||
|
dir := cwd
|
||||||
|
for dir != "" {
|
||||||
|
gitPath := filepath.Join(dir, ".git")
|
||||||
|
info, err := os.Stat(gitPath)
|
||||||
|
if err == nil {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.Join(gitPath, "config")
|
||||||
|
}
|
||||||
|
content, errRead := os.ReadFile(gitPath)
|
||||||
|
if errRead == nil {
|
||||||
|
s := strings.TrimSpace(string(content))
|
||||||
|
if after, ok := strings.CutPrefix(s, "gitdir: "); ok {
|
||||||
|
gitDir := strings.TrimSpace(after)
|
||||||
|
if !filepath.IsAbs(gitDir) {
|
||||||
|
gitDir = filepath.Join(dir, gitDir)
|
||||||
|
}
|
||||||
|
return filepath.Join(gitDir, "config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, ".git", "config")
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GitProvider) parse(cwd string) []AliasEntry {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Try with --show-scope first
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "config", "--get-regexp", "--show-scope", "^alias\\.")
|
||||||
|
cmd.Dir = cwd
|
||||||
|
out, err := cmd.Output()
|
||||||
|
hasScope := true
|
||||||
|
if err != nil {
|
||||||
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel2()
|
||||||
|
// Fallback for older git
|
||||||
|
cmd = exec.CommandContext(ctx2, "git", "config", "--get-regexp", "^alias\\.")
|
||||||
|
cmd.Dir = cwd
|
||||||
|
out, err = cmd.Output()
|
||||||
|
hasScope = false
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p.parseOutput(out, hasScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GitProvider) parseOutput(out []byte, hasScope bool) []AliasEntry {
|
||||||
|
var entries []AliasEntry
|
||||||
|
lines := strings.Split(string(bytes.TrimSpace(out)), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasScope {
|
||||||
|
idx := strings.IndexAny(line, " \t")
|
||||||
|
if idx == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scope := line[:idx]
|
||||||
|
rest := strings.TrimSpace(line[idx:])
|
||||||
|
parts := strings.SplitN(rest, " ", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
name := strings.TrimPrefix(parts[0], "alias.")
|
||||||
|
entries = append(entries, AliasEntry{
|
||||||
|
Name: name,
|
||||||
|
Expansion: parts[1],
|
||||||
|
Scope: scope,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts := strings.SplitN(line, " ", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
name := strings.TrimPrefix(parts[0], "alias.")
|
||||||
|
entries = append(entries, AliasEntry{
|
||||||
|
Name: name,
|
||||||
|
Expansion: parts[1],
|
||||||
|
Scope: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&GitProvider{})
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package alias
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AliasEntry struct {
|
||||||
|
Name string
|
||||||
|
Expansion string
|
||||||
|
Scope string // "local" | "global"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Provider interface {
|
||||||
|
ToolName() string
|
||||||
|
GetAliases(cwd string) []AliasEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
providers = make(map[string]Provider)
|
||||||
|
mu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func Register(p Provider) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
providers[p.ToolName()] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the registered providers map, restoring it to its initial empty state.
|
||||||
|
func Reset() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
providers = make(map[string]Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetProvider(toolName string) Provider {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
return providers[toolName]
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package alias
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCargoProvider(t *testing.T) {
|
||||||
|
t.Setenv("CARGO_HOME", t.TempDir())
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
cargoDir := filepath.Join(tempDir, ".cargo")
|
||||||
|
if err := os.Mkdir(cargoDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configToml := `
|
||||||
|
[alias]
|
||||||
|
b = "build"
|
||||||
|
c = "check"
|
||||||
|
t = ["test", "--", "--nocapture"]
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(cargoDir, "config.toml"), []byte(configToml), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &CargoProvider{}
|
||||||
|
aliases := p.parse(tempDir)
|
||||||
|
|
||||||
|
expected := map[string]string{
|
||||||
|
"b": "build",
|
||||||
|
"c": "check",
|
||||||
|
"t": "test -- --nocapture",
|
||||||
|
}
|
||||||
|
|
||||||
|
aliasMap := make(map[string]string)
|
||||||
|
for _, a := range aliases {
|
||||||
|
aliasMap[a.Name] = a.Expansion
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, wantExp := range expected {
|
||||||
|
gotExp, ok := aliasMap[k]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("expected alias %q to be present", k)
|
||||||
|
} else if gotExp != wantExp {
|
||||||
|
t.Errorf("expected alias %q to map to %q, got %q", k, wantExp, gotExp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitProviderParseOutput(t *testing.T) {
|
||||||
|
outputWithScope := []byte("global\talias.recent !git for-each-ref --sort=committerdate --format=\"%(committerdate:relative) %(refname:short)\" refs/heads/ | tail -10\nlocal\talias.co checkout\n")
|
||||||
|
p := &GitProvider{}
|
||||||
|
entries := p.parseOutput(outputWithScope, true)
|
||||||
|
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d: %v", len(entries), entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries[0].Name != "recent" {
|
||||||
|
t.Errorf("expected name 'recent', got %q", entries[0].Name)
|
||||||
|
}
|
||||||
|
if entries[0].Scope != "global" {
|
||||||
|
t.Errorf("expected scope 'global', got %q", entries[0].Scope)
|
||||||
|
}
|
||||||
|
if entries[0].Expansion != "!git for-each-ref --sort=committerdate --format=\"%(committerdate:relative) %(refname:short)\" refs/heads/ | tail -10" {
|
||||||
|
t.Errorf("unexpected expansion: %q", entries[0].Expansion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries[1].Name != "co" {
|
||||||
|
t.Errorf("expected name 'co', got %q", entries[1].Name)
|
||||||
|
}
|
||||||
|
if entries[1].Scope != "local" {
|
||||||
|
t.Errorf("expected scope 'local', got %q", entries[1].Scope)
|
||||||
|
}
|
||||||
|
if entries[1].Expansion != "checkout" {
|
||||||
|
t.Errorf("expected expansion 'checkout', got %q", entries[1].Expansion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetProvider_UnregisteredCommand(t *testing.T) {
|
||||||
|
p := GetProvider("unregistered_command_xyz")
|
||||||
|
if p != nil {
|
||||||
|
t.Errorf("expected nil for unregistered command, got %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGitConfigPath_WalkUp(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
gitDir := filepath.Join(tempDir, ".git")
|
||||||
|
_ = os.Mkdir(gitDir, 0755)
|
||||||
|
configPath := filepath.Join(gitDir, "config")
|
||||||
|
_ = os.WriteFile(configPath, []byte("[alias]\nst = status"), 0644)
|
||||||
|
|
||||||
|
subDir := filepath.Join(tempDir, "a", "b", "c")
|
||||||
|
_ = os.MkdirAll(subDir, 0755)
|
||||||
|
|
||||||
|
resolved := resolveGitConfigPath(subDir)
|
||||||
|
if resolved != configPath {
|
||||||
|
t.Errorf("expected %q, got %q", configPath, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGitConfigPath_WorktreeFile(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
mainGitDir := filepath.Join(tempDir, "mainrepo", ".git")
|
||||||
|
_ = os.MkdirAll(mainGitDir, 0755)
|
||||||
|
mainConfig := filepath.Join(mainGitDir, "config")
|
||||||
|
_ = os.WriteFile(mainConfig, []byte("[alias]\nst = status"), 0644)
|
||||||
|
|
||||||
|
wtDir := filepath.Join(tempDir, "wt")
|
||||||
|
_ = os.MkdirAll(wtDir, 0755)
|
||||||
|
wtGitFile := filepath.Join(wtDir, ".git")
|
||||||
|
_ = os.WriteFile(wtGitFile, []byte("gitdir: "+mainGitDir), 0644)
|
||||||
|
|
||||||
|
wtSubDir := filepath.Join(wtDir, "sub")
|
||||||
|
_ = os.MkdirAll(wtSubDir, 0755)
|
||||||
|
|
||||||
|
resolved := resolveGitConfigPath(wtSubDir)
|
||||||
|
if resolved != mainConfig {
|
||||||
|
t.Errorf("expected %q, got %q", mainConfig, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
-1
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/versenilvis/iris/integration/shell"
|
"github.com/versenilvis/iris/integration/shell"
|
||||||
"github.com/versenilvis/iris/internal/logger"
|
"github.com/versenilvis/iris/internal/logger"
|
||||||
|
"github.com/versenilvis/iris/spec/alias"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -51,6 +52,9 @@ func Lookup(input string) []Suggestion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokens := Tokenize(input)
|
tokens := Tokenize(input)
|
||||||
|
originalTokens := append([]string(nil), tokens...)
|
||||||
|
originalPrefix := ""
|
||||||
|
expandedPrefix := ""
|
||||||
|
|
||||||
if len(tokens) == 1 && tokens[0] == "" {
|
if len(tokens) == 1 && tokens[0] == "" {
|
||||||
return topLevelSuggestions("", aliases)
|
return topLevelSuggestions("", aliases)
|
||||||
@@ -77,6 +81,44 @@ func Lookup(input string) []Suggestion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expand tool aliases
|
||||||
|
isAliasExpanded := false
|
||||||
|
aliasExpansionEnd := 0
|
||||||
|
if len(tokens) > 2 {
|
||||||
|
rootCmdName := tokens[0]
|
||||||
|
if provider := alias.GetProvider(rootCmdName); provider != nil {
|
||||||
|
cwd := GetCWD()
|
||||||
|
toolAliases := provider.GetAliases(cwd)
|
||||||
|
subCmd := tokens[1]
|
||||||
|
for _, a := range toolAliases {
|
||||||
|
if a.Name == subCmd {
|
||||||
|
if strings.HasPrefix(a.Expansion, "!") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
aliasTokens := Tokenize(a.Expansion)
|
||||||
|
if len(aliasTokens) > 0 && aliasTokens[len(aliasTokens)-1] == "" {
|
||||||
|
aliasTokens = aliasTokens[:len(aliasTokens)-1]
|
||||||
|
}
|
||||||
|
if len(aliasTokens) > 0 && aliasTokens[0] == rootCmdName {
|
||||||
|
aliasTokens = aliasTokens[1:]
|
||||||
|
}
|
||||||
|
newTokens := make([]string, 0, len(tokens)-1+len(aliasTokens))
|
||||||
|
newTokens = append(newTokens, tokens[0])
|
||||||
|
newTokens = append(newTokens, aliasTokens...)
|
||||||
|
newTokens = append(newTokens, tokens[2:]...)
|
||||||
|
tokens = newTokens
|
||||||
|
isAliasExpanded = true
|
||||||
|
aliasExpansionEnd = len(aliasTokens)
|
||||||
|
expandedPrefix = strings.Join(tokens[:1+aliasExpansionEnd], " ")
|
||||||
|
if len(originalTokens) >= 2 {
|
||||||
|
originalPrefix = strings.Join(originalTokens[:2], " ")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(tokens) == 1 {
|
if len(tokens) == 1 {
|
||||||
query := tokens[0]
|
query := tokens[0]
|
||||||
results := topLevelSuggestions(query, aliases)
|
results := topLevelSuggestions(query, aliases)
|
||||||
@@ -152,7 +194,7 @@ func Lookup(input string) []Suggestion {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// invalid subcommand word typed
|
// invalid subcommand word typed
|
||||||
if len(currentSubs) > 0 {
|
if len(currentSubs) > 0 && (!isAliasExpanded || depth > aliasExpansionEnd) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -280,6 +322,30 @@ func Lookup(input string) []Suggestion {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if depth == 1 {
|
||||||
|
if provider := alias.GetProvider(rootCmdName); provider != nil {
|
||||||
|
cwd := GetCWD()
|
||||||
|
for _, a := range provider.GetAliases(cwd) {
|
||||||
|
if partial == "" || HasPrefix(a.Name, partial) {
|
||||||
|
priority := 70
|
||||||
|
switch a.Scope {
|
||||||
|
case "local", "worktree", "command":
|
||||||
|
priority = 85
|
||||||
|
case "system":
|
||||||
|
priority = 65
|
||||||
|
}
|
||||||
|
results = append(results, Suggestion{
|
||||||
|
Cmd: prefix + " " + a.Name,
|
||||||
|
Desc: "Alias for: " + a.Expansion,
|
||||||
|
Icon: rootCmdName,
|
||||||
|
Priority: priority,
|
||||||
|
Source: "alias",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
usedOpts := make(map[string]bool)
|
usedOpts := make(map[string]bool)
|
||||||
@@ -305,6 +371,14 @@ func Lookup(input string) []Suggestion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isAliasExpanded && expandedPrefix != "" && originalPrefix != "" {
|
||||||
|
for i := range results {
|
||||||
|
if strings.HasPrefix(results[i].Cmd, expandedPrefix) {
|
||||||
|
results[i].Cmd = originalPrefix + strings.TrimPrefix(results[i].Cmd, expandedPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/versenilvis/iris/spec/alias"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLookup(t *testing.T) {
|
func TestLookup(t *testing.T) {
|
||||||
@@ -231,3 +233,160 @@ func TestLookup_NestedDirectoryTrailingSpace(t *testing.T) {
|
|||||||
t.Errorf("expected hello.txt in results, got %v", results)
|
t.Errorf("expected hello.txt in results, got %v", results)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mockGitProvider struct{}
|
||||||
|
|
||||||
|
func (m *mockGitProvider) ToolName() string { return "git" }
|
||||||
|
func (m *mockGitProvider) GetAliases(cwd string) []alias.AliasEntry {
|
||||||
|
return []alias.AliasEntry{
|
||||||
|
{
|
||||||
|
Name: "co",
|
||||||
|
Expansion: "checkout",
|
||||||
|
Scope: "global",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "recent",
|
||||||
|
Expansion: "!git for-each-ref --sort=committerdate --format='%(committerdate:relative) %(refname:short)' refs/heads/ | tail -10",
|
||||||
|
Scope: "global",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "local-alias",
|
||||||
|
Expansion: "status",
|
||||||
|
Scope: "local",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookup_GitRecentAlias(t *testing.T) {
|
||||||
|
ResetRegistry()
|
||||||
|
Register(&Spec{
|
||||||
|
Name: "git",
|
||||||
|
Subcommands: []Subcommand{
|
||||||
|
{Name: "commit"},
|
||||||
|
{Name: "checkout", Options: []Option{{Name: "-b"}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
alias.Register(&mockGitProvider{})
|
||||||
|
t.Cleanup(alias.Reset)
|
||||||
|
|
||||||
|
// Test Type 1: Standard subcommand alias 'git co' -> 'git checkout'
|
||||||
|
resultsCo := Lookup("git co ")
|
||||||
|
foundCo := false
|
||||||
|
for _, r := range resultsCo {
|
||||||
|
if strings.Contains(r.Cmd, "-b") {
|
||||||
|
foundCo = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundCo {
|
||||||
|
t.Errorf("expected '-b' option after expanding 'git co ', got %v", resultsCo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Type 2: Shell pipeline alias 'git rec' -> 'git recent'
|
||||||
|
resultsRec := Lookup("git rec")
|
||||||
|
foundRec := false
|
||||||
|
for _, r := range resultsRec {
|
||||||
|
if strings.Contains(r.Cmd, "git recent") {
|
||||||
|
foundRec = true
|
||||||
|
if r.Priority != 70 {
|
||||||
|
t.Errorf("expected global alias priority 70, got %d", r.Priority)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundRec {
|
||||||
|
t.Errorf("expected 'git recent' suggestion for 'git rec', got %v", resultsRec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Local Scope Priority (Priority = 85)
|
||||||
|
resultsLocal := Lookup("git local")
|
||||||
|
foundLocal := false
|
||||||
|
for _, r := range resultsLocal {
|
||||||
|
if strings.Contains(r.Cmd, "git local-alias") {
|
||||||
|
foundLocal = true
|
||||||
|
if r.Priority != 85 {
|
||||||
|
t.Errorf("expected local alias priority 85, got %d", r.Priority)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundLocal {
|
||||||
|
t.Errorf("expected 'git local-alias' suggestion, got %v", resultsLocal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Type 2 expansion with trailing space: 'git recent ' (shell pipeline alias starting with !) should be skipped and return nil
|
||||||
|
resultsRecentSpace := Lookup("git recent ")
|
||||||
|
if len(resultsRecentSpace) != 0 {
|
||||||
|
t.Errorf("expected 0 suggestions for shell pipeline alias 'git recent ', got %v", resultsRecentSpace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookup_InvalidSubcommandAfterAliasExpansion(t *testing.T) {
|
||||||
|
ResetRegistry()
|
||||||
|
Register(&Spec{
|
||||||
|
Name: "git",
|
||||||
|
Subcommands: []Subcommand{
|
||||||
|
{Name: "commit"},
|
||||||
|
{Name: "status", Subcommands: []Subcommand{{Name: "porcelain"}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
alias.Register(&mockGitProvider{})
|
||||||
|
|
||||||
|
// token beyond the alias expansion must be validated against the expanded subcommand
|
||||||
|
results := Lookup("git local-alias badcmd ")
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Errorf("expected 0 suggestions for invalid subcommand after alias expansion, got %v", results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expansion itself is still allowed even when it is not a registered subcommand
|
||||||
|
resultsOk := Lookup("git co ")
|
||||||
|
if len(resultsOk) == 0 {
|
||||||
|
t.Errorf("expected suggestions for expanded alias 'git co ', got none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookup_RealGitProvider(t *testing.T) {
|
||||||
|
ResetRegistry()
|
||||||
|
Register(&Spec{
|
||||||
|
Name: "git",
|
||||||
|
})
|
||||||
|
alias.Reset()
|
||||||
|
|
||||||
|
// Isolate from the developer's real git configuration by pointing HOME at a temp dir
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
if err := os.WriteFile(filepath.Join(home, ".gitconfig"), []byte("[alias]\nrecent = for-each-ref --sort=committerdate refs/heads\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
provider := &alias.GitProvider{}
|
||||||
|
aliases := provider.GetAliases(cwd)
|
||||||
|
t.Logf("Real Git aliases: %v", aliases)
|
||||||
|
foundAlias := false
|
||||||
|
for _, a := range aliases {
|
||||||
|
if a.Name == "recent" {
|
||||||
|
foundAlias = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundAlias {
|
||||||
|
t.Skipf("git unavailable or could not read isolated config, got aliases: %v", aliases)
|
||||||
|
}
|
||||||
|
alias.Register(provider)
|
||||||
|
t.Cleanup(alias.Reset)
|
||||||
|
|
||||||
|
results := Lookup("git rec")
|
||||||
|
t.Logf("Lookup('git rec') results: %v", results)
|
||||||
|
found := false
|
||||||
|
for _, r := range results {
|
||||||
|
if strings.Contains(r.Cmd, "recent") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected 'git recent' in results for real GitProvider, got %v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user