diff --git a/SabreTools.CommandLine.Test/CommandSetTests.cs b/SabreTools.CommandLine.Test/CommandSetTests.cs
index 6caddf6..cafe044 100644
--- a/SabreTools.CommandLine.Test/CommandSetTests.cs
+++ b/SabreTools.CommandLine.Test/CommandSetTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.IO;
using SabreTools.CommandLine.Inputs;
using Xunit;
@@ -839,6 +840,144 @@ public void ProcessArgs_NestedArgs_Success()
#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
+
+ ///
+ /// Build a representative command set with nested inputs and detail text
+ ///
+ 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;
+ }
+
///
/// Mock Feature implementation for testing
///
diff --git a/SabreTools.CommandLine.Test/Features/ManpageTests.cs b/SabreTools.CommandLine.Test/Features/ManpageTests.cs
new file mode 100644
index 0000000..169f67c
--- /dev/null
+++ b/SabreTools.CommandLine.Test/Features/ManpageTests.cs
@@ -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);
+ }
+
+ ///
+ /// Build a representative command set with nested inputs and detail text
+ ///
+ 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;
+ }
+
+ ///
+ /// Mock Feature implementation for testing
+ ///
+ 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)
+ {
+ }
+
+ ///
+ public override bool Execute() => true;
+
+ ///
+ public override bool VerifyInputs() => true;
+ }
+ }
+}
diff --git a/SabreTools.CommandLine/CommandSet.cs b/SabreTools.CommandLine/CommandSet.cs
index 4b2be69..52c4cd3 100644
--- a/SabreTools.CommandLine/CommandSet.cs
+++ b/SabreTools.CommandLine/CommandSet.cs
@@ -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 @@ private static void WriteOutWithPauses(List helpText)
#endregion
+ #region Manpage Output
+
+ ///
+ /// Generate a man page from the current set of inputs
+ ///
+ /// Document-level metadata for the man page
+ /// True if detailed descriptions should be included, false otherwise
+ /// The complete man page as a roff-formatted string
+ ///
+ /// 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.
+ ///
+ public string OutputManpage(ManpageInfo info, bool includeVerbose = false)
+ {
+ // Start building the output list
+ List 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";
+ }
+
+ ///
+ /// Generate a man page from the current set of inputs and write it to a file
+ ///
+ /// Path to write the generated man page to
+ /// Document-level metadata for the man page
+ /// True if detailed descriptions should be included, false otherwise
+ /// The file is written as UTF-8 without a byte order mark
+ public void OutputManpage(string path, ManpageInfo info, bool includeVerbose = false)
+ {
+ string content = OutputManpage(info, includeVerbose);
+ File.WriteAllText(path, content);
+ }
+
+ ///
+ /// Build the roff .TH title line from the page metadata
+ ///
+ /// Document-level metadata for the man page
+ /// The formatted title line
+ private static string BuildTitleHeader(ManpageInfo info)
+ {
+ return ".TH "
+ + Quote(info.Name.ToUpperInvariant())
+ + " " + Quote(info.Section)
+ + " " + Quote(info.Date)
+ + " " + Quote(info.Version)
+ + " " + Quote(info.Title);
+ }
+
+ ///
+ /// Wrap an escaped value in double quotes for a .TH field
+ ///
+ /// Value to quote, if any
+ /// The escaped, quoted value
+ private static string Quote(string? value)
+ => "\"" + Roff.EscapeField(value) + "\"";
+
+ #endregion
+
#region Processing
///
@@ -1008,6 +1122,11 @@ public bool ProcessArgs(string[] args)
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))
diff --git a/SabreTools.CommandLine/Features/Manpage.cs b/SabreTools.CommandLine/Features/Manpage.cs
new file mode 100644
index 0000000..5256f95
--- /dev/null
+++ b/SabreTools.CommandLine/Features/Manpage.cs
@@ -0,0 +1,72 @@
+using System;
+
+namespace SabreTools.CommandLine.Features
+{
+ ///
+ /// Reference feature that emits a man page for the enclosing command set
+ ///
+ ///
+ /// This mirrors the built-in feature: it must be given
+ /// the enclosing 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 tool man > tool.1.
+ ///
+ 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.";
+
+ ///
+ /// Document-level metadata for the generated man page
+ ///
+ private readonly ManpageInfo _info;
+
+ ///
+ /// Indicates if detailed descriptions should be included
+ ///
+ 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;
+ }
+
+ ///
+ public override bool ProcessArgs(string[] args, int index)
+ => ProcessArgs(args, index, null);
+
+ ///
+ /// Reference to the enclosing parent set
+ 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;
+ }
+
+ ///
+ public override bool VerifyInputs() => true;
+
+ ///
+ public override bool Execute() => true;
+ }
+}
diff --git a/SabreTools.CommandLine/Inputs/UserInput.cs b/SabreTools.CommandLine/Inputs/UserInput.cs
index 97904ff..0426fa9 100644
--- a/SabreTools.CommandLine/Inputs/UserInput.cs
+++ b/SabreTools.CommandLine/Inputs/UserInput.cs
@@ -717,6 +717,47 @@ public List FormatRecursive(bool detailed = false)
public List FormatRecursive(int pre, int midpoint, bool detailed = false)
=> FormatRecursive(tabLevel: 0, pre, midpoint, detailed);
+ ///
+ /// Create roff man page entries for this input and all of its children
+ ///
+ /// True if the detailed description should be included, false otherwise
+ /// Roff man page lines for this input and its children
+ internal List FormatManpage(bool includeVerbose)
+ {
+ List 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;
+ }
+
///
/// Pre-format the flags for output
///
diff --git a/SabreTools.CommandLine/ManpageInfo.cs b/SabreTools.CommandLine/ManpageInfo.cs
new file mode 100644
index 0000000..d65b293
--- /dev/null
+++ b/SabreTools.CommandLine/ManpageInfo.cs
@@ -0,0 +1,69 @@
+namespace SabreTools.CommandLine
+{
+ ///
+ /// Document-level metadata used when generating a man page
+ ///
+ ///
+ /// These values populate the parts of a man page that cannot be
+ /// derived from the command-line model itself, such as the
+ /// .TH title line and the NAME section.
+ ///
+ public class ManpageInfo
+ {
+ ///
+ /// Program name as invoked on the command line
+ ///
+ ///
+ /// Used for the .TH title line, the NAME section,
+ /// and the SYNOPSIS section.
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// Manual section the page belongs to
+ ///
+ /// Defaults to section 1 (user commands)
+ public string Section { get; set; } = "1";
+
+ ///
+ /// Date used for the .TH title line
+ ///
+ ///
+ /// When left null or empty, the field is emitted blank so that a
+ /// downstream packager can stamp it at build time.
+ ///
+ public string? Date { get; set; }
+
+ ///
+ /// Version string used for the .TH title line
+ ///
+ public string? Version { get; set; }
+
+ ///
+ /// Manual title used for the .TH title line
+ ///
+ /// Typically a value such as "User Commands"
+ public string? Title { get; set; }
+
+ ///
+ /// Short description used for the NAME section
+ ///
+ /// Should be a single printable line
+ public string? Description { get; set; }
+
+ ///
+ /// Section heading used for the list of inputs
+ ///
+ /// Defaults to "OPTIONS"
+ public string OptionsHeading { get; set; } = "OPTIONS";
+
+ ///
+ /// Create a new for the given program name
+ ///
+ /// Program name as invoked on the command line
+ public ManpageInfo(string name)
+ {
+ Name = name;
+ }
+ }
+}
diff --git a/SabreTools.CommandLine/Roff.cs b/SabreTools.CommandLine/Roff.cs
new file mode 100644
index 0000000..de55afc
--- /dev/null
+++ b/SabreTools.CommandLine/Roff.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace SabreTools.CommandLine
+{
+ ///
+ /// Helpers for emitting roff-formatted man page text
+ ///
+ ///
+ /// Output is restricted to the portable man(7) macro set so that the
+ /// same page renders without warnings under both groff and mandoc.
+ ///
+ internal static class Roff
+ {
+ ///
+ /// Maximum input line length, in characters, before wrapping
+ ///
+ private const int LineWidth = 78;
+
+ ///
+ /// Escape a value for safe inclusion in roff output
+ ///
+ /// Value to escape, if any
+ /// The escaped value, or an empty string if null or empty
+ 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;
+ }
+
+ ///
+ /// Escape a value for inclusion in a quoted .TH title field
+ ///
+ /// Value to escape, if any
+ /// The escaped value, or an empty string if null or empty
+ ///
+ /// Unlike , 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.
+ ///
+ public static string EscapeField(string? value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return string.Empty;
+
+ return value!.Replace("\\", "\\e").Replace("\"", "\\(dq");
+ }
+
+ ///
+ /// Format a block of text into wrapped, escaped roff output lines
+ ///
+ /// Text block to format, which may contain newlines
+ /// Roff-safe output lines for the text block
+ public static List FormatText(string? text)
+ {
+ List 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;
+ }
+
+ ///
+ /// Wrap a single paragraph into escaped roff output lines
+ ///
+ /// Single-line paragraph text to wrap
+ /// Escaped output lines no longer than the configured width
+ private static List WrapParagraph(string paragraph)
+ {
+ List 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;
+ }
+
+ ///
+ /// Protect a text line from being parsed as a roff control line
+ ///
+ /// Line to protect
+ /// The line, prefixed with a zero-width escape when needed
+ 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;
+ }
+ }
+}