Files
pad/internal/cmdhelp/md.go
T
xarmian 5e93abe552 feat(cmdhelp): implement --format md emitter (TASK-935) (#328)
Adds internal/cmdhelp/md.go that renders the Document built in TASK-934
as markdown with the predictable section order from cmdhelp v0.1 §6.
Replaces the markdown stub in cmd/pad/help_cmdhelp.go so `pad help
--format md` (and the `--llm` alias) produce real output.

Section order per command (spec §6):
  ## `binary path`
  summary  /  description (when distinct from summary)
  ### Synopsis           — fenced usage line, reconstructed from args + flags
  ### Arguments          — table with name | type | required | description
  ### Flags              — table with flag | type | default | description
  ### Stdin              — when Stdin.Accepted is true
  ### Examples           — fenced bash blocks, drawn from same canonical
                          example set as JSON (spec §6 same-source rule)
  ### Output             — text_template + json_schema_ref when populated
  ### Exit codes         — table when ExitCodes is populated
  ### See also           — bullet list of related command paths

Top-level YAML frontmatter:
  cmdhelp_version, binary, version, generated (RFC3339, UTC).
  Now is overridable via Options.Now for snapshot-test stability.

Top-level structure: `# binary` heading, summary, optional homepage,
optional `## Workspace context` (populated in TASK-936), `## Global flags`
table, then per-command sections sorted by path for determinism.

Synopsis reconstruction uses the structured Args from Build() (rather
than cobra.UseLine) so JSON and MD stay driven by the same parsed data
— the variadic `<ref>...` and alternation enums from TASK-934 carry
through naturally.

Pipes in flag/arg descriptions are escaped (`\|`) so they don't break
markdown table grids.

cmd/pad/help_cmdhelp.go: emitCmdhelpMarkdown stub replaced with a call
into cmdhelp.EmitMarkdown. --depth/--all threaded through MaxDepth
identically to the JSON path.

Tests:
- 15 markdown emitter tests in internal/cmdhelp/md_test.go covering
  frontmatter (presence + timestamp injectability), per-command
  section order, synopsis reconstruction (incl. variadic + alternation),
  global-flag dedup, fenced-bash examples, hidden-thing exclusion,
  deterministic ordering, Stdin/Output/ExitCodes/SeeAlso sections,
  Workspace context, table-pipe escaping.
- TestHelpCmd_FormatMarkdownStubError replaced by
  TestHelpCmd_FormatMarkdownEmits (asserts frontmatter + structural
  markers for both md and llm).
- TestHelpCmd_FormatLLMAliasRoutesToMarkdown replaced by
  TestHelpCmd_FormatLLMIsAliasForMD (asserts md and llm produce
  byte-identical output modulo the timestamp).

End-to-end on the real binary:
- pad help --format md emits valid markdown with all sections.
- pad help --format llm produces byte-identical output (after
  timestamp normalization).
- pad help item create --format md scopes correctly.
- make check clean.

Out of scope (deferred):
- Dynamic Workspace context population — TASK-936.
- --capabilities discovery flag — TASK-937.
- Schema-validation + golden-file tests in CI — TASK-938.
- Examples populated for all pad commands (still in cobra Long for now)
  — TASK-939.

Parent: PLAN-930.
2026-05-01 01:05:30 -04:00

303 lines
8.3 KiB
Go

package cmdhelp
import (
"fmt"
"io"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
)
// EmitMarkdown walks the command tree below `target`, builds a cmdhelp
// v0.1 Document, and writes it to w as a markdown document with the
// predictable section order from spec §6.
//
// `--format llm` is an alias for `--format md` at the routing layer; both
// reach this function. Callers that need to vary llm-specific behavior
// in the future can branch upstream — the markdown shape itself is the
// same regardless of which alias was invoked.
func EmitMarkdown(target, root *cobra.Command, opts Options, w io.Writer) error {
doc := Build(target, root, opts)
return RenderMarkdown(doc, opts, w)
}
// RenderMarkdown writes a Document as markdown matching cmdhelp v0.1 §6.
// Useful for tests that build a Document manually and want to render it
// without re-walking a cobra tree.
func RenderMarkdown(doc *Document, opts Options, w io.Writer) error {
now := opts.Now
if now == nil {
now = time.Now
}
// YAML frontmatter (spec §6).
if _, err := fmt.Fprintln(w, "---"); err != nil {
return err
}
fmt.Fprintf(w, "cmdhelp_version: %q\n", doc.CmdhelpVersion)
fmt.Fprintf(w, "binary: %s\n", doc.Binary)
if doc.Version != "" {
fmt.Fprintf(w, "version: %s\n", doc.Version)
}
fmt.Fprintf(w, "generated: %s\n", now().UTC().Format(time.RFC3339))
fmt.Fprintln(w, "---")
fmt.Fprintln(w)
// Top-level binary heading + summary.
fmt.Fprintf(w, "# %s\n\n", doc.Binary)
if doc.Summary != "" {
fmt.Fprintf(w, "%s\n\n", doc.Summary)
}
if doc.Homepage != "" {
fmt.Fprintf(w, "Homepage: <%s>\n\n", doc.Homepage)
}
// Workspace context (dynamic, optional — populated in TASK-936).
if ctx := doc.Context; ctx != nil && (ctx.Workspace != "" || ctx.Profile != "" || ctx.Auth != "") {
fmt.Fprintln(w, "## Workspace context")
fmt.Fprintln(w)
if ctx.Workspace != "" {
fmt.Fprintf(w, "- workspace: `%s`\n", ctx.Workspace)
}
if ctx.Profile != "" {
fmt.Fprintf(w, "- profile: `%s`\n", ctx.Profile)
}
if ctx.Auth != "" {
fmt.Fprintf(w, "- auth: `%s`\n", ctx.Auth)
}
fmt.Fprintln(w)
}
// Global flags (emitted once at top level — never duplicated per command).
if len(doc.GlobalFlags) > 0 {
fmt.Fprintln(w, "## Global flags")
fmt.Fprintln(w)
writeFlagsTable(w, doc.GlobalFlags)
fmt.Fprintln(w)
}
// Commands. Sorted by path for deterministic output (golden snapshots).
if len(doc.Commands) > 0 {
paths := make([]string, 0, len(doc.Commands))
for p := range doc.Commands {
paths = append(paths, p)
}
sort.Strings(paths)
for _, p := range paths {
renderCommand(w, doc.Binary, p, doc.Commands[p])
}
}
return nil
}
// renderCommand writes one command's markdown section. Section order
// matches cmdhelp v0.1 §6 exactly.
func renderCommand(w io.Writer, binary, path string, cmd Command) {
fmt.Fprintf(w, "## `%s %s`\n\n", binary, path)
if cmd.Summary != "" {
fmt.Fprintf(w, "%s\n\n", cmd.Summary)
}
// Long-form description: only emit when it adds something beyond Summary.
if cmd.Description != "" && cmd.Description != cmd.Summary {
fmt.Fprintf(w, "%s\n\n", cmd.Description)
}
// 1. Synopsis
fmt.Fprintln(w, "### Synopsis")
fmt.Fprintln(w)
fmt.Fprintf(w, "```\n%s\n```\n\n", renderSynopsis(binary, path, cmd))
// 2. Arguments
if len(cmd.Args) > 0 {
fmt.Fprintln(w, "### Arguments")
fmt.Fprintln(w)
writeArgsTable(w, cmd.Args)
fmt.Fprintln(w)
}
// 3. Flags
if len(cmd.Flags) > 0 {
fmt.Fprintln(w, "### Flags")
fmt.Fprintln(w)
writeFlagsTable(w, cmd.Flags)
fmt.Fprintln(w)
}
// 4. Stdin
if cmd.Stdin != nil && cmd.Stdin.Accepted {
fmt.Fprintln(w, "### Stdin")
fmt.Fprintln(w)
if cmd.Stdin.Format != "" {
fmt.Fprintf(w, "Accepts stdin (`%s`).\n\n", cmd.Stdin.Format)
} else {
fmt.Fprintln(w, "Accepts stdin.")
fmt.Fprintln(w)
}
}
// 5. Examples — same canonical example set as JSON, rendered as
// fenced bash blocks with note as accompanying prose (spec §6).
if len(cmd.Examples) > 0 {
fmt.Fprintln(w, "### Examples")
fmt.Fprintln(w)
for _, ex := range cmd.Examples {
fmt.Fprintf(w, "```bash\n%s\n```\n\n", ex.Cmd)
if ex.Note != "" {
fmt.Fprintf(w, "%s\n\n", ex.Note)
}
}
}
// 6. Output
if cmd.Stdout != nil && (cmd.Stdout.TextTemplate != "" || cmd.Stdout.JSONSchemaRef != "") {
fmt.Fprintln(w, "### Output")
fmt.Fprintln(w)
if cmd.Stdout.TextTemplate != "" {
fmt.Fprintf(w, "Stdout (text): `%s`\n\n", cmd.Stdout.TextTemplate)
}
if cmd.Stdout.JSONSchemaRef != "" {
fmt.Fprintf(w, "Stdout (`--format json`): schema `%s`\n\n", cmd.Stdout.JSONSchemaRef)
}
}
// 7. Exit codes (when present — pad commands don't typically populate
// this yet, but the schema and spec §5.2 support it).
if len(cmd.ExitCodes) > 0 {
fmt.Fprintln(w, "### Exit codes")
fmt.Fprintln(w)
writeExitCodesTable(w, cmd.ExitCodes)
fmt.Fprintln(w)
}
// 8. See also
if len(cmd.SeeAlso) > 0 {
fmt.Fprintln(w, "### See also")
fmt.Fprintln(w)
for _, related := range cmd.SeeAlso {
fmt.Fprintf(w, "- `%s`\n", related)
}
fmt.Fprintln(w)
}
}
// renderSynopsis reconstructs a cobra-style usage line from the
// Document's structured args + flags. Output looks like:
//
// pad item create <collection> <title> [--priority <value>] [flags]
//
// We use the structured Args (not cmd.UseLine()) so the synopsis is
// driven by the same parsed data the JSON emitter uses — keeping the
// two formats in lockstep.
func renderSynopsis(binary, path string, cmd Command) string {
parts := []string{binary}
if path != "" {
parts = append(parts, path)
}
for _, a := range cmd.Args {
var token string
if a.Required {
token = "<" + a.Name + ">"
} else {
token = "[" + a.Name + "]"
}
if a.Repeatable {
token += "..."
}
parts = append(parts, token)
}
if len(cmd.Flags) > 0 {
parts = append(parts, "[flags]")
}
return strings.Join(parts, " ")
}
// writeFlagsTable renders a flag map as a markdown table with sorted
// keys. Repeatable flags are marked in the type column for visibility.
func writeFlagsTable(w io.Writer, flags map[string]Flag) {
fmt.Fprintln(w, "| flag | type | default | description |")
fmt.Fprintln(w, "| --- | --- | --- | --- |")
names := make([]string, 0, len(flags))
for n := range flags {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
f := flags[n]
typ := f.Type
if f.Repeatable {
typ += " (repeatable)"
}
if len(f.Enum) > 0 {
vals := make([]string, len(f.Enum))
for i, v := range f.Enum {
vals[i] = fmt.Sprint(v)
}
typ = fmt.Sprintf("enum: %s", strings.Join(vals, "\\|"))
}
def := ""
if f.Default != nil {
def = fmt.Sprintf("`%v`", f.Default)
}
fmt.Fprintf(w, "| `--%s` | %s | %s | %s |\n",
n, typ, def, escapeTable(f.Description))
}
}
// writeArgsTable renders positional args. Args are emitted in source
// order (no sorting) so the table reflects invocation order.
func writeArgsTable(w io.Writer, args []Arg) {
fmt.Fprintln(w, "| name | type | required | description |")
fmt.Fprintln(w, "| --- | --- | --- | --- |")
for _, a := range args {
typ := a.Type
if a.Repeatable {
typ += " (repeatable)"
}
if len(a.Enum) > 0 {
vals := make([]string, len(a.Enum))
for i, v := range a.Enum {
vals[i] = fmt.Sprint(v)
}
typ = fmt.Sprintf("enum: %s", strings.Join(vals, "\\|"))
}
req := "no"
if a.Required {
req = "yes"
}
fmt.Fprintf(w, "| `%s` | %s | %s | %s |\n",
a.Name, typ, req, escapeTable(a.Description))
}
}
// writeExitCodesTable renders an exit_codes map. Keys are numeric strings
// from the schema; sort them numerically (best-effort string sort works
// for sensible exit codes 0-255).
func writeExitCodesTable(w io.Writer, codes map[string]ExitCode) {
fmt.Fprintln(w, "| code | when | recovery |")
fmt.Fprintln(w, "| --- | --- | --- |")
keys := make([]string, 0, len(codes))
for k := range codes {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
c := codes[k]
when := c.When
if when == "" {
when = c.Description
}
fmt.Fprintf(w, "| `%s` | %s | %s |\n", k, escapeTable(when), escapeTable(c.Recovery))
}
}
// escapeTable replaces newlines and pipes so a value can sit in a
// markdown table cell without breaking the table grid.
func escapeTable(s string) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "|", `\|`)
return strings.TrimSpace(s)
}