Files

492 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
function usage {
cat <<'EoT'
NAME
manpage - format an ascii text file into man page format
SYNOPSIS
manpage [-PR] [-d pdate] [-r rel] [-v vol] [-s sect] [-t title]
[-w ncols] [filename]
DESCRIPTION
manpage converts its input into the man(7) macros as used by man(1),
but without the user needed to understand any formatting syntax.
It is designed to process a flat ascii file that roughly appears in the format
desired, adding the specific flourishes that distinguish a readable man page:
- font changes for names and arguments throughout the document
- automatic recognition of command, C, C++, and Java names, types and arguments
- automatic recognition of paragraphs, list formats, sections, and non-formatted displays
- constant width fonts for code and displays
- cross-references highlighted
- pass-through of nroff formatting commands
The input resembles the man page to be formatted, with just a few rules of presentation:
Sections ~
Section names are input lines in all upper case. If a section name begins
with spaces, it starts a subsection.
Paragraphs ~
A blank line between text marks a paragraph. All text in the same
paragraph must be left-aligned relative to itself.
Line Breaks ~
A twiddle (~) at the end of a line is consumed and replaced
with a line break. This is especially useful in tag lists (this section).
Displays ~
These are sections not to be formatted further. They are introduced
by a preceding blank line and indented relative to the surrounding text.
Displays are output in constant width font, and all code escapes may
be presented naturally:
printf("Hello, World\n");
Tag Lists ~
The tag is identified by three (3) or more spaces between itself
and its description. These rules are tag lists. Note especially
the trailing spaces on the tags in this section.
Bullet Lists ~
These are identified by a leading dash (-), dot (.), or star (*).
Numbered Lists ~
These begin with a number and a dot; e.g. "3. Number three".
Cross References ~
This is a word followed by a number in parenthesis; e.g. man(1).
Bold / Italic ~
A word surrounded by underscores (_) is rendered in _italic_;
and by stars (*) is rendered as *boldface*.
There are two sections handled specially:
NAME
This is the name of the command or function, followed by a terse,
one-line summary of its primary use.
SYNOPSIS
This section is scanned for names, arguments, and flags. Names found
in this area are remembered, and each occurance throughout the document
are presented in bold. Arguments are similarly detected and remembered,
and presented in italics. Other text in this area is presented in
a constant width font.
OPTIONS
-d pdate set the man page date
-r rel set the release name
-v vol set the volume name
-s sect set the section name
-t title set the man page title
-w ncols force the output to be ncols wide
-P PostScript output
-R roff output
EXAMPLES
This usage is in the proper format for manpage. Try:
manpage -h | manpage -tmanpage -s1
To view the nroff(1) formatted page, perhaps for inclusion in the system man pages:
manpage -h | manpage -tmanpage -s1 -R > manpage.1
ENVIRONMENT
PAGER defaults to less(1); used if neither -P nor -R is specified as arguments
MANPATH input .man files searched in current dir, then HOME/man, then MANPATH
ORG name of organization (e.g. $ORG Reference Manual)
SEE ALSO
man(1), man(7), groff(1), less(1), txt2man(1)
PORTABILITY
This program uses generic bourne shell and awk syntax, POSIX compatible systems should work.
BUGS
Code displays that span page breaks do not restore the troff(1) escape
during header / footer processing.
AUTHOR
Dave Burton <ioccc@snox.net>
manpage(1) input format is inspired by txt2man(1) by Marc Vertes <mvertes@free.fr>,
although the code is a clean redesign from first principles.
EoT
}
function getwidth {
stty -a 2>/dev/null |
if test "$(uname)" == "Darwin"; then
awk '/columns/{for(i=1;i<=NF;++i)if($i~/columns/)print$(i-1)+0}'
else
awk '/columns/{for(i=1;i<=NF;++i)if($i~/columns/)print$(i+1)+0}'
fi
}
function scan {
MAN_PATH=${MANPATH:="/opt/homebrew/share/man:/usr/local/share/man:/usr/local/man:/usr/share/man:/opt/X11/share/man"}
MAN_SET=$(echo "$MAN_PATH" | tr : '\n')
for dir in . "$HOME/man" "$MAN_SET"; do
test -r "$dir/$1" && { echo "$dir/$1"; break; }
done
}
function searchfor {
# examine the current dir for name, name.man, or scan a searchpath for name.man
test "$1" || { echo ""; return; }
filename="$1"
if ! test -r "$filename" || test -x "$filename"; then
expr "$filename" : '.*.man' >/dev/null 2>&1 || filename="$filename.man"
result=$(scan "$filename")
if ! test "$result"; then
echo "cannot find $filename in curdir, $HOME/man or MANPATH" >&2
exit 1
fi
filename="$result"
fi
echo "$filename"
}
width=0
export sys
sys=("$uname" -s)
date=
rel=
vol=
sect=
title=
post=
args="?hPRd:r:v:s:t:w:"
while getopts "$args" opt
do
case $opt in
d) date=$OPTARG;;
r) rel=$OPTARG;;
v) vol=$OPTARG;;
s) sect=$OPTARG;;
t) title=$OPTARG;;
w) width=$OPTARG;;
P) post="groff -man -Tps";;
R) post="cat";;
\?) echo "usage: $0 [-$args] [filename]" >&2; exit 1;;
h|*) usage; exit 1;;
esac
done
shift $((OPTIND-1))
date=${date:-$(date +'%d %B %Y')}
# format and display the page now
if test "$width" -eq 0 -a ! "$post" ; then
width=$(getwidth)
fi
# -is for pre-el-capitan
: "${post:=groff -man -Tascii | ${PAGER:-less -R}}"
# seek for it, perhaps without the .man extension
filename=$(searchfor "$1") || exit; shift;
# echo $filename; exit
# intuit the title, section and volume from the input file
if ! test "$title" && test "$filename"; then
title=${filename##*/}; title=${title%.man}
title=$(echo "$title" | tr '[:lower:]' '[:upper:]')
if sed '/DESCRIPTION/q' "$filename" | grep -E -q "#include|.import"; then
sect=${sect:-3}
vol=${vol:-"$ORG Programmer's Manual"}
else
sect=${sect:-1}
vol=${vol:-"$ORG Reference Manual"}
fi
fi
# --------------------------------------------------
# the formatting engine
expand "$filename" |
awk -v width="$width" -v th="$title $sect \"$date\" \"$rel\" \"$vol\"" '
BEGIN {
if (width+0 > 0) {
# adjust output to a specific width; useful as man(1) replacement
cpi = (width+0)/11
print ".ll " cpi "i"
print ".nr LL " cpi "i"
}
print ".TH", th
print ".if n .na\n.if n .nh"
block = 0 # depth of the block display
list = 0 # depth of the list indent
indent = 0 # current RS indent level
rs[indent] = 0 # array of starting indents
lw = 0 # leading whitespace
blank = 0 # NR of last blank line
}
function bold(w) { return "\\fB" w "\\fP" }
function italic(w) { return "\\fI" w "\\fP" }
function before(s) { return substr(s, 1, RSTART-1) }
function after(s) { return substr(s, RSTART+RLENGTH) }
function add(t, r) {
gsub(/\./, "\\.", t)
pattern[t] = r
}
# replace logic is W*P, but both W (words) and P (patterns) are small,
# and this is the only way to portably, reliably detect substitutable patterns
# within the larger text
#
function replace(dummy, i,count,other,pat,re) {
other = "[^-_A-Za-z0-9]+"
for (i=1; i<=NF; ++i) {
count = 0
for (pat in pattern) {
# re = "^" pat "$|^" other pat "$|^" pat other "$|^" other pat other "$"
re = "^" pat "$|" other pat "$|^" pat other "|" other pat other
if ($i ~ re) {
count = sub(pat, pattern[pat], $i)
break;
}
}
# one-time, in place bold, italic, and cross-ref
if (section != "SYNOPSIS" && count == 0) {
# match foo(1) or foo(1pkg) not foo(1 2) or foo(1, 2) or foo(int)
# but not in a see also section
if ((section != "SEE") && # aka SEE ALSO
($i ~ /[_A-Za-z0-9]+\([0-9][A-Za-z0-9]*\)/)) {
a = index($i, "(")
$i = bold(substr($i, 1, a-1)) substr($i, a)
}
else if (match($i, "[A-Za-z]_.*_[A-Za-z]"))
;
else if (match($i, "_.*_"))
$i = before($i) italic(substr($i, RSTART+1, RLENGTH-2)) after($i)
else if (match($i, "\\*.*\\*"))
$i = before($i) bold(substr($i, RSTART+1, RLENGTH-2)) after($i)
}
}
if ($NF == "~") {
$NF = ""
return 1
}
return 0
}
function paragraph() {
print ".PP"
list = lw # reset list on a paragraph
tplen = ""
}
function restore() {
if (blank == NR-1) # restore the blank line to the display
print ""
n = lw - block
if (n==0) n=1 # avoid eating leading dots in displays
printf "%*s", n, " " # restore leading spaces in displays
}
function block_start() {
block = lw
blank = 0
paragraph()
print ".RS " block
print ".eo\n.nf\n.fam C"
}
function block_end() {
if (block > 0) {
block = 0
print ".ec\n.fi\n.fam T"
print ".RE"
}
}
function reset_indent() {
rs[0] = indent = 0
}
# pass through nroff formatting commands
/^\..+/ {
print $0
next
}
# remember leading whitespace, strip blank lines
{
lw = match($0, /[^ ]/)
if (lw == 0) {
blank = NR
next
} else {
$0 = substr($0, lw)
# sub(/^ */, "")
# sub(/ *$/, "") # prevents TP match if desc on next line
}
--lw
if (rs[indent] == 0)
rs[indent] = lw
}
# new section (must end with a cap letter to avoid tag list match)
/^[A-Z ]+[A-Z]$/ {
if (section == "SYNOPSIS")
print ".fi"
block_end()
reset_indent()
list = 0
tplen = ""
section = $1
print (lw>0) ? ".SS" : ".SH", $0
if (section == "SYNOPSIS") {
print ".nf"
print ".fam C"
}
if (section == "SEE")
print ".nf"
next
}
section == "NAME" && name == "" && $1 != "" {
name = $1
# add(name, bold(name))
sub(/ - /, " \\- ")
}
section == "SYNOPSIS" {
if (format == "") {
block = lw # this is a pseudo-block; see new section
if ($0 ~ /\(/) format = "code"
else if ($1 == "struct" || $2 == "struct") format = "struct"
else if ($1 == "union" || $2 == "union") format = "struct"
else if ($1 == "class" || $2 == "class") format = "class"
else if ($1 !~ /#|import|typedef|enum/) format = "cmd"
}
if (format == "cmd") {
# cmd [-a N] [-b N] [-m ab] [-p S]
# [-r] [-qst] @interface ip:port [filename ...]
i = 1
if ($1 !~ /^\[/) {
++i
a = $1
gsub("-", "\\-", a)
add(a, bold(a))
}
for (; i<=NF; ++i) {
a = $i
gsub(/[\[\]]/, "", a) # remove all brackets
if (a == "-") ;
else if (a ~ /^-./) add(a, bold("\\" a))
else if (a !~ /\.\.\./) add(a, italic(a))
}
} else if (format == "code") {
# void foo()
# void foo(int);
# void foo(int a);
# void foo(int a, char *b);
# void *foo(char **b, int *a, ...)
# void (*signal(int sig, void (*func)(int)))(int);
# ssize_t readv(int d, const struct iovec *iov, int iovcnt);
if ($1 != "typedef")
for (i = 1; i<=NF; ++i) {
a = $i
gsub("[\\*]", "", a) # dont want to mess with pointers
if (match(a, "[A-Za-z0-9_]+\\(")) {
a = substr(a, RSTART, RLENGTH-1)
add(a, bold(a))
} else if (match(a, "[A-Za-z0-9_]+[,\\)]")) {
a = substr(a, RSTART, RLENGTH-1)
add(a, italic(a))
}
}
} else if (format == "struct") {
if (match($0, "[A-Za-z0-9_]+[ ]*{")) {
a = substr($0, RSTART)
sub("[ ]*{", "", a);
add(a, bold(a));
}
format = "code"
} else if (format == "class") {
# public class Foo extends Bar implements Snox, Blah, Nuts {
# so remove from extends or implements, play as if C++
a = $0
if (match(a, "extends|implements"))
a = substr(a, 1, RSTART-1)
if (match(a, "[A-Za-z0-9_]+[ ]*{")) {
a = substr(a, RSTART)
sub("[ ]*{", "", a);
add(a, bold(a));
format = "code"
}
}
replace()
}
block == 0 {
if (lw > rs[indent]+2) {
if (blank == NR-1)
block_start()
else if (lw > list) {
# XXX list?
rs[++indent] = lw;
print ".RS"
}
} else while (lw < rs[indent]) {
print ".RE"
--indent
list = 0
tplen = ""
}
}
block > 0 {
if (lw < block)
block_end()
else {
restore()
print $0
next
}
}
{
# if replace before matching, awk collapses multiple spaces into one....
if (match($0, /[^ ] +/)) { # tag description
list = lw + RSTART + RLENGTH
tag = substr($0, 1, RSTART)
def = substr($0, RSTART + RLENGTH)
$0 = tag
if (tplen == "" && def !~ /[.~]/)
tplen = list-3
replace()
print ".TP " tplen "\n" bold($0)
$0 = def
} else if (match($0, /^[o\.\*+] +/)) { # bullet list
list = lw + RSTART + RLENGTH
print ".IP \\(bu 3"
$0 = substr($0, RLENGTH+1)
} else if (match($0, /^- +/)) { # dash list
list = lw + RSTART + RLENGTH
print ".IP \\(em 3"
$0 = substr($0, RLENGTH+1)
} else if (match($0, /^[0-9]+\. +/)) { # number list
list = lw + RSTART + RLENGTH
print ".IP " $1 " 3"
$0 = substr($0, RLENGTH+1)
} else {
# the lists above supply their own vertical separation
if (blank == NR-1)
paragraph()
}
# if there is anything left to print...
if (length $0 > 0) {
br = replace();
if (length $0 > 0)
print $0
if (br) print ".br"
}
}
' | eval "$post"