diff --git a/Makefile b/Makefile index a07d52a..c13f3cb 100644 --- a/Makefile +++ b/Makefile @@ -7,4 +7,8 @@ clean: cp autocomplete/specs/specs_.go autocomplete/ rm -r autocomplete/specs mkdir autocomplete/specs - mv autocomplete/specs_.go autocomplete/specs/ \ No newline at end of file + mv autocomplete/specs_.go autocomplete/specs/ + +.PHONY: build +build: + go build -o clac main.go \ No newline at end of file diff --git a/autocomplete/completion.go b/autocomplete/completion.go index b9be60e..e67c4c9 100644 --- a/autocomplete/completion.go +++ b/autocomplete/completion.go @@ -1,6 +1,7 @@ package autocomplete import ( + "log" "log/slog" "regexp" @@ -8,11 +9,6 @@ import ( "github.com/cpendery/clac/autocomplete/specs" ) -type Suggestion struct { - Name string - Description string -} - var ( cmdDelimiter = regexp.MustCompile(`(\|\|)|(&&)|(;)`) lastSuggestionCmd = "" @@ -20,6 +16,11 @@ var ( lastCmdRunes = 0 ) +type Suggestion struct { + Name string + Description string +} + func getOption(token string, options []model.Option) *model.Option { for _, option := range options { for _, optionName := range option.Name { @@ -73,48 +74,66 @@ func getShortName(names []string) string { return shortestName } -func getSubcommandDrivenRecommendation(spec model.Subcommand, persistentOptions []model.Option, partialCmd *commandToken) []Suggestion { - suggestions := []Suggestion{} +func getSubcommandDrivenRecommendation(spec model.Subcommand, persistentOptions []model.Option, partialCmd *commandToken, onlyRecommendSubcommands bool) []model.TermSuggestion { + log.Println("sub rec") + suggestions := []model.TermSuggestion{} allOptions := append(spec.Options, persistentOptions...) + + if onlyRecommendSubcommands { + getSubcommandDrivenRecommendations(spec, &suggestions) + } else { + if len(spec.Args) != 0 { + activeArg := spec.Args[0] + getSuggestionDrivenRecommendations(activeArg.Suggestions, &suggestions) + getTemplateDrivenRecommendations(activeArg.Templates, &suggestions) + } + getSubcommandDrivenRecommendations(spec, &suggestions) + getOptionDrivenRecommendations(allOptions, &suggestions) + } + if partialCmd != nil { switch spec.FilterStrategy { case model.FilterStrategyFuzzy: - return append( - fuzzyMatchSubcommands(partialCmd.token, spec.Subcommands), - fuzzyMatchOptions(partialCmd.token, allOptions)..., - ) + getFuzzyFilteredRecommendations(partialCmd.token, &suggestions) case model.FilterStrategyPrefix, model.FilterStrategyEmpty: - return append( - prefixMatchSubcommands(partialCmd.token, spec.Subcommands), - prefixMatchOptions(partialCmd.token, allOptions)..., - ) + getPrefixFilteredRecommendations(partialCmd.token, &suggestions) } } - for _, sub := range spec.Subcommands { - suggestions = append(suggestions, Suggestion{ - Name: getLongName(sub.Name), - Description: sub.Description, - }) - } - for _, op := range allOptions { - suggestions = append(suggestions, Suggestion{ - Name: getShortName(op.Name), - Description: op.Description, - }) - } return suggestions } -func getArgDrivenRecommendation(args []model.Arg, spec model.Subcommand, persistentOptions []model.Option) []Suggestion { - return []Suggestion{} +func getArgDrivenRecommendation(args []model.Arg, spec model.Subcommand, persistentOptions []model.Option, partialCmd *commandToken) []model.TermSuggestion { + log.Println("arg rec") + suggestions := []model.TermSuggestion{} + activeArg := args[0] + allOptions := append(spec.Options, persistentOptions...) + + getSuggestionDrivenRecommendations(activeArg.Suggestions, &suggestions) + getTemplateDrivenRecommendations(activeArg.Templates, &suggestions) + + if activeArg.IsOptional { + getSubcommandDrivenRecommendations(spec, &suggestions) + getOptionDrivenRecommendations(allOptions, &suggestions) + } + + if partialCmd != nil { + switch spec.FilterStrategy { + case model.FilterStrategyFuzzy: + getFuzzyFilteredRecommendations(partialCmd.token, &suggestions) + case model.FilterStrategyPrefix, model.FilterStrategyEmpty: + getPrefixFilteredRecommendations(partialCmd.token, &suggestions) + } + } + + return suggestions } -func handleSubcommand(tokens []commandToken, spec model.Subcommand, persistentOptions []model.Option) (suggestions []Suggestion) { +func handleSubcommand(tokens []commandToken, spec model.Subcommand, persistentOptions []model.Option, argsDepleted bool) (suggestions []model.TermSuggestion) { if len(tokens) == 0 { - return getSubcommandDrivenRecommendation(spec, persistentOptions, nil) + return getSubcommandDrivenRecommendation(spec, persistentOptions, nil, argsDepleted) } else if !tokens[0].complete { - return getSubcommandDrivenRecommendation(spec, persistentOptions, &tokens[0]) + return getSubcommandDrivenRecommendation(spec, persistentOptions, &tokens[0], argsDepleted) } for _, option := range spec.Options { if option.IsPersistent { @@ -129,30 +148,30 @@ func handleSubcommand(tokens []commandToken, spec model.Subcommand, persistentOp return } if subcommand := getSubcommand(activeCmd.token, spec); subcommand != nil { - return handleSubcommand(tokens[1:], *subcommand, persistentOptions) + return handleSubcommand(tokens[1:], *subcommand, persistentOptions, false) } return handleArg(tokens, spec.Args, spec, persistentOptions) } -func handleOption(tokens []commandToken, option model.Option, spec model.Subcommand, persistentOptions []model.Option) (suggestions []Suggestion) { +func handleOption(tokens []commandToken, option model.Option, spec model.Subcommand, persistentOptions []model.Option) (suggestions []model.TermSuggestion) { if len(tokens) == 0 { slog.Error("invalid state reached, option with no tokens") return } if len(option.Args) == 0 { - return handleSubcommand(tokens[1:], spec, persistentOptions) + return handleSubcommand(tokens[1:], spec, persistentOptions, false) } return handleArg(tokens[1:], option.Args, spec, persistentOptions) } -func handleArg(tokens []commandToken, args []model.Arg, spec model.Subcommand, persistentOptions []model.Option) (suggestions []Suggestion) { - if len(tokens) == 0 { - return getArgDrivenRecommendation(args, spec, persistentOptions) +func handleArg(tokens []commandToken, args []model.Arg, spec model.Subcommand, persistentOptions []model.Option) (suggestions []model.TermSuggestion) { + if len(args) == 0 { + return handleSubcommand(tokens, spec, persistentOptions, true) + } else if len(tokens) == 0 { + return getArgDrivenRecommendation(args, spec, persistentOptions, nil) } else if !tokens[0].complete { - return getArgDrivenRecommendation(args, spec, persistentOptions) - } else if len(args) == 0 { - return handleSubcommand(tokens, spec, persistentOptions) + return getArgDrivenRecommendation(args, spec, persistentOptions, &tokens[0]) } activeCmd := tokens[0] @@ -165,27 +184,27 @@ func handleArg(tokens []commandToken, args []model.Arg, spec model.Subcommand, p } subcommand := getSubcommand(activeCmd.token, spec) if subcommand != nil { - return handleSubcommand(tokens[1:], *subcommand, persistentOptions) + return handleSubcommand(tokens[1:], *subcommand, persistentOptions, false) } } activeArg := args[0] if activeArg.IsVariadic { - return []Suggestion{{Name: activeArg.Name}} + return handleArg(tokens[1:], args, spec, persistentOptions) } else if activeArg.IsCommand { if len(tokens) <= 1 { return } activeCmd = tokens[1] if subcommand := getSubcommand(activeCmd.token, spec); subcommand != nil { - return handleSubcommand(tokens[2:], *subcommand, persistentOptions) + return handleSubcommand(tokens[2:], *subcommand, persistentOptions, false) } return } return handleArg(tokens[1:], args[1:], spec, persistentOptions) } -func loadSuggestions(cmd string) (suggestions []Suggestion, charsInLastCmd int) { +func loadSuggestions(cmd string) (suggestions []model.TermSuggestion, charsInLastCmd int) { activeCmd := ParseCommand(cmd) if len(activeCmd) <= 0 { return @@ -200,7 +219,7 @@ func loadSuggestions(cmd string) (suggestions []Suggestion, charsInLastCmd int) charsInLastCmd = 0 } if spec, ok := specs.Specs[rootToken.token]; ok { - return handleSubcommand(activeCmd[1:], spec, []model.Option{}), charsInLastCmd + return handleSubcommand(activeCmd[1:], spec, []model.Option{}, false), charsInLastCmd } return } @@ -209,7 +228,11 @@ func LoadSuggestions(cmd string) ([]Suggestion, int) { if cmd == lastSuggestionCmd { return lastSuggestion, lastCmdRunes } - suggestions, lastRunes := loadSuggestions(cmd) + termSuggestions, lastRunes := loadSuggestions(cmd) + suggestions := []Suggestion{} + for _, suggestion := range termSuggestions { + suggestions = append(suggestions, Suggestion{Name: suggestion.Name, Description: suggestion.Description}) + } lastSuggestionCmd, lastSuggestion, lastCmdRunes = cmd, suggestions, lastRunes return suggestions, lastRunes } diff --git a/autocomplete/generators/templates.go b/autocomplete/generators/templates.go new file mode 100644 index 0000000..f456049 --- /dev/null +++ b/autocomplete/generators/templates.go @@ -0,0 +1,64 @@ +package generators + +import ( + "log/slog" + "os" + + "github.com/cpendery/clac/autocomplete/model" +) + +// * - filepaths: show folders and filepaths. Allow autoexecute on filepaths +// * - folders: show folders only. Allow autoexecute on folders +// * - history: show suggestions for all items in history matching this pattern +// * - help: show subcommands. Only includes the 'siblings' of the nearest 'parent' subcommand + +const ( + directory = "Directory" + file = "File" +) + +func walk(includeFiles bool) []model.TermSuggestion { + wd, err := os.Getwd() + if err != nil { + slog.Error("unable to get working dir", slog.String("error", err.Error())) + return []model.TermSuggestion{} + } + dirItems, err := os.ReadDir(wd) + if err != nil { + slog.Error("unable to read files from working dir", slog.String("error", err.Error())) + return []model.TermSuggestion{} + } + suggestions := []model.TermSuggestion{} + for _, dirItem := range dirItems { + if !dirItem.IsDir() && !includeFiles { + continue + } + description := file + if dirItem.IsDir() { + description = directory + } + suggestions = append(suggestions, model.TermSuggestion{ + Name: dirItem.Name(), + Description: description, + }) + } + return suggestions +} + +func Filepaths() []model.TermSuggestion { + return walk(true) +} + +func Folders() []model.TermSuggestion { + return walk(false) +} + +// TODO: implement history template +func History() []model.TermSuggestion { + return []model.TermSuggestion{} +} + +// TODO: implement help template +func Help() []model.TermSuggestion { + return []model.TermSuggestion{} +} diff --git a/autocomplete/match.go b/autocomplete/match.go deleted file mode 100644 index 6fbd32c..0000000 --- a/autocomplete/match.go +++ /dev/null @@ -1,84 +0,0 @@ -package autocomplete - -import ( - "sort" - "strings" - - "github.com/cpendery/clac/autocomplete/model" - "github.com/lithammer/fuzzysearch/fuzzy" -) - -type matchable interface { - GetName() []string - GetDescription() string -} - -func fuzzyMatch[M matchable](input string, targets []M) []Suggestion { - type match struct { - name string - rank int - item M - } - matchers := []match{} - for _, item := range targets { - bestName := "" - bestNameRank := -1 - for _, n := range item.GetName() { - rank := fuzzy.RankMatch(input, n) - if rank > bestNameRank { - bestName = n - bestNameRank = rank - } - } - if bestNameRank != -1 { - matchers = append(matchers, match{ - name: bestName, - item: item, - rank: bestNameRank, - }) - } - } - sort.Slice(matchers, func(i, j int) bool { - return matchers[i].rank > matchers[j].rank - }) - results := []Suggestion{} - for _, m := range matchers { - results = append(results, Suggestion{ - Name: m.name, - Description: m.item.GetDescription(), - }) - } - return results -} - -func fuzzyMatchSubcommands(input string, subcommands []model.Subcommand) []Suggestion { - return fuzzyMatch[model.Subcommand](input, subcommands) -} - -func fuzzyMatchOptions(input string, options []model.Option) []Suggestion { - return fuzzyMatch[model.Option](input, options) -} - -func prefixMatch[M matchable](input string, subcommands []M) []Suggestion { - results := []Suggestion{} - for _, sub := range subcommands { - for _, n := range sub.GetName() { - if strings.HasPrefix(n, input) { - results = append(results, Suggestion{ - Name: n, - Description: sub.GetDescription(), - }) - break - } - } - } - return results -} - -func prefixMatchSubcommands(input string, subcommands []model.Subcommand) []Suggestion { - return prefixMatch[model.Subcommand](input, subcommands) -} - -func prefixMatchOptions(input string, options []model.Option) []Suggestion { - return prefixMatch[model.Option](input, options) -} diff --git a/autocomplete/model/model.go b/autocomplete/model/model.go index cc66f00..21c2cba 100644 --- a/autocomplete/model/model.go +++ b/autocomplete/model/model.go @@ -50,6 +50,19 @@ type Suggestion struct { Description string } +type TermSuggestion struct { + Name string + Description string +} + +func (t TermSuggestion) GetName() []string { + return []string{t.Name} +} + +func (t TermSuggestion) GetDescription() string { + return t.Description +} + type Generator struct { Script string Function func() string @@ -75,5 +88,5 @@ const ( ) var ( - Templates = []Template{TemplateFilepaths, TemplateFolders, TemplateHistory} + Templates = []Template{TemplateFilepaths, TemplateFolders, TemplateHistory, TemplateHelp} ) diff --git a/autocomplete/suggestions.go b/autocomplete/suggestions.go new file mode 100644 index 0000000..00b98dd --- /dev/null +++ b/autocomplete/suggestions.go @@ -0,0 +1,123 @@ +package autocomplete + +import ( + "slices" + "sort" + "strings" + + "github.com/cpendery/clac/autocomplete/generators" + "github.com/cpendery/clac/autocomplete/model" + "github.com/lithammer/fuzzysearch/fuzzy" +) + +type matchable interface { + GetName() []string + GetDescription() string +} + +func fuzzyMatch[M matchable](input string, targets []M, suggestions *[]model.TermSuggestion) { + type match struct { + name string + rank int + item M + } + matchers := []match{} + for _, item := range targets { + bestName := "" + bestNameRank := -1 + for _, n := range item.GetName() { + rank := fuzzy.RankMatch(input, n) + if rank > bestNameRank { + bestName = n + bestNameRank = rank + } + } + if bestNameRank != -1 { + matchers = append(matchers, match{ + name: bestName, + item: item, + rank: bestNameRank, + }) + } + } + sort.Slice(matchers, func(i, j int) bool { + return matchers[i].rank > matchers[j].rank + }) + for _, m := range matchers { + *suggestions = append(*suggestions, model.TermSuggestion{ + Name: m.name, + Description: m.item.GetDescription(), + }) + } +} + +func getFuzzyFilteredRecommendations(input string, suggestions *[]model.TermSuggestion) { + results := []model.TermSuggestion{} + fuzzyMatch[model.TermSuggestion](input, *suggestions, &results) + *suggestions = results +} + +func prefixMatch[M matchable](input string, subcommands []M, suggestions *[]model.TermSuggestion) { + for _, sub := range subcommands { + for _, n := range sub.GetName() { + if strings.HasPrefix(n, input) { + *suggestions = append(*suggestions, model.TermSuggestion{ + Name: n, + Description: sub.GetDescription(), + }) + break + } + } + } +} + +func getPrefixFilteredRecommendations(input string, suggestions *[]model.TermSuggestion) { + results := []model.TermSuggestion{} + prefixMatch[model.TermSuggestion](input, *suggestions, &results) + *suggestions = results +} + +func getTemplateDrivenRecommendations(templates []model.Template, suggestions *[]model.TermSuggestion) { + for _, template := range templates { + switch template { + case model.TemplateFilepaths: + *suggestions = append(*suggestions, generators.Filepaths()...) + case model.TemplateFolders: + if slices.Contains(templates, model.TemplateFilepaths) { + continue + } + *suggestions = append(*suggestions, generators.Folders()...) + case model.TemplateHelp: + *suggestions = append(*suggestions, generators.Help()...) + case model.TemplateHistory: + *suggestions = append(*suggestions, generators.History()...) + } + } +} + +func getSuggestionDrivenRecommendations(suggestionSet []model.Suggestion, suggestions *[]model.TermSuggestion) { + for _, suggestion := range suggestionSet { + *suggestions = append(*suggestions, model.TermSuggestion{ + Name: getLongName(suggestion.Name), + Description: suggestion.Description, + }) + } +} + +func getSubcommandDrivenRecommendations(spec model.Subcommand, suggestions *[]model.TermSuggestion) { + for _, sub := range spec.Subcommands { + *suggestions = append(*suggestions, model.TermSuggestion{ + Name: getLongName(sub.Name), + Description: sub.Description, + }) + } +} + +func getOptionDrivenRecommendations(options []model.Option, suggestions *[]model.TermSuggestion) { + for _, op := range options { + *suggestions = append(*suggestions, model.TermSuggestion{ + Name: getShortName(op.Name), + Description: op.Description, + }) + } +} diff --git a/ui/suggestions/suggestions.go b/ui/suggestions/suggestions.go index 4efdcbd..e9a6be7 100644 --- a/ui/suggestions/suggestions.go +++ b/ui/suggestions/suggestions.go @@ -76,6 +76,10 @@ func (m Model) ActiveSuggestion() (string, int) { return m.suggestions[m.cursor].Name, m.runesToRemove } +func (m *Model) ResetCursor() { + m.cursor = 0 +} + func (m Model) Update(msg tea.Msg, command string, userInputCursorLocation int) Model { switch msg := msg.(type) { case tea.KeyMsg: diff --git a/ui/ui.go b/ui/ui.go index 3cf7e14..e269566 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -54,6 +54,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s := currentValue[:len(currentValue)-runesToRemove] + activeSuggestion + " " m.textInput.SetValue(s) m.textInput.SetCursor(len(s)) + m.suggestions.ResetCursor() return m, nil } case cursor.BlinkMsg: