From edbfbad35b23fa189680acfdeadd7f0fdea7fbce Mon Sep 17 00:00:00 2001 From: Nitin Kumar <59679977+lazysegtree@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:16:56 +0530 Subject: [PATCH] feat: New wrapping algorithm, tests, and using wrapping on help text above options list --- include/cxxopts.hpp | 216 ++++++++++++++++++---------- test/options.cpp | 340 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 479 insertions(+), 77 deletions(-) diff --git a/include/cxxopts.hpp b/include/cxxopts.hpp index c077668..3e61da3 100644 --- a/include/cxxopts.hpp +++ b/include/cxxopts.hpp @@ -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, ¤t](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& 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); diff --git a/test/options.cpp b/test/options.cpp index a3606f5..ceb6795 100644 --- a/test/options.cpp +++ b/test/options.cpp @@ -1578,6 +1578,120 @@ TEST_CASE("Help output wrapping", "[help]") std::vector 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); + } + } +}