// This file is part of gohelp2man.
//
// Copyright (C) 2025 Nicolas Peugnet <nicolas@club1.fr>
//
// gohelp2man is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// gohelp2man is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, see <https://www.gnu.org/licenses/>.
//go:generate go build
//go:generate go run . -version-string=v0.6.0 -include=gohelp2man.h2m -output=gohelp2man.1 ./gohelp2man
package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime/debug"
"strconv"
"strings"
"time"
)
const (
Name = "gohelp2man"
Usage = `%s generates a man page out of a Go program's -help output.
Go has a simple but very effective "flag" package that can be used to quickly
create CLI applications without any additional dependencies. But this package
cannot generate man pages.
gohelp2man takes inspiration from GNU help2man, and generates a man page from
the -help output of your Go program. It is specifically designed to recognise
the help message generated by the "flag" package.
It is a great match with "go get -tool" and "go generate"!
Usage: %s [OPTION]... EXECUTABLE
`
RegexSection = `^\[([^]]+)\]\s*$`
RegexUsage = `[Uu]sage(:| of) (?U:(.*)):?$`
RegexUsage2 = `^((\t|\s+or: )(.*)| ([^-].*))$`
RegexHeader = `^(\w.*):\s*$`
RegexFlag = `^ -((\w)\t(.*)|([-\w]+) (.+)|[-\w]+)$`
RegexFUsage = `^ [^-].*$`
)
var (
debugMode = os.Getenv("GOH2M_DEBUG") != ""
l = log.New(os.Stderr, Name+": ", 0)
regexSection = regexp.MustCompile(RegexSection)
regexUsage = regexp.MustCompile(RegexUsage)
regexUsage2 = regexp.MustCompile(RegexUsage2)
regexHeader = regexp.MustCompile(RegexHeader)
regexFlag = regexp.MustCompile(RegexFlag)
regexFUsage = regexp.MustCompile(RegexFUsage)
)
var KnownSections = [12]string{
"NAME",
"SYNOPSIS",
"DESCRIPTION",
"OPTIONS",
// Other
"ENVIRONMENT",
"FILES",
"EXAMPLES",
"AUTHOR",
"REPORTING BUGS",
"COPYRIGHT",
"SEE ALSO",
}
func findKnownSection(s string) (title string, found bool) {
title = strings.ToUpper(s)
switch title {
case "OPTIONS", "FLAGS":
title = "OPTIONS"
fallthrough
case "NAME",
"SYNOPSIS",
"DESCRIPTION",
"ENVIRONMENT",
"FILES",
"EXAMPLES",
"AUTHOR",
"REPORTING BUGS",
"COPYRIGHT",
"SEE ALSO":
found = true
}
return
}
type Section struct {
Title string
Text string
Pos byte
}
func (s *Section) String() string {
return fmt.Sprintf("{%q %q %q}", s.Title, s.Text, s.Pos)
}
type Flag struct {
Name string
Arg string
Usage string
}
func (f *Flag) String() string {
return fmt.Sprintf("-%s %q: %s", f.Name, f.Arg, f.Usage)
}
type Help struct {
Usage string
Flags []*Flag
Sections map[string]*Section
scanner *bufio.Scanner
}
func NewHelp(help io.Reader) *Help {
return &Help{
Sections: make(map[string]*Section),
scanner: bufio.NewScanner(help),
}
}
// parseUsage parses synopsis lines from the internal reader. It will continue
// until the current line does not look like a synopsis/usage string, leaving
// the current line to be parsed.
func (h *Help) parseUsage() {
var text strings.Builder
line := h.scanner.Bytes()
m := regexUsage.FindSubmatch(line)
if m != nil {
if bytes.IndexRune(m[2], ' ') != -1 {
text.Write(m[2])
}
for h.scanner.Scan() {
m = regexUsage2.FindSubmatch(h.scanner.Bytes())
if m != nil {
text.WriteString("\n")
text.Write(bytes.TrimSpace(m[3]))
text.Write(bytes.TrimSpace(m[4]))
} else {
break
}
}
h.Usage = strings.TrimSpace(text.String())
}
}
// parseFlag parses a flag in the current line. If returns (nil, false) if the
// line does not match.
func (h *Help) parseFlag() (f *Flag, found bool) {
line := h.scanner.Text()
m := regexFlag.FindStringSubmatch(line)
found = m != nil
if found {
f = new(Flag)
switch {
case m[2] != "": // short flag
f.Name = m[2]
f.Usage = m[3]
return
case m[4] != "": // flag with arg
f.Name = m[4]
f.Arg = m[5]
default:
f.Name = m[1]
}
}
return
}
// parseFlags parses flags from the internal reader. It will continue until
// the current line does not look like a flag or a flag description, leaving
// the current line to be parsed.
func (h *Help) parseFlags() {
// TODO: maybe group together flags with the same description
// TODO: maybe group together short flag without description
// and the only flag that start with this letter?
for {
if f, found := h.parseFlag(); found {
var text strings.Builder
if f.Usage != "" {
text.WriteString(f.Usage)
}
h.Flags = append(h.Flags, f)
for h.scanner.Scan() {
line := h.scanner.Text()
if regexFUsage.MatchString(line) {
text.WriteString("\n")
text.WriteString(strings.TrimSpace(line))
} else {
break
}
}
f.Usage = strings.TrimSpace(text.String())
} else {
break
}
}
}
func (h *Help) parseHeader() (header string, found bool) {
line := h.scanner.Text()
m := regexHeader.FindStringSubmatch(line)
if m != nil {
return m[1], true
}
return "", false
}
// parse parses the help message from the internal reader.
func (h *Help) parse() error {
var s *Section = &Section{Title: "DESCRIPTION"}
var text strings.Builder
finaliseSection := func() {
s.Text = strings.TrimSpace(text.String())
if s.Text != "" {
h.Sections[s.Title] = s
}
text.Reset()
}
for h.scanner.Scan() {
h.parseUsage()
h.parseFlags()
if hr, found := h.parseHeader(); found {
if title, found := findKnownSection(hr); found {
finaliseSection()
s = &Section{Title: title}
continue
}
}
text.Write(h.scanner.Bytes())
text.WriteString("\n")
}
finaliseSection()
return h.scanner.Err()
}
// sectionMarkup returns the text of a known section if found, ready to be
// written on the output man page as is.
func (h *Help) sectionMarkup(title string) (markup string, found bool) {
s, found := h.Sections[title]
b := &strings.Builder{}
if found {
efprintln(b, s.Text)
}
switch title {
case "OPTIONS":
found = true
for _, f := range h.Flags {
if f.Arg != "" {
efprintf(b, ".TP\n\\fB\\-%s\\fR %s\n", f.Name, f.Arg)
} else {
efprintf(b, ".TP\n\\fB\\-%s\\fR\n", f.Name)
}
efprintln(b, f.Usage)
}
}
markup = b.String()
return
}
type Include struct {
Sections map[string]*Section
OtherSections []*Section
}
func readInclude(path string, optional bool) (include *Include, err error) {
f, err := os.Open(path)
if err != nil {
if optional {
return &Include{}, nil
}
return nil, err
}
include, err = parseInclude(bufio.NewReader(f))
if err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
return
}
// parseInclude parses an .h2m include file.
func parseInclude(r io.Reader) (*Include, error) {
i := &Include{Sections: make(map[string]*Section)}
var s *Section
var text strings.Builder
finaliseSection := func() {
if s != nil {
s.Text = strings.TrimSpace(text.String())
}
text.Reset()
}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
m := regexSection.FindStringSubmatch(line)
if m != nil {
finaliseSection()
s = &Section{}
title := m[1]
switch r := m[1][0]; r {
case '<', '=', '>':
s.Pos = r
title = m[1][1:]
}
title, found := findKnownSection(title)
s.Title = title
if found {
i.Sections[title] = s
} else {
i.OtherSections = append(i.OtherSections, s)
}
continue
}
text.WriteString(line)
text.WriteString("\n")
}
finaliseSection()
return i, scanner.Err()
}
// getHelp runs the given exe with the -help flag to return its output.
func getHelp(exe string) ([]byte, error) {
cmd := exec.Command(exe, "-help")
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("run %s: %w", cmd, err)
}
if len(out) == 0 {
return nil, fmt.Errorf("run %s: empty output", cmd)
}
return out, err
}
// version returns the current version of gohelp2man as found in build info.
func version() string {
v := "(unknown)"
info, ok := debug.ReadBuildInfo()
if ok {
v = info.Main.Version
}
return v
}
// now returns the current time or the value of SOURCE_DATE_EPOCH if defined.
func now() time.Time {
if epoch := os.Getenv("SOURCE_DATE_EPOCH"); epoch != "" {
unixEpoch, err := strconv.ParseInt(epoch, 10, 64)
if err != nil {
panic("invalid SOURCE_DATE_EPOCH: " + err.Error())
}
return time.Unix(unixEpoch, 0)
} else {
return time.Now()
}
}
var blockEscaper = NewRegexpReplacer(
`-`, `\-`,
`\\`, `\(rs`,
"\n\n+", "\n.PP\n",
`(?m)^\.`, `\&.`,
`(?m)^\'`, `\&'`,
)
var blockFormatter = NewRegexpReplacer(
// Format second level headers
`(?m)^(?:.PP\n)?(\w.*):\s*$`, `.SS $1:`,
// Format man(1) style notation
`\b(\w|\w(?:\\-|\w|\.|:)*\w)\((\w+)\)\B`, `\fB$1\fP($2)`,
// Format -flag in bold
`\B(\\-(?:\\-|\w)*\w)\b`, `\fB$1\fP`,
)
var fieldEscaper = NewRegexpReplacer(
`-`, `\-`,
`"`, `\(dq`,
)
// must panics if err is not nil.
func must(n int, err error) int {
if err != nil {
panic(err)
}
return n
}
// mfprint is [fmt.Fprint] wrapped with [must].
func mfprint(w io.Writer, args ...any) int {
return must(fmt.Fprint(w, args...))
}
// mfprintln is [fmt.Fprintln] wrapped with [must].
func mfprintln(w io.Writer, args ...any) int {
return must(fmt.Fprintln(w, args...))
}
// mfprintf is [fmt.Fprintf] wrapped with [must].
func mfprintf(w io.Writer, format string, args ...any) int {
return must(fmt.Fprintf(w, format, args...))
}
// e escapes and formats a value to be included as is in a man page as a block of text.
func e(v any) string {
escaped := blockEscaper.Replace(fmt.Sprint(v))
return blockFormatter.Replace(escaped)
}
func eArgs(args []any) []any {
eargs := make([]any, len(args))
for i, arg := range args {
eargs[i] = e(arg)
}
return eargs
}
// efprint is [mfprint] with all args escaped with [e].
func efprint(w io.Writer, args ...any) int {
return mfprint(w, eArgs(args)...)
}
// efprintln is [mfprintln] with all args escaped with [e].
func efprintln(w io.Writer, args ...any) int {
return mfprintln(w, eArgs(args)...)
}
// efprintf is [mfprintf] with all args escaped with [e].
func efprintf(w io.Writer, format string, args ...any) int {
return mfprintf(w, format, eArgs(args)...)
}
// writeSynopsis formats a synopsis line by writing the command name in bold
// and the arguments inside brackets in italic.
func writeSynopsis(w io.Writer, synopsis string) {
name, rest, found := strings.Cut(strings.TrimSpace(synopsis), " ")
if !found {
efprintf(w, "\\fB%s\\fR", name)
return
}
splits := strings.Split(rest, "\n"+name+" ")
re := regexp.MustCompile(`\[([^[]+)\]`)
for i, args := range splits {
if i != 0 {
mfprint(w, ".br\n")
}
efprintf(w, "\\fB%s\\fR ", name)
mfprintln(w, re.ReplaceAllString(e(args), `[\fI${1}\fR]`))
}
}
// writeKnownSection writes the section with given title in w if it is present
// at least in i or h. It withHeader is true and the section is found, then
// the title of this section it prependend to the section's text.
//
// The text from i is written first, and if the section is present in both i
// and h, then they will be in different paragraphs.
func writeKnownSection(w io.Writer, i *Include, h *Help, title string) {
si, foundi := i.Sections[title]
sh, foundh := h.sectionMarkup(title)
if !foundi && !foundh {
return
}
mfprintf(w, ".SH %s\n", title)
switch {
case foundi && foundh:
switch si.Pos {
case '>':
mfprint(w, sh)
mfprintln(w, ".PP")
mfprintln(w, si.Text)
case '=':
mfprintln(w, si.Text)
case '<':
fallthrough
default:
mfprintln(w, si.Text)
mfprintln(w, ".PP")
mfprint(w, sh)
}
case foundi:
mfprintln(w, si.Text)
case foundh:
mfprint(w, sh)
}
}
func writeManPage(w io.Writer, name, description, v string, include *Include, help *Help, section, manual string) (err error) {
defer func() {
if !debugMode {
if p := recover(); p != nil {
err = fmt.Errorf("%v", p)
}
}
}()
// Write generator comment
mfprintf(w, ".\\\" Generated by %s %s; DO NOT EDIT.\n", Name, version())
// Write title
mfprintf(w, `.TH %s %v %s "%s"`,
fieldEscaper.Replace(strings.ToUpper(name)),
section,
now().Format("2006-01-02"),
fieldEscaper.Replace(v),
)
if manual != "" {
mfprintf(w, ` "%s"`, fieldEscaper.Replace(manual))
}
mfprintln(w)
// Write NAME section
efprintf(w, ".SH NAME\n%v \\- %v\n", name, description)
// Write SYNOPSIS section
mfprintln(w, ".SH SYNOPSIS")
if s, found := include.Sections["SYNOPSIS"]; found {
mfprintln(w, s.Text)
} else if help.Usage != "" {
writeSynopsis(w, help.Usage)
} else {
efprintf(w, "\\fB%s\\fR [\\fIOPTION\\fR]... [\\fIARGUMENT\\fR]...\n", name)
}
// Write DESCRIPTION section
writeKnownSection(w, include, help, "DESCRIPTION")
// Write OPTIONS section
writeKnownSection(w, include, help, "OPTIONS")
// Write other included sections
for _, s := range include.OtherSections {
mfprintf(w, ".SH %s\n%s\n", s.Title, s.Text)
}
// Write last known sections
for _, title := range KnownSections[4:] {
writeKnownSection(w, include, help, title)
}
return
}
func main() {
cli := flag.NewFlagSet(Name, flag.ExitOnError)
cli.Usage = func() {
fmt.Fprintf(cli.Output(), Usage, Name, Name)
cli.PrintDefaults()
}
var (
flagHelp bool
flagInclude string
flagManual string
flagName string
flagOptInclude string
flagOutput string
flagSection string
flagVersion bool
flagVersionString string
)
cli.BoolVar(&flagHelp, "help", false, "Show this help and exit.")
cli.StringVar(&flagInclude, "include", "", "Include material from `FILE`.")
cli.StringVar(&flagManual, "manual", "", "Set the name of the manual section to `SECTION`, used as a centred\n"+
"heading for the manual page. By default it is omitted to let man(1)\n"+
"fill it accordingly. Commonly used values are \"User Commands\" for\n"+
"pages in section 1, \"Games\" for section 6 and \"System Administration\n"+
"Utilities\" for sections 8 and 1M.")
cli.StringVar(&flagName, "name", "", "Description for the NAME paragraph.")
cli.StringVar(&flagOptInclude, "opt-include", "", "A variant of -include which does not require `FILE` to exist.")
cli.StringVar(&flagOutput, "output", "", "Send output to `FILE` rather than stdout.")
cli.StringVar(&flagSection, "section", "1", "Set the section of the manual page to `NUMBER` (e.g. 1, 6 or 8). See\n"+
"man(1) for common section numbers.")
cli.BoolVar(&flagVersion, "version", false, "Show version number and exit.")
cli.StringVar(&flagVersionString, "version-string", "", "Set the `VERSION` to use in the footer.")
envOpts := strings.Fields(os.Getenv("GOH2M_OPTIONS"))
cli.Parse(append(envOpts, os.Args[1:]...))
if flagHelp {
cli.Usage()
os.Exit(0)
}
if flagVersion {
fmt.Println(Name, version())
os.Exit(0)
}
exe := cli.Arg(0)
if exe == "" {
l.Print("missing argument: executable")
cli.Usage()
os.Exit(2)
}
include := &Include{}
hasOptInclude, hasInclude := flagOptInclude != "", flagInclude != ""
if hasOptInclude && hasInclude {
l.Fatalln("-opt-include and -include cannot be specified at the same time")
}
var err error
if hasOptInclude {
include, err = readInclude(flagOptInclude, true)
}
if hasInclude {
include, err = readInclude(flagInclude, false)
}
if err != nil {
l.Fatalln("include file:", err)
}
out, err := getHelp(exe)
if err != nil {
l.Fatalln("get help:", err)
}
help := NewHelp(bytes.NewBuffer(out))
err = help.parse()
if err != nil {
l.Fatalln("parse output:", err)
}
name := filepath.Base(exe)
description := "manual page for " + name
if s, found := include.Sections["NAME"]; found {
n, d, ok := strings.Cut(s.Text, " - ")
if !ok {
l.Fatalf("invalid [name] section %q", s.Text)
}
if i := strings.IndexAny(n, " \t\n\r"); i != -1 {
l.Fatalf("illegal character %q in program name: %q", n[i], n)
}
name, description = n, d
}
if flagName != "" {
description = flagName
}
v := flagVersionString
fields := strings.Fields(v)
switch len(fields) {
case 0:
v = name
case 1:
v = name + " " + fields[0]
default:
v = strings.Join(fields, " ")
}
var w io.Writer
if flagOutput != "" {
file, err := os.Create(flagOutput)
if err != nil {
l.Fatalln("create output file:", err)
}
w = bufio.NewWriter(file)
} else {
w = os.Stdout
}
b := bufio.NewWriter(w)
// Print man page
err = writeManPage(b, name, description, v, include, help, flagSection, flagManual)
if err != nil {
l.Fatalln("write man page:", err)
}
if err := b.Flush(); err != nil {
l.Fatalln("print man page:", err)
}
}
// This file is part of gohelp2man.
//
// Copyright (C) 2025 Nicolas Peugnet <nicolas@club1.fr>
//
// gohelp2man is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// gohelp2man is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, see <https://www.gnu.org/licenses/>.
package main
import (
"regexp"
)
type RegexpReplacer struct {
compound *regexp.Regexp
subexps []int
regexps []*regexp.Regexp
repls []string
}
func NewRegexpReplacer(oldnew ...string) *RegexpReplacer {
if len(oldnew)%2 == 1 {
panic("RegexpReplacer: odd argument count")
}
var (
subexps []int
regexps []*regexp.Regexp
repls []string
)
// Create a compound regex that match all of the "old" values
buf := []byte{'('}
for i := 0; i < len(oldnew); i += 2 {
old := oldnew[i]
new := oldnew[i+1]
re := regexp.MustCompile(old)
if re.Match([]byte{}) {
panic("RegexpReplacer: regexp matches empty string: " + old)
}
regexps = append(regexps, re)
repls = append(repls, new)
subexps = append(subexps, re.NumSubexp()+1)
buf = append(buf, '(')
buf = append(buf, old[:]...)
buf = append(buf, ')', '|')
}
buf[len(buf)-1] = ')'
return &RegexpReplacer{
compound: regexp.MustCompile(string(buf)),
subexps: subexps,
regexps: regexps,
repls: repls,
}
}
func (rr *RegexpReplacer) Replace(s string) string {
buf := make([]byte, 0, len(s))
pos := 0
matches := rr.compound.FindAllStringSubmatchIndex(s, -1)
for pos < len(s) {
if len(matches) == 0 {
buf = append(buf, s[pos:]...)
break
}
submatches := matches[0]
// Ignore both full match and the first submatch used to create
// the coumpound regex
subFrom := 4
for i, subexp := range rr.subexps {
subTo := subFrom + subexp*2
submatch := submatches[subFrom:subTo]
start := submatches[subFrom]
end := submatches[subFrom+1]
if start != -1 {
buf = append(buf, s[pos:start]...)
re := rr.regexps[i]
repl := rr.repls[i]
buf = re.ExpandString(buf, repl, s, submatch)
pos = end
break
}
subFrom = subTo
}
matches = matches[1:]
}
return string(buf)
}