Skip to content

Commit 88c49b7

Browse files
authored
support for inline doc comments on optional flags (#549)
support for inline doc comments on optional flags
1 parent 707313f commit 88c49b7

File tree

8 files changed

+263
-43
lines changed

8 files changed

+263
-43
lines changed

mage/args_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,73 @@ Usage:
455455
}
456456
}
457457

458+
func TestOptionalArgsFlagDocs(t *testing.T) {
459+
stderr := &bytes.Buffer{}
460+
stdout := &bytes.Buffer{}
461+
inv := Invocation{
462+
Dir: "./testdata/optargs",
463+
Stderr: stderr,
464+
Stdout: stdout,
465+
Help: true,
466+
Args: []string{"flagdocs"},
467+
}
468+
code := Invoke(inv)
469+
if code != 0 {
470+
t.Log("stderr:", stderr)
471+
t.Log("stdout:", stdout)
472+
t.Fatalf("expected code 0, but got %v", code)
473+
}
474+
actual := stdout.String()
475+
expected := `FlagDocs tests that docs on flags are properly displayed when you run mage -h FlagDocs.
476+
477+
Usage:
478+
479+
mage flagdocs <name> [<flags>]
480+
481+
Flags:
482+
483+
-greeting=<string> the message to append to the name
484+
-repeat=<int> the number of times to repeat
485+
486+
`
487+
if actual != expected {
488+
t.Fatalf("output is not expected:\ngot: %q\nwant: %q", actual, expected)
489+
}
490+
}
491+
492+
func TestOptionalArgsSingleFlagDoc(t *testing.T) {
493+
stderr := &bytes.Buffer{}
494+
stdout := &bytes.Buffer{}
495+
inv := Invocation{
496+
Dir: "./testdata/optargs",
497+
Stderr: stderr,
498+
Stdout: stdout,
499+
Help: true,
500+
Args: []string{"singleflagdoc"},
501+
}
502+
code := Invoke(inv)
503+
if code != 0 {
504+
t.Log("stderr:", stderr)
505+
t.Log("stdout:", stdout)
506+
t.Fatalf("expected code 0, but got %v", code)
507+
}
508+
actual := stdout.String()
509+
expected := `SingleFlagDoc tests that a single documented flag shows the Flags section.
510+
511+
Usage:
512+
513+
mage singleflagdoc <name> [-greeting=<string>]
514+
515+
Flags:
516+
517+
-greeting=<string> the greeting to use
518+
519+
`
520+
if actual != expected {
521+
t.Fatalf("output is not expected:\ngot: %q\nwant: %q", actual, expected)
522+
}
523+
}
524+
458525
func TestOptionalArgsCaseInsensitive(t *testing.T) {
459526
stderr := &bytes.Buffer{}
460527
stdout := &bytes.Buffer{}

mage/template.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,9 @@ Options:
372372
_fmt.Println({{printf "%q" .Comment}})
373373
_fmt.Println()
374374
{{end}}
375-
_fmt.Print("Usage:\n\n\t{{$.BinaryName}} {{lower .TargetName}}{{range .RequiredArgs}} <{{.Name}}>{{end}}{{range .OptionalArgs}} [-{{.Name}}=<{{.Type}}>]{{end}}\n\n")
375+
_fmt.Print("Usage:\n\n\t{{$.BinaryName}} {{lower .TargetName}}{{range .RequiredArgs}} <{{.Name}}>{{end}}{{if .MultipleOptionalArgs}} [<flags>]{{else}}{{range .OptionalArgs}} [-{{.Name}}=<{{.Type}}>]{{end}}{{end}}\n\n")
376+
{{if .ShowFlagDocs}}_fmt.Print({{printf "%q" .FlagDocsString}})
377+
{{end -}}
376378
var aliases []string
377379
{{- $name := .Name -}}
378380
{{- $recv := .Receiver -}}
@@ -391,7 +393,9 @@ Options:
391393
_fmt.Println({{printf "%q" .Comment}})
392394
_fmt.Println()
393395
{{end}}
394-
_fmt.Print("Usage:\n\n\t{{$.BinaryName}} {{lower .TargetName}}{{range .RequiredArgs}} <{{.Name}}>{{end}}{{range .OptionalArgs}} [-{{.Name}}=<{{.Type}}>]{{end}}\n\n")
396+
_fmt.Print("Usage:\n\n\t{{$.BinaryName}} {{lower .TargetName}}{{range .RequiredArgs}} <{{.Name}}>{{end}}{{if .MultipleOptionalArgs}} [<flags>]{{else}}{{range .OptionalArgs}} [-{{.Name}}=<{{.Type}}>]{{end}}{{end}}\n\n")
397+
{{if .ShowFlagDocs}}_fmt.Print({{printf "%q" .FlagDocsString}})
398+
{{end -}}
395399
var aliases []string
396400
{{- $name := .Name -}}
397401
{{- $recv := .Receiver -}}

mage/testdata/optargs/magefile.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,27 @@ func Mixed(ctx context.Context, name string, greeting *string, count int) error
9999
}
100100
return nil
101101
}
102+
103+
// FlagDocs tests that docs on flags are properly displayed when you
104+
// run mage -h FlagDocs.
105+
func FlagDocs(ctx context.Context, name string,
106+
greeting *string, // the message to append to the name
107+
repeat *int, // the number of times to repeat
108+
) error {
109+
// should show up like this with mage -h FlagDocs below the current usage text:
110+
//
111+
// -greeting=<string> the message to append to the name
112+
// -repeat=<int> the number of times to repeat
113+
return nil
114+
}
115+
116+
// SingleFlagDoc tests that a single documented flag shows the Flags section.
117+
func SingleFlagDoc(name string,
118+
greeting *string, // the greeting to use
119+
) {
120+
if greeting != nil {
121+
fmt.Printf("%s, %s!\n", *greeting, name)
122+
} else {
123+
fmt.Printf("Hello, %s!\n", name)
124+
}
125+
}

parse/parse.go

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ func (s Functions) Swap(i, j int) {
7878
type Arg struct {
7979
Name, Type string
8080
Optional bool
81+
Comment string
8182
}
8283

8384
// ID returns user-readable information about where this function is defined.
@@ -149,6 +150,71 @@ func (f Function) HasOptionalArgs() bool {
149150
return false
150151
}
151152

153+
// MultipleOptionalArgs reports whether the function has more than one optional argument.
154+
func (f Function) MultipleOptionalArgs() bool {
155+
n := 0
156+
for _, a := range f.Args {
157+
if a.Optional {
158+
n++
159+
if n > 1 {
160+
return true
161+
}
162+
}
163+
}
164+
return false
165+
}
166+
167+
// ShowFlagDocs reports whether the Flags section should be displayed.
168+
// This is true when there are multiple optional args (since they are
169+
// condensed to [<flags>] in the usage line) or when any optional arg
170+
// has a doc comment.
171+
func (f Function) ShowFlagDocs() bool {
172+
if f.MultipleOptionalArgs() {
173+
return true
174+
}
175+
for _, a := range f.Args {
176+
if a.Optional && a.Comment != "" {
177+
return true
178+
}
179+
}
180+
return false
181+
}
182+
183+
// FlagDocsString returns a formatted string documenting optional arguments.
184+
// It aligns comments to the same column based on the longest flag name.
185+
func (f Function) FlagDocsString() string {
186+
opts := f.OptionalArgs()
187+
if len(opts) == 0 {
188+
return ""
189+
}
190+
// Compute flag labels and find max width for alignment
191+
type entry struct {
192+
label string
193+
comment string
194+
}
195+
var entries []entry
196+
maxLen := 0
197+
for _, a := range opts {
198+
label := fmt.Sprintf("-%s=<%s>", a.Name, a.Type)
199+
if len(label) > maxLen {
200+
maxLen = len(label)
201+
}
202+
entries = append(entries, entry{label: label, comment: a.Comment})
203+
}
204+
205+
var buf strings.Builder
206+
_, _ = buf.WriteString("Flags:\n\n")
207+
for _, e := range entries {
208+
if e.comment != "" {
209+
_, _ = fmt.Fprintf(&buf, "\t%-*s %s\n", maxLen, e.label, e.comment)
210+
} else {
211+
_, _ = fmt.Fprintf(&buf, "\t%s\n", e.label)
212+
}
213+
}
214+
_, _ = buf.WriteString("\n")
215+
return buf.String()
216+
}
217+
152218
// ExecCode returns code for the template switch to run the target.
153219
// It wraps each target call to match the func(context.Context) error that
154220
// runTarget requires.
@@ -413,7 +479,23 @@ func Package(path string, files []string, multiline bool) (*PkgInfo, error) {
413479
debug.Printf("found %s tag, using multiline descriptions", multilineTag)
414480
multiline = true
415481
}
416-
p := doc.New(pkg, "./", 0)
482+
p := doc.New(pkg, "./", doc.PreserveAST)
483+
484+
// Build a map from AST fields to their inline comments. We use
485+
// ast.NewCommentMap because the Go parser does not populate
486+
// ast.Field.Comment for function parameters (only for struct fields).
487+
fieldComments := make(map[*ast.Field]string)
488+
for _, f := range pkg.Files {
489+
cmap := ast.NewCommentMap(fset, f, f.Comments)
490+
for node, groups := range cmap {
491+
field, ok := node.(*ast.Field)
492+
if !ok || len(groups) == 0 {
493+
continue
494+
}
495+
fieldComments[field] = strings.TrimSpace(groups[0].Text())
496+
}
497+
}
498+
417499
pi := &PkgInfo{
418500
AstPkg: pkg,
419501
DocPkg: p,
@@ -425,8 +507,8 @@ func Package(path string, files []string, multiline bool) (*PkgInfo, error) {
425507
pi.Description = toOneLine(p.Doc)
426508
}
427509

428-
setNamespaces(pi)
429-
setFuncs(pi)
510+
setNamespaces(pi, fieldComments)
511+
setFuncs(pi, fieldComments)
430512

431513
hasDupes, names := checkDupeTargets(pi)
432514
if hasDupes {
@@ -517,29 +599,29 @@ func (s Imports) Swap(i, j int) {
517599
s[i], s[j] = s[j], s[i]
518600
}
519601

520-
func setFuncs(pi *PkgInfo) {
602+
func setFuncs(pi *PkgInfo, fieldComments map[*ast.Field]string) {
521603
for _, f := range pi.DocPkg.Funcs {
522604
if f.Recv != "" {
523605
debug.Printf("skipping method %s.%s", f.Recv, f.Name)
524606
// skip methods
525607
continue
526608
}
527-
fn, ok := funcFromDoc(f, pi.DocPkg.ImportPath, f.Name, pi.Multiline)
609+
fn, ok := funcFromDoc(f, pi.DocPkg.ImportPath, f.Name, pi.Multiline, fieldComments)
528610
if !ok {
529611
continue
530612
}
531613
pi.Funcs = append(pi.Funcs, fn)
532614
}
533615
}
534616

535-
func setNamespaces(pi *PkgInfo) {
617+
func setNamespaces(pi *PkgInfo, fieldComments map[*ast.Field]string) {
536618
for _, t := range pi.DocPkg.Types {
537619
if !isNamespace(t) {
538620
continue
539621
}
540622
debug.Printf("found namespace %s %s", pi.DocPkg.ImportPath, t.Name)
541623
for _, f := range t.Methods {
542-
fn, ok := funcFromDoc(f, pi.DocPkg.ImportPath, t.Name+"."+f.Name, pi.Multiline)
624+
fn, ok := funcFromDoc(f, pi.DocPkg.ImportPath, t.Name+"."+f.Name, pi.Multiline, fieldComments)
543625
if !ok {
544626
continue
545627
}
@@ -549,11 +631,11 @@ func setNamespaces(pi *PkgInfo) {
549631
}
550632
}
551633

552-
func funcFromDoc(f *doc.Func, importpath, funcname string, multiline bool) (*Function, bool) {
634+
func funcFromDoc(f *doc.Func, importpath, funcname string, multiline bool, fieldComments map[*ast.Field]string) (*Function, bool) {
553635
if !ast.IsExported(f.Name) {
554636
return nil, false
555637
}
556-
fn, err := funcType(f.Decl.Type)
638+
fn, err := funcType(f.Decl.Type, fieldComments)
557639
if err != nil {
558640
debug.Printf("skipping invalid method %s %s: %v", importpath, funcname, err)
559641
return nil, false
@@ -987,7 +1069,7 @@ func hasErrorReturn(ft *ast.FuncType) (bool, error) {
9871069
return false, errors.New("EBADRETURNTYPE")
9881070
}
9891071

990-
func funcType(ft *ast.FuncType) (*Function, error) {
1072+
func funcType(ft *ast.FuncType, fieldComments map[*ast.Field]string) (*Function, error) {
9911073
var err error
9921074
f := &Function{}
9931075
f.IsContext, err = hasContextParam(ft)
@@ -998,6 +1080,7 @@ func funcType(ft *ast.FuncType) (*Function, error) {
9981080
if err != nil {
9991081
return nil, err
10001082
}
1083+
10011084
x := 0
10021085
if f.IsContext {
10031086
x++
@@ -1019,9 +1102,10 @@ func funcType(ft *ast.FuncType) (*Function, error) {
10191102
}
10201103
return nil, fmt.Errorf("unsupported argument type: %s", t)
10211104
}
1105+
comment := fieldComments[param]
10221106
// support for foo, bar string
10231107
for _, name := range param.Names {
1024-
f.Args = append(f.Args, Arg{Name: name.Name, Type: typ, Optional: optional})
1108+
f.Args = append(f.Args, Arg{Name: name.Name, Type: typ, Optional: optional, Comment: comment})
10251109
}
10261110
}
10271111
return f, nil

parse/parse_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,14 @@ func TestOptionalArgs(t *testing.T) {
284284
{Name: "b", Type: "int", Optional: true},
285285
},
286286
},
287+
{
288+
Name: "FlagDocFunc",
289+
Args: []Arg{
290+
{Name: "name", Type: "string"},
291+
{Name: "greeting", Type: "string", Optional: true, Comment: "the greeting message"},
292+
{Name: "count", Type: "int", Optional: true, Comment: "how many times"},
293+
},
294+
},
287295
{
288296
Name: "OptionalBool",
289297
Args: []Arg{

parse/testdata/optargs.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,9 @@ func OptionalBool(verbose *bool) {}
1616
func OptionalDuration(base time.Duration, extra *time.Duration) {}
1717

1818
func AllOptional(a *string, b *int) {}
19+
20+
func FlagDocFunc(name string,
21+
greeting *string, // the greeting message
22+
count *int, // how many times
23+
) {
24+
}

0 commit comments

Comments
 (0)