mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
5e93abe552
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.
303 lines
8.3 KiB
Go
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)
|
|
}
|