26 Commits
1.3.2 ... 1.6.0

Author SHA1 Message Date
Matt Nadareski
7831da365c Bump version 2026-11-11 09:20:10 +01:00
Matt Nadareski
c5ec963fd1 Add .NET 11 support 2026-11-11 09:18:03 +01:00
Matt Nadareski
4efc3becf5 Add .NET 11 support 2026-11-11 09:15:50 +01:00
Matt Nadareski
88c2c25a71 Bump version 2026-07-10 09:30:28 -04:00
Matt Nadareski
71ef11ac49 Update copyright dates 2026-07-10 09:25:25 -04:00
Matt Nadareski
2753759b33 Update test packages 2026-07-10 09:24:13 -04:00
Matt Nadareski
09f08a9031 Clean up formatting to personal taste 2026-07-10 09:04:55 -04:00
Matt Nadareski
8695e519c6 Add new manpage reference implementation to README 2026-07-01 13:54:59 -04:00
gmipf
754aa6bc32 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>
2026-07-01 13:53:34 -04:00
Matt Nadareski
1da60f1091 Remove CONTRIBUTING due to org-level version 2026-05-16 15:01:49 -04:00
Matt Nadareski
5fd4b8b0a5 Update CONTRIBUTING to be more accurate 2026-05-15 13:05:20 -04:00
Matt Nadareski
2bca1ff744 Add CONTRIBUTING document 2026-05-15 10:59:28 -04:00
Matt Nadareski
8be595bf56 Update gitignore for new VSCode feature 2026-05-15 10:34:14 -04:00
Matt Nadareski
47d067df1c Add editorconfig, fix issues 2026-01-25 17:14:38 -05:00
Matt Nadareski
90bfcc8fe2 Format GHA definitions 2025-11-17 08:36:23 -05:00
Matt Nadareski
be081c701d Bump version 2025-11-12 19:49:06 -05:00
Matt Nadareski
66b0e27d4e Add support for .NET 10 2025-11-11 16:43:21 -05:00
Matt Nadareski
573fe2e160 Update rolling tag 2025-10-26 20:22:42 -04:00
Matt Nadareski
6ad9fa4521 Fix split method in custom help 2025-10-07 12:37:54 -04:00
Matt Nadareski
a261f428f2 Add and allow custom help text 2025-10-07 12:36:00 -04:00
Matt Nadareski
5011331164 Clarify the case where default also has real flags 2025-10-07 12:27:51 -04:00
Matt Nadareski
e257c30892 Clarify help printing with default features 2025-10-07 12:14:18 -04:00
Matt Nadareski
029cfe6dc6 Add optional default feature to CommandSet 2025-10-07 12:13:11 -04:00
Matt Nadareski
f38b8734b5 Simplify the method name 2025-10-07 12:07:41 -04:00
Matt Nadareski
df54b92031 Add AddChildrenFrom method to CommandSet 2025-10-07 12:03:00 -04:00
Matt Nadareski
39acb90dbc Use All help by default instead of top-level only 2025-10-06 08:50:21 -04:00
29 changed files with 1397 additions and 113 deletions

167
.editorconfig Normal file
View File

@@ -0,0 +1,167 @@
# top-most EditorConfig file
root = true
# C# files
[*.cs]
# Indentation and spacing
charset = utf-8
indent_size = 4
indent_style = space
tab_width = 4
trim_trailing_whitespace = true
# New line preferences
end_of_line = lf
insert_final_newline = true
max_line_length = unset
# using directive preferences
csharp_using_directive_placement = outside_namespace
dotnet_diagnostic.IDE0005.severity = error
# Code-block preferences
csharp_style_namespace_declarations = block_scoped
csharp_style_prefer_method_group_conversion = true
csharp_style_prefer_top_level_statements = false
# Expression-level preferences
csharp_prefer_simple_default_expression = true
csharp_style_inlined_variable_declaration = true
csharp_style_unused_value_assignment_preference = discard_variable
csharp_style_unused_value_expression_statement_preference = discard_variable
dotnet_diagnostic.IDE0001.severity = warning
dotnet_diagnostic.IDE0002.severity = warning
dotnet_diagnostic.IDE0004.severity = warning
dotnet_diagnostic.IDE0010.severity = error
dotnet_diagnostic.IDE0051.severity = warning
dotnet_diagnostic.IDE0052.severity = warning
dotnet_diagnostic.IDE0072.severity = warning
dotnet_diagnostic.IDE0080.severity = warning
dotnet_diagnostic.IDE0100.severity = error
dotnet_diagnostic.IDE0110.severity = error
dotnet_diagnostic.IDE0120.severity = warning
dotnet_diagnostic.IDE0121.severity = warning
dotnet_diagnostic.IDE0240.severity = error
dotnet_diagnostic.IDE0241.severity = error
dotnet_style_coalesce_expression = true
dotnet_style_namespace_match_folder = false
dotnet_style_null_propagation = true
dotnet_style_prefer_auto_properties = true
dotnet_style_prefer_collection_expression = when_types_loosely_match
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_compound_assignment = true
csharp_style_prefer_simple_property_accessors = true
dotnet_style_prefer_simplified_interpolation = true
dotnet_style_prefer_simplified_boolean_expressions = true
csharp_style_prefer_unbound_generic_type_in_nameof = true
# Field preferences
dotnet_diagnostic.IDE0044.severity = warning
dotnet_style_readonly_field = true
# Language keyword vs. framework types preferences
dotnet_diagnostic.IDE0049.severity = error
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Modifier preferences
csharp_prefer_static_local_function = true
csharp_style_prefer_readonly_struct = true
dotnet_diagnostic.IDE0036.severity = warning
dotnet_diagnostic.IDE0040.severity = error
dotnet_diagnostic.IDE0380.severity = error
dotnet_style_require_accessibility_modifiers = always
# New-line preferences
dotnet_diagnostic.IDE2000.severity = warning
dotnet_diagnostic.IDE2002.severity = warning
dotnet_diagnostic.IDE2003.severity = warning
dotnet_diagnostic.IDE2004.severity = warning
dotnet_diagnostic.IDE2005.severity = warning
dotnet_diagnostic.IDE2006.severity = warning
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = false
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = false
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false
dotnet_style_allow_multiple_blank_lines_experimental = false
dotnet_style_allow_statement_immediately_after_block_experimental = false
# Null-checking preferences
csharp_style_conditional_delegate_call = true
# Parameter preferences
dotnet_code_quality_unused_parameters = all
dotnet_diagnostic.IDE0280.severity = error
# Parentheses preferences
dotnet_diagnostic.IDE0047.severity = warning
dotnet_diagnostic.IDE0048.severity = warning
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = always_for_clarity
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Pattern-matching preferences
dotnet_diagnostic.IDE0019.severity = warning
dotnet_diagnostic.IDE0020.severity = warning
dotnet_diagnostic.IDE0038.severity = warning
dotnet_diagnostic.IDE0066.severity = none
dotnet_diagnostic.IDE0083.severity = warning
dotnet_diagnostic.IDE0260.severity = warning
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true
# this. and Me. preferences
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# var preferences
csharp_style_var_for_built_in_types = false
csharp_style_var_when_type_is_apparent = true
# .NET formatting options
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = true
# C# formatting options
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = false
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false

View File

@@ -1,40 +1,49 @@
name: Build and Test
on:
push:
branches: [ "main" ]
push:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
6.0.x
8.0.x
9.0.x
- name: Run tests
run: dotnet test
- name: Run publish script
run: ./publish-nix.sh
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Upload to rolling
uses: ncipollo/release-action@v1.14.0
with:
allowUpdates: True
artifacts: "*.nupkg,*.snupkg"
body: 'Last built commit: ${{ github.sha }}'
name: 'Rolling Release'
prerelease: True
replacesArtifacts: True
tag: "rolling"
updateOnlyUnreleased: True
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x
11.0.x
- name: Run tests
run: dotnet test
- name: Run publish script
run: ./publish-nix.sh
- name: Update rolling tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -f rolling
git push origin :refs/tags/rolling || true
git push origin rolling --force
- name: Upload to rolling
uses: ncipollo/release-action@v1.20.0
with:
allowUpdates: True
artifacts: "*.nupkg,*.snupkg"
body: "Last built commit: ${{ github.sha }}"
name: "Rolling Release"
prerelease: True
replacesArtifacts: True
tag: "rolling"
updateOnlyUnreleased: True

View File

@@ -3,21 +3,22 @@ name: Build PR
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
6.0.x
8.0.x
9.0.x
- name: Build
run: dotnet build
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Run tests
run: dotnet test
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x
11.0.x
- name: Build
run: dotnet build
- name: Run tests
run: dotnet test

329
.gitignore vendored
View File

@@ -1,22 +1,18 @@
*.swp
*.*~
project.lock.json
.DS_Store
*.pyc
nupkg/
# Visual Studio Code
.vscode
# Rider
.idea
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.lscache
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
@@ -24,15 +20,312 @@ nupkg/
[Rr]eleases/
x64/
x86/
build/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
msbuild.log
msbuild.err
msbuild.wrn
[Ll]og/
# Visual Studio 2015
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
**/Properties/launchSettings.json
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/

24
.vscode/tasks.json vendored Executable file
View File

@@ -0,0 +1,24 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "shell",
"args": [
"build",
// Ask dotnet build to generate full paths for file names.
"/property:GenerateFullPaths=true",
// Do not generate summary otherwise it leads to duplicate errors in Problems panel
"/consoleloggerparameters:NoSummary"
],
"group": "build",
"presentation": {
"reveal": "silent"
},
"problemMatcher": "$msCompile"
}
]
}

View File

@@ -1,4 +1,4 @@
Copyright (c) 2016-2025 Matt Nadareski
Copyright (c) 2016-2026 Matt Nadareski
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@@ -65,6 +65,7 @@ Three reference feature implementations are included in the `SabreTools.CommandL
| --- | --- |
| `Help` | Outputs either a generic help text or one specific to a feature name included as the second argument |
| `HelpExtended` | Outputs either a generic help text with extended descriptions or one specific to a feature name included as the second argument |
| `Manpage` | Outputs a roff-formatted manpage based on the current command set |
| `Version` | Outputs the version number of the program, derived from the informational version |
## Releases

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using SabreTools.CommandLine.Inputs;
using Xunit;
@@ -13,9 +14,16 @@ namespace SabreTools.CommandLine.Test
var input1 = new FlagInput("input1", "--input1", "input1");
var input2 = new FlagInput("input2", "--input2", "input2");
var feature1 = new MockFeature("feature1", "feature1", "feature1");
var inputA = new FlagInput("inputA", "--inputA", "inputA");
var inputB = new FlagInput("inputB", "--inputB", "inputB");
feature1.Add(inputA);
feature1.Add(inputB);
var featureSet = new CommandSet();
featureSet.Add(input1);
featureSet.Add(input2);
featureSet.AddFrom(feature1);
var actualInput1 = featureSet["input1"];
Assert.NotNull(actualInput1);
@@ -27,6 +35,14 @@ namespace SabreTools.CommandLine.Test
var actualInput3 = featureSet["input3"];
Assert.Null(actualInput3);
var actualInputA = featureSet["inputA"];
Assert.NotNull(actualInputA);
Assert.Equal("inputA", actualInputA.Name);
var actualinputB = featureSet["inputB"];
Assert.NotNull(actualinputB);
Assert.Equal("inputB", actualinputB.Name);
}
[Fact]
@@ -764,7 +780,7 @@ namespace SabreTools.CommandLine.Test
[Fact]
public void ProcessArgs_EmptyArgs_Success()
{
CommandSet commandSet = new CommandSet();
var commandSet = new CommandSet();
string[] args = [];
@@ -775,7 +791,7 @@ namespace SabreTools.CommandLine.Test
[Fact]
public void ProcessArgs_ValidArgs_Success()
{
CommandSet commandSet = new CommandSet();
var commandSet = new CommandSet();
Feature feature = new MockFeature("a", "a", "a");
feature.Add(new FlagInput("b", "b", "b"));
feature.Add(new FlagInput("c", "c", "c"));
@@ -791,7 +807,7 @@ namespace SabreTools.CommandLine.Test
[Fact]
public void ProcessArgs_InvalidArg_AddedAsGeneric()
{
CommandSet commandSet = new CommandSet();
var commandSet = new CommandSet();
Feature feature = new MockFeature("a", "a", "a");
feature.Add(new FlagInput("b", "b", "b"));
feature.Add(new FlagInput("d", "d", "d"));
@@ -808,7 +824,7 @@ namespace SabreTools.CommandLine.Test
[Fact]
public void ProcessArgs_NestedArgs_Success()
{
CommandSet commandSet = new CommandSet();
var commandSet = new CommandSet();
Feature feature = new MockFeature("a", "a", "a");
var sub = new FlagInput("b", "b", "b");
sub.Add(new FlagInput("c", "c", "c"));
@@ -824,6 +840,152 @@ 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,83 @@
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();
try
{
Console.SetOut(writer);
bool result = set.ProcessArgs(["man"]);
Assert.True(result);
}
finally
{
Console.SetOut(original);
}
string actual = writer.ToString();
Assert.Contains(".TH \"SAMPLE\" \"1\"", actual);
Assert.Contains(".SH NAME", actual);
Assert.Contains(".B \\-\\-convert", actual);
}
/// <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,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0;net10.0;net11.0</TargetFrameworks>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
@@ -12,13 +12,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="coverlet.collector" Version="10.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using SabreTools.CommandLine.Features;
using SabreTools.CommandLine.Inputs;
using SabreTools.CommandLine.Tools;
namespace SabreTools.CommandLine
{
@@ -40,6 +42,41 @@ namespace SabreTools.CommandLine
#endregion
#region Properties
/// <summary>
/// Custom help text to print instead of the automatically
/// generated generic help
/// </summary>
/// <remarks>
/// This only replaces the automatically generated help
/// for <see cref="OutputGenericHelp"/>. It does not impact
/// either <see cref="OutputAllHelp"/> or <see cref="OutputFeatureHelp"/>.
/// </remarks>
public string? CustomHelp { get; set; }
/// <summary>
/// Feature that represents the default functionality
/// for a command set
/// </summary>
/// <remarks>
/// It is recommended to use this in applications that
/// do not need multiple distinct functional modes.
///
/// When printing help text, the flags and description
/// of this feature will be omitted, instead printing
/// the children of the feature directly at the
/// top level.
///
/// If the default feature is included as a normal
/// top-level input for the command set, then the flags
/// will be printed twice - once under the feature
/// itself and once at the top level.
/// </remarks>
public Feature? DefaultFeature { get; set; }
#endregion
#region Constructors
/// <summary>
@@ -120,6 +157,29 @@ namespace SabreTools.CommandLine
public void Add(UserInput input)
=> _inputs.Add(input.Name, input);
/// <summary>
/// Add all children from an input to the set
/// </summary>
/// <param name="input">UserInput object to retrieve children from</param>
/// <remarks>
/// This should only be used in situations where an input is defined
/// but not used within the context of the command set directly.
///
/// This is helpful for when there are applications with default functionality
/// that need to be able to expose both defined features as well as
/// the default functionality in help text.
///
/// If there is any overlap between existing names and the names from
/// any of the children, this operation will overwrite them.
/// </reamrks>
public void AddFrom(UserInput input)
{
foreach (var kvp in input.Children)
{
_inputs.Add(kvp.Key, kvp.Value);
}
}
#endregion
#region Children
@@ -730,13 +790,34 @@ namespace SabreTools.CommandLine
if (_header.Count > 0)
output.AddRange(_header);
// Now append all available top-level flags
output.Add("Available options:");
foreach (var input in _inputs.Values)
// If custom help text is defined
if (CustomHelp is not null)
{
var outputs = input.Format(pre: 2, midpoint: 30, detailed);
if (outputs != null)
output.AddRange(outputs);
string customHelp = CustomHelp.Replace("\r\n", "\n");
string[] customLines = customHelp.Split('\n');
output.AddRange(customLines);
}
else
{
// Append all available top-level flags
output.Add("Available options:");
foreach (var input in _inputs.Values)
{
var outputs = input.Format(pre: 2, midpoint: 30, detailed);
if (outputs is not null)
output.AddRange(outputs);
}
// If there is a default feature
if (DefaultFeature is not null)
{
foreach (var input in DefaultFeature.Children)
{
var outputs = input.Value.Format(pre: 2, midpoint: 30, detailed);
if (outputs is not null)
output.AddRange(outputs);
}
}
}
// Append the footer, if needed
@@ -760,15 +841,26 @@ namespace SabreTools.CommandLine
if (_header.Count > 0)
output.AddRange(_header);
// Now append all available flags recursively
// Append all available flags recursively
output.Add("Available options:");
foreach (var input in _inputs.Values)
{
var outputs = input.FormatRecursive(pre: 2, midpoint: 30, detailed);
if (outputs != null)
if (outputs is not null)
output.AddRange(outputs);
}
// If there is a default feature
if (DefaultFeature is not null)
{
foreach (var input in DefaultFeature.Children)
{
var outputs = input.Value.Format(pre: 2, midpoint: 30, detailed);
if (outputs is not null)
output.AddRange(outputs);
}
}
// Append the footer, if needed
if (_footer.Count > 0)
output.AddRange(_footer);
@@ -873,6 +965,120 @@ 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(item: $".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]) + "\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>
@@ -891,7 +1097,7 @@ namespace SabreTools.CommandLine
// If there's no arguments, show help
if (args.Length == 0)
{
OutputGenericHelp();
OutputAllHelp();
return true;
}
@@ -900,7 +1106,7 @@ namespace SabreTools.CommandLine
// Get the associated feature
var topLevel = GetTopLevel(featureName);
if (topLevel == null || topLevel is not Feature feature)
if (topLevel is null || topLevel is not Feature feature)
{
Console.WriteLine($"'{featureName}' is not valid feature flag");
OutputFeatureHelp(featureName);
@@ -918,6 +1124,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

@@ -46,7 +46,7 @@ namespace SabreTools.CommandLine.Features
try
{
var assembly = Assembly.GetEntryAssembly();
if (assembly == null)
if (assembly is null)
return null;
var assemblyVersion = Attribute.GetCustomAttribute(assembly, typeof(AssemblyInformationalVersionAttribute)) as AssemblyInformationalVersionAttribute;

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="bool"/>
/// Represents a user input bounded to the range of <see cref="bool"/>
/// </summary>
public class BooleanInput : UserInput<bool?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="short"/>
/// Represents a user input bounded to the range of <see cref="short"/>
/// </summary>
public class Int16Input : UserInput<short?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="int"/>
/// Represents a user input bounded to the range of <see cref="int"/>
/// </summary>
public class Int32Input : UserInput<int?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="long"/>
/// Represents a user input bounded to the range of <see cref="long"/>
/// </summary>
public class Int64Input : UserInput<long?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="sbyte"/>
/// Represents a user input bounded to the range of <see cref="sbyte"/>
/// </summary>
public class Int8Input : UserInput<sbyte?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="ushort"/>
/// Represents a user input bounded to the range of <see cref="ushort"/>
/// </summary>
public class UInt16Input : UserInput<ushort?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="uint"/>
/// Represents a user input bounded to the range of <see cref="uint"/>
/// </summary>
public class UInt32Input : UserInput<uint?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="ulong"/>
/// Represents a user input bounded to the range of <see cref="ulong"/>
/// </summary>
public class UInt64Input : UserInput<ulong?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="byte"/>
/// Represents a user input bounded to the range of <see cref="byte"/>
/// </summary>
public class UInt8Input : UserInput<byte?>
{

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Text;
using SabreTools.CommandLine.Tools;
namespace SabreTools.CommandLine.Inputs
{
@@ -41,7 +42,7 @@ namespace SabreTools.CommandLine.Inputs
/// <summary>
/// Set of children associated with this input
/// </summary>
protected readonly Dictionary<string, UserInput> Children = [];
protected internal readonly Dictionary<string, UserInput> Children = [];
#endregion
@@ -86,7 +87,7 @@ namespace SabreTools.CommandLine.Inputs
/// </summary>
public UserInput? this[UserInput subfeature]
{
get
get
{
if (!Children.TryGetValue(subfeature.Name, out var input))
return null;
@@ -723,13 +724,45 @@ namespace SabreTools.CommandLine.Inputs
protected abstract string FormatFlags();
/// <summary>
/// Create a padding space based on the given length
/// Create roff man page entries for this input and all of its children
/// </summary>
/// <param name="spaces">Number of padding spaces to add</param>
/// <returns>String with requested number of blank spaces</returns>
private static string CreatePadding(int spaces) => spaces > 0
? string.Empty.PadRight(spaces)
: string.Empty;
/// <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>
/// Format the standard help output line
@@ -839,6 +872,15 @@ namespace SabreTools.CommandLine.Inputs
return outputList;
}
/// <summary>
/// Create a padding space based on the given length
/// </summary>
/// <param name="spaces">Number of padding spaces to add</param>
/// <returns>String with requested number of blank spaces</returns>
private static string CreatePadding(int spaces) => spaces > 0
? string.Empty.PadRight(spaces)
: string.Empty;
/// <summary>
/// Create formatted help text including all children
/// </summary>

View File

@@ -27,10 +27,10 @@ namespace SabreTools.CommandLine.Inputs
#region Instance Methods
/// <inheritdoc/>
public override abstract bool ProcessInput(string[] args, ref int index);
public abstract override bool ProcessInput(string[] args, ref int index);
/// <inheritdoc/>
protected override abstract string FormatFlags();
protected abstract override string FormatFlags();
#endregion
}

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

@@ -2,7 +2,7 @@
<PropertyGroup>
<!-- Assembly Properties -->
<TargetFrameworks>net20;net35;net40;net452;net462;net472;net48;netcoreapp3.1;net5.0;net6.0;net7.0;net8.0;net9.0;netstandard2.0;netstandard2.1</TargetFrameworks>
<TargetFrameworks>net20;net35;net40;net452;net462;net472;net48;netcoreapp3.1;net5.0;net6.0;net7.0;net8.0;net9.0;net10.0;net11.0;netstandard2.0;netstandard2.1</TargetFrameworks>
<CheckEolTargetFramework>false</CheckEolTargetFramework>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<IncludeSymbols>true</IncludeSymbols>
@@ -11,12 +11,12 @@
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Version>1.3.2</Version>
<Version>1.6.0</Version>
<!-- Package Properties -->
<Authors>Matt Nadareski</Authors>
<Description>Commandline parsing and output library</Description>
<Copyright>Copyright (c)2016-2025 Matt Nadareski</Copyright>
<Copyright>Copyright (c)2016-2026 Matt Nadareski</Copyright>
<PackageProjectUrl>https://github.com/SabreTools/</PackageProjectUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/SabreTools/SabreTools.CommandLine</RepositoryUrl>

View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SabreTools.CommandLine.Tools
{
/// <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(["\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");
List<string> wrapped = WrapParagraph(paragraph);
output.AddRange(wrapped);
}
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 sb = new StringBuilder();
for (int i = 0; i < words.Length; i++)
{
string word = Escape(words[i]);
if (word.Length == 0)
continue;
if (sb.Length == 0)
{
sb.Append(word);
}
else if (sb.Length + 1 + word.Length <= LineWidth)
{
sb.Append(' ');
sb.Append(word);
}
else
{
lines.Add(Protect(sb.ToString()));
sb = new StringBuilder();
sb.Append(word);
}
}
if (sb.Length > 0)
lines.Add(Protect(sb.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;
}
}
}

View File

@@ -1,7 +1,7 @@
#! /bin/bash
# This batch file assumes the following:
# - .NET 9.0 (or newer) SDK is installed and in PATH
# - .NET 11.0 (or newer) SDK is installed and in PATH
#
# If any of these are not satisfied, the operation may fail
# in an unpredictable way and result in an incomplete output.

View File

@@ -1,5 +1,5 @@
# This batch file assumes the following:
# - .NET 9.0 (or newer) SDK is installed and in PATH
# - .NET 11.0 (or newer) SDK is installed and in PATH
#
# If any of these are not satisfied, the operation may fail
# in an unpredictable way and result in an incomplete output.