Generate man pages for kitten and all its sub-commands recursively

Fixes #6808
This commit is contained in:
Kovid Goyal
2023-11-11 17:09:23 +05:30
parent 0f2196357c
commit 70bc4f1033
22 changed files with 485 additions and 32 deletions

View File

@@ -11,12 +11,6 @@ import (
var _ = fmt.Print
var _ = os.Getenv
func (self *Completions) add_group(group *MatchGroup) {
if len(group.Matches) > 0 {
self.Groups = append(self.Groups, group)
}
}
func (self *Completions) add_options_group(options []*Option, word string) {
group := self.AddMatchGroup("Options")
if strings.HasPrefix(word, "--") {
@@ -122,7 +116,6 @@ func complete_word(word string, completions *Completions, only_args_allowed bool
if cmd.ArgCompleter != nil {
cmd.ArgCompleter(completions, word, arg_num)
}
return
}
func completion_parse_args(cmd *Command, words []string, completions *Completions) {

View File

@@ -8,12 +8,15 @@ import (
"os"
"os/exec"
"strings"
"time"
"golang.org/x/exp/slices"
"golang.org/x/sys/unix"
"kitty"
"kitty/tools/cli/markup"
"kitty/tools/tty"
"kitty/tools/utils"
"kitty/tools/utils/style"
)
@@ -62,6 +65,37 @@ func (self *Command) FormatSubCommands(output io.Writer, formatter *markup.Conte
}
func (self *Option) FormatOptionForMan(output io.Writer) {
fmt.Fprintln(output, ".TP")
fmt.Fprint(output, ".BI \"")
for i, a := range self.Aliases {
fmt.Fprint(output, a.String())
if i != len(self.Aliases)-1 {
fmt.Fprint(output, ", ")
}
}
fmt.Fprint(output, "\" ")
defval := self.Default
switch self.OptionType {
case StringOption:
if self.IsList {
defval = ""
}
case BoolOption, CountOption:
defval = ""
}
if defval != "" {
fmt.Fprintf(output, "\" [=%s]\"", escape_text_for_man(defval))
}
fmt.Fprintln(output)
fmt.Fprintln(output, escape_help_for_man(self.Help))
if self.Choices != nil {
fmt.Fprintln(output)
fmt.Fprintln(output, "Choices: "+strings.Join(self.Choices, ", "))
}
}
func (self *Option) FormatOption(output io.Writer, formatter *markup.Context, screen_width int) {
fmt.Fprint(output, " ")
for i, a := range self.Aliases {
@@ -101,6 +135,82 @@ func ShowHelpInPager(text string) {
_ = pager.Run()
}
func (self *Command) GenerateManPages(level int, recurse bool) (err error) {
var names []string
p := self
for p != nil {
names = append(names, p.Name)
p = p.Parent
}
slices.Reverse(names)
name := strings.Join(names, "-")
outf, err := os.Create(fmt.Sprintf("%s.%d", name, level))
if err != nil {
return err
}
defer outf.Close()
fmt.Fprintf(outf, `.TH "%s" "1" "%s" "%s" "%s"`, name, time.Now().Format("Jan 02, 2006"), kitty.VersionString, "kitten Manual")
fmt.Fprintln(outf)
fmt.Fprintln(outf, ".SH Name")
fmt.Fprintln(outf, name, "\\-", escape_text_for_man(self.ShortDescription))
fmt.Fprintln(outf, ".SH Usage")
fmt.Fprintln(outf, ".SY", `"`+self.CommandStringForUsage()+` `+self.Usage+`"`)
fmt.Fprintln(outf, ".YS")
if self.HelpText != "" {
fmt.Fprintln(outf, ".SH Description")
fmt.Fprintln(outf, escape_help_for_man(self.HelpText))
}
if self.HasVisibleSubCommands() {
for _, g := range self.SubCommandGroups {
if !g.HasVisibleSubCommands() {
continue
}
title := g.Title
if title == "" {
title = "Commands"
}
fmt.Fprintln(outf, ".SH", title)
for _, c := range utils.Sort(g.SubCommands, func(a, b *Command) int { return strings.Compare(a.Name, b.Name) }) {
if c.Hidden {
continue
}
if recurse {
if err = c.GenerateManPages(level, recurse); err != nil {
return err
}
}
fmt.Fprintln(outf, ".TP", "2")
fmt.Fprintln(outf, c.Name)
fmt.Fprintln(outf, escape_text_for_man(c.ShortDescription)+".", "See: ")
fmt.Fprintf(outf, ".MR %s %d\n", name+"-"+c.Name, level)
}
}
fmt.Fprintln(outf, ".PP")
fmt.Fprintln(outf, "Get help for an individual command by running:")
fmt.Fprintln(outf, ".SY", self.CommandStringForUsage())
fmt.Fprintln(outf, "command", "-h")
fmt.Fprintln(outf, ".YS")
}
group_titles, gmap := self.GetVisibleOptions()
if len(group_titles) > 0 {
for _, title := range group_titles {
ptitle := title
if title == "" {
ptitle = "Options"
}
fmt.Fprintln(outf, ".SH", ptitle)
for _, opt := range gmap[title] {
opt.FormatOptionForMan(outf)
}
}
}
return
}
func (self *Command) ShowHelpWithCommandString(cs string) {
formatter := markup.New(tty.IsTerminal(os.Stdout.Fd()))
screen_width := 80

View File

@@ -53,7 +53,7 @@ func New(allow_escape_codes bool) *Context {
return &ans
}
func remove_backslash_escapes(text string) string {
func Remove_backslash_escapes(text string) string {
// see https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#escaping-mechanism
out := strings.Builder{}
prev_was_slash := false
@@ -75,11 +75,11 @@ func remove_backslash_escapes(text string) string {
return out.String()
}
func replace_all_rst_roles(str string, repl func(rst_format_match) string) string {
var m rst_format_match
func ReplaceAllRSTRoles(str string, repl func(Rst_format_match) string) string {
var m Rst_format_match
rf := func(full_match string, groupdict map[string]utils.SubMatch) string {
m.payload = groupdict["payload"].Text
m.role = groupdict["role"].Text
m.Payload = groupdict["payload"].Text
m.Role = groupdict["role"].Text
return repl(m)
}
return utils.ReplaceAll(utils.MustCompile(":(?P<role>[a-z]+):(?:(?:`(?P<payload>[^`]+)`)|(?:'(?P<payload>[^']+)'))"), str, rf)
@@ -103,35 +103,35 @@ func (self *Context) hyperlink_for_path(path string, text string) string {
return self.hyperlink_for_url(url, text)
}
func text_and_target(x string) (text string, target string) {
func Text_and_target(x string) (text string, target string) {
parts := strings.SplitN(x, "<", 2)
text = strings.TrimSpace(parts[0])
target = strings.TrimRight(parts[len(parts)-1], ">")
return
}
type rst_format_match struct {
role, payload string
type Rst_format_match struct {
Role, Payload string
}
func (self *Context) link(x string) string {
text, url := text_and_target(x)
text, url := Text_and_target(x)
return self.hyperlink_for_url(url, text)
}
func (self *Context) ref_hyperlink(x string, prefix string) string {
text, target := text_and_target(x)
text, target := Text_and_target(x)
url := "kitty+doc://" + utils.Hostname() + "/#ref=" + prefix + target
text = replace_all_rst_roles(text, func(group rst_format_match) string {
return group.payload
text = ReplaceAllRSTRoles(text, func(group Rst_format_match) string {
return group.Payload
})
return self.hyperlink_for_url(url, text)
}
func (self *Context) Prettify(text string) string {
return replace_all_rst_roles(text, func(group rst_format_match) string {
val := group.payload
switch group.role {
return ReplaceAllRSTRoles(text, func(group Rst_format_match) string {
val := group.Payload
switch group.Role {
case "file":
if val == "kitty.conf" && self.fmt_ctx.AllowEscapeCodes {
path := filepath.Join(utils.ConfigDir(), val)
@@ -141,7 +141,7 @@ func (self *Context) Prettify(text string) string {
case "env", "envvar":
return self.ref_hyperlink(val, "envvar-")
case "doc":
text, target := text_and_target(val)
text, target := Text_and_target(val)
no_title := text == target
target = strings.Trim(target, "/")
if title, ok := kitty.DocTitleMap[target]; ok && no_title {
@@ -163,7 +163,7 @@ func (self *Context) Prettify(text string) string {
case "term":
return self.ref_hyperlink(val, "term-")
case "code":
return self.Code(remove_backslash_escapes(val))
return self.Code(Remove_backslash_escapes(val))
case "link":
return self.link(val)
case "option":

View File

@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"kitty/tools/cli/markup"
"kitty/tools/utils"
)
@@ -193,6 +194,168 @@ func indent_of_line(x string) int {
return len(x) - len(strings.TrimLeft(x, " \n\t\v\f"))
}
func escape_text_for_man(raw string) string {
italic := func(x string) string {
return "\n.I " + x
}
bold := func(x string) string {
return "\n.B " + x
}
text_without_target := func(val string) string {
text, target := markup.Text_and_target(val)
no_title := text == target
if no_title {
return val
}
return text
}
ref_hyperlink := func(val, prefix string) string {
return text_without_target(val)
}
raw = markup.ReplaceAllRSTRoles(raw, func(group markup.Rst_format_match) string {
val := group.Payload
switch group.Role {
case "file":
return italic(val)
case "env", "envvar":
return bold(val)
case "doc":
return text_without_target(val)
case "iss":
return "Issue #" + val
case "pull":
return "PR #" + val
case "disc":
return "Discussion #" + val
case "ref":
return ref_hyperlink(val, "")
case "ac":
return ref_hyperlink(val, "action-")
case "term":
return ref_hyperlink(val, "term-")
case "code":
return markup.Remove_backslash_escapes(val)
case "link":
return text_without_target(val)
case "option":
idx := strings.LastIndex(val, "--")
if idx < 0 {
idx = strings.Index(val, "-")
}
if idx > -1 {
val = strings.TrimSuffix(val[idx:], ">")
}
return bold(val)
case "opt":
return bold(val)
case "yellow":
return val
case "blue":
return val
case "green":
return val
case "cyan":
return val
case "magenta":
return val
case "emph":
return val
default:
return val
}
})
sb := strings.Builder{}
sb.Grow(2 * len(raw))
replacements := map[rune]string{
'"': `\[dq]`, '\'': `\[aq]`, '-': `\-`, '\\': `\e`, '^': `\(ha`, '`': `\(ga`, '~': `\(ti`,
}
for _, ch := range raw {
if rep, found := replacements[ch]; found {
sb.WriteString(rep)
} else {
sb.WriteRune(ch)
}
}
return sb.String()
}
func escape_help_for_man(raw string) string {
help := strings.Builder{}
help.Grow(len(raw) + 256)
prev_indent := 0
in_code_block := false
lines := utils.Splitlines(raw)
handle_non_empty_line := func(i int, line string) int {
if strings.TrimSpace(line) == "#placeholder_for_formatting#" {
return i + 1
}
if strings.HasPrefix(line, ".. code::") {
in_code_block = true
return i + 1
}
current_indent := indent_of_line(line)
if current_indent > 1 {
if prev_indent == 0 {
help.WriteString("\n")
} else {
line = strings.TrimSpace(line)
}
}
prev_indent = current_indent
if help.Len() > 0 && !strings.HasSuffix(help.String(), "\n") {
help.WriteString(" ")
}
help.WriteString(line)
return i
}
handle_empty_line := func(i int, line string) int {
prev_indent = 0
help.WriteString("\n")
if !strings.HasSuffix(help.String(), "::") {
help.WriteString("\n")
}
return i
}
handle_code_block_line := func(i int, line string) int {
if line == "" {
help.WriteString("\n")
return i
}
current_indent := indent_of_line(line)
if current_indent == 0 {
in_code_block = false
return handle_non_empty_line(i, line)
}
line = line[4:]
is_prompt := strings.HasPrefix(line, "$ ")
if is_prompt {
help.WriteString(":yellow:`$ `")
line = line[2:]
}
help.WriteString(line)
help.WriteString("\n")
return i
}
for i := 0; i < len(lines); i++ {
line := lines[i]
if in_code_block {
i = handle_code_block_line(i, line)
continue
}
if line != "" {
i = handle_non_empty_line(i, line)
} else {
i = handle_empty_line(i, line)
}
}
return escape_text_for_man(help.String())
}
func prepare_help_text_for_display(raw string) string {
help := strings.Builder{}
help.Grow(len(raw) + 256)

View File

@@ -20,7 +20,8 @@ func main() {
return
}
root := cli.NewRootCommand()
root.ShortDescription = "Fast, statically compiled implementations for various kittens (command line tools for use with kitty)"
root.ShortDescription = "Fast, statically compiled implementations of various kittens (command line tools for use with kitty)"
root.HelpText = "kitten serves as a launcher for running individual kittens. Each kitten can be run as :code:`kitten command`. The list of available kittens is given below."
root.Usage = "command [command options] [command args]"
root.Run = func(cmd *cli.Command, args []string) (int, error) {
cmd.ShowHelp()

View File

@@ -96,4 +96,29 @@ func KittyToolEntryPoints(root *cli.Command) {
return confirm_and_run_shebang(args)
},
})
// __generate_man_pages__
root.AddSubCommand(&cli.Command{
Name: "__generate_man_pages__",
Hidden: true,
OnlyArgsAllowed: true,
Run: func(cmd *cli.Command, args []string) (rc int, err error) {
q := root
if len(args) > 0 {
for _, scname := range args {
sc := q.FindSubCommand(scname)
if sc == nil {
return 1, fmt.Errorf("No sub command named: %s found", scname)
}
if err = sc.GenerateManPages(1, true); err != nil {
return 1, err
}
}
} else {
if err = q.GenerateManPages(1, false); err != nil {
rc = 1
}
}
return
},
})
}