diff --git a/src/linekind.rs b/src/linekind.rs index e644d87..d4e092f 100644 --- a/src/linekind.rs +++ b/src/linekind.rs @@ -10,8 +10,9 @@ use crate::classify::DocFlavor; use crate::textline::{ - FAST_PATH_TAB_WIDTH, advance_col, bookend_match, fence_marker_run, is_art, is_indented_code, - is_kernel_doc_tag, is_table_row, line_is_art_only, one_sided_banner, + FAST_PATH_TAB_WIDTH, advance_col, bookend_match, fence_marker_run, is_art, is_assignment_line, + is_indented_code, is_kernel_doc_tag, is_table_row, keep_complete_row_runs, line_is_art_only, + one_sided_banner, }; /// One comment source line with its marker prefix split off: "prefix" is what @@ -36,6 +37,12 @@ pub enum LineKind { DoxyVerbatimClose, IndentedCode, TableRow, + /// A compact assignment or formula row (for example, "ir->imm = value"). + /// These rows document a mapping, not a sentence, so preserve their + /// one-row layout instead of merging them into adjacent prose. Only a run + /// of two or more adjacent rows qualifies, and the run freezes whole; see + /// "textline::keep_complete_row_runs". + AssignmentForm, Blockquote, ReferenceLink, SetextUnderline, @@ -60,6 +67,67 @@ pub enum LineKind { Banner, } +impl LineKind { + /// Does this kind go out verbatim instead of being packed into a prose + /// paragraph? "normalize::group_paragraphs" keys paragraph boundaries on + /// this, so a kind that answers wrong is reflowed as prose. + /// + /// Exhaustive on purpose: a new variant must answer this question before + /// it compiles, rather than defaulting to "reflow it" and being found by + /// a corrupted diagram later. + pub(crate) fn is_preformatted(self) -> bool { + match self { + LineKind::FenceOpen + | LineKind::FenceContent + | LineKind::FenceClose + | LineKind::DoxyVerbatimOpen + | LineKind::DoxyVerbatimContent + | LineKind::DoxyVerbatimClose + | LineKind::IndentedCode + | LineKind::TableRow + | LineKind::AssignmentForm + | LineKind::Blockquote + | LineKind::ReferenceLink + | LineKind::Metadata + | LineKind::LabelRow + | LineKind::Banner + | LineKind::Art => true, + LineKind::Blank + | LineKind::Prose + | LineKind::SetextUnderline + | LineKind::AtxHeader + | LineKind::ListItem + | LineKind::DoxygenTag => false, + } + } + + /// The kinds that re-emit their body behind the canonical prefix instead + /// of replaying their raw source line: rows whose bytes are ordinary text, + /// not layout, so a drifted "**" marker and a stripped decorative bookend + /// land like every reflowed sibling. Raw replay is for the kinds whose + /// exact source bytes are the content (art, code, tables) and for + /// metadata, which is not ours to retouch. + /// + /// Named once so "emits_canonically" and the invariant test that it + /// implies "is_preformatted" cannot drift apart. + #[cfg(test)] + pub(crate) const CANONICAL: [LineKind; 3] = [ + LineKind::LabelRow, + LineKind::Banner, + LineKind::AssignmentForm, + ]; + + /// See "CANONICAL". Unlike "is_preformatted" this is not exhaustive: the + /// safe default is raw replay, and a new variant is already forced into + /// this impl block by the exhaustive match above. + pub(crate) fn emits_canonically(self) -> bool { + matches!( + self, + LineKind::LabelRow | LineKind::Banner | LineKind::AssignmentForm + ) + } +} + /// What a Doxygen tag does to the line that carries it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TagShape { @@ -172,20 +240,27 @@ enum DoxyState { /// 7. ATX header, then metadata (license/SPDX). /// 8. Blockquote, reference link, table row, indented code. All preformatted. /// 9. Label runs. Deliberately AFTER indented code: a "Key: value" line with a -/// code sample's indentation belongs to the sample. See "is_label_run". +/// code sample's indentation belongs to the sample. See "label_runs". /// 10. One-sided banners ("label -------"), which freeze only when they stand /// alone in a paragraph. See "textline::one_sided_banner". /// 11. Art, list item, and finally prose as the fallback. /// +/// Mapping runs are not in that order at all: they are decided after the loop, +/// over the lines nothing else claimed, so a run cannot reach through a code +/// sample or a table. See "mark_assignment_runs". +/// /// A check that yields a preformatted kind can be reordered against other -/// preformatted checks without changing output (they all emit the same way); -/// anything else needs the reasoning above rechecked. +/// preformatted checks that share its emit path without changing output; the +/// two paths are "LineKind::emits_canonically". Reordering across that +/// boundary, or moving any non-preformatted check, needs the reasoning above +/// rechecked. pub(crate) fn classify_lines( lines: &[StrippedLine], flavor: DocFlavor, label_budget: usize, ) -> Vec { let mut out = vec![LineKind::Prose; lines.len()]; + let in_label_run = label_runs(lines, label_budget); let mut fence = FenceState::Closed; let mut doxy = DoxyState::Closed; @@ -310,7 +385,7 @@ pub(crate) fn classify_lines( // After IndentedCode: a banner may pad one space past the marker for // alignment, which is below the indented-code threshold. A deeper // indent belongs to a code sample and is already claimed above. - if is_label_run(lines, i, label_budget) { + if in_label_run[i] { out[i] = LineKind::LabelRow; continue; } @@ -339,9 +414,48 @@ pub(crate) fn classify_lines( out[i] = LineKind::Prose; } + mark_assignment_runs(lines, &mut out); + out } +/// Mapping runs, decided after every other check rather than inside the loop. +/// +/// A row's neighbour has to be a row in the OUTPUT, not merely row-shaped in +/// the source. An indented code sample reads as a row once trimmed, so judging +/// on shape alone let " ret = foo(a, b);" pair with the sentence under it and +/// freeze that sentence on its own -- the lone-row case the run rule exists to +/// reflow. Only lines nothing else claimed are eligible, so the run cannot +/// reach through a code sample, a table row, a blockquote, or a fence. +/// +/// Running last costs nothing against the kinds that would have come after it. +/// A label row and a mapping row are emitted the same way, so which one claims +/// a line that could be either does not change the output; art already declines +/// a mapping row; and a list item or a lone banner is never row-shaped. +/// +/// No width budget here, unlike a label run: a mapping row cannot be wrapped +/// without destroying the mapping, so a frozen run may overflow the column +/// limit exactly as a table row or an indented code sample may. See +/// "preformatted_assignment_run_may_overflow_by_design". +fn mark_assignment_runs(lines: &[StrippedLine], out: &mut [LineKind]) { + // Most comments carry no "=" at all, and skipping them keeps the common + // case free of the vector below. + if !lines.iter().any(|l| l.body.as_bytes().contains(&b'=')) { + return; + } + let mut rows: Vec = out + .iter() + .zip(lines) + .map(|(kind, l)| *kind == LineKind::Prose && is_assignment_line(&l.body)) + .collect(); + keep_complete_row_runs(&mut rows); + for (i, in_run) in rows.into_iter().enumerate() { + if in_run { + out[i] = LineKind::AssignmentForm; + } + } +} + // Classify-pass twin of the strip pass's "is_protective" adjacency check (see // "strip_decorative_bookends"): a bookend that survived stripping because a // neighbor is art is itself emitted as Art (verbatim), not reflowed as prose. @@ -456,29 +570,32 @@ fn is_metadata_line(body: &str) -> bool { } /// "Key: value" banner lines, the "File:" / "Task:" shape of a file header, -/// keep their own line instead of packing into one paragraph. Only a run of two -/// or more adjacent ones counts: a lone "Note: ..." starting a prose paragraph -/// is a sentence, and wrapping it is correct. -fn is_label_run(lines: &[StrippedLine], i: usize, budget: usize) -> bool { - let eligible = |j: usize| label_eligible(lines, j, budget); - eligible(i) && (i.checked_sub(1).is_some_and(eligible) || eligible(i + 1)) -} +/// keep their own line instead of packing into one paragraph, on the shared +/// row-run rule. A label that does not already fit the budget is not a row: +/// unlike a mapping row it is a wrappable sentence, so freezing it would park +/// it over the column limit forever. +fn label_runs(lines: &[StrippedLine], budget: usize) -> Vec { + let label: Vec = lines + .iter() + .map(|l| is_label_line(&l.body, budget)) + .collect(); -/// A label line joins a run only when the line below it ends the run cleanly: -/// blank, gone, or another label. A label followed by ordinary prose is the -/// head of a badly wrapped paragraph, and freezing it strands the tail on its -/// own, which is the exact damage this tool exists to repair. -fn label_eligible(lines: &[StrippedLine], i: usize, budget: usize) -> bool { - let Some(l) = lines.get(i) else { - return false; - }; - if !is_label_line(&l.body, budget) { - return false; - } - match lines.get(i + 1) { - None => true, - Some(next) => next.body.trim().is_empty() || is_label_line(&next.body, budget), - } + // A label joins a run only when the line below ends the run cleanly: blank, + // gone, or another label. A label followed by ordinary prose is the head of + // a badly wrapped paragraph, and freezing it strands the tail. A mapping + // row deliberately forgoes this: its value is code, not a clause, so it + // does not wrap into the line below. That conjunct is the whole difference + // between the two rules; the run arithmetic is shared. + let mut eligible: Vec = (0..lines.len()) + .map(|j| { + label[j] + && lines + .get(j + 1) + .is_none_or(|next| next.body.trim().is_empty() || label[j + 1]) + }) + .collect(); + keep_complete_row_runs(&mut eligible); + eligible } fn is_label_line(body: &str, budget: usize) -> bool { @@ -706,6 +823,129 @@ mod tests { assert_eq!(classify_one("1. one", DocFlavor::None), LineKind::ListItem); } + #[test] + fn canonical_emit_implies_preformatted() { + // Raw replay is the fallback in "reflow::emit_paragraphs", reached only + // inside a preformatted paragraph. A kind that emits canonically but is + // not preformatted would never reach either branch. + for k in LineKind::CANONICAL { + assert!(k.emits_canonically(), "{k:?} is missing from the matches!"); + assert!( + k.is_preformatted(), + "{k:?} emits canonically but is not preformatted" + ); + } + } + + #[test] + fn assignment_runs_are_preformatted() { + use LineKind::AssignmentForm as A; + + // A run of two or more rows freezes; every spelling of the operator and + // any alignment padding counts as the same row shape. + assert_eq!(kinds(&["X = Y", "Z = W"], DocFlavor::None), vec![A, A]); + assert_eq!( + kinds( + &[ + "ir->imm = immediate", + "ir->imm2 = offset", + "ir->rd = dest" + ], + DocFlavor::None + ), + vec![A, A, A] + ); + assert_eq!( + kinds(&["a=b", "c += 1", "flags |= MASK"], DocFlavor::None), + vec![A, A, A] + ); + assert_eq!( + kinds(&["Foo::bar = x", "f(x) = y", "%eax = 0"], DocFlavor::None), + vec![A, A, A] + ); + } + + #[test] + fn a_lone_assignment_row_is_prose() { + use LineKind::Prose as P; + + // One row is a sentence: "count = zero ..." must reflow with the + // paragraph it belongs to, not freeze and strand the tail. + assert_eq!(classify_one("X = Y", DocFlavor::None), LineKind::Prose); + assert_eq!( + kinds( + &[ + "The mapping is fixed by the header:", + "offset = base plus the length of the header, rounded up to", + "the next multiple of the alignment the caller requested.", + ], + DocFlavor::None + ), + vec![P, P, P] + ); + // A row whose successor is prose heads a badly wrapped paragraph. + assert_eq!( + kinds( + &["a = 1", "and then we continue the sentence"], + DocFlavor::None + ), + vec![P, P] + ); + } + + #[test] + fn arrow_spellings_are_not_assignments() { + // "=>" is an arrow and "=<" is nothing at all. Neither opens a mapping + // row, however many of them sit together. + for op in ["=>", "=<"] { + let k = kinds( + &[&format!("a {op} 1"), &format!("b {op} 2")], + DocFlavor::None, + ); + assert!( + !k.contains(&LineKind::AssignmentForm), + "{op} must not classify as a mapping row, got {k:?}" + ); + } + // Tested unspaced, so a real value opening with "<" still qualifies. + assert_eq!( + kinds(&["first = ", "second = "], DocFlavor::None), + vec![LineKind::AssignmentForm; 2] + ); + } + + #[test] + fn comparisons_are_not_assignments() { + assert_eq!( + kinds( + &["when a == b, continue", "and c === d too"], + DocFlavor::None + ), + vec![LineKind::Prose, LineKind::Prose] + ); + + // "!=", "<=", ">=" never assign. (A bare "c <= d" is short and dense + // enough that the art rule claims it first; what matters here is only + // that the assignment rule does not.) + assert!( + !kinds(&["a != b", "c <= d", "e >= f"], DocFlavor::None) + .contains(&LineKind::AssignmentForm) + ); + // Multi-token left-hand sides are prose, however they are spelled. + assert_eq!( + kinds( + &["the value = means enabled", "the other = means disabled"], + DocFlavor::None + ), + vec![LineKind::Prose, LineKind::Prose] + ); + // "<<=" and ">>=" do assign, unlike "<=" and ">=". + assert_eq!( + kinds(&["mask <<= 1", "other >>= 2"], DocFlavor::None), + vec![LineKind::AssignmentForm, LineKind::AssignmentForm] + ); + } + #[test] fn doxygen_tag_recognized() { assert_eq!( @@ -812,8 +1052,19 @@ mod tests { LineKind::Metadata ); - // 9. Label runs come AFTER indented code, so a "Key: value" line with a - // code sample's indentation belongs to the sample. + // 9. Assignment runs come after indented code too: an aligned mapping + // row carrying a code sample's indentation belongs to the sample. + assert_eq!( + kinds(&[" a = 1", " b = 2"], DocFlavor::None), + [LineKind::IndentedCode, LineKind::IndentedCode] + ); + assert_eq!( + kinds(&["a = 1", "b = 2"], DocFlavor::None), + [LineKind::AssignmentForm, LineKind::AssignmentForm] + ); + + // 10. Label runs come AFTER indented code, so a "Key: value" line with + // a code sample's indentation belongs to the sample. assert_eq!( kinds(&[" File: x.c", " Task: y"], DocFlavor::None), [LineKind::IndentedCode, LineKind::IndentedCode] @@ -823,7 +1074,7 @@ mod tests { [LineKind::LabelRow, LineKind::LabelRow] ); - // 10. Art, list item, then prose as the fallback. + // 12. Art, list item, then prose as the fallback. assert_eq!(kinds(&["- one"], DocFlavor::None)[0], LineKind::ListItem); assert_eq!( kinds(&["ordinary sentence"], DocFlavor::None)[0], diff --git a/src/normalize.rs b/src/normalize.rs index c5608ba..4dcc11b 100644 --- a/src/normalize.rs +++ b/src/normalize.rs @@ -1172,9 +1172,9 @@ fn group_paragraphs(lines: &[Line]) -> Vec { blank_pending = false; i += 1; } - kind if is_preformatted_kind(kind) => { + kind if kind.is_preformatted() => { let start = i; - while i < lines.len() && is_preformatted_kind(lines[i].kind) { + while i < lines.len() && lines[i].kind.is_preformatted() { i += 1; } paragraphs.push(Paragraph { @@ -1184,10 +1184,11 @@ fn group_paragraphs(lines: &[Line]) -> Vec { }); blank_pending = false; } - LineKind::Prose - | LineKind::DoxygenTag - | LineKind::ListItem - | LineKind::SetextUnderline => { + // Everything "is_preformatted" answered no to, which the guard + // above has already taken the yes-cases from. A catch-all rather + // than a list, so a new variant lands where its own answer says it + // belongs instead of being frozen by a trailing wildcard. + _ => { let start = i; i += 1; while i < lines.len() @@ -1203,40 +1204,11 @@ fn group_paragraphs(lines: &[Line]) -> Vec { }); blank_pending = false; } - _ => { - paragraphs.push(Paragraph { - kind: ParagraphKind::Preformatted, - line_indices: vec![i], - preceded_by_blank: blank_pending, - }); - blank_pending = false; - i += 1; - } } } paragraphs } -fn is_preformatted_kind(k: LineKind) -> bool { - matches!( - k, - LineKind::FenceOpen - | LineKind::FenceContent - | LineKind::FenceClose - | LineKind::DoxyVerbatimOpen - | LineKind::DoxyVerbatimContent - | LineKind::DoxyVerbatimClose - | LineKind::IndentedCode - | LineKind::TableRow - | LineKind::Blockquote - | LineKind::ReferenceLink - | LineKind::Metadata - | LineKind::LabelRow - | LineKind::Banner - | LineKind::Art - ) -} - fn starts_new_prose_paragraph(l: &Line) -> bool { let t = l.text.trim_start(); starts_with_word(t, "Return") || starts_with_word(t, "Returns") diff --git a/src/parse.rs b/src/parse.rs index b86a961..d9b8dad 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -5,7 +5,8 @@ use tree_sitter::{Node, Parser, Tree}; use crate::signature::{collect_param_shifts, manpage_relocate_target}; use crate::textline::{ - block_is_doc, fence_marker_run, is_art, is_horizontal_rule, is_indented_code, is_table_row, + BookendKind, block_is_doc, bookend_match, fence_marker_run, is_art, is_assignment_line, + is_horizontal_rule, is_indented_code, is_table_row, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -547,10 +548,37 @@ fn block_body_lines(text: &str) -> Vec { } fn block_merge_has_preformatted(c: &Comment) -> bool { - marker_kind(&c.text) == Some(MarkerKind::PlainBlock) - && block_body_lines(&c.text) - .iter() - .any(|line| block_line_is_preformatted(line)) + if marker_kind(&c.text) != Some(MarkerKind::PlainBlock) { + return false; + } + let body = block_body_lines(&c.text); + body.iter().any(|line| block_line_is_preformatted(line)) || has_assignment_run(&body) +} + +/// The merge-stage twin of the classifier's assignment-row rule. A mapping +/// table is preformatted at the classify stage, so merging a neighboring +/// comment into it would pull unrelated prose inside the frozen block. +/// +/// This has to answer exactly what the classifier would answer for this +/// comment standing alone, in both directions. Under-blocking merges a comment +/// into a frozen table; over-blocking refuses a merge the classifier would +/// have been happy with, leaving two comments where one belongs. Sharing +/// Sharing "is_assignment_line" is what keeps the two answers identical, over +/// the same +/// two-adjacent-rows threshold the classifier uses; a lone row is a sentence +/// there too, and must not block a merge. +fn has_assignment_run(body: &[String]) -> bool { + // Through the bookend, because "strip_decorative_bookends" runs between + // this stage and the classifier: the classifier will see "base = 0x40" + // where this sees "==== base = 0x40 ====", and answering on the raw line + // lets a prose comment merge into a table that then freezes around it. A + // maximal run of two or more exists exactly when some adjacent pair both + // qualify, so the gate needs no run marking of its own. + let row = |l: &String| match bookend_match(l) { + Some(BookendKind::Labeled(label)) => is_assignment_line(&label), + _ => is_assignment_line(l), + }; + body.windows(2).any(|w| row(&w[0]) && row(&w[1])) } fn block_line_is_preformatted(line: &str) -> bool { diff --git a/src/reflow.rs b/src/reflow.rs index 99ede07..d03aa38 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -191,15 +191,20 @@ fn emit_paragraphs( for &li in ¶.line_indices { let raw = &doc.lines[li].raw; - // A banner row is the one preformatted kind whose bytes are - // ordinary text, not layout: re-emit it behind the - // canonical prefix so a drifted "**" marker and a stripped - // decorative bookend land like every reflowed sibling. The - // body itself still goes out verbatim, unwrapped. Metadata - // keeps raw replay: a license block is not ours to retouch. - if matches!(doc.lines[li].kind, LineKind::LabelRow | LineKind::Banner) - && !(skip_first_emit_inline_opener && li == 0) - { + // A banner, label, or assignment row is a preformatted kind + // whose bytes are ordinary text, not layout: re-emit it + // behind the canonical prefix so a drifted "**" marker and + // a stripped decorative bookend land like every reflowed + // sibling. The body itself still goes out verbatim, + // unwrapped. Metadata keeps raw replay: a license block is + // not ours to retouch. + // + // Line 0 of an inline-opener block included: raw replay + // would hand back the pre-strip bytes for that one line + // while its siblings keep the strip, and the next pass + // would settle on different ones. See + // "preformatted_label_row_on_an_inline_opener_keeps_its_strip". + if doc.lines[li].kind.emits_canonically() { out.push( format!("{prefix}{}", doc.lines[li].text) .trim_end() diff --git a/src/textline.rs b/src/textline.rs index e1f5c43..c4fb327 100644 --- a/src/textline.rs +++ b/src/textline.rs @@ -89,6 +89,80 @@ pub(crate) fn is_table_row(body: &str) -> bool { t.matches('|').count() >= 2 || (t.contains('\t') && t.split_whitespace().count() >= 3) } +/// Narrows a row mask to the rows that sit in a maximal run of two or more +/// adjacent ones: the shape that freezes instead of packing into a paragraph. +/// +/// Requiring two is what keeps a sentence out. A lone "Note: ..." or +/// "count = zero when the queue drained" is prose that happens to open like a +/// row, and wrapping it is correct. Two or more adjacent rows are not one +/// wrapped sentence, so the run freezes whole and whatever follows is simply +/// the next paragraph -- releasing its last row to a prose tail would split +/// one logical table, which is the damage this rule exists to prevent. +pub(crate) fn keep_complete_row_runs(is_row: &mut [bool]) { + let mut prev = false; + for i in 0..is_row.len() { + let cur = is_row[i]; + let next = is_row.get(i + 1).copied().unwrap_or(false); + is_row[i] = cur && (prev || next); + prev = cur; + } +} + +/// One "X = Y" mapping row: a compact assignment or formula that documents a +/// mapping rather than stating a sentence, so its one-row layout is the +/// content. "ir->imm = immediate", "flags |= MASK", "imm = value". +/// +/// Shared by the classify stage and the merge stage, which must agree byte +/// for byte on what a mapping row is. +/// +/// What keeps prose out is the left-hand side: it must be exactly one +/// code-like token. A sentence that happens to contain an equals sign +/// ("the default width = 80 columns") has spaces on the left and is rejected +/// here; a sentence that happens to START with one ("count = zero when the +/// queue drained") is rejected by the run rule at the call site, not here. +pub(crate) fn is_assignment_line(body: &str) -> bool { + let t = body.trim(); + + // The operator is the first '='. If it turns out to compare rather than + // assign, the '=' it leaves behind on the left fails the token test below, + // so no later '=' could rescue the line anyway. + let Some((lhs, rhs)) = t.split_once('=') else { + return false; + }; + + // "==" / "===" compare, "=>" is an arrow and "=<" is nothing at all; a + // right side of nothing is not a mapping either. Tested unspaced on + // purpose, so a genuine value like "x = " still reads as one. + if rhs.starts_with(['=', '>', '<']) || rhs.trim().is_empty() { + return false; + } + + let b = lhs.as_bytes(); + let last = b.last().copied(); + let prev = b.len().checked_sub(2).map(|j| b[j]); + let left = match last { + // "!=", and bare "<=" / ">=", compare; "<<=" / ">>=" assign. + Some(b'!') => return false, + Some(c @ (b'<' | b'>')) if prev != Some(c) => return false, + Some(b'<' | b'>') => &lhs[..lhs.len() - 2], + // Compound assignment: "+=", "-=", "*=", "/=", "%=", "|=", "&=", "^=". + Some(b'+' | b'-' | b'*' | b'/' | b'%' | b'|' | b'&' | b'^') => &lhs[..lhs.len() - 1], + _ => lhs, + } + .trim_end(); + + // One token, code-shaped, and short enough to be an identifier rather than + // a clause. Every accepted byte is ASCII, so the byte length is the char + // count; the cap is a sanity bound, not a real limit, and has to clear + // names like "ctx->sub->field[MAX_INDEX]". + !left.is_empty() + && left.len() <= 48 + && left.bytes().any(|c| c.is_ascii_alphabetic() || c == b'_') + && left + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"_.->:[]()*%$".contains(&c)) +} + pub(crate) fn is_indented_code(body: &str) -> bool { // Intentional content alignment: ≥2 leading spaces OR ≥1 leading tab. A // single leading ASCII space is too common as a wrap artifact (it can @@ -135,6 +209,20 @@ pub(crate) fn is_art(body: &str) -> bool { if has_alpha_word_min4(body) { return false; } + + // An assignment row's operator is content, not drawing. "=" is a member of + // the art alphabet, so a short mapping row like "a += b" clears the density + // threshold on the very characters that make it a row, and art would claim + // it after the run rule had deliberately declined to freeze it -- stranding + // the tail of the paragraph it belongs to, the damage the run rule exists + // to avoid. Whether a mapping row freezes is the run rule's alone. A + // rule-decorated line is excepted: released to prose, the packer can join + // "x += y ---" to a "--- label" above it and build a line ruled on both + // ends, which the next pass strips. Different bytes each pass, and + // "--check" would never settle. Decoration outranks the mapping reading. + if is_assignment_line(body) && bookend_match(body).is_none() && !one_sided_banner(body) { + return false; + } if ascii_art_density(body) { return true; } diff --git a/tests/convergence.rs b/tests/convergence.rs index dd44fb3..8e3160f 100644 --- a/tests/convergence.rs +++ b/tests/convergence.rs @@ -125,6 +125,15 @@ const VOCAB: &[&str] = &[ "\\param.h", "@see.com", "user@example.com", + // Assignment operators, which "textline::is_assignment_line" keys on. + // Without these no token in this vocabulary can spell a mapping row, so + // the packer could never forge one at a line start and the run rule in + // "linekind" would be unreachable from this sweep. "==" is here to be + // rejected: it is the comparison the row rule must not claim. + "=", + "==", + "+=", + "x=1", // Rule runs, which the bookend and one-sided-banner rules key on. A run // that lands alone in a paragraph is decoration and freezes; one inside a // paragraph is an em-dash and must keep reflowing. diff --git a/tests/invariants.rs b/tests/invariants.rs index a7b8337..11d7672 100644 --- a/tests/invariants.rs +++ b/tests/invariants.rs @@ -651,6 +651,172 @@ fn markdown_list_boundary_numbered_list_not_collapsed() { // preformatted_borderline +#[test] +fn preformatted_assignment_row_spellings_pass_through() { + // The classifier pins these shapes in "assignment_runs_are_preformatted"; + // what the pipeline adds is that a classified run is emitted byte for byte, + // including the alignment padding that is part of the layout. + for src in [ + "/*\n * ir->imm = immediate\n * ir->imm2 = offset\n * ir->rd = dest\n */\nint f(void) { return 0; }\n", + "/*\n * ir->imm=lui immediate\n * ir->imm2=addi immediate\n * ir->rd=destination register\n */\nint f(void) { return 0; }\n", + "/*\n * total += delta\n * count -= 1\n * flags |= MASK\n */\nint g(void) { return 0; }\n", + ] { + let out = pipeline(src, detect("foo.c"), 60); + assert_eq!(out, src, "mapping rows must survive verbatim, got:\n{out}"); + } +} + +#[test] +fn preformatted_assignment_run_survives_a_prose_tail() { + // The run freezes whole or not at all. Releasing only its last row to a + // following sentence would split one logical table, and a table sitting + // directly above its explanation, with no blank line, is the common shape. + let src = "/*\n * ir->imm = lui immediate\n * ir->imm2 = addi immediate\n * ir->rd = destination register\n * The immediate is already shifted left by twelve.\n */\nint f(void);\n"; + let out = pipeline(src, detect("foo.c"), 60); + assert!( + out.contains(" * ir->imm = lui immediate\n * ir->imm2 = addi immediate\n * ir->rd = destination register\n"), + "the whole run must survive its prose tail, got:\n{out}" + ); + assert!( + out.contains(" * The immediate is already shifted left by twelve.\n"), + "the tail must reflow as its own paragraph, got:\n{out}" + ); +} + +#[test] +fn preformatted_assignment_run_does_not_reach_through_a_code_sample() { + // An indented code sample reads as a row once trimmed, so judging on shape + // alone paired it with the sentence below and froze that sentence on its + // own. A row's neighbour has to be a row in the output, not in the source. + let src = "/*\n * x = 1\n * count = zero when the queue drained, and\n * any drift between the two numbers means a leak.\n */\nint a(void);\n"; + let out = pipeline(src, detect("foo.c"), 72); + assert!( + out.contains(" * x = 1\n"), + "the sample must stay put, got:\n{out}" + ); + assert!( + out.contains("count = zero when the queue drained, and any drift"), + "the lone row must rejoin its paragraph, got:\n{out}" + ); +} + +#[test] +fn preformatted_rule_decorated_row_stays_art() { + // Released to prose, the packer joins "x += y ---" to the "--- Mapping" + // above it and builds a line ruled on both ends, which the next pass + // strips: different bytes every pass, and "--check" never settles. + let src = "/*\n * --- Mapping\n * x += y ---\n */\nint f(void);\n"; + let out = pipeline(src, detect("foo.c"), 80); + assert_eq!( + out, src, + "decoration outranks the mapping reading, got:\n{out}" + ); + assert_eq!(pipeline(&out, detect("foo.c"), 80), out, "must converge"); +} + +#[test] +fn preformatted_bracketed_value_is_still_a_mapping_row() { + // The arrow rejection is tested unspaced, so a genuine value that opens + // with "<" keeps reading as one. (The "=>" / "=<" rejection itself is + // pinned at classification level in + // "linekind::arrow_spellings_are_not_assignments".) + let src = "/*\n * first = \n * second = \n */\nint f(void);\n"; + assert_eq!(pipeline(src, detect("foo.c"), 80), src); +} + +#[test] +fn preformatted_lone_assignment_row_reflows_with_its_paragraph() { + // One row is a sentence, not a mapping: freezing it would strand the tail + // of the sentence and pin the source's accidental line breaks forever. + let src = "/*\n * The invariant the allocator maintains is simple enough: the\n * count = zero exactly when the queue has been drained, and any\n * drift between the two numbers means a leak somewhere.\n */\nint a(void);\n"; + let out = pipeline(src, detect("foo.c"), 72); + assert!( + out.contains("simple enough: the count =\n"), + "a lone assignment-shaped line must refill with its paragraph, got:\n{out}" + ); +} + +#[test] +fn preformatted_lone_assignment_row_over_the_limit_still_wraps() { + // A frozen row is emitted as-is, so a lone over-long one would park over + // the column limit forever. Only a run of rows is unwrappable layout. + let src = "/* timeout = the number of milliseconds the poller waits before it gives up and returns an error */\nint f(void);\n"; + let out = pipeline(src, detect("foo.c"), 80); + assert!( + out.lines().all(|l| l.chars().count() <= 80), + "a lone assignment-shaped sentence must wrap, got:\n{out}" + ); +} + +#[test] +fn preformatted_lone_assignment_row_is_not_claimed_by_art() { + // "=" is a member of the art alphabet, so a short row like "a += b" clears + // the density threshold on the very characters that make it a row. Art used + // to claim it after the run rule had declined to freeze it, which stranded + // the paragraph tail the run rule exists to protect. + let src = "/*\n * a += b\n * and then the sentence continues here with more words than fit\n */\nint f(void);\n"; + let out = pipeline(src, detect("foo.c"), 40); + assert!( + out.contains(" * a += b and then the sentence\n"), + "a lone row must rejoin its paragraph, got:\n{out}" + ); + + // The run rule still owns the other half of the decision. + let run = "/*\n * a += b\n * c -= d\n * e |= f\n */\nint g(void);\n"; + assert_eq!( + pipeline(run, detect("foo.c"), 40), + run, + "a run must still freeze" + ); + + // And a real drawing is untouched: "|a" is not a single code-like token. + let art = "/*\n * +-----+\n * |a = b|\n * +-----+\n */\nint h(void);\n"; + let out = pipeline(art, detect("foo.c"), 40); + assert!( + out.contains(" * |a = b|\n"), + "art must survive, got:\n{out}" + ); +} + +#[test] +fn preformatted_assignment_run_may_overflow_by_design() { + // The boundary of the no-width-budget trade, pinned so it is not mistaken + // for a packing bug: a LONE over-long row reflows (see the test above), but + // a run of them freezes and stays over the limit, exactly as a table row or + // an indented code sample does. + let src = "/*\n * timeout = the number of milliseconds the poller waits before it gives up\n * retries = the number of attempts that are made before the caller sees an error\n */\nint f(void);\n"; + let out = pipeline(src, detect("foo.c"), 60); + assert_eq!(out, src, "a frozen run must stay verbatim, got:\n{out}"); + assert!(out.lines().any(|l| l.chars().count() > 60)); +} + +#[test] +fn preformatted_assignment_rows_keep_the_bookend_strip() { + // The rows replay behind the canonical prefix, like a label or banner row, + // so the decorative bookend the strip pass removed stays removed. + let src = "/*\n * ==== base = 0x40 ====\n * ==== top = 0x80 ====\n */\nint g(void);\n"; + let out = pipeline(src, detect("foo.c"), 60); + assert_eq!( + out, "/*\n * base = 0x40\n * top = 0x80\n */\nint g(void);\n", + "bookend strip must not be undone by raw replay, got:\n{out}" + ); +} + +#[test] +fn preformatted_label_row_on_an_inline_opener_keeps_its_strip() { + // Line 0 of a "/* " block took the raw-replay branch, which + // rebuilds the line from bytes predating "strip_decorative_bookends". Its + // siblings kept the strip, so pass 1 emitted a mixed result and pass 2 + // finished the job: "--check" would report a diff forever. + let src = "/* ==== Foo: the first value ====\n * ==== Bar: the second value ====\n */\nint f(void);\n"; + let out = pipeline(src, detect("foo.c"), 60); + assert_eq!( + out, "/*\n * Foo: the first value\n * Bar: the second value\n */\nint f(void);\n", + "the opener's line must be stripped like its siblings, got:\n{out}" + ); + assert_eq!(pipeline(&out, detect("foo.c"), 60), out, "must converge"); +} + #[test] fn preformatted_borderline_operator_prose_reflows() { // "when a > b && c < d, return early" must reflow normally; it is not ASCII diff --git a/tests/pipeline.rs b/tests/pipeline.rs index 5169448..9d619fd 100644 --- a/tests/pipeline.rs +++ b/tests/pipeline.rs @@ -326,6 +326,37 @@ fn adjacent_block_comments_skip_table_merge() { assert_eq!(out, src, "table block must not be merged, got:\n{out}"); } +#[test] +fn adjacent_block_comments_skip_bookended_assignment_run_merge() { + // "strip_decorative_bookends" runs between the merge stage and the + // classifier, so the merge gate has to read through the decoration: the + // classifier will see "base = 0x40" where the raw line says + // "==== base = 0x40 ====", and answering on the raw line let this prose + // comment merge into a table that then froze around it. + let src = "/*\n * ==== base = 0x40 ====\n * ==== limit = 0x80 ====\n */\n/* more words */\n"; + let out = pipeline(src, detect("foo.c"), 80); + assert!( + out.contains("/* more words */"), + "prose must stay its own comment, got:\n{out}" + ); + // The decoration strip happens on pass one, so pass two must agree. + assert_eq!( + pipeline(&out, detect("foo.c"), 80), + out, + "must converge, got:\n{out}" + ); +} + +#[test] +fn adjacent_block_comments_skip_assignment_run_merge() { + // A mapping table is preformatted at the classify stage, so merging the + // next comment into it would pull unrelated prose inside the frozen block. + let src = + "/*\n * ir->imm = lui immediate\n * ir->imm2 = addi immediate\n */\n/* more words */\n"; + let out = pipeline(src, detect("foo.c"), 80); + assert_eq!(out, src, "assignment block must not be merged, got:\n{out}"); +} + #[test] fn adjacent_block_comments_skip_fence_merge() { let src = "/*\n * ```\n * code();\n * ```\n */\n/* more words */\n";