mirror of
https://github.com/SabreTools/SabreTools.CommandLine.git
synced 2026-07-08 17:56:48 +00:00
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:
@@ -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>
|
||||
|
||||
80
SabreTools.CommandLine.Test/Features/ManpageTests.cs
Normal file
80
SabreTools.CommandLine.Test/Features/ManpageTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
72
SabreTools.CommandLine/Features/Manpage.cs
Normal file
72
SabreTools.CommandLine/Features/Manpage.cs
Normal 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 > 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
69
SabreTools.CommandLine/ManpageInfo.cs
Normal file
69
SabreTools.CommandLine/ManpageInfo.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
148
SabreTools.CommandLine/Roff.cs
Normal file
148
SabreTools.CommandLine/Roff.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user