Download
223 lines 6 KiBPythonModified 2025-11-29executable raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
#!/bin/env python3

# This script converts API YAML files into markdown documentation
# and prints it to standard out.

from datetime import datetime
import re
import yaml

def escape(text, spaces=False):
    """
    Escapes text for safe inclusion in groff documents.
    - Escapes backslashes as '\\e'
    - Escapes periods at the start of a line as '\\&.'
    """
    escaped_lines = []
    for line in text.splitlines():
        # Escape backslashes
        line = line.replace("\\", r"\[rs]")
        # Escape leading period or apostrophe (groff macro triggers)
        if line.startswith('.'):
            line = r'\&' + line
        elif line.startswith("'"):
            line = r'\&' + line
        if spaces:
            line = line.replace(' ', '\\ ')
        escaped_lines.append(line)
    return '\n'.join(escaped_lines)

def arg_signature(name, arg):
    sig = name
    if "type" in arg:
        sig = sig + ": " + arg["type"]
    if "default" in arg:
        sig = sig + " = " + arg["default"]
    return sig

def get_signature(name, info):
    if "type" in info:
        return ".BI " +escape(f'{name} : {info["type"]}', spaces=True)
    sig = f'{name} : func('
    if info["args"]:
        sig += ", ".join(arg_signature(name,arg) for name,arg in info["args"].items())
        sig += " -> " + info["return"].get("type", "Void")
    else:
        sig += "-> " + info["return"].get("type", "Void")
    sig += ')'
    return ".BI " + escape(sig, spaces=True)

template = ''''\\" t
.\\" Copyright (c) {year} Bruce Hill
.\\" All rights reserved.
.\\"
.TH {name} 3 {date} "Tomo man-pages"
.SH NAME
{name} \\- {short}
.SH LIBRARY
Tomo Standard Library
.SH SYNOPSIS
.nf
{signature}
.fi
.SH DESCRIPTION
{description}
'''

arg_prefix = '''
.TS
allbox;
lb lb lbx lb
l l l l.
Name	Type	Description	Default'''

arg_prefix_no_default = '''
.TS
allbox;
lb lb lbx
l l l.
Name	Type	Description'''

type_template = ''''\\" t
.\\" Copyright (c) {year} Bruce Hill
.\\" All rights reserved.
.\\"
.TH {type} 3 {date} "Tomo man-pages"
.SH NAME
{type} \\- a Tomo type
.SH LIBRARY
Tomo Standard Library
.fi
.SH METHODS
{methods}
'''

def markdown_to_roff(text):
    text = text.replace('\n', ' ')
    text = re.sub(r'`([^`]*)`', '\\\\fB\\1\\\\fR', text)
    return text

def write_method(path, name, info):
    lines = []
    year = datetime.now().strftime("%Y")
    date = datetime.now().strftime("%Y-%m-%d")
    signature = get_signature(name, info)
    lines.append(template.format(year=year, date=date, name=name, short=info["short"], description=info["description"], signature=signature))

    if "args" in info and info["args"]:
        lines.append(".SH ARGUMENTS")
        has_defaults = any('default' in a for a in info['args'].values())
        lines.append(arg_prefix if has_defaults else arg_prefix_no_default)
        for arg,arg_info in info["args"].items():
            if has_defaults:
                default = escape(arg_info['default'], spaces=True) if 'default' in arg_info else '-'
                description = markdown_to_roff(arg_info['description'])
                lines.append(f"{arg}\t{arg_info.get('type', '')}\t{description}\t{default}")
            else:
                description = markdown_to_roff(arg_info['description'])
                lines.append(f"{arg}\t{arg_info.get('type', '')}\t{description}")
        lines.append(".TE")

    if "return" in info:
        lines.append(".SH RETURN")
        lines.append(info['return'].get('description', 'Nothing.'))

    if "note" in info:
        lines.append(".SH NOTES")
        lines.append(info["note"])

    if "errors" in info:
        lines.append(".SH ERRORS")
        lines.append(info["errors"])

    if "example" in info:
        lines.append(".SH EXAMPLES")
        lines.append(".EX")
        lines.append(escape(info['example']))
        lines.append(".EE")

    if "." in name:
        type,_ = name.split(".")
        lines.append(".SH SEE ALSO")
        lines.append(f".BR Tomo-{type} (3)")

    to_write = '\n'.join(lines) + '\n'
    try:
        with open(path, "r") as f:
            existing = f.read()
            if to_write.splitlines()[5:] == existing.splitlines()[5:]:
                return
    except FileNotFoundError:
        pass

    with open(path, "w") as f:
        f.write(to_write)
        print(f"Updated {path}")

fn_summary_template = '''
.TP
{signature}
{description}

For more, see:
.BR Tomo-{type}.{name} (3)
'''

def fn_summary(type, name, info) -> str:
    signature = get_signature(type+"."+name, info)
    return fn_summary_template.format(
        type=type,
        name=name,
        signature=signature,
        description=markdown_to_roff(info["description"]),
    )

def write_type_manpage(path, type, methods):
    year = datetime.now().strftime("%Y")
    date = datetime.now().strftime("%Y-%m-%d")
    method_summaries = [fn_summary(type, name, methods[name]) for name in sorted(methods.keys())]
    type_manpage = type_template.format(
        year=year,
        date=date,
        type=type,
        methods='\n'.join(method_summaries),
    )

    try:
        with open(path, "r") as f:
            existing = f.read()
            if type_manpage.splitlines()[5:] == existing.splitlines()[5:]:
                return
    except FileNotFoundError:
        pass

    with open(path, "w") as f:
        f.write(type_manpage)
        print(f"Updated {path}")


def convert_to_markdown(yaml_doc:str)->str:
    data = yaml.safe_load(yaml_doc)

    types = {}
    for name,info in data.items():
        write_method(f"man/man3/tomo-{name}.3", name, data[name])
        if "." in name:
            type,fn = name.split(".")
            if type not in types:
                types[type] = {}
            types[type][fn] = info

    for type,methods in types.items():
        write_type_manpage(f"man/man3/tomo-{type}.3", type, methods)

if __name__ == "__main__":
    import sys
    if len(sys.argv) > 1:
        all_files = ""
        for filename in sys.argv[1:]:
            with open(filename, "r") as f:
                all_files += f.read()
        convert_to_markdown(all_files)
    else:
        convert_to_markdown(sys.stdin.read())