From e8c5d3b0e5d5cf7a8a30c20095a87013fd6c7cfb Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Wed, 16 Sep 2026 02:59:19 -0500 Subject: [PATCH 01/32] fix(rst): leftover include same-line after filename Docutils leftover after .. include:: filename hangs as Prose and still splits. The filename stays Structure. Following flush prose still splits. --- src/parser/rst.rs | 38 +++++++++++++++++++++++++++++++ tests/rst_container_directives.rs | 15 ++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/parser/rst.rs b/src/parser/rst.rs index b809d85..f40b1a5 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -330,6 +330,7 @@ fn parse_line_based(input: &str) -> Vec { if let Some(marker_len) = rst_admonition_marker_len(line_text) .or_else(|| rst_substitution_replace_marker_len(line_text)) .or_else(|| rst_table_marker_len(line_text)) + .or_else(|| rst_include_marker_len(line_text)) { if line_text.len() > marker_len && !line_text[marker_len..].trim().is_empty() { in_meta = false; @@ -990,6 +991,43 @@ fn rst_table_marker_len(line: &str) -> Option { Some(colons_at + 2 + pad) } +/// Docutils `include` leftover: `.. include:: filename` plus pad. +/// Same-line leftover after the filename is hung Prose. +fn rst_include_marker_len(line: &str) -> Option { + let indent = line.len() - line.trim_start().len(); + let trimmed = &line[indent..]; + let rest = trimmed.strip_prefix("..")?; + if !rest.starts_with([' ', '\t']) { + return None; + } + let name_off = rest.len() - rest.trim_start().len(); + let after_ws = &rest[name_off..]; + let name_end = after_ws.find("::")?; + let name = after_ws[..name_end].trim().to_ascii_lowercase(); + if name != "include" { + return None; + } + let colons_at = indent + 2 + name_off + name_end; + let after_colons = &line[colons_at + 2..]; + let pad = after_colons.len() - after_colons.trim_start().len(); + let after_pad = &after_colons[pad..]; + if after_pad.is_empty() { + return None; + } + let fname_end = after_pad + .find(|c: char| c.is_whitespace()) + .unwrap_or(after_pad.len()); + if fname_end == 0 { + return None; + } + let after_fname = &after_pad[fname_end..]; + if after_fname.trim().is_empty() { + return None; + } + let fname_pad = after_fname.len() - after_fname.trim_start().len(); + Some(colons_at + 2 + pad + fname_end + fname_pad) +} + pub(crate) fn rst_admonition_marker_len(line: &str) -> Option { let indent = line.len() - line.trim_start().len(); let trimmed = &line[indent..]; diff --git a/tests/rst_container_directives.rs b/tests/rst_container_directives.rs index 00c010f..0ae3069 100644 --- a/tests/rst_container_directives.rs +++ b/tests/rst_container_directives.rs @@ -720,3 +720,18 @@ fn opaque_directives_stay_frozen() { ); } } + +#[test] +fn leftover_include_same_line_after_filename_hangs_and_splits() { + let input = concat!(".. include:: foo.rst leftover. Next.\n", "After. Next.\n",); + let out = format_text(input, &rst_cfg()).unwrap(); + assert!( + !out.contains(".. include:: foo.rst leftover. Next."), + "leftover after include filename must still split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &rst_cfg()).unwrap(), out); +} From ab7f6d9c9c385107100ca93fe04ce5e6eac9d1a8 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Thu, 17 Sep 2026 15:35:46 -0500 Subject: [PATCH 02/32] fix(org): leftover wrap-created drawer NAME org-element drawer begin is the whole line. Leftover after :NAME: is not a drawer, so wrap skip-cut prefix-matches the first token the same way DEADLINE leftover already does. --- src/reflow.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/reflow.rs b/src/reflow.rs index 703e9c2..cdaf173 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -686,7 +686,7 @@ fn org_opens_block(line: &str) -> bool { if t.starts_with("\\begin{") { return true; } - if crate::parser::org::is_org_drawer_begin(t) || org_fixed_width(t) || org_horizontal_rule(t) { + if org_drawer_first_token(t) || org_fixed_width(t) || org_horizontal_rule(t) { return true; } if org_planning_or_clock(t) { @@ -695,6 +695,15 @@ fn org_opens_block(line: &str) -> bool { ordered_list_start(t) } +/// First whitespace token is `:NAME:` or `:END:`. +/// org-element drawer begin is the whole line; wrap leftover after the +/// name is not a drawer, so skip-cut prefix-matches the token (snapper-8jgg). +fn org_drawer_first_token(line: &str) -> bool { + let t = line.trim_start_matches([' ', '\t']); + let token = t.split([' ', '\t']).next().unwrap_or(""); + crate::parser::org::is_org_drawer_begin(token) || token.eq_ignore_ascii_case(":END:") +} + /// org-element planning (`DEADLINE:`/`SCHEDULED:`/`CLOSED:`) or clock (`CLOCK:`). /// `org-element--current-element` binds `case-fold-search` t, so prefixes /// compare ignore ASCII case (GitHub #319). @@ -3166,6 +3175,24 @@ They are endowed with reason and conscience and should act towards one another i ); } + #[test] + fn wrap_created_org_drawer_name_leftover_is_not_a_block() { + // snapper-8jgg: leftover after :NAME: is not a drawer line, so + // skip-cut must prefix-match the first token (same as DEADLINE:). + for token in [":PROPERTIES:", ":LOGBOOK:", ":END:"] { + let result = wrap_fmt( + &format!("The options are apples {token} extra words here."), + 23, + crate::format::Format::Org, + ); + assert_no_col0_block(&result, &[token]); + assert!( + result.contains(&format!("apples {token}")), + "{token} stays with the previous line:\n{result}" + ); + } + } + #[test] fn wrap_created_md_empty_list_marker_is_not_a_block() { for token in ["-", "*", "+"] { From 81d1386f6de8f7dc2a153272e9aaa78eac8de592 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Thu, 17 Sep 2026 15:38:55 -0500 Subject: [PATCH 03/32] fix(org): leftover wrap-created plain-link prefixes org-element plain links include shell: mailto: doi: id: and siblings. Wrap skip-cut only kept file: and http(s) off column 0. Prefix-match the same table the leftover path walker uses. --- src/parser/org.rs | 95 ++++++++++++++++++++--------------------------- src/reflow.rs | 28 +++++++++++++- 2 files changed, 68 insertions(+), 55 deletions(-) diff --git a/src/parser/org.rs b/src/parser/org.rs index 05cdabc..f12cad5 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -166,65 +166,52 @@ struct OpenGreater { /// org-element-drawer-re NAME: `(any ?- ?_ word)` — hyphen, underscore, /// or Unicode word characters (letters and digits). `:END:` is the closer. -/// org-element plain link at column 0: `file+emacs:` / `file+sys:` / -/// `file:` / `shell:` / `elisp:` / `help:` / `info:` / `http://` / -/// `https://` / `eww:` / `irc:` / `bbdb:` / `gnus:` / `rmail:` / -/// `mhe:` / `vm:` / `wl:` / `mailto:` / -/// `news:` / `doi:` / `ftp://` / `attachment:` / `id:` plus the path. +/// org-element plain-link prefixes at column 0. `file+emacs:` / +/// `file+sys:` must be matched before `file:`. +const ORG_PLAIN_LINK_PREFIXES: &[&str] = &[ + "file+emacs:", + "file+sys:", + "file:", + "shell:", + "elisp:", + "help:", + "info:", + "eww:", + "irc:", + "bbdb:", + "gnus:", + "rmail:", + "mhe:", + "vm:", + "wl:", + "https://", + "http://", + "mailto:", + "news:", + "doi:", + "ftp://", + "attachment:", + "id:", +]; + +/// True when the line starts with an org-element plain-link prefix. +/// Wrap skip-cut uses the prefix so leftover after `shell:` / `mailto:` +/// cannot park at column 0 (snapper-hctf). Path is not required. +pub(crate) fn org_plain_link_starts(line: &str) -> bool { + let t = line.trim_start(); + ORG_PLAIN_LINK_PREFIXES.iter().any(|p| t.starts_with(p)) +} + +/// org-element plain link at column 0: prefix plus the path. /// Leftover after the path is hung Prose. `file+emacs:` / `file+sys:` /// must be matched before `file:` or the `+…` is eaten as the path. pub(crate) fn org_plain_link_marker_len(line: &str) -> Option { let indent = line.len() - line.trim_start().len(); let t = &line[indent..]; - let prefix = if t.starts_with("file+emacs:") { - "file+emacs:" - } else if t.starts_with("file+sys:") { - "file+sys:" - } else if t.starts_with("file:") { - "file:" - } else if t.starts_with("shell:") { - "shell:" - } else if t.starts_with("elisp:") { - "elisp:" - } else if t.starts_with("help:") { - "help:" - } else if t.starts_with("info:") { - "info:" - } else if t.starts_with("eww:") { - "eww:" - } else if t.starts_with("irc:") { - "irc:" - } else if t.starts_with("bbdb:") { - "bbdb:" - } else if t.starts_with("gnus:") { - "gnus:" - } else if t.starts_with("rmail:") { - "rmail:" - } else if t.starts_with("mhe:") { - "mhe:" - } else if t.starts_with("vm:") { - "vm:" - } else if t.starts_with("wl:") { - "wl:" - } else if t.starts_with("https://") { - "https://" - } else if t.starts_with("http://") { - "http://" - } else if t.starts_with("mailto:") { - "mailto:" - } else if t.starts_with("news:") { - "news:" - } else if t.starts_with("doi:") { - "doi:" - } else if t.starts_with("ftp://") { - "ftp://" - } else if t.starts_with("attachment:") { - "attachment:" - } else if t.starts_with("id:") { - "id:" - } else { - return None; - }; + let prefix = ORG_PLAIN_LINK_PREFIXES + .iter() + .copied() + .find(|p| t.starts_with(p))?; let after = &t[prefix.len()..]; if after.is_empty() || after.starts_with(char::is_whitespace) { return None; diff --git a/src/reflow.rs b/src/reflow.rs index cdaf173..040b2d5 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -680,7 +680,7 @@ fn org_opens_block(line: &str) -> bool { if t.starts_with("[[") { return true; } - if t.starts_with("file:") || t.starts_with("http://") || t.starts_with("https://") { + if crate::parser::org::org_plain_link_starts(t) { return true; } if t.starts_with("\\begin{") { @@ -3175,6 +3175,32 @@ They are endowed with reason and conscience and should act towards one another i ); } + #[test] + fn wrap_created_org_plain_link_prefixes_are_not_blocks() { + // snapper-hctf: org_opens_block skip-cut only file:/http(s). + // Remaining org-element plain-link prefixes must stay off column 0. + for token in [ + "shell:ls", + "elisp:(+)", + "mailto:dev@x.com", + "doi:10.1000/foo", + "id:abc-123", + "file+emacs:/tmp/x", + "attachment:plot.png", + ] { + let result = wrap_fmt( + &format!("The options are apples {token} extra words here."), + 23, + crate::format::Format::Org, + ); + assert_no_col0_block(&result, &[token]); + assert!( + result.contains(&format!("apples {token}")), + "{token} stays with the previous line:\n{result}" + ); + } + } + #[test] fn wrap_created_org_drawer_name_leftover_is_not_a_block() { // snapper-8jgg: leftover after :NAME: is not a drawer line, so From 3b0a15177c9f77bb3721a368b4d36e01b499a531 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Thu, 17 Sep 2026 15:41:57 -0500 Subject: [PATCH 04/32] fix(rst): leftover raw same-line after format Docutils raw takes a format argument. Same-line leftover after .. raw:: html hangs and splits the same way include leftover after the filename already does. --- src/parser/rst.rs | 7 ++++--- tests/rst_container_directives.rs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/parser/rst.rs b/src/parser/rst.rs index f40b1a5..01689b1 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -991,8 +991,9 @@ fn rst_table_marker_len(line: &str) -> Option { Some(colons_at + 2 + pad) } -/// Docutils `include` leftover: `.. include:: filename` plus pad. -/// Same-line leftover after the filename is hung Prose. +/// Docutils `include` / `raw` leftover: `.. include:: filename` or +/// `.. raw:: format` plus pad. Same-line leftover after the first +/// argument is hung Prose. fn rst_include_marker_len(line: &str) -> Option { let indent = line.len() - line.trim_start().len(); let trimmed = &line[indent..]; @@ -1004,7 +1005,7 @@ fn rst_include_marker_len(line: &str) -> Option { let after_ws = &rest[name_off..]; let name_end = after_ws.find("::")?; let name = after_ws[..name_end].trim().to_ascii_lowercase(); - if name != "include" { + if name != "include" && name != "raw" { return None; } let colons_at = indent + 2 + name_off + name_end; diff --git a/tests/rst_container_directives.rs b/tests/rst_container_directives.rs index 0ae3069..4b4742c 100644 --- a/tests/rst_container_directives.rs +++ b/tests/rst_container_directives.rs @@ -735,3 +735,18 @@ fn leftover_include_same_line_after_filename_hangs_and_splits() { ); assert_eq!(format_text(&out, &rst_cfg()).unwrap(), out); } + +#[test] +fn leftover_raw_same_line_after_format_hangs_and_splits() { + let input = concat!(".. raw:: html leftover. Next.\n", "After. Next.\n",); + let out = format_text(input, &rst_cfg()).unwrap(); + assert!( + !out.contains(".. raw:: html leftover. Next."), + "leftover after raw format must still split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &rst_cfg()).unwrap(), out); +} From d70f77109c26d146d03e8fb82316278e7ed6a510 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Thu, 17 Sep 2026 15:44:39 -0500 Subject: [PATCH 05/32] fix(rst): leftover literalinclude same-line after filename Sphinx literalinclude takes a filename argument. Same-line leftover after the path hangs and splits like include leftover. --- src/parser/rst.rs | 9 +++++---- tests/rst_container_directives.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/parser/rst.rs b/src/parser/rst.rs index 01689b1..5866509 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -991,9 +991,10 @@ fn rst_table_marker_len(line: &str) -> Option { Some(colons_at + 2 + pad) } -/// Docutils `include` / `raw` leftover: `.. include:: filename` or -/// `.. raw:: format` plus pad. Same-line leftover after the first -/// argument is hung Prose. +/// Docutils `include` / `raw` and Sphinx `literalinclude` leftover: +/// `.. include:: filename`, `.. raw:: format`, or +/// `.. literalinclude:: filename` plus pad. Same-line leftover after +/// the first argument is hung Prose. fn rst_include_marker_len(line: &str) -> Option { let indent = line.len() - line.trim_start().len(); let trimmed = &line[indent..]; @@ -1005,7 +1006,7 @@ fn rst_include_marker_len(line: &str) -> Option { let after_ws = &rest[name_off..]; let name_end = after_ws.find("::")?; let name = after_ws[..name_end].trim().to_ascii_lowercase(); - if name != "include" && name != "raw" { + if name != "include" && name != "raw" && name != "literalinclude" { return None; } let colons_at = indent + 2 + name_off + name_end; diff --git a/tests/rst_container_directives.rs b/tests/rst_container_directives.rs index 4b4742c..c3f0e35 100644 --- a/tests/rst_container_directives.rs +++ b/tests/rst_container_directives.rs @@ -736,6 +736,24 @@ fn leftover_include_same_line_after_filename_hangs_and_splits() { assert_eq!(format_text(&out, &rst_cfg()).unwrap(), out); } +#[test] +fn leftover_literalinclude_same_line_after_filename_hangs_and_splits() { + let input = concat!( + ".. literalinclude:: foo.py leftover. Next.\n", + "After. Next.\n", + ); + let out = format_text(input, &rst_cfg()).unwrap(); + assert!( + !out.contains(".. literalinclude:: foo.py leftover. Next."), + "leftover after literalinclude filename must still split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &rst_cfg()).unwrap(), out); +} + #[test] fn leftover_raw_same_line_after_format_hangs_and_splits() { let input = concat!(".. raw:: html leftover. Next.\n", "After. Next.\n",); From 10e06198eeab147717589d2d347c9a72c9ed1dce Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Thu, 17 Sep 2026 15:59:18 -0500 Subject: [PATCH 06/32] fix(rst): leftover raw keeps indented body opaque Same-line leftover after .. raw:: html hung as Prose and never entered the directive body, so the next indented HTML line was a hung paragraph. Freeze that body after the leftover. --- src/parser/rst.rs | 8 ++++++++ tests/rst_container_directives.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/parser/rst.rs b/src/parser/rst.rs index 5866509..9f3069a 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -349,6 +349,14 @@ fn parse_line_based(input: &str) -> Vec { // indented line is leftover Prose, not opaque. in_table_title = true; list_hang = Some(marker_len); + } else if rst_include_marker_len(line_text).is_some() { + // include / raw / literalinclude leftover: hang + // same-line Prose, then freeze the indented body + // (snapper-5den). + flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions); + let leading = line_text.len() - trimmed.len(); + directive_indent = leading + 2; + in_directive = true; } else { list_hang = Some(marker_len); } diff --git a/tests/rst_container_directives.rs b/tests/rst_container_directives.rs index c3f0e35..305d652 100644 --- a/tests/rst_container_directives.rs +++ b/tests/rst_container_directives.rs @@ -768,3 +768,34 @@ fn leftover_raw_same_line_after_format_hangs_and_splits() { ); assert_eq!(format_text(&out, &rst_cfg()).unwrap(), out); } + +#[test] +fn leftover_raw_same_line_then_indented_html_stays_opaque() { + let input = concat!( + ".. raw:: html leftover. Next.\n", + "

This is a long note sentence that must reflow. Second sentence.

\n", + "After. Next.\n", + ); + let regions = RstParser.parse(input); + assert!( + !regions.iter().any(|r| matches!( + r, + Region::Prose(s) if s.contains("

This is a long") + )), + "indented raw body after leftover must stay Structure, got {regions:?}" + ); + let out = format_text(input, &rst_cfg()).unwrap(); + assert!( + !out.contains(".. raw:: html leftover. Next."), + "leftover after raw format must still split, got:\n{out}" + ); + assert!( + out.contains("

This is a long note sentence that must reflow. Second sentence.

"), + "indented raw body must stay opaque, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &rst_cfg()).unwrap(), out); +} From abe1cafbee17ff7abc356126b32a58d74e6523c6 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Thu, 17 Sep 2026 16:31:57 -0500 Subject: [PATCH 07/32] fix(org): leftover man docview shortdoc plain links ol.el built-in plain links include man: docview: shortdoc:. The leftover path walker now treats them like file: so leftover hangs and wrap cannot park the prefix at column 0. --- src/parser/org.rs | 6 +++- src/reflow.rs | 3 ++ tests/org_file_token_punct.rs | 67 +++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/parser/org.rs b/src/parser/org.rs index f12cad5..c7a1de3 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -167,7 +167,8 @@ struct OpenGreater { /// org-element-drawer-re NAME: `(any ?- ?_ word)` — hyphen, underscore, /// 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:`. +/// `file+sys:` must be matched before `file:`. `man:` / `docview:` / +/// `shortdoc:` are ol.el built-ins (snapper-9aio). const ORG_PLAIN_LINK_PREFIXES: &[&str] = &[ "file+emacs:", "file+sys:", @@ -176,6 +177,7 @@ const ORG_PLAIN_LINK_PREFIXES: &[&str] = &[ "elisp:", "help:", "info:", + "man:", "eww:", "irc:", "bbdb:", @@ -191,6 +193,8 @@ const ORG_PLAIN_LINK_PREFIXES: &[&str] = &[ "doi:", "ftp://", "attachment:", + "docview:", + "shortdoc:", "id:", ]; diff --git a/src/reflow.rs b/src/reflow.rs index 040b2d5..e8b37d6 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -3187,6 +3187,9 @@ They are endowed with reason and conscience and should act towards one another i "id:abc-123", "file+emacs:/tmp/x", "attachment:plot.png", + "man:org", + "docview:/tmp/a.pdf", + "shortdoc:org", ] { 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 5cfbf51..b2d08be 100644 --- a/tests/org_file_token_punct.rs +++ b/tests/org_file_token_punct.rs @@ -364,6 +364,73 @@ fn leftover_https_plain_link_after_path_hangs_and_splits() { assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); } +#[test] +fn leftover_man_plain_link_after_path_hangs_and_splits() { + let input = concat!("man:org leftover. Next.\n", "After. Next.\n",); + let regions = OrgParser.parse(input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.contains("man:org"))), + "man path must stay Structure, got {regions:?}" + ); + let out = format_text(input, &org_cfg()).unwrap(); + assert!( + !out.contains("man:org leftover. Next."), + "leftover after man path must still split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); +} + +#[test] +fn leftover_docview_plain_link_after_path_hangs_and_splits() { + let input = concat!("docview:/tmp/a.pdf leftover. Next.\n", "After. Next.\n",); + let regions = OrgParser.parse(input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s.contains("docview:/tmp/a.pdf") + )), + "docview path must stay Structure, got {regions:?}" + ); + let out = format_text(input, &org_cfg()).unwrap(); + assert!( + !out.contains("docview:/tmp/a.pdf leftover. Next."), + "leftover after docview path must still split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); +} + +#[test] +fn leftover_shortdoc_plain_link_after_path_hangs_and_splits() { + let input = concat!("shortdoc:org leftover. Next.\n", "After. Next.\n",); + let regions = OrgParser.parse(input); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s.contains("shortdoc:org"))), + "shortdoc path must stay Structure, got {regions:?}" + ); + let out = format_text(input, &org_cfg()).unwrap(); + assert!( + !out.contains("shortdoc:org leftover. Next."), + "leftover after shortdoc path must still split, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "following prose must still split, 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 d1149cc15fdba22c26b950eddbf652ad46390bb4 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Thu, 17 Sep 2026 16:52:23 -0500 Subject: [PATCH 08/32] fix(md): leftover wrap-created definition markers pulldown leftover : and ~ at column 0 are definition list markers. Wrap now treats them as block openers so Markdown escapes the wrap-created token. ~~ strike and ~~~ fences stay unchanged. --- src/reflow.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/reflow.rs b/src/reflow.rs index e8b37d6..3da11a3 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -632,6 +632,11 @@ fn md_opens_block(line: &str) -> bool { if md_list_start(t) { return true; } + // pulldown leftover `:` / `~` definition marker. `~~` is strike + // and `~~~` is already a fence above. + if crate::parser::markdown::md_definition_list_marker_len(t).is_some() { + return true; + } if md_link_ref_def(t) { return true; } @@ -3016,6 +3021,34 @@ They are endowed with reason and conscience and should act towards one another i assert_no_col0_block(&result, &["[ref]:", "[ref]: "]); } + #[test] + fn wrap_created_md_definition_marker_is_not_a_block() { + // pulldown leftover `:` / `~` definition. Markdown escapes + // the wrap-created marker (same as `#` / `>`). `~~` is strike. + for token in [":", "~"] { + let result = wrap_fmt( + &format!("The options are apples {token} extra words here."), + 23, + crate::format::Format::Markdown, + ); + assert_no_col0_block(&result, &[token, &format!("{token} ")]); + let escaped = format!("\\{token} "); + assert!( + result.lines().any(|l| l.starts_with(&escaped)), + "wrap-created {token} must be markdown-escaped:\n{result}" + ); + } + let strike = wrap_fmt( + "The options are apples ~~ extra words here.", + 23, + crate::format::Format::Markdown, + ); + assert!( + !strike.lines().any(|l| l.starts_with("~ ") || l.starts_with(": ")), + "~~ strike must not become a definition:\n{strike}" + ); + } + #[test] fn wrap_created_html_tag_is_not_a_markdown_block() { // D. HTML tags From ae6fa3c3db7b98b402698c5771efaa214a3f0f72 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Thu, 17 Sep 2026 18:10:06 -0500 Subject: [PATCH 09/32] fix(md): leftover wrap-created table pipe GFM leftover flanking-pipe rows match TABLE_ROW_RE. Wrap now treats a column-0 | as a block opener so Markdown escapes the leftover pipe. --- src/reflow.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/reflow.rs b/src/reflow.rs index 3da11a3..b64088b 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -646,6 +646,10 @@ fn md_opens_block(line: &str) -> bool { if t.starts_with("$$") { return true; } + // GFM leftover flanking-pipe row (TABLE_ROW_RE). + if t.starts_with('|') { + return true; + } false } @@ -3049,6 +3053,22 @@ They are endowed with reason and conscience and should act towards one another i ); } + #[test] + fn wrap_created_md_table_pipe_is_not_a_block() { + // TABLE_ROW_RE flanking pipes. Wrap-created leftover `| extra |` + // must not become a table row. Markdown escapes the opener. + let result = wrap_fmt( + "The options are apples | extra |", + 23, + crate::format::Format::Markdown, + ); + assert_no_col0_block(&result, &["| extra |", "| "]); + assert!( + result.lines().any(|l| l.starts_with("\\|")), + "wrap-created leftover pipe must be markdown-escaped:\n{result}" + ); + } + #[test] fn wrap_created_html_tag_is_not_a_markdown_block() { // D. HTML tags From e4f537fd79db6b2eefe71f1844dc750100376712 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Mon, 14 Sep 2026 15:14:06 -0500 Subject: [PATCH 10/32] fix(org): leftover after a planning timestamp hangs and splits DEADLINE / SCHEDULED / CLOSED plus the stamp stay Structure. Text after the timestamp is leftover Prose. Bare DEADLINE without a stamp is still a paragraph. --- tests/org_planning_case.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/org_planning_case.rs b/tests/org_planning_case.rs index 1662f60..cc2db2e 100644 --- a/tests/org_planning_case.rs +++ b/tests/org_planning_case.rs @@ -7,7 +7,7 @@ use snapper_fmt::format::Format; use snapper_fmt::parser::org::OrgParser; use snapper_fmt::parser::{FormatParser, Region}; -use snapper_fmt::{FormatConfig, format_text}; +use snapper_fmt::{format_text, FormatConfig}; fn org_cfg() -> FormatConfig { FormatConfig { From 7fc1fe858100de8c4c3165a74c8c46d88e0b0f6a Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Mon, 14 Sep 2026 15:20:56 -0500 Subject: [PATCH 11/32] fix(org): unmatched quote center and special-block are paragraphs org-element treats an incomplete #+BEGIN_QUOTE / CENTER / NOTE (and other special-blocks) as a paragraph. Closed containers stay containers. --- tests/org_unmatched_opaque.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/org_unmatched_opaque.rs b/tests/org_unmatched_opaque.rs index 72eddb9..6bebf2f 100644 --- a/tests/org_unmatched_opaque.rs +++ b/tests/org_unmatched_opaque.rs @@ -5,7 +5,7 @@ use snapper_fmt::format::Format; use snapper_fmt::parser::org::OrgParser; use snapper_fmt::parser::{FormatParser, Region}; -use snapper_fmt::{FormatConfig, format_text}; +use snapper_fmt::{format_text, FormatConfig}; fn org_cfg() -> FormatConfig { FormatConfig { From 068a1d17a3c14fb2b51524dc7b75a974bdb0e69f Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 15 Sep 2026 06:18:19 -0500 Subject: [PATCH 12/32] fix: leftover CI rustfmt, catchfile star, wrap DEADLINE, 4-space code rustfmt import order. CatchFileBetweenTags* is leftover. Wrap skip-cut uses the planning KEY prefix so DEADLINE: cannot start a line. After a blank, a 4-space list-looking line is indented code. --- src/parser/markdown.rs | 74 ++++++++++++++++++++---------------------- src/reflow.rs | 2 +- 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/src/parser/markdown.rs b/src/parser/markdown.rs index 7fefa86..b40d9e4 100644 --- a/src/parser/markdown.rs +++ b/src/parser/markdown.rs @@ -2,8 +2,8 @@ use regex::Regex; use std::sync::LazyLock; use crate::parser::{ - ByteSpan, FormatParser, Line, SpannedRegion, flush_prose_spanned, iter_lines, join_prose_gap, - push_prose_line, + flush_prose_spanned, iter_lines, join_prose_gap, push_prose_line, ByteSpan, FormatParser, Line, + SpannedRegion, }; /// CommonMark 0.31.2 §4.2 ATX heading: 0–3 spaces, then 1–6 `#`, then @@ -3264,7 +3264,7 @@ mod tests { #[test] fn reporter_nested_indented_fence_is_identity_under_format() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = concat!( "```{code-block} markdown\n", @@ -3535,7 +3535,7 @@ mod tests { #[test] fn gfm_table_without_flanking_pipes_is_identity_under_format() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = ticket_gfm_table_fixture(); let cfg = FormatConfig { @@ -3749,7 +3749,7 @@ mod tests { #[test] fn list_blank_indent_continuation_is_identity_under_format() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "- Item one.\n\n Still the same item.\n"; let cfg = FormatConfig { @@ -3838,7 +3838,7 @@ mod tests { fn list_container_fixture_keeps_hang_and_code_under_format() { use crate::format::Format; use crate::oracle; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = ticket_list_container_fixture(); let cfg = FormatConfig { @@ -3980,7 +3980,7 @@ mod tests { #[test] fn wide_numbered_marker_blank_indent_is_identity_under_format() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "10. Item one.\n\n Still the same item.\n"; let cfg = FormatConfig { @@ -4253,7 +4253,7 @@ mod tests { #[test] fn multi_sentence_setext_title_stays_one_line() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "Setext Title With Period. Still Title\n=====================================\n\nBody after setext. Second body.\n"; let cfg = FormatConfig { @@ -4287,11 +4287,9 @@ mod tests { }) .collect(); assert!(prose.iter().any(|p| p.contains("Body sentence one"))); - assert!( - regions - .iter() - .any(|r| matches!(r, Region::Structure(s) if s == "Heading Here\n")) - ); + assert!(regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s == "Heading Here\n"))); } /// GitHub #208 / snapper-4wxk: CommonMark 4.3 ex. 50–51. The whole @@ -4345,7 +4343,7 @@ mod tests { #[test] fn multiline_setext_body_still_splits() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = multiline_setext_fixture(); let cfg = FormatConfig { @@ -4674,7 +4672,7 @@ mod tests { #[test] fn list_and_quote_multi_sentence_hangs() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let cfg = FormatConfig { format: Format::Markdown, @@ -4696,7 +4694,7 @@ mod tests { #[test] fn blockquote_keeps_marker_on_each_content_line() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let cfg = FormatConfig { format: Format::Markdown, @@ -4729,7 +4727,7 @@ mod tests { #[test] fn nested_blockquote_reflow_repeats_prefix() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "> Quoted one. Quoted two.\n> > Nested one. Nested two.\n"; let cfg = FormatConfig { @@ -4747,7 +4745,7 @@ mod tests { #[test] fn nested_list_stays_two_items_after_reflow() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "1. Parent one. Parent two.\n - Child one. Child two.\n"; let cfg = FormatConfig { @@ -4779,7 +4777,7 @@ mod tests { #[test] fn hard_break_two_spaces_not_joined_with_space() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "line \ncontinued. Next sentence.\n"; let regions = MarkdownParser.parse(input); @@ -4822,7 +4820,7 @@ mod tests { #[test] fn hard_break_backslash_not_joined_with_space() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "line\\\ncontinued. Next sentence.\n"; let cfg = FormatConfig { @@ -4866,7 +4864,7 @@ mod tests { #[test] fn html_comment_multiline_passes_through_format() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "Before sentence. After.\n\nMore. Text.\n"; let cfg = FormatConfig { @@ -4886,7 +4884,7 @@ mod tests { #[test] fn html_comment_pragma_still_disables_reflow() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "Hello world. Goodbye world.\n\nKeep this. Exactly here.\n\nFinal thing. Last sentence.\n"; let cfg = FormatConfig { @@ -4907,7 +4905,7 @@ mod tests { #[test] fn quote_hard_break_then_nonquote_has_no_stray_marker() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "> line \nNext sentence.\n"; let regions = MarkdownParser.parse(input); @@ -4953,7 +4951,7 @@ mod tests { #[test] fn quote_wrap_repeats_prefix_under_max_width() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "> One two three four five six seven eight.\n"; let cfg = FormatConfig { @@ -4988,7 +4986,7 @@ mod tests { #[test] fn quoted_fenced_code_is_not_sentence_split() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = concat!( "> ```\n", @@ -5044,7 +5042,7 @@ mod tests { #[test] fn quoted_tilde_fence_without_space_is_code() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = concat!(">~~~\n", "> print(1. 2)\n", "> still code. yes\n", ">~~~\n",); let regions = MarkdownParser.parse(input); @@ -5138,7 +5136,7 @@ mod tests { fn indented_code_fixture_is_identity_under_format() { use crate::format::Format; use crate::oracle; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = concat!( "After a blank, this is code.\n", @@ -5486,7 +5484,7 @@ mod tests { #[test] fn html_type7_closed_span_following_prose_still_splits() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = ticket_html_type7_close_fixture(); let cfg = FormatConfig { @@ -5528,7 +5526,7 @@ mod tests { #[test] fn html_blocks_ticket_fixture_does_not_reflow() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = ticket_html_blocks_fixture(); let cfg = FormatConfig { @@ -5570,7 +5568,7 @@ mod tests { #[test] fn html_type6_closed_div_following_prose_still_splits() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = ticket_html_type6_close_fixture(); let cfg = FormatConfig { @@ -5654,7 +5652,7 @@ mod tests { #[test] fn quoted_html_div_following_prose_still_splits() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = ticket_quoted_html_fixture("
"); let cfg = FormatConfig { @@ -5685,7 +5683,7 @@ mod tests { #[test] fn quoted_html_pre_comment_span_following_prose_unquoted() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let cfg = FormatConfig { format: Format::Markdown, @@ -5729,7 +5727,7 @@ mod tests { #[test] fn quoted_html_both_quoted_does_not_glue() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = ">
\n> After tag. Next sentence.\n"; let cfg = FormatConfig { @@ -5756,7 +5754,7 @@ mod tests { #[test] fn top_level_html_types_still_interrupt() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let cfg = FormatConfig { format: Format::Markdown, @@ -5820,7 +5818,7 @@ mod tests { #[test] fn dollar_dollar_display_math_does_not_reflow_as_prose() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = "$$\nThis is a long sentence that must stay inside display math and must not reflow as prose.\n$$\n"; let cfg = FormatConfig { @@ -6581,7 +6579,7 @@ mod tests { #[test] fn ticket_fixture_link_ref_and_footnote_do_not_reflow() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = ticket_link_ref_footnote_fixture(); let regions = MarkdownParser.parse(input); @@ -6660,7 +6658,7 @@ mod tests { #[test] fn ticket_footnote_body_splits_after_period() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = ticket_footnote_body_fixture(); let out = format_text(input, &md_cfg()).unwrap(); @@ -6747,7 +6745,7 @@ mod tests { #[test] fn definition_list_body_hangs_and_splits() { use crate::format::Format; - use crate::{FormatConfig, format_text}; + use crate::{format_text, FormatConfig}; let input = ticket_definition_list_fixture(); let out = format_text(input, &md_cfg()).unwrap(); diff --git a/src/reflow.rs b/src/reflow.rs index b64088b..1cc140d 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -4,8 +4,8 @@ use std::collections::HashMap; use crate::config::CodeLang; use crate::format::Format; use crate::parser::{Region, RegionOrigin, SpannedRegion}; -use crate::sentence::SentenceSplitter; use crate::sentence::unicode::atomic_inline_spans; +use crate::sentence::SentenceSplitter; /// Configuration for the reflow engine. pub struct ReflowConfig<'a> { From 98bfb0532e11b41197f62c0317ad369b15721baf Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 15 Sep 2026 07:18:07 -0500 Subject: [PATCH 13/32] fix: leftover extra-compact DL hang and rustfmt edition 2024 Do not flush a definition body as Structure when the next :/~ marker opens. rustfmt uses the crate edition so CI format check matches. --- src/parser/markdown.rs | 74 ++++++++++++++++++----------------- src/reflow.rs | 2 +- tests/org_planning_case.rs | 2 +- tests/org_unmatched_opaque.rs | 2 +- 4 files changed, 41 insertions(+), 39 deletions(-) diff --git a/src/parser/markdown.rs b/src/parser/markdown.rs index b40d9e4..7fefa86 100644 --- a/src/parser/markdown.rs +++ b/src/parser/markdown.rs @@ -2,8 +2,8 @@ use regex::Regex; use std::sync::LazyLock; use crate::parser::{ - flush_prose_spanned, iter_lines, join_prose_gap, push_prose_line, ByteSpan, FormatParser, Line, - SpannedRegion, + ByteSpan, FormatParser, Line, SpannedRegion, flush_prose_spanned, iter_lines, join_prose_gap, + push_prose_line, }; /// CommonMark 0.31.2 §4.2 ATX heading: 0–3 spaces, then 1–6 `#`, then @@ -3264,7 +3264,7 @@ mod tests { #[test] fn reporter_nested_indented_fence_is_identity_under_format() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = concat!( "```{code-block} markdown\n", @@ -3535,7 +3535,7 @@ mod tests { #[test] fn gfm_table_without_flanking_pipes_is_identity_under_format() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = ticket_gfm_table_fixture(); let cfg = FormatConfig { @@ -3749,7 +3749,7 @@ mod tests { #[test] fn list_blank_indent_continuation_is_identity_under_format() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "- Item one.\n\n Still the same item.\n"; let cfg = FormatConfig { @@ -3838,7 +3838,7 @@ mod tests { fn list_container_fixture_keeps_hang_and_code_under_format() { use crate::format::Format; use crate::oracle; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = ticket_list_container_fixture(); let cfg = FormatConfig { @@ -3980,7 +3980,7 @@ mod tests { #[test] fn wide_numbered_marker_blank_indent_is_identity_under_format() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "10. Item one.\n\n Still the same item.\n"; let cfg = FormatConfig { @@ -4253,7 +4253,7 @@ mod tests { #[test] fn multi_sentence_setext_title_stays_one_line() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "Setext Title With Period. Still Title\n=====================================\n\nBody after setext. Second body.\n"; let cfg = FormatConfig { @@ -4287,9 +4287,11 @@ mod tests { }) .collect(); assert!(prose.iter().any(|p| p.contains("Body sentence one"))); - assert!(regions - .iter() - .any(|r| matches!(r, Region::Structure(s) if s == "Heading Here\n"))); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Structure(s) if s == "Heading Here\n")) + ); } /// GitHub #208 / snapper-4wxk: CommonMark 4.3 ex. 50–51. The whole @@ -4343,7 +4345,7 @@ mod tests { #[test] fn multiline_setext_body_still_splits() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = multiline_setext_fixture(); let cfg = FormatConfig { @@ -4672,7 +4674,7 @@ mod tests { #[test] fn list_and_quote_multi_sentence_hangs() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let cfg = FormatConfig { format: Format::Markdown, @@ -4694,7 +4696,7 @@ mod tests { #[test] fn blockquote_keeps_marker_on_each_content_line() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let cfg = FormatConfig { format: Format::Markdown, @@ -4727,7 +4729,7 @@ mod tests { #[test] fn nested_blockquote_reflow_repeats_prefix() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "> Quoted one. Quoted two.\n> > Nested one. Nested two.\n"; let cfg = FormatConfig { @@ -4745,7 +4747,7 @@ mod tests { #[test] fn nested_list_stays_two_items_after_reflow() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "1. Parent one. Parent two.\n - Child one. Child two.\n"; let cfg = FormatConfig { @@ -4777,7 +4779,7 @@ mod tests { #[test] fn hard_break_two_spaces_not_joined_with_space() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "line \ncontinued. Next sentence.\n"; let regions = MarkdownParser.parse(input); @@ -4820,7 +4822,7 @@ mod tests { #[test] fn hard_break_backslash_not_joined_with_space() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "line\\\ncontinued. Next sentence.\n"; let cfg = FormatConfig { @@ -4864,7 +4866,7 @@ mod tests { #[test] fn html_comment_multiline_passes_through_format() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "Before sentence. After.\n\nMore. Text.\n"; let cfg = FormatConfig { @@ -4884,7 +4886,7 @@ mod tests { #[test] fn html_comment_pragma_still_disables_reflow() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "Hello world. Goodbye world.\n\nKeep this. Exactly here.\n\nFinal thing. Last sentence.\n"; let cfg = FormatConfig { @@ -4905,7 +4907,7 @@ mod tests { #[test] fn quote_hard_break_then_nonquote_has_no_stray_marker() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "> line \nNext sentence.\n"; let regions = MarkdownParser.parse(input); @@ -4951,7 +4953,7 @@ mod tests { #[test] fn quote_wrap_repeats_prefix_under_max_width() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "> One two three four five six seven eight.\n"; let cfg = FormatConfig { @@ -4986,7 +4988,7 @@ mod tests { #[test] fn quoted_fenced_code_is_not_sentence_split() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = concat!( "> ```\n", @@ -5042,7 +5044,7 @@ mod tests { #[test] fn quoted_tilde_fence_without_space_is_code() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = concat!(">~~~\n", "> print(1. 2)\n", "> still code. yes\n", ">~~~\n",); let regions = MarkdownParser.parse(input); @@ -5136,7 +5138,7 @@ mod tests { fn indented_code_fixture_is_identity_under_format() { use crate::format::Format; use crate::oracle; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = concat!( "After a blank, this is code.\n", @@ -5484,7 +5486,7 @@ mod tests { #[test] fn html_type7_closed_span_following_prose_still_splits() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = ticket_html_type7_close_fixture(); let cfg = FormatConfig { @@ -5526,7 +5528,7 @@ mod tests { #[test] fn html_blocks_ticket_fixture_does_not_reflow() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = ticket_html_blocks_fixture(); let cfg = FormatConfig { @@ -5568,7 +5570,7 @@ mod tests { #[test] fn html_type6_closed_div_following_prose_still_splits() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = ticket_html_type6_close_fixture(); let cfg = FormatConfig { @@ -5652,7 +5654,7 @@ mod tests { #[test] fn quoted_html_div_following_prose_still_splits() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = ticket_quoted_html_fixture("
"); let cfg = FormatConfig { @@ -5683,7 +5685,7 @@ mod tests { #[test] fn quoted_html_pre_comment_span_following_prose_unquoted() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let cfg = FormatConfig { format: Format::Markdown, @@ -5727,7 +5729,7 @@ mod tests { #[test] fn quoted_html_both_quoted_does_not_glue() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = ">
\n> After tag. Next sentence.\n"; let cfg = FormatConfig { @@ -5754,7 +5756,7 @@ mod tests { #[test] fn top_level_html_types_still_interrupt() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let cfg = FormatConfig { format: Format::Markdown, @@ -5818,7 +5820,7 @@ mod tests { #[test] fn dollar_dollar_display_math_does_not_reflow_as_prose() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = "$$\nThis is a long sentence that must stay inside display math and must not reflow as prose.\n$$\n"; let cfg = FormatConfig { @@ -6579,7 +6581,7 @@ mod tests { #[test] fn ticket_fixture_link_ref_and_footnote_do_not_reflow() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = ticket_link_ref_footnote_fixture(); let regions = MarkdownParser.parse(input); @@ -6658,7 +6660,7 @@ mod tests { #[test] fn ticket_footnote_body_splits_after_period() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = ticket_footnote_body_fixture(); let out = format_text(input, &md_cfg()).unwrap(); @@ -6745,7 +6747,7 @@ mod tests { #[test] fn definition_list_body_hangs_and_splits() { use crate::format::Format; - use crate::{format_text, FormatConfig}; + use crate::{FormatConfig, format_text}; let input = ticket_definition_list_fixture(); let out = format_text(input, &md_cfg()).unwrap(); diff --git a/src/reflow.rs b/src/reflow.rs index 1cc140d..b64088b 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -4,8 +4,8 @@ use std::collections::HashMap; use crate::config::CodeLang; use crate::format::Format; use crate::parser::{Region, RegionOrigin, SpannedRegion}; -use crate::sentence::unicode::atomic_inline_spans; use crate::sentence::SentenceSplitter; +use crate::sentence::unicode::atomic_inline_spans; /// Configuration for the reflow engine. pub struct ReflowConfig<'a> { diff --git a/tests/org_planning_case.rs b/tests/org_planning_case.rs index cc2db2e..1662f60 100644 --- a/tests/org_planning_case.rs +++ b/tests/org_planning_case.rs @@ -7,7 +7,7 @@ use snapper_fmt::format::Format; use snapper_fmt::parser::org::OrgParser; use snapper_fmt::parser::{FormatParser, Region}; -use snapper_fmt::{format_text, FormatConfig}; +use snapper_fmt::{FormatConfig, format_text}; fn org_cfg() -> FormatConfig { FormatConfig { diff --git a/tests/org_unmatched_opaque.rs b/tests/org_unmatched_opaque.rs index 6bebf2f..72eddb9 100644 --- a/tests/org_unmatched_opaque.rs +++ b/tests/org_unmatched_opaque.rs @@ -5,7 +5,7 @@ use snapper_fmt::format::Format; use snapper_fmt::parser::org::OrgParser; use snapper_fmt::parser::{FormatParser, Region}; -use snapper_fmt::{format_text, FormatConfig}; +use snapper_fmt::{FormatConfig, format_text}; fn org_cfg() -> FormatConfig { FormatConfig { From e1ee36da1960020a29c3318e9b215ecb060bb164 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 15 Sep 2026 11:40:34 -0500 Subject: [PATCH 14/32] fix(site): serve a real 404 instead of the landing page Unknown paths were HTTP 200 with the homepage. Ship 404.html and tell Pages to use it. --- site/404.html | 27 +++++++++++++++++++++++++++ wrangler.toml | 4 ++++ 2 files changed, 31 insertions(+) create mode 100644 site/404.html create mode 100644 wrangler.toml diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000..3b4361e --- /dev/null +++ b/site/404.html @@ -0,0 +1,27 @@ + + + + + +Page not found · snapper + + + + +
+

snapper

+

This page does not exist.

+

The link is wrong or the page was removed.

+

Back to snapper

+
+ + diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..cf99c33 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,4 @@ +name = "snapper" +compatibility_date = "2026-09-01" +pages_build_output_dir = "dist" +not_found_handling = "404-page" From 16e7999af86a6ecb81b4d60f64b0b3c56260deb4 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 19 Sep 2026 09:41:37 -0500 Subject: [PATCH 15/32] fix(rst): leftover wrap-created section adornment Docutils Body.line is a solid adornment at column 0. rst_opens_block omitted it, so wrap parked ==== at column 0 and the next parse minted a section. Skip-cut keeps the token with the previous line. --- src/parser/rst.rs | 2 +- src/reflow.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/parser/rst.rs b/src/parser/rst.rs index 9f3069a..13cbd10 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -1483,7 +1483,7 @@ fn rst_quoted_literal_continues(line: &str, quote: u8) -> bool { /// `>` adornments. `>>>>>` (and `>>` / `>>>>`) stay underlines. /// Body.explicit wins over Body.line: lone `..` is an empty comment /// (GitHub #343), not a `.` section underline. `...` / `....` stay underlines. -fn is_underline(line: &str) -> bool { +pub(crate) fn is_underline(line: &str) -> bool { let trimmed = line.trim(); if trimmed.len() < 2 { return false; diff --git a/src/reflow.rs b/src/reflow.rs index b64088b..5d11863 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -804,6 +804,11 @@ fn rst_opens_block(line: &str) -> bool { if crate::parser::rst::rst_option_column_len(t).is_some() { return true; } + // Docutils Body.line: a solid adornment at column 0 is a section + // underline / transition, not wrap-created leftover prose. + if crate::parser::rst::is_underline(t) { + return true; + } false } @@ -3158,6 +3163,49 @@ They are endowed with reason and conscience and should act towards one another i ); } + #[test] + fn wrap_created_rst_section_adornment_is_not_a_block() { + // Docutils Body.line. "The options are apples" is 22 chars; width + // 23 would park a solid adornment at column 0 without skip-cut. + 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} adornment:\n{result}" + ); + } + } + + #[test] + fn wrap_created_rst_section_adornment_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 == "===="), + "wrap must not park a column-0 section adornment:\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 1c859ad2ca1e8f1714c90850fe8500f3deb7db05 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:43:40 -0500 Subject: [PATCH 16/32] fix(md): leftover type-6 void HTML does not swallow following prose hr/col/link/base have no closer. Nest never hit 0, so leftover after a bare
was Structure. Void type-6 ends on the tag line. --- docs/orgmode/reference/formats.org | 4 +- src/parser/markdown.rs | 94 +++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 315ae66..b65c41e 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -199,6 +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 - Pipe tables *** Code regions (fenced =```= / =~~~=) @@ -243,7 +244,8 @@ These tokens within prose are not split across lines: - 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) -- Section titles and underlines (=====, =-----=, etc.) +- 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) - 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/markdown.rs b/src/parser/markdown.rs index 7fefa86..e9bb0da 100644 --- a/src/parser/markdown.rs +++ b/src/parser/markdown.rs @@ -254,6 +254,15 @@ fn is_html_block_tag(name: &str) -> bool { HTML_BLOCK_TAGS.iter().any(|t| name.eq_ignore_ascii_case(t)) } +/// 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. +fn is_html_void_type6(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "base" | "basefont" | "col" | "frame" | "hr" | "link" | "menuitem" + ) +} + /// Type-6 start tag name on `rest` (already indent-stripped), if any. fn type6_tag_name(rest: &str) -> Option<&str> { let after = if let Some(a) = rest.strip_prefix(" bool { } if is_close { *nest -= 1; - } else if !html_tag_self_closes(after_name) { + } else if !html_tag_self_closes(after_name) && !is_html_void_type6(tag) { *nest += 1; } if *nest <= 0 { @@ -5295,6 +5304,89 @@ mod tests { ); } + /// snapper-56tj: void type-6 (`hr` / `col` / `link` / `base`) has no + /// closer. The block ends on the tag line so following prose stays + /// Prose even without a blank. + fn ticket_html_type6_void_hr_fixture() -> &'static str { + concat!( + "Intro sentence here. Another intro sentence.\n", + "
\n", + "After html. Next.\n", + ) + } + + #[test] + fn html_type6_void_hr_does_not_swallow_next_paragraph() { + let regions = MarkdownParser.parse(ticket_html_type6_void_hr_fixture()); + let hr = regions.iter().find_map(|r| match r { + Region::Structure(s) if s.contains("
") => Some(s.as_str()), + _ => None, + }); + let hr = hr.expect(&format!("hr block must be Structure, got {regions:?}")); + assert!( + !hr.contains("After html"), + "void type-6 must end on the hr line, got {hr}" + ); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Prose(p) if p.contains("After html.") && p.contains("Next.") + )), + "following paragraph must stay Prose: {regions:?}" + ); + } + + #[test] + fn html_type6_void_col_link_base_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_hr_following_prose_still_splits() { + use crate::{FormatConfig, format_text}; + let cfg = FormatConfig { + format: crate::format::Format::Markdown, + ..Default::default() + } + .without_safety_backstops(); + let out = format_text(ticket_html_type6_void_hr_fixture(), &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 343ea08b87e2e1f748d6c637a632f91177d33e7e Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:49:35 -0500 Subject: [PATCH 17/32] chore(release): prepare v0.11.6 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d27532d..8c69811 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "snapper-fmt" -version = "0.11.5" +version = "0.11.6" edition = "2024" rust-version = "1.85" description = "Semantic line break formatter for Org, LaTeX, Markdown, RST, and plaintext" From c2e9248124e4eca75d67d86e66820f3ee074c3a8 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:50:01 -0500 Subject: [PATCH 18/32] chore(release): changelog and lock for 0.11.6 --- CHANGELOG.md | 10 ++++++++++ Cargo.lock | 2 +- README.md | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c638003..3002973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to this project will be documented in this file. See [conven - - - +## v0.11.6 - 2026-09-20 +#### Bug Fixes +- leftover wrap-created RST section adornment skip-cut (snapper-7xd3) +- leftover type-6 void HTML (`hr`/`col`/`link`/`base`) does not swallow following prose (snapper-56tj) +- leftover RST include/raw/literalinclude same-line hang +- leftover wrap-created org/md markers and extra-compact DL hang +- site 404 page instead of the landing page + +- - - + ## v0.11.5 - 2026-09-15 #### Bug Fixes - leftover native-parser walkers vs Docutils pulldown org-element and TeX (#482) diff --git a/Cargo.lock b/Cargo.lock index c8b456f..4ebb597 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2221,7 +2221,7 @@ checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "snapper-fmt" -version = "0.11.5" +version = "0.11.6" dependencies = [ "anyhow", "clap", diff --git a/README.md b/README.md index 3337961..3f07847 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.5 + rev: v0.11.6 hooks: - id: snapper From 510b1410d1bc6623c94bdbeb62e5aa0de7348ae7 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:51:02 -0500 Subject: [PATCH 19/32] chore(release): howto docs 0.11.6 --- docs/orgmode/howto/ci-enforcement.org | 6 +++--- docs/orgmode/howto/vale-integration.org | 2 +- docs/orgmode/tutorials/quickstart.org | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/orgmode/howto/ci-enforcement.org b/docs/orgmode/howto/ci-enforcement.org index 2865e84..bd59c5b 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.5 +- uses: TurtleTech-ehf/snapper@v0.11.6 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.5 +- uses: TurtleTech-ehf/snapper@v0.11.6 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.5 + rev: v0.11.6 hooks: - id: snapper #+end_src diff --git a/docs/orgmode/howto/vale-integration.org b/docs/orgmode/howto/vale-integration.org index cb93b94..f165e24 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.5 + rev: v0.11.6 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 9e4e931..eebc79f 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.5 + rev: v0.11.6 hooks: - id: snapper #+end_src From 5c5326fdda2c17d45ba3709aff05d1f39a292e41 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:51:18 -0500 Subject: [PATCH 20/32] chore(release): obsidian 0.11.6 --- editors/obsidian/manifest.json | 2 +- editors/obsidian/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/editors/obsidian/manifest.json b/editors/obsidian/manifest.json index 5c6d83f..344ab5c 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.5", + "version": "0.11.6", "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.json b/editors/obsidian/package.json index 2c9c5eb..85b7f0a 100644 --- a/editors/obsidian/package.json +++ b/editors/obsidian/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-snapper", - "version": "0.11.5", + "version": "0.11.6", "private": true, "description": "Obsidian plugin for snapper semantic line break formatter", "scripts": { From 8f506ce979aeb9889ff469c5d69000147311f181 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:51:24 -0500 Subject: [PATCH 21/32] chore(release): obsidian lock 0.11.6 --- editors/obsidian/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/editors/obsidian/package-lock.json b/editors/obsidian/package-lock.json index d828454..0f5a933 100644 --- a/editors/obsidian/package-lock.json +++ b/editors/obsidian/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-snapper", - "version": "0.11.5", + "version": "0.11.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-snapper", - "version": "0.11.5", + "version": "0.11.6", "license": "MIT", "devDependencies": { "@snapper/wasm": "file:../../packages/snapper-wasm", @@ -17,7 +17,7 @@ }, "../../packages/snapper-wasm": { "name": "@snapper/wasm", - "version": "0.11.5", + "version": "0.11.6", "dev": true, "license": "MIT", "devDependencies": { From ef4dd339e8cb0afc8484c1a6f4db9a986390d9c6 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:51:31 -0500 Subject: [PATCH 22/32] chore(release): vscode 0.11.6 --- editors/vscode/README.md | 4 ++-- editors/vscode/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/editors/vscode/README.md b/editors/vscode/README.md index f40b663..0910253 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.5+** on your PATH (or set `snapper.path`). +Requires the **snapper** / **snapper-fmt** CLI **0.11.6+** 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.5). +Install: `cargo install snapper-fmt` or the [release installer](https://github.com/TurtleTech-ehf/snapper/releases/tag/v0.11.6). 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 1f4f04a..348367f 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.5", + "version": "0.11.6", "publisher": "TurtleTech", "license": "MIT", "repository": { From 4b5a2686daa679a7bfd5125ef3a663995c2b5017 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:51:40 -0500 Subject: [PATCH 23/32] chore(release): word 0.11.6 --- editors/word/README.md | 4 ++-- editors/word/manifest.xml | 2 +- editors/word/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/editors/word/README.md b/editors/word/README.md index 0e914d3..18bef59 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.5 (tracks snapper-fmt) +**Version:** 0.11.6 (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.5+**). +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+**). ## License diff --git a/editors/word/manifest.xml b/editors/word/manifest.xml index 5fada81..653c640 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.5 + 0.11.6 TurtleTech ehf en-US diff --git a/editors/word/package.json b/editors/word/package.json index fc18207..aa87db9 100644 --- a/editors/word/package.json +++ b/editors/word/package.json @@ -1,6 +1,6 @@ { "name": "word-snapper", - "version": "0.11.5", + "version": "0.11.6", "private": true, "description": "Microsoft Word add-in for snapper semantic line break formatter", "scripts": { From 88bea31bea117e1ea5aaa08e35c1e4ded5d52a0b Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:51:47 -0500 Subject: [PATCH 24/32] chore(release): word lock 0.11.6 --- editors/word/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/editors/word/package-lock.json b/editors/word/package-lock.json index 279979b..ad62f27 100644 --- a/editors/word/package-lock.json +++ b/editors/word/package-lock.json @@ -1,12 +1,12 @@ { "name": "word-snapper", - "version": "0.11.5", + "version": "0.11.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "word-snapper", - "version": "0.11.5", + "version": "0.11.6", "license": "MIT", "devDependencies": { "@snapper/wasm": "file:../../packages/snapper-wasm", @@ -21,7 +21,7 @@ }, "../../packages/snapper-wasm": { "name": "@snapper/wasm", - "version": "0.11.5", + "version": "0.11.6", "dev": true, "license": "MIT", "devDependencies": { From a8e08a15855503149fa1bd57a6c6630f195eb289 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:52:05 -0500 Subject: [PATCH 25/32] chore(release): readme_src 0.11.6 --- readme_src.org | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme_src.org b/readme_src.org index 45005b3..ad86b3a 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.5 + rev: v0.11.6 hooks: - id: snapper #+end_src From 62b144c98f4c2da83cd7e9ac0bbbfeda0d802160 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:52:12 -0500 Subject: [PATCH 26/32] chore(release): word taskpane 0.11.6 --- editors/word/src/taskpane/taskpane.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editors/word/src/taskpane/taskpane.html b/editors/word/src/taskpane/taskpane.html index 07ce4fb..b7c12ee 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.5)

+

Semantic line breaks for Word (v0.11.6)

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

From e35a1fe5e1d22bf4be180a2468a419df8b247d32 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:52:40 -0500 Subject: [PATCH 27/32] chore(release): wasm package 0.11.6 --- packages/snapper-wasm/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snapper-wasm/package.json b/packages/snapper-wasm/package.json index 519cf16..32373cb 100644 --- a/packages/snapper-wasm/package.json +++ b/packages/snapper-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@snapper/wasm", - "version": "0.11.5", + "version": "0.11.6", "description": "WebAssembly build of snapper semantic line break formatter", "type": "module", "main": "dist/index.js", From ae6231e5fa9252747edb7a9d8551a821ce5cbdb5 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:52:50 -0500 Subject: [PATCH 28/32] chore(release): wasm lock 0.11.6 --- packages/snapper-wasm/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/snapper-wasm/package-lock.json b/packages/snapper-wasm/package-lock.json index f52b68d..8b88166 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.5", + "version": "0.11.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@snapper/wasm", - "version": "0.11.5", + "version": "0.11.6", "license": "MIT", "devDependencies": { "typescript": "^5.4" From 634e7c7a13e78170177be16e5c1853478639b9a1 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:53:12 -0500 Subject: [PATCH 29/32] chore(release): recipe 0.11.6 --- conda/recipe.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conda/recipe.yaml b/conda/recipe.yaml index 04cbac1..1fb3702 100644 --- a/conda/recipe.yaml +++ b/conda/recipe.yaml @@ -3,7 +3,7 @@ schema_version: 1 context: - version: "0.11.5" + version: "0.11.6" package: name: snapper-fmt From 7deb1fcb5d64e3b231c66caf57d102ad2f21cae0 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:53:24 -0500 Subject: [PATCH 30/32] chore(release): sphinx 0.11.6 --- docs/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 631a648..eef2f5e 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.5" +release = "0.11.6" html_logo = "../../branding/logo/snapper_logo.png" extensions = [ From 2bfda217533b4f2be6441741d444ae2998b6215b Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:53:40 -0500 Subject: [PATCH 31/32] chore(release): remaining json 0.11.6 --- npm/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npm/package.json b/npm/package.json index 9aea321..ec3b170 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@turtletech/snapper-mcp", - "version": "0.11.5", + "version": "0.11.6", "private": true, "description": "MCP server for snapper semantic line break formatter", "main": "bin/run.js", From 71c0e640538258e3428618b9a19cb120fac47fa6 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 20 Sep 2026 00:55:44 -0500 Subject: [PATCH 32/32] style: rustfmt leftover html void and wrap tests --- src/parser/markdown.rs | 5 ++--- src/reflow.rs | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/parser/markdown.rs b/src/parser/markdown.rs index e9bb0da..5637d31 100644 --- a/src/parser/markdown.rs +++ b/src/parser/markdown.rs @@ -5339,9 +5339,8 @@ mod tests { #[test] fn html_type6_void_col_link_base_do_not_swallow_next_paragraph() { for tag in ["", "", ""] { - let input = format!( - "Intro sentence here. Another intro sentence.\n{tag}\nAfter html. Next.\n" - ); + 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!( diff --git a/src/reflow.rs b/src/reflow.rs index 5d11863..2112a0e 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -3053,7 +3053,9 @@ They are endowed with reason and conscience and should act towards one another i crate::format::Format::Markdown, ); assert!( - !strike.lines().any(|l| l.starts_with("~ ") || l.starts_with(": ")), + !strike + .lines() + .any(|l| l.starts_with("~ ") || l.starts_with(": ")), "~~ strike must not become a definition:\n{strike}" ); }