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
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
22 changed files with 485 additions and 32 deletions

View file

@ -7,6 +7,7 @@
# full list see the documentation:
# https://www.sphinx-doc.org/en/master/config
import glob
import os
import re
import subprocess
@ -27,7 +28,7 @@ if kitty_src not in sys.path:
sys.path.insert(0, kitty_src)
from kitty.conf.types import Definition, expand_opt_references # noqa
from kitty.constants import str_version, website_url # noqa
from kitty.constants import str_version, website_url # noqa
# config {{{
# -- Project information -----------------------------------------------------
@ -176,8 +177,8 @@ manpages_url = 'https://man7.org/linux/man-pages/man{section}/{page}.{section}.h
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('invocation', 'kitty', 'kitty Documentation', [author], 1),
('conf', 'kitty.conf', 'kitty terminal emulator configuration file', [author], 5)
('invocation', 'kitty', 'The fast, feature rich terminal emulator', [author], 1),
('conf', 'kitty.conf', 'Configuration file for kitty', [author], 5)
]
@ -549,10 +550,13 @@ def add_html_context(app: Any, pagename: str, templatename: str, context: Any, d
@lru_cache
def monkeypatch_man_writer() -> None:
'''
Monkeypatch the docutils man translator to output better tables
Monkeypatch the docutils man translator to be nicer
'''
from docutils.writers.manpage import Table, Translator
from docutils.nodes import Element
from sphinx.writers.manpage import ManualPageTranslator
# Generate nicer tables https://sourceforge.net/p/docutils/bugs/475/
class PatchedTable(Table): # type: ignore
_options: list[str]
def __init__(self) -> None:
@ -572,15 +576,100 @@ def monkeypatch_man_writer() -> None:
del ans[3] # top border
del ans[-2] # bottom border
return ans
def visit_table(self: Translator, node: object) -> None:
def visit_table(self: ManualPageTranslator, node: object) -> None:
setattr(self, '_active_table', PatchedTable())
setattr(Translator, 'visit_table', visit_table)
setattr(ManualPageTranslator, 'visit_table', visit_table)
# Improve header generation
def header(self: ManualPageTranslator) -> str:
di = getattr(self, '_docinfo')
di['ktitle'] = di['title'].replace('_', '-')
th = (".TH \"%(ktitle)s\" %(manual_section)s"
" \"%(date)s\" \"%(version)s\"") % di
if di["manual_group"]:
th += " \"%(manual_group)s\"" % di
th += "\n"
sh_tmpl: str = (".SH Name\n"
"%(ktitle)s \\- %(subtitle)s\n")
return th + sh_tmpl % di # type: ignore
setattr(ManualPageTranslator, 'header', header)
def visit_image(self: ManualPageTranslator, node: Element) -> None:
pass
def depart_image(self: ManualPageTranslator, node: Element) -> None:
pass
def depart_figure(self: ManualPageTranslator, node: Element) -> None:
self.body.append(' (images not supported)\n')
Translator.depart_figure(self, node)
setattr(ManualPageTranslator, 'visit_image', visit_image)
setattr(ManualPageTranslator, 'depart_image', depart_image)
setattr(ManualPageTranslator, 'depart_figure', depart_figure)
orig_astext = Translator.astext
def astext(self: Translator) -> str:
b = []
for line in self.body:
if line.startswith('.SH'):
x, y = line.split(' ', 1)
parts = y.splitlines(keepends=True)
parts[0] = parts[0].capitalize()
line = x + ' ' + '\n'.join(parts)
b.append(line)
self.body = b
return orig_astext(self)
setattr(Translator, 'astext', astext)
def setup_man_pages() -> None:
from kittens.runner import get_kitten_cli_docs
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for x in glob.glob(os.path.join(base, 'docs/kittens/*.rst')):
kn = os.path.basename(x).rpartition('.')[0]
if kn == 'custom':
continue
cd = get_kitten_cli_docs(kn) or {}
khn = kn.replace('_', '-')
man_pages.append((f'kittens/{kn}', 'kitten-' + khn, cd.get('short_desc', 'kitten Documentation'), [author], 1))
monkeypatch_man_writer()
def build_extra_man_pages() -> None:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
kitten = os.path.join(base, 'kitty/launcher/kitten')
if not os.path.exists(kitten):
raise Exception('The kitten binary is not built cannot generate man pages')
raw = subprocess.check_output([kitten, '-h']).decode()
started = 0
names = set()
for line in raw.splitlines():
if line.strip() == '@':
started = len(line.rstrip()[:-1])
q = line.strip()
if started and len(q.split()) == 1 and not q.startswith('-') and ':' not in q:
if len(line) - len(line.lstrip()) == started:
if not os.path.exists(os.path.join(base, f'docs/kittens/{q}.rst')):
names.add(q)
cwd = os.path.join(base, 'docs/_build/man')
subprocess.check_call([kitten, '__generate_man_pages__'], cwd=cwd)
subprocess.check_call([kitten, '__generate_man_pages__'] + list(names), cwd=cwd)
if building_man_pages:
setup_man_pages()
def build_finished(*a: Any, **kw: Any) -> None:
if building_man_pages:
build_extra_man_pages()
def setup(app: Any) -> None:
os.makedirs('generated/conf', exist_ok=True)
from kittens.runner import all_kitten_names
monkeypatch_man_writer()
kn = all_kitten_names()
write_cli_docs(kn)
write_remote_control_protocol_docs()
@ -589,6 +678,7 @@ def setup(app: Any) -> None:
app.connect('source-read', replace_string)
app.add_config_value('analytics_id', '', 'env')
app.connect('html-page-context', add_html_context)
app.connect('build-finished', build_finished)
app.add_lexer('session', SessionLexer() if version_info[0] < 3 else SessionLexer)
app.add_role('link', link_role)
app.add_role('commit', commit_role)

View file

@ -1,6 +1,11 @@
broadcast
==================================================
.. only:: man
Overview
--------------
*Type text in all kitty windows simultaneously*
The ``broadcast`` kitten can be used to type text simultaneously in all

View file

@ -1,6 +1,11 @@
clipboard
==================================================
.. only:: man
Overview
--------------
*Copy/paste to the system clipboard from shell scripts*
.. highlight:: sh

View file

@ -1,6 +1,12 @@
Hints
==========
.. only:: man
Overview
--------------
|kitty| has a *hints mode* to select and act on arbitrary text snippets
currently visible on the screen. For example, you can press :sc:`open_url`
to choose any URL visible on the screen and then open it using your default web

View file

@ -1,6 +1,12 @@
Hyperlinked grep
=================
.. only:: man
Overview
--------------
.. note::
As of ripgrep versions newer that 13.0 it supports hyperlinks

View file

@ -1,6 +1,12 @@
icat
========================================
.. only:: man
Overview
--------------
*Display images in the terminal*
The ``icat`` kitten can be used to display arbitrary images in the |kitty|

View file

@ -3,6 +3,11 @@ Draw a GPU accelerated dock panel on your desktop
.. highlight:: sh
.. only:: man
Overview
--------------
You can use this kitten to draw a GPU accelerated panel on the edge of your
screen, that shows the output from an arbitrary terminal program.

View file

@ -1,6 +1,12 @@
Query terminal
=================
.. only:: man
Overview
--------------
This kitten is used to query |kitty| from terminal programs about version, values
of various runtime options controlling its features, etc.

View file

@ -1,6 +1,12 @@
Remote files
==============
.. only:: man
Overview
--------------
|kitty| has the ability to easily *Edit*, *Open* or *Download* files from a
computer into which you are SSHed. In your SSH session run::

View file

@ -1,6 +1,11 @@
Truly convenient SSH
=========================================
.. only:: man
Overview
----------------
* Automatic :ref:`shell_integration` on remote hosts
* Easily :ref:`clone local shell/editor config <real_world_ssh_kitten_config>` on remote hosts

View file

@ -1,6 +1,12 @@
Changing kitty colors
========================
.. only:: man
Overview
--------------
The themes kitten allows you to easily change color themes, from a collection of
over three hundred pre-built themes available at `kitty-themes
<https://github.com/kovidgoyal/kitty-themes>`_. To use it, simply run::

View file

@ -1,6 +1,12 @@
Transfer files
================
.. only:: man
Overview
--------------
.. versionadded:: 0.30.0
.. _rsync: https://en.wikipedia.org/wiki/Rsync

View file

@ -1,6 +1,12 @@
Unicode input
================
.. only:: man
Overview
--------------
You can input Unicode characters by name, hex code, recently used and even an
editable favorites list. Press :sc:`input_unicode_character` to start the
unicode input kitten, shown below.

View file

@ -160,3 +160,4 @@ elif __name__ == '__doc__':
cd['usage'] = usage
cd['options'] = OPTIONS
cd['help_text'] = help_text
cd['short_desc'] = 'Broadcast typed text to kitty windows'

View file

@ -149,3 +149,4 @@ elif __name__ == '__doc__':
cd['usage'] = usage
cd['options'] = OPTIONS
cd['help_text'] = help_text
cd['short_desc'] = help_text

View file

@ -258,3 +258,4 @@ elif __name__ == '__doc__':
cd['usage'] = usage
cd['options'] = options_spec
cd['help_text'] = help_text
cd['short_desc'] = 'Query the terminal for various capabilities'

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
},
})
}