diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..286ab973 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,58 @@ +# Copilot Instructions for Mage + +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. + +## Build, Test, and Lint + +```bash +# Build +go build ./... + +# Test (full suite, including race detector as required by CI) +go test -race ./... + +# Test a single package +go test -race ./parse/ + +# 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) — 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 + +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`). +- **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/.gitignore b/.gitignore index 8d101a18..02626a08 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,8 @@ Session.vim # Hugo build lock .hugo_build.lock + +/site/public/ + +# Release output +/dist \ No newline at end of file diff --git a/.golangci.toml b/.golangci.toml index 6c81374c..0c2e3763 100644 --- a/.golangci.toml +++ b/.golangci.toml @@ -21,14 +21,13 @@ disable = [ 'asciicheck', 'canonicalheader', 'containedctx', - 'copyloopvar', # Not applicable in go versions under 1.22 + 'copyloopvar', 'cyclop', 'depguard', 'dogsled', 'dupl', 'dupword', 'err113', - 'errcheck', 'exhaustive', 'exhaustruct', 'forbidigo', @@ -61,8 +60,6 @@ disable = [ 'thelper', 'unparam', 'varnamelen', - 'wastedassign', - 'whitespace', 'wrapcheck', 'wsl', 'wsl_v5' @@ -81,7 +78,6 @@ pattern = 'time.After\.*(# use of time After can create memory allocation issues [linters.settings.gocritic] disabled-checks = [ 'importShadow', -# 'unnamedResult' ] enabled-tags = [ 'diagnostic', @@ -98,21 +94,18 @@ 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) + 'G703', # Path traversal via taint analysis + '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 = [ + 'fieldalignment', 'shadow', - 'fieldalignment' ] enable-all = true @@ -120,16 +113,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 +130,6 @@ name = 'comment-spacings' arguments = [ 'nolint' ] -disabled = false [[linters.settings.revive.rules]] name = 'argument-limit' @@ -168,34 +157,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 +177,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/colors.go b/mage/colors.go new file mode 100644 index 00000000..1561a769 --- /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) + } + + return str +} 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..4baea9ef --- /dev/null +++ b/mage/completion.go @@ -0,0 +1,422 @@ +package mage + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +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", "pwsh": + return installPowerShellCompletion(stdout) + default: + return fmt.Errorf("unsupported shell %q; supported shells: bash, zsh, fish, powershell (or pwsh)", shell) + } +} + +// 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 { + exe, err := os.Executable() + if err != nil { + return "mage" + } + return exe +} + +// 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) + // #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 -- 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. +// 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) + beforeStart, afterStart, foundStart := strings.Cut(existingStr, mageCompletionMarker) + if foundStart { + _, afterEnd, foundEnd := strings.Cut(afterStart, mageCompletionMarkerEnd) + if foundEnd { + newContent := beforeStart + block + afterEnd + // #nosec -- path is constructed internally from trusted locations. + return os.WriteFile(path, []byte(newContent), 0o600) + } + } + + // Append to file, creating parent directories if needed + if mkdirErr := os.MkdirAll(filepath.Dir(path), 0o750); mkdirErr != nil { + return mkdirErr + } + f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) + if openErr != nil { + return openErr + } + 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 _, writeErr := f.WriteString("\n"); writeErr != nil { + return writeErr + } + } + _, writeErr := f.WriteString(block + "\n") + return writeErr +} + +func installBashCompletion(stdout io.Writer) error { + bin := mageExePath() + script := bashCompletionScript(bin) + + dir, err := completionConfigDir() + if err != nil { + return err + } + + scriptPath := filepath.Join(dir, "completion.bash") + if writeErr := writeCompletionFile(scriptPath, script); writeErr != nil { + return fmt.Errorf("could not write completion script: %w", writeErr) + } + + home, err := os.UserHomeDir() + if err != nil { + 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") + 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 { + _, _ = 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) + _, _ = 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 := mageExePath() + 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) + } + + // 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(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) + return nil + } + + _, _ = 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 := mageExePath() + script := fishCompletionScript(bin) + + // 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") + } + + 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.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 := mageExePath() + 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) + + 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 { + _, _ = 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"} { + 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 + } + } + } + + // 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() { + 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 +} +if (( $+functions[compdef] )); then + compdef _mage mage +else + autoload -Uz compinit && compinit + compdef _mage mage +fi +` +} + +// 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 pwsh' -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..b793906d --- /dev/null +++ b/mage/completion_test.go @@ -0,0 +1,425 @@ +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") + } + + // 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("rc file not found:", err) + } + rcStr := string(rc) + if !strings.Contains(rcStr, mageCompletionMarker) { + t.Error("rc file missing completion marker") + } + if !strings.Contains(rcStr, "source") { + 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 + if err := os.WriteFile(filepath.Join(home, ".bashrc"), []byte("# existing\n"), 0o600); err != nil { + t.Fatal(err) + } + + 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") + 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 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) + 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") + } + + // Should have either updated a profile or printed instructions + output := stdout.String() + if !strings.Contains(output, "Installed PowerShell completion") { + t.Error("output should confirm installation") + } +} + +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 TestInstallBashFallbackOnRcFailure(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // Create .bash_profile as a directory to force addGuardedBlock to fail + if err := os.MkdirAll(filepath.Join(home, ".bash_profile"), 0o750); err != nil { + t.Fatal(err) + } + + 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 + if err := os.MkdirAll(filepath.Join(home, ".zshrc"), 0o750); err != nil { + t.Fatal(err) + } + + 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") + + 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 + 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 { + 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 49df3d39..49f3d61e 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" @@ -82,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 @@ -93,27 +96,29 @@ 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 + 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, @@ -160,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: @@ -219,6 +230,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 @@ -231,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/pwsh)") fs.Usage = func() { _, _ = fmt.Fprint(stdout, ` @@ -239,11 +253,15 @@ 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 -h show this help -init create a starting template if no mage files exist + -install + install shell tab completion (bash, zsh, fish, powershell/pwsh) -l list mage targets in this directory -version show version info for the mage binary @@ -288,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 @@ -295,8 +317,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, -install, -autocomplete and -version cannot be used simultaneously") } default: // no command flags set @@ -304,6 +325,9 @@ Options: if inv.Help { numCommands++ } + if inv.Autocomplete { + numCommands++ + } if inv.Debug { debug.SetOutput(stderr) @@ -313,7 +337,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, -install, -autocomplete and -version cannot be used simultaneously") } if cmd != CompileStatic && (inv.GOARCH != "" || inv.GOOS != "") { @@ -334,6 +358,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) @@ -349,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 } } @@ -394,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 } @@ -440,23 +463,51 @@ 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 } 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 { @@ -479,13 +530,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,26 +768,14 @@ 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) if err != nil { return fmt.Errorf("error creating generated mainfile: %w", err) } - 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 - } + defer func() { _ = f.Close() }() debug.Println("writing new file at", path) if err := mainfileTemplate.Execute(f, data); err != nil { @@ -703,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 { @@ -718,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 6830cc22..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{} @@ -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, @@ -1605,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 { @@ -1887,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/mage/template.go b/mage/template.go index eb755be3..32c5b4ac 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 -}} @@ -410,16 +285,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 } @@ -429,7 +304,7 @@ Options: {{- else}} if err := list(); err != nil { logger.Println("Error:", err) - os.Exit(1) + _os.Exit(1) } return {{- end}} @@ -454,7 +329,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}}") @@ -471,7 +346,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}}") @@ -482,7 +357,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/magefiles/targets/targets.go b/magefiles/targets/targets.go index 1a1627ae..652f9d36 100644 --- a/magefiles/targets/targets.go +++ b/magefiles/targets/targets.go @@ -66,7 +66,8 @@ func Release(tag string, dryRun *bool) (err error) { } 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) }() @@ -76,7 +77,7 @@ func Release(tag string, dryRun *bool) (err error) { if err := sh.RunV("git", "tag", "-a", tag, "-m", tag); err != nil { 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/parse/parse.go b/parse/parse.go index 2e9de794..b618511d 100644 --- a/parse/parse.go +++ b/parse/parse.go @@ -241,34 +241,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": _, _ = 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: @@ -282,7 +282,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 @@ -312,7 +312,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]) @@ -333,37 +333,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: @@ -373,14 +373,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 " @@ -1153,3 +1153,11 @@ var argTypes = map[string]string{ "&{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 +} 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/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. 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"))