Add man page generation from the command-line model (#2)

* Add man page generation from the command-line model

Add CommandSet.OutputManPage (string and file overloads), a ManPageInfo
type for document-level metadata, a reference ManPage feature invoked as a
man command, and UserInput.FormatManPage which reuses FormatFlags so the
page cannot drift from --help. Output uses only portable man(7) macros and
validates warning-free under groff and mandoc -T lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address review feedback on man page generation

Rename the man page API to the single-word "Manpage" spelling throughout
(Manpage feature, ManpageInfo, CommandSet.OutputManpage,
UserInput.FormatManpage) for consistency with the "manpage" convention.

- Drop the redundant null check in OutputManpage; info is non-nullable
  under the enabled nullable context.
- Move the tests that exercise CommandSet.OutputManpage into
  CommandSetTests as a "Manpage Output" region, and relocate the Manpage
  feature test into a path-synced Features subfolder
  (SabreTools.CommandLine.Test.Features). Private test helpers now sit at
  the bottom of their classes.
- Fix the Manpage feature doc example to use the actual "man" command.

Builds clean across all target frameworks; the 235 tests pass and the
generated page still validates warning-free under groff -man -ww and
mandoc -T lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gmipf
2026-07-01 19:53:34 +02:00
committed by GitHub
parent 1da60f1091
commit 754aa6bc32
7 changed files with 668 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using SabreTools.CommandLine.Inputs;
using Xunit;
@@ -839,6 +840,144 @@ namespace SabreTools.CommandLine.Test
#endregion
#region Manpage Output
[Fact]
public void OutputManpage_Structure_ContainsRequiredSections()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { Version = "sample 1.0", Description = "do sample things" };
string page = set.OutputManpage(info);
Assert.Contains(".TH \"SAMPLE\" \"1\"", page);
Assert.Contains(".SH NAME", page);
Assert.Contains("sample \\- do sample things", page);
Assert.Contains(".SH SYNOPSIS", page);
Assert.Contains(".SH DESCRIPTION", page);
Assert.Contains(".SH OPTIONS", page);
Assert.Contains(".TP", page);
}
[Fact]
public void OutputManpage_EscapesHyphensInFlags()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");
string page = set.OutputManpage(info);
Assert.Contains(".B \\-\\-convert", page);
Assert.DoesNotContain(".B --convert", page);
}
[Fact]
public void OutputManpage_Verbose_TogglesDetailedText()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");
string terse = set.OutputManpage(info, includeVerbose: false);
string verbose = set.OutputManpage(info, includeVerbose: true);
Assert.DoesNotContain("Built\\-in to most of the programs", terse);
Assert.Contains("Built\\-in to most of the programs", verbose);
}
[Fact]
public void OutputManpage_NestsChildren()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");
string page = set.OutputManpage(info);
Assert.Contains(".RS", page);
Assert.Contains(".RE", page);
Assert.Contains(".B \\-\\-output", page);
}
[Fact]
public void OutputManpage_HonorsCustomOptionsHeading()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { OptionsHeading = "COMMANDS" };
string page = set.OutputManpage(info);
Assert.Contains(".SH COMMANDS", page);
Assert.DoesNotContain(".SH OPTIONS", page);
}
[Fact]
public void OutputManpage_LeavesBlankDateForStamping()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { Version = "sample 1.0" };
string page = set.OutputManpage(info);
// Section, then an empty date field, then the version
Assert.Contains(".TH \"SAMPLE\" \"1\" \"\" \"sample 1.0\"", page);
}
[Fact]
public void OutputManpage_LinesWithinWidth()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { Description = "do sample things" };
string page = set.OutputManpage(info, includeVerbose: true);
foreach (string line in page.Split('\n'))
{
Assert.True(line.Length <= 78, $"Line exceeds 78 columns: {line}");
}
}
[Fact]
public void OutputManpage_FileOverload_WritesSameContent()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");
string expected = set.OutputManpage(info);
string path = Path.Combine(Path.GetTempPath(), $"sabretools-manpage-{Guid.NewGuid():N}.1");
try
{
set.OutputManpage(path, info);
string actual = File.ReadAllText(path);
Assert.Equal(expected, actual);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
#endregion
/// <summary>
/// Build a representative command set with nested inputs and detail text
/// </summary>
private static CommandSet BuildSampleSet()
{
var set = new CommandSet("Sample header line");
var help = new MockFeature("Help", ["?", "h", "help"], "Show this help",
"Built-in to most of the programs is a basic help text.");
set.Add(help);
var convert = new MockFeature("Convert", "--convert", "Convert input files",
"Converts the provided input files into the desired output format.");
convert.Add(new StringInput("output", "--output", "Set the output path"));
convert.Add(new FlagInput("force", "--force", "Overwrite existing files"));
set.Add(convert);
return set;
}
/// <summary>
/// Mock Feature implementation for testing
/// </summary>

View File

@@ -0,0 +1,80 @@
using System;
using System.IO;
using SabreTools.CommandLine.Features;
using SabreTools.CommandLine.Inputs;
using Xunit;
namespace SabreTools.CommandLine.Test.Features
{
public class ManpageTests
{
[Fact]
public void ManpageFeature_WritesPageToStandardOutput()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { Version = "sample 1.0" };
set.Add(new Manpage(info));
var original = Console.Out;
var writer = new StringWriter();
string output;
try
{
Console.SetOut(writer);
bool result = set.ProcessArgs(["man"]);
Assert.True(result);
}
finally
{
Console.SetOut(original);
}
output = writer.ToString();
Assert.Contains(".TH \"SAMPLE\" \"1\"", output);
Assert.Contains(".SH NAME", output);
Assert.Contains(".B \\-\\-convert", output);
}
/// <summary>
/// Build a representative command set with nested inputs and detail text
/// </summary>
private static CommandSet BuildSampleSet()
{
var set = new CommandSet("Sample header line");
var help = new MockFeature("Help", ["?", "h", "help"], "Show this help",
"Built-in to most of the programs is a basic help text.");
set.Add(help);
var convert = new MockFeature("Convert", "--convert", "Convert input files",
"Converts the provided input files into the desired output format.");
convert.Add(new StringInput("output", "--output", "Set the output path"));
convert.Add(new FlagInput("force", "--force", "Overwrite existing files"));
set.Add(convert);
return set;
}
/// <summary>
/// Mock Feature implementation for testing
/// </summary>
private class MockFeature : Feature
{
public MockFeature(string name, string flag, string description, string? detailed = null)
: base(name, flag, description, detailed)
{
}
public MockFeature(string name, string[] flags, string description, string? detailed = null)
: base(name, flags, description, detailed)
{
}
/// <inheritdoc/>
public override bool Execute() => true;
/// <inheritdoc/>
public override bool VerifyInputs() => true;
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using SabreTools.CommandLine.Features;
using SabreTools.CommandLine.Inputs;
@@ -963,6 +964,119 @@ namespace SabreTools.CommandLine
#endregion
#region Manpage Output
/// <summary>
/// Generate a man page from the current set of inputs
/// </summary>
/// <param name="info">Document-level metadata for the man page</param>
/// <param name="includeVerbose">True if detailed descriptions should be included, false otherwise</param>
/// <returns>The complete man page as a roff-formatted string</returns>
/// <remarks>
/// The output uses only the portable man(7) macro set so that it
/// renders without warnings under both groff and mandoc. The page
/// content is derived from the same model used to format help text,
/// so it stays in sync with the output of the help features.
/// </remarks>
public string OutputManpage(ManpageInfo info, bool includeVerbose = false)
{
// Start building the output list
List<string> output = [];
// Leading comment and title line
output.Add(".\\\" Generated from the command-line model. Do not edit by hand.");
output.Add(BuildTitleHeader(info));
// Name section
output.Add(".SH NAME");
string nameLine = Roff.Escape(info.Name);
if (!string.IsNullOrEmpty(info.Description))
nameLine += " \\- " + Roff.Escape(info.Description);
output.Add(nameLine);
// Synopsis section
output.Add(".SH SYNOPSIS");
output.Add(".B " + Roff.Escape(info.Name));
output.Add("[options]");
// Description section, derived from the header lines
if (_header.Count > 0)
{
output.Add(".SH DESCRIPTION");
foreach (string line in _header)
{
output.AddRange(Roff.FormatText(line));
}
}
// Options section, derived from all available inputs
output.Add(".SH " + info.OptionsHeading);
foreach (var input in _inputs.Values)
{
output.AddRange(input.FormatManpage(includeVerbose));
}
// If there is a default feature, include its children directly
if (DefaultFeature is not null)
{
foreach (var input in DefaultFeature.Children.Values)
{
output.AddRange(input.FormatManpage(includeVerbose));
}
}
// Notes section, derived from the footer lines
if (_footer.Count > 0)
{
output.Add(".SH NOTES");
foreach (string line in _footer)
{
output.AddRange(Roff.FormatText(line));
}
}
// Join the lines with a trailing newline for a well-formed file
return string.Join("\n", output.ToArray()) + "\n";
}
/// <summary>
/// Generate a man page from the current set of inputs and write it to a file
/// </summary>
/// <param name="path">Path to write the generated man page to</param>
/// <param name="info">Document-level metadata for the man page</param>
/// <param name="includeVerbose">True if detailed descriptions should be included, false otherwise</param>
/// <remarks>The file is written as UTF-8 without a byte order mark</remarks>
public void OutputManpage(string path, ManpageInfo info, bool includeVerbose = false)
{
string content = OutputManpage(info, includeVerbose);
File.WriteAllText(path, content);
}
/// <summary>
/// Build the roff <c>.TH</c> title line from the page metadata
/// </summary>
/// <param name="info">Document-level metadata for the man page</param>
/// <returns>The formatted title line</returns>
private static string BuildTitleHeader(ManpageInfo info)
{
return ".TH "
+ Quote(info.Name.ToUpperInvariant())
+ " " + Quote(info.Section)
+ " " + Quote(info.Date)
+ " " + Quote(info.Version)
+ " " + Quote(info.Title);
}
/// <summary>
/// Wrap an escaped value in double quotes for a <c>.TH</c> field
/// </summary>
/// <param name="value">Value to quote, if any</param>
/// <returns>The escaped, quoted value</returns>
private static string Quote(string? value)
=> "\"" + Roff.EscapeField(value) + "\"";
#endregion
#region Processing
/// <summary>
@@ -1008,6 +1122,11 @@ namespace SabreTools.CommandLine
helpExtFeature.ProcessArgs(args, 0, this);
return true;
}
else if (topLevel is Manpage manFeature)
{
manFeature.ProcessArgs(args, 0, this);
return true;
}
// Now verify that all other flags are valid
if (!feature.ProcessArgs(args, 1))

View File

@@ -0,0 +1,72 @@
using System;
namespace SabreTools.CommandLine.Features
{
/// <summary>
/// Reference feature that emits a man page for the enclosing command set
/// </summary>
/// <remarks>
/// This mirrors the built-in <see cref="Help"/> feature: it must be given
/// the enclosing <see cref="CommandSet"/> so that it can render the page
/// from the same model used to format help text. The roff output is
/// written to standard output so that it can be redirected to a file at
/// packaging time, for example <c>tool man &gt; tool.1</c>.
/// </remarks>
public class Manpage : Feature
{
public const string DisplayName = "Manpage";
private static readonly string[] _defaultFlags = ["man"];
private const string _description = "Generate a man page";
private const string _detailedDescription = "Write a roff-formatted man page for this program to standard output.";
/// <summary>
/// Document-level metadata for the generated man page
/// </summary>
private readonly ManpageInfo _info;
/// <summary>
/// Indicates if detailed descriptions should be included
/// </summary>
private readonly bool _includeVerbose;
public Manpage(ManpageInfo info, bool includeVerbose = true)
: base(DisplayName, _defaultFlags, _description, _detailedDescription)
{
_info = info;
_includeVerbose = includeVerbose;
RequiresInputs = false;
}
public Manpage(ManpageInfo info, string[] flags, bool includeVerbose = true)
: base(DisplayName, flags, _description, _detailedDescription)
{
_info = info;
_includeVerbose = includeVerbose;
RequiresInputs = false;
}
/// <inheritdoc/>
public override bool ProcessArgs(string[] args, int index)
=> ProcessArgs(args, index, null);
/// <inheritdoc cref="ProcessArgs(string[], int)"/>
/// <param name="parentSet">Reference to the enclosing parent set</param>
public bool ProcessArgs(string[] args, int index, CommandSet? parentSet)
{
// The page can only be rendered with the enclosing set
if (parentSet is not null)
Console.Write(parentSet.OutputManpage(_info, _includeVerbose));
return true;
}
/// <inheritdoc/>
public override bool VerifyInputs() => true;
/// <inheritdoc/>
public override bool Execute() => true;
}
}

View File

@@ -717,6 +717,47 @@ namespace SabreTools.CommandLine.Inputs
public List<string> FormatRecursive(int pre, int midpoint, bool detailed = false)
=> FormatRecursive(tabLevel: 0, pre, midpoint, detailed);
/// <summary>
/// Create roff man page entries for this input and all of its children
/// </summary>
/// <param name="includeVerbose">True if the detailed description should be included, false otherwise</param>
/// <returns>Roff man page lines for this input and its children</returns>
internal List<string> FormatManpage(bool includeVerbose)
{
List<string> output = [];
// Use the flags as the tag, falling back to the name when none are formatted
string tag = FormatFlags();
if (tag.Length == 0)
tag = Name;
// Tagged paragraph: bold flags followed by the description
output.Add(".TP");
output.Add(".B " + Roff.Escape(tag));
output.AddRange(Roff.FormatText(Description));
// Append the detailed description as an additional paragraph
if (includeVerbose && !string.IsNullOrEmpty(DetailedDescription))
{
output.Add(".sp");
output.AddRange(Roff.FormatText(DetailedDescription));
}
// Indent and append all children recursively
if (Children.Count > 0)
{
output.Add(".RS");
foreach (var child in Children.Values)
{
output.AddRange(child.FormatManpage(includeVerbose));
}
output.Add(".RE");
}
return output;
}
/// <summary>
/// Pre-format the flags for output
/// </summary>

View File

@@ -0,0 +1,69 @@
namespace SabreTools.CommandLine
{
/// <summary>
/// Document-level metadata used when generating a man page
/// </summary>
/// <remarks>
/// These values populate the parts of a man page that cannot be
/// derived from the command-line model itself, such as the
/// <c>.TH</c> title line and the <c>NAME</c> section.
/// </remarks>
public class ManpageInfo
{
/// <summary>
/// Program name as invoked on the command line
/// </summary>
/// <remarks>
/// Used for the <c>.TH</c> title line, the <c>NAME</c> section,
/// and the <c>SYNOPSIS</c> section.
/// </remarks>
public string Name { get; set; }
/// <summary>
/// Manual section the page belongs to
/// </summary>
/// <remarks>Defaults to section 1 (user commands)</remarks>
public string Section { get; set; } = "1";
/// <summary>
/// Date used for the <c>.TH</c> title line
/// </summary>
/// <remarks>
/// When left null or empty, the field is emitted blank so that a
/// downstream packager can stamp it at build time.
/// </remarks>
public string? Date { get; set; }
/// <summary>
/// Version string used for the <c>.TH</c> title line
/// </summary>
public string? Version { get; set; }
/// <summary>
/// Manual title used for the <c>.TH</c> title line
/// </summary>
/// <remarks>Typically a value such as "User Commands"</remarks>
public string? Title { get; set; }
/// <summary>
/// Short description used for the <c>NAME</c> section
/// </summary>
/// <remarks>Should be a single printable line</remarks>
public string? Description { get; set; }
/// <summary>
/// Section heading used for the list of inputs
/// </summary>
/// <remarks>Defaults to "OPTIONS"</remarks>
public string OptionsHeading { get; set; } = "OPTIONS";
/// <summary>
/// Create a new <see cref="ManpageInfo"/> for the given program name
/// </summary>
/// <param name="name">Program name as invoked on the command line</param>
public ManpageInfo(string name)
{
Name = name;
}
}
}

View File

@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SabreTools.CommandLine
{
/// <summary>
/// Helpers for emitting roff-formatted man page text
/// </summary>
/// <remarks>
/// Output is restricted to the portable man(7) macro set so that the
/// same page renders without warnings under both groff and mandoc.
/// </remarks>
internal static class Roff
{
/// <summary>
/// Maximum input line length, in characters, before wrapping
/// </summary>
private const int LineWidth = 78;
/// <summary>
/// Escape a value for safe inclusion in roff output
/// </summary>
/// <param name="value">Value to escape, if any</param>
/// <returns>The escaped value, or an empty string if null or empty</returns>
public static string Escape(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
// Backslashes are escaped first so that escapes introduced by
// later replacements are not themselves re-escaped
string escaped = value!.Replace("\\", "\\e");
// Render hyphens as literal ASCII minus signs instead of letting
// groff translate them to Unicode hyphens in UTF-8 output
escaped = escaped.Replace("-", "\\-");
return escaped;
}
/// <summary>
/// Escape a value for inclusion in a quoted <c>.TH</c> title field
/// </summary>
/// <param name="value">Value to escape, if any</param>
/// <returns>The escaped value, or an empty string if null or empty</returns>
/// <remarks>
/// Unlike <see cref="Escape"/>, hyphens are left intact so that the
/// date field remains parseable by mandoc and version strings render
/// verbatim. Embedded quotes are escaped so they cannot terminate the
/// surrounding field.
/// </remarks>
public static string EscapeField(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return value!.Replace("\\", "\\e").Replace("\"", "\\(dq");
}
/// <summary>
/// Format a block of text into wrapped, escaped roff output lines
/// </summary>
/// <param name="text">Text block to format, which may contain newlines</param>
/// <returns>Roff-safe output lines for the text block</returns>
public static List<string> FormatText(string? text)
{
List<string> output = [];
if (string.IsNullOrEmpty(text))
return output;
// Normalize line endings and split into paragraphs on blank lines
string normalized = text!.Replace("\r\n", "\n").Replace("\r", "\n");
string[] paragraphs = normalized.Split(new string[] { "\n\n" }, StringSplitOptions.None);
for (int i = 0; i < paragraphs.Length; i++)
{
// Collapse single newlines within a paragraph into spaces
string paragraph = paragraphs[i].Replace("\n", " ").Trim();
if (paragraph.Length == 0)
continue;
// Separate consecutive paragraphs with a blank line
if (output.Count > 0)
output.Add(".sp");
output.AddRange(WrapParagraph(paragraph));
}
return output;
}
/// <summary>
/// Wrap a single paragraph into escaped roff output lines
/// </summary>
/// <param name="paragraph">Single-line paragraph text to wrap</param>
/// <returns>Escaped output lines no longer than the configured width</returns>
private static List<string> WrapParagraph(string paragraph)
{
List<string> lines = [];
string[] words = paragraph.Split(' ');
var current = new StringBuilder();
for (int i = 0; i < words.Length; i++)
{
string word = Escape(words[i]);
if (word.Length == 0)
continue;
if (current.Length == 0)
{
current.Append(word);
}
else if (current.Length + 1 + word.Length <= LineWidth)
{
current.Append(' ');
current.Append(word);
}
else
{
lines.Add(Protect(current.ToString()));
current = new StringBuilder();
current.Append(word);
}
}
if (current.Length > 0)
lines.Add(Protect(current.ToString()));
return lines;
}
/// <summary>
/// Protect a text line from being parsed as a roff control line
/// </summary>
/// <param name="line">Line to protect</param>
/// <returns>The line, prefixed with a zero-width escape when needed</returns>
private static string Protect(string line)
{
// A line beginning with a control character would be treated as a
// request; a leading zero-width escape forces it to be text
if (line.Length > 0 && (line[0] == '.' || line[0] == '\''))
return "\\&" + line;
return line;
}
}
}