diff --git a/include/cxxopts.hpp b/include/cxxopts.hpp index c077668..891e78a 100644 --- a/include/cxxopts.hpp +++ b/include/cxxopts.hpp @@ -2210,6 +2210,143 @@ 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 contentEnd = current; + auto lastSpaceContentEnd = current; + auto size = std::size_t{}; + + bool firstLine = true; + const auto textEnd = 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, start](Iterator begin, Iterator end) { + // begin == end means empty line + // Handle newlines, clamping, everything here + if(!firstLine) { + stringAppend(result, 1, '\n'); + } + + // 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, &contentEnd, &lastSpaceContentEnd, ¤t](Iterator itr, Iterator lineContentEnd) { + startLine = itr; + lastSpace = startLine; + contentEnd = lineContentEnd; + lastSpaceContentEnd = startLine; + + size = std::distance(startLine, std::next(current)); + }; + + + for (; current != textEnd; ++current) + { + const auto currentNext = std::next(current); + + if(*current == '\n') { + add_line(startLine, contentEnd); + reset_line_start(currentNext, 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 == textEnd) { + add_line(currentNext, currentNext); + } + + } else { + size ++ ; + if(is_space(current)) { + lastSpace = current; + lastSpaceContentEnd = contentEnd; + } else { + contentEnd = currentNext; + } + bool endHere = false; + auto endLine = contentEnd; + auto nextLineStart = currentNext; + + if(currentNext == textEnd) { + endHere = true; + } + else if(is_space(current) && size == 1) { + // Ignore leading spaces + reset_line_start(currentNext, 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 + // - otherwise split from the last whitespace inside the line + if(lastSpace != startLine && lastSpace != current) + { + endLine = lastSpaceContentEnd; + nextLineStart = 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(nextLineStart, currentNext); + } + } + } + + return result; +} + String format_option ( @@ -2279,7 +2416,6 @@ format_description } } - String result; if (tab_expansion) { @@ -2307,83 +2443,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 +2988,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 +3060,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 06e9e0a..fb495ed 100644 --- a/test/options.cpp +++ b/test/options.cpp @@ -1568,3 +1568,419 @@ TEST_CASE("Ordering of multiple long options", "[help]") CHECK(help.find("--alphaw") != std::string::npos); CHECK(help.find("--beta") != std::string::npos); } + +TEST_CASE("Help output wrapping", "[help]") +{ + struct { + std::string name; + cxxopts::Options parser; + std::vector> opts; + 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") + .set_width(18), + {{"o,opt", "01234567890"}}, + {}, + "\n" + "Usage:\n" + "prog [OPTION...]\n" + "\n" + " -o, --opt 0123456789\n" + " 0\n" + }, + { + "Consecutive explicit newlines keep following text", + cxxopts::Options("prog") + .set_width(18), + {{"o,opt", "a\n\nb"}}, + {}, + "\n" + "Usage:\n" + "prog [OPTION...]\n" + "\n" + " -o, --opt a\n" + "\n" + " b\n" + }, + { + "Trailing newline does not create whitespace-only continuation", + cxxopts::Options("prog") + .set_width(18), + {{"o,opt", "abc\n"}}, + {}, + "\n" + "Usage:\n" + "prog [OPTION...]\n" + "\n" + " -o, --opt abc\n" + "\n" + }, + { + "Spaces before explicit newline are trimmed", + cxxopts::Options("prog") + .set_width(18), + {{"o,opt", "abc \nxyz"}}, + {}, + "\n" + "Usage:\n" + "prog [OPTION...]\n" + "\n" + " -o, --opt abc\n" + " xyz\n" + }, + }; + + for (auto& tc : tests) + { + SECTION(tc.name) + { + for (const auto& opt : tc.opts) + { + tc.parser.add_options()(opt.first, opt.second); + } + tc.parser.parse_positional(tc.positionals); + CHECK(tc.parser.help() == tc.expected); + } + } +} + + + +TEST_CASE("wrap_text", "[wrap_text]") +{ + struct { + std::string name; + cxxopts::String text; + std::size_t allowed; + std::size_t start; + cxxopts::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); + } + } +}