refactor(commands): separate commands and its core into two folders (#32)
- I think it would be better if one for commands only, one for the spec handling core logic so people won't be confused by the core logic inside the commands folder (cleaner I guess) - Regen docs
This commit is contained in:
+158
@@ -0,0 +1,158 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// for tracking current working dir
|
||||
var ShellPID int
|
||||
|
||||
// GetCWD returns the current working directory of the underlying shell
|
||||
// on Linux, it reads it from /proc/[pid]/cwd
|
||||
func GetCWD() string {
|
||||
if ShellPID > 0 {
|
||||
path := fmt.Sprintf("/proc/%d/cwd", ShellPID)
|
||||
cwd, err := os.Readlink(path)
|
||||
if err == nil {
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
return cwd
|
||||
}
|
||||
|
||||
// FileGenerator provides directory and file suggestions.
|
||||
// It handles nested paths (e.g., 'src/main') by resolving the directory
|
||||
// and filtering based on the last path component
|
||||
//
|
||||
// E.g, we have both src/main.go and src/main.py
|
||||
// you type "cat src/mai", partial = "src/mai"
|
||||
// dir = "src/", filePrefix = "mai"
|
||||
// search for which matches "mai" case, and show it (whici is both main.go and main.py)
|
||||
// but if you type "go run src/mai" -> it will only shows suggestion about src/main.go
|
||||
// NOTE: it will skip hidden file start with dot prefix
|
||||
func FileGenerator(filters ...string) GeneratorFunc {
|
||||
dirOnly := false
|
||||
filterSet := make(map[string]bool)
|
||||
for _, f := range filters {
|
||||
if f == "/" {
|
||||
dirOnly = true
|
||||
continue
|
||||
}
|
||||
filterSet[strings.ToLower(f)] = true
|
||||
}
|
||||
|
||||
return func(tokens []string, prefix string, partial string) []Suggestion {
|
||||
base := GetCWD()
|
||||
dir := base
|
||||
filePrefix := partial
|
||||
|
||||
// check if the partial string contains path separators
|
||||
if i := strings.LastIndexAny(partial, "/\\"); i != -1 {
|
||||
pathDir := partial[:i+1]
|
||||
if filepath.IsAbs(pathDir) || strings.HasPrefix(pathDir, "~") {
|
||||
if strings.HasPrefix(pathDir, "~") {
|
||||
home, _ := os.UserHomeDir()
|
||||
pathDir = filepath.Join(home, pathDir[1:])
|
||||
}
|
||||
dir = pathDir
|
||||
} else {
|
||||
dir = filepath.Join(base, pathDir)
|
||||
}
|
||||
filePrefix = partial[i+1:]
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// build the path prefix for the relative suggestions
|
||||
// e.g. if partial is "src/m", then pathPrefix is "src/"
|
||||
pathPrefix := ""
|
||||
if i := strings.LastIndexAny(partial, "/\\"); i != -1 {
|
||||
pathPrefix = partial[:i+1]
|
||||
}
|
||||
|
||||
var results []Suggestion
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
|
||||
// skip hidden files
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
match := false
|
||||
if filePrefix == "" {
|
||||
match = true
|
||||
} else if dirOnly {
|
||||
match = strings.Contains(strings.ToLower(name), strings.ToLower(filePrefix))
|
||||
} else {
|
||||
match = HasPrefix(name, filePrefix)
|
||||
}
|
||||
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := pathPrefix + name
|
||||
if entry.IsDir() {
|
||||
if dirOnly || len(filterSet) == 0 {
|
||||
results = append(results, Suggestion{
|
||||
Cmd: fullPath + "/",
|
||||
Desc: "directory",
|
||||
})
|
||||
} else {
|
||||
// scan only 1 level deeper if there is a filter
|
||||
subEntries, err := os.ReadDir(filepath.Join(dir, name))
|
||||
if err == nil {
|
||||
for _, subEntry := range subEntries {
|
||||
if subEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
subName := subEntry.Name()
|
||||
if strings.HasPrefix(subName, ".") {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(subName))
|
||||
if filterSet[ext] {
|
||||
results = append(results, Suggestion{
|
||||
Cmd: fullPath + "/" + subName,
|
||||
Desc: "file",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if dirOnly {
|
||||
continue
|
||||
}
|
||||
|
||||
// if filters are set, only show matching extensions
|
||||
if len(filterSet) > 0 {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !filterSet[ext] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
results = append(results, Suggestion{
|
||||
Cmd: fullPath,
|
||||
Desc: "file",
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return strings.ToLower(results[i].Cmd) < strings.ToLower(results[j].Cmd)
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/versenilvis/iris/integration/shell"
|
||||
"github.com/versenilvis/iris/logger"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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 gca="git commit -a"
|
||||
// if the first word match it, IRIS will suggest "git commit -a"
|
||||
if 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:]...)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
rootCmdName := tokens[0]
|
||||
spec, exists := Registry[rootCmdName]
|
||||
logger.Debugf("core lookup tokens: %v, registry exists: %v", tokens, exists)
|
||||
if !exists {
|
||||
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 {
|
||||
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 {
|
||||
genResults := currentGen(tokens, prefix, partial)
|
||||
|
||||
for _, g := range genResults {
|
||||
if partial != "" && !HasPrefix(g.Cmd, partial) && !strings.Contains(g.Cmd, partial) {
|
||||
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 {
|
||||
finalCmd = strings.TrimSpace(linePrefix) + " " + 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(partial) > 0 && partial[0] == '-' {
|
||||
usedOpts := make(map[string]bool)
|
||||
for _, t := range tokens {
|
||||
if strings.HasPrefix(t, "-") {
|
||||
usedOpts[t] = true
|
||||
}
|
||||
}
|
||||
for _, opt := range currentOpts {
|
||||
if !usedOpts[opt.Name] && (partial == "" || HasPrefix(opt.Name, partial)) {
|
||||
results = append(results, Suggestion{
|
||||
Cmd: linePrefix + " " + opt.Name, Desc: opt.Description, Icon: rootCmdName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package spec
|
||||
|
||||
type GeneratorFunc func(tokens []string, prefix string, partial string) []Suggestion
|
||||
|
||||
// Spec defines a top-level command structure
|
||||
type Spec struct {
|
||||
Name string
|
||||
Aliases []string
|
||||
Description string
|
||||
Icon string
|
||||
Subcommands []Subcommand
|
||||
Options []Option
|
||||
Generator GeneratorFunc
|
||||
MaxArgs int
|
||||
}
|
||||
|
||||
// Subcommand defines nested command logic
|
||||
type Subcommand struct {
|
||||
Name string
|
||||
Aliases []string
|
||||
Description string
|
||||
Icon string
|
||||
Subcommands []Subcommand
|
||||
Options []Option
|
||||
Generator GeneratorFunc
|
||||
MaxArgs int
|
||||
}
|
||||
|
||||
// Option represents a command flag or option
|
||||
type Option struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Suggestion represents an item in the suggestion menu
|
||||
type Suggestion struct {
|
||||
Cmd string
|
||||
Desc string
|
||||
Icon string
|
||||
}
|
||||
|
||||
var Registry = map[string]*Spec{}
|
||||
|
||||
// Register adds a new spec to the global Registry
|
||||
// example: Register(&Spec{Name: "git"})
|
||||
func Register(s *Spec) {
|
||||
Registry[s.Name] = s
|
||||
}
|
||||
|
||||
// ResetRegistry clears all registered specs - use in tests only
|
||||
func ResetRegistry() {
|
||||
Registry = make(map[string]*Spec)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
pathCmds = make(map[string]bool)
|
||||
pathCmdsOnce sync.Once
|
||||
)
|
||||
|
||||
// scanExternalCommands populates pathCmds with system executables
|
||||
func scanExternalCommands() {
|
||||
pathCmdsOnce.Do(func() {
|
||||
scanPath()
|
||||
})
|
||||
}
|
||||
|
||||
// scanPath populates pathCmds with all executable files found in path environment variable
|
||||
func scanPath() {
|
||||
dirs := filepath.SplitList(os.Getenv("PATH"))
|
||||
for _, dir := range dirs {
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range files {
|
||||
if !f.IsDir() {
|
||||
info, err := f.Info()
|
||||
if err == nil && info.Mode()&0111 != 0 {
|
||||
pathCmds[f.Name()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SplitAliasTokens parses the input string into shell-like tokens handling quotes
|
||||
// example: SplitAliasTokens("git commit -m \"hello world\"")
|
||||
func Tokenize(s string) []string {
|
||||
tokens := []string{}
|
||||
var current strings.Builder
|
||||
inQuote := false
|
||||
var quoteChar rune
|
||||
|
||||
escaped := false
|
||||
for _, c := range s {
|
||||
if escaped {
|
||||
current.WriteRune(c)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case c == '\\':
|
||||
escaped = true
|
||||
case !inQuote && (c == '"' || c == '\''):
|
||||
inQuote = true
|
||||
quoteChar = c
|
||||
case inQuote && c == quoteChar:
|
||||
inQuote = false
|
||||
case c == ' ' && !inQuote:
|
||||
if current.Len() > 0 {
|
||||
tokens = append(tokens, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
default:
|
||||
current.WriteRune(c)
|
||||
}
|
||||
}
|
||||
|
||||
tokens = append(tokens, current.String())
|
||||
return tokens
|
||||
}
|
||||
|
||||
// HasPrefix checks if s starts with prefix using case-insensitive matching
|
||||
func HasPrefix(s, prefix string) bool {
|
||||
if len(prefix) > len(s) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(prefix); i++ {
|
||||
a, b := s[i], prefix[i]
|
||||
if a >= 'A' && a <= 'Z' {
|
||||
a += 32
|
||||
}
|
||||
if b >= 'A' && b <= 'Z' {
|
||||
b += 32
|
||||
}
|
||||
if a != b {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CI = case insensitive
|
||||
func HasPrefixCI(s, prefix string) bool {
|
||||
return HasPrefix(s, prefix)
|
||||
}
|
||||
Reference in New Issue
Block a user