feat(core): spec
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
|
||||
type GeneratorFunc func(tokens []string, prefix string) []Suggestion
|
||||
|
||||
// Spec defines a top-level CLI command
|
||||
type Spec struct {
|
||||
Name string
|
||||
Description string
|
||||
Subcommands []Subcommand
|
||||
Options []Option
|
||||
Generator GeneratorFunc
|
||||
}
|
||||
|
||||
// Subcommand contains nested subcommands
|
||||
type Subcommand struct {
|
||||
Name string
|
||||
Description string
|
||||
Subcommands []Subcommand
|
||||
Options []Option
|
||||
Generator GeneratorFunc
|
||||
}
|
||||
|
||||
// Option represents a flag or option
|
||||
type Option struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Suggestion is what gets shown in the dropdown
|
||||
type Suggestion struct {
|
||||
Cmd string
|
||||
Desc string
|
||||
Icon string
|
||||
}
|
||||
|
||||
var registry = map[string]*Spec{}
|
||||
|
||||
// Register adds a spec to the global registry
|
||||
func Register(s *Spec) {
|
||||
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"
|
||||
func Lookup(input string) []Suggestion {
|
||||
tokens := tokenize(input)
|
||||
if len(tokens) == 0 {
|
||||
return topLevelSuggestions(input)
|
||||
}
|
||||
|
||||
// if only 1 token and no trailing space, user is still typing the root command
|
||||
if len(tokens) == 1 {
|
||||
return topLevelSuggestions(tokens[0])
|
||||
}
|
||||
|
||||
root := tokens[0]
|
||||
spec, exists := registry[root]
|
||||
if !exists {
|
||||
// no spec found for this command, return nothing to avoid clutter
|
||||
return nil
|
||||
}
|
||||
|
||||
currentSubs := spec.Subcommands
|
||||
currentOpts := spec.Options
|
||||
currentGen := spec.Generator
|
||||
depth := 1
|
||||
|
||||
for depth < len(tokens) {
|
||||
tok := tokens[depth]
|
||||
found := false
|
||||
for _, sub := range currentSubs {
|
||||
if sub.Name == tok {
|
||||
currentSubs = sub.Subcommands
|
||||
currentOpts = sub.Options
|
||||
currentGen = sub.Generator
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
depth++
|
||||
}
|
||||
|
||||
// build prefix from tokens consumed so far
|
||||
prefix := ""
|
||||
for i := 0; i < depth; i++ {
|
||||
if i > 0 {
|
||||
prefix += " "
|
||||
}
|
||||
prefix += tokens[i]
|
||||
}
|
||||
|
||||
// partial is what user is currently typing (might be incomplete)
|
||||
partial := ""
|
||||
if depth < len(tokens) {
|
||||
partial = tokens[depth]
|
||||
}
|
||||
|
||||
results := []Suggestion{}
|
||||
|
||||
// suggest subcommands
|
||||
for _, sub := range currentSubs {
|
||||
if partial == "" || hasPrefix(sub.Name, partial) {
|
||||
results = append(results, Suggestion{
|
||||
Cmd: prefix + " " + sub.Name,
|
||||
Desc: sub.Description,
|
||||
Icon: root,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// suggest options if typing with '-'
|
||||
if partial == "" || partial[0] == '-' {
|
||||
for _, opt := range currentOpts {
|
||||
if partial == "" || hasPrefix(opt.Name, partial) {
|
||||
results = append(results, Suggestion{
|
||||
Cmd: prefix + " " + opt.Name,
|
||||
Desc: opt.Description,
|
||||
Icon: root,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// use generator if available
|
||||
if currentGen != nil {
|
||||
genResults := currentGen(tokens[:depth], prefix)
|
||||
for _, g := range genResults {
|
||||
// extract simple name for prefix matching
|
||||
parts := strings.Split(g.Cmd, " ")
|
||||
name := parts[len(parts)-1]
|
||||
if partial == "" || hasPrefix(name, partial) {
|
||||
results = append(results, Suggestion{
|
||||
Cmd: g.Cmd,
|
||||
Desc: g.Desc,
|
||||
Icon: root,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func topLevelSuggestions(query string) []Suggestion {
|
||||
results := []Suggestion{}
|
||||
for name, spec := range registry {
|
||||
if query == "" || hasPrefix(name, query) {
|
||||
results = append(results, Suggestion{
|
||||
Cmd: name,
|
||||
Desc: spec.Description,
|
||||
Icon: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func tokenize(s string) []string {
|
||||
tokens := []string{}
|
||||
current := ""
|
||||
for _, c := range s {
|
||||
if c == ' ' {
|
||||
tokens = append(tokens, current)
|
||||
current = ""
|
||||
} else {
|
||||
current += string(c)
|
||||
}
|
||||
}
|
||||
tokens = append(tokens, current) // always append the last token (could be empty if space was last character)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user