Skip to content

Commit e9405c3

Browse files
committed
make -h no long require compiling
1 parent 791a5b4 commit e9405c3

4 files changed

Lines changed: 278 additions & 0 deletions

File tree

mage/main.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,20 @@ func Invoke(inv Invocation) int {
493493
return 0
494494
}
495495

496+
if inv.Help {
497+
if len(inv.Args) < 1 {
498+
_, _ = fmt.Fprintln(inv.Stderr, "no target specified")
499+
return 2
500+
}
501+
output, code := mageHelpOutput(data, inv.Args[0])
502+
if code != 0 {
503+
_, _ = fmt.Fprint(inv.Stderr, output)
504+
} else {
505+
_, _ = fmt.Fprint(inv.Stdout, output)
506+
}
507+
return code
508+
}
509+
496510
// ensure we use the same color output code in the generated mainfile as we do in mage's own output.
497511
idx := strings.Index(colorsFile, "var printName =")
498512
if idx == -1 {
@@ -586,6 +600,71 @@ func mageListOutput(data mainfileTemplateData, info *parse.PkgInfo) string {
586600
return list.String()
587601
}
588602

603+
// mageHelpOutput generates help text for a single target without compiling.
604+
// It returns the formatted output string and an exit code (0 for success, 2 for errors).
605+
// The output matches what a compiled binary produces for -h.
606+
func mageHelpOutput(data mainfileTemplateData, target string) (output string, code int) {
607+
target = strings.ToLower(target)
608+
609+
// Collect all functions from main package and imports.
610+
var allFuncs []*parse.Function
611+
allFuncs = append(allFuncs, data.Funcs...)
612+
for _, imp := range data.Imports {
613+
allFuncs = append(allFuncs, imp.Info.Funcs...)
614+
}
615+
616+
// Find the matching function.
617+
var fn *parse.Function
618+
for _, f := range allFuncs {
619+
if strings.ToLower(f.TargetName()) == target {
620+
fn = f
621+
break
622+
}
623+
}
624+
if fn == nil {
625+
return fmt.Sprintf("Unknown target: %q\n", target), 2
626+
}
627+
628+
var buf strings.Builder
629+
630+
if fn.Comment != "" {
631+
_, _ = fmt.Fprintln(&buf, fn.Comment)
632+
_, _ = fmt.Fprintln(&buf)
633+
}
634+
635+
// Build usage line matching template format.
636+
_, _ = fmt.Fprintf(&buf, "Usage:\n\n\t%s %s", data.BinaryName, strings.ToLower(fn.TargetName()))
637+
for _, a := range fn.RequiredArgs() {
638+
_, _ = fmt.Fprintf(&buf, " <%s>", a.Name)
639+
}
640+
if fn.MultipleOptionalArgs() {
641+
_, _ = fmt.Fprint(&buf, " [<flags>]")
642+
} else {
643+
for _, a := range fn.OptionalArgs() {
644+
_, _ = fmt.Fprintf(&buf, " [-%s=<%s>]", a.Name, a.Type)
645+
}
646+
}
647+
_, _ = fmt.Fprint(&buf, "\n\n")
648+
649+
if fn.ShowFlagDocs() {
650+
_, _ = fmt.Fprint(&buf, fn.FlagDocsString())
651+
}
652+
653+
// Collect and sort aliases for deterministic output.
654+
var aliases []string
655+
for alias, af := range data.Aliases {
656+
if af.Name == fn.Name && af.Receiver == fn.Receiver {
657+
aliases = append(aliases, alias)
658+
}
659+
}
660+
if len(aliases) > 0 {
661+
sort.Strings(aliases)
662+
_, _ = fmt.Fprintf(&buf, "Aliases: %s\n\n", strings.Join(aliases, ", "))
663+
}
664+
665+
return buf.String(), 0
666+
}
667+
589668
// printAutocompleteTargets outputs target names one per line for shell completion.
590669
func printAutocompleteTargets(stdout io.Writer, info *parse.PkgInfo) int {
591670
names := map[string]struct{}{}

mage/main_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,176 @@ func TestMultilineTag(t *testing.T) {
691691
}
692692
}
693693

694+
// TestHelpNoCompile verifies that -h works without compilation by using a
695+
// fixture whose function bodies reference an undefined package. The AST parser
696+
// can still extract targets, but go build would fail.
697+
func TestHelpNoCompile(t *testing.T) {
698+
for _, tc := range []struct {
699+
name string
700+
target string
701+
output string
702+
code int
703+
stderr bool
704+
}{
705+
{
706+
name: "known target",
707+
target: "build",
708+
output: "Build compiles the project.\n\nUsage:\n\n\tmage build\n\n",
709+
code: 0,
710+
},
711+
{
712+
name: "multiline comment",
713+
target: "deploy",
714+
output: "Deploy pushes to production. This is the extended description.\n\nUsage:\n\n\tmage deploy\n\n",
715+
code: 0,
716+
},
717+
{
718+
name: "namespace target",
719+
target: "ns:run",
720+
output: "Run runs within the namespace.\n\nUsage:\n\n\tmage ns:run\n\n",
721+
code: 0,
722+
},
723+
{
724+
name: "unknown target",
725+
target: "doesnotexist",
726+
output: "Unknown target: \"doesnotexist\"\n",
727+
code: 2,
728+
stderr: true,
729+
},
730+
} {
731+
t.Run(tc.name, func(t *testing.T) {
732+
stdout := &bytes.Buffer{}
733+
stderr := &bytes.Buffer{}
734+
inv := Invocation{
735+
Dir: "testdata/help_no_compile",
736+
Stdout: stdout,
737+
Stderr: stderr,
738+
Help: true,
739+
Args: []string{tc.target},
740+
}
741+
code := Invoke(inv)
742+
if code != tc.code {
743+
t.Fatalf("expected exit code %d, got %d\nstdout: %s\nstderr: %s", tc.code, code, stdout, stderr)
744+
}
745+
var got string
746+
if tc.stderr {
747+
got = stderr.String()
748+
} else {
749+
got = stdout.String()
750+
}
751+
if got != tc.output {
752+
t.Errorf("expected output %q, got %q", tc.output, got)
753+
}
754+
})
755+
}
756+
}
757+
758+
// TestHelpNoTarget verifies that -h without a target name prints an error.
759+
func TestHelpNoTarget(t *testing.T) {
760+
stdout := &bytes.Buffer{}
761+
stderr := &bytes.Buffer{}
762+
inv := Invocation{
763+
Dir: "testdata/multiline",
764+
Stdout: stdout,
765+
Stderr: stderr,
766+
Help: true,
767+
Args: []string{},
768+
}
769+
code := Invoke(inv)
770+
if code != 2 {
771+
t.Fatalf("expected exit code 2, got %d\nstdout: %s\nstderr: %s", code, stdout, stderr)
772+
}
773+
got := stderr.String()
774+
want := "no target specified\n"
775+
if got != want {
776+
t.Errorf("expected %q, got %q", want, got)
777+
}
778+
}
779+
780+
// TestHelpAliases verifies that -h shows sorted aliases.
781+
func TestHelpAliases(t *testing.T) {
782+
stdout := &bytes.Buffer{}
783+
stderr := &bytes.Buffer{}
784+
inv := Invocation{
785+
Dir: "testdata/alias",
786+
Stdout: stdout,
787+
Stderr: stderr,
788+
Help: true,
789+
Args: []string{"status"},
790+
}
791+
code := Invoke(inv)
792+
if code != 0 {
793+
t.Fatalf("expected exit code 0, got %d\nstdout: %s\nstderr: %s", code, stdout, stderr)
794+
}
795+
got := stdout.String()
796+
want := "Prints status.\n\nUsage:\n\n\tmage status\n\nAliases: st, stat\n\n"
797+
if got != want {
798+
t.Errorf("expected %q, got %q", want, got)
799+
}
800+
}
801+
802+
// TestHelpMatchesCompiledBinary verifies that mage -h and a compiled binary's
803+
// -h produce identical output for the same target.
804+
func TestHelpMatchesCompiledBinary(t *testing.T) {
805+
dir := "./testdata/compiled"
806+
compileDir := t.TempDir()
807+
name := filepath.Join(compileDir, "mage_help_test")
808+
if runtime.GOOS == "windows" {
809+
name += ".exe"
810+
}
811+
812+
// Compile the binary.
813+
stderr := &bytes.Buffer{}
814+
inv := Invocation{
815+
Dir: dir,
816+
Stdout: io.Discard,
817+
Stderr: stderr,
818+
CompileOut: name,
819+
}
820+
code := Invoke(inv)
821+
if code != 0 {
822+
t.Fatalf("compile failed with code %d: %s", code, stderr)
823+
}
824+
825+
// Get help from mage directly (no compilation path).
826+
stdout := &bytes.Buffer{}
827+
stderr.Reset()
828+
inv = Invocation{
829+
Dir: dir,
830+
Stdout: stdout,
831+
Stderr: stderr,
832+
Help: true,
833+
Args: []string{"deploy"},
834+
}
835+
code = Invoke(inv)
836+
if code != 0 {
837+
t.Fatalf("mage -h deploy failed with code %d: %s", code, stderr)
838+
}
839+
mageOutput := stdout.String()
840+
841+
// Get help from the compiled binary.
842+
stdout.Reset()
843+
stderr.Reset()
844+
cmd := exec.CommandContext(context.Background(), name, "-h", "deploy")
845+
cmd.Env = os.Environ()
846+
cmd.Stdout = stdout
847+
cmd.Stderr = stderr
848+
if err := cmd.Run(); err != nil {
849+
t.Fatalf("compiled binary -h deploy failed: %v\nstderr: %s", err, stderr)
850+
}
851+
compiledOutput := stdout.String()
852+
853+
// The binary name differs (compiled binary uses its own filename), so
854+
// normalize both outputs by replacing the binary name with a placeholder.
855+
binaryBase := filepath.Base(name)
856+
normalizedMage := strings.ReplaceAll(mageOutput, "\tmage ", "\tBINARY ")
857+
normalizedCompiled := strings.ReplaceAll(compiledOutput, "\t"+binaryBase+" ", "\tBINARY ")
858+
859+
if normalizedMage != normalizedCompiled {
860+
t.Errorf("help output mismatch (after normalizing binary name):\nmage -h: %q\ncompiled -h: %q", mageOutput, compiledOutput)
861+
}
862+
}
863+
694864
func TestList(t *testing.T) {
695865
stdout := &bytes.Buffer{}
696866
inv := Invocation{

mage/template.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ Options:
257257
{{if and (eq $name $func.Name) (eq $recv $func.Receiver)}}aliases = append(aliases, "{{$alias}}"){{end -}}
258258
{{- end}}
259259
if len(aliases) > 0 {
260+
_sort.Strings(aliases)
260261
_fmt.Printf("Aliases: %s\n\n", _strings.Join(aliases, ", "))
261262
}
262263
return
@@ -278,6 +279,7 @@ Options:
278279
{{if and (eq $name $func.Name) (eq $recv $func.Receiver)}}aliases = append(aliases, "{{$alias}}"){{end -}}
279280
{{- end}}
280281
if len(aliases) > 0 {
282+
_sort.Strings(aliases)
281283
_fmt.Printf("Aliases: %s\n\n", _strings.Join(aliases, ", "))
282284
}
283285
return
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//go:build mage
2+
3+
// Package doc for help_no_compile.
4+
package main
5+
6+
import "github.com/magefile/mage/mg"
7+
8+
// Build compiles the project.
9+
func Build() {
10+
// This references a package that doesn't exist, so go build will fail,
11+
// but the AST parser can still extract target metadata.
12+
doesnotexist.Fail()
13+
}
14+
15+
// Deploy pushes to production.
16+
// This is the extended description.
17+
func Deploy() {
18+
doesnotexist.Fail()
19+
}
20+
21+
// NS is a namespace for grouped targets.
22+
type NS mg.Namespace
23+
24+
// Run runs within the namespace.
25+
func (NS) Run() {
26+
doesnotexist.Fail()
27+
}

0 commit comments

Comments
 (0)