package main // alias.go — user-defined command aliases, persisted in ~/.mgshrc. // // An alias maps a name to an expansion template, which is itself a mgsh command // line. The template may reference the arguments passed to the alias: // // $1 … $N the Nth argument ("" when missing) // $* $@ all arguments, space-joined // // When the template contains no placeholder, the arguments are appended (the // classic shell-alias behaviour). Since mgsh does not forward unknown commands // to a shell, a shell command inside an alias needs the '!' prefix, e.g. // // alias ec '!echo $1' ec hello -> runs `echo hello` // alias co 'checkout $1' co v2 -> runs the `checkout` builtin // alias p 'push $*' p fixed bug -> runs `push fixed bug` import ( "fmt" "os" "path/filepath" "regexp" "sort" "strconv" "strings" ) // aliases maps an alias name to its expansion template. Loaded at startup and // re-persisted on every change. var aliases = map[string]string{} // maxAliasDepth bounds recursive alias expansion (guards against cycles). const maxAliasDepth = 16 var ( aliasNameRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) aliasVarRe = regexp.MustCompile(`\$(\d+|\*|@)`) ) // builtinCmds is the set of reserved command words that cannot be shadowed by // an alias (mirrors the switch in runCommand). var builtinCmds = map[string]bool{ "": true, "quit": true, "exit": true, "help": true, "rescan": true, "dist": true, "list": true, "show": true, "log": true, "status": true, "diff": true, "pull": true, "fetch": true, "push": true, "edit": true, "pushremote": true, "deleteremote": true, "overview": true, "archive": true, "init": true, "login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true, "count": true, "tag": true, "alias": true, "unalias": true, "config": true, "release": true, } func isBuiltin(name string) bool { return builtinCmds[name] } // aliasHeader labels the managed alias block inside ~/.mgshrc. const aliasHeader = "# aliases — managed by the `alias` command; format: alias ''" // legacyAliasFile is the pre-4.x standalone alias file, migrated into ~/.mgshrc // on first run. func legacyAliasFile() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".mgsh_aliases") } // loadAliases reads alias definitions from ~/.mgshrc. Builtins are skipped so a // hand-edited file can never shadow a real command. func loadAliases() { data, err := os.ReadFile(configFile()) if err != nil { return } for _, ln := range strings.Split(string(data), "\n") { if name, body, ok := parseAliasLine(ln); ok && !isBuiltin(name) { aliases[name] = body } } } // parseAliasLine parses one "alias ''" line. ok is false for any // line that is not an alias definition. func parseAliasLine(ln string) (name, body string, ok bool) { t := strings.TrimSpace(ln) if f := strings.Fields(t); len(f) < 2 || f[0] != "alias" { return "", "", false } rest := strings.TrimSpace(strings.TrimPrefix(t, "alias")) i := strings.IndexAny(rest, " \t") if i < 0 { return "", "", false } name = rest[:i] body = unquote(strings.TrimSpace(rest[i+1:])) if name == "" || body == "" { return "", "", false } return name, body, true } // readLegacyAliases parses the old ~/.mgsh_aliases file ("namebody" // lines), returning nil when it does not exist. func readLegacyAliases() map[string]string { data, err := os.ReadFile(legacyAliasFile()) if err != nil { return nil } m := map[string]string{} for _, ln := range strings.Split(string(data), "\n") { t := strings.TrimSpace(ln) if t == "" || strings.HasPrefix(t, "#") { continue } i := strings.IndexAny(t, " \t") if i < 0 { continue } name := t[:i] body := strings.TrimSpace(t[i+1:]) if name != "" && body != "" && !isBuiltin(name) { m[name] = body } } return m } // aliasBlock renders the managed alias section for a set of aliases (sorted by // name), or "" when there are none. func aliasBlock(m map[string]string) string { if len(m) == 0 { return "" } names := make([]string, 0, len(m)) for n := range m { names = append(names, n) } sort.Strings(names) var b strings.Builder b.WriteString(aliasHeader + "\n") for _, n := range names { fmt.Fprintf(&b, "alias %s '%s'\n", n, m[n]) } return b.String() } // saveAliases rewrites ~/.mgshrc, preserving the configuration lines and // replacing the managed alias block with the current alias set. func saveAliases() { path := configFile() var keep []string if data, err := os.ReadFile(path); err == nil { for _, ln := range strings.Split(string(data), "\n") { if strings.TrimSpace(ln) == aliasHeader { continue } if _, _, ok := parseAliasLine(ln); ok { continue } keep = append(keep, ln) } } // drop any trailing blank lines so the block isn't pushed down over time for len(keep) > 0 && strings.TrimSpace(keep[len(keep)-1]) == "" { keep = keep[:len(keep)-1] } var b strings.Builder for _, ln := range keep { b.WriteString(ln + "\n") } if blk := aliasBlock(aliases); blk != "" { b.WriteString("\n" + blk) } // os.WriteFile only applies the mode when creating the file, so an existing // ~/.mgshrc keeps whatever the user set; a fresh one is created private. if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil { errorln("could not save aliases: " + err.Error()) } } // aliasNames returns the alias names, sorted. func aliasNames() []string { names := make([]string, 0, len(aliases)) for n := range aliases { names = append(names, n) } sort.Strings(names) return names } // handleAlias implements the `alias` command: // // alias list all aliases // alias show a single alias // alias define an alias (surrounding quotes on optional) func handleAlias(line string) { rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "alias")) if rest == "" { listAliases() return } i := strings.IndexAny(rest, " \t") if i < 0 { // just a name: show it if body, ok := aliases[rest]; ok { fmt.Println(formatAlias(rest, body)) } else { errorln("alias not defined: " + rest) } return } name := rest[:i] body := unquote(strings.TrimSpace(rest[i+1:])) switch { case !aliasNameRe.MatchString(name): errorln("invalid alias name: " + name) case isBuiltin(name): errorln("cannot alias builtin command: " + name) case body == "": errorln("empty alias definition") default: aliases[name] = body saveAliases() fmt.Println(formatAlias(name, body)) } } // handleUnalias removes an alias by name. func handleUnalias(name string) { if name == "" { errorln("usage: unalias ") return } if _, ok := aliases[name]; !ok { errorln("alias not defined: " + name) return } delete(aliases, name) saveAliases() fmt.Println("removed alias " + col(cGreen, name)) } func listAliases() { if len(aliases) == 0 { fmt.Println(col(cDark, "no aliases defined")) return } for _, n := range aliasNames() { fmt.Println(formatAlias(n, aliases[n])) } } // formatAlias renders `name = 'body'` with color. func formatAlias(name, body string) string { return col(cGreen, name) + col(cDark, " = ") + col(cYellow, "'"+body+"'") } // expandAlias substitutes positional parameters in an alias body. With no // placeholder present, the arguments are appended instead. func expandAlias(body string, args []string) string { all := strings.Join(args, " ") used := false out := aliasVarRe.ReplaceAllStringFunc(body, func(m string) string { used = true tok := m[1:] if tok == "*" || tok == "@" { return all } n, _ := strconv.Atoi(tok) if n >= 1 && n <= len(args) { return args[n-1] } return "" }) if !used && len(args) > 0 { out = strings.TrimRight(out, " ") + " " + all } return strings.TrimSpace(out) } // unquote strips one pair of matching surrounding single or double quotes. func unquote(s string) string { if len(s) >= 2 { if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') { return s[1 : len(s)-1] } } return s } // dynAliasNames feeds Tab completion for `alias`/`unalias`. func dynAliasNames(string) []string { return aliasNames() }