diff --git a/mage/main.go b/mage/main.go index 49f3d61..b51ea95 100644 --- a/mage/main.go +++ b/mage/main.go @@ -493,6 +493,20 @@ func Invoke(inv Invocation) int { return 0 } + if inv.Help { + if len(inv.Args) < 1 { + _, _ = fmt.Fprintln(inv.Stderr, "no target specified") + return 2 + } + output, code := mageHelpOutput(data, inv.Args[0]) + if code != 0 { + _, _ = fmt.Fprint(inv.Stderr, output) + } else { + _, _ = fmt.Fprint(inv.Stdout, output) + } + return code + } + // 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 { @@ -586,6 +600,71 @@ func mageListOutput(data mainfileTemplateData, info *parse.PkgInfo) string { return list.String() } +// mageHelpOutput generates help text for a single target without compiling. +// It returns the formatted output string and an exit code (0 for success, 2 for errors). +// The output matches what a compiled binary produces for -h. +func mageHelpOutput(data mainfileTemplateData, target string) (output string, code int) { + target = strings.ToLower(target) + + // Collect all functions from main package and imports. + var allFuncs []*parse.Function + allFuncs = append(allFuncs, data.Funcs...) + for _, imp := range data.Imports { + allFuncs = append(allFuncs, imp.Info.Funcs...) + } + + // Find the matching function. + var fn *parse.Function + for _, f := range allFuncs { + if strings.ToLower(f.TargetName()) == target { + fn = f + break + } + } + if fn == nil { + return fmt.Sprintf("Unknown target: %q\n", target), 2 + } + + var buf strings.Builder + + if fn.Comment != "" { + _, _ = fmt.Fprintln(&buf, fn.Comment) + _, _ = fmt.Fprintln(&buf) + } + + // Build usage line matching template format. + _, _ = fmt.Fprintf(&buf, "Usage:\n\n\t%s %s", data.BinaryName, strings.ToLower(fn.TargetName())) + for _, a := range fn.RequiredArgs() { + _, _ = fmt.Fprintf(&buf, " <%s>", a.Name) + } + if fn.MultipleOptionalArgs() { + _, _ = fmt.Fprint(&buf, " []") + } else { + for _, a := range fn.OptionalArgs() { + _, _ = fmt.Fprintf(&buf, " [-%s=<%s>]", a.Name, a.Type) + } + } + _, _ = fmt.Fprint(&buf, "\n\n") + + if fn.ShowFlagDocs() { + _, _ = fmt.Fprint(&buf, fn.FlagDocsString()) + } + + // Collect and sort aliases for deterministic output. + var aliases []string + for alias, af := range data.Aliases { + if af.Name == fn.Name && af.Receiver == fn.Receiver { + aliases = append(aliases, alias) + } + } + if len(aliases) > 0 { + sort.Strings(aliases) + _, _ = fmt.Fprintf(&buf, "Aliases: %s\n\n", strings.Join(aliases, ", ")) + } + + return buf.String(), 0 +} + // printAutocompleteTargets outputs target names one per line for shell completion. func printAutocompleteTargets(stdout io.Writer, info *parse.PkgInfo) int { names := map[string]struct{}{} diff --git a/mage/main_test.go b/mage/main_test.go index 0a0e67c..25b9b56 100644 --- a/mage/main_test.go +++ b/mage/main_test.go @@ -691,6 +691,176 @@ func TestMultilineTag(t *testing.T) { } } +// TestHelpNoCompile verifies that -h works without compilation by using a +// fixture whose function bodies reference an undefined package. The AST parser +// can still extract targets, but go build would fail. +func TestHelpNoCompile(t *testing.T) { + for _, tc := range []struct { + name string + target string + output string + code int + stderr bool + }{ + { + name: "known target", + target: "build", + output: "Build compiles the project.\n\nUsage:\n\n\tmage build\n\n", + code: 0, + }, + { + name: "multiline comment", + target: "deploy", + output: "Deploy pushes to production. This is the extended description.\n\nUsage:\n\n\tmage deploy\n\n", + code: 0, + }, + { + name: "namespace target", + target: "ns:run", + output: "Run runs within the namespace.\n\nUsage:\n\n\tmage ns:run\n\n", + code: 0, + }, + { + name: "unknown target", + target: "doesnotexist", + output: "Unknown target: \"doesnotexist\"\n", + code: 2, + stderr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + inv := Invocation{ + Dir: "testdata/help_no_compile", + Stdout: stdout, + Stderr: stderr, + Help: true, + Args: []string{tc.target}, + } + code := Invoke(inv) + if code != tc.code { + t.Fatalf("expected exit code %d, got %d\nstdout: %s\nstderr: %s", tc.code, code, stdout, stderr) + } + var got string + if tc.stderr { + got = stderr.String() + } else { + got = stdout.String() + } + if got != tc.output { + t.Errorf("expected output %q, got %q", tc.output, got) + } + }) + } +} + +// TestHelpNoTarget verifies that -h without a target name prints an error. +func TestHelpNoTarget(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + inv := Invocation{ + Dir: "testdata/multiline", + Stdout: stdout, + Stderr: stderr, + Help: true, + Args: []string{}, + } + code := Invoke(inv) + if code != 2 { + t.Fatalf("expected exit code 2, got %d\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + got := stderr.String() + want := "no target specified\n" + if got != want { + t.Errorf("expected %q, got %q", want, got) + } +} + +// TestHelpAliases verifies that -h shows sorted aliases. +func TestHelpAliases(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + inv := Invocation{ + Dir: "testdata/alias", + Stdout: stdout, + Stderr: stderr, + Help: true, + Args: []string{"status"}, + } + code := Invoke(inv) + if code != 0 { + t.Fatalf("expected exit code 0, got %d\nstdout: %s\nstderr: %s", code, stdout, stderr) + } + got := stdout.String() + want := "Prints status.\n\nUsage:\n\n\tmage status\n\nAliases: st, stat\n\n" + if got != want { + t.Errorf("expected %q, got %q", want, got) + } +} + +// TestHelpMatchesCompiledBinary verifies that mage -h and a compiled binary's +// -h produce identical output for the same target. +func TestHelpMatchesCompiledBinary(t *testing.T) { + dir := "./testdata/compiled" + compileDir := t.TempDir() + name := filepath.Join(compileDir, "mage_help_test") + if runtime.GOOS == "windows" { + name += ".exe" + } + + // Compile the binary. + stderr := &bytes.Buffer{} + inv := Invocation{ + Dir: dir, + Stdout: io.Discard, + Stderr: stderr, + CompileOut: name, + } + code := Invoke(inv) + if code != 0 { + t.Fatalf("compile failed with code %d: %s", code, stderr) + } + + // Get help from mage directly (no compilation path). + stdout := &bytes.Buffer{} + stderr.Reset() + inv = Invocation{ + Dir: dir, + Stdout: stdout, + Stderr: stderr, + Help: true, + Args: []string{"deploy"}, + } + code = Invoke(inv) + if code != 0 { + t.Fatalf("mage -h deploy failed with code %d: %s", code, stderr) + } + mageOutput := stdout.String() + + // Get help from the compiled binary. + stdout.Reset() + stderr.Reset() + cmd := exec.CommandContext(context.Background(), name, "-h", "deploy") + cmd.Env = os.Environ() + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + t.Fatalf("compiled binary -h deploy failed: %v\nstderr: %s", err, stderr) + } + compiledOutput := stdout.String() + + // The binary name differs (compiled binary uses its own filename), so + // normalize both outputs by replacing the binary name with a placeholder. + binaryBase := filepath.Base(name) + normalizedMage := strings.ReplaceAll(mageOutput, "\tmage ", "\tBINARY ") + normalizedCompiled := strings.ReplaceAll(compiledOutput, "\t"+binaryBase+" ", "\tBINARY ") + + if normalizedMage != normalizedCompiled { + t.Errorf("help output mismatch (after normalizing binary name):\nmage -h: %q\ncompiled -h: %q", mageOutput, compiledOutput) + } +} + func TestList(t *testing.T) { stdout := &bytes.Buffer{} inv := Invocation{ diff --git a/mage/template.go b/mage/template.go index 32c5b4a..4778fca 100644 --- a/mage/template.go +++ b/mage/template.go @@ -257,6 +257,7 @@ Options: {{if and (eq $name $func.Name) (eq $recv $func.Receiver)}}aliases = append(aliases, "{{$alias}}"){{end -}} {{- end}} if len(aliases) > 0 { + _sort.Strings(aliases) _fmt.Printf("Aliases: %s\n\n", _strings.Join(aliases, ", ")) } return @@ -278,6 +279,7 @@ Options: {{if and (eq $name $func.Name) (eq $recv $func.Receiver)}}aliases = append(aliases, "{{$alias}}"){{end -}} {{- end}} if len(aliases) > 0 { + _sort.Strings(aliases) _fmt.Printf("Aliases: %s\n\n", _strings.Join(aliases, ", ")) } return diff --git a/mage/testdata/help_no_compile/magefile.go b/mage/testdata/help_no_compile/magefile.go new file mode 100644 index 0000000..efe600a --- /dev/null +++ b/mage/testdata/help_no_compile/magefile.go @@ -0,0 +1,27 @@ +//go:build mage + +// Package doc for help_no_compile. +package main + +import "github.com/magefile/mage/mg" + +// Build compiles the project. +func Build() { + // This references a package that doesn't exist, so go build will fail, + // but the AST parser can still extract target metadata. + doesnotexist.Fail() +} + +// Deploy pushes to production. +// This is the extended description. +func Deploy() { + doesnotexist.Fail() +} + +// NS is a namespace for grouped targets. +type NS mg.Namespace + +// Run runs within the namespace. +func (NS) Run() { + doesnotexist.Fail() +}