Sonar: split flood fill, CSV separator, noise collapse and numbered backspace

Extract helpers with unchanged behaviour to bring these functions under the
cognitive-complexity limit (S3776):
- engine cmd/objects.rs clear_background: border_pixels, run_span, push_open_runs.
- engine cmd/tools.rs collapse_noise_matches: flatten_region (Kind is now module-level).
- engine cmd/mailings.rs parse_csv: csv_separator, push_csv_row.
- engine cmd/text.rs backspace_inner: remove_number.
This commit is contained in:
Dylan M. Taylor
2026-10-25 21:30:49 -04:00
parent ee7e98eafb
commit 4fd9075e99
4 changed files with 98 additions and 64 deletions
+18 -10
View File
@@ -522,7 +522,8 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
}
/// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs).
pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
/// The field separator: whichever of `,` `;` and tab the first line (outside quotes) uses most.
fn csv_separator(text: &str) -> char {
let first = text.lines().next().unwrap_or("");
let mut quoted = false;
let mut unquoted = String::new();
@@ -533,7 +534,20 @@ pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
unquoted.push(c);
}
}
let sep = [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',');
[',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',')
}
/// Ends the current row: kept when it has any non-empty field, otherwise dropped.
fn push_csv_row(rows: &mut Vec<Vec<String>>, row: &mut Vec<String>) {
if row.iter().any(|x| !x.is_empty()) {
rows.push(std::mem::take(row));
} else {
row.clear();
}
}
pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
let sep = csv_separator(text);
let mut rows = Vec::new();
let mut row = Vec::new();
let mut cell = String::new();
@@ -560,11 +574,7 @@ pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
chars.next();
}
row.push(std::mem::take(&mut cell));
if row.iter().any(|x| !x.is_empty()) {
rows.push(std::mem::take(&mut row));
} else {
row.clear();
}
push_csv_row(&mut rows, &mut row);
} else {
cell.push(c);
}
@@ -573,9 +583,7 @@ pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
}
}
row.push(cell);
if row.iter().any(|x| !x.is_empty()) {
rows.push(row);
}
push_csv_row(&mut rows, &mut row);
rows
}
+44 -27
View File
@@ -1396,27 +1396,13 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
!seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0))
};
let mut seen = vec![false; (w as usize) * (h as usize)];
let mut stack: Vec<(u32, u32)> = Vec::new();
for x in 0..w {
stack.push((x, 0));
stack.push((x, h - 1));
}
for y in 0..h {
stack.push((0, y));
stack.push((w - 1, y));
}
let mut stack = border_pixels(w, h);
let mut most = stack.len();
while let Some((x, y)) = stack.pop() {
if !open(rgba, &seen, x, y) {
continue;
}
let (mut x0, mut x1) = (x, x);
while x0 > 0 && open(rgba, &seen, x0 - 1, y) {
x0 -= 1;
}
while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) {
x1 += 1;
}
let (x0, x1) = run_span(x, y, w, |xi, yi| open(rgba, &seen, xi, yi));
for xi in x0..=x1 {
if let Some(s) = seen.get_mut(at(xi, y)) {
*s = true;
@@ -1426,23 +1412,54 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
}
}
for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() {
let mut xi = x0;
while xi <= x1 {
if open(rgba, &seen, xi, ny) {
stack.push((xi, ny));
while xi <= x1 && open(rgba, &seen, xi, ny) {
xi += 1;
}
} else {
xi += 1;
}
}
push_open_runs(&mut stack, x0, x1, ny, |xi, yi| open(rgba, &seen, xi, yi));
}
most = most.max(stack.len());
}
most
}
/// Every pixel on the image's edge, the flood-fill seeds.
fn border_pixels(w: u32, h: u32) -> Vec<(u32, u32)> {
let mut stack = Vec::new();
for x in 0..w {
stack.push((x, 0));
stack.push((x, h - 1));
}
for y in 0..h {
stack.push((0, y));
stack.push((w - 1, y));
}
stack
}
/// The horizontal run of open pixels around `(x, y)`, both ends included.
fn run_span(x: u32, y: u32, w: u32, open: impl Fn(u32, u32) -> bool) -> (u32, u32) {
let (mut x0, mut x1) = (x, x);
while x0 > 0 && open(x0 - 1, y) {
x0 -= 1;
}
while x1 + 1 < w && open(x1 + 1, y) {
x1 += 1;
}
(x0, x1)
}
/// Queues the first pixel of each open run in row `ny` between `x0` and `x1`.
fn push_open_runs(stack: &mut Vec<(u32, u32)>, x0: u32, x1: u32, ny: u32, open: impl Fn(u32, u32) -> bool) {
let mut xi = x0;
while xi <= x1 {
if open(xi, ny) {
stack.push((xi, ny));
while xi <= x1 && open(xi, ny) {
xi += 1;
}
} else {
xi += 1;
}
}
}
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result<image::DynamicImage, CmdError> {
let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?;
let mut out = canvas(w, h, color)?;
+20 -16
View File
@@ -916,6 +916,24 @@ fn backspace(s: &mut Session, v: &Value) -> CmdResult {
/// numbering while keeping the text position, takes a List Paragraph out of the list, outdents,
/// joins the previous paragraph or moves into a preceding table; the command wrapper restores a
/// content-control placeholder.
/// The first Backspace at a numbered paragraph's start removes the number but keeps the item's
/// indent (Word): the text stays where it was, so the indent becomes the paragraph's own.
fn remove_number(s: &mut Session, f: &Pos, n: NumRef) -> CmdResult {
let text_at = s.doc.numbering.level(n.num, n.level).map(|l| l.indent);
let p = s.doc.para_mut(f.story, &f.path)?;
if p.props.style.as_deref() == Some("ListParagraph") {
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
p.props.indent_left = text_at;
}
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
p.props.indent_first = Some(0.0);
}
}
p.props.numbering = Some(NumRef { num: 0, level: 0 });
p.touch();
sel_result(s)
}
fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
s.goal_x = None;
s.pending = None;
@@ -949,22 +967,8 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
return sel_result(s);
}
// At the start of a paragraph.
if para.props.numbering.is_some_and(|n| n.num != 0) {
// First Backspace removes the number but keeps the item's indent (Word): the text stays
// where it was, so the indent becomes the paragraph's own (`num: 0` alone drops it).
let text_at = para.props.numbering.and_then(|n| s.doc.numbering.level(n.num, n.level)).map(|l| l.indent);
let p = s.doc.para_mut(f.story, &f.path)?;
if p.props.style.as_deref() == Some("ListParagraph") {
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
p.props.indent_left = text_at;
}
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
p.props.indent_first = Some(0.0);
}
}
p.props.numbering = Some(NumRef { num: 0, level: 0 });
p.touch();
return sel_result(s);
if let Some(n) = para.props.numbering.filter(|n| n.num != 0) {
return remove_number(s, &f, n);
}
if para.props.style.as_deref() == Some("ListParagraph") {
// The second one takes it out of the list's indent instead of joining paragraphs.
+16 -11
View File
@@ -1554,12 +1554,13 @@ const NOISE_MATCH_LEN: usize = 1;
/// stripped before running its table, so it's left untouched regardless of length; only a short
/// match produced by the table's own backtracking, which can only ever be interior, is a
/// candidate.
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Keep,
Fold,
}
fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Keep,
Fold,
}
let mut runs: Vec<(Op, usize, Kind)> = Vec::new();
let mut k = 0;
while k < ops.len() {
@@ -1586,12 +1587,7 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
}
let region = &runs[start..i];
if region.iter().any(|(_, _, kind)| *kind == Kind::Fold) {
// Flatten the region: every original character first (in its own order), then every
// revised character (in its own order) — one clean delete, one clean insert.
let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum();
let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum();
out.extend(std::iter::repeat_n(Op::Del, dels));
out.extend(std::iter::repeat_n(Op::Ins, inss));
flatten_region(&mut out, region);
} else {
for (op, len, _) in region {
out.extend(std::iter::repeat_n(*op, *len));
@@ -1601,6 +1597,15 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
out
}
/// Flattens a region with a folded run: every original character first (in its own order), then
/// every revised character (in its own order) — one clean delete, one clean insert.
fn flatten_region(out: &mut Vec<Op>, region: &[(Op, usize, Kind)]) {
let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum();
let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum();
out.extend(std::iter::repeat_n(Op::Del, dels));
out.extend(std::iter::repeat_n(Op::Ins, inss));
}
/// Words with the spaces after them ("The ", "cat "), or single characters.
fn tokens(s: &str, characters: bool) -> Vec<String> {
if characters {