diff --git a/Command.cc b/Command.cc index 965c2df..cf80920 100644 --- a/Command.cc +++ b/Command.cc @@ -3726,6 +3726,8 @@ cmd_backspace(CommandContext &ctx) ensure_cursor_visible(ctx.editor, *buf); return true; } + // A count is one undo step, including any line joins among its deletions. + UndoGroupGuard count_group(repeat > 1 ? u : nullptr); for (int i = 0; i < repeat; ++i) { // Refresh a read-only view of lines for char capture/lengths const auto &rows_view = rows_of(*buf); @@ -3832,6 +3834,8 @@ cmd_delete_char(CommandContext &ctx) ensure_cursor_visible(ctx.editor, *buf); return true; } + // A count is one undo step, including any line joins among its deletions. + UndoGroupGuard count_group(repeat > 1 ? u : nullptr); for (int i = 0; i < repeat; ++i) { const auto &rows_view = rows_of(*buf); if (y >= rows_view.size()) @@ -5056,7 +5060,12 @@ cmd_indent_region(CommandContext &ctx) } UndoSystem *u = buf->Undo(); UndoGroupGuard guard(u); + // Empty lines are left alone: indenting them only added trailing + // whitespace, and the empty row after a final newline became a tab-only + // last line. for (std::size_t y = sy; y <= ey && y < rows_of(*buf).size(); ++y) { + if (rows_of(*buf)[y].empty()) + continue; buf->insert_text(static_cast(y), 0, std::string_view("\t")); if (u) { buf->SetCursor(0, y); @@ -5143,18 +5152,38 @@ cmd_reflow_paragraph(CommandContext &ctx) // Treat a universal-argument count of 1 as "no width specified". // Editor::UArgGet() returns 1 when no explicit count was provided. int width = ctx.count > 1 ? ctx.count : 72; - // A blank line separates paragraphs; expanding from it would merge the - // paragraphs above and below. - if (y >= rows.size() || rows[y].empty()) + // A blank line (empty, or only spaces, tabs or a CR) separates + // paragraphs; expanding from it would merge the paragraphs above and + // below. + auto is_blank_row = [&](std::size_t i) { + for (const char c: line_view(rows[i])) + if (c != ' ' && c != '\t' && c != '\r') + return false; + return true; + }; + if (y >= rows.size() || is_blank_row(y)) return true; std::size_t para_start = y; - while (para_start > 0 && !rows[para_start - 1].empty()) + while (para_start > 0 && !is_blank_row(para_start - 1)) --para_start; std::size_t para_end = y; - while (para_end + 1 < rows.size() && !rows[para_end + 1].empty()) + while (para_end + 1 < rows.size() && !is_blank_row(para_end + 1)) ++para_end; if (para_start > para_end) return false; + // CRLF text: reflow the lines without their CR (it would otherwise be + // joined into the middle of lines) and end every new line with one. + bool crlf = true; + for (std::size_t i = para_start; i <= para_end && crlf; ++i) { + const std::string_view lv = line_view(rows[i]); + crlf = !lv.empty() && lv.back() == '\r'; + } + auto row_text = [&](std::size_t i) { + std::string t = static_cast(rows[i]); + if (crlf) + t.pop_back(); + return t; + }; auto is_space = [](char c) { return c == ' ' || c == '\t'; @@ -5272,8 +5301,11 @@ cmd_reflow_paragraph(CommandContext &ctx) for (std::size_t i = 0; i < words.size(); ++i) { const std::string &wrd = words[i]; std::size_t needed = wrd.size() + (first_word_on_line ? 0 : 1); - if (static_cast(cur_len + needed) > w) { - // wrap + // Wrap before a word that does not fit, but never before the + // first word on a line: that emitted a line holding only the + // prefix (a blank line, or a bullet marker split from its text) + // ahead of a word longer than the width, again on every reflow. + if (!first_word_on_line && static_cast(cur_len + needed) > w) { flush_line(); } if (!first_word_on_line) { @@ -5293,7 +5325,7 @@ cmd_reflow_paragraph(CommandContext &ctx) // 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 s = row_text(i); std::string indent; char marker; std::size_t idx; @@ -5311,7 +5343,7 @@ cmd_reflow_paragraph(CommandContext &ctx) 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]); + std::string s = row_text(i); std::string indent; char marker = 0; std::size_t after_idx = 0; @@ -5322,7 +5354,7 @@ cmd_reflow_paragraph(CommandContext &ctx) // consume continuation lines that are part of this bullet item std::size_t j = i + 1; while (j <= para_end) { - std::string ns = static_cast(rows[j]); + std::string ns = row_text(j); // stop if next bullet at same indentation or different structure std::string nindent; char nmarker; @@ -5356,7 +5388,7 @@ cmd_reflow_paragraph(CommandContext &ctx) // 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]); + std::string ns = row_text(j); if (starts_with(ns, cont_prefix)) { content += ' '; content += ns.substr(cont_prefix.size()); @@ -5383,7 +5415,7 @@ cmd_reflow_paragraph(CommandContext &ctx) 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 ns = row_text(j); std::string nindent = leading_ws(ns); std::string tmp_indent; char tmp_marker; @@ -5411,11 +5443,11 @@ cmd_reflow_paragraph(CommandContext &ctx) } } else { // Normal paragraph: preserve indentation of first line - std::string s0 = static_cast(rows[para_start]); + std::string s0 = row_text(para_start); std::string pfx = leading_ws(s0); std::string content; for (std::size_t i = para_start; i <= para_end; ++i) { - std::string si = static_cast(rows[i]); + std::string si = row_text(i); // strip the same prefix length if present if (si.size() >= pfx.size() && starts_with(si, pfx)) si.erase(0, pfx.size()); @@ -5442,6 +5474,8 @@ cmd_reflow_paragraph(CommandContext &ctx) if (i > 0) new_text.push_back('\n'); new_text += new_lines[i]; + if (crlf) + new_text.push_back('\r'); } if (new_text != old_text) replace_rows_text(*buf, para_start, old_text, new_text, buf->Undo()); diff --git a/docs/audits/claude-findings-20260923.md b/docs/audits/claude-findings-20260923.md index d41e343..acc6f5d 100644 --- a/docs/audits/claude-findings-20260923.md +++ b/docs/audits/claude-findings-20260923.md @@ -463,6 +463,20 @@ before the fix (unless noted). exactly 255 bytes, which broke compaction's ".tmp" sibling and let the journal grow without bound; it now leaves room for it.) +**Seventh round (highlighters, region and paragraph commands, buffers)** +- Indent-region on a selection ending in the empty row after a final + newline added a tab-only last line (empty lines are now skipped). +- Reflow emitted a prefix-only line before a first word longer than the + width (a blank line before a long URL, a bullet split from its text), + adding another on every reflow; joined paragraphs separated by a + whitespace-only line; and moved the CRs of CRLF lines into the middle + of lines. +- A counted backspace/delete that joined lines took several undos. +- The C++ highlighter scanned to the end of the line for a raw-string + '(' at every R", quadratic on long lines. +- Fuzzing every highlighter (incremental vs full), 20,000 random command + sequences, and 6,300 random buffer-list sessions found nothing else. + **Known limitations (not fixed)** - Catastrophic regex backtracking (e.g. `(a*)*b`) can still take very long; std::regex has no time limit. diff --git a/syntax/CppHighlighter.cc b/syntax/CppHighlighter.cc index 4088352..4dd65c7 100644 --- a/syntax/CppHighlighter.cc +++ b/syntax/CppHighlighter.cc @@ -163,11 +163,14 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf, if (c == 'R' && i + 1 < n && s[i + 1] == '"') { int k = i + 2; std::string delim; - while (k < n && s[k] != '(') { + // A raw-string delimiter is at most 16 characters (and has no + // spaces, backslashes or parentheses); scanning the rest of the + // line for '(' at every R" made long lines quadratic. + while (k < n && s[k] != '(' && delim.size() <= 16) { delim.push_back(s[k]); ++k; } - if (k < n && s[k] == '(') { + if (k < n && s[k] == '(' && delim.size() <= 16) { int body_start = k + 1; std::string needle = ")" + delim + "\""; auto pos = s.find(needle, static_cast(body_start)); diff --git a/tests/test_audit_regressions.cc b/tests/test_audit_regressions.cc index 861088f..5934eef 100644 --- a/tests/test_audit_regressions.cc +++ b/tests/test_audit_regressions.cc @@ -1532,3 +1532,88 @@ TEST(Audit_Swap_LongBasenameStillJournaled) sm.Detach(&a, true); ASSERT_TRUE(!std::filesystem::exists(swp)); } + + +// Indenting a region leaves empty lines alone: with the whole buffer +// selected, the empty row after the final newline became a tab-only line. +TEST(Audit_IndentRegion_SkipsEmptyLines) +{ + TestHarness h; + h.Buf().insert_text(0, 0, "a\n\nb\n"); + h.Buf().SetCursor(0, 0); + ASSERT_TRUE(h.Exec(CommandId::MarkAllAndJumpEnd)); + ASSERT_TRUE(h.Exec(CommandId::IndentRegion)); + ASSERT_EQ(h.Text(), std::string("\ta\n\n\tb\n")); + ASSERT_TRUE(h.Undo()); + ASSERT_EQ(h.Text(), std::string("a\n\nb\n")); +} + + +// Reflow never wraps before a line's first word: a first word longer than +// the width produced a prefix-only line (another one on each reflow). +TEST(Audit_Reflow_LongFirstWordNoBlankLine) +{ + const std::string url = "https://example.com/" + std::string(70, 'x'); + { + TestHarness h; + h.Buf().insert_text(0, 0, "see:\n\n" + url + " is the link\n"); + h.Buf().SetCursor(0, 2); + ASSERT_TRUE(h.Exec(CommandId::ReflowParagraph)); + ASSERT_TRUE(h.Exec(CommandId::ReflowParagraph)); + ASSERT_EQ(h.Text(), std::string("see:\n\n" + url + "\nis the link\n")); + } + { + TestHarness h; + h.Buf().insert_text(0, 0, "- " + url + " x\n"); + h.Buf().SetCursor(0, 0); + ASSERT_TRUE(h.Exec(CommandId::ReflowParagraph)); + ASSERT_EQ(h.Text(), std::string("- " + url + "\n x\n")); + } +} + + +// A whitespace-only line separates paragraphs, and CRLF lines keep their +// CR at the end of each reflowed line. +TEST(Audit_Reflow_BlankishSeparatorAndCrlf) +{ + { + TestHarness h; + h.Buf().insert_text(0, 0, "First paragraph.\n \nSecond paragraph.\n"); + h.Buf().SetCursor(0, 0); + ASSERT_TRUE(h.Exec(CommandId::ReflowParagraph)); + ASSERT_EQ(h.Text(), std::string("First paragraph.\n \nSecond paragraph.\n")); + } + { + TestHarness h; + h.Buf().insert_text(0, 0, "one two\r\nthree four\r\nfive\r\n\r\nnext\r\n"); + h.Buf().SetCursor(0, 0); + ASSERT_TRUE(h.Exec(CommandId::ReflowParagraph)); + ASSERT_EQ(h.Text(), std::string("one two three four five\r\n\r\nnext\r\n")); + ASSERT_TRUE(h.Undo()); + ASSERT_EQ(h.Text(), std::string("one two\r\nthree four\r\nfive\r\n\r\nnext\r\n")); + } +} + + +// A counted backspace or delete that joins lines undoes in one step. +TEST(Audit_CountedDelete_AcrossJoin_OneUndo) +{ + { + TestHarness h; + h.Buf().insert_text(0, 0, "abc\nde"); + h.Buf().SetCursor(1, 1); + ASSERT_TRUE(h.Exec(CommandId::Backspace, "", 3)); + ASSERT_EQ(h.Text(), std::string("abe")); + ASSERT_TRUE(h.Undo()); + ASSERT_EQ(h.Text(), std::string("abc\nde")); + } + { + TestHarness h; + h.Buf().insert_text(0, 0, "abc\nde"); + h.Buf().SetCursor(2, 0); + ASSERT_TRUE(h.Exec(CommandId::DeleteChar, "", 3)); + ASSERT_EQ(h.Text(), std::string("abe")); + ASSERT_TRUE(h.Undo()); + ASSERT_EQ(h.Text(), std::string("abc\nde")); + } +}