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 `!`)
433 lines
11 KiB
Go
433 lines
11 KiB
Go
package spec
|
|
|
|
import (
|
|
"maps"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/versenilvis/iris/integration/shell"
|
|
"github.com/versenilvis/iris/internal/logger"
|
|
"github.com/versenilvis/iris/spec/alias"
|
|
)
|
|
|
|
var (
|
|
ShellAliases = map[string]string{}
|
|
shellAliasesMu sync.RWMutex
|
|
)
|
|
|
|
func GetAlias(name string) (string, bool) {
|
|
shellAliasesMu.RLock()
|
|
defer shellAliasesMu.RUnlock()
|
|
val, ok := ShellAliases[name]
|
|
return val, ok
|
|
}
|
|
|
|
func GetAliasesCopy() map[string]string {
|
|
shellAliasesMu.RLock()
|
|
defer shellAliasesMu.RUnlock()
|
|
cp := make(map[string]string, len(ShellAliases))
|
|
maps.Copy(cp, ShellAliases)
|
|
return cp
|
|
}
|
|
|
|
// Lookup finds matching suggestions for your input by looking at how many words you typed
|
|
// it changes aliases to real commands, finds subcommands inside others, and runs generators for more suggestions
|
|
// e.g. Lookup("git che") -> suggests "git checkout"
|
|
// e.g. Lookup("git checkout ") -> suggests branch names via generator
|
|
func Lookup(input string) []Suggestion {
|
|
if shell.Current != nil {
|
|
aliases := shell.Current.ScanAliases()
|
|
shellAliasesMu.Lock()
|
|
ShellAliases = aliases
|
|
shellAliasesMu.Unlock()
|
|
}
|
|
|
|
shellAliasesMu.RLock()
|
|
aliases := ShellAliases
|
|
shellAliasesMu.RUnlock()
|
|
|
|
if input == "" {
|
|
return topLevelSuggestions("", aliases)
|
|
}
|
|
|
|
tokens := Tokenize(input)
|
|
originalTokens := append([]string(nil), tokens...)
|
|
originalPrefix := ""
|
|
expandedPrefix := ""
|
|
|
|
if len(tokens) == 1 && tokens[0] == "" {
|
|
return topLevelSuggestions("", aliases)
|
|
}
|
|
// NOTE: remember that wrapper already check if we type nothing, but I just want to make sure
|
|
// in the future, maybe we will write unit test or using Lookup in another module
|
|
// it will be safer because we maybe forget to check it in that module
|
|
|
|
scanExternalCommands()
|
|
|
|
// if you have an alias in your shell config like: alias nv="nvim"
|
|
// expand it even when there's only one token (e.g. "nv ") so that the
|
|
// target spec's Generator (FileGenerator etc.) can still fire.
|
|
// only expand when there's a trailing space — i.e. the user has committed
|
|
// to the alias name and is now typing arguments (tokens last elem == "")
|
|
hasTrailingSpace := len(tokens) > 0 && tokens[len(tokens)-1] == ""
|
|
if hasTrailingSpace || len(tokens) > 1 {
|
|
if target, ok := aliases[tokens[0]]; ok {
|
|
aliasTokens := Tokenize(target)
|
|
if len(aliasTokens) > 0 && aliasTokens[len(aliasTokens)-1] == "" {
|
|
aliasTokens = aliasTokens[:len(aliasTokens)-1]
|
|
}
|
|
tokens = append(aliasTokens, tokens[1:]...)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
query := tokens[0]
|
|
results := topLevelSuggestions(query, aliases)
|
|
|
|
if spec, exists := Registry[query]; exists {
|
|
hasTrailingSpace := query != "" && query[len(query)-1] == ' '
|
|
|
|
if hasTrailingSpace {
|
|
partial := ""
|
|
prefix := query
|
|
|
|
for _, sub := range spec.Subcommands {
|
|
results = append(results, Suggestion{
|
|
Cmd: strings.TrimSpace(query) + " " + sub.Name, Desc: sub.Description, Icon: query, Priority: sub.Priority,
|
|
})
|
|
}
|
|
if spec.Generator != nil {
|
|
genResults := spec.Generator(tokens, prefix, partial)
|
|
for _, g := range genResults {
|
|
results = append(results, Suggestion{
|
|
Cmd: strings.TrimSpace(query) + " " + g.Cmd, Desc: g.Desc, Icon: query, Priority: g.Priority,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
rootCmdName := tokens[0]
|
|
spec, exists := Registry[rootCmdName]
|
|
logger.Debugf("core lookup tokens: %v, registry exists: %v", tokens, exists)
|
|
if !exists {
|
|
partial := tokens[len(tokens)-1]
|
|
args := tokens[1:]
|
|
if len(args) > 0 && args[len(args)-1] == partial {
|
|
args = args[:len(args)-1]
|
|
}
|
|
if cobSugg := QueryCobraComplete(rootCmdName, args, partial); len(cobSugg) > 0 {
|
|
return cobSugg
|
|
}
|
|
return nil
|
|
}
|
|
|
|
currentSubs, currentOpts, currentGen := spec.Subcommands, spec.Options, spec.Generator
|
|
depth := 1
|
|
|
|
for depth < len(tokens)-1 {
|
|
tok := tokens[depth]
|
|
if tok == "" || strings.HasPrefix(tok, "-") || strings.Contains(tok, "=") {
|
|
depth++
|
|
continue
|
|
}
|
|
|
|
found := false
|
|
for _, sub := range currentSubs {
|
|
match := sub.Name == tok
|
|
if !match {
|
|
if slices.Contains(sub.Aliases, tok) {
|
|
match = true
|
|
}
|
|
}
|
|
|
|
if match {
|
|
currentSubs, currentOpts, currentGen = sub.Subcommands, sub.Options, sub.Generator
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if found {
|
|
depth++
|
|
continue
|
|
}
|
|
// invalid subcommand word typed
|
|
if len(currentSubs) > 0 && (!isAliasExpanded || depth > aliasExpansionEnd) {
|
|
return nil
|
|
}
|
|
break
|
|
}
|
|
|
|
results := []Suggestion{}
|
|
|
|
currentLimit := spec.MaxArgs
|
|
tempSubs := spec.Subcommands
|
|
for i := 1; i < depth; i++ {
|
|
tok := tokens[i]
|
|
for _, sub := range tempSubs {
|
|
if sub.Name == tok {
|
|
currentLimit = sub.MaxArgs
|
|
tempSubs = sub.Subcommands
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
argCount := 0
|
|
for i := depth; i < len(tokens)-1; i++ {
|
|
t := tokens[i]
|
|
if t != "" && !strings.HasPrefix(t, "-") && !strings.Contains(t, "=") {
|
|
argCount++
|
|
}
|
|
}
|
|
|
|
partial := tokens[len(tokens)-1]
|
|
allowMoreArgs := currentLimit <= 0 || argCount < currentLimit
|
|
|
|
logger.Debugf("core query tokens: %v (partial: '%s')", tokens, partial)
|
|
logger.Debugf("core depth: %d, argCount: %d, limit: %d, allowMore: %v", depth, argCount, currentLimit, allowMoreArgs)
|
|
|
|
prefixBuilder := strings.Builder{}
|
|
for i := 0; i < depth; i++ {
|
|
if i > 0 {
|
|
prefixBuilder.WriteByte(' ')
|
|
}
|
|
prefixBuilder.WriteString(tokens[i])
|
|
}
|
|
prefix := prefixBuilder.String()
|
|
|
|
linePrefixBuilder := strings.Builder{}
|
|
for i := 0; i < len(tokens)-1; i++ {
|
|
if i > 0 {
|
|
linePrefixBuilder.WriteByte(' ')
|
|
}
|
|
linePrefixBuilder.WriteString(tokens[i])
|
|
}
|
|
linePrefix := linePrefixBuilder.String()
|
|
|
|
if currentGen != nil && allowMoreArgs {
|
|
genPartial := partial
|
|
genLinePrefix := linePrefix
|
|
|
|
if partial == "" && len(tokens) >= 3 {
|
|
prevToken := tokens[len(tokens)-2]
|
|
if strings.HasSuffix(prevToken, "/") || strings.HasSuffix(prevToken, "\\") {
|
|
genPartial = prevToken
|
|
lpBuilder := strings.Builder{}
|
|
for i := 0; i < len(tokens)-2; i++ {
|
|
if i > 0 {
|
|
lpBuilder.WriteByte(' ')
|
|
}
|
|
lpBuilder.WriteString(tokens[i])
|
|
}
|
|
genLinePrefix = lpBuilder.String()
|
|
}
|
|
}
|
|
|
|
genResults := currentGen(tokens, prefix, genPartial)
|
|
|
|
for _, g := range genResults {
|
|
if genPartial != "" && !HasPrefix(g.Cmd, genPartial) && !strings.Contains(g.Cmd, genPartial) {
|
|
continue
|
|
}
|
|
|
|
suggested := g.Cmd
|
|
if strings.Contains(suggested, " ") && !strings.HasPrefix(suggested, "\"") {
|
|
suggested = "\"" + suggested + "\""
|
|
}
|
|
|
|
// 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
|
|
finalCmd := ""
|
|
if len(tokens) > depth+1 && strings.HasPrefix(g.Cmd, tokens[depth]) {
|
|
finalCmd = prefix + " " + suggested
|
|
} else if genLinePrefix != "" {
|
|
finalCmd = genLinePrefix + " " + suggested
|
|
} else {
|
|
finalCmd = suggested
|
|
}
|
|
|
|
newTokens := Tokenize(finalCmd)
|
|
if len(newTokens) > 0 {
|
|
lastToken := newTokens[len(newTokens)-1]
|
|
isDuplicate := false
|
|
for i := 0; i < len(tokens)-1; i++ {
|
|
if tokens[i] == lastToken {
|
|
isDuplicate = true
|
|
break
|
|
}
|
|
}
|
|
if isDuplicate {
|
|
continue
|
|
}
|
|
}
|
|
|
|
results = append(results, Suggestion{
|
|
Cmd: finalCmd,
|
|
Desc: g.Desc,
|
|
Icon: rootCmdName,
|
|
Priority: g.Priority,
|
|
})
|
|
}
|
|
}
|
|
|
|
if allowMoreArgs {
|
|
for _, sub := range currentSubs {
|
|
if partial == "" || HasPrefix(sub.Name, partial) {
|
|
results = append(results, Suggestion{
|
|
Cmd: prefix + " " + sub.Name, Desc: sub.Description, Icon: rootCmdName, Priority: sub.Priority,
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
for _, t := range tokens {
|
|
if strings.HasPrefix(t, "-") {
|
|
usedOpts[t] = true
|
|
}
|
|
}
|
|
for _, opt := range currentOpts {
|
|
trimmedOpt := strings.TrimLeft(opt.Name, "-")
|
|
if !usedOpts[opt.Name] && (partial == "" || HasPrefix(opt.Name, partial) || HasPrefix(trimmedOpt, partial)) {
|
|
optPriority := opt.Priority
|
|
if optPriority == 0 {
|
|
if strings.HasPrefix(partial, "-") {
|
|
optPriority = 80
|
|
} else {
|
|
optPriority = 10
|
|
}
|
|
}
|
|
results = append(results, Suggestion{
|
|
Cmd: linePrefix + " " + opt.Name, Desc: opt.Description, Icon: rootCmdName, Priority: optPriority,
|
|
})
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func topLevelSuggestions(query string, aliases map[string]string) []Suggestion {
|
|
scanExternalCommands()
|
|
results, seen := []Suggestion{}, make(map[string]bool)
|
|
|
|
for name, target := range aliases {
|
|
if !seen[name] && (query == "" || HasPrefix(name, query)) {
|
|
results = append(results, Suggestion{
|
|
Cmd: name, Desc: target, Icon: "alias",
|
|
})
|
|
seen[name] = true
|
|
}
|
|
}
|
|
|
|
for name, spec := range Registry {
|
|
if seen[name] {
|
|
continue
|
|
}
|
|
match := false
|
|
if query == "" || HasPrefix(name, query) {
|
|
match = true
|
|
} else {
|
|
for _, a := range spec.Aliases {
|
|
if HasPrefix(a, query) {
|
|
match = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if match {
|
|
icon := spec.Icon
|
|
if icon == "" {
|
|
icon = name
|
|
}
|
|
results = append(results, Suggestion{Cmd: name, Desc: spec.Description, Icon: icon})
|
|
seen[name] = true
|
|
}
|
|
}
|
|
|
|
for name := range pathCmds {
|
|
if !seen[name] && (query == "" || HasPrefix(name, query)) {
|
|
results = append(results, Suggestion{
|
|
Cmd: name, Desc: "system command", Icon: "system",
|
|
})
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|