Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions mage/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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, " [<flags>]")
} 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{}{}
Expand Down
170 changes: 170 additions & 0 deletions mage/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
2 changes: 2 additions & 0 deletions mage/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions mage/testdata/help_no_compile/magefile.go
Original file line number Diff line number Diff line change
@@ -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()
}