From c34fe379d2137551b537713f01a36604ada05a95 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 04:12:59 -0500 Subject: [PATCH 01/37] feat(init): emit working git hook; add wrap-only sdiff fixtures Init writes an executable pre-commit hook that prefers snapper-fmt and stages without the colliding Btrfs snapper clean filter. The local git filter is snapper-fmt --native --stdin-filepath %f. Fixtures cover abbreviations, LaTeX math, Org blocks, Markdown fences, and wrapped lists. sdiff is empty when only wrapping differs. Default clause_breaks stay off. Both CLI names stay. --- docs/orgmode/howto/git-filter.org | 5 +- docs/orgmode/reference/cli.org | 5 +- src/init.rs | 147 ++++++++++- tests/fixtures/abbreviations.txt | 1 + tests/fixtures/math.tex | 7 + tests/fixtures/md_fences.md | 8 + tests/fixtures/org_blocks.org | 10 + tests/fixtures/wrapped_lists.md | 5 + tests/snapper_19jf.rs | 388 ++++++++++++++++++++++++++++++ 9 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/abbreviations.txt create mode 100644 tests/fixtures/math.tex create mode 100644 tests/fixtures/md_fences.md create mode 100644 tests/fixtures/org_blocks.org create mode 100644 tests/fixtures/wrapped_lists.md create mode 100644 tests/snapper_19jf.rs diff --git a/docs/orgmode/howto/git-filter.org b/docs/orgmode/howto/git-filter.org index 500b0b5..33eb573 100644 --- a/docs/orgmode/howto/git-filter.org +++ b/docs/orgmode/howto/git-filter.org @@ -14,7 +14,10 @@ This makes semantic line breaks transparent to collaborators who do not use snap :CUSTOM_ID: setup :END: -Configure the filter in your local git config: +=snapper init= writes this filter into the local git config as +=snapper-fmt --native --stdin-filepath %f= (extension auto-detect; =snapper-fmt= +avoids openSUSE's Btrfs =snapper=) plus a working pre-commit hook. +To configure it by hand: #+begin_src bash git config filter.snapper.clean "snapper --native --format org" diff --git a/docs/orgmode/reference/cli.org b/docs/orgmode/reference/cli.org index 2eed746..2039453 100644 --- a/docs/orgmode/reference/cli.org +++ b/docs/orgmode/reference/cli.org @@ -255,10 +255,13 @@ Detects which prose formats exist in the directory tree and generates: - =.snapperrc.toml= with sensible defaults - =.gitattributes= entries for the git smudge/clean filter -- pre-commit hook snippet +- a working =.git/hooks/pre-commit= (native parsers, prefers =snapper-fmt= so openSUSE Btrfs =snapper= does not collide) when run inside a git repository +- =filter.snapper.clean= / =smudge= in the local git config (=snapper-fmt --native --stdin-filepath %f=) so the attributes filter actually runs +- pre-commit framework snippet - Apheleia (Emacs) configuration snippet Use =--dry-run= to preview without writing files. +An existing pre-commit hook that was not generated by snapper is left alone. *** =snapper sdiff = :PROPERTIES: diff --git a/src/init.rs b/src/init.rs index fed70a8..d45bf61 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,5 +1,6 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::process::Command; use anyhow::{Context, Result}; @@ -143,6 +144,126 @@ fn generate_precommit() -> String { ) } +const HOOK_MARKER: &str = "Generated by `snapper init`"; + +/// Git pre-commit hook that formats staged prose with native parsers. +fn generate_precommit_hook() -> String { + format!( + r#"#!/bin/sh +# {HOOK_MARKER}. Format staged prose with native parsers. +set -e +# Prefer snapper-fmt: argv0 snapper collides with openSUSE's Btrfs tool. +if command -v snapper-fmt >/dev/null 2>&1; then + SNAPPER=snapper-fmt +elif command -v snapper >/dev/null 2>&1; then + SNAPPER=snapper +else + echo "snapper: neither snapper-fmt nor snapper on PATH; skip" >&2 + exit 0 +fi + +git diff --cached --name-only --diff-filter=ACM -z | +while IFS= read -r -d '' f || [ -n "$f" ]; do + case "$f" in + *.org|*.tex|*.latex|*.md|*.markdown|*.rst|*.txt) + "$SNAPPER" --native --in-place -- "$f" + git -c filter.snapper.clean=cat add -- "$f" + ;; + esac +done +"# + ) +} + +fn git_hooks_dir(cwd: &Path) -> Option { + let output = Command::new("git") + .args(["rev-parse", "--git-path", "hooks"]) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let rel = String::from_utf8_lossy(&output.stdout); + let rel = rel.trim(); + if rel.is_empty() { + return None; + } + let path = Path::new(rel); + Some(if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }) +} + +fn write_precommit_hook(cwd: &Path, dry_run: bool) -> Result<()> { + let Some(hooks_dir) = git_hooks_dir(cwd) else { + eprintln!(" not a git repository; skip pre-commit hook"); + return Ok(()); + }; + let hook_path = hooks_dir.join("pre-commit"); + let content = generate_precommit_hook(); + if dry_run { + eprintln!("\n--- {} ---", hook_path.display()); + eprint!("{content}"); + return Ok(()); + } + if hook_path.exists() { + let existing = fs::read_to_string(&hook_path).unwrap_or_default(); + if !existing.contains(HOOK_MARKER) { + eprintln!( + " {} already exists and is not a snapper hook, skipping", + hook_path.display() + ); + return Ok(()); + } + } + fs::create_dir_all(&hooks_dir).context("failed to create git hooks directory")?; + fs::write(&hook_path, content).context("failed to write git pre-commit hook")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&hook_path)?.permissions(); + perms.set_mode(perms.mode() | 0o755); + fs::set_permissions(&hook_path, perms).context("failed to chmod git pre-commit hook")?; + } + eprintln!(" Created {}", hook_path.display()); + Ok(()) +} + +fn configure_git_filter(cwd: &Path, dry_run: bool) -> Result<()> { + if git_hooks_dir(cwd).is_none() { + return Ok(()); + } + if dry_run { + eprintln!(" git config filter.snapper.clean \"snapper-fmt --native --stdin-filepath %f\""); + eprintln!(" git config filter.snapper.smudge cat"); + return Ok(()); + } + let clean = Command::new("git") + .args([ + "config", + "filter.snapper.clean", + "snapper-fmt --native --stdin-filepath %f", + ]) + .current_dir(cwd) + .status(); + let smudge = Command::new("git") + .args(["config", "filter.snapper.smudge", "cat"]) + .current_dir(cwd) + .status(); + match (clean, smudge) { + (Ok(c), Ok(s)) if c.success() && s.success() => { + eprintln!(" Configured git filter.snapper.clean/smudge"); + } + _ => { + eprintln!(" git config filter.snapper.* failed, skipping"); + } + } + Ok(()) +} + /// Generate Apheleia elisp snippet. fn generate_apheleia(formats: &[&str]) -> String { let mut s = String::from(";; Add to your Emacs config:\n(with-eval-after-load 'apheleia\n"); @@ -211,15 +332,13 @@ pub fn run_init(dry_run: bool) -> Result<()> { } } + write_precommit_hook(&cwd, dry_run)?; + configure_git_filter(&cwd, dry_run)?; + // Print pre-commit and Apheleia snippets eprintln!("\n{}", generate_precommit()); eprintln!("{}", generate_apheleia(&formats)); - // Git filter setup reminder - eprintln!("To enable the git smudge/clean filter, run:"); - eprintln!(" git config filter.snapper.clean \"snapper\""); - eprintln!(" git config filter.snapper.smudge cat"); - Ok(()) } @@ -278,4 +397,20 @@ mod tests { assert!(ga.contains("# snapper")); assert!(!ga.contains("filter=snapper")); } + + #[test] + fn generate_precommit_hook_is_native_and_has_fmt_fallback() { + let hook = generate_precommit_hook(); + assert!(hook.starts_with("#!/bin/sh")); + assert!(hook.contains(HOOK_MARKER)); + assert!(hook.contains("--native")); + assert!( + hook.find("command -v snapper-fmt").unwrap() + < hook.find("command -v snapper >/dev/null").unwrap(), + "hook must prefer snapper-fmt over argv0 snapper" + ); + assert!(hook.contains("--in-place")); + assert!(hook.contains("filter.snapper.clean=cat")); + assert!(hook.contains("*.org|*.tex|*.latex|*.md|*.markdown|*.rst|*.txt")); + } } diff --git a/tests/fixtures/abbreviations.txt b/tests/fixtures/abbreviations.txt new file mode 100644 index 0000000..547a32e --- /dev/null +++ b/tests/fixtures/abbreviations.txt @@ -0,0 +1 @@ +Dr. Smith went home. He was tired. See Fig. 3 for details. Use e.g. snapper here. diff --git a/tests/fixtures/math.tex b/tests/fixtures/math.tex new file mode 100644 index 0000000..3934cbd --- /dev/null +++ b/tests/fixtures/math.tex @@ -0,0 +1,7 @@ +See $a. b$ inline. Next. + +\begin{equation} +E = mc^2. not prose +\end{equation} + +After the equation. Next. diff --git a/tests/fixtures/md_fences.md b/tests/fixtures/md_fences.md new file mode 100644 index 0000000..51d1f78 --- /dev/null +++ b/tests/fixtures/md_fences.md @@ -0,0 +1,8 @@ +Before the fence. Next sentence. + +```python +x = 1. 2 +print("stay") +``` + +After the fence. Next sentence. diff --git a/tests/fixtures/org_blocks.org b/tests/fixtures/org_blocks.org new file mode 100644 index 0000000..27eb134 --- /dev/null +++ b/tests/fixtures/org_blocks.org @@ -0,0 +1,10 @@ +#+BEGIN_SRC python +x = 1. 2 +print("stay") +#+END_SRC + +#+BEGIN_NOTE +Quoted one. Quoted two. +#+END_NOTE + +After. Next. diff --git a/tests/fixtures/wrapped_lists.md b/tests/fixtures/wrapped_lists.md new file mode 100644 index 0000000..429bb49 --- /dev/null +++ b/tests/fixtures/wrapped_lists.md @@ -0,0 +1,5 @@ +- First item is a long sentence that should wrap at a modest width without minting a new block. Second sentence stays in the item. + +- Second item is shorter. Another. + +After the list. Next. diff --git a/tests/snapper_19jf.rs b/tests/snapper_19jf.rs new file mode 100644 index 0000000..b89244f --- /dev/null +++ b/tests/snapper_19jf.rs @@ -0,0 +1,388 @@ +//! Fixtures for abbreviations, LaTeX math, Org blocks, Markdown fences, +//! and wrapped lists. `sdiff` is empty when only wrapping differs. +//! Default break rules stay unless a fixture requires a change. +//! Both `snapper` and `snapper-fmt` binaries stay. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use snapper_fmt::format::Format; +use snapper_fmt::parser::latex::LatexParser; +use snapper_fmt::parser::markdown::MarkdownParser; +use snapper_fmt::parser::org::OrgParser; +use snapper_fmt::parser::{FormatParser, Region}; +use snapper_fmt::sdiff::sentence_diff; +use snapper_fmt::{FormatConfig, format_text}; + +fn cfg(format: Format) -> FormatConfig { + FormatConfig { + format, + max_width: 0, + ..Default::default() + } + .without_safety_backstops() +} + +fn wrap_cfg(format: Format, max_width: usize) -> FormatConfig { + FormatConfig { + format, + max_width, + ..Default::default() + } + .without_safety_backstops() +} + +fn fixture_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +fn fixture(name: &str) -> String { + fs::read_to_string(fixture_path(name)).unwrap() +} + +fn sdiff_texts(old: &str, new: &str, format: Format, stem: &str) -> String { + let dir = std::env::temp_dir(); + let old_path = dir.join(format!("snapper_19jf_{stem}_old.txt")); + let new_path = dir.join(format!("snapper_19jf_{stem}_new.txt")); + fs::write(&old_path, old).unwrap(); + fs::write(&new_path, new).unwrap(); + let result = sentence_diff(&old_path, &new_path, Some(format), false).unwrap(); + let _ = fs::remove_file(&old_path); + let _ = fs::remove_file(&new_path); + result +} + +fn assert_sdiff_empty_wrap_only(input: &str, format: Format, stem: &str, wrap_width: usize) { + let sentence = format_text(input, &cfg(format)).unwrap(); + let wrapped = format_text(input, &wrap_cfg(format, wrap_width)).unwrap(); + let vs_sentence = sdiff_texts(input, &sentence, format, &format!("{stem}_sent")); + assert!( + vs_sentence.is_empty(), + "sdiff must be empty after sentence wrap ({stem}), got:\n{vs_sentence}\n--- input ---\n{input}--- formatted ---\n{sentence}" + ); + let vs_width = sdiff_texts(input, &wrapped, format, &format!("{stem}_width")); + assert!( + vs_width.is_empty(), + "sdiff must be empty after max_width wrap ({stem}), got:\n{vs_width}\n--- input ---\n{input}--- wrapped ---\n{wrapped}" + ); +} + +#[test] +fn abbreviations_do_not_split_titles_or_latin() { + let input = fixture("abbreviations.txt"); + let out = format_text(&input, &cfg(Format::Plaintext)).unwrap(); + assert!( + out.contains("Dr. Smith went home.\n"), + "Dr. must stay in the title sentence, got:\n{out}" + ); + assert!( + !out.contains("Dr.\n"), + "Dr. must not be its own sentence, got:\n{out}" + ); + assert!( + out.contains("See Fig. 3 for details.\n"), + "Fig. must stay with the figure number, got:\n{out}" + ); + assert!( + out.contains("Use e.g. snapper here.\n"), + "e.g. must stay with the example, got:\n{out}" + ); + assert!( + out.contains("He was tired.\n"), + "the sentence after Dr. must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg(Format::Plaintext)).unwrap(), out); + assert_sdiff_empty_wrap_only(&input, Format::Plaintext, "abbr", 24); +} + +#[test] +fn latex_math_stays_atomic_and_following_splits() { + let input = fixture("math.tex"); + let regions = LatexParser::default().parse(&input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s.contains("E = mc^2. not prose") + )), + "equation body must stay Structure, got {regions:?}" + ); + assert!( + !regions.iter().any(|r| matches!( + r, + Region::Prose(p) if p.contains("mc^2. not prose") + )), + "equation body must not be Prose, got {regions:?}" + ); + let out = format_text(&input, &cfg(Format::Latex)).unwrap(); + assert!( + out.contains("$a. b$"), + "inline math with an interior period must stay one token, got:\n{out}" + ); + assert!( + !out.contains("$a.\n"), + "must not wrap inside inline math, got:\n{out}" + ); + assert!( + out.contains("E = mc^2. not prose"), + "display math must not reflow as prose, got:\n{out}" + ); + assert!( + out.contains("See $a. b$ inline.\nNext.\n"), + "prose around inline math must still split, got:\n{out}" + ); + assert!( + out.contains("After the equation.\nNext.\n"), + "prose after display math must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg(Format::Latex)).unwrap(), out); + assert_sdiff_empty_wrap_only(&input, Format::Latex, "math", 20); +} + +#[test] +fn org_blocks_keep_src_and_split_note() { + let input = fixture("org_blocks.org"); + let regions = OrgParser.parse(&input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Code { body, .. } if body.contains("x = 1. 2") + )), + "BEGIN_SRC body must be Code, got {regions:?}" + ); + let out = format_text(&input, &cfg(Format::Org)).unwrap(); + assert!( + out.contains("x = 1. 2\nprint(\"stay\")\n"), + "source block body must not split on interior periods, got:\n{out}" + ); + assert!( + out.contains("#+BEGIN_NOTE\nQuoted one.\nQuoted two.\n#+END_NOTE\n"), + "NOTE fences stay; inner prose must split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext.\n"), + "prose after the blocks must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg(Format::Org)).unwrap(), out); + assert_sdiff_empty_wrap_only(&input, Format::Org, "org_blocks", 20); +} + +#[test] +fn markdown_fences_stay_and_following_splits() { + let input = fixture("md_fences.md"); + let regions = MarkdownParser.parse(&input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Code { body, .. } if body.contains("x = 1. 2") + )), + "fenced body must be Code, got {regions:?}" + ); + let out = format_text(&input, &cfg(Format::Markdown)).unwrap(); + assert!( + out.contains("```python\nx = 1. 2\nprint(\"stay\")\n```\n"), + "fence body must not split on interior periods, got:\n{out}" + ); + assert!( + out.contains("Before the fence.\nNext sentence.\n"), + "prose before the fence must still split, got:\n{out}" + ); + assert!( + out.contains("After the fence.\nNext sentence.\n"), + "prose after the fence must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg(Format::Markdown)).unwrap(), out); + assert_sdiff_empty_wrap_only(&input, Format::Markdown, "md_fences", 20); +} + +#[test] +fn wrapped_lists_hang_and_do_not_mint_a_block() { + let input = fixture("wrapped_lists.md"); + let out = format_text(&input, &cfg(Format::Markdown)).unwrap(); + assert!( + out.contains("- First item is a long sentence that should wrap at a modest width without minting a new block.\n Second sentence stays in the item.\n"), + "list item must hang the second sentence, got:\n{out}" + ); + assert!( + out.contains("After the list.\nNext.\n"), + "prose after the list must still split, got:\n{out}" + ); + let wrapped = format_text(&input, &wrap_cfg(Format::Markdown, 28)).unwrap(); + assert!( + wrapped.contains("- First item"), + "list marker must stay, got:\n{wrapped}" + ); + let markers = |s: &str| s.lines().filter(|l| l.starts_with("- ")).count(); + assert_eq!( + markers(&wrapped), + markers(&input), + "wrap must not mint a new list marker, got:\n{wrapped}" + ); + assert!( + !wrapped.contains("```") && !wrapped.contains("# "), + "wrap must not mint a fence or heading, got:\n{wrapped}" + ); + assert_eq!(format_text(&out, &cfg(Format::Markdown)).unwrap(), out); + assert_sdiff_empty_wrap_only(&input, Format::Markdown, "lists", 28); +} + +#[test] +fn default_clause_breaks_stay_off() { + let default = FormatConfig::default(); + assert!( + !default.clause_breaks, + "clause_breaks default must stay false" + ); + assert_eq!(default.max_width, 0, "max_width default must stay 0"); + let input = "Hello, world; still one sentence.\n"; + let out = format_text(input, &cfg(Format::Plaintext)).unwrap(); + assert_eq!( + out, input, + "default break rules must not split on comma or semicolon, got:\n{out}" + ); +} + +#[test] +fn snapper_and_snapper_fmt_binaries_exist() { + let snapper = Path::new(env!("CARGO_BIN_EXE_snapper")); + let snapper_fmt = snapper.with_file_name("snapper-fmt"); + assert!( + snapper.exists(), + "snapper binary missing at {}", + snapper.display() + ); + assert!( + snapper_fmt.exists(), + "snapper-fmt binary missing at {}", + snapper_fmt.display() + ); + for bin in [snapper, snapper_fmt.as_path()] { + let output = Command::new(bin) + .arg("--version") + .output() + .unwrap_or_else(|e| panic!("{} --version failed: {e}", bin.display())); + assert!( + output.status.success(), + "{} --version status {:?}", + bin.display(), + output.status + ); + let text = String::from_utf8_lossy(&output.stdout); + assert!( + text.contains("snapper"), + "{} --version must name snapper, got {text}", + bin.display() + ); + } +} + +#[test] +fn init_writes_working_git_hook() { + let dir = tempfile::tempdir().unwrap(); + let git_init = Command::new("git") + .args(["-c", "init.templateDir=", "init"]) + .current_dir(dir.path()) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .output() + .expect("git init"); + assert!( + git_init.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&git_init.stderr) + ); + fs::write(dir.path().join("note.md"), "One. Two.\n").unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_snapper")) + .arg("init") + .current_dir(dir.path()) + .output() + .expect("snapper init"); + assert!( + output.status.success(), + "snapper init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("pre-commit"), + "init must mention the hook, stderr={stderr}" + ); + + let hooks = Command::new("git") + .args(["rev-parse", "--git-path", "hooks"]) + .current_dir(dir.path()) + .output() + .expect("git rev-parse hooks"); + assert!(hooks.status.success()); + let hooks_rel = String::from_utf8_lossy(&hooks.stdout).trim().to_string(); + let hooks_dir = if Path::new(&hooks_rel).is_absolute() { + PathBuf::from(&hooks_rel) + } else { + dir.path().join(&hooks_rel) + }; + let hook = hooks_dir.join("pre-commit"); + assert!(hook.exists(), "init must write {}", hook.display()); + let hook_text = fs::read_to_string(&hook).unwrap(); + assert!( + hook_text.contains("Generated by `snapper init`"), + "hook must be the generated script, got:\n{hook_text}" + ); + assert!( + hook_text.contains("--native"), + "working hook must force native parsers, got:\n{hook_text}" + ); + assert!( + hook_text.contains("snapper-fmt"), + "hook must fall back to snapper-fmt, got:\n{hook_text}" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&hook).unwrap().permissions().mode(); + assert!(mode & 0o111 != 0, "hook must be executable, mode={mode:o}"); + } + + let filter = Command::new("git") + .args(["config", "--get", "filter.snapper.clean"]) + .current_dir(dir.path()) + .output() + .expect("git config"); + let clean = String::from_utf8_lossy(&filter.stdout); + assert!( + clean.contains("snapper-fmt --native --stdin-filepath %f"), + "init must configure a working clean filter, got {clean}" + ); + + let bin_dir = Path::new(env!("CARGO_BIN_EXE_snapper")) + .parent() + .expect("snapper bin dir"); + let mut paths = vec![bin_dir.to_path_buf()]; + if let Some(existing) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&existing)); + } + let path = std::env::join_paths(paths).expect("PATH join"); + let add = Command::new("git") + .args(["-c", "filter.snapper.clean=cat", "add", "note.md"]) + .current_dir(dir.path()) + .status() + .unwrap(); + assert!(add.success(), "git add note.md must succeed"); + let hook_run = Command::new(&hook) + .current_dir(dir.path()) + .env("PATH", &path) + .output() + .expect("run generated hook"); + assert!( + hook_run.status.success(), + "generated hook must run, stderr={} stdout={}", + String::from_utf8_lossy(&hook_run.stderr), + String::from_utf8_lossy(&hook_run.stdout) + ); + let staged = fs::read_to_string(dir.path().join("note.md")).unwrap(); + assert_eq!( + staged, "One.\nTwo.\n", + "working hook must format staged prose, got {staged:?}" + ); +} From fd93961bc68eb950a9f44ad7979848e4dd086363 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 05:09:16 -0500 Subject: [PATCH 02/37] fix(md): leftover type-6 void param track do not swallow following prose CommonMark type-6 void tags include param and track. End the HTML block on the tag line so leftover following prose stays Prose and still splits. --- CHANGELOG.md | 2 + docs/orgmode/reference/formats.org | 2 +- src/parser/markdown.rs | 59 +++++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3002973..afa1c1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) +#### Bug Fixes +- leftover type-6 void HTML (`param`/`track`) does not swallow following prose - - - diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index b65c41e..4f1d294 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -199,7 +199,7 @@ These tokens within prose are not split across lines: - Hard line breaks (two trailing spaces, or a trailing backslash) - HTML comments (==, including multiline); == / == remain pragmas - Closed type-6 and type-7 HTML blocks end at the matching close tag; following prose stays Prose -- Void type-6 tags (=
= / == / == / ==) end on the tag line; they have no closer, so leftover following prose stays Prose even without a blank +- Void type-6 tags (=
= / == / == / == / == / ==) end on the tag line; they have no closer, so leftover following prose stays Prose even without a blank - Pipe tables *** Code regions (fenced =```= / =~~~=) diff --git a/src/parser/markdown.rs b/src/parser/markdown.rs index 5637d31..938a302 100644 --- a/src/parser/markdown.rs +++ b/src/parser/markdown.rs @@ -255,11 +255,12 @@ fn is_html_block_tag(name: &str) -> bool { } /// Type-6 tags with no closer (HTML void elements on the CM type-6 list). -/// snapper-56tj: nest never hits 0, so leftover following prose was Structure. +/// snapper-56tj / snapper-18gq: nest never hits 0, so leftover following +/// prose was Structure. `param` / `track` are the remaining CM type-6 voids. fn is_html_void_type6(name: &str) -> bool { matches!( name.to_ascii_lowercase().as_str(), - "base" | "basefont" | "col" | "frame" | "hr" | "link" | "menuitem" + "base" | "basefont" | "col" | "frame" | "hr" | "link" | "menuitem" | "param" | "track" ) } @@ -5386,6 +5387,60 @@ mod tests { assert_eq!(format_text(&out, &cfg).unwrap(), out); } + /// snapper-18gq: CM type-6 void `param` / `track` were missing from + /// `is_html_void_type6`, so nest never hit 0 and leftover following + /// prose was Structure. + #[test] + fn html_type6_void_param_track_do_not_swallow_next_paragraph() { + for tag in ["", ""] { + let input = + format!("Intro sentence here. Another intro sentence.\n{tag}\nAfter html. Next.\n"); + let regions = MarkdownParser.parse(&input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s.contains(tag) + )), + "{tag} must be Structure, got {regions:?}" + ); + assert!( + !regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s.contains(tag) && s.contains("After html") + )), + "void type-6 {tag} must not swallow following prose, got {regions:?}" + ); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Prose(p) if p.contains("After html.") && p.contains("Next.") + )), + "following paragraph after {tag} must stay Prose: {regions:?}" + ); + } + } + + #[test] + fn leftover_html_type6_void_param_following_prose_still_splits() { + use crate::{FormatConfig, format_text}; + let cfg = FormatConfig { + format: crate::format::Format::Markdown, + ..Default::default() + } + .without_safety_backstops(); + let input = "Intro sentence here. Another intro sentence.\n\nAfter html. Next.\n"; + let out = format_text(input, &cfg).unwrap(); + assert!( + out.contains(""), + "void type-6 tag must stay, got:\n{out}" + ); + assert!( + out.contains("After html.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg).unwrap(), out); + } + #[test] fn html_script_block_is_code() { let regions = MarkdownParser.parse(ticket_html_blocks_fixture()); From 1eee3f7a2823eb690048d5d67807c70312ba57f7 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 05:09:50 -0500 Subject: [PATCH 03/37] fix(rst): leftover wrap-created simple-table border skip-cut Docutils isolate_simple_table borders have interior spaces, so Body.line is false. Skip-cut keeps ===== ===== off column 0 like a section adornment. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 1 + src/parser/rst.rs | 3 +- src/reflow.rs | 47 ++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afa1c1f..183883d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. See [conven ## Unreleased (main) #### Bug Fixes - leftover type-6 void HTML (`param`/`track`) does not swallow following prose +- leftover wrap-created RST simple-table border skip-cut - - - diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 4f1d294..961f974 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -246,6 +246,7 @@ These tokens within prose are not split across lines: - Literal blocks (text after =::= with indented or line-prefix-quoted content) - Section titles and underlines (=====, =-----=, etc.). A wrap cut that would park a solid Docutils =Body.line= adornment at column 0 skip-cuts the token onto the previous line (snapper-7xd3) + A wrap cut that would park a Docutils simple-table border at column 0 skip-cuts the token onto the previous line (snapper-2knx) - Field lists (=:Author:=, =:Date:=, etc.) - Empty list items, empty doctest openers, and empty field-list markers at EOL stay Structure - Comments (=..= without a directive) diff --git a/src/parser/rst.rs b/src/parser/rst.rs index 13cbd10..f11aed9 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -1500,7 +1500,8 @@ pub(crate) fn is_underline(line: &str) -> bool { /// RST simple-table border: `=` column groups separated by spaces /// (`===== =====`). A solid `=====` is a section underline, not a table. -fn is_simple_table_border(line: &str) -> bool { +/// Wrap skip-cut uses this so leftover `===== =====` cannot park at column 0. +pub(crate) fn is_simple_table_border(line: &str) -> bool { let t = line.trim(); if t.len() < 3 { return false; diff --git a/src/reflow.rs b/src/reflow.rs index 2112a0e..88aaecf 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -809,6 +809,11 @@ fn rst_opens_block(line: &str) -> bool { if crate::parser::rst::is_underline(t) { return true; } + // Docutils isolate_simple_table: `===== =====` has interior spaces, + // so is_underline is false. Wrap-created leftover must skip-cut. + if crate::parser::rst::is_simple_table_border(t) { + return true; + } false } @@ -3208,6 +3213,48 @@ They are endowed with reason and conscience and should act towards one another i assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); } + #[test] + fn wrap_created_rst_simple_table_border_is_not_a_block() { + // snapper-2knx: Docutils isolate_simple_table. Interior spaces + // mean is_underline is false, so skip-cut must name the border. + let result = wrap_fmt( + "The options are apples ===== =====", + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&result, &["===== =====", "====="]); + assert!( + result.contains("apples ===== ====="), + "RST skip-cut keeps the simple-table border:\n{result}" + ); + } + + #[test] + fn wrap_created_rst_simple_table_border_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples ===== =====\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines() + .any(|l| l.trim() == "===== =====" || l.trim() == "====="), + "wrap must not park a column-0 simple-table border:\n{out}" + ); + assert!( + out.contains("apples ===== ====="), + "skip-cut must keep ===== ===== with the previous line:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn wrap_created_org_table_pipe_is_not_a_block() { // H. Org | From 877657ba73d4fe74155d8bed39a1e684bf7ae984 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 05:10:10 -0500 Subject: [PATCH 04/37] fix(org): leftover bibtex eshell w3m plain links hang and wrap ol-bibtex, ol-eshell, and ol-w3m prefixes join the org-element plain-link table so leftover after the path hangs and wrap cannot park them at column 0. --- CHANGELOG.md | 1 + src/parser/org.rs | 6 +++++- src/reflow.rs | 3 +++ tests/org_file_token_punct.rs | 29 +++++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 183883d..4fc019b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. See [conven #### Bug Fixes - leftover type-6 void HTML (`param`/`track`) does not swallow following prose - leftover wrap-created RST simple-table border skip-cut +- leftover org bibtex/eshell/w3m plain links hang and wrap - - - diff --git a/src/parser/org.rs b/src/parser/org.rs index c7a1de3..1c4c17e 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -168,11 +168,13 @@ struct OpenGreater { /// or Unicode word characters (letters and digits). `:END:` is the closer. /// org-element plain-link prefixes at column 0. `file+emacs:` / /// `file+sys:` must be matched before `file:`. `man:` / `docview:` / -/// `shortdoc:` are ol.el built-ins (snapper-9aio). +/// `shortdoc:` are ol.el built-ins (snapper-9aio). `bibtex:` / `eshell:` / +/// `w3m:` are ol-bibtex / ol-eshell / ol-w3m (snapper-zmue). const ORG_PLAIN_LINK_PREFIXES: &[&str] = &[ "file+emacs:", "file+sys:", "file:", + "eshell:", "shell:", "elisp:", "help:", @@ -195,6 +197,8 @@ const ORG_PLAIN_LINK_PREFIXES: &[&str] = &[ "attachment:", "docview:", "shortdoc:", + "bibtex:", + "w3m:", "id:", ]; diff --git a/src/reflow.rs b/src/reflow.rs index 88aaecf..03d5e73 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -3340,6 +3340,9 @@ They are endowed with reason and conscience and should act towards one another i "man:org", "docview:/tmp/a.pdf", "shortdoc:org", + "bibtex:file.bib", + "eshell:ls", + "w3m:index.html", ] { let result = wrap_fmt( &format!("The options are apples {token} extra words here."), diff --git a/tests/org_file_token_punct.rs b/tests/org_file_token_punct.rs index b2d08be..a4f929e 100644 --- a/tests/org_file_token_punct.rs +++ b/tests/org_file_token_punct.rs @@ -431,6 +431,35 @@ fn leftover_shortdoc_plain_link_after_path_hangs_and_splits() { assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); } +#[test] +fn leftover_bibtex_eshell_w3m_plain_link_after_path_hangs_and_splits() { + // snapper-zmue: ol-bibtex / ol-eshell / ol-w3m prefixes were missing. + for (path, needle) in [ + ("bibtex:file.bib leftover. Next.\n", "bibtex:file.bib"), + ("eshell:ls leftover. Next.\n", "eshell:ls"), + ("w3m:index.html leftover. Next.\n", "w3m:index.html"), + ] { + let input = format!("{path}After. Next.\n"); + let regions = OrgParser.parse(&input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.contains(needle))), + "{needle} path must stay Structure, got {regions:?}" + ); + let out = format_text(&input, &org_cfg()).unwrap(); + assert!( + !out.contains(&format!("{needle} leftover. Next.")), + "leftover after {needle} must still split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split after {needle}, got:\n{out}" + ); + assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); + } +} + #[test] fn org_file_token_same_line_splits() { let two_line = "See file:/tmp/foo.\nNext sentence.\n"; From 7502a52f8c003fe2324fb1d917e745e12223fb46 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 08:39:58 -0500 Subject: [PATCH 05/37] fix(rst): leftover wrap-created grid-table top skip-cut Docutils grid-table tops are structure. A wrap cut that parked +---+ at column 0 minted a table. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 1 + src/parser/rst.rs | 2 +- src/reflow.rs | 47 ++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc019b..13b431d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover wrap-created RST grid-table top skip-cut - leftover type-6 void HTML (`param`/`track`) does not swallow following prose - leftover wrap-created RST simple-table border skip-cut - leftover org bibtex/eshell/w3m plain links hang and wrap diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 961f974..dd8d6f4 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -247,6 +247,7 @@ These tokens within prose are not split across lines: - Section titles and underlines (=====, =-----=, etc.). A wrap cut that would park a solid Docutils =Body.line= adornment at column 0 skip-cuts the token onto the previous line (snapper-7xd3) A wrap cut that would park a Docutils simple-table border at column 0 skip-cuts the token onto the previous line (snapper-2knx) + A wrap cut that would park a Docutils grid-table top (=+---+=) at column 0 skip-cuts the token onto the previous line - Field lists (=:Author:=, =:Date:=, etc.) - Empty list items, empty doctest openers, and empty field-list markers at EOL stay Structure - Comments (=..= without a directive) diff --git a/src/parser/rst.rs b/src/parser/rst.rs index f11aed9..fd3dd66 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -1264,7 +1264,7 @@ pub(crate) fn rst_line_block_marker_len(line: &str) -> Option { } /// Docutils `Body.grid_table_top_pat`: `\+-[-+]+-\+ *$`. -fn is_rst_grid_table_top(trimmed: &str) -> bool { +pub(crate) fn is_rst_grid_table_top(trimmed: &str) -> bool { GRID_TABLE_TOP_RE.is_match(trimmed) } diff --git a/src/reflow.rs b/src/reflow.rs index 03d5e73..4a9315b 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -814,6 +814,11 @@ fn rst_opens_block(line: &str) -> bool { if crate::parser::rst::is_simple_table_border(t) { return true; } + // Docutils grid_table_top: `+---+` is not an underline and not a + // simple-table border. A wrap cut that parks it at column 0 mints a table. + if crate::parser::rst::is_rst_grid_table_top(t) { + return true; + } false } @@ -3255,6 +3260,48 @@ They are endowed with reason and conscience and should act towards one another i assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); } + #[test] + fn wrap_created_rst_grid_table_top_is_not_a_block() { + // Docutils Body.grid_table_top. Width 23 would park +---+ at column 0. + for token in ["+---+", "+---+---+"] { + let result = wrap_fmt( + &format!("The options are apples {token}"), + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&result, &[token]); + assert!( + result.contains(&format!("apples {token}")), + "RST skip-cut keeps the {token} grid top:\n{result}" + ); + } + } + + #[test] + fn wrap_created_rst_grid_table_top_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples +---+\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines().any(|l| l.trim() == "+---+"), + "wrap must not park a column-0 grid-table top:\n{out}" + ); + assert!( + out.contains("apples +---+"), + "skip-cut must keep +---+ with the previous line:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn wrap_created_org_table_pipe_is_not_a_block() { // H. Org | From d4e187a0e9b42c112d794eac31619f58fbe672ef Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 09:01:31 -0500 Subject: [PATCH 06/37] fix(rst): leftover wrap-created jinja and anonymous target skip-cut A Jinja statement and a Docutils anonymous target are structure. A wrap cut that parked either at column 0 minted a block. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 1 + src/reflow.rs | 50 ++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13b431d..bfd84c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover wrap-created RST jinja statement and anonymous target skip-cut - leftover wrap-created RST grid-table top skip-cut - leftover type-6 void HTML (`param`/`track`) does not swallow following prose - leftover wrap-created RST simple-table border skip-cut diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index dd8d6f4..8af10a2 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -248,6 +248,7 @@ These tokens within prose are not split across lines: A wrap cut that would park a solid Docutils =Body.line= adornment at column 0 skip-cuts the token onto the previous line (snapper-7xd3) A wrap cut that would park a Docutils simple-table border at column 0 skip-cuts the token onto the previous line (snapper-2knx) A wrap cut that would park a Docutils grid-table top (=+---+=) at column 0 skip-cuts the token onto the previous line + A wrap cut that would park a Jinja statement (=\{% ... %\}=) or a Docutils anonymous target (=__ uri=) at column 0 skip-cuts the token onto the previous line - Field lists (=:Author:=, =:Date:=, etc.) - Empty list items, empty doctest openers, and empty field-list markers at EOL stay Structure - Comments (=..= without a directive) diff --git a/src/reflow.rs b/src/reflow.rs index 4a9315b..61170db 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -819,6 +819,16 @@ fn rst_opens_block(line: &str) -> bool { if crate::parser::rst::is_rst_grid_table_top(t) { return true; } + // sphinx-jinja / Jinja2 `{% ... %}`. Not an underline. A wrap cut + // that parks the statement at column 0 mints a structure line. + if crate::parser::rst::is_rst_jinja_statement(t) { + return true; + } + // Docutils anonymous hyperlink target `__` / `__ uri`. A solid `__` + // is already an underline; `__ uri` is not. + if crate::parser::rst::is_rst_anonymous_target(t) { + return true; + } false } @@ -3302,6 +3312,46 @@ They are endowed with reason and conscience and should act towards one another i assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); } + #[test] + fn wrap_created_rst_jinja_statement_is_not_a_block() { + for token in ["{%endfor%}", "{%-endfor-%}", "{%+endfor+%}"] { + let result = wrap_fmt( + &format!("The options are apples {token}"), + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&result, &[token]); + assert!( + result.contains(&format!("apples {token}")), + "RST skip-cut keeps the {token} jinja statement:\n{result}" + ); + } + let spaced = wrap_fmt( + r#"The options are apples {% set x = 1 %}"#, + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&spaced, &["{% set x = 1 %}", "{%"]); + assert!( + spaced.contains("{%"), + "jinja opener stays with the previous line:\n{spaced}" + ); + } + + #[test] + fn wrap_created_rst_anonymous_target_is_not_a_block() { + let result = wrap_fmt( + "The options are apples __ https://x.test", + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&result, &["__ https://x.test", "__"]); + assert!( + result.contains("apples __"), + "anonymous target marker stays with the previous line:\n{result}" + ); + } + #[test] fn wrap_created_org_table_pipe_is_not_a_block() { // H. Org | From eb4aafc8020576f00c063b4e19ac14e251e8fdb3 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 09:21:28 -0500 Subject: [PATCH 07/37] fix(rst): leftover wrap-created plus-fragment skip-cut A column-0 line that starts with + and is not a grid-table top is still structure. Skip-cut keeps +===+ and +foo with the previous line. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 1 + src/reflow.rs | 49 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd84c6..bf7f5ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover wrap-created RST plus-fragment skip-cut - leftover wrap-created RST jinja statement and anonymous target skip-cut - leftover wrap-created RST grid-table top skip-cut - leftover type-6 void HTML (`param`/`track`) does not swallow following prose diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 8af10a2..0a9fda4 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -249,6 +249,7 @@ These tokens within prose are not split across lines: A wrap cut that would park a Docutils simple-table border at column 0 skip-cuts the token onto the previous line (snapper-2knx) A wrap cut that would park a Docutils grid-table top (=+---+=) at column 0 skip-cuts the token onto the previous line A wrap cut that would park a Jinja statement (=\{% ... %\}=) or a Docutils anonymous target (=__ uri=) at column 0 skip-cuts the token onto the previous line + A wrap cut that would park a leftover plus fragment (=+===+=, =+foo=) at column 0 skip-cuts the token onto the previous line - Field lists (=:Author:=, =:Date:=, etc.) - Empty list items, empty doctest openers, and empty field-list markers at EOL stay Structure - Comments (=..= without a directive) diff --git a/src/reflow.rs b/src/reflow.rs index 61170db..d487533 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -829,6 +829,12 @@ fn rst_opens_block(line: &str) -> bool { if crate::parser::rst::is_rst_anonymous_target(t) { return true; } + // Leftover `+` fragment. A grid-table top is `+---+` and a list + // marker is `+ `. Any other column-0 `+` line (`+===+`, `+foo`) + // is Structure, including the words after the fragment. + if t.starts_with('+') { + return true; + } false } @@ -3352,6 +3358,49 @@ They are endowed with reason and conscience and should act towards one another i ); } + #[test] + fn wrap_created_rst_plus_fragment_is_not_a_block() { + // Leftover `+` that is not `grid_table_top` (`+---+`). Width 23 + // would park `+===+` at column 0 and freeze the rest of the line. + for token in ["+===+", "+===+===+", "+foo"] { + let result = wrap_fmt( + &format!("The options are apples {token} extra words."), + 23, + crate::format::Format::Rst, + ); + assert_no_col0_block(&result, &[token, "+"]); + assert!( + result.contains(&format!("apples {token}")), + "RST skip-cut keeps the {token} plus fragment:\n{result}" + ); + } + } + + #[test] + fn wrap_created_rst_plus_fragment_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples +===+ extra words.\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines().any(|l| l.starts_with('+')), + "wrap must not park a column-0 plus fragment:\n{out}" + ); + assert!( + out.contains("apples +===+"), + "skip-cut must keep +===+ with the previous line:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn wrap_created_org_table_pipe_is_not_a_block() { // H. Org | From b18698693553f3a1212f107aea3fc586e7edc8ac Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 09:26:47 -0500 Subject: [PATCH 08/37] fix(md): leftover closing fence rejects a non-space tail CommonMark allows only spaces or tabs after a closing fence. A marker with any other tail stays inside the code block. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 +- src/parser/markdown.rs | 97 ++++++++++++++++++++++++++++-- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf7f5ac..af258fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover markdown closing fence with a non-space tail stays inside the code block - leftover wrap-created RST plus-fragment skip-cut - leftover wrap-created RST jinja statement and anonymous target skip-cut - leftover wrap-created RST grid-table top skip-cut diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 0a9fda4..0fba94c 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -206,7 +206,8 @@ These tokens within prose are not split across lines: :PROPERTIES: :CUSTOM_ID: markdown-code :END: -- Opening and closing fence lines are structure +- Opening and closing fence lines are structure. + A closing fence may be followed only by spaces or tabs; any other tail stays inside the code block - Indented fence bodies preserve indentation on reflowed comment lines - Language from the fence info string selects =[code.]=; unknown or missing lang passes the body through unchanged (unless =--format-code= is not applicable without a formatter entry) diff --git a/src/parser/markdown.rs b/src/parser/markdown.rs index 938a302..755c7d8 100644 --- a/src/parser/markdown.rs +++ b/src/parser/markdown.rs @@ -1534,18 +1534,26 @@ fn quoted_setext_ok(title: &str, underline: &str, prev: Option<&str>) -> bool { } /// Closing fence: same marker char, length at least the opener, indent at -/// most `max(3, opener_indent)`. CommonMark allows 0–3 spaces on a closer; -/// list-nested openers keep their own indent so a matching 4-space closer -/// still ends the block. Deeper inner fences stay content. +/// most `max(3, opener_indent)`. CommonMark 0.31.2 allows 0–3 spaces on a +/// closer and only spaces or tabs after the marker. List-nested openers +/// keep their own indent so a matching 4-space closer still ends the +/// block. Deeper inner fences stay content. A tail such as +/// `` ``` not a closer `` is still code. fn is_closing_fence(line: &str, fence_marker: &str, opener_indent: usize) -> bool { if line_indent(line) > opener_indent.max(3) { return false; } - let Some(caps) = FENCED_CODE_RE.captures(line.trim_start()) else { + let trimmed = line.trim_start(); + let Some(caps) = FENCED_CODE_RE.captures(trimmed) else { return false; }; let marker = caps.get(1).unwrap().as_str(); - marker.chars().next() == fence_marker.chars().next() && marker.len() >= fence_marker.len() + if marker.chars().next() != fence_marker.chars().next() || marker.len() < fence_marker.len() { + return false; + } + trimmed[marker.len()..] + .bytes() + .all(|b| b == b' ' || b == b'\t') } /// True when `s` contains an unescaped `|` (GFM table cell separator). @@ -3334,6 +3342,85 @@ mod tests { } } + #[test] + fn closing_fence_tail_is_still_code() { + // CommonMark 0.31.2: a closing fence may be followed only by + // spaces or tabs. ` ``` not a closer` stays inside the block. + for closer in ["``` not a closer", "~~~ not a closer", "```not"] { + let fence = &closer[..3]; + let input = format!( + "{fence}\nKeep this sentence in the fence. Keep this one too.\n{closer}\nStill inside the fence. Must not split.\n{fence}\n" + ); + let regions = MarkdownParser.parse(&input); + match ®ions[0] { + Region::Code { body, footer, .. } => { + assert!( + body.contains(closer), + "tailed fence {closer:?} stays in the body, got {body:?}" + ); + assert!( + body.contains("Still inside the fence. Must not split.\n"), + "text after {closer:?} stays in the body, got {body:?}" + ); + assert_eq!(footer.as_str(), format!("{fence}\n").as_str()); + } + other => panic!("tailed fence {closer:?} must not close, got {other:?}"), + } + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("Must not split"))), + "fence body must not become prose for {closer:?}: {regions:?}" + ); + } + } + + #[test] + fn closing_fence_tail_is_identity_under_format() { + use crate::format::Format; + use crate::{FormatConfig, format_text}; + + let input = concat!( + "```\n", + "Keep this sentence in the fence. Keep this one too.\n", + "``` not a closer\n", + "Still inside the fence. Must not split.\n", + "```\n", + ); + let cfg = FormatConfig { + format: Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = format_text(input, &cfg).unwrap(); + assert!( + out.contains("Still inside the fence. Must not split.\n"), + "fence body must not sentence-split, got:\n{out}" + ); + assert_eq!(format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn closing_fence_trailing_blank_still_closes() { + for closer in ["``` ", "```\t", "~~~~ "] { + let fence = if closer.starts_with('`') { + "```" + } else { + "~~~~" + }; + let input = format!("{fence}\ncode\n{closer}\n"); + let regions = MarkdownParser.parse(&input); + match ®ions[0] { + Region::Code { body, footer, .. } => { + assert_eq!(body, "code\n"); + assert_eq!(footer.as_str(), format!("{closer}\n").as_str()); + } + other => panic!("blank tail {closer:?} must close, got {other:?}"), + } + } + } + #[test] fn frontmatter_preserved() { let input = "---\ntitle: Test\nauthor: Someone\n---\n\nSome text."; From 9554a38a5f0c1e8ac740ed761f6448e2fa226a07 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 09:30:44 -0500 Subject: [PATCH 09/37] fix(org): leftover item bullet allows a tab Emacs org-item-re takes a space or a tab after the bullet. A tab was prose, so the item joined the paragraph above it. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 2 +- src/parser/org.rs | 47 ++++++++++++++++++++++++++++-- src/reflow.rs | 12 ++++++-- 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af258fc..f19c746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover org item bullet allows a tab - leftover markdown closing fence with a non-space tail stays inside the code block - leftover wrap-created RST plus-fragment skip-cut - leftover wrap-created RST jinja statement and anonymous target skip-cut diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 0fba94c..ec540e7 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -32,7 +32,7 @@ The classification depends on the format. - Comment lines (starting with =#= but not =#+= ) - Column-0 diary-sexp lines (=%%(...)=; org-element-diary-sexp-parser) - Full headline lines (stars, optional TODO keyword, and title text) -- List item markers (=-=, =+=, =1.=); continuation sentences hang at the marker width +- List item markers (=-=, =+=, =1.=, including a tab after the bullet); continuation sentences hang at the marker width - LaTeX environments (=\begin{equation}= ... =\end{equation}=, =\begin{align}=, etc.). An unmatched =\begin{env}= is a paragraph (org-element latex-environment-parser) - Display math (=\[= ... =\]=) - Line breaks (org-element-line-break-parser: =\\= plus optional spaces or tabs at end of line) diff --git a/src/parser/org.rs b/src/parser/org.rs index 1c4c17e..f2776c4 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -9,12 +9,13 @@ use crate::parser::{ static HEADLINE_RE: LazyLock = LazyLock::new(|| Regex::new(r"^(\*+\s+(?:TODO\s+|DONE\s+|NEXT\s+|WAIT\s+)?)(.*)$").unwrap()); -/// Org unordered/ordered marker plus a trailing space or EOL. +/// Org unordered/ordered marker plus a trailing space, tab, or EOL. /// Emacs 30.2 `org-item-re` is bullet then `[ \t]+` or `$` (GitHub #320). /// org-syntax 4.2.6 / orgize: `*` is a bullet only when indent > 0; /// column-0 `*` is a headline (`HEADLINE_RE` is matched first). -static LIST_ITEM_RE: LazyLock = - LazyLock::new(|| Regex::new(r"^(\s*(?:[-+]|\d+[.)])(?: |$)|[ \t]+\*(?: |$))(.*)$").unwrap()); +static LIST_ITEM_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(\s*(?:[-+]|\d+[.)])(?:[ \t]|$)|[ \t]+\*(?:[ \t]|$))(.*)$").unwrap() +}); /// org-element-export-snippet-parser prefix: `@@BACKEND:VALUE@@`. /// Backend is `[-A-Za-z0-9]+`. Value runs to the next `@@` (may contain @@ -1672,6 +1673,46 @@ mod tests { ); } + #[test] + fn tab_after_item_bullet_stays_a_list() { + // Emacs org-item-re: bullet then [ \t]+ or EOL. + let input = "See the note below and keep reading.\n-\tTab after the bullet. Second sentence stays in the item.\n"; + let regions = OrgParser.parse(input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s == "-\t")), + "tab after a dash is the item marker, got {regions:?}" + ); + assert!( + regions.iter().any(|r| { + matches!(r, Region::Prose(p) if p.contains("Tab after the bullet.") && !p.contains("keep reading")) + }), + "item body stays apart from the intro, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Org, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("-\tTab after the bullet.\n"), + "item body still splits, got:\n{out}" + ); + assert!( + out.contains("Second sentence stays in the item."), + "second sentence stays in the item, got:\n{out}" + ); + assert!( + !out.lines() + .any(|l| l.contains("keep reading") && l.contains("Tab after")), + "tab item must not join the intro, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn numbered_empty_item_at_eol_is_structure_not_uax() { let input = diff --git a/src/reflow.rs b/src/reflow.rs index d487533..6f90845 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -452,7 +452,8 @@ fn ordered_list_start(text: &str) -> bool { if i == 0 || i > 9 { return false; } - matches!(bytes.get(i), Some(b'.') | Some(b')')) && matches!(bytes.get(i + 1), Some(b' ') | None) + matches!(bytes.get(i), Some(b'.') | Some(b')')) + && matches!(bytes.get(i + 1), Some(b' ') | Some(b'\t') | None) } fn thematic_or_setext_token(text: &str) -> bool { @@ -674,7 +675,14 @@ fn org_opens_block(line: &str) -> bool { } // Emacs org-item-re: `-`/`+` then space or EOL. Lone markers must // not be wrap-created at column 0 (GitHub #320). - if t == "-" || t == "+" || t.starts_with("- ") || t.starts_with("+ ") { + // Emacs org-item-re: bullet then space, tab, or EOL. + if t == "-" + || t == "+" + || t.starts_with("- ") + || t.starts_with("+ ") + || t.starts_with("-\t") + || t.starts_with("+\t") + { return true; } if t.starts_with("$$") { From 546309ead3fc5c1b06eae34cdc59798f40137c66 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 09:33:28 -0500 Subject: [PATCH 10/37] fix(latex): leftover wrap-created sectioning command skip-cut An optional short title and the KOMA commands addsec, addchap, and addpart are sectioning lines. Skip-cut keeps them off column 0. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 +- src/reflow.rs | 53 ++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f19c746..78bf539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover wrap-created LaTeX sectioning command skip-cut - leftover org item bullet allows a tab - leftover markdown closing fence with a non-space tail stays inside the code block - leftover wrap-created RST plus-fragment skip-cut diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index ec540e7..933c994 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -98,7 +98,8 @@ These tokens within prose are not split across lines: - =\iffalse= through =\fi= (tree-sitter =block_comment=) - Comment lines (starting with =%=) - =\end{document}= -- Full sectioning command lines (=\section{...}=, =\subsection{...}=, and friends, including title text) +- Full sectioning command lines (=\section{...}=, =\subsection{...}=, KOMA =\addsec= / =\addchap= / =\addpart=, including an optional short title and the title text). + A wrap cut that would park one of those commands at column 0 skip-cuts the token onto the previous line *** Code regions (=minted=, =lstlisting=, =verbatim=, =comment=, =Piton=) :PROPERTIES: diff --git a/src/reflow.rs b/src/reflow.rs index 6f90845..a5b5353 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -776,11 +776,16 @@ fn latex_opens_block(line: &str) -> bool { "\\subsubsection", "\\paragraph", "\\subparagraph", + "\\addsec", + "\\addchap", + "\\addpart", ]; for cmd in CMDS { if let Some(after) = t.strip_prefix(cmd) { + // Optional short title is `[...]` before the brace (KOMA too). if after.is_empty() || after.starts_with('{') + || after.starts_with('[') || after.starts_with('*') || after.starts_with(' ') { @@ -3184,6 +3189,54 @@ They are endowed with reason and conscience and should act towards one another i ); } + #[test] + fn wrap_created_latex_section_optional_and_koma_are_not_blocks() { + // Optional [short] and KOMA \addsec/\addchap/\addpart are sectioning + // lines. Width 23 would park the command at column 0. + for token in [ + "\\section[Short]{Long}", + "\\addsec{Title}", + "\\addchap{Title}", + "\\addpart{Title}", + ] { + let result = wrap_fmt( + &format!("The options are apples {token} extra words."), + 23, + crate::format::Format::Latex, + ); + assert_no_col0_block(&result, &[token]); + assert!( + result.contains(&format!("apples {token}")), + "LaTeX skip-cut keeps {token}:\n{result}" + ); + } + } + + #[test] + fn wrap_created_latex_section_optional_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples \\section[Short]{Long} extra words.\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines().any(|l| l.starts_with("\\section")), + "wrap must not park a column-0 section command:\n{out}" + ); + assert!( + out.contains("apples \\section[Short]{Long}"), + "skip-cut must keep the section command:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn wrap_created_rst_directive_is_not_a_block() { // G. RST .. From 2aa1f69b5f0fdd220a96359005fbb7ae16991d1d Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 09:35:50 -0500 Subject: [PATCH 11/37] fix(md): leftover short setext underline is escaped A line of one or two equals or hyphens is a setext underline. Wrap escapes it instead of leaving it at column 0. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 +- src/reflow.rs | 54 ++++++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78bf539..ddb0ef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover short markdown setext underline is escaped - leftover wrap-created LaTeX sectioning command skip-cut - leftover org item bullet allows a tab - leftover markdown closing fence with a non-space tail stays inside the code block diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 933c994..0eb40ee 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -188,7 +188,8 @@ These tokens within prose are not split across lines: - Front matter (=---= or =+++= delimited at file start) - Full ATX heading lines (=#= … =######= including title text) - Empty ATX headings (a mark line with no title text) stay Structure -- Setext headings (title line plus ====== or ------ underline) +- Setext headings (title line plus a line of one or more = or - characters). + A wrap cut that would park a short underline at column 0 escapes it - List item markers (=-=, =*=, =+=, =1.=); continuation sentences hang at the marker width - Empty list markers (including quoted) stay Structure - Definition-list terms and =: = markers (pulldown =ENABLE_DEFINITION_LIST=); the body hangs at the marker width diff --git a/src/reflow.rs b/src/reflow.rs index a5b5353..eb88365 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -458,9 +458,15 @@ fn ordered_list_start(text: &str) -> bool { fn thematic_or_setext_token(text: &str) -> bool { let first = text.split_whitespace().next().unwrap_or(""); - if first.len() >= 3 { + if !first.is_empty() { let b = first.as_bytes()[0]; - if matches!(b, b'-' | b'=' | b'*' | b'_') && first.bytes().all(|c| c == b) { + let solid = first.bytes().all(|c| c == b); + // CommonMark setext underlines are one or more `=` or `-`. + // Thematic breaks (`*`, `_`, and `-` of length >= 3) stay below. + if solid && matches!(b, b'=' | b'-') { + return true; + } + if solid && first.len() >= 3 && matches!(b, b'*' | b'_') { return true; } } @@ -3582,6 +3588,50 @@ They are endowed with reason and conscience and should act towards one another i } } + #[test] + fn wrap_created_md_short_setext_is_escaped() { + // CommonMark setext underlines are one or more = or -. A run of + // length 1 or 2 is not a thematic break, and it still promotes + // the previous line when it is the whole next line. + for token in ["=", "==", "--"] { + let result = wrap_fmt( + &format!("The options are apples {token}"), + 23, + crate::format::Format::Markdown, + ); + assert!( + !result.lines().any(|l| l.trim() == token), + "wrap must not leave a column-0 setext underline {token:?}:\n{result}" + ); + let escaped = format!("\\{token}"); + assert!( + result.lines().any(|l| l.trim() == escaped), + "short setext {token:?} must be markdown-escaped:\n{result}" + ); + } + } + + #[test] + fn wrap_created_md_short_setext_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples ==\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines().any(|l| l.trim() == "=="), + "wrap must not emit a setext underline:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn wrap_created_md_empty_list_marker_is_not_a_block() { for token in ["-", "*", "+"] { From 506b818342fe77c44bdc82cdad2a1bddc473e453 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 09:37:36 -0500 Subject: [PATCH 12/37] fix(org): leftover ordered marker past nine digits skip-cut Org ordered markers are one or more digits. Wrap no longer uses the CommonMark nine-digit cap, so a long marker stays off column 0. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 4 ++- src/reflow.rs | 45 +++++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb0ef6..a9e16d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover org ordered marker past nine digits skip-cut - leftover short markdown setext underline is escaped - leftover wrap-created LaTeX sectioning command skip-cut - leftover org item bullet allows a tab diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 0eb40ee..dc9ba03 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -32,7 +32,9 @@ The classification depends on the format. - Comment lines (starting with =#= but not =#+= ) - Column-0 diary-sexp lines (=%%(...)=; org-element-diary-sexp-parser) - Full headline lines (stars, optional TODO keyword, and title text) -- List item markers (=-=, =+=, =1.=, including a tab after the bullet); continuation sentences hang at the marker width +- List item markers (=-=, =+=, =1.=, including a tab after the bullet). + An ordered marker is one or more digits. A wrap cut that would park it at column 0 skip-cuts the token onto the previous line. + Continuation sentences hang at the marker width - LaTeX environments (=\begin{equation}= ... =\end{equation}=, =\begin{align}=, etc.). An unmatched =\begin{env}= is a paragraph (org-element latex-environment-parser) - Display math (=\[= ... =\]=) - Line breaks (org-element-line-break-parser: =\\= plus optional spaces or tabs at end of line) diff --git a/src/reflow.rs b/src/reflow.rs index eb88365..f938fb4 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -443,13 +443,15 @@ fn is_ordered_list_marker(word: &str) -> bool { (1..=9).contains(&digits.len()) && digits.iter().all(|b| b.is_ascii_digit()) } +/// Org ordered item. Emacs `org-item-re` is `[0-9]+`, not CommonMark's +/// 1–9 digit cap (`md_ordered_list_start`). fn ordered_list_start(text: &str) -> bool { let bytes = text.as_bytes(); let mut i = 0; while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } - if i == 0 || i > 9 { + if i == 0 { return false; } matches!(bytes.get(i), Some(b'.') | Some(b')')) @@ -3651,6 +3653,47 @@ They are endowed with reason and conscience and should act towards one another i } } + #[test] + fn wrap_created_org_long_ordered_marker_is_not_a_block() { + // Org ordered markers have no digit cap. Width 23 would park + // 1234567890. at column 0 and the next parse reads an item. + let result = wrap_fmt( + "The options are apples 1234567890. extra words here.", + 23, + crate::format::Format::Org, + ); + assert_no_col0_block(&result, &["1234567890.", "1234567890"]); + assert!( + result.contains("apples 1234567890."), + "Org skip-cut keeps the long marker:\n{result}" + ); + } + + #[test] + fn wrap_created_org_long_ordered_marker_is_identity_under_format() { + let cfg = crate::FormatConfig { + format: crate::format::Format::Org, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let input = "The options are apples 1234567890. extra words here.\n\nAfter. Next.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines().any(|l| l.starts_with("1234567890.")), + "wrap must not park a column-0 ordered marker:\n{out}" + ); + assert!( + out.contains("apples 1234567890."), + "skip-cut must keep the marker:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn wrap_created_org_empty_list_marker_is_not_a_block() { for token in ["-", "+", "1.", "1)"] { From f2143aee9666a45803d34c7dd53f16090ecb9486 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 12:12:21 -0500 Subject: [PATCH 13/37] fix(rst): leftover bare colon paragraph opens a literal block A paragraph whose text is only :: is a literal marker. A two-character title underlined with :: stays a section. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 +- src/parser/rst.rs | 91 +++++++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e16d6..f678cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover bare colon paragraph opens an RST literal block - leftover org ordered marker past nine digits skip-cut - leftover short markdown setext underline is escaped - leftover wrap-created LaTeX sectioning command skip-cut diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index dc9ba03..d31e4d8 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -248,7 +248,8 @@ These tokens within prose are not split across lines: - Container directive openers (=.. note::=, =.. warning::=, =.. figure::=, =.. topic::=, =.. sidebar::=, =.. container::=, leftover =.. parsed-literal::=, =.. epigraph::=, =.. highlights::=, =.. pull-quote::=, =.. compound::=, =.. header::=, =.. footer::=) and their =:option:= fields; the indented body is prose - Same-line body after =::= on those containers, leftover =.. parsed-literal::=, =.. header::=, =.. footer::=, =.. |name| replace::=, and =.. meta::= fields hangs and splits. Flush bibliographic fields close a meta leftover -- Literal blocks (text after =::= with indented or line-prefix-quoted content) +- Literal blocks (text after =::= with indented or line-prefix-quoted content). + A paragraph whose text is only =::= is that marker. A two-character title underlined with =::= stays a section - Section titles and underlines (=====, =-----=, etc.). A wrap cut that would park a solid Docutils =Body.line= adornment at column 0 skip-cuts the token onto the previous line (snapper-7xd3) A wrap cut that would park a Docutils simple-table border at column 0 skip-cuts the token onto the previous line (snapper-2knx) diff --git a/src/parser/rst.rs b/src/parser/rst.rs index fd3dd66..bfe7f17 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -482,8 +482,13 @@ fn parse_line_based(input: &str) -> Vec { continue; } - // Section underline - if is_underline(line_text) { + // Section underline. A paragraph whose text is only `::`, at the + // start of the document or after a blank, is a literal marker + // (Docutils), not a two-colon adornment. `Hi\n::` has a non-blank + // previous line, so `is_underline` still promotes that title. + let bare_colon_literal = + line_text.trim() == "::" && (i == 0 || lines[i - 1].text.trim().is_empty()); + if is_underline(line_text) && !bare_colon_literal { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); regions.push(SpannedRegion::structure(input, line.span())); i += 1; @@ -1848,6 +1853,88 @@ mod tests { assert!(structure_count >= 3); } + #[test] + fn bare_colon_paragraph_opens_a_literal_block() { + // Docutils: a paragraph whose text is only `::` is an empty + // paragraph plus a literal block. It is not a section underline. + let input = "::\n\n kept verbatim. Not wrapped.\n\nAfter. Next.\n"; + let regions = RstParser.parse(input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.trim() == "::")), + "bare :: stays the literal marker, got {regions:?}" + ); + assert!( + regions.iter().any( + |r| matches!(r, Region::Structure(s) if s.contains("kept verbatim. Not wrapped.")) + ), + "indented body stays literal structure, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("kept verbatim"))), + "literal body must not be a block quote, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("kept verbatim. Not wrapped.\n"), + "literal body must not sentence-split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the literal must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn two_char_colon_underline_stays_a_section() { + let input = "Hi\n::\n\nAfter. Next.\n"; + let regions = RstParser.parse(input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.trim() == "Hi")), + "two-character title stays structure, got {regions:?}" + ); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.trim() == "::")), + "colon underline stays structure, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("kept") || p.trim() == "Hi")), + "title must not become prose, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("Hi\n::\n"), + "section adornment must stay, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the section must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn quoted_literal_block_lines_are_structure() { let input = concat!( From c92d4b4d00e1e9f51b4f4f1eb985dd713d543b76 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 12:27:05 -0500 Subject: [PATCH 14/37] fix(rst): leftover anonymous target keeps its indented link block Docutils keeps the indented block under an anonymous target until a blank. That block was a quote, so its sentences split. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 +- src/parser/rst.rs | 64 ++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f678cd3..4c2657f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover RST anonymous target keeps its indented link block - leftover bare colon paragraph opens an RST literal block - leftover org ordered marker past nine digits skip-cut - leftover short markdown setext underline is escaped diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index d31e4d8..ce43fbf 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -254,7 +254,8 @@ These tokens within prose are not split across lines: A wrap cut that would park a solid Docutils =Body.line= adornment at column 0 skip-cuts the token onto the previous line (snapper-7xd3) A wrap cut that would park a Docutils simple-table border at column 0 skip-cuts the token onto the previous line (snapper-2knx) A wrap cut that would park a Docutils grid-table top (=+---+=) at column 0 skip-cuts the token onto the previous line - A wrap cut that would park a Jinja statement (=\{% ... %\}=) or a Docutils anonymous target (=__ uri=) at column 0 skip-cuts the token onto the previous line + A wrap cut that would park a Jinja statement (=\{% ... %\}=) or a Docutils anonymous target (=__ uri=) at column 0 skip-cuts the token onto the previous line. + The indented link block under that target stays structure until a blank A wrap cut that would park a leftover plus fragment (=+===+=, =+foo=) at column 0 skip-cuts the token onto the previous line - Field lists (=:Author:=, =:Date:=, etc.) - Empty list items, empty doctest openers, and empty field-list markers at EOL stay Structure diff --git a/src/parser/rst.rs b/src/parser/rst.rs index bfe7f17..07f1e1c 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -79,6 +79,10 @@ fn parse_line_based(input: &str) -> Vec { // A blank closes the comment (GitHub #176). let mut in_comment = false; let mut comment_indent: usize = 0; + // Anonymous-target link block: indented lines after `__` / `__ uri` + // stay Structure until a blank (Docutils get_first_known_indented). + let mut in_anon_block = false; + let mut anon_indent: usize = 0; // Hang column of the current list item (`- ` → 2) or block quote. // Continuation paragraphs after a blank stay in the item when // indented this far. @@ -267,6 +271,22 @@ fn parse_line_based(input: &str) -> Vec { } } + // Anonymous-target link block. A blank ends it, same as a comment, + // so a later indent is a block quote rather than more target text. + if in_anon_block { + if line_text.trim().is_empty() { + in_anon_block = false; + } else { + let leading = line_text.len() - line_text.trim_start().len(); + if leading >= anon_indent { + regions.push(SpannedRegion::structure(input, line.span())); + i += 1; + continue; + } + in_anon_block = false; + } + } + // Blank line if line_text.trim().is_empty() { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); @@ -443,6 +463,9 @@ fn parse_line_based(input: &str) -> Vec { if is_rst_anonymous_target(trimmed) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); regions.push(SpannedRegion::structure(input, line.span())); + let leading = line_text.len() - trimmed.len(); + anon_indent = leading + 1; + in_anon_block = true; i += 1; continue; } @@ -5286,6 +5309,47 @@ mod tests { ); } + #[test] + fn anonymous_target_keeps_its_indented_link_block() { + // Docutils anonymous_target: get_first_known_indented until a blank. + let input = "__\n See the target. Next sentence.\n\nAfter. Next.\n"; + let regions = RstParser.parse(input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.trim() == "__")), + "anonymous opener stays structure, got {regions:?}" + ); + assert!( + regions.iter().any( + |r| matches!(r, Region::Structure(s) if s.contains("See the target. Next sentence.")) + ), + "indented link block stays structure, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("See the target"))), + "link block must not be a block quote, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Rst, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("See the target. Next sentence.\n"), + "link block must not sentence-split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the target must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn indented_anonymous_target_is_structure() { let input = " __ https://example.com/a/very/long/path\n"; From a623533cf8f351991e38a3917f3306f8bbe57e8e Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 12:57:13 -0500 Subject: [PATCH 15/37] fix(org): leftover plain list ends on two blanks One blank line keeps an indented paragraph in the item. Two blank lines end the plain list. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 +- src/parser/org.rs | 64 ++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2657f..267c226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover org plain list ends on two blanks - leftover RST anonymous target keeps its indented link block - leftover bare colon paragraph opens an RST literal block - leftover org ordered marker past nine digits skip-cut diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index ce43fbf..f8209b1 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -34,7 +34,8 @@ The classification depends on the format. - Full headline lines (stars, optional TODO keyword, and title text) - List item markers (=-=, =+=, =1.=, including a tab after the bullet). An ordered marker is one or more digits. A wrap cut that would park it at column 0 skip-cuts the token onto the previous line. - Continuation sentences hang at the marker width + Continuation sentences hang at the marker width. + One blank line stays inside the item. Two blank lines end the plain list - LaTeX environments (=\begin{equation}= ... =\end{equation}=, =\begin{align}=, etc.). An unmatched =\begin{env}= is a paragraph (org-element latex-environment-parser) - Display math (=\[= ... =\]=) - Line breaks (org-element-line-break-parser: =\\= plus optional spaces or tabs at end of line) diff --git a/src/parser/org.rs b/src/parser/org.rs index f2776c4..8a33626 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -926,6 +926,9 @@ impl FormatParser for OrgParser { // Track list item context: indent level of the marker text. // Continuation lines indented at or beyond this level belong to the item. let mut list_item_indent: Option = None; + // org-list-end-re ends a plain list on two blanks. One blank + // keeps the item open for an indented paragraph. + let mut list_saw_blank = false; // org-element footnote-separator is headline / next `[fn:]` / // two consecutive blanks. One blank plus an indented // continuation stays in the definition. @@ -1170,10 +1173,13 @@ impl FormatParser for OrgParser { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); if in_footnote_def && !footnote_saw_blank { footnote_saw_blank = true; + } else if !in_footnote_def && list_item_indent.is_some() && !list_saw_blank { + list_saw_blank = true; } else { in_footnote_def = false; footnote_saw_blank = false; list_item_indent = None; + list_saw_blank = false; } regions.push(SpannedRegion::blank(input, line.span())); continue; @@ -1207,6 +1213,7 @@ impl FormatParser for OrgParser { if let Some(marker_len) = org_caption_marker_len(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); list_item_indent = Some(marker_len); + list_saw_blank = false; let marker_span = ByteSpan::new(line.start, line.start + marker_len); regions.push(SpannedRegion::structure(input, marker_span)); Self::emit_hung_text(input, &line, marker_len, &mut regions); @@ -1239,6 +1246,7 @@ impl FormatParser for OrgParser { if let Some(marker_len) = org_plain_link_marker_len(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); list_item_indent = None; + list_saw_blank = false; let body = &line_text[marker_len..]; if body.trim().is_empty() { regions.push(SpannedRegion::structure(input, line.span())); @@ -1254,6 +1262,7 @@ impl FormatParser for OrgParser { if Self::is_standalone_org_link(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); list_item_indent = None; + list_saw_blank = false; regions.push(SpannedRegion::structure(input, line.span())); continue; } @@ -1283,6 +1292,7 @@ impl FormatParser for OrgParser { ByteSpan::new(line.start, line.start + marker_len), )); list_item_indent = Some(marker_len); + list_saw_blank = false; Self::emit_hung_text(input, &line, marker_len, &mut regions); } continue; @@ -1306,6 +1316,7 @@ impl FormatParser for OrgParser { if let Some(marker_len) = org_footnote_definition_marker_len(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); list_item_indent = Some(marker_len); + list_saw_blank = false; in_footnote_def = true; footnote_saw_blank = false; let marker_span = ByteSpan::new(line.start, line.start + marker_len); @@ -1320,6 +1331,7 @@ impl FormatParser for OrgParser { if leading > 0 { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); list_item_indent = Some(leading); + list_saw_blank = false; footnote_saw_blank = false; regions.push(SpannedRegion::structure( input, @@ -1338,6 +1350,7 @@ impl FormatParser for OrgParser { let marker = caps.get(1).unwrap().as_str(); // Track indent for continuation detection: text starts at marker length list_item_indent = Some(marker.len()); + list_saw_blank = false; in_footnote_def = false; footnote_saw_blank = false; let marker_span = ByteSpan::new(line.start, line.start + marker.len()); @@ -1414,11 +1427,25 @@ impl FormatParser for OrgParser { )); } Self::emit_hung_text(input, &line, leading, &mut regions); + list_saw_blank = false; + continue; + } + // One blank, then an indented paragraph: still the item. + if list_saw_blank { + if leading > 0 { + regions.push(SpannedRegion::structure( + input, + ByteSpan::new(line.start, line.start + leading), + )); + } + Self::emit_hung_text(input, &line, leading, &mut regions); + list_saw_blank = false; continue; } } // Not a continuation: leave list context list_item_indent = None; + list_saw_blank = false; } // org-syntax 5.2: `\[CONTENTS\]` may be mid-line (Kang: Structure). @@ -1713,6 +1740,43 @@ mod tests { assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); } + #[test] + fn org_list_second_paragraph_stays_in_the_item() { + // org-list-end-re: one blank stays in the item; two blanks end it. + let input = concat!( + "- First paragraph in the item. It has two sentences.\n", + "\n", + " Second paragraph stays in the item. Another sentence.\n", + "\n", + "\n", + "After the list. Next sentence.\n", + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Org, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + let second = out + .lines() + .find(|l| l.contains("Second paragraph")) + .unwrap_or(""); + assert!( + second.starts_with(' ') || second.starts_with('\t'), + "second item paragraph must stay indented, got:\n{out}" + ); + assert!( + out.lines().any(|l| l.starts_with("After the list.")), + "two blanks end the list, got:\n{out}" + ); + assert!( + out.contains("Next sentence."), + "prose after the list must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn numbered_empty_item_at_eol_is_structure_not_uax() { let input = From 5d56197b6905e31ddbc737f581c0947e9cdc9900 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 13:11:24 -0500 Subject: [PATCH 16/37] fix(md): leftover list marker remainder stays blocks Text after a list marker is parsed as blocks. A fence, heading, thematic break, HTML block, or table there is not item prose. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 +- src/parser/markdown.rs | 223 +++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 267c226..bd72fca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover markdown list marker remainder stays blocks - leftover org plain list ends on two blanks - leftover RST anonymous target keeps its indented link block - leftover bare colon paragraph opens an RST literal block diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index f8209b1..5b035a5 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -193,7 +193,8 @@ These tokens within prose are not split across lines: - Empty ATX headings (a mark line with no title text) stay Structure - Setext headings (title line plus a line of one or more = or - characters). A wrap cut that would park a short underline at column 0 escapes it -- List item markers (=-=, =*=, =+=, =1.=); continuation sentences hang at the marker width +- List item markers (=-=, =*=, =+=, =1.=); continuation sentences hang at the marker width. + A fence, ATX heading, thematic break, HTML block, or GFM table in the marker remainder stays a block - Empty list markers (including quoted) stay Structure - Definition-list terms and =: = markers (pulldown =ENABLE_DEFINITION_LIST=); the body hangs at the marker width - Blockquote markers (=>= / nested => > =); continuation sentences repeat the quote prefix diff --git a/src/parser/markdown.rs b/src/parser/markdown.rs index 755c7d8..5ac7462 100644 --- a/src/parser/markdown.rs +++ b/src/parser/markdown.rs @@ -1277,6 +1277,67 @@ fn list_item_empty_pad(rest: &str) -> usize { usize::from(rest == " ") } +/// Block that starts in the text after a list marker: ATX heading, +/// thematic break, HTML block, or GFM table. The returned index is the +/// last source line of that block. A fence is handled by the caller so +/// the body stays `Code`. +fn list_remainder_raw_end(lines: &[Line<'_>], start: usize, rest: &str) -> Option { + let content = rest.trim_start(); + if content.is_empty() || FENCED_CODE_RE.is_match(content) { + return None; + } + if HEADING_RE.is_match(content) || is_thematic_break(content) { + return Some(start); + } + if let Some(kind) = html_block_kind(content) { + return Some(match kind { + HtmlBlock::Type6 | HtmlBlock::Type7 => { + let tag = if kind == HtmlBlock::Type6 { + type6_tag_name(content) + } else { + type7_tag_name(content) + }; + if let Some(tag) = tag { + named_html_end_idx(lines, start, tag) + } else { + let mut j = start; + while j + 1 < lines.len() && !lines[j + 1].text.trim().is_empty() { + j += 1; + } + j + } + } + _ => { + if html_block_line_ends(content, kind) + || html_block_line_ends(lines[start].text, kind) + { + start + } else { + let mut j = start + 1; + while j < lines.len() && !html_block_line_ends(lines[j].text, kind) { + j += 1; + } + j.min(lines.len().saturating_sub(1)) + } + } + }); + } + let header = gfm_table_cells(content)?; + let delim = lines.get(start + 1)?; + let dcells = gfm_table_cells(delim.text)?; + if header.len() != dcells.len() || !dcells.iter().all(|c| is_gfm_delimiter_cell(c)) { + return None; + } + let mut end = start + 1; + for (j, line) in lines.iter().enumerate().skip(start + 2) { + if !is_gfm_table_row(line.text) { + break; + } + end = j; + } + Some(end) +} + /// CommonMark 5.2 hang: marker width after quote markers (`- ` is 2, /// `1. ` is 3, lone `-` is W+1). A setext underline shallower than this /// hang is lazy paragraph text, not a closer (GitHub #261). @@ -2934,6 +2995,37 @@ impl FormatParser for MarkdownParser { // item stays empty and the next unindented line is not // lazy continuation (GitHub #337). let marker_len = marker.len() + list_item_empty_pad(rest); + let content = rest.get(list_item_empty_pad(rest)..).unwrap_or(""); + // pulldown parses the remainder as blocks. A fence, + // heading, thematic break, HTML block, or GFM table + // there is not item prose. + if let Some(fcaps) = FENCED_CODE_RE.captures(content.trim_start()) { + let spaces = content.len() - content.trim_start().len(); + fence_marker = fcaps.get(1).unwrap().as_str().to_string(); + fence_indent = marker_len + spaces; + fence_quote_depth = 0; + in_fenced_code = true; + in_list_item = false; + list_hang = None; + code_lang = FENCED_LANG_RE + .captures(content.trim_start()) + .map(|c| c.get(1).unwrap().as_str().to_string()); + code_header = line.span(); + code_body_start = line.end; + i += 1; + continue; + } + if let Some(end) = list_remainder_raw_end(&lines, i, content) { + in_list_item = true; + list_hang = Some(list_marker_hang(marker)); + list_after_blank = false; + in_definition_list = false; + for row in &lines[i..=end] { + regions.push(SpannedRegion::structure(input, row.span())); + } + i = end + 1; + continue; + } let marker_span = ByteSpan::new(line.start, line.start + marker_len); regions.push(SpannedRegion::structure(input, marker_span)); in_list_item = true; @@ -3302,6 +3394,137 @@ mod tests { assert_eq!(format_text(input, &cfg).unwrap(), input); } + #[test] + fn list_marker_fence_remainder_stays_code() { + // pulldown parses the text after a list marker as blocks. + // The fence opener on that line is not item prose. + let input = concat!( + "- ```\n", + " Keep this sentence in the fence. Keep this one too.\n", + " ```\n", + ); + let regions = MarkdownParser.parse(input); + match regions.iter().find(|r| matches!(r, Region::Code { .. })) { + Some(Region::Code { body, footer, .. }) => { + assert!( + body.contains("Keep this sentence in the fence. Keep this one too.\n"), + "fence body stays one code line, got {body:?}" + ); + assert!( + footer.contains("```"), + "indented closer ends the fence, got {footer:?}" + ); + } + other => panic!("list-marker fence must be Code, got {other:?} in {regions:?}"), + } + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("Keep this"))), + "fence body must not be prose: {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("Keep this sentence in the fence. Keep this one too.\n"), + "fence body must not sentence-split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn list_marker_html_remainder_stays_raw() { + let input = concat!( + "-
\n", + " Inside the block. Second sentence.\n", + "
\n", + "\n", + "After. Next.\n", + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("Inside the block. Second sentence.\n"), + "html remainder must not sentence-split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the html block must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn list_marker_table_remainder_stays_raw() { + let input = concat!( + "- | a | b |\n", + " | --- | --- |\n", + " | one. | two. |\n", + "\n", + "After. Next.\n", + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("| one. | two. |\n"), + "table remainder must stay one row, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the table must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + + #[test] + fn list_marker_heading_remainder_stays_one_line() { + let input = "- # Heading one. Heading two must not split.\n\nAfter. Next.\n"; + let regions = MarkdownParser.parse(input); + assert!( + regions.iter().any( + |r| matches!(r, Region::Structure(s) if s.contains("Heading one. Heading two")) + ), + "heading remainder stays structure, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("Heading two"))), + "heading remainder must not be prose, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("- # Heading one. Heading two must not split.\n"), + "heading must stay one line, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn list_nested_fence_still_closes_at_opener_indent() { let input = concat!( From 6a20f6ef89981a9930be1e234e4deafdee853343 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 13:14:57 -0500 Subject: [PATCH 17/37] fix(md): leftover footnote continuation keeps an ATX heading A four-space footnote continuation is reparsed as blocks. An ATX heading on that line stays structure instead of joining the note. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 +- src/parser/markdown.rs | 51 ++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd72fca..817b872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover markdown footnote continuation keeps an ATX heading - leftover markdown list marker remainder stays blocks - leftover org plain list ends on two blanks - leftover RST anonymous target keeps its indented link block diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 5b035a5..3676b22 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -224,7 +224,8 @@ These tokens within prose are not split across lines: - Paragraph text - List item text (after the marker); continuation sentences hang at the marker width - Definition-list body (after the =: = marker); continuation sentences hang at the marker width -- GFM footnote-definition body (after =[^label]:=); continuation sentences hang at the marker width +- GFM footnote-definition body (after =[^label]:=); continuation sentences hang at the marker width. + An ATX heading in that continuation stays one structure line - Blockquote inner text (after the =>= marker); continuation sentences repeat the quote prefix *** Inline tokens (kept atomic) diff --git a/src/parser/markdown.rs b/src/parser/markdown.rs index 5ac7462..7288077 100644 --- a/src/parser/markdown.rs +++ b/src/parser/markdown.rs @@ -2444,6 +2444,17 @@ impl FormatParser for MarkdownParser { let row = &lines[j]; let hang = line_indent(row.text); let inner = row.text.get(hang..).unwrap_or(""); + if HEADING_RE.is_match(inner) { + flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + if let Some(span) = list_term.take() { + if !span.is_empty() { + regions.push(SpannedRegion::structure(input, span)); + } + } + regions.push(SpannedRegion::structure(input, row.span())); + j += 1; + continue; + } if FENCED_CODE_RE.is_match(inner.trim_start()) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); let fence = FENCED_CODE_RE @@ -7034,6 +7045,46 @@ mod tests { ); } + #[test] + fn footnote_continuation_heading_stays_one_line() { + let input = concat!( + "[^1]: Note text.\n", + "\n", + " # Heading one. Heading two must not split.\n", + "\n", + "After. Next.\n", + ); + let regions = MarkdownParser.parse(input); + assert!( + regions.iter().any( + |r| matches!(r, Region::Structure(s) if s.contains("# Heading one. Heading two")) + ), + "footnote heading stays structure, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("Heading two"))), + "footnote heading must not be prose, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("# Heading one. Heading two must not split.\n"), + "heading must stay one line, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after the footnote must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn ticket_fixture_link_ref_and_footnote_do_not_reflow() { use crate::format::Format; From 34996f8d8a1f6e5c88d37b84fc0444657d4b6a54 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 13:21:07 -0500 Subject: [PATCH 18/37] fix(md): leftover HTML type 6 and 7 blocks run to a blank line CommonMark ends those blocks at the next blank line. A matching end tag does not. Void tags still stop on the tag line. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 2 +- src/parser/markdown.rs | 116 ++++++++++++++++++++--------- tests/md_html_type6_close.rs | 31 +++++--- tests/md_html_type7_close.rs | 37 ++++----- 5 files changed, 124 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 817b872..d944a1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover markdown HTML type 6 and 7 blocks run to a blank line - leftover markdown footnote continuation keeps an ATX heading - leftover markdown list marker remainder stays blocks - leftover org plain list ends on two blanks diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 3676b22..a49b27b 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -204,7 +204,7 @@ These tokens within prose are not split across lines: - GFM footnote definitions (=[^label]:=); the marker is Structure and the body hangs - Hard line breaks (two trailing spaces, or a trailing backslash) - HTML comments (==, including multiline); == / == remain pragmas -- Closed type-6 and type-7 HTML blocks end at the matching close tag; following prose stays Prose +- Type-6 and type-7 HTML blocks run to the next blank line. A matching end tag does not end the block - Void type-6 tags (=
= / == / == / == / == / ==) end on the tag line; they have no closer, so leftover following prose stays Prose even without a blank - Pipe tables diff --git a/src/parser/markdown.rs b/src/parser/markdown.rs index 7288077..e591cde 100644 --- a/src/parser/markdown.rs +++ b/src/parser/markdown.rs @@ -397,7 +397,11 @@ fn named_html_end_idx(lines: &[Line<'_>], start_idx: usize, tag: &str) -> usize let mut nest = 0i32; let mut j = start_idx; loop { - if apply_type6_named_tags(lines[j].text, tag, &mut nest) { + let closed = apply_type6_named_tags(lines[j].text, tag, &mut nest); + // A void tag has no body, so the block is that line. A matching + // end tag does not end type 6 or 7; CommonMark runs to the next + // blank line. + if closed && is_html_void_type6(tag) { return j; } if j + 1 >= lines.len() || lines[j + 1].text.trim().is_empty() { @@ -465,9 +469,9 @@ fn html_block_line_ends(line: &str, kind: HtmlBlock) -> bool { fn html_block_end_idx(kind: HtmlBlock, lines: &[Line<'_>], start_idx: usize) -> usize { match kind { HtmlBlock::Type6 | HtmlBlock::Type7 => { - // Closed type-6 (`
…
`) and type-7 (`…`) - // end at the matching close so the next paragraph stays Prose - // (GitHub #332 / #356). Unclosed still runs to a following blank. + // CommonMark 0.31.2: type 6 and 7 end at the next blank line. + // A matching end tag does not end the block. Void tags still + // stop on the tag line. let rest = html_block_rest(lines[start_idx].text); let tag = if kind == HtmlBlock::Type6 { type6_tag_name(rest) @@ -563,7 +567,8 @@ fn quoted_named_html_end_idx( let mut j = start_idx; loop { if let Some(inner) = quoted_html_inner(lines[j].text, depth) { - if apply_type6_named_tags(inner, tag, &mut nest) { + let closed = apply_type6_named_tags(inner, tag, &mut nest); + if closed && is_html_void_type6(tag) { return j; } } else { @@ -2674,8 +2679,8 @@ impl FormatParser for MarkdownParser { } // CommonMark 4.6 HTML blocks types 1 and 3–7. Type 2 is above. - // Unclosed type-7 cannot interrupt a paragraph. Closed type-7 - // (`…`) is a leaf island (GitHub #356). + // Unclosed type-7 cannot interrupt a paragraph. Type 6 and 7 + // run to the next blank line. if let Some(kind) = html_block_kind(line_text) { let in_paragraph = !current_prose.is_empty() || in_list_item; if kind.can_interrupt() @@ -5583,8 +5588,8 @@ mod tests { ); } - /// GitHub #332 / snapper-v85k: closed type-6 must not swallow - /// the following paragraph. + /// CommonMark type 6 runs to the next blank line. Text after + /// `` with no blank stays in the block. fn ticket_html_type6_close_fixture() -> &'static str { concat!( "Intro sentence here. Another intro sentence.\n", @@ -5607,23 +5612,16 @@ mod tests { assert!(div.contains("First. Second."), "{div}"); assert!(div.contains(""), "{div}"); assert!( - !div.contains("After html"), - "closed type-6 must end at , got {div}" + div.contains("After html. Next."), + "text before a blank stays in the type-6 block, got {div}" ); assert!( !regions.iter().any(|r| matches!( r, - Region::Prose(p) if p.contains("First.") || p.contains("` with no blank stays in the block. fn ticket_html_type7_close_fixture() -> &'static str { concat!( "Intro sentence here. Another intro sentence.\n", @@ -5931,23 +5929,16 @@ mod tests { assert!(span.contains("First. Second."), "{span}"); assert!(span.contains(""), "{span}"); assert!( - !span.contains("After html"), - "closed type-7 must end at , got {span}" + span.contains("After html. Next."), + "text before a blank stays in the type-7 block, got {span}" ); assert!( !regions.iter().any(|r| matches!( r, - Region::Prose(p) if p.contains("First.") || p.contains(", got:\n{out}" ); assert!( - out.contains("After html.\nNext.\n"), - "following paragraph must stay Prose and split, got:\n{out}" + out.contains("After html. Next.\n"), + "text before a blank stays in the span block, got:\n{out}" + ); + assert!( + !out.contains("After html.\nNext."), + "type-7 text must not sentence-split before a blank, got:\n{out}" ); assert!( !out.contains("First.\nSecond."), @@ -6054,8 +6049,12 @@ mod tests { "div HTML block must stay raw through , got:\n{out}" ); assert!( - out.contains("After html.\nNext.\n"), - "following paragraph must stay Prose and split, got:\n{out}" + out.contains("After html. Next.\n"), + "text before a blank stays in the div block, got:\n{out}" + ); + assert!( + !out.contains("After html.\nNext."), + "type-6 text must not sentence-split before a blank, got:\n{out}" ); assert!( !out.contains("First.\nSecond."), @@ -7045,6 +7044,53 @@ mod tests { ); } + #[test] + fn html_type6_runs_to_a_blank_line() { + // CommonMark 0.31.2 type 6 ends at a blank line. The matching + // close tag does not end the block. + let input = "
\nFirst. Second.\n
\nAfter html. Next.\n"; + let regions = MarkdownParser.parse(input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) + if s.contains("") && s.contains("After html. Next.") + )), + "text after stays in the html block, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("After html"))), + "text before the blank must not be prose, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("After html. Next.\n"), + "html block must not sentence-split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + + let blanked = "
\nFirst. Second.\n
\n\nAfter html. Next.\n"; + let blanked_out = crate::format_text(blanked, &cfg).unwrap(); + assert!( + blanked_out.contains("After html.\nNext.\n"), + "a blank line ends the block, got:\n{blanked_out}" + ); + let hr = "
\nAfter html. Next.\n"; + let hr_out = crate::format_text(hr, &cfg).unwrap(); + assert!( + hr_out.contains("After html.\nNext.\n"), + "a void tag does not swallow the next line, got:\n{hr_out}" + ); + } + #[test] fn footnote_continuation_heading_stays_one_line() { let input = concat!( diff --git a/tests/md_html_type6_close.rs b/tests/md_html_type6_close.rs index 7bcc836..1eb05dc 100644 --- a/tests/md_html_type6_close.rs +++ b/tests/md_html_type6_close.rs @@ -1,6 +1,6 @@ -//! GitHub #332 / snapper-v85k: CommonMark 0.31.2 sec 4.6 HTML type 6. -//! A closed `
…
` must not include the next paragraph. -//! `After html.` / `Next.` stay Prose and still split. Type-1 `
`
+//! CommonMark 0.31.2 sec 4.6 HTML type 6 ends at a blank line.
+//! Text after `` and before a blank stays in the block.
+//! A blank line lets the next paragraph split. Type-1 `
`
 //! still ends at `
`. use snapper_fmt::format::Format; @@ -35,8 +35,7 @@ fn expected_ticket() -> &'static str { "
\n", "First. Second.\n", "
\n", - "After html.\n", - "Next.\n", + "After html. Next.\n", ) } @@ -52,8 +51,8 @@ fn closed_div_is_structure_through_close() { assert!(div.contains("First. Second."), "{div}"); assert!(div.contains(""), "{div}"); assert!( - !div.contains("After html"), - "closed type-6 must end at , got {div}" + div.contains("After html. Next."), + "text before a blank stays in the type-6 block, got {div}" ); assert!( !regions.iter().any(|r| matches!( @@ -65,14 +64,26 @@ fn closed_div_is_structure_through_close() { } #[test] -fn after_html_stays_prose() { - let regions = MarkdownParser.parse(ticket_fixture()); +fn blank_line_ends_type6_and_the_next_paragraph_splits() { + let input = concat!( + "
\n", + "First. Second.\n", + "
\n", + "\n", + "After html. Next.\n", + ); + let regions = MarkdownParser.parse(input); assert!( regions.iter().any(|r| matches!( r, Region::Prose(p) if p.contains("After html.") && p.contains("Next.") )), - "After html. / Next. must stay Prose, got {regions:?}" + "a blank line ends the block, got {regions:?}" + ); + let out = format_text(input, &md_cfg()).unwrap(); + assert!( + out.contains("After html.\nNext.\n"), + "paragraph after the blank still splits, got:\n{out}" ); } diff --git a/tests/md_html_type7_close.rs b/tests/md_html_type7_close.rs index d846494..e2d51d4 100644 --- a/tests/md_html_type7_close.rs +++ b/tests/md_html_type7_close.rs @@ -1,7 +1,5 @@ -//! GitHub #356 / snapper-615s: CommonMark 0.31.2 sec 4.6 HTML type 7. -//! A closed `…` must not include the next paragraph. -//! `After html.` / `Next.` stay Prose and still split. Type-6 close -//! (`
…
`) and quoted HTML stay intact. +//! CommonMark 0.31.2 sec 4.6 HTML type 7 ends at a blank line. +//! Text after `` and before a blank stays in the block. use snapper_fmt::format::Format; use snapper_fmt::parser::markdown::MarkdownParser; @@ -35,8 +33,7 @@ fn expected_ticket() -> &'static str { "\n", "First. Second.\n", "\n", - "After html.\n", - "Next.\n", + "After html. Next.\n", ) } @@ -52,8 +49,8 @@ fn closed_span_is_structure_through_close() { assert!(span.contains("First. Second."), "{span}"); assert!(span.contains(""), "{span}"); assert!( - !span.contains("After html"), - "closed type-7 must end at , got {span}" + span.contains("After html. Next."), + "text before a blank stays in the type-7 block, got {span}" ); assert!( !regions.iter().any(|r| matches!( @@ -65,14 +62,21 @@ fn closed_span_is_structure_through_close() { } #[test] -fn after_html_stays_prose() { - let regions = MarkdownParser.parse(ticket_fixture()); +fn blank_line_ends_type7_and_the_next_paragraph_splits() { + let input = concat!( + "\n", + "First. Second.\n", + "\n", + "\n", + "After html. Next.\n", + ); + let regions = MarkdownParser.parse(input); assert!( regions.iter().any(|r| matches!( r, Region::Prose(p) if p.contains("After html.") && p.contains("Next.") )), - "After html. / Next. must stay Prose, got {regions:?}" + "a blank line ends the block, got {regions:?}" ); } @@ -85,7 +89,7 @@ fn ticket_fixture_keeps_span_and_splits_next() { } #[test] -fn type6_closed_div_still_ends_at_close() { +fn type6_closed_div_runs_to_a_blank_line() { let input = concat!( "Intro sentence here. Another intro sentence.\n", "
\n", @@ -101,8 +105,8 @@ fn type6_closed_div_still_ends_at_close() { let div = div.expect(&format!("div block must be Structure, got {regions:?}")); assert!(div.contains("
"), "{div}"); assert!( - !div.contains("After html"), - "type-6 close intact, got {div}" + div.contains("After html. Next."), + "type-6 runs to a blank line, got {div}" ); let out = format_text(input, &md_cfg()).unwrap(); assert_eq!( @@ -113,10 +117,9 @@ fn type6_closed_div_still_ends_at_close() { "
\n", "First. Second.\n", "
\n", - "After html.\n", - "Next.\n", + "After html. Next.\n", ), - "type-6 close must stay intact, got:\n{out}" + "type-6 runs to a blank line, got:\n{out}" ); } From 8ca8425614271e2c51d990d20aa1536e15433290 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 13:42:27 -0500 Subject: [PATCH 19/37] fix(latex): leftover verb with an internal space stays one wrap token Sentence splitting already keeps a verb span whole. Width wrap now does too, so a space inside the delimiter cannot break the span. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 ++- src/reflow.rs | 27 +++++++++++++++++++++++++++ src/sentence/unicode.rs | 10 ++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d944a1e..0238e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover LaTeX verb with an internal space stays one wrap token - leftover markdown HTML type 6 and 7 blocks run to a blank line - leftover markdown footnote continuation keeps an ATX heading - leftover markdown list marker remainder stays blocks diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index a49b27b..616e6d0 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -140,7 +140,8 @@ These tokens within prose are not split across lines: - Extra names from =[latex].verbatim_envs= are code regions too - Body follows the same comment-reflow and optional =--format-code= rules as other formats when language is known (=minted= / =minted*= language arg, =lstlisting= / =lstlisting*= =language== option) - Inline leftover =\verb= / =\verb*= / leftover =\lstinline= / leftover =\spverb= / leftover =\mintinline= / leftover =\mint= / leftover fancyvrb =\Verb= / =\Verb*= / leftover =\SaveVerb= / leftover =\UseVerb= / leftover =\UseVerbatim= / leftover =\LUseVerbatim= / leftover =\BUseVerbatim= / leftover =\DefineShortVerb= / =\UndefineShortVerb= / leftover fvextra =\EscVerb= / leftover fvextra =\VerbatimInsertBuffer= / =\VerbatimClearBuffer= / =\InsertBuffer= / =\IterateBuffer= / =\VerbatimInput= / =\BVerbatimInput= / =\LVerbatimInput= / piton.sty leftover =\piton= / =\PitonInputFile= / =\PitonInputFileT= / =\PitonInputFileF= / =\PitonInputFileTF= / listings.sty =\lstinputlisting= / minted.sty =\inputminted= / tools/verbatim.sty =\verbatiminput= / tcolorbox =\tcbinputlisting= / pythontex.sty =\inputpy= / =\inputpycon= / leftover inline =\py= / =\pyc= / =\pys= / =\pyb= / =\pyv= / =\pycon= and twins / =\sympy= / =\pylab= and twins / leftover usefamily =\ruby= / =\rb= / =\julia= / =\jl= / =\matlab= / =\octave= / =\bash= / =\sage= / =\rust= / =\rs= / =\R= / =\perl= / =\pl= / =\perlsix= / =\psix= / =\javascript= / =\js= and twins / =\inputpygments= / =\pygment= / leftover =\pythontexcustomc= / pythonhighlight.sty =\inputpython= / =\inputpythonfile= / leftover =\pyth= / catchfilebetweentags.sty =\CatchFileBetweenTags= / =\CatchFileBetweenDelims= / =\ExecuteMetaData= / catchfile.sty leftover =\CatchFileDef= / =\CatchFileEdef= / moreverb =\listinginput= / leftover =\verbatimtabinput= / =\verbatimtabinput*= / leftover =\verbatimwrite= / =\verbatimwrite*= / leftover =\listingcont= / sagetex =\sageinput= / leftover inline =\sageplot= / =\sagestr= / scontents leftover =\Scontents= / =\Scontents*= / =\typestored= / =\getstored= / =\mergesc= / =\meaningsc= / =\foreachsc= (and extra =[latex].verbatim_commands=) stay atomic; inner =.!?%= do not split or comment. - leftover =\verb= / =\verb*= / =\lstinline= / =\spverb= take a delimiter or, for =\lstinline=, optional =[...]= then a delimiter or ={...}=; a flush following sentence stays on its own line + leftover =\verb= / =\verb*= / =\lstinline= / =\spverb= take a delimiter or, for =\lstinline=, optional =[...]= then a delimiter or ={...}=; a flush following sentence stays on its own line. + A width wrap keeps a space inside that delimiter on the same line leftover =\mintinline= / =\mint= take optional =[...]=, ={lang}=, then a delimiter or ={...}= body; a flush following sentence stays on its own line leftover =\SaveVerb= takes optional =[...]=, a ={name}=, then the same delimiter body as =\Verb=; a flush following sentence stays on its own line leftover =\UseVerb= / =\UseVerb*= take optional =[...]= then a ={name}=; leftover =\UseVerbatim= / =\LUseVerbatim= / =\BUseVerbatim= take optional =[...]= then a ={name}=; a flush following sentence stays on its own line diff --git a/src/reflow.rs b/src/reflow.rs index f938fb4..17ee507 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -3197,6 +3197,33 @@ They are endowed with reason and conscience and should act towards one another i ); } + #[test] + fn latex_verb_with_space_is_not_split_by_wrap() { + // Sentence splitting keeps `\verb|foo bar|` whole. Width wrap + // still splits on the space inside the delimiter. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 16, + ..Default::default() + } + .without_safety_backstops(); + let input = "Use \\verb|foo bar| here. Next sentence.\n"; + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("\\verb|foo bar|"), + "verb span must stay one token, got:\n{out}" + ); + assert!( + !out.contains("\\verb|foo\n") && !out.contains("\\verb|foo\r"), + "wrap must not break inside the verb, got:\n{out}" + ); + assert!( + out.contains("Next sentence."), + "following sentence must remain, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn wrap_created_latex_section_optional_and_koma_are_not_blocks() { // Optional [short] and KOMA \addsec/\addchap/\addpart are sectioning diff --git a/src/sentence/unicode.rs b/src/sentence/unicode.rs index 2e7dfb7..35823ce 100644 --- a/src/sentence/unicode.rs +++ b/src/sentence/unicode.rs @@ -2929,6 +2929,7 @@ fn find_md_code_span(text: &str, open_at: usize) -> Option { /// Org `{{{name}}}` / `{{{name(args)}}}`, Org timestamps /// (``, `[YYYY-MM-DD…]`, ranges `--`, diary `<%%(...)>`), /// Org latex-fragments (`\(...\)`, `$...$`, `\cmd{arg}`, `\cmd[opt]{arg}`), +/// LaTeX `\verb|...|` and the other delimiter verbs, /// Org `src_lang{...}` / `call_name(...)`, Org brace `H_{...}` / `x^{...}` /// (org-match-substring-regexp), RST `|fig. 1|` / `|name|_` / `|name|__`, /// paired spans). @@ -2963,6 +2964,15 @@ pub fn atomic_inline_spans(text: &str) -> Vec<(usize, usize)> { i = end; continue; } + if bytes[i] == b'\\' { + // `\verb|foo bar|` is one token. Sentence splitting already + // hides it; width wrap still sees the restored spaces. + if let Some(end) = latex_verb_span_end_with(text, i, &[]) { + spans.push((i, end)); + i = end; + continue; + } + } if bytes[i] == b'`' { if let Some(end) = find_md_code_span(text, i) { spans.push((i, end)); From 945bb617cc6cd0cd9b166447c058f8fef02953ca Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 13:53:24 -0500 Subject: [PATCH 20/37] fix(org): leftover item ends at the bullet column An item ends at a line indented less than or equal to its bullet. A two-space continuation stays in a column-0 ordered item. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 ++- src/parser/org.rs | 36 ++++++++++++++++++++++++++++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0238e21..44dd6bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover org item ends at the bullet column - leftover LaTeX verb with an internal space stays one wrap token - leftover markdown HTML type 6 and 7 blocks run to a blank line - leftover markdown footnote continuation keeps an ATX heading diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 616e6d0..630528b 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -35,7 +35,8 @@ The classification depends on the format. - List item markers (=-=, =+=, =1.=, including a tab after the bullet). An ordered marker is one or more digits. A wrap cut that would park it at column 0 skip-cuts the token onto the previous line. Continuation sentences hang at the marker width. - One blank line stays inside the item. Two blank lines end the plain list + One blank line stays inside the item. Two blank lines end the plain list. + An item also ends at a line indented less than or equal to its bullet, so a two-space line stays in a column-0 item - LaTeX environments (=\begin{equation}= ... =\end{equation}=, =\begin{align}=, etc.). An unmatched =\begin{env}= is a paragraph (org-element latex-environment-parser) - Display math (=\[= ... =\]=) - Line breaks (org-element-line-break-parser: =\\= plus optional spaces or tabs at end of line) diff --git a/src/parser/org.rs b/src/parser/org.rs index 8a33626..43d98c8 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -1348,8 +1348,11 @@ impl FormatParser for OrgParser { if let Some(caps) = LIST_ITEM_RE.captures(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); let marker = caps.get(1).unwrap().as_str(); - // Track indent for continuation detection: text starts at marker length - list_item_indent = Some(marker.len()); + // An item ends at a line indented <= its bullet. The text + // column (`1. ` is three) is the hang, not that test, so a + // two-space line stays in a column-0 item. + let bullet_col = marker.len() - marker.trim_start().len(); + list_item_indent = Some(bullet_col + 1); list_saw_blank = false; in_footnote_def = false; footnote_saw_blank = false; @@ -1740,6 +1743,35 @@ mod tests { assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); } + #[test] + fn org_item_ends_at_bullet_column() { + // Org manual: an item ends before the next line indented less + // than or equal to its bullet. A two-space line stays in a + // column-0 ordered item. The flush line does not. + let input = "1. Ordered item text\n still inside here\nplus outside text.\n"; + let cfg = crate::FormatConfig { + format: crate::format::Format::Org, + max_width: 23, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines() + .any(|l| l.contains("still") && l.contains("plus")), + "continuation must not join the following paragraph, got:\n{out}" + ); + assert!( + out.contains("still inside"), + "indented line must stay, got:\n{out}" + ); + assert!( + out.lines().any(|l| l.starts_with("plus outside")), + "flush line ends the item, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn org_list_second_paragraph_stays_in_the_item() { // org-list-end-re: one blank stays in the item; two blanks end it. From 85e13fdeb681215a6a0a3f70c956f5d836f6fa76 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 16:59:18 -0500 Subject: [PATCH 21/37] fix(org): leftover nested item returns to the parent list A line indented like a child bullet ends that child. The outer bullet stays open, so the line is a new paragraph of the parent. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 +- src/parser/org.rs | 79 +++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44dd6bf..3af23bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover org nested item returns to the parent list - leftover org item ends at the bullet column - leftover LaTeX verb with an internal space stays one wrap token - leftover markdown HTML type 6 and 7 blocks run to a blank line diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 630528b..9aae60c 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -36,7 +36,8 @@ The classification depends on the format. An ordered marker is one or more digits. A wrap cut that would park it at column 0 skip-cuts the token onto the previous line. Continuation sentences hang at the marker width. One blank line stays inside the item. Two blank lines end the plain list. - An item also ends at a line indented less than or equal to its bullet, so a two-space line stays in a column-0 item + An item also ends at a line indented less than or equal to its bullet, so a two-space line stays in a column-0 item. + A line indented like a child bullet ends that child and stays in the parent - LaTeX environments (=\begin{equation}= ... =\end{equation}=, =\begin{align}=, etc.). An unmatched =\begin{env}= is a paragraph (org-element latex-environment-parser) - Display math (=\[= ... =\]=) - Line breaks (org-element-line-break-parser: =\\= plus optional spaces or tabs at end of line) diff --git a/src/parser/org.rs b/src/parser/org.rs index 43d98c8..f5cb89c 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -926,6 +926,9 @@ impl FormatParser for OrgParser { // Track list item context: indent level of the marker text. // Continuation lines indented at or beyond this level belong to the item. let mut list_item_indent: Option = None; + // Bullet columns, outer first. A line indented at or before a + // bullet ends that item; an outer bullet stays open. + let mut list_stack: Vec = Vec::new(); // org-list-end-re ends a plain list on two blanks. One blank // keeps the item open for an indented paragraph. let mut list_saw_blank = false; @@ -1178,6 +1181,7 @@ impl FormatParser for OrgParser { } else { in_footnote_def = false; footnote_saw_blank = false; + list_stack.clear(); list_item_indent = None; list_saw_blank = false; } @@ -1212,6 +1216,7 @@ impl FormatParser for OrgParser { // value hangs. `#+NAME:` / `#+ATTR_*` stay whole-line. if let Some(marker_len) = org_caption_marker_len(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + list_stack.clear(); list_item_indent = Some(marker_len); list_saw_blank = false; let marker_span = ByteSpan::new(line.start, line.start + marker_len); @@ -1245,6 +1250,7 @@ impl FormatParser for OrgParser { // the path is hung Prose (org-element plain link). if let Some(marker_len) = org_plain_link_marker_len(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + list_stack.clear(); list_item_indent = None; list_saw_blank = false; let body = &line_text[marker_len..]; @@ -1261,6 +1267,7 @@ impl FormatParser for OrgParser { } if Self::is_standalone_org_link(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + list_stack.clear(); list_item_indent = None; list_saw_blank = false; regions.push(SpannedRegion::structure(input, line.span())); @@ -1291,6 +1298,7 @@ impl FormatParser for OrgParser { input, ByteSpan::new(line.start, line.start + marker_len), )); + list_stack.clear(); list_item_indent = Some(marker_len); list_saw_blank = false; Self::emit_hung_text(input, &line, marker_len, &mut regions); @@ -1315,6 +1323,7 @@ impl FormatParser for OrgParser { // Marker is Structure; same-line body is hung Prose (GitHub #180). if let Some(marker_len) = org_footnote_definition_marker_len(line_text) { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + list_stack.clear(); list_item_indent = Some(marker_len); list_saw_blank = false; in_footnote_def = true; @@ -1330,6 +1339,7 @@ impl FormatParser for OrgParser { let leading = line_text.len() - line_text.trim_start().len(); if leading > 0 { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + list_stack.clear(); list_item_indent = Some(leading); list_saw_blank = false; footnote_saw_blank = false; @@ -1352,6 +1362,10 @@ impl FormatParser for OrgParser { // column (`1. ` is three) is the hang, not that test, so a // two-space line stays in a column-0 item. let bullet_col = marker.len() - marker.trim_start().len(); + while list_stack.last().is_some_and(|&col| col >= bullet_col) { + list_stack.pop(); + } + list_stack.push(bullet_col); list_item_indent = Some(bullet_col + 1); list_saw_blank = false; in_footnote_def = false; @@ -1362,9 +1376,25 @@ impl FormatParser for OrgParser { continue; } - // List item continuation: indented line following a list item + // List item continuation: indented line following a list item. + // Pop bullets this line ends, then keep an outer item whose + // bullet is still to the left. + let leading = line_text.len() - line_text.trim_start().len(); + // Popping a child must not append this line onto the child's prose. + let mut returned_to_outer = false; + if !list_stack.is_empty() { + while list_stack.last().is_some_and(|&col| leading <= col) { + list_stack.pop(); + returned_to_outer = true; + } + if list_stack.is_empty() { + list_item_indent = None; + list_saw_blank = false; + } else { + list_item_indent = Some(list_stack.last().copied().unwrap() + 1); + } + } if let Some(indent) = list_item_indent { - let leading = line_text.len() - line_text.trim_start().len(); if leading >= indent && !line_text.trim().is_empty() && Self::find_unescaped_display_bracket(line_text, 0, b'[').is_none() @@ -1386,6 +1416,17 @@ impl FormatParser for OrgParser { .. }) if is_org_line_break_structure(s) ); + if returned_to_outer { + if leading > 0 { + regions.push(SpannedRegion::structure( + input, + ByteSpan::new(line.start, line.start + leading), + )); + } + Self::emit_hung_text(input, &line, leading, &mut regions); + list_saw_blank = false; + continue; + } if is_term { regions.pop(); if let Some(break_at) = org_line_break_at(line_text) { @@ -1447,6 +1488,7 @@ impl FormatParser for OrgParser { } } // Not a continuation: leave list context + list_stack.clear(); list_item_indent = None; list_saw_blank = false; } @@ -1743,6 +1785,39 @@ mod tests { assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); } + #[test] + fn org_nested_item_returns_to_the_parent() { + // A line indented like the child bullet ends the child and + // stays in the parent. It must not join the flush paragraph. + let input = "- Parent item\n 1. Child item\n back in the parent\nAfter.\n"; + let cfg = crate::FormatConfig { + format: crate::format::Format::Org, + max_width: 40, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + !out.lines() + .any(|l| l.contains("back in the parent") && l.contains("After")), + "parent continuation must not join the following paragraph, got:\n{out}" + ); + assert!( + out.contains("back in the parent"), + "parent line must stay, got:\n{out}" + ); + assert!( + !out.lines() + .any(|l| l.contains("Child") && l.contains("back in the parent")), + "the line returns to the parent, not the child, got:\n{out}" + ); + assert!( + out.lines().any(|l| l.starts_with("After")), + "flush line ends the list, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn org_item_ends_at_bullet_column() { // Org manual: an item ends before the next line indented less From 09c51b643e381b0838dfcb0abba7b1e190330d3e Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 19:06:14 -0500 Subject: [PATCH 22/37] fix(md): leftover hang-plus-four line stays in an open paragraph Indented code cannot interrupt a paragraph. A list line indented to the marker hang plus four spaces stays in the open item. A blank line before that indent is still indented code. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 1 + src/parser/markdown.rs | 47 ++++++++++++++++++++++++++++-- tests/md_list_hang_code.rs | 26 ++++++++--------- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af23bd..9808282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover markdown hang-plus-four line stays in an open list paragraph - leftover org nested item returns to the parent list - leftover org item ends at the bullet column - leftover LaTeX verb with an internal space stays one wrap token diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 9aae60c..e44d065 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -197,6 +197,7 @@ These tokens within prose are not split across lines: - Setext headings (title line plus a line of one or more = or - characters). A wrap cut that would park a short underline at column 0 escapes it - List item markers (=-=, =*=, =+=, =1.=); continuation sentences hang at the marker width. + A line indented four spaces past the marker stays in an open item paragraph. After a blank line it is indented code. A fence, ATX heading, thematic break, HTML block, or GFM table in the marker remainder stays a block - Empty list markers (including quoted) stay Structure - Definition-list terms and =: = markers (pulldown =ENABLE_DEFINITION_LIST=); the body hangs at the marker width diff --git a/src/parser/markdown.rs b/src/parser/markdown.rs index e591cde..3978d05 100644 --- a/src/parser/markdown.rs +++ b/src/parser/markdown.rs @@ -3146,11 +3146,17 @@ impl FormatParser for MarkdownParser { } last_was_def_term = false; - // hang+4 indented code inside a list item, with or without a - // blank. CM 5.2: indent ≥ hang+4 is code, not lazy paragraph. + // hang+4 indented code inside a list item only after the + // paragraph has closed. CommonMark 4.4: indented code cannot + // interrupt an open paragraph, so a hang+4 line with no blank + // stays in the item. A blank flushes prose; the next hang+4 + // line is code. if in_list_item { if let Some(hang) = list_hang { - if is_indented_code_line(line_text) && line_indent(line_text) >= hang + 4 { + if current_prose.is_empty() + && is_indented_code_line(line_text) + && line_indent(line_text) >= hang + 4 + { flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); if let Some(span) = list_term.take() { if !span.is_empty() { @@ -4293,6 +4299,41 @@ mod tests { ); } + #[test] + fn hang_plus_four_without_blank_joins_the_paragraph() { + // CommonMark 4.4: indented code cannot interrupt a paragraph. + // hang+4 with no blank stays in the open item and splits. + let input = "- foo bar sentence.\n continues the item. Second sentence.\n"; + let regions = MarkdownParser.parse(input); + assert!( + !regions.iter().any(|r| matches!(r, Region::Code { .. })), + "open paragraph must not become indented code, got {regions:?}" + ); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Prose(p) if p.contains("foo bar sentence.") && p.contains("continues the item.") + )), + "hang+4 line joins the item paragraph, got {regions:?}" + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Markdown, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("continues the item.\n"), + "joined paragraph must sentence-split, got:\n{out}" + ); + assert!( + !out.contains(" continues"), + "the line must not stay a six-space code line, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn hang_plus_four_after_blank_is_indented_code() { let input = "- Item one.\n\n indented code inside the item\n"; diff --git a/tests/md_list_hang_code.rs b/tests/md_list_hang_code.rs index c0e3769..27139bd 100644 --- a/tests/md_list_hang_code.rs +++ b/tests/md_list_hang_code.rs @@ -1,5 +1,6 @@ -//! List item then hang+4 indented code with no blank line stays Code. -//! Following item prose still splits. pulldown / CommonMark 5.2. +//! hang+4 with no blank continues the open list paragraph. +//! CommonMark 4.4: indented code cannot interrupt a paragraph. +//! A blank line, then hang+4, stays indented code. use snapper_fmt::format::Format; use snapper_fmt::parser::markdown::MarkdownParser; @@ -24,21 +25,18 @@ fn ticket_fixture() -> &'static str { } #[test] -fn hang_plus_four_without_blank_is_code() { +fn hang_plus_four_without_blank_joins_the_item() { let regions = MarkdownParser.parse(ticket_fixture()); assert!( - regions.iter().any(|r| matches!( - r, - Region::Code { body, .. } if body.contains("indented. not split") - )), - "hang+4 without a blank must be Code, got {regions:?}" + !regions.iter().any(|r| matches!(r, Region::Code { .. })), + "hang+4 without a blank must not be Code, got {regions:?}" ); assert!( - !regions.iter().any(|r| matches!( + regions.iter().any(|r| matches!( r, Region::Prose(p) if p.contains("indented. not split") )), - "hang+4 line must not be Prose, got {regions:?}" + "hang+4 line joins the item, got {regions:?}" ); } @@ -47,12 +45,12 @@ fn hang_plus_four_stays_and_following_item_splits() { let input = ticket_fixture(); let out = format_text(input, &md_cfg()).unwrap(); assert!( - out.contains(" indented. not split\n"), - "hang+4 code must stay intact, got:\n{out}" + out.contains("indented. not split"), + "joined hang+4 text must stay in the item, got:\n{out}" ); assert!( - !out.contains(" indented.\n"), - "must not sentence-split hang+4 code, got:\n{out}" + !out.contains(" indented"), + "hang+4 without a blank must not stay a code line, got:\n{out}" ); assert!( out.contains("- Item one is a sentence.\n Second sentence."), From 4fdc8afe80240afd58ed5ff9932d06e54b25a109 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 22 Sep 2026 19:09:27 -0500 Subject: [PATCH 23/37] fix(latex): leftover smallmatrix body stays non-prose amsmath smallmatrix and its delimiter variants are math arrays, the same class as matrix. A period in the body no longer splits. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 2 +- src/parser/latex.rs | 53 ++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9808282..cdb3276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover LaTeX smallmatrix body stays non-prose - leftover markdown hang-plus-four line stays in an open list paragraph - leftover org nested item returns to the parent list - leftover org item ends at the bullet column diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index e44d065..d34757b 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -95,7 +95,7 @@ These tokens within prose are not split across lines: :CUSTOM_ID: latex-structure :END: - Preamble (everything before =\begin{document}=) -- Non-prose environments: equation, align, tabular, tikzpicture, and their starred variants (plus other non-code envs) +- Non-prose environments: equation, align, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) - Float chrome (=\begin{figure}= / =\centering= / =\end{figure}=, and =table= / starred variants): Structure. The =\caption= long argument is Prose. - Extra names from =[latex].structure_envs= in =.snapperrc.toml= (for example =algorithm=) diff --git a/src/parser/latex.rs b/src/parser/latex.rs index f51aa7b..7e57c3a 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -103,6 +103,12 @@ static NON_PROSE_ENVS: &[&str] = &[ "Bmatrix", "vmatrix", "Vmatrix", + "smallmatrix", + "psmallmatrix", + "bsmallmatrix", + "Bsmallmatrix", + "vsmallmatrix", + "Vsmallmatrix", "cases", "cases*", "dcases", @@ -2881,6 +2887,53 @@ More text. assert!(structure_count >= 4); } + #[test] + fn smallmatrix_body_is_not_sentence_split() { + // amsmath smallmatrix is a math array, same class as matrix. + let input = concat!( + "\\begin{smallmatrix}\n", + "a & b. Another sentence stays put.\n", + "\\end{smallmatrix}\n", + "After the array. Second sentence.\n", + ); + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("a & b. Another sentence stays put.\n"), + "smallmatrix body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the array.\nSecond sentence."), + "prose after the array must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + for name in [ + "psmallmatrix", + "bsmallmatrix", + "Bsmallmatrix", + "vsmallmatrix", + "Vsmallmatrix", + ] { + let env = format!( + "\\begin{{{name}}}\na & b. Another sentence stays put.\n\\end{{{name}}}\nAfter the array. Second sentence.\n" + ); + let env_out = crate::format_text(&env, &cfg).unwrap(); + assert!( + env_out.contains("a & b. Another sentence stays put.\n"), + "{name} body must stay one line, got:\n{env_out}" + ); + assert!( + env_out.contains("After the array.\nSecond sentence."), + "prose after {name} must still split, got:\n{env_out}" + ); + } + } + #[test] fn comments_preserved() { let input = r"\begin{document} From 0684feff23859836c4bc22aa266c44e5111deab9 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 02:10:19 -0500 Subject: [PATCH 24/37] fix(latex): leftover xalignat body stays non-prose amsmath xalignat and xxalignat are alignment displays, the same class as alignat. A period in the body no longer splits. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 2 +- src/parser/latex.rs | 31 ++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdb3276..50d136d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover LaTeX xalignat body stays non-prose - leftover LaTeX smallmatrix body stays non-prose - leftover markdown hang-plus-four line stays in an open list paragraph - leftover org nested item returns to the parent list diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index d34757b..2942b78 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -95,7 +95,7 @@ These tokens within prose are not split across lines: :CUSTOM_ID: latex-structure :END: - Preamble (everything before =\begin{document}=) -- Non-prose environments: equation, align, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) +- Non-prose environments: equation, align, alignat, xalignat, xxalignat, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) - Float chrome (=\begin{figure}= / =\centering= / =\end{figure}=, and =table= / starred variants): Structure. The =\caption= long argument is Prose. - Extra names from =[latex].structure_envs= in =.snapperrc.toml= (for example =algorithm=) diff --git a/src/parser/latex.rs b/src/parser/latex.rs index 7e57c3a..ed1f91a 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -30,6 +30,10 @@ static NON_PROSE_ENVS: &[&str] = &[ "align*", "alignat", "alignat*", + "xalignat", + "xalignat*", + "xxalignat", + "xxalignat*", "aligned", "aligned*", "alignedat", @@ -2887,6 +2891,33 @@ More text. assert!(structure_count >= 4); } + #[test] + fn xalignat_body_is_not_sentence_split() { + // amsmath xalignat / xxalignat are alignment displays, same + // class as alignat. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + for name in ["xalignat", "xxalignat"] { + let input = format!( + "\\begin{{{name}}}{{2}}\na &= b. Second sentence stays put.\n\\end{{{name}}}\nAfter the align. Next.\n" + ); + let out = crate::format_text(&input, &cfg).unwrap(); + assert!( + out.contains("a &= b. Second sentence stays put.\n"), + "{name} body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the align.\nNext."), + "prose after {name} must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + } + #[test] fn smallmatrix_body_is_not_sentence_split() { // amsmath smallmatrix is a math array, same class as matrix. From cedd0db7aa1490fb8effaf0d7a549ccfbabfb888 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 02:13:37 -0500 Subject: [PATCH 25/37] fix(latex): leftover subarray body stays non-prose amsmath subarray is a math stack, the same class as array. A period in the body no longer splits. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 2 +- src/parser/latex.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50d136d..ed38e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover LaTeX subarray body stays non-prose - leftover LaTeX xalignat body stays non-prose - leftover LaTeX smallmatrix body stays non-prose - leftover markdown hang-plus-four line stays in an open list paragraph diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 2942b78..725d1a3 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -95,7 +95,7 @@ These tokens within prose are not split across lines: :CUSTOM_ID: latex-structure :END: - Preamble (everything before =\begin{document}=) -- Non-prose environments: equation, align, alignat, xalignat, xxalignat, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) +- Non-prose environments: equation, align, alignat, xalignat, xxalignat, array, subarray, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) - Float chrome (=\begin{figure}= / =\centering= / =\end{figure}=, and =table= / starred variants): Structure. The =\caption= long argument is Prose. - Extra names from =[latex].structure_envs= in =.snapperrc.toml= (for example =algorithm=) diff --git a/src/parser/latex.rs b/src/parser/latex.rs index ed1f91a..fadc5ea 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -101,6 +101,7 @@ static NON_PROSE_ENVS: &[&str] = &[ "axis*", "array", "array*", + "subarray", "matrix", "pmatrix", "bmatrix", @@ -2891,6 +2892,33 @@ More text. assert!(structure_count >= 4); } + #[test] + fn subarray_body_is_not_sentence_split() { + // amsmath subarray is a math stack, same class as array. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{subarray}{c}\n", + "a. Second sentence stays put.\n", + "\\end{subarray}\n", + "After the stack. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("a. Second sentence stays put.\n"), + "subarray body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the stack.\nNext."), + "prose after the stack must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn xalignat_body_is_not_sentence_split() { // amsmath xalignat / xxalignat are alignment displays, same From a7d08a8653a16dfdea8c68536bfaa31fc8e148ae Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 02:19:27 -0500 Subject: [PATCH 26/37] fix(latex): leftover CD diagram body stays non-prose amscd CD is a commutative diagram, the same class as a math array. A period in the body no longer splits. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 2 +- src/parser/latex.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed38e88..3b8edbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover LaTeX CD diagram body stays non-prose - leftover LaTeX subarray body stays non-prose - leftover LaTeX xalignat body stays non-prose - leftover LaTeX smallmatrix body stays non-prose diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 725d1a3..bfc6257 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -95,7 +95,7 @@ These tokens within prose are not split across lines: :CUSTOM_ID: latex-structure :END: - Preamble (everything before =\begin{document}=) -- Non-prose environments: equation, align, alignat, xalignat, xxalignat, array, subarray, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) +- Non-prose environments: equation, align, alignat, xalignat, xxalignat, array, subarray, CD, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) - Float chrome (=\begin{figure}= / =\centering= / =\end{figure}=, and =table= / starred variants): Structure. The =\caption= long argument is Prose. - Extra names from =[latex].structure_envs= in =.snapperrc.toml= (for example =algorithm=) diff --git a/src/parser/latex.rs b/src/parser/latex.rs index fadc5ea..5f057c8 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -102,6 +102,7 @@ static NON_PROSE_ENVS: &[&str] = &[ "array", "array*", "subarray", + "CD", "matrix", "pmatrix", "bmatrix", @@ -2892,6 +2893,33 @@ More text. assert!(structure_count >= 4); } + #[test] + fn cd_body_is_not_sentence_split() { + // amscd CD is a commutative diagram, same class as a math array. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{CD}\n", + "Arrow to the target. Second sentence stays put.\n", + "\\end{CD}\n", + "After the diagram. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("Arrow to the target. Second sentence stays put.\n"), + "CD body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the diagram.\nNext."), + "prose after the diagram must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn subarray_body_is_not_sentence_split() { // amsmath subarray is a math stack, same class as array. From 8bd2e827d19e1f1a7410f335cae238baac7d158a Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 02:39:36 -0500 Subject: [PATCH 27/37] fix(latex): leftover prooftree body stays non-prose bussproofs prooftree is a proof diagram, the same class as a commutative diagram. A period in the body no longer splits. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 2 +- src/parser/latex.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8edbd..a537ebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover LaTeX prooftree body stays non-prose - leftover LaTeX CD diagram body stays non-prose - leftover LaTeX subarray body stays non-prose - leftover LaTeX xalignat body stays non-prose diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index bfc6257..9db681a 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -95,7 +95,7 @@ These tokens within prose are not split across lines: :CUSTOM_ID: latex-structure :END: - Preamble (everything before =\begin{document}=) -- Non-prose environments: equation, align, alignat, xalignat, xxalignat, array, subarray, CD, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) +- Non-prose environments: equation, align, alignat, xalignat, xxalignat, array, subarray, CD, prooftree, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) - Float chrome (=\begin{figure}= / =\centering= / =\end{figure}=, and =table= / starred variants): Structure. The =\caption= long argument is Prose. - Extra names from =[latex].structure_envs= in =.snapperrc.toml= (for example =algorithm=) diff --git a/src/parser/latex.rs b/src/parser/latex.rs index 5f057c8..d596e21 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -103,6 +103,7 @@ static NON_PROSE_ENVS: &[&str] = &[ "array*", "subarray", "CD", + "prooftree", "matrix", "pmatrix", "bmatrix", @@ -2893,6 +2894,33 @@ More text. assert!(structure_count >= 4); } + #[test] + fn prooftree_body_is_not_sentence_split() { + // bussproofs prooftree is a proof diagram, same class as CD. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{prooftree}\n", + "The premise holds. The next claim stays put.\n", + "\\end{prooftree}\n", + "After the proof. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("The premise holds. The next claim stays put.\n"), + "prooftree body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the proof.\nNext."), + "prose after the proof must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn cd_body_is_not_sentence_split() { // amscd CD is a commutative diagram, same class as a math array. From f60b98abc7b60b7f12350738165d5685c30910d3 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 02:42:15 -0500 Subject: [PATCH 28/37] fix(latex): leftover empheq body stays non-prose empheq wraps a display. A body line that is not an inner non-prose environment no longer sentence-splits. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 2 +- src/parser/latex.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a537ebf..b6b3722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover LaTeX empheq body stays non-prose - leftover LaTeX prooftree body stays non-prose - leftover LaTeX CD diagram body stays non-prose - leftover LaTeX subarray body stays non-prose diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 9db681a..b0778c2 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -95,7 +95,7 @@ These tokens within prose are not split across lines: :CUSTOM_ID: latex-structure :END: - Preamble (everything before =\begin{document}=) -- Non-prose environments: equation, align, alignat, xalignat, xxalignat, array, subarray, CD, prooftree, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) +- Non-prose environments: equation, align, alignat, xalignat, xxalignat, empheq, array, subarray, CD, prooftree, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) - Float chrome (=\begin{figure}= / =\centering= / =\end{figure}=, and =table= / starred variants): Structure. The =\caption= long argument is Prose. - Extra names from =[latex].structure_envs= in =.snapperrc.toml= (for example =algorithm=) diff --git a/src/parser/latex.rs b/src/parser/latex.rs index d596e21..1367fe1 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -104,6 +104,8 @@ static NON_PROSE_ENVS: &[&str] = &[ "subarray", "CD", "prooftree", + "empheq", + "empheq*", "matrix", "pmatrix", "bmatrix", @@ -2894,6 +2896,34 @@ More text. assert!(structure_count >= 4); } + #[test] + fn empheq_body_is_not_sentence_split() { + // empheq wraps a display. A line that is not itself an inner + // non-prose environment still must not sentence-split. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{empheq}{align}\n", + "The premise holds. The next claim stays put.\n", + "\\end{empheq}\n", + "After the display. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("The premise holds. The next claim stays put.\n"), + "empheq body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the display.\nNext."), + "prose after the display must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn prooftree_body_is_not_sentence_split() { // bussproofs prooftree is a proof diagram, same class as CD. From b84d99eb0a7b0738b5dfa07aa9a28e0dcefa7661 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 05:20:10 -0500 Subject: [PATCH 29/37] fix(latex): leftover intertext argument stays prose amsmath intertext and mathtools shortintertext are paragraphs between alignment rows. A period in the argument now splits, and the alignment rows stay intact. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 3 +- src/parser/latex.rs | 82 ++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b3722..d8aa9d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover LaTeX intertext argument stays prose - leftover LaTeX empheq body stays non-prose - leftover LaTeX prooftree body stays non-prose - leftover LaTeX CD diagram body stays non-prose diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index b0778c2..6b05efe 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -95,7 +95,8 @@ These tokens within prose are not split across lines: :CUSTOM_ID: latex-structure :END: - Preamble (everything before =\begin{document}=) -- Non-prose environments: equation, align, alignat, xalignat, xxalignat, empheq, array, subarray, CD, prooftree, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs) +- Non-prose environments: equation, align, alignat, xalignat, xxalignat, empheq, array, subarray, CD, prooftree, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs). + =\\intertext= and =\\shortintertext= arguments inside those displays stay prose - Float chrome (=\begin{figure}= / =\centering= / =\end{figure}=, and =table= / starred variants): Structure. The =\caption= long argument is Prose. - Extra names from =[latex].structure_envs= in =.snapperrc.toml= (for example =algorithm=) diff --git a/src/parser/latex.rs b/src/parser/latex.rs index 1367fe1..6d70f27 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -740,6 +740,45 @@ fn caption_open_brace(line: &str, cmd_end: usize, stop: usize) -> Option } /// Matching `}` for the `{` at `open_at`, skipping `\{` / `\}` and `\verb`. +/// Index of the `{` that opens `\intertext` or `\shortintertext`. +fn find_intertext_brace(line: &str, extra: &[String]) -> Option { + let bytes = line.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] != b'\\' { + i += 1; + continue; + } + if let Some(end) = latex_verb_span_end_with(line, i, extra) { + i = end; + continue; + } + let rest = &line[i + 1..]; + let name = if rest.starts_with("shortintertext") { + "shortintertext" + } else if rest.starts_with("intertext") { + "intertext" + } else { + i += 1; + continue; + }; + let after_name = i + 1 + name.len(); + if after_name < bytes.len() && bytes[after_name].is_ascii_alphabetic() { + i += 1; + continue; + } + let mut j = after_name; + while j < bytes.len() && matches!(bytes[j], b' ' | b'\t') { + j += 1; + } + if j < bytes.len() && bytes[j] == b'{' { + return Some(j); + } + i += 1; + } + None +} + fn find_matching_curly(s: &str, open_at: usize, extra_cmds: &[String]) -> Option { let bytes = s.as_bytes(); let mut depth = 0; @@ -1941,6 +1980,16 @@ impl<'a> ParseState<'a> { i = hit.end; } } + let extra = self.parser.extra_verbatim_commands.clone(); + if let Some(open) = find_intertext_brace(line.text, &extra) { + if find_matching_curly(line.text, open, &extra).is_some() { + // amsmath \intertext / mathtools \shortintertext is a + // paragraph between alignment rows, not a math row. + self.push_structure(ByteSpan::new(line.start, line.start + open + 1)); + self.emit_caption_group(line, open); + return; + } + } self.regions .push(SpannedRegion::structure(self.input, line.span())); } @@ -2924,6 +2973,39 @@ More text. assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); } + #[test] + fn intertext_argument_is_prose() { + // amsmath \intertext / mathtools \shortintertext are paragraphs + // between alignment rows. The math rows stay structure. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + for cmd in ["intertext", "shortintertext"] { + let input = format!( + "\\begin{{align}}\na &= b \\\\\n\\{cmd}{{The note holds. The next claim stays put.}}\nc &= d\n\\end{{align}}\nAfter the align. Next.\n" + ); + let out = crate::format_text(&input, &cfg).unwrap(); + assert!( + out.contains(&format!( + "\\{cmd}{{The note holds.\nThe next claim stays put.}}" + )), + "{cmd} argument must split, got:\n{out}" + ); + assert!( + out.contains("a &= b \\\\"), + "alignment row must stay intact, got:\n{out}" + ); + assert!( + out.contains("After the align.\nNext."), + "prose after the align must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + } + #[test] fn prooftree_body_is_not_sentence_split() { // bussproofs prooftree is a proof diagram, same class as CD. From 1b6d77c0f4e8a3c8b6438aac4083d796111873e6 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 05:38:48 -0500 Subject: [PATCH 30/37] fix(latex): leftover numcases body stays non-prose cases.sty numcases and subnumcases are numbered case displays, the same class as amsmath cases. A period in a case no longer splits. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 2 +- src/parser/latex.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8aa9d6..e40c0e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover LaTeX numcases body stays non-prose - leftover LaTeX intertext argument stays prose - leftover LaTeX empheq body stays non-prose - leftover LaTeX prooftree body stays non-prose diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 6b05efe..e164f7a 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -95,7 +95,7 @@ These tokens within prose are not split across lines: :CUSTOM_ID: latex-structure :END: - Preamble (everything before =\begin{document}=) -- Non-prose environments: equation, align, alignat, xalignat, xxalignat, empheq, array, subarray, CD, prooftree, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs). +- Non-prose environments: equation, align, alignat, xalignat, xxalignat, empheq, cases, numcases, array, subarray, CD, prooftree, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs). =\\intertext= and =\\shortintertext= arguments inside those displays stay prose - Float chrome (=\begin{figure}= / =\centering= / =\end{figure}=, and =table= / starred variants): Structure. The =\caption= long argument is Prose. diff --git a/src/parser/latex.rs b/src/parser/latex.rs index 6d70f27..92f96ab 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -120,6 +120,8 @@ static NON_PROSE_ENVS: &[&str] = &[ "Vsmallmatrix", "cases", "cases*", + "numcases", + "subnumcases", "dcases", "dcases*", "rcases", @@ -2945,6 +2947,33 @@ More text. assert!(structure_count >= 4); } + #[test] + fn numcases_body_is_not_sentence_split() { + // cases.sty numcases / subnumcases are numbered cases, same + // class as amsmath cases. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + for name in ["numcases", "subnumcases"] { + let input = format!( + "\\begin{{{name}}}{{y =}}\nx & x > 0. Second sentence stays put.\n\\end{{{name}}}\nAfter the cases. Next.\n" + ); + let out = crate::format_text(&input, &cfg).unwrap(); + assert!( + out.contains("x & x > 0. Second sentence stays put.\n"), + "{name} body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the cases.\nNext."), + "prose after {name} must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + } + #[test] fn empheq_body_is_not_sentence_split() { // empheq wraps a display. A line that is not itself an inner From 2e855746e6ffa991a1a297e99f3fe42559cddd40 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 05:40:45 -0500 Subject: [PATCH 31/37] fix(latex): leftover quantikz body stays non-prose quantikz is a circuit diagram, the same class as tikzcd. A period in the body no longer splits. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 2 +- src/parser/latex.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e40c0e6..73d919a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover LaTeX quantikz body stays non-prose - leftover LaTeX numcases body stays non-prose - leftover LaTeX intertext argument stays prose - leftover LaTeX empheq body stays non-prose diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index e164f7a..8d173f1 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -95,7 +95,7 @@ These tokens within prose are not split across lines: :CUSTOM_ID: latex-structure :END: - Preamble (everything before =\begin{document}=) -- Non-prose environments: equation, align, alignat, xalignat, xxalignat, empheq, cases, numcases, array, subarray, CD, prooftree, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs). +- Non-prose environments: equation, align, alignat, xalignat, xxalignat, empheq, cases, numcases, array, subarray, CD, prooftree, quantikz, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs). =\\intertext= and =\\shortintertext= arguments inside those displays stay prose - Float chrome (=\begin{figure}= / =\centering= / =\end{figure}=, and =table= / starred variants): Structure. The =\caption= long argument is Prose. diff --git a/src/parser/latex.rs b/src/parser/latex.rs index 92f96ab..8932559 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -95,6 +95,7 @@ static NON_PROSE_ENVS: &[&str] = &[ "tikzpicture", "tikzcd", "tikzcd*", + "quantikz", "pgfpicture", "pgfpicture*", "axis", @@ -2947,6 +2948,33 @@ More text. assert!(structure_count >= 4); } + #[test] + fn quantikz_body_is_not_sentence_split() { + // quantikz is a circuit diagram, same class as tikzcd. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{quantikz}\n", + "The wire is open. The next claim stays put.\n", + "\\end{quantikz}\n", + "After the circuit. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("The wire is open. The next claim stays put.\n"), + "quantikz body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the circuit.\nNext."), + "prose after the circuit must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn numcases_body_is_not_sentence_split() { // cases.sty numcases / subnumcases are numbered cases, same From ab11c694e05de9039b4c6dca466760ab9a686eb5 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 05:43:58 -0500 Subject: [PATCH 32/37] fix(latex): leftover yquant body stays non-prose yquant is a circuit diagram, the same class as quantikz. A period in the body no longer splits. --- CHANGELOG.md | 1 + docs/orgmode/reference/formats.org | 2 +- src/parser/latex.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d919a..a8b7924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## Unreleased (main) #### Bug Fixes +- leftover LaTeX yquant body stays non-prose - leftover LaTeX quantikz body stays non-prose - leftover LaTeX numcases body stays non-prose - leftover LaTeX intertext argument stays prose diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 8d173f1..37bdd50 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -95,7 +95,7 @@ These tokens within prose are not split across lines: :CUSTOM_ID: latex-structure :END: - Preamble (everything before =\begin{document}=) -- Non-prose environments: equation, align, alignat, xalignat, xxalignat, empheq, cases, numcases, array, subarray, CD, prooftree, quantikz, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs). +- Non-prose environments: equation, align, alignat, xalignat, xxalignat, empheq, cases, numcases, array, subarray, CD, prooftree, quantikz, yquant, matrix, smallmatrix, tabular, tikzpicture, and their starred variants (plus other non-code envs). =\\intertext= and =\\shortintertext= arguments inside those displays stay prose - Float chrome (=\begin{figure}= / =\centering= / =\end{figure}=, and =table= / starred variants): Structure. The =\caption= long argument is Prose. diff --git a/src/parser/latex.rs b/src/parser/latex.rs index 8932559..84ffa2b 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -96,6 +96,7 @@ static NON_PROSE_ENVS: &[&str] = &[ "tikzcd", "tikzcd*", "quantikz", + "yquant", "pgfpicture", "pgfpicture*", "axis", @@ -2948,6 +2949,33 @@ More text. assert!(structure_count >= 4); } + #[test] + fn yquant_body_is_not_sentence_split() { + // yquant is a circuit diagram, same class as quantikz. + let cfg = crate::FormatConfig { + format: crate::format::Format::Latex, + max_width: 0, + ..Default::default() + } + .without_safety_backstops(); + let input = concat!( + "\\begin{yquant}\n", + "The wire is open. The next claim stays put.\n", + "\\end{yquant}\n", + "After the circuit. Next.\n", + ); + let out = crate::format_text(input, &cfg).unwrap(); + assert!( + out.contains("The wire is open. The next claim stays put.\n"), + "yquant body must stay one line, got:\n{out}" + ); + assert!( + out.contains("After the circuit.\nNext."), + "prose after the circuit must still split, got:\n{out}" + ); + assert_eq!(crate::format_text(&out, &cfg).unwrap(), out); + } + #[test] fn quantikz_body_is_not_sentence_split() { // quantikz is a circuit diagram, same class as tikzcd. From 4ad7a55631d7e39a39cc364d33c99b56f59a7152 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 05:50:02 -0500 Subject: [PATCH 33/37] chore(release): v0.11.7 Pin the channel versions and record the bugfix changelog. --- CHANGELOG.md | 2 +- Cargo.lock | 4 ++-- Cargo.toml | 2 +- README.md | 2 +- conda/recipe.yaml | 2 +- docs/orgmode/howto/ci-enforcement.org | 6 +++--- docs/orgmode/howto/vale-integration.org | 2 +- docs/orgmode/tutorials/quickstart.org | 2 +- docs/source/conf.py | 2 +- editors/obsidian/manifest.json | 2 +- editors/obsidian/package-lock.json | 6 +++--- editors/obsidian/package.json | 2 +- editors/vscode/README.md | 4 ++-- editors/vscode/package.json | 2 +- editors/word/README.md | 4 ++-- editors/word/manifest.xml | 2 +- editors/word/package-lock.json | 6 +++--- editors/word/package.json | 2 +- editors/word/src/taskpane/taskpane.html | 2 +- npm/package.json | 2 +- packages/snapper-wasm/package-lock.json | 4 ++-- packages/snapper-wasm/package.json | 2 +- readme_src.org | 2 +- 23 files changed, 33 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8b7924..8530378 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. - - - -## Unreleased (main) +## v0.11.7 - 2026-09-23 #### Bug Fixes - leftover LaTeX yquant body stays non-prose - leftover LaTeX quantikz body stays non-prose diff --git a/Cargo.lock b/Cargo.lock index 4ebb597..82b6ad1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2221,7 +2221,7 @@ checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "snapper-fmt" -version = "0.11.6" +version = "0.11.7" dependencies = [ "anyhow", "clap", @@ -3325,7 +3325,7 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml index 8c69811..d0e3008 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "snapper-fmt" -version = "0.11.6" +version = "0.11.7" edition = "2024" rust-version = "1.85" description = "Semantic line break formatter for Org, LaTeX, Markdown, RST, and plaintext" diff --git a/README.md b/README.md index 3f07847..802a4f9 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ Configuration guide (org source in-tree): `docs/orgmode/howto/mcp-integration.or ## Pre-commit hook - repo: https://github.com/TurtleTech-ehf/snapper - rev: v0.11.6 + rev: v0.11.7 hooks: - id: snapper diff --git a/conda/recipe.yaml b/conda/recipe.yaml index 1fb3702..5127094 100644 --- a/conda/recipe.yaml +++ b/conda/recipe.yaml @@ -3,7 +3,7 @@ schema_version: 1 context: - version: "0.11.6" + version: "0.11.7" package: name: snapper-fmt diff --git a/docs/orgmode/howto/ci-enforcement.org b/docs/orgmode/howto/ci-enforcement.org index bd59c5b..209a97d 100644 --- a/docs/orgmode/howto/ci-enforcement.org +++ b/docs/orgmode/howto/ci-enforcement.org @@ -10,7 +10,7 @@ The easiest way to enforce semantic line breaks in CI. Add to your workflow: #+begin_src yaml -- uses: TurtleTech-ehf/snapper@v0.11.6 +- uses: TurtleTech-ehf/snapper@v0.11.7 with: files: '**/*.org **/*.tex **/*.md' #+end_src @@ -20,7 +20,7 @@ This installs snapper and runs =--check= on the specified files. For GitHub Code Scanning integration (SARIF annotations on PRs): #+begin_src yaml -- uses: TurtleTech-ehf/snapper@v0.11.6 +- uses: TurtleTech-ehf/snapper@v0.11.7 with: files: '**/*.org **/*.tex **/*.md' sarif: 'true' @@ -35,7 +35,7 @@ Add to =.pre-commit-config.yaml=: #+begin_src yaml - repo: https://github.com/TurtleTech-ehf/snapper - rev: v0.11.6 + rev: v0.11.7 hooks: - id: snapper #+end_src diff --git a/docs/orgmode/howto/vale-integration.org b/docs/orgmode/howto/vale-integration.org index f165e24..65905ff 100644 --- a/docs/orgmode/howto/vale-integration.org +++ b/docs/orgmode/howto/vale-integration.org @@ -70,7 +70,7 @@ A typical workflow: #+begin_src yaml - repo: https://github.com/TurtleTech-ehf/snapper - rev: v0.11.6 + rev: v0.11.7 hooks: - id: snapper - repo: https://github.com/errata-ai/vale diff --git a/docs/orgmode/tutorials/quickstart.org b/docs/orgmode/tutorials/quickstart.org index eebc79f..78595c4 100644 --- a/docs/orgmode/tutorials/quickstart.org +++ b/docs/orgmode/tutorials/quickstart.org @@ -232,7 +232,7 @@ Add to your =.pre-commit-config.yaml=: #+begin_src yaml - repo: https://github.com/TurtleTech-ehf/snapper - rev: v0.11.6 + rev: v0.11.7 hooks: - id: snapper #+end_src diff --git a/docs/source/conf.py b/docs/source/conf.py index eef2f5e..140e42b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -3,7 +3,7 @@ project = "snapper" copyright = '2026--present, Rohit Goswami' author = "Rohit Goswami" -release = "0.11.6" +release = "0.11.7" html_logo = "../../branding/logo/snapper_logo.png" extensions = [ diff --git a/editors/obsidian/manifest.json b/editors/obsidian/manifest.json index 344ab5c..d944b98 100644 --- a/editors/obsidian/manifest.json +++ b/editors/obsidian/manifest.json @@ -1,7 +1,7 @@ { "id": "snapper", "name": "Snapper - Semantic Line Breaks", - "version": "0.11.6", + "version": "0.11.7", "minAppVersion": "1.0.0", "description": "Format prose with semantic line breaks for clean git diffs. Supports Org-mode, LaTeX, Markdown, and plaintext.", "author": "TurtleTech", diff --git a/editors/obsidian/package-lock.json b/editors/obsidian/package-lock.json index 0f5a933..ec95ae5 100644 --- a/editors/obsidian/package-lock.json +++ b/editors/obsidian/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-snapper", - "version": "0.11.6", + "version": "0.11.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-snapper", - "version": "0.11.6", + "version": "0.11.7", "license": "MIT", "devDependencies": { "@snapper/wasm": "file:../../packages/snapper-wasm", @@ -17,7 +17,7 @@ }, "../../packages/snapper-wasm": { "name": "@snapper/wasm", - "version": "0.11.6", + "version": "0.11.7", "dev": true, "license": "MIT", "devDependencies": { diff --git a/editors/obsidian/package.json b/editors/obsidian/package.json index 85b7f0a..3cf5cb0 100644 --- a/editors/obsidian/package.json +++ b/editors/obsidian/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-snapper", - "version": "0.11.6", + "version": "0.11.7", "private": true, "description": "Obsidian plugin for snapper semantic line break formatter", "scripts": { diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 0910253..df1ea77 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -1,9 +1,9 @@ # snapper - Semantic Line Breaks -Requires the **snapper** / **snapper-fmt** CLI **0.11.6+** on your PATH (or set `snapper.path`). +Requires the **snapper** / **snapper-fmt** CLI **0.11.7+** on your PATH (or set `snapper.path`). The crate is `snapper-fmt`; installers ship both names. If [openSUSE snapper](https://github.com/openSUSE/snapper) already owns `/usr/bin/snapper`, call `snapper-fmt` or set `snapper.path`. -Install: `cargo install snapper-fmt` or the [release installer](https://github.com/TurtleTech-ehf/snapper/releases/tag/v0.11.6). +Install: `cargo install snapper-fmt` or the [release installer](https://github.com/TurtleTech-ehf/snapper/releases/tag/v0.11.7). Format prose so each sentence occupies its own line, producing clean git diffs for collaborative writing. diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 348367f..11f52ff 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "snapper", "displayName": "snapper - Semantic Line Breaks", "description": "Format prose with semantic line breaks for clean git diffs", - "version": "0.11.6", + "version": "0.11.7", "publisher": "TurtleTech", "license": "MIT", "repository": { diff --git a/editors/word/README.md b/editors/word/README.md index 18bef59..6c23fe6 100644 --- a/editors/word/README.md +++ b/editors/word/README.md @@ -2,7 +2,7 @@ Office add-in that formats document prose with **semantic line breaks** (one sentence per line) using the **snapper WASM** build (`@snapper/wasm`). Useful when drafting in Word and exporting to Org, Markdown, or LaTeX for git-friendly diffs. -**Version:** 0.11.6 (tracks snapper-fmt) +**Version:** 0.11.7 (tracks snapper-fmt) Development preview; not published in AppSource. @@ -34,7 +34,7 @@ CI builds the Word add-in in `.github/workflows/wasm.yml` (`build-word` job). ## Relationship to the CLI -Word uses the **plaintext** format path in WASM. For Org/LaTeX fidelity, prefer the CLI or VS Code extension (LSP). Delimiter-span and abbreviation behavior matches the current WASM API (requires snapper **0.11.6+**). +Word uses the **plaintext** format path in WASM. For Org/LaTeX fidelity, prefer the CLI or VS Code extension (LSP). Delimiter-span and abbreviation behavior matches the current WASM API (requires snapper **0.11.7+**). ## License diff --git a/editors/word/manifest.xml b/editors/word/manifest.xml index 653c640..d4a342b 100644 --- a/editors/word/manifest.xml +++ b/editors/word/manifest.xml @@ -5,7 +5,7 @@ xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xsi:type="TaskPaneApp"> a8f3c2e1-9b4d-4e7a-8c5f-1d2e3f4a5b6c - 0.11.6 + 0.11.7 TurtleTech ehf en-US diff --git a/editors/word/package-lock.json b/editors/word/package-lock.json index ad62f27..8f70665 100644 --- a/editors/word/package-lock.json +++ b/editors/word/package-lock.json @@ -1,12 +1,12 @@ { "name": "word-snapper", - "version": "0.11.6", + "version": "0.11.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "word-snapper", - "version": "0.11.6", + "version": "0.11.7", "license": "MIT", "devDependencies": { "@snapper/wasm": "file:../../packages/snapper-wasm", @@ -21,7 +21,7 @@ }, "../../packages/snapper-wasm": { "name": "@snapper/wasm", - "version": "0.11.6", + "version": "0.11.7", "dev": true, "license": "MIT", "devDependencies": { diff --git a/editors/word/package.json b/editors/word/package.json index aa87db9..f029253 100644 --- a/editors/word/package.json +++ b/editors/word/package.json @@ -1,6 +1,6 @@ { "name": "word-snapper", - "version": "0.11.6", + "version": "0.11.7", "private": true, "description": "Microsoft Word add-in for snapper semantic line break formatter", "scripts": { diff --git a/editors/word/src/taskpane/taskpane.html b/editors/word/src/taskpane/taskpane.html index b7c12ee..6919521 100644 --- a/editors/word/src/taskpane/taskpane.html +++ b/editors/word/src/taskpane/taskpane.html @@ -10,7 +10,7 @@

Snapper

-

Semantic line breaks for Word (v0.11.6)

+

Semantic line breaks for Word (v0.11.7)

Formats paragraphs as plaintext. Use the CLI or VS Code for Org/LaTeX structure awareness.

diff --git a/npm/package.json b/npm/package.json index ec3b170..d5a5adb 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@turtletech/snapper-mcp", - "version": "0.11.6", + "version": "0.11.7", "private": true, "description": "MCP server for snapper semantic line break formatter", "main": "bin/run.js", diff --git a/packages/snapper-wasm/package-lock.json b/packages/snapper-wasm/package-lock.json index 8b88166..7687340 100644 --- a/packages/snapper-wasm/package-lock.json +++ b/packages/snapper-wasm/package-lock.json @@ -1,12 +1,12 @@ { "name": "@snapper/wasm", - "version": "0.11.6", + "version": "0.11.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@snapper/wasm", - "version": "0.11.6", + "version": "0.11.7", "license": "MIT", "devDependencies": { "typescript": "^5.4" diff --git a/packages/snapper-wasm/package.json b/packages/snapper-wasm/package.json index 32373cb..c0f5143 100644 --- a/packages/snapper-wasm/package.json +++ b/packages/snapper-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@snapper/wasm", - "version": "0.11.6", + "version": "0.11.7", "description": "WebAssembly build of snapper semantic line break formatter", "type": "module", "main": "dist/index.js", diff --git a/readme_src.org b/readme_src.org index ad86b3a..76a02a3 100644 --- a/readme_src.org +++ b/readme_src.org @@ -157,7 +157,7 @@ Configuration guide (org source in-tree): =docs/orgmode/howto/mcp-integration.or :END: #+begin_src yaml - repo: https://github.com/TurtleTech-ehf/snapper - rev: v0.11.6 + rev: v0.11.7 hooks: - id: snapper #+end_src From ea7ad0550c27019d51740e66990c2744ecdd13ad Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 05:56:14 -0500 Subject: [PATCH 34/37] fix(release): keep zerovec at its crates.io version The 0.11.7 pin rewrote every 0.11.6 in the lockfile. zerovec is a registry crate, and cargo metadata --locked rejected the new checksum. --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 82b6ad1..6a7ca24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3325,7 +3325,7 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ From 1f0ec4e44c02b13638131132f3381376031d6a3d Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 06:12:22 -0500 Subject: [PATCH 35/37] style(docs): reflow the git filter howto Dogfood --check reformats the smudge/clean setup paragraph onto one line. --- docs/orgmode/howto/git-filter.org | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/orgmode/howto/git-filter.org b/docs/orgmode/howto/git-filter.org index 33eb573..9689468 100644 --- a/docs/orgmode/howto/git-filter.org +++ b/docs/orgmode/howto/git-filter.org @@ -14,9 +14,7 @@ This makes semantic line breaks transparent to collaborators who do not use snap :CUSTOM_ID: setup :END: -=snapper init= writes this filter into the local git config as -=snapper-fmt --native --stdin-filepath %f= (extension auto-detect; =snapper-fmt= -avoids openSUSE's Btrfs =snapper=) plus a working pre-commit hook. +=snapper init= writes this filter into the local git config as =snapper-fmt --native --stdin-filepath %f= (extension auto-detect; =snapper-fmt= avoids openSUSE's Btrfs =snapper=) plus a working pre-commit hook. To configure it by hand: #+begin_src bash From fbbfd51d0e075fbfeea1dfe48a771c7f3e498fc6 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 06:20:41 -0500 Subject: [PATCH 36/37] fix(init): run the generated pre-commit hook with bash NUL-delimited read is not POSIX. Ubuntu dash skipped the loop, so staged prose was left unchanged. --- CHANGELOG.md | 1 + src/init.rs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8530378..fc91b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. See [conven - - - ## v0.11.7 - 2026-09-23 #### Bug Fixes +- generated pre-commit hook runs under bash so the format loop runs on Ubuntu - leftover LaTeX yquant body stays non-prose - leftover LaTeX quantikz body stays non-prose - leftover LaTeX numcases body stays non-prose diff --git a/src/init.rs b/src/init.rs index d45bf61..8c6b086 100644 --- a/src/init.rs +++ b/src/init.rs @@ -149,8 +149,10 @@ const HOOK_MARKER: &str = "Generated by `snapper init`"; /// Git pre-commit hook that formats staged prose with native parsers. fn generate_precommit_hook() -> String { format!( - r#"#!/bin/sh + r#"#!/usr/bin/env bash # {HOOK_MARKER}. Format staged prose with native parsers. +# bash, not /bin/sh: NUL-delimited `read -d ''` is not POSIX, and +# Ubuntu's dash skips the loop. set -e # Prefer snapper-fmt: argv0 snapper collides with openSUSE's Btrfs tool. if command -v snapper-fmt >/dev/null 2>&1; then From 8f7a62959d4d6740f5d1887622280b7f78f84e2d Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 23 Sep 2026 08:30:42 -0500 Subject: [PATCH 37/37] test(init): lock the generated pre-commit hook to a bash shebang The unit test still required a POSIX sh shebang after the hook moved to bash, so the ubuntu check and the wasm feature suite failed on it. --- src/init.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/init.rs b/src/init.rs index 8c6b086..b9af60d 100644 --- a/src/init.rs +++ b/src/init.rs @@ -403,7 +403,8 @@ mod tests { #[test] fn generate_precommit_hook_is_native_and_has_fmt_fallback() { let hook = generate_precommit_hook(); - assert!(hook.starts_with("#!/bin/sh")); + // NUL-delimited `read -d ''` is not POSIX. Ubuntu dash skips the loop. + assert!(hook.starts_with("#!/usr/bin/env bash\n")); assert!(hook.contains(HOOK_MARKER)); assert!(hook.contains("--native")); assert!(