From 25513884206e55f710c14349f52b603d43ca520a Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 10 Feb 2026 21:23:20 -0800 Subject: [PATCH] Support numbered lists in reflow-paragraph. Add `reflow-paragraph` tests for numbered lists with hanging indents and extend support for numbered list parsing and wrapping logic. --- CMakeLists.txt | 5 ++ Command.cc | 119 ++++++++++++++++++++++++++------- tests/test_reflow_paragraph.cc | 102 ++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 26 deletions(-) create mode 100644 tests/test_reflow_paragraph.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index 63a9fcb..70437c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -301,10 +301,15 @@ if (BUILD_TESTS) tests/test_buffer_io.cc tests/test_piece_table.cc tests/test_search.cc + tests/test_reflow_paragraph.cc # minimal engine sources required by Buffer PieceTable.cc Buffer.cc + Editor.cc + Command.cc + HelpText.cc + Swap.cc OptimizedSearch.cc UndoNode.cc UndoTree.cc diff --git a/Command.cc b/Command.cc index 0fa155e..6ea4fbf 100644 --- a/Command.cc +++ b/Command.cc @@ -4026,6 +4026,29 @@ cmd_reflow_paragraph(CommandContext &ctx) return false; }; + auto is_numbered_line = [&](const std::string &s, + std::string &indent_out, + std::string &marker_out, + std::size_t &after_prefix_idx) -> bool { + indent_out = leading_ws(s); + std::size_t i = indent_out.size(); + if (i >= s.size() || !std::isdigit(static_cast(s[i]))) + return false; + std::size_t j = i; + while (j < s.size() && std::isdigit(static_cast(s[j]))) + ++j; + if (j >= s.size()) + return false; + char delim = s[j]; + if (!(delim == '.' || delim == ')')) + return false; + if (j + 1 >= s.size() || s[j + 1] != ' ') + return false; + marker_out = s.substr(i, (j - i) + 1); // e.g. "1." or "10)" + after_prefix_idx = j + 2; // after delimiter + space + return true; + }; + auto normalize_spaces = [](const std::string &in) { std::string out; out.reserve(in.size()); @@ -4107,20 +4130,25 @@ cmd_reflow_paragraph(CommandContext &ctx) std::vector new_lines; - // Determine if this region looks like a list: any line starting with bullet - bool region_has_bullet = false; + // Determine if this region looks like a list: any line starting with bullet or number + bool region_has_list = false; for (std::size_t i = para_start; i <= para_end; ++i) { std::string s = static_cast(rows[i]); std::string indent; char marker; std::size_t idx; if (is_bullet_line(s, indent, marker, idx)) { - region_has_bullet = true; + region_has_list = true; + break; + } + std::string nmarker; + if (is_numbered_line(s, indent, nmarker, idx)) { + region_has_list = true; break; } } - if (region_has_bullet) { + if (region_has_list) { // Parse as list items; support hanging indent continuations for (std::size_t i = para_start; i <= para_end; ++i) { std::string s = static_cast(rows[i]); @@ -4148,6 +4176,10 @@ cmd_reflow_paragraph(CommandContext &ctx) if (is_bullet_line(ns, nindent, nmarker, nidx)) { break; // next item } + std::string nnmarker; + if (is_numbered_line(ns, nindent, nnmarker, nidx)) { + break; // next item + } // Not a continuation and not a bullet: stop (treat as separate paragraph chunk) break; } @@ -4155,30 +4187,65 @@ cmd_reflow_paragraph(CommandContext &ctx) wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines); i = j - 1; // advance } else { - // A non-bullet line within a list region; treat as its own wrapped paragraph preserving its indent - std::string base_indent = leading_ws(s); - std::string content = s.substr(base_indent.size()); - std::size_t j = i + 1; - while (j <= para_end) { - std::string ns = static_cast(rows[j]); - std::string nindent = leading_ws(ns); - std::string tmp_indent; - char tmp_marker; - std::size_t tmp_idx; - if (is_bullet_line(ns, tmp_indent, tmp_marker, tmp_idx)) { - break; // next bullet starts - } - if (nindent.size() >= base_indent.size()) { - content += ' '; - content += ns.substr(base_indent.size()); - ++j; - } else { + std::string nmarker; + if (is_numbered_line(s, indent, nmarker, after_idx)) { + std::string first_prefix = indent + nmarker + " "; + std::string cont_prefix = indent + std::string(nmarker.size() + 1, ' '); + std::string content = s.substr(after_idx); + // consume continuation lines that are part of this numbered item + std::size_t j = i + 1; + while (j <= para_end) { + std::string ns = static_cast(rows[j]); + if (starts_with(ns, cont_prefix)) { + content += ' '; + content += ns.substr(cont_prefix.size()); + ++j; + continue; + } + // stop if next item + std::string nindent2; + char bmarker; + std::size_t nidx; + if (is_bullet_line(ns, nindent2, bmarker, nidx)) + break; + std::string nnmarker; + if (is_numbered_line(ns, nindent2, nnmarker, nidx)) + break; break; } + content = normalize_spaces(content); + wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines); + i = j - 1; + } else { + // A non-bullet line within a list region; treat as its own wrapped paragraph preserving its indent + std::string base_indent = leading_ws(s); + std::string content = s.substr(base_indent.size()); + std::size_t j = i + 1; + while (j <= para_end) { + std::string ns = static_cast(rows[j]); + std::string nindent = leading_ws(ns); + std::string tmp_indent; + char tmp_marker; + std::size_t tmp_idx; + if (is_bullet_line(ns, tmp_indent, tmp_marker, tmp_idx)) { + break; // next bullet starts + } + std::string tmp_nmarker; + if (is_numbered_line(ns, tmp_indent, tmp_nmarker, tmp_idx)) { + break; // next numbered starts + } + if (nindent.size() >= base_indent.size()) { + content += ' '; + content += ns.substr(base_indent.size()); + ++j; + } else { + break; + } + } + content = normalize_spaces(content); + wrap_with_prefixes(content, base_indent, base_indent, width, new_lines); + i = j - 1; } - content = normalize_spaces(content); - wrap_with_prefixes(content, base_indent, base_indent, width, new_lines); - i = j - 1; } } } else { @@ -4566,4 +4633,4 @@ Execute(Editor &ed, const std::string &name, const std::string &arg, int count) return false; CommandContext ctx{ed, arg, count}; return cmd->handler ? cmd->handler(ctx) : false; -} +} \ No newline at end of file diff --git a/tests/test_reflow_paragraph.cc b/tests/test_reflow_paragraph.cc new file mode 100644 index 0000000..ed07127 --- /dev/null +++ b/tests/test_reflow_paragraph.cc @@ -0,0 +1,102 @@ +#include "Test.h" + +#include "Buffer.h" +#include "Command.h" +#include "Editor.h" + +#include +#include + + +static std::string +to_string_rows(const Buffer &buf) +{ + std::string out; + for (const auto &r: buf.Rows()) { + out += static_cast(r); + out.push_back('\n'); + } + return out; +} + + +TEST (ReflowParagraph_NumberedList_HangingIndent) +{ + InstallDefaultCommands(); + + Editor ed; + ed.SetDimensions(24, 80); + + Buffer b; + // Two list items in one paragraph (no blank lines). + // Second line of each item already uses a hanging indent. + const std::string initial = + "1. one two three four five six seven eight nine ten eleven\n" + " twelve thirteen fourteen\n" + "10. alpha beta gamma delta epsilon zeta eta theta iota kappa lambda\n" + " mu nu xi omicron\n"; + b.insert_text(0, 0, initial); + // Put cursor on first item + b.SetCursor(0, 0); + ed.AddBuffer(std::move(b)); + + Buffer *buf = ed.CurrentBuffer(); + ASSERT_TRUE(buf != nullptr); + + const int width = 25; + ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width)); + + const auto &rows = buf->Rows(); + ASSERT_TRUE(!rows.empty()); + const std::string dump = to_string_rows(*buf); + + // Find the start of the second item. + bool any_too_long = false; + std::size_t idx_10 = rows.size(); + for (std::size_t i = 0; i < rows.size(); ++i) { + const std::string line = static_cast(rows[i]); + if (static_cast(line.size()) > width) + any_too_long = true; + if (line.rfind("10. ", 0) == 0) { + idx_10 = i; + break; + } + } + ASSERT_TRUE(idx_10 < rows.size()); + if (any_too_long) { + std::cerr << "Reflow produced a line longer than width=" << width << "\n"; + std::cerr << to_string_rows(*buf) << "\n"; + } + EXPECT_TRUE(!any_too_long); + + // Item 1: first line has "1. ", continuation lines have 3 spaces. + for (std::size_t i = 0; i < idx_10; ++i) { + const std::string line = static_cast(rows[i]); + if (i == 0) { + ASSERT_TRUE(line.rfind("1. ", 0) == 0); + } else { + ASSERT_TRUE(line.rfind(" ", 0) == 0); + ASSERT_TRUE(line.rfind("1. ", 0) != 0); + } + } + + // Item 10: first line has "10. ", continuation lines have 4 spaces. + ASSERT_TRUE(static_cast(rows[idx_10]).rfind("10. ", 0) == 0); + bool bad_10 = false; + for (std::size_t i = idx_10 + 1; i < rows.size(); ++i) { + const std::string line = static_cast(rows[i]); + if (line.empty()) + break; // paragraph terminator / trailing empty line + if (line.rfind(" ", 0) != 0) + bad_10 = true; + if (line.rfind("10. ", 0) == 0) + bad_10 = true; + } + if (bad_10) { + std::cerr << "Unexpected prefix in reflow output:\n" << dump << "\n"; + } + ASSERT_TRUE(!bad_10); + + // Debug helper if something goes wrong (kept as a string for easy inspection). + EXPECT_TRUE(!to_string_rows(*buf).empty()); +} \ No newline at end of file