Region and paragraph commands: indent, reflow, counted deletes

- Indent-region skips empty lines. With the whole buffer selected, the
  empty row after the final newline became a tab-only last line.
- Reflow no longer wraps before a line's first word: a first word longer
  than the width (a URL) got a prefix-only line before it, a blank line
  or a bullet marker split from its text, and another on every reflow.
- A whitespace-only line (spaces, tabs, a CR) separates paragraphs for
  reflow; such paragraphs were merged.
- Reflow of CRLF lines strips the CR before wrapping and restores it on
  every output line; the CRs ended up in the middle of joined lines.
- A counted backspace or delete-char is one undo step even when it joins
  lines.
- The C++ highlighter stops looking for a raw-string '(' after 16
  delimiter characters; scanning the whole line at every R" was
  quadratic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
This commit is contained in:
Claude
2026-09-24 09:16:04 -07:00
committed by Kyle Isom
parent b9f91b0ed2
commit 790fbc90e1
4 changed files with 152 additions and 16 deletions
+48 -14
View File
@@ -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<int>(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<std::string>(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<int>(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<int>(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<std::string>(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<std::string>(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<std::string>(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<std::string>(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<std::string>(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<std::string>(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<std::string>(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());
+14
View File
@@ -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.
+5 -2
View File
@@ -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<std::size_t>(body_start));
+85
View File
@@ -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"));
}
}