initial commit [141.14.129.234,mike]
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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, "overview": true, "archive": true, "init": true,
|
||||
"login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true,
|
||||
"open": true, "view": true, "count": true, "tag": true, "alias": true,
|
||||
"unalias": 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 <name> '<expansion>'"
|
||||
|
||||
// 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 <name> '<body>'" 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 ("name<whitespace>body"
|
||||
// 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)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0644); 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 <name> show a single alias
|
||||
// alias <name> <cmd> define an alias (surrounding quotes on <cmd> 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 <name>")
|
||||
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(cGray, "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(cGray, " = ") + 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() }
|
||||
Reference in New Issue
Block a user