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-10 17:42:55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/rumdl (Old) and /work/SRC/openSUSE:Factory/.rumdl.new.1991 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "rumdl" Fri Jul 10 17:42:55 2026 rev:85 rq:1364821 version:0.2.30 Changes: -------- --- /work/SRC/openSUSE:Factory/rumdl/rumdl.changes 2026-07-03 16:11:54.263822462 +0200 +++ /work/SRC/openSUSE:Factory/.rumdl.new.1991/rumdl.changes 2026-07-10 17:45:23.477586177 +0200 @@ -1,0 +2,17 @@ +Fri Jul 10 05:02:48 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.2.30: + * Added + - MD007: clamp explicit fixed-style indent under ordered + parents (1f3a32d) + * Fixed + - lint-context: keep lazy continuation lines in their list + block (a17f0af) +- Update to version 0.2.29: + * Fixed + - lint-context: keep indented continuation lines when a + different list type follows (5dd3c3f) + - MD013: treat tabs and extra spaces after a list marker as + marker padding (f0f97a2) + +------------------------------------------------------------------- Old: ---- rumdl-0.2.28.obscpio New: ---- rumdl-0.2.30.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ rumdl.spec ++++++ --- /var/tmp/diff_new_pack.ygU0rn/_old 2026-07-10 17:45:31.973877217 +0200 +++ /var/tmp/diff_new_pack.ygU0rn/_new 2026-07-10 17:45:31.977877355 +0200 @@ -17,7 +17,7 @@ Name: rumdl -Version: 0.2.28 +Version: 0.2.30 Release: 0 Summary: Markdown Linter written in Rust License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.ygU0rn/_old 2026-07-10 17:45:32.029879136 +0200 +++ /var/tmp/diff_new_pack.ygU0rn/_new 2026-07-10 17:45:32.033879273 +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.28</param> + <param name="revision">refs/tags/v0.2.30</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.ygU0rn/_old 2026-07-10 17:45:32.061880232 +0200 +++ /var/tmp/diff_new_pack.ygU0rn/_new 2026-07-10 17:45:32.065880370 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/rvben/rumdl.git</param> - <param name="changesrevision">c5f50cbbbc790d7adb5583b51e4322ed3dcae65c</param></service></servicedata> + <param name="changesrevision">12e34feb01c07d62a8161b5ac74940ffe8954c21</param></service></servicedata> (No newline at EOF) ++++++ rumdl-0.2.28.obscpio -> rumdl-0.2.30.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/.github/workflows/release.yml new/rumdl-0.2.30/.github/workflows/release.yml --- old/rumdl-0.2.28/.github/workflows/release.yml 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/.github/workflows/release.yml 2026-07-09 19:39:55.000000000 +0200 @@ -662,10 +662,26 @@ UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} run: | # Upload only Python packages (wheels and sdist), not Homebrew archives. - # --check-url skips files already present on PyPI so re-runs stay - # idempotent (the equivalent of twine's --skip-existing). - uv publish --check-url https://pypi.org/simple/rumdl/ \ - artifacts/wheel-*/*.whl artifacts/sdist/*.tar.gz + # --check-url skips a file only when its bytes already exist on PyPI, so + # re-running the publish job with the SAME artifacts is idempotent. A + # re-run that REBUILT the artifacts produces different bytes (Rust + # builds are not reproducible), uv then attempts the upload, and PyPI + # rejects the filename collision with "400 File already exists". + # Publish per file and tolerate that rejection, the way the crates.io + # and npm steps do, so a re-run keeps going and still uploads any file + # PyPI does not have yet. PyPI keeps the first upload of a filename. + status=0 + for f in artifacts/wheel-*/*.whl artifacts/sdist/*.tar.gz; do + if output=$(uv publish --check-url https://pypi.org/simple/rumdl/ "$f" 2>&1); then + echo "$output" + elif echo "$output" | grep -qi "already exists"; then + echo "⏭️ $(basename "$f") already on PyPI (first upload wins), skipping" + else + echo "$output" + status=1 + fi + done + exit $status - name: Test PyPI upload (dry run) if: ${{ inputs.dry_run == true && inputs.skip_pypi != true }} @@ -694,10 +710,14 @@ node-version: '24' registry-url: 'https://registry.npmjs.org' + # Pinned, not @latest: npm 12.0.0 ships a provenance path that fails with + # "Cannot find module 'sigstore'", so `npm publish --provenance` aborts. + # An unpinned upgrade puts every release at the mercy of the newest npm. + # 11.18.0 is the last 11.x and supports the OIDC publishing this needs. - name: Upgrade npm for OIDC support if: ${{ inputs.skip_npm != true }} run: | - npm install -g npm@latest + npm install -g [email protected] echo "npm version: $(npm --version)" - name: Publish WASM to npm diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/.lychee.toml new/rumdl-0.2.30/.lychee.toml --- old/rumdl-0.2.28/.lychee.toml 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/.lychee.toml 2026-07-09 19:39:55.000000000 +0200 @@ -21,7 +21,11 @@ # Exclude URLs from checking (regex). npmjs.com returns 403 to automated link # checkers due to bot protection; the package page is valid and is linked from # the stability docs alongside crates.io and PyPI. -exclude = ['^https://www\.npmjs\.com/'] +# +# GitHub returns 404 on /stargazers to non-browser clients, with or without a +# token; github.com/rust-lang/rust/stargazers behaves identically. The stars +# badge in README.md links there and resolves fine in a browser. +exclude = ['^https://www\.npmjs\.com/', '^https://github\.com/.+/stargazers$'] # Timeout per request in seconds timeout = 30 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/CHANGELOG.md new/rumdl-0.2.30/CHANGELOG.md --- old/rumdl-0.2.28/CHANGELOG.md 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/CHANGELOG.md 2026-07-09 19:39:55.000000000 +0200 @@ -68,6 +68,25 @@ + + +## [0.2.30](https://github.com/rvben/rumdl/compare/v0.2.29...v0.2.30) - 2026-07-09 + +### Added + +- **MD007**: clamp explicit fixed-style indent under ordered parents ([1f3a32d](https://github.com/rvben/rumdl/commit/1f3a32df48f8983fc49fafbc6d6a00743dda763b)) + +### Fixed + +- **lint-context**: keep lazy continuation lines in their list block ([a17f0af](https://github.com/rvben/rumdl/commit/a17f0af7b7d9c2ab71b7416472f05bb00f928d96)) + +## [0.2.29](https://github.com/rvben/rumdl/compare/v0.2.28...v0.2.29) - 2026-07-09 + +### Fixed + +- **lint-context**: keep indented continuation lines when a different list type follows ([5dd3c3f](https://github.com/rvben/rumdl/commit/5dd3c3fcdfef353ce8f0aaf575e704ce3754d2fc)) +- **MD013**: treat tabs and extra spaces after a list marker as marker padding ([f0f97a2](https://github.com/rvben/rumdl/commit/f0f97a29a3d768f8728d5650c63ccb1046cc279c)) + ## [0.2.28](https://github.com/rvben/rumdl/compare/v0.2.27...v0.2.28) - 2026-07-03 ### Added diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/Cargo.lock new/rumdl-0.2.30/Cargo.lock --- old/rumdl-0.2.28/Cargo.lock 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/Cargo.lock 2026-07-09 19:39:55.000000000 +0200 @@ -2283,7 +2283,7 @@ [[package]] name = "rumdl" -version = "0.2.28" +version = "0.2.30" dependencies = [ "anyhow", "assert_cmd", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/Cargo.toml new/rumdl-0.2.30/Cargo.toml --- old/rumdl-0.2.28/Cargo.toml 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/Cargo.toml 2026-07-09 19:39:55.000000000 +0200 @@ -1,6 +1,6 @@ [package] name = "rumdl" -version = "0.2.28" +version = "0.2.30" 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.28/README.md new/rumdl-0.2.30/README.md --- old/rumdl-0.2.28/README.md 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/README.md 2026-07-09 19:39:55.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.28 + rev: v0.2.30 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.28 + rev: v0.2.30 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.28 + rev: v0.2.30 hooks: - id: rumdl args: [--no-exclude] # Disable all exclude patterns diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/docs/getting-started/quickstart.md new/rumdl-0.2.30/docs/getting-started/quickstart.md --- old/rumdl-0.2.28/docs/getting-started/quickstart.md 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/docs/getting-started/quickstart.md 2026-07-09 19:39:55.000000000 +0200 @@ -96,7 +96,7 @@ ```yaml repos: - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.28 # Use latest version + rev: v0.2.30 # Use latest version hooks: - id: rumdl ``` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/docs/global-settings.md new/rumdl-0.2.30/docs/global-settings.md --- old/rumdl-0.2.28/docs/global-settings.md 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/docs/global-settings.md 2026-07-09 19:39:55.000000000 +0200 @@ -1376,7 +1376,7 @@ ```yaml - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.28 + rev: v0.2.30 hooks: - id: rumdl args: [--config=.rumdl.toml] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/docs/md007.md new/rumdl-0.2.30/docs/md007.md --- old/rumdl-0.2.28/docs/md007.md 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/docs/md007.md 2026-07-09 19:39:55.000000000 +0200 @@ -89,9 +89,12 @@ #### Mixed Lists: Ordered and Unordered -When you have bullets nested under ordered lists, the bullets **always use text-aligned** positioning, -even if you've set a specific `indent` value. This is because ordered list markers have variable -widths (`1.` vs `10.` vs `100.`), making fixed indentation impractical. +When you have bullets nested under ordered lists and `style` is not explicitly `"fixed"`, the +bullets **use text-aligned** positioning, even if you've set a specific `indent` value. This is +because ordered list markers have variable widths (`1.` vs `10.` vs `100.`), making fixed +indentation impractical. With an explicit `style = "fixed"`, bullets under ordered lists are +checked against the fixed indent clamped to the parent's content column (see the `fixed` style +below). ```toml [MD007] @@ -148,6 +151,12 @@ - You prefer strict mathematical multiples - Your team uses markdownlint and you want consistency +**Bullets under ordered lists are clamped.** A bullet cannot sit to the left of its parent's +content column without leaving the item, so under an ordered parent the expected indent is the +larger of the fixed value and the parent's content column. With `indent = 2` under `1. `, the +fixed value would be 2 but the content column is 3, so 3 is expected; deeper levels build on the +corrected position of their parent. This keeps autofixes from restructuring the document. + #### Example: Difference between styles With a 3-character marker like `1.`: @@ -164,8 +173,16 @@ ```markdown 1. First item - * Child at 2 spaces (indent × 1) - * Grandchild at 4 spaces (indent × 2) + * Child at 3 spaces (fixed 2 clamped to the parent's content column) + * Grandchild at 5 spaces (fixed 4 clamped to the child's content column) +``` + +**fixed (indent = 4):** + +```markdown +1. First item + * Child at 4 spaces (indent × 1, already past the content column) + * Grandchild at 8 spaces (indent × 2) ``` ### Example with start-indented: true diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/docs/mdformat-comparison.md new/rumdl-0.2.30/docs/mdformat-comparison.md --- old/rumdl-0.2.28/docs/mdformat-comparison.md 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/docs/mdformat-comparison.md 2026-07-09 19:39:55.000000000 +0200 @@ -232,7 +232,7 @@ # After - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.28 + rev: v0.2.30 hooks: - id: rumdl ``` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/docs/usage/ci-cd.md new/rumdl-0.2.30/docs/usage/ci-cd.md --- old/rumdl-0.2.28/docs/usage/ci-cd.md 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/docs/usage/ci-cd.md 2026-07-09 19:39:55.000000000 +0200 @@ -40,7 +40,7 @@ ```yaml - uses: rvben/rumdl@v0 with: - version: "0.2.28" + version: "0.2.30" path: docs/ ``` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/docs/usage/pre-commit.md new/rumdl-0.2.30/docs/usage/pre-commit.md --- old/rumdl-0.2.28/docs/usage/pre-commit.md 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/docs/usage/pre-commit.md 2026-07-09 19:39:55.000000000 +0200 @@ -13,7 +13,7 @@ ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.28 # Use latest version + rev: v0.2.30 # 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.28/npm/cli-darwin-arm64/package.json new/rumdl-0.2.30/npm/cli-darwin-arm64/package.json --- old/rumdl-0.2.28/npm/cli-darwin-arm64/package.json 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/npm/cli-darwin-arm64/package.json 2026-07-09 19:39:55.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-darwin-arm64", - "version": "0.2.28", + "version": "0.2.30", "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.28/npm/cli-darwin-x64/package.json new/rumdl-0.2.30/npm/cli-darwin-x64/package.json --- old/rumdl-0.2.28/npm/cli-darwin-x64/package.json 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/npm/cli-darwin-x64/package.json 2026-07-09 19:39:55.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-darwin-x64", - "version": "0.2.28", + "version": "0.2.30", "description": "rumdl binary for macOS x64 (Intel)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/npm/cli-linux-arm64/package.json new/rumdl-0.2.30/npm/cli-linux-arm64/package.json --- old/rumdl-0.2.28/npm/cli-linux-arm64/package.json 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/npm/cli-linux-arm64/package.json 2026-07-09 19:39:55.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-arm64", - "version": "0.2.28", + "version": "0.2.30", "description": "rumdl binary for Linux ARM64 (glibc)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/npm/cli-linux-arm64-musl/package.json new/rumdl-0.2.30/npm/cli-linux-arm64-musl/package.json --- old/rumdl-0.2.28/npm/cli-linux-arm64-musl/package.json 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/npm/cli-linux-arm64-musl/package.json 2026-07-09 19:39:55.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-arm64-musl", - "version": "0.2.28", + "version": "0.2.30", "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.28/npm/cli-linux-x64/package.json new/rumdl-0.2.30/npm/cli-linux-x64/package.json --- old/rumdl-0.2.28/npm/cli-linux-x64/package.json 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/npm/cli-linux-x64/package.json 2026-07-09 19:39:55.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-x64", - "version": "0.2.28", + "version": "0.2.30", "description": "rumdl binary for Linux x64 (glibc)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/npm/cli-linux-x64-musl/package.json new/rumdl-0.2.30/npm/cli-linux-x64-musl/package.json --- old/rumdl-0.2.28/npm/cli-linux-x64-musl/package.json 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/npm/cli-linux-x64-musl/package.json 2026-07-09 19:39:55.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-x64-musl", - "version": "0.2.28", + "version": "0.2.30", "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.28/npm/cli-win32-x64/package.json new/rumdl-0.2.30/npm/cli-win32-x64/package.json --- old/rumdl-0.2.28/npm/cli-win32-x64/package.json 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/npm/cli-win32-x64/package.json 2026-07-09 19:39:55.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-win32-x64", - "version": "0.2.28", + "version": "0.2.30", "description": "rumdl binary for Windows x64", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/npm/rumdl/package.json new/rumdl-0.2.30/npm/rumdl/package.json --- old/rumdl-0.2.28/npm/rumdl/package.json 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/npm/rumdl/package.json 2026-07-09 19:39:55.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "rumdl", - "version": "0.2.28", + "version": "0.2.30", "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.28", - "@rumdl/cli-darwin-arm64": "0.2.28", - "@rumdl/cli-linux-x64": "0.2.28", - "@rumdl/cli-linux-arm64": "0.2.28", - "@rumdl/cli-linux-x64-musl": "0.2.28", - "@rumdl/cli-linux-arm64-musl": "0.2.28", - "@rumdl/cli-win32-x64": "0.2.28" + "@rumdl/cli-darwin-x64": "0.2.30", + "@rumdl/cli-darwin-arm64": "0.2.30", + "@rumdl/cli-linux-x64": "0.2.30", + "@rumdl/cli-linux-arm64": "0.2.30", + "@rumdl/cli-linux-x64-musl": "0.2.30", + "@rumdl/cli-linux-arm64-musl": "0.2.30", + "@rumdl/cli-win32-x64": "0.2.30" } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/src/lint_context/list_blocks.rs new/rumdl-0.2.30/src/lint_context/list_blocks.rs --- old/rumdl-0.2.28/src/lint_context/list_blocks.rs 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/src/lint_context/list_blocks.rs 2026-07-09 19:39:55.000000000 +0200 @@ -358,16 +358,10 @@ &mut min_continuation_for_tracking, ); } else { - // End current block and start a new one - // When a different list type starts AT THE SAME LEVEL (not nested), - // trim back lazy continuation lines - if !same_type - && !is_nested - && let Some(&last_item) = block.item_lines.last() - { - block.end_line = last_item; - } - + // End current block and start a new one. The block keeps every + // continuation line it collected, including lazy (column 0) ones: + // per CommonMark those continue the item's paragraph, so cutting + // them off would make MD032 insert its blank line inside the item. let new_block = ListBlock { start_line: line_num, end_line: line_num, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/src/rules/md007_ul_indent.rs new/rumdl-0.2.30/src/rules/md007_ul_indent.rs --- old/rumdl-0.2.28/src/rules/md007_ul_indent.rs 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/src/rules/md007_ul_indent.rs 2026-07-09 19:39:55.000000000 +0200 @@ -117,10 +117,10 @@ fn terminate_closed_items( ctx: &crate::lint_context::LintContext, line_info: &crate::lint_context::LineInfo, - list_stack: &mut Vec<(usize, usize, bool, usize, usize, bool)>, + list_stack: &mut Vec<(usize, usize, bool, usize, usize, bool, usize)>, line_bq_depth: usize, ) { - while let Some(&(_, _, _, content_col, item_bq_depth, _)) = list_stack.last() { + while let Some(&(_, _, _, content_col, item_bq_depth, _, _)) = list_stack.last() { let closed = match item_bq_depth.cmp(&line_bq_depth) { // The line has exited a deeper blockquote the item lived in. std::cmp::Ordering::Greater => true, @@ -230,7 +230,7 @@ fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult { let mut warnings = Vec::new(); - let mut list_stack: Vec<(usize, usize, bool, usize, usize, bool)> = Vec::new(); // Stack of (marker_visual_col, line_num, is_ordered, content_visual_col, blockquote_depth, exempt) for tracking nesting. `exempt` marks an unordered item that inherited the ordered-ancestor MD007 exemption. + let mut list_stack: Vec<(usize, usize, bool, usize, usize, bool, usize)> = Vec::new(); // Stack of (marker_visual_col, line_num, is_ordered, content_visual_col, blockquote_depth, chain, source_content_visual_col) for tracking nesting. `chain` marks an unordered item that inherited the ordered-ancestor MD007 exemption or was checked under the fixed-style clamp. `content_visual_col` is the corrected (post-fix) column that expectation math builds on; `source_content_visual_col` is the column as written, which containment is judged against. for (line_idx, line_info) in ctx.lines.iter().enumerate() { // Skip if this line is in a code block, front matter, or mkdocstrings @@ -356,7 +356,7 @@ // Clean up stack - remove items at same or deeper indentation, // but only consider items at the same blockquote depth - while let Some(&(indent, _, _, _, item_bq_depth, _)) = list_stack.last() { + while let Some(&(indent, _, _, _, item_bq_depth, _, _)) = list_stack.last() { if item_bq_depth == bq_depth && indent >= visual_marker_for_nesting { list_stack.pop(); } else if item_bq_depth > bq_depth { @@ -379,7 +379,7 @@ // A deeper quote indented into the ancestor's content is part of that // item and keeps it open. Same-depth nesting and items already inside a // blockquote are left to the loop above and the exemption check below. - while let Some(&(_, _, _, content_col, item_bq_depth, _)) = list_stack.last() { + while let Some(&(_, _, _, content_col, item_bq_depth, _, _)) = list_stack.last() { if item_bq_depth < bq_depth && content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth) { @@ -400,6 +400,7 @@ visual_content_column, bq_depth, false, + visual_content_column, )); continue; } @@ -430,12 +431,44 @@ let threshold_ok = list_stack .iter() .any(|item| item.4 == bq_depth && item.2 && item.3 <= visual_marker_column); + // The marker-based cleanup above keeps a list item whose marker sits + // between the previous item's marker and its content column, even + // though per CommonMark such an item is the previous one's sibling, + // not its child. Harmless under the exemption, but the clamp would + // then resolve against the too-deep parent and its fix would rewrite + // the sibling into a child (and contradict MD005). Before judging the + // chain, pop every trailing item whose source content column this + // marker sits left of, ordered ones included: with nested ordered + // lists, threshold_ok can hold via an outer ordered ancestor while + // the innermost ordered item does not contain the marker at all. + if ctx.flavor != crate::config::MarkdownFlavor::MkDocs + && threshold_ok + && self.config.style_explicit + && self.config.style == md007_config::IndentStyle::Fixed + { + while let Some(&(_, _, _, _, item_bq_depth, _, source_content_col)) = list_stack.last() { + if item_bq_depth == bq_depth && source_content_col > visual_marker_column { + list_stack.pop(); + } else { + break; + } + } + } let chain_ok = list_stack .iter() .rev() .find(|item| item.4 == bq_depth) .is_some_and(|item| item.2 || item.5); - if ctx.flavor != crate::config::MarkdownFlavor::MkDocs && threshold_ok && chain_ok { + let ordered_chain = ctx.flavor != crate::config::MarkdownFlavor::MkDocs && threshold_ok && chain_ok; + // An explicit style = "fixed" opts out of the exemption: the item is + // checked against the fixed expectation, clamped below to the parent's + // content column. The clamp is what makes checking safe here: a bullet + // left of its parent's content column leaves the item, so an unclamped + // fixed expectation would let the fix restructure the document. + let clamp_to_parent = ordered_chain + && self.config.style_explicit + && self.config.style == md007_config::IndentStyle::Fixed; + if ordered_chain && !clamp_to_parent { list_stack.push(( visual_marker_column, line_idx, @@ -443,6 +476,7 @@ visual_content_column, bq_depth, true, + visual_content_column, )); continue; } @@ -455,7 +489,7 @@ .iter() .rev() .find(|item| item.4 == bq_depth) - .map(|&(_, _, is_ordered, content_col, _, _)| (is_ordered, content_col)); + .map(|&(_, _, is_ordered, content_col, _, _, _)| (is_ordered, content_col)); // Calculate expected indent using per-parent logic // When start_indented is true, only depth-0 items use the start_indent value. @@ -468,21 +502,33 @@ self.calculate_expected_indent(nesting_level, parent_info) }; + // Clamp the fixed expectation to the parent's content column. The + // floor cascades: a fixed child stores its corrected content column + // on the stack, so a grandchild's floor is measured from where the + // fix puts the child, not from the source. + if clamp_to_parent && let Some((_, parent_content_col)) = parent_info { + expected_indent = expected_indent.max(parent_content_col); + } + // When indent is explicitly set and parent is ordered, also accept // the fixed indent value (nesting_level * indent). This lets users // choose either text-aligned or their configured indent under ordered lists. - let also_acceptable = - if self.config.indent_explicit && parent_info.is_some_and(|(is_ordered, _)| is_ordered) { - Some(nesting_level * self.config.indent.get() as usize) - } else { - None - }; + // Not under the clamp: there the fixed value can sit left of the + // parent's content column, and suggesting it would detach the item. + let also_acceptable = if !clamp_to_parent + && self.config.indent_explicit + && parent_info.is_some_and(|(is_ordered, _)| is_ordered) + { + Some(nesting_level * self.config.indent.get() as usize) + } else { + None + }; // MkDocs (Python-Markdown) uses 4-space-tab continuation for list items. // Under an ordered list item, Python-Markdown requires at least // marker_column + 4 spaces for continuation content to be recognized. if ctx.flavor == crate::config::MarkdownFlavor::MkDocs - && let Some(&(parent_marker_col, _, true, _, _, _)) = + && let Some(&(parent_marker_col, _, true, _, _, _, _)) = list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2) { expected_indent = expected_indent.max(parent_marker_col + 4); @@ -509,13 +555,18 @@ // stored value is unchanged. let marker_width = visual_content_column.saturating_sub(visual_marker_column); let expected_content_visual_col = accepted_indent + marker_width; + // A clamped item stays part of the ordered-rooted chain: its own + // descendants must resolve through the chain flag exactly as they + // would have through an exempt ancestor, or they would fall back to + // the unclamped fixed expectation and detach. list_stack.push(( visual_marker_column, line_idx, false, expected_content_visual_col, bq_depth, - false, + clamp_to_parent, + visual_content_column, )); // A top-level item (depth 0) is expected at column 0 when start_indented @@ -2488,6 +2539,284 @@ ); } + // An explicit style = "fixed" under an ordered parent clamps instead of + // exempting: the expectation is max(nesting_level * indent, parent content + // column). A bullet cannot sit left of its parent's content column without + // leaving the item, so the floor keeps the fix from walking an item out of + // its list; the ISSUE_638_INPUT guard above stays green because 3 and 5 are + // exactly the clamped expectations for indent = 2. + fn fixed_style_rule(indent: u8) -> MD007ULIndent { + MD007ULIndent::from_config_struct(MD007Config { + indent: crate::types::IndentSize::from_const(indent), + start_indented: false, + start_indent: crate::types::IndentSize::from_const(2), + style: md007_config::IndentStyle::Fixed, + style_explicit: true, + indent_explicit: true, + }) + } + + #[test] + fn test_fixed_style_clamp_flags_over_indented_bullet_under_ordered() { + let rule = fixed_style_rule(2); + let content = "1. Some text\n - four spaces\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!( + result.len(), + 1, + "a bullet at 4 under a content column of 3 is flagged: {result:?}" + ); + assert!( + result[0].message.contains("Expected 3") && result[0].message.contains("found 4"), + "clamped expectation is the parent content column, got: {}", + result[0].message + ); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "1. Some text\n - four spaces\n"); + } + + #[test] + fn test_fixed_style_clamp_accepts_bullet_at_parent_content_column() { + let rule = fixed_style_rule(2); + let content = "1. Some text\n - three spaces\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert!(result.is_empty(), "the clamped expectation itself passes: {result:?}"); + } + + #[test] + fn test_fixed_style_clamp_pulls_five_spaces_to_content_column() { + let rule = fixed_style_rule(2); + let content = "1. Some text\n - five spaces\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!(result.len(), 1, "{result:?}"); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "1. Some text\n - five spaces\n"); + } + + #[test] + fn test_fixed_style_clamp_cascades_through_nested_bullets() { + // The grandchild's floor is the (fixed) child's content column: the + // child sits at the clamped 3, its content starts at 5, so fixed 4 is + // clamped to 5 and the source's 6 is pulled in. + let rule = fixed_style_rule(2); + let content = "1. Ordered\n - child\n - grandchild\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!(result.len(), 1, "only the grandchild is off: {result:?}"); + assert!( + result[0].message.contains("Expected 5") && result[0].message.contains("found 6"), + "got: {}", + result[0].message + ); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "1. Ordered\n - child\n - grandchild\n"); + let refixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None); + assert!(rule.check(&refixed_ctx).unwrap().is_empty(), "fix is stable"); + } + + #[test] + fn test_fixed_style_clamp_respects_wider_fixed_indent() { + // With indent = 4 the fixed expectation (4) is already at or past the + // parent content column (3), so the clamp is inert and fixed style wins. + let rule = fixed_style_rule(4); + let content = "1. Some text\n - three spaces\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!(result.len(), 1, "{result:?}"); + assert!( + result[0].message.contains("Expected 4") && result[0].message.contains("found 3"), + "got: {}", + result[0].message + ); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "1. Some text\n - three spaces\n"); + } + + #[test] + fn test_fixed_style_clamp_uses_measured_content_column_of_wide_marker() { + // An ol-align-column style source pads the ordered marker, so the + // content column is 4; the floor follows the measured column. + let rule = fixed_style_rule(2); + let content = "1. Some text\n - five spaces\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!(result.len(), 1, "{result:?}"); + assert!( + result[0].message.contains("Expected 4") && result[0].message.contains("found 5"), + "got: {}", + result[0].message + ); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "1. Some text\n - five spaces\n"); + + let ok = "1. Some text\n - four spaces\n"; + let ok_ctx = LintContext::new(ok, crate::config::MarkdownFlavor::Standard, None); + assert!(rule.check(&ok_ctx).unwrap().is_empty()); + } + + #[test] + fn test_fixed_style_clamp_in_blockquote() { + let rule = fixed_style_rule(2); + let content = "> 1. Some text\n> - four spaces\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!(result.len(), 1, "{result:?}"); + assert!( + result[0].message.contains("Expected 3") && result[0].message.contains("found 4"), + "got: {}", + result[0].message + ); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "> 1. Some text\n> - four spaces\n"); + } + + #[test] + fn test_fixed_style_clamp_treats_near_sibling_as_sibling() { + // The bullet at 4 sits left of the previous bullet's content column + // (5), so per CommonMark it is that bullet's sibling, not its child. + // The clamp must resolve it against the ordered parent (expected 3, + // agreeing with MD005), not against the deeper bullet (5): a fix to 5 + // would rewrite the sibling into a child. + let rule = fixed_style_rule(2); + let content = "1. x\n - a\n - b\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!(result.len(), 1, "{result:?}"); + assert!( + result[0].message.contains("Expected 3") && result[0].message.contains("found 4"), + "near-sibling resolves against the ordered parent, got: {}", + result[0].message + ); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "1. x\n - a\n - b\n"); + } + + #[test] + fn test_fixed_style_clamp_child_after_near_sibling_resolves_against_it() { + // b is a's sibling (marker 4 < a's content 5, then fixed to 3); c at 6 + // is inside b's content and is its child, so c's floor is b's corrected + // content column. + let rule = fixed_style_rule(2); + let content = "1. x\n - a\n - b\n - c\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!(result.len(), 2, "b and c are both off: {result:?}"); + assert!( + result[0].message.contains("Expected 3") && result[0].message.contains("found 4"), + "got: {}", + result[0].message + ); + assert!( + result[1].message.contains("Expected 5") && result[1].message.contains("found 6"), + "got: {}", + result[1].message + ); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "1. x\n - a\n - b\n - c\n"); + } + + #[test] + fn test_fixed_style_clamp_pops_near_sibling_of_over_indented_bullet() { + // Containment is judged against a's SOURCE content column (6), so b at + // 5 is a's sibling even though a itself gets pulled to 3. Both land at + // 3; resolving b against a's corrected column would leave b at 5 and + // silently turn the sibling into a child once a moves. + let rule = fixed_style_rule(2); + let content = "1. x\n - a\n - b\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!(result.len(), 2, "a and b are both flagged: {result:?}"); + assert!( + result[1].message.contains("Expected 3") && result[1].message.contains("found 5"), + "b resolves against the ordered parent, got: {}", + result[1].message + ); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "1. x\n - a\n - b\n"); + } + + #[test] + fn test_fixed_style_clamp_keeps_child_of_over_indented_bullet() { + // c at 7 is inside a's source content column (6), so it stays a's + // child and follows a to the corrected floor. + let rule = fixed_style_rule(2); + let content = "1. x\n - a\n - c\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!(result.len(), 2, "a and c are both flagged: {result:?}"); + assert!( + result[1].message.contains("Expected 5") && result[1].message.contains("found 7"), + "c's floor is a's corrected content column, got: {}", + result[1].message + ); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "1. x\n - a\n - c\n"); + } + + #[test] + fn test_fixed_style_clamp_pops_ordered_near_sibling() { + // b at 6 sits between the nested ordered item's marker (5) and its + // content column (8), so b is that item's sibling: a child of a, whose + // corrected content column (5) is its floor. Resolving b against the + // nested ordered item would fix it to 8 and rewrite the sibling into + // a child. + let rule = fixed_style_rule(2); + let content = "1. root\n - a\n 1. sub\n - b\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!(result.len(), 1, "only b is off: {result:?}"); + assert!( + result[0].message.contains("Expected 5") && result[0].message.contains("found 6"), + "b resolves against a, not the nested ordered sibling, got: {}", + result[0].message + ); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "1. root\n - a\n 1. sub\n - b\n"); + } + + #[test] + fn test_fixed_style_clamp_leaves_sibling_bullet_left_of_content_column() { + // A bullet left of the ordered content column is a sibling list, not a + // sublist. The plain fixed expectation applies, 2 matches it, and no + // fix ever moves a sibling right into the item (the #638 failure mode). + let rule = fixed_style_rule(2); + let content = "1. Some text\n - two spaces\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert!( + result.is_empty(), + "sibling bullet at the fixed indent stays silent: {result:?}" + ); + } + + #[test] + fn test_fixed_style_clamp_requires_explicit_style() { + // indent_explicit alone keeps the ordered-ancestor exemption; only an + // explicit style = "fixed" opts into the clamp. + let config = MD007Config { + indent: crate::types::IndentSize::from_const(2), + start_indented: false, + start_indent: crate::types::IndentSize::from_const(2), + style: md007_config::IndentStyle::TextAligned, + style_explicit: false, + indent_explicit: true, + }; + let rule = MD007ULIndent::from_config_struct(config); + let content = "1. Some text\n - four spaces\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert!(result.is_empty(), "no explicit style, exemption stays: {result:?}"); + + let smart = MD007ULIndent::new(2); + assert!( + smart.check(&ctx).unwrap().is_empty(), + "smart default keeps the exemption too" + ); + } + #[test] fn test_issue_638_deeper_unordered_chain_under_ordered() { // Every unordered item below the ordered ancestor is exempt, at any depth. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/src/rules/md013_line_length/helpers.rs new/rumdl-0.2.30/src/rules/md013_line_length/helpers.rs --- old/rumdl-0.2.28/src/rules/md013_line_length/helpers.rs 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/src/rules/md013_line_length/helpers.rs 2026-07-09 19:39:55.000000000 +0200 @@ -112,6 +112,91 @@ segments } +/// GFM task list checkboxes, including the space that separates them from the item text. +const TASK_CHECKBOXES: [&str; 3] = ["[ ] ", "[x] ", "[X] "]; + +/// Whitespace that separates a list marker from the item text. CommonMark +/// expands tabs, so a tab is marker padding rather than content, and rumdl's +/// own parser accepts `-\tfoo` as a list item. +const MARKER_PADDING: [char; 2] = [' ', '\t']; + +/// Strip a GFM task list checkbox from the text that follows a list marker. +/// +/// Authors may pad between the marker and the checkbox (`- [ ] task`). That +/// padding belongs to the marker, so it is skipped before matching. Without +/// this the checkbox reaches the reflow engine as prose, which normalizes the +/// space inside it and rewrites `[ ]` as `[]`, silently turning a task item +/// into plain text. +/// +/// Returns the checkbox and the item text after it, or `None` when the item +/// carries no checkbox. +fn strip_task_checkbox(after_marker: &str) -> Option<(&'static str, &str)> { + let content = after_marker.trim_start_matches(MARKER_PADDING); + TASK_CHECKBOXES + .iter() + .find_map(|checkbox| content.strip_prefix(checkbox).map(|text| (*checkbox, text))) +} + +/// Display width of `s`, expanding tabs to CommonMark's four-column tab stops. +fn display_width(s: &str) -> usize { + s.chars() + .fold(0, |col, c| if c == '\t' { col + 4 - col % 4 } else { col + 1 }) +} + +/// The source marker of a list item: the text before the item's content, exactly as +/// written, plus the column at which that content begins. +/// +/// This is distinct from the marker that [`extract_list_marker_and_content`] returns. +/// That one is *normalized* to a single space and is what gets re-emitted; this one +/// describes the source and is what indentation arithmetic must measure against. A +/// reflowed item's nested blocks move by `new_content_column - source.content_col`, +/// so measuring the shift against the normalized marker silently corrupts any item +/// whose author wrote more than one space after the marker. +/// +/// `content_col` is a display column, so a tab in the padding counts as the columns +/// it actually occupies rather than its single byte. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct SourceMarker { + pub text: String, + pub content_col: usize, +} + +/// Returns `None` when the line does not open a list item. +pub(crate) fn source_list_marker(line: &str) -> Option<SourceMarker> { + let indent_len = line.len() - line.trim_start().len(); + let trimmed = &line[indent_len..]; + + let after_marker = if let Some(rest) = trimmed.strip_prefix(['-', '*', '+']) { + rest + } else { + // Ordered marker: digits then '.' + let digits = trimmed.find('.')?; + if digits == 0 || !trimmed[..digits].chars().all(|c| c.is_ascii_digit()) { + return None; + } + &trimmed[digits + 1..] + }; + + // A marker must be followed by padding. + if !after_marker.starts_with(MARKER_PADDING) { + return None; + } + let after_padding = after_marker.trim_start_matches(MARKER_PADDING); + // The checkbox is glued to the marker so reflow never rewrites it, matching the + // width that `extract_list_marker_and_content` reports for a task item. + let consumed = TASK_CHECKBOXES + .iter() + .find_map(|checkbox| after_padding.strip_prefix(checkbox).map(|_| checkbox.len())) + .unwrap_or(0); + + let marker_end = line.len() - after_padding.len() + consumed; + let text = line[..marker_end].to_string(); + Some(SourceMarker { + content_col: display_width(&text), + text, + }) +} + pub(crate) fn extract_list_marker_and_content(line: &str) -> (String, String) { // First, find the leading indentation let indent_len = line.len() - line.trim_start().len(); @@ -120,20 +205,27 @@ // Handle bullet lists // Trim trailing whitespace while preserving hard breaks - for bullet in ["- ", "* ", "+ "] { - if let Some(rest) = trimmed.strip_prefix(bullet) { - let marker_prefix = &bullet[..bullet.len() - 1]; // "-", "*", or "+" - // Include GFM task list checkboxes in the non-wrappable marker prefix - for checkbox in ["[ ] ", "[x] ", "[X] "] { - if let Some(content) = rest.strip_prefix(checkbox) { - return ( - format!("{indent}{marker_prefix} {checkbox}"), - trim_preserving_hard_break(content), - ); - } - } - return (format!("{indent}{bullet}"), trim_preserving_hard_break(rest)); + for bullet in ['-', '*', '+'] { + let Some(after_bullet) = trimmed.strip_prefix(bullet) else { + continue; + }; + let mut padding = after_bullet.chars(); + if !padding.next().is_some_and(|c| MARKER_PADDING.contains(&c)) { + continue; } + // Include GFM task list checkboxes in the non-wrappable marker prefix + if let Some((checkbox, content)) = strip_task_checkbox(after_bullet) { + return ( + format!("{indent}{bullet} {checkbox}"), + trim_preserving_hard_break(content), + ); + } + // Only the first padding character belongs to the marker. Any further + // padding stays with the content, where reflow normalizes it away. + return ( + format!("{indent}{bullet} "), + trim_preserving_hard_break(padding.as_str()), + ); } // Handle numbered lists on trimmed content @@ -143,20 +235,20 @@ while let Some(c) = chars.next() { marker_content.push(c); if c == '.' { - // Check if next char is a space + // Check if next char is marker padding if let Some(next) = chars.next() - && next == ' ' + && MARKER_PADDING.contains(&next) { - marker_content.push(next); + // Normalize the padding: a tab would otherwise land in the marker, + // where its byte length misreports the content column. + marker_content.push(' '); let rest = chars.as_str(); // Check for GFM task list checkboxes - for checkbox in ["[ ] ", "[x] ", "[X] "] { - if let Some(content) = rest.strip_prefix(checkbox) { - return ( - format!("{indent}{marker_content}{checkbox}"), - trim_preserving_hard_break(content), - ); - } + if let Some((checkbox, content)) = strip_task_checkbox(rest) { + return ( + format!("{indent}{marker_content}{checkbox}"), + trim_preserving_hard_break(content), + ); } let content = trim_preserving_hard_break(rest); return (format!("{indent}{marker_content}"), content); @@ -187,9 +279,9 @@ // Can have more digits while let Some(c) = chars.next() { if c == '.' { - // After period, must have a space (consistent with extract_list_marker_and_content) + // After period, must have marker padding (consistent with extract_list_marker_and_content) // "2019." alone is NOT treated as a list item to avoid false positives - return chars.next() == Some(' '); + return chars.next().is_some_and(|c| MARKER_PADDING.contains(&c)); } if !c.is_numeric() { return false; @@ -201,8 +293,7 @@ pub(crate) fn is_list_item(line: &str) -> bool { // Bullet lists if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+')) - && line.len() > 1 - && line.chars().nth(1) == Some(' ') + && line.chars().nth(1).is_some_and(|c| MARKER_PADDING.contains(&c)) { return true; } @@ -540,6 +631,73 @@ ); } + #[test] + fn test_extract_list_marker_task_checkbox_with_wide_marker_spacing() { + // Extra spaces between the marker and the checkbox are marker padding. + // The checkbox must still be recognized, otherwise it is handed to the + // reflow engine as prose and "[ ]" collapses to "[]", silently turning + // a task item into plain text. + assert_eq!( + extract_list_marker_and_content("- [ ] wide unchecked"), + ("- [ ] ".to_string(), "wide unchecked".to_string()) + ); + assert_eq!( + extract_list_marker_and_content("* [x] wide checked"), + ("* [x] ".to_string(), "wide checked".to_string()) + ); + assert_eq!( + extract_list_marker_and_content("+ [X] wide checked upper"), + ("+ [X] ".to_string(), "wide checked upper".to_string()) + ); + assert_eq!( + extract_list_marker_and_content(" - [ ] indented and wide"), + (" - [ ] ".to_string(), "indented and wide".to_string()) + ); + // Ordered markers pad after the period. + assert_eq!( + extract_list_marker_and_content("1. [ ] wide ordered"), + ("1. [ ] ".to_string(), "wide ordered".to_string()) + ); + assert_eq!( + extract_list_marker_and_content("99. [x] wide multi-digit"), + ("99. [x] ".to_string(), "wide multi-digit".to_string()) + ); + // A tab is padding too: CommonMark expands it, rumdl's parser accepts + // it, and leaving it in place mangles the checkbox as extra spaces did. + assert_eq!( + extract_list_marker_and_content("- \t[ ] tab padded"), + ("- [ ] ".to_string(), "tab padded".to_string()) + ); + assert_eq!( + extract_list_marker_and_content("-\t[ ] tab only"), + ("- [ ] ".to_string(), "tab only".to_string()) + ); + assert_eq!( + extract_list_marker_and_content("1.\t[x] ordered tab only"), + ("1. [x] ".to_string(), "ordered tab only".to_string()) + ); + // A tab-padded item without a checkbox normalizes the marker too. + assert_eq!( + extract_list_marker_and_content("-\tplain tab item"), + ("- ".to_string(), "plain tab item".to_string()) + ); + assert_eq!( + extract_list_marker_and_content("1.\tplain ordered tab item"), + ("1. ".to_string(), "plain ordered tab item".to_string()) + ); + // Padding without a checkbox keeps the existing shape: the marker is + // normalized to one space and the padding stays with the content. + assert_eq!( + extract_list_marker_and_content("- plain wide item"), + ("- ".to_string(), " plain wide item".to_string()) + ); + // A bracket pair that is not a checkbox is content, not a marker. + assert_eq!( + extract_list_marker_and_content("- [link] text"), + ("- ".to_string(), " [link] text".to_string()) + ); + } + #[test] fn test_is_horizontal_rule_commonmark_indent() { // Up to 3 spaces of leading indent is allowed (CommonMark thematic break). diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/src/rules/md013_line_length/tests.rs new/rumdl-0.2.30/src/rules/md013_line_length/tests.rs --- old/rumdl-0.2.28/src/rules/md013_line_length/tests.rs 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/src/rules/md013_line_length/tests.rs 2026-07-09 19:39:55.000000000 +0200 @@ -4302,6 +4302,30 @@ ); } +/// A thematic break inside a blockquote is not a bullet list, whichever +/// whitespace separates its markers. Reflowing it as list prose destroys it. +#[test] +fn test_blockquote_thematic_break_is_not_reflowed() { + let config = MD013Config { + line_length: crate::types::LineLength::new(40), + reflow: true, + reflow_mode: ReflowMode::Normalize, + ..Default::default() + }; + let rule = MD013LineLength::from_config_struct(config); + + for separator in [" ", "\t"] { + let body = std::iter::repeat_n("-", 41).collect::<Vec<_>>().join(separator); + let content = format!("> {body}\n"); + let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None); + assert_eq!( + rule.fix(&ctx).unwrap(), + content, + "thematic break separated by {separator:?} was rewritten" + ); + } +} + /// A GFM task-list checkbox item in a blockquote wraps with continuation lines /// aligned under the content (the checkbox is part of the marker, not the body). #[test] @@ -4762,6 +4786,56 @@ } #[test] + fn test_task_item_wide_marker_spacing_does_not_corrupt_checkbox() { + // Padding between the list marker and the checkbox is marker spacing, + // not content. Reflow must still recognize the task item; handing + // "[ ]" to the reflow engine as prose collapses it to "[]" and the + // item silently stops being a task item. + let rule = make_rule(80); + let tail = "This task has a really long description that exceeds the line limit and should wrap"; + for marker in ["- ", "- ", "* ", "+ ", "1. ", "10. ", "- \t", "-\t", "1.\t"] { + let content = format!("{marker}[ ] {tail}\n"); + let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None); + let fixed = rule.fix(&ctx).unwrap(); + assert!( + !fixed.contains("[]"), + "marker {marker:?} corrupted the checkbox: {fixed:?}" + ); + assert!( + fixed.contains("[ ] "), + "marker {marker:?} dropped the checkbox: {fixed:?}" + ); + } + } + + #[test] + fn test_tab_padded_item_stays_a_list_item() { + // rumdl's parser accepts a tab as marker padding. When MD013's own + // heuristic did not, the item was reflowed as a plain paragraph and its + // continuation landed at column 0, dissolving the list. + let rule = make_rule(40); + let ctx = LintContext::new( + "-\tAlpha beta gamma delta epsilon zeta eta theta\n", + MarkdownFlavor::Standard, + None, + ); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "- Alpha beta gamma delta epsilon zeta\n eta theta\n"); + } + + #[test] + fn test_task_item_wide_marker_spacing_wraps_at_content_column() { + // The rebuilt marker is normalized to MD030's single space, so the + // continuation aligns under the checkbox content column, exactly as it + // does for a single-space source marker. + let rule = make_rule(40); + let content = "- [ ] Alpha beta gamma delta epsilon zeta eta theta\n"; + let ctx = LintContext::new(content, MarkdownFlavor::Standard, None); + let fixed = rule.fix(&ctx).unwrap(); + assert_eq!(fixed, "- [ ] Alpha beta gamma delta epsilon\n zeta eta theta\n"); + } + + #[test] fn test_task_item_all_bullet_markers() { // All bullet markers (-, *, +) should handle task checkboxes correctly let rule = make_rule(80); @@ -9165,3 +9239,44 @@ assert!(result[0].fix.is_some(), "First warning should have a fix"); assert!(result[1].fix.is_some(), "Second warning should have a fix"); } + +#[test] +fn test_text_reflow_preserves_nested_code_blocks_in_lists() { + let config = MD013Config { + line_length: crate::types::LineLength::from_const(40), + reflow: true, + ..Default::default() + }; + let mut rule = MD013LineLength::from_config_struct(config); + rule.list_spacing = MD030Config { + ul_single: crate::types::PositiveUsize::from_const(3), + ul_multi: crate::types::PositiveUsize::from_const(3), + ..Default::default() + }; + + let content = indoc! {" + - This is a very long list item text that will definitely exceed the forty character limit and force a reflow. + + ``` + code block line 1 + code block line 2 + ``` + "}; + + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let fixed = rule.fix(&ctx).unwrap(); + + let expected = indoc! {" + - This is a very long list item text + that will definitely exceed the + forty character limit and force a + reflow. + + ``` + code block line 1 + code block line 2 + ``` + "}; + + assert_eq!(fixed, expected); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/src/rules/md013_line_length.rs new/rumdl-0.2.30/src/rules/md013_line_length.rs --- old/rumdl-0.2.28/src/rules/md013_line_length.rs 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/src/rules/md013_line_length.rs 2026-07-09 19:39:55.000000000 +0200 @@ -26,7 +26,7 @@ use block_builder::{Block, BlockBuilder}; use helpers::{ extract_list_marker_and_content, has_hard_break, is_github_alert_marker, is_horizontal_rule, is_html_only_line, - is_list_item, is_standalone_link_or_image_line, is_unwrappable_line, split_into_segments, + is_list_item, is_standalone_link_or_image_line, is_unwrappable_line, source_list_marker, split_into_segments, trim_preserving_hard_break, }; pub use md013_config::MD013Config; @@ -1033,6 +1033,13 @@ start_bq.prefix.clone() }; + // A thematic break opens with what looks like a bullet marker (`- - -`). + // It is not a list item, and reflowing it as prose destroys the break. + // The top-level reflow path applies the same exemption. + if is_horizontal_rule(&start_bq.content) { + return (None, start_idx + 1); + } + let (marker, first_body) = extract_list_marker_and_content(&start_bq.content); if marker.is_empty() { return (None, start_idx + 1); @@ -1277,11 +1284,11 @@ // content from column 2 to 4), its nested list/blockquote children are reflowed // independently and would otherwise keep their original indent — leaving them // under the parent's new content column, where a CommonMark parser reparses them - // as siblings rather than children. Each frame is (source content column, + // as siblings rather than children. Each frame is (normalized marker width, // cumulative shift applied to that item's content column); a descendant adds its // innermost open ancestor's shift to its own indent so the whole subtree moves - // together. With default MD030 every shift is 0, so this is inert and the output - // is byte-identical. + // together. With a default MD030 and no marker padding every shift is 0, so this + // is inert and the output is byte-identical. let mut list_shift_stack: Vec<(usize, isize)> = Vec::new(); let mut i = 0; @@ -1970,6 +1977,12 @@ let list_start = i; let (marker, first_content) = extract_list_marker_and_content(lines[i]); let marker_len = marker.len(); + // The normalized marker above is what gets re-emitted; the source marker + // is where the item's content actually starts. Nested blocks move by the + // difference between the two content columns, so the shift must be + // measured against the source, not against the normalized width. + let source_marker = source_list_marker(lines[i]); + let source_content_col = source_marker.as_ref().map_or(marker_len, |m| m.content_col); // Checkbox ([ ]/[x]/[X]) is inline content, not part of the list marker. // Use the base bullet/number marker width for continuation recognition @@ -2421,9 +2434,14 @@ let spaces = self.list_spacing.expected_spaces(li.is_ordered, is_multi, bullet_len); let new_marker = format!("{indent_prefix}{}{}{checkbox_tail}", li.marker, " ".repeat(spaces)); let new_col = new_marker.chars().count(); - let shift = new_col as isize - marker_len as isize; + let shift = new_col as isize - source_content_col as isize; (new_marker, new_col, shift) } else { + // MkDocs enforces a rigid structural indent, so the item is emitted + // exactly as written and its nested blocks never move. Re-emitting + // the normalized marker here would narrow the content column while + // leaving those blocks behind. + let marker = source_marker.map_or(marker, |m| m.text); (marker, indent_size, 0isize) }; let expected_indent = " ".repeat(indent_size); @@ -2496,8 +2514,11 @@ // Record this item's frame so its nested children inherit the shift. // Only a reflowed item's marker actually moves; an unreflowed one keeps // its source position and so contributes no shift to its children. The - // source content column (`marker_len`) is the threshold that decides - // which following lines are inside this item. + // threshold that decides which following lines are inside this item is + // the normalized marker width, NOT `source_content_col`: the collection + // loop above gathers continuations by that same width, so the frame + // boundary must match it or the two would disagree about ownership of + // lines indented between the normalized and the source content column. list_shift_stack.push((marker_len, if needs_reflow { code_indent_shift } else { 0 })); if needs_reflow { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/src/rules/md032_blanks_around_lists.rs new/rumdl-0.2.30/src/rules/md032_blanks_around_lists.rs --- old/rumdl-0.2.28/src/rules/md032_blanks_around_lists.rs 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/src/rules/md032_blanks_around_lists.rs 2026-07-09 19:39:55.000000000 +0200 @@ -1009,6 +1009,61 @@ rule.fix(&ctx).expect("Lint fix failed") } + #[test] + fn test_fix_does_not_split_item_before_different_list_type() { + // The continuation line belongs to the bullet item. Only one blank line + // is needed, between the two lists; inserting one after the marker line + // would split the item into a list plus a stray paragraph. + let content = "- alpha beta\n aligned\n1. ordered item\n cont\n"; + assert_eq!(fix(content), "- alpha beta\n aligned\n\n1. ordered item\n cont\n"); + + // check() anchors the boundary on the first list's last line and the + // second list's first line, exactly as it does when neither item wraps. + let warnings = lint(content); + assert_eq!(warnings.len(), 2); + assert_eq!(warnings[0].line, 2); + assert_eq!(warnings[1].line, 3); + } + + #[test] + fn test_fix_does_not_split_blockquoted_item_before_different_list_type() { + let content = "> - alpha beta\n> aligned\n> 1. ordered item\n"; + assert_eq!(fix(content), "> - alpha beta\n> aligned\n>\n> 1. ordered item\n"); + } + + #[test] + fn test_fix_keeps_lazy_continuation_with_its_item() { + // Per CommonMark the lazy line continues the item's paragraph, so the + // blank belongs after it, before the new list. Splitting it off would + // promote it to a standalone paragraph and change the rendering. + let content = "- alpha beta\nlazy\n1. ordered item\n"; + assert_eq!(fix(content), "- alpha beta\nlazy\n\n1. ordered item\n"); + + let warnings = lint(content); + assert_eq!(warnings.len(), 2); + assert_eq!(warnings[0].line, 2); + assert_eq!(warnings[1].line, 3); + } + + #[test] + fn test_fix_keeps_blockquoted_lazy_continuation_with_its_item() { + let content = "> - alpha beta\n> lazy\n> 1. ordered item\n"; + assert_eq!(fix(content), "> - alpha beta\n> lazy\n>\n> 1. ordered item\n"); + } + + #[test] + fn test_fix_indents_lazy_continuation_when_not_allowed() { + // With allow_lazy_continuation = false the lazy line is first indented + // into the item (Phase 0), then the blank separates the two lists. + let rule = MD032BlanksAroundLists::from_config_struct(MD032Config { + allow_lazy_continuation: false, + }); + let content = "- alpha beta\nlazy\n1. ordered item\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let fixed = rule.fix(&ctx).expect("Lint fix failed"); + assert_eq!(fixed, "- alpha beta\n lazy\n\n1. ordered item\n"); + } + // Test that warnings include Fix objects fn check_warnings_have_fixes(content: &str) { let warnings = lint(content); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/tests/formats/md013_mkdocs_reflow_test.rs new/rumdl-0.2.30/tests/formats/md013_mkdocs_reflow_test.rs --- old/rumdl-0.2.28/tests/formats/md013_mkdocs_reflow_test.rs 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/tests/formats/md013_mkdocs_reflow_test.rs 2026-07-09 19:39:55.000000000 +0200 @@ -1332,7 +1332,7 @@ let expected = "\ # Test -1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod +1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. !!! note @@ -1372,7 +1372,7 @@ let fixed = rule.fix(&ctx).unwrap(); let expected = "\ -- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod +- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. !!! note @@ -1413,7 +1413,7 @@ let fixed = rule.fix(&ctx).unwrap(); let expected = "\ -1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod +1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. !!! note @@ -1496,7 +1496,7 @@ // MD030 normalizes "1. " to "1. " — only the overlong line and // marker spacing should change, everything else verbatim. let expected = "\ -1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod +1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. !!! note @@ -1546,7 +1546,7 @@ let fixed = rule.fix(&ctx).unwrap(); let expected = "\ -1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod +1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. !!! note @@ -1595,7 +1595,7 @@ let fixed = rule.fix(&ctx).unwrap(); let expected = "\ -1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod +1. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. !!! note diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/tests/regressions/lint_context_list_blocks_type_change_test.rs new/rumdl-0.2.30/tests/regressions/lint_context_list_blocks_type_change_test.rs --- old/rumdl-0.2.28/tests/regressions/lint_context_list_blocks_type_change_test.rs 1970-01-01 01:00:00.000000000 +0100 +++ new/rumdl-0.2.30/tests/regressions/lint_context_list_blocks_type_change_test.rs 2026-07-09 19:39:55.000000000 +0200 @@ -0,0 +1,87 @@ +// Regression tests: a list block that ends because a list of a *different type* +// follows must keep its continuation lines, both properly indented ones and +// lazy (column 0) ones. Per CommonMark a lazy line continues the item's +// paragraph, so it belongs to the block. +// +// When the block was truncated to its last marker line, MD032 saw the item as +// ending at the marker and inserted a blank line *inside* the item, splitting +// it into a list plus a stray paragraph. + +use rumdl_lib::config::MarkdownFlavor; +use rumdl_lib::lint_context::LintContext; + +fn blocks(content: &str) -> Vec<(usize, usize)> { + let ctx = LintContext::new(content, MarkdownFlavor::Standard, None); + ctx.list_blocks.iter().map(|b| (b.start_line, b.end_line)).collect() +} + +#[test] +fn test_bullet_continuation_kept_when_ordered_list_follows() { + // Line 2 is indented to the bullet's content column, so it belongs to item 1. + assert_eq!( + blocks("- alpha beta\n aligned\n1. ordered item\n"), + vec![(1, 2), (3, 3)] + ); +} + +#[test] +fn test_ordered_continuation_kept_when_bullet_list_follows() { + assert_eq!( + blocks("1. alpha beta\n aligned\n- bullet item\n"), + vec![(1, 2), (3, 3)] + ); +} + +#[test] +fn test_multiple_continuation_lines_kept() { + assert_eq!( + blocks("- alpha beta\n second\n third\n1. ordered item\n"), + vec![(1, 3), (4, 4)] + ); +} + +#[test] +fn test_lazy_continuation_kept_when_different_type_follows() { + // A column-0 line after a list item lazily continues the item's paragraph, + // so the block owns it. Ending the block at the marker line instead made + // MD032 insert its blank there, splitting the paragraph in two. + assert_eq!(blocks("- alpha beta\nlazy\n1. ordered item\n"), vec![(1, 2), (3, 3)]); +} + +#[test] +fn test_multiple_lazy_continuations_kept() { + assert_eq!( + blocks("- alpha beta\nlazy one\nlazy two\n1. ordered item\n"), + vec![(1, 3), (4, 4)] + ); +} + +#[test] +fn test_same_type_list_still_merges_continuations() { + // A same-type marker never triggers the trim; the whole run is one block. + assert_eq!(blocks("- alpha beta\n aligned\n- second item\n"), vec![(1, 3)]); +} + +#[test] +fn test_no_continuation_is_unaffected() { + assert_eq!(blocks("- alpha beta\n1. ordered item\n"), vec![(1, 1), (2, 2)]); +} + +#[test] +fn test_blockquote_continuation_kept_when_different_type_follows() { + // Inside a blockquote the raw indent is 0 because of the `>` marker. The + // indentation that matters is the one after the blockquote prefix. + assert_eq!( + blocks("> - alpha beta\n> aligned\n> 1. ordered item\n"), + vec![(1, 2), (3, 3)] + ); +} + +#[test] +fn test_blockquote_lazy_continuation_kept() { + // No indentation after the `>`, so this line is lazy, as at the root level. + assert_eq!( + blocks("> - alpha beta\n> lazy\n> 1. ordered item\n"), + vec![(1, 2), (3, 3)] + ); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.2.28/tests/regressions/mod.rs new/rumdl-0.2.30/tests/regressions/mod.rs --- old/rumdl-0.2.28/tests/regressions/mod.rs 2026-07-03 11:03:19.000000000 +0200 +++ new/rumdl-0.2.30/tests/regressions/mod.rs 2026-07-09 19:39:55.000000000 +0200 @@ -6,6 +6,7 @@ mod final_confidence_assessment; mod html_comments_test; mod lint_context_list_blocks_issue_148_test; +mod lint_context_list_blocks_type_change_test; mod lint_context_list_continuation_test; mod lint_context_visual_indent_test; mod md009_md013_integration_test; ++++++ rumdl.obsinfo ++++++ --- /var/tmp/diff_new_pack.ygU0rn/_old 2026-07-10 17:45:33.413926547 +0200 +++ /var/tmp/diff_new_pack.ygU0rn/_new 2026-07-10 17:45:33.437927369 +0200 @@ -1,5 +1,5 @@ name: rumdl -version: 0.2.28 -mtime: 1783069399 -commit: c5f50cbbbc790d7adb5583b51e4322ed3dcae65c +version: 0.2.30 +mtime: 1783618795 +commit: 12e34feb01c07d62a8161b5ac74940ffe8954c21 ++++++ vendor.tar.zst ++++++ /work/SRC/openSUSE:Factory/rumdl/vendor.tar.zst /work/SRC/openSUSE:Factory/.rumdl.new.1991/vendor.tar.zst differ: char 7, line 1
