github.com/clusterize-io/tusk@v0.6.3-0.20211001020217-cfe8a8cd0d4a/appcli/install_completion.go (about)

     1  package appcli
     2  
     3  import (
     4  	"bufio"
     5  	"fmt"
     6  	"io/ioutil"
     7  	"os"
     8  	"path/filepath"
     9  	"regexp"
    10  
    11  	// Embed completion scripts.
    12  	_ "embed"
    13  
    14  	"github.com/clusterize-io/tusk/runner"
    15  	"github.com/clusterize-io/tusk/ui"
    16  )
    17  
    18  //go:embed completion/tusk-completion.bash
    19  var rawBashCompletion string
    20  
    21  //go:embed completion/tusk.fish
    22  var rawFishCompletion string
    23  
    24  //go:embed completion/_tusk
    25  var rawZshCompletion string
    26  
    27  const (
    28  	bashCompletionFile = "tusk-completion.bash"
    29  	fishCompletionFile = "tusk.fish"
    30  	zshCompletionFile  = "_tusk"
    31  	zshInstallDir      = "/usr/local/share/zsh/site-functions"
    32  )
    33  
    34  var bashRCFiles = []string{".bashrc", ".bash_profile", ".profile"}
    35  
    36  // InstallCompletion installs command line tab completion for a given shell.
    37  func InstallCompletion(meta *runner.Metadata) error {
    38  	shell := meta.InstallCompletion
    39  	switch shell {
    40  	case "bash":
    41  		return installBashCompletion(meta.Logger)
    42  	case "fish":
    43  		return installFishCompletion(meta.Logger)
    44  	case "zsh":
    45  		return installZshCompletion(meta.Logger, zshInstallDir)
    46  	default:
    47  		return fmt.Errorf("tab completion for %q is not supported", shell)
    48  	}
    49  }
    50  
    51  // UninstallCompletion uninstalls command line tab completion for a given shell.
    52  func UninstallCompletion(meta *runner.Metadata) error {
    53  	shell := meta.UninstallCompletion
    54  	switch shell {
    55  	case "bash":
    56  		return uninstallBashCompletion()
    57  	case "fish":
    58  		return uninstallFishCompletion()
    59  	case "zsh":
    60  		return uninstallZshCompletion(zshInstallDir)
    61  	default:
    62  		return fmt.Errorf("tab completion for %q is not supported", shell)
    63  	}
    64  }
    65  
    66  func installBashCompletion(logger *ui.Logger) error {
    67  	dir, err := getDataDir()
    68  	if err != nil {
    69  		return err
    70  	}
    71  
    72  	err = installFileInDir(logger, dir, bashCompletionFile, []byte(rawBashCompletion))
    73  	if err != nil {
    74  		return err
    75  	}
    76  
    77  	rcfile, err := getBashRCFile()
    78  	if err != nil {
    79  		return err
    80  	}
    81  
    82  	slashPath := filepath.ToSlash(filepath.Join(dir, bashCompletionFile))
    83  	command := fmt.Sprintf("source %q", slashPath)
    84  	return appendIfAbsent(rcfile, command)
    85  }
    86  
    87  func getBashRCFile() (string, error) {
    88  	homedir, err := os.UserHomeDir()
    89  	if err != nil {
    90  		return "", err
    91  	}
    92  
    93  	for _, rcfile := range bashRCFiles {
    94  		path := filepath.Join(homedir, rcfile)
    95  		if _, err := os.Stat(path); err != nil {
    96  			if os.IsNotExist(err) {
    97  				continue
    98  			}
    99  
   100  			return "", err
   101  		}
   102  
   103  		return path, nil
   104  	}
   105  
   106  	return filepath.Join(homedir, ".bashrc"), nil
   107  }
   108  
   109  func appendIfAbsent(path, text string) error {
   110  	// nolint: gosec
   111  	f, err := os.OpenFile(path, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0644)
   112  	if err != nil {
   113  		return err
   114  	}
   115  	defer f.Close() // nolint: errcheck
   116  
   117  	scanner := bufio.NewScanner(f)
   118  
   119  	prependNewline := false
   120  	for scanner.Scan() {
   121  		switch scanner.Text() {
   122  		case text:
   123  			return nil
   124  		case "":
   125  			prependNewline = false
   126  		default:
   127  			prependNewline = true
   128  		}
   129  	}
   130  	if serr := scanner.Err(); serr != nil {
   131  		return serr
   132  	}
   133  
   134  	if prependNewline {
   135  		text = "\n" + text
   136  	}
   137  
   138  	_, err = fmt.Fprintln(f, text)
   139  	return err
   140  }
   141  
   142  func uninstallBashCompletion() error {
   143  	dir, err := getDataDir()
   144  	if err != nil {
   145  		return err
   146  	}
   147  
   148  	err = uninstallFileFromDir(dir, bashCompletionFile)
   149  	if err != nil {
   150  		return err
   151  	}
   152  
   153  	rcfile, err := getBashRCFile()
   154  	if err != nil {
   155  		return err
   156  	}
   157  
   158  	re := regexp.MustCompile(fmt.Sprintf(`source ".*/%s"`, bashCompletionFile))
   159  	return removeLineInFile(rcfile, re)
   160  }
   161  
   162  func removeLineInFile(path string, re *regexp.Regexp) error {
   163  	rf, err := os.OpenFile(path, os.O_RDONLY, 0644) // nolint: gosec
   164  	if err != nil {
   165  		return err
   166  	}
   167  	defer rf.Close() // nolint: errcheck
   168  
   169  	wf, err := ioutil.TempFile("", ".profile.tusk.bkp")
   170  	if err != nil {
   171  		return err
   172  	}
   173  	defer wf.Close() // nolint: errcheck
   174  
   175  	scanner := bufio.NewScanner(rf)
   176  
   177  	buf := ""
   178  	for scanner.Scan() {
   179  		line := scanner.Text()
   180  		switch {
   181  		case re.MatchString(line):
   182  			continue
   183  		case line == "":
   184  			buf += "\n"
   185  			continue
   186  		}
   187  
   188  		_, err := fmt.Fprintln(wf, buf+line)
   189  		if err != nil {
   190  			return err
   191  		}
   192  
   193  		buf = ""
   194  	}
   195  	if serr := scanner.Err(); serr != nil {
   196  		return serr
   197  	}
   198  
   199  	rf.Close() // nolint: errcheck
   200  	wf.Close() // nolint: errcheck
   201  	return os.Rename(wf.Name(), path)
   202  }
   203  
   204  func installFishCompletion(logger *ui.Logger) error {
   205  	dir, err := getFishCompletionsDir()
   206  	if err != nil {
   207  		return err
   208  	}
   209  
   210  	return installFileInDir(logger, dir, fishCompletionFile, []byte(rawFishCompletion))
   211  }
   212  
   213  func uninstallFishCompletion() error {
   214  	dir, err := getFishCompletionsDir()
   215  	if err != nil {
   216  		return err
   217  	}
   218  
   219  	return uninstallFileFromDir(dir, fishCompletionFile)
   220  }
   221  
   222  func getDataDir() (string, error) {
   223  	if xdgHome := os.Getenv("XDG_DATA_HOME"); xdgHome != "" {
   224  		return filepath.Join(xdgHome, "tusk"), nil
   225  	}
   226  
   227  	homedir, err := os.UserHomeDir()
   228  	if err != nil {
   229  		return "", err
   230  	}
   231  
   232  	return filepath.Join(homedir, ".local", "share", "tusk"), nil
   233  }
   234  
   235  // getFishCompletionsDir gets the directory to place completions in, adhering
   236  // to the XDG base directory.
   237  func getFishCompletionsDir() (string, error) {
   238  	if xdgHome := os.Getenv("XDG_CONFIG_HOME"); xdgHome != "" {
   239  		return filepath.Join(xdgHome, "fish", "completions"), nil
   240  	}
   241  
   242  	homedir, err := os.UserHomeDir()
   243  	if err != nil {
   244  		return "", err
   245  	}
   246  
   247  	return filepath.Join(homedir, ".config", "fish", "completions"), nil
   248  }
   249  
   250  func installZshCompletion(logger *ui.Logger, dir string) error {
   251  	return installFileInDir(logger, dir, zshCompletionFile, []byte(rawZshCompletion))
   252  }
   253  
   254  func installFileInDir(logger *ui.Logger, dir, file string, content []byte) error {
   255  	// nolint: gosec
   256  	if err := os.MkdirAll(dir, 0755); err != nil {
   257  		return err
   258  	}
   259  
   260  	target := filepath.Join(dir, file)
   261  
   262  	// nolint: gosec
   263  	if err := ioutil.WriteFile(target, content, 0644); err != nil {
   264  		return err
   265  	}
   266  
   267  	logger.Info("Tab completion successfully installed", target)
   268  	logger.Info("You may need to restart your shell for completion to take effect")
   269  	return nil
   270  }
   271  
   272  func uninstallZshCompletion(dir string) error {
   273  	return uninstallFileFromDir(dir, zshCompletionFile)
   274  }
   275  
   276  func uninstallFileFromDir(dir, file string) error {
   277  	err := os.Remove(filepath.Join(dir, file))
   278  	if !os.IsNotExist(err) {
   279  		return err
   280  	}
   281  
   282  	return nil
   283  }