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:
VERSE
2026-07-05 11:27:30 +07:00
committed by GitHub
parent 6b98455957
commit ebde9ba68f
536 changed files with 2241 additions and 2241 deletions
+158
View File
@@ -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
View File
@@ -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
}
+53
View File
@@ -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)
}
+38
View File
@@ -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
}
}
}
}
}
+68
View File
@@ -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)
}