feat: bug fixes & add support for recommendations on command arg (w/ duplicate recommendation after acceptance)

Signed-off-by: cpendery <cpendery@vt.edu>
This commit is contained in:
cpendery
2023-09-12 15:03:18 -04:00
parent 89c7186ad0
commit 9dfac208a2
8 changed files with 281 additions and 133 deletions
+5 -1
View File
@@ -7,4 +7,8 @@ clean:
cp autocomplete/specs/specs_.go autocomplete/
rm -r autocomplete/specs
mkdir autocomplete/specs
mv autocomplete/specs_.go autocomplete/specs/
mv autocomplete/specs_.go autocomplete/specs/
.PHONY: build
build:
go build -o clac main.go
+70 -47
View File
@@ -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
}
+64
View File
@@ -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{}
}
-84
View File
@@ -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)
}
+14 -1
View File
@@ -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}
)
+123
View File
@@ -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,
})
}
}
+4
View File
@@ -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:
+1
View File
@@ -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: