refactor(spec): seperate into small files
This commit is contained in:
@@ -0,0 +1,292 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/versenilvis/iris/integration/shell"
|
||||||
|
)
|
||||||
|
|
||||||
|
var shellAliases = make(map[string]string)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
shellAliases = shell.Current.ScanAliases()
|
||||||
|
} else {
|
||||||
|
shellAliases = make(map[string]string)
|
||||||
|
}
|
||||||
|
// if input is empty, we show nothing
|
||||||
|
if input == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert raw input into tokens to find out how many words you typed
|
||||||
|
tokens := tokenize(input)
|
||||||
|
|
||||||
|
// if the only word is empty, we show nothing
|
||||||
|
if len(tokens) == 1 && tokens[0] == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
|
||||||
|
pathCmds = make(map[string]bool)
|
||||||
|
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 := shellAliases[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 you only typed one word, we show root commands and their first subcommands if there is a space
|
||||||
|
if len(tokens) == 1 {
|
||||||
|
query := tokens[0]
|
||||||
|
results := topLevelSuggestions(query)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// find the first command name and check if it is in our registry
|
||||||
|
rootCmdName := tokens[0]
|
||||||
|
spec, exists := registry[rootCmdName]
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// look deep into subcommands to find exactly where you are typing right now
|
||||||
|
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 {
|
||||||
|
for _, a := range sub.Aliases {
|
||||||
|
if a == tok {
|
||||||
|
match = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if match {
|
||||||
|
currentSubs, currentOpts, currentGen = sub.Subcommands, sub.Options, sub.Generator
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found {
|
||||||
|
depth++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
results := []Suggestion{}
|
||||||
|
|
||||||
|
// find out how many words this command is allowed to have
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// count how many words you already typed so we can follow the command rules
|
||||||
|
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
|
||||||
|
|
||||||
|
debugLog("[core] query tokens: %v (partial: '%s')", tokens, partial)
|
||||||
|
debugLog("[core] depth: %d, argCount: %d, limit: %d, allowMore: %v", depth, argCount, currentLimit, allowMoreArgs)
|
||||||
|
|
||||||
|
// make the start of the command line to show it in the suggestion menu
|
||||||
|
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()
|
||||||
|
|
||||||
|
// run special code to find things like git branch names or file names
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
finalCmd := g.Cmd
|
||||||
|
cleanLinePrefix := strings.TrimSpace(linePrefix)
|
||||||
|
if cleanLinePrefix != "" && !strings.HasPrefix(finalCmd, cleanLinePrefix) && !strings.HasPrefix(finalCmd, rootCmdName) {
|
||||||
|
finalCmd = cleanLinePrefix + " " + g.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// do not suggest words that you already typed to avoid repeating the same word
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add normal subcommands that match the letters you are typing
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// show flags or options only if you start typing with a dash character
|
||||||
|
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: prefix + " " + opt.Name, Desc: opt.Description, Icon: rootCmdName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// topLevelSuggestions looks for matches in your aliases, iris specs, and system commands
|
||||||
|
func topLevelSuggestions(query string) []Suggestion {
|
||||||
|
results, seen := []Suggestion{}, make(map[string]bool)
|
||||||
|
|
||||||
|
// check the shell aliases first because they are the most important
|
||||||
|
for name, target := range shellAliases {
|
||||||
|
if !seen[name] && (query == "" || hasPrefix(name, query)) {
|
||||||
|
results = append(results, Suggestion{
|
||||||
|
Cmd: target, Desc: "alias: " + name, Icon: "root",
|
||||||
|
})
|
||||||
|
seen[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we search for iris commands that were registered in the code
|
||||||
|
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 {
|
||||||
|
results = append(results, Suggestion{Cmd: name, Desc: spec.Description, Icon: name})
|
||||||
|
seen[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we still find nothing, we look for system commands in your path folders
|
||||||
|
for name := range pathCmds {
|
||||||
|
if !seen[name] && (query == "" || hasPrefix(name, query)) {
|
||||||
|
results = append(results, Suggestion{
|
||||||
|
Cmd: name, Desc: "system command", Icon: "root",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
+9
-396
@@ -1,26 +1,8 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/versenilvis/iris/integration/shell"
|
|
||||||
)
|
|
||||||
|
|
||||||
var DebugWriter io.Writer
|
|
||||||
|
|
||||||
func debugLog(format string, a ...interface{}) {
|
|
||||||
if DebugWriter != nil {
|
|
||||||
fmt.Fprintf(DebugWriter, format+"\n", a...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type GeneratorFunc func(tokens []string, prefix string, partial string) []Suggestion
|
type GeneratorFunc func(tokens []string, prefix string, partial string) []Suggestion
|
||||||
|
|
||||||
// Spec defines a top-level CLI command
|
// Spec defines a top-level command structure
|
||||||
type Spec struct {
|
type Spec struct {
|
||||||
Name string
|
Name string
|
||||||
Aliases []string
|
Aliases []string
|
||||||
@@ -28,10 +10,10 @@ type Spec struct {
|
|||||||
Subcommands []Subcommand
|
Subcommands []Subcommand
|
||||||
Options []Option
|
Options []Option
|
||||||
Generator GeneratorFunc
|
Generator GeneratorFunc
|
||||||
MaxArgs int // 0 means unlimited
|
MaxArgs int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subcommand contains nested subcommands
|
// Subcommand defines nested command logic
|
||||||
type Subcommand struct {
|
type Subcommand struct {
|
||||||
Name string
|
Name string
|
||||||
Aliases []string
|
Aliases []string
|
||||||
@@ -39,395 +21,26 @@ type Subcommand struct {
|
|||||||
Subcommands []Subcommand
|
Subcommands []Subcommand
|
||||||
Options []Option
|
Options []Option
|
||||||
Generator GeneratorFunc
|
Generator GeneratorFunc
|
||||||
MaxArgs int // 0 means unlimited
|
MaxArgs int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option represents a flag or option
|
// Option represents a command flag or option
|
||||||
type Option struct {
|
type Option struct {
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggestion is what gets shown in the dropdown
|
// Suggestion represents an item in the suggestion menu
|
||||||
type Suggestion struct {
|
type Suggestion struct {
|
||||||
Cmd string
|
Cmd string
|
||||||
Desc string
|
Desc string
|
||||||
Icon string
|
Icon string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var registry = map[string]*Spec{}
|
||||||
registry = map[string]*Spec{}
|
|
||||||
pathCmds = make(map[string]bool)
|
|
||||||
shellAliases = make(map[string]string) // alias name -> target command
|
|
||||||
)
|
|
||||||
|
|
||||||
// scanExternalCommands populates pathCmds
|
// Register adds a new spec to the global registry
|
||||||
func scanExternalCommands() {
|
// example: Register(&Spec{Name: "git"})
|
||||||
scanPath()
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanPath populates pathCmds with all executable files found in $PATH
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register adds a spec to the global registry
|
|
||||||
func Register(s *Spec) {
|
func Register(s *Spec) {
|
||||||
registry[s.Name] = s
|
registry[s.Name] = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup finds suggestions based on user input
|
|
||||||
// We need to tokenize the input to detect the depth of the command,
|
|
||||||
// like when you type "gi" and it shows suggestion about "git",
|
|
||||||
// then if you press tab (not enter) it will continuely show suggestions about "git" subcommands,
|
|
||||||
// when you type like "git add", it will continuely show the next suggestion from "git add"
|
|
||||||
// if we don't tokenize it. iris cannot get which command to suggest.
|
|
||||||
// And tokenizer helps us to detect white space like we can't know if you are typing "git-lfs"
|
|
||||||
// or "git" to show the suggestions, without it, if you type "git " (with white space), it still shows
|
|
||||||
// suggestion for "git-lfs"
|
|
||||||
//
|
|
||||||
// priority: file/dir -> subcommands -> options
|
|
||||||
func Lookup(input string) []Suggestion {
|
|
||||||
if shell.Current != nil {
|
|
||||||
shellAliases = shell.Current.ScanAliases()
|
|
||||||
} else {
|
|
||||||
shellAliases = make(map[string]string)
|
|
||||||
}
|
|
||||||
pathCmds = make(map[string]bool)
|
|
||||||
scanExternalCommands()
|
|
||||||
|
|
||||||
tokens := tokenize(input)
|
|
||||||
|
|
||||||
// Token Injection: resolve shell alias (e.g. gca -> git commit -a)
|
|
||||||
if len(tokens) > 1 {
|
|
||||||
if target, ok := shellAliases[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) == 0 {
|
|
||||||
return topLevelSuggestions(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tokens) == 1 {
|
|
||||||
query := tokens[0]
|
|
||||||
results := topLevelSuggestions(query)
|
|
||||||
|
|
||||||
// If query is a full match for a root command, also include its sub-items
|
|
||||||
if spec, exists := registry[query]; exists {
|
|
||||||
// ONLY suggest parameters if there's a trailing space in the query
|
|
||||||
// or if we are filtering. But for 'just' (len=1), if no space, wait.
|
|
||||||
hasTrailingSpace := query != "" && query[len(query)-1] == ' '
|
|
||||||
|
|
||||||
if hasTrailingSpace {
|
|
||||||
partial := ""
|
|
||||||
prefix := query
|
|
||||||
|
|
||||||
// Subcommands
|
|
||||||
for _, sub := range spec.Subcommands {
|
|
||||||
results = append(results, Suggestion{
|
|
||||||
Cmd: strings.TrimSpace(query) + " " + sub.Name, Desc: sub.Description, Icon: query,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Generator (recipes, branches, etc.)
|
|
||||||
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]
|
|
||||||
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 {
|
|
||||||
for _, a := range sub.Aliases {
|
|
||||||
if a == tok {
|
|
||||||
match = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if match {
|
|
||||||
currentSubs, currentOpts, currentGen = sub.Subcommands, sub.Options, sub.Generator
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if found {
|
|
||||||
depth++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// If not a subcommand, it's an argument. Stop traversal here.
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
results := []Suggestion{}
|
|
||||||
|
|
||||||
// Track current max args
|
|
||||||
// Re-track the limit from the actual deepest subcommand reached
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count arguments ALREADY TYPED for this subcommand/spec
|
|
||||||
argCount := 0
|
|
||||||
for i := depth; i < len(tokens)-1; i++ {
|
|
||||||
t := tokens[i]
|
|
||||||
// Skip empty tokens or flags
|
|
||||||
if t != "" && !strings.HasPrefix(t, "-") && !strings.Contains(t, "=") {
|
|
||||||
argCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
partial := tokens[len(tokens)-1]
|
|
||||||
// If we hit the limit, don't allow ANY more argument suggestions
|
|
||||||
// (only flags/options which start with '-' are allowed later)
|
|
||||||
allowMoreArgs := currentLimit <= 0 || argCount < currentLimit
|
|
||||||
|
|
||||||
debugLog("[Core] query tokens: %v (partial: '%s')", tokens, partial)
|
|
||||||
debugLog("[Core] depth: %d, argCount: %d, limit: %d, allowMore: %v", depth, argCount, currentLimit, allowMoreArgs)
|
|
||||||
|
|
||||||
rootCmdName = tokens[0]
|
|
||||||
|
|
||||||
// 'prefix' is the breadcrumb for the command (e.g. "git checkout")
|
|
||||||
prefixBuilder := strings.Builder{}
|
|
||||||
for i := 0; i < depth; i++ {
|
|
||||||
if i > 0 {
|
|
||||||
prefixBuilder.WriteByte(' ')
|
|
||||||
}
|
|
||||||
prefixBuilder.WriteString(tokens[i])
|
|
||||||
}
|
|
||||||
prefix := prefixBuilder.String()
|
|
||||||
|
|
||||||
// 'linePrefix' is the full context except the last partial word (e.g. "just run build")
|
|
||||||
linePrefixBuilder := strings.Builder{}
|
|
||||||
for i := 0; i < len(tokens)-1; i++ {
|
|
||||||
if i > 0 {
|
|
||||||
linePrefixBuilder.WriteByte(' ')
|
|
||||||
}
|
|
||||||
linePrefixBuilder.WriteString(tokens[i])
|
|
||||||
}
|
|
||||||
linePrefix := linePrefixBuilder.String()
|
|
||||||
|
|
||||||
// already have partial from above
|
|
||||||
rootCmdName = tokens[0]
|
|
||||||
|
|
||||||
if currentGen != nil && allowMoreArgs {
|
|
||||||
genResults := currentGen(tokens, prefix, partial)
|
|
||||||
|
|
||||||
for _, g := range genResults {
|
|
||||||
// Basic filtering based on partial input
|
|
||||||
if partial != "" && !hasPrefix(g.Cmd, partial) && !strings.Contains(g.Cmd, partial) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
finalCmd := g.Cmd
|
|
||||||
// Smart Absolute Building: join fragments to the line context if needed.
|
|
||||||
// Only add linePrefix if the command doesn't already seem to have it.
|
|
||||||
cleanLinePrefix := strings.TrimSpace(linePrefix)
|
|
||||||
if cleanLinePrefix != "" && !strings.HasPrefix(finalCmd, cleanLinePrefix) && !strings.HasPrefix(finalCmd, rootCmdName) {
|
|
||||||
finalCmd = cleanLinePrefix + " " + g.Cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// UNIVERSAL DEDUPLICATION:
|
|
||||||
// Check if the NEW word being suggested is already present in the typed tokens.
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only suggest options if partial starts with '-'
|
|
||||||
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: prefix + " " + opt.Name, Desc: opt.Description, Icon: rootCmdName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
func topLevelSuggestions(query string) []Suggestion {
|
|
||||||
results, seen := []Suggestion{}, make(map[string]bool)
|
|
||||||
|
|
||||||
// 1. shell aliases (User's choice precedence)
|
|
||||||
for name, target := range shellAliases {
|
|
||||||
if !seen[name] && (query == "" || hasPrefix(name, query)) {
|
|
||||||
results = append(results, Suggestion{
|
|
||||||
Cmd: target, Desc: "alias: " + name, Icon: "root",
|
|
||||||
// Cmd: target, Desc: "alias: " + name, Icon: "DEBUG", //--> this is just for debug on reloading, dont mind it
|
|
||||||
})
|
|
||||||
seen[name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Manual specs (High Priority)
|
|
||||||
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 {
|
|
||||||
results = append(results, Suggestion{Cmd: name, Desc: spec.Description, Icon: name})
|
|
||||||
seen[name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. system commands from $PATH
|
|
||||||
for name := range pathCmds {
|
|
||||||
if !seen[name] && (query == "" || hasPrefix(name, query)) {
|
|
||||||
results = append(results, Suggestion{
|
|
||||||
Cmd: name, Desc: "system command", Icon: "root",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
func tokenize(s string) []string {
|
|
||||||
tokens := []string{}
|
|
||||||
var current strings.Builder
|
|
||||||
inQuote := false
|
|
||||||
var quoteChar rune
|
|
||||||
|
|
||||||
for _, c := range s {
|
|
||||||
switch {
|
|
||||||
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()
|
|
||||||
} else if len(tokens) > 0 && tokens[len(tokens)-1] != "" {
|
|
||||||
// preserve trailing space
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
current.WriteRune(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens = append(tokens, current.String())
|
|
||||||
return tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pathCmds = make(map[string]bool)
|
||||||
|
|
||||||
|
// scanExternalCommands populates pathCmds with system executables
|
||||||
|
func scanExternalCommands() {
|
||||||
|
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,64 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DebugWriter io.Writer
|
||||||
|
|
||||||
|
func debugLog(format string, a ...interface{}) {
|
||||||
|
if DebugWriter != nil {
|
||||||
|
fmt.Fprintf(DebugWriter, format+"\n", a...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenize parses the input string into shell-like tokens handling quotes
|
||||||
|
// example: tokenize("git commit -m \"hello world\"")
|
||||||
|
func tokenize(s string) []string {
|
||||||
|
tokens := []string{}
|
||||||
|
var current strings.Builder
|
||||||
|
inQuote := false
|
||||||
|
var quoteChar rune
|
||||||
|
|
||||||
|
for _, c := range s {
|
||||||
|
switch {
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user