diff --git a/commands/core/spec.go b/commands/core/spec.go new file mode 100644 index 0000000..f62f254 --- /dev/null +++ b/commands/core/spec.go @@ -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 +}