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:
+48
-14
@@ -3726,6 +3726,8 @@ cmd_backspace(CommandContext &ctx)
|
|||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
return true;
|
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) {
|
for (int i = 0; i < repeat; ++i) {
|
||||||
// Refresh a read-only view of lines for char capture/lengths
|
// Refresh a read-only view of lines for char capture/lengths
|
||||||
const auto &rows_view = rows_of(*buf);
|
const auto &rows_view = rows_of(*buf);
|
||||||
@@ -3832,6 +3834,8 @@ cmd_delete_char(CommandContext &ctx)
|
|||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
return true;
|
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) {
|
for (int i = 0; i < repeat; ++i) {
|
||||||
const auto &rows_view = rows_of(*buf);
|
const auto &rows_view = rows_of(*buf);
|
||||||
if (y >= rows_view.size())
|
if (y >= rows_view.size())
|
||||||
@@ -5056,7 +5060,12 @@ cmd_indent_region(CommandContext &ctx)
|
|||||||
}
|
}
|
||||||
UndoSystem *u = buf->Undo();
|
UndoSystem *u = buf->Undo();
|
||||||
UndoGroupGuard guard(u);
|
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) {
|
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"));
|
buf->insert_text(static_cast<int>(y), 0, std::string_view("\t"));
|
||||||
if (u) {
|
if (u) {
|
||||||
buf->SetCursor(0, y);
|
buf->SetCursor(0, y);
|
||||||
@@ -5143,18 +5152,38 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
// Treat a universal-argument count of 1 as "no width specified".
|
// Treat a universal-argument count of 1 as "no width specified".
|
||||||
// Editor::UArgGet() returns 1 when no explicit count was provided.
|
// Editor::UArgGet() returns 1 when no explicit count was provided.
|
||||||
int width = ctx.count > 1 ? ctx.count : 72;
|
int width = ctx.count > 1 ? ctx.count : 72;
|
||||||
// A blank line separates paragraphs; expanding from it would merge the
|
// A blank line (empty, or only spaces, tabs or a CR) separates
|
||||||
// paragraphs above and below.
|
// paragraphs; expanding from it would merge the paragraphs above and
|
||||||
if (y >= rows.size() || rows[y].empty())
|
// 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;
|
return true;
|
||||||
std::size_t para_start = y;
|
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;
|
--para_start;
|
||||||
std::size_t para_end = y;
|
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;
|
++para_end;
|
||||||
if (para_start > para_end)
|
if (para_start > para_end)
|
||||||
return false;
|
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) {
|
auto is_space = [](char c) {
|
||||||
return c == ' ' || c == '\t';
|
return c == ' ' || c == '\t';
|
||||||
@@ -5272,8 +5301,11 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
for (std::size_t i = 0; i < words.size(); ++i) {
|
for (std::size_t i = 0; i < words.size(); ++i) {
|
||||||
const std::string &wrd = words[i];
|
const std::string &wrd = words[i];
|
||||||
std::size_t needed = wrd.size() + (first_word_on_line ? 0 : 1);
|
std::size_t needed = wrd.size() + (first_word_on_line ? 0 : 1);
|
||||||
if (static_cast<int>(cur_len + needed) > w) {
|
// Wrap before a word that does not fit, but never before the
|
||||||
// wrap
|
// 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();
|
flush_line();
|
||||||
}
|
}
|
||||||
if (!first_word_on_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
|
// Determine if this region looks like a list: any line starting with bullet or number
|
||||||
bool region_has_list = false;
|
bool region_has_list = false;
|
||||||
for (std::size_t i = para_start; i <= para_end; ++i) {
|
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;
|
std::string indent;
|
||||||
char marker;
|
char marker;
|
||||||
std::size_t idx;
|
std::size_t idx;
|
||||||
@@ -5311,7 +5343,7 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
if (region_has_list) {
|
if (region_has_list) {
|
||||||
// Parse as list items; support hanging indent continuations
|
// Parse as list items; support hanging indent continuations
|
||||||
for (std::size_t i = para_start; i <= para_end; ++i) {
|
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;
|
std::string indent;
|
||||||
char marker = 0;
|
char marker = 0;
|
||||||
std::size_t after_idx = 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
|
// consume continuation lines that are part of this bullet item
|
||||||
std::size_t j = i + 1;
|
std::size_t j = i + 1;
|
||||||
while (j <= para_end) {
|
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
|
// stop if next bullet at same indentation or different structure
|
||||||
std::string nindent;
|
std::string nindent;
|
||||||
char nmarker;
|
char nmarker;
|
||||||
@@ -5356,7 +5388,7 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
// consume continuation lines that are part of this numbered item
|
// consume continuation lines that are part of this numbered item
|
||||||
std::size_t j = i + 1;
|
std::size_t j = i + 1;
|
||||||
while (j <= para_end) {
|
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)) {
|
if (starts_with(ns, cont_prefix)) {
|
||||||
content += ' ';
|
content += ' ';
|
||||||
content += ns.substr(cont_prefix.size());
|
content += ns.substr(cont_prefix.size());
|
||||||
@@ -5383,7 +5415,7 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
std::string content = s.substr(base_indent.size());
|
std::string content = s.substr(base_indent.size());
|
||||||
std::size_t j = i + 1;
|
std::size_t j = i + 1;
|
||||||
while (j <= para_end) {
|
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 nindent = leading_ws(ns);
|
||||||
std::string tmp_indent;
|
std::string tmp_indent;
|
||||||
char tmp_marker;
|
char tmp_marker;
|
||||||
@@ -5411,11 +5443,11 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal paragraph: preserve indentation of first line
|
// 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 pfx = leading_ws(s0);
|
||||||
std::string content;
|
std::string content;
|
||||||
for (std::size_t i = para_start; i <= para_end; ++i) {
|
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
|
// strip the same prefix length if present
|
||||||
if (si.size() >= pfx.size() && starts_with(si, pfx))
|
if (si.size() >= pfx.size() && starts_with(si, pfx))
|
||||||
si.erase(0, pfx.size());
|
si.erase(0, pfx.size());
|
||||||
@@ -5442,6 +5474,8 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
if (i > 0)
|
if (i > 0)
|
||||||
new_text.push_back('\n');
|
new_text.push_back('\n');
|
||||||
new_text += new_lines[i];
|
new_text += new_lines[i];
|
||||||
|
if (crlf)
|
||||||
|
new_text.push_back('\r');
|
||||||
}
|
}
|
||||||
if (new_text != old_text)
|
if (new_text != old_text)
|
||||||
replace_rows_text(*buf, para_start, old_text, new_text, buf->Undo());
|
replace_rows_text(*buf, para_start, old_text, new_text, buf->Undo());
|
||||||
|
|||||||
@@ -463,6 +463,20 @@ before the fix (unless noted).
|
|||||||
exactly 255 bytes, which broke compaction's ".tmp" sibling and let
|
exactly 255 bytes, which broke compaction's ".tmp" sibling and let
|
||||||
the journal grow without bound; it now leaves room for it.)
|
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)**
|
**Known limitations (not fixed)**
|
||||||
- Catastrophic regex backtracking (e.g. `(a*)*b`) can still take very
|
- Catastrophic regex backtracking (e.g. `(a*)*b`) can still take very
|
||||||
long; std::regex has no time limit.
|
long; std::regex has no time limit.
|
||||||
|
|||||||
@@ -163,11 +163,14 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
|
|||||||
if (c == 'R' && i + 1 < n && s[i + 1] == '"') {
|
if (c == 'R' && i + 1 < n && s[i + 1] == '"') {
|
||||||
int k = i + 2;
|
int k = i + 2;
|
||||||
std::string delim;
|
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]);
|
delim.push_back(s[k]);
|
||||||
++k;
|
++k;
|
||||||
}
|
}
|
||||||
if (k < n && s[k] == '(') {
|
if (k < n && s[k] == '(' && delim.size() <= 16) {
|
||||||
int body_start = k + 1;
|
int body_start = k + 1;
|
||||||
std::string needle = ")" + delim + "\"";
|
std::string needle = ")" + delim + "\"";
|
||||||
auto pos = s.find(needle, static_cast<std::size_t>(body_start));
|
auto pos = s.find(needle, static_cast<std::size_t>(body_start));
|
||||||
|
|||||||
@@ -1532,3 +1532,88 @@ TEST(Audit_Swap_LongBasenameStillJournaled)
|
|||||||
sm.Detach(&a, true);
|
sm.Detach(&a, true);
|
||||||
ASSERT_TRUE(!std::filesystem::exists(swp));
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user