From 4379944077dbe3226700a301fbfa31fd6700e562 Mon Sep 17 00:00:00 2001 From: Nate Finch Date: Sat, 28 Mar 2026 09:12:33 -0400 Subject: [PATCH 01/15] add tab completion output and make mage -l no longer compile the whole code --- mage/args_test.go | 2 + mage/colors.go | 140 +++++++++++++++++++ mage/main.go | 202 +++++++++++++++++++++------ mage/main_test.go | 92 ++++++++++++- mage/template.go | 207 ++++++---------------------- mage/testdata/onlyStdLib/command.go | 2 + parse/parse.go | 42 +++--- 7 files changed, 451 insertions(+), 236 deletions(-) create mode 100644 mage/colors.go diff --git a/mage/args_test.go b/mage/args_test.go index fe3e6663..1de68e20 100644 --- a/mage/args_test.go +++ b/mage/args_test.go @@ -186,6 +186,7 @@ func TestOptionalArgs(t *testing.T) { Dir: "./testdata/optargs", Stderr: stderr, Stdout: stdout, + Keep: true, Args: []string{"greet", "World", "-greeting=Hi"}, } code := Invoke(inv) @@ -207,6 +208,7 @@ func TestOptionalArgsOmitted(t *testing.T) { Dir: "./testdata/optargs", Stderr: stderr, Stdout: stdout, + Keep: true, Args: []string{"greet", "World"}, } code := Invoke(inv) diff --git a/mage/colors.go b/mage/colors.go new file mode 100644 index 00000000..5491e084 --- /dev/null +++ b/mage/colors.go @@ -0,0 +1,140 @@ +package mage + +//nolint:revive // these are named this way because we also use this code in the generated output and we don't want the imports to potentially conflict with globals in user code. +import ( + _fmt "fmt" + _os "os" + _strconv "strconv" + _strings "strings" +) + +var printName = func(str string) string { + // color is ANSI color type + type color int + + // If you add/change/remove any items in this constant, + // you will need to run "stringer -type=color" in this directory again. + // NOTE: Please keep the list in an alphabetical order. + const ( + black color = iota + red + green + yellow + blue + magenta + cyan + white + brightblack + brightred + brightgreen + brightyellow + brightblue + brightmagenta + brightcyan + brightwhite + ) + + // AnsiColor are ANSI color codes for supported terminal colors. + var ansiColor = map[color]string{ + black: "\u001b[30m", + red: "\u001b[31m", + green: "\u001b[32m", + yellow: "\u001b[33m", + blue: "\u001b[34m", + magenta: "\u001b[35m", + cyan: "\u001b[36m", + white: "\u001b[37m", + brightblack: "\u001b[30;1m", + brightred: "\u001b[31;1m", + brightgreen: "\u001b[32;1m", + brightyellow: "\u001b[33;1m", + brightblue: "\u001b[34;1m", + brightmagenta: "\u001b[35;1m", + brightcyan: "\u001b[36;1m", + brightwhite: "\u001b[37;1m", + } + + const colorName = "blackredgreenyellowbluemagentacyanwhitebrightblackbrightredbrightgreenbrightyellowbrightbluebrightmagentabrightcyanbrightwhite" + + var colorIndex = [...]uint8{0, 5, 8, 13, 19, 23, 30, 34, 39, 50, 59, 70, 82, 92, 105, 115, 126} + + colorToLowerString := func(i color) string { + if i < 0 || i >= color(len(colorIndex)-1) { + return "color(" + _strconv.FormatInt(int64(i), 10) + ")" + } + return colorName[colorIndex[i]:colorIndex[i+1]] + } + + // ansiColorReset is an ANSI color code to reset the terminal color. + const ansiColorReset = "\033[0m" + + // defaultTargetAnsiColor is a default ANSI color for colorizing targets. + // It is set to Cyan as an arbitrary color, because it has a neutral meaning + var defaultTargetAnsiColor = ansiColor[cyan] + + getAnsiColor := func(color string) (string, bool) { + colorLower := _strings.ToLower(color) + for k, v := range ansiColor { + colorConstLower := colorToLowerString(k) + if colorConstLower == colorLower { + return v, true + } + } + return "", false + } + + // Terminals which don't support color: + // + // TERM=vt100 + // TERM=cygwin + // TERM=xterm-mono + var noColorTerms = map[string]bool{ + "vt100": false, + "cygwin": false, + "xterm-mono": false, + } + + // terminalSupportsColor checks if the current console supports color output + // + // Supported: + // + // linux, mac, or windows's ConEmu, Cmder, putty, git-bash.exe, pwsh.exe + // + // Not supported: + // + // windows cmd.exe, powerShell.exe + terminalSupportsColor := func() bool { + envTerm := _os.Getenv("TERM") + if _, ok := noColorTerms[envTerm]; ok { + return false + } + return true + } + + // enableColor reports whether the user has requested to enable a color output. + enableColor := func() bool { + b, _ := _strconv.ParseBool(_os.Getenv("MAGEFILE_ENABLE_COLOR")) + return b + } + + // targetColor returns the ANSI color which should be used to colorize targets. + targetColor := func() string { + s, exists := _os.LookupEnv("MAGEFILE_TARGET_COLOR") + if exists { + if c, ok := getAnsiColor(s); ok { + return c + } + } + return defaultTargetAnsiColor + } + + // store the color terminal variables, so that the detection isn't repeated for each target + var enableColorValue = enableColor() && terminalSupportsColor() + var targetColorValue = targetColor() + + if enableColorValue { + return _fmt.Sprintf("%s%s%s", targetColorValue, str, ansiColorReset) + } else { + return str + } +} diff --git a/mage/main.go b/mage/main.go index 49df3d39..0a2e98f0 100644 --- a/mage/main.go +++ b/mage/main.go @@ -4,6 +4,7 @@ package mage import ( "context" "crypto/sha256" + _ "embed" // so we can use //go:embed for the magefile template and colors "errors" "flag" "fmt" @@ -20,6 +21,7 @@ import ( "sort" "strings" "syscall" + "text/tabwriter" "text/template" "time" @@ -93,27 +95,28 @@ func Main() int { // Invocation contains the args for invoking a run of Mage. type Invocation struct { - Debug bool // turn on debug messages - Dir string // directory to read magefiles from - WorkDir string // directory where magefiles will run - Force bool // forces recreation of the compiled binary - Verbose bool // tells the magefile to print out log statements - List bool // tells the magefile to print out a list of targets - Help bool // tells the magefile to print out help for a specific target - Keep bool // tells mage to keep the generated main file after compiling - Timeout time.Duration // tells mage to set a timeout to running the targets - CompileOut string // tells mage to compile a static binary to this path, but not execute - GOOS string // sets the GOOS when producing a binary with -compileout - GOARCH string // sets the GOARCH when producing a binary with -compileout - Ldflags string // sets the ldflags when producing a binary with -compileout - Stdout io.Writer // writer to write stdout messages to - Stderr io.Writer // writer to write stderr messages to - Stdin io.Reader // reader to read stdin from - Args []string // args to pass to the compiled binary - GoCmd string // the go binary command to run - CacheDir string // the directory where we should store compiled binaries - HashFast bool // don't rely on GOCACHE, just hash the magefiles - Multiline bool // whether to retain line returns in help text for the generated main file + Debug bool // turn on debug messages + Dir string // directory to read magefiles from + WorkDir string // directory where magefiles will run + Force bool // forces recreation of the compiled binary + Verbose bool // tells the magefile to print out log statements + List bool // tells the magefile to print out a list of targets + Help bool // tells the magefile to print out help for a specific target + Keep bool // tells mage to keep the generated main file after compiling + Timeout time.Duration // tells mage to set a timeout to running the targets + CompileOut string // tells mage to compile a static binary to this path, but not execute + GOOS string // sets the GOOS when producing a binary with -compileout + GOARCH string // sets the GOARCH when producing a binary with -compileout + Ldflags string // sets the ldflags when producing a binary with -compileout + Stdout io.Writer // writer to write stdout messages to + Stderr io.Writer // writer to write stderr messages to + Stdin io.Reader // reader to read stdin from + Args []string // args to pass to the compiled binary + GoCmd string // the go binary command to run + CacheDir string // the directory where we should store compiled binaries + HashFast bool // don't rely on GOCACHE, just hash the magefiles + Multiline bool // whether to retain line returns in help text for the generated main file + Autocomplete bool // parse magefiles and print target names for shell completion } // MagefilesDirName is the name of the default folder to look for if no directory was specified, @@ -219,6 +222,7 @@ func Parse(stderr, stdout io.Writer, args []string) (inv Invocation, cmd Command fs.StringVar(&inv.GOOS, "goos", "", "set GOOS for binary produced with -compile") fs.StringVar(&inv.GOARCH, "goarch", "", "set GOARCH for binary produced with -compile") fs.StringVar(&inv.Ldflags, "ldflags", "", "set ldflags for binary produced with -compile") + fs.BoolVar(&inv.Autocomplete, "autocomplete", false, "print target names for shell completion, without compiling") // commands below @@ -248,6 +252,8 @@ Commands: -version show version info for the mage binary Options: + -autocomplete + print target names for shell completion, without compiling -d directory to read magefiles from (default "." or "magefiles" if exists) -debug turn on debug messages @@ -295,8 +301,7 @@ Options: numCommands++ cmd = Clean if fs.NArg() > 0 { - // Temporary dupe of below check until we refactor the other commands to use this check - return inv, cmd, errors.New("-h, -init, -clean, -compile and -version cannot be used simultaneously") + return inv, cmd, errors.New("-h, -init, -clean, -compile, -autocomplete and -version cannot be used simultaneously") } default: // no command flags set @@ -304,6 +309,9 @@ Options: if inv.Help { numCommands++ } + if inv.Autocomplete { + numCommands++ + } if inv.Debug { debug.SetOutput(stderr) @@ -313,7 +321,7 @@ Options: if numCommands > 1 { debug.Printf("%d commands defined", numCommands) - return inv, cmd, errors.New("-h, -init, -clean, -compile and -version cannot be used simultaneously") + return inv, cmd, errors.New("-h, -init, -clean, -compile, -autocomplete and -version cannot be used simultaneously") } if cmd != CompileStatic && (inv.GOARCH != "" || inv.GOOS != "") { @@ -334,6 +342,9 @@ Options: const dotDirectory = "." +//go:embed colors.go +var colorsFile string + // Invoke runs Mage with the given arguments. func Invoke(inv Invocation) int { errlog := log.New(inv.Stderr, "", 0) @@ -440,17 +451,45 @@ func Invoke(inv Invocation) int { return 1 } + if inv.Autocomplete { + return printAutocompleteTargets(inv.Stdout, info) + } + // reproducible output for deterministic builds sort.Sort(info.Funcs) sort.Sort(info.Imports) - main := filepath.Join(inv.Dir, mainfile) binaryName := "mage" if inv.CompileOut != "" { binaryName = filepath.Base(inv.CompileOut) } - err = GenerateMainfile(binaryName, main, info) + data := mainfileTemplateData{ + Description: info.Description, + Funcs: info.Funcs, + Aliases: info.Aliases, + Imports: info.Imports, + BinaryName: binaryName, + } + + if info.DefaultFunc != nil { + data.DefaultFunc = *info.DefaultFunc + } + + if inv.List { + _, _ = fmt.Fprint(inv.Stdout, mageListOutput(data, info)) + return 0 + } + + // ensure we use the same color output code in the generated mainfile as we do in mage's own output. + idx := strings.Index(colorsFile, "var printName =") + if idx == -1 { + panic(errors.New("unable to find printName func in colorsFile colors.go")) + } + data.PrintNameFunc = colorsFile[idx:] + + main := filepath.Join(inv.Dir, mainfile) + err = GenerateMainfile(data, main) if err != nil { errlog.Println("Error:", err) return 1 @@ -479,13 +518,98 @@ func Invoke(inv Invocation) int { return RunCompiled(inv, exePath, errlog) } +func mageListOutput(data mainfileTemplateData, info *parse.PkgInfo) string { + list := strings.Builder{} + + lowerFirst := func(s string) string { + parts := strings.Split(s, ":") + for i, t := range parts { + parts[i] = lowerFirstWord(t) + } + return strings.Join(parts, ":") + } + + var defaultFunc parse.Function + if info.DefaultFunc != nil { + defaultFunc = *info.DefaultFunc + } + + if data.Description != "" { + _, _ = fmt.Fprintf(&list, "%s\n\n", data.Description) + } + + targets := map[string]string{} + for _, f := range data.Funcs { + name := lowerFirst(f.TargetName()) + if f.Name == defaultFunc.Name && f.Receiver == defaultFunc.Receiver { + name += "*" + } + targets[name] = f.Synopsis + } + for _, imp := range data.Imports { + for _, f := range imp.Info.Funcs { + name := lowerFirst(f.TargetName()) + if f.Name == defaultFunc.Name && f.Receiver == defaultFunc.Receiver { + name += "*" + } + targets[name] = f.Synopsis + } + } + + keys := make([]string, 0, len(targets)) + for name := range targets { + keys = append(keys, name) + } + sort.Strings(keys) + + _, _ = fmt.Fprintln(&list, "Targets:") + w := tabwriter.NewWriter(&list, 0, 4, 4, ' ', 0) + for _, name := range keys { + _, _ = fmt.Fprintf(w, " %v\t%v\n", printName(name), targets[name]) + } + _ = w.Flush() + if defaultFunc.Name != "" { + _, _ = fmt.Fprintln(&list, "\n* default target") + } + return list.String() +} + +// printAutocompleteTargets outputs target names one per line for shell completion. +func printAutocompleteTargets(stdout io.Writer, info *parse.PkgInfo) int { + names := map[string]struct{}{} + + for _, f := range info.Funcs { + names[strings.ToLower(f.TargetName())] = struct{}{} + } + for _, imp := range info.Imports { + for _, f := range imp.Info.Funcs { + names[strings.ToLower(f.TargetName())] = struct{}{} + } + } + for alias := range info.Aliases { + names[strings.ToLower(alias)] = struct{}{} + } + + sorted := make([]string, 0, len(names)) + for name := range names { + sorted = append(sorted, name) + } + sort.Strings(sorted) + + for _, name := range sorted { + _, _ = fmt.Fprintln(stdout, name) + } + return 0 +} + type mainfileTemplateData struct { - Description string - Funcs []*parse.Function - DefaultFunc parse.Function - Aliases map[string]*parse.Function - Imports []*parse.Import - BinaryName string + Description string + Funcs []*parse.Function + DefaultFunc parse.Function + Aliases map[string]*parse.Function + Imports []*parse.Import + BinaryName string + PrintNameFunc string } // listGoFiles returns a list of all .go files in a given directory, @@ -632,7 +756,7 @@ func Compile(goos, goarch, ldflags, magePath, goCmd, compileTo string, gofiles [ } // GenerateMainfile generates the mage mainfile at path. -func GenerateMainfile(binaryName, path string, info *parse.PkgInfo) error { +func GenerateMainfile(data mainfileTemplateData, path string) error { debug.Println("Creating mainfile at", path) f, err := os.Create(path) @@ -641,18 +765,6 @@ func GenerateMainfile(binaryName, path string, info *parse.PkgInfo) error { } defer f.Close() - data := mainfileTemplateData{ - Description: info.Description, - Funcs: info.Funcs, - Aliases: info.Aliases, - Imports: info.Imports, - BinaryName: binaryName, - } - - if info.DefaultFunc != nil { - data.DefaultFunc = *info.DefaultFunc - } - debug.Println("writing new file at", path) if err := mainfileTemplate.Execute(f, data); err != nil { return fmt.Errorf("can't execute mainfile template: %w", err) diff --git a/mage/main_test.go b/mage/main_test.go index 6830cc22..a6ba28d4 100644 --- a/mage/main_test.go +++ b/mage/main_test.go @@ -722,6 +722,90 @@ Targets: } } +func TestAutocomplete(t *testing.T) { + stdout := &bytes.Buffer{} + inv := Invocation{ + Dir: "./testdata/list", + Stdout: stdout, + Stderr: io.Discard, + Autocomplete: true, + } + + code := Invoke(inv) + if code != 0 { + t.Errorf("expected to exit with code 0, but got %v", code) + } + actual := stdout.String() + expected := "somepig\ntestverbose\n" + if actual != expected { + t.Logf("expected: %q", expected) + t.Logf(" actual: %q", actual) + t.Fatalf("expected:\n%v\n\ngot:\n%v", expected, actual) + } +} + +func TestAutocompleteNamespaces(t *testing.T) { + stdout := &bytes.Buffer{} + inv := Invocation{ + Dir: "./testdata/namespaces", + Stdout: stdout, + Stderr: io.Discard, + Autocomplete: true, + } + + code := Invoke(inv) + if code != 0 { + t.Errorf("expected to exit with code 0, but got %v", code) + } + actual := stdout.String() + expected := "ns:bare\nns:barectx\nns:ctxerr\nns:error\ntestnamespacedep\n" + if actual != expected { + t.Logf("expected: %q", expected) + t.Logf(" actual: %q", actual) + t.Fatalf("expected:\n%v\n\ngot:\n%v", expected, actual) + } +} + +func TestAutocompleteAliases(t *testing.T) { + stdout := &bytes.Buffer{} + inv := Invocation{ + Dir: "./testdata/alias", + Stdout: stdout, + Stderr: io.Discard, + Autocomplete: true, + } + + code := Invoke(inv) + if code != 0 { + t.Errorf("expected to exit with code 0, but got %v", code) + } + actual := stdout.String() + // aliases (co, st, stat) plus the actual targets (checkout, status) + expected := "checkout\nco\nst\nstat\nstatus\n" + if actual != expected { + t.Logf("expected: %q", expected) + t.Logf(" actual: %q", actual) + t.Fatalf("expected:\n%v\n\ngot:\n%v", expected, actual) + } +} + +func TestParseAutocomplete(t *testing.T) { + inv, _, err := Parse(io.Discard, io.Discard, []string{"-autocomplete"}) + if err != nil { + t.Fatal("unexpected error", err) + } + if !inv.Autocomplete { + t.Error("autocomplete should be true but was false") + } +} + +func TestParseAutocompleteConflictsWithOtherCommands(t *testing.T) { + _, _, err := Parse(io.Discard, io.Discard, []string{"-autocomplete", "-version"}) + if err == nil { + t.Fatal("expected error when using -autocomplete with -version") + } +} + var terminals = []struct { code string supportsColor bool @@ -958,14 +1042,14 @@ func TestHashTemplate(t *testing.T) { func TestKeepFlag(t *testing.T) { buildFile := fmt.Sprintf("./testdata/keep_flag/%s", mainfile) _ = os.Remove(buildFile) - defer func() { _ = os.Remove(buildFile) }() + t.Cleanup(func() { _ = os.Remove(buildFile) }) w := tLogWriter{t} inv := Invocation{ Dir: "./testdata/keep_flag", Stdout: w, Stderr: w, - List: true, + Args: []string{"noop"}, Keep: true, Force: true, // need force so we always regenerate } @@ -992,7 +1076,7 @@ func (t tLogWriter) Write(b []byte) (n int, err error) { func TestOnlyStdLib(t *testing.T) { buildFile := fmt.Sprintf("./testdata/onlyStdLib/%s", mainfile) _ = os.Remove(buildFile) - defer func() { _ = os.Remove(buildFile) }() + t.Cleanup(func() { _ = os.Remove(buildFile) }) w := tLogWriter{t} @@ -1000,7 +1084,7 @@ func TestOnlyStdLib(t *testing.T) { Dir: "./testdata/onlyStdLib", Stdout: w, Stderr: w, - List: true, + Args: []string{"noop"}, Keep: true, Force: true, // need force so we always regenerate Verbose: true, diff --git a/mage/template.go b/mage/template.go index 807c2d17..90fb49d7 100644 --- a/mage/template.go +++ b/mage/template.go @@ -9,20 +9,20 @@ var mageMainfileTplString = `//go:build ignore package main import ( - "context" + _context "context" _flag "flag" _fmt "fmt" _io "io" _log "log" - "os" - "os/signal" + _os "os" + _signal "os/signal" _filepath "path/filepath" _sort "sort" - "strconv" + _strconv "strconv" _strings "strings" - "syscall" + _syscall "syscall" _tabwriter "text/tabwriter" - "time" + _time "time" {{range .Imports}}{{.UniqueName}} "{{.Path}}" {{end}} ) @@ -33,16 +33,16 @@ func main() { Verbose bool // print out log statements List bool // print out a list of targets Help bool // print out help for a specific target - Timeout time.Duration // set a timeout to running the targets + Timeout _time.Duration // set a timeout to running the targets Args []string // args contain the non-flag command-line arguments } parseBool := func(env string) bool { - val := os.Getenv(env) + val := _os.Getenv(env) if val == "" { return false } - b, err := strconv.ParseBool(val) + b, err := _strconv.ParseBool(val) if err != nil { _log.Printf("warning: environment variable %s is not a valid bool value: %v", env, val) return false @@ -50,12 +50,12 @@ func main() { return b } - parseDuration := func(env string) time.Duration { - val := os.Getenv(env) + parseDuration := func(env string) _time.Duration { + val := _os.Getenv(env) if val == "" { return 0 } - d, err := time.ParseDuration(val) + d, err := _time.ParseDuration(val) if err != nil { _log.Printf("warning: environment variable %s is not a valid duration value: %v", env, val) return 0 @@ -64,7 +64,7 @@ func main() { } args := arguments{} fs := _flag.FlagSet{} - fs.SetOutput(os.Stdout) + fs.SetOutput(_os.Stdout) // default flag set with ExitOnError and auto generated PrintDefaults should be sufficient fs.BoolVar(&args.Verbose, "v", parseBool("MAGEFILE_VERBOSE"), "show verbose output when running targets") @@ -72,7 +72,7 @@ func main() { fs.BoolVar(&args.Help, "h", parseBool("MAGEFILE_HELP"), "print out help for a specific target") fs.DurationVar(&args.Timeout, "t", parseDuration("MAGEFILE_TIMEOUT"), "timeout in duration parsable format (e.g. 5m30s)") fs.Usage = func() { - _fmt.Fprintf(os.Stdout, ` + "`" + ` + _fmt.Fprintf(_os.Stdout, ` + "`" + ` %s [options] [target] Commands: @@ -84,9 +84,9 @@ Options: -t timeout in duration parsable format (e.g. 5m30s) -v show verbose output when running targets - ` + "`" + `[1:], _filepath.Base(os.Args[0])) + ` + "`" + `[1:], _filepath.Base(_os.Args[0])) } - if err := fs.Parse(os.Args[1:]); err != nil { + if err := fs.Parse(_os.Args[1:]); err != nil { // flag will have printed out an error already. return } @@ -96,132 +96,7 @@ Options: return } - // color is ANSI color type - type color int - - // If you add/change/remove any items in this constant, - // you will need to run "stringer -type=color" in this directory again. - // NOTE: Please keep the list in an alphabetical order. - const ( - black color = iota - red - green - yellow - blue - magenta - cyan - white - brightblack - brightred - brightgreen - brightyellow - brightblue - brightmagenta - brightcyan - brightwhite - ) - - // AnsiColor are ANSI color codes for supported terminal colors. - var ansiColor = map[color]string{ - black: "\u001b[30m", - red: "\u001b[31m", - green: "\u001b[32m", - yellow: "\u001b[33m", - blue: "\u001b[34m", - magenta: "\u001b[35m", - cyan: "\u001b[36m", - white: "\u001b[37m", - brightblack: "\u001b[30;1m", - brightred: "\u001b[31;1m", - brightgreen: "\u001b[32;1m", - brightyellow: "\u001b[33;1m", - brightblue: "\u001b[34;1m", - brightmagenta: "\u001b[35;1m", - brightcyan: "\u001b[36;1m", - brightwhite: "\u001b[37;1m", - } - - const _color_name = "blackredgreenyellowbluemagentacyanwhitebrightblackbrightredbrightgreenbrightyellowbrightbluebrightmagentabrightcyanbrightwhite" - - var _color_index = [...]uint8{0, 5, 8, 13, 19, 23, 30, 34, 39, 50, 59, 70, 82, 92, 105, 115, 126} - - colorToLowerString := func (i color) string { - if i < 0 || i >= color(len(_color_index)-1) { - return "color(" + strconv.FormatInt(int64(i), 10) + ")" - } - return _color_name[_color_index[i]:_color_index[i+1]] - } - - // ansiColorReset is an ANSI color code to reset the terminal color. - const ansiColorReset = "\033[0m" - - // defaultTargetAnsiColor is a default ANSI color for colorizing targets. - // It is set to Cyan as an arbitrary color, because it has a neutral meaning - var defaultTargetAnsiColor = ansiColor[cyan] - - getAnsiColor := func(color string) (string, bool) { - colorLower := _strings.ToLower(color) - for k, v := range ansiColor { - colorConstLower := colorToLowerString(k) - if colorConstLower == colorLower { - return v, true - } - } - return "", false - } - - // Terminals which don't support color: - // TERM=vt100 - // TERM=cygwin - // TERM=xterm-mono - var noColorTerms = map[string]bool{ - "vt100": false, - "cygwin": false, - "xterm-mono": false, - } - - // terminalSupportsColor checks if the current console supports color output - // - // Supported: - // linux, mac, or windows's ConEmu, Cmder, putty, git-bash.exe, pwsh.exe - // Not supported: - // windows cmd.exe, powerShell.exe - terminalSupportsColor := func() bool { - envTerm := os.Getenv("TERM") - if _, ok := noColorTerms[envTerm]; ok { - return false - } - return true - } - - // enableColor reports whether the user has requested to enable a color output. - enableColor := func() bool { - b, _ := strconv.ParseBool(os.Getenv("MAGEFILE_ENABLE_COLOR")) - return b - } - - // targetColor returns the ANSI color which should be used to colorize targets. - targetColor := func() string { - s, exists := os.LookupEnv("MAGEFILE_TARGET_COLOR") - if exists { - if c, ok := getAnsiColor(s); ok { - return c - } - } - return defaultTargetAnsiColor - } - - // store the color terminal variables, so that the detection isn't repeated for each target - var enableColorValue = enableColor() && terminalSupportsColor() - var targetColorValue = targetColor() - - printName := func(str string) string { - if enableColorValue { - return _fmt.Sprintf("%s%s%s", targetColorValue, str, ansiColorReset) - } else { - return str - } - } + {{.PrintNameFunc}} list := func() error { {{with .Description}}_fmt.Println(` + "`{{.}}\n`" + `) @@ -245,7 +120,7 @@ Options: _sort.Strings(keys) _fmt.Println("Targets:") - w := _tabwriter.NewWriter(os.Stdout, 0, 4, 4, ' ', 0) + w := _tabwriter.NewWriter(_os.Stdout, 0, 4, 4, ' ', 0) for _, name := range keys { _fmt.Fprintf(w, " %v\t%v\n", printName(name), targets[name]) } @@ -258,7 +133,7 @@ Options: return err } - var ctx context.Context + var ctx _context.Context ctxCancel := func(){} // by deferring in a closure, we let the cancel function get replaced @@ -267,19 +142,19 @@ Options: ctxCancel() }() - getContext := func() (context.Context, func()) { + getContext := func() (_context.Context, func()) { if ctx == nil { if args.Timeout != 0 { - ctx, ctxCancel = context.WithTimeout(context.Background(), args.Timeout) + ctx, ctxCancel = _context.WithTimeout(_context.Background(), args.Timeout) } else { - ctx, ctxCancel = context.WithCancel(context.Background()) + ctx, ctxCancel = _context.WithCancel(_context.Background()) } } return ctx, ctxCancel } - runTarget := func(logger *_log.Logger, fn func(context.Context) error) interface{} { + runTarget := func(logger *_log.Logger, fn func(_context.Context) error) interface{} { var err interface{} ctx, cancel := getContext() d := make(chan interface{}) @@ -291,13 +166,13 @@ Options: err := fn(ctx) d <- err }() - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT) + sigCh := make(chan _os.Signal, 1) + _signal.Notify(sigCh, _syscall.SIGINT) select { case <-sigCh: logger.Println("cancelling mage targets, waiting up to 5 seconds for cleanup...") cancel() - cleanupCh := time.After(5 * time.Second) + cleanupCh := _time.After(5 * _time.Second) select { // target exited by itself @@ -318,7 +193,7 @@ Options: return e case err = <-d: // we intentionally don't cancel the context here, because - // the next target will need to run with the same context. + // the next target will need to run with the same _context. return err } } @@ -333,29 +208,29 @@ Options: ExitStatus() int } if c, ok := err.(code); ok { - os.Exit(c.ExitStatus()) + _os.Exit(c.ExitStatus()) } - os.Exit(1) + _os.Exit(1) } } _ = handleError // Set MAGEFILE_VERBOSE so mg.Verbose() reflects the flag value. if args.Verbose { - os.Setenv("MAGEFILE_VERBOSE", "1") + _os.Setenv("MAGEFILE_VERBOSE", "1") } else { - os.Setenv("MAGEFILE_VERBOSE", "0") + _os.Setenv("MAGEFILE_VERBOSE", "0") } _log.SetFlags(0) if !args.Verbose { _log.SetOutput(_io.Discard) } - logger := _log.New(os.Stderr, "", 0) + logger := _log.New(_os.Stderr, "", 0) if args.List { if err := list(); err != nil { _log.Println(err) - os.Exit(1) + _os.Exit(1) } return } @@ -363,7 +238,7 @@ Options: if args.Help { if len(args.Args) < 1 { logger.Println("no target specified") - os.Exit(2) + _os.Exit(2) } switch _strings.ToLower(args.Args[0]) { {{range .Funcs -}} @@ -406,16 +281,16 @@ Options: {{end -}} default: logger.Printf("Unknown target: %q\n", args.Args[0]) - os.Exit(2) + _os.Exit(2) } } if len(args.Args) < 1 { {{- if .DefaultFunc.Name}} - ignoreDefault, _ := strconv.ParseBool(os.Getenv("MAGEFILE_IGNOREDEFAULT")) + ignoreDefault, _ := _strconv.ParseBool(_os.Getenv("MAGEFILE_IGNOREDEFAULT")) if ignoreDefault { if err := list(); err != nil { logger.Println("Error:", err) - os.Exit(1) + _os.Exit(1) } return } @@ -425,7 +300,7 @@ Options: {{- else}} if err := list(); err != nil { logger.Println("Error:", err) - os.Exit(1) + _os.Exit(1) } return {{- end}} @@ -450,7 +325,7 @@ Options: // note that expected and args at this point include the arg for the target itself // so we subtract 1 here to show the number of args without the target. logger.Printf("not enough arguments for target \"{{.TargetName}}\", expected %v, got %v\n", expected-1, len(args.Args)-1) - os.Exit(2) + _os.Exit(2) } if args.Verbose { logger.Println("Running target:", "{{.TargetName}}") @@ -467,7 +342,7 @@ Options: // note that expected and args at this point include the arg for the target itself // so we subtract 1 here to show the number of args without the target. logger.Printf("not enough arguments for target \"{{.TargetName}}\", expected %v, got %v\n", expected-1, len(args.Args)-1) - os.Exit(2) + _os.Exit(2) } if args.Verbose { logger.Println("Running target:", "{{.TargetName}}") @@ -478,7 +353,7 @@ Options: {{- end}} default: logger.Printf("Unknown target specified: %q\n", target) - os.Exit(2) + _os.Exit(2) } } } diff --git a/mage/testdata/onlyStdLib/command.go b/mage/testdata/onlyStdLib/command.go index e1c4b825..a8ba6404 100644 --- a/mage/testdata/onlyStdLib/command.go +++ b/mage/testdata/onlyStdLib/command.go @@ -12,6 +12,8 @@ import ( var Default = SomePig +func NOOP() {} + // this should not be a target because it returns a string func ReturnsString() string { fmt.Println("more stuff") diff --git a/parse/parse.go b/parse/parse.go index 21b331f6..33dc16ce 100644 --- a/parse/parse.go +++ b/parse/parse.go @@ -175,34 +175,34 @@ func (f Function) ExecCode() string { x++`, x) case "int": _, _ = fmt.Fprintf(&parseargs, ` - arg%d, err := strconv.Atoi(args.Args[x]) + arg%d, err := _strconv.Atoi(args.Args[x]) if err != nil { logger.Printf("can't convert argument %%q to int\n", args.Args[x]) - os.Exit(2) + _os.Exit(2) } x++`, x) case "float64": _, _ = fmt.Fprintf(&parseargs, ` - arg%d, err := strconv.ParseFloat(args.Args[x], 64) + arg%d, err := _strconv.ParseFloat(args.Args[x], 64) if err != nil { logger.Printf("can't convert argument %%q to float64\n", args.Args[x]) - os.Exit(2) + _os.Exit(2) } x++`, x) case "bool": _, _ = fmt.Fprintf(&parseargs, ` - arg%d, err := strconv.ParseBool(args.Args[x]) + arg%d, err := _strconv.ParseBool(args.Args[x]) if err != nil { logger.Printf("can't convert argument %%q to bool\n", args.Args[x]) - os.Exit(2) + _os.Exit(2) } x++`, x) - case "time.Duration": + case "_time.Duration": _, _ = fmt.Fprintf(&parseargs, ` - arg%d, err := time.ParseDuration(args.Args[x]) + arg%d, err := _time.ParseDuration(args.Args[x]) if err != nil { logger.Printf("can't convert argument %%q to time.Duration\n", args.Args[x]) - os.Exit(2) + _os.Exit(2) } x++`, x) default: @@ -246,7 +246,7 @@ func (f Function) ExecCode() string { _, _ = fmt.Fprintf(&parseargs, ` default: logger.Printf("invalid option %%q for target \"%s\", expected -name=value format\n", _optArg) - os.Exit(2) + _os.Exit(2) } } else { _optName = _strings.ToLower(_optArg[1:_eqIdx]) @@ -267,37 +267,37 @@ func (f Function) ExecCode() string { case "int": _, _ = fmt.Fprintf(&parseargs, ` case %q: - _tmp%d, err := strconv.Atoi(_optVal) + _tmp%d, err := _strconv.Atoi(_optVal) if err != nil { logger.Printf("can't convert option %%q value %%q to int\n", _optName, _optVal) - os.Exit(2) + _os.Exit(2) } arg%d = &_tmp%d`, lowerName, x, x, x) case "float64": _, _ = fmt.Fprintf(&parseargs, ` case %q: - _tmp%d, err := strconv.ParseFloat(_optVal, 64) + _tmp%d, err := _strconv.ParseFloat(_optVal, 64) if err != nil { logger.Printf("can't convert option %%q value %%q to float64\n", _optName, _optVal) - os.Exit(2) + _os.Exit(2) } arg%d = &_tmp%d`, lowerName, x, x, x) case "bool": _, _ = fmt.Fprintf(&parseargs, ` case %q: - _tmp%d, err := strconv.ParseBool(_optVal) + _tmp%d, err := _strconv.ParseBool(_optVal) if err != nil { logger.Printf("can't convert option %%q value %%q to bool\n", _optName, _optVal) - os.Exit(2) + _os.Exit(2) } arg%d = &_tmp%d`, lowerName, x, x, x) case "time.Duration": _, _ = fmt.Fprintf(&parseargs, ` case %q: - _tmp%d, err := time.ParseDuration(_optVal) + _tmp%d, err := _time.ParseDuration(_optVal) if err != nil { logger.Printf("can't convert option %%q value %%q to time.Duration\n", _optName, _optVal) - os.Exit(2) + _os.Exit(2) } arg%d = &_tmp%d`, lowerName, x, x, x) default: @@ -307,14 +307,14 @@ func (f Function) ExecCode() string { _, _ = fmt.Fprintf(&parseargs, ` default: logger.Printf("unknown option %%q for target \"%s\"\n", _optName) - os.Exit(2) + _os.Exit(2) } x++ }`, f.TargetName()) } out := parseargs.String() + ` - wrapFn := func(ctx context.Context) error { + wrapFn := func(ctx _context.Context) error { ` if f.IsError { out += "return " @@ -1051,6 +1051,6 @@ var argTypes = map[string]string{ "string": "string", "int": "int", "float64": "float64", - "&{time Duration}": "time.Duration", + "&{time Duration}": "_time.Duration", "bool": "bool", } From 94488507493613effd77c7f4b37543192b191323 Mon Sep 17 00:00:00 2001 From: Nate Finch Date: Wed, 15 Apr 2026 22:19:38 -0400 Subject: [PATCH 02/15] tab completion --- .github/copilot-instructions.md | 15 ++ .gitignore | 3 + mage/args_test.go | 1 - mage/command_string.go | 4 +- mage/completion.go | 348 ++++++++++++++++++++++++++++++++ mage/completion_test.go | 309 ++++++++++++++++++++++++++++ mage/main.go | 20 +- parse/parse.go | 14 +- 8 files changed, 706 insertions(+), 8 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 mage/completion.go create mode 100644 mage/completion_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..c131129e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,15 @@ +# Agent Instructions + +## Go Compatibility + +This project targets Go 1.18. Do not use language features or standard library +functions/types introduced after Go 1.18. + +## Go Dependencies + +Do not add any external module dependencies. Only import packages from the Go +standard library or from within this module. + +## Go Formatting + +After modifying any Go files, run `goimports -w` on the changed files. diff --git a/.gitignore b/.gitignore index 8d101a18..07c616c1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ Session.vim # Hugo build lock .hugo_build.lock + +# Release output +/dist \ No newline at end of file diff --git a/mage/args_test.go b/mage/args_test.go index 1de68e20..f96d79f5 100644 --- a/mage/args_test.go +++ b/mage/args_test.go @@ -186,7 +186,6 @@ func TestOptionalArgs(t *testing.T) { Dir: "./testdata/optargs", Stderr: stderr, Stdout: stdout, - Keep: true, Args: []string{"greet", "World", "-greeting=Hi"}, } code := Invoke(inv) diff --git a/mage/command_string.go b/mage/command_string.go index fcdaf9f9..c5c12efd 100644 --- a/mage/command_string.go +++ b/mage/command_string.go @@ -4,9 +4,9 @@ package mage import "strconv" -const _Command_name = "NoneVersionInitCleanCompileStatic" +const _Command_name = "NoneVersionInitCleanCompileStaticInstall" -var _Command_index = [...]uint8{0, 4, 11, 15, 20, 33} +var _Command_index = [...]uint8{0, 4, 11, 15, 20, 33, 40} func (i Command) String() string { if i < 0 || i >= Command(len(_Command_index)-1) { diff --git a/mage/completion.go b/mage/completion.go new file mode 100644 index 00000000..9dc3a8c6 --- /dev/null +++ b/mage/completion.go @@ -0,0 +1,348 @@ +package mage + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const ( + mageCompletionMarker = "# begin mage tab completion" + mageCompletionMarkerEnd = "# end mage tab completion" +) + +// installCompletion installs shell tab completion for the given shell. +func installCompletion(stdout io.Writer, shell string) error { + shell = strings.ToLower(strings.TrimSpace(shell)) + switch shell { + case "bash": + return installBashCompletion(stdout) + case "zsh": + return installZshCompletion(stdout) + case "fish": + return installFishCompletion(stdout) + case "powershell": + return installPowerShellCompletion(stdout) + default: + return fmt.Errorf("unsupported shell %q; supported shells: bash, zsh, fish, powershell", shell) + } +} + +// mageExePath returns the resolved path of the running mage executable. +func mageExePath() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + return "", err + } + return resolved, nil +} + +// completionConfigDir returns the directory for mage completion config files. +func completionConfigDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + return filepath.Join(home, ".config", "mage"), nil +} + +// writeCompletionFile writes the completion script to the given path, +// creating parent directories as needed. +func writeCompletionFile(path, content string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("could not create directory %s: %w", dir, err) + } + return os.WriteFile(path, []byte(content), 0644) +} + +// addGuardedBlock adds a guarded block of content to the given file. +// If a guarded block already exists, it is replaced. Otherwise the block +// is appended. The file is created if it doesn't exist. +func addGuardedBlock(path, content string) error { + existing, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + + block := mageCompletionMarker + "\n" + content + "\n" + mageCompletionMarkerEnd + + existingStr := string(existing) + if start := strings.Index(existingStr, mageCompletionMarker); start != -1 { + end := strings.Index(existingStr, mageCompletionMarkerEnd) + if end != -1 { + end += len(mageCompletionMarkerEnd) + newContent := existingStr[:start] + block + existingStr[end:] + return os.WriteFile(path, []byte(newContent), 0644) + } + } + + // Append to file + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) + if err != nil { + return err + } + defer f.Close() + + // Add a newline before the block if the file is non-empty and doesn't end with one + if len(existing) > 0 && existing[len(existing)-1] != '\n' { + if _, err := f.WriteString("\n"); err != nil { + return err + } + } + _, err = f.WriteString(block + "\n") + return err +} + +func installBashCompletion(stdout io.Writer) error { + bin, err := mageExePath() + if err != nil { + return err + } + script := bashCompletionScript(bin) + + dir, err := completionConfigDir() + if err != nil { + return err + } + + scriptPath := filepath.Join(dir, "completion.bash") + if err := writeCompletionFile(scriptPath, script); err != nil { + return fmt.Errorf("could not write completion script: %w", err) + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + rcFile := filepath.Join(home, ".bashrc") + sourceLine := fmt.Sprintf(`[ -f %q ] && source %q`, scriptPath, scriptPath) + if err := addGuardedBlock(rcFile, sourceLine); err != nil { + return fmt.Errorf("could not update %s: %w", rcFile, err) + } + + fmt.Fprintf(stdout, "Installed bash completion to %s\n", scriptPath) + fmt.Fprintf(stdout, "Updated %s\n", rcFile) + fmt.Fprintf(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) + return nil +} + +func installZshCompletion(stdout io.Writer) error { + bin, err := mageExePath() + if err != nil { + return err + } + script := zshCompletionScript(bin) + + dir, err := completionConfigDir() + if err != nil { + return err + } + + scriptPath := filepath.Join(dir, "completion.zsh") + if err := writeCompletionFile(scriptPath, script); err != nil { + return fmt.Errorf("could not write completion script: %w", err) + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + rcFile := filepath.Join(home, ".zshrc") + sourceLine := fmt.Sprintf(`[ -f %q ] && source %q`, scriptPath, scriptPath) + if err := addGuardedBlock(rcFile, sourceLine); err != nil { + return fmt.Errorf("could not update %s: %w", rcFile, err) + } + + fmt.Fprintf(stdout, "Installed zsh completion to %s\n", scriptPath) + fmt.Fprintf(stdout, "Updated %s\n", rcFile) + fmt.Fprintf(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) + return nil +} + +func installFishCompletion(stdout io.Writer) error { + bin, err := mageExePath() + if err != nil { + return err + } + script := fishCompletionScript(bin) + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + // Honor XDG_CONFIG_HOME if set + configDir := os.Getenv("XDG_CONFIG_HOME") + if configDir == "" { + configDir = filepath.Join(home, ".config") + } + + scriptPath := filepath.Join(configDir, "fish", "completions", "mage.fish") + if err := writeCompletionFile(scriptPath, script); err != nil { + return fmt.Errorf("could not write completion script: %w", err) + } + + fmt.Fprintf(stdout, "Installed fish completion to %s\n", scriptPath) + fmt.Fprintln(stdout, "Fish loads completions automatically. Restart your shell or run 'source "+scriptPath+"' to enable.") + return nil +} + +func installPowerShellCompletion(stdout io.Writer) error { + bin, err := mageExePath() + if err != nil { + return err + } + script := powerShellCompletionScript(bin) + + dir, err := completionConfigDir() + if err != nil { + return err + } + + scriptPath := filepath.Join(dir, "completion.ps1") + if err := writeCompletionFile(scriptPath, script); err != nil { + return fmt.Errorf("could not write completion script: %w", err) + } + + fmt.Fprintf(stdout, "Installed PowerShell completion to %s\n", scriptPath) + fmt.Fprintln(stdout, "") + fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile") + fmt.Fprintln(stdout, "(run '$PROFILE' in PowerShell to see the profile path):") + fmt.Fprintln(stdout, "") + fmt.Fprintf(stdout, " . %q\n", scriptPath) + return nil +} + +// bashCompletionScript returns a bash completion script that uses mage -autocomplete. +func bashCompletionScript(mageBin string) string { + return `_mage_completions() { + local cur="${COMP_WORDS[COMP_CWORD]}" + if [[ "$cur" == -* ]]; then + local flags="-l -h -v -f -debug -t -d -w -keep -compile -clean -init -version -gocmd -goos -goarch -ldflags -autocomplete -install -multiline" + COMPREPLY=($(compgen -W "$flags" -- "$cur")) + return + fi + local IFS=$'\n' + COMPREPLY=($(compgen -W "$(` + mageBin + ` -autocomplete 2>/dev/null)" -- "$cur")) +} +complete -F _mage_completions mage +` +} + +// zshCompletionScript returns a zsh completion script that uses mage -autocomplete. +func zshCompletionScript(mageBin string) string { + return `#compdef mage +_mage() { + local -a targets + if [[ "$PREFIX" == -* ]]; then + local -a flags + flags=( + '-l:list mage targets in this directory' + '-h:show this help' + '-v:show verbose output when running mage targets' + '-f:force recreation of compiled magefile' + '-debug:turn on debug messages' + '-t:timeout in duration parsable format' + '-d:directory to read magefiles from' + '-w:working directory where magefiles will run' + '-keep:keep intermediate mage files around after running' + '-compile:output a static binary to the given path' + '-clean:clean out old generated binaries from CACHE_DIR' + '-init:create a starting template if no mage files exist' + '-version:show version info for the mage binary' + '-gocmd:use the given go binary to compile the output' + '-goos:set GOOS for binary produced with -compile' + '-goarch:set GOARCH for binary produced with -compile' + '-ldflags:set ldflags for binary produced with -compile' + '-autocomplete:print target names for shell completion' + '-install:install shell completion for the given shell' + '-multiline:retain line returns in help text' + ) + _describe 'flag' flags + return + fi + targets=(${(f)"$(` + mageBin + ` -autocomplete 2>/dev/null)"}) + _describe 'target' targets +} +compdef _mage mage +` +} + +// fishCompletionScript returns a fish completion script that uses mage -autocomplete. +func fishCompletionScript(mageBin string) string { + return `# mage tab completion for fish +complete -c mage -f +complete -c mage -a '(` + mageBin + ` -autocomplete 2>/dev/null)' -d 'mage target' +complete -c mage -s l -d 'list mage targets in this directory' +complete -c mage -s h -d 'show this help' +complete -c mage -s v -d 'show verbose output when running mage targets' +complete -c mage -s f -d 'force recreation of compiled magefile' +complete -c mage -l debug -d 'turn on debug messages' +complete -c mage -s t -r -d 'timeout in duration parsable format' +complete -c mage -s d -r -F -d 'directory to read magefiles from' +complete -c mage -s w -r -F -d 'working directory where magefiles will run' +complete -c mage -l keep -d 'keep intermediate mage files around after running' +complete -c mage -l compile -r -F -d 'output a static binary to the given path' +complete -c mage -l clean -d 'clean out old generated binaries from CACHE_DIR' +complete -c mage -l init -d 'create a starting template if no mage files exist' +complete -c mage -l version -d 'show version info for the mage binary' +complete -c mage -l gocmd -r -d 'use the given go binary to compile the output' +complete -c mage -l goos -r -d 'set GOOS for binary produced with -compile' +complete -c mage -l goarch -r -d 'set GOARCH for binary produced with -compile' +complete -c mage -l ldflags -r -d 'set ldflags for binary produced with -compile' +complete -c mage -l autocomplete -d 'print target names for shell completion' +complete -c mage -l install -r -a 'bash zsh fish powershell' -d 'install shell completion' +complete -c mage -l multiline -d 'retain line returns in help text' +` +} + +// powerShellCompletionScript returns a PowerShell completion script that uses mage -autocomplete. +func powerShellCompletionScript(mageBin string) string { + return `# mage tab completion for PowerShell +Register-ArgumentCompleter -CommandName mage -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + if ($wordToComplete.StartsWith('-')) { + $flags = @( + @{N='-l'; D='list mage targets in this directory'}, + @{N='-h'; D='show this help'}, + @{N='-v'; D='show verbose output'}, + @{N='-f'; D='force recreation of compiled magefile'}, + @{N='-debug'; D='turn on debug messages'}, + @{N='-t'; D='timeout in duration parsable format'}, + @{N='-d'; D='directory to read magefiles from'}, + @{N='-w'; D='working directory where magefiles will run'}, + @{N='-keep'; D='keep intermediate mage files around'}, + @{N='-compile'; D='output a static binary to the given path'}, + @{N='-clean'; D='clean out old generated binaries'}, + @{N='-init'; D='create a starting template'}, + @{N='-version'; D='show version info'}, + @{N='-gocmd'; D='use the given go binary'}, + @{N='-goos'; D='set GOOS for -compile'}, + @{N='-goarch'; D='set GOARCH for -compile'}, + @{N='-ldflags'; D='set ldflags for -compile'}, + @{N='-autocomplete'; D='print target names for shell completion'}, + @{N='-install'; D='install shell completion'}, + @{N='-multiline'; D='retain line returns in help text'} + ) + $flags | Where-Object { $_.N -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_.N, $_.N, 'ParameterValue', $_.D) + } + } else { + (& '` + mageBin + `' -autocomplete 2>$null) -split "` + "`n" + `" | + Where-Object { $_ -ne '' -and $_ -like "$wordToComplete*" } | + ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } +} +` +} diff --git a/mage/completion_test.go b/mage/completion_test.go new file mode 100644 index 00000000..5593a503 --- /dev/null +++ b/mage/completion_test.go @@ -0,0 +1,309 @@ +package mage + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInstallCompletionBash(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + stdout := &bytes.Buffer{} + err := installCompletion(stdout, "bash") + if err != nil { + t.Fatal("unexpected error:", err) + } + + // Verify the completion script was written + scriptPath := filepath.Join(home, ".config", "mage", "completion.bash") + content, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal("completion script not found:", err) + } + if !strings.Contains(string(content), "_mage_completions") { + t.Error("completion script missing _mage_completions function") + } + if !strings.Contains(string(content), "complete -F _mage_completions mage") { + t.Error("completion script missing complete command") + } + + // Verify .bashrc was updated + rcPath := filepath.Join(home, ".bashrc") + rc, err := os.ReadFile(rcPath) + if err != nil { + t.Fatal(".bashrc not found:", err) + } + rcStr := string(rc) + if !strings.Contains(rcStr, mageCompletionMarker) { + t.Error(".bashrc missing completion marker") + } + if !strings.Contains(rcStr, "source") { + t.Error(".bashrc missing source line") + } +} + +func TestInstallCompletionZsh(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + stdout := &bytes.Buffer{} + err := installCompletion(stdout, "zsh") + if err != nil { + t.Fatal("unexpected error:", err) + } + + scriptPath := filepath.Join(home, ".config", "mage", "completion.zsh") + content, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal("completion script not found:", err) + } + if !strings.Contains(string(content), "#compdef mage") { + t.Error("completion script missing #compdef header") + } + if !strings.Contains(string(content), "_mage") { + t.Error("completion script missing _mage function") + } + + rcPath := filepath.Join(home, ".zshrc") + rc, err := os.ReadFile(rcPath) + if err != nil { + t.Fatal(".zshrc not found:", err) + } + if !strings.Contains(string(rc), mageCompletionMarker) { + t.Error(".zshrc missing completion marker") + } +} + +func TestInstallCompletionFish(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + + stdout := &bytes.Buffer{} + err := installCompletion(stdout, "fish") + if err != nil { + t.Fatal("unexpected error:", err) + } + + scriptPath := filepath.Join(home, ".config", "fish", "completions", "mage.fish") + content, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal("completion script not found:", err) + } + if !strings.Contains(string(content), "complete -c mage") { + t.Error("completion script missing complete command") + } +} + +func TestInstallCompletionFishXDG(t *testing.T) { + home := t.TempDir() + xdg := filepath.Join(home, "custom-config") + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", xdg) + + stdout := &bytes.Buffer{} + err := installCompletion(stdout, "fish") + if err != nil { + t.Fatal("unexpected error:", err) + } + + scriptPath := filepath.Join(xdg, "fish", "completions", "mage.fish") + if _, err := os.Stat(scriptPath); err != nil { + t.Fatal("completion script not found at XDG location:", err) + } +} + +func TestInstallCompletionPowerShell(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + stdout := &bytes.Buffer{} + err := installCompletion(stdout, "powershell") + if err != nil { + t.Fatal("unexpected error:", err) + } + + scriptPath := filepath.Join(home, ".config", "mage", "completion.ps1") + content, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal("completion script not found:", err) + } + if !strings.Contains(string(content), "Register-ArgumentCompleter") { + t.Error("completion script missing Register-ArgumentCompleter") + } + + // PowerShell should print instructions, not modify files + output := stdout.String() + if !strings.Contains(output, "$PROFILE") { + t.Error("output should contain instructions mentioning $PROFILE") + } +} + +func TestInstallCompletionUnsupportedShell(t *testing.T) { + err := installCompletion(io.Discard, "tcsh") + if err == nil { + t.Fatal("expected error for unsupported shell") + } + if !strings.Contains(err.Error(), "unsupported shell") { + t.Errorf("expected 'unsupported shell' error, got: %v", err) + } +} + +func TestInstallCompletionCaseInsensitive(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + stdout := &bytes.Buffer{} + err := installCompletion(stdout, "BASH") + if err != nil { + t.Fatal("unexpected error:", err) + } + + scriptPath := filepath.Join(home, ".config", "mage", "completion.bash") + if _, err := os.Stat(scriptPath); err != nil { + t.Fatal("completion script not found:", err) + } +} + +func TestAddGuardedBlockNew(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "testrc") + + err := addGuardedBlock(path, "test content") + if err != nil { + t.Fatal("unexpected error:", err) + } + + content, _ := os.ReadFile(path) + s := string(content) + if !strings.Contains(s, mageCompletionMarker) { + t.Error("missing start marker") + } + if !strings.Contains(s, mageCompletionMarkerEnd) { + t.Error("missing end marker") + } + if !strings.Contains(s, "test content") { + t.Error("missing content") + } +} + +func TestAddGuardedBlockExisting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "testrc") + + // Write initial content + os.WriteFile(path, []byte("existing stuff\n"), 0644) + + err := addGuardedBlock(path, "test content") + if err != nil { + t.Fatal("unexpected error:", err) + } + + content, _ := os.ReadFile(path) + s := string(content) + if !strings.Contains(s, "existing stuff") { + t.Error("lost existing content") + } + if !strings.Contains(s, "test content") { + t.Error("missing new content") + } +} + +func TestAddGuardedBlockReplace(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "testrc") + + // First install + err := addGuardedBlock(path, "old content") + if err != nil { + t.Fatal("unexpected error:", err) + } + + // Reinstall should replace + err = addGuardedBlock(path, "new content") + if err != nil { + t.Fatal("unexpected error:", err) + } + + content, _ := os.ReadFile(path) + s := string(content) + if strings.Contains(s, "old content") { + t.Error("old content should have been replaced") + } + if !strings.Contains(s, "new content") { + t.Error("missing new content") + } + // Ensure markers appear exactly once + if strings.Count(s, mageCompletionMarker) != 1 { + t.Error("expected exactly one start marker") + } +} + +func TestParseInstall(t *testing.T) { + inv, cmd, err := Parse(io.Discard, io.Discard, []string{"-install", "bash"}) + if err != nil { + t.Fatal("unexpected error:", err) + } + if cmd != Install { + t.Errorf("expected Install command, got %v", cmd) + } + if inv.InstallShell != "bash" { + t.Errorf("expected InstallShell 'bash', got %q", inv.InstallShell) + } +} + +func TestParseInstallConflictsWithOtherCommands(t *testing.T) { + _, _, err := Parse(io.Discard, io.Discard, []string{"-install", "bash", "-autocomplete"}) + if err == nil { + t.Fatal("expected error when using -install with -autocomplete") + } +} + +func TestCompletionScriptContents(t *testing.T) { + bin := "/usr/local/bin/mage" + + t.Run("bash", func(t *testing.T) { + s := bashCompletionScript(bin) + if !strings.Contains(s, bin) { + t.Error("script should contain the mage binary path") + } + if !strings.Contains(s, "-autocomplete") { + t.Error("script should call -autocomplete") + } + }) + + t.Run("zsh", func(t *testing.T) { + s := zshCompletionScript(bin) + if !strings.Contains(s, bin) { + t.Error("script should contain the mage binary path") + } + if !strings.Contains(s, "#compdef mage") { + t.Error("script should have compdef header") + } + }) + + t.Run("fish", func(t *testing.T) { + s := fishCompletionScript(bin) + if !strings.Contains(s, bin) { + t.Error("script should contain the mage binary path") + } + if !strings.Contains(s, "complete -c mage") { + t.Error("script should have complete commands") + } + }) + + t.Run("powershell", func(t *testing.T) { + s := powerShellCompletionScript(bin) + if !strings.Contains(s, bin) { + t.Error("script should contain the mage binary path") + } + if !strings.Contains(s, "Register-ArgumentCompleter") { + t.Error("script should have Register-ArgumentCompleter") + } + }) +} diff --git a/mage/main.go b/mage/main.go index 0a2e98f0..2fcd6ca3 100644 --- a/mage/main.go +++ b/mage/main.go @@ -84,6 +84,7 @@ const ( Init // create a starting template for mage Clean // clean out old compiled mage binaries from the cache CompileStatic // compile a static binary of the current directory + Install // install shell tab completion ) // Main is the entrypoint for running mage. It exists external to mage's main @@ -117,6 +118,7 @@ type Invocation struct { HashFast bool // don't rely on GOCACHE, just hash the magefiles Multiline bool // whether to retain line returns in help text for the generated main file Autocomplete bool // parse magefiles and print target names for shell completion + InstallShell string // shell to install tab completion for (bash, zsh, fish, powershell) } // MagefilesDirName is the name of the default folder to look for if no directory was specified, @@ -163,6 +165,12 @@ func ParseAndRun(stdout, stderr io.Writer, stdin io.Reader, args []string) int { } out.Println(inv.CacheDir, "cleaned") return 0 + case Install: + if err := installCompletion(stdout, inv.InstallShell); err != nil { + errlog.Println("Error:", err) + return 1 + } + return 0 case CompileStatic, None: return Invoke(inv) default: @@ -235,6 +243,8 @@ func Parse(stderr, stdout io.Writer, args []string) (inv Invocation, cmd Command fs.BoolVar(&clean, "clean", false, "clean out old generated binaries from CACHE_DIR") var compileOutPath string fs.StringVar(&compileOutPath, "compile", "", "output a static binary to the given path") + var installShell string + fs.StringVar(&installShell, "install", "", "install shell tab completion (bash, zsh, fish, powershell)") fs.Usage = func() { _, _ = fmt.Fprint(stdout, ` @@ -248,6 +258,8 @@ Commands: output a static binary to the given path -h show this help -init create a starting template if no mage files exist + -install + install shell tab completion (bash, zsh, fish, powershell) -l list mage targets in this directory -version show version info for the mage binary @@ -294,6 +306,10 @@ Options: cmd = CompileStatic inv.CompileOut = compileOutPath inv.Force = true + case installShell != "": + numCommands++ + cmd = Install + inv.InstallShell = installShell case showVersion: numCommands++ cmd = Version @@ -301,7 +317,7 @@ Options: numCommands++ cmd = Clean if fs.NArg() > 0 { - return inv, cmd, errors.New("-h, -init, -clean, -compile, -autocomplete and -version cannot be used simultaneously") + return inv, cmd, errors.New("-h, -init, -clean, -compile, -install, -autocomplete and -version cannot be used simultaneously") } default: // no command flags set @@ -321,7 +337,7 @@ Options: if numCommands > 1 { debug.Printf("%d commands defined", numCommands) - return inv, cmd, errors.New("-h, -init, -clean, -compile, -autocomplete and -version cannot be used simultaneously") + return inv, cmd, errors.New("-h, -init, -clean, -compile, -install, -autocomplete and -version cannot be used simultaneously") } if cmd != CompileStatic && (inv.GOARCH != "" || inv.GOOS != "") { diff --git a/parse/parse.go b/parse/parse.go index 33dc16ce..f9176c60 100644 --- a/parse/parse.go +++ b/parse/parse.go @@ -197,7 +197,7 @@ func (f Function) ExecCode() string { _os.Exit(2) } x++`, x) - case "_time.Duration": + case "time.Duration": _, _ = fmt.Fprintf(&parseargs, ` arg%d, err := _time.ParseDuration(args.Args[x]) if err != nil { @@ -216,7 +216,7 @@ func (f Function) ExecCode() string { continue } _, _ = fmt.Fprintf(&parseargs, ` - var arg%d *%s`, x, arg.Type) + var arg%d *%s`, x, genType(arg.Type)) } // Phase 3: Parse optional arguments from -name=value flags @@ -1051,6 +1051,14 @@ var argTypes = map[string]string{ "string": "string", "int": "int", "float64": "float64", - "&{time Duration}": "_time.Duration", + "&{time Duration}": "time.Duration", "bool": "bool", } + +// genType converts a logical type name to the type name used in generated code. +func genType(typ string) string { + if typ == "time.Duration" { + return "_time.Duration" + } + return typ +} From 36dd6a335de4a35451b2290d04b43fdf15e8debb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:47:11 +0000 Subject: [PATCH 03/15] fix lint errors in completion support changes Agent-Logs-Url: https://github.com/magefile/mage/sessions/e946e5e6-ab3f-455c-a51f-eec28c14291c Co-authored-by: natefinch <3185864+natefinch@users.noreply.github.com> --- mage/colors.go | 4 +- mage/completion.go | 83 +++++++++++++++++++++++++++-------------- mage/completion_test.go | 4 +- 3 files changed, 61 insertions(+), 30 deletions(-) diff --git a/mage/colors.go b/mage/colors.go index 5491e084..1561a769 100644 --- a/mage/colors.go +++ b/mage/colors.go @@ -134,7 +134,7 @@ var printName = func(str string) string { if enableColorValue { return _fmt.Sprintf("%s%s%s", targetColorValue, str, ansiColorReset) - } else { - return str } + + return str } diff --git a/mage/completion.go b/mage/completion.go index 9dc3a8c6..a3cf5493 100644 --- a/mage/completion.go +++ b/mage/completion.go @@ -56,10 +56,12 @@ func completionConfigDir() (string, error) { // creating parent directories as needed. func writeCompletionFile(path, content string) error { dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { + // #nosec G703 -- path is constructed internally from trusted locations. + if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("could not create directory %s: %w", dir, err) } - return os.WriteFile(path, []byte(content), 0644) + // #nosec G703 -- path is constructed internally from trusted locations. + return os.WriteFile(path, []byte(content), 0o600) } // addGuardedBlock adds a guarded block of content to the given file. @@ -74,17 +76,18 @@ func addGuardedBlock(path, content string) error { block := mageCompletionMarker + "\n" + content + "\n" + mageCompletionMarkerEnd existingStr := string(existing) - if start := strings.Index(existingStr, mageCompletionMarker); start != -1 { - end := strings.Index(existingStr, mageCompletionMarkerEnd) - if end != -1 { - end += len(mageCompletionMarkerEnd) - newContent := existingStr[:start] + block + existingStr[end:] - return os.WriteFile(path, []byte(newContent), 0644) + beforeStart, afterStart, foundStart := strings.Cut(existingStr, mageCompletionMarker) + if foundStart { + _, afterEnd, foundEnd := strings.Cut(afterStart, mageCompletionMarkerEnd) + if foundEnd { + newContent := beforeStart + block + afterEnd + // #nosec G703 -- path is constructed internally from trusted locations. + return os.WriteFile(path, []byte(newContent), 0o600) } } // Append to file - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) if err != nil { return err } @@ -100,6 +103,16 @@ func addGuardedBlock(path, content string) error { return err } +func writef(w io.Writer, format string, args ...interface{}) error { + _, err := fmt.Fprintf(w, format, args...) + return err +} + +func writeln(w io.Writer, line string) error { + _, err := fmt.Fprintln(w, line) + return err +} + func installBashCompletion(stdout io.Writer) error { bin, err := mageExePath() if err != nil { @@ -128,10 +141,13 @@ func installBashCompletion(stdout io.Writer) error { return fmt.Errorf("could not update %s: %w", rcFile, err) } - fmt.Fprintf(stdout, "Installed bash completion to %s\n", scriptPath) - fmt.Fprintf(stdout, "Updated %s\n", rcFile) - fmt.Fprintf(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) - return nil + if err := writef(stdout, "Installed bash completion to %s\n", scriptPath); err != nil { + return err + } + if err := writef(stdout, "Updated %s\n", rcFile); err != nil { + return err + } + return writef(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) } func installZshCompletion(stdout io.Writer) error { @@ -162,10 +178,13 @@ func installZshCompletion(stdout io.Writer) error { return fmt.Errorf("could not update %s: %w", rcFile, err) } - fmt.Fprintf(stdout, "Installed zsh completion to %s\n", scriptPath) - fmt.Fprintf(stdout, "Updated %s\n", rcFile) - fmt.Fprintf(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) - return nil + if err := writef(stdout, "Installed zsh completion to %s\n", scriptPath); err != nil { + return err + } + if err := writef(stdout, "Updated %s\n", rcFile); err != nil { + return err + } + return writef(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) } func installFishCompletion(stdout io.Writer) error { @@ -191,9 +210,10 @@ func installFishCompletion(stdout io.Writer) error { return fmt.Errorf("could not write completion script: %w", err) } - fmt.Fprintf(stdout, "Installed fish completion to %s\n", scriptPath) - fmt.Fprintln(stdout, "Fish loads completions automatically. Restart your shell or run 'source "+scriptPath+"' to enable.") - return nil + if err := writef(stdout, "Installed fish completion to %s\n", scriptPath); err != nil { + return err + } + return writeln(stdout, "Fish loads completions automatically. Restart your shell or run 'source "+scriptPath+"' to enable.") } func installPowerShellCompletion(stdout io.Writer) error { @@ -213,13 +233,22 @@ func installPowerShellCompletion(stdout io.Writer) error { return fmt.Errorf("could not write completion script: %w", err) } - fmt.Fprintf(stdout, "Installed PowerShell completion to %s\n", scriptPath) - fmt.Fprintln(stdout, "") - fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile") - fmt.Fprintln(stdout, "(run '$PROFILE' in PowerShell to see the profile path):") - fmt.Fprintln(stdout, "") - fmt.Fprintf(stdout, " . %q\n", scriptPath) - return nil + if err := writef(stdout, "Installed PowerShell completion to %s\n", scriptPath); err != nil { + return err + } + if err := writeln(stdout, ""); err != nil { + return err + } + if err := writeln(stdout, "To enable, add the following line to your PowerShell profile"); err != nil { + return err + } + if err := writeln(stdout, "(run '$PROFILE' in PowerShell to see the profile path):"); err != nil { + return err + } + if err := writeln(stdout, ""); err != nil { + return err + } + return writef(stdout, " . %q\n", scriptPath) } // bashCompletionScript returns a bash completion script that uses mage -autocomplete. diff --git a/mage/completion_test.go b/mage/completion_test.go index 5593a503..fda008c7 100644 --- a/mage/completion_test.go +++ b/mage/completion_test.go @@ -197,7 +197,9 @@ func TestAddGuardedBlockExisting(t *testing.T) { path := filepath.Join(dir, "testrc") // Write initial content - os.WriteFile(path, []byte("existing stuff\n"), 0644) + if err := os.WriteFile(path, []byte("existing stuff\n"), 0o600); err != nil { + t.Fatal("could not write initial content:", err) + } err := addGuardedBlock(path, "test content") if err != nil { From 2d94a1ac98d08d6f85b23c21a078b614d7d8d41f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:48:07 +0000 Subject: [PATCH 04/15] adjust nosec directives per review feedback Agent-Logs-Url: https://github.com/magefile/mage/sessions/e946e5e6-ab3f-455c-a51f-eec28c14291c Co-authored-by: natefinch <3185864+natefinch@users.noreply.github.com> --- mage/completion.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mage/completion.go b/mage/completion.go index a3cf5493..fd5068e9 100644 --- a/mage/completion.go +++ b/mage/completion.go @@ -56,11 +56,11 @@ func completionConfigDir() (string, error) { // creating parent directories as needed. func writeCompletionFile(path, content string) error { dir := filepath.Dir(path) - // #nosec G703 -- path is constructed internally from trusted locations. + // #nosec -- path is constructed internally from trusted locations. if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("could not create directory %s: %w", dir, err) } - // #nosec G703 -- path is constructed internally from trusted locations. + // #nosec -- path is constructed internally from trusted locations. return os.WriteFile(path, []byte(content), 0o600) } @@ -81,7 +81,7 @@ func addGuardedBlock(path, content string) error { _, afterEnd, foundEnd := strings.Cut(afterStart, mageCompletionMarkerEnd) if foundEnd { newContent := beforeStart + block + afterEnd - // #nosec G703 -- path is constructed internally from trusted locations. + // #nosec -- path is constructed internally from trusted locations. return os.WriteFile(path, []byte(newContent), 0o600) } } From 619e7436d420882b87fcaab4121a08559ba4b206 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:48:53 +0000 Subject: [PATCH 05/15] use any in completion helper signature Agent-Logs-Url: https://github.com/magefile/mage/sessions/e946e5e6-ab3f-455c-a51f-eec28c14291c Co-authored-by: natefinch <3185864+natefinch@users.noreply.github.com> --- mage/completion.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mage/completion.go b/mage/completion.go index fd5068e9..0d1801ca 100644 --- a/mage/completion.go +++ b/mage/completion.go @@ -103,7 +103,7 @@ func addGuardedBlock(path, content string) error { return err } -func writef(w io.Writer, format string, args ...interface{}) error { +func writef(w io.Writer, format string, args ...any) error { _, err := fmt.Fprintf(w, format, args...) return err } From 7a649cec43c1574c0b7b8956ae681fcf3bf37d4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:50:01 +0000 Subject: [PATCH 06/15] inline completion output error handling Agent-Logs-Url: https://github.com/magefile/mage/sessions/e946e5e6-ab3f-455c-a51f-eec28c14291c Co-authored-by: natefinch <3185864+natefinch@users.noreply.github.com> --- mage/completion.go | 42 ++++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/mage/completion.go b/mage/completion.go index 0d1801ca..b76e907e 100644 --- a/mage/completion.go +++ b/mage/completion.go @@ -103,16 +103,6 @@ func addGuardedBlock(path, content string) error { return err } -func writef(w io.Writer, format string, args ...any) error { - _, err := fmt.Fprintf(w, format, args...) - return err -} - -func writeln(w io.Writer, line string) error { - _, err := fmt.Fprintln(w, line) - return err -} - func installBashCompletion(stdout io.Writer) error { bin, err := mageExePath() if err != nil { @@ -141,13 +131,14 @@ func installBashCompletion(stdout io.Writer) error { return fmt.Errorf("could not update %s: %w", rcFile, err) } - if err := writef(stdout, "Installed bash completion to %s\n", scriptPath); err != nil { + if _, err := fmt.Fprintf(stdout, "Installed bash completion to %s\n", scriptPath); err != nil { return err } - if err := writef(stdout, "Updated %s\n", rcFile); err != nil { + if _, err := fmt.Fprintf(stdout, "Updated %s\n", rcFile); err != nil { return err } - return writef(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) + _, err = fmt.Fprintf(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) + return err } func installZshCompletion(stdout io.Writer) error { @@ -178,13 +169,14 @@ func installZshCompletion(stdout io.Writer) error { return fmt.Errorf("could not update %s: %w", rcFile, err) } - if err := writef(stdout, "Installed zsh completion to %s\n", scriptPath); err != nil { + if _, err := fmt.Fprintf(stdout, "Installed zsh completion to %s\n", scriptPath); err != nil { return err } - if err := writef(stdout, "Updated %s\n", rcFile); err != nil { + if _, err := fmt.Fprintf(stdout, "Updated %s\n", rcFile); err != nil { return err } - return writef(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) + _, err = fmt.Fprintf(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) + return err } func installFishCompletion(stdout io.Writer) error { @@ -210,10 +202,11 @@ func installFishCompletion(stdout io.Writer) error { return fmt.Errorf("could not write completion script: %w", err) } - if err := writef(stdout, "Installed fish completion to %s\n", scriptPath); err != nil { + if _, err := fmt.Fprintf(stdout, "Installed fish completion to %s\n", scriptPath); err != nil { return err } - return writeln(stdout, "Fish loads completions automatically. Restart your shell or run 'source "+scriptPath+"' to enable.") + _, err = fmt.Fprintln(stdout, "Fish loads completions automatically. Restart your shell or run 'source "+scriptPath+"' to enable.") + return err } func installPowerShellCompletion(stdout io.Writer) error { @@ -233,22 +226,23 @@ func installPowerShellCompletion(stdout io.Writer) error { return fmt.Errorf("could not write completion script: %w", err) } - if err := writef(stdout, "Installed PowerShell completion to %s\n", scriptPath); err != nil { + if _, err := fmt.Fprintf(stdout, "Installed PowerShell completion to %s\n", scriptPath); err != nil { return err } - if err := writeln(stdout, ""); err != nil { + if _, err := fmt.Fprintln(stdout, ""); err != nil { return err } - if err := writeln(stdout, "To enable, add the following line to your PowerShell profile"); err != nil { + if _, err := fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile"); err != nil { return err } - if err := writeln(stdout, "(run '$PROFILE' in PowerShell to see the profile path):"); err != nil { + if _, err := fmt.Fprintln(stdout, "(run '$PROFILE' in PowerShell to see the profile path):"); err != nil { return err } - if err := writeln(stdout, ""); err != nil { + if _, err := fmt.Fprintln(stdout, ""); err != nil { return err } - return writef(stdout, " . %q\n", scriptPath) + _, err = fmt.Fprintf(stdout, " . %q\n", scriptPath) + return err } // bashCompletionScript returns a bash completion script that uses mage -autocomplete. From 712218aca43c201e40465c2f26944a8d4c553fa5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:51:06 +0000 Subject: [PATCH 07/15] avoid failing on cosmetic blank-line writes Agent-Logs-Url: https://github.com/magefile/mage/sessions/e946e5e6-ab3f-455c-a51f-eec28c14291c Co-authored-by: natefinch <3185864+natefinch@users.noreply.github.com> --- mage/completion.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/mage/completion.go b/mage/completion.go index b76e907e..eeaedc44 100644 --- a/mage/completion.go +++ b/mage/completion.go @@ -229,18 +229,14 @@ func installPowerShellCompletion(stdout io.Writer) error { if _, err := fmt.Fprintf(stdout, "Installed PowerShell completion to %s\n", scriptPath); err != nil { return err } - if _, err := fmt.Fprintln(stdout, ""); err != nil { - return err - } + _, _ = fmt.Fprintln(stdout, "") if _, err := fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile"); err != nil { return err } if _, err := fmt.Fprintln(stdout, "(run '$PROFILE' in PowerShell to see the profile path):"); err != nil { return err } - if _, err := fmt.Fprintln(stdout, ""); err != nil { - return err - } + _, _ = fmt.Fprintln(stdout, "") _, err = fmt.Fprintf(stdout, " . %q\n", scriptPath) return err } From a872d559d3f68e6f56368216d92aa5bdd072e398 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:52:09 +0000 Subject: [PATCH 08/15] simplify powershell output formatting writes Agent-Logs-Url: https://github.com/magefile/mage/sessions/e946e5e6-ab3f-455c-a51f-eec28c14291c Co-authored-by: natefinch <3185864+natefinch@users.noreply.github.com> --- mage/completion.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mage/completion.go b/mage/completion.go index eeaedc44..7415cf89 100644 --- a/mage/completion.go +++ b/mage/completion.go @@ -229,15 +229,13 @@ func installPowerShellCompletion(stdout io.Writer) error { if _, err := fmt.Fprintf(stdout, "Installed PowerShell completion to %s\n", scriptPath); err != nil { return err } - _, _ = fmt.Fprintln(stdout, "") - if _, err := fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile"); err != nil { + if _, err := fmt.Fprintln(stdout, "\nTo enable, add the following line to your PowerShell profile"); err != nil { return err } if _, err := fmt.Fprintln(stdout, "(run '$PROFILE' in PowerShell to see the profile path):"); err != nil { return err } - _, _ = fmt.Fprintln(stdout, "") - _, err = fmt.Fprintf(stdout, " . %q\n", scriptPath) + _, err = fmt.Fprintf(stdout, "\n . %q\n", scriptPath) return err } From a752cd3b33335a9b3503e3d723f107c24a1ba92e Mon Sep 17 00:00:00 2001 From: Nate Finch Date: Tue, 21 Apr 2026 22:20:12 -0400 Subject: [PATCH 09/15] update tab completion code to be more robust --- .github/copilot-instructions.md | 58 +++++++++++-- mage/completion.go | 144 +++++++++++++++++++++++++------- mage/completion_test.go | 124 +++++++++++++++++++++++++-- mage/main.go | 4 +- 4 files changed, 279 insertions(+), 51 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c131129e..3aedea7e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,15 +1,55 @@ -# Agent Instructions +# Copilot Instructions for Mage -## Go Compatibility +Mage is a make-like build tool that uses Go functions as build targets. Users write plain Go functions in "magefiles" and mage makes them runnable from the command line. -This project targets Go 1.18. Do not use language features or standard library -functions/types introduced after Go 1.18. +## Build, Test, and Lint -## Go Dependencies +```bash +# Build +go build ./... -Do not add any external module dependencies. Only import packages from the Go -standard library or from within this module. +# Test (full suite, including race detector as required by CI) +go test -race ./... -## Go Formatting +# Test a single package +go test -race ./parse/ -After modifying any Go files, run `goimports -w` on the changed files. +# Test a single test function +go test -race ./mage/ -run TestGoCmd + +# CI runs tests with: go test -v -vet=all -tags CI -race ./... + +# Lint (requires golangci-lint) +golangci-lint run ./... +``` + +Mage builds itself with mage. The bootstrap path (`go run bootstrap.go`) is for building mage when mage isn't installed yet. The project's own build targets live in `magefiles/`. + +## Architecture + +Mage works by **parsing user Go source files and generating a temporary CLI binary** that dispatches to the user's target functions: + +1. **Entry** — `main.go` calls `mage.Main()` which parses CLI flags and dispatches commands. +2. **File scanning** — `mage/main.go` finds magefiles (files with `//go:build mage` or `// +build mage` tags) in the current directory. +3. **AST parsing** — `parse.PrimaryPackage()` uses `go/parser` and `go/doc` to extract exported functions, namespaces (types embedding `mg.Namespace`), `//mage:import` directives, aliases, and the default target. +4. **Code generation** — `GenerateMainfile()` renders `mage/magefile_tmpl.go` (a Go `text/template`) into a wrapper `main` package that handles flag parsing, help output, and dispatching to user targets. +5. **Compilation & caching** — `Compile()` runs `go build` on the magefiles plus the generated wrapper. The output binary is cached by content hash in the user's cache directory. + +### Package Map + +- **`mage/`** — Core library: CLI entry point, file scanning, code generation, compilation, and execution. Can be used as a library (`mage.Invoke()`). +- **`mg/`** — User-facing API for magefiles: `Deps`/`CtxDeps` for dependency declaration, `mg.F()` for parameterized targets, `mg.Namespace` for grouping targets, `Fatal`/`Fatalf` for error handling. +- **`parse/`** — Go AST parser that extracts target metadata (functions, namespaces, imports, aliases, defaults) from magefiles into a `parse.PkgInfo` model consumed by code generation. +- **`sh/`** — Shell helper functions (`sh.Run`, `sh.Output`, `sh.Exec`) for use in magefiles. +- **`internal/`** — Shared low-level utilities for command execution and debug output. +- **`target/`** — Timestamp-based rebuild helpers (`target.Path`, `target.Dir`, `target.Glob`) for use in magefiles. + +## Key Conventions + +- **Zero external dependencies.** Mage uses only the Go standard library. This is intentional — since mage is often vendored into projects, adding dependencies to mage adds them to every project that uses it. Do not add external dependencies. +- **Go 1.18 minimum.** The `go.mod` specifies Go 1.18. CI tests against both Go 1.18 and stable. Avoid language features or stdlib APIs from newer Go versions. +- **Target function signatures** follow strict rules enforced by `parse/parse.go` (`funcType`): optional leading `context.Context` parameter, supported arg types (`string`, `int`, `bool`, `time.Duration`), and must return either nothing or `error`. Pointer args become optional CLI arguments. +- **`//mage:import`** comments on blank imports cause mage to recursively parse imported packages and surface their exported functions as targets. +- **Namespace targets** are methods on types that embed `mg.Namespace`. The type name becomes a CLI prefix (e.g., `mage ns:target`). +- **Formatting** uses `goimports` (configured in `.golangci.toml`). +- **Tests** are primarily integration-style: `mage/main_test.go` calls `Invoke()` against fixture directories under `testdata/`. Table-driven unit tests are used in `parse/`, `sh/`, `internal/`, and `target/`. Always run tests with `-race`. diff --git a/mage/completion.go b/mage/completion.go index 9dc3a8c6..f9cb5b84 100644 --- a/mage/completion.go +++ b/mage/completion.go @@ -4,7 +4,9 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" + "runtime" "strings" ) @@ -30,17 +32,16 @@ func installCompletion(stdout io.Writer, shell string) error { } } -// mageExePath returns the resolved path of the running mage executable. +// mageExePath returns the path to use for the mage executable in generated +// completion scripts. It prefers the unresolved executable path (preserving +// symlinks) so completions survive package-manager upgrades. Falls back to +// "mage" if the path cannot be determined. func mageExePath() (string, error) { exe, err := os.Executable() if err != nil { - return "", err + return "mage", nil } - resolved, err := filepath.EvalSymlinks(exe) - if err != nil { - return "", err - } - return resolved, nil + return exe, nil } // completionConfigDir returns the directory for mage completion config files. @@ -83,7 +84,10 @@ func addGuardedBlock(path, content string) error { } } - // Append to file + // Append to file, creating parent directories if needed + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) if err != nil { return err @@ -122,10 +126,23 @@ func installBashCompletion(stdout io.Writer) error { return err } + // On macOS, bash reads .bash_profile for login shells (the default + // terminal behavior) rather than .bashrc. Use .bashrc if it exists, + // otherwise fall back to .bash_profile. rcFile := filepath.Join(home, ".bashrc") - sourceLine := fmt.Sprintf(`[ -f %q ] && source %q`, scriptPath, scriptPath) + if _, err := os.Stat(rcFile); os.IsNotExist(err) { + rcFile = filepath.Join(home, ".bash_profile") + } + + sourceLine := fmt.Sprintf(`[ -f '%s' ] && source '%s'`, scriptPath, scriptPath) if err := addGuardedBlock(rcFile, sourceLine); err != nil { - return fmt.Errorf("could not update %s: %w", rcFile, err) + fmt.Fprintf(stdout, "Installed bash completion to %s\n", scriptPath) + fmt.Fprintln(stdout, "") + fmt.Fprintf(stdout, "Could not update %s: %v\n", rcFile, err) + fmt.Fprintln(stdout, "To enable, add the following line to your shell profile:") + fmt.Fprintln(stdout, "") + fmt.Fprintf(stdout, " source '%s'\n", scriptPath) + return nil } fmt.Fprintf(stdout, "Installed bash completion to %s\n", scriptPath) @@ -151,15 +168,26 @@ func installZshCompletion(stdout io.Writer) error { return fmt.Errorf("could not write completion script: %w", err) } - home, err := os.UserHomeDir() - if err != nil { - return err + // Honor ZDOTDIR if set, otherwise use $HOME + zdotdir := os.Getenv("ZDOTDIR") + if zdotdir == "" { + home, err := os.UserHomeDir() + if err != nil { + return err + } + zdotdir = home } - rcFile := filepath.Join(home, ".zshrc") - sourceLine := fmt.Sprintf(`[ -f %q ] && source %q`, scriptPath, scriptPath) + rcFile := filepath.Join(zdotdir, ".zshrc") + sourceLine := fmt.Sprintf(`[ -f '%s' ] && source '%s'`, scriptPath, scriptPath) if err := addGuardedBlock(rcFile, sourceLine); err != nil { - return fmt.Errorf("could not update %s: %w", rcFile, err) + fmt.Fprintf(stdout, "Installed zsh completion to %s\n", scriptPath) + fmt.Fprintln(stdout, "") + fmt.Fprintf(stdout, "Could not update %s: %v\n", rcFile, err) + fmt.Fprintln(stdout, "To enable, add the following line to your .zshrc:") + fmt.Fprintln(stdout, "") + fmt.Fprintf(stdout, " source '%s'\n", scriptPath) + return nil } fmt.Fprintf(stdout, "Installed zsh completion to %s\n", scriptPath) @@ -175,14 +203,13 @@ func installFishCompletion(stdout io.Writer) error { } script := fishCompletionScript(bin) - home, err := os.UserHomeDir() - if err != nil { - return err - } - - // Honor XDG_CONFIG_HOME if set + // Honor XDG_CONFIG_HOME if set, otherwise use ~/.config configDir := os.Getenv("XDG_CONFIG_HOME") if configDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return err + } configDir = filepath.Join(home, ".config") } @@ -192,7 +219,7 @@ func installFishCompletion(stdout io.Writer) error { } fmt.Fprintf(stdout, "Installed fish completion to %s\n", scriptPath) - fmt.Fprintln(stdout, "Fish loads completions automatically. Restart your shell or run 'source "+scriptPath+"' to enable.") + fmt.Fprintf(stdout, "Fish loads completions automatically. Restart your shell or run 'source %s' to enable.\n", scriptPath) return nil } @@ -214,14 +241,62 @@ func installPowerShellCompletion(stdout io.Writer) error { } fmt.Fprintf(stdout, "Installed PowerShell completion to %s\n", scriptPath) - fmt.Fprintln(stdout, "") - fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile") - fmt.Fprintln(stdout, "(run '$PROFILE' in PowerShell to see the profile path):") - fmt.Fprintln(stdout, "") - fmt.Fprintf(stdout, " . %q\n", scriptPath) + + profilePath := discoverPowerShellProfile() + if profilePath == "" { + fmt.Fprintln(stdout, "") + fmt.Fprintln(stdout, "Could not detect your PowerShell profile path.") + fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile") + fmt.Fprintln(stdout, "(run 'echo $PROFILE' in PowerShell to see the profile path):") + fmt.Fprintln(stdout, "") + fmt.Fprintf(stdout, " . %q\n", scriptPath) + return nil + } + + sourceLine := fmt.Sprintf(". %q", scriptPath) + if err := addGuardedBlock(profilePath, sourceLine); err != nil { + // Fall back to manual instructions if we can't write the profile + fmt.Fprintln(stdout, "") + fmt.Fprintf(stdout, "Could not update %s: %v\n", profilePath, err) + fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile:") + fmt.Fprintln(stdout, "") + fmt.Fprintf(stdout, " . %q\n", scriptPath) + return nil + } + + fmt.Fprintf(stdout, "Updated %s\n", profilePath) + fmt.Fprintln(stdout, "Restart PowerShell to enable completions.") return nil } +// discoverPowerShellProfile attempts to find the PowerShell profile path. +// It tries running pwsh/powershell to query $PROFILE, then falls back to +// well-known default locations. +func discoverPowerShellProfile() string { + // Try querying pwsh (PowerShell Core) first, then powershell (Windows PowerShell) + for _, shell := range []string{"pwsh", "powershell"} { + out, err := exec.Command(shell, "-NoProfile", "-NonInteractive", "-Command", "echo $PROFILE").Output() + if err == nil { + if p := strings.TrimSpace(string(out)); p != "" { + return p + } + } + } + + // Fall back to well-known default locations + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + if runtime.GOOS == "windows" { + // Windows PowerShell default + return filepath.Join(home, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1") + } + // PowerShell Core on macOS/Linux + return filepath.Join(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1") +} + // bashCompletionScript returns a bash completion script that uses mage -autocomplete. func bashCompletionScript(mageBin string) string { return `_mage_completions() { @@ -232,7 +307,7 @@ func bashCompletionScript(mageBin string) string { return fi local IFS=$'\n' - COMPREPLY=($(compgen -W "$(` + mageBin + ` -autocomplete 2>/dev/null)" -- "$cur")) + COMPREPLY=($(compgen -W "$('` + mageBin + `' -autocomplete 2>/dev/null)" -- "$cur")) } complete -F _mage_completions mage ` @@ -270,10 +345,15 @@ _mage() { _describe 'flag' flags return fi - targets=(${(f)"$(` + mageBin + ` -autocomplete 2>/dev/null)"}) + targets=(${(f)"$('` + mageBin + `' -autocomplete 2>/dev/null)"}) _describe 'target' targets } -compdef _mage mage +if (( $+functions[compdef] )); then + compdef _mage mage +else + autoload -Uz compinit && compinit + compdef _mage mage +fi ` } @@ -281,7 +361,7 @@ compdef _mage mage func fishCompletionScript(mageBin string) string { return `# mage tab completion for fish complete -c mage -f -complete -c mage -a '(` + mageBin + ` -autocomplete 2>/dev/null)' -d 'mage target' +complete -c mage -a '('` + mageBin + `' -autocomplete 2>/dev/null)' -d 'mage target' complete -c mage -s l -d 'list mage targets in this directory' complete -c mage -s h -d 'show this help' complete -c mage -s v -d 'show verbose output when running mage targets' diff --git a/mage/completion_test.go b/mage/completion_test.go index 5593a503..e66454cf 100644 --- a/mage/completion_test.go +++ b/mage/completion_test.go @@ -32,24 +32,53 @@ func TestInstallCompletionBash(t *testing.T) { t.Error("completion script missing complete command") } - // Verify .bashrc was updated - rcPath := filepath.Join(home, ".bashrc") + // Since neither .bashrc nor .bash_profile exist, it falls back to + // .bash_profile and creates it. + rcPath := filepath.Join(home, ".bash_profile") rc, err := os.ReadFile(rcPath) if err != nil { - t.Fatal(".bashrc not found:", err) + t.Fatal("rc file not found:", err) } rcStr := string(rc) if !strings.Contains(rcStr, mageCompletionMarker) { - t.Error(".bashrc missing completion marker") + t.Error("rc file missing completion marker") } if !strings.Contains(rcStr, "source") { - t.Error(".bashrc missing source line") + t.Error("rc file missing source line") + } +} + +func TestInstallCompletionBashPrefersBashrc(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // Create .bashrc so it's preferred over .bash_profile + os.WriteFile(filepath.Join(home, ".bashrc"), []byte("# existing\n"), 0644) + + stdout := &bytes.Buffer{} + err := installCompletion(stdout, "bash") + if err != nil { + t.Fatal("unexpected error:", err) + } + + rc, err := os.ReadFile(filepath.Join(home, ".bashrc")) + if err != nil { + t.Fatal(".bashrc not found:", err) + } + if !strings.Contains(string(rc), mageCompletionMarker) { + t.Error(".bashrc missing completion marker") + } + + // .bash_profile should not have been created + if _, err := os.Stat(filepath.Join(home, ".bash_profile")); !os.IsNotExist(err) { + t.Error(".bash_profile should not exist when .bashrc is present") } } func TestInstallCompletionZsh(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("ZDOTDIR", "") stdout := &bytes.Buffer{} err := installCompletion(stdout, "zsh") @@ -79,6 +108,34 @@ func TestInstallCompletionZsh(t *testing.T) { } } +func TestInstallCompletionZshZDOTDIR(t *testing.T) { + home := t.TempDir() + zdotdir := filepath.Join(home, "custom-zsh") + t.Setenv("HOME", home) + t.Setenv("ZDOTDIR", zdotdir) + + stdout := &bytes.Buffer{} + err := installCompletion(stdout, "zsh") + if err != nil { + t.Fatal("unexpected error:", err) + } + + // .zshrc should be in ZDOTDIR, not HOME + rcPath := filepath.Join(zdotdir, ".zshrc") + rc, err := os.ReadFile(rcPath) + if err != nil { + t.Fatal(".zshrc in ZDOTDIR not found:", err) + } + if !strings.Contains(string(rc), mageCompletionMarker) { + t.Error(".zshrc in ZDOTDIR missing completion marker") + } + + // HOME/.zshrc should not exist + if _, err := os.Stat(filepath.Join(home, ".zshrc")); !os.IsNotExist(err) { + t.Error("$HOME/.zshrc should not exist when ZDOTDIR is set") + } +} + func TestInstallCompletionFish(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) @@ -137,10 +194,10 @@ func TestInstallCompletionPowerShell(t *testing.T) { t.Error("completion script missing Register-ArgumentCompleter") } - // PowerShell should print instructions, not modify files + // Should have either updated a profile or printed instructions output := stdout.String() - if !strings.Contains(output, "$PROFILE") { - t.Error("output should contain instructions mentioning $PROFILE") + if !strings.Contains(output, "Installed PowerShell completion") { + t.Error("output should confirm installation") } } @@ -170,6 +227,57 @@ func TestInstallCompletionCaseInsensitive(t *testing.T) { } } +func TestInstallBashFallbackOnRcFailure(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // Create .bash_profile as a directory to force addGuardedBlock to fail + os.MkdirAll(filepath.Join(home, ".bash_profile"), 0755) + + stdout := &bytes.Buffer{} + err := installCompletion(stdout, "bash") + if err != nil { + t.Fatal("should not return error, should fall back to instructions:", err) + } + + output := stdout.String() + if !strings.Contains(output, "Could not update") { + t.Error("should mention failed update") + } + if !strings.Contains(output, "source") { + t.Error("should print manual source instructions") + } + + // Script itself should still have been written + scriptPath := filepath.Join(home, ".config", "mage", "completion.bash") + if _, err := os.Stat(scriptPath); err != nil { + t.Fatal("completion script should still be installed:", err) + } +} + +func TestInstallZshFallbackOnRcFailure(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("ZDOTDIR", "") + + // Create .zshrc as a directory to force addGuardedBlock to fail + os.MkdirAll(filepath.Join(home, ".zshrc"), 0755) + + stdout := &bytes.Buffer{} + err := installCompletion(stdout, "zsh") + if err != nil { + t.Fatal("should not return error, should fall back to instructions:", err) + } + + output := stdout.String() + if !strings.Contains(output, "Could not update") { + t.Error("should mention failed update") + } + if !strings.Contains(output, "source") { + t.Error("should print manual source instructions") + } +} + func TestAddGuardedBlockNew(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "testrc") diff --git a/mage/main.go b/mage/main.go index 2fcd6ca3..5faab34f 100644 --- a/mage/main.go +++ b/mage/main.go @@ -253,6 +253,8 @@ mage [options] [target] Mage is a make-like command runner. See https://magefile.org for full docs. Commands: + -autocomplete + print target names for shell completion, without compiling -clean clean out old generated binaries from CACHE_DIR -compile output a static binary to the given path @@ -264,8 +266,6 @@ Commands: -version show version info for the mage binary Options: - -autocomplete - print target names for shell completion, without compiling -d directory to read magefiles from (default "." or "magefiles" if exists) -debug turn on debug messages From d30334cd6fc4792b4d964be141435f26e1f3c502 Mon Sep 17 00:00:00 2001 From: Nate Finch Date: Wed, 22 Apr 2026 09:07:30 -0400 Subject: [PATCH 10/15] clean up some linter errors --- .github/copilot-instructions.md | 5 +- .golangci.toml | 46 ++--------- bootstrap.go | 1 - install_test.go | 1 - mage/completion.go | 138 ++++++++++++++------------------ mage/completion_test.go | 12 ++- mage/main.go | 38 ++++----- mage/main_test.go | 22 ++--- magefiles/targets/targets.go | 4 +- sh/helpers.go | 2 +- target/newer_test.go | 8 +- 11 files changed, 115 insertions(+), 162 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3aedea7e..286ab973 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,10 +19,12 @@ go test -race ./mage/ -run TestGoCmd # CI runs tests with: go test -v -vet=all -tags CI -race ./... -# Lint (requires golangci-lint) +# Lint (requires golangci-lint) — run after any code changes golangci-lint run ./... ``` +After making changes, always run `golangci-lint run ./...` before committing to catch lint issues early. + Mage builds itself with mage. The bootstrap path (`go run bootstrap.go`) is for building mage when mage isn't installed yet. The project's own build targets live in `magefiles/`. ## Architecture @@ -51,5 +53,6 @@ Mage works by **parsing user Go source files and generating a temporary CLI bina - **Target function signatures** follow strict rules enforced by `parse/parse.go` (`funcType`): optional leading `context.Context` parameter, supported arg types (`string`, `int`, `bool`, `time.Duration`), and must return either nothing or `error`. Pointer args become optional CLI arguments. - **`//mage:import`** comments on blank imports cause mage to recursively parse imported packages and surface their exported functions as targets. - **Namespace targets** are methods on types that embed `mg.Namespace`. The type name becomes a CLI prefix (e.g., `mage ns:target`). +- **Documentation** — All functions, methods, types, package variables, and package constants must have Go doc comments describing their purpose, including unexported ones. Every package must have a detailed package-level doc comment explaining what the package is for and how to use it. - **Formatting** uses `goimports` (configured in `.golangci.toml`). - **Tests** are primarily integration-style: `mage/main_test.go` calls `Invoke()` against fixture directories under `testdata/`. Table-driven unit tests are used in `parse/`, `sh/`, `internal/`, and `target/`. Always run tests with `-race`. diff --git a/.golangci.toml b/.golangci.toml index 6c81374c..93b72440 100644 --- a/.golangci.toml +++ b/.golangci.toml @@ -21,14 +21,12 @@ disable = [ 'asciicheck', 'canonicalheader', 'containedctx', - 'copyloopvar', # Not applicable in go versions under 1.22 'cyclop', 'depguard', 'dogsled', 'dupl', 'dupword', 'err113', - 'errcheck', 'exhaustive', 'exhaustruct', 'forbidigo', @@ -61,8 +59,6 @@ disable = [ 'thelper', 'unparam', 'varnamelen', - 'wastedassign', - 'whitespace', 'wrapcheck', 'wsl', 'wsl_v5' @@ -81,7 +77,6 @@ pattern = 'time.After\.*(# use of time After can create memory allocation issues [linters.settings.gocritic] disabled-checks = [ 'importShadow', -# 'unnamedResult' ] enabled-tags = [ 'diagnostic', @@ -98,20 +93,15 @@ sizeThreshold = 256 [linters.settings.gosec] excludes = [ - 'G204', - 'G304', - 'G307', - 'G702', - 'G706' + 'G204', # Audit use of exec.Command with variable arguments (command injection risk) + 'G304', # Audit file path provided as taint input (path traversal via user-supplied file paths) + 'G307', # Deferring a method which returns an error (e.g., defer f.Close() without checking the error) + 'G702', # net/http SetDeadline not called (HTTP server timeout not configured) + 'G706', # Audit use of io.ReadAll (potential denial of service from unbounded reads) ] -[linters.settings.gosec.config] -[linters.settings.gosec.config.G104] -# os = ['Setenv'] - [linters.settings.govet] disable = [ - 'shadow', 'fieldalignment' ] enable-all = true @@ -120,16 +110,13 @@ enable-all = true disable = ["any"] [linters.settings.nestif] -min-complexity = 9 +min-complexity = 6 [linters.settings.nolintlint] require-explanation = true require-specific = true allow-unused = false -# [linters.settings.recvcheck] -# exclusions = ['*.UnmarshalJSON'] - [linters.settings.revive] confidence = 0.8 severity = 'error' @@ -140,7 +127,6 @@ name = 'comment-spacings' arguments = [ 'nolint' ] -disabled = false [[linters.settings.revive.rules]] name = 'argument-limit' @@ -168,34 +154,18 @@ disabled = true name = 'flag-parameter' disabled = true -[[linters.settings.revive.rules]] -name = 'blank-imports' -disabled = false - [[linters.settings.revive.rules]] name = 'cognitive-complexity' disabled = true -[[linters.settings.revive.rules]] -name = 'constant-logical-expr' -disabled = false - [[linters.settings.revive.rules]] name = 'cyclomatic' disabled = true -[[linters.settings.revive.rules]] -name = 'file-header' -disabled = false - [[linters.settings.revive.rules]] name = 'function-length' disabled = true -[[linters.settings.revive.rules]] -name = 'get-return' -disabled = false - [[linters.settings.revive.rules]] name = 'line-length-limit' disabled = true @@ -204,10 +174,6 @@ disabled = true name = 'max-public-structs' disabled = true -[[linters.settings.revive.rules]] -name = 'optimize-operands-order' -disabled = false - [[linters.settings.revive.rules]] name = 'redundant-test-main-exit' disabled = true diff --git a/bootstrap.go b/bootstrap.go index 136aa66a..55a6b922 100644 --- a/bootstrap.go +++ b/bootstrap.go @@ -1,5 +1,4 @@ //go:build ignore -// +build ignore package main diff --git a/install_test.go b/install_test.go index 6c9a8151..73209870 100644 --- a/install_test.go +++ b/install_test.go @@ -1,5 +1,4 @@ //go:build CI -// +build CI package main diff --git a/mage/completion.go b/mage/completion.go index 89fa9159..e2a8d4ae 100644 --- a/mage/completion.go +++ b/mage/completion.go @@ -1,6 +1,7 @@ package mage import ( + "context" "fmt" "io" "os" @@ -8,6 +9,7 @@ import ( "path/filepath" "runtime" "strings" + "time" ) const ( @@ -36,12 +38,12 @@ func installCompletion(stdout io.Writer, shell string) error { // completion scripts. It prefers the unresolved executable path (preserving // symlinks) so completions survive package-manager upgrades. Falls back to // "mage" if the path cannot be determined. -func mageExePath() (string, error) { +func mageExePath() string { exe, err := os.Executable() if err != nil { - return "mage", nil + return "mage" } - return exe, nil + return exe } // completionConfigDir returns the directory for mage completion config files. @@ -88,30 +90,27 @@ func addGuardedBlock(path, content string) error { } // Append to file, creating parent directories if needed - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err + if mkdirErr := os.MkdirAll(filepath.Dir(path), 0o750); mkdirErr != nil { + return mkdirErr } - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) - if err != nil { - return err + f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) + if openErr != nil { + return openErr } - defer f.Close() + defer func() { _ = f.Close() }() // Add a newline before the block if the file is non-empty and doesn't end with one if len(existing) > 0 && existing[len(existing)-1] != '\n' { - if _, err := f.WriteString("\n"); err != nil { - return err + if _, writeErr := f.WriteString("\n"); writeErr != nil { + return writeErr } } - _, err = f.WriteString(block + "\n") - return err + _, writeErr := f.WriteString(block + "\n") + return writeErr } func installBashCompletion(stdout io.Writer) error { - bin, err := mageExePath() - if err != nil { - return err - } + bin := mageExePath() script := bashCompletionScript(bin) dir, err := completionConfigDir() @@ -120,8 +119,8 @@ func installBashCompletion(stdout io.Writer) error { } scriptPath := filepath.Join(dir, "completion.bash") - if err := writeCompletionFile(scriptPath, script); err != nil { - return fmt.Errorf("could not write completion script: %w", err) + if writeErr := writeCompletionFile(scriptPath, script); writeErr != nil { + return fmt.Errorf("could not write completion script: %w", writeErr) } home, err := os.UserHomeDir() @@ -139,30 +138,23 @@ func installBashCompletion(stdout io.Writer) error { sourceLine := fmt.Sprintf(`[ -f '%s' ] && source '%s'`, scriptPath, scriptPath) if err := addGuardedBlock(rcFile, sourceLine); err != nil { - fmt.Fprintf(stdout, "Installed bash completion to %s\n", scriptPath) - fmt.Fprintln(stdout, "") - fmt.Fprintf(stdout, "Could not update %s: %v\n", rcFile, err) - fmt.Fprintln(stdout, "To enable, add the following line to your shell profile:") - fmt.Fprintln(stdout, "") - fmt.Fprintf(stdout, " source '%s'\n", scriptPath) + _, _ = fmt.Fprintf(stdout, "Installed bash completion to %s\n", scriptPath) + _, _ = fmt.Fprintln(stdout, "") + _, _ = fmt.Fprintf(stdout, "Could not update %s: %v\n", rcFile, err) + _, _ = fmt.Fprintln(stdout, "To enable, add the following line to your shell profile:") + _, _ = fmt.Fprintln(stdout, "") + _, _ = fmt.Fprintf(stdout, " source '%s'\n", scriptPath) return nil } - if _, err := fmt.Fprintf(stdout, "Installed bash completion to %s\n", scriptPath); err != nil { - return err - } - if _, err := fmt.Fprintf(stdout, "Updated %s\n", rcFile); err != nil { - return err - } - _, err = fmt.Fprintf(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) - return err + _, _ = fmt.Fprintf(stdout, "Installed bash completion to %s\n", scriptPath) + _, _ = fmt.Fprintf(stdout, "Updated %s\n", rcFile) + _, _ = fmt.Fprintf(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) + return nil } func installZshCompletion(stdout io.Writer) error { - bin, err := mageExePath() - if err != nil { - return err - } + bin := mageExePath() script := zshCompletionScript(bin) dir, err := completionConfigDir() @@ -188,30 +180,23 @@ func installZshCompletion(stdout io.Writer) error { rcFile := filepath.Join(zdotdir, ".zshrc") sourceLine := fmt.Sprintf(`[ -f '%s' ] && source '%s'`, scriptPath, scriptPath) if err := addGuardedBlock(rcFile, sourceLine); err != nil { - fmt.Fprintf(stdout, "Installed zsh completion to %s\n", scriptPath) - fmt.Fprintln(stdout, "") - fmt.Fprintf(stdout, "Could not update %s: %v\n", rcFile, err) - fmt.Fprintln(stdout, "To enable, add the following line to your .zshrc:") - fmt.Fprintln(stdout, "") - fmt.Fprintf(stdout, " source '%s'\n", scriptPath) + _, _ = fmt.Fprintf(stdout, "Installed zsh completion to %s\n", scriptPath) + _, _ = fmt.Fprintln(stdout, "") + _, _ = fmt.Fprintf(stdout, "Could not update %s: %v\n", rcFile, err) + _, _ = fmt.Fprintln(stdout, "To enable, add the following line to your .zshrc:") + _, _ = fmt.Fprintln(stdout, "") + _, _ = fmt.Fprintf(stdout, " source '%s'\n", scriptPath) return nil } - if _, err := fmt.Fprintf(stdout, "Installed zsh completion to %s\n", scriptPath); err != nil { - return err - } - if _, err := fmt.Fprintf(stdout, "Updated %s\n", rcFile); err != nil { - return err - } - _, err = fmt.Fprintf(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) - return err + _, _ = fmt.Fprintf(stdout, "Installed zsh completion to %s\n", scriptPath) + _, _ = fmt.Fprintf(stdout, "Updated %s\n", rcFile) + _, _ = fmt.Fprintf(stdout, "Run 'source %s' or restart your shell to enable completions.\n", rcFile) + return nil } func installFishCompletion(stdout io.Writer) error { - bin, err := mageExePath() - if err != nil { - return err - } + bin := mageExePath() script := fishCompletionScript(bin) // Honor XDG_CONFIG_HOME if set, otherwise use ~/.config @@ -229,15 +214,13 @@ func installFishCompletion(stdout io.Writer) error { return fmt.Errorf("could not write completion script: %w", err) } - _, _ = fmt.Fprintln(stdout, "Fish loads completions automatically. Restart your shell or run 'source "+scriptPath+"' to enable.") - return err + _, _ = fmt.Fprintf(stdout, "Installed fish completion to %s\n", scriptPath) + _, _ = fmt.Fprintf(stdout, "Fish loads completions automatically. Restart your shell or run 'source %s' to enable.\n", scriptPath) + return nil } func installPowerShellCompletion(stdout io.Writer) error { - bin, err := mageExePath() - if err != nil { - return err - } + bin := mageExePath() script := powerShellCompletionScript(bin) dir, err := completionConfigDir() @@ -250,32 +233,31 @@ func installPowerShellCompletion(stdout io.Writer) error { return fmt.Errorf("could not write completion script: %w", err) } - fmt.Fprintf(stdout, "Installed PowerShell completion to %s\n", scriptPath) + _, _ = fmt.Fprintf(stdout, "Installed PowerShell completion to %s\n", scriptPath) profilePath := discoverPowerShellProfile() if profilePath == "" { - fmt.Fprintln(stdout, "") - fmt.Fprintln(stdout, "Could not detect your PowerShell profile path.") - fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile") - fmt.Fprintln(stdout, "(run 'echo $PROFILE' in PowerShell to see the profile path):") - fmt.Fprintln(stdout, "") - fmt.Fprintf(stdout, " . %q\n", scriptPath) + _, _ = fmt.Fprintln(stdout, "") + _, _ = fmt.Fprintln(stdout, "Could not detect your PowerShell profile path.") + _, _ = fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile") + _, _ = fmt.Fprintln(stdout, "(run 'echo $PROFILE' in PowerShell to see the profile path):") + _, _ = fmt.Fprintln(stdout, "") + _, _ = fmt.Fprintf(stdout, " . %q\n", scriptPath) return nil } sourceLine := fmt.Sprintf(". %q", scriptPath) if err := addGuardedBlock(profilePath, sourceLine); err != nil { - // Fall back to manual instructions if we can't write the profile - fmt.Fprintln(stdout, "") - fmt.Fprintf(stdout, "Could not update %s: %v\n", profilePath, err) - fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile:") - fmt.Fprintln(stdout, "") - fmt.Fprintf(stdout, " . %q\n", scriptPath) + _, _ = fmt.Fprintln(stdout, "") + _, _ = fmt.Fprintf(stdout, "Could not update %s: %v\n", profilePath, err) + _, _ = fmt.Fprintln(stdout, "To enable, add the following line to your PowerShell profile:") + _, _ = fmt.Fprintln(stdout, "") + _, _ = fmt.Fprintf(stdout, " . %q\n", scriptPath) return nil } - fmt.Fprintf(stdout, "Updated %s\n", profilePath) - fmt.Fprintln(stdout, "Restart PowerShell to enable completions.") + _, _ = fmt.Fprintf(stdout, "Updated %s\n", profilePath) + _, _ = fmt.Fprintln(stdout, "Restart PowerShell to enable completions.") return nil } @@ -285,7 +267,9 @@ func installPowerShellCompletion(stdout io.Writer) error { func discoverPowerShellProfile() string { // Try querying pwsh (PowerShell Core) first, then powershell (Windows PowerShell) for _, shell := range []string{"pwsh", "powershell"} { - out, err := exec.Command(shell, "-NoProfile", "-NonInteractive", "-Command", "echo $PROFILE").Output() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + out, err := exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-Command", "echo $PROFILE").Output() + cancel() if err == nil { if p := strings.TrimSpace(string(out)); p != "" { return p diff --git a/mage/completion_test.go b/mage/completion_test.go index cac02020..b793906d 100644 --- a/mage/completion_test.go +++ b/mage/completion_test.go @@ -53,7 +53,9 @@ func TestInstallCompletionBashPrefersBashrc(t *testing.T) { t.Setenv("HOME", home) // Create .bashrc so it's preferred over .bash_profile - os.WriteFile(filepath.Join(home, ".bashrc"), []byte("# existing\n"), 0644) + if err := os.WriteFile(filepath.Join(home, ".bashrc"), []byte("# existing\n"), 0o600); err != nil { + t.Fatal(err) + } stdout := &bytes.Buffer{} err := installCompletion(stdout, "bash") @@ -232,7 +234,9 @@ func TestInstallBashFallbackOnRcFailure(t *testing.T) { t.Setenv("HOME", home) // Create .bash_profile as a directory to force addGuardedBlock to fail - os.MkdirAll(filepath.Join(home, ".bash_profile"), 0755) + if err := os.MkdirAll(filepath.Join(home, ".bash_profile"), 0o750); err != nil { + t.Fatal(err) + } stdout := &bytes.Buffer{} err := installCompletion(stdout, "bash") @@ -261,7 +265,9 @@ func TestInstallZshFallbackOnRcFailure(t *testing.T) { t.Setenv("ZDOTDIR", "") // Create .zshrc as a directory to force addGuardedBlock to fail - os.MkdirAll(filepath.Join(home, ".zshrc"), 0755) + if err := os.MkdirAll(filepath.Join(home, ".zshrc"), 0o750); err != nil { + t.Fatal(err) + } stdout := &bytes.Buffer{} err := installCompletion(stdout, "zsh") diff --git a/mage/main.go b/mage/main.go index 5faab34f..bcd17e4e 100644 --- a/mage/main.go +++ b/mage/main.go @@ -376,19 +376,15 @@ func Invoke(inv Invocation) int { magefilesDir := filepath.Join(inv.Dir, MagefilesDirName) // . will be default unless we find a mage folder. mfSt, err := os.Stat(magefilesDir) - if err == nil { - if mfSt.IsDir() { - originalDir := inv.Dir - inv.Dir = magefilesDir // preemptive assignment - // TODO: Remove this fallback and the above Magefiles invocation when the bw compatibility is removed. - files, err := Magefiles(originalDir, inv.GOOS, inv.GOARCH, inv.Debug) - if err == nil { - if len(files) != 0 { - errlog.Println("[WARNING] You have both a magefiles directory and mage files in the " + - "current directory, in future versions the files will be ignored in favor of the directory") - inv.Dir = originalDir - } - } + if err == nil && mfSt.IsDir() { + originalDir := inv.Dir + inv.Dir = magefilesDir // preemptive assignment + // TODO: Remove this fallback and the above Magefiles invocation when the bw compatibility is removed. + existingFiles, mfErr := Magefiles(originalDir, inv.GOOS, inv.GOARCH, inv.Debug) + if mfErr == nil && len(existingFiles) != 0 { + errlog.Println("[WARNING] You have both a magefiles directory and mage files in the " + + "current directory, in future versions the files will be ignored in favor of the directory") + inv.Dir = originalDir } } @@ -421,15 +417,15 @@ func Invoke(inv Invocation) int { if inv.HashFast { debug.Println("user has set MAGEFILE_HASHFAST, so we'll ignore GOCACHE") } else { - s, err := internal.OutputDebug(inv.GoCmd, "env", "GOCACHE") - if err != nil { - errlog.Printf("failed to run %s env GOCACHE: %s", inv.GoCmd, err) + gocache, gocacheErr := internal.OutputDebug(inv.GoCmd, "env", "GOCACHE") + if gocacheErr != nil { + errlog.Printf("failed to run %s env GOCACHE: %s", inv.GoCmd, gocacheErr) return 1 } // if GOCACHE exists, always rebuild, so we catch transitive // dependencies that have changed. - if s != "" { + if gocache != "" { debug.Println("go build cache exists, will ignore any compiled binary") useCache = true } @@ -511,7 +507,7 @@ func Invoke(inv Invocation) int { return 1 } if !inv.Keep { - defer os.RemoveAll(main) + defer func() { _ = os.RemoveAll(main) }() } files = append(files, main) if err := Compile(inv.GOOS, inv.GOARCH, inv.Ldflags, inv.Dir, inv.GoCmd, exePath, files, inv.Debug, inv.Stderr, inv.Stdout); err != nil { @@ -779,7 +775,7 @@ func GenerateMainfile(data mainfileTemplateData, path string) error { if err != nil { return fmt.Errorf("error creating generated mainfile: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() debug.Println("writing new file at", path) if err := mainfileTemplate.Execute(f, data); err != nil { @@ -831,7 +827,7 @@ func hashFile(fn string) (string, error) { if err != nil { return "", fmt.Errorf("can't open input file for hashing: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() h := sha256.New() if _, err := io.Copy(h, f); err != nil { @@ -846,7 +842,7 @@ func generateInit(dir string) error { if err != nil { return fmt.Errorf("could not create mage template: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() if err := initOutput.Execute(f, nil); err != nil { return fmt.Errorf("can't execute magefile template: %w", err) diff --git a/mage/main_test.go b/mage/main_test.go index a6ba28d4..0a0e67c6 100644 --- a/mage/main_test.go +++ b/mage/main_test.go @@ -50,7 +50,7 @@ func runmain(m *testing.M) error { if err != nil { return err } - defer os.RemoveAll(dir) + defer func() { _ = os.RemoveAll(dir) }() if err := os.Setenv(mg.CacheEnv, dir); err != nil { return err } @@ -125,11 +125,11 @@ func TestTransitiveDepCache(t *testing.T) { if err := os.Rename("testdata/transitiveDeps/dep/dog.go", "testdata/transitiveDeps/dep/dog.notgo"); err != nil { t.Fatal(err) } - defer os.Rename("testdata/transitiveDeps/dep/dog.notgo", "testdata/transitiveDeps/dep/dog.go") + defer func() { _ = os.Rename("testdata/transitiveDeps/dep/dog.notgo", "testdata/transitiveDeps/dep/dog.go") }() if err := os.Rename("testdata/transitiveDeps/dep/cat.notgo", "testdata/transitiveDeps/dep/cat.go"); err != nil { t.Fatal(err) } - defer os.Rename("testdata/transitiveDeps/dep/cat.go", "testdata/transitiveDeps/dep/cat.notgo") + defer func() { _ = os.Rename("testdata/transitiveDeps/dep/cat.go", "testdata/transitiveDeps/dep/cat.notgo") }() stderr.Reset() stdout.Reset() code = Invoke(inv) @@ -177,11 +177,11 @@ func TestTransitiveHashFast(t *testing.T) { if err := os.Rename("testdata/transitiveDeps/dep/dog.go", "testdata/transitiveDeps/dep/dog.notgo"); err != nil { t.Fatal(err) } - defer os.Rename("testdata/transitiveDeps/dep/dog.notgo", "testdata/transitiveDeps/dep/dog.go") + defer func() { _ = os.Rename("testdata/transitiveDeps/dep/dog.notgo", "testdata/transitiveDeps/dep/dog.go") }() if err := os.Rename("testdata/transitiveDeps/dep/cat.notgo", "testdata/transitiveDeps/dep/cat.go"); err != nil { t.Fatal(err) } - defer os.Rename("testdata/transitiveDeps/dep/cat.go", "testdata/transitiveDeps/dep/cat.notgo") + defer func() { _ = os.Rename("testdata/transitiveDeps/dep/cat.go", "testdata/transitiveDeps/dep/cat.notgo") }() stderr.Reset() stdout.Reset() inv.HashFast = true @@ -355,7 +355,7 @@ func TestMagefilesFolder(t *testing.T) { t.Fatalf("changing to magefolders tests data: %v", err) } // restore previous state - defer os.Chdir(wd) + defer func() { _ = os.Chdir(wd) }() stderr := &bytes.Buffer{} stdout := &bytes.Buffer{} @@ -387,7 +387,7 @@ func TestMagefilesFolderMixedWithMagefiles(t *testing.T) { t.Fatalf("changing to magefolders tests data: %v", err) } // restore previous state - defer os.Chdir(wd) + defer func() { _ = os.Chdir(wd) }() stderr := &bytes.Buffer{} stdout := &bytes.Buffer{} @@ -425,7 +425,7 @@ func TestUntaggedMagefilesFolder(t *testing.T) { t.Fatalf("changing to magefolders tests data: %v", err) } // restore previous state - defer os.Chdir(wd) + defer func() { _ = os.Chdir(wd) }() stderr := &bytes.Buffer{} stdout := &bytes.Buffer{} @@ -457,7 +457,7 @@ func TestMixedTaggingMagefilesFolder(t *testing.T) { t.Fatalf("changing to magefolders tests data: %v", err) } // restore previous state - defer os.Chdir(wd) + defer func() { _ = os.Chdir(wd) }() stderr := &bytes.Buffer{} stdout := &bytes.Buffer{} @@ -1689,7 +1689,7 @@ func TestCompiledDeterministic(t *testing.T) { if err != nil { t.Fatal(err) } - defer f.Close() + defer func() { _ = f.Close() }() hasher := sha256.New() if _, err := io.Copy(hasher, f); err != nil { @@ -1971,7 +1971,7 @@ func fileData(file string) (exeType, archSize, error) { if err != nil { return -1, -1, err } - defer f.Close() + defer func() { _ = f.Close() }() data := make([]byte, 16) if _, err := io.ReadFull(f, data); err != nil { return -1, -1, err diff --git a/magefiles/targets/targets.go b/magefiles/targets/targets.go index eff99197..f99dc52b 100644 --- a/magefiles/targets/targets.go +++ b/magefiles/targets/targets.go @@ -61,10 +61,10 @@ func Release(tag string) (err error) { return errors.New("TAG environment variable must be in semver v1.x.x format, but was " + tag) } - if err := sh.RunV("git", "tag", "-a", tag, "-m", tag); err != nil { + if err = sh.RunV("git", "tag", "-a", tag, "-m", tag); err != nil { //nolint:gocritic // using = to assign named return for deferred cleanup return err } - if err := sh.RunV("git", "push", "origin", tag); err != nil { + if err = sh.RunV("git", "push", "origin", tag); err != nil { //nolint:gocritic // using = to assign named return for deferred cleanup return err } defer func() { diff --git a/sh/helpers.go b/sh/helpers.go index 11be360c..28c96b76 100644 --- a/sh/helpers.go +++ b/sh/helpers.go @@ -22,7 +22,7 @@ func Copy(dst, src string) error { if err != nil { return fmt.Errorf(`can't copy %s: %w`, src, err) } - defer from.Close() + defer func() { _ = from.Close() }() finfo, err := from.Stat() if err != nil { return fmt.Errorf(`can't stat %s: %w`, src, err) diff --git a/target/newer_test.go b/target/newer_test.go index 7f043b3e..4c243f5d 100644 --- a/target/newer_test.go +++ b/target/newer_test.go @@ -22,11 +22,11 @@ func TestNewestModTime(t *testing.T) { if err != nil { t.Fatalf("error opening file to append: %s", err.Error()) } - if _, err := outfh.WriteString("\nbye!\n"); err != nil { - t.Fatalf("error appending to file: %s", err.Error()) + if _, writeErr := outfh.WriteString("\nbye!\n"); writeErr != nil { + t.Fatalf("error appending to file: %s", writeErr.Error()) } - if err := outfh.Close(); err != nil { - t.Fatalf("error closing file: %s", err.Error()) + if closeErr := outfh.Close(); closeErr != nil { + t.Fatalf("error closing file: %s", closeErr.Error()) } afi, err := os.Stat(filepath.Join(dir, "a")) From d3371bdee7952451515833df531ec63cec793ec0 Mon Sep 17 00:00:00 2001 From: Nate Finch Date: Wed, 22 Apr 2026 09:14:07 -0400 Subject: [PATCH 11/15] revert a linter change that just causes two linters to fight --- .golangci.toml | 4 +++- magefiles/targets/targets.go | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.golangci.toml b/.golangci.toml index 93b72440..051ef9bb 100644 --- a/.golangci.toml +++ b/.golangci.toml @@ -21,6 +21,7 @@ disable = [ 'asciicheck', 'canonicalheader', 'containedctx', + 'copyloopvar', 'cyclop', 'depguard', 'dogsled', @@ -102,7 +103,8 @@ excludes = [ [linters.settings.govet] disable = [ - 'fieldalignment' + 'fieldalignment', + 'shadow', ] enable-all = true diff --git a/magefiles/targets/targets.go b/magefiles/targets/targets.go index 45b87fee..652f9d36 100644 --- a/magefiles/targets/targets.go +++ b/magefiles/targets/targets.go @@ -65,16 +65,16 @@ func Release(tag string, dryRun *bool) (err error) { return errors.New("TAG environment variable must be in semver v1.x.x format, but was " + tag) } - if dryRun != nil && *dryRun { - if err = sh.RunV("git", "tag", "-a", tag, "-m", tag); err != nil { + err := sh.RunV("git", "tag", "-a", tag, "-m", tag) + if err != nil { return err } defer func() { _ = sh.RunV("git", "tag", "--delete", tag) }() return sh.RunV("goreleaser", "release", "--skip=publish", "--skip=validate", "--clean") } - if err = sh.RunV("git", "tag", "-a", tag, "-m", tag); err != nil { + if err := sh.RunV("git", "tag", "-a", tag, "-m", tag); err != nil { return err } if err = sh.RunV("git", "push", "origin", tag); err != nil { //nolint:gocritic // using = to assign named return for deferred cleanup From b41486829c31cd2ff2e40811c4c205eb0ff7d101 Mon Sep 17 00:00:00 2001 From: Nate Finch Date: Wed, 22 Apr 2026 09:17:41 -0400 Subject: [PATCH 12/15] turn off G703 --- .golangci.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.golangci.toml b/.golangci.toml index 051ef9bb..0c2e3763 100644 --- a/.golangci.toml +++ b/.golangci.toml @@ -98,6 +98,7 @@ excludes = [ 'G304', # Audit file path provided as taint input (path traversal via user-supplied file paths) 'G307', # Deferring a method which returns an error (e.g., defer f.Close() without checking the error) 'G702', # net/http SetDeadline not called (HTTP server timeout not configured) + 'G703', # Path traversal via taint analysis 'G706', # Audit use of io.ReadAll (potential denial of service from unbounded reads) ] From 71bcb1d76f05ee95aba23706938031982ff02b70 Mon Sep 17 00:00:00 2001 From: Nate Finch Date: Wed, 22 Apr 2026 11:15:19 -0400 Subject: [PATCH 13/15] Apply suggestion from @natefinch --- mage/args_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/mage/args_test.go b/mage/args_test.go index bda4707e..b8b421c7 100644 --- a/mage/args_test.go +++ b/mage/args_test.go @@ -207,7 +207,6 @@ func TestOptionalArgsOmitted(t *testing.T) { Dir: "./testdata/optargs", Stderr: stderr, Stdout: stdout, - Keep: true, Args: []string{"greet", "World"}, } code := Invoke(inv) From 0b5c736def8541beea4dbc2f8a7bb61d88e6fa37 Mon Sep 17 00:00:00 2001 From: Nate Finch Date: Wed, 22 Apr 2026 12:16:46 -0400 Subject: [PATCH 14/15] support pwsh as an alias for powershell in mage install --- mage/completion.go | 6 +++--- mage/main.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mage/completion.go b/mage/completion.go index e2a8d4ae..4baea9ef 100644 --- a/mage/completion.go +++ b/mage/completion.go @@ -27,10 +27,10 @@ func installCompletion(stdout io.Writer, shell string) error { return installZshCompletion(stdout) case "fish": return installFishCompletion(stdout) - case "powershell": + case "powershell", "pwsh": return installPowerShellCompletion(stdout) default: - return fmt.Errorf("unsupported shell %q; supported shells: bash, zsh, fish, powershell", shell) + return fmt.Errorf("unsupported shell %q; supported shells: bash, zsh, fish, powershell (or pwsh)", shell) } } @@ -374,7 +374,7 @@ complete -c mage -l goos -r -d 'set GOOS for binary produced with -compile' complete -c mage -l goarch -r -d 'set GOARCH for binary produced with -compile' complete -c mage -l ldflags -r -d 'set ldflags for binary produced with -compile' complete -c mage -l autocomplete -d 'print target names for shell completion' -complete -c mage -l install -r -a 'bash zsh fish powershell' -d 'install shell completion' +complete -c mage -l install -r -a 'bash zsh fish powershell pwsh' -d 'install shell completion' complete -c mage -l multiline -d 'retain line returns in help text' ` } diff --git a/mage/main.go b/mage/main.go index bcd17e4e..49f3d61e 100644 --- a/mage/main.go +++ b/mage/main.go @@ -118,7 +118,7 @@ type Invocation struct { HashFast bool // don't rely on GOCACHE, just hash the magefiles Multiline bool // whether to retain line returns in help text for the generated main file Autocomplete bool // parse magefiles and print target names for shell completion - InstallShell string // shell to install tab completion for (bash, zsh, fish, powershell) + InstallShell string // shell to install tab completion for (bash, zsh, fish, powershell/pwsh) } // MagefilesDirName is the name of the default folder to look for if no directory was specified, @@ -244,7 +244,7 @@ func Parse(stderr, stdout io.Writer, args []string) (inv Invocation, cmd Command var compileOutPath string fs.StringVar(&compileOutPath, "compile", "", "output a static binary to the given path") var installShell string - fs.StringVar(&installShell, "install", "", "install shell tab completion (bash, zsh, fish, powershell)") + fs.StringVar(&installShell, "install", "", "install shell tab completion (bash, zsh, fish, powershell/pwsh)") fs.Usage = func() { _, _ = fmt.Fprint(stdout, ` @@ -261,7 +261,7 @@ Commands: -h show this help -init create a starting template if no mage files exist -install - install shell tab completion (bash, zsh, fish, powershell) + install shell tab completion (bash, zsh, fish, powershell/pwsh) -l list mage targets in this directory -version show version info for the mage binary From 84434c29c072242b038ba2281513f4639f81ef4d Mon Sep 17 00:00:00 2001 From: Nate Finch Date: Wed, 22 Apr 2026 12:37:23 -0400 Subject: [PATCH 15/15] add page for tab completion --- .gitignore | 2 + site/content/tabcompletion/_index.en.md | 99 +++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 site/content/tabcompletion/_index.en.md diff --git a/.gitignore b/.gitignore index 07c616c1..02626a08 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,7 @@ Session.vim # Hugo build lock .hugo_build.lock +/site/public/ + # Release output /dist \ No newline at end of file diff --git a/site/content/tabcompletion/_index.en.md b/site/content/tabcompletion/_index.en.md new file mode 100644 index 00000000..e32a1152 --- /dev/null +++ b/site/content/tabcompletion/_index.en.md @@ -0,0 +1,99 @@ ++++ +title = "Tab Completion" +weight = 48 ++++ + +Mage supports tab completion for all of its built-in flags as well as your +project's targets. Pressing **Tab** after typing `mage ` will suggest available +targets in the current directory, so you never have to remember exact names. + +## Quick Install + +The easiest way to get tab completion is the built-in installer. Run +`mage -install` with the name of your shell: + +``` +mage -install bash +mage -install zsh +mage -install fish +mage -install powershell # also accepts "pwsh" +``` + +The installer will: + +1. Write a completion script to `~/.config/mage/` (or the appropriate + platform-specific config directory). +2. Add a source line to your shell's startup file (`.bashrc`, `.zshrc`, + PowerShell `$PROFILE`, etc.) so completions load automatically in every new + session. +3. Print a message telling you to restart your shell (or source the config + file) to activate completions immediately. + +If mage can't update your shell config file for any reason, it will print the +line you need to add manually — so the command is always safe to run. + +Running `mage -install` again for the same shell is safe. It replaces the +previous completion block rather than duplicating it. + +### Shell-Specific Notes + +**Bash** — the installer sources the completion script from your `.bashrc` +(preferred) or `.bash_profile`. It uses the standard `complete` built-in, so no +extra packages are required. + +**Zsh** — the installer sources the script from your `.zshrc` (honoring +`$ZDOTDIR` if set). If `compdef` is not yet available when the script loads, it +automatically calls `compinit` first. + +**Fish** — completions are placed in +`$XDG_CONFIG_HOME/fish/completions/mage.fish` (defaulting to +`~/.config/fish/completions/`). Fish loads files from this directory +automatically, so no startup-file modification is needed. + +**PowerShell** — the installer writes a `.ps1` script and sources it from your +`$PROFILE`. Both PowerShell Core (`pwsh`) and Windows PowerShell are supported. +If the profile file or its parent directory doesn't exist yet, mage creates +them. + +## The -autocomplete Flag + +Under the hood, all of the completion scripts call: + +``` +mage -autocomplete +``` + +This prints a plain list of targets (one per line) for the current directory and +exits. You can run it yourself to see what completions would be offered: + +``` +$ mage -autocomplete +build +clean +deploy +test +``` + +### Custom / Advanced Usage + +If you use a shell that isn't directly supported, or you want to integrate mage +completions into a custom tool, you can wire up `mage -autocomplete` yourself. +The contract is simple: + +* It prints target names separated by newlines to stdout. +* It returns exit code 0 on success. +* It reads magefiles from the **current working directory**, so make sure the + completion function `cd`s to the project directory (or runs mage there) before + invoking it. + +For example, a minimal POSIX-shell completion function might look like: + +```sh +_mage_complete() { + COMPREPLY=( $(mage -autocomplete 2>/dev/null) ) +} +complete -F _mage_complete mage +``` + +Adapt this pattern for any environment that can execute a command and consume its +line-delimited output.