From 4b5cea36ab5b4966ba5e7b62c82ee070d041941b Mon Sep 17 00:00:00 2001 From: gmipf Date: Fri, 26 Jun 2026 11:13:46 +0200 Subject: [PATCH 1/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 --- SabreTools.CommandLine.Test/ManPageTests.cs | 193 ++++++++++++++++++++ SabreTools.CommandLine/CommandSet.cs | 122 +++++++++++++ SabreTools.CommandLine/Features/ManPage.cs | 72 ++++++++ SabreTools.CommandLine/Inputs/UserInput.cs | 41 +++++ SabreTools.CommandLine/ManPageInfo.cs | 69 +++++++ SabreTools.CommandLine/Roff.cs | 148 +++++++++++++++ 6 files changed, 645 insertions(+) create mode 100644 SabreTools.CommandLine.Test/ManPageTests.cs create mode 100644 SabreTools.CommandLine/Features/ManPage.cs create mode 100644 SabreTools.CommandLine/ManPageInfo.cs create mode 100644 SabreTools.CommandLine/Roff.cs diff --git a/SabreTools.CommandLine.Test/ManPageTests.cs b/SabreTools.CommandLine.Test/ManPageTests.cs new file mode 100644 index 0000000..30b2485 --- /dev/null +++ b/SabreTools.CommandLine.Test/ManPageTests.cs @@ -0,0 +1,193 @@ +using System; +using System.IO; +using SabreTools.CommandLine.Inputs; +using Xunit; + +namespace SabreTools.CommandLine.Test +{ + public class ManPageTests + { + /// + /// 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; + } + + [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); + } + } + + [Fact] + public void ManPageFeature_WritesPageToStandardOutput() + { + var set = BuildSampleSet(); + var info = new ManPageInfo("sample") { Version = "sample 1.0" }; + set.Add(new Features.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); + } + + /// + /// 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..7cc5191 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,122 @@ private static void WriteOutWithPauses(List helpText) #endregion + #region Man Page 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) + { + if (info is null) + throw new ArgumentNullException(nameof(info)); + + // 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 +1125,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..653138c --- /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 help-man > tool.1. + /// + public class ManPage : Feature + { + public const string DisplayName = "Man Page"; + + 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..673c6a5 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..83400d2 --- /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; + } + } +} From 71c5519a086f5b10ebd1cd1b4c515c70fde493ca Mon Sep 17 00:00:00 2001 From: gmipf Date: Wed, 1 Jul 2026 19:10:24 +0200 Subject: [PATCH 2/2] 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 --- .../CommandSetTests.cs | 139 +++++++++++++ .../Features/ManpageTests.cs | 80 ++++++++ SabreTools.CommandLine.Test/ManPageTests.cs | 193 ------------------ SabreTools.CommandLine/CommandSet.cs | 19 +- .../Features/{ManPage.cs => Manpage.cs} | 14 +- SabreTools.CommandLine/Inputs/UserInput.cs | 4 +- .../{ManPageInfo.cs => ManpageInfo.cs} | 6 +- 7 files changed, 239 insertions(+), 216 deletions(-) create mode 100644 SabreTools.CommandLine.Test/Features/ManpageTests.cs delete mode 100644 SabreTools.CommandLine.Test/ManPageTests.cs rename SabreTools.CommandLine/Features/{ManPage.cs => Manpage.cs} (84%) rename SabreTools.CommandLine/{ManPageInfo.cs => ManpageInfo.cs} (94%) 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.Test/ManPageTests.cs b/SabreTools.CommandLine.Test/ManPageTests.cs deleted file mode 100644 index 30b2485..0000000 --- a/SabreTools.CommandLine.Test/ManPageTests.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System; -using System.IO; -using SabreTools.CommandLine.Inputs; -using Xunit; - -namespace SabreTools.CommandLine.Test -{ - public class ManPageTests - { - /// - /// 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; - } - - [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); - } - } - - [Fact] - public void ManPageFeature_WritesPageToStandardOutput() - { - var set = BuildSampleSet(); - var info = new ManPageInfo("sample") { Version = "sample 1.0" }; - set.Add(new Features.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); - } - - /// - /// 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 7cc5191..52c4cd3 100644 --- a/SabreTools.CommandLine/CommandSet.cs +++ b/SabreTools.CommandLine/CommandSet.cs @@ -964,7 +964,7 @@ private static void WriteOutWithPauses(List helpText) #endregion - #region Man Page Output + #region Manpage Output /// /// Generate a man page from the current set of inputs @@ -978,11 +978,8 @@ private static void WriteOutWithPauses(List helpText) /// 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) + public string OutputManpage(ManpageInfo info, bool includeVerbose = false) { - if (info is null) - throw new ArgumentNullException(nameof(info)); - // Start building the output list List output = []; @@ -1016,7 +1013,7 @@ public string OutputManPage(ManPageInfo info, bool includeVerbose = false) output.Add(".SH " + info.OptionsHeading); foreach (var input in _inputs.Values) { - output.AddRange(input.FormatManPage(includeVerbose)); + output.AddRange(input.FormatManpage(includeVerbose)); } // If there is a default feature, include its children directly @@ -1024,7 +1021,7 @@ public string OutputManPage(ManPageInfo info, bool includeVerbose = false) { foreach (var input in DefaultFeature.Children.Values) { - output.AddRange(input.FormatManPage(includeVerbose)); + output.AddRange(input.FormatManpage(includeVerbose)); } } @@ -1049,9 +1046,9 @@ public string OutputManPage(ManPageInfo info, bool includeVerbose = false) /// 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) + public void OutputManpage(string path, ManpageInfo info, bool includeVerbose = false) { - string content = OutputManPage(info, includeVerbose); + string content = OutputManpage(info, includeVerbose); File.WriteAllText(path, content); } @@ -1060,7 +1057,7 @@ public void OutputManPage(string path, ManPageInfo info, bool includeVerbose = f /// /// Document-level metadata for the man page /// The formatted title line - private static string BuildTitleHeader(ManPageInfo info) + private static string BuildTitleHeader(ManpageInfo info) { return ".TH " + Quote(info.Name.ToUpperInvariant()) @@ -1125,7 +1122,7 @@ public bool ProcessArgs(string[] args) helpExtFeature.ProcessArgs(args, 0, this); return true; } - else if (topLevel is ManPage manFeature) + else if (topLevel is Manpage manFeature) { manFeature.ProcessArgs(args, 0, this); return true; diff --git a/SabreTools.CommandLine/Features/ManPage.cs b/SabreTools.CommandLine/Features/Manpage.cs similarity index 84% rename from SabreTools.CommandLine/Features/ManPage.cs rename to SabreTools.CommandLine/Features/Manpage.cs index 653138c..5256f95 100644 --- a/SabreTools.CommandLine/Features/ManPage.cs +++ b/SabreTools.CommandLine/Features/Manpage.cs @@ -10,11 +10,11 @@ namespace SabreTools.CommandLine.Features /// 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 help-man > tool.1. + /// packaging time, for example tool man > tool.1. /// - public class ManPage : Feature + public class Manpage : Feature { - public const string DisplayName = "Man Page"; + public const string DisplayName = "Manpage"; private static readonly string[] _defaultFlags = ["man"]; @@ -25,14 +25,14 @@ public class ManPage : Feature /// /// Document-level metadata for the generated man page /// - private readonly ManPageInfo _info; + private readonly ManpageInfo _info; /// /// Indicates if detailed descriptions should be included /// private readonly bool _includeVerbose; - public ManPage(ManPageInfo info, bool includeVerbose = true) + public Manpage(ManpageInfo info, bool includeVerbose = true) : base(DisplayName, _defaultFlags, _description, _detailedDescription) { _info = info; @@ -40,7 +40,7 @@ public ManPage(ManPageInfo info, bool includeVerbose = true) RequiresInputs = false; } - public ManPage(ManPageInfo info, string[] flags, bool includeVerbose = true) + public Manpage(ManpageInfo info, string[] flags, bool includeVerbose = true) : base(DisplayName, flags, _description, _detailedDescription) { _info = info; @@ -58,7 +58,7 @@ 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)); + Console.Write(parentSet.OutputManpage(_info, _includeVerbose)); return true; } diff --git a/SabreTools.CommandLine/Inputs/UserInput.cs b/SabreTools.CommandLine/Inputs/UserInput.cs index 673c6a5..0426fa9 100644 --- a/SabreTools.CommandLine/Inputs/UserInput.cs +++ b/SabreTools.CommandLine/Inputs/UserInput.cs @@ -722,7 +722,7 @@ public List FormatRecursive(int pre, int midpoint, bool detailed = false /// /// 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) + internal List FormatManpage(bool includeVerbose) { List output = []; @@ -749,7 +749,7 @@ internal List FormatManPage(bool includeVerbose) output.Add(".RS"); foreach (var child in Children.Values) { - output.AddRange(child.FormatManPage(includeVerbose)); + output.AddRange(child.FormatManpage(includeVerbose)); } output.Add(".RE"); diff --git a/SabreTools.CommandLine/ManPageInfo.cs b/SabreTools.CommandLine/ManpageInfo.cs similarity index 94% rename from SabreTools.CommandLine/ManPageInfo.cs rename to SabreTools.CommandLine/ManpageInfo.cs index 83400d2..d65b293 100644 --- a/SabreTools.CommandLine/ManPageInfo.cs +++ b/SabreTools.CommandLine/ManpageInfo.cs @@ -8,7 +8,7 @@ /// derived from the command-line model itself, such as the /// .TH title line and the NAME section. /// - public class ManPageInfo + public class ManpageInfo { /// /// Program name as invoked on the command line @@ -58,10 +58,10 @@ public class ManPageInfo public string OptionsHeading { get; set; } = "OPTIONS"; /// - /// Create a new for the given program name + /// Create a new for the given program name /// /// Program name as invoked on the command line - public ManPageInfo(string name) + public ManpageInfo(string name) { Name = name; }