feat: New wrapping algorithm, tests, and using wrapping on help text above options list

This commit is contained in:
Nitin Kumar 2026-04-16 17:16:56 +05:30
parent 185cf9ef80
commit edbfbad35b
2 changed files with 479 additions and 77 deletions

View File

@ -2210,6 +2210,141 @@ namespace {
constexpr std::size_t OPTION_LONGEST = 30;
constexpr std::size_t OPTION_DESC_GAP = 2;
String
wrap_text
(
const String& text,
std::size_t allowed,
std::size_t start = 0 // spaces_to_append_at_newline
)
{
if(allowed == 0) return String{};
String result;
auto current = std::begin(text);
using Iterator = decltype(current);
auto startLine = current;
auto lastSpace = current;
auto size = std::size_t{};
bool firstLine = true;
const auto end = std::end(text);
// Loop invariants at the beginning of each iteration:
// 1 - [std::begin(text), startLine) is already added to result
// 2 - currentLine [startLine, current) is not added to result yet
// 3 - size is the number of characters in [startLine, current)
//
// At every loop we try to include current in the currentLine.
// If there is a need to start a new line, we do that first.
// Treat explicit newlines as whitespace for trimming and break detection.
auto is_space = [](Iterator itr) -> bool {
return *itr == ' ' || *itr == '\t' || *itr == '\n';
};
// Ensure when calling begin <= end
auto add_line = [&firstLine, &result, &is_space, &start](Iterator begin, Iterator end) {
// begin == end means empty line
// Handle newlines, clamping, everything here
if(!firstLine) {
stringAppend(result, 1, '\n');
}
// Trim trailing spaces
// Note: Its possible to be left with empty line after trim
// So this trimming should be done before the next check.
while(end != begin && is_space(std::prev(end))) {
--end;
}
// Actual Content
if(begin != end) {
// Clamp if not the first line
if(!firstLine) stringAppend(result, start, ' ');
stringAppend(result, begin, end);
}
firstLine = false;
};
// Make the line [itr, current]
// It is assumed, as a special case for the below algorithm
// that [itr, current] doesn't contains any space.
// either its called with itr = std::next(current)
// or with an itr <= current in case of word splitting
auto reset_line_start = [&size, &startLine, &lastSpace, &current](Iterator itr) {
startLine = itr;
lastSpace = startLine;
size = std::distance(startLine, std::next(current));
};
for (; current != end; ++current)
{
const auto currentNext = std::next(current);
if(*current == '\n') {
add_line(startLine, current);
reset_line_start(currentNext);
// Last character is a newline. Hence there is another line to be added. An empty one
// And we need to do that now as we don't be doing further iterations
if(currentNext == end) {
add_line(currentNext, currentNext);
}
} else {
size ++ ;
if(is_space(current)) {
lastSpace = current;
}
bool endHere = false;
auto endLine = currentNext;
if(currentNext == end) {
endHere = true;
}
else if(is_space(current) && size == 1) {
// Ignore leading spaces
reset_line_start(currentNext);
}
else if(size >= allowed && !is_space(currentNext)) {
// Don't break. Think of cases 'abc \nxyz' with allowed=5
// we will decide in the next iteration if needed
//
// Now we know currentNext is not a space:
// - if there is no breakable whitespace, we have to split the word
// - if the line ends in whitespace, split here; add_line() will trim it
// - otherwise split from the last whitespace inside the line
if(lastSpace != startLine && lastSpace != current)
{
endLine = std::next(lastSpace);
}
// If the chosen break lands right before an explicit newline, let the
// newline branch handle it instead of forcing an extra wrapped line.
if(*endLine == '\n') {
endHere = false;
} else {
endHere = true;
}
}
if(endHere) {
add_line(startLine, endLine);
reset_line_start(endLine);
}
}
}
return result;
}
String
format_option
(
@ -2279,7 +2414,6 @@ format_description
}
}
String result;
if (tab_expansion)
{
@ -2307,83 +2441,10 @@ format_description
desc = desc2;
}
desc += " ";
auto current = std::begin(desc);
auto previous = current;
auto startLine = current;
auto lastSpace = current;
auto size = std::size_t{};
bool appendNewLine;
bool onlyWhiteSpace = true;
while (current != std::end(desc))
{
appendNewLine = false;
if (*previous == ' ' || *previous == '\t')
{
lastSpace = current;
}
if (*current != ' ' && *current != '\t')
{
onlyWhiteSpace = false;
}
while (*current == '\n')
{
previous = current;
++current;
appendNewLine = true;
}
if (!appendNewLine && size >= allowed)
{
if (lastSpace != startLine)
{
current = lastSpace;
previous = current;
}
appendNewLine = true;
}
if (appendNewLine)
{
stringAppend(result, startLine, current);
startLine = current;
lastSpace = current;
if (*previous != '\n')
{
stringAppend(result, "\n");
}
stringAppend(result, start, ' ');
if (*previous != '\n')
{
stringAppend(result, lastSpace, current);
}
onlyWhiteSpace = true;
size = 0;
}
previous = current;
++current;
++size;
}
//append whatever is left but ignore whitespace
if (!onlyWhiteSpace)
{
stringAppend(result, startLine, previous);
}
return result;
return wrap_text(desc, allowed, start);
}
} // namespace
inline
@ -2925,6 +2986,7 @@ Options::help_one_group(const std::string& g) const
auto d = format_description(o, longest + OPTION_DESC_GAP, allowed, m_tab_expansion);
result += fiter->first;
if (stringLength(fiter->first) > longest)
{
result += '\n';
@ -2996,6 +3058,8 @@ Options::help(const std::vector<std::string>& help_groups, bool print_usage) con
result += "\n\n";
result = wrap_text(result, m_width, 0);
if (help_groups.empty())
{
generate_all_groups_help(result);

View File

@ -1578,6 +1578,120 @@ TEST_CASE("Help output wrapping", "[help]")
std::vector<std::string> positionals;
std::string expected;
} tests[] = {
{
"Basic test",
cxxopts::Options("prog_abc", "This is a sample program for snake jazz")
.positional_help("Positional help")
.custom_help("Custom help")
.set_width(15),
{{"o,opt", "Sample description"}},
{"o"},
"This is a\n"
"sample program\n"
"for snake jazz\n"
"Usage:\n"
"prog_abc Custom\n"
"help Positional\n"
"help\n"
"\n"
" -o, --opt Sample\n"
" descriptio\n"
" n\n"
},
{
"Custom help manual newline",
cxxopts::Options("prog")
.custom_help("Custom\nHelp")
.set_width(12),
{{"o,opt", "desc"}},
{},
"\n"
"Usage:\n"
"prog Custom\n"
"Help\n"
"\n"
" -o, --opt desc\n"
},
{
"Description spaces before explicit newline",
cxxopts::Options("prog")
.set_width(18),
{{"o,opt", "alpha \nbeta"}},
{},
"\n"
"Usage:\n"
"prog [OPTION...]\n"
"\n"
" -o, --opt alpha\n"
" beta\n"
},
{
"Description blank line is preserved",
cxxopts::Options("prog")
.set_width(18),
{{"o,opt", "alpha\n\nbeta"}},
{},
"\n"
"Usage:\n"
"prog [OPTION...]\n"
"\n"
" -o, --opt alpha\n"
"\n"
" beta\n"
},
{
"Description trailing newline is preserved",
cxxopts::Options("prog")
.set_width(18),
{{"o,opt", "alpha\n"}},
{},
"\n"
"Usage:\n"
"prog [OPTION...]\n"
"\n"
" -o, --opt alpha\n"
"\n"
},
{
"Description leading newline is preserved",
cxxopts::Options("prog")
.set_width(18),
{{"o,opt", "\nalpha"}},
{},
"\n"
"Usage:\n"
"prog [OPTION...]\n"
"\n"
" -o, --opt \n"
" alpha\n"
},
{
"Custom help trailing newline is preserved",
cxxopts::Options("prog")
.custom_help("Custom\n")
.set_width(12),
{{"o,opt", "desc"}},
{},
"\n"
"Usage:\n"
"prog Custom\n"
"\n"
"\n"
" -o, --opt desc\n"
},
{
"Tab expansion happens before description wrapping",
cxxopts::Options("prog")
.set_width(26)
.set_tab_expansion(true),
{{"o,opt", "a\tb"}},
{},
"\n"
"Usage:\n"
"prog [OPTION...]\n"
"\n"
" -o, --opt a b\n"
},
{
"Long word does not drop trailing character",
cxxopts::Options("prog")
@ -1635,7 +1749,7 @@ TEST_CASE("Help output wrapping", "[help]")
for (auto& tc : tests)
{
SECTION(tc.name)
SECTION(tc.name)
{
for (const auto& opt : tc.opts)
{
@ -1646,3 +1760,227 @@ TEST_CASE("Help output wrapping", "[help]")
}
}
}
TEST_CASE("wrap_text", "[wrap_text]")
{
struct {
std::string name;
std::string text;
std::size_t allowed;
std::size_t start;
std::string expected;
} tests[] = {
{
"Plain Newline",
"\n",
3,
3,
"\n"
},
{
"Manual newlines",
"abc\ndef\nghi",
3,
0,
"abc\n"
"def\n"
"ghi"
},
{
"Basic wrap",
"abc def ghi",
3,
0,
"abc\n"
"def\n"
"ghi"
},
{
"Word splitting",
"abcdefghi",
3,
0,
"abc\n"
"def\n"
"ghi"
},
{
"Clamping with manual newlines",
"\na\n\nbcdef",
3,
3,
"\n"
" a\n"
"\n"
" bcd\n"
" ef"
},
{
"Trailing newline is preserved",
"abc\n",
3,
3,
"abc\n"
},
{
"Trailing spaces after final newline preserve newline",
"abc\n ",
5,
2,
"abc\n"
},
{
"Whitespace around final newline preserves blank line",
" \n ",
5,
2,
"\n"
},
{
"Consecutive newlines stay consecutive",
"a\n\nb",
3,
3,
"a\n"
"\n"
" b"
},
{
"Leading spaces do not create blank lines",
" abc",
3,
3,
"abc"
},
{
"Exact fit with separating space stays on one line",
"a b",
3,
0,
"a b"
},
{
"Exact fit with longer words stays on one line",
"abc def",
7,
0,
"abc def"
},
{
"Exact fit with separating tab stays on one line",
"a\tb",
3,
0,
"a\tb"
},
{
"Whitespace wrap keeps the trailing word",
"abc def",
4,
2,
"abc\n"
" def"
},
{
"Empty string stays empty",
"",
3,
0,
""
},
{
"Whitespace only stays empty",
" ",
3,
2,
""
},
{
"Exact fit word stays on one line",
"abc",
3,
0,
"abc"
},
{
"Internal spaces are preserved when no wrap is needed",
"a b",
10,
0,
"a b"
},
{
"Whitespace runs at wrap boundaries keep all words",
"23423 23424 343",
10,
3,
"23423\n"
" 23424\n"
" 343"
},
{
"Wrapped line followed by newline",
"abcd\n",
3,
2,
"abc\n"
" d\n"
},
{
"Wrapped line followed by newline 2",
"abcd\nef",
3,
2,
"abc\n"
" d\n"
" ef"
},
{
"Edge case of minimum width",
"abc\n\nx y\n z",
1,
1,
"a\n"
" b\n"
" c\n"
"\n"
" x\n"
" y\n"
" z"
},
{
"0 allowed",
"abc",
0,
1,
"",
},
{
"trailing spaces before an explicit newline",
"abc \nxyz",
5,
0,
"abc\n"
"xyz"
},
{
"Consecutive trailing newlines are preserved",
"a\n\n",
3,
2,
"a\n"
"\n"
}
};
for (auto& tc : tests)
{
SECTION(tc.name)
{
CHECK(cxxopts::wrap_text(tc.text, tc.allowed, tc.start) == tc.expected);
}
}
}