diff --git a/docs/md013.md b/docs/md013.md index a92eecca..3ea01210 100644 --- a/docs/md013.md +++ b/docs/md013.md @@ -52,7 +52,6 @@ and work with in various contexts. line-length = 100 # Maximum characters per line (default: 80) code-blocks = false # Don't check code blocks (default: true) code-spans = false # Don't flag lines whose only overflow is an unbreakable inline code span (default: true) -emphasis-spans = false # Allow reflow breaking inside emphasis/strong/strikethrough spans (default: false) tables = false # Don't check tables (default: false) headings = true # Check headings (default: true) paragraphs = true # Check paragraph/regular text (default: true) @@ -67,6 +66,7 @@ reflow-mode = "default" # Reflow mode: "default", "normalize", "sentence-per-li length-mode = "visual" # How to count line length: "visual", "chars", or "bytes" (default: "visual") abbreviations = ["Assn", "Univ"] # Add custom abbreviations for sentence-per-line mode require-sentence-capital = true # Require uppercase after periods for sentence detection (default: true) +atomic-spans = true # Treat code/emphasis spans as atomic units during reflow (default: true) ``` ### Configuration options explained @@ -74,7 +74,6 @@ require-sentence-capital = true # Require uppercase after periods for sentence - `line-length`: The maximum number of characters allowed per line (set to `0` to disable all line length checks) - `code-blocks`: Whether to check line length in code blocks (default: `true`) - `code-spans`: Whether to check lines whose length comes from an inline code span (default: `true`). Inline code spans (`` `like this` ``) cannot be wrapped, so reflow cannot shorten a line whose excess length is one. When `false`, a line is not reported if it would fit within the limit once its inline code spans are excluded - useful with `reflow` so an unbreakable code incantation does not fail an otherwise-clean file -- `emphasis-spans`: Whether to allow reflow breaking inside emphasis, strong, and strikethrough spans (default: `false`). When `true`, text inside emphasis elements is wrapped word-by-word like normal text; inline code nested inside an emphasis span wraps along with the surrounding words (a code span across a soft line break renders identically). When `false`, emphasis spans are treated as atomic units and kept on a single line unless they exceed the maximum line length on their own - `tables`: Whether to check line length in tables (default: `false`) - `headings`: Whether to check line length in headings (default: `true`) - `paragraphs`: Whether to check line length in regular text/paragraphs (default: `true`). When false, `line-length` is still used for reflow but no warnings are reported. Blockquote content is treated as paragraph text, so `paragraphs = false` also skips blockquotes @@ -98,6 +97,7 @@ require-sentence-capital = true # Require uppercase after periods for sentence - When `true`, only `word. Capital` is treated as a sentence boundary (fewer false positives) - When `false`, `word. lowercase` is also treated as a sentence boundary (more splitting) - Does not affect `!` and `?` which are always treated as sentence boundaries +- `atomic-spans`: Whether to hold emphasis/strong/strikethrough and code spans atomic during reflow (default: `true`). When `true`, these spans are treated as atomic units. When `false`, they can be wrapped word-by-word like normal text. Note that if an emphasis span contains a nested code span, the emphasis span is kept atomic to prevent formatting corruption. ## Ignoring inline link URLs (non-strict mode) diff --git a/src/rules/md013_line_length.rs b/src/rules/md013_line_length.rs index ed8d4494..5cd5a9ad 100644 --- a/src/rules/md013_line_length.rs +++ b/src/rules/md013_line_length.rs @@ -73,7 +73,7 @@ impl MD013LineLength { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }, list_spacing: MD030Config::default(), } @@ -135,7 +135,7 @@ impl MD013LineLength { None }, defined_references: Some(Self::defined_reference_labels(ctx)), - emphasis_spans: config.emphasis_spans, + atomic_spans: config.atomic_spans, } } diff --git a/src/rules/md013_line_length/md013_config.rs b/src/rules/md013_line_length/md013_config.rs index 08ec5860..4cea15fd 100644 --- a/src/rules/md013_line_length/md013_config.rs +++ b/src/rules/md013_line_length/md013_config.rs @@ -166,11 +166,11 @@ pub struct MD013Config { )] pub require_sentence_capital: bool, - /// Whether to allow reflow breaking inside emphasis/strong/strikethrough spans. - /// When true, emphasis text is wrapped word-by-word like normal text. - /// When false (default), emphasis spans are treated as atomic units. - #[serde(default = "default_emphasis_spans", alias = "emphasis_spans")] - pub emphasis_spans: bool, + /// Whether to hold emphasis/strong/strikethrough and code spans atomic during reflow. + /// When true (default), these spans are treated as atomic units. + /// When false, they can be wrapped word-by-word like normal text. + #[serde(default = "default_atomic_spans", alias = "atomic_spans")] + pub atomic_spans: bool, } fn default_line_length() -> LineLength { @@ -209,8 +209,8 @@ fn default_ignore_link_urls() -> bool { true } -fn default_emphasis_spans() -> bool { - false +fn default_atomic_spans() -> bool { + true } impl Default for MD013Config { @@ -233,7 +233,7 @@ impl Default for MD013Config { length_mode: LengthMode::default(), abbreviations: Vec::new(), require_sentence_capital: default_require_sentence_capital(), - emphasis_spans: default_emphasis_spans(), + atomic_spans: default_atomic_spans(), } } } @@ -255,21 +255,16 @@ impl MD013Config { /// pre-filter candidate lines: any line shorter than this can never /// violate, regardless of which context it falls under. pub fn min_effective_line_length(&self) -> LineLength { - let mut limits: Vec = vec![self.line_length]; - if let Some(h) = self.heading_line_length { - limits.push(h); - } - if let Some(c) = self.code_block_line_length { - limits.push(c); - } - // "Unlimited" (0) is the laxest possible budget, so it must not win - // the minimum unless all budgets are unlimited. - let bounded: Vec = limits.iter().copied().filter(|l| !l.is_unlimited()).collect(); - if bounded.is_empty() { - LineLength::from_const(0) - } else { - bounded.into_iter().min_by_key(|l| l.get()).unwrap() - } + [ + Some(self.line_length), + self.heading_line_length, + self.code_block_line_length, + ] + .into_iter() + .flatten() + .filter(|l| !l.is_unlimited()) + .min_by_key(|l| l.get()) + .unwrap_or(LineLength::from_const(0)) } /// Convert abbreviations Vec to Option for ReflowOptions @@ -311,7 +306,7 @@ impl MD013Config { // No document context here (config-only), so shortcut references // stay atomic. The rule's fix path supplies the defined labels. defined_references: None, - emphasis_spans: self.emphasis_spans, + atomic_spans: self.atomic_spans, } } } @@ -417,7 +412,7 @@ mod tests { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let toml_str = toml::to_string(&config).unwrap(); diff --git a/src/rules/md013_line_length/tests.rs b/src/rules/md013_line_length/tests.rs index 66b659ad..bb9bfddb 100644 --- a/src/rules/md013_line_length/tests.rs +++ b/src/rules/md013_line_length/tests.rs @@ -2699,7 +2699,7 @@ fn test_paragraphs_false_skips_regular_text() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -2737,7 +2737,7 @@ fn test_paragraphs_false_still_checks_code_blocks() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -2776,7 +2776,7 @@ fn test_paragraphs_false_still_checks_headings() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -2813,7 +2813,7 @@ fn test_paragraphs_false_with_reflow_sentence_per_line() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -2850,7 +2850,7 @@ fn test_paragraphs_true_checks_regular_text() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -2887,7 +2887,7 @@ fn test_line_length_zero_disables_all_checks() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -2924,7 +2924,7 @@ fn test_line_length_zero_with_headings() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -2961,7 +2961,7 @@ fn test_line_length_zero_with_code_blocks() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -2998,7 +2998,7 @@ fn test_line_length_zero_with_sentence_per_line_reflow() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -3063,7 +3063,7 @@ Final paragraph. abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); let result = rule.check(&ctx).unwrap(); @@ -3132,7 +3132,7 @@ fn test_reflow_preserves_mkdocstrings_autodoc_block() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -3167,7 +3167,7 @@ fn test_reflow_preserves_mkdocstrings_with_identifier() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -3203,7 +3203,7 @@ fn test_reflow_preserves_mkdocstrings_surrounded_by_paragraphs() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -3242,7 +3242,7 @@ fn test_reflow_mkdocstrings_not_detected_in_standard_flavor() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -3274,7 +3274,7 @@ fn test_reflow_preserves_mkdocstrings_with_blank_line_in_block() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5281,7 +5281,7 @@ fn test_reflow_admonition_in_list_item_basic() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5346,7 +5346,7 @@ fn test_reflow_collapsible_admonition_in_list_item() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5403,7 +5403,7 @@ fn test_reflow_multiple_admonitions_in_list_item() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5482,7 +5482,7 @@ fn test_reflow_admonition_short_content_preserved() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5530,7 +5530,7 @@ fn test_reflow_admonition_with_multiple_paragraphs() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5600,7 +5600,7 @@ fn test_reflow_admonition_not_in_standard_flavor() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5650,7 +5650,7 @@ fn test_reflow_admonition_idempotent() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5705,7 +5705,7 @@ fn test_reflow_admonition_only_in_list_no_long_text() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5765,7 +5765,7 @@ fn test_reflow_content_after_admonition_in_list_item() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5830,7 +5830,7 @@ fn test_reflow_content_after_admonition_short_lines() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5879,7 +5879,7 @@ fn test_reflow_multiple_blocks_after_admonition() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5936,7 +5936,7 @@ fn test_reflow_admonition_empty_body() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -5987,7 +5987,7 @@ fn test_reflow_admonition_no_blank_line_before_body() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -6042,7 +6042,7 @@ fn test_reflow_admonition_body_indent_preserved() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -6103,7 +6103,7 @@ fn test_reflow_admonition_with_code_block_in_list_item() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -6169,7 +6169,7 @@ fn test_reflow_admonition_with_tilde_fence_in_list_item() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -6229,7 +6229,7 @@ fn test_reflow_admonition_with_multiple_code_blocks_in_list_item() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -6297,7 +6297,7 @@ fn test_reflow_admonition_code_block_idempotent() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -6350,7 +6350,7 @@ fn test_reflow_tab_container_in_list_item() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -6996,7 +6996,7 @@ fn test_paragraphs_false_skips_blockquote_content() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7033,7 +7033,7 @@ fn test_blockquotes_false_skips_blockquote_content() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7070,7 +7070,7 @@ fn test_blockquotes_true_paragraphs_true_checks_blockquotes() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7106,7 +7106,7 @@ fn test_blockquotes_false_still_checks_regular_paragraphs() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7142,7 +7142,7 @@ fn test_blockquotes_false_paragraphs_false_skips_blockquotes() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7194,7 +7194,7 @@ fn test_nested_blockquote_skipped_when_blockquotes_false() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7230,7 +7230,7 @@ fn test_paragraphs_false_skips_nested_blockquote() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7267,7 +7267,7 @@ fn test_blockquotes_false_skips_reflow_warnings() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7304,7 +7304,7 @@ fn test_paragraphs_false_skips_blockquote_reflow_warnings() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7341,7 +7341,7 @@ fn test_blockquotes_true_with_reflow_still_warns() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7377,7 +7377,7 @@ fn test_blockquotes_false_skips_lazy_continuation() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7413,7 +7413,7 @@ fn test_blockquotes_false_reflow_skips_lazy_continuation() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); @@ -7450,7 +7450,7 @@ fn test_blockquotes_false_paragraph_after_blockquote_still_warns() { abbreviations: Vec::new(), require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }; let rule = MD013LineLength::from_config_struct(config); diff --git a/src/utils/text_reflow.rs b/src/utils/text_reflow.rs index 7c7974de..51919a06 100644 --- a/src/utils/text_reflow.rs +++ b/src/utils/text_reflow.rs @@ -109,9 +109,10 @@ pub struct ReflowOptions { /// atomic regardless, because their `][ref]` / `[]` syntax is an explicit /// link signal that does not depend on a definition being in scope. pub defined_references: Option>, - /// Whether to allow reflow breaking inside emphasis/strong/strikethrough spans - /// when they would otherwise exceed line length or fit on a new line. - pub emphasis_spans: bool, + /// Whether to hold emphasis/strong/strikethrough and code spans atomic during reflow. + /// When true (default), these spans are treated as atomic units. + /// When false, they can be wrapped word-by-word like normal text. + pub atomic_spans: bool, } impl Default for ReflowOptions { @@ -129,7 +130,7 @@ impl Default for ReflowOptions { require_sentence_capital: true, max_list_continuation_indent: None, defined_references: None, - emphasis_spans: false, + atomic_spans: true, } } } @@ -144,37 +145,6 @@ pub fn normalize_reference_label(label: &str) -> String { label.split_whitespace().collect::>().join(" ").to_lowercase() } -/// Build a boolean mask indicating which character positions are inside inline code spans. -/// Handles single, double, and triple backtick delimiters. -fn compute_inline_code_mask(text: &str) -> Vec { - let code_spans = extract_code_spans(text); - let chars: Vec = text.chars().collect(); - let mut mask = vec![false; chars.len()]; - let mut span_it = code_spans.iter().peekable(); - let mut byte_idx = 0; - // Map character indices to byte-offset based code spans in a single pass. - // Since code spans are sorted by start offset, we advance the span iterator - // as our character byte index passes the end of the current span. - for (char_idx, ch) in chars.iter().enumerate() { - let next_byte_idx = byte_idx + ch.len_utf8(); - while let Some(span) = span_it.peek() { - if span.end <= byte_idx { - span_it.next(); - } else { - break; - } - } - if let Some(span) = span_it.peek() - && byte_idx >= span.start - && byte_idx < span.end - { - mask[char_idx] = true; - } - byte_idx = next_byte_idx; - } - mask -} - /// If `chars` starts at `start` with one or more consecutive footnote /// references (`[^label]`, matching the same `[a-zA-Z0-9_-]+` label grammar as /// `FOOTNOTE_REF` in `mkdocs_footnotes.rs`), return the position just past the @@ -210,6 +180,7 @@ fn is_sentence_boundary( text: &str, chars: &[char], pos: usize, + byte_offset_after_punct: usize, abbreviations: &HashSet, require_sentence_capital: bool, ) -> bool { @@ -350,8 +321,7 @@ fn is_sentence_boundary( if pos > 0 { // Check for common abbreviations - let byte_offset: usize = chars[..=pos].iter().map(|ch| ch.len_utf8()).sum(); - if text_ends_with_abbreviation(&text[..byte_offset], abbreviations) { + if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) { return false; } @@ -395,46 +365,81 @@ fn split_into_sentences_with_set( abbreviations: &HashSet, require_sentence_capital: bool, ) -> Vec { - // Pre-compute which character positions are inside inline code spans - let in_code = compute_inline_code_mask(text); - // Collect chars once and share the slice with is_sentence_boundary, which - // would otherwise re-collect the whole text on every position it checks. let char_vec: Vec = text.chars().collect(); + // Precompute byte offsets for each character in text. + // char_offsets[i] represents the byte index of char_vec[i] in text. + // char_offsets[char_vec.len()] is the byte index of the end of text. + let mut char_offsets = Vec::with_capacity(char_vec.len() + 1); + let mut offset = 0; + for c in &char_vec { + char_offsets.push(offset); + offset += c.len_utf8(); + } + char_offsets.push(offset); + + // Track active inline code spans inline since they are sorted and non-overlapping. + let code_spans = extract_code_spans(text); + let mut span_it = code_spans.iter().peekable(); + let mut sentences = Vec::new(); let mut current_sentence = String::new(); - let mut chars = text.chars().peekable(); let mut pos = 0; - while let Some(c) = chars.next() { + while pos < char_vec.len() { + let c = char_vec[pos]; current_sentence.push(c); - if !in_code[pos] && is_sentence_boundary(text, &char_vec, pos, abbreviations, require_sentence_capital) { + let byte_idx = char_offsets[pos]; + + // Advance active code span iterator if the current char start is past its end. + while let Some(span) = span_it.peek() { + if span.end <= byte_idx { + span_it.next(); + } else { + break; + } + } + + // True if the current character position falls inside an inline code span. + let in_code = if let Some(span) = span_it.peek() { + byte_idx >= span.start && byte_idx < span.end + } else { + false + }; + + if !in_code + && is_sentence_boundary( + text, + &char_vec, + pos, + char_offsets[pos + 1], + abbreviations, + require_sentence_capital, + ) + { // Consume any trailing footnote references glued to the punctuation - // (they belong to the current sentence, e.g. "sentence.[^1]" keeps - // the marker attached to the sentence it annotates rather than - // leaking onto the next one). if let Some(end_pos) = footnote_refs_end(&char_vec, pos + 1) { while pos + 1 < end_pos { - current_sentence.push(chars.next().unwrap()); pos += 1; + current_sentence.push(char_vec[pos]); } } - // Consume any trailing emphasis/strikethrough markers and quotes (they belong to the current sentence) - while let Some(&next) = chars.peek() { + // Consume any trailing emphasis/strikethrough markers and quotes + while pos + 1 < char_vec.len() { + let next = char_vec[pos + 1]; if next == '*' || next == '_' || next == '~' || is_closing_quote(next) { - current_sentence.push(chars.next().unwrap()); pos += 1; + current_sentence.push(char_vec[pos]); } else { break; } } // Consume the space after the sentence - if chars.peek() == Some(&' ') { - chars.next(); - pos += 1; + if pos + 1 < char_vec.len() && char_vec[pos + 1] == ' ' { + pos += 1; // skip space (not pushed to current_sentence) } sentences.push(current_sentence.trim().to_string()); @@ -680,7 +685,7 @@ enum Element { /// exactly; treated as atomic so it is never split mid-role. MystRole(String), /// Inline code `code` - Code(String), + Code { content: String, marker: String }, /// Bold text **text** or __text__ Bold { content: String, @@ -722,7 +727,7 @@ impl std::fmt::Display for Element { Element::HugoShortcode(s) => write!(f, "{s}"), Element::AttrList(s) => write!(f, "{s}"), Element::MystRole(s) => write!(f, "{s}"), - Element::Code(s) => write!(f, "{s}"), + Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"), Element::Bold { content, underscore } => { if *underscore { write!(f, "__{content}__") @@ -741,6 +746,37 @@ impl std::fmt::Display for Element { } } +impl Element { + fn display_len(&self, mode: ReflowLengthMode) -> usize { + match self { + Element::Text(s) + | Element::Link(s) + | Element::ReferenceLink(s) + | Element::EmptyReferenceLink(s) + | Element::ShortcutReference(s) + | Element::InlineImage(s) + | Element::ReferenceImage(s) + | Element::EmptyReferenceImage(s) + | Element::LinkedImage(s) + | Element::FootnoteReference(s) + | Element::Autolink(s) + | Element::HtmlTag(s) + | Element::HtmlEntity(s) + | Element::HugoShortcode(s) + | Element::AttrList(s) + | Element::MystRole(s) => display_len(s, mode), + Element::WikiLink(s) => display_len(s, mode) + 4, + Element::InlineMath(s) => display_len(s, mode) + 2, + Element::DisplayMath(s) => display_len(s, mode) + 4, + Element::EmojiShortcode(s) => display_len(s, mode) + 2, + Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 }, + Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2, + Element::Bold { content, .. } => display_len(content, mode) + 4, + Element::Italic { content, .. } => display_len(content, mode) + 2, + } + } +} + /// An emphasis or formatting span parsed by pulldown-cmark #[derive(Debug, Clone)] struct EmphasisSpan { @@ -1508,8 +1544,15 @@ fn parse_markdown_elements_inner( "pulldown_code" => { let span = next_code_span.unwrap(); let span_len = span.end - span.start; - let code = &remaining[..span_len]; - elements.push(Element::Code(code.to_string())); + let code_raw = &remaining[..span_len]; + if let Some((content, marker)) = decompose_code_span(code_raw) { + elements.push(Element::Code { + content: content.to_string(), + marker: marker.to_string(), + }); + } else { + elements.push(Element::Text(code_raw.to_string())); + } remaining = &remaining[span_len..]; } "attr_list" => { @@ -1582,16 +1625,16 @@ fn should_insert_space_before_join(current: &str) -> bool { /// A paragraph-continuation line like this converts the previous line into a /// heading or inserts a horizontal rule. fn is_setext_or_thematic(text: &str) -> bool { - let mut marker = '\0'; + let mut marker = 0u8; let mut count = 0usize; let mut has_space = false; - for c in text.chars() { - match c { - ' ' | '\t' => has_space = true, - '-' | '=' | '*' | '_' => { - if marker == '\0' { - marker = c; - } else if c != marker { + for &b in text.as_bytes() { + match b { + b' ' | b'\t' => has_space = true, + b'-' | b'=' | b'*' | b'_' => { + if marker == 0 { + marker = b; + } else if b != marker { return false; } count += 1; @@ -1600,9 +1643,9 @@ fn is_setext_or_thematic(text: &str) -> bool { } } match marker { - '=' => !has_space, - '-' => !has_space || count >= 3, - '*' | '_' => count >= 3, + b'=' => !has_space, + b'-' => !has_space || count >= 3, + b'*' | b'_' => count >= 3, _ => false, } } @@ -1630,6 +1673,8 @@ fn starts_block_construct(text: &str) -> bool { b'>' => true, b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text), b'_' | b'=' => is_setext_or_thematic(text), + b':' => is_definition_list_item(text) || text.starts_with(":::"), + b'|' => true, b'#' => { let hashes = bytes.iter().take_while(|&&b| b == b'#').count(); hashes <= 6 && marker_then_boundary(hashes) @@ -1705,8 +1750,6 @@ fn reflow_elements_sentence_per_line( let mut current_line = String::new(); for (idx, element) in elements.iter().enumerate() { - let element_str = format!("{element}"); - // For text elements, split into sentences if let Element::Text(text) = element { // Simply append text - it already has correct spacing from tokenization @@ -1805,6 +1848,7 @@ fn reflow_elements_sentence_per_line( ); } else { // Non-text, non-emphasis elements (Code, Links, etc.) + let element_str = format!("{element}"); // Check if this element is adjacent to the preceding text (no // breakable space between; a non-breaking space keeps the pair // attached and must not have an ASCII space appended after it) @@ -2109,8 +2153,7 @@ fn compute_element_spans(elements: &[Element]) -> Vec<(usize, usize)> { let mut spans = Vec::new(); let mut offset = 0; for element in elements { - let rendered = format!("{element}"); - let len = rendered.len(); + let len = element.display_len(ReflowLengthMode::Bytes); if !matches!(element, Element::Text(_)) { spans.push((offset, offset + len)); } @@ -2538,10 +2581,7 @@ fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec let length_mode = options.length_mode; for (idx, element) in elements.iter().enumerate() { - // Derive the display width from the already-formatted string rather than - // formatting the element a second time just to measure it. - let element_str = format!("{element}"); - let element_len = display_len(&element_str, length_mode); + let element_len = element.display_len(length_mode); // Determine adjacency from the original elements, not from current_line. // Elements are adjacent when there's no breakable whitespace between them @@ -2681,126 +2721,164 @@ fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec current_length += word_len; } } - } else if matches!( - element, - Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. } - ) && (options.emphasis_spans || element_len > options.line_length) - { - // Italic, bold, and strikethrough with content longer than line_length need word wrapping. - // Split content word-by-word, attach the opening marker to the first word - // and the closing marker to the last word. - let (content, marker): (&str, &str) = match element { - Element::Italic { content, underscore } => (content.as_str(), if *underscore { "_" } else { "*" }), - Element::Bold { content, underscore } => (content.as_str(), if *underscore { "__" } else { "**" }), - Element::Strikethrough { content, double } => (content.as_str(), if *double { "~~" } else { "~" }), - _ => unreachable!(), + } else { + let span_info = match element { + Element::Italic { content, underscore } => { + Some((content.as_str(), if *underscore { "_" } else { "*" }, false)) + } + Element::Bold { content, underscore } => { + Some((content.as_str(), if *underscore { "__" } else { "**" }, false)) + } + Element::Strikethrough { content, double } => { + Some((content.as_str(), if *double { "~~" } else { "~" }, false)) + } + Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), true)), + _ => None, + }; + + let is_eligible = match span_info { + Some((content, _, is_code)) => { + if is_code { + !options.atomic_spans + } else { + (!options.atomic_spans || element_len > options.line_length) + && !content.contains(['`', '*', '_', '~']) + } + } + None => false, }; - let words: Vec<&str> = split_breakable_words(content).collect(); - let n = words.len(); + if is_eligible { + let (content, marker, is_code) = span_info.unwrap(); + let words: Vec<&str> = split_breakable_words(content).collect(); + let n = words.len(); + if n == 0 { + // Empty span — treat as atomic + let full = format!("{marker}{marker}"); + let full_len = display_len(&full, length_mode); + if !is_adjacent_to_prev && current_length > 0 { + current_line.push(' '); + current_length += 1; + } + current_line.push_str(&full); + current_length += full_len; + } else { + for (i, word) in words.iter().enumerate() { + let is_first = i == 0; + let is_last = i == n - 1; - if n == 0 { - // Empty span — treat as atomic - let full = format!("{marker}{marker}"); - let full_len = display_len(&full, length_mode); - if !is_adjacent_to_prev && current_length > 0 { - current_line.push(' '); - current_length += 1; + let space_start = if is_first && is_code && word.starts_with('`') { + " " + } else { + "" + }; + let space_end = if is_last && is_code && word.ends_with('`') { + " " + } else { + "" + }; + + let word_str: String = match (is_first, is_last) { + (true, true) => format!("{marker}{space_start}{word}{space_end}{marker}"), + (true, false) => format!("{marker}{space_start}{word}"), + (false, true) => format!("{word}{space_end}{marker}"), + (false, false) => word.to_string(), + }; + let word_len = display_len(&word_str, length_mode); + + let needs_space = if is_first { + !is_adjacent_to_prev && current_length > 0 + } else { + current_length > 0 + }; + + if needs_space + && current_length + 1 + word_len > options.line_length + && !starts_block_construct(&word_str) + { + lines.push(current_line.trim_matches(is_breakable_whitespace).to_string()); + current_line = word_str; + current_length = word_len; + current_line_element_spans.clear(); + } else { + if needs_space { + current_line.push(' '); + current_length += 1; + } + current_line.push_str(&word_str); + current_length += word_len; + } + } } - current_line.push_str(&full); - current_length += full_len; } else { - for (i, word) in words.iter().enumerate() { - let is_first = i == 0; - let is_last = i == n - 1; - let word_str: String = match (is_first, is_last) { - (true, true) => format!("{marker}{word}{marker}"), - (true, false) => format!("{marker}{word}"), - (false, true) => format!("{word}{marker}"), - (false, false) => word.to_string(), - }; - let word_len = display_len(&word_str, length_mode); - - let needs_space = if is_first { - !is_adjacent_to_prev && current_length > 0 - } else { - current_length > 0 - }; - - if needs_space - && current_length + 1 + word_len > options.line_length - && !starts_block_construct(&word_str) + // For non-text elements (code, links, references), treat as atomic units + // These should never be broken across lines + let element_str = format!("{element}"); + + if is_adjacent_to_prev { + // Adjacent to preceding text — attach directly without space + if current_length + element_len > options.line_length + && let Some(carried) = break_before_attached( + &mut lines, + &mut current_line, + &mut current_length, + &mut current_line_element_spans, + &element_str, + "", + length_mode, + ) { - lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string()); - current_line = word_str; - current_length = word_len; - current_line_element_spans.clear(); + // Would exceed limit — broke before the adjacent word group + // at the last safe space (element-aware, so links/code stay + // intact). Record the element span in the new current_line. + current_line_element_spans.push((carried, carried + element_str.len())); } else { - if needs_space { - current_line.push(' '); - current_length += 1; - } - current_line.push_str(&word_str); - current_length += word_len; + let start = current_line.len(); + current_line.push_str(&element_str); + current_length += element_len; + current_line_element_spans.push((start, current_line.len())); } - } - } - } else { - // For non-text elements (code, links, references), treat as atomic units - // These should never be broken across lines - - if is_adjacent_to_prev { - // Adjacent to preceding text — attach directly without space - if current_length + element_len > options.line_length - && let Some(carried) = break_before_attached( + } else if current_length > 0 && current_length + 1 + element_len > options.line_length { + if !starts_block_construct(&element_str) { + // Not adjacent, would exceed — start new line + lines.push(current_line.trim_matches(is_breakable_whitespace).to_string()); + current_line.clone_from(&element_str); + current_length = element_len; + current_line_element_spans.clear(); + current_line_element_spans.push((0, element_str.len())); + } else if let Some(carried) = break_before_attached( &mut lines, &mut current_line, &mut current_length, &mut current_line_element_spans, &element_str, - "", + " ", length_mode, - ) - { - // Would exceed limit — broke before the adjacent word group - // at the last safe space (element-aware, so links/code stay - // intact). Record the element span in the new current_line. - current_line_element_spans.push((carried, carried + element_str.len())); - } else { - // Fits, or no safe space to break at — accept the line - let start = current_line.len(); - current_line.push_str(&element_str); - current_length += element_len; - current_line_element_spans.push((start, current_line.len())); - } - } else if current_length > 0 && current_length + 1 + element_len > options.line_length { - if !starts_block_construct(&element_str) { - // Not adjacent, would exceed — start new line - lines.push(current_line.trim_matches(is_breakable_whitespace).to_string()); - current_line.clone_from(&element_str); - current_length = element_len; - current_line_element_spans.clear(); - current_line_element_spans.push((0, element_str.len())); - } else if let Some(carried) = break_before_attached( - &mut lines, - &mut current_line, - &mut current_length, - &mut current_line_element_spans, - &element_str, - " ", - length_mode, - ) { - // The overflowing element would open a block construct at - // line start (e.g. an HtmlTag like `
`). Broke one word - // earlier instead so the element stays mid-line. - let start = carried + 1; - current_line_element_spans.push((start, start + element_str.len())); + ) { + // The overflowing element would open a block construct at + // line start (e.g. an HtmlTag like `
`). Broke one word + // earlier instead so the element stays mid-line. + let start = carried + 1; + current_line_element_spans.push((start, start + element_str.len())); + } else { + // No safe earlier break point — keep the element attached + // and accept the long line rather than corrupt the structure. + let ends_with_opener = + current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{'); + if !ends_with_opener { + current_line.push(' '); + current_length += 1; + } + let start = current_line.len(); + current_line.push_str(&element_str); + current_length += element_len; + current_line_element_spans.push((start, current_line.len())); + } } else { - // No safe earlier break point — keep the element attached - // and accept the long line rather than corrupt the structure. + // Not adjacent, fits — add with space let ends_with_opener = current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{'); - if !ends_with_opener { + if current_length > 0 && !ends_with_opener { current_line.push(' '); current_length += 1; } @@ -2809,18 +2887,6 @@ fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec current_length += element_len; current_line_element_spans.push((start, current_line.len())); } - } else { - // Not adjacent, fits — add with space - let ends_with_opener = - current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{'); - if current_length > 0 && !ends_with_opener { - current_line.push(' '); - current_length += 1; - } - let start = current_line.len(); - current_line.push_str(&element_str); - current_length += element_len; - current_line_element_spans.push((start, current_line.len())); } } } @@ -3781,6 +3847,23 @@ pub fn reflow_paragraph_at_line_with_options( reflowed_text, }) } +/// Decomposes a raw inline code span string into its inner content and backtick marker. +/// +/// For example, `decompose_code_span("`code`")` returns `Some(("code", "`"))`. +/// If the input is not a valid code span (e.g., it doesn't start and end with the +/// same number of backticks), returns `None`. +fn decompose_code_span(raw: &str) -> Option<(&str, &str)> { + let marker_len = raw.bytes().take_while(|&b| b == b'`').count(); + if marker_len == 0 { + return None; + } + let marker = &raw[..marker_len]; + if raw.len() < marker_len * 2 { + return None; + } + let content = &raw[marker_len..raw.len() - marker_len]; + Some((content, marker)) +} #[cfg(test)] mod tests { @@ -4255,22 +4338,24 @@ mod tests { // 1. Single backtick let elements = parse_markdown_elements_inner("`code`", false, false, None); assert_eq!(elements.len(), 1); - assert!(matches!(&elements[0], Element::Code(s) if s == "`code`")); + assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`")); // 2. Double backtick let elements = parse_markdown_elements_inner("``code``", false, false, None); assert_eq!(elements.len(), 1); - assert!(matches!(&elements[0], Element::Code(s) if s == "``code``")); + assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``")); // 3. Double backtick with single backtick inside let elements = parse_markdown_elements_inner("``code`inside``", false, false, None); assert_eq!(elements.len(), 1); - assert!(matches!(&elements[0], Element::Code(s) if s == "``code`inside``")); + assert!( + matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``") + ); // 4. Spaces inside let elements = parse_markdown_elements_inner("`` code ``", false, false, None); assert_eq!(elements.len(), 1); - assert!(matches!(&elements[0], Element::Code(s) if s == "`` code ``")); + assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``")); // 5. Unclosed backtick (should be parsed as Text) let elements = parse_markdown_elements_inner("`unclosed", false, false, None); @@ -4391,35 +4476,82 @@ mod tests { } #[test] - fn test_emphasis_spans() { - let text = "hello **word1 word2**"; + fn test_atomic_spans() { + // --- Emphasis Spans --- + let text_emphasis = "hello **word1 word2**"; - // With emphasis_spans = false (default), the short emphasis span is kept atomic - // because its total length (15) <= line_length (18), even though it doesn't fit on line 1. let options_disabled = ReflowOptions { line_length: 18, - emphasis_spans: false, + atomic_spans: true, ..Default::default() }; - let lines_disabled = reflow_line(text, &options_disabled); + let lines_disabled = reflow_line(text_emphasis, &options_disabled); assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]); - // With emphasis_spans = true, the emphasis span is split across lines. let options_enabled = ReflowOptions { line_length: 18, - emphasis_spans: true, + atomic_spans: false, ..Default::default() }; - let lines_enabled = reflow_line(text, &options_enabled); + let lines_enabled = reflow_line(text_emphasis, &options_enabled); assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]); - // Verify other markers work too (italic and strikethrough) - let text_italic = "hello *word1 word2*"; - let lines_italic = reflow_line(text_italic, &options_enabled); - assert_eq!(lines_italic, vec!["hello *word1", "word2*"]); + // --- Code Spans --- + let text_code = "hello `word1 word2`"; + + let lines_code_disabled = reflow_line(text_code, &options_disabled); + assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]); + + let lines_code_enabled = reflow_line(text_code, &options_enabled); + assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]); + + // Test multiple backticks with space padding + let text_code_padding = "hello `` `word1` `word2` ``"; + let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled); + assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]); + } + + #[test] + fn test_emphasis_containing_markers_is_not_split() { + let options = ReflowOptions { + line_length: 5, + atomic_spans: false, + ..Default::default() + }; + // Emphasis containing internal markers (e.g. escaped asterisks) should not be split to avoid formatting corruption + let lines = reflow_line(r#"*foo \*bar*"#, &options); + assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]); + } + + #[test] + fn test_definition_list_marker_does_not_start_line() { + let options = ReflowOptions { + line_length: 20, + ..Default::default() + }; + // Wrap should not start a line with ": " + let lines = reflow_line("This is a term and : definition here.", &options); + for line in &lines { + assert!( + !line.trim_start().starts_with(": "), + "Wrapped line should not start with definition marker: {line}" + ); + } + } - let text_strike = "hello ~~word1 word2~~"; - let lines_strike = reflow_line(text_strike, &options_enabled); - assert_eq!(lines_strike, vec!["hello ~~word1", "word2~~"]); + #[test] + fn test_div_marker_does_not_start_line() { + let options = ReflowOptions { + line_length: 20, + ..Default::default() + }; + // Wrap should not start a line with ":::" + let lines = reflow_line("This is some text with ::: class marker.", &options); + for line in &lines { + assert!( + !line.trim_start().starts_with(":::"), + "Wrapped line should not start with div marker: {line}" + ); + } } } diff --git a/tests/formats/sentence_per_line_test.rs b/tests/formats/sentence_per_line_test.rs index 30f523e3..e43eab74 100644 --- a/tests/formats/sentence_per_line_test.rs +++ b/tests/formats/sentence_per_line_test.rs @@ -23,7 +23,7 @@ fn create_sentence_per_line_rule() -> MD013LineLength { abbreviations: vec![], require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }) } @@ -311,7 +311,7 @@ fn test_single_sentence_with_no_line_length_constraint() { abbreviations: vec![], require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }); let content = "This document provides advice for porting Rust code using PyO3 to run under\n\ free-threaded Python."; @@ -379,7 +379,7 @@ fn test_custom_abbreviations_recognized() { abbreviations: vec!["Assn".to_string()], require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }); // With custom "Assn" abbreviation, this should be ONE sentence @@ -415,7 +415,7 @@ fn test_custom_abbreviations_merged_with_builtin() { abbreviations: vec!["Assn".to_string()], require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }); // Both "Dr." (built-in) and "Assn." (custom) should be recognized @@ -451,7 +451,7 @@ fn test_custom_abbreviation_with_period_in_config() { abbreviations: vec!["Univ".to_string()], require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }); let rule_with_period = MD013LineLength::from_config_struct(MD013Config { @@ -472,7 +472,7 @@ fn test_custom_abbreviation_with_period_in_config() { abbreviations: vec!["Univ.".to_string()], require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }); let content = "Visit Univ. Campus for the tour."; @@ -515,7 +515,7 @@ fn test_issue_335_abbreviations_config_empty_vec_uses_defaults() { abbreviations: vec![], // Empty = use built-in defaults require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }); // "Dr." is a built-in abbreviation - should NOT split after it @@ -572,7 +572,7 @@ fn test_issue_335_custom_abbreviations_extend_defaults() { abbreviations: vec!["Corp".to_string(), "Inc".to_string()], require_sentence_capital: true, ignore_link_urls: true, - emphasis_spans: false, + atomic_spans: true, }); // Single sentence with multiple abbreviations - no warning expected diff --git a/tests/utils/text_reflow_test.rs b/tests/utils/text_reflow_test.rs index 1068871c..0a3091b3 100644 --- a/tests/utils/text_reflow_test.rs +++ b/tests/utils/text_reflow_test.rs @@ -20,7 +20,7 @@ fn test_list_item_trailing_whitespace_removal() { require_sentence_capital: true, max_list_continuation_indent: None, defined_references: None, - emphasis_spans: false, + atomic_spans: true, }; let result = reflow_markdown(input, &options); @@ -408,7 +408,7 @@ fn test_sentence_per_line_reflow() { require_sentence_capital: true, max_list_continuation_indent: None, defined_references: None, - emphasis_spans: false, + atomic_spans: true, }; let input = "First sentence. Second sentence. Third sentence."; @@ -742,7 +742,7 @@ fn test_ie_abbreviation_split_debug() { require_sentence_capital: true, max_list_continuation_indent: None, defined_references: None, - emphasis_spans: false, + atomic_spans: true, }; let result = reflow_line(input, &options); @@ -769,7 +769,7 @@ fn test_ie_abbreviation_paragraph() { require_sentence_capital: true, max_list_continuation_indent: None, defined_references: None, - emphasis_spans: false, + atomic_spans: true, }; let result = reflow_markdown(input, &options); @@ -851,7 +851,7 @@ fn test_definition_list_with_paragraphs() { require_sentence_capital: true, max_list_continuation_indent: None, defined_references: None, - emphasis_spans: false, + atomic_spans: true, }; let content = "Regular paragraph. With multiple sentences.\n\nTerm\n: Definition.\n\nAnother paragraph."; @@ -6726,7 +6726,6 @@ proptest::proptest! { } } } - // --------------------------------------------------------------------------- // Issue #740: reflow must not delete the space before French double // punctuation (: ; ! ?) and must preserve non-breaking spaces byte-for-byte. @@ -6868,7 +6867,7 @@ fn test_nbsp_after_code_span_not_replaced() { fn test_nbsp_survives_in_emphasis_span_wrap() { let options = ReflowOptions { line_length: 40, - emphasis_spans: true, + atomic_spans: false, ..Default::default() }; let input = "Il y a **dix\u{00A0}mille raisons de vérifier que les espaces insécables survivent** au reflow."; @@ -6977,3 +6976,36 @@ fn test_sentence_per_line_trailing_nbsp_preserved() { "a trailing NBSP must survive the sentence-mode final push. got: {joined:?}" ); } + +#[test] +fn test_nested_code_span_in_emphasis_wrapping_safety() { + let options = ReflowOptions { + line_length: 12, + atomic_spans: false, + ..Default::default() + }; + // Padded double-backtick code span inside emphasis + let input = "hello *a `` `b` `` c*"; + let result = reflow_line(input, &options); + + // Check that we don't split the double-backticks from the code content + // If it wraps, it should keep the code span intact. + assert!( + result.iter().any(|line| line.contains("`` `b` ``")), + "Double-backtick code span was split: {result:?}" + ); +} + +#[test] +fn test_block_construct_trigger_in_wrapped_emphasis() { + let options = ReflowOptions { + line_length: 12, + atomic_spans: false, + ..Default::default() + }; + let input = "hello *word1 - word2*"; + let result = reflow_line(input, &options); + + // Line 2 should not start with a bullet marker "- " + assert_eq!(result, vec!["hello *word1 -", "word2*"]); +}