From 2dfc31264aee50fac62be6f021d62fa34fca1944 Mon Sep 17 00:00:00 2001 From: Jordan Barrett <90195985+barrettj12@users.noreply.github.com.> Date: Fri, 30 Aug 2024 16:59:16 -0500 Subject: [PATCH] feat: exported PrintMarkdown function --- cmdtesting/cmd.go | 2 +- documentation.go | 265 +++------------------------------- documentation_test.go | 7 +- markdown.go | 322 ++++++++++++++++++++++++++++++++++++++++++ markdown_test.go | 107 ++++++++++++++ testdata/add-cloud.md | 30 ++++ 6 files changed, 484 insertions(+), 249 deletions(-) create mode 100644 markdown.go create mode 100644 markdown_test.go create mode 100644 testdata/add-cloud.md diff --git a/cmdtesting/cmd.go b/cmdtesting/cmd.go index 474ca8be..ba58e25a 100644 --- a/cmdtesting/cmd.go +++ b/cmdtesting/cmd.go @@ -43,7 +43,7 @@ func Context(c *gc.C) *cmd.Context { Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, } - ctx.Context = context.TODO() + ctx.Context = context.Background() return ctx } diff --git a/documentation.go b/documentation.go index 326c0d04..ed18aa52 100644 --- a/documentation.go +++ b/documentation.go @@ -5,6 +5,7 @@ package cmd import ( "bufio" + "bytes" "errors" "fmt" "io" @@ -354,83 +355,35 @@ func (c *documentationCommand) linkForCommand(cmd string) string { // whether the command name should be a title or not. This is particularly // handy when splitting the commands in different files. func (c *documentationCommand) formatCommand(ref commandReference, title bool, commandSeq []string) string { - formatted := "" + var fmtedTitle string if title { - formatted = "# " + strings.ToUpper(strings.Join(commandSeq[1:], " ")) + "\n" + fmtedTitle = strings.ToUpper(strings.Join(commandSeq[1:], " ")) } - var info *Info - if ref.name == "documentation" { - info = c.Info() - } else { - info = ref.command.Info() - } - - // See Also - if len(info.SeeAlso) > 0 { - formatted += "> See also: " - prefix := "#" - if c.ids != nil { - prefix = "/t/" - } - if c.url != "" { - prefix = c.url + "t/" - } + var buf bytes.Buffer + PrintMarkdown(&buf, ref.command, MarkdownOptions{ + Title: fmtedTitle, + UsagePrefix: strings.Join(commandSeq[:len(commandSeq)-1], " ") + " ", + LinkForCommand: func(s string) string { + prefix := "#" + if c.ids != nil { + prefix = "/t/" + } + if c.url != "" { + prefix = c.url + "t/" + } - for i, s := range info.SeeAlso { target, err := c.getTargetCmd(s) if err != nil { fmt.Println(err.Error()) } - formatted += fmt.Sprintf("[%s](%s%s)", s, prefix, target) - if i < len(info.SeeAlso)-1 { - formatted += ", " - } - } - formatted += "\n" - } - - if ref.alias != "" { - formatted += "**Alias:** " + ref.alias + "\n" - } - if ref.check != nil && ref.check.Obsolete() { - formatted += "*This command is deprecated*\n" - } - formatted += "\n" - - // Summary - formatted += "## Summary\n" + info.Purpose + "\n\n" - - // Usage - if strings.TrimSpace(info.Args) != "" { - formatted += fmt.Sprintf(`## Usage -`+"```"+`%s [options] %s`+"```"+` - -`, strings.Join(commandSeq, " "), info.Args) - } - - // Options - formattedFlags := c.formatFlags(ref.command, info) - if len(formattedFlags) > 0 { - formatted += "### Options\n" + formattedFlags + "\n" - } - - // Examples - examples := info.Examples - if strings.TrimSpace(examples) != "" { - formatted += "## Examples\n" + examples + "\n\n" - } - - // Details - doc := EscapeMarkdown(info.Doc) - if strings.TrimSpace(doc) != "" { - formatted += "## Details\n" + doc + "\n\n" - } - - formatted += c.formatSubcommands(info.Subcommands, commandSeq) - formatted += "---\n\n" - - return formatted + return fmt.Sprintf("%s%s", prefix, target) + }, + LinkForSubcommand: func(s string) string { + return c.linkForCommand(strings.Join(append(commandSeq[1:], s), "_")) + }, + }) + return buf.String() } // getTargetCmd is an auxiliary function that returns the target command or @@ -456,177 +409,3 @@ func (d *documentationCommand) getTargetCmd(cmd string) (string, error) { } } - -// formatFlags is an internal formatting solution similar to -// the gnuflag.PrintDefaults. The code is extended here -// to permit additional formatting without modifying the -// gnuflag package. -func (d *documentationCommand) formatFlags(c Command, info *Info) string { - flagsAlias := FlagAlias(c, "") - if flagsAlias == "" { - // For backward compatibility, the default is 'flag'. - flagsAlias = "flag" - } - f := gnuflag.NewFlagSetWithFlagKnownAs(info.Name, gnuflag.ContinueOnError, flagsAlias) - - // if we are working with the documentation command, - // we have to set flags on a new instance, otherwise - // we will overwrite the current flag values - if info.Name != "documentation" { - c.SetFlags(f) - } else { - c = newDocumentationCommand(d.super) - c.SetFlags(f) - } - - // group together all flags for a given value, meaning that flag which sets the same value are - // grouped together and displayed with the same description, as below: - // - // -s, --short, --alternate-string | default value | some description. - flags := make(map[interface{}]flagsByLength) - f.VisitAll(func(f *gnuflag.Flag) { - flags[f.Value] = append(flags[f.Value], f) - }) - if len(flags) == 0 { - return "" - } - - // sort the output flags by shortest name for each group. - // Caution: this mean that description/default value displayed in documentation will - // be the one of the shortest alias. Other will be discarded. Be careful to have the same default - // values between each alias, and put the description on the shortest alias. - var byName flagsByName - for _, fl := range flags { - sort.Sort(fl) - byName = append(byName, fl) - } - sort.Sort(byName) - - formatted := "| Flag | Default | Usage |\n" - formatted += "| --- | --- | --- |\n" - for _, fs := range byName { - // Collect all flag aliases (usually a short one and a plain one, like -v / --verbose) - formattedFlags := "" - for i, f := range fs { - if i > 0 { - formattedFlags += ", " - } - if len(f.Name) == 1 { - formattedFlags += fmt.Sprintf("`-%s`", f.Name) - } else { - formattedFlags += fmt.Sprintf("`--%s`", f.Name) - } - } - // display all the flags aliases and the default value and description of the shortest one. - // Escape Markdown in description in order to display it cleanly in the final documentation. - formatted += fmt.Sprintf("| %s | %s | %s |\n", formattedFlags, - EscapeMarkdown(fs[0].DefValue), - strings.ReplaceAll(EscapeMarkdown(fs[0].Usage), "\n", " "), - ) - } - return formatted -} - -// flagsByLength is a slice of flags implementing sort.Interface, -// sorting primarily by the length of the flag, and secondarily -// alphabetically. -type flagsByLength []*gnuflag.Flag - -func (f flagsByLength) Less(i, j int) bool { - s1, s2 := f[i].Name, f[j].Name - if len(s1) != len(s2) { - return len(s1) < len(s2) - } - return s1 < s2 -} -func (f flagsByLength) Swap(i, j int) { - f[i], f[j] = f[j], f[i] -} -func (f flagsByLength) Len() int { - return len(f) -} - -// flagsByName is a slice of slices of flags implementing sort.Interface, -// alphabetically sorting by the name of the first flag in each slice. -type flagsByName [][]*gnuflag.Flag - -func (f flagsByName) Less(i, j int) bool { - return f[i][0].Name < f[j][0].Name -} -func (f flagsByName) Swap(i, j int) { - f[i], f[j] = f[j], f[i] -} -func (f flagsByName) Len() int { - return len(f) -} - -// EscapeMarkdown returns a copy of the input string, in which any special -// Markdown characters (e.g. < > |) are escaped. -func EscapeMarkdown(raw string) string { - escapeSeqs := map[rune]string{ - '<': "<", - '>': ">", - '&': "&", - '|': "|", - } - - var escaped strings.Builder - escaped.Grow(len(raw)) - - lines := strings.Split(raw, "\n") - for i, line := range lines { - if strings.HasPrefix(line, " ") { - // Literal code block - don't escape anything - escaped.WriteString(line) - - } else { - // Keep track of whether we are inside a code span `...` - // If so, don't escape characters - insideCodeSpan := false - - for _, c := range line { - if c == '`' { - insideCodeSpan = !insideCodeSpan - } - - if !insideCodeSpan { - if escapeSeq, ok := escapeSeqs[c]; ok { - escaped.WriteString(escapeSeq) - continue - } - } - escaped.WriteRune(c) - } - } - - if i < len(lines)-1 { - escaped.WriteRune('\n') - } - } - - return escaped.String() -} - -func (c *documentationCommand) formatSubcommands(subcommands map[string]string, commandSeq []string) string { - var output string - - sorted := []string{} - for name := range subcommands { - if isDefaultCommand(name) { - continue - } - sorted = append(sorted, name) - } - sort.Strings(sorted) - - if len(sorted) > 0 { - output += "## Subcommands\n" - for _, name := range sorted { - output += fmt.Sprintf("- [%s](%s)\n", name, - c.linkForCommand(strings.Join(append(commandSeq[1:], name), "_"))) - } - output += "\n" - } - - return output -} diff --git a/documentation_test.go b/documentation_test.go index b3b37e95..362dccf0 100644 --- a/documentation_test.go +++ b/documentation_test.go @@ -38,6 +38,8 @@ func (s *documentationSuite) TestFormatCommand(c *gc.C) { expected: (` > See also: [clouds](#clouds), [update-cloud](#update-cloud), [remove-cloud](#remove-cloud), [update-credential](#update-credential) +**Aliases:** cloud-add, import-cloud + ## Summary summary for add-cloud... @@ -57,8 +59,6 @@ examples for add-cloud... ## Details details for add-cloud... ---- - `)[1:], }, { // no flags - don't print "Options" table @@ -74,7 +74,6 @@ details for add-cloud... }, title: false, expected: (` - ## Summary insert summary here... @@ -87,8 +86,6 @@ insert examples here... ## Details insert details here... ---- - `)[1:], }} diff --git a/markdown.go b/markdown.go new file mode 100644 index 00000000..8693e574 --- /dev/null +++ b/markdown.go @@ -0,0 +1,322 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the LGPLv3, see LICENCE file for details. + +package cmd + +import ( + "bytes" + "fmt" + "io" + "sort" + "strings" + + "github.com/juju/gnuflag" +) + +// InfoCommand is a subset of Command methods needed to print the Markdown +// document. In particular, all these methods are "static", hence should not +// do anything scary or destructive. +type InfoCommand interface { + // Info returns information about the Command. + Info() *Info + // SetFlags adds command specific flags to the flag set. + SetFlags(f *gnuflag.FlagSet) +} + +// MarkdownOptions configures the output of the PrintMarkdown function. +type MarkdownOptions struct { + // Title defines the title to print at the top of the document. If this + // field is empty, no title will be printed. + Title string + // UsagePrefix will be printed before the command usage (for example, the + // name of the supercommand). + UsagePrefix string + // LinkForCommand maps each "peer command" name (e.g. see also commands) to + // the link target for that command (e.g. a section of the Markdown doc, or + // a webpage). + LinkForCommand func(string) string + // LinkForSubcommand maps each sub-command name to the link target for that + //command (e.g. a section of the Markdown doc, or a webpage). + LinkForSubcommand func(string) string +} + +// PrintMarkdown prints Markdown documentation about the given command to the +// given io.Writer. The MarkdownOptions can be provided to customise the +// output. +func PrintMarkdown(w io.Writer, cmd InfoCommand, opts MarkdownOptions) error { + // We will write the document to a bytes.Buffer, then copy it over to the + // specified io.Writer. This saves us having to check errors on every + // single write - we can just check at the end when we copy over. + var doc bytes.Buffer + + if opts.Title != "" { + fmt.Fprintf(&doc, "# %s\n\n", opts.Title) + } + + info := cmd.Info() + + // See Also + if len(info.SeeAlso) > 0 { + printSeeAlso(&doc, info.SeeAlso, opts.LinkForCommand) + } + + if len(info.Aliases) > 0 { + fmt.Fprint(&doc, "**Aliases:** ") + fmt.Fprint(&doc, strings.Join(info.Aliases, ", ")) + fmt.Fprintln(&doc) + fmt.Fprintln(&doc) + } + + // Summary + fmt.Fprintln(&doc, "## Summary") + fmt.Fprintln(&doc, info.Purpose) + 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) + } + + // Options + printFlags(&doc, cmd) + + // Examples + if info.Examples != "" { + fmt.Fprintln(&doc, "## Examples") + fmt.Fprintln(&doc, info.Examples) + fmt.Fprintln(&doc) + } + + // Details + if info.Doc != "" { + fmt.Fprintln(&doc, "## Details") + fmt.Fprintln(&doc, EscapeMarkdown(info.Doc)) + fmt.Fprintln(&doc) + } + + if len(info.Subcommands) > 0 { + printSubcommands(&doc, info.Subcommands, opts.LinkForSubcommand) + } + + _, err := io.Copy(w, &doc) + if err != nil { + return fmt.Errorf("writing Markdown: %w", err) + } + return nil +} + +func printSeeAlso( + w io.Writer, + seeAlso []string, + linkForCommand func(string) string, +) { + fmt.Fprint(w, "> See also: ") + + for i, cmdName := range seeAlso { + fmt.Fprint(w, markdownLink(cmdName, linkForCommand)) + + // Separate command names by commas + if i < len(seeAlso)-1 { + fmt.Fprint(w, ", ") + } + } + fmt.Fprintln(w) + fmt.Fprintln(w) +} + +// getFlagsName returns the default name for a command's flags, if this is not +// defined in the info. +func getFlagsName(fka string) string { + if fka == "" { + return "option" + } + return fka +} + +func printFlags(w io.Writer, cmd InfoCommand) { + info := cmd.Info() + + flagKnownAs := getFlagsName(info.FlagKnownAs) + f := gnuflag.NewFlagSetWithFlagKnownAs(info.Name, gnuflag.ContinueOnError, flagKnownAs) + cmd.SetFlags(f) + + // group together all flags for a given value, meaning that flag which sets the same value are + // grouped together and displayed with the same description, as below: + // + // -s, --short, --alternate-string | default value | some description. + flags := make(map[interface{}]flagsByLength) + f.VisitAll(func(f *gnuflag.Flag) { + flags[f.Value] = append(flags[f.Value], f) + }) + if len(flags) == 0 { + // No flags, so we won't print this section + return + } + + // sort the output flags by shortest name for each group. + // Caution: this mean that description/default value displayed in documentation will + // be the one of the shortest alias. Other will be discarded. Be careful to have the same default + // values between each alias, and put the description on the shortest alias. + var byName flagsByName + for _, fl := range flags { + sort.Sort(fl) + byName = append(byName, fl) + } + sort.Sort(byName) + + fmt.Fprintln(w, "### Options") + fmt.Fprintln(w, "| Flag | Default | Usage |") + fmt.Fprintln(w, "| --- | --- | --- |") + + for _, fs := range byName { + // Collect all flag aliases (usually a short one and a plain one, like -v / --verbose) + formattedFlags := "" + for i, f := range fs { + if i > 0 { + formattedFlags += ", " + } + if len(f.Name) == 1 { + formattedFlags += fmt.Sprintf("`-%s`", f.Name) + } else { + formattedFlags += fmt.Sprintf("`--%s`", f.Name) + } + } + // display all the flags aliases and the default value and description of the shortest one. + // Escape Markdown in description in order to display it cleanly in the final documentation. + fmt.Fprintf(w, "| %s | %s | %s |\n", formattedFlags, + EscapeMarkdown(fs[0].DefValue), + strings.ReplaceAll(EscapeMarkdown(fs[0].Usage), "\n", " "), + ) + } + fmt.Fprintln(w) +} + +// flagsByLength is a slice of flags implementing sort.Interface, +// sorting primarily by the length of the flag, and secondarily +// alphabetically. +type flagsByLength []*gnuflag.Flag + +func (f flagsByLength) Less(i, j int) bool { + s1, s2 := f[i].Name, f[j].Name + if len(s1) != len(s2) { + return len(s1) < len(s2) + } + return s1 < s2 +} +func (f flagsByLength) Swap(i, j int) { + f[i], f[j] = f[j], f[i] +} +func (f flagsByLength) Len() int { + return len(f) +} + +// flagsByName is a slice of slices of flags implementing sort.Interface, +// alphabetically sorting by the name of the first flag in each slice. +type flagsByName [][]*gnuflag.Flag + +func (f flagsByName) Less(i, j int) bool { + return f[i][0].Name < f[j][0].Name +} +func (f flagsByName) Swap(i, j int) { + f[i], f[j] = f[j], f[i] +} +func (f flagsByName) Len() int { + return len(f) +} + +func printSubcommands( + w io.Writer, + subcommands map[string]string, + linkForSubcommand func(string) string, +) { + sorted := []string{} + for name := range subcommands { + if isDefaultCommand(name) { + continue + } + sorted = append(sorted, name) + } + sort.Strings(sorted) + + if len(sorted) > 0 { + fmt.Fprintln(w, "## Subcommands") + for _, name := range sorted { + fmt.Fprint(w, "- ") + fmt.Fprint(w, markdownLink(name, linkForSubcommand)) + fmt.Fprintln(w) + } + fmt.Fprintln(w) + } +} + +// markdownLink uses the provided linker function to generate a Markdown +// hyperlink for the given key. It attempts to call the linker function on the +// given key to get the link target. If the function is nil or the output is +// empty, just the key (as a non-link) will be returned. +func markdownLink(key string, linker func(string) string) string { + var target string + if linker != nil { + target = linker(key) + } + + if target == "" { + // We don't have a link target for this key, so just return the key. + return key + } else { + return fmt.Sprintf("[%s](%s)", key, target) + } +} + +// EscapeMarkdown returns a copy of the input string, in which certain special +// Markdown characters (e.g. < > |) are escaped. These characters can otherwise +// cause the Markdown to display incorrectly if not escaped. +func EscapeMarkdown(raw string) string { + escapeSeqs := map[rune]string{ + '<': "<", + '>': ">", + '&': "&", + '|': "|", + } + + var escaped strings.Builder + escaped.Grow(len(raw)) + + lines := strings.Split(raw, "\n") + for i, line := range lines { + if strings.HasPrefix(line, " ") { + // Literal code block - don't escape anything + escaped.WriteString(line) + + } else { + // Keep track of whether we are inside a code span `...` + // If so, don't escape characters + insideCodeSpan := false + + for _, c := range line { + if c == '`' { + insideCodeSpan = !insideCodeSpan + } + + if !insideCodeSpan { + if escapeSeq, ok := escapeSeqs[c]; ok { + escaped.WriteString(escapeSeq) + continue + } + } + escaped.WriteRune(c) + } + } + + if i < len(lines)-1 { + escaped.WriteRune('\n') + } + } + + return escaped.String() +} diff --git a/markdown_test.go b/markdown_test.go new file mode 100644 index 00000000..1b115f9c --- /dev/null +++ b/markdown_test.go @@ -0,0 +1,107 @@ +// Copyright 2024 Canonical Ltd. +// Licensed under the LGPLv3, see LICENCE file for details. + +package cmd_test + +import ( + "bytes" + "errors" + "os" + + jc "github.com/juju/testing/checkers" + gc "gopkg.in/check.v1" + + "github.com/juju/cmd/v3" +) + +type markdownSuite struct{} + +var _ = gc.Suite(&markdownSuite{}) + +// TestWriteError ensures that the cmd.PrintMarkdown function surfaces errors +// returned by the writer. +func (*markdownSuite) TestWriteError(c *gc.C) { + expectedErr := errors.New("foo") + writer := errorWriter{err: expectedErr} + command := &docTestCommand{ + info: &cmd.Info{}, + } + err := cmd.PrintMarkdown(writer, command, cmd.MarkdownOptions{}) + c.Assert(err, gc.NotNil) + c.Check(err, gc.ErrorMatches, ".*foo") +} + +// errorWriter is an io.Writer that returns an error whenever the Write method +// is called. +type errorWriter struct { + err error +} + +func (e errorWriter) Write([]byte) (n int, err error) { + return 0, e.err +} + +// TestOutput tests that the output of the PrintMarkdown function is +// fundamentally correct. +func (*markdownSuite) TestOutput(c *gc.C) { + seeAlso := []string{"clouds", "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: "add-cloud", + Args: " []", + Purpose: "Add a cloud definition to Juju.", + Doc: "details for add-cloud...", + Examples: "examples for add-cloud...", + SeeAlso: seeAlso, + Aliases: []string{"new-cloud", "cloud-add"}, + Subcommands: subcommands, + }, + flags: []testFlag{{ + name: "force", + }, { + name: "file", + short: "f", + }, { + name: "credential", + short: "c", + }}, + } + + // 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/add-cloud/" + s + } + + expected, err := os.ReadFile("testdata/add-cloud.md") + c.Assert(err, jc.ErrorIsNil) + + var buf bytes.Buffer + err = cmd.PrintMarkdown(&buf, command, cmd.MarkdownOptions{ + Title: `Command "juju add-cloud"`, + UsagePrefix: "juju ", + LinkForCommand: linkForCommand, + LinkForSubcommand: linkForSubcommand, + }) + c.Assert(err, jc.ErrorIsNil) + c.Check(buf.String(), gc.Equals, string(expected)) +} diff --git a/testdata/add-cloud.md b/testdata/add-cloud.md new file mode 100644 index 00000000..1b350ecb --- /dev/null +++ b/testdata/add-cloud.md @@ -0,0 +1,30 @@ +# Command "juju add-cloud" + +> See also: [clouds](https://docs.com/clouds), [update-cloud](https://docs.com/update-cloud), [remove-cloud](https://docs.com/remove-cloud), [update-credential](https://docs.com/update-credential) + +**Aliases:** new-cloud, cloud-add + +## Summary +Add a cloud definition to Juju. + +## Usage +```juju add-cloud [options] []``` + +### Options +| Flag | Default | Usage | +| --- | --- | --- | +| `-c`, `--credential` | default value for "credential" flag | description for "credential" flag | +| `-f`, `--file` | default value for "file" flag | description for "file" flag | +| `--force` | default value for "force" flag | description for "force" flag | + +## Examples +examples for add-cloud... + +## Details +details for add-cloud... + +## Subcommands +- [bar](https://docs.com/add-cloud/bar) +- [baz](https://docs.com/add-cloud/baz) +- [foo](https://docs.com/add-cloud/foo) +