Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package rumdl for openSUSE:Factory checked in at 2026-07-29 19:00:40 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/rumdl (Old) and /work/SRC/openSUSE:Factory/.rumdl.new.2004 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "rumdl" Wed Jul 29 19:00:40 2026 rev:93 rq:1368296 version:0.2.45 Changes: -------- --- /work/SRC/openSUSE:Factory/rumdl/rumdl.changes 2026-07-28 17:59:24.227615336 +0200 +++ /work/SRC/openSUSE:Factory/.rumdl.new.2004/rumdl.changes 2026-07-29 19:02:24.788190977 +0200 @@ -1,0 +2,19 @@ +Wed Jul 29 06:00:19 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.2.45: + * Fixed + - md013: keep every word when a sentence boundary falls inside + emphasis (207823b) + - md037: don't flag text between emphasis spans when a span + wraps (17db4f9) + * Changed + - md013: reflow output changed for emphasis spanning several + sentences. In sentence-per-line and semantic-line-breaks + modes, **First. Second.** now becomes **First.\nSecond.** + instead of **First.**\n**Second.**. Reopening the markers on + each line replaced the sentence-separating space with a + marker pair, which deleted the space and, on many inputs, + whole words. A line break inside a span is whitespace in + CommonMark, so the rendered output is unchanged. + +------------------------------------------------------------------- Old: ---- rumdl-0.2.44.obscpio New: ---- rumdl-0.2.45.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ rumdl.spec ++++++ --- /var/tmp/diff_new_pack.rCFS8M/_old 2026-07-29 19:02:28.888332282 +0200 +++ /var/tmp/diff_new_pack.rCFS8M/_new 2026-07-29 19:02:28.888332282 +0200 @@ -17,7 +17,7 @@ Name: rumdl -Version: 0.2.44 +Version: 0.2.45 Release: 0 Summary: Markdown Linter written in Rust License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.rCFS8M/_old 2026-07-29 19:02:28.952334488 +0200 +++ /var/tmp/diff_new_pack.rCFS8M/_new 2026-07-29 19:02:28.956334626 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/rvben/rumdl.git</param> <param name="scm">git</param> <param name="submodules">enable</param> - <param name="revision">refs/tags/v0.2.44</param> + <param name="revision">refs/tags/v0.2.45</param> <param name="match-tag">v*.*.*</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.rCFS8M/_old 2026-07-29 19:02:28.988335729 +0200 +++ /var/tmp/diff_new_pack.rCFS8M/_new 2026-07-29 19:02:28.996336004 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/rvben/rumdl.git</param> - <param name="changesrevision">f4ec7f95f17043a4ddfeedeafef49a9f698e3845</param></service></servicedata> + <param name="changesrevision">0610735e0d36bc1cfd5a77cc720c08132c241182</param></service></servicedata> (No newline at EOF) ++++++ rumdl-0.2.44.obscpio -> rumdl-0.2.45.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/CHANGELOG.md new/rumdl-0.2.45/CHANGELOG.md --- old/rumdl-0.2.44/CHANGELOG.md 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/CHANGELOG.md 2026-07-28 20:41:14.000000000 +0200 @@ -84,6 +84,23 @@ + +## [0.2.45](https://github.com/rvben/rumdl/compare/v0.2.44...v0.2.45) - 2026-07-28 + +### Fixed + +- **md013**: keep every word when a sentence boundary falls inside emphasis ([207823b](https://github.com/rvben/rumdl/commit/207823b664ea5ca34c2c7c1ec5b2335366ea5b17)) +- **md037**: don't flag text between emphasis spans when a span wraps ([17db4f9](https://github.com/rvben/rumdl/commit/17db4f9437bd7f63fee5f552712cd6503caa2d48)) + +### Changed + +- **md013**: reflow output changed for emphasis spanning several sentences. In + `sentence-per-line` and `semantic-line-breaks` modes, `**First. Second.**` now + becomes `**First.\nSecond.**` instead of `**First.**\n**Second.**`. Reopening the + markers on each line replaced the sentence-separating space with a marker pair, + which deleted the space and, on many inputs, whole words. A line break inside a + span is whitespace in CommonMark, so the rendered output is unchanged. + ## [0.2.44](https://github.com/rvben/rumdl/compare/v0.2.43...v0.2.44) - 2026-07-28 ### Added diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/Cargo.lock new/rumdl-0.2.45/Cargo.lock --- old/rumdl-0.2.44/Cargo.lock 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/Cargo.lock 2026-07-28 20:41:14.000000000 +0200 @@ -2289,7 +2289,7 @@ [[package]] name = "rumdl" -version = "0.2.44" +version = "0.2.45" dependencies = [ "anyhow", "assert_cmd", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/Cargo.toml new/rumdl-0.2.45/Cargo.toml --- old/rumdl-0.2.44/Cargo.toml 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/Cargo.toml 2026-07-28 20:41:14.000000000 +0200 @@ -1,6 +1,6 @@ [package] name = "rumdl" -version = "0.2.44" +version = "0.2.45" edition = "2024" rust-version = "1.94.0" description = "A fast Markdown linter written in Rust (Ru(st) MarkDown Linter)" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/README.md new/rumdl-0.2.45/README.md --- old/rumdl-0.2.44/README.md 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/README.md 2026-07-28 20:41:14.000000000 +0200 @@ -207,7 +207,7 @@ mise install rumdl # Use a specific version for the project -mise use [email protected] +mise use [email protected] ``` ### Using Nix (macOS/Linux) @@ -436,7 +436,7 @@ ```yaml repos: - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.44 + rev: v0.2.45 hooks: - id: rumdl # Lint only; add args [--fix] to auto-fix - id: rumdl-fmt # Pure format, always exits 0 @@ -452,7 +452,7 @@ ```yaml repos: - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.44 + rev: v0.2.45 hooks: - id: rumdl args: [--fix] # Auto-fix violations in place @@ -469,7 +469,7 @@ ```yaml repos: - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.44 + rev: v0.2.45 hooks: - id: rumdl args: [--no-exclude] # Disable all exclude patterns diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/docs/getting-started/quickstart.md new/rumdl-0.2.45/docs/getting-started/quickstart.md --- old/rumdl-0.2.44/docs/getting-started/quickstart.md 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/docs/getting-started/quickstart.md 2026-07-28 20:41:14.000000000 +0200 @@ -96,7 +96,7 @@ ```yaml repos: - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.44 # Use latest version + rev: v0.2.45 # Use latest version hooks: - id: rumdl ``` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/docs/global-settings.md new/rumdl-0.2.45/docs/global-settings.md --- old/rumdl-0.2.44/docs/global-settings.md 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/docs/global-settings.md 2026-07-28 20:41:14.000000000 +0200 @@ -1513,7 +1513,7 @@ ```yaml - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.44 + rev: v0.2.45 hooks: - id: rumdl args: [--config=.rumdl.toml] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/docs/mdformat-comparison.md new/rumdl-0.2.45/docs/mdformat-comparison.md --- old/rumdl-0.2.44/docs/mdformat-comparison.md 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/docs/mdformat-comparison.md 2026-07-28 20:41:14.000000000 +0200 @@ -232,7 +232,7 @@ # After - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.44 + rev: v0.2.45 hooks: - id: rumdl ``` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/docs/usage/ci-cd.md new/rumdl-0.2.45/docs/usage/ci-cd.md --- old/rumdl-0.2.44/docs/usage/ci-cd.md 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/docs/usage/ci-cd.md 2026-07-28 20:41:14.000000000 +0200 @@ -40,7 +40,7 @@ ```yaml - uses: rvben/rumdl@v0 with: - version: "0.2.44" + version: "0.2.45" path: docs/ ``` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/docs/usage/pre-commit.md new/rumdl-0.2.45/docs/usage/pre-commit.md --- old/rumdl-0.2.44/docs/usage/pre-commit.md 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/docs/usage/pre-commit.md 2026-07-28 20:41:14.000000000 +0200 @@ -13,7 +13,7 @@ ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.44 # Use latest version + rev: v0.2.45 # Use latest version hooks: - id: rumdl # Lint only; add args [--fix] to auto-fix - id: rumdl-fmt # Pure format, always exits 0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/npm/cli-darwin-arm64/package.json new/rumdl-0.2.45/npm/cli-darwin-arm64/package.json --- old/rumdl-0.2.44/npm/cli-darwin-arm64/package.json 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/npm/cli-darwin-arm64/package.json 2026-07-28 20:41:14.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-darwin-arm64", - "version": "0.2.44", + "version": "0.2.45", "description": "rumdl binary for macOS ARM64 (Apple Silicon)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/npm/cli-darwin-x64/package.json new/rumdl-0.2.45/npm/cli-darwin-x64/package.json --- old/rumdl-0.2.44/npm/cli-darwin-x64/package.json 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/npm/cli-darwin-x64/package.json 2026-07-28 20:41:14.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-darwin-x64", - "version": "0.2.44", + "version": "0.2.45", "description": "rumdl binary for macOS x64 (Intel)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/npm/cli-linux-arm64/package.json new/rumdl-0.2.45/npm/cli-linux-arm64/package.json --- old/rumdl-0.2.44/npm/cli-linux-arm64/package.json 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/npm/cli-linux-arm64/package.json 2026-07-28 20:41:14.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-arm64", - "version": "0.2.44", + "version": "0.2.45", "description": "rumdl binary for Linux ARM64 (glibc)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/npm/cli-linux-arm64-musl/package.json new/rumdl-0.2.45/npm/cli-linux-arm64-musl/package.json --- old/rumdl-0.2.44/npm/cli-linux-arm64-musl/package.json 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/npm/cli-linux-arm64-musl/package.json 2026-07-28 20:41:14.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-arm64-musl", - "version": "0.2.44", + "version": "0.2.45", "description": "rumdl binary for Linux ARM64 (musl/Alpine)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/npm/cli-linux-x64/package.json new/rumdl-0.2.45/npm/cli-linux-x64/package.json --- old/rumdl-0.2.44/npm/cli-linux-x64/package.json 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/npm/cli-linux-x64/package.json 2026-07-28 20:41:14.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-x64", - "version": "0.2.44", + "version": "0.2.45", "description": "rumdl binary for Linux x64 (glibc)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/npm/cli-linux-x64-musl/package.json new/rumdl-0.2.45/npm/cli-linux-x64-musl/package.json --- old/rumdl-0.2.44/npm/cli-linux-x64-musl/package.json 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/npm/cli-linux-x64-musl/package.json 2026-07-28 20:41:14.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-x64-musl", - "version": "0.2.44", + "version": "0.2.45", "description": "rumdl binary for Linux x64 (musl/Alpine)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/npm/cli-win32-x64/package.json new/rumdl-0.2.45/npm/cli-win32-x64/package.json --- old/rumdl-0.2.44/npm/cli-win32-x64/package.json 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/npm/cli-win32-x64/package.json 2026-07-28 20:41:14.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-win32-x64", - "version": "0.2.44", + "version": "0.2.45", "description": "rumdl binary for Windows x64", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/npm/rumdl/package.json new/rumdl-0.2.45/npm/rumdl/package.json --- old/rumdl-0.2.44/npm/rumdl/package.json 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/npm/rumdl/package.json 2026-07-28 20:41:14.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "rumdl", - "version": "0.2.44", + "version": "0.2.45", "description": "A fast Markdown linter written in Rust", "license": "MIT", "repository": { @@ -33,12 +33,12 @@ "node": ">=18.0.0" }, "optionalDependencies": { - "@rumdl/cli-darwin-x64": "0.2.44", - "@rumdl/cli-darwin-arm64": "0.2.44", - "@rumdl/cli-linux-x64": "0.2.44", - "@rumdl/cli-linux-arm64": "0.2.44", - "@rumdl/cli-linux-x64-musl": "0.2.44", - "@rumdl/cli-linux-arm64-musl": "0.2.44", - "@rumdl/cli-win32-x64": "0.2.44" + "@rumdl/cli-darwin-x64": "0.2.45", + "@rumdl/cli-darwin-arm64": "0.2.45", + "@rumdl/cli-linux-x64": "0.2.45", + "@rumdl/cli-linux-arm64": "0.2.45", + "@rumdl/cli-linux-x64-musl": "0.2.45", + "@rumdl/cli-linux-arm64-musl": "0.2.45", + "@rumdl/cli-win32-x64": "0.2.45" } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/src/rules/md037_spaces_around_emphasis.rs new/rumdl-0.2.45/src/rules/md037_spaces_around_emphasis.rs --- old/rumdl-0.2.44/src/rules/md037_spaces_around_emphasis.rs 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/src/rules/md037_spaces_around_emphasis.rs 2026-07-28 20:41:14.000000000 +0200 @@ -125,6 +125,40 @@ // Check reference definitions [ref]: url "title" using pre-computed data (O(1) vs O(n)) ctx.is_in_reference_def(byte_pos) } + + /// Where each emphasis span ends, ascending, paired with where it began. + /// + /// Ordering by end position is what keeps [`Self::closes_earlier_emphasis`] + /// logarithmic: a run only has to consider the spans that close inside it, + /// which is a handful, rather than every span in the document. + fn emphasis_span_ends(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> { + let mut ends: Vec<(usize, usize)> = ctx + .emphasis_spans() + .iter() + .map(|span| (span.byte_end, span.byte_offset)) + .collect(); + ends.sort_unstable(); + ends + } + + /// Check whether a flagged run only looks spaced because its opening marker + /// is really the closing delimiter of emphasis that began further up. + /// + /// Markers are paired one line at a time, so on a line that continues a span + /// the first marker reads as an opener and every marker after it pairs one + /// position off, putting ordinary words between what look like delimiters. + /// The document-level parse settles it: a marker already spent closing a + /// span cannot also open one. + /// + /// A span that both starts and ends inside the run is a different thing, and + /// stays reported: `* _real_ *` really is emphasis with spaces in it. + fn closes_earlier_emphasis(span_ends: &[(usize, usize)], start: usize, end: usize) -> bool { + let first_after_start = span_ends.partition_point(|(span_end, _)| *span_end <= start); + span_ends[first_after_start..] + .iter() + .take_while(|(span_end, _)| *span_end < end) + .any(|(_, span_start)| *span_start <= start) + } } impl Rule for MD037NoSpaceInEmphasis { @@ -197,6 +231,11 @@ // Filter out warnings for emphasis markers that are inside links, HTML comments, math, or MkDocs markup let mut filtered_warnings = Vec::new(); let lines = ctx.raw_lines(); + let span_ends = if warnings.is_empty() { + Vec::new() + } else { + Self::emphasis_span_ends(ctx) + }; for (line_idx, line) in lines.iter().enumerate() { let line_num = line_idx + 1; @@ -221,7 +260,9 @@ // detector grammar, so MD037's spaced-emphasis warnings can // never land inside one. let in_pandoc_construct = ctx.flavor.is_pandoc_compatible() && ctx.is_in_bracketed_span(byte_pos); + let byte_end = line_start_pos + (warning.end_column - 1); if !in_pandoc_construct + && !Self::closes_earlier_emphasis(&span_ends, byte_pos, byte_end) && !self.is_in_link(ctx, byte_pos) && !ctx.is_in_html_comment(byte_pos) && !is_in_math_context(ctx, byte_pos) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/src/utils/text_reflow.rs new/rumdl-0.2.45/src/utils/text_reflow.rs --- old/rumdl-0.2.44/src/utils/text_reflow.rs 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/src/utils/text_reflow.rs 2026-07-28 20:41:14.000000000 +0200 @@ -841,7 +841,71 @@ ) } +/// Reflow a line, falling back to the input when the result would not preserve it. +/// +/// Reflow redistributes whitespace: it decides where lines break, never which +/// characters a paragraph contains. So the sequence of non-whitespace characters +/// is invariant across a correct reflow, and any difference means the reflow +/// dropped, duplicated, reordered, or invented content. Returning the input +/// unchanged in that case costs a paragraph that stays unwrapped; the alternative +/// is writing corrupted prose into the user's file. A caller comparing its +/// replacement against the original then sees no change and reports nothing. pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> { + let reflowed = reflow_line_unchecked(line, options); + if preserves_content(line, &reflowed) { + reflowed + } else { + vec![line.to_string()] + } +} + +/// Whether `reflowed` still holds `original`'s text: the same non-whitespace +/// characters in the same order, with every word boundary intact. +/// +/// Reflow may add a boundary the input did not have, since wrapping a script +/// that writes without spaces has to break somewhere. Removing one is different: +/// it glues two words into a word the author never wrote. +fn preserves_content(original: &str, reflowed: &[String]) -> bool { + let (original_text, original_breaks) = visible_text_and_breaks(original.chars()); + let (reflowed_text, reflowed_breaks) = + visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n']))); + + original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks) +} + +/// The non-whitespace characters of `text`, and for each interior run of +/// whitespace, how many characters precede it. +fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) { + let mut visible = String::new(); + let mut breaks = Vec::new(); + let mut count = 0usize; + let mut pending_break = false; + + for c in text { + if c.is_whitespace() { + pending_break = count > 0; + } else { + if pending_break { + breaks.push(count); + pending_break = false; + } + visible.push(c); + count += 1; + } + } + + (visible, breaks) +} + +/// Whether every value in `subset` appears in `superset`. Both are ascending. +fn contains_all(superset: &[usize], subset: &[usize]) -> bool { + let mut candidates = superset.iter(); + subset + .iter() + .all(|wanted| candidates.by_ref().any(|found| found == wanted)) +} + +fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> { // For sentence-per-line mode, always process regardless of length if options.sentence_per_line { let elements = parse_elements(line, options); @@ -1998,47 +2062,59 @@ let mut current_line = String::new(); for (idx, element) in elements.iter().enumerate() { - // For text elements, split into sentences - if let Element::Text(text) = element { - // Simply append text - it already has correct spacing from tokenization - let combined = format!("{current_line}{text}"); + // Text and emphasis are absorbed the same way. An emphasis span is + // rendered back to its source form and then treated as ordinary text, + // so a sentence boundary inside it breaks the line without closing and + // reopening the markers: a line break inside a span is whitespace, and + // whitespace is all a reflow is allowed to change. + let piece = match element { + // Text already carries its own spacing from tokenization. + Element::Text(text) => Some(text.clone()), + Element::Italic { content, underscore } => Some(wrap_emphasis( + content, + if *underscore { "_" } else { "*" }, + &mut current_line, + )), + Element::Bold { content, underscore } => Some(wrap_emphasis( + content, + if *underscore { "__" } else { "**" }, + &mut current_line, + )), + Element::Strikethrough { content, double } => Some(wrap_emphasis( + content, + if *double { "~~" } else { "~" }, + &mut current_line, + )), + _ => None, + }; + + if let Some(piece) = piece { + let combined = format!("{current_line}{piece}"); // Use the pre-computed abbreviations set to avoid redundant computation let sentences = split_into_sentences_with_set(&combined, &abbreviations, require_sentence_capital); if sentences.len() > 1 { - // We found sentence boundaries + // Accumulate rather than emit-and-overwrite: a sentence held + // back for the next element must absorb what follows it, or the + // text that follows would reach the output ahead of it. + let mut pending = String::new(); + let last = sentences.len() - 1; for (i, sentence) in sentences.iter().enumerate() { - if i == 0 { - // First sentence might continue from previous elements - // But check if it ends with an abbreviation - let trimmed = sentence.trim(); - - if text_ends_with_abbreviation(trimmed, &abbreviations) { - // Don't emit yet - this sentence ends with abbreviation, continue accumulating - current_line.clone_from(sentence); - } else { - // Normal case - emit the first sentence - lines.push(sentence.clone()); - current_line.clear(); - } - } else if i == sentences.len() - 1 { - // Last sentence: check if it's complete or incomplete - let trimmed = sentence.trim(); - let ends_with_sentence_punct = ends_with_sentence_punct(trimmed); - - if ends_with_sentence_punct && !text_ends_with_abbreviation(trimmed, &abbreviations) { - // Complete sentence - emit it immediately - lines.push(sentence.clone()); - current_line.clear(); - } else { - // Incomplete sentence - save for next iteration - current_line.clone_from(sentence); - } - } else { - // Complete sentences in the middle - lines.push(sentence.clone()); + if !pending.is_empty() { + pending.push(' '); + } + pending.push_str(sentence); + + // The splitter already decided every boundary except the + // final one, which is just the leftover tail. Hold a tail + // that no punctuation closed, and hold any piece ending in + // an abbreviation the splitter broke after regardless. + let closed = i < last || ends_with_sentence_punct(&pending); + if closed && !text_ends_with_abbreviation(&pending, &abbreviations) { + lines.push(std::mem::take(&mut pending)); } } + current_line = pending; } else { // Single sentence - check if it's complete let trimmed = combined.trim(); @@ -2062,38 +2138,6 @@ current_line = combined; } } - } else if let Element::Italic { content, underscore } = element { - // Handle italic elements - may contain multiple sentences that need continuation - let marker = if *underscore { "_" } else { "*" }; - handle_emphasis_sentence_split( - content, - marker, - &abbreviations, - require_sentence_capital, - &mut current_line, - &mut lines, - ); - } else if let Element::Bold { content, underscore } = element { - // Handle bold elements - may contain multiple sentences that need continuation - let marker = if *underscore { "__" } else { "**" }; - handle_emphasis_sentence_split( - content, - marker, - &abbreviations, - require_sentence_capital, - &mut current_line, - &mut lines, - ); - } else if let Element::Strikethrough { content, double } = element { - // Handle strikethrough elements - may contain multiple sentences that need continuation - handle_emphasis_sentence_split( - content, - if *double { "~~" } else { "~" }, - &abbreviations, - require_sentence_capital, - &mut current_line, - &mut lines, - ); } else { // Non-text, non-emphasis elements (Code, Links, etc.) let element_str = format!("{element}"); @@ -2124,82 +2168,14 @@ lines } -/// Handle splitting emphasis content at sentence boundaries while preserving markers -fn handle_emphasis_sentence_split( - content: &str, - marker: &str, - abbreviations: &HashSet<String>, - require_sentence_capital: bool, - current_line: &mut String, - lines: &mut Vec<String>, -) { - // Split the emphasis content into sentences - let sentences = split_into_sentences_with_set(content, abbreviations, require_sentence_capital); - - if sentences.len() <= 1 { - // Single sentence or no boundaries - treat as atomic - if should_insert_space_before_join(current_line) { - current_line.push(' '); - } - current_line.push_str(marker); - current_line.push_str(content); - current_line.push_str(marker); - - // Check if the emphasis content ends with sentence punctuation - if so, emit - let trimmed = content.trim(); - let ends_with_punct = ends_with_sentence_punct(trimmed); - if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) { - lines.push(current_line.clone()); - current_line.clear(); - } - } else { - // Multiple sentences - each gets its own emphasis markers - for (i, sentence) in sentences.iter().enumerate() { - let trimmed = sentence.trim(); - if trimmed.is_empty() { - continue; - } - - if i == 0 { - // First sentence: combine with current_line and emit - if should_insert_space_before_join(current_line) { - current_line.push(' '); - } - current_line.push_str(marker); - current_line.push_str(trimmed); - current_line.push_str(marker); - - // Check if this is a complete sentence - let ends_with_punct = ends_with_sentence_punct(trimmed); - if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) { - lines.push(current_line.clone()); - current_line.clear(); - } - } else if i == sentences.len() - 1 { - // Last sentence: check if complete - let ends_with_punct = ends_with_sentence_punct(trimmed); - - let mut line = String::new(); - line.push_str(marker); - line.push_str(trimmed); - line.push_str(marker); - - if ends_with_punct && !text_ends_with_abbreviation(trimmed, abbreviations) { - lines.push(line); - } else { - // Incomplete - keep in current_line for potential continuation - *current_line = line; - } - } else { - // Middle sentences: emit with markers - let mut line = String::new(); - line.push_str(marker); - line.push_str(trimmed); - line.push_str(marker); - lines.push(line); - } - } +/// Restore an emphasis span to its source form, opening a gap before it when +/// the line it joins needs one. Unlike text elements, a span carries no +/// surrounding whitespace of its own. +fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String) -> String { + if should_insert_space_before_join(current_line) { + current_line.push(' '); } + format!("{marker}{content}{marker}") } /// English break-words used for semantic line break splitting. @@ -4121,6 +4097,66 @@ mod tests { use super::*; + /// `preserves_content` is the last line of defense against a reflow writing + /// corrupted prose into a file, so it has to actually reject the ways a + /// reflow can go wrong - not merely accept the ways it can go right. + #[test] + fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() { + let accepted: &[(&str, &[&str])] = &[ + ("one two three", &["one two three"]), + ("one two three", &["one two", "three"]), + ("one two three", &["one", "two", "three"]), + // Collapsing runs of whitespace and dropping trailing whitespace + ("one two ", &["one two"]), + // A script written without spaces has to break somewhere + ("日本語のテキスト", &["日本語の", "テキスト"]), + // Markers move to the line their content moved to + ("_First. Second._", &["_First.", "Second._"]), + ]; + for (original, reflowed) in accepted { + let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect(); + assert!( + preserves_content(original, &reflowed), + "{original:?} -> {reflowed:?} only moves whitespace" + ); + } + + let rejected: &[(&str, &[&str])] = &[ + // Dropped + ("one two three", &["one two"]), + // Invented + ("one two", &["one two three"]), + // Reordered + ("one two", &["two one"]), + // Duplicated + ("_First. Second._", &["_First._", "_Second._"]), + // Two words glued into one + ("alpha and beta", &["alpha", "andbeta"]), + // A space deleted around punctuation + ("mot suivant : autre", &["mot suivant: autre"]), + ]; + for (original, reflowed) in rejected { + let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect(); + assert!( + !preserves_content(original, &reflowed), + "{original:?} -> {reflowed:?} changes the text, not just its line breaks" + ); + } + } + + /// A rejected reflow leaves the line alone rather than writing the damage. + #[test] + fn reflow_line_falls_back_to_the_input_when_content_would_change() { + let options = ReflowOptions { + line_length: 40, + ..Default::default() + }; + let line = "one two three four five six seven eight nine ten"; + + assert!(preserves_content(line, &reflow_line(line, &options))); + assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options)); + } + #[test] fn cascade_split_line_handles_a_very_long_line_without_overflowing() { // A single line of thousands of words once drove `cascade_split_line` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/tests/formats/sentence_per_line_test.rs new/rumdl-0.2.45/tests/formats/sentence_per_line_test.rs --- old/rumdl-0.2.44/tests/formats/sentence_per_line_test.rs 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/tests/formats/sentence_per_line_test.rs 2026-07-28 20:41:14.000000000 +0200 @@ -864,22 +864,12 @@ #[test] fn test_emphasis_wrapping_multiple_sentences() { - // Test case from issue #360 - emphasis spanning multiple sentences + // Emphasis spanning multiple sentences let rule = create_sentence_per_line_rule(); let content = "**First sentence. Second sentence.**\n"; let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None); let result = rule.check(&ctx).unwrap(); - println!("Content: {content:?}"); - println!("Warnings: {:?}", result.len()); - for w in &result { - println!(" Warning: {}", w.message); - if let Some(fix) = &w.fix { - println!(" Fix range: {:?}", fix.range); - println!(" Replacement: {:?}", fix.replacement); - } - } - assert!(!result.is_empty(), "Should detect multiple sentences"); assert!(result[0].fix.is_some(), "Should have fix"); @@ -887,12 +877,14 @@ // The fix should NOT have leading spaces on the second line assert!( - !fix.replacement.contains(" **Second"), + !fix.replacement.contains(" Second"), "Second sentence should not have leading spaces: {:?}", fix.replacement ); - // The expected output is each sentence on its own line with emphasis preserved - let expected = "**First sentence.**\n**Second sentence.**\n"; + // Each sentence gets its own line. The span is not reopened per line: a line + // break inside emphasis is whitespace, and duplicating the markers would + // delete the space the closing marker replaces. + let expected = "**First sentence.\nSecond sentence.**\n"; assert_eq!(fix.replacement, expected, "Fix should produce correct output"); } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/tests/regressions/md013_reflow_emphasis_content_loss_issue_767_test.rs new/rumdl-0.2.45/tests/regressions/md013_reflow_emphasis_content_loss_issue_767_test.rs --- old/rumdl-0.2.44/tests/regressions/md013_reflow_emphasis_content_loss_issue_767_test.rs 1970-01-01 01:00:00.000000000 +0100 +++ new/rumdl-0.2.45/tests/regressions/md013_reflow_emphasis_content_loss_issue_767_test.rs 2026-07-28 20:41:14.000000000 +0200 @@ -0,0 +1,213 @@ +//! Regression tests for issues #767 and #768: MD013 reflow deleted words when a +//! sentence boundary fell inside an emphasis span. +//! +//! Reflow reopened the span on each new line, and the marker it wrote consumed +//! the space that separated the sentences, so the surrounding words were +//! swallowed. A quoted, emphasized paragraph could lose half its text. +//! +//! Every test runs the real `rumdl fmt` pipeline. The core assertion is the +//! invariant reflow must never break: it chooses where lines end, so the output +//! holds exactly the input's non-whitespace characters, in order. + +use std::fs; +use std::path::Path; +use tempfile::TempDir; + +const SENTENCE_PER_LINE: &[&str] = &[ + "MD013.reflow = true", + "MD013.reflow-mode = \"sentence-per-line\"", + "MD013.line-length = 0", +]; + +const SEMANTIC_LINE_BREAKS: &[&str] = &[ + "MD013.reflow = true", + "MD013.reflow-mode = \"semantic-line-breaks\"", + "MD013.line-length = 80", +]; + +/// Run `rumdl fmt` on `content` and return the formatted file. +fn fmt(dir: &Path, name: &str, settings: &[&str], content: &str) -> String { + let file_path = dir.join(name); + fs::write(&file_path, content).unwrap(); + + let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_rumdl")); + command.arg("fmt").arg("--no-config").arg("--no-cache"); + for setting in settings { + command.arg("-c").arg(setting); + } + + let output = command.arg(&file_path).output().expect("Failed to execute rumdl"); + let status = output.status.code(); + assert!( + status == Some(0) || status == Some(1), + "rumdl fmt should succeed, got status {status:?}; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + fs::read_to_string(&file_path).unwrap() +} + +/// The document's content: every non-whitespace character except the blockquote +/// markers, which are structure. Splitting a quoted paragraph legitimately adds +/// a `>` prefix to each new line. +fn content_of(markdown: &str) -> String { + markdown + .lines() + .map(|line| line.trim_start().trim_start_matches(['>', ' ', '\t'])) + .flat_map(|line| line.chars()) + .filter(|c| !c.is_whitespace()) + .collect() +} + +/// Format `input` twice, asserting reflow changed only whitespace and converged. +fn assert_reflow_preserves(name: &str, settings: &[&str], input: &str) -> String { + let temp = TempDir::new().unwrap(); + let once = fmt(temp.path(), "a.md", settings, input); + let twice = fmt(temp.path(), "b.md", settings, &once); + + assert_eq!( + content_of(input), + content_of(&once), + "{name}: reflow changed content, not just line breaks.\ninput:\n{input}\ngot:\n{once}" + ); + assert_eq!( + once, twice, + "{name}: reflow must be idempotent.\nfirst:\n{once}\nsecond:\n{twice}" + ); + once +} + +/// The reporter's table from #767: an emphasized, quoted paragraph in every +/// marker style and every container. Each one lost words before the fix. +#[test] +fn test_emphasis_spanning_sentences_keeps_every_word() { + let cases: &[(&str, &str)] = &[ + ( + "plain", + r#""The reviewers called it "a solid contribution." The paper was accepted.""#, + ), + ( + "italic", + "_The paper was accepted without revision. Publication follows in March._", + ), + ( + "italic_parens", + "_The paper was accepted (after one revision.) Publication follows in March._", + ), + ( + "italic_code", + "_The paper was accepted `after one revision.` Publication follows in March._", + ), + ( + "italic_quote", + r#"_The reviewers called it "a solid contribution." The paper was accepted._"#, + ), + ( + "italic_single_quote", + "_The reviewers called it 'a solid contribution.' The paper was accepted._", + ), + ( + "italic_leading_quote", + r#"_"A solid contribution." The paper was accepted without revision._"#, + ), + ( + "italic_nested_quotes", + r#"_"The reviewers called it "a solid contribution." The paper was accepted."_"#, + ), + ( + "bold_nested_quotes", + r#"**"The reviewers called it "a solid contribution." The paper was accepted."**"#, + ), + ( + "three_sentences", + r#"_The reviewers called it "a solid contribution." The paper was accepted. Publication follows in March._"#, + ), + ( + "two_spans", + r#"_"She said "yes." The vote carried."_ and _"He said "no." The motion failed."_"#, + ), + ( + "list_item", + r#"- _"The reviewers called it "a solid contribution." The paper was accepted."_"#, + ), + ( + "nested_list", + "- outer\n - _\"The reviewers called it \"a solid contribution.\" The paper was accepted.\"_", + ), + ( + "blockquote", + r#"> _"The reviewers called it "a solid contribution." The paper was accepted."_"#, + ), + ( + "strikethrough", + r#"~~"The reviewers called it "a solid contribution." The paper was accepted."~~"#, + ), + ( + "underscore_bold", + r#"__"The reviewers called it "a solid contribution." The paper was accepted."__"#, + ), + ( + "asterisk_italic", + r#"*"The reviewers called it "a solid contribution." The paper was accepted."*"#, + ), + ]; + + for (name, paragraph) in cases { + let input = format!("# Notes\n\n{paragraph}\n"); + assert_reflow_preserves(name, SEMANTIC_LINE_BREAKS, &input); + } +} + +/// The worst case from #767: two emphasized spans on one line, each with nested +/// quotes. Reflow used to reduce this to a fragment of its words. +#[test] +fn test_two_emphasis_spans_on_one_line() { + let input = "# Notes\n\n_\"She said \"yes.\" The vote carried.\"_ and _\"He said \"no.\" The motion failed.\"_\n"; + let fixed = assert_reflow_preserves("two_spans", SEMANTIC_LINE_BREAKS, input); + + for word in ["She said", "The vote carried", "He said", "The motion failed"] { + assert!(fixed.contains(word), "'{word}' must survive reflow. got:\n{fixed}"); + } +} + +/// #768: a bold span inside a list item, followed by two plain sentences. The +/// trailing sentence disappeared entirely. +#[test] +fn test_bold_span_in_list_item_keeps_trailing_sentences() { + let input = "- **A \"Widget vs. Gadget: which do I pick?\" guide** - decision-oriented. \ + Heuristic: pick Widget for one-off jobs, Gadget for repeat jobs. \ + This sentence must survive.\n"; + let fixed = assert_reflow_preserves("list_bold_quote", SENTENCE_PER_LINE, input); + + assert!( + fixed.contains("This sentence must survive."), + "the trailing sentence must survive reflow. got:\n{fixed}" + ); +} + +/// #768: a bold span spanning four sentences inside a blockquote. Each sentence +/// gets its own quoted line, and the span still opens and closes exactly once. +#[test] +fn test_bold_span_in_blockquote_splits_without_reopening() { + let input = "> **Alpha. Beta. Gamma. Delta.**\n"; + let fixed = assert_reflow_preserves("blockquote_bold", SENTENCE_PER_LINE, input); + + assert_eq!(fixed, "> **Alpha.\n> Beta.\n> Gamma.\n> Delta.**\n"); + assert_eq!(fixed.matches("**").count(), 2, "the span must not be reopened per line"); +} + +/// #768: reflow must not indent the lines it creates. +#[test] +fn test_split_lines_are_not_indented() { + let input = "**Choosing a type.**\n\ + Ask one question: *does this affect what users receive?*\n\ + Yes selects one type; no selects the other.\n"; + let fixed = assert_reflow_preserves("leading_spaces", SENTENCE_PER_LINE, input); + + for line in fixed.lines() { + assert!( + !line.starts_with(' '), + "reflow must not indent a line it created. got:\n{fixed}" + ); + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/tests/regressions/md037_multiline_span_false_positive_test.rs new/rumdl-0.2.45/tests/regressions/md037_multiline_span_false_positive_test.rs --- old/rumdl-0.2.44/tests/regressions/md037_multiline_span_false_positive_test.rs 1970-01-01 01:00:00.000000000 +0100 +++ new/rumdl-0.2.45/tests/regressions/md037_multiline_span_false_positive_test.rs 2026-07-28 20:41:14.000000000 +0200 @@ -0,0 +1,98 @@ +//! Regression tests for MD037 on emphasis that spans more than one line. +//! +//! Markers are paired one line at a time. On a line that continues a span, the +//! first marker on that line is really the span's *closing* delimiter, but the +//! line-local pairing read it as an opener, so every marker after it paired one +//! position off. Ordinary words then sat between what looked like delimiters, +//! MD037 reported "spaces inside emphasis markers", and `rumdl fmt` deleted +//! those spaces: `_ and _` became `_and_`, joining two words into one. +//! +//! The document-level parse settles it: a marker already spent closing a span +//! cannot also open one. + +use rumdl_lib::config::MarkdownFlavor; +use rumdl_lib::lint_context::LintContext; +use rumdl_lib::rule::Rule; +use rumdl_lib::rules::MD037NoSpaceInEmphasis; + +fn check(content: &str) -> Vec<String> { + let ctx = LintContext::new(content, MarkdownFlavor::Standard, None); + MD037NoSpaceInEmphasis + .check(&ctx) + .unwrap() + .into_iter() + .map(|w| format!("{}:{} {}", w.line, w.column, w.message)) + .collect() +} + +fn fix(content: &str) -> String { + let ctx = LintContext::new(content, MarkdownFlavor::Standard, None); + MD037NoSpaceInEmphasis.fix(&ctx).unwrap() +} + +/// The failing case: two quoted spans, the first continuing onto a second line. +/// The `and` between the spans was reported as emphasized-with-spaces. +#[test] +fn test_text_between_spans_is_not_flagged_when_a_span_wraps() { + let content = "_\"She said \"yes.\"\nThe vote carried.\"_ and _\"He said \"no.\" The motion failed.\"_\n"; + + assert!( + check(content).is_empty(), + "text between two spans is not emphasis: {:?}", + check(content) + ); + assert_eq!(fix(content), content, "fix must not touch the spaces around 'and'"); +} + +/// Control: the same text on a single line was always correct, so the bug is +/// specific to a span crossing a line boundary. +#[test] +fn test_single_line_control_is_unaffected() { + let content = "_\"She said \"yes.\" The vote carried.\"_ and _\"He said \"no.\" The motion failed.\"_\n"; + assert!(check(content).is_empty(), "{:?}", check(content)); +} + +/// Control: a wrapped span without interior quotes was always correct. +#[test] +fn test_wrapped_span_without_quotes_control() { + let content = "_She said yes.\nThe vote carried._ and _She said no. The motion failed._\n"; + assert!(check(content).is_empty(), "{:?}", check(content)); +} + +/// A genuine violation must still be reported, including on a line that also +/// continues a real span. Suppressing the false positive must not blind the rule. +#[test] +fn test_real_violations_still_reported() { + let plain = "This is * spaced emphasis * text.\n"; + assert_eq!( + check(plain).len(), + 1, + "plain violation must be flagged: {:?}", + check(plain) + ); + + let after_wrapped_span = "_alpha\nbeta._ then * spaced * here\n"; + let warnings = check(after_wrapped_span); + assert_eq!( + warnings.len(), + 1, + "a violation after a wrapped span must still be flagged: {warnings:?}" + ); + assert!(warnings[0].starts_with("2:"), "on the second line: {warnings:?}"); + assert_eq!(fix(after_wrapped_span), "_alpha\nbeta._ then *spaced* here\n"); +} + +/// A span that both opens and closes inside the flagged run is a different +/// shape from a span that only closes inside it, and must stay reported: +/// `* _real_ *` really is emphasis with spaces in it. +#[test] +fn test_span_nested_inside_the_run_is_still_a_violation() { + let content = "Text * _real_ * more.\n"; + assert_eq!( + check(content).len(), + 1, + "spaces inside the outer markers are a violation: {:?}", + check(content) + ); + assert_eq!(fix(content), "Text *_real_* more.\n"); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/tests/regressions/mod.rs new/rumdl-0.2.45/tests/regressions/mod.rs --- old/rumdl-0.2.44/tests/regressions/mod.rs 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/tests/regressions/mod.rs 2026-07-28 20:41:14.000000000 +0200 @@ -14,6 +14,7 @@ mod md009_md013_order_test; mod md013_hard_breaks_test; mod md013_html_test; +mod md013_reflow_emphasis_content_loss_issue_767_test; mod md013_reflow_french_punctuation_issue_740_test; mod md013_reflow_math_blocks_issue_762_test; mod md013_reflow_ordered_lookalike_issue_760_test; @@ -23,6 +24,7 @@ mod md033_edge_cases_test; mod md033_link_img_fix_test; mod md034_parentheses_url_test; +mod md037_multiline_span_false_positive_test; mod md037_xxxx_regression_test; mod md038_false_positive_test; mod md040_blockquote_fence_issue_684_test; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.44/tests/utils/text_reflow_test.rs new/rumdl-0.2.45/tests/utils/text_reflow_test.rs --- old/rumdl-0.2.44/tests/utils/text_reflow_test.rs 2026-07-28 11:12:36.000000000 +0200 +++ new/rumdl-0.2.45/tests/utils/text_reflow_test.rs 2026-07-28 20:41:14.000000000 +0200 @@ -1,6 +1,20 @@ use rumdl_lib::utils::text_reflow::*; use std::time::Instant; +/// Assert that reflow only moved line breaks: the joined output must hold the +/// same non-whitespace characters, in the same order, as the input. +/// +/// Reflow decides where a paragraph breaks, never which characters it contains, +/// so any difference here is content loss or invented markup. +fn assert_whitespace_only(input: &str, result: &[String]) { + let visible = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>(); + assert_eq!( + visible(input), + visible(&result.join("\n")), + "reflow changed content, not just line breaks: {result:?}" + ); +} + #[test] fn test_list_item_trailing_whitespace_removal() { // Test for issue #76 - hard breaks (2 trailing spaces) should be preserved @@ -1843,9 +1857,12 @@ } } -/// Issue #251: Sentence reflow & formatting markers (bold, italic) -/// When reflowing multi-sentence emphasized text, emphasis markers should -/// continue across line breaks to maintain formatting on each line. +/// Sentence reflow inside emphasis (bold, italic, strikethrough). +/// +/// A multi-sentence emphasized span splits at its sentence boundaries like any +/// other text. The span itself is left alone: a line break inside emphasis is +/// whitespace, so the markers stay where the author put them and the rendered +/// output is unchanged. Reflow moves line breaks and nothing else. mod issue_251_emphasis_continuation { use super::*; @@ -1922,23 +1939,12 @@ let input = "*Sentence one. Sentence two. Sentence three.*"; let result = reflow_line(input, &options); - // Each sentence should have its own italic markers - assert_eq!(result.len(), 3, "Should have 3 lines: {result:?}"); - assert!( - result[0].starts_with('*') && result[0].ends_with('*'), - "First line should have italic markers: {:?}", - result[0] - ); - assert!( - result[1].starts_with('*') && result[1].ends_with('*'), - "Second line should have italic markers: {:?}", - result[1] - ); - assert!( - result[2].starts_with('*') && result[2].ends_with('*'), - "Third line should have italic markers: {:?}", - result[2] + assert_eq!( + result, + vec!["*Sentence one.", "Sentence two.", "Sentence three.*"], + "Should split at sentence boundaries and leave the span intact" ); + assert_whitespace_only(input, &result); } #[test] @@ -1952,23 +1958,12 @@ let input = "_Sentence one. Sentence two. Sentence three._"; let result = reflow_line(input, &options); - // Each sentence should have its own italic markers (underscore style) - assert_eq!(result.len(), 3, "Should have 3 lines: {result:?}"); - assert!( - result[0].starts_with('_') && result[0].ends_with('_'), - "First line should have underscore markers: {:?}", - result[0] - ); - assert!( - result[1].starts_with('_') && result[1].ends_with('_'), - "Second line should have underscore markers: {:?}", - result[1] - ); - assert!( - result[2].starts_with('_') && result[2].ends_with('_'), - "Third line should have underscore markers: {:?}", - result[2] + assert_eq!( + result, + vec!["_Sentence one.", "Sentence two.", "Sentence three._"], + "Should split at sentence boundaries and leave the span intact" ); + assert_whitespace_only(input, &result); } #[test] @@ -1982,18 +1977,12 @@ let input = "**Sentence one. Sentence two.**"; let result = reflow_line(input, &options); - // Each sentence should have its own bold markers - assert_eq!(result.len(), 2, "Should have 2 lines: {result:?}"); - assert!( - result[0].starts_with("**") && result[0].ends_with("**"), - "First line should have bold markers: {:?}", - result[0] - ); - assert!( - result[1].starts_with("**") && result[1].ends_with("**"), - "Second line should have bold markers: {:?}", - result[1] + assert_eq!( + result, + vec!["**Sentence one.", "Sentence two.**"], + "Should split at the sentence boundary and leave the span intact" ); + assert_whitespace_only(input, &result); } #[test] @@ -2007,18 +1996,12 @@ let input = "__Sentence one. Sentence two.__"; let result = reflow_line(input, &options); - // Each sentence should have its own bold markers (underscore style) - assert_eq!(result.len(), 2, "Should have 2 lines: {result:?}"); - assert!( - result[0].starts_with("__") && result[0].ends_with("__"), - "First line should have underscore bold markers: {:?}", - result[0] - ); - assert!( - result[1].starts_with("__") && result[1].ends_with("__"), - "Second line should have underscore bold markers: {:?}", - result[1] + assert_eq!( + result, + vec!["__Sentence one.", "Sentence two.__"], + "Should split at the sentence boundary and leave the span intact" ); + assert_whitespace_only(input, &result); } // ============================================================ @@ -2033,41 +2016,22 @@ ..Default::default() }; - // The exact pattern from issue #251 + // A quoted, emphasized citation spanning three sentences let input = r#"He said, _"There is this whole spectrum of crazy futures. But the one that I feel we're almost guaranteed to get. It's the same either way"_ [^ref]."#; let result = reflow_markdown(input, &options); - let lines: Vec<&str> = result.lines().collect(); + let lines: Vec<String> = result.lines().map(str::to_string).collect(); - // Should split into multiple sentences, each with emphasis markers - assert!( - lines.len() >= 3, - "Should have at least 3 lines for 3 sentences: {result:?}" - ); - - // First line should start with context and have opening emphasis - assert!( - lines[0].contains("_\"There is this whole spectrum"), - "First line should have opening quote with emphasis: {:?}", - lines[0] - ); - - // Middle lines should have emphasis markers on both ends - for line in &lines[1..lines.len() - 1] { - if !line.trim().is_empty() && !line.starts_with("He said") { - assert!( - line.trim().starts_with('_') || line.contains("_\""), - "Middle line should start with emphasis: {line:?}" - ); - } - } - - // Last line should have closing emphasis with quote and footnote - let last_line = lines.last().unwrap(); - assert!( - last_line.contains("\"_") || last_line.ends_with('_'), - "Last line should have closing emphasis: {last_line:?}" + assert_eq!( + lines, + vec![ + r#"He said, _"There is this whole spectrum of crazy futures."#, + "But the one that I feel we're almost guaranteed to get.", + r#"It's the same either way"_ [^ref]."#, + ], + "Each sentence gets its own line; the emphasis opens once and closes once" ); + assert_whitespace_only(input, &lines); } #[test] @@ -2078,25 +2042,16 @@ ..Default::default() }; - // Simplified version of issue #251 + // A quoted, emphasized pair of sentences let input = r#"_"First sentence. Second sentence."_"#; let result = reflow_line(input, &options); - assert_eq!(result.len(), 2, "Should have 2 lines: {result:?}"); - - // First sentence should have opening quote inside emphasis - assert!( - result[0].starts_with("_\"") && result[0].ends_with('_'), - "First line: {:?}", - result[0] - ); - - // Second sentence should have closing quote inside emphasis - assert!( - result[1].starts_with('_') && result[1].ends_with("\"_"), - "Second line: {:?}", - result[1] + assert_eq!( + result, + vec![r#"_"First sentence."#, r#"Second sentence."_"#], + "The quote and the emphasis each open once and close once" ); + assert_whitespace_only(input, &result); } // ============================================================ @@ -2168,19 +2123,12 @@ ..Default::default() }; - // Bold text containing sentences - each should get markers + // Bold text containing two sentences let input = "**First bold sentence. Second bold sentence.**"; let result = reflow_line(input, &options); - assert_eq!(result.len(), 2, "Should have 2 lines: {result:?}"); - - // Each line should have bold markers - for (i, line) in result.iter().enumerate() { - assert!( - line.starts_with("**") && line.ends_with("**"), - "Line {i} should have bold markers: {line:?}" - ); - } + assert_eq!(result, vec!["**First bold sentence.", "Second bold sentence.**"]); + assert_whitespace_only(input, &result); } #[test] @@ -2239,16 +2187,14 @@ let input = "*Sentence one. Sentence two.*"; let result = reflow_line(input, &options); - // All markers should be asterisks, not underscores + // The author's asterisks survive verbatim; none are rewritten or added + assert_eq!(result, vec!["*Sentence one.", "Sentence two.*"]); + assert_whitespace_only(input, &result); for line in &result { assert!( !line.contains('_'), "Asterisk emphasis should not become underscore: {line:?}" ); - assert!( - line.starts_with('*') && line.ends_with('*'), - "Should use asterisk markers: {line:?}" - ); } } @@ -2263,13 +2209,13 @@ let input = "_Sentence one. Sentence two._"; let result = reflow_line(input, &options); - // All markers should be underscores, not asterisks + // The author's underscores survive verbatim; none are rewritten or added + assert_eq!(result, vec!["_Sentence one.", "Sentence two._"]); + assert_whitespace_only(input, &result); for line in &result { - // Check that we don't have asterisks acting as emphasis markers - // (asterisks in content are OK, but the wrapper should be underscore) assert!( - line.starts_with('_') && line.ends_with('_'), - "Should use underscore markers: {line:?}" + !line.contains('*'), + "Underscore emphasis should not become asterisk: {line:?}" ); } } @@ -2691,16 +2637,15 @@ let input = format!("*{long_sentence}*"); let result = reflow_line(&input, &options); - // Should split into 50 sentences + // Should split into 50 sentences, with the single span still opening on + // the first line and closing on the last assert_eq!(result.len(), 50, "Should have 50 sentences"); - - // Each line should have emphasis markers - for line in &result { - assert!( - line.starts_with('*') && line.ends_with('*'), - "Each line should have emphasis: {line}" - ); + assert_eq!(result[0], "*This is a sentence."); + assert_eq!(result[49], "This is a sentence.*"); + for line in &result[1..49] { + assert_eq!(line, "This is a sentence.", "Interior lines carry no markers"); } + assert_whitespace_only(&input, &result); } #[test] @@ -3576,10 +3521,9 @@ let input = "**First sentence. Second sentence.**"; let result = reflow_line(input, &options); - // Should split into two lines, each with its own emphasis markers - assert_eq!(result.len(), 2, "Should produce 2 lines: {result:?}"); - assert_eq!(result[0], "**First sentence.**"); - assert_eq!(result[1], "**Second sentence.**"); + // Should split at the sentence boundary, leaving the single span intact + assert_eq!(result, vec!["**First sentence.", "Second sentence.**"]); + assert_whitespace_only(input, &result); // Idempotency check: reflowing the result again should produce the same output // This was the bug - the second reflow would add a leading space @@ -3610,11 +3554,11 @@ let test_cases = vec![ // (input, expected_first_line, expected_second_line) - ("**Bold one. Bold two.**", "**Bold one.**", "**Bold two.**"), - ("*Italic one. Italic two.*", "*Italic one.*", "*Italic two.*"), - ("~~Strike one. Strike two.~~", "~~Strike one.~~", "~~Strike two.~~"), - ("__Bold underscore. Second.__", "__Bold underscore.__", "__Second.__"), - ("_Italic underscore. Second._", "_Italic underscore._", "_Second._"), + ("**Bold one. Bold two.**", "**Bold one.", "Bold two.**"), + ("*Italic one. Italic two.*", "*Italic one.", "Italic two.*"), + ("~~Strike one. Strike two.~~", "~~Strike one.", "Strike two.~~"), + ("__Bold underscore. Second.__", "__Bold underscore.", "Second.__"), + ("_Italic underscore. Second._", "_Italic underscore.", "Second._"), ]; for (input, expected_first, expected_second) in test_cases { @@ -3622,6 +3566,7 @@ assert_eq!(result.len(), 2, "Input {input:?} should produce 2 lines: {result:?}"); assert_eq!(result[0], expected_first, "First line mismatch for {input:?}"); assert_eq!(result[1], expected_second, "Second line mismatch for {input:?}"); + assert_whitespace_only(input, &result); // Idempotency: reflow the result and verify it's unchanged let joined = result.join("\n"); ++++++ rumdl.obsinfo ++++++ --- /var/tmp/diff_new_pack.rCFS8M/_old 2026-07-29 19:02:30.536389080 +0200 +++ /var/tmp/diff_new_pack.rCFS8M/_new 2026-07-29 19:02:30.540389218 +0200 @@ -1,5 +1,5 @@ name: rumdl -version: 0.2.44 -mtime: 1785229956 -commit: f4ec7f95f17043a4ddfeedeafef49a9f698e3845 +version: 0.2.45 +mtime: 1785264074 +commit: 0610735e0d36bc1cfd5a77cc720c08132c241182 ++++++ vendor.tar.zst ++++++ /work/SRC/openSUSE:Factory/rumdl/vendor.tar.zst /work/SRC/openSUSE:Factory/.rumdl.new.2004/vendor.tar.zst differ: char 7, line 1
