diff --git a/temporalcli/commandsgen/docs.go b/temporalcli/commandsgen/docs.go index e7fef0cec..c0ac28bc9 100644 --- a/temporalcli/commandsgen/docs.go +++ b/temporalcli/commandsgen/docs.go @@ -3,23 +3,28 @@ package commandsgen import ( "bytes" "fmt" + "regexp" "sort" "strings" ) -type DocsFile struct { - FileName string -} - func GenerateDocsFiles(commands Commands) (map[string][]byte, error) { + optionSetMap := make(map[string]OptionSets) for i, optionSet := range commands.OptionSets { optionSetMap[optionSet.Name] = commands.OptionSets[i] } - w := &docWriter{fileMap: make(map[string]*bytes.Buffer), optionSetMap: optionSetMap} + w := &docWriter{ + fileMap: make(map[string]*bytes.Buffer), + optionSetMap: optionSetMap, + usages: commands.Usages, + } + + // cmd-options.mdx + w.writeCommandOptions() - // sort by parent command (activity, batch, etc) + // sorted ascending by full name of command (activity complete, batch list, etc) for _, cmd := range commands.CommandList { if err := cmd.writeDoc(w); err != nil { return nil, fmt.Errorf("failed writing docs for command %s: %w", cmd.FullName, err) @@ -34,27 +39,44 @@ func GenerateDocsFiles(commands Commands) (map[string][]byte, error) { return finalMap, nil } -type docWriter struct { - fileMap map[string]*bytes.Buffer - optionSetMap map[string]OptionSets - optionsStack [][]Option -} - func (c *Command) writeDoc(w *docWriter) error { - commandLength := len(strings.Split(c.FullName, " ")) w.processOptions(c) // If this is a root command, write a new file - if commandLength == 2 { + if c.Depth == 1 { w.writeCommand(c) - } else if commandLength > 2 { + } else if c.Depth > 1 { w.writeSubcommand(c) } return nil } +type docWriter struct { + fileMap map[string]*bytes.Buffer + optionSetMap map[string]OptionSets + optionsStack [][]Option + usages Usages +} + +func (w *docWriter) processOptions(c *Command) { + // Pop options from stack if we are moving up a level + if len(w.optionsStack) >= len(strings.Split(c.FullName, " ")) { + w.optionsStack = w.optionsStack[:len(w.optionsStack)-1] + } + var options []Option + options = append(options, c.Options...) + + // Maintain stack of options available from parent commands + for _, set := range c.OptionSets { + optionSetOptions := w.optionSetMap[set].Options + options = append(options, optionSetOptions...) + } + + w.optionsStack = append(w.optionsStack, options) +} + func (w *docWriter) writeCommand(c *Command) { - fileName := strings.Split(c.FullName, " ")[1] + fileName := c.FileName w.fileMap[fileName] = &bytes.Buffer{} w.fileMap[fileName].WriteString("---\n") w.fileMap[fileName].WriteString("id: " + fileName + "\n") @@ -62,6 +84,7 @@ func (w *docWriter) writeCommand(c *Command) { w.fileMap[fileName].WriteString("sidebar_label: " + c.FullName + "\n") w.fileMap[fileName].WriteString("description: " + c.Docs.DescriptionHeader + "\n") w.fileMap[fileName].WriteString("toc_max_heading_level: 4\n") + w.fileMap[fileName].WriteString("keywords:\n") for _, keyword := range c.Docs.Keywords { w.fileMap[fileName].WriteString(" - " + keyword + "\n") @@ -75,43 +98,126 @@ func (w *docWriter) writeCommand(c *Command) { } func (w *docWriter) writeSubcommand(c *Command) { - fileName := strings.Split(c.FullName, " ")[1] - subCommand := strings.Join(strings.Split(c.FullName, " ")[2:], "") - w.fileMap[fileName].WriteString("## " + subCommand + "\n\n") + fileName := c.FileName + prefix := strings.Repeat("#", c.Depth) + w.fileMap[fileName].WriteString(prefix + " " + c.LeafName + "\n\n") w.fileMap[fileName].WriteString(c.Description + "\n\n") - w.fileMap[fileName].WriteString("Use the following options to change the behavior of this command.\n\n") - // gather options from command and all options aviailable from parent commands - var allOptions = make([]Option, 0) - for _, options := range w.optionsStack { - allOptions = append(allOptions, options...) - } + if len(c.Children) == 0 { + w.fileMap[fileName].WriteString("Use the following options to change the behavior of this command.\n\n") - // alphabetize options - sort.Slice(allOptions, func(i, j int) bool { - return allOptions[i].Name < allOptions[j].Name - }) + // gather options from command and all options aviailable from parent commands + var allOptions = make([]Option, 0) + for _, options := range w.optionsStack { + allOptions = append(allOptions, options...) + } - for _, option := range allOptions { - w.fileMap[fileName].WriteString(fmt.Sprintf("## %s\n\n", option.Name)) - w.fileMap[fileName].WriteString(option.Description + "\n\n") + // alphabetize options + sort.Slice(allOptions, func(i, j int) bool { + return allOptions[i].Name < allOptions[j].Name + }) + for _, option := range allOptions { + w.fileMap[fileName].WriteString(fmt.Sprintf("- [--%s](/cli/cmd-options#%s)\n\n", option.Name, option.Name)) + } } } -func (w *docWriter) processOptions(c *Command) { - // Pop options from stack if we are moving up a level - if len(w.optionsStack) >= len(strings.Split(c.FullName, " ")) { - w.optionsStack = w.optionsStack[:len(w.optionsStack)-1] - } - var options []Option - options = append(options, c.Options...) +func (w *docWriter) writeCommandOptions() { + fileName := "cmd-options" + w.fileMap[fileName] = &bytes.Buffer{} + w.fileMap[fileName].WriteString("---\n") + w.fileMap[fileName].WriteString("id: " + fileName + "\n") + w.fileMap[fileName].WriteString("title: Temporal CLI command options reference\n") + w.fileMap[fileName].WriteString("sidebar_label: cmd options\n") + w.fileMap[fileName].WriteString("description: Discover how to manage Temporal Workflows, from Activity Execution to Workflow Ids, using clusters, cron schedules, dynamic configurations, and logging. Perfect for developers.\n") + w.fileMap[fileName].WriteString("toc_max_heading_level: 4\n") - // Maintain stack of options available from parent commands - for _, set := range c.OptionSets { - optionSetOptions := w.optionSetMap[set].Options - options = append(options, optionSetOptions...) + w.fileMap[fileName].WriteString("keywords:\n") + w.fileMap[fileName].WriteString(" - " + "cli reference" + "\n") + w.fileMap[fileName].WriteString(" - " + "command line interface cli" + "\n") + w.fileMap[fileName].WriteString(" - " + "temporal cli" + "\n") + + w.fileMap[fileName].WriteString("tags:\n") + w.fileMap[fileName].WriteString(" - " + "cli-reference" + "\n") + w.fileMap[fileName].WriteString(" - " + "command-line-interface-cli" + "\n") + w.fileMap[fileName].WriteString(" - " + "temporal-cli" + "\n") + + w.fileMap[fileName].WriteString("---\n\n") + + /////// option a + for _, option := range w.usages.OptionUsagesByOptionDescription { + w.fileMap[fileName].WriteString(fmt.Sprintf("## %s\n\n", option.OptionName)) + + if len(option.Usages) == 1 { + usageDescription := option.Usages[0] + usage := usageDescription.UsageSites[0] + w.fileMap[fileName].WriteString(encodeJSONExample(usage.Option.Description) + "\n\n") + + if usage.Option.Experimental { + w.fileMap[fileName].WriteString(":::note" + "\n\n") + w.fileMap[fileName].WriteString("Option is experimental." + "\n\n") + w.fileMap[fileName].WriteString(":::" + "\n\n") + } + } else { + for i, usageDescription := range option.Usages { + if i > 0 { + w.fileMap[fileName].WriteString("\n") + + } + w.fileMap[fileName].WriteString(encodeJSONExample(usageDescription.OptionDescription) + "\n\n") + + for _, usage := range usageDescription.UsageSites { + experimentalDescr := "" + if usage.Option.Experimental { + experimentalDescr = " (option usage is EXPERIMENTAL)" + } + if usage.UsageSiteType == UsageTypeCommand { + w.fileMap[fileName].WriteString("- `" + usage.UsageSiteDescription + "`" + experimentalDescr + "\n") + } else { + w.fileMap[fileName].WriteString("- " + usage.UsageSiteDescription + experimentalDescr + "\n") + } + } + + } + } } - w.optionsStack = append(w.optionsStack, options) + /////// option b + + /* + + for _, option := range w.usages.OptionUsages { + w.fileMap[fileName].WriteString(fmt.Sprintf("## %s\n\n", option.OptionName)) + + if len(option.Usages) == 1 { + usage := option.Usages[0] + w.fileMap[fileName].WriteString(usage.Option.Description + "\n\n") + + if usage.Option.Experimental { + w.fileMap[fileName].WriteString(":::note" + "\n\n") + w.fileMap[fileName].WriteString("Option is experimental and may be removed at a future date." + "\n\n") + w.fileMap[fileName].WriteString(":::" + "\n\n") + } + } else { + for _, usage := range option.Usages { + w.fileMap[fileName].WriteString("**" + usage.UsageDescription + "**\n") + w.fileMap[fileName].WriteString(usage.Option.Description + "\n\n") + + if usage.Option.Experimental { + w.fileMap[fileName].WriteString(":::note" + "\n\n") + w.fileMap[fileName].WriteString("Option is experimental and may be removed at a future date." + "\n\n") + w.fileMap[fileName].WriteString(":::" + "\n\n") + } + } + } + } + */ +} + +func encodeJSONExample(v string) string { + // example: `'YourKey={"your": "value"}'` + re := regexp.MustCompile(`('[a-zA-Z0-9]*={.*}')`) + v = re.ReplaceAllString(v, "`$1`") + return v } diff --git a/temporalcli/commandsgen/parse.go b/temporalcli/commandsgen/parse.go index d4aea8764..68bb3397c 100644 --- a/temporalcli/commandsgen/parse.go +++ b/temporalcli/commandsgen/parse.go @@ -8,6 +8,7 @@ import ( "fmt" "regexp" "slices" + "sort" "strings" "gopkg.in/yaml.v3" @@ -19,15 +20,16 @@ var CommandsYAML []byte type ( // Option represents the structure of an option within option sets. Option struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - Description string `yaml:"description"` - Short string `yaml:"short,omitempty"` - Default string `yaml:"default,omitempty"` - Env string `yaml:"env,omitempty"` - Required bool `yaml:"required,omitempty"` - Aliases []string `yaml:"aliases,omitempty"` - EnumValues []string `yaml:"enum-values,omitempty"` + Name string `yaml:"name"` + Type string `yaml:"type"` + Description string `yaml:"description"` + Short string `yaml:"short,omitempty"` + Default string `yaml:"default,omitempty"` + Env string `yaml:"env,omitempty"` + Required bool `yaml:"required,omitempty"` + Aliases []string `yaml:"aliases,omitempty"` + EnumValues []string `yaml:"enum-values,omitempty"` + Experimental bool `yaml:"experimental,omitempty"` } // Command represents the structure of each command in the commands map. @@ -45,6 +47,15 @@ type ( Options []Option `yaml:"options"` OptionSets []string `yaml:"option-sets"` Docs Docs `yaml:"docs"` + Index int + Base *Command + Parent *Command + Children []*Command + Depth int + FileName string + SubCommandName string + LeafName string + MaxChildDepth int } // Docs represents docs-only information that is not used in CLI generation. @@ -55,15 +66,50 @@ type ( // OptionSets represents the structure of option sets. OptionSets struct { - Name string `yaml:"name"` - Options []Option `yaml:"options"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Options []Option `yaml:"options"` } // Commands represents the top-level structure holding commands and option sets. Commands struct { CommandList []Command `yaml:"commands"` OptionSets []OptionSets `yaml:"option-sets"` + Usages Usages + } + + Usages struct { + OptionUsages []OptionUsages + OptionUsagesByOptionDescription []OptionUsagesByOptionDescription + } + + OptionUsages struct { + OptionName string + UsageSites []OptionUsageSite + } + + OptionUsageSite struct { + Option Option + UsageSiteDescription string + UsageSiteType UsageSiteType + } + + UsageSiteType string + + OptionUsagesByOptionDescription struct { + OptionName string + Usages []OptionUsageByOptionDescription } + + OptionUsageByOptionDescription struct { + OptionDescription string + UsageSites []OptionUsageSite + } +) + +const ( + UsageTypeCommand UsageSiteType = "command" + UsageTypeOptionSet UsageSiteType = "optionset" ) func ParseCommands() (Commands, error) { @@ -87,7 +133,8 @@ func ParseCommands() (Commands, error) { return Commands{}, fmt.Errorf("failed parsing command section %q: %w", command.FullName, err) } } - return m, nil + + return enrichCommands(m) } var markdownLinkPattern = regexp.MustCompile(`\[(.*?)\]\((.*?)\)`) @@ -206,3 +253,239 @@ func (o *Option) processSection() error { } return nil } + +// EnrichCommands populates additional fields on Commands +// beyond those read from commands.yml to make it easier +// for docs.go to generate docs +func enrichCommands(m Commands) (Commands, error) { + commandLookup := make(map[string]*Command) + + for i, command := range m.CommandList { + m.CommandList[i].Index = i + commandLookup[command.FullName] = &m.CommandList[i] + } + + var rootCommand *Command + + //populate parent and basic meta + for i, c := range m.CommandList { + commandLength := len(strings.Split(c.FullName, " ")) + if commandLength == 1 { + rootCommand = &m.CommandList[i] + continue + } + parentName := strings.Join(strings.Split(c.FullName, " ")[:commandLength-1], " ") + parent, ok := commandLookup[parentName] + if ok { + m.CommandList[i].Parent = &m.CommandList[parent.Index] + m.CommandList[i].Depth = len(strings.Split(c.FullName, " ")) - 1 + m.CommandList[i].FileName = strings.Split(c.FullName, " ")[1] + m.CommandList[i].LeafName = strings.Join(strings.Split(c.FullName, " ")[m.CommandList[i].Depth:], "") + } + } + + //populate children and base command + for _, c := range m.CommandList { + if c.Parent == nil { + continue + } + + m.CommandList[c.Parent.Index].Children = append(m.CommandList[c.Parent.Index].Children, &m.CommandList[c.Index]) + + base := &c + for base.Depth > 1 { + base = base.Parent + } + m.CommandList[c.Index].Base = &m.CommandList[base.Index] + } + + setMaxChildDepthVisitor(*rootCommand, &m) + + for i, c := range m.CommandList { + if c.Parent == nil { + continue + } + + subCommandStartDepth := 1 + if c.Base.MaxChildDepth > 2 { + subCommandStartDepth = 2 + } + + subCommandName := "" + if c.Depth >= subCommandStartDepth { + subCommandName = strings.Join(strings.Split(c.FullName, " ")[subCommandStartDepth:], " ") + } + + if len(subCommandName) == 0 && c.Depth == 1 { + // for operator base command to show up in tags, keywords, etc. + subCommandName = c.LeafName + } + + m.CommandList[i].SubCommandName = subCommandName + } + + // sorted ascending by full name of command (activity complete, batch list, etc) + sortChildrenVisitor(rootCommand) + + // pull flat list in same order as sorted children + m.CommandList = make([]Command, 0) + collectCommandVisitor(*rootCommand, &m) + + // option usages + optionUsages := getAllOptionUsages(m) + optionUsagesByOptionDescription := getOptionUsagesByOptionDescription(optionUsages) + m.Usages = Usages{ + OptionUsages: optionUsages, + OptionUsagesByOptionDescription: optionUsagesByOptionDescription, + } + + return m, nil +} + +func collectCommandVisitor(c Command, m *Commands) { + + m.CommandList = append(m.CommandList, c) + + for _, child := range c.Children { + collectCommandVisitor(*child, m) + } +} + +func sortChildrenVisitor(c *Command) { + sort.Slice(c.Children, func(i, j int) bool { + return c.Children[i].FullName < c.Children[j].FullName + }) + for _, command := range c.Children { + sortChildrenVisitor(command) + } +} + +func setMaxChildDepthVisitor(c Command, commands *Commands) int { + maxChildDepth := 0 + children := commands.CommandList[c.Index].Children + if len(children) > 0 { + for _, child := range children { + depth := setMaxChildDepthVisitor(*child, commands) + if depth > maxChildDepth { + maxChildDepth = depth + } + } + } + + commands.CommandList[c.Index].MaxChildDepth = maxChildDepth + return maxChildDepth + 1 +} + +func getAllOptionUsages(commands Commands) []OptionUsages { + var optionUsageSitesMap = make(map[string]map[string]OptionUsageSite) + + // option sets + for i, optionSet := range commands.OptionSets { + usage := optionSet.Description + if len(usage) == 0 { + usage = optionSet.Name + } + + for j, option := range optionSet.Options { + _, found := optionUsageSitesMap[option.Name] + if !found { + optionUsageSitesMap[option.Name] = make(map[string]OptionUsageSite) + } + optionUsageSitesMap[option.Name][optionSet.Name] = OptionUsageSite{ + Option: commands.OptionSets[i].Options[j], + UsageSiteDescription: usage, + UsageSiteType: UsageTypeOptionSet, + } + } + } + + //command options + for i, cmd := range commands.CommandList { + usage := cmd.FullName + if len(usage) == 0 { + usage = cmd.FullName + } + + for j, option := range cmd.Options { + _, found := optionUsageSitesMap[option.Name] + if !found { + optionUsageSitesMap[option.Name] = make(map[string]OptionUsageSite) + } + optionUsageSitesMap[option.Name][cmd.FullName] = OptionUsageSite{ + Option: commands.CommandList[i].Options[j], + UsageSiteDescription: usage, + UsageSiteType: UsageTypeOptionSet, + } + } + } + + // all options + var allOptionUsages = make([]OptionUsages, 0) + + for optionName, usages := range optionUsageSitesMap { + option := OptionUsages{ + OptionName: optionName, + UsageSites: make([]OptionUsageSite, 0), + } + for _, usage := range usages { + option.UsageSites = append(option.UsageSites, usage) + } + allOptionUsages = append(allOptionUsages, option) + } + + sort.Slice(allOptionUsages, func(i, j int) bool { + return allOptionUsages[i].OptionName < allOptionUsages[j].OptionName + }) + + for u := range allOptionUsages { + sort.Slice(allOptionUsages[u].UsageSites, func(i, j int) bool { + return allOptionUsages[u].UsageSites[i].UsageSiteDescription < allOptionUsages[u].UsageSites[j].UsageSiteDescription + }) + } + + return allOptionUsages +} + +func getOptionUsagesByOptionDescription(allOptionUsages []OptionUsages) []OptionUsagesByOptionDescription { + out := make([]OptionUsagesByOptionDescription, len(allOptionUsages)) + + for i, optionUsages := range allOptionUsages { + out[i].OptionName = optionUsages.OptionName + + if len(optionUsages.UsageSites) == 1 { + usage := allOptionUsages[i].UsageSites[0] + out[i].Usages = make([]OptionUsageByOptionDescription, 1) + out[i].Usages[0].OptionDescription = usage.Option.Description + out[i].Usages[0].UsageSites = []OptionUsageSite{usage} + + continue + } + + optionUsageByOptionDescriptionMap := make(map[string]OptionUsageByOptionDescription) + + // collate on option description in each usage site + for j, usage := range optionUsages.UsageSites { + _, found := optionUsageByOptionDescriptionMap[usage.Option.Description] + if !found { + optionUsageByOptionDescriptionMap[usage.Option.Description] = OptionUsageByOptionDescription{ + OptionDescription: usage.Option.Description, + UsageSites: make([]OptionUsageSite, 0), + } + } + u := optionUsageByOptionDescriptionMap[usage.Option.Description] + u.UsageSites = append(u.UsageSites, allOptionUsages[i].UsageSites[j]) + + // put all distinct option descriptions withing the option usages + optionUsageByOptionDescriptionMap[u.OptionDescription] = u + } + + out[i].Usages = make([]OptionUsageByOptionDescription, len(optionUsageByOptionDescriptionMap)) + j := 0 + for _, v := range optionUsageByOptionDescriptionMap { + out[i].Usages[j] = v + j++ + } + } + + return out +}