From 7a1306151f6e82866e82df111dcdc1941c276a70 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 04:33:34 -0500 Subject: [PATCH 1/8] fix(org): keep list item tags on the item line An item tag before " :: " stays with the bullet. The description after the separator still reflows. --- src/parser/org.rs | 57 ++++++++++++++++++++++++++++++++++++++++--- src/reflow.rs | 5 ++++ tests/org_item_tag.rs | 54 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 tests/org_item_tag.rs diff --git a/src/parser/org.rs b/src/parser/org.rs index f5cb89c8..9b87f689 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -17,6 +17,53 @@ static LIST_ITEM_RE: LazyLock = LazyLock::new(|| { Regex::new(r"^(\s*(?:[-+]|\d+[.)])(?:[ \t]|$)|[ \t]+\*(?:[ \t]|$))(.*)$").unwrap() }); +/// org-syntax item: `tag :: description`. +/// +/// Bytes of `after_bullet` through `[ \t]+::` and the spaces that follow. +/// The tag stays on the item line; the description hangs as prose. +/// `None` when the item has no tag (`::` needs whitespace before it, +/// and whitespace or end after it). +pub(crate) fn org_item_tag_end(after_bullet: &str) -> Option { + let bytes = after_bullet.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b' ' || bytes[i] == b'\t' { + let mut j = i; + while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') { + j += 1; + } + if j + 1 < bytes.len() && bytes[j] == b':' && bytes[j + 1] == b':' { + let mut k = j + 2; + if k == bytes.len() || bytes[k] == b' ' || bytes[k] == b'\t' { + while k < bytes.len() && (bytes[k] == b' ' || bytes[k] == b'\t') { + k += 1; + } + return Some(k); + } + } + i = j; + continue; + } + i += 1; + } + None +} + +/// Hang width when `s` is a list marker plus an item tag and nothing else. +/// Description reflow stays under `tag ::`. +pub(crate) fn org_item_tag_hang_width(s: &str) -> Option { + if !s.ends_with([' ', '\t']) { + return None; + } + let marker_len = LIST_ITEM_RE.captures(s)?.get(1)?.end(); + let tag_end = org_item_tag_end(&s[marker_len..])?; + if marker_len + tag_end == s.len() { + Some(s.chars().count()) + } else { + None + } +} + /// org-element-export-snippet-parser prefix: `@@BACKEND:VALUE@@`. /// Backend is `[-A-Za-z0-9]+`. Value runs to the next `@@` (may contain /// a single `@`; GitHub #354). @@ -1354,7 +1401,8 @@ impl FormatParser for OrgParser { footnote_saw_blank = false; } - // List item: marker is structure, rest is prose + // List item: bullet is structure. An item tag (`tag ::`) + // stays on that line; the description after ` :: ` hangs. 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(); @@ -1370,9 +1418,12 @@ impl FormatParser for OrgParser { list_saw_blank = false; in_footnote_def = false; footnote_saw_blank = false; - let marker_span = ByteSpan::new(line.start, line.start + marker.len()); + let struct_len = org_item_tag_end(&line_text[marker.len()..]) + .map(|n| marker.len() + n) + .unwrap_or(marker.len()); + let marker_span = ByteSpan::new(line.start, line.start + struct_len); regions.push(SpannedRegion::structure(input, marker_span)); - Self::emit_hung_text(input, &line, marker.len(), &mut regions); + Self::emit_hung_text(input, &line, struct_len, &mut regions); continue; } diff --git a/src/reflow.rs b/src/reflow.rs index 17ee5074..d18294e3 100644 --- a/src/reflow.rs +++ b/src/reflow.rs @@ -1219,6 +1219,11 @@ fn hanging_indent_width(s: &str) -> usize { if crate::parser::org::org_caption_marker_len(s) == Some(s.len()) { return s.chars().count(); } + // Org item tag (`- tag :: `): hang at the tag so the description + // stays on the item and a period in the tag does not split it. + if let Some(width) = crate::parser::org::org_item_tag_hang_width(s) { + return width; + } // Markdown definition marker (`: ` / ` : `): hang at marker width // so the body stays inside the definition (GitHub #210). if crate::parser::markdown::md_definition_list_marker_len(s) == Some(s.len()) { diff --git a/tests/org_item_tag.rs b/tests/org_item_tag.rs new file mode 100644 index 00000000..7b5db65d --- /dev/null +++ b/tests/org_item_tag.rs @@ -0,0 +1,54 @@ +//! Org list item tag (`tag :: description`) stays on the item line. +//! The description after ` :: ` reflows. A sentence in the tag does not +//! split away from the bullet. + +use snapper_fmt::format::Format; +use snapper_fmt::parser::org::OrgParser; +use snapper_fmt::parser::{FormatParser, Region}; +use snapper_fmt::{FormatConfig, format_text}; + +fn org_cfg() -> FormatConfig { + FormatConfig { + format: Format::Org, + max_width: 0, + ..Default::default() + } + .without_safety_backstops() +} + +#[test] +fn item_tag_stays_on_the_item_line_description_reflows() { + let input = "- Alpha. Beta :: One. Two.\n"; + let regions = OrgParser.parse(input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s == "- Alpha. Beta :: " + )), + "tag and separator stay Structure, got {regions:?}" + ); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p == "One. Two.")), + "description after :: is Prose, got {regions:?}" + ); + assert!( + !regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("Alpha."))), + "a sentence in the tag must not be Prose, got {regions:?}" + ); + let out = format_text(input, &org_cfg()).unwrap(); + let hang = " ".repeat("- Alpha. Beta :: ".chars().count()); + assert_eq!( + out, + format!("- Alpha. Beta :: One.\n{hang}Two.\n"), + "tag stays; description splits under the separator, got:\n{out}" + ); + assert!( + !out.contains("- Alpha.\n"), + "tag sentence must not leave the item marker, got:\n{out}" + ); + assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); +} From 389e8642d987dd6207094a2c957b5645f3f3a8d8 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 04:33:34 -0500 Subject: [PATCH 2/8] fix(rst): keep directive title arguments on the directive line admonition, rubric, topic, sidebar, list-table, and contents titles stay on the opener. Body prose under the directive still reflows, and note and warning same-line bodies still split. --- src/parser/rst.rs | 25 +++++---- tests/rst_container_directives.rs | 24 ++++----- tests/rst_directive_title.rs | 86 +++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 23 deletions(-) create mode 100644 tests/rst_directive_title.rs diff --git a/src/parser/rst.rs b/src/parser/rst.rs index 07f1e1c3..0573b56d 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -922,7 +922,9 @@ fn rst_directive_name(trimmed: &str) -> Option { /// arguments (epigraph / highlights / pull-quote / compound / header / /// footer / parsed-literal / line-block): same-line text after `::` is /// the first nested-parsed body paragraph (GitHub #349 / #351 / #422 / -/// #426 / #430). +/// #426 / #430). `title` is the same no-argument class here. +/// `admonition` / `rubric` / `topic` / `sidebar` / `list-table` / +/// `contents` take a title argument and are not in this set. fn is_rst_specific_admonition(name: &str) -> bool { matches!( name, @@ -943,16 +945,19 @@ fn is_rst_specific_admonition(name: &str) -> bool { | "footer" | "parsed-literal" | "line-block" - | "rubric" - | "topic" - | "sidebar" - | "admonition" - | "list-table" - | "contents" | "title" ) } +/// Directives whose same-line text is a title argument, not a body +/// paragraph. The title stays on the directive line. +fn is_rst_title_argument_directive(name: &str) -> bool { + matches!( + name, + "admonition" | "rubric" | "topic" | "sidebar" | "list-table" | "contents" + ) +} + /// Docutils admonitions plus figure/topic/sidebar/container: bodies /// nested-parse, so hang + reflow. Leftover body.py names in /// `is_rst_specific_admonition` (including parsed-literal / line-block) @@ -962,10 +967,8 @@ fn is_rst_specific_admonition(name: &str) -> bool { /// nested-parsed paragraph (GitHub #434). fn is_rst_container_directive(name: &str) -> bool { is_rst_specific_admonition(name) - || matches!( - name, - "admonition" | "figure" | "topic" | "sidebar" | "container" | "class" | "list-table" - ) + || is_rst_title_argument_directive(name) + || matches!(name, "figure" | "container" | "class") } /// Docutils `meta` directive. Takes no argument; the body is a field diff --git a/tests/rst_container_directives.rs b/tests/rst_container_directives.rs index 305d652f..af969a4e 100644 --- a/tests/rst_container_directives.rs +++ b/tests/rst_container_directives.rs @@ -163,8 +163,8 @@ fn leftover_list_table_title_hangs_cells_still_split() { ); let out = format_text(input, &rst_cfg()).unwrap(); assert!( - !out.contains(".. list-table:: fig. 1 is here. After."), - "list-table title must still split, got:\n{out}" + out.contains(".. list-table:: fig. 1 is here. After."), + "list-table title stays on the directive line, got:\n{out}" ); assert!( out.contains("After.\nNext."), @@ -178,8 +178,8 @@ fn leftover_contents_same_line_title_hangs_and_splits() { let input = concat!(".. contents:: fig. 1 is here. After.\n", "After. Next.\n",); let out = format_text(input, &rst_cfg()).unwrap(); assert!( - !out.contains(".. contents:: fig. 1 is here. After."), - "contents title must still split, got:\n{out}" + out.contains(".. contents:: fig. 1 is here. After."), + "contents title stays on the directive line, got:\n{out}" ); assert!( out.contains("After.\nNext."), @@ -235,14 +235,14 @@ fn leftover_topic_same_line_title_hangs_and_splits() { assert!( regions.iter().any(|r| matches!( r, - Region::Prose(p) if p.contains("fig. 1 is here.") && p.contains("After.") + Region::Structure(s) if s.contains(".. topic:: fig. 1 is here. After.") )), - "topic title leftover must be Prose, got {regions:?}" + "topic title stays Structure on the directive line, got {regions:?}" ); let out = format_text(input, &rst_cfg()).unwrap(); assert!( - !out.contains(".. topic:: fig. 1 is here. After."), - "topic title must still split, got:\n{out}" + out.contains(".. topic:: fig. 1 is here. After."), + "topic title stays on the directive line, got:\n{out}" ); assert!( out.contains("After.\nNext."), @@ -264,14 +264,14 @@ fn leftover_rubric_same_line_hangs_and_splits() { assert!( regions.iter().any(|r| matches!( r, - Region::Prose(p) if p.contains("fig. 1 is here.") && p.contains("After.") + Region::Structure(s) if s.contains(".. rubric:: fig. 1 is here. After.") )), - "rubric argument must be leftover Prose, got {regions:?}" + "rubric title stays Structure on the directive line, got {regions:?}" ); let out = format_text(input, &rst_cfg()).unwrap(); assert!( - !out.contains(".. rubric:: fig. 1 is here. After."), - "rubric argument must still split, got:\n{out}" + out.contains(".. rubric:: fig. 1 is here. After."), + "rubric title stays on the directive line, got:\n{out}" ); assert!( out.contains("After.\nNext."), diff --git a/tests/rst_directive_title.rs b/tests/rst_directive_title.rs new file mode 100644 index 00000000..885dc83b --- /dev/null +++ b/tests/rst_directive_title.rs @@ -0,0 +1,86 @@ +//! Title arguments of admonition, rubric, topic, sidebar, list-table, +//! and contents stay on the directive line. Body prose still reflows. +//! `note` / `warning` same-line text is a body paragraph and still splits. + +use snapper_fmt::format::Format; +use snapper_fmt::parser::rst::RstParser; +use snapper_fmt::parser::{FormatParser, Region}; +use snapper_fmt::{FormatConfig, format_text}; + +fn rst_cfg() -> FormatConfig { + FormatConfig { + format: Format::Rst, + max_width: 0, + ..Default::default() + } + .without_safety_backstops() +} + +#[test] +fn title_argument_stays_on_directive_line_body_reflows() { + for name in [ + "admonition", + "rubric", + "topic", + "sidebar", + "list-table", + "contents", + ] { + let input = format!( + ".. {name}:: First title. Second title.\n\n Body one. Body two.\n\nAfter. Next.\n" + ); + let regions = RstParser.parse(&input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s.contains(&format!(".. {name}:: First title. Second title.")) + )), + "{name} title must stay Structure, got {regions:?}" + ); + assert!( + !regions.iter().any(|r| matches!( + r, + Region::Prose(p) if p.contains("First title.") + )), + "{name} title must not be Prose, got {regions:?}" + ); + let out = format_text(&input, &rst_cfg()).unwrap(); + assert!( + out.contains(&format!(".. {name}:: First title. Second title.")), + "{name} title stays on the directive line, got:\n{out}" + ); + assert!( + !out.contains("First title.\n"), + "{name} title must not split, got:\n{out}" + ); + assert!( + out.contains(" Body one.\n Body two."), + "{name} body must still reflow, got:\n{out}" + ); + assert!( + out.contains("After.\nNext."), + "prose after {name} must still reflow, got:\n{out}" + ); + assert_eq!(format_text(&out, &rst_cfg()).unwrap(), out); + } +} + +#[test] +fn note_and_warning_same_line_body_still_reflows() { + for name in ["note", "warning"] { + let input = format!(".. {name}:: Body one. Body two.\n"); + let out = format_text(&input, &rst_cfg()).unwrap(); + assert!( + out.contains(&format!(".. {name}:: Body one.\n")), + "{name} same-line body must still split, got:\n{out}" + ); + assert!( + out.contains("Body two."), + "{name} second sentence must remain, got:\n{out}" + ); + assert!( + !out.contains(&format!(".. {name}:: Body one. Body two.")), + "{name} same-line body must not stay one line, got:\n{out}" + ); + } +} From 711f7a8461d61a392607c15c9f1889c98c80b032 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 04:45:57 -0500 Subject: [PATCH 3/8] fix(org,rst): keep continued tags and titles whole A description on the line after an item tag stays prose and still splits. An indented directive title stays structure until the blank line, and the body after that blank still splits. --- src/parser/org.rs | 16 ++++++++++++++++ src/parser/rst.rs | 23 ++++++++++++++++++++++ tests/org_item_tag.rs | 37 ++++++++++++++++++++++++++++++++++++ tests/rst_directive_title.rs | 30 +++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+) diff --git a/src/parser/org.rs b/src/parser/org.rs index 9b87f689..91336d8d 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -1479,6 +1479,22 @@ impl FormatParser for OrgParser { continue; } if is_term { + // A tag-only item has Structure then a newline and + // no Prose. The next indented line is the description. + let prev_is_prose = regions.iter().rev().nth(1).is_some_and(|r| { + matches!(r.region, Region::Prose(_)) + }); + if !prev_is_prose { + 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; + } regions.pop(); if let Some(break_at) = org_line_break_at(line_text) { let content = line_text[..break_at].trim(); diff --git a/src/parser/rst.rs b/src/parser/rst.rs index 0573b56d..b14a2859 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -72,6 +72,9 @@ fn parse_line_based(input: &str) -> Vec { // Empty `.. table::` / `.. csv-table::`: first indented paragraph // is leftover title Prose; the table body then stays opaque. let mut in_table_title = false; + // Indented lines after a title-argument directive stay Structure + // until a blank. The body after that blank still reflows. + let mut in_title_arg = false; let mut directive_indent: usize = 0; let mut in_definition = false; let mut definition_indent: usize = 0; @@ -184,6 +187,20 @@ fn parse_line_based(input: &str) -> Vec { in_literal_block = false; } + if in_title_arg { + if line_text.trim().is_empty() { + in_title_arg = false; + } else { + let leading = line_text.len() - line_text.trim_start().len(); + if leading > 0 { + regions.push(SpannedRegion::structure(input, line.span())); + i += 1; + continue; + } + in_title_arg = false; + } + } + // Empty table opener: leftover title until a blank or a table // border, then the body stays opaque `in_directive`. if in_table_title { @@ -403,6 +420,12 @@ fn parse_line_based(input: &str) -> Vec { || rst_substitution_replace_marker_len(line_text).is_some() { in_meta = dir_name.as_deref().is_some_and(is_rst_meta_directive); + if dir_name + .as_deref() + .is_some_and(is_rst_title_argument_directive) + { + in_title_arg = true; + } i += 1; continue; } diff --git a/tests/org_item_tag.rs b/tests/org_item_tag.rs index 7b5db65d..9b21bc17 100644 --- a/tests/org_item_tag.rs +++ b/tests/org_item_tag.rs @@ -52,3 +52,40 @@ fn item_tag_stays_on_the_item_line_description_reflows() { ); assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); } + +#[test] +fn tagged_item_description_on_the_next_line_is_kept() { + let input = "- Alpha. Beta ::\n One. Two.\n"; + let regions = OrgParser.parse(input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s.contains("- Alpha. Beta ::") + )), + "tag stays Structure, got {regions:?}" + ); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p.contains("One. Two."))), + "next-line description must be Prose, got {regions:?}" + ); + let out = format_text(input, &org_cfg()).unwrap(); + assert!( + out.contains("- Alpha. Beta ::"), + "tag line stays, got:\n{out}" + ); + assert!( + out.contains("One."), + "first description sentence stays, got:\n{out}" + ); + assert!( + out.contains("Two."), + "second description sentence stays, got:\n{out}" + ); + assert!( + !out.contains("One. Two."), + "description must still split, got:\n{out}" + ); + assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); +} diff --git a/tests/rst_directive_title.rs b/tests/rst_directive_title.rs index 885dc83b..932f07cf 100644 --- a/tests/rst_directive_title.rs +++ b/tests/rst_directive_title.rs @@ -65,6 +65,36 @@ fn title_argument_stays_on_directive_line_body_reflows() { } } +#[test] +fn continued_title_argument_stays_structure_body_reflows() { + let input = ".. admonition:: First title.\n Second title. Third title.\n\n Body one. Body two.\n"; + let regions = RstParser.parse(input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s.contains("Second title. Third title.") + )), + "continued title must stay Structure, got {regions:?}" + ); + assert!( + !regions.iter().any(|r| matches!( + r, + Region::Prose(p) if p.contains("Second title.") + )), + "continued title must not be Prose, got {regions:?}" + ); + let out = format_text(input, &rst_cfg()).unwrap(); + assert!( + out.contains("Second title. Third title."), + "continued title must not split, got:\n{out}" + ); + assert!( + out.contains("Body one.\n Body two."), + "body must still reflow, got:\n{out}" + ); + assert_eq!(format_text(&out, &rst_cfg()).unwrap(), out); +} + #[test] fn note_and_warning_same_line_body_still_reflows() { for name in ["note", "warning"] { From ad672584668923e36906ecaad0a5a33a51e64db2 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 04:49:41 -0500 Subject: [PATCH 4/8] fix(org): take the item tag through the last separator Org item tags run to the last " :: " on the line. An earlier separator stays in the tag, and the description after the last one still splits. --- src/parser/org.rs | 10 ++++++---- tests/org_item_tag.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/parser/org.rs b/src/parser/org.rs index 91336d8d..57167db1 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -19,13 +19,15 @@ static LIST_ITEM_RE: LazyLock = LazyLock::new(|| { /// org-syntax item: `tag :: description`. /// -/// Bytes of `after_bullet` through `[ \t]+::` and the spaces that follow. -/// The tag stays on the item line; the description hangs as prose. +/// Bytes of `after_bullet` through the last `[ \t]+::` and the spaces +/// that follow. The tag stays on the item line; the description hangs +/// as prose. /// `None` when the item has no tag (`::` needs whitespace before it, /// and whitespace or end after it). pub(crate) fn org_item_tag_end(after_bullet: &str) -> Option { let bytes = after_bullet.as_bytes(); let mut i = 0; + let mut found = None; while i < bytes.len() { if bytes[i] == b' ' || bytes[i] == b'\t' { let mut j = i; @@ -38,7 +40,7 @@ pub(crate) fn org_item_tag_end(after_bullet: &str) -> Option { while k < bytes.len() && (bytes[k] == b' ' || bytes[k] == b'\t') { k += 1; } - return Some(k); + found = Some(k); } } i = j; @@ -46,7 +48,7 @@ pub(crate) fn org_item_tag_end(after_bullet: &str) -> Option { } i += 1; } - None + found } /// Hang width when `s` is a list marker plus an item tag and nothing else. diff --git a/tests/org_item_tag.rs b/tests/org_item_tag.rs index 9b21bc17..8f52abb4 100644 --- a/tests/org_item_tag.rs +++ b/tests/org_item_tag.rs @@ -53,6 +53,32 @@ fn item_tag_stays_on_the_item_line_description_reflows() { assert_eq!(format_text(&out, &org_cfg()).unwrap(), out); } +#[test] +fn item_tag_uses_the_last_separator() { + let input = "- Alpha :: Beta. Gamma :: One. Two.\n"; + let regions = OrgParser.parse(input); + assert!( + regions.iter().any(|r| matches!( + r, + Region::Structure(s) if s == "- Alpha :: Beta. Gamma :: " + )), + "the tag runs through the last separator, got {regions:?}" + ); + assert!( + regions + .iter() + .any(|r| matches!(r, Region::Prose(p) if p == "One. Two.")), + "only the text after the last separator is Prose, got {regions:?}" + ); + let out = format_text(input, &org_cfg()).unwrap(); + assert!( + out.starts_with("- Alpha :: Beta. Gamma :: One.\n"), + "a sentence inside the tag stays on the item line, got:\n{out}" + ); + assert!(out.contains("Two.\n"), "description still splits, got:\n{out}"); + assert!(!out.contains("- Alpha.\n"), "tag must not split, got:\n{out}"); +} + #[test] fn tagged_item_description_on_the_next_line_is_kept() { let input = "- Alpha. Beta ::\n One. Two.\n"; From 9bda3449254ce8accdede762a0b4b6d53ba48a60 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 05:01:53 -0500 Subject: [PATCH 5/8] docs(org): hang the vale item description under its tag The SnapperCheck continuation lines up under "- =SnapperCheck= :: ". The conventional-commit tags in the contributing guide already match. --- docs/orgmode/howto/vale-integration.org | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/orgmode/howto/vale-integration.org b/docs/orgmode/howto/vale-integration.org index 65905ff9..d6315b39 100644 --- a/docs/orgmode/howto/vale-integration.org +++ b/docs/orgmode/howto/vale-integration.org @@ -40,8 +40,8 @@ Level: warning. - =LongProseLine= :: Flags prose lines exceeding 120 characters. Level: suggestion. - =SnapperCheck= :: External action. - Runs =snapper --check --output-format json= and reports =fused= / =wrap= on the matching lines when =snapper= is on =PATH=. - Advisory =long= stays on =LongProseLine=. + Runs =snapper --check --output-format json= and reports =fused= / =wrap= on the matching lines when =snapper= is on =PATH=. + Advisory =long= stays on =LongProseLine=. * Precise CI enforcement :PROPERTIES: From 3356281591445ddb862f156e9b193b73cc3fe58c Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 05:08:19 -0500 Subject: [PATCH 6/8] style(org,rst): wrap the lines rustfmt rejected The item-tag predicate and two test inputs were over the width. --- src/parser/org.rs | 8 +++++--- tests/org_item_tag.rs | 10 ++++++++-- tests/rst_directive_title.rs | 3 ++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/parser/org.rs b/src/parser/org.rs index 57167db1..4f2888c5 100644 --- a/src/parser/org.rs +++ b/src/parser/org.rs @@ -1483,9 +1483,11 @@ impl FormatParser for OrgParser { if is_term { // A tag-only item has Structure then a newline and // no Prose. The next indented line is the description. - let prev_is_prose = regions.iter().rev().nth(1).is_some_and(|r| { - matches!(r.region, Region::Prose(_)) - }); + let prev_is_prose = regions + .iter() + .rev() + .nth(1) + .is_some_and(|r| matches!(r.region, Region::Prose(_))); if !prev_is_prose { if leading > 0 { regions.push(SpannedRegion::structure( diff --git a/tests/org_item_tag.rs b/tests/org_item_tag.rs index 8f52abb4..6ff7a7dc 100644 --- a/tests/org_item_tag.rs +++ b/tests/org_item_tag.rs @@ -75,8 +75,14 @@ fn item_tag_uses_the_last_separator() { out.starts_with("- Alpha :: Beta. Gamma :: One.\n"), "a sentence inside the tag stays on the item line, got:\n{out}" ); - assert!(out.contains("Two.\n"), "description still splits, got:\n{out}"); - assert!(!out.contains("- Alpha.\n"), "tag must not split, got:\n{out}"); + assert!( + out.contains("Two.\n"), + "description still splits, got:\n{out}" + ); + assert!( + !out.contains("- Alpha.\n"), + "tag must not split, got:\n{out}" + ); } #[test] diff --git a/tests/rst_directive_title.rs b/tests/rst_directive_title.rs index 932f07cf..d403b468 100644 --- a/tests/rst_directive_title.rs +++ b/tests/rst_directive_title.rs @@ -67,7 +67,8 @@ fn title_argument_stays_on_directive_line_body_reflows() { #[test] fn continued_title_argument_stays_structure_body_reflows() { - let input = ".. admonition:: First title.\n Second title. Third title.\n\n Body one. Body two.\n"; + let input = + ".. admonition:: First title.\n Second title. Third title.\n\n Body one. Body two.\n"; let regions = RstParser.parse(input); assert!( regions.iter().any(|r| matches!( From 66651ecbb1c938aa2458350427f458dbc74ab670 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 05:25:11 -0500 Subject: [PATCH 7/8] fix(rst): hang the indented line after a same-line title That line is the body. It splits. Treating it as more title kept "Body here. Second body." on one line. --- src/parser/rst.rs | 23 ----------------------- tests/rst_directive_title.rs | 29 +++++++++-------------------- 2 files changed, 9 insertions(+), 43 deletions(-) diff --git a/src/parser/rst.rs b/src/parser/rst.rs index b14a2859..0573b56d 100644 --- a/src/parser/rst.rs +++ b/src/parser/rst.rs @@ -72,9 +72,6 @@ fn parse_line_based(input: &str) -> Vec { // Empty `.. table::` / `.. csv-table::`: first indented paragraph // is leftover title Prose; the table body then stays opaque. let mut in_table_title = false; - // Indented lines after a title-argument directive stay Structure - // until a blank. The body after that blank still reflows. - let mut in_title_arg = false; let mut directive_indent: usize = 0; let mut in_definition = false; let mut definition_indent: usize = 0; @@ -187,20 +184,6 @@ fn parse_line_based(input: &str) -> Vec { in_literal_block = false; } - if in_title_arg { - if line_text.trim().is_empty() { - in_title_arg = false; - } else { - let leading = line_text.len() - line_text.trim_start().len(); - if leading > 0 { - regions.push(SpannedRegion::structure(input, line.span())); - i += 1; - continue; - } - in_title_arg = false; - } - } - // Empty table opener: leftover title until a blank or a table // border, then the body stays opaque `in_directive`. if in_table_title { @@ -420,12 +403,6 @@ fn parse_line_based(input: &str) -> Vec { || rst_substitution_replace_marker_len(line_text).is_some() { in_meta = dir_name.as_deref().is_some_and(is_rst_meta_directive); - if dir_name - .as_deref() - .is_some_and(is_rst_title_argument_directive) - { - in_title_arg = true; - } i += 1; continue; } diff --git a/tests/rst_directive_title.rs b/tests/rst_directive_title.rs index d403b468..cb03e0d7 100644 --- a/tests/rst_directive_title.rs +++ b/tests/rst_directive_title.rs @@ -66,32 +66,21 @@ fn title_argument_stays_on_directive_line_body_reflows() { } #[test] -fn continued_title_argument_stays_structure_body_reflows() { +fn indented_line_after_same_line_title_still_splits() { let input = - ".. admonition:: First title.\n Second title. Third title.\n\n Body one. Body two.\n"; - let regions = RstParser.parse(input); - assert!( - regions.iter().any(|r| matches!( - r, - Region::Structure(s) if s.contains("Second title. Third title.") - )), - "continued title must stay Structure, got {regions:?}" - ); + ".. admonition:: Title\n Body here. Second body.\nAfter markup. Next sentence.\n"; + let out = format_text(input, &rst_cfg()).unwrap(); assert!( - !regions.iter().any(|r| matches!( - r, - Region::Prose(p) if p.contains("Second title.") - )), - "continued title must not be Prose, got {regions:?}" + out.contains(".. admonition:: Title\n"), + "same-line title stays on the directive line, got:\n{out}" ); - let out = format_text(input, &rst_cfg()).unwrap(); assert!( - out.contains("Second title. Third title."), - "continued title must not split, got:\n{out}" + out.contains(" Body here.\n Second body.\n"), + "indented body must hang and split, got:\n{out}" ); assert!( - out.contains("Body one.\n Body two."), - "body must still reflow, got:\n{out}" + out.contains("After markup.\nNext sentence.\n"), + "flush prose after the directive must split, got:\n{out}" ); assert_eq!(format_text(&out, &rst_cfg()).unwrap(), out); } From c32592647942456ce778bd49dd2b2d95c64be397 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 05:32:51 -0500 Subject: [PATCH 8/8] style(rst): keep the title-body fixture on one line rustfmt fits that string on the assignment line. --- tests/rst_directive_title.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/rst_directive_title.rs b/tests/rst_directive_title.rs index cb03e0d7..71ecf9e0 100644 --- a/tests/rst_directive_title.rs +++ b/tests/rst_directive_title.rs @@ -67,8 +67,7 @@ fn title_argument_stays_on_directive_line_body_reflows() { #[test] fn indented_line_after_same_line_title_still_splits() { - let input = - ".. admonition:: Title\n Body here. Second body.\nAfter markup. Next sentence.\n"; + let input = ".. admonition:: Title\n Body here. Second body.\nAfter markup. Next sentence.\n"; let out = format_text(input, &rst_cfg()).unwrap(); assert!( out.contains(".. admonition:: Title\n"),