aboutsummaryrefslogblamecommitdiffstats
path: root/commands/commands.go
blob: a06e8ef966b270c08d90b3fb432f29e72fa390ac (plain) (tree)
1
2
3
4
5
6
7
8
9


                
               
                
              
                 
              
                 
                 
 
                                     
 
                                    
                                       
                                        

                                              
                                       

 






                                              
                    

                                                

                                                  








                                                
                                       


                                  
                                         






                                   
                        
                            
                                
                          
                               

 
                                  
 









                                                                  

 


                                   
                                           

                                         




                                        





                                                

 


                                    
 



                                                    
         
 


                    



                                               

 






                                                                
                                             



                                                
         















                                                                                  
         
 


                                                     
 
                                            

 
















                                                                            

 
                  


                                  
                                    
 
                                     
                        
                                                               

                                      
                                          

                                      


                                               
                                     


                                   




                                                                      
 
                          

 
                                                        
                                    

                                                 
         
                                                         





                                                                     
 
 



                                                                     

                                                                   
                               
                                      

                 

                                              


                                                     
                                      

                 
                                
         
 
                     
 
 
                           
                   
                            




















                                                                        
                            



                                                      
                                                        
                               


                                                                       
                                            




                             
                                                                       

                                    
                                             

                                                                     





                                                        
                                        
 
 
                                      
                                     
                        
                                        
         
                                                              
 
 











                                                           
         
                           
                                          


                                     
                                     


                                        
                         










                                                                                
         


                                                                     

 
















                                                                             
package commands

import (
	"bytes"
	"errors"
	"path"
	"reflect"
	"sort"
	"strings"
	"unicode"

	"git.sr.ht/~rjarry/go-opt/v2"

	"git.sr.ht/~rjarry/aerc/app"
	"git.sr.ht/~rjarry/aerc/config"
	"git.sr.ht/~rjarry/aerc/lib/log"
	"git.sr.ht/~rjarry/aerc/lib/state"
	"git.sr.ht/~rjarry/aerc/lib/templates"
	"git.sr.ht/~rjarry/aerc/models"
)

type CommandContext uint32

const (
	NONE = 1 << iota
	// available everywhere
	GLOBAL
	// only when a message list is focused
	MESSAGE_LIST
	// only when a message viewer is focused
	MESSAGE_VIEWER
	// only when a message composer is focused
	COMPOSE
	// only when a terminal
	TERMINAL
)

func CurrentContext() CommandContext {
	var context CommandContext = GLOBAL

	switch app.SelectedTabContent().(type) {
	case *app.AccountView:
		context |= MESSAGE_LIST
	case *app.Composer:
		context |= COMPOSE
	case *app.MessageViewer:
		context |= MESSAGE_VIEWER
	case *app.Terminal:
		context |= TERMINAL
	}

	return context
}

type Command interface {
	Description() string
	Context() CommandContext
	Aliases() []string
	Execute([]string) error
}

var allCommands map[string]Command

func Register(cmd Command) {
	if allCommands == nil {
		allCommands = make(map[string]Command)
	}
	for _, alias := range cmd.Aliases() {
		if allCommands[alias] != nil {
			panic("duplicate command alias: " + alias)
		}
		allCommands[alias] = cmd
	}
}

func ActiveCommands() []Command {
	var cmds []Command
	context := CurrentContext()
	seen := make(map[reflect.Type]bool)

	for _, cmd := range allCommands {
		t := reflect.TypeOf(cmd)
		if seen[t] {
			continue
		}
		seen[t] = true
		if cmd.Context()&context != 0 {
			cmds = append(cmds, cmd)
		}
	}

	return cmds
}

func ActiveCommandNames() []string {
	var names []string
	context := CurrentContext()

	for alias, cmd := range allCommands {
		if cmd.Context()&context != 0 {
			names = append(names, alias)
		}
	}

	return names
}

type NoSuchCommand string

func (err NoSuchCommand) Error() string {
	return "Unknown command " + string(err)
}

// Expand non-ambiguous command abbreviations.
//
//	q  --> quit
//	ar --> archive
//	im --> import-mbox
func ExpandAbbreviations(name string) (string, Command, error) {
	context := CurrentContext()
	name = strings.TrimLeft(name, ": \t")

	cmd, found := allCommands[name]
	if found && cmd.Context()&context != 0 {
		return name, cmd, nil
	}

	var candidate Command
	var candidateName string

	for alias, cmd := range allCommands {
		if cmd.Context()&context == 0 || !strings.HasPrefix(alias, name) {
			continue
		}
		if candidate != nil {
			// We have more than one command partially
			// matching the input.
			return name, nil, NoSuchCommand(name)
		}
		// We have a partial match.
		candidate = cmd
		candidateName = alias
	}

	if candidate == nil {
		return name, nil, NoSuchCommand(name)
	}

	return candidateName, candidate, nil
}

func ResolveCommand(
	cmdline string, acct *config.AccountConfig, msg *models.MessageInfo,
) (string, Command, error) {
	cmdline, err := ExpandTemplates(cmdline, acct, msg)
	if err != nil {
		return "", nil, err
	}
	name, rest, didCut := strings.Cut(cmdline, " ")
	name, cmd, err := ExpandAbbreviations(name)
	if err != nil {
		return "", nil, err
	}
	cmdline = name
	if didCut {
		cmdline += " " + rest
	}
	return cmdline, cmd, nil
}

func templateData(
	cfg *config.AccountConfig,
	msg *models.MessageInfo,
) models.TemplateData {
	var folder *models.Directory

	acct := app.SelectedAccount()
	if acct != nil {
		folder = acct.Directories().SelectedDirectory()
	}
	if cfg == nil && acct != nil {
		cfg = acct.AccountConfig()
	}
	if msg == nil && acct != nil {
		msg, _ = acct.SelectedMessage()
	}

	data := state.NewDataSetter()
	data.SetAccount(cfg)
	data.SetFolder(folder)
	data.SetInfo(msg, 0, false)
	if acct != nil {
		acct.SetStatus(func(s *state.AccountState, _ string) {
			data.SetState(s)
		})
	}

	return data.Data()
}

func ExecuteCommand(cmd Command, cmdline string) error {
	args := opt.LexArgs(cmdline)
	if args.Count() == 0 {
		return errors.New("No arguments")
	}
	log.Tracef("executing command %s", args.String())
	// copy zeroed struct
	tmp := reflect.New(reflect.TypeOf(cmd)).Interface().(Command)
	if err := opt.ArgsToStruct(args.Clone(), tmp); err != nil {
		return err
	}
	return tmp.Execute(args.Args())
}

// expand template expressions
func ExpandTemplates(
	s string, cfg *config.AccountConfig, msg *models.MessageInfo,
) (string, error) {
	if strings.Contains(s, "{{") && strings.Contains(s, "}}") {
		t, err := templates.ParseTemplate("execute", s)
		if err != nil {
			return "", err
		}

		data := templateData(cfg, msg)

		var buf bytes.Buffer
		err = templates.Render(t, &buf, data)
		if err != nil {
			return "", err
		}

		s = buf.String()
	}

	return s, nil
}

func GetTemplateCompletion(
	cmd string,
) ([]string, string, bool) {
	countLeft := strings.Count(cmd, "{{")
	if countLeft == 0 {
		return nil, "", false
	}
	countRight := strings.Count(cmd, "}}")

	switch {
	case countLeft > countRight:
		// complete template terms
		var i int
		for i = len(cmd) - 1; i >= 0; i-- {
			if strings.ContainsRune("{()| ", rune(cmd[i])) {
				break
			}
		}
		search, prefix := cmd[i+1:], cmd[:i+1]
		padding := strings.Repeat(" ",
			len(search)-len(strings.TrimLeft(search, " ")))
		options := FilterList(
			templates.Terms(),
			strings.TrimSpace(search),
			nil,
		)
		return options, prefix + padding, true
	case countLeft == countRight:
		// expand template
		s, err := ExpandTemplates(cmd, nil, nil)
		if err != nil {
			log.Warnf("template rendering failed: %v", err)
			return nil, "", false
		}
		return []string{s}, "", true
	}

	return nil, "", false
}

// GetCompletions returns the completion options and the command prefix
func GetCompletions(
	cmd Command, args *opt.Args,
) (options []opt.Completion, prefix string) {
	// copy zeroed struct
	tmp := reflect.New(reflect.TypeOf(cmd)).Interface().(Command)
	s, err := args.ArgSafe(0)
	if err != nil {
		log.Errorf("completions error: %v", err)
		return options, prefix
	}
	spec := opt.NewCmdSpec(s, tmp)
	return spec.GetCompletions(args)
}

func GetFolders(arg string) []string {
	acct := app.SelectedAccount()
	if acct == nil {
		return make([]string, 0)
	}
	return FilterList(acct.Directories().List(), arg, nil)
}

func GetTemplates(arg string) []string {
	templates := make(map[string]bool)
	for _, dir := range config.Templates.TemplateDirs {
		for _, f := range listDir(dir, false) {
			if !isDir(path.Join(dir, f)) {
				templates[f] = true
			}
		}
	}
	names := make([]string, len(templates))
	for n := range templates {
		names = append(names, n)
	}
	sort.Strings(names)
	return FilterList(names, arg, nil)
}

func GetLabels(arg string) []string {
	acct := app.SelectedAccount()
	if acct == nil {
		return make([]string, 0)
	}
	var prefix string
	if arg != "" {
		// + and - are used to denote tag addition / removal and need to
		// be striped only the last tag should be completed, so that
		// multiple labels can be selected
		switch arg[0] {
		case '+':
			prefix = "+"
		case '-':
			prefix = "-"
		}
		arg = strings.TrimLeft(arg, "+-")
	}
	return FilterList(acct.Labels(), arg, func(s string) string {
		return opt.QuoteArg(prefix+s) + " "
	})
}

// hasCaseSmartPrefix checks whether s starts with prefix, using a case
// sensitive match if and only if prefix contains upper case letters.
func hasCaseSmartPrefix(s, prefix string) bool {
	if hasUpper(prefix) {
		return strings.HasPrefix(s, prefix)
	}
	return strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix))
}

func hasUpper(s string) bool {
	for _, r := range s {
		if unicode.IsUpper(r) {
			return true
		}
	}
	return false
}