refactor(spec): improve reading aliases in shell config
This commit is contained in:
+75
-93
@@ -3,7 +3,6 @@ package core
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -50,8 +49,6 @@ var (
|
|||||||
pathOnce sync.Once
|
pathOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
var aliasRegex = regexp.MustCompile(`^alias\s+([a-zA-Z0-9_\-]+)=['"]?([^'"]+)['"]?`)
|
|
||||||
|
|
||||||
// scanShellAliases parses shell config files for aliases
|
// scanShellAliases parses shell config files for aliases
|
||||||
func scanShellAliases() {
|
func scanShellAliases() {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
@@ -59,26 +56,60 @@ func scanShellAliases() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
files := []string{".zshrc", ".bashrc", ".bash_profile", ".bash_aliases"}
|
for _, f := range []string{".zshrc", ".bashrc", ".bash_profile", ".bash_aliases"} {
|
||||||
for _, f := range files {
|
|
||||||
content, err := os.ReadFile(filepath.Join(home, f))
|
content, err := os.ReadFile(filepath.Join(home, f))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(string(content), "\n")
|
for _, line := range strings.Split(string(content), "\n") {
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
matches := aliasRegex.FindStringSubmatch(line)
|
if !strings.HasPrefix(line, "alias") {
|
||||||
if len(matches) == 3 {
|
continue
|
||||||
name := matches[1]
|
}
|
||||||
target := matches[2]
|
|
||||||
shellAliases[name] = target
|
body := strings.TrimSpace(strings.TrimPrefix(line, "alias"))
|
||||||
|
for _, pair := range splitAliasTokens(body) {
|
||||||
|
if eqIdx := strings.IndexByte(pair, '='); eqIdx > 0 {
|
||||||
|
k := strings.TrimSpace(pair[:eqIdx])
|
||||||
|
v := strings.Trim(strings.TrimSpace(pair[eqIdx+1:]), "\"'")
|
||||||
|
if k != "" && v != "" {
|
||||||
|
shellAliases[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitAliasTokens(s string) []string {
|
||||||
|
var pairs []string
|
||||||
|
var cur strings.Builder
|
||||||
|
inQuote := false
|
||||||
|
var quote rune
|
||||||
|
for _, c := range s {
|
||||||
|
switch {
|
||||||
|
case !inQuote && (c == '"' || c == '\''):
|
||||||
|
inQuote, quote = true, c
|
||||||
|
cur.WriteRune(c)
|
||||||
|
case inQuote && c == quote:
|
||||||
|
inQuote = false
|
||||||
|
cur.WriteRune(c)
|
||||||
|
case c == ' ' && !inQuote:
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
pairs = append(pairs, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cur.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
pairs = append(pairs, cur.String())
|
||||||
|
}
|
||||||
|
return pairs
|
||||||
|
}
|
||||||
|
|
||||||
// scanExternalCommands populates pathCmds and shellAliases
|
// scanExternalCommands populates pathCmds and shellAliases
|
||||||
func scanExternalCommands() {
|
func scanExternalCommands() {
|
||||||
scanPath()
|
scanPath()
|
||||||
@@ -87,23 +118,18 @@ func scanExternalCommands() {
|
|||||||
|
|
||||||
// scanPath populates pathCmds with all executable files found in $PATH
|
// scanPath populates pathCmds with all executable files found in $PATH
|
||||||
func scanPath() {
|
func scanPath() {
|
||||||
pathVar := os.Getenv("PATH")
|
dirs := filepath.SplitList(os.Getenv("PATH"))
|
||||||
dirs := filepath.SplitList(pathVar)
|
|
||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
files, err := os.ReadDir(dir)
|
files, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
if f.IsDir() {
|
if !f.IsDir() {
|
||||||
continue
|
info, err := f.Info()
|
||||||
}
|
if err == nil && info.Mode()&0111 != 0 {
|
||||||
// check if it's executable
|
pathCmds[f.Name()] = true
|
||||||
info, err := f.Info()
|
}
|
||||||
if err == nil && info.Mode()&0111 != 0 {
|
|
||||||
pathCmds[f.Name()] = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,15 +153,12 @@ func Register(s *Spec) {
|
|||||||
// priority: file/dir -> subcommands -> options
|
// priority: file/dir -> subcommands -> options
|
||||||
func Lookup(input string) []Suggestion {
|
func Lookup(input string) []Suggestion {
|
||||||
pathOnce.Do(scanExternalCommands)
|
pathOnce.Do(scanExternalCommands)
|
||||||
|
|
||||||
tokens := tokenize(input)
|
tokens := tokenize(input)
|
||||||
|
|
||||||
// resolve shell alias (e.g. gca -> git commit -a)
|
// Token Injection: resolve shell alias (e.g. gca -> git commit -a)
|
||||||
if len(tokens) > 0 {
|
if len(tokens) > 1 {
|
||||||
if target, ok := shellAliases[tokens[0]]; ok {
|
if target, ok := shellAliases[tokens[0]]; ok {
|
||||||
aliasTokens := tokenize(target)
|
aliasTokens := tokenize(target)
|
||||||
// if the alias expansion ends with a space, tokenize might have an empty last token
|
|
||||||
// we should merge it carefully with tokens[1:]
|
|
||||||
if len(aliasTokens) > 0 && aliasTokens[len(aliasTokens)-1] == "" {
|
if len(aliasTokens) > 0 && aliasTokens[len(aliasTokens)-1] == "" {
|
||||||
aliasTokens = aliasTokens[:len(aliasTokens)-1]
|
aliasTokens = aliasTokens[:len(aliasTokens)-1]
|
||||||
}
|
}
|
||||||
@@ -147,7 +170,6 @@ func Lookup(input string) []Suggestion {
|
|||||||
return topLevelSuggestions(input)
|
return topLevelSuggestions(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if only 1 token and no trailing space, user is still typing the root command
|
|
||||||
if len(tokens) == 1 {
|
if len(tokens) == 1 {
|
||||||
return topLevelSuggestions(tokens[0])
|
return topLevelSuggestions(tokens[0])
|
||||||
}
|
}
|
||||||
@@ -155,29 +177,19 @@ func Lookup(input string) []Suggestion {
|
|||||||
rootCmdName := tokens[0]
|
rootCmdName := tokens[0]
|
||||||
spec, exists := registry[rootCmdName]
|
spec, exists := registry[rootCmdName]
|
||||||
if !exists {
|
if !exists {
|
||||||
// no spec found for this command, return nothing to avoid clutter
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentSubs, currentOpts, currentGen := spec.Subcommands, spec.Options, spec.Generator
|
||||||
currentSubs := spec.Subcommands
|
|
||||||
currentOpts := spec.Options
|
|
||||||
currentGen := spec.Generator
|
|
||||||
depth := 1
|
depth := 1
|
||||||
|
|
||||||
for depth < len(tokens) {
|
for depth < len(tokens) {
|
||||||
tok := tokens[depth]
|
tok := tokens[depth]
|
||||||
if tok == "" {
|
if tok == "" || strings.HasPrefix(tok, "-") || strings.Contains(tok, "=") {
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// skip options
|
|
||||||
if strings.HasPrefix(tok, "-") || strings.Contains(tok, "=") {
|
|
||||||
depth++
|
depth++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to match subcommands (including aliases)
|
|
||||||
found := false
|
found := false
|
||||||
for _, sub := range currentSubs {
|
for _, sub := range currentSubs {
|
||||||
match := sub.Name == tok
|
match := sub.Name == tok
|
||||||
@@ -191,9 +203,7 @@ func Lookup(input string) []Suggestion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if match {
|
if match {
|
||||||
currentSubs = sub.Subcommands
|
currentSubs, currentOpts, currentGen = sub.Subcommands, sub.Options, sub.Generator
|
||||||
currentOpts = sub.Options
|
|
||||||
currentGen = sub.Generator
|
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -203,19 +213,15 @@ func Lookup(input string) []Suggestion {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// if no subcommand matches but we have a generator,
|
|
||||||
// and this is NOT the last token (meaning it's a finished argument),
|
|
||||||
// we consume it and move depth forward.
|
|
||||||
if currentGen != nil && depth < len(tokens)-1 {
|
if currentGen != nil && depth < len(tokens)-1 {
|
||||||
depth++
|
depth++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// build prefix from tokens consumed so far
|
results := []Suggestion{}
|
||||||
var prefixBuilder strings.Builder
|
prefixBuilder := strings.Builder{}
|
||||||
for i := 0; i < depth; i++ {
|
for i := 0; i < depth; i++ {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
prefixBuilder.WriteByte(' ')
|
prefixBuilder.WriteByte(' ')
|
||||||
@@ -223,64 +229,40 @@ func Lookup(input string) []Suggestion {
|
|||||||
prefixBuilder.WriteString(tokens[i])
|
prefixBuilder.WriteString(tokens[i])
|
||||||
}
|
}
|
||||||
prefix := prefixBuilder.String()
|
prefix := prefixBuilder.String()
|
||||||
|
partial := tokens[len(tokens)-1]
|
||||||
|
|
||||||
// partial is what user is currently typing (might be incomplete)
|
|
||||||
partial := ""
|
|
||||||
if depth < len(tokens) {
|
|
||||||
partial = tokens[depth]
|
|
||||||
}
|
|
||||||
|
|
||||||
results := []Suggestion{}
|
|
||||||
|
|
||||||
// file/dir
|
|
||||||
if currentGen != nil {
|
if currentGen != nil {
|
||||||
genResults := currentGen(tokens[:depth], prefix, partial)
|
genResults := currentGen(tokens[:depth], prefix, partial)
|
||||||
for _, g := range genResults {
|
for _, g := range genResults {
|
||||||
// extract simple name for prefix matching
|
|
||||||
parts := strings.Split(g.Cmd, " ")
|
parts := strings.Split(g.Cmd, " ")
|
||||||
name := parts[len(parts)-1]
|
name := parts[len(parts)-1]
|
||||||
if partial == "" || hasPrefix(name, partial) {
|
if partial == "" || hasPrefix(name, partial) {
|
||||||
results = append(results, Suggestion{
|
results = append(results, Suggestion{
|
||||||
Cmd: g.Cmd,
|
Cmd: g.Cmd, Desc: g.Desc, Icon: rootCmdName,
|
||||||
Desc: g.Desc,
|
|
||||||
Icon: rootCmdName,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// subcommands
|
|
||||||
for _, sub := range currentSubs {
|
for _, sub := range currentSubs {
|
||||||
if partial == "" || hasPrefix(sub.Name, partial) {
|
if partial == "" || hasPrefix(sub.Name, partial) {
|
||||||
results = append(results, Suggestion{
|
results = append(results, Suggestion{
|
||||||
Cmd: prefix + " " + sub.Name,
|
Cmd: prefix + " " + sub.Name, Desc: sub.Description, Icon: rootCmdName,
|
||||||
Desc: sub.Description,
|
|
||||||
Icon: rootCmdName,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Options (Flags)
|
|
||||||
if partial == "" || (len(partial) > 0 && partial[0] == '-') {
|
if partial == "" || (len(partial) > 0 && partial[0] == '-') {
|
||||||
// Identify already used options to filter them out
|
|
||||||
usedOpts := make(map[string]bool)
|
usedOpts := make(map[string]bool)
|
||||||
for _, t := range tokens {
|
for _, t := range tokens {
|
||||||
if strings.HasPrefix(t, "-") {
|
if strings.HasPrefix(t, "-") {
|
||||||
usedOpts[t] = true
|
usedOpts[t] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range currentOpts {
|
for _, opt := range currentOpts {
|
||||||
// Skip if already used
|
if !usedOpts[opt.Name] && (partial == "" || hasPrefix(opt.Name, partial)) {
|
||||||
if usedOpts[opt.Name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if partial == "" || hasPrefix(opt.Name, partial) {
|
|
||||||
results = append(results, Suggestion{
|
results = append(results, Suggestion{
|
||||||
Cmd: prefix + " " + opt.Name,
|
Cmd: prefix + " " + opt.Name, Desc: opt.Description, Icon: rootCmdName,
|
||||||
Desc: opt.Description,
|
|
||||||
Icon: rootCmdName,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,10 +273,9 @@ func Lookup(input string) []Suggestion {
|
|||||||
|
|
||||||
func topLevelSuggestions(query string) []Suggestion {
|
func topLevelSuggestions(query string) []Suggestion {
|
||||||
pathOnce.Do(scanExternalCommands)
|
pathOnce.Do(scanExternalCommands)
|
||||||
|
results, seen := []Suggestion{}, make(map[string]bool)
|
||||||
|
|
||||||
results := []Suggestion{}
|
// 1. Manual specs (High Priority)
|
||||||
seen := make(map[string]bool)
|
|
||||||
|
|
||||||
for name, spec := range registry {
|
for name, spec := range registry {
|
||||||
match := false
|
match := false
|
||||||
if query == "" || hasPrefix(name, query) {
|
if query == "" || hasPrefix(name, query) {
|
||||||
@@ -307,26 +288,27 @@ func topLevelSuggestions(query string) []Suggestion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if match {
|
if match {
|
||||||
|
results = append(results, Suggestion{Cmd: name, Desc: spec.Description, Icon: name})
|
||||||
|
seen[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. shell aliases (User's choice precedence)
|
||||||
|
for name, target := range shellAliases {
|
||||||
|
if !seen[name] && (query == "" || hasPrefix(name, query)) {
|
||||||
results = append(results, Suggestion{
|
results = append(results, Suggestion{
|
||||||
Cmd: name,
|
Cmd: target, Desc: "alias: " + name, Icon: "root",
|
||||||
Desc: spec.Description,
|
|
||||||
Icon: name,
|
|
||||||
})
|
})
|
||||||
seen[name] = true
|
seen[name] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. system commands from $PATH
|
||||||
for name := range pathCmds {
|
for name := range pathCmds {
|
||||||
if seen[name] {
|
if !seen[name] && (query == "" || hasPrefix(name, query)) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
if query == "" || hasPrefix(name, query) {
|
|
||||||
results = append(results, Suggestion{
|
results = append(results, Suggestion{
|
||||||
Cmd: name,
|
Cmd: name, Desc: "system command", Icon: "root",
|
||||||
Desc: "system command",
|
|
||||||
Icon: "root",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user