package core import ( "os" "path/filepath" "regexp" "strings" "sync" ) type GeneratorFunc func(tokens []string, prefix string, partial string) []Suggestion // Spec defines a top-level CLI command type Spec struct { Name string Aliases []string Description string Subcommands []Subcommand Options []Option Generator GeneratorFunc } // Subcommand contains nested subcommands type Subcommand struct { Name string Aliases []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{} pathCmds = make(map[string]bool) shellAliases = make(map[string]string) // alias name -> target command pathOnce sync.Once ) var aliasRegex = regexp.MustCompile(`^alias\s+([a-zA-Z0-9_\-]+)=['"]?([^'"]+)['"]?`) // scanShellAliases parses shell config files for aliases func scanShellAliases() { home, err := os.UserHomeDir() if err != nil { return } files := []string{".zshrc", ".bashrc", ".bash_profile", ".bash_aliases"} for _, f := range files { content, err := os.ReadFile(filepath.Join(home, f)) if err != nil { continue } lines := strings.Split(string(content), "\n") for _, line := range lines { line = strings.TrimSpace(line) matches := aliasRegex.FindStringSubmatch(line) if len(matches) == 3 { name := matches[1] target := matches[2] fields := strings.Fields(target) if len(fields) > 0 { shellAliases[name] = fields[0] } } } } } // scanExternalCommands populates pathCmds and shellAliases func scanExternalCommands() { scanPath() scanShellAliases() } // scanPath populates pathCmds with all executable files found in $PATH func scanPath() { pathVar := os.Getenv("PATH") dirs := filepath.SplitList(pathVar) for _, dir := range dirs { files, err := os.ReadDir(dir) if err != nil { continue } for _, f := range files { if f.IsDir() { continue } // check if it's executable 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) { 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 { pathOnce.Do(scanExternalCommands) 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]) } rootCmdName := tokens[0] // resolve shell alias (e.g. g -> git) if target, ok := shellAliases[rootCmdName]; ok { rootCmdName = target } spec, exists := registry[rootCmdName] 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] if tok == "" { break } // skip options if strings.HasPrefix(tok, "-") || strings.Contains(tok, "=") { depth++ continue } // try to match subcommands (including aliases) 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 = sub.Subcommands currentOpts = sub.Options currentGen = sub.Generator found = true break } } if found { depth++ continue } // if no subcommand matches but we have a generator, // and this is NOT the last token (meaning it's a finished argument), // we consume it and move depth forward. if currentGen != nil && depth < len(tokens)-1 { depth++ continue } break } // build prefix from tokens consumed so far var prefixBuilder strings.Builder for i := 0; i < depth; i++ { if i > 0 { prefixBuilder.WriteByte(' ') } prefixBuilder.WriteString(tokens[i]) } prefix := prefixBuilder.String() // partial is what user is currently typing (might be incomplete) partial := "" if depth < len(tokens) { partial = tokens[depth] } results := []Suggestion{} // file/dir if currentGen != nil { genResults := currentGen(tokens[:depth], prefix, partial) 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: rootCmdName, }) } } } // subcommands for _, sub := range currentSubs { if partial == "" || hasPrefix(sub.Name, partial) { results = append(results, Suggestion{ Cmd: prefix + " " + sub.Name, Desc: sub.Description, Icon: rootCmdName, }) } } // 3. Options (Flags) if partial == "" || (len(partial) > 0 && partial[0] == '-') { // Identify already used options to filter them out usedOpts := make(map[string]bool) for _, t := range tokens { if strings.HasPrefix(t, "-") { usedOpts[t] = true } } for _, opt := range currentOpts { // Skip if already used if usedOpts[opt.Name] { continue } if 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 { pathOnce.Do(scanExternalCommands) results := []Suggestion{} seen := make(map[string]bool) for name, spec := range registry { 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 } } for name := range pathCmds { if seen[name] { continue } if 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 }