From c0672207a327bf86b79c4f8f25f97558d41877ae Mon Sep 17 00:00:00 2001 From: Kian Parvin Date: Tue, 3 Dec 2024 15:09:06 +0200 Subject: [PATCH 1/4] chore: fix subcommand URI fragments Links to a different section of text in markdown should use - instead of _ for links with spaces. Also adds a newline before the line break marker in the index. --- documentation.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation.go b/documentation.go index ed18aa52..2e370f88 100644 --- a/documentation.go +++ b/documentation.go @@ -328,7 +328,7 @@ func (c *documentationCommand) writeIndex(w io.Writer) error { } // TODO: handle subcommands ?? } - _, err = fmt.Fprintf(w, "---\n\n") + _, err = fmt.Fprintf(w, "\n---\n\n") return err } @@ -380,7 +380,7 @@ func (c *documentationCommand) formatCommand(ref commandReference, title bool, c return fmt.Sprintf("%s%s", prefix, target) }, LinkForSubcommand: func(s string) string { - return c.linkForCommand(strings.Join(append(commandSeq[1:], s), "_")) + return c.linkForCommand(strings.Join(append(commandSeq[1:], s), "-")) }, }) return buf.String() From eb95f3f238d61d7ff749a06de655b485c6d7016d Mon Sep 17 00:00:00 2001 From: Kian Parvin Date: Wed, 4 Dec 2024 14:24:41 +0200 Subject: [PATCH 2/4] fix: always print usage The usage was only printed if the command has arguments, common for `list` style commands. But these commands should also have a usage section in the docs. --- markdown.go | 16 ++++++------ markdown_test.go | 56 +++++++++++++++++++++++++++++++++++++++++ testdata/list-clouds.md | 23 +++++++++++++++++ 3 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 testdata/list-clouds.md diff --git a/markdown.go b/markdown.go index 8693e574..2a9784b8 100644 --- a/markdown.go +++ b/markdown.go @@ -73,15 +73,13 @@ func PrintMarkdown(w io.Writer, cmd InfoCommand, opts MarkdownOptions) error { fmt.Fprintln(&doc) // Usage - if strings.TrimSpace(info.Args) != "" { - fmt.Fprintln(&doc, "## Usage") - fmt.Fprintf(&doc, "```") - fmt.Fprint(&doc, opts.UsagePrefix) - fmt.Fprintf(&doc, "%s [%ss] %s", info.Name, getFlagsName(info.FlagKnownAs), info.Args) - fmt.Fprintf(&doc, "```") - fmt.Fprintln(&doc) - fmt.Fprintln(&doc) - } + fmt.Fprintln(&doc, "## Usage") + fmt.Fprintf(&doc, "```") + fmt.Fprint(&doc, opts.UsagePrefix) + fmt.Fprintf(&doc, "%s [%ss] %s", info.Name, getFlagsName(info.FlagKnownAs), info.Args) + fmt.Fprintf(&doc, "```") + fmt.Fprintln(&doc) + fmt.Fprintln(&doc) // Options printFlags(&doc, cmd) diff --git a/markdown_test.go b/markdown_test.go index 1b115f9c..20bda7a0 100644 --- a/markdown_test.go +++ b/markdown_test.go @@ -105,3 +105,59 @@ func (*markdownSuite) TestOutput(c *gc.C) { c.Assert(err, jc.ErrorIsNil) c.Check(buf.String(), gc.Equals, string(expected)) } + +// TestOutputWithoutArgs tests that the output of the PrintMarkdown function is +// correct when a command does not need arguments, e.g. list commands. +func (*markdownSuite) TestOutputWithoutArgs(c *gc.C) { + seeAlso := []string{"add-cloud", "update-cloud", "remove-cloud", "update-credential"} + subcommands := map[string]string{ + "foo": "foo the bar baz", + "bar": "bar the baz foo", + "baz": "baz the foo bar", + } + + command := &docTestCommand{ + info: &cmd.Info{ + Name: "clouds", + Args: "", //Empty args should still result in a usage field. + Purpose: "List clouds.", + Doc: "details for clouds...", + Examples: "examples for clouds...", + SeeAlso: seeAlso, + Aliases: []string{"list-clouds"}, + Subcommands: subcommands, + }, + } + + // These functions verify the provided argument is in the expected set. + linkForCommand := func(s string) string { + for _, cmd := range seeAlso { + if cmd == s { + return "https://docs.com/" + cmd + } + } + c.Fatalf("linkForCommand called with unexpected command %q", s) + return "" + } + + linkForSubcommand := func(s string) string { + _, ok := subcommands[s] + if !ok { + c.Fatalf("linkForSubcommand called with unexpected subcommand %q", s) + } + return "https://docs.com/clouds/" + s + } + + expected, err := os.ReadFile("testdata/list-clouds.md") + c.Assert(err, jc.ErrorIsNil) + + var buf bytes.Buffer + err = cmd.PrintMarkdown(&buf, command, cmd.MarkdownOptions{ + Title: `Command "juju clouds"`, + UsagePrefix: "juju ", + LinkForCommand: linkForCommand, + LinkForSubcommand: linkForSubcommand, + }) + c.Assert(err, jc.ErrorIsNil) + c.Check(buf.String(), gc.Equals, string(expected)) +} diff --git a/testdata/list-clouds.md b/testdata/list-clouds.md new file mode 100644 index 00000000..16d58419 --- /dev/null +++ b/testdata/list-clouds.md @@ -0,0 +1,23 @@ +# Command "juju clouds" + +> See also: [add-cloud](https://docs.com/add-cloud), [update-cloud](https://docs.com/update-cloud), [remove-cloud](https://docs.com/remove-cloud), [update-credential](https://docs.com/update-credential) + +**Aliases:** list-clouds + +## Summary +List clouds. + +## Usage +```juju clouds [options] ``` + +## Examples +examples for clouds... + +## Details +details for clouds... + +## Subcommands +- [bar](https://docs.com/clouds/bar) +- [baz](https://docs.com/clouds/baz) +- [foo](https://docs.com/clouds/foo) + From 5cebac48f75c338210c9ccd16dfff896ce3a6e7b Mon Sep 17 00:00:00 2001 From: Kian Parvin Date: Tue, 28 Jan 2025 12:49:36 +0200 Subject: [PATCH 3/4] chore: update doc headings Update doc headings from UpperCase() to LowerCase( ). E.g. a command like `juju add-controller` would receive a title of `ADD-CONTROLLER` but is now `juju add-controller` to better reflect the CLI usage. This shouldn't affect Juju documentation because there the title is often excluded since each command is documented on a different page, but for other tools using this package the title is more useful. --- documentation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation.go b/documentation.go index 2e370f88..f777cd69 100644 --- a/documentation.go +++ b/documentation.go @@ -357,7 +357,7 @@ func (c *documentationCommand) linkForCommand(cmd string) string { func (c *documentationCommand) formatCommand(ref commandReference, title bool, commandSeq []string) string { var fmtedTitle string if title { - fmtedTitle = strings.ToUpper(strings.Join(commandSeq[1:], " ")) + fmtedTitle = strings.ToLower(strings.Join(commandSeq, " ")) } var buf bytes.Buffer From 0f853e1863854bbb0ea33e2bf3b91af0d51c1c76 Mon Sep 17 00:00:00 2001 From: Kian Parvin Date: Wed, 19 Mar 2025 08:44:49 +0200 Subject: [PATCH 4/4] feat: enable commands to trigger plugins This change allows a command, in it's init() method, to return a specific error that will trigger the command missing callback. This is particularly useful if a command wants to trigger its missing callback when certain flags are set. --- supercommand.go | 48 ++++++++++++++++++++++++++++++++------------ supercommand_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/supercommand.go b/supercommand.go index 9c760a99..b267ffdf 100644 --- a/supercommand.go +++ b/supercommand.go @@ -4,6 +4,7 @@ package cmd import ( + stderr "errors" "fmt" "io/ioutil" "sort" @@ -16,6 +17,11 @@ import ( var logger = loggo.GetLogger("cmd") +// ErrCommandMissing can be returned during the Init() method +// of a command to trigger the super command's missingCallback +// if one is set. +var ErrCommandMissing = stderr.New("missing command") + type topic struct { short string long func() string @@ -457,24 +463,30 @@ func (c *SuperCommand) Init(args []string) error { } found := false + setupMissingCallback := func() { + c.action = commandReference{ + command: &missingCommand{ + callback: c.missingCallback, + superName: c.Name, + name: args[0], + args: args[1:], + }, + } + } + // Look for the command. if c.action, found = c.subcmds[args[0]]; !found { if c.missingCallback != nil { - c.action = commandReference{ - command: &missingCommand{ - callback: c.missingCallback, - superName: c.Name, - name: args[0], - args: args[1:], - }, - } + setupMissingCallback() // Yes return here, no Init called on missing Command. return nil } return fmt.Errorf("unrecognized command: %s %s", c.Name, args[0]) } - args = args[1:] + // Keep the original args + cleanArgs := make([]string, len(args[1:])) + copy(cleanArgs, args[1:]) subcmd := c.action.command if subcmd.IsSuperCommand() { f := gnuflag.NewFlagSetWithFlagKnownAs(c.Info().Name, gnuflag.ContinueOnError, FlagAlias(subcmd, "flag")) @@ -483,17 +495,27 @@ func (c *SuperCommand) Init(args []string) error { } else { subcmd.SetFlags(c.commonflags) } - if err := c.commonflags.Parse(subcmd.AllowInterspersedFlags(), args); err != nil { + if err := c.commonflags.Parse(subcmd.AllowInterspersedFlags(), cleanArgs); err != nil { return err } - args = c.commonflags.Args() + cleanArgs = c.commonflags.Args() if c.showHelp { // We want to treat help for the command the same way we would if we went "help foo". - args = []string{c.action.name} + cleanArgs = []string{c.action.name} c.action = c.subcmds["help"] } - return c.action.command.Init(args) + + err := c.action.command.Init(cleanArgs) + + // Commands may intentionally return a command missing + // error during init to trigger their missing callback. + if !stderr.Is(err, ErrCommandMissing) || c.missingCallback == nil { + return err + } + + setupMissingCallback() + return nil } // Run executes the subcommand that was selected in Init. diff --git a/supercommand_test.go b/supercommand_test.go index 65dff8f1..92fb65be 100644 --- a/supercommand_test.go +++ b/supercommand_test.go @@ -391,6 +391,52 @@ func (s *SuperCommandSuite) TestMissingCallbackContextWiredIn(c *gc.C) { c.Assert(cmdtesting.Stderr(s.ctx), gc.Equals, "this is std err") } +type simpleWithInitError struct { + cmd.CommandBase + name string + initError error +} + +var _ cmd.Command = (*simpleWithInitError)(nil) + +func (s *simpleWithInitError) Info() *cmd.Info { + return &cmd.Info{Name: s.name, Purpose: "to be simple"} +} + +func (s *simpleWithInitError) Init(args []string) error { + return s.initError +} + +func (s *simpleWithInitError) Run(_ *cmd.Context) error { + return errors.New("unexpected-error") +} + +func (s *SuperCommandSuite) TestMissingCallbackSetOnError(c *gc.C) { + callback := func(ctx *cmd.Context, subcommand string, args []string) error { + fmt.Fprint(ctx.Stdout, "reached callback: "+strings.Join(args, " ")) + return nil + } + + jc := cmd.NewSuperCommand(cmd.SuperCommandParams{ + Name: "jujutest", + Log: &cmd.Log{}, + MissingCallback: callback, + }) + jc.Register(&simpleWithInitError{name: "foo", initError: cmd.ErrCommandMissing}) + jc.Register(&simpleWithInitError{name: "bar", initError: errors.New("my-fake-error")}) + + code := cmd.Main(jc, s.ctx, []string{"bar"}) + c.Assert(code, gc.Equals, 2) + c.Assert(cmdtesting.Stderr(s.ctx), gc.Equals, "ERROR my-fake-error\n") + + // Verify that a call to foo, which returns a ErrCommandMissing error + // triggers the command missing callback and ensure all expected + // args were correctly sent to the callback. + code = cmd.Main(jc, s.ctx, []string{"foo", "bar", "baz", "--debug"}) + c.Assert(code, gc.Equals, 0) + c.Assert(cmdtesting.Stdout(s.ctx), gc.Equals, "reached callback: bar baz --debug") +} + func (s *SuperCommandSuite) TestSupercommandAliases(c *gc.C) { jc := cmd.NewSuperCommand(cmd.SuperCommandParams{ Name: "jujutest",