diff options
-rw-r--r-- | commands/commands.go | 133 | ||||
-rw-r--r-- | commands/completion_helpers.go | 78 | ||||
-rw-r--r-- | commands/completion_helpers_test.go | 44 | ||||
-rw-r--r-- | commands/parser.go | 134 | ||||
-rw-r--r-- | commands/parser_test.go | 258 | ||||
-rw-r--r-- | commands/prompt.go | 2 | ||||
-rw-r--r-- | main.go | 13 | ||||
-rw-r--r-- | widgets/aerc.go | 8 |
8 files changed, 632 insertions, 38 deletions
diff --git a/commands/commands.go b/commands/commands.go index 0a7050e9..f50a1e8a 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -23,6 +23,16 @@ type Command interface { Complete(*widgets.Aerc, []string) []string } +type OptionsProvider interface { + Command + Options() string +} + +type OptionCompleter interface { + OptionsProvider + CompleteOption(*widgets.Aerc, rune, string) []string +} + type Commands map[string]Command func NewCommands() *Commands { @@ -131,49 +141,98 @@ func (cmds *Commands) ExecuteCommand( return NoSuchCommand(args[0]) } -func (cmds *Commands) GetCompletions(aerc *widgets.Aerc, cmd string) []string { - args, err := shlex.Split(cmd) +// GetCompletions returns the completion options and the command prefix +func (cmds *Commands) GetCompletions( + aerc *widgets.Aerc, cmd string, +) (options []string, prefix string) { + log.Tracef("completing command: %s", cmd) + + // start completion + args, err := splitCmd(cmd) if err != nil { - return nil + return } // nothing entered, list all commands if len(args) == 0 { - names := cmds.Names() - sort.Strings(names) - return names + options = cmds.Names() + sort.Strings(options) + return + } + + // complete command name + spaceTerminated := cmd[len(cmd)-1] == ' ' + if len(args) == 1 && !spaceTerminated { + for _, n := range cmds.Names() { + options = append(options, n+" ") + } + options = CompletionFromList(aerc, options, args) + + return + } + + // look for command in dictionary + c, ok := cmds.dict()[args[0]] + if !ok { + return } // complete options - if len(args) > 1 || cmd[len(cmd)-1] == ' ' { - if cmd, ok := cmds.dict()[args[0]]; ok { - var completions []string - if len(args) > 1 { - completions = cmd.Complete(aerc, args[1:]) - } else { - completions = cmd.Complete(aerc, []string{}) + var spec string + if provider, ok := c.(OptionsProvider); ok { + spec = provider.Options() + } + + parser, err := newParser(cmd, spec, spaceTerminated) + if err != nil { + log.Debugf("completion parser failed: %v", err) + return + } + + switch parser.kind { + case SHORT_OPTION: + for _, r := range strings.ReplaceAll(spec, ":", "") { + if strings.ContainsRune(parser.flag, r) { + continue } - if completions != nil && len(completions) == 0 { - return nil + option := string(r) + if strings.Contains(spec, option+":") { + option += " " } - - options := make([]string, 0) - for _, option := range completions { - options = append(options, args[0]+" "+option) + options = append(options, option) + } + prefix = cmd + case OPTION_ARGUMENT: + cmpl, ok := c.(OptionCompleter) + if !ok { + return + } + stem := cmd + if parser.arg != "" { + stem = strings.TrimSuffix(cmd, parser.arg) + } + pad := "" + if !strings.HasSuffix(stem, " ") { + pad += " " + } + s := parser.flag + r := rune(s[len(s)-1]) + for _, option := range cmpl.CompleteOption(aerc, r, parser.arg) { + options = append(options, pad+escape(option)+" ") + } + prefix = stem + case OPERAND: + stem := strings.Join(args[:parser.optind], " ") + for _, option := range c.Complete(aerc, args[1:]) { + if strings.Contains(option, " ") { + option = escape(option) } - return options + options = append(options, " "+option) } - return nil + prefix = stem } - // complete available commands - names := cmds.Names() - options := FilterList(names, args[0], "", aerc.SelectedAccountUiConfig().FuzzyComplete) - - if len(options) > 0 { - return options - } - return nil + return } func GetFolders(aerc *widgets.Aerc, args []string) []string { @@ -246,3 +305,19 @@ func hasUpper(s string) bool { } return false } + +// splitCmd splits the command into arguments +func splitCmd(cmd string) ([]string, error) { + args, err := shlex.Split(cmd) + if err != nil { + return nil, err + } + return args, nil +} + +func escape(s string) string { + if strings.Contains(s, " ") { + return strings.ReplaceAll(s, " ", "\\ ") + } + return s +} diff --git a/commands/completion_helpers.go b/commands/completion_helpers.go new file mode 100644 index 00000000..96a423ee --- /dev/null +++ b/commands/completion_helpers.go @@ -0,0 +1,78 @@ +package commands + +import ( + "fmt" + "net/mail" + "strings" + + "git.sr.ht/~rjarry/aerc/completer" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/log" + "git.sr.ht/~rjarry/aerc/widgets" +) + +// GetAddress uses the address-book-cmd for address completion +func GetAddress(aerc *widgets.Aerc, search string) []string { + var options []string + + cmd := aerc.SelectedAccount().AccountConfig().AddressBookCmd + if cmd == "" { + cmd = config.Compose.AddressBookCmd + if cmd == "" { + return nil + } + } + + cmpl := completer.New(cmd, func(err error) { + aerc.PushError( + fmt.Sprintf("could not complete header: %v", err)) + log.Warnf("could not complete header: %v", err) + }) + + if len(search) > config.Ui.CompletionMinChars && cmpl != nil { + addrList, _ := cmpl.ForHeader("to")(search) + for _, full := range addrList { + addr, err := mail.ParseAddress(full) + if err != nil { + continue + } + options = append(options, addr.Address) + } + } + + return options +} + +// GetFlagList returns a list of available flags for completion +func GetFlagList() []string { + return []string{"Seen", "Answered", "Flagged"} +} + +// GetDateList returns a list of date terms for completion +func GetDateList() []string { + return []string{ + "today", "yesterday", "this_week", "this_month", + "this_year", "last_week", "last_month", "last_year", + "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", + "Saturday", "Sunday", + } +} + +// Operands returns a slice without any option flags or mandatory option +// arguments +func Operands(args []string, spec string) []string { + var result []string + for i := 0; i < len(args); i++ { + if s := args[i]; s == "--" { + return args[i+1:] + } else if strings.HasPrefix(s, "-") && len(spec) > 0 { + r := string(s[len(s)-1]) + ":" + if strings.Contains(spec, r) { + i++ + } + continue + } + result = append(result, args[i]) + } + return result +} diff --git a/commands/completion_helpers_test.go b/commands/completion_helpers_test.go new file mode 100644 index 00000000..876dc26d --- /dev/null +++ b/commands/completion_helpers_test.go @@ -0,0 +1,44 @@ +package commands_test + +import ( + "strings" + "testing" + + "git.sr.ht/~rjarry/aerc/commands" +) + +func TestCommands_Operand(t *testing.T) { + tests := []struct { + args []string + spec string + want string + }{ + { + args: []string{"cmd", "-a", "-b", "arg1", "-c", "bla"}, + spec: "ab:c", + want: "cmdbla", + }, + { + args: []string{"cmd", "-a", "-b", "arg1", "-c", "--", "bla"}, + spec: "ab:c", + want: "bla", + }, + { + args: []string{"cmd", "-a", "-b", "arg1", "-c", "bla"}, + spec: "ab:c:", + want: "cmd", + }, + { + args: nil, + spec: "ab:c:", + want: "", + }, + } + for i, test := range tests { + arg := strings.Join(commands.Operands(test.args, test.spec), "") + if arg != test.want { + t.Errorf("failed test %d: want '%s', got '%s'", i, + test.want, arg) + } + } +} diff --git a/commands/parser.go b/commands/parser.go new file mode 100644 index 00000000..e8146506 --- /dev/null +++ b/commands/parser.go @@ -0,0 +1,134 @@ +package commands + +import ( + "strings" +) + +type completionType int + +const ( + NONE completionType = iota + COMMAND + OPERAND + SHORT_OPTION + OPTION_ARGUMENT +) + +type parser struct { + tokens []string + optind int + spec string + space bool + kind completionType + flag string + arg string + err error +} + +func newParser(cmd, spec string, spaceTerminated bool) (*parser, error) { + args, err := splitCmd(cmd) + if err != nil { + return nil, err + } + + p := &parser{ + tokens: args, + optind: 0, + spec: spec, + space: spaceTerminated, + kind: NONE, + flag: "", + arg: "", + err: nil, + } + + state := command + for state != nil { + state = state(p) + } + + return p, p.err +} + +func (p *parser) empty() bool { + return len(p.tokens) == 0 +} + +func (p *parser) peek() string { + return p.tokens[0] +} + +func (p *parser) advance() string { + if p.empty() { + return "" + } + tok := p.tokens[0] + p.tokens = p.tokens[1:] + p.optind++ + return tok +} + +func (p *parser) set(t completionType) { + p.kind = t +} + +func (p *parser) hasArgument() bool { + n := len(p.flag) + if n > 0 { + s := string(p.flag[n-1]) + ":" + return strings.Contains(p.spec, s) + } + return false +} + +type stateFn func(*parser) stateFn + +func command(p *parser) stateFn { + p.set(COMMAND) + p.advance() + return peek(p) +} + +func peek(p *parser) stateFn { + if p.empty() { + if p.space { + return operand + } + return nil + } + if p.spec == "" { + return operand + } + s := p.peek() + switch { + case s == "--": + p.advance() + case strings.HasPrefix(s, "-"): + return short_option + } + return operand +} + +func short_option(p *parser) stateFn { + p.set(SHORT_OPTION) + tok := p.advance() + p.flag = tok[1:] + if p.hasArgument() { + return option_argument + } + return peek(p) +} + +func option_argument(p *parser) stateFn { + p.set(OPTION_ARGUMENT) + p.arg = p.advance() + if p.empty() && len(p.arg) == 0 { + return nil + } + return peek(p) +} + +func operand(p *parser) stateFn { + p.set(OPERAND) + return nil +} diff --git a/commands/parser_test.go b/commands/parser_test.go new file mode 100644 index 00000000..d6ccd385 --- /dev/null +++ b/commands/parser_test.go @@ -0,0 +1,258 @@ +package commands + +import ( + "testing" +) + +var parserTests = []struct { + name string + cmd string + wantType completionType + wantFlag string + wantArg string + wantOptind int +}{ + { + name: "empty command", + cmd: "", + wantType: COMMAND, + wantFlag: "", + wantArg: "", + wantOptind: 0, + }, + { + name: "command only", + cmd: "cmd", + wantType: COMMAND, + wantFlag: "", + wantArg: "", + wantOptind: 1, + }, + { + name: "with space", + cmd: "cmd ", + wantType: OPERAND, + wantFlag: "", + wantArg: "", + wantOptind: 1, + }, + { + name: "with two spaces", + cmd: "cmd ", + wantType: OPERAND, + wantFlag: "", + wantArg: "", + wantOptind: 1, + }, + { + name: "with single option flag", + cmd: "cmd -", + wantType: SHORT_OPTION, + wantFlag: "", + wantArg: "", + wantOptind: 2, + }, + { + name: "with single option flag two spaces", + cmd: "cmd -", + wantType: SHORT_OPTION, + wantFlag: "", + wantArg: "", + wantOptind: 2, + }, + { + name: "with single option flag completed", + cmd: "cmd -a", + wantType: SHORT_OPTION, + wantFlag: "a", + wantArg: "", + wantOptind: 2, + }, + { + name: "with single option flag completed and space", + cmd: "cmd -a ", + wantType: OPERAND, + wantFlag: "a", + wantArg: "", + wantOptind: 2, + }, + { + name: "with single option flag completed and two spaces", + cmd: "cmd -a ", + wantType: OPERAND, + wantFlag: "a", + wantArg: "", + wantOptind: 2, + }, + { + name: "with two single option flag completed", + cmd: "cmd -b -a", + wantType: SHORT_OPTION, + wantFlag: "a", + wantArg: "", + wantOptind: 3, + }, + { + name: "with two single option flag combined", + cmd: "cmd -ab", + wantType: SHORT_OPTION, + wantFlag: "ab", + wantArg: "", + wantOptind: 2, + }, + { + name: "with two single option flag and space", + cmd: "cmd -ab ", + wantType: OPERAND, + wantFlag: "ab", + wantArg: "", + wantOptind: 2, + }, + { + name: "with mandatory option flag", + cmd: "cmd -f", + wantType: OPTION_ARGUMENT, + wantFlag: "f", + wantArg: "", + wantOptind: 2, + }, + { + name: "with mandatory option flag and space", + cmd: "cmd -f ", + wantType: OPTION_ARGUMENT, + wantFlag: "f", + wantArg: "", + wantOptind: 2, + }, + { + name: "with mandatory option flag and two spaces", + cmd: "cmd -f ", + wantType: OPTION_ARGUMENT, + wantFlag: "f", + wantArg: "", + wantOptind: 2, + }, + { + name: "with mandatory option flag and completed", + cmd: "cmd -f a", + wantType: OPTION_ARGUMENT, + wantFlag: "f", + wantArg: "a", + wantOptind: 3, + }, + { + name: "with mandatory option flag and completed quote", + cmd: "cmd -f 'a b'", + wantType: OPTION_ARGUMENT, + wantFlag: "f", + wantArg: "a b", + wantOptind: 3, + }, + { + name: "with mandatory option flag and operand", + cmd: "cmd -f 'a b' hello", + wantType: OPERAND, + wantFlag: "f", + wantArg: "a b", + wantOptind: 3, + }, + { + name: "with mandatory option flag and two spaces between", + cmd: "cmd -f a", + wantType: OPTION_ARGUMENT, + wantFlag: "f", + wantArg: "a", + wantOptind: 3, + }, + { + name: "with mandatory option flag and more spaces", + cmd: "cmd -f a ", + wantType: OPERAND, + wantFlag: "f", + wantArg: "a", + wantOptind: 3, + }, + { + name: "with template data", + cmd: "cmd -a {{if .Size}} hello {{else}} {{end}}", + wantType: OPERAND, + wantFlag: "a", + wantArg: "", + wantOptind: 2, + }, + { + name: "with operand", + cmd: "cmd -ab /tmp/aerc-", + wantType: OPERAND, + wantFlag: "ab", + wantArg: "", + wantOptind: 2, + }, + { + name: "with operand indicator", + cmd: "cmd -ab -- /tmp/aerc-", + wantType: OPERAND, + wantFlag: "ab", + wantArg: "", + wantOptind: 3, + }, + { + name: "hyphen connected command", + cmd: "cmd-dmc", + wantType: COMMAND, + wantFlag: "", + wantArg: "", + wantOptind: 1, + }, + { + name: "incomplete hyphen connected command", + cmd: "cmd-", + wantType: COMMAND, + wantFlag: "", + wantArg: "", + wantOptind: 1, + }, + { + name: "hyphen connected command with option", + cmd: "cmd-dmc -a", + wantType: SHORT_OPTION, + wantFlag: "a", + wantArg: "", + wantOptind: 2, + }, +} + +func TestCommands_Parser(t *testing.T) { + for i, test := range parserTests { + n := len(test.cmd) + spaceTerminated := n > 0 && test.cmd[n-1] == ' ' + parser, err := newParser(test.cmd, "abf:", spaceTerminated) + if err != nil { + t.Errorf("parser error: %v", err) + } + + if test.wantType != parser.kind { + t.Errorf("test %d '%s': completion type does not match: "+ + "want %d, but got %d", i, test.cmd, test.wantType, + parser.kind) + } + + if test.wantFlag != parser.flag { + t.Errorf("test %d '%s': flag does not match: "+ + "want %s, but got %s", i, test.cmd, test.wantFlag, + parser.flag) + } + + if test.wantArg != parser.arg { + t.Errorf("test %d '%s': arg does not match: "+ + "want %s, but got %s", i, test.cmd, test.wantArg, + parser.arg) + } + + if test.wantOptind != parser.optind { + t.Errorf("test %d '%s': optind does not match: "+ + "want %d, but got %d", i, test.cmd, test.wantOptind, + parser.optind) + } + } +} diff --git a/commands/prompt.go b/commands/prompt.go index 8746bcfe..a93d19a9 100644 --- a/commands/prompt.go +++ b/commands/prompt.go @@ -36,7 +36,7 @@ func (Prompt) Complete(aerc *widgets.Aerc, args []string) []string { if hascommand { return nil } - cs = GlobalCommands.GetCompletions(aerc, args[1]) + cs, _ = GlobalCommands.GetCompletions(aerc, args[1]) } if cs == nil { return nil @@ -88,13 +88,18 @@ func execCommand( return nil } -func getCompletions(aerc *widgets.Aerc, cmd string) []string { +func getCompletions(aerc *widgets.Aerc, cmd string) ([]string, string) { var completions []string + var prefix string for _, set := range getCommands(aerc.SelectedTabContent()) { - completions = append(completions, set.GetCompletions(aerc, cmd)...) + options, s := set.GetCompletions(aerc, cmd) + if s != "" { + prefix = s + } + completions = append(completions, options...) } sort.Strings(completions) - return completions + return completions, prefix } // set at build time @@ -198,7 +203,7 @@ func main() { msg *models.MessageInfo, ) error { return execCommand(aerc, ui, cmd, acct, msg) - }, func(cmd string) []string { + }, func(cmd string) ([]string, string) { return getCompletions(aerc, cmd) }, &commands.CmdHistory, deferLoop) diff --git a/widgets/aerc.go b/widgets/aerc.go index f4812dd4..9f88ecb6 100644 --- a/widgets/aerc.go +++ b/widgets/aerc.go @@ -28,7 +28,7 @@ type Aerc struct { accounts map[string]*AccountView cmd func([]string, *config.AccountConfig, *models.MessageInfo) error cmdHistory lib.History - complete func(cmd string) []string + complete func(cmd string) ([]string, string) focused ui.Interactive grid *ui.Grid simulating int @@ -54,7 +54,7 @@ type Choice struct { func NewAerc( crypto crypto.Provider, cmd func([]string, *config.AccountConfig, *models.MessageInfo) error, - complete func(cmd string) []string, cmdHistory lib.History, + complete func(cmd string) ([]string, string), cmdHistory lib.History, deferLoop chan struct{}, ) *Aerc { tabs := ui.NewTabs(config.Ui) @@ -290,7 +290,7 @@ func (aerc *Aerc) simulate(strokes []config.KeyStroke) { // If we are still focused on the exline, turn on tab complete if exline, ok := aerc.focused.(*ExLine); ok { exline.TabComplete(func(cmd string) ([]string, string) { - return aerc.complete(cmd), "" + return aerc.complete(cmd) }) // send tab to text input to trigger completion exline.Event(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) @@ -594,7 +594,7 @@ func (aerc *Aerc) BeginExCommand(cmd string) { tabComplete = nil } else { tabComplete = func(cmd string) ([]string, string) { - return aerc.complete(cmd), "" + return aerc.complete(cmd) } } exline := NewExLine(cmd, func(cmd string) { |