rumdl_lib/utils/text_reflow.rs
1//! Text reflow utilities for MD013
2//!
3//! This module implements text wrapping/reflow functionality that preserves
4//! Markdown elements like links, emphasis, code spans, etc.
5
6use crate::utils::calculate_indentation_width_default;
7use crate::utils::mkdocs_attr_list::{ATTR_LIST_PATTERN, is_standalone_attr_list};
8use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
9use crate::utils::regex_cache::{
10 DISPLAY_MATH_REGEX, EMAIL_PATTERN, EMOJI_SHORTCODE_REGEX, HTML_ENTITY_REGEX, HTML_TAG_PATTERN,
11 HUGO_SHORTCODE_REGEX, INLINE_MATH_REGEX, WIKI_LINK_REGEX,
12};
13use crate::utils::sentence_utils::{
14 get_abbreviations, is_cjk_char, is_cjk_sentence_ending, is_closing_bracket, is_closing_quote, is_opening_quote,
15 text_ends_with_abbreviation,
16};
17use pulldown_cmark::{BrokenLink, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd};
18use std::cell::OnceCell;
19use std::collections::HashSet;
20use unicode_width::UnicodeWidthStr;
21
22/// Length calculation mode for reflow
23#[derive(Clone, Copy, Debug, Default, PartialEq)]
24pub enum ReflowLengthMode {
25 /// Count Unicode characters (grapheme clusters)
26 Chars,
27 /// Count visual display width (CJK = 2 columns, emoji = 2, etc.)
28 #[default]
29 Visual,
30 /// Count raw bytes
31 Bytes,
32}
33
34/// Calculate the display length of a string based on the length mode
35pub(crate) fn display_len(s: &str, mode: ReflowLengthMode) -> usize {
36 match mode {
37 ReflowLengthMode::Chars => s.chars().count(),
38 ReflowLengthMode::Visual => s.width(),
39 ReflowLengthMode::Bytes => s.len(),
40 }
41}
42
43/// Whitespace characters whose whole purpose is to forbid a line break:
44/// no-break space (U+00A0), narrow no-break space (U+202F), and figure
45/// space (U+2007).
46fn is_non_breaking_space(c: char) -> bool {
47 matches!(c, '\u{00A0}' | '\u{202F}' | '\u{2007}')
48}
49
50/// Whitespace on which reflow may break and rejoin lines. Non-breaking
51/// spaces are excluded: they stay inside the surrounding token so they
52/// survive reflow byte-for-byte and never become a wrap point (e.g. the
53/// French `mot\u{00A0}:` pair or a `10\u{00A0}000` thousands separator).
54fn is_breakable_whitespace(c: char) -> bool {
55 c.is_whitespace() && !is_non_breaking_space(c)
56}
57
58/// Split text into wrappable tokens on breakable whitespace only.
59fn split_breakable_words(text: &str) -> impl Iterator<Item = &str> {
60 text.split(is_breakable_whitespace).filter(|word| !word.is_empty())
61}
62
63/// Whether an inline code span's content can be word-wrapped without altering it.
64///
65/// Interior whitespace in code spans is literal. Word-splitting collapses a run
66/// of whitespace to a single space and cannot represent tabs, so wrapping would
67/// corrupt content like `a b` (four spaces) into `a b`. Only wrap when every
68/// breakable-whitespace separator is already a single plain space; a lone
69/// leading/trailing space still round-trips (CommonMark normalizes it and the
70/// marker-padding path re-adds it for backtick-adjacent content).
71fn code_span_wraps_losslessly(content: &str) -> bool {
72 let mut prev_ws = false;
73 for c in content.chars() {
74 let ws = is_breakable_whitespace(c);
75 if ws && (prev_ws || c != ' ') {
76 return false;
77 }
78 prev_ws = ws;
79 }
80 true
81}
82
83/// How the inline structure nested inside a span's content constrains where a
84/// line break may land.
85struct NestedStructure {
86 /// Ranges a break may never land inside, merged into outermost,
87 /// non-overlapping ranges. The whitespace in a code span is literal, and
88 /// the whitespace in a link destination or an HTML tag is structural, so
89 /// replacing it with a newline rewrites the document. A link, image or
90 /// attr list is held whole beyond that too, matching how the top level
91 /// treats one.
92 atomic: Vec<(usize, usize)>,
93 /// The delimiter runs of nested emphasis, strong and strikethrough spans.
94 /// These marker characters belong to a well-formed span, so they do not
95 /// force the whole span to be kept whole, but they are not break points
96 /// either: the prose between them breaks at whitespace as usual.
97 markers: Vec<(usize, usize)>,
98 /// The subset of `markers` that closes a span rather than opening one,
99 /// merged so that stacked closers such as the `*_` of `_*text*_` form one
100 /// range. A closer travels with the text in front of it and an opener with
101 /// the text behind it, so telling the two apart is what lets a line break
102 /// land between a closing run and the opening run glued to it.
103 marker_closers: Vec<(usize, usize)>,
104 /// Every link, image, wikilink and footnote reference the parse recognised,
105 /// nested ones included, sorted by start. Where `atomic` folds a construct
106 /// into the one enclosing it, this keeps each one's own start, so a sentence
107 /// opener can be walked into a link whose text begins with an image.
108 links: Vec<(usize, usize)>,
109 /// Every code span the parse recognised, sorted by start. A backtick that
110 /// opens none is ordinary text, so this is what tells the two apart.
111 code_spans: Vec<(usize, usize)>,
112}
113
114/// An emphasis, strong or strikethrough span whose end has not been seen yet.
115struct OpenSpan {
116 /// The span's full range, delimiters included.
117 span: (usize, usize),
118 /// Bounds of the content found inside it so far. What falls outside these
119 /// but inside `span` is the delimiter run.
120 content: Option<(usize, usize)>,
121}
122
123/// Record an event as content of every enclosing span still open, widening the
124/// bounds that separate a span's delimiters from what sits between them.
125fn note_span_content(open: &mut [OpenSpan], start: usize, end: usize) {
126 for open_span in open.iter_mut() {
127 if start >= open_span.span.0 && end <= open_span.span.1 {
128 open_span.content = Some(match open_span.content {
129 Some((known_start, known_end)) => (known_start.min(start), known_end.max(end)),
130 None => (start, end),
131 });
132 }
133 }
134}
135
136fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
137 // A nested construct is reported alongside its parent, so any range
138 // starting at or before the current end is already covered by it.
139 ranges.sort_unstable();
140 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
141 for (start, end) in ranges {
142 match merged.last_mut() {
143 Some(last) if start <= last.1 => last.1 = last.1.max(end),
144 _ => merged.push((start, end)),
145 }
146 }
147 merged
148}
149
150/// Classify the inline constructs nested inside a span's content.
151fn nested_structure(content: &str, defined_references: Option<&HashSet<String>>, attr_lists: bool) -> NestedStructure {
152 let mut options = Options::empty();
153 options.insert(Options::ENABLE_STRIKETHROUGH);
154
155 let mut atomic: Vec<(usize, usize)> = Vec::new();
156 let mut markers: Vec<(usize, usize)> = Vec::new();
157 let mut marker_closers: Vec<(usize, usize)> = Vec::new();
158 let mut links: Vec<(usize, usize)> = Vec::new();
159 let mut code_spans: Vec<(usize, usize)> = Vec::new();
160 // Emphasis-like spans whose end has not been seen yet, each with the bounds
161 // of the content found inside it so far.
162 let mut open: Vec<OpenSpan> = Vec::new();
163
164 for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
165 let (start, end) = (range.start, range.end);
166 // An `End` repeats the range its `Start` already contributed, and the
167 // one closing a span covers that span whole, which would swallow its
168 // own delimiters.
169 if !matches!(event, Event::End(_)) {
170 note_span_content(&mut open, start, end);
171 }
172 match event {
173 Event::Start(Tag::Link { .. } | Tag::Image { .. }) => {
174 atomic.push((start, end));
175 links.push((start, end));
176 }
177 Event::Code(_) => {
178 atomic.push((start, end));
179 code_spans.push((start, end));
180 }
181 Event::InlineHtml(_) => {
182 atomic.push((start, end));
183 }
184 Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough) => {
185 open.push(OpenSpan {
186 span: (start, end),
187 content: None,
188 });
189 }
190 Event::End(TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough) => {
191 if let Some(OpenSpan {
192 span: (span_start, span_end),
193 content,
194 }) = open.pop()
195 {
196 match content {
197 // Whatever sits outside the content is the delimiter run.
198 // Its length is read off the parse rather than assumed,
199 // since `~x~` and `~~x~~` are both strikethrough.
200 Some((content_start, content_end)) => {
201 markers.push((span_start, content_start));
202 markers.push((content_end, span_end));
203 marker_closers.push((content_end, span_end));
204 }
205 // Nothing inside to anchor the delimiters against, so
206 // keep the span whole rather than guess where they end.
207 None => atomic.push((span_start, span_end)),
208 }
209 }
210 }
211 _ => {}
212 }
213 }
214
215 // Reference-style links and images (`[text][ref]`, `[text][]`, `[text]`) and
216 // footnote references need the document's definitions to be recognised, which
217 // the parse above has no access to. Reusing the walk the top level runs
218 // keeps a link atomic under exactly the same conditions wherever it appears.
219 // Nested constructs come along too: the reference image inside
220 // `[![alt][img]](url)` is what that link's sentence opens with.
221 for span in all_link_spans(content, defined_references) {
222 atomic.push((span.start, span.end));
223 links.push((span.start, span.end));
224 }
225
226 // Constructs pulldown does not model, but that `parse_elements` holds
227 // atomic at the top level. Only those that can contain whitespace matter
228 // here: an emoji shortcode or HTML entity has no break point inside it.
229 for found in WIKI_LINK_REGEX.find_iter(content) {
230 atomic.push((found.start(), found.end()));
231 links.push((found.start(), found.end()));
232 }
233 for found in HUGO_SHORTCODE_REGEX.find_iter(content) {
234 atomic.push((found.start(), found.end()));
235 }
236
237 // A `$` inside a code span, a link, an HTML tag or a shortcode neither
238 // opens nor closes a math span: the code span wins in the renderer, and
239 // the top level takes the construct that starts first. Read over a whole
240 // paragraph, a `$` in prose and one in a later code span, or two code
241 // spans each holding a `$`, would otherwise pair up across the prose
242 // between them and hide every sentence end there. The math sweeps run
243 // over a copy with those ranges blanked, character by character so every
244 // byte offset stays the same.
245 let held = merge_ranges(atomic.clone());
246 let mut masked = String::with_capacity(content.len());
247 let mut next_held = 0;
248 for (pos, ch) in content.char_indices() {
249 while held.get(next_held).is_some_and(|&(_, end)| end <= pos) {
250 next_held += 1;
251 }
252 if held.get(next_held).is_some_and(|&(start, _)| start <= pos) {
253 masked.extend(std::iter::repeat_n(' ', ch.len_utf8()));
254 } else {
255 masked.push(ch);
256 }
257 }
258 for found in DISPLAY_MATH_REGEX.find_iter(&masked) {
259 atomic.push((found.start(), found.end()));
260 }
261 let mut from = 0;
262 while let Ok(Some(found)) = INLINE_MATH_REGEX.find_from_pos(&masked, from) {
263 atomic.push((found.start(), found.end()));
264 from = found.end();
265 }
266
267 // A MkDocs/kramdown attr list (`{.class key="value"}`) holds interior
268 // whitespace that is structural, so breaking inside one rewrites it. The
269 // pattern anchors on `{`, so a non-overlapping sweep finds the same units
270 // the top level does. Only when the flavor is enabled: otherwise `{a b}` is
271 // literal prose and breaks like any other words, matching the top level.
272 if attr_lists {
273 for found in ATTR_LIST_PATTERN.find_iter(content) {
274 atomic.push((found.start(), found.end()));
275 }
276 }
277
278 // Both parses report an outermost inline link, so the same range can arrive
279 // twice; a nested one arrives once, from the parse without definitions.
280 links.sort_unstable();
281 links.dedup();
282
283 // Every list is sorted by start: the merged ones by the merge, which also
284 // leaves them non-overlapping and so with their ends in order, the links
285 // by the sort above, and the code spans by the parse, since code spans do
286 // not nest. A window onto a paragraph bisects them on that.
287 NestedStructure {
288 atomic: merge_ranges(atomic),
289 markers: merge_ranges(markers),
290 marker_closers: merge_ranges(marker_closers),
291 links,
292 code_spans,
293 }
294}
295
296/// Split an emphasis, strong or strikethrough span's content into the units
297/// that may be placed on separate lines, or `None` when the span has to stay
298/// atomic.
299///
300/// Breaking such a span at whitespace is safe: emphasis carries across a soft
301/// line break, and a newline is whitespace just like the space it replaces, so
302/// every delimiter keeps its flanking classification. Two things are not safe,
303/// and this rules them out:
304///
305/// - A break inside a code span, link, image or HTML tag. Each is one
306/// unbreakable unit, matching how they are already held atomic at the top
307/// level, because the whitespace in one is literal or structural.
308/// - A marker character that belongs to no well-formed nested span: a stray or
309/// backslash-escaped `` ` ``, `*`, `_` or `~`. The content's structure is then
310/// not fully modelled, so the span is kept whole rather than broken on a guess.
311/// This matters: breaking `**a * b**` at its spaces would put a literal `*` at
312/// the start of a line, turning it into a list item.
313///
314/// A nested emphasis, strong or strikethrough span is not one of those. The
315/// whitespace inside it is ordinary prose whitespace, so it breaks like any
316/// other, and only its delimiter runs are held together with the words they
317/// flank.
318fn breakable_units<'a>(
319 content: &'a str,
320 defined_references: Option<&HashSet<String>>,
321 attr_lists: bool,
322) -> Option<Vec<&'a str>> {
323 // Plain prose cannot hold a nested construct, so every whitespace run is a
324 // break point and the parse below can be skipped.
325 if !content.contains(['`', '*', '_', '~', '[', '<', '$', '{']) {
326 return Some(split_breakable_words(content).collect());
327 }
328
329 let NestedStructure { atomic, markers, .. } = nested_structure(content, defined_references, attr_lists);
330
331 let mut units = Vec::new();
332 let mut unit_start = None;
333 let mut next_atomic = 0;
334 let mut next_marker = 0;
335 for (offset, ch) in content.char_indices() {
336 while atomic.get(next_atomic).is_some_and(|&(_, end)| end <= offset) {
337 next_atomic += 1;
338 }
339 if atomic.get(next_atomic).is_some_and(|&(start, _)| offset >= start) {
340 // Inside an atomic construct: never a break point, and its markers
341 // are accounted for.
342 if unit_start.is_none() {
343 unit_start = Some(offset);
344 }
345 continue;
346 }
347 while markers.get(next_marker).is_some_and(|&(_, end)| end <= offset) {
348 next_marker += 1;
349 }
350 if matches!(ch, '`' | '*' | '_' | '~') && markers.get(next_marker).is_none_or(|&(start, _)| offset < start) {
351 return None;
352 }
353 if is_breakable_whitespace(ch) {
354 if let Some(start) = unit_start.take() {
355 units.push(&content[start..offset]);
356 }
357 } else if unit_start.is_none() {
358 unit_start = Some(offset);
359 }
360 }
361 if let Some(start) = unit_start {
362 units.push(&content[start..]);
363 }
364 Some(units)
365}
366
367/// Split a link or image's text into wrappable units, or `None` when the
368/// construct has to stay whole. Only consulted when
369/// [`ReflowOptions::break_link_text`] is enabled.
370///
371/// `inner` is the bracketed text and `suffix` everything from the closing `]`
372/// on (`](url)`, `][ref]`, `][]`, or a bare `]`). The suffix is never split:
373/// its whitespace is structural (a destination or title), and the checker's
374/// inline-URL exemption only ever applies to an intact link. That bounds what
375/// breaking can achieve, and two checks keep reflow from splitting a link
376/// into lines the checker would then report but reflow could never fix:
377///
378/// - When the suffix alone exceeds the budget but the bracketed text fits,
379/// every split still leaves a line at least as wide as the suffix. Breaking
380/// would trade one forgiven line (a standalone link is exempt, and an intact
381/// inline link earns the URL exemption) for fragments that earn nothing, so
382/// the link stays whole.
383/// - The last line of a split link is the final unit plus the suffix. That
384/// closing line must either fit or overflow only within its final
385/// whitespace-delimited token, the one overflow the checker forgives.
386pub(crate) fn link_text_break_units<'a>(
387 inner: &'a str,
388 suffix: &str,
389 budget: usize,
390 mode: ReflowLengthMode,
391 defined_references: Option<&HashSet<String>>,
392 attr_lists: bool,
393) -> Option<Vec<&'a str>> {
394 if display_len(suffix, mode) > budget && display_len(inner, mode) + 2 <= budget {
395 return None;
396 }
397 let units = breakable_units(inner, defined_references, attr_lists)?;
398 if units.len() < 2 {
399 return None;
400 }
401 let tail = format!("{}{suffix}", units[units.len() - 1]);
402 if display_len(&tail, mode) > budget && !last_token_overflow_only(&tail, budget, mode) {
403 return None;
404 }
405 Some(units)
406}
407
408/// Whether `line` overflows `budget` only within its final
409/// whitespace-delimited token. Mirrors the checker's trailing-token
410/// forgiveness (markdownlint's `line.replace(/\S*$/u, "#")`): the width up to
411/// and including the last whitespace, plus one for the replaced token, is what
412/// the checker measures.
413fn last_token_overflow_only(line: &str, budget: usize, mode: ReflowLengthMode) -> bool {
414 match line.rfind(char::is_whitespace) {
415 None => true,
416 Some(pos) => {
417 let ws_len = line[pos..].chars().next().map_or(1, char::len_utf8);
418 display_len(&line[..pos + ws_len], mode) < budget
419 }
420 }
421}
422
423/// Options for reflowing text
424#[derive(Clone)]
425pub struct ReflowOptions {
426 /// Target line length
427 pub line_length: usize,
428 /// Whether to break on sentence boundaries when possible
429 pub break_on_sentences: bool,
430 /// Whether to preserve existing line breaks in paragraphs
431 pub preserve_breaks: bool,
432 /// Whether to enforce one sentence per line
433 pub sentence_per_line: bool,
434 /// Whether to use semantic line breaks (cascading split strategy)
435 pub semantic_line_breaks: bool,
436 /// Custom abbreviations for sentence detection
437 /// Periods are optional - both "Dr" and "Dr." work the same
438 /// Custom abbreviations are always added to the built-in defaults
439 pub abbreviations: Option<Vec<String>>,
440 /// How to measure string length for line-length comparisons
441 pub length_mode: ReflowLengthMode,
442 /// Whether to treat {#id .class key="value"} as atomic (unsplittable) elements.
443 /// Enabled for MkDocs and Kramdown flavors.
444 pub attr_lists: bool,
445 /// Whether to treat MyST inline roles (`` {role}`content` ``) as atomic
446 /// (unsplittable) elements. Enabled for the MyST flavor so the colon inside
447 /// `{domain:role}` is never used as a clause-break point.
448 pub myst_roles: bool,
449 /// Whether to require uppercase after periods for sentence detection.
450 /// When true (default), only "word. Capital" is a sentence boundary.
451 /// When false, "word. lowercase" is also treated as a sentence boundary.
452 /// Does not affect ! and ? which are always treated as sentence boundaries.
453 pub require_sentence_capital: bool,
454 /// Cap list continuation indent to this value when set.
455 /// Used by mkdocs flavor where continuation is always 4 spaces
456 /// regardless of checkbox markers.
457 pub max_list_continuation_indent: Option<usize>,
458 /// Defined reference labels for the surrounding document, used to decide
459 /// whether a bare shortcut reference (`[text]`) is a real link (kept atomic
460 /// during reflow) or literal bracketed prose (wrapped like normal text).
461 ///
462 /// `None` means no reference information is available: every shortcut is
463 /// treated as atomic. This is the safe default - it never splits a real
464 /// link, at the cost of also not wrapping literal bracketed prose.
465 ///
466 /// `Some(set)` enables definition-aware behavior: a shortcut is atomic only
467 /// when its normalized label (see [`normalize_reference_label`]) is in the
468 /// set. Full and collapsed reference links and reference images are always
469 /// atomic regardless, because their `][ref]` / `[]` syntax is an explicit
470 /// link signal that does not depend on a definition being in scope.
471 pub defined_references: Option<HashSet<String>>,
472 /// Whether to hold emphasis/strong/strikethrough and code spans atomic during reflow.
473 /// When true (default), these spans are treated as atomic units.
474 /// When false, they can be wrapped word-by-word like normal text.
475 pub atomic_spans: bool,
476 /// Whether the text of a link or image may wrap at its whitespace.
477 /// When false (default), every link and image is one atomic token. When
478 /// true, `[text](url)` and its reference, shortcut and image forms follow
479 /// the same rules `atomic_spans` applies to emphasis spans: the text wraps
480 /// when the construct alone can never fit a line (or always, when
481 /// `atomic_spans` is off). The `](...)` tail is never split, and a link
482 /// whose tail rules out a useful break stays whole; see
483 /// `link_text_break_units`.
484 pub break_link_text: bool,
485 /// Which of the checker's line-length exemptions reflow mirrors when it
486 /// measures a line. Empty measures the markdown as written.
487 pub length_exemptions: LengthExemptions,
488}
489
490/// The line-length exemptions MD013's check applies, as far as reflow can mirror
491/// them.
492///
493/// The checker tests each one against the budget separately, so a line is
494/// forgiven when either reduced length fits, never when the two savings together
495/// would fit. Reflow therefore has to keep them apart too.
496#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
497pub struct LengthExemptions {
498 /// An inline `[text](url)` costs `[text]` and an inline `` costs
499 /// `![alt]`. Reference, collapsed and shortcut forms are never exempt.
500 pub link_urls: bool,
501 /// An inline code span costs nothing, because it cannot be wrapped.
502 pub code_spans: bool,
503}
504
505impl LengthExemptions {
506 /// Whether any exemption is active. When none is, every width below reduces
507 /// to the plain source width and the whole mechanism is inert.
508 fn any(&self) -> bool {
509 self.link_urls || self.code_spans
510 }
511}
512
513impl Default for ReflowOptions {
514 fn default() -> Self {
515 Self {
516 line_length: 80,
517 break_on_sentences: true,
518 preserve_breaks: false,
519 sentence_per_line: false,
520 semantic_line_breaks: false,
521 abbreviations: None,
522 length_mode: ReflowLengthMode::default(),
523 attr_lists: false,
524 myst_roles: false,
525 require_sentence_capital: true,
526 max_list_continuation_indent: None,
527 defined_references: None,
528 atomic_spans: true,
529 break_link_text: false,
530 length_exemptions: LengthExemptions::default(),
531 }
532 }
533}
534
535/// A line's width under each exemption the checker applies independently.
536///
537/// The checker forgives a line when the link-exempt length fits *or* the
538/// code-exempt length fits, so the width that decides whether reflow may stop is
539/// the smaller of the two, never one total with both savings taken out. Adding
540/// widths is component-wise, which is what makes it usable as a running total.
541#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
542struct LineWidth {
543 /// Width with inline link and image destinations discounted.
544 link_exempt: usize,
545 /// Width with inline code spans discounted.
546 code_exempt: usize,
547}
548
549impl LineWidth {
550 /// A span of text that no exemption touches, so both components are its
551 /// full width.
552 fn plain(width: usize) -> Self {
553 Self {
554 link_exempt: width,
555 code_exempt: width,
556 }
557 }
558
559 /// The width the checker measures this line at: whichever exemption helps
560 /// more.
561 fn effective(self) -> usize {
562 self.link_exempt.min(self.code_exempt)
563 }
564
565 fn fits(self, line_length: usize) -> bool {
566 self.effective() <= line_length
567 }
568
569 /// Whether nothing has been accumulated. Only an empty string measures zero
570 /// under both exemptions: the cheapest an exempt link can be is `[]`, and a
571 /// code span still costs its full width against the link exemption.
572 fn is_empty(self) -> bool {
573 self.link_exempt == 0 && self.code_exempt == 0
574 }
575}
576
577impl std::ops::Add for LineWidth {
578 type Output = Self;
579
580 fn add(self, other: Self) -> Self {
581 Self {
582 link_exempt: self.link_exempt + other.link_exempt,
583 code_exempt: self.code_exempt + other.code_exempt,
584 }
585 }
586}
587
588impl std::ops::AddAssign for LineWidth {
589 fn add_assign(&mut self, other: Self) {
590 *self = *self + other;
591 }
592}
593
594/// Normalize a reference label for definition matching: collapse internal
595/// whitespace runs to a single space, trim, and lowercase (CommonMark-style
596/// label matching). Both the defined labels and the shortcut references checked
597/// against them are run through this function, so matching is case- and
598/// whitespace-insensitive. Biasing toward matching keeps a real shortcut link
599/// atomic even when its use and definition differ only in case or whitespace.
600pub fn normalize_reference_label(label: &str) -> String {
601 label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
602}
603
604/// If `chars` starts at `start` with a footnote reference (`[^label]`,
605/// matching the same `[a-zA-Z0-9_-]+` label grammar as `FOOTNOTE_REF` in
606/// `mkdocs_footnotes.rs`), return the position just past it. Returns `None`
607/// if `start` is not the beginning of a footnote reference, so a bare `[1]` or
608/// `[text]` never matches.
609fn footnote_ref_end(chars: &[char], start: usize) -> Option<usize> {
610 if chars.get(start) != Some(&'[') || chars.get(start + 1) != Some(&'^') {
611 return None;
612 }
613 let label_start = start + 2;
614 let mut label_end = label_start;
615 while matches!(chars.get(label_end), Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '-') {
616 label_end += 1;
617 }
618 (label_end > label_start && chars.get(label_end) == Some(&']')).then_some(label_end + 1)
619}
620
621/// If `chars` starts at `start` with one or more consecutive footnote
622/// references, return the position just past the last one; `None` when no
623/// footnote reference starts there.
624fn footnote_refs_end(chars: &[char], start: usize) -> Option<usize> {
625 let mut end = footnote_ref_end(chars, start)?;
626 while let Some(after_ref) = footnote_ref_end(chars, end) {
627 end = after_ref;
628 }
629 Some(end)
630}
631
632/// Byte offset of each char in the text `chars` was collected from, with the
633/// text's byte length as a final entry.
634fn char_byte_offsets(chars: &[char]) -> Vec<usize> {
635 let mut offsets = Vec::with_capacity(chars.len() + 1);
636 let mut offset = 0;
637 for c in chars {
638 offsets.push(offset);
639 offset += c.len_utf8();
640 }
641 offsets.push(offset);
642 offsets
643}
644
645/// A text being split into sentences, with the views the boundary check reads
646/// alongside it: its chars, each char's byte offset (plus the text's length as
647/// a final entry), and the `links` list of its [`NestedStructure`], the
648/// link-like constructs a sentence may open with, nested ones included.
649struct SentenceText<'a> {
650 text: &'a str,
651 chars: &'a [char],
652 char_offsets: &'a [usize],
653 links: &'a [(usize, usize)],
654 code_spans: &'a [(usize, usize)],
655 markers: &'a [(usize, usize)],
656 marker_closers: &'a [(usize, usize)],
657 /// The paragraph `text` is a part of, with the byte offset at which it
658 /// begins there. A cut is judged against the whole paragraph when the caller
659 /// knows it, since a delimiter run pairs with one that can sit outside the
660 /// part being split.
661 paragraph: Option<ParagraphStructure<'a>>,
662 /// The spans `text` pairs as written, for a text that is its own
663 /// paragraph. A part of a paragraph reads them off `paragraph` instead.
664 emphasis: EmphasisSpans,
665}
666
667impl SentenceText<'_> {
668 /// Whether `chars[pos]` belongs to the delimiter run of a span the parse
669 /// matched. The run may be the one closing that span or the one opening it;
670 /// either way the characters are markup rather than the literal asterisks,
671 /// underscores or tildes an unmatched run renders as.
672 fn in_span_delimiter(&self, pos: usize) -> bool {
673 let Some(&offset) = self.char_offsets.get(pos) else {
674 return false;
675 };
676 match self.markers.binary_search_by_key(&offset, |&(start, _)| start) {
677 Ok(_) => true,
678 Err(0) => false,
679 Err(i) => offset < self.markers[i - 1].1,
680 }
681 }
682
683 /// Char index just past the closing delimiter runs that cover `chars[pos]`,
684 /// or `None` when the parse reads no closing run there.
685 ///
686 /// Stacked closers are one range, so `_*已经完成。*_` reports the end of
687 /// `*_` from the first of its two characters.
688 fn span_closer_end(&self, pos: usize) -> Option<usize> {
689 let &offset = self.char_offsets.get(pos)?;
690 let covering = match self.marker_closers.binary_search_by_key(&offset, |&(start, _)| start) {
691 Ok(i) => i,
692 Err(0) => return None,
693 Err(i) if offset < self.marker_closers[i - 1].1 => i - 1,
694 Err(_) => return None,
695 };
696 let end = self.marker_closers[covering].1;
697 Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
698 }
699
700 /// Whether a code span the parse recognised opens at `chars[pos]`.
701 ///
702 /// An unmatched backtick opens nothing, and the sentence it sits in carries
703 /// on through it, so the character alone cannot stand for a code span.
704 fn opens_code_span(&self, pos: usize) -> bool {
705 self.char_offsets
706 .get(pos)
707 .is_some_and(|&start| self.code_spans.binary_search_by_key(&start, |&(s, _)| s).is_ok())
708 }
709
710 /// Char index just past the link, image, wikilink or footnote reference
711 /// that starts at `chars[pos]`, or `None` when no such construct starts
712 /// there. The construct is one the parse behind `links` recognised, so a
713 /// bracket the parse reads as text (`[Smith 2020]` with no such reference
714 /// defined, `[text](unterminated`) opens nothing here either, and one
715 /// nested in another (`[](url)`) is found at its own start. An
716 /// Obsidian embed `![[note]]` is known to the parse from its `[[`, so the
717 /// range starts one char after its `!`.
718 fn link_end_at(&self, pos: usize) -> Option<usize> {
719 let range_start = match self.chars.get(pos) {
720 Some('[') => pos,
721 Some('!') if self.chars.get(pos + 1) == Some(&'[') => match self.link_range_end_at(pos) {
722 Some(end) => return Some(end),
723 None => pos + 1,
724 },
725 _ => return None,
726 };
727 self.link_range_end_at(range_start)
728 }
729
730 /// Char index just past the link-like construct that starts at `chars[pos]`.
731 fn link_range_end_at(&self, pos: usize) -> Option<usize> {
732 let start = self.char_offsets[pos];
733 let idx = self.links.binary_search_by_key(&start, |&(s, _)| s).ok()?;
734 let end = self.links[idx].1;
735 Some(self.char_offsets.binary_search(&end).unwrap_or_else(|i| i))
736 }
737
738 /// The byte offset of `chars[pos]`, or the text's length past the last char.
739 fn byte_at(&self, pos: usize) -> usize {
740 self.char_offsets.get(pos).copied().unwrap_or(self.text.len())
741 }
742
743 /// Whether a line break written in place of `chars[cut..resume]` can be
744 /// taken back.
745 ///
746 /// A line that reads as a block construct is folded into the line above with
747 /// a space between the two. Where the break replaced whitespace that gives
748 /// the source back; where it was written into text that ran on without any,
749 /// as one CJK sentence does into the next, the space is text the author
750 /// never wrote, so the cut is refused.
751 fn cut_keeps_text(&self, cut: usize, resume: usize) -> bool {
752 if cut < resume {
753 return true;
754 }
755 let resume_byte = self.byte_at(resume);
756 let rest = match self.paragraph {
757 Some(paragraph) => ¶graph.text[paragraph.base + resume_byte..],
758 None => &self.text[resume_byte..],
759 };
760 !starts_block_construct(rest)
761 }
762
763 /// Whether the shape of the text around a cut settles that a line break
764 /// written in place of `chars[cut..resume]` leaves the text meaning what
765 /// it means now.
766 ///
767 /// What a delimiter run can do is decided by the characters on either side
768 /// of it, and the break rewrites one of them. A run between a closing
769 /// bracket and a letter can open a span and close one, and the rule of three
770 /// is then what keeps a shorter run inside it from pairing; at the head of a
771 /// line the same run can only open, the rule of three no longer applies, and
772 /// the text renders as different emphasis. The question arises only where a
773 /// delimiter character touches the cut, which is where the answer can be no.
774 ///
775 /// Two shapes of cut are known to leave every run what it is. A break that
776 /// replaces whitespace gives the run the neighbour it had, since a space and
777 /// a line break are both whitespace to the flanking rules; the first
778 /// replaced character decides this, and a character the rules may read as
779 /// something else is not settled here. A break written between two
780 /// characters gives the run it touches a whitespace neighbour in place of a
781 /// character, and that changes the run's flanking only when the replaced
782 /// neighbour is not punctuation or the character on the run's other side is
783 /// whitespace or punctuation. So a run after the cut keeps its flanking
784 /// when the character before the cut is a terminator, a closer or another
785 /// ASCII punctuation character and the character after the run is a letter
786 /// or a digit. A run before the cut is read the same way mirrored, and with
787 /// a run on each side both must hold.
788 ///
789 /// A cut of any other shape is one the parse of the text carrying the
790 /// break has to confirm, which [`Self::confirm_cuts`] does for every such
791 /// cut of a text at once.
792 fn cut_keeps_emphasis_by_shape(&self, cut: usize, resume: usize) -> bool {
793 let is_delimiter = |c: Option<&char>| matches!(c, Some('*' | '_' | '~'));
794 let run_before = is_delimiter(cut.checked_sub(1).and_then(|i| self.chars.get(i)));
795 let run_after = is_delimiter(self.chars.get(resume));
796 if !run_before && !run_after {
797 return true;
798 }
799 if cut < resume {
800 return matches!(self.chars.get(cut), Some(' ' | '\t' | '\u{00A0}' | '\u{3000}'));
801 }
802 let is_punctuation = |c: Option<&char>| {
803 c.is_some_and(|&c| {
804 is_cjk_sentence_ending(c) || is_closing_bracket(c) || is_closing_quote(c) || c.is_ascii_punctuation()
805 })
806 };
807 let is_alphanumeric = |c: Option<&char>| c.is_some_and(|c| c.is_alphanumeric());
808 let after_keeps = !run_after
809 || (is_punctuation(cut.checked_sub(1).and_then(|i| self.chars.get(i)))
810 && is_alphanumeric(self.chars.get(delimiter_run_extent(self.chars, cut))));
811 let before_keeps = !run_before
812 || (is_punctuation(self.chars.get(cut))
813 && is_alphanumeric(
814 delimiter_run_start(self.chars, cut)
815 .checked_sub(1)
816 .and_then(|i| self.chars.get(i)),
817 ));
818 after_keeps && before_keeps
819 }
820
821 /// Drop from `cuts` every cut whose line break the parse refuses, `cuts`
822 /// being every cut the boundary check approved in the text, in order.
823 ///
824 /// The cuts the shape of the text could not settle are confirmed together:
825 /// the text carrying every cut is parsed once, and when its spans are the
826 /// spans of the source they all stand. When they are not, the unsettled
827 /// cuts are halved and each half is parsed on its own, together with the
828 /// settled cuts again, until the cuts that change a span are found; a half
829 /// that passes stands whole. Every parse carries the settled cuts, so the
830 /// text judged is the text the caller writes. A cut is judged against the
831 /// whole paragraph where the caller has it, since a run pairs with one that
832 /// can sit outside the part being split.
833 fn confirm_cuts(&self, cuts: &mut Vec<Cut>) {
834 if !cuts.iter().any(|cut| cut.unconfirmed) {
835 return;
836 }
837 let (text, spans, base) = match self.paragraph {
838 Some(paragraph) => (paragraph.text, paragraph.emphasis.of(paragraph.text), paragraph.base),
839 None => (self.text, self.emphasis.of(self.text), 0),
840 };
841 let as_break = |cut: &Cut| (base + self.byte_at(cut.at), base + self.byte_at(cut.resume));
842 let settled: Vec<_> = cuts.iter().filter(|cut| !cut.unconfirmed).map(as_break).collect();
843 let (unconfirmed_at, unconfirmed): (Vec<usize>, Vec<_>) = cuts
844 .iter()
845 .enumerate()
846 .filter(|(_, cut)| cut.unconfirmed)
847 .map(|(idx, cut)| (idx, as_break(cut)))
848 .unzip();
849 let mut keep = vec![true; cuts.len()];
850 for (idx, refused) in unconfirmed_at
851 .into_iter()
852 .zip(refused_breaks(text, spans, &settled, &unconfirmed))
853 {
854 keep[idx] = !refused;
855 }
856 let mut idx = 0;
857 cuts.retain(|_| {
858 idx += 1;
859 keep[idx - 1]
860 });
861 }
862}
863
864/// One place the sentence splitter cuts a text: the char index the line break
865/// is written at, where the text resumes after it, and whether the parse still
866/// has to confirm that the break leaves every span what it is.
867#[derive(Clone, Copy, Debug)]
868struct Cut {
869 at: usize,
870 resume: usize,
871 unconfirmed: bool,
872}
873
874/// One of the three spans a delimiter run can pair into.
875#[derive(Clone, Copy, PartialEq, Eq, Debug)]
876enum SpanKind {
877 Emphasis,
878 Strong,
879 Strikethrough,
880}
881
882/// The emphasis, strong and strikethrough spans a parse of `text` pairs, each as
883/// the byte range it covers.
884fn emphasis_spans(text: &str) -> Vec<(usize, usize, SpanKind)> {
885 let mut options = Options::empty();
886 options.insert(Options::ENABLE_STRIKETHROUGH);
887 Parser::new_ext(text, options)
888 .into_offset_iter()
889 .filter_map(|(event, range)| {
890 let kind = match event {
891 Event::Start(Tag::Emphasis) => SpanKind::Emphasis,
892 Event::Start(Tag::Strong) => SpanKind::Strong,
893 Event::Start(Tag::Strikethrough) => SpanKind::Strikethrough,
894 _ => return None,
895 };
896 Some((range.start, range.end, kind))
897 })
898 .collect()
899}
900
901/// The spans one text pairs as written, parsed the first time a cut in that
902/// text asks for them and kept for every cut after it.
903///
904/// A text is cut many times, and what it pairs as written is the same at every
905/// cut, so one parse serves them all. The parse waits for the first cut that
906/// needs it: most texts are split without a cut touching a delimiter run, and
907/// those never pay for it.
908#[derive(Default)]
909struct EmphasisSpans(OnceCell<Vec<(usize, usize, SpanKind)>>);
910
911impl EmphasisSpans {
912 /// The spans of `text`, parsed on the first call.
913 fn of(&self, text: &str) -> &[(usize, usize, SpanKind)] {
914 self.0.get_or_init(|| emphasis_spans(text))
915 }
916}
917
918/// Which of the `unconfirmed` breaks the parse refuses, one flag per break in
919/// the order given, each judged with the `settled` breaks written in as well.
920///
921/// One parse of the text carrying every break answers for all of them when it
922/// passes. When it fails, the unconfirmed breaks are halved and each half is
923/// parsed with the settled ones, until the breaks that change a span are
924/// found: a half that passes stands whole, and a half of one break that fails
925/// is a break refused. The breaks are byte ranges into `text`, in order and
926/// not overlapping, as the splitter finds them.
927fn refused_breaks(
928 text: &str,
929 spans: &[(usize, usize, SpanKind)],
930 settled: &[(usize, usize)],
931 unconfirmed: &[(usize, usize)],
932) -> Vec<bool> {
933 let mut refused = vec![false; unconfirmed.len()];
934 // Each entry is an index range into `unconfirmed`, start and past the end.
935 let mut halves = vec![(0, unconfirmed.len())];
936 while let Some((start, end)) = halves.pop() {
937 if start == end {
938 continue;
939 }
940 let mut breaks: Vec<(usize, usize)> = settled.iter().chain(&unconfirmed[start..end]).copied().collect();
941 breaks.sort_unstable();
942 if emphasis_survives_breaks(text, spans, &breaks) {
943 continue;
944 }
945 if end - start == 1 {
946 refused[start] = true;
947 continue;
948 }
949 let mid = start + (end - start) / 2;
950 halves.push((mid, end));
951 halves.push((start, mid));
952 }
953 refused
954}
955
956/// Whether writing a line break in place of each `text[cut..resume]` in
957/// `breaks` leaves every emphasis, strong and strikethrough span covering the
958/// text it covers now, `spans` being what `text` pairs as written and `breaks`
959/// sorted and not overlapping.
960///
961/// Each break is one byte where the whitespace it replaces was `resume - cut`,
962/// so the spans found in the broken text are read back onto the original
963/// coordinates before the two lists are compared: an offset moves by the
964/// whitespace every break in front of it took out, less the byte each wrote.
965fn emphasis_survives_breaks(text: &str, spans: &[(usize, usize, SpanKind)], breaks: &[(usize, usize)]) -> bool {
966 let mut broken = String::with_capacity(text.len() + breaks.len());
967 // Where each line break sits in `broken`, with the whitespace the breaks
968 // up to it replaced and the line breaks written for them.
969 let mut shifts: Vec<(usize, usize, usize)> = Vec::with_capacity(breaks.len());
970 let (mut copied, mut replaced, mut written) = (0, 0, 0);
971 for &(cut, resume) in breaks {
972 if cut < copied
973 || cut > resume
974 || resume > text.len()
975 || !text.is_char_boundary(cut)
976 || !text.is_char_boundary(resume)
977 {
978 return true;
979 }
980 broken.push_str(&text[copied..cut]);
981 replaced += resume - cut;
982 written += 1;
983 shifts.push((broken.len(), replaced, written));
984 broken.push('\n');
985 copied = resume;
986 }
987 broken.push_str(&text[copied..]);
988
989 let restore = |offset: usize| match shifts.partition_point(|&(at, _, _)| at < offset).checked_sub(1) {
990 Some(i) => {
991 let (_, replaced, written) = shifts[i];
992 offset + replaced - written
993 }
994 None => offset,
995 };
996 let broken_spans: Vec<_> = emphasis_spans(&broken)
997 .into_iter()
998 .map(|(start, end, kind)| (restore(start), restore(end), kind))
999 .collect();
1000 broken_spans.as_slice() == spans
1001}
1002
1003/// The inline structure of a whole paragraph, as one parse of that paragraph
1004/// read it, and the byte offset at which the text being split begins in it.
1005///
1006/// What a run of characters is inside a paragraph is a property of the
1007/// paragraph, not of any part of it. A delimiter run closes a span whose opener
1008/// can sit arbitrarily far in front of it, and a parse of a part alone reads
1009/// such a closer as an opener. Three backticks at the head of a part are a
1010/// fenced code block to a parse of that part alone, and the link after them is
1011/// gone from that parse, where the paragraph reads them as three characters of
1012/// text in front of a link. So a caller splitting a paragraph piece by piece
1013/// carries this instead of parsing the piece, and the structure stays the one
1014/// the paragraph has.
1015#[derive(Clone, Copy)]
1016struct ParagraphStructure<'a> {
1017 text: &'a str,
1018 structure: &'a NestedStructure,
1019 /// The spans the paragraph pairs as written, shared by every part of it.
1020 emphasis: &'a EmphasisSpans,
1021 base: usize,
1022}
1023
1024impl ParagraphStructure<'_> {
1025 /// The paragraph's structure moved onto the window `[base, base + len)`.
1026 ///
1027 /// A range straddling an edge keeps the part that is inside, which is the
1028 /// part whose characters the split reads. The links and code spans are
1029 /// found by where they start, so one starting outside the window is left
1030 /// out: no character inside the window is where it starts.
1031 ///
1032 /// A paragraph is windowed once per part it is split into, so each list is
1033 /// bisected rather than walked: the lists are sorted by start, and the
1034 /// merged ones do not overlap, which puts their ends in order too. The
1035 /// ranges touching the window are then one stretch of each list.
1036 fn window(&self, len: usize) -> NestedStructure {
1037 let base = self.base;
1038 let window_end = base + len;
1039 let inside = |ranges: &[(usize, usize)]| -> Vec<(usize, usize)> {
1040 let first = ranges.partition_point(|&(_, end)| end <= base);
1041 let past = first + ranges[first..].partition_point(|&(start, _)| start < window_end);
1042 ranges[first..past]
1043 .iter()
1044 .map(|&(start, end)| (start.saturating_sub(base), (end - base).min(len)))
1045 .collect()
1046 };
1047 let starting_inside = |ranges: &[(usize, usize)]| -> Vec<(usize, usize)> {
1048 let first = ranges.partition_point(|&(start, _)| start < base);
1049 let past = first + ranges[first..].partition_point(|&(start, _)| start < window_end);
1050 ranges[first..past]
1051 .iter()
1052 .map(|&(start, end)| (start - base, (end - base).min(len)))
1053 .collect()
1054 };
1055 NestedStructure {
1056 atomic: inside(&self.structure.atomic),
1057 markers: inside(&self.structure.markers),
1058 marker_closers: inside(&self.structure.marker_closers),
1059 links: starting_inside(&self.structure.links),
1060 code_spans: starting_inside(&self.structure.code_spans),
1061 }
1062 }
1063}
1064
1065/// Char index just past the stretch of emphasis and strikethrough markers
1066/// starting at `from`, which is `from` itself when no marker sits there.
1067///
1068/// The stretch mixes the three marker characters, because the closers of nested
1069/// spans are written together: `_**` closes `**_bold ital._**` in one stretch of
1070/// three characters that CommonMark reads as two delimiter runs.
1071fn marker_run_extent(chars: &[char], from: usize) -> usize {
1072 let mut end = from;
1073 while end < chars.len() && matches!(chars[end], '*' | '_' | '~') {
1074 end += 1;
1075 }
1076 end
1077}
1078
1079/// Char index of the first character of the CommonMark delimiter run ending in
1080/// front of `to`, which is `to` itself when no marker sits there.
1081///
1082/// The mirror of [`delimiter_run_extent`]: one delimiter character repeated, so
1083/// from the end of `_**` this reports where the `**` begins.
1084fn delimiter_run_start(chars: &[char], to: usize) -> usize {
1085 let Some(&last) = to.checked_sub(1).and_then(|i| chars.get(i)) else {
1086 return to;
1087 };
1088 if !matches!(last, '*' | '_' | '~') {
1089 return to;
1090 }
1091 let mut start = to;
1092 while start > 0 && chars[start - 1] == last {
1093 start -= 1;
1094 }
1095 start
1096}
1097
1098/// Char index just past the CommonMark delimiter run starting at `from`, which
1099/// is `from` itself when no marker sits there.
1100///
1101/// A delimiter run is one delimiter character repeated, so `**_` is two runs and
1102/// this reports the end of the first.
1103fn delimiter_run_extent(chars: &[char], from: usize) -> usize {
1104 let Some(&first) = chars.get(from) else {
1105 return from;
1106 };
1107 if !matches!(first, '*' | '_' | '~') {
1108 return from;
1109 }
1110 let mut end = from;
1111 while chars.get(end) == Some(&first) {
1112 end += 1;
1113 }
1114 end
1115}
1116
1117/// Char index just past everything glued to the CJK sentence ender at
1118/// `chars[pos]` that belongs to the sentence ending there, or `None` when a
1119/// marker run sits there that the reflow must not move.
1120///
1121/// This is the one reading of where such a sentence ends: the boundary check
1122/// validates the cut it returns and the range consumer takes that same cut, so
1123/// the cut a pairing check approved is the cut the line breaks at. Four kinds
1124/// of thing follow such an ender, and this walks them in whatever order they
1125/// appear:
1126///
1127/// - A footnote reference. `完成。[^1]` is annotated by its footnote, which
1128/// stays with the sentence it annotates, and whatever closes the sentence
1129/// after it is read the same way as when it stands right after the ender.
1130/// The same bracket can open a link, `[^1](url)`, whose text happens to read
1131/// like a label; the parse behind `links` knows the link, and a link opens
1132/// the next sentence whole, so the sentence ends in front of it.
1133/// - A bracket or a quote closing what encloses the sentence. `(已经完成。)`
1134/// ends after its bracket rather than in front of it.
1135/// - A delimiter run the parse reads as closing a span. The run is markup that
1136/// belongs to the text in front of it, so it travels with the sentence, and a
1137/// stretch of stacked closers travels whole.
1138/// - A delimiter run the parse reads as opening a span. It belongs to the
1139/// sentence that follows, so the sentence ends in front of it. This is what
1140/// separates the `**已经完成。**` closing a span from the `_继续_` opening the
1141/// next.
1142///
1143/// A run the parse matched to nothing renders as literal marker characters. It
1144/// comes along when whitespace or the end of the text follows it, since nothing
1145/// then reads it as markup. Otherwise there is no boundary here at all: the run
1146/// is markup the parse cannot place, and moving it to either side of a line
1147/// break can change what the text renders as, so the paragraph stays as written.
1148///
1149/// One delimiter run can hold both a closer and an opener: `***` is three
1150/// asterisks the parse divides between the span ending there and the one
1151/// starting. Whether a run matches at all depends on its whole length, so a
1152/// break inside it changes the lengths CommonMark reads and can leave both
1153/// halves matching nothing. There is no boundary inside a run either.
1154fn cjk_sentence_end(st: &SentenceText<'_>, pos: usize) -> Option<usize> {
1155 let mut end = pos + 1;
1156 loop {
1157 if let Some(after_ref) = footnote_ref_end(st.chars, end)
1158 && st.link_range_end_at(end).is_none_or(|link_end| link_end == after_ref)
1159 {
1160 end = after_ref;
1161 continue;
1162 }
1163 let Some(&next) = st.chars.get(end) else {
1164 return Some(end);
1165 };
1166 if is_closing_bracket(next) || is_closing_quote(next) {
1167 end += 1;
1168 continue;
1169 }
1170 if !matches!(next, '*' | '_' | '~') {
1171 return Some(end);
1172 }
1173 if let Some(closer_end) = st.span_closer_end(end) {
1174 if st
1175 .chars
1176 .get(closer_end)
1177 .is_some_and(|after| Some(after) == st.chars.get(closer_end - 1))
1178 {
1179 return None;
1180 }
1181 end = closer_end;
1182 continue;
1183 }
1184 if st.in_span_delimiter(end) {
1185 return Some(end);
1186 }
1187 let run_end = delimiter_run_extent(st.chars, end);
1188 match st.chars.get(run_end) {
1189 None => return Some(run_end),
1190 Some(after) if after.is_whitespace() => return Some(run_end),
1191 Some(_) => return None,
1192 }
1193 }
1194}
1195
1196/// The cut at which the sentence ending at `chars[pos]` ends, or `None` when
1197/// no sentence ends there.
1198///
1199/// The break replaces the whitespace from the cut to the next sentence, and is
1200/// written in where a CJK sentence runs into the next one without any. The cut
1201/// returned is the one every check here approved, and the caller cutting the
1202/// text takes it as it is: a second reading of the closers glued to the ender
1203/// could land the break where no check looked. One check is left to the
1204/// caller: whether the break leaves every emphasis span what it is, where the
1205/// shape of the text cannot settle that, is confirmed by a parse once every
1206/// cut of the text is known, and the cut says whether it needs one.
1207///
1208/// Based on the approach from github.com/JoshuaKGoldberg/sentences-per-line.
1209/// Supports both ASCII punctuation (. ! ?) and CJK punctuation (。 ! ?).
1210fn sentence_boundary(
1211 st: &SentenceText<'_>,
1212 pos: usize,
1213 abbreviations: &HashSet<String>,
1214 require_sentence_capital: bool,
1215) -> Option<Cut> {
1216 let SentenceText { text, chars, .. } = *st;
1217 if pos + 1 >= chars.len() {
1218 return None;
1219 }
1220 let byte_offset_after_punct = st.char_offsets[pos + 1];
1221
1222 let c = chars[pos];
1223 let next_char = chars[pos + 1];
1224
1225 // Check for CJK sentence-ending punctuation (。, !, ?)
1226 // CJK punctuation doesn't require space or uppercase after it
1227 if is_cjk_sentence_ending(c) {
1228 // Skip everything glued to the ender: footnote references, the
1229 // brackets and quotes closing what encloses the sentence, and the
1230 // delimiter runs closing the spans it ends inside. A run the parse
1231 // cannot place stops the boundary here.
1232 let cut = cjk_sentence_end(st, pos)?;
1233
1234 // Skip whitespace
1235 let mut after_punct_pos = cut;
1236 while after_punct_pos < chars.len() && chars[after_punct_pos].is_whitespace() {
1237 after_punct_pos += 1;
1238 }
1239 let resume = after_punct_pos;
1240
1241 // Check if we have more content (any non-whitespace). What is left of a
1242 // sentence once its own closers are taken off it is not a sentence.
1243 if after_punct_pos >= chars.len() {
1244 return None;
1245 }
1246
1247 // Same rule as after ASCII punctuation below: no sentence opens with
1248 // an ordered-list marker.
1249 if opens_ordered_list_marker(&chars[after_punct_pos..]) {
1250 return None;
1251 }
1252
1253 // Skip leading emphasis/strikethrough markers
1254 while after_punct_pos < chars.len()
1255 && (chars[after_punct_pos] == '*' || chars[after_punct_pos] == '_' || chars[after_punct_pos] == '~')
1256 {
1257 after_punct_pos += 1;
1258 }
1259
1260 if after_punct_pos >= chars.len() {
1261 return None;
1262 }
1263
1264 // For CJK, we accept any character as the start of the next sentence
1265 // (no uppercase requirement, since CJK doesn't have case)
1266 return st.cut_keeps_text(cut, resume).then(|| Cut {
1267 at: cut,
1268 resume,
1269 unconfirmed: !st.cut_keeps_emphasis_by_shape(cut, resume),
1270 });
1271 }
1272
1273 // Check for ASCII sentence-ending punctuation
1274 if c != '.' && c != '!' && c != '?' {
1275 return None;
1276 }
1277
1278 // A terminator immediately followed by a closing quote sits inside the
1279 // quotation, not after it.
1280 let inside_quotation = is_closing_quote(next_char);
1281
1282 // Must be followed by space, closing quote, or a run of emphasis/strikethrough
1283 // markers followed by space
1284 let (space_pos, after_space_pos) = if next_char == ' ' {
1285 // Normal case: punctuation followed by space
1286 (pos + 1, pos + 2)
1287 } else if is_closing_quote(next_char) && pos + 2 < chars.len() {
1288 // Sentence ends with a quote, optionally closing spans around it:
1289 // 'sentence." ', 'sentence."* ', 'sentence."** '
1290 match marker_run_end(chars, pos + 2) {
1291 Some(end) => (end, end + 1),
1292 None => return None,
1293 }
1294 } else if matches!(next_char, '*' | '_' | '~') {
1295 // Sentence ends inside one or more spans, whose closers form a run of
1296 // any length and any mix: "sentence.* ", "sentence.** ", "sentence.~ ",
1297 // "sentence.*** ", "sentence._** ".
1298 match marker_run_end(chars, pos + 1) {
1299 Some(end) => (end, end + 1),
1300 None => return None,
1301 }
1302 } else if next_char == '[' {
1303 // Sentence ends with one or more footnote references glued directly to
1304 // the punctuation, e.g. "sentence.[^1]" or "sentence.[^1][^2]". A bare
1305 // `[1]` or `[text]` doesn't match `footnote_refs_end` and falls through
1306 // to `return None` below, since that's link/citation-like text, not
1307 // footnote syntax.
1308 match footnote_refs_end(chars, pos + 1) {
1309 Some(end_pos) if chars.get(end_pos) == Some(&' ') => (end_pos, end_pos + 1),
1310 _ => return None,
1311 }
1312 } else {
1313 return None;
1314 };
1315
1316 // Skip all whitespace after the space to find the start of the next sentence
1317 let mut next_char_pos = after_space_pos;
1318 while next_char_pos < chars.len() && chars[next_char_pos].is_whitespace() {
1319 next_char_pos += 1;
1320 }
1321
1322 // Check if we reached the end of the string
1323 if next_char_pos >= chars.len() {
1324 return None;
1325 }
1326
1327 // The line break replaces the whitespace between the two sentences. Whether
1328 // it leaves every span what it is is read off the shape of the text here,
1329 // and a cut the shape cannot settle is marked for the parse.
1330 let checked_cut = || {
1331 Some(Cut {
1332 at: space_pos,
1333 resume: next_char_pos,
1334 unconfirmed: !st.cut_keeps_emphasis_by_shape(space_pos, next_char_pos),
1335 })
1336 };
1337
1338 // A sentence is not allowed to open with an ordered-list marker. Every
1339 // line this splitter produces ends a sentence, and text shaped `2. Do that`
1340 // right after such a line is a list item: to CommonMark when the number is
1341 // 1, and to MD032 (which reports a list item missing its blank line, in
1342 // any document that has a list) for any number. So `Do this. 2. Do that.`
1343 // keeps its enumerator on the line of the sentence before it, and the
1344 // enumerated text opens the next line. The CJK path above applies the
1345 // same rule.
1346 if opens_ordered_list_marker(&chars[next_char_pos..]) {
1347 return None;
1348 }
1349
1350 // Skip leading emphasis/strikethrough markers, opening quotes and the
1351 // opener of a link, image or wikilink to find the actual first letter: a
1352 // sentence that starts with `[Link text](url)` starts with its text. A
1353 // bracket the parse reads as text is not skipped, so a citation like
1354 // `[Smith 2020]` or a footnote label opens no sentence.
1355 let mut first_letter_pos = next_char_pos;
1356 while first_letter_pos < chars.len() {
1357 let ch = chars[first_letter_pos];
1358 if let Some(end) = st.link_end_at(first_letter_pos) {
1359 first_letter_pos += link_opener_len(chars, first_letter_pos, end);
1360 } else if matches!(ch, '*' | '_' | '~') || is_opening_quote(ch) {
1361 first_letter_pos += 1;
1362 } else {
1363 break;
1364 }
1365 }
1366
1367 // Check if we reached the end after skipping emphasis
1368 if first_letter_pos >= chars.len() {
1369 return None;
1370 }
1371
1372 let first_char = chars[first_letter_pos];
1373
1374 // A bare ! or ? ends a sentence unambiguously, unlike a period, which also
1375 // ends abbreviations and initials. Inside a quotation it is ambiguous
1376 // again: the question can belong to the quoted phrase rather than to the
1377 // sentence carrying it, as in `A "Is this a test?" guide`. A lowercase
1378 // word after the closing quote means that sentence continues.
1379 if c == '!' || c == '?' {
1380 if inside_quotation && require_sentence_capital && !opens_sentence_in_strict_mode(first_char) {
1381 return None;
1382 }
1383 return checked_cut();
1384 }
1385
1386 // Period-specific checks: periods are ambiguous (abbreviations, initials)
1387 // so we apply additional guards before accepting a sentence boundary. A
1388 // decimal such as `3.14` never reaches this point: the period must be
1389 // followed by a space to get here.
1390
1391 if pos > 0 {
1392 // Check for common abbreviations
1393 if text_ends_with_abbreviation(&text[..byte_offset_after_punct], abbreviations) {
1394 return None;
1395 }
1396
1397 // Check for single-letter initials (e.g., "J. K. Rowling")
1398 // A single uppercase letter before the period preceded by whitespace or start
1399 // is likely an initial, not a sentence ending.
1400 if chars[pos - 1].is_ascii_uppercase() && (pos == 1 || (pos >= 2 && chars[pos - 2].is_whitespace())) {
1401 return None;
1402 }
1403 }
1404
1405 // Both relaxations below end a sentence where the word after it does not
1406 // vouch for one, so both read the form of the period instead. Every other
1407 // discrimination `require_sentence_capital` was making here is already caught
1408 // by a guard above — abbreviations, initials, and a decimal, which needs a
1409 // digit after the period as well as in front of it.
1410 //
1411 // One mark of an elision is never a terminator. A period closing a digit run
1412 // is an enumerator or a version where it stands bare, as in `Steps: 1. ` and
1413 // `0.2.43. `; between a span's closing markers and the space it belongs to a
1414 // label instead, as in `**A2.**`, and a label ends what it labels.
1415 let elision = pos > 0 && chars[pos - 1] == '.';
1416 let digit_run = pos > 0 && chars[pos - 1].is_numeric();
1417 let bare = space_pos == pos + 1;
1418
1419 // A code span opens a sentence on its own terms. It starts on a backtick
1420 // rather than on a letter, and the case of what it holds belongs to the code,
1421 // so `require_sentence_capital` has nothing to read there. `!` and `?` already
1422 // accept any following character above; a period was the outlier. Vouching for
1423 // itself is also what lets it act on a label's period.
1424 if st.opens_code_span(first_letter_pos) && !elision && !(digit_run && bare) {
1425 return checked_cut();
1426 }
1427
1428 // In strict mode the next sentence must open with something a lowercase
1429 // continuation cannot. In relaxed mode, accept any character.
1430 if require_sentence_capital && !opens_sentence_in_strict_mode(first_char) {
1431 return None;
1432 }
1433
1434 checked_cut()
1435}
1436
1437/// Index of the space that follows the run of emphasis and strikethrough
1438/// markers starting at `from`, or `None` when something else follows it.
1439///
1440/// The run is read by character rather than by shape, so closers of any length
1441/// (`*`, `**`, `***`, `****`) and any nesting (`_**` closing `**_bold ital._**`)
1442/// all end the sentence they close. An empty run is allowed, which is what lets
1443/// a closing quote be followed directly by the space.
1444fn marker_run_end(chars: &[char], from: usize) -> Option<usize> {
1445 let end = marker_run_extent(chars, from);
1446 (chars.get(end) == Some(&' ')).then_some(end)
1447}
1448
1449/// Whether `first_char` can open a sentence under `require-sentence-capital`.
1450///
1451/// The option exists to keep `word. lowercase` continuations such as
1452/// `etc. and` or `approx. ten` from being read as two sentences, so what it
1453/// requires is a first character that is not a lowercase letter: an uppercase
1454/// letter, a digit (`2nd try.`, `1976 was hot.`, `6:00 is early.`), or a CJK
1455/// character, none of which has a lowercase form.
1456fn opens_sentence_in_strict_mode(first_char: char) -> bool {
1457 first_char.is_uppercase() || first_char.is_numeric() || is_cjk_char(first_char)
1458}
1459
1460/// Whether `chars` opens with an ordered-list marker: digits, `.` or `)`,
1461/// then a space or tab. Any number qualifies, and any number of digits:
1462/// CommonMark stops reading a marker at nine digits and only lets `1` open a
1463/// list mid-paragraph, but a line shaped this way reads as a list item to a
1464/// person and to MD032 alike, whatever the number.
1465fn opens_ordered_list_marker(chars: &[char]) -> bool {
1466 let digits = chars.iter().take_while(|c| c.is_ascii_digit()).count();
1467 digits > 0 && matches!(chars.get(digits), Some('.' | ')')) && matches!(chars.get(digits + 1), Some(' ' | '\t'))
1468}
1469
1470/// Length in chars of the opener of the link, image, wikilink or footnote
1471/// reference occupying `chars[pos..end]`: the part before the text a reader
1472/// sees. `[` opens a link and `![` an image. A wikilink's `[[` opener runs
1473/// past its alias pipe, since `[[target|display]]` shows `display`; the pipe
1474/// is looked for inside the construct only, before its closing `]]`.
1475fn link_opener_len(chars: &[char], pos: usize, end: usize) -> usize {
1476 let open = if chars[pos] == '!' { pos + 1 } else { pos };
1477 let body = open + 1;
1478 if chars.get(body) != Some(&'[') {
1479 return body - pos;
1480 }
1481 let body = body + 1;
1482 let alias = chars[body..end.saturating_sub(2).max(body)]
1483 .iter()
1484 .position(|&c| c == '|')
1485 .map_or(body, |p| body + p + 1);
1486 alias - pos
1487}
1488
1489/// Split text into sentences.
1490///
1491/// `defined_references` is the document's set of normalized reference labels,
1492/// which decides whether a bare `[text]` is a link (its label is defined) or
1493/// prose; `None` means the definitions are unknown and every shortcut is held
1494/// to be a link, so no real one is ever split. See
1495/// [`ReflowOptions::defined_references`].
1496///
1497/// `require_sentence_capital` is the caller's configured value, not a fixed
1498/// policy: it decides whether a lowercase word after a period opens a sentence,
1499/// so a caller that counts sentences and a caller that splits them have to be
1500/// given the same answer. See [`ReflowOptions::require_sentence_capital`].
1501pub fn split_into_sentences(
1502 text: &str,
1503 defined_references: Option<&HashSet<String>>,
1504 require_sentence_capital: bool,
1505) -> Vec<String> {
1506 let abbreviations = get_abbreviations(&None);
1507 split_into_sentences_with_set(text, &abbreviations, require_sentence_capital, defined_references, None)
1508}
1509
1510/// Internal function to split text into sentences with a pre-computed abbreviations set
1511/// Use this when calling multiple times in a loop to avoid repeatedly computing the set
1512///
1513/// `paragraph` carries the structure of the paragraph `text` is a part of, for
1514/// callers that assemble a line one element at a time. Without it the structure
1515/// comes from parsing `text` on its own, which is right only when `text` is a
1516/// whole paragraph.
1517fn split_into_sentences_with_set(
1518 text: &str,
1519 abbreviations: &HashSet<String>,
1520 require_sentence_capital: bool,
1521 defined_references: Option<&HashSet<String>>,
1522 paragraph: Option<ParagraphStructure<'_>>,
1523) -> Vec<String> {
1524 split_into_sentence_ranges(
1525 text,
1526 abbreviations,
1527 require_sentence_capital,
1528 defined_references,
1529 paragraph,
1530 )
1531 .into_iter()
1532 .map(|(start, end)| text[start..end].to_string())
1533 .collect()
1534}
1535
1536/// `start..end` with the whitespace at either end left out, or `None` when
1537/// nothing else is there.
1538fn trim_range(text: &str, start: usize, end: usize) -> Option<(usize, usize)> {
1539 let slice = &text[start..end];
1540 let leading = slice.len() - slice.trim_start().len();
1541 let trailing = slice.len() - slice.trim_end().len();
1542 (leading + trailing < slice.len()).then(|| (start + leading, end - trailing))
1543}
1544
1545/// The byte ranges [`split_into_sentences_with_set`] cuts `text` into, each one
1546/// a sentence with its surrounding whitespace left out.
1547///
1548/// A caller assembling a line element by element works in these coordinates: a
1549/// sentence it holds back is the text between two of them, so the line it
1550/// carries stays the paragraph's own bytes and the structure read off the
1551/// paragraph keeps applying to it.
1552fn split_into_sentence_ranges(
1553 text: &str,
1554 abbreviations: &HashSet<String>,
1555 require_sentence_capital: bool,
1556 defined_references: Option<&HashSet<String>>,
1557 paragraph: Option<ParagraphStructure<'_>>,
1558) -> Vec<(usize, usize)> {
1559 let char_vec: Vec<char> = text.chars().collect();
1560 let char_offsets = char_byte_offsets(&char_vec);
1561
1562 // The constructs a boundary must not fall inside, sorted and non-overlapping,
1563 // and the link-like ones a sentence may open with. A part of a paragraph
1564 // takes them from the paragraph's parse; only a whole text is parsed here.
1565 let NestedStructure {
1566 atomic,
1567 links,
1568 code_spans,
1569 markers,
1570 marker_closers,
1571 } = match paragraph {
1572 Some(paragraph) => paragraph.window(text.len()),
1573 None => sentence_structure(text, defined_references),
1574 };
1575 let mut atomic_it = atomic.iter().peekable();
1576 let st = SentenceText {
1577 text,
1578 chars: &char_vec,
1579 char_offsets: &char_offsets,
1580 links: &links,
1581 code_spans: &code_spans,
1582 markers: &markers,
1583 marker_closers: &marker_closers,
1584 paragraph,
1585 emphasis: EmphasisSpans::default(),
1586 };
1587
1588 // The space after a sentence belongs to neither it nor the next one, so
1589 // the next sentence begins past it.
1590 let next_sentence_start = |cut: usize| if char_vec.get(cut) == Some(&' ') { cut + 1 } else { cut };
1591
1592 // Every cut the boundary check approves, in order. A cut is judged where
1593 // it is found, from the text and its structure alone, so no cut depends
1594 // on the ones before it, and the cuts the shape of the text could not
1595 // settle are confirmed together once every cut is known.
1596 let mut cuts: Vec<Cut> = Vec::new();
1597 let mut pos = 0;
1598
1599 while pos < char_vec.len() {
1600 let byte_idx = char_offsets[pos];
1601
1602 // Advance past every atomic range the current char start has left behind.
1603 while let Some(&&(_, end)) = atomic_it.peek() {
1604 if end <= byte_idx {
1605 atomic_it.next();
1606 } else {
1607 break;
1608 }
1609 }
1610
1611 // True if the current character position falls inside an atomic construct.
1612 let in_atomic = atomic_it
1613 .peek()
1614 .is_some_and(|&&(start, end)| byte_idx >= start && byte_idx < end);
1615
1616 if !in_atomic && let Some(cut) = sentence_boundary(&st, pos, abbreviations, require_sentence_capital) {
1617 // Everything from the ender to the cut is glued to the sentence,
1618 // and none of it ends one, so the walk resumes past the cut.
1619 pos = next_sentence_start(cut.at);
1620 cuts.push(cut);
1621 continue;
1622 }
1623
1624 pos += 1;
1625 }
1626 st.confirm_cuts(&mut cuts);
1627
1628 let mut sentences = Vec::new();
1629 // Where the sentence under construction begins. A cut the parse refused
1630 // is no cut, so the sentence in front of it runs on to the next one.
1631 let mut sentence_start = 0;
1632 for cut in &cuts {
1633 // The sentence runs to the cut the boundary check validated, which
1634 // sits after the ender and everything glued to it: footnote
1635 // references, closing quotes and brackets, and the delimiter runs
1636 // closing the spans the sentence ends inside. Taking that cut as
1637 // it is keeps `check` and `fmt` cutting in the same place.
1638 if let Some(range) = trim_range(text, sentence_start, char_offsets[cut.at]) {
1639 sentences.push(range);
1640 }
1641 sentence_start = char_offsets[next_sentence_start(cut.at)];
1642 }
1643
1644 // Add any remaining text as the last sentence
1645 if let Some(range) = trim_range(text, sentence_start, text.len()) {
1646 sentences.push(range);
1647 }
1648 sentences
1649}
1650
1651/// The inline structure of a text being split into sentences: the byte ranges
1652/// a boundary must not fall inside, and the link-like constructs a sentence
1653/// may open with.
1654///
1655/// A link's text, destination and title, an image's alt text, a wikilink's
1656/// target, a math span, an HTML tag's attributes and a code span each hold text
1657/// that reads like prose to the boundary check (`[First. Second](url)`) but is
1658/// one construct to the renderer, so a line break inside it rewrites the
1659/// document rather than its layout. These are the ranges `parse_elements`
1660/// holds atomic, computed here from the raw text so that the check counting a
1661/// line's sentences and the reflow splitting them agree on where a sentence
1662/// can end. A bare `[text]` is a link only when `defined_references` holds
1663/// its label, prose otherwise; without the definitions (`None`) it is held
1664/// atomic wherever it appears, which keeps a real shortcut link whole at the
1665/// price of leaving a bracketed prose aside on one line.
1666fn sentence_structure(text: &str, defined_references: Option<&HashSet<String>>) -> NestedStructure {
1667 // Every construct that can hold whitespace, and every link-like construct,
1668 // opens with one of these. A CJK sentence ender sharing the text with an
1669 // emphasis marker needs the parse as well, since whether the marker run
1670 // belongs to a span decides where the sentence ends. Plain prose matches
1671 // neither and skips the parse entirely.
1672 let holds_construct = text.contains(['`', '[', '<', '$']);
1673 let holds_cjk_emphasis = text.contains(['*', '_', '~']) && text.contains(['。', '!', '?']);
1674 if !holds_construct && !holds_cjk_emphasis {
1675 return NestedStructure {
1676 atomic: Vec::new(),
1677 markers: Vec::new(),
1678 marker_closers: Vec::new(),
1679 links: Vec::new(),
1680 code_spans: Vec::new(),
1681 };
1682 }
1683 nested_structure(text, defined_references, false)
1684}
1685
1686/// Check if a line is a horizontal rule (---, ___, ***)
1687fn is_horizontal_rule(line: &str) -> bool {
1688 if line.len() < 3 {
1689 return false;
1690 }
1691
1692 // Line must consist only of a single marker char (-, _, or *) plus spaces,
1693 // with at least 3 markers. Scan chars directly to avoid allocating a Vec.
1694 let mut chars = line.chars();
1695 let Some(first_char) = chars.next() else {
1696 return false;
1697 };
1698 if first_char != '-' && first_char != '_' && first_char != '*' {
1699 return false;
1700 }
1701
1702 let mut non_space_count = 1usize; // first_char is a marker
1703 for c in chars {
1704 if c == ' ' {
1705 continue;
1706 }
1707 if c != first_char {
1708 return false;
1709 }
1710 non_space_count += 1;
1711 }
1712 non_space_count >= 3
1713}
1714
1715/// Check if a line is a numbered list item (e.g., "1. ", "10. ")
1716fn is_numbered_list_item(line: &str) -> bool {
1717 let mut chars = line.chars();
1718
1719 // Must start with a digit
1720 if !chars.next().is_some_and(char::is_numeric) {
1721 return false;
1722 }
1723
1724 // Can have more digits
1725 while let Some(c) = chars.next() {
1726 if c == '.' {
1727 // After period, must have a space (consistent with list marker extraction)
1728 // "2019." alone is NOT treated as a list item to avoid false positives
1729 return chars.next() == Some(' ');
1730 }
1731 if !c.is_numeric() {
1732 return false;
1733 }
1734 }
1735
1736 false
1737}
1738
1739/// Check if a trimmed line is an unordered list item (-, *, + followed by space)
1740fn is_unordered_list_marker(s: &str) -> bool {
1741 matches!(s.as_bytes().first(), Some(b'-' | b'*' | b'+'))
1742 && !is_horizontal_rule(s)
1743 && (s.len() == 1 || s.as_bytes().get(1) == Some(&b' '))
1744}
1745
1746/// True when `line` opens a definition, which is to say a colon is its first
1747/// character and at most three columns of indentation precede it.
1748///
1749/// The definition-list extensions read the colon and nothing else: `:text`
1750/// opens a definition exactly as `: text` does, and a colon with nothing after
1751/// it opens an empty one. The shared check wants whitespace after the colon,
1752/// which suits the rules that look for the definition itself; a reflow has to
1753/// see the marker wherever a parse sees one, in both directions. A source line
1754/// holding one is a block of its own rather than a paragraph continuation, and
1755/// a line the split left holding one must be folded back into the line above.
1756///
1757/// The fourth column is where a parse stops reading a marker and reads a lazy
1758/// continuation of the paragraph above, which is prose and reflows as prose.
1759/// The count runs from wherever the block's own content starts, so a caller
1760/// holding lines that carry a blockquote prefix or a list item's indentation
1761/// takes that off first.
1762///
1763/// The colon opens a definition only under a term, so which lines a caller
1764/// asks about decides what a positive answer means. A caller collecting a
1765/// block's lines reads a marker only on a line with a line of the same block
1766/// before it: the first line of a paragraph, of a list item's content or of a
1767/// blockquote's content is prose whatever it starts with, and it reflows as
1768/// prose with its colon staying at the head of the block's first emitted
1769/// line. A caller asking whether a line ends the paragraph above it, or
1770/// whether a line the split produced has to fold back onto the line above,
1771/// holds a line with a line before it by construction and reads every
1772/// colon-led line as a marker.
1773pub(crate) fn is_definition_list_marker(line: &str) -> bool {
1774 let trimmed = line.trim_start();
1775 trimmed.starts_with(':') && calculate_indentation_width_default(&line[..line.len() - trimmed.len()]) <= 3
1776}
1777
1778/// Whether `line` is a whole line holding one closed `$$...$$` display-math
1779/// span.
1780///
1781/// A renderer that reads `$$` shows such a line as a centred display block,
1782/// and shows the same span sharing a line with prose inline or not as math at
1783/// all. So the line is a block for reflow's purposes: it keeps the line it was
1784/// written on, and the prose before and after it reflows within its own
1785/// paragraph.
1786///
1787/// The check runs on the block's own content, so a caller holding lines that
1788/// carry a list item's indentation takes that off first; a blockquote prefix
1789/// comes off here, and so does the backslash of a hard break, which such a
1790/// line may end in like any other. The span has to close the line: a span
1791/// followed by prose renders inline, and a line of two dollar signs alone
1792/// opens a multi-line block rather than holding an expression. The first `$$`
1793/// after the opening one closes the span, so it has to be the pair ending the
1794/// line: a line whose span closes earlier holds prose beside it, whatever the
1795/// line ends in.
1796///
1797/// A line inside a code span opened on an earlier line is code however it is
1798/// spelled; a caller that can hold one asks about the span beside this.
1799pub(crate) fn is_self_contained_display_math_line(line: &str) -> bool {
1800 let trimmed = line.trim();
1801 let inner = crate::utils::blockquote::parse_blockquote_prefix(trimmed).map_or(trimmed, |p| p.content.trim());
1802 let inner = inner.strip_suffix('\\').map_or(inner, str::trim_end);
1803 inner.len() >= 4
1804 && inner.starts_with("$$")
1805 && inner.ends_with("$$")
1806 && inner[2..].find("$$") == Some(inner.len() - 4)
1807}
1808
1809/// Whether each line of `text` is touched on either boundary by a code span
1810/// crossing more than one line: a span containing the newline that ends the
1811/// line before it, or the newline that ends it. One entry per line of
1812/// `str::lines`.
1813///
1814/// A renderer reads the line break inside a code span as one space, so such a
1815/// line is code however it is spelled, and a `$$...$$` expression on it is no
1816/// display block. A span that begins and ends on the line itself does not
1817/// matter. The flags only ever gate the display math recognizer, so a text
1818/// holding no `$$` never reads them and the parse is skipped along with the
1819/// no-backtick case. The parse runs once over the whole text and the spans
1820/// and the lines both run forward through it, so one cursor over the spans
1821/// finds the span reaching each line from an earlier one; a line also
1822/// touches whichever span reaches the line after it, since that is the same
1823/// span crossing the newline this line ends on.
1824///
1825/// The parse reads code spans on their own, not alongside math delimiters: a
1826/// `$$...$$` pair closes wherever the next `$$` sits regardless of what
1827/// stands between, so it can close over a backtick that in fact opens a span
1828/// reaching past the line. Reading code spans this way keeps that backtick's
1829/// span visible to callers deciding whether a `$$...$$` line is a display
1830/// block.
1831pub(crate) fn lines_touching_multiline_code_span(text: &str) -> Vec<bool> {
1832 let line_count = text.lines().count();
1833 if !text.contains('`') || !text.contains("$$") {
1834 return vec![false; line_count];
1835 }
1836 let code_spans = nested_structure(text, None, false).code_spans;
1837 let mut spans = code_spans.iter().copied().peekable();
1838 let mut starts_inside = Vec::with_capacity(line_count);
1839 let mut line_start = 0;
1840 for line in text.split_inclusive('\n') {
1841 while spans.next_if(|&(_, end)| end <= line_start).is_some() {}
1842 starts_inside.push(spans.peek().is_some_and(|&(start, _)| start < line_start));
1843 line_start += line.len();
1844 }
1845 (0..line_count)
1846 .map(|i| starts_inside[i] || starts_inside.get(i + 1).is_some_and(|&b| b))
1847 .collect()
1848}
1849
1850/// Whether the source line at `index` has a line of its own block before it,
1851/// which is what lets a colon leading it open a definition.
1852///
1853/// A blank line, a heading, a fence, a thematic break and a div marker each
1854/// close their block, so a line after one of them starts a block of its own.
1855/// Any other line above is prose, a marker, or a line of a container the line
1856/// at `index` continues, and the line is read as a marker: a marker line is
1857/// kept as written, and a line kept as written renders as it did.
1858fn has_block_line_above(lines: &[&str], index: usize) -> bool {
1859 let Some(previous) = index.checked_sub(1).map(|above| lines[above].trim()) else {
1860 return false;
1861 };
1862 !(previous.is_empty()
1863 || previous.starts_with('#')
1864 || previous.starts_with("```")
1865 || previous.starts_with("~~~")
1866 || previous.starts_with(":::")
1867 || is_horizontal_rule(previous))
1868}
1869
1870/// Shared structural checks for block boundary detection.
1871/// Checks elements that only depend on the trimmed line content.
1872fn is_block_boundary_core(trimmed: &str) -> bool {
1873 trimmed.is_empty()
1874 || trimmed.starts_with('#')
1875 || trimmed.starts_with("```")
1876 || trimmed.starts_with("~~~")
1877 || trimmed.starts_with('>')
1878 || (trimmed.starts_with('[') && trimmed.contains("]:"))
1879 || is_horizontal_rule(trimmed)
1880 || is_unordered_list_marker(trimmed)
1881 || is_numbered_list_item(trimmed)
1882 || is_definition_list_marker(trimmed)
1883 || trimmed.starts_with(":::")
1884}
1885
1886/// Check if a trimmed line starts a new structural block element.
1887/// Used for paragraph boundary detection in `reflow_markdown()`.
1888fn is_block_boundary(trimmed: &str) -> bool {
1889 is_block_boundary_core(trimmed) || trimmed.starts_with('|')
1890}
1891
1892/// Check if a line starts a new structural block for paragraph boundary detection
1893/// in `reflow_paragraph_at_line()`. Extends the core checks with indented code blocks
1894/// (≥4 spaces) and table row detection via `is_potential_table_row`.
1895fn is_paragraph_boundary(trimmed: &str, line: &str) -> bool {
1896 is_block_boundary_core(trimmed)
1897 || calculate_indentation_width_default(line) >= 4
1898 || crate::utils::table_utils::TableUtils::is_potential_table_row(line)
1899}
1900
1901/// Check if a line ends with a hard break (either two spaces or backslash)
1902///
1903/// CommonMark supports two formats for hard line breaks:
1904/// 1. Two or more trailing spaces
1905/// 2. A backslash at the end of the line
1906fn has_hard_break(line: &str) -> bool {
1907 let line = line.strip_suffix('\r').unwrap_or(line);
1908 line.ends_with(" ") || line.ends_with('\\')
1909}
1910
1911/// Join the source lines of one paragraph part, writing the single space a
1912/// renderer shows where a soft line break was.
1913///
1914/// Outside a code span a renderer drops the ASCII spaces and tabs ending a
1915/// line and shows the break as one space, so joining the lines as written
1916/// would put that whitespace in the output on top of the joining space.
1917/// Inside a code span it keeps every character and shows the break itself as
1918/// one space, so a line ending inside one keeps its whitespace. A no-break
1919/// space, ASCII or ideographic, is content to a renderer wherever it sits and
1920/// stays as well. Only the last line keeps its end as written, since a hard
1921/// break closes the part it ends and so is always last.
1922///
1923/// Which joins sit inside a code span is read off the parse of the joined
1924/// text, so a backtick that opens no span leaves its line end outside one.
1925pub(crate) fn join_soft_break_lines(lines: &[&str]) -> String {
1926 let mut joined = String::new();
1927 // The byte offset in `joined` of the space written for each join.
1928 let mut joins = Vec::with_capacity(lines.len().saturating_sub(1));
1929 for (idx, line) in lines.iter().enumerate() {
1930 if idx + 1 == lines.len() {
1931 joined.push_str(line);
1932 } else {
1933 joined.push_str(line.strip_suffix('\r').unwrap_or(line));
1934 joins.push(joined.len());
1935 joined.push(' ');
1936 }
1937 }
1938 if joins.is_empty() {
1939 return joined;
1940 }
1941 let code_spans = if joined.contains('`') {
1942 nested_structure(&joined, None, false).code_spans
1943 } else {
1944 Vec::new()
1945 };
1946 // The joins and the code spans both run forward through the text, so one
1947 // cursor over the spans finds the span around each join, and the text is
1948 // copied once with the whitespace before each join outside a span left
1949 // out. The copy never reaches back past the join before it, so a line of
1950 // whitespace alone keeps the space joining it.
1951 let mut trimmed = String::with_capacity(joined.len());
1952 let mut copied = 0;
1953 let mut spans = code_spans.iter().copied().peekable();
1954 for &join in &joins {
1955 while spans.next_if(|&(_, end)| end <= join).is_some() {}
1956 let inside_code_span = spans.peek().is_some_and(|&(start, _)| start <= join);
1957 if !inside_code_span {
1958 let content_end = joined[..join].trim_end_matches([' ', '\t']).len();
1959 trimmed.push_str(&joined[copied..content_end.max(copied)]);
1960 copied = join;
1961 }
1962 }
1963 trimmed.push_str(&joined[copied..]);
1964 trimmed
1965}
1966
1967/// Check if text ends with sentence-terminating punctuation (. ! ?)
1968fn ends_with_sentence_punct(text: &str) -> bool {
1969 text.ends_with('.') || text.ends_with('!') || text.ends_with('?')
1970}
1971
1972/// Whether `text` ends with a CJK sentence ender followed by the brackets or
1973/// quotes closing it, as in `(已经完成。)` or `(“已经完成。”)`.
1974///
1975/// At least one closer is required: a line ending in a bare `。` is ambiguous
1976/// between a finished sentence and a clause the next line carries on, and the
1977/// sentence splitter reads that from the joined text instead.
1978fn ends_cjk_sentence_with_closer(text: &str) -> bool {
1979 let stripped = text.trim_end_matches(|c| is_closing_bracket(c) || is_closing_quote(c));
1980 stripped.len() < text.len() && stripped.chars().next_back().is_some_and(is_cjk_sentence_ending)
1981}
1982
1983/// Trim trailing whitespace while preserving hard breaks (two trailing spaces or backslash)
1984///
1985/// Hard breaks in Markdown can be indicated by:
1986/// 1. Two trailing spaces before a newline (traditional)
1987/// 2. A backslash at the end of the line (mdformat style)
1988fn trim_preserving_hard_break(s: &str) -> String {
1989 // Strip trailing \r from CRLF line endings first to handle Windows files
1990 let s = s.strip_suffix('\r').unwrap_or(s);
1991
1992 // Check for backslash hard break (mdformat style)
1993 if s.ends_with('\\') {
1994 // Preserve the backslash exactly as-is
1995 return s.to_string();
1996 }
1997
1998 // Check if there are at least 2 trailing spaces (traditional hard break)
1999 if s.ends_with(" ") {
2000 // Find the position where non-space content ends
2001 let content_end = s.trim_end().len();
2002 if content_end == 0 {
2003 // String is all whitespace
2004 return String::new();
2005 }
2006 // Preserve exactly 2 trailing spaces for hard break
2007 format!("{} ", &s[..content_end])
2008 } else {
2009 // No hard break, just trim all trailing whitespace
2010 s.trim_end().to_string()
2011 }
2012}
2013
2014/// Parse markdown elements using the appropriate parser based on options.
2015fn parse_elements(text: &str, options: &ReflowOptions) -> Vec<Element> {
2016 parse_markdown_elements_inner(
2017 text,
2018 options.attr_lists,
2019 options.myst_roles,
2020 options.defined_references.as_ref(),
2021 )
2022}
2023
2024/// Reflow a line, falling back to the input when the result would not preserve it.
2025///
2026/// Reflow redistributes whitespace: it decides where lines break, never which
2027/// characters a paragraph contains. So the sequence of non-whitespace characters
2028/// is invariant across a correct reflow, and any difference means the reflow
2029/// dropped, duplicated, reordered, or invented content. Returning the input
2030/// unchanged in that case costs a paragraph that stays unwrapped; the alternative
2031/// is writing corrupted prose into the user's file. A caller comparing its
2032/// replacement against the original then sees no change and reports nothing.
2033pub fn reflow_line(line: &str, options: &ReflowOptions) -> Vec<String> {
2034 let reflowed = reflow_line_unchecked(line, options);
2035 if preserves_content(line, &reflowed) {
2036 reflowed
2037 } else {
2038 vec![line.to_string()]
2039 }
2040}
2041
2042/// Whether `reflowed` still holds `original`'s text: the same non-whitespace
2043/// characters in the same order, with every word boundary intact.
2044///
2045/// Reflow may add a boundary the input did not have, since wrapping a script
2046/// that writes without spaces has to break somewhere. Removing one is different:
2047/// it glues two words into a word the author never wrote.
2048fn preserves_content(original: &str, reflowed: &[String]) -> bool {
2049 let (original_text, original_breaks) = visible_text_and_breaks(original.chars());
2050 let (reflowed_text, reflowed_breaks) =
2051 visible_text_and_breaks(reflowed.iter().flat_map(|line| line.chars().chain(['\n'])));
2052
2053 original_text == reflowed_text && contains_all(&reflowed_breaks, &original_breaks)
2054}
2055
2056/// The non-whitespace characters of `text`, and for each interior run of
2057/// whitespace, how many characters precede it.
2058fn visible_text_and_breaks(text: impl Iterator<Item = char>) -> (String, Vec<usize>) {
2059 let mut visible = String::new();
2060 let mut breaks = Vec::new();
2061 let mut count = 0usize;
2062 let mut pending_break = false;
2063
2064 for c in text {
2065 if c.is_whitespace() {
2066 pending_break = count > 0;
2067 } else {
2068 if pending_break {
2069 breaks.push(count);
2070 pending_break = false;
2071 }
2072 visible.push(c);
2073 count += 1;
2074 }
2075 }
2076
2077 (visible, breaks)
2078}
2079
2080/// Whether every value in `subset` appears in `superset`. Both are ascending.
2081fn contains_all(superset: &[usize], subset: &[usize]) -> bool {
2082 let mut candidates = superset.iter();
2083 subset
2084 .iter()
2085 .all(|wanted| candidates.by_ref().any(|found| found == wanted))
2086}
2087
2088fn reflow_line_unchecked(line: &str, options: &ReflowOptions) -> Vec<String> {
2089 // For sentence-per-line mode, always process regardless of length
2090 if options.sentence_per_line {
2091 let elements = parse_elements(line, options);
2092 return merge_block_construct_continuations(reflow_elements_sentence_per_line(&elements, options));
2093 }
2094
2095 // For semantic line breaks mode, use cascading split strategy
2096 if options.semantic_line_breaks {
2097 let elements = parse_elements(line, options);
2098 return merge_block_construct_continuations(reflow_elements_semantic(&elements, options));
2099 }
2100
2101 // Quick check: if line is already short enough or no wrapping requested, return as-is
2102 // line_length = 0 means no wrapping (unlimited line length)
2103 if options.line_length == 0 || line_fits(line, options) {
2104 return vec![line.to_string()];
2105 }
2106
2107 // Parse the markdown to identify elements
2108 let elements = parse_elements(line, options);
2109
2110 // Reflow the elements into lines
2111 merge_block_construct_continuations(reflow_elements(&elements, options))
2112}
2113
2114/// Represents a piece of content in the markdown
2115#[derive(Debug, Clone)]
2116enum Element {
2117 /// Plain text that can be wrapped
2118 Text(String),
2119 /// A complete markdown inline link `[text](url)`
2120 Link(String),
2121 /// A complete markdown reference link `[text][ref]`
2122 ReferenceLink(String),
2123 /// A complete markdown empty reference link `[text][]`
2124 EmptyReferenceLink(String),
2125 /// A complete markdown shortcut reference link `[ref]`
2126 ShortcutReference(String),
2127 /// A complete markdown inline image 
2128 InlineImage(String),
2129 /// A complete markdown reference image ![alt][ref]
2130 ReferenceImage(String),
2131 /// A complete markdown empty reference image ![alt][]
2132 EmptyReferenceImage(String),
2133 /// A clickable image badge
2134 LinkedImage(String),
2135 /// Footnote reference [^note]
2136 FootnoteReference(String),
2137 /// Strikethrough text ~~text~~ or ~text~ (GFM allows one or two tildes)
2138 Strikethrough {
2139 content: String,
2140 /// True if the original used a double-tilde (~~) marker, false for a single tilde (~)
2141 double: bool,
2142 },
2143 /// Wiki-style link `[[wiki]]` or `[[wiki|text]]`
2144 WikiLink(String),
2145 /// Inline math $math$
2146 InlineMath(String),
2147 /// Display math $$math$$
2148 DisplayMath(String),
2149 /// Emoji shortcode :emoji:
2150 EmojiShortcode(String),
2151 /// Autolink <https://...> or <mailto:...> or <user@domain.com>
2152 Autolink(String),
2153 /// HTML tag `<tag>` or `</tag>` or `<tag/>`
2154 HtmlTag(String),
2155 /// HTML entity or {
2156 HtmlEntity(String),
2157 /// Hugo/Go template shortcode {{< ... >}} or {{% ... %}}
2158 HugoShortcode(String),
2159 /// MkDocs/kramdown attribute list {#id .class key="value"}
2160 AttrList(String),
2161 /// MyST inline role `` {role}`content` `` (or `` {domain:role}`content` ``).
2162 /// Stored as the raw matched text and rendered verbatim so it round-trips
2163 /// exactly; treated as atomic so it is never split mid-role.
2164 MystRole(String),
2165 /// Inline code `code`
2166 Code { content: String, marker: String },
2167 /// Bold text **text** or __text__
2168 Bold {
2169 content: String,
2170 /// True if underscore markers (__), false for asterisks (**)
2171 underscore: bool,
2172 },
2173 /// Italic text *text* or _text_
2174 Italic {
2175 content: String,
2176 /// True if underscore marker (_), false for asterisk (*)
2177 underscore: bool,
2178 },
2179}
2180
2181impl Element {
2182 /// Whether the element's source form opens with `[` or `![`: a link,
2183 /// image, wikilink, footnote or shortcut reference. What the sentence
2184 /// splitter makes of that bracket decides whether the element can start a
2185 /// sentence, so the reflow defers to the splitter for these.
2186 fn opens_with_bracket(&self) -> bool {
2187 matches!(
2188 self,
2189 Element::Link(_)
2190 | Element::ReferenceLink(_)
2191 | Element::EmptyReferenceLink(_)
2192 | Element::ShortcutReference(_)
2193 | Element::FootnoteReference(_)
2194 | Element::InlineImage(_)
2195 | Element::ReferenceImage(_)
2196 | Element::EmptyReferenceImage(_)
2197 | Element::LinkedImage(_)
2198 | Element::WikiLink(_)
2199 )
2200 }
2201}
2202
2203impl std::fmt::Display for Element {
2204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2205 match self {
2206 Element::Text(s) => write!(f, "{s}"),
2207 Element::Link(s) => write!(f, "{s}"),
2208 Element::ReferenceLink(s) => write!(f, "{s}"),
2209 Element::EmptyReferenceLink(s) => write!(f, "{s}"),
2210 Element::ShortcutReference(s) => write!(f, "{s}"),
2211 Element::InlineImage(s) => write!(f, "{s}"),
2212 Element::ReferenceImage(s) => write!(f, "{s}"),
2213 Element::EmptyReferenceImage(s) => write!(f, "{s}"),
2214 Element::LinkedImage(s) => write!(f, "{s}"),
2215 Element::FootnoteReference(s) => write!(f, "{s}"),
2216 Element::Strikethrough { content, double } => {
2217 let marker = if *double { "~~" } else { "~" };
2218 write!(f, "{marker}{content}{marker}")
2219 }
2220 Element::WikiLink(s) => write!(f, "[[{s}]]"),
2221 Element::InlineMath(s) => write!(f, "${s}$"),
2222 Element::DisplayMath(s) => write!(f, "$${s}$$"),
2223 Element::EmojiShortcode(s) => write!(f, ":{s}:"),
2224 Element::Autolink(s) => write!(f, "{s}"),
2225 Element::HtmlTag(s) => write!(f, "{s}"),
2226 Element::HtmlEntity(s) => write!(f, "{s}"),
2227 Element::HugoShortcode(s) => write!(f, "{s}"),
2228 Element::AttrList(s) => write!(f, "{s}"),
2229 Element::MystRole(s) => write!(f, "{s}"),
2230 Element::Code { content, marker } => write!(f, "{marker}{content}{marker}"),
2231 Element::Bold { content, underscore } => {
2232 if *underscore {
2233 write!(f, "__{content}__")
2234 } else {
2235 write!(f, "**{content}**")
2236 }
2237 }
2238 Element::Italic { content, underscore } => {
2239 if *underscore {
2240 write!(f, "_{content}_")
2241 } else {
2242 write!(f, "*{content}*")
2243 }
2244 }
2245 }
2246 }
2247}
2248
2249impl Element {
2250 fn display_len(&self, mode: ReflowLengthMode) -> usize {
2251 match self {
2252 Element::Text(s)
2253 | Element::Link(s)
2254 | Element::ReferenceLink(s)
2255 | Element::EmptyReferenceLink(s)
2256 | Element::ShortcutReference(s)
2257 | Element::InlineImage(s)
2258 | Element::ReferenceImage(s)
2259 | Element::EmptyReferenceImage(s)
2260 | Element::LinkedImage(s)
2261 | Element::FootnoteReference(s)
2262 | Element::Autolink(s)
2263 | Element::HtmlTag(s)
2264 | Element::HtmlEntity(s)
2265 | Element::HugoShortcode(s)
2266 | Element::AttrList(s)
2267 | Element::MystRole(s) => display_len(s, mode),
2268 Element::WikiLink(s) => display_len(s, mode) + 4,
2269 Element::InlineMath(s) => display_len(s, mode) + 2,
2270 Element::DisplayMath(s) => display_len(s, mode) + 4,
2271 Element::EmojiShortcode(s) => display_len(s, mode) + 2,
2272 Element::Strikethrough { content, double } => display_len(content, mode) + if *double { 4 } else { 2 },
2273 Element::Code { content, marker } => display_len(content, mode) + display_len(marker, mode) * 2,
2274 Element::Bold { content, .. } => display_len(content, mode) + 4,
2275 Element::Italic { content, .. } => display_len(content, mode) + 2,
2276 }
2277 }
2278
2279 /// The width the checker measures this element at, under each exemption.
2280 ///
2281 /// An inline link costs `[text]` and an inline image `![alt]`, exactly as
2282 /// `MD013`'s check computes them; a code span costs nothing. Every other
2283 /// element, reference and shortcut link forms included, costs its full
2284 /// width, because the check does not exempt those either.
2285 ///
2286 /// An element whose text cannot be delimited is charged in full. Charging
2287 /// too much only makes reflow wrap a line the check would have forgiven;
2288 /// charging too little would leave a line the check reports.
2289 fn exempt_width(&self, mode: ReflowLengthMode, exemptions: LengthExemptions) -> LineWidth {
2290 let full = self.display_len(mode);
2291 let mut width = LineWidth::plain(full);
2292 match self {
2293 Element::Link(s) | Element::LinkedImage(s) if exemptions.link_urls => {
2294 if let Some(text) = bracketed_text(s, 0) {
2295 width.link_exempt = (2 + display_len(text, mode)).min(full);
2296 }
2297 }
2298 Element::InlineImage(s) if exemptions.link_urls => {
2299 if let Some(alt) = bracketed_text(s, 1) {
2300 width.link_exempt = (3 + display_len(alt, mode)).min(full);
2301 }
2302 }
2303 Element::Code { .. } if exemptions.code_spans => width.code_exempt = 0,
2304 _ => {}
2305 }
2306 width
2307 }
2308}
2309
2310/// The source between the `[` at byte `open` and its matching `]`.
2311///
2312/// Bracket matching follows the document parser: a nested `[` raises the depth,
2313/// a backslash-escaped bracket is literal, and a bracket inside a code span is
2314/// literal too, so `[a \] b](url)` and ``[a `]` b](url)`` are each one link.
2315/// Returns `None` when `open` is not a `[` or the bracket never closes.
2316pub(crate) fn bracketed_text(s: &str, open: usize) -> Option<&str> {
2317 let bytes = s.as_bytes();
2318 if bytes.get(open) != Some(&b'[') {
2319 return None;
2320 }
2321 let mut depth = 0usize;
2322 let mut in_code_span = false;
2323 let mut escaped = false;
2324 for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
2325 if escaped {
2326 escaped = false;
2327 continue;
2328 }
2329 match byte {
2330 b'\\' => escaped = true,
2331 b'`' => in_code_span = !in_code_span,
2332 b'[' if !in_code_span => depth += 1,
2333 b']' if !in_code_span => match depth.checked_sub(1) {
2334 Some(next) => depth = next,
2335 None => return s.get(open + 1..i),
2336 },
2337 _ => {}
2338 }
2339 }
2340 None
2341}
2342
2343/// An emphasis or formatting span parsed by pulldown-cmark
2344#[derive(Debug, Clone)]
2345struct EmphasisSpan {
2346 /// Byte offset where the emphasis starts (including markers)
2347 start: usize,
2348 /// Byte offset where the emphasis ends (after closing markers)
2349 end: usize,
2350 /// The content inside the emphasis markers
2351 content: String,
2352 /// Whether this is strong (bold) emphasis
2353 is_strong: bool,
2354 /// Whether this is strikethrough (~~text~~)
2355 is_strikethrough: bool,
2356 /// Whether the original used underscore markers (for emphasis only)
2357 uses_underscore: bool,
2358 /// For strikethrough spans, whether the original used a double-tilde (~~)
2359 /// marker rather than a single tilde (~). Meaningless for other spans.
2360 strikethrough_double: bool,
2361}
2362
2363/// Extract emphasis and strikethrough spans from text using pulldown-cmark
2364///
2365/// This provides CommonMark-compliant emphasis parsing, correctly handling:
2366/// - Nested emphasis like `*text **bold** more*`
2367/// - Left/right flanking delimiter rules
2368/// - Underscore vs asterisk markers
2369/// - GFM strikethrough (~~text~~)
2370///
2371/// Returns spans sorted by start position.
2372fn extract_emphasis_and_code_spans(text: &str) -> (Vec<EmphasisSpan>, Vec<CodeSpan>) {
2373 // If neither marker is present, skip the parser entirely.
2374 let has_emphasis = text.contains(['*', '_', '~']);
2375 let has_code = text.contains('`');
2376 if !has_emphasis && !has_code {
2377 return (Vec::new(), Vec::new());
2378 }
2379
2380 let mut emphasis_spans = Vec::new();
2381 let mut code_spans = Vec::new();
2382
2383 let mut options = Options::empty();
2384 if has_emphasis {
2385 options.insert(Options::ENABLE_STRIKETHROUGH);
2386 }
2387
2388 // Stacks to track nested formatting with their start positions
2389 let mut emphasis_stack: Vec<(usize, bool)> = Vec::new(); // (start_byte, uses_underscore)
2390 let mut strong_stack: Vec<(usize, bool)> = Vec::new();
2391 let mut strikethrough_stack: Vec<usize> = Vec::new();
2392
2393 let parser = Parser::new_ext(text, options).into_offset_iter();
2394
2395 for (event, range) in parser {
2396 match event {
2397 Event::Code(_) => {
2398 code_spans.push(CodeSpan {
2399 start: range.start,
2400 end: range.end,
2401 });
2402 }
2403 Event::Start(Tag::Emphasis) => {
2404 // Check if this uses underscore by looking at the original text
2405 let uses_underscore = text.get(range.start..range.start + 1) == Some("_");
2406 emphasis_stack.push((range.start, uses_underscore));
2407 }
2408 Event::End(TagEnd::Emphasis) => {
2409 if let Some((start_byte, uses_underscore)) = emphasis_stack.pop() {
2410 let content_start = start_byte + 1;
2411 let content_end = range.end - 1;
2412 if content_end > content_start
2413 && let Some(content) = text.get(content_start..content_end)
2414 {
2415 emphasis_spans.push(EmphasisSpan {
2416 start: start_byte,
2417 end: range.end,
2418 content: content.to_string(),
2419 is_strong: false,
2420 is_strikethrough: false,
2421 uses_underscore,
2422 strikethrough_double: false,
2423 });
2424 }
2425 }
2426 }
2427 Event::Start(Tag::Strong) => {
2428 let uses_underscore = text.get(range.start..range.start + 2) == Some("__");
2429 strong_stack.push((range.start, uses_underscore));
2430 }
2431 Event::End(TagEnd::Strong) => {
2432 if let Some((start_byte, uses_underscore)) = strong_stack.pop() {
2433 let content_start = start_byte + 2;
2434 let content_end = range.end - 2;
2435 if content_end > content_start
2436 && let Some(content) = text.get(content_start..content_end)
2437 {
2438 emphasis_spans.push(EmphasisSpan {
2439 start: start_byte,
2440 end: range.end,
2441 content: content.to_string(),
2442 is_strong: true,
2443 is_strikethrough: false,
2444 uses_underscore,
2445 strikethrough_double: false,
2446 });
2447 }
2448 }
2449 }
2450 Event::Start(Tag::Strikethrough) => {
2451 strikethrough_stack.push(range.start);
2452 }
2453 Event::End(TagEnd::Strikethrough) => {
2454 if let Some(start_byte) = strikethrough_stack.pop() {
2455 let double = text.get(start_byte..start_byte + 2) == Some("~~");
2456 let marker_len = if double { 2 } else { 1 };
2457 let content_start = start_byte + marker_len;
2458 let content_end = range.end - marker_len;
2459 if content_end > content_start
2460 && let Some(content) = text.get(content_start..content_end)
2461 {
2462 emphasis_spans.push(EmphasisSpan {
2463 start: start_byte,
2464 end: range.end,
2465 content: content.to_string(),
2466 is_strong: false,
2467 is_strikethrough: true,
2468 uses_underscore: false,
2469 strikethrough_double: double,
2470 });
2471 }
2472 }
2473 }
2474 _ => {}
2475 }
2476 }
2477
2478 emphasis_spans.sort_by_key(|s| s.start);
2479 (emphasis_spans, code_spans)
2480}
2481
2482#[derive(Debug, Clone)]
2483struct CodeSpan {
2484 start: usize,
2485 end: usize,
2486}
2487
2488#[derive(Debug, Clone)]
2489struct LinkSpan {
2490 start: usize,
2491 end: usize,
2492 link_type: Option<LinkType>,
2493 is_image: bool,
2494 is_footnote: bool,
2495 /// How many links or images enclose this one. The image in
2496 /// `[](url)` sits at depth 1.
2497 depth: usize,
2498}
2499
2500/// The outermost links, images and footnote references in `text`, sorted by
2501/// start. The top level holds each of these whole, so a construct nested in
2502/// another is covered by the one enclosing it.
2503fn extract_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
2504 let mut spans = all_link_spans(text, defined_references);
2505 spans.retain(|span| span.depth == 0);
2506 spans
2507}
2508
2509/// Every link, image and footnote reference in `text`, nested ones included,
2510/// sorted by start.
2511fn all_link_spans(text: &str, defined_references: Option<&HashSet<String>>) -> Vec<LinkSpan> {
2512 // Links, images, and footnote references all open with `[`; skip the
2513 // parser entirely without one.
2514 if !text.contains('[') {
2515 return Vec::new();
2516 }
2517
2518 let mut spans = Vec::new();
2519 let mut options = Options::empty();
2520 options.insert(Options::ENABLE_FOOTNOTES);
2521
2522 // Reflow parses each paragraph in isolation, so the document's reference
2523 // definitions are never in scope. Without a broken-link callback,
2524 // pulldown-cmark would emit reference-style links (`[text][ref]`,
2525 // `[text][]`, `[text]`, `![alt][ref]`) as plain text, and reflow would wrap
2526 // their text mid-link. Resolving an unresolved reference to a dummy
2527 // destination makes pulldown emit the full link span so reflow treats it as
2528 // an atomic unit; the destination is unused because the element is rebuilt
2529 // verbatim from the source bytes.
2530 //
2531 // Full and collapsed references and reference images carry explicit
2532 // `][ref]` / `[]` syntax, so they are always resolved (atomic). A bare
2533 // shortcut `[text]` is ambiguous: it is only a real link when its label is
2534 // actually defined. With `Some(defined_references)` an undefined shortcut is
2535 // left unresolved (returns `None`) so it reflows as literal prose; with
2536 // `None` (no reference info) every shortcut stays atomic, which never splits
2537 // a real link.
2538 let resolve = move |link: BrokenLink<'_>| -> Option<(CowStr<'_>, CowStr<'_>)> {
2539 // The callback reports the syntactic reference type (`Shortcut` for a
2540 // bare `[text]`); the eventual emitted tag carries the `*Unknown`
2541 // variant. Only a bare shortcut is ambiguous - full and collapsed
2542 // references fall through and stay atomic.
2543 let atomic = match link.link_type {
2544 LinkType::Shortcut | LinkType::ShortcutUnknown => match defined_references {
2545 Some(defs) => defs.contains(&normalize_reference_label(link.reference.as_ref())),
2546 None => true,
2547 },
2548 _ => true,
2549 };
2550 atomic.then_some((CowStr::Borrowed(""), CowStr::Borrowed("")))
2551 };
2552 let parser = Parser::new_with_broken_link_callback(text, options, Some(resolve)).into_offset_iter();
2553 let mut stack = Vec::new();
2554
2555 for (event, range) in parser {
2556 match event {
2557 Event::Start(Tag::Link { link_type, .. }) => {
2558 stack.push((range.start, Some(link_type), false));
2559 }
2560 Event::Start(Tag::Image { link_type, .. }) => {
2561 stack.push((range.start, Some(link_type), true));
2562 }
2563 Event::End(TagEnd::Link | TagEnd::Image) => {
2564 if let Some((start_byte, link_type, is_image)) = stack.pop() {
2565 let mut end = range.end;
2566 if matches!(link_type, Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown))
2567 && text[end..].starts_with("[]")
2568 {
2569 end += 2;
2570 }
2571 spans.push(LinkSpan {
2572 start: start_byte,
2573 end,
2574 link_type,
2575 is_image,
2576 is_footnote: false,
2577 depth: stack.len(),
2578 });
2579 }
2580 }
2581 Event::FootnoteReference(_) => {
2582 spans.push(LinkSpan {
2583 start: range.start,
2584 end: range.end,
2585 link_type: None,
2586 is_image: false,
2587 is_footnote: true,
2588 depth: stack.len(),
2589 });
2590 }
2591 _ => {}
2592 }
2593 }
2594
2595 spans.sort_by_key(|s| s.start);
2596 spans
2597}
2598
2599/// If `text` starts with a MyST inline role (`` {name}`content` `` or
2600/// `` {domain:role}`content` ``), return the byte length of the whole role unit.
2601///
2602/// Mirrors the grammar in `lint_context::flavor_detection::detect_myst_role_ranges`:
2603/// a `{`, a name starting with an ASCII letter or `_` and continuing with
2604/// alphanumerics / `-` / `_` / `:` / `.`, a closing `}`, then a balanced inline
2605/// code span using one or more backticks. Returns `None` when any part is missing.
2606fn myst_role_len_at(text: &str, absolute_pos: usize, code_spans: &[CodeSpan]) -> Option<usize> {
2607 let bytes = text.as_bytes();
2608 if bytes.first() != Some(&b'{') {
2609 return None;
2610 }
2611
2612 // Role name.
2613 let mut j = 1;
2614 match bytes.get(j) {
2615 Some(&b) if b.is_ascii_alphabetic() || b == b'_' => {}
2616 _ => return None,
2617 }
2618 while let Some(&b) = bytes.get(j) {
2619 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.') {
2620 j += 1;
2621 } else {
2622 break;
2623 }
2624 }
2625 if bytes.get(j) != Some(&b'}') {
2626 return None;
2627 }
2628 j += 1; // past '}'
2629
2630 // Must be immediately followed by an inline code span.
2631 let code_span_start = absolute_pos + j;
2632 if let Ok(idx) = code_spans.binary_search_by_key(&code_span_start, |span| span.start) {
2633 let span = &code_spans[idx];
2634 let code_span_len = span.end - span.start;
2635 return Some(j + code_span_len);
2636 }
2637
2638 None
2639}
2640
2641/// Byte length of an inline-math span (`$math$`) starting at the very
2642/// beginning of `s`, if one starts there.
2643///
2644/// Mirrors INLINE_MATH_REGEX (`(?<!\$)\$(?!\$)([^\$]+)\$(?!\$)`) with the
2645/// leading lookbehind dropped: callers probe only at a slice start, where
2646/// the lookbehind passes vacuously.
2647fn inline_math_len_at_start(s: &str) -> Option<usize> {
2648 let bytes = s.as_bytes();
2649 // Opening `$` not followed by another `$` (that would be display math).
2650 if bytes.first() != Some(&b'$') || bytes.get(1) == Some(&b'$') {
2651 return None;
2652 }
2653 // Content is `[^$]+`: everything up to the closing `$`. It is non-empty
2654 // whenever a closing `$` exists, because the byte at index 1 is not `$`.
2655 let close = 1 + s[1..].find('$')?;
2656 // Closing `$` not followed by another `$`.
2657 if bytes.get(close + 1) == Some(&b'$') {
2658 return None;
2659 }
2660 Some(close + 1)
2661}
2662
2663/// Absolute byte offsets of a cached pattern match within the full input text.
2664#[derive(Clone, Copy, Debug)]
2665struct PatternMatch {
2666 start: usize,
2667 end: usize,
2668}
2669
2670/// Lazily-computed earliest match of one pattern within the unparsed suffix.
2671///
2672/// `parse_markdown_elements_inner` probes every pattern on every loop
2673/// iteration; re-running each search against the whole remaining suffix made
2674/// pathological inputs quadratic. The cache keeps the previous result as
2675/// absolute offsets: until the parse cursor moves past a cached match, that
2676/// match is still the earliest one, so the search is skipped.
2677///
2678/// This is sound only for patterns whose match at a given position does not
2679/// depend on where the searched slice starts (no `^`, no lookbehind): for
2680/// those, a cached miss stays a miss and a cached hit stays the earliest hit
2681/// as the cursor advances. A start-sensitive pattern needs a dedicated probe
2682/// at the cursor first (see the inline-math call site).
2683#[derive(Clone, Copy)]
2684enum PatternCache {
2685 Unsearched,
2686 NotFound,
2687 Found(PatternMatch),
2688}
2689
2690impl PatternCache {
2691 /// Returns the earliest match at or after `cursor` as offsets relative to
2692 /// `remaining` (the unparsed suffix starting at `cursor`), re-running
2693 /// `find` on the suffix only when the cached result no longer applies.
2694 fn earliest_in(
2695 &mut self,
2696 remaining: &str,
2697 cursor: usize,
2698 find: impl FnOnce(&str) -> Option<(usize, usize)>,
2699 ) -> Option<(usize, usize)> {
2700 let stale = match self {
2701 PatternCache::Found(pm) => pm.start < cursor,
2702 PatternCache::NotFound => false,
2703 PatternCache::Unsearched => true,
2704 };
2705 if stale {
2706 *self = match find(remaining) {
2707 Some((start, end)) => PatternCache::Found(PatternMatch {
2708 start: cursor + start,
2709 end: cursor + end,
2710 }),
2711 None => PatternCache::NotFound,
2712 };
2713 }
2714 match self {
2715 PatternCache::Found(pm) => Some((pm.start - cursor, pm.end - cursor)),
2716 _ => None,
2717 }
2718 }
2719}
2720
2721/// Parse markdown elements from text preserving the raw syntax.
2722///
2723/// Detection order is critical:
2724/// 1. Linked images `[](link)` - must be detected first as atomic units
2725/// 2. Inline images `` - before links to handle ! prefix
2726/// 3. Reference images `![alt][ref]` - before reference links
2727/// 4. Inline links `[text](url)` - before reference links
2728/// 5. Reference links `[text][ref]` - before shortcut references
2729/// 6. Shortcut reference links `[ref]` - detected last to avoid false positives
2730/// 7. Other elements (code, bold, italic, MyST roles, etc.) - processed normally
2731fn parse_markdown_elements_inner(
2732 text: &str,
2733 attr_lists: bool,
2734 myst_roles: bool,
2735 defined_references: Option<&HashSet<String>>,
2736) -> Vec<Element> {
2737 let mut elements = Vec::new();
2738 let mut remaining = text;
2739
2740 // Pre-extract emphasis spans, link spans, and code spans using pulldown-cmark.
2741 // Emphasis and code spans are extracted in a single shared parse to reduce cmark overhead.
2742 // Link spans must run as a separate parse because link resolution (the broken-link
2743 // callback) changes bracket collapses, which shifts delimiter range boundaries.
2744 let (emphasis_spans, code_spans) = extract_emphasis_and_code_spans(text);
2745 let link_spans = extract_link_spans(text, defined_references);
2746
2747 // One cache per probed pattern to avoid an O(N^2) worst case on long
2748 // inputs; see PatternCache for the validity rules.
2749 let mut cached_wiki_link = PatternCache::Unsearched;
2750 let mut cached_display_math = PatternCache::Unsearched;
2751 let mut cached_inline_math = PatternCache::Unsearched;
2752 let mut cached_emoji = PatternCache::Unsearched;
2753 let mut cached_html_entity = PatternCache::Unsearched;
2754 let mut cached_hugo_shortcode = PatternCache::Unsearched;
2755 let mut cached_html_tag = PatternCache::Unsearched;
2756 let mut cached_next_curly = PatternCache::Unsearched;
2757
2758 // Cursor indices into the sorted span lists: spans behind the parse cursor
2759 // can never match again, so each list is advanced monotonically instead of
2760 // rescanned from the start on every iteration.
2761 let mut link_span_idx = 0usize;
2762 let mut emphasis_span_idx = 0usize;
2763 let mut code_span_idx = 0usize;
2764
2765 while !remaining.is_empty() {
2766 // Calculate current byte offset in original text
2767 let current_offset = text.len() - remaining.len();
2768 // Find the earliest occurrence of any markdown pattern
2769 // Store (start, end, pattern_name) to unify regex and span-list results
2770 let mut earliest_match: Option<(usize, usize, &str)> = None;
2771
2772 // Find the earliest link span
2773 while link_span_idx < link_spans.len() && link_spans[link_span_idx].start < current_offset {
2774 link_span_idx += 1;
2775 }
2776 let next_link: Option<&LinkSpan> = link_spans.get(link_span_idx);
2777
2778 if let Some(span) = next_link {
2779 let pos_in_remaining = span.start - current_offset;
2780 if earliest_match
2781 .as_ref()
2782 .is_none_or(|(start, _, _)| pos_in_remaining < *start)
2783 {
2784 let match_end = span.end - current_offset;
2785 earliest_match = Some((pos_in_remaining, match_end, "link_span"));
2786 }
2787 }
2788
2789 // Check for wiki-style links - [[wiki]]
2790 if let Some((start, end)) = cached_wiki_link.earliest_in(remaining, current_offset, |suffix| {
2791 WIKI_LINK_REGEX.find(suffix).map(|m| (m.start(), m.end()))
2792 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2793 {
2794 earliest_match = Some((start, end, "wiki_link"));
2795 }
2796
2797 // Check for display math first (before inline) - $$math$$
2798 if let Some((start, end)) = cached_display_math.earliest_in(remaining, current_offset, |suffix| {
2799 DISPLAY_MATH_REGEX.find(suffix).map(|m| (m.start(), m.end()))
2800 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2801 {
2802 earliest_match = Some((start, end, "display_math"));
2803 }
2804
2805 // Check for inline math - $math$
2806 // INLINE_MATH_REGEX opens with the lookbehind `(?<!\$)`, which is
2807 // slice-start-sensitive: at the start of the searched slice there is
2808 // no preceding character, so the lookbehind trivially passes, while
2809 // the cached search, anchored earlier, saw the real `$` predecessor
2810 // and can have rejected the same position. Positions past the cursor
2811 // are unaffected by where the slice starts, so the cache stays valid
2812 // for them; only a match beginning exactly at the cursor can be
2813 // missing from it. When the cursor sits directly after a `$`, probe
2814 // for that one match in place, leaving the cache untouched. (Either
2815 // rescanning the suffix here or storing the probe hit in the cache is
2816 // quadratic on math-heavy inputs: each consumed span would trigger a
2817 // fresh scan of everything that follows.)
2818 let inline_math_probe = if current_offset > 0 && text.as_bytes()[current_offset - 1] == b'$' {
2819 inline_math_len_at_start(remaining).map(|len| (0, len))
2820 } else {
2821 None
2822 };
2823 if let Some((start, end)) = inline_math_probe.or_else(|| {
2824 cached_inline_math.earliest_in(remaining, current_offset, |suffix| {
2825 INLINE_MATH_REGEX
2826 .find(suffix)
2827 .ok()
2828 .flatten()
2829 .map(|m| (m.start(), m.end()))
2830 })
2831 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2832 {
2833 earliest_match = Some((start, end, "inline_math"));
2834 }
2835
2836 // Check for emoji shortcodes - :emoji:
2837 if let Some((start, end)) = cached_emoji.earliest_in(remaining, current_offset, |suffix| {
2838 EMOJI_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
2839 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2840 {
2841 earliest_match = Some((start, end, "emoji"));
2842 }
2843
2844 // Check for HTML entities - etc
2845 if let Some((start, end)) = cached_html_entity.earliest_in(remaining, current_offset, |suffix| {
2846 HTML_ENTITY_REGEX.find(suffix).map(|m| (m.start(), m.end()))
2847 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2848 {
2849 earliest_match = Some((start, end, "html_entity"));
2850 }
2851
2852 // Check for Hugo shortcodes - {{< ... >}} or {{% ... %}}
2853 // Must be checked before other patterns to avoid false sentence breaks
2854 if let Some((start, end)) = cached_hugo_shortcode.earliest_in(remaining, current_offset, |suffix| {
2855 HUGO_SHORTCODE_REGEX.find(suffix).map(|m| (m.start(), m.end()))
2856 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2857 {
2858 earliest_match = Some((start, end, "hugo_shortcode"));
2859 }
2860
2861 // Check for HTML tags - <tag> </tag> <tag/>
2862 // But exclude autolinks like <https://...> or <mailto:...> or email
2863 // autolinks <user@domain.com>: those are left for link_span handling.
2864 // The search skips past autolinks instead of giving up so the cache
2865 // lands on the first real tag; bailing out at an autolink would re-run
2866 // this scan from the same spot on every iteration.
2867 if let Some((start, end)) = cached_html_tag.earliest_in(remaining, current_offset, |suffix| {
2868 let mut from = 0;
2869 while let Some(m) = HTML_TAG_PATTERN.find(&suffix[from..]) {
2870 let (tag_start, tag_end) = (from + m.start(), from + m.end());
2871 let tag = &suffix[tag_start..tag_end];
2872 // Autolink starting with a protocol or mailto:?
2873 let is_url_autolink = tag.starts_with("<http://")
2874 || tag.starts_with("<https://")
2875 || tag.starts_with("<mailto:")
2876 || tag.starts_with("<ftp://")
2877 || tag.starts_with("<ftps://");
2878 // Email autolink (per CommonMark spec: <local@domain.tld>)?
2879 // Use centralized EMAIL_PATTERN for consistency with MD034 and other rules
2880 let is_email_autolink = {
2881 let content = tag.trim_start_matches('<').trim_end_matches('>');
2882 EMAIL_PATTERN.is_match(content)
2883 };
2884 if is_url_autolink || is_email_autolink {
2885 from = tag_end;
2886 } else {
2887 return Some((tag_start, tag_end));
2888 }
2889 }
2890 None
2891 }) && earliest_match.as_ref().is_none_or(|(s, _, _)| start < *s)
2892 {
2893 earliest_match = Some((start, end, "html_tag"));
2894 }
2895
2896 // Find earliest non-link special characters
2897 let mut next_special = remaining.len();
2898 let mut special_type = "";
2899 let mut pulldown_emphasis: Option<&EmphasisSpan> = None;
2900 let mut attr_list_len: usize = 0;
2901 let mut myst_role_len: usize = 0;
2902
2903 // Check for code spans using pulldown-cmark pre-extracted spans
2904 while code_span_idx < code_spans.len() && code_spans[code_span_idx].start < current_offset {
2905 code_span_idx += 1;
2906 }
2907 let next_code_span: Option<&CodeSpan> = code_spans.get(code_span_idx);
2908 if let Some(span) = next_code_span {
2909 let pos_in_remaining = span.start - current_offset;
2910 if pos_in_remaining < next_special {
2911 next_special = pos_in_remaining;
2912 special_type = "pulldown_code";
2913 }
2914 }
2915
2916 // Position of the next `{`, shared by the MyST-role and attr-list
2917 // probes below
2918 let next_curly_pos = cached_next_curly
2919 .earliest_in(remaining, current_offset, |suffix| {
2920 suffix.find('{').map(|pos| (pos, pos + 1))
2921 })
2922 .map(|(start, _)| start);
2923
2924 // Check for MyST inline roles - {role}`content` (e.g. {cite:p}`ref`).
2925 // Checked before the bare code-span handling so the role's trailing code
2926 // span is absorbed into the atomic role rather than split off, and before
2927 // attr lists since a role's `{` would otherwise be probed as an attr list.
2928 if myst_roles
2929 && let Some(pos) = next_curly_pos
2930 && pos < next_special
2931 && let Some(role_len) = myst_role_len_at(&remaining[pos..], current_offset + pos, &code_spans)
2932 {
2933 next_special = pos;
2934 special_type = "myst_role";
2935 myst_role_len = role_len;
2936 }
2937
2938 // Check for MkDocs/kramdown attr lists - {#id .class key="value"}
2939 if attr_lists
2940 && let Some(pos) = next_curly_pos
2941 && pos < next_special
2942 && let Some(m) = ATTR_LIST_PATTERN.find(&remaining[pos..])
2943 && m.start() == 0
2944 {
2945 next_special = pos;
2946 special_type = "attr_list";
2947 attr_list_len = m.end();
2948 }
2949
2950 // Check for emphasis using pulldown-cmark's pre-extracted spans
2951 while emphasis_span_idx < emphasis_spans.len() && emphasis_spans[emphasis_span_idx].start < current_offset {
2952 emphasis_span_idx += 1;
2953 }
2954 if let Some(span) = emphasis_spans.get(emphasis_span_idx) {
2955 let pos_in_remaining = span.start - current_offset;
2956 if pos_in_remaining < next_special {
2957 next_special = pos_in_remaining;
2958 special_type = "pulldown_emphasis";
2959 pulldown_emphasis = Some(span);
2960 }
2961 }
2962
2963 // Determine which pattern to process first
2964 let should_process_markdown_link = if let Some((pos, _, _)) = earliest_match {
2965 pos < next_special
2966 } else {
2967 false
2968 };
2969
2970 if should_process_markdown_link {
2971 let (pos, match_end, pattern_type) = earliest_match.unwrap();
2972
2973 // Add any text before the match
2974 if pos > 0 {
2975 elements.push(Element::Text(remaining[..pos].to_string()));
2976 }
2977
2978 // Process the matched pattern
2979 match pattern_type {
2980 "link_span" => {
2981 let span = next_link.unwrap();
2982 let raw_text = remaining[pos..match_end].to_string();
2983 if span.is_footnote {
2984 elements.push(Element::FootnoteReference(raw_text));
2985 } else if span.is_image {
2986 match span.link_type {
2987 Some(LinkType::Inline) => elements.push(Element::InlineImage(raw_text)),
2988 // `*Unknown` variants are produced when reflow's broken-link
2989 // callback resolves a reference whose definition is out of scope.
2990 Some(LinkType::Reference)
2991 | Some(LinkType::ReferenceUnknown)
2992 | Some(LinkType::Shortcut)
2993 | Some(LinkType::ShortcutUnknown) => elements.push(Element::ReferenceImage(raw_text)),
2994 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
2995 elements.push(Element::EmptyReferenceImage(raw_text))
2996 }
2997 _ => elements.push(Element::InlineImage(raw_text)),
2998 }
2999 } else {
3000 match span.link_type {
3001 Some(LinkType::Inline) => {
3002 if raw_text.starts_with('[') && raw_text.contains("![") {
3003 elements.push(Element::LinkedImage(raw_text));
3004 } else {
3005 elements.push(Element::Link(raw_text));
3006 }
3007 }
3008 // `*Unknown` variants are produced when reflow's broken-link
3009 // callback resolves a reference whose definition is out of scope.
3010 Some(LinkType::Reference) | Some(LinkType::ReferenceUnknown) => {
3011 elements.push(Element::ReferenceLink(raw_text))
3012 }
3013 Some(LinkType::Collapsed) | Some(LinkType::CollapsedUnknown) => {
3014 elements.push(Element::EmptyReferenceLink(raw_text))
3015 }
3016 Some(LinkType::Shortcut) | Some(LinkType::ShortcutUnknown) => {
3017 elements.push(Element::ShortcutReference(raw_text))
3018 }
3019 Some(LinkType::Autolink) | Some(LinkType::Email) => {
3020 elements.push(Element::Autolink(raw_text))
3021 }
3022 _ => elements.push(Element::Link(raw_text)),
3023 }
3024 }
3025 remaining = &remaining[match_end..];
3026 }
3027 "wiki_link" => {
3028 if let Some(caps) = WIKI_LINK_REGEX.captures(remaining) {
3029 let content = caps.get(1).map_or("", |m| m.as_str());
3030 elements.push(Element::WikiLink(content.to_string()));
3031 remaining = &remaining[match_end..];
3032 } else {
3033 elements.push(Element::Text("[[".to_string()));
3034 remaining = &remaining[2..];
3035 }
3036 }
3037 "display_math" => {
3038 if let Some(caps) = DISPLAY_MATH_REGEX.captures(remaining) {
3039 let math = caps.get(1).map_or("", |m| m.as_str());
3040 elements.push(Element::DisplayMath(math.to_string()));
3041 remaining = &remaining[match_end..];
3042 } else {
3043 elements.push(Element::Text("$$".to_string()));
3044 remaining = &remaining[2..];
3045 }
3046 }
3047 "inline_math" => {
3048 if let Ok(Some(caps)) = INLINE_MATH_REGEX.captures(remaining) {
3049 let math = caps.get(1).map_or("", |m| m.as_str());
3050 elements.push(Element::InlineMath(math.to_string()));
3051 remaining = &remaining[match_end..];
3052 } else {
3053 elements.push(Element::Text("$".to_string()));
3054 remaining = &remaining[1..];
3055 }
3056 }
3057 "emoji" => {
3058 if let Some(caps) = EMOJI_SHORTCODE_REGEX.captures(remaining) {
3059 let emoji = caps.get(1).map_or("", |m| m.as_str());
3060 elements.push(Element::EmojiShortcode(emoji.to_string()));
3061 remaining = &remaining[match_end..];
3062 } else {
3063 elements.push(Element::Text(":".to_string()));
3064 remaining = &remaining[1..];
3065 }
3066 }
3067 "html_entity" => {
3068 // HTML entities are captured whole
3069 elements.push(Element::HtmlEntity(remaining[pos..match_end].to_string()));
3070 remaining = &remaining[match_end..];
3071 }
3072 "hugo_shortcode" => {
3073 // Hugo shortcodes are atomic elements - preserve them exactly
3074 elements.push(Element::HugoShortcode(remaining[pos..match_end].to_string()));
3075 remaining = &remaining[match_end..];
3076 }
3077 "html_tag" => {
3078 // HTML tags are captured whole
3079 elements.push(Element::HtmlTag(remaining[pos..match_end].to_string()));
3080 remaining = &remaining[match_end..];
3081 }
3082 _ => unreachable!("unknown pattern type: {}", pattern_type),
3083 }
3084 } else {
3085 // Process non-link special characters
3086
3087 // Add any text before the special character
3088 if next_special > 0 && next_special < remaining.len() {
3089 elements.push(Element::Text(remaining[..next_special].to_string()));
3090 remaining = &remaining[next_special..];
3091 }
3092
3093 // Process the special element
3094 match special_type {
3095 "pulldown_code" => {
3096 let span = next_code_span.unwrap();
3097 let span_len = span.end - span.start;
3098 let code_raw = &remaining[..span_len];
3099 if let Some((content, marker)) = decompose_code_span(code_raw) {
3100 elements.push(Element::Code {
3101 content: content.to_string(),
3102 marker: marker.to_string(),
3103 });
3104 } else {
3105 elements.push(Element::Text(code_raw.to_string()));
3106 }
3107 remaining = &remaining[span_len..];
3108 }
3109 "attr_list" => {
3110 elements.push(Element::AttrList(remaining[..attr_list_len].to_string()));
3111 remaining = &remaining[attr_list_len..];
3112 }
3113 "myst_role" => {
3114 elements.push(Element::MystRole(remaining[..myst_role_len].to_string()));
3115 remaining = &remaining[myst_role_len..];
3116 }
3117 "pulldown_emphasis" => {
3118 // Use pre-extracted emphasis/strikethrough span from pulldown-cmark
3119 let span = pulldown_emphasis.expect("pulldown_emphasis must be set");
3120 let span_len = span.end - span.start;
3121 if span.is_strikethrough {
3122 elements.push(Element::Strikethrough {
3123 content: span.content.clone(),
3124 double: span.strikethrough_double,
3125 });
3126 } else if span.is_strong {
3127 elements.push(Element::Bold {
3128 content: span.content.clone(),
3129 underscore: span.uses_underscore,
3130 });
3131 } else {
3132 elements.push(Element::Italic {
3133 content: span.content.clone(),
3134 underscore: span.uses_underscore,
3135 });
3136 }
3137 remaining = &remaining[span_len..];
3138 }
3139 _ => {
3140 // No special elements found, add all remaining text
3141 elements.push(Element::Text(remaining.to_string()));
3142 break;
3143 }
3144 }
3145 }
3146 }
3147
3148 // Merge contiguous text elements to clean up the output.
3149 let mut merged_elements = Vec::new();
3150 for el in elements {
3151 match el {
3152 Element::Text(s) => {
3153 if let Some(Element::Text(last_s)) = merged_elements.last_mut() {
3154 last_s.push_str(&s);
3155 } else {
3156 merged_elements.push(Element::Text(s));
3157 }
3158 }
3159 other => merged_elements.push(other),
3160 }
3161 }
3162 merged_elements
3163}
3164
3165/// The whitespace the source put in front of the element at `idx`, as reflow
3166/// should re-emit it.
3167///
3168/// A span carries no whitespace of its own, so the gap before it lives at the
3169/// end of the text element preceding it, which the sentence and clause paths
3170/// trim away as they accumulate. Reading the gap back from the source is what
3171/// keeps a standalone `-` from being glued onto the span after it: the
3172/// characters a line happens to end with cannot tell a dash that closes a word
3173/// from one that stands alone, and the same holds for a bracket or paren.
3174///
3175/// A run of breakable whitespace renders as one space and comes back as one. A
3176/// non-breaking space is a character the reader sees, so a gap containing one
3177/// is carried through exactly as written.
3178fn source_gap_before(elements: &[Element], idx: usize) -> &str {
3179 let Some(Element::Text(previous)) = idx.checked_sub(1).map(|prev| &elements[prev]) else {
3180 return "";
3181 };
3182
3183 let gap = &previous[previous.trim_end_matches(char::is_whitespace).len()..];
3184 if gap.is_empty() {
3185 ""
3186 } else if gap.contains(is_non_breaking_space) {
3187 gap
3188 } else {
3189 " "
3190 }
3191}
3192
3193/// Open `gap` before the element about to be appended, unless the line has
3194/// nothing for it to follow or already ends with whitespace of its own.
3195fn push_source_gap(current_line: &mut String, gap: &str) {
3196 if !gap.is_empty() && !current_line.is_empty() && !current_line.ends_with(char::is_whitespace) {
3197 current_line.push_str(gap);
3198 }
3199}
3200
3201/// True when `text` consists solely of setext-underline or thematic-break
3202/// characters: a run of `=` or `-` (setext underline, any count, no internal
3203/// spaces) or 3+ `-`/`*`/`_` optionally separated by spaces (thematic break).
3204/// A paragraph-continuation line like this converts the previous line into a
3205/// heading or inserts a horizontal rule.
3206fn is_setext_or_thematic(text: &str) -> bool {
3207 let mut marker = 0u8;
3208 let mut count = 0usize;
3209 let mut has_space = false;
3210 for &b in text.as_bytes() {
3211 match b {
3212 b' ' | b'\t' => has_space = true,
3213 b'-' | b'=' | b'*' | b'_' => {
3214 if marker == 0 {
3215 marker = b;
3216 } else if b != marker {
3217 return false;
3218 }
3219 count += 1;
3220 }
3221 _ => return false,
3222 }
3223 }
3224 match marker {
3225 b'=' => !has_space,
3226 b'-' => !has_space || count >= 3,
3227 b'*' | b'_' => count >= 3,
3228 _ => false,
3229 }
3230}
3231
3232/// True when `text`, placed at the start of a paragraph-continuation line,
3233/// would be re-parsed as opening a block construct - a list item (`- `, `* `,
3234/// `+ `, `1. `, `1) `), blockquote (`>`), ATX heading (`# `), code fence
3235/// (3+ backticks or tildes), thematic break, setext underline, footnote or
3236/// link-reference definition (`[^note]:`, `[label]: url`), or HTML block
3237/// (`<div>` and the other block-level tags rumdl's parser recognizes).
3238/// Reflow must never start a wrapped line with such content: prose that was
3239/// harmless mid-line becomes real block syntax at line start, silently
3240/// changing the document's structure (a `- ` clause becomes a nested list
3241/// item, a `# ` becomes a heading, a `[ref]: url` turns a dangling reference
3242/// elsewhere in the document into a live link, and so on).
3243fn starts_block_construct(text: &str) -> bool {
3244 let text = text.trim_start();
3245 let bytes = text.as_bytes();
3246 let Some(&first) = bytes.first() else {
3247 return false;
3248 };
3249 let marker_then_boundary = |len: usize| bytes.len() == len || bytes[len] == b' ' || bytes[len] == b'\t';
3250 match first {
3251 // A blockquote marker needs no following space
3252 b'>' => true,
3253 b'-' | b'*' | b'+' => marker_then_boundary(1) || is_setext_or_thematic(text),
3254 b'_' | b'=' => is_setext_or_thematic(text),
3255 // A leading colon opens a definition, and three of them open a fenced div.
3256 b':' => true,
3257 b'|' => true,
3258 b'#' => {
3259 let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
3260 hashes <= 6 && marker_then_boundary(hashes)
3261 }
3262 b'`' => bytes.iter().take_while(|&&b| b == b'`').count() >= 3,
3263 b'~' => bytes.iter().take_while(|&&b| b == b'~').count() >= 3,
3264 // An ordered list is the one construct here that cannot always
3265 // interrupt a paragraph: it does so only when it is numbered 1 and its
3266 // first item has content. `7. item` and a bare `123456.` are prose to
3267 // the parser, so guarding them would refuse a legal wrap and leave an
3268 // unfixable long line. Leading zeros still make the number 1 (`01.`),
3269 // and a marker is at most 9 digits.
3270 b'0'..=b'9' => {
3271 let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
3272 digits <= 9
3273 && text[..digits].trim_start_matches('0') == "1"
3274 && bytes.len() > digits + 1
3275 && (bytes[digits] == b'.' || bytes[digits] == b')')
3276 && (bytes[digits + 1] == b' ' || bytes[digits + 1] == b'\t')
3277 }
3278 // Footnote/link-reference definition: `[label]:` anchored at line
3279 // start, meaning the label's own closing bracket is immediately
3280 // followed by a colon ("[ref]: url", "[^1]: note" - but not
3281 // "[a](b) [ref]:", whose first bracket is an inline link). rumdl's
3282 // parser recognizes definitions even on paragraph-continuation lines,
3283 // so hoisting one to line start reclassifies it (and can resolve
3284 // dangling references elsewhere in the document).
3285 b'[' => {
3286 let mut escaped = false;
3287 let mut label_close = None;
3288 for (i, &b) in bytes.iter().enumerate().skip(1) {
3289 if escaped {
3290 escaped = false;
3291 } else if b == b'\\' {
3292 escaped = true;
3293 } else if b == b']' {
3294 label_close = Some(i);
3295 break;
3296 }
3297 }
3298 label_close.is_some_and(|i| bytes.get(i + 1) == Some(&b':'))
3299 }
3300 // Block-level HTML tag per rumdl's parser (shared predicate, so the
3301 // guard cannot drift from what lint_context classifies as a block).
3302 b'<' => crate::utils::html_block::parse_html_block_start(text).is_some(),
3303 _ => false,
3304 }
3305}
3306
3307/// Merge any reflowed continuation line that would open a block construct back
3308/// into the previous line. This is the safety net behind the per-break-site
3309/// guards: no matter which emitter produced the lines, a wrapped continuation
3310/// must never turn prose into a list item, heading, blockquote, code fence, or
3311/// horizontal rule. The first line keeps its position - it replaces the
3312/// paragraph's original start, where the source already established the
3313/// context. The merged line may exceed the configured width; a long line is
3314/// the correct failure direction, corrupted structure is not.
3315fn merge_block_construct_continuations(lines: Vec<String>) -> Vec<String> {
3316 let mut merged: Vec<String> = Vec::with_capacity(lines.len());
3317 for line in lines {
3318 merged.push(line);
3319 // A merge can itself produce an opener: a line holding just `1.` is
3320 // inert on its own, but absorbing a following `[ref]:` turns it into
3321 // `1. [ref]:`, a real list item. Keep folding until the tail is inert.
3322 while merged.len() > 1 && starts_block_construct(merged.last().expect("non-empty")) {
3323 let last = merged.pop().expect("non-empty");
3324 let prev = merged.last_mut().expect("len > 1");
3325 prev.push(' ');
3326 prev.push_str(last.trim_start());
3327 }
3328 }
3329 merged
3330}
3331
3332/// The paragraph's source text as [`reflow_elements_sentence_per_line`]
3333/// assembles it, together with the byte range each element's own text occupies
3334/// in it.
3335///
3336/// The ranges are what lets a line under construction be a range of the
3337/// paragraph: a line runs from where an element or a held-back sentence begins
3338/// to where the last element absorbed into it ends.
3339fn elements_source_text(elements: &[Element]) -> (String, Vec<(usize, usize)>) {
3340 let mut text = String::new();
3341 let mut ranges = Vec::with_capacity(elements.len());
3342 for (idx, element) in elements.iter().enumerate() {
3343 let gap = source_gap_before(elements, idx);
3344 let piece = match element {
3345 // Text already carries its own spacing from tokenization.
3346 Element::Text(content) => content.clone(),
3347 Element::Italic { content, underscore } => {
3348 wrap_emphasis(content, if *underscore { "_" } else { "*" }, &mut text, gap)
3349 }
3350 Element::Bold { content, underscore } => {
3351 wrap_emphasis(content, if *underscore { "__" } else { "**" }, &mut text, gap)
3352 }
3353 Element::Strikethrough { content, double } => {
3354 wrap_emphasis(content, if *double { "~~" } else { "~" }, &mut text, gap)
3355 }
3356 _ => {
3357 push_source_gap(&mut text, gap);
3358 element.to_string()
3359 }
3360 };
3361 let start = text.len();
3362 text.push_str(&piece);
3363 ranges.push((start, text.len()));
3364 }
3365 (text, ranges)
3366}
3367
3368/// Reflow elements for sentence-per-line mode
3369fn reflow_elements_sentence_per_line(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
3370 let abbreviations = get_abbreviations(&options.abbreviations);
3371 let require_sentence_capital = options.require_sentence_capital;
3372 let mut lines = Vec::new();
3373
3374 // The inline structure is read off one parse of the whole paragraph. A line
3375 // under construction holds the tail of the text, and a parse of that tail
3376 // alone reads it differently: the runs closing spans that opened in an
3377 // already emitted sentence have no opener left in it and read as openers,
3378 // and three backticks at its head open a code fence and swallow the link
3379 // after them, so the sentence breaks on the wrong side of a run or inside
3380 // a link.
3381 let (paragraph_text, piece_ranges) = elements_source_text(elements);
3382 let paragraph = sentence_structure(¶graph_text, options.defined_references.as_ref());
3383 // The spans the paragraph pairs as written, read once for every cut in it.
3384 let paragraph_emphasis = EmphasisSpans::default();
3385 let structure_from = |base: usize| ParagraphStructure {
3386 text: ¶graph_text,
3387 structure: ¶graph,
3388 emphasis: ¶graph_emphasis,
3389 base,
3390 };
3391 // Where the line under construction begins in the paragraph, or `None` for
3392 // an empty line. The line is a byte range of the paragraph: it begins where
3393 // an element or a sentence held back from an earlier element begins and
3394 // runs to the end of the last element absorbed into it, so its bytes are
3395 // the paragraph's own, the gap in front of an element it absorbs is the gap
3396 // the source had there, and the structure read off the paragraph applies
3397 // to it from its start.
3398 let mut line: Option<usize> = None;
3399 // The line beginning at `line_start` once the element occupying `piece` is
3400 // absorbed into it, as its byte range and the structure the paragraph has
3401 // from that start. A line begins at or in front of the element it absorbs,
3402 // since everything on it was read from earlier elements. A start past the
3403 // element is a bug in this loop, so the debug build says so and the
3404 // release build takes the element on its own, with no structure to split
3405 // it by, and leaves it whole.
3406 let absorb = |line_start: usize, (piece_start, piece_end): (usize, usize)| {
3407 let in_front = line_start <= piece_start;
3408 debug_assert!(
3409 in_front,
3410 "line under construction begins at {line_start}, past the element at {piece_start} of {paragraph_text:?}"
3411 );
3412 if in_front {
3413 (line_start, piece_end, Some(structure_from(line_start)))
3414 } else {
3415 (piece_start, piece_end, None)
3416 }
3417 };
3418
3419 for (idx, element) in elements.iter().enumerate() {
3420 let (line_start, line_end, structure) = absorb(line.unwrap_or(piece_ranges[idx].0), piece_ranges[idx]);
3421 let combined = ¶graph_text[line_start..line_end];
3422
3423 // Text and emphasis are absorbed the same way. An emphasis span is
3424 // rendered back to its source form and then treated as ordinary text,
3425 // so a sentence boundary inside it breaks the line without closing and
3426 // reopening the markers: a line break inside a span is whitespace, and
3427 // whitespace is all a reflow is allowed to change.
3428 let splits = matches!(
3429 element,
3430 Element::Text(_) | Element::Italic { .. } | Element::Bold { .. } | Element::Strikethrough { .. }
3431 );
3432
3433 if splits {
3434 // Use the pre-computed abbreviations set to avoid redundant computation.
3435 // Without the structure the line is left whole: one range covering it.
3436 let sentences = match structure {
3437 Some(structure) => split_into_sentence_ranges(
3438 combined,
3439 &abbreviations,
3440 require_sentence_capital,
3441 options.defined_references.as_ref(),
3442 Some(structure),
3443 ),
3444 None => trim_range(combined, 0, combined.len()).into_iter().collect(),
3445 };
3446
3447 // A bracketed element right after the piece may hold the sentence
3448 // in front of it open: `Claim ends here. [smith](url) continues.`
3449 // is one sentence to the splitter, since the link's text starts
3450 // with a lowercase letter, and so is `Claim ends here. [Smith 2020]`.
3451 // The splitter decides by reading the sentence with the element
3452 // appended, so the check counting sentences and this reflow agree
3453 // on where the line breaks. Every other kind of element closes the
3454 // sentence in front of it.
3455 let next_bracketed = elements
3456 .get(idx + 1)
3457 .filter(|next| next.opens_with_bracket())
3458 .map(|next| (source_gap_before(elements, idx + 1), next.to_string()));
3459 let closes_before_next = |sentence: &str| -> bool {
3460 let Some((gap, next_str)) = &next_bracketed else {
3461 return true;
3462 };
3463 let mut probe = sentence.to_string();
3464 push_source_gap(&mut probe, gap);
3465 probe.push_str(next_str);
3466 let probe_sentences = split_into_sentences_with_set(
3467 &probe,
3468 &abbreviations,
3469 require_sentence_capital,
3470 options.defined_references.as_ref(),
3471 None,
3472 );
3473 probe_sentences.last().is_some_and(|last| last == next_str)
3474 };
3475
3476 if sentences.len() > 1 {
3477 // Accumulate rather than emit-and-overwrite: a sentence held
3478 // back for the next element must absorb what follows it, or the
3479 // text that follows would reach the output ahead of it. What is
3480 // held is the text between the two cuts, so the line carried on
3481 // stays the paragraph's own bytes.
3482 let mut pending_start = None;
3483 let last = sentences.len() - 1;
3484 for (i, &(start, end)) in sentences.iter().enumerate() {
3485 let pending = &combined[*pending_start.get_or_insert(start)..end];
3486
3487 // The splitter already decided every boundary except the
3488 // final one, which is just the leftover tail. Hold a tail
3489 // that no punctuation closed, and hold any piece ending in
3490 // an abbreviation the splitter broke after regardless.
3491 let closed = i < last || (ends_with_sentence_punct(pending) && closes_before_next(pending));
3492 if closed && !text_ends_with_abbreviation(pending, &abbreviations) {
3493 lines.push(pending.to_string());
3494 pending_start = None;
3495 }
3496 }
3497 line = pending_start.map(|held| line_start + held);
3498 } else {
3499 // Single sentence - check if it's complete
3500 let trimmed = combined.trim();
3501
3502 // If the combined result is only whitespace, don't accumulate it.
3503 // This prevents leading spaces on subsequent elements when lines
3504 // are joined with spaces during reflow iteration.
3505 if trimmed.is_empty() {
3506 continue;
3507 }
3508
3509 let ends_with_sentence_punct = ends_with_sentence_punct(trimmed);
3510
3511 if ends_with_sentence_punct
3512 && !text_ends_with_abbreviation(trimmed, &abbreviations)
3513 && closes_before_next(trimmed)
3514 {
3515 // Complete single sentence - emit it (trimming only
3516 // breakable whitespace so edge NBSPs survive)
3517 lines.push(combined.trim_matches(is_breakable_whitespace).to_string());
3518 line = None;
3519 } else {
3520 // Incomplete sentence - continue accumulating
3521 line = Some(line_start);
3522 }
3523 }
3524 } else {
3525 // Non-text, non-emphasis elements (Code, Links, etc.) join the
3526 // line without the splitter reading them.
3527 line = Some(line_start);
3528 }
3529 }
3530
3531 // Add any remaining content.
3532 //
3533 // An atomic element (a code span, a link, an autolink) is appended without
3534 // the splitter ever reading it, on the understanding that a later text
3535 // element re-splits the line. A trailing one has no later element, so a
3536 // sentence boundary in front of it is taken here or lost, and a lost one
3537 // leaves `check` reporting a paragraph that `fmt` will not break.
3538 //
3539 // Not when the tail carries a non-breaking space. The splitter trims each
3540 // sentence with `str::trim`, which counts one as whitespace, and the edge
3541 // trimming below exists precisely to keep it.
3542 if let Some(line_start) = line {
3543 // The leftover runs from where the line began to the end of the
3544 // paragraph, and is split with the structure the paragraph has from
3545 // there.
3546 let tail = ¶graph_text[line_start..];
3547 let split_tail = (!tail.contains(is_non_breaking_space))
3548 .then(|| {
3549 split_into_sentences_with_set(
3550 tail,
3551 &abbreviations,
3552 require_sentence_capital,
3553 options.defined_references.as_ref(),
3554 Some(structure_from(line_start)),
3555 )
3556 })
3557 .filter(|sentences| sentences.len() > 1);
3558
3559 match split_tail {
3560 Some(sentences) => lines.extend(sentences),
3561 None => lines.push(tail.trim_matches(is_breakable_whitespace).to_string()),
3562 }
3563 }
3564 lines
3565}
3566
3567/// Restore an emphasis span to its source form, opening the gap the source had
3568/// in front of it. Unlike text elements, a span carries no surrounding
3569/// whitespace of its own.
3570fn wrap_emphasis(content: &str, marker: &str, current_line: &mut String, gap: &str) -> String {
3571 push_source_gap(current_line, gap);
3572 format!("{marker}{content}{marker}")
3573}
3574
3575/// English break-words used for semantic line break splitting.
3576/// These are conjunctions and relative pronouns where a line break
3577/// reads naturally.
3578const BREAK_WORDS: &[&str] = &[
3579 "and",
3580 "or",
3581 "but",
3582 "nor",
3583 "yet",
3584 "so",
3585 "for",
3586 "which",
3587 "that",
3588 "because",
3589 "when",
3590 "if",
3591 "while",
3592 "where",
3593 "although",
3594 "though",
3595 "unless",
3596 "since",
3597 "after",
3598 "before",
3599 "until",
3600 "as",
3601 "once",
3602 "whether",
3603 "however",
3604 "therefore",
3605 "moreover",
3606 "furthermore",
3607 "nevertheless",
3608 "whereas",
3609];
3610
3611/// Check if a character is clause punctuation for semantic line breaks
3612fn is_clause_punctuation(c: char) -> bool {
3613 matches!(c, ',' | ';' | ':' | '\u{2014}') // comma, semicolon, colon, em dash
3614}
3615
3616/// Whether a clause-punctuation char at `chars[i]` is a legitimate break point.
3617///
3618/// A real clause boundary is followed by breakable whitespace (or ends the
3619/// text). Two reasons, and they agree: `,;:` with no following space sit
3620/// *inside* a token (`16:9`, `key:value`, a MyST role like `{cite:p}`), and a
3621/// line break renders as a space, so breaking where the source has none inserts
3622/// one. That holds for the em dash too: `cost—benefit` renders as one word,
3623/// `cost—\nbenefit` as two. A non-breaking space is not a boundary either: it
3624/// exists to forbid the break, so the scan keeps looking for an earlier one.
3625fn clause_break_allowed_after(chars: &[char], i: usize) -> bool {
3626 match chars.get(i + 1) {
3627 None => true,
3628 Some(next) => is_breakable_whitespace(*next),
3629 }
3630}
3631
3632/// Find the closing `)` that balances the `(` at the start of `slice`.
3633///
3634/// `offset` is the byte position of the `(` in the original full-line string;
3635/// it is used to translate local byte positions into global positions for
3636/// element-span lookups. Parens inside markdown element spans are skipped so
3637/// that, e.g., the closing `)` of an inline link does not prematurely end the
3638/// scan. The char's *start* byte (not byte-after) is used for the span check
3639/// so that closing element delimiters — which sit exactly at the span's
3640/// exclusive-end boundary — are correctly excluded.
3641///
3642/// Returns `(end_local, inner)` where `end_local` is the byte offset within
3643/// `slice` just past the closing `)`, and `inner` is the content between the
3644/// outermost `(` and `)`.
3645fn paren_group_end<'a>(slice: &'a str, element_spans: &[ElementSpan], offset: usize) -> Option<(usize, &'a str)> {
3646 debug_assert!(slice.starts_with('('));
3647 let mut depth: i32 = 0;
3648 for (local_byte, c) in slice.char_indices() {
3649 let global_byte = offset + local_byte;
3650 // When depth > 0, skip parens that belong to a markdown element.
3651 // Use the char's start byte so that a closing element delimiter
3652 // (whose byte_after equals the span's exclusive end) is treated as
3653 // inside the element rather than outside it.
3654 if depth > 0 && is_inside_element(global_byte, element_spans) {
3655 continue;
3656 }
3657 match c {
3658 '(' => depth += 1,
3659 ')' => {
3660 depth -= 1;
3661 if depth == 0 {
3662 let end = local_byte + 1;
3663 let inner = &slice[1..local_byte];
3664 return Some((end, inner));
3665 }
3666 }
3667 _ => {}
3668 }
3669 }
3670 None
3671}
3672
3673/// Split a line at a parenthetical boundary for semantic line breaks.
3674///
3675/// Two strategies are tried in order:
3676///
3677/// 1. **Leading parenthetical** — if the line begins with `(`, isolate the
3678/// entire balanced group on this line and start the rest on the next.
3679/// This handles lines produced by a prior split that placed a `(` at the
3680/// very beginning.
3681///
3682/// 2. **Mid-line parenthetical** — find the rightmost balanced `(…)` whose
3683/// content spans multiple words and whose preceding text fits within
3684/// `[min_first_len, line_length]`. Split just before the `(` so the
3685/// parenthetical begins the following line.
3686///
3687/// Parentheses that fall inside markdown element spans (links, code, etc.)
3688/// are ignored in both strategies.
3689fn split_at_parenthetical(
3690 text: &str,
3691 line_length: usize,
3692 element_spans: &[ElementSpan],
3693 length_mode: ReflowLengthMode,
3694) -> Option<(String, String)> {
3695 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
3696
3697 // Strategy 1: text starts with '(' — isolate the parenthetical as its own line.
3698 if text.starts_with('(')
3699 && let Some((end_local, inner)) = paren_group_end(text, element_spans, 0)
3700 && inner.contains(' ')
3701 {
3702 // Whatever follows the closing ')' up to the next breakable whitespace
3703 // belongs to the parenthetical: a break there would render as a space the
3704 // text does not have, and it would orphan the punctuation (`).`, `),`,
3705 // `)"`) at the head of the continuation line. Whitespace inside an inline
3706 // element is that element's own content, so a boundary landing there is
3707 // no boundary at all and the scan resumes past the element.
3708 let mut first_end = end_local;
3709 loop {
3710 first_end += text[first_end..]
3711 .char_indices()
3712 .take_while(|(_, c)| !is_breakable_whitespace(*c))
3713 .last()
3714 .map_or(0, |(idx, c)| idx + c.len_utf8());
3715 match element_containing(first_end, element_spans) {
3716 Some(span) => first_end = span.end,
3717 None => break,
3718 }
3719 }
3720 let rest_start = first_end;
3721 let first = &text[..first_end];
3722 // No MIN_SPLIT_RATIO check: a parenthetical unit is always a valid
3723 // semantic line regardless of its length.
3724 if measure(first, 0, element_spans, length_mode).fits(line_length) {
3725 let rest = text[rest_start..].trim_start();
3726 if !rest.is_empty() {
3727 return Some((first.to_string(), rest.to_string()));
3728 }
3729 }
3730 }
3731
3732 // Strategy 2: find the rightmost multi-word '(' whose preceding text fits.
3733 let mut best_open_byte: Option<usize> = None;
3734 let mut pos = 0usize;
3735 while pos < text.len() {
3736 // '(' is ASCII so a single-byte comparison is safe in UTF-8.
3737 if text.as_bytes()[pos] != b'(' {
3738 let c = text[pos..].chars().next().unwrap();
3739 pos += c.len_utf8();
3740 continue;
3741 }
3742 // Skip '(' that are part of a markdown element (use start byte).
3743 if is_inside_element(pos, element_spans) {
3744 pos += 1;
3745 continue;
3746 }
3747 if let Some((end_local, inner)) = paren_group_end(&text[pos..], element_spans, pos) {
3748 let first = text[..pos].trim_end_matches(is_breakable_whitespace);
3749 let first_len = measure(first, 0, element_spans, length_mode).effective();
3750 // The '(' must follow breakable whitespace: splitting `f(a b)` into
3751 // `f` and `(a b)` would render as `f (a b)`.
3752 if first.len() < pos
3753 && !first.is_empty()
3754 && first_len >= min_first_len
3755 && first_len <= line_length
3756 && inner.contains(' ')
3757 && best_open_byte.is_none_or(|prev| pos > prev)
3758 {
3759 best_open_byte = Some(pos);
3760 }
3761 pos += end_local;
3762 } else {
3763 pos += 1;
3764 }
3765 }
3766
3767 let open_byte = best_open_byte?;
3768 let first = text[..open_byte].trim_end_matches(is_breakable_whitespace).to_string();
3769 let rest = text[open_byte..].to_string();
3770 if first.is_empty() || rest.trim().is_empty() {
3771 return None;
3772 }
3773 Some((first, rest))
3774}
3775
3776/// A non-Text element's byte span in a flat text representation, with the
3777/// columns each of the checker's exemptions forgives it.
3778///
3779/// The offsets exist so a split position can be kept out of an element; the
3780/// savings exist so a substring can be measured the way the checker measures it.
3781/// Both are computed from the same walk over the same elements, which is what
3782/// keeps them consistent.
3783#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3784struct ElementSpan {
3785 start: usize,
3786 end: usize,
3787 full: usize,
3788 /// Columns the link/image URL exemption forgives, zero when it is off or
3789 /// does not apply to this element.
3790 link_saving: usize,
3791 /// Columns the code-span exemption forgives, on the same terms.
3792 code_saving: usize,
3793 /// Whether this element is hard atomic (cannot be broken even as fallback)
3794 is_hard: bool,
3795}
3796
3797impl ElementSpan {
3798 /// A span covering `len` bytes from `start`, for an element whose full
3799 /// width is `full` and whose exempt widths are `width`.
3800 fn new(start: usize, len: usize, full: usize, width: LineWidth, is_hard: bool) -> Self {
3801 Self {
3802 start,
3803 end: start + len,
3804 full,
3805 link_saving: full - width.link_exempt,
3806 code_saving: full - width.code_exempt,
3807 is_hard,
3808 }
3809 }
3810
3811 fn contains(&self, pos: usize) -> bool {
3812 pos > self.start && pos < self.end
3813 }
3814
3815 fn within(&self, start: usize, end: usize) -> bool {
3816 self.start >= start && self.end <= end
3817 }
3818
3819 fn exempt_width(&self) -> LineWidth {
3820 LineWidth {
3821 link_exempt: self.full - self.link_saving,
3822 code_exempt: self.full - self.code_saving,
3823 }
3824 }
3825}
3826
3827/// The wrappable text of a link or image element, with the source around it:
3828/// `(prefix, inner, suffix)` where `prefix` is `[` or `![`, `inner` the
3829/// bracketed text, and `suffix` everything from the closing `]` on. `None`
3830/// when the element's text may not wrap: the option is off, or the element
3831/// has no prose text to wrap (a linked image's "text" is an image).
3832fn link_text_parts(element: &Element, break_link_text: bool) -> Option<(&str, &str, &str)> {
3833 if !break_link_text {
3834 return None;
3835 }
3836 let (raw, open) = match element {
3837 Element::Link(raw)
3838 | Element::ReferenceLink(raw)
3839 | Element::EmptyReferenceLink(raw)
3840 | Element::ShortcutReference(raw) => (raw.as_str(), 0),
3841 Element::InlineImage(raw) | Element::ReferenceImage(raw) | Element::EmptyReferenceImage(raw) => {
3842 (raw.as_str(), 1)
3843 }
3844 _ => return None,
3845 };
3846 let inner = bracketed_text(raw, open)?;
3847 Some((&raw[..=open], inner, &raw[open + 1 + inner.len()..]))
3848}
3849
3850/// Whether an element's span is hard atomic: even the fallback pass that
3851/// relaxes soft spans may not break inside it. An emphasis span is soft
3852/// unless it nests a construct whose whitespace is not prose (a code span,
3853/// link, HTML tag, math or attr list). A link or image whose text may wrap
3854/// (see [`ReflowOptions::break_link_text`]) is soft on the same terms, and
3855/// additionally only when its suffix holds no whitespace: a break inside a
3856/// title or a spaced destination would rewrite the link.
3857fn element_is_hard(element: &Element, break_link_text: bool) -> bool {
3858 match element {
3859 Element::Bold { content, .. } | Element::Italic { content, .. } | Element::Strikethrough { content, .. } => {
3860 content.contains(['[', '`', '<', '$', '{'])
3861 }
3862 _ => match link_text_parts(element, break_link_text) {
3863 Some((_, inner, suffix)) => {
3864 inner.contains(['[', '`', '<', '$', '{']) || suffix.chars().any(char::is_whitespace)
3865 }
3866 None => true,
3867 },
3868 }
3869}
3870
3871/// Compute element spans for a flat text representation of elements.
3872///
3873/// The offsets are byte positions, so they are always measured in
3874/// [`ReflowLengthMode::Bytes`] regardless of how lines are measured; only the
3875/// savings depend on `mode` and the active exemptions.
3876fn compute_element_spans(
3877 elements: &[Element],
3878 mode: ReflowLengthMode,
3879 exemptions: LengthExemptions,
3880 break_link_text: bool,
3881) -> Vec<ElementSpan> {
3882 let mut spans = Vec::new();
3883 let mut offset = 0;
3884 for element in elements {
3885 let len = element.display_len(ReflowLengthMode::Bytes);
3886 if !matches!(element, Element::Text(_)) {
3887 let full = element.display_len(mode);
3888 let width = element.exempt_width(mode, exemptions);
3889 spans.push(ElementSpan::new(
3890 offset,
3891 len,
3892 full,
3893 width,
3894 element_is_hard(element, break_link_text),
3895 ));
3896 }
3897 offset += len;
3898 }
3899 spans
3900}
3901
3902/// Width of `text`, which sits at `[offset, offset + text.len())` of the line the
3903/// spans were computed for, under each exemption separately.
3904///
3905/// Only elements lying wholly inside the range are discounted. A split never
3906/// lands inside an element, so a partially covered element means the caller is
3907/// measuring something that is not a candidate line, and charging it in full is
3908/// the safe reading.
3909fn measure(text: &str, offset: usize, spans: &[ElementSpan], mode: ReflowLengthMode) -> LineWidth {
3910 let full = display_len(text, mode);
3911 let end = offset + text.len();
3912 let mut width = LineWidth::plain(full);
3913 for span in spans.iter().filter(|span| span.within(offset, end)) {
3914 width.link_exempt -= span.link_saving;
3915 width.code_exempt -= span.code_saving;
3916 }
3917 width
3918}
3919
3920/// Width of a standalone line under each exemption.
3921///
3922/// Callers that already hold the line's element spans should use [`measure`];
3923/// this is for the sites that see only the finished line and so have to parse it.
3924fn line_width_components(line: &str, options: &ReflowOptions) -> LineWidth {
3925 let raw = display_len(line, options.length_mode);
3926 if !options.length_exemptions.any() {
3927 return LineWidth::plain(raw);
3928 }
3929 let elements = parse_markdown_elements_inner(
3930 line,
3931 options.attr_lists,
3932 options.myst_roles,
3933 options.defined_references.as_ref(),
3934 );
3935 let spans = compute_element_spans(
3936 &elements,
3937 options.length_mode,
3938 options.length_exemptions,
3939 options.break_link_text,
3940 );
3941 measure(line, 0, &spans, options.length_mode)
3942}
3943
3944/// Width of a standalone line as the checker measures it.
3945fn line_width(line: &str, options: &ReflowOptions) -> usize {
3946 line_width_components(line, options).effective()
3947}
3948
3949/// Whether a standalone line fits the budget as the checker measures it.
3950///
3951/// A saving is never negative, so the exempt width never exceeds the raw width:
3952/// a line that already fits as written fits under any exemption too, and needs
3953/// no parse. That keeps the parse off the path most lines take.
3954fn line_fits(line: &str, options: &ReflowOptions) -> bool {
3955 display_len(line, options.length_mode) <= options.line_length || line_width(line, options) <= options.line_length
3956}
3957
3958/// The non-Text element span that strictly contains `pos`, if any.
3959fn element_containing(pos: usize, spans: &[ElementSpan]) -> Option<ElementSpan> {
3960 spans.iter().copied().find(|span| span.contains(pos))
3961}
3962
3963/// Check if a byte position falls inside any non-Text element span
3964fn is_inside_element(pos: usize, spans: &[ElementSpan]) -> bool {
3965 element_containing(pos, spans).is_some()
3966}
3967
3968/// Minimum fraction of line_length that the first part of a split must occupy.
3969/// Prevents awkwardly short first lines like "A," or "Note:" on their own.
3970const MIN_SPLIT_RATIO: f64 = 0.3;
3971
3972/// Split a line at the latest clause punctuation that keeps the first part
3973/// within `line_length`. Returns None if no valid split point exists or if
3974/// the split would create an unreasonably short first line.
3975fn split_at_clause_punctuation(
3976 text: &str,
3977 line_length: usize,
3978 element_spans: &[ElementSpan],
3979 length_mode: ReflowLengthMode,
3980) -> Option<(String, String)> {
3981 let chars: Vec<char> = text.chars().collect();
3982 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
3983
3984 // Find the char index where accumulated display width exceeds line_length.
3985 // An element the checker discounts is charged its reduced width and stepped
3986 // over whole, so the search window reaches as far as the exempt measure of a
3987 // prefix allows; scanning char by char through a discounted URL would stop
3988 // short of break points that are legal under it.
3989 let mut width_acc = LineWidth::default();
3990 let mut search_end_char = 0;
3991 let mut byte = 0usize;
3992 let mut idx = 0usize;
3993 while idx < chars.len() {
3994 let (advance_chars, advance_bytes, width) = match element_spans.iter().find(|s| s.start == byte) {
3995 Some(span) => {
3996 let source = &text[span.start..span.end];
3997 (
3998 source.chars().count(),
3999 source.len(),
4000 measure(source, span.start, element_spans, length_mode),
4001 )
4002 }
4003 None => {
4004 let c = chars[idx];
4005 (
4006 1,
4007 c.len_utf8(),
4008 LineWidth::plain(display_len(&c.to_string(), length_mode)),
4009 )
4010 }
4011 };
4012 if !(width_acc + width).fits(line_length) {
4013 break;
4014 }
4015 width_acc += width;
4016 byte += advance_bytes;
4017 idx += advance_chars;
4018 search_end_char = idx;
4019 }
4020
4021 // Scan backwards tracking parenthesis depth to skip clause punctuation
4022 // inside plain-text parenthetical groups. Scanning right-to-left means
4023 // ')' opens a depth level and '(' closes it. Parens that belong to a
4024 // markdown element are excluded using the char's start byte (not byte-after)
4025 // so that closing element delimiters at the span boundary are correctly
4026 // treated as part of the element.
4027 let mut paren_depth: i32 = 0;
4028 let mut best_pos = None;
4029 for i in (0..search_end_char).rev() {
4030 // Start byte of char i (for paren element check)
4031 let byte_start: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
4032 // Byte just after char i (for clause punctuation element check — existing convention)
4033 let byte_after: usize = byte_start + chars[i].len_utf8();
4034
4035 if !is_inside_element(byte_start, element_spans) {
4036 match chars[i] {
4037 ')' => paren_depth += 1,
4038 '(' => paren_depth = paren_depth.saturating_sub(1),
4039 _ => {}
4040 }
4041 }
4042
4043 if paren_depth == 0
4044 && is_clause_punctuation(chars[i])
4045 && clause_break_allowed_after(&chars, i)
4046 && !is_inside_element(byte_after, element_spans)
4047 {
4048 best_pos = Some(i);
4049 break;
4050 }
4051 }
4052
4053 let pos = best_pos?;
4054
4055 // Reject splits that create very short first lines
4056 let first: String = chars[..=pos].iter().collect();
4057 if measure(&first, 0, element_spans, length_mode).effective() < min_first_len {
4058 return None;
4059 }
4060
4061 // Split after the punctuation character
4062 let rest: String = chars[pos + 1..].iter().collect();
4063 let rest = rest.trim_start().to_string();
4064
4065 if rest.is_empty() {
4066 return None;
4067 }
4068
4069 Some((first, rest))
4070}
4071
4072/// Compute plain-text paren-depth at each byte offset in `text`.
4073///
4074/// Returns a `Vec<i32>` of length `text.len()` where entry `i` is the
4075/// nesting depth at byte `i` — counting only `(` and `)` that fall
4076/// outside markdown element spans. This lets callers quickly check
4077/// whether a byte position lies inside a plain-text parenthetical group.
4078fn paren_depth_map(text: &str, element_spans: &[ElementSpan]) -> Vec<i32> {
4079 let mut map = vec![0i32; text.len()];
4080 let mut depth = 0i32;
4081 for (byte, c) in text.char_indices() {
4082 if !is_inside_element(byte, element_spans) {
4083 match c {
4084 '(' => depth += 1,
4085 ')' => depth = depth.saturating_sub(1),
4086 _ => {}
4087 }
4088 }
4089 // Fill the depth value for every byte of this (possibly multi-byte) char.
4090 let end = (byte + c.len_utf8()).min(map.len());
4091 for slot in &mut map[byte..end] {
4092 *slot = depth;
4093 }
4094 }
4095 map
4096}
4097
4098/// Return `true` if `line` is a complete, balanced, multi-word parenthetical
4099/// group — i.e. it starts with `(`, ends with `)` (possibly followed by the
4100/// punctuation `split_at_parenthetical` attaches to it), has balanced parens
4101/// throughout, and the inner content contains at least one space (matching the
4102/// ≥2-word threshold used by `split_at_parenthetical` when deciding to split).
4103///
4104/// Used to prevent the short-line merge step from collapsing intentional
4105/// parenthetical splits back into the previous line.
4106fn is_standalone_parenthetical(line: &str) -> bool {
4107 let trimmed = line.trim();
4108 if !trimmed.starts_with('(') {
4109 return false;
4110 }
4111 // Strip the attached tail to find the real end: everything after the last
4112 // ')' belongs to the group only when no whitespace separates it.
4113 let Some(close) = trimmed.rfind(')') else {
4114 return false;
4115 };
4116 if trimmed[close + 1..].contains(char::is_whitespace) {
4117 return false;
4118 }
4119 let core = &trimmed[..=close];
4120 // Inner content must span multiple words (same threshold as split_at_parenthetical).
4121 let inner = &core[1..core.len() - 1];
4122 if !inner.contains(' ') {
4123 return false;
4124 }
4125 // Verify the parens are balanced (depth returns to 0 at the last ')').
4126 let mut depth = 0i32;
4127 for c in core.chars() {
4128 match c {
4129 '(' => depth += 1,
4130 ')' => depth -= 1,
4131 _ => {}
4132 }
4133 if depth < 0 {
4134 return false;
4135 }
4136 }
4137 depth == 0
4138}
4139
4140/// Split a line before the latest break-word that keeps the first part
4141/// within `line_length`. Returns None if no valid split point exists or if
4142/// the split would create an unreasonably short first line.
4143fn split_at_break_word(
4144 text: &str,
4145 line_length: usize,
4146 element_spans: &[ElementSpan],
4147 length_mode: ReflowLengthMode,
4148) -> Option<(String, String)> {
4149 let lower = text.to_lowercase();
4150 let min_first_len = ((line_length as f64) * MIN_SPLIT_RATIO) as usize;
4151 let mut best_split: Option<(usize, usize)> = None; // (byte_start, word_len_bytes)
4152
4153 // Build a paren-depth map so we can skip break-words inside plain-text
4154 // parenthetical groups (matching the protection added to split_at_clause_punctuation).
4155 let depth_map = paren_depth_map(text, element_spans);
4156
4157 for &word in BREAK_WORDS {
4158 let mut search_start = 0;
4159 while let Some(pos) = lower[search_start..].find(word) {
4160 let abs_pos = search_start + pos;
4161
4162 // Verify it's a word boundary: preceded by space, followed by space
4163 let preceded_by_space = abs_pos == 0 || text.as_bytes().get(abs_pos - 1) == Some(&b' ');
4164 let followed_by_space = text.as_bytes().get(abs_pos + word.len()) == Some(&b' ');
4165
4166 if preceded_by_space && followed_by_space {
4167 // The break goes BEFORE the word, so first part ends at abs_pos - 1
4168 let first_part = text[..abs_pos].trim_end();
4169 let first_part_len = measure(first_part, 0, element_spans, length_mode).effective();
4170
4171 // Skip break-words inside plain-text parenthetical groups.
4172 let inside_paren = depth_map.get(abs_pos).is_some_and(|&d| d > 0);
4173
4174 if first_part_len >= min_first_len
4175 && first_part_len <= line_length
4176 && !is_inside_element(abs_pos, element_spans)
4177 && !inside_paren
4178 {
4179 // Prefer the latest valid split point
4180 if best_split.is_none_or(|(prev_pos, _)| abs_pos > prev_pos) {
4181 best_split = Some((abs_pos, word.len()));
4182 }
4183 }
4184 }
4185
4186 search_start = abs_pos + word.len();
4187 }
4188 }
4189
4190 let (byte_start, _word_len) = best_split?;
4191
4192 let first = text[..byte_start].trim_end().to_string();
4193 let rest = text[byte_start..].to_string();
4194
4195 if first.is_empty() || rest.trim().is_empty() {
4196 return None;
4197 }
4198
4199 Some((first, rest))
4200}
4201
4202/// Whether a proposed split takes the place of whitespace that `text` already has.
4203///
4204/// `first` is a prefix of `text` with its trailing whitespace removed and `rest`
4205/// a suffix with its leading whitespace removed, so the bytes between them are
4206/// exactly what the split consumed. That gap must be non-empty and hold nothing
4207/// but breakable whitespace the paragraph owns: the newline replacing it renders
4208/// as a single space, so an empty gap inserts a word boundary the author did not
4209/// write, and a gap holding anything else drops content. Whitespace inside an
4210/// inline element belongs to that element, where it is literal (a code span) or
4211/// structural (a link destination), never a place a line may break.
4212fn replaces_whitespace(text: &str, first: &str, rest: &str, element_spans: &[ElementSpan]) -> bool {
4213 if !text.starts_with(first) || !text.ends_with(rest) {
4214 return false;
4215 }
4216 let gap_end = text.len() - rest.len();
4217 gap_end > first.len()
4218 && text[first.len()..gap_end].chars().all(is_breakable_whitespace)
4219 && !element_spans
4220 .iter()
4221 .any(|span| first.len() < span.end && span.start < gap_end)
4222}
4223
4224/// Cascade-split a line that exceeds line_length.
4225/// Tries parenthetical boundaries, then clause punctuation, then break-words,
4226/// then word wrap.
4227///
4228/// This is iterative rather than recursive so a single very long line (tens of
4229/// thousands of words) cannot overflow the stack. Each accepted split shrinks
4230/// the remaining text by a non-empty prefix, so the loop always makes progress.
4231/// The whole line is parsed into markdown elements once up front; every
4232/// remaining suffix reuses those element spans (re-based to the suffix offset)
4233/// instead of re-parsing, which keeps repeated element parsing out of the loop.
4234fn cascade_split_line(text: &str, options: &ReflowOptions) -> Vec<String> {
4235 let line_length = options.line_length;
4236 let length_mode = options.length_mode;
4237 let attr_lists = options.attr_lists;
4238 let myst_roles = options.myst_roles;
4239 let defined_references = options.defined_references.as_ref();
4240 if line_length == 0 || display_len(text, length_mode) <= line_length {
4241 return vec![text.to_string()];
4242 }
4243
4244 let elements = parse_markdown_elements_inner(text, attr_lists, myst_roles, defined_references);
4245 let element_spans = compute_element_spans(
4246 &elements,
4247 length_mode,
4248 options.length_exemptions,
4249 options.break_link_text,
4250 );
4251
4252 // The raw width is over budget, but an exemption may still bring the line
4253 // under it, in which case the checker accepts it as written.
4254 if measure(text, 0, &element_spans, length_mode).fits(line_length) {
4255 return vec![text.to_string()];
4256 }
4257
4258 // Element spans of the remaining suffix `text[start..]`, re-based so their
4259 // offsets are relative to the suffix. Split points never fall inside an
4260 // element, so every span lies wholly before or wholly at/after `start`.
4261 let rebased_spans = |start: usize| -> Vec<ElementSpan> {
4262 if start == 0 {
4263 return element_spans.clone();
4264 }
4265 element_spans
4266 .iter()
4267 .filter(|span| span.end > start)
4268 .map(|span| ElementSpan {
4269 start: span.start.saturating_sub(start),
4270 end: span.end.saturating_sub(start),
4271 ..*span
4272 })
4273 .collect()
4274 };
4275
4276 let mut result = Vec::new();
4277 let mut start = 0usize;
4278
4279 loop {
4280 let remaining = &text[start..];
4281 let spans = rebased_spans(start);
4282 if measure(remaining, 0, &spans, length_mode).fits(line_length) {
4283 result.push(remaining.to_string());
4284 return result;
4285 }
4286
4287 // `rest` is always a suffix of `remaining` (the splitters only trim its
4288 // leading whitespace), so `remaining.len() - rest.len()` is the number of
4289 // bytes consumed, and the new absolute offset is `start + consumed`.
4290 //
4291 // Every candidate must stand in for whitespace the text already has: a
4292 // line break renders as a space, so one placed between two characters
4293 // that were adjacent changes the rendered paragraph. A strategy that
4294 // proposes such a split is skipped and the next one gets a turn.
4295 let at_whitespace = |candidate: Option<(String, String)>| {
4296 candidate.filter(|(first, rest)| replaces_whitespace(remaining, first, rest, &spans))
4297 };
4298 let split = at_whitespace(split_at_parenthetical(remaining, line_length, &spans, length_mode))
4299 .or_else(|| at_whitespace(split_at_clause_punctuation(remaining, line_length, &spans, length_mode)))
4300 .or_else(|| at_whitespace(split_at_break_word(remaining, line_length, &spans, length_mode)));
4301
4302 if let Some((first, rest)) = split {
4303 let consumed = remaining.len().saturating_sub(rest.len());
4304 // Defensive: a zero-length advance would loop forever. Splitters only
4305 // return a non-empty `first`, so this never triggers, but guard anyway.
4306 if consumed == 0 {
4307 break;
4308 }
4309 result.push(first);
4310 start += consumed;
4311 continue;
4312 }
4313
4314 // No semantic split point: word-wrap the remaining suffix and finish.
4315 break;
4316 }
4317
4318 // Fallback: word wrap the still-oversized suffix using reflow_elements.
4319 let mut fallback_options = options.clone();
4320 fallback_options.break_on_sentences = false;
4321 fallback_options.preserve_breaks = false;
4322 fallback_options.sentence_per_line = false;
4323 fallback_options.semantic_line_breaks = false;
4324 fallback_options.require_sentence_capital = true;
4325 fallback_options.max_list_continuation_indent = None;
4326 fallback_options.defined_references = None;
4327 let remaining = &text[start..];
4328 let tail_elements = if start == 0 {
4329 elements
4330 } else {
4331 parse_markdown_elements_inner(remaining, attr_lists, myst_roles, defined_references)
4332 };
4333 result.extend(reflow_elements(&tail_elements, &fallback_options));
4334 result
4335}
4336
4337/// Reflow elements using semantic line breaks strategy:
4338/// 1. Split at sentence boundaries (always)
4339/// 2. For lines exceeding line_length, cascade through clause punct → break-words → word wrap
4340fn reflow_elements_semantic(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
4341 // Step 1: Split into sentences using existing sentence-per-line logic
4342 let sentence_lines = reflow_elements_sentence_per_line(elements, options);
4343
4344 // Step 2: For each sentence line, apply cascading splits if it exceeds line_length
4345 // When line_length is 0 (unlimited), skip cascading — sentence splits only
4346 if options.line_length == 0 {
4347 return sentence_lines;
4348 }
4349
4350 let mut result = Vec::new();
4351 for line in sentence_lines {
4352 if line_fits(&line, options) {
4353 result.push(line);
4354 } else {
4355 result.extend(cascade_split_line(&line, options));
4356 }
4357 }
4358
4359 // Step 3: Merge very short trailing lines back into the previous line.
4360 // Word wrap can produce lines like "was" or "see" on their own, which reads poorly.
4361 let min_line_len = ((options.line_length as f64) * MIN_SPLIT_RATIO) as usize;
4362 let mut merged: Vec<String> = Vec::with_capacity(result.len());
4363 for line in result {
4364 if !merged.is_empty() && line_width(&line, options) < min_line_len && !line.trim().is_empty() {
4365 // Don't merge a line that is itself a standalone parenthetical group —
4366 // it was placed on its own line intentionally by split_at_parenthetical.
4367 if is_standalone_parenthetical(&line) {
4368 merged.push(line);
4369 continue;
4370 }
4371
4372 // Don't merge across sentence boundaries — sentence splits are intentional
4373 let prev_ends_at_sentence = {
4374 let trimmed = merged.last().unwrap().trim_end();
4375 trimmed
4376 .chars()
4377 .rev()
4378 .find(|c| !matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']'))
4379 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
4380 };
4381
4382 if !prev_ends_at_sentence {
4383 let prev = merged.last_mut().unwrap();
4384 let combined = format!("{prev} {line}");
4385 // Only merge if the combined line fits within the limit
4386 if line_fits(&combined, options) {
4387 *prev = combined;
4388 continue;
4389 }
4390 }
4391 }
4392 merged.push(line);
4393 }
4394 merged
4395}
4396
4397/// Find the last space in `line` that is safe to split at.
4398/// Safe spaces are those NOT inside rendered non-Text elements and whose
4399/// suffix would not open a block construct when placed at line start.
4400/// `element_spans` locates the non-Text elements in the line. Spans use
4401/// exclusive bounds (pos > start && pos < end) because element delimiters
4402/// (e.g., `[`, `]`, `(`, `)`, `<`, `>`, `` ` ``) are never spaces, so only
4403/// interior positions need protection. The scan keeps looking left past
4404/// construct-leading suffixes (e.g. a trailing `- `), so a usable earlier break
4405/// point is found instead of forcing an overlong line.
4406fn rfind_safe_space(
4407 line: &str,
4408 element_spans: &[ElementSpan],
4409 options: &ReflowOptions,
4410 relax_soft_spans: bool,
4411) -> Option<usize> {
4412 line.char_indices().rev().map(|(pos, _)| pos).find(|&pos| {
4413 line.as_bytes()[pos] == b' '
4414 && !is_inside_element_filtered(pos, element_spans, options, relax_soft_spans)
4415 && !starts_block_construct(&line[pos + 1..])
4416 })
4417}
4418
4419fn is_inside_element_filtered(
4420 pos: usize,
4421 spans: &[ElementSpan],
4422 options: &ReflowOptions,
4423 relax_soft_spans: bool,
4424) -> bool {
4425 spans.iter().any(|span| {
4426 span.contains(pos)
4427 && (!relax_soft_spans
4428 || span.is_hard
4429 || (options.atomic_spans && span.exempt_width().fits(options.line_length)))
4430 })
4431}
4432
4433/// A token that must not start a wrapped line, together with the width it
4434/// contributes and the separator that precedes it. The width travels with the
4435/// text because only the caller knows which construct produced it, and so which
4436/// exemption it earns.
4437#[derive(Clone, Copy)]
4438struct Attached<'a> {
4439 text: &'a str,
4440 width: LineWidth,
4441 separator: &'a str,
4442}
4443
4444/// Break `current_line` one word earlier so `attach` never starts a wrapped
4445/// line: everything before the line's last safe space is emitted as a
4446/// finished line, and the carried word plus the separator plus the attached
4447/// text becomes the new current line. The returned byte length of the carried
4448/// word lets callers re-record a span for the attached text. Returns `None`
4449/// (line untouched) when the line has no safe break point.
4450///
4451/// The new width is the carried text measured through the spans it came with,
4452/// plus the attached width, so an exemption the carried text or the attached
4453/// token earns is preserved across the break instead of being re-derived from a
4454/// bare string.
4455///
4456/// The carried text keeps the element spans that fell inside it, rebased to the
4457/// new line. Dropping them would leave a later break blind to an element the
4458/// carried text still holds, and so free to split a link or a code span down
4459/// the middle.
4460fn break_before_attached(
4461 lines: &mut Vec<String>,
4462 current_line: &mut String,
4463 current_width: &mut LineWidth,
4464 element_spans: &mut Vec<ElementSpan>,
4465 attach: Attached<'_>,
4466 options: &ReflowOptions,
4467) -> Option<usize> {
4468 let length_mode = options.length_mode;
4469 let last_space = rfind_safe_space(current_line, element_spans, options, false)
4470 .or_else(|| rfind_safe_space(current_line, element_spans, options, true))?;
4471 let before = current_line[..last_space]
4472 .trim_end_matches(is_breakable_whitespace)
4473 .to_string();
4474 let after = current_line[last_space + 1..].to_string();
4475 let after_width = measure(&after, last_space + 1, element_spans, length_mode);
4476 lines.push(before);
4477 let carried = after.len();
4478 let Attached { text, width, separator } = attach;
4479 *current_line = format!("{after}{separator}{text}");
4480 *current_width = after_width + LineWidth::plain(display_len(separator, length_mode)) + width;
4481 rebase_spans_after_break(element_spans, last_space + 1);
4482 Some(carried)
4483}
4484
4485/// Keep the spans that reach into the text starting at `carried_start` and move
4486/// them into that text's coordinates, discarding the ones that belong to the
4487/// line just emitted.
4488///
4489/// A span that starts before `carried_start` is clamped to 0 rather than
4490/// dropped. `rfind_safe_space` never breaks inside a span, so this cannot
4491/// normally happen; clamping keeps the whole prefix protected if it ever does,
4492/// where dropping the span would license a break inside an element.
4493fn rebase_spans_after_break(element_spans: &mut Vec<ElementSpan>, carried_start: usize) {
4494 element_spans.retain(|span| span.end > carried_start);
4495 for span in element_spans.iter_mut() {
4496 span.start = span.start.saturating_sub(carried_start);
4497 span.end -= carried_start;
4498 }
4499}
4500
4501/// Reflow elements into lines that fit within the line length
4502fn reflow_elements(elements: &[Element], options: &ReflowOptions) -> Vec<String> {
4503 let mut lines = Vec::new();
4504 let mut current_line = String::new();
4505 // The line's width under each exemption the checker applies. With no
4506 // exemption active both components are the plain display width.
4507 let mut current_width = LineWidth::default();
4508 // Track byte spans of non-Text elements in current_line for safe splitting
4509 let mut current_line_element_spans: Vec<ElementSpan> = Vec::new();
4510 let length_mode = options.length_mode;
4511 let exemptions = options.length_exemptions;
4512
4513 for (idx, element) in elements.iter().enumerate() {
4514 let element_len = element.display_len(length_mode);
4515 let element_width = element.exempt_width(length_mode, exemptions);
4516 let is_hard = element_is_hard(element, options.break_link_text);
4517
4518 // Determine adjacency from the original elements, not from current_line.
4519 // Elements are adjacent when there's no breakable whitespace between them
4520 // in the source (a non-breaking space stays inside the neighboring token,
4521 // so the pair must also stay attached):
4522 // - Text("v") → HugoShortcode("{{<...>}}") = adjacent (text has no trailing space)
4523 // - Text(" and ") → InlineLink("[a](url)") = NOT adjacent (text has trailing space)
4524 // - HugoShortcode("{{<...>}}") → Text(",") = adjacent (text has no leading space)
4525 // - Code("`x`") → Text("\u{00A0}:") = adjacent (only a non-breaking space between)
4526 let is_adjacent_to_prev = if idx > 0 {
4527 match (&elements[idx - 1], element) {
4528 (Element::Text(t), _) => !t.is_empty() && !t.ends_with(is_breakable_whitespace),
4529 (_, Element::Text(t)) => !t.is_empty() && !t.starts_with(is_breakable_whitespace),
4530 _ => true,
4531 }
4532 } else {
4533 false
4534 };
4535
4536 // For text elements that might need breaking
4537 if let Element::Text(text) = element {
4538 // Check if original text had leading breakable whitespace
4539 let has_leading_space = text.starts_with(is_breakable_whitespace);
4540 // If this is a text element, always process it word by word
4541 let words: Vec<&str> = split_breakable_words(text).collect();
4542
4543 for (i, word) in words.iter().enumerate() {
4544 // A bare word carries no construct the checker exempts.
4545 let word_width = LineWidth::plain(display_len(word, length_mode));
4546 // A token that is only punctuation (optionally led by a
4547 // non-breaking space, e.g. French "\u{00A0}:") must never be
4548 // hoisted to the start of a line. Tokens are never empty
4549 // (`split_breakable_words` filters), so `all` cannot be
4550 // vacuously true.
4551 let is_trailing_punct = word.chars().all(|c| {
4552 matches!(c, ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}') || is_non_breaking_space(c)
4553 });
4554
4555 // First word of text adjacent to preceding non-text element
4556 // must stay attached (e.g., shortcode followed by punctuation or text)
4557 let is_first_adjacent = i == 0 && is_adjacent_to_prev;
4558
4559 if is_first_adjacent {
4560 // Attach directly without space, preventing line break
4561 if !(current_width + word_width).fits(options.line_length)
4562 && !current_width.is_empty()
4563 && break_before_attached(
4564 &mut lines,
4565 &mut current_line,
4566 &mut current_width,
4567 &mut current_line_element_spans,
4568 Attached {
4569 text: word,
4570 width: word_width,
4571 separator: "",
4572 },
4573 options,
4574 )
4575 .is_some()
4576 {
4577 // Would exceed — broke before the adjacent group at the
4578 // last safe space (element-aware, so links/code stay
4579 // intact); with no safe break point the group is
4580 // attached and the long line accepted.
4581 } else {
4582 current_line.push_str(word);
4583 current_width += word_width;
4584 }
4585 } else if !current_width.is_empty()
4586 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
4587 {
4588 if is_trailing_punct {
4589 // The overflowing token is bare punctuation, which must
4590 // not start a line. Break one word earlier so the mark
4591 // travels with the word it follows ("… mot :"), keeping
4592 // the source space (French double punctuation requires
4593 // it); with no safe earlier break point, accept the
4594 // overlong line rather than rewrite content.
4595 if break_before_attached(
4596 &mut lines,
4597 &mut current_line,
4598 &mut current_width,
4599 &mut current_line_element_spans,
4600 Attached {
4601 text: word,
4602 width: word_width,
4603 separator: " ",
4604 },
4605 options,
4606 )
4607 .is_none()
4608 {
4609 current_line.push(' ');
4610 current_line.push_str(word);
4611 current_width += LineWidth::plain(1) + word_width;
4612 }
4613 } else if !starts_block_construct(word) {
4614 // Start a new line
4615 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
4616 current_line = word.to_string();
4617 current_width = word_width;
4618 current_line_element_spans.clear();
4619 } else if break_before_attached(
4620 &mut lines,
4621 &mut current_line,
4622 &mut current_width,
4623 &mut current_line_element_spans,
4624 Attached {
4625 text: word,
4626 width: word_width,
4627 separator: " ",
4628 },
4629 options,
4630 )
4631 .is_some()
4632 {
4633 // The overflowing word would open a block construct at line
4634 // start. Broke one word earlier instead so the marker stays
4635 // mid-line: "... and then" + "- clause" becomes "... and" +
4636 // "then - clause".
4637 } else {
4638 // No safe earlier break point — keep the marker attached and
4639 // accept the long line rather than corrupt the structure.
4640 if i > 0 || has_leading_space {
4641 current_line.push(' ');
4642 current_width += LineWidth::plain(1);
4643 }
4644 current_line.push_str(word);
4645 current_width += word_width;
4646 }
4647 } else {
4648 // Add a space wherever the source had breakable whitespace at
4649 // this position. For the first word of a text run (i == 0)
4650 // that means the run had a leading space — and reaching this
4651 // branch already implies the word is not adjacent to the
4652 // previous element, so the space is real. Later words
4653 // (i > 0) always had whitespace before them: that is what
4654 // separated them during tokenization. This holds for bare
4655 // punctuation too ("ligne : la" keeps its French
4656 // orthographic space): reflow moves line breaks, it does not
4657 // rewrite characters. The no-space (adjacent) case is
4658 // handled above by `is_first_adjacent`.
4659 let add_space = !current_width.is_empty() && (i > 0 || has_leading_space);
4660 if add_space {
4661 current_line.push(' ');
4662 current_width += LineWidth::plain(1);
4663 }
4664 current_line.push_str(word);
4665 current_width += word_width;
4666 }
4667 }
4668 } else {
4669 let link_parts = link_text_parts(element, options.break_link_text);
4670 let span_info = match element {
4671 Element::Italic { content, underscore } => {
4672 let marker = if *underscore { "_" } else { "*" };
4673 Some((content.as_str(), marker, marker, false))
4674 }
4675 Element::Bold { content, underscore } => {
4676 let marker = if *underscore { "__" } else { "**" };
4677 Some((content.as_str(), marker, marker, false))
4678 }
4679 Element::Strikethrough { content, double } => {
4680 let marker = if *double { "~~" } else { "~" };
4681 Some((content.as_str(), marker, marker, false))
4682 }
4683 Element::Code { content, marker } => Some((content.as_str(), marker.as_str(), marker.as_str(), true)),
4684 _ => link_parts.map(|(prefix, inner, suffix)| (inner, prefix, suffix, false)),
4685 };
4686 let is_link = link_parts.is_some();
4687
4688 // A span that alone exceeds the line budget is broken even when
4689 // spans are atomic, since keeping it whole would leave a line that can
4690 // never fit. `breakable_units` decides where that is safe. A link is
4691 // measured through its exemptions (a whole link may be forgiven where
4692 // a split one is not), and `link_text_break_units` additionally rules
4693 // out splits whose lines the checker would report.
4694 let breakable: Option<Vec<&str>> = match span_info {
4695 Some((content, _, suffix, is_code)) => {
4696 if is_code {
4697 (!options.atomic_spans && code_span_wraps_losslessly(content))
4698 .then(|| split_breakable_words(content).collect())
4699 } else if is_link {
4700 (!options.atomic_spans || !element_width.fits(options.line_length))
4701 .then(|| {
4702 link_text_break_units(
4703 content,
4704 suffix,
4705 options.line_length,
4706 length_mode,
4707 options.defined_references.as_ref(),
4708 options.attr_lists,
4709 )
4710 })
4711 .flatten()
4712 } else {
4713 (!options.atomic_spans || element_len > options.line_length)
4714 .then(|| breakable_units(content, options.defined_references.as_ref(), options.attr_lists))
4715 .flatten()
4716 }
4717 }
4718 None => None,
4719 };
4720
4721 if let Some(words) = breakable {
4722 let (_, prefix, suffix, is_code) = span_info.expect("breakable implies a span");
4723 let n = words.len();
4724 if n == 0 {
4725 // Empty span — treat as atomic
4726 let full = format!("{prefix}{suffix}");
4727 let full_width = LineWidth::plain(display_len(&full, length_mode));
4728 if !is_adjacent_to_prev && !current_width.is_empty() {
4729 current_line.push(' ');
4730 current_width += LineWidth::plain(1);
4731 }
4732 current_line.push_str(&full);
4733 current_width += full_width;
4734 } else {
4735 // A split link's tail earns no exemption from the checker
4736 // (only an intact inline link does), so the suffix is
4737 // measured at its plain width. The span is hard: a title or
4738 // spaced destination inside it must never host a fallback
4739 // break.
4740 let suffix_span_width = LineWidth::plain(display_len(suffix, length_mode));
4741
4742 for (i, word) in words.iter().enumerate() {
4743 let is_first = i == 0;
4744 let is_last = i == n - 1;
4745
4746 let space_start = if is_first && is_code && word.starts_with('`') {
4747 " "
4748 } else {
4749 ""
4750 };
4751 let space_end = if is_last && is_code && word.ends_with('`') {
4752 " "
4753 } else {
4754 ""
4755 };
4756
4757 let word_str: String = match (is_first, is_last) {
4758 (true, true) => format!("{prefix}{space_start}{word}{space_end}{suffix}"),
4759 (true, false) => format!("{prefix}{space_start}{word}"),
4760 (false, true) => format!("{word}{space_end}{suffix}"),
4761 (false, false) => word.to_string(),
4762 };
4763 let word_elements = parse_elements(&word_str, options);
4764 let word_spans =
4765 compute_element_spans(&word_elements, length_mode, exemptions, options.break_link_text);
4766 let word_width = measure(&word_str, 0, &word_spans, length_mode);
4767
4768 let needs_space = if is_first {
4769 !is_adjacent_to_prev && !current_width.is_empty()
4770 } else {
4771 !current_width.is_empty()
4772 };
4773
4774 if needs_space
4775 && !(current_width + LineWidth::plain(1) + word_width).fits(options.line_length)
4776 && !starts_block_construct(&word_str)
4777 {
4778 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
4779 current_line = word_str;
4780 current_width = word_width;
4781 current_line_element_spans.clear();
4782 for span in word_spans {
4783 current_line_element_spans.push(span);
4784 }
4785 if is_link && is_last {
4786 current_line_element_spans.push(ElementSpan::new(
4787 word.len(),
4788 suffix.len(),
4789 display_len(suffix, length_mode),
4790 suffix_span_width,
4791 true,
4792 ));
4793 }
4794 } else {
4795 let mut start_pos = current_line.len();
4796 if needs_space {
4797 current_line.push(' ');
4798 current_width += LineWidth::plain(1);
4799 start_pos += 1;
4800 }
4801 current_line.push_str(&word_str);
4802 current_width += word_width;
4803 for mut span in word_spans {
4804 span.start += start_pos;
4805 span.end += start_pos;
4806 current_line_element_spans.push(span);
4807 }
4808 if is_link && is_last {
4809 current_line_element_spans.push(ElementSpan::new(
4810 start_pos + word.len(),
4811 suffix.len(),
4812 display_len(suffix, length_mode),
4813 suffix_span_width,
4814 true,
4815 ));
4816 }
4817 }
4818 }
4819 }
4820 } else {
4821 // For non-text elements (code, links, references), treat as atomic units
4822 // These should never be broken across lines
4823 let element_str = format!("{element}");
4824
4825 if is_adjacent_to_prev {
4826 // Adjacent to preceding text — attach directly without space
4827 if !(current_width + element_width).fits(options.line_length)
4828 && let Some(carried) = break_before_attached(
4829 &mut lines,
4830 &mut current_line,
4831 &mut current_width,
4832 &mut current_line_element_spans,
4833 Attached {
4834 text: &element_str,
4835 width: element_width,
4836 separator: "",
4837 },
4838 options,
4839 )
4840 {
4841 // Would exceed limit — broke before the adjacent word group
4842 // at the last safe space (element-aware, so links/code stay
4843 // intact). Record the element span in the new current_line.
4844 current_line_element_spans.push(ElementSpan::new(
4845 carried,
4846 element_str.len(),
4847 element_len,
4848 element_width,
4849 is_hard,
4850 ));
4851 } else {
4852 let start = current_line.len();
4853 current_line.push_str(&element_str);
4854 current_width += element_width;
4855 current_line_element_spans.push(ElementSpan::new(
4856 start,
4857 element_str.len(),
4858 element_len,
4859 element_width,
4860 is_hard,
4861 ));
4862 }
4863 } else if !current_width.is_empty()
4864 && !(current_width + LineWidth::plain(1) + element_width).fits(options.line_length)
4865 {
4866 if !starts_block_construct(&element_str) {
4867 // Not adjacent, would exceed — start new line
4868 lines.push(current_line.trim_matches(is_breakable_whitespace).to_string());
4869 current_line.clone_from(&element_str);
4870 current_width = element_width;
4871 current_line_element_spans.clear();
4872 current_line_element_spans.push(ElementSpan::new(
4873 0,
4874 element_str.len(),
4875 element_len,
4876 element_width,
4877 is_hard,
4878 ));
4879 } else if let Some(carried) = break_before_attached(
4880 &mut lines,
4881 &mut current_line,
4882 &mut current_width,
4883 &mut current_line_element_spans,
4884 Attached {
4885 text: &element_str,
4886 width: element_width,
4887 separator: " ",
4888 },
4889 options,
4890 ) {
4891 // The overflowing element would open a block construct at
4892 // line start (e.g. an HtmlTag like `<div>`). Broke one word
4893 // earlier instead so the element stays mid-line.
4894 let start = carried + 1;
4895 current_line_element_spans.push(ElementSpan::new(
4896 start,
4897 element_str.len(),
4898 element_len,
4899 element_width,
4900 is_hard,
4901 ));
4902 } else {
4903 // No safe earlier break point — keep the element attached
4904 // and accept the long line rather than corrupt the structure.
4905 let ends_with_opener =
4906 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
4907 if !ends_with_opener {
4908 current_line.push(' ');
4909 current_width += LineWidth::plain(1);
4910 }
4911 let start = current_line.len();
4912 current_line.push_str(&element_str);
4913 current_width += element_width;
4914 current_line_element_spans.push(ElementSpan::new(
4915 start,
4916 element_str.len(),
4917 element_len,
4918 element_width,
4919 is_hard,
4920 ));
4921 }
4922 } else {
4923 // Not adjacent, fits — add with space
4924 let ends_with_opener =
4925 current_line.ends_with('(') || current_line.ends_with('[') || current_line.ends_with('{');
4926 if !current_width.is_empty() && !ends_with_opener {
4927 current_line.push(' ');
4928 current_width += LineWidth::plain(1);
4929 }
4930 let start = current_line.len();
4931 current_line.push_str(&element_str);
4932 current_width += element_width;
4933 current_line_element_spans.push(ElementSpan::new(
4934 start,
4935 element_str.len(),
4936 element_len,
4937 element_width,
4938 is_hard,
4939 ));
4940 }
4941 }
4942 }
4943 }
4944
4945 // Don't forget the last line
4946 if !current_line.is_empty() {
4947 lines.push(current_line.trim_end_matches(is_breakable_whitespace).to_string());
4948 }
4949
4950 lines
4951}
4952
4953/// Reflow markdown content preserving structure
4954pub fn reflow_markdown(content: &str, options: &ReflowOptions) -> String {
4955 let lines: Vec<&str> = content.lines().collect();
4956 // One entry per line of `lines`, read off one parse of the whole content.
4957 let inside_code_span = lines_touching_multiline_code_span(content);
4958 let mut result = Vec::new();
4959 let mut i = 0;
4960
4961 while i < lines.len() {
4962 let line = lines[i];
4963 let trimmed = line.trim();
4964
4965 // Preserve empty lines
4966 if trimmed.is_empty() {
4967 result.push(String::new());
4968 i += 1;
4969 continue;
4970 }
4971
4972 // Preserve headings as-is
4973 if trimmed.starts_with('#') {
4974 result.push(line.to_string());
4975 i += 1;
4976 continue;
4977 }
4978
4979 // Preserve Quarto/Pandoc div markers (:::) as-is
4980 if trimmed.starts_with(":::") {
4981 result.push(line.to_string());
4982 i += 1;
4983 continue;
4984 }
4985
4986 // Preserve fenced code blocks
4987 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
4988 result.push(line.to_string());
4989 i += 1;
4990 // Copy lines until closing fence
4991 while i < lines.len() {
4992 result.push(lines[i].to_string());
4993 if lines[i].trim().starts_with("```") || lines[i].trim().starts_with("~~~") {
4994 i += 1;
4995 break;
4996 }
4997 i += 1;
4998 }
4999 continue;
5000 }
5001
5002 // Preserve indented code blocks (4+ columns accounting for tab expansion)
5003 if calculate_indentation_width_default(line) >= 4 {
5004 // Collect all consecutive indented lines
5005 result.push(line.to_string());
5006 i += 1;
5007 while i < lines.len() {
5008 let next_line = lines[i];
5009 // Continue if next line is also indented or empty (empty lines in code blocks are ok)
5010 if calculate_indentation_width_default(next_line) >= 4 || next_line.trim().is_empty() {
5011 result.push(next_line.to_string());
5012 i += 1;
5013 } else {
5014 break;
5015 }
5016 }
5017 continue;
5018 }
5019
5020 // Preserve block quotes (but reflow their content)
5021 if trimmed.starts_with('>') {
5022 // find() returns byte position which is correct for str slicing
5023 // The unwrap is safe because we already verified trimmed starts with '>'
5024 let gt_pos = line.find('>').expect("'>' must exist since trimmed.starts_with('>')");
5025 let quote_prefix = line[0..=gt_pos].to_string();
5026 let quote_content = &line[quote_prefix.len()..].trim_start();
5027
5028 let reflowed = reflow_line(quote_content, options);
5029 for reflowed_line in &reflowed {
5030 result.push(format!("{quote_prefix} {reflowed_line}"));
5031 }
5032 i += 1;
5033 continue;
5034 }
5035
5036 // Preserve horizontal rules first (before checking for lists)
5037 if is_horizontal_rule(trimmed) {
5038 result.push(line.to_string());
5039 i += 1;
5040 continue;
5041 }
5042
5043 // Preserve lists (but not horizontal rules)
5044 if is_unordered_list_marker(trimmed) || is_numbered_list_item(trimmed) {
5045 // Find the list marker and preserve indentation
5046 let indent = line.len() - line.trim_start().len();
5047 let indent_str = " ".repeat(indent);
5048
5049 // For numbered lists, find the period and the space after it
5050 // For bullet lists, find the marker and the space after it
5051 let mut marker_end = indent;
5052 let mut content_start = indent;
5053
5054 if trimmed.chars().next().is_some_and(char::is_numeric) {
5055 // Numbered list: find the period
5056 if let Some(period_pos) = line[indent..].find('.') {
5057 marker_end = indent + period_pos + 1; // Include the period
5058 content_start = marker_end;
5059 // Skip any spaces after the period to find content start
5060 // Use byte-based check since content_start is a byte index
5061 // This is safe because space is ASCII (single byte)
5062 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
5063 content_start += 1;
5064 }
5065 }
5066 } else {
5067 // Bullet list: marker is single character
5068 marker_end = indent + 1; // Just the marker character
5069 content_start = marker_end;
5070 // Skip any spaces after the marker
5071 // Use byte-based check since content_start is a byte index
5072 // This is safe because space is ASCII (single byte)
5073 while content_start < line.len() && line.as_bytes().get(content_start) == Some(&b' ') {
5074 content_start += 1;
5075 }
5076 }
5077
5078 // Minimum indent for continuation lines (based on list marker, before checkbox)
5079 let min_continuation_indent = content_start;
5080
5081 // Detect checkbox/task list markers: [ ], [x], [X]
5082 // GFM task lists work with both unordered and ordered lists
5083 let rest = &line[content_start..];
5084 if rest.starts_with("[ ] ") || rest.starts_with("[x] ") || rest.starts_with("[X] ") {
5085 marker_end = content_start + 3; // Include the checkbox `[ ]`
5086 content_start += 4; // Skip past `[ ] `
5087 }
5088
5089 let marker = &line[indent..marker_end];
5090
5091 // Collect all content for this list item (including continuation lines)
5092 // Preserve hard breaks (2 trailing spaces) while trimming excessive whitespace
5093 let mut list_content = vec![trim_preserving_hard_break(&line[content_start..])];
5094 i += 1;
5095
5096 // Collect continuation lines (indented lines that are part of this list item)
5097 // Use the base marker indent (not checkbox-extended) for collection,
5098 // since users may indent continuations to the bullet level, not the checkbox level
5099 while i < lines.len() {
5100 let next_line = lines[i];
5101 let next_trimmed = next_line.trim();
5102
5103 // Stop if we hit an empty line or another list item or special block
5104 if is_block_boundary(next_trimmed) {
5105 break;
5106 }
5107
5108 // Check if this line is indented (continuation of list item)
5109 let next_indent = next_line.len() - next_line.trim_start().len();
5110 if next_indent >= min_continuation_indent {
5111 // This is a continuation line - add its content
5112 // Preserve hard breaks while trimming excessive whitespace
5113 let trimmed_start = next_line.trim_start();
5114 list_content.push(trim_preserving_hard_break(trimmed_start));
5115 i += 1;
5116 } else {
5117 // Not indented enough, not part of this list item
5118 break;
5119 }
5120 }
5121
5122 // Join content, but respect hard breaks (lines ending with 2 spaces or backslash)
5123 // Hard breaks should prevent joining with the next line
5124 let combined_content = if options.preserve_breaks {
5125 list_content[0].clone()
5126 } else {
5127 // Check if any lines have hard breaks - if so, preserve the structure
5128 let has_hard_breaks = list_content.iter().any(|line| has_hard_break(line));
5129 if has_hard_breaks {
5130 // Don't join lines with hard breaks - keep them separate with newlines
5131 list_content.join("\n")
5132 } else {
5133 // No hard breaks, safe to join with spaces
5134 list_content.join(" ")
5135 }
5136 };
5137
5138 // Calculate the proper indentation for continuation lines
5139 let trimmed_marker = marker;
5140 let continuation_spaces = if let Some(max_indent) = options.max_list_continuation_indent {
5141 // Cap the relative indent (past the nesting level) to max_indent,
5142 // then add back the nesting indent so nested items stay correct
5143 indent + (content_start - indent).min(max_indent)
5144 } else {
5145 content_start
5146 };
5147
5148 // Adjust line length to account for list marker and space
5149 let prefix_length = indent + trimmed_marker.len() + 1;
5150
5151 // Create adjusted options with reduced line length
5152 let adjusted_options = ReflowOptions {
5153 line_length: options.line_length.saturating_sub(prefix_length),
5154 ..options.clone()
5155 };
5156
5157 let reflowed = reflow_line(&combined_content, &adjusted_options);
5158 for (j, reflowed_line) in reflowed.iter().enumerate() {
5159 if j == 0 {
5160 result.push(format!("{indent_str}{trimmed_marker} {reflowed_line}"));
5161 } else {
5162 // Continuation lines aligned with text after marker
5163 let continuation_indent = " ".repeat(continuation_spaces);
5164 result.push(format!("{continuation_indent}{reflowed_line}"));
5165 }
5166 }
5167 continue;
5168 }
5169
5170 // Preserve tables
5171 if crate::utils::table_utils::TableUtils::is_potential_table_row(line) {
5172 result.push(line.to_string());
5173 i += 1;
5174 continue;
5175 }
5176
5177 // Preserve reference definitions
5178 if trimmed.starts_with('[') && line.contains("]:") {
5179 result.push(line.to_string());
5180 i += 1;
5181 continue;
5182 }
5183
5184 // A colon-led line opens a definition when a line of its block precedes
5185 // it. The paragraph collection below ends in front of such a line, so
5186 // a block's later marker lines arrive here one at a time and are kept
5187 // as written. The first line of a block is prose whatever it starts
5188 // with, and reflows as prose below.
5189 if is_definition_list_marker(trimmed) && has_block_line_above(&lines, i) {
5190 result.push(line.to_string());
5191 i += 1;
5192 continue;
5193 }
5194
5195 // Check if this is a single line that doesn't need processing
5196 let mut is_single_line_paragraph = true;
5197 if i + 1 < lines.len() {
5198 let next_trimmed = lines[i + 1].trim();
5199 // Check if next line continues this paragraph
5200 if !is_block_boundary(next_trimmed) {
5201 is_single_line_paragraph = false;
5202 }
5203 }
5204
5205 // If it's a single line that fits, just add it as-is
5206 if is_single_line_paragraph && line_fits(line, options) {
5207 result.push(line.to_string());
5208 i += 1;
5209 continue;
5210 }
5211
5212 // For regular paragraphs, collect consecutive lines
5213 // Each part carries whether it closed at a hard break, which decides
5214 // whether the break is written back after the part is reflowed.
5215 let mut paragraph_parts: Vec<(String, bool)> = Vec::new();
5216 let mut current_part = vec![line];
5217 i += 1;
5218
5219 // If preserve_breaks is true, treat each line separately
5220 if options.preserve_breaks {
5221 // Don't collect consecutive lines - just reflow this single line
5222 let hard_break_type = if line.strip_suffix('\r').unwrap_or(line).ends_with('\\') {
5223 Some("\\")
5224 } else if line.ends_with(" ") {
5225 Some(" ")
5226 } else {
5227 None
5228 };
5229 let reflowed = reflow_line(line, options);
5230
5231 // Preserve hard breaks (two trailing spaces or backslash)
5232 if let Some(break_marker) = hard_break_type {
5233 if !reflowed.is_empty() {
5234 let mut reflowed_with_break = reflowed;
5235 let last_idx = reflowed_with_break.len() - 1;
5236 if !has_hard_break(&reflowed_with_break[last_idx]) {
5237 reflowed_with_break[last_idx].push_str(break_marker);
5238 }
5239 result.extend(reflowed_with_break);
5240 }
5241 } else {
5242 result.extend(reflowed);
5243 }
5244 } else {
5245 // Original behavior: collect consecutive lines into a paragraph.
5246 //
5247 // The whitespace inside a link, an image, a code span or an HTML tag
5248 // is structural, so a part never ends at a line break one of them
5249 // spans: ending it there leaves the newline inside the construct and
5250 // rewrites the document. The ranges are read off a parse of the
5251 // whole paragraph joined, since a construct opened on one line is
5252 // closed on a later one and an unterminated one is no construct at
5253 // all.
5254 //
5255 // Only one sentence per line starts a part in the middle of a
5256 // paragraph, so only that mode asks where the constructs are and
5257 // only there is the paragraph read ahead and parsed.
5258 let paragraph_start = i - 1;
5259 let paragraph_scan = options.sentence_per_line.then(|| {
5260 let mut paragraph_end = i;
5261 while paragraph_end < lines.len() && !is_block_boundary(lines[paragraph_end].trim()) {
5262 paragraph_end += 1;
5263 }
5264 let joined_paragraph = lines[paragraph_start..paragraph_end].join(" ");
5265 let joined_atomic = sentence_structure(&joined_paragraph, options.defined_references.as_ref()).atomic;
5266 let mut line_starts = Vec::with_capacity(paragraph_end - paragraph_start);
5267 let mut line_offset = 0;
5268 for paragraph_line in &lines[paragraph_start..paragraph_end] {
5269 line_starts.push(line_offset);
5270 line_offset += paragraph_line.len() + 1;
5271 }
5272 (joined_atomic, line_starts)
5273 });
5274
5275 while i < lines.len() {
5276 let prev_line = if !current_part.is_empty() {
5277 current_part.last().unwrap()
5278 } else {
5279 ""
5280 };
5281 let next_line = lines[i];
5282 let next_trimmed = next_line.trim();
5283
5284 // Stop at empty lines or special blocks
5285 if is_block_boundary(next_trimmed) {
5286 break;
5287 }
5288
5289 // Check if previous line ends with hard break (two spaces or backslash)
5290 // or is a complete sentence in sentence_per_line mode
5291 let prev_trimmed = prev_line.trim();
5292 let abbreviations = get_abbreviations(&options.abbreviations);
5293 let ends_with_sentence = (prev_trimmed.ends_with('.')
5294 || prev_trimmed.ends_with('!')
5295 || prev_trimmed.ends_with('?')
5296 || prev_trimmed.ends_with(".*")
5297 || prev_trimmed.ends_with("!*")
5298 || prev_trimmed.ends_with("?*")
5299 || prev_trimmed.ends_with("._")
5300 || prev_trimmed.ends_with("!_")
5301 || prev_trimmed.ends_with("?_")
5302 // Quote-terminated sentences (straight and curly quotes)
5303 || prev_trimmed.ends_with(".\"")
5304 || prev_trimmed.ends_with("!\"")
5305 || prev_trimmed.ends_with("?\"")
5306 || prev_trimmed.ends_with(".'")
5307 || prev_trimmed.ends_with("!'")
5308 || prev_trimmed.ends_with("?'")
5309 || prev_trimmed.ends_with(".\u{201D}")
5310 || prev_trimmed.ends_with("!\u{201D}")
5311 || prev_trimmed.ends_with("?\u{201D}")
5312 || prev_trimmed.ends_with(".\u{2019}")
5313 || prev_trimmed.ends_with("!\u{2019}")
5314 || prev_trimmed.ends_with("?\u{2019}")
5315 // A CJK sentence closed by a bracket or a quote, as in
5316 // `(已经完成。)`. The bare CJK enders are left to the reflow
5317 // below, which splits a joined CJK paragraph into sentences
5318 // of its own.
5319 || ends_cjk_sentence_with_closer(prev_trimmed))
5320 && !text_ends_with_abbreviation(
5321 prev_trimmed.trim_end_matches(['*', '_', '"', '\'', '\u{201D}', '\u{2019}']),
5322 &abbreviations,
5323 );
5324
5325 // The space that joins this line to the one before it, and
5326 // whether an atomic range of the parse spans it.
5327 let inside_construct = paragraph_scan.as_ref().is_some_and(|(joined_atomic, line_starts)| {
5328 let join_offset = line_starts[i - paragraph_start] - 1;
5329 joined_atomic
5330 .iter()
5331 .any(|&(start, end)| start <= join_offset && join_offset < end)
5332 });
5333
5334 // A line that is one whole `$$...$$` expression is a display
5335 // block of its own, so it is a part of its own: the part before
5336 // it closes, and a new one opens after it. A line touched by a
5337 // code span crossing one of its boundaries is code, not such a
5338 // block. The previous line is the one before `next_line` in
5339 // `lines`.
5340 let ends_at_hard_break = has_hard_break(prev_line);
5341 if ends_at_hard_break
5342 || (is_self_contained_display_math_line(prev_line) && !inside_code_span[i - 1])
5343 || (is_self_contained_display_math_line(next_line) && !inside_code_span[i])
5344 || (options.sentence_per_line && ends_with_sentence && !inside_construct)
5345 {
5346 // Start a new part after hard break, display math or complete sentence
5347 paragraph_parts.push((join_soft_break_lines(¤t_part), ends_at_hard_break));
5348 current_part = vec![next_line];
5349 } else {
5350 current_part.push(next_line);
5351 }
5352 i += 1;
5353 }
5354
5355 // Add the last part
5356 if !current_part.is_empty() {
5357 if current_part.len() == 1 {
5358 // Single line, don't add trailing space
5359 paragraph_parts.push((current_part[0].to_string(), false));
5360 } else {
5361 paragraph_parts.push((join_soft_break_lines(¤t_part), false));
5362 }
5363 }
5364
5365 // Reflow each part separately, preserving hard breaks
5366 for (j, (part, ends_at_hard_break)) in paragraph_parts.iter().enumerate() {
5367 let reflowed = reflow_line(part, options);
5368 result.extend(reflowed);
5369
5370 // Preserve hard break by ensuring last line of part ends with hard break marker
5371 // Use two spaces as the default hard break format for reflows
5372 // But don't add hard breaks in sentence_per_line mode - lines are already separate
5373 // A part that closed at a display-math line ends at no hard break
5374 // and gets no marker.
5375 if *ends_at_hard_break
5376 && j < paragraph_parts.len() - 1
5377 && !result.is_empty()
5378 && !options.sentence_per_line
5379 {
5380 let last_idx = result.len() - 1;
5381 if !has_hard_break(&result[last_idx]) {
5382 result[last_idx].push_str(" ");
5383 }
5384 }
5385 }
5386 }
5387 }
5388
5389 // Preserve trailing newline if the original content had one
5390 let result_text = result.join("\n");
5391 if content.ends_with('\n') && !result_text.ends_with('\n') {
5392 format!("{result_text}\n")
5393 } else {
5394 result_text
5395 }
5396}
5397
5398/// Information about a reflowed paragraph
5399#[derive(Debug, Clone)]
5400pub struct ParagraphReflow {
5401 /// Starting byte offset of the paragraph in the original content
5402 pub start_byte: usize,
5403 /// Ending byte offset of the paragraph in the original content
5404 pub end_byte: usize,
5405 /// The reflowed text for this paragraph
5406 pub reflowed_text: String,
5407}
5408
5409/// A collected blockquote line used for style-preserving reflow.
5410///
5411/// The invariant `is_explicit == true` iff `prefix.is_some()` is enforced by the
5412/// constructors. Use [`BlockquoteLineData::explicit`] or [`BlockquoteLineData::lazy`]
5413/// rather than constructing the struct directly.
5414#[derive(Debug, Clone)]
5415pub struct BlockquoteLineData {
5416 /// Trimmed content without the `> ` prefix.
5417 pub(crate) content: String,
5418 /// Whether this line carries an explicit blockquote marker.
5419 pub(crate) is_explicit: bool,
5420 /// Full blockquote prefix (e.g. `"> "`, `"> > "`). `None` for lazy continuation lines.
5421 pub(crate) prefix: Option<String>,
5422}
5423
5424impl BlockquoteLineData {
5425 /// Create an explicit (marker-bearing) blockquote line.
5426 pub fn explicit(content: String, prefix: String) -> Self {
5427 Self {
5428 content,
5429 is_explicit: true,
5430 prefix: Some(prefix),
5431 }
5432 }
5433
5434 /// Create a lazy continuation line (no blockquote marker).
5435 pub fn lazy(content: String) -> Self {
5436 Self {
5437 content,
5438 is_explicit: false,
5439 prefix: None,
5440 }
5441 }
5442}
5443
5444/// Style for blockquote continuation lines after reflow.
5445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5446pub enum BlockquoteContinuationStyle {
5447 Explicit,
5448 Lazy,
5449}
5450
5451/// Determine the continuation style for a blockquote paragraph from its collected lines.
5452///
5453/// The first line is always explicit (it carries the marker), so only continuation
5454/// lines (index 1+) are counted. Ties resolve to `Explicit`.
5455///
5456/// When the slice has only one element (no continuation lines to inspect), both
5457/// counts are zero and the tie-breaking rule returns `Explicit`.
5458pub fn blockquote_continuation_style(lines: &[BlockquoteLineData]) -> BlockquoteContinuationStyle {
5459 let mut explicit_count = 0usize;
5460 let mut lazy_count = 0usize;
5461
5462 for line in lines.iter().skip(1) {
5463 if line.is_explicit {
5464 explicit_count += 1;
5465 } else {
5466 lazy_count += 1;
5467 }
5468 }
5469
5470 if explicit_count > 0 && lazy_count == 0 {
5471 BlockquoteContinuationStyle::Explicit
5472 } else if lazy_count > 0 && explicit_count == 0 {
5473 BlockquoteContinuationStyle::Lazy
5474 } else if explicit_count >= lazy_count {
5475 BlockquoteContinuationStyle::Explicit
5476 } else {
5477 BlockquoteContinuationStyle::Lazy
5478 }
5479}
5480
5481/// Determine the dominant blockquote prefix for a paragraph.
5482///
5483/// The most frequently occurring explicit prefix wins. Ties are broken by earliest
5484/// first appearance. Falls back to `fallback` when no explicit lines are present.
5485pub fn dominant_blockquote_prefix(lines: &[BlockquoteLineData], fallback: &str) -> String {
5486 let mut counts: std::collections::HashMap<String, (usize, usize)> = std::collections::HashMap::new();
5487
5488 for (idx, line) in lines.iter().enumerate() {
5489 let Some(prefix) = line.prefix.as_ref() else {
5490 continue;
5491 };
5492 counts
5493 .entry(prefix.clone())
5494 .and_modify(|entry| entry.0 += 1)
5495 .or_insert((1, idx));
5496 }
5497
5498 counts
5499 .into_iter()
5500 .max_by(|(_, (count_a, first_idx_a)), (_, (count_b, first_idx_b))| {
5501 count_a.cmp(count_b).then_with(|| first_idx_b.cmp(first_idx_a))
5502 })
5503 .map_or_else(|| fallback.to_string(), |(prefix, _)| prefix)
5504}
5505
5506/// Whether a reflowed blockquote content line must carry an explicit prefix.
5507///
5508/// Lines that would start a new block structure (headings, fences, lists, etc.)
5509/// cannot safely use lazy continuation syntax.
5510pub(crate) fn should_force_explicit_blockquote_line(content_line: &str) -> bool {
5511 let trimmed = content_line.trim_start();
5512 trimmed.starts_with('>')
5513 || trimmed.starts_with('#')
5514 || trimmed.starts_with("```")
5515 || trimmed.starts_with("~~~")
5516 || is_unordered_list_marker(trimmed)
5517 || is_numbered_list_item(trimmed)
5518 || is_horizontal_rule(trimmed)
5519 || is_definition_list_marker(trimmed)
5520 || (trimmed.starts_with('[') && trimmed.contains("]:"))
5521 || trimmed.starts_with(":::")
5522 || (trimmed.starts_with('<')
5523 && !trimmed.starts_with("<http")
5524 && !trimmed.starts_with("<https")
5525 && !trimmed.starts_with("<mailto:"))
5526}
5527
5528/// Reflow blockquote content lines and apply continuation style.
5529///
5530/// Segments separated by hard breaks are reflowed independently. The output lines
5531/// receive blockquote prefixes according to `continuation_style`: the first line and
5532/// any line that would start a new block structure always get an explicit prefix;
5533/// other lines follow the detected style.
5534///
5535/// Returns the styled, reflowed lines (without a trailing newline).
5536pub fn reflow_blockquote_content(
5537 lines: &[BlockquoteLineData],
5538 explicit_prefix: &str,
5539 continuation_style: BlockquoteContinuationStyle,
5540 options: &ReflowOptions,
5541) -> Vec<String> {
5542 let content_strs: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
5543 let segments = split_into_segments_strs(&content_strs);
5544 let mut reflowed_content_lines: Vec<String> = Vec::new();
5545
5546 for segment in segments {
5547 let hard_break_type = segment.last().and_then(|&line| {
5548 let line = line.strip_suffix('\r').unwrap_or(line);
5549 if line.ends_with('\\') {
5550 Some("\\")
5551 } else if line.ends_with(" ") {
5552 Some(" ")
5553 } else {
5554 None
5555 }
5556 });
5557
5558 let pieces: Vec<&str> = segment
5559 .iter()
5560 .map(|&line| {
5561 if let Some(l) = line.strip_suffix('\\') {
5562 l.trim_end()
5563 } else if let Some(l) = line.strip_suffix(" ") {
5564 l.trim_end()
5565 } else {
5566 line.trim_end()
5567 }
5568 })
5569 .collect();
5570
5571 let segment_text = pieces.join(" ");
5572 let segment_text = segment_text.trim();
5573 if segment_text.is_empty() {
5574 continue;
5575 }
5576
5577 let mut reflowed = reflow_line(segment_text, options);
5578 if let Some(break_marker) = hard_break_type
5579 && !reflowed.is_empty()
5580 {
5581 let last_idx = reflowed.len() - 1;
5582 if !has_hard_break(&reflowed[last_idx]) {
5583 reflowed[last_idx].push_str(break_marker);
5584 }
5585 }
5586 reflowed_content_lines.extend(reflowed);
5587 }
5588
5589 let mut styled_lines: Vec<String> = Vec::new();
5590 for (idx, line) in reflowed_content_lines.iter().enumerate() {
5591 let force_explicit = idx == 0
5592 || continuation_style == BlockquoteContinuationStyle::Explicit
5593 || should_force_explicit_blockquote_line(line);
5594 if force_explicit {
5595 styled_lines.push(format!("{explicit_prefix}{line}"));
5596 } else {
5597 styled_lines.push(line.clone());
5598 }
5599 }
5600
5601 styled_lines
5602}
5603
5604fn is_blockquote_content_boundary(content: &str) -> bool {
5605 let trimmed = content.trim();
5606 trimmed.is_empty()
5607 || is_block_boundary(trimmed)
5608 || crate::utils::table_utils::TableUtils::is_potential_table_row(content)
5609 || trimmed.starts_with(":::")
5610 || crate::utils::is_template_directive_only(content)
5611 || is_standalone_attr_list(content)
5612 || is_snippet_block_delimiter(content)
5613}
5614
5615fn split_into_segments_strs<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
5616 let mut segments = Vec::new();
5617 let mut current = Vec::new();
5618 // The lines are the quote's own content, so a code span runs across them
5619 // as it does across the content joined by its line breaks. A trailing
5620 // empty line has no entry and is never an expression either.
5621 let inside_code_span = lines_touching_multiline_code_span(&lines.join("\n"));
5622
5623 for (idx, &line) in lines.iter().enumerate() {
5624 // A line that is one whole `$$...$$` expression is a display block of
5625 // its own, so it is a segment of its own: it closes the segment above
5626 // it and closes again on itself. A line touched by a code span
5627 // crossing one of its boundaries is code, not such a block.
5628 let is_display_math =
5629 is_self_contained_display_math_line(line) && !inside_code_span.get(idx).is_some_and(|&inside| inside);
5630 if is_display_math && !current.is_empty() {
5631 segments.push(std::mem::take(&mut current));
5632 }
5633 current.push(line);
5634 if has_hard_break(line) || is_display_math {
5635 segments.push(std::mem::take(&mut current));
5636 }
5637 }
5638
5639 if !current.is_empty() {
5640 segments.push(current);
5641 }
5642
5643 segments
5644}
5645
5646fn reflow_blockquote_paragraph_at_line(
5647 content: &str,
5648 lines: &[&str],
5649 target_idx: usize,
5650 options: &ReflowOptions,
5651) -> Option<ParagraphReflow> {
5652 let mut anchor_idx = target_idx;
5653 let mut target_level = if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[target_idx]) {
5654 parsed.nesting_level
5655 } else {
5656 let mut found = None;
5657 let mut idx = target_idx;
5658 loop {
5659 if lines[idx].trim().is_empty() {
5660 break;
5661 }
5662 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[idx]) {
5663 found = Some((idx, parsed.nesting_level));
5664 break;
5665 }
5666 if idx == 0 {
5667 break;
5668 }
5669 idx -= 1;
5670 }
5671 let (idx, level) = found?;
5672 anchor_idx = idx;
5673 level
5674 };
5675
5676 // Expand backward to capture prior quote content at the same nesting level.
5677 let mut para_start = anchor_idx;
5678 while para_start > 0 {
5679 let prev_idx = para_start - 1;
5680 let prev_line = lines[prev_idx];
5681
5682 if prev_line.trim().is_empty() {
5683 break;
5684 }
5685
5686 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(prev_line) {
5687 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
5688 break;
5689 }
5690 para_start = prev_idx;
5691 continue;
5692 }
5693
5694 let prev_lazy = prev_line.trim_start();
5695 if is_blockquote_content_boundary(prev_lazy) {
5696 break;
5697 }
5698 para_start = prev_idx;
5699 }
5700
5701 // Lazy continuation cannot precede the first explicit marker.
5702 while para_start < lines.len() {
5703 let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(lines[para_start]) else {
5704 para_start += 1;
5705 continue;
5706 };
5707 target_level = parsed.nesting_level;
5708 break;
5709 }
5710
5711 if para_start >= lines.len() || para_start > target_idx {
5712 return None;
5713 }
5714
5715 // Collect explicit lines at target level and lazy continuation lines.
5716 // Each entry is (original_line_idx, BlockquoteLineData).
5717 let mut collected: Vec<(usize, BlockquoteLineData)> = Vec::new();
5718 let mut idx = para_start;
5719 while idx < lines.len() {
5720 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].1.content) {
5721 break;
5722 }
5723
5724 let line = lines[idx];
5725 if line.trim().is_empty() {
5726 break;
5727 }
5728
5729 if let Some(parsed) = crate::utils::blockquote::parse_blockquote_prefix(line) {
5730 if parsed.nesting_level != target_level || is_blockquote_content_boundary(parsed.content) {
5731 break;
5732 }
5733 collected.push((
5734 idx,
5735 BlockquoteLineData::explicit(trim_preserving_hard_break(parsed.content), parsed.prefix.to_string()),
5736 ));
5737 idx += 1;
5738 continue;
5739 }
5740
5741 let lazy_content = line.trim_start();
5742 if is_blockquote_content_boundary(lazy_content) {
5743 break;
5744 }
5745
5746 collected.push((idx, BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content))));
5747 idx += 1;
5748 }
5749
5750 if collected.is_empty() {
5751 return None;
5752 }
5753
5754 let para_end = collected[collected.len() - 1].0;
5755 if target_idx < para_start || target_idx > para_end {
5756 return None;
5757 }
5758
5759 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|(_, d)| d.clone()).collect();
5760
5761 let fallback_prefix = line_data
5762 .iter()
5763 .find_map(|d| d.prefix.clone())
5764 .unwrap_or_else(|| "> ".to_string());
5765 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
5766 let continuation_style = blockquote_continuation_style(&line_data);
5767
5768 let adjusted_line_length = options
5769 .line_length
5770 .saturating_sub(display_len(&explicit_prefix, options.length_mode))
5771 .max(1);
5772
5773 let adjusted_options = ReflowOptions {
5774 line_length: adjusted_line_length,
5775 ..options.clone()
5776 };
5777
5778 let styled_lines = reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &adjusted_options);
5779
5780 if styled_lines.is_empty() {
5781 return None;
5782 }
5783
5784 // Calculate byte offsets.
5785 let mut start_byte = 0;
5786 for line in lines.iter().take(para_start) {
5787 start_byte += line.len() + 1;
5788 }
5789
5790 let mut end_byte = start_byte;
5791 for line in lines.iter().take(para_end + 1).skip(para_start) {
5792 end_byte += line.len() + 1;
5793 }
5794
5795 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
5796 if !includes_trailing_newline {
5797 end_byte -= 1;
5798 }
5799
5800 let reflowed_joined = styled_lines.join("\n");
5801 let reflowed_text = if includes_trailing_newline {
5802 if reflowed_joined.ends_with('\n') {
5803 reflowed_joined
5804 } else {
5805 format!("{reflowed_joined}\n")
5806 }
5807 } else if reflowed_joined.ends_with('\n') {
5808 reflowed_joined.trim_end_matches('\n').to_string()
5809 } else {
5810 reflowed_joined
5811 };
5812
5813 Some(ParagraphReflow {
5814 start_byte,
5815 end_byte,
5816 reflowed_text,
5817 })
5818}
5819
5820/// Reflow a single paragraph at the specified line number
5821///
5822/// This function finds the paragraph containing the given line number,
5823/// reflows it according to the specified line length, and returns
5824/// information about the paragraph location and its reflowed text.
5825///
5826/// # Arguments
5827///
5828/// * `content` - The full document content
5829/// * `line_number` - The 1-based line number within the paragraph to reflow
5830/// * `line_length` - The target line length for reflowing
5831///
5832/// # Returns
5833///
5834/// Returns `Some(ParagraphReflow)` if a paragraph was found and reflowed,
5835/// or `None` if the line number is out of bounds or the content at that
5836/// line shouldn't be reflowed (e.g., code blocks, headings, etc.)
5837pub fn reflow_paragraph_at_line(content: &str, line_number: usize, line_length: usize) -> Option<ParagraphReflow> {
5838 reflow_paragraph_at_line_with_mode(content, line_number, line_length, ReflowLengthMode::default())
5839}
5840
5841/// Reflow a paragraph at the given line with a specific length mode.
5842pub fn reflow_paragraph_at_line_with_mode(
5843 content: &str,
5844 line_number: usize,
5845 line_length: usize,
5846 length_mode: ReflowLengthMode,
5847) -> Option<ParagraphReflow> {
5848 let options = ReflowOptions {
5849 line_length,
5850 length_mode,
5851 ..Default::default()
5852 };
5853 reflow_paragraph_at_line_with_options(content, line_number, &options)
5854}
5855
5856/// Reflow a paragraph at the given line using the provided options.
5857///
5858/// This is the canonical implementation used by both the rule's fix mode and the
5859/// LSP "Reflow paragraph" action. Passing a fully configured `ReflowOptions` allows
5860/// the LSP action to respect user-configured reflow mode, abbreviations, etc.
5861///
5862/// # Returns
5863///
5864/// Returns `Some(ParagraphReflow)` with byte offsets and reflowed text, or `None`
5865/// if the line is out of bounds or sits inside a non-reflow-able construct.
5866pub fn reflow_paragraph_at_line_with_options(
5867 content: &str,
5868 line_number: usize,
5869 options: &ReflowOptions,
5870) -> Option<ParagraphReflow> {
5871 if line_number == 0 {
5872 return None;
5873 }
5874
5875 let lines: Vec<&str> = content.lines().collect();
5876
5877 // Check if line number is valid (1-based)
5878 if line_number > lines.len() {
5879 return None;
5880 }
5881
5882 let target_idx = line_number - 1; // Convert to 0-based
5883 let target_line = lines[target_idx];
5884 let trimmed = target_line.trim();
5885
5886 // Handle blockquote paragraphs (including lazy continuation lines) with
5887 // style-preserving output.
5888 if let Some(blockquote_reflow) = reflow_blockquote_paragraph_at_line(content, &lines, target_idx, options) {
5889 return Some(blockquote_reflow);
5890 }
5891
5892 // Don't reflow special blocks
5893 if is_paragraph_boundary(trimmed, target_line) {
5894 return None;
5895 }
5896
5897 // Find paragraph start - scan backward until blank line or special block
5898 let mut para_start = target_idx;
5899 while para_start > 0 {
5900 let prev_idx = para_start - 1;
5901 let prev_line = lines[prev_idx];
5902 let prev_trimmed = prev_line.trim();
5903
5904 // Stop at blank line or special blocks
5905 if is_paragraph_boundary(prev_trimmed, prev_line) {
5906 break;
5907 }
5908
5909 para_start = prev_idx;
5910 }
5911
5912 // Find paragraph end - scan forward until blank line or special block
5913 let mut para_end = target_idx;
5914 while para_end + 1 < lines.len() {
5915 let next_idx = para_end + 1;
5916 let next_line = lines[next_idx];
5917 let next_trimmed = next_line.trim();
5918
5919 // Stop at blank line or special blocks
5920 if is_paragraph_boundary(next_trimmed, next_line) {
5921 break;
5922 }
5923
5924 para_end = next_idx;
5925 }
5926
5927 // Extract paragraph lines
5928 let paragraph_lines = &lines[para_start..=para_end];
5929
5930 // Calculate byte offsets
5931 let mut start_byte = 0;
5932 for line in lines.iter().take(para_start) {
5933 start_byte += line.len() + 1; // +1 for newline
5934 }
5935
5936 let mut end_byte = start_byte;
5937 for line in paragraph_lines {
5938 end_byte += line.len() + 1; // +1 for newline
5939 }
5940
5941 // Track whether the byte range includes a trailing newline
5942 // (it doesn't if this is the last line and the file doesn't end with newline)
5943 let includes_trailing_newline = para_end != lines.len() - 1 || content.ends_with('\n');
5944
5945 // Adjust end_byte if the last line doesn't have a newline
5946 if !includes_trailing_newline {
5947 end_byte -= 1;
5948 }
5949
5950 // Join paragraph lines and reflow
5951 let paragraph_text = paragraph_lines.join("\n");
5952
5953 // Reflow the paragraph using reflow_markdown to handle it properly
5954 let reflowed = reflow_markdown(¶graph_text, options);
5955
5956 // Ensure reflowed text matches whether the byte range includes a trailing newline
5957 // This is critical: if the range includes a newline, the replacement must too,
5958 // otherwise the next line will get appended to the reflowed paragraph
5959 let reflowed_text = if includes_trailing_newline {
5960 // Range includes newline - ensure reflowed text has one
5961 if reflowed.ends_with('\n') {
5962 reflowed
5963 } else {
5964 format!("{reflowed}\n")
5965 }
5966 } else {
5967 // Range doesn't include newline - ensure reflowed text doesn't have one
5968 if reflowed.ends_with('\n') {
5969 reflowed.trim_end_matches('\n').to_string()
5970 } else {
5971 reflowed
5972 }
5973 };
5974
5975 Some(ParagraphReflow {
5976 start_byte,
5977 end_byte,
5978 reflowed_text,
5979 })
5980}
5981/// Decomposes a raw inline code span string into its inner content and backtick marker.
5982///
5983/// For example, `decompose_code_span("`code`")` returns `Some(("code", "`"))`.
5984/// If the input is not a valid code span (e.g., it doesn't start and end with the
5985/// same number of backticks), returns `None`.
5986fn decompose_code_span(raw: &str) -> Option<(&str, &str)> {
5987 let marker_len = raw.bytes().take_while(|&b| b == b'`').count();
5988 if marker_len == 0 {
5989 return None;
5990 }
5991 let marker = &raw[..marker_len];
5992 if raw.len() < marker_len * 2 {
5993 return None;
5994 }
5995 let content = &raw[marker_len..raw.len() - marker_len];
5996 Some((content, marker))
5997}
5998
5999#[cfg(test)]
6000mod tests {
6001 use super::*;
6002
6003 /// `preserves_content` is the last line of defense against a reflow writing
6004 /// corrupted prose into a file, so it has to actually reject the ways a
6005 /// reflow can go wrong - not merely accept the ways it can go right.
6006 #[test]
6007 fn preserves_content_accepts_whitespace_changes_and_rejects_the_rest() {
6008 let accepted: &[(&str, &[&str])] = &[
6009 ("one two three", &["one two three"]),
6010 ("one two three", &["one two", "three"]),
6011 ("one two three", &["one", "two", "three"]),
6012 // Collapsing runs of whitespace and dropping trailing whitespace
6013 ("one two ", &["one two"]),
6014 // A script written without spaces has to break somewhere
6015 ("日本語のテキスト", &["日本語の", "テキスト"]),
6016 // Markers move to the line their content moved to
6017 ("_First. Second._", &["_First.", "Second._"]),
6018 ];
6019 for (original, reflowed) in accepted {
6020 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
6021 assert!(
6022 preserves_content(original, &reflowed),
6023 "{original:?} -> {reflowed:?} only moves whitespace"
6024 );
6025 }
6026
6027 let rejected: &[(&str, &[&str])] = &[
6028 // Dropped
6029 ("one two three", &["one two"]),
6030 // Invented
6031 ("one two", &["one two three"]),
6032 // Reordered
6033 ("one two", &["two one"]),
6034 // Duplicated
6035 ("_First. Second._", &["_First._", "_Second._"]),
6036 // Two words glued into one
6037 ("alpha and beta", &["alpha", "andbeta"]),
6038 // A space deleted around punctuation
6039 ("mot suivant : autre", &["mot suivant: autre"]),
6040 ];
6041 for (original, reflowed) in rejected {
6042 let reflowed: Vec<String> = reflowed.iter().map(ToString::to_string).collect();
6043 assert!(
6044 !preserves_content(original, &reflowed),
6045 "{original:?} -> {reflowed:?} changes the text, not just its line breaks"
6046 );
6047 }
6048 }
6049
6050 /// A rejected reflow leaves the line alone rather than writing the damage.
6051 #[test]
6052 fn reflow_line_falls_back_to_the_input_when_content_would_change() {
6053 let options = ReflowOptions {
6054 line_length: 40,
6055 ..Default::default()
6056 };
6057 let line = "one two three four five six seven eight nine ten";
6058
6059 assert!(preserves_content(line, &reflow_line(line, &options)));
6060 assert_eq!(reflow_line(line, &options), reflow_line_unchecked(line, &options));
6061 }
6062
6063 #[test]
6064 fn cascade_split_line_handles_a_very_long_line_without_overflowing() {
6065 // A single line of thousands of words once drove `cascade_split_line`
6066 // into deep recursion (stack overflow / hang). The iterative version
6067 // must complete and split it into many lines that each fit the width and
6068 // that together preserve every word. The test finishing at all is the
6069 // core assertion (no stack overflow); the content checks guard behavior.
6070 let words: Vec<String> = (0..4000).map(|i| format!("word{i}")).collect();
6071 let line = words.join(" ");
6072
6073 let options = ReflowOptions {
6074 line_length: 80,
6075 length_mode: ReflowLengthMode::Chars,
6076 ..Default::default()
6077 };
6078 let out = cascade_split_line(&line, &options);
6079
6080 assert!(out.len() > 1, "a very long line should split into many lines");
6081 for segment in &out {
6082 assert!(
6083 display_len(segment, ReflowLengthMode::Chars) <= 80 || !segment.contains(' '),
6084 "each wrapped line should fit the width (or be a single unbreakable token)"
6085 );
6086 }
6087 // Every original word survives, in order.
6088 let rejoined = out.join(" ");
6089 let original_words: Vec<&str> = line.split(' ').collect();
6090 let result_words: Vec<&str> = rejoined.split_whitespace().collect();
6091 assert_eq!(original_words, result_words, "reflow must preserve all words in order");
6092 }
6093
6094 /// Unit test for private helper function text_ends_with_abbreviation()
6095 ///
6096 /// This test stays inline because it tests a private function.
6097 /// All other tests (public API, integration tests) are in tests/utils/text_reflow_test.rs
6098 #[test]
6099 fn test_helper_function_text_ends_with_abbreviation() {
6100 // Test the helper function directly
6101 let abbreviations = get_abbreviations(&None);
6102
6103 // True cases - built-in abbreviations (titles and i.e./e.g.)
6104 assert!(text_ends_with_abbreviation("Dr.", &abbreviations));
6105 assert!(text_ends_with_abbreviation("word Dr.", &abbreviations));
6106 assert!(text_ends_with_abbreviation("e.g.", &abbreviations));
6107 assert!(text_ends_with_abbreviation("i.e.", &abbreviations));
6108 assert!(text_ends_with_abbreviation("Mr.", &abbreviations));
6109 assert!(text_ends_with_abbreviation("Mrs.", &abbreviations));
6110 assert!(text_ends_with_abbreviation("Ms.", &abbreviations));
6111 assert!(text_ends_with_abbreviation("Prof.", &abbreviations));
6112
6113 // False cases - NOT in built-in list (etc doesn't always have period)
6114 assert!(!text_ends_with_abbreviation("etc.", &abbreviations));
6115 assert!(!text_ends_with_abbreviation("paradigms.", &abbreviations));
6116 assert!(!text_ends_with_abbreviation("programs.", &abbreviations));
6117 assert!(!text_ends_with_abbreviation("items.", &abbreviations));
6118 assert!(!text_ends_with_abbreviation("systems.", &abbreviations));
6119 assert!(!text_ends_with_abbreviation("Dr?", &abbreviations)); // question mark, not period
6120 assert!(!text_ends_with_abbreviation("Mr!", &abbreviations)); // exclamation, not period
6121 assert!(!text_ends_with_abbreviation("paradigms?", &abbreviations)); // question mark
6122 assert!(!text_ends_with_abbreviation("word", &abbreviations)); // no punctuation
6123 assert!(!text_ends_with_abbreviation("", &abbreviations)); // empty string
6124 }
6125
6126 #[test]
6127 fn test_footnote_after_period_splits_sentence() {
6128 // A footnote reference glued to the period (no space) must not swallow
6129 // the sentence boundary; the reference stays attached to the sentence
6130 // it annotates.
6131 let text = "First sentence.[^1] Second sentence.";
6132 let sentences = split_into_sentences(text, None, true);
6133 assert_eq!(
6134 sentences,
6135 vec!["First sentence.[^1]".to_string(), "Second sentence.".to_string()],
6136 "footnote glued to the period should keep the boundary and stay attached to the first sentence"
6137 );
6138 }
6139
6140 #[test]
6141 fn test_multiple_consecutive_footnotes_after_period_splits_sentence() {
6142 // Multiple footnote references glued back-to-back after the period.
6143 let text = "Notes here.[^1][^2] Second sentence.";
6144 let sentences = split_into_sentences(text, None, true);
6145 assert_eq!(
6146 sentences,
6147 vec!["Notes here.[^1][^2]".to_string(), "Second sentence.".to_string()]
6148 );
6149 }
6150
6151 #[test]
6152 fn test_footnote_before_period_still_splits_sentence() {
6153 // Control: a footnote reference before the period was already followed
6154 // by a space, so this boundary worked before this fix and must keep
6155 // working.
6156 let text = "Annotation here[^1]. Second sentence.";
6157 let sentences = split_into_sentences(text, None, true);
6158 assert_eq!(
6159 sentences,
6160 vec!["Annotation here[^1].".to_string(), "Second sentence.".to_string()]
6161 );
6162 }
6163
6164 #[test]
6165 fn test_mid_sentence_footnote_does_not_split() {
6166 // A footnote reference not glued to sentence-ending punctuation must not
6167 // introduce a spurious boundary at the bracket itself.
6168 let text = "The system word[^1] more words. Next sentence.";
6169 let sentences = split_into_sentences(text, None, true);
6170 assert_eq!(
6171 sentences,
6172 vec![
6173 "The system word[^1] more words.".to_string(),
6174 "Next sentence.".to_string()
6175 ]
6176 );
6177 }
6178
6179 #[test]
6180 fn test_bare_numeric_bracket_after_period_does_not_split() {
6181 // A bare `[1]` is link/citation-like text, not footnote syntax; the fix
6182 // is scoped to `[^label]` only.
6183 let text = "Citation here.[1] Second sentence.";
6184 let sentences = split_into_sentences(text, None, true);
6185 assert_eq!(
6186 sentences,
6187 vec![text.to_string()],
6188 "a bare numeric bracket must not be treated as a sentence boundary"
6189 );
6190 }
6191
6192 #[test]
6193 fn test_footnote_glued_to_following_word_does_not_split() {
6194 // No whitespace after the footnote reference means there is nowhere a
6195 // next sentence can start, so this must not be treated as a boundary.
6196 let text = "First sentence.[^1]Continued glued text.";
6197 let sentences = split_into_sentences(text, None, true);
6198 assert_eq!(sentences, vec![text.to_string()]);
6199 }
6200
6201 #[test]
6202 fn test_footnote_at_end_of_text_is_preserved() {
6203 // A footnote reference at the very end of the text has nothing after it
6204 // to split off; it is preserved as part of the single trailing sentence.
6205 let text = "Sentence.[^1]";
6206 let sentences = split_into_sentences(text, None, true);
6207 assert_eq!(sentences, vec![text.to_string()]);
6208 }
6209
6210 #[test]
6211 fn test_abbreviation_before_footnote_does_not_split() {
6212 // The existing abbreviation guard must still apply when a footnote
6213 // reference immediately follows the abbreviation's period.
6214 let text = "See the notes, e.g.[^1] this one.";
6215 let sentences = split_into_sentences(text, None, true);
6216 assert_eq!(
6217 sentences,
6218 vec![text.to_string()],
6219 "e.g. is an abbreviation, not a sentence boundary"
6220 );
6221 }
6222
6223 #[test]
6224 fn sentence_boundary_never_falls_inside_an_atomic_construct() {
6225 // Each construct holds text that reads like a sentence boundary
6226 // (`. ` followed by a capital) but is one unit to the renderer: a
6227 // break inside it rewrites the document. The boundary after each
6228 // construct is real and must still split, so a construct that simply
6229 // silenced the splitter would fail here too.
6230 let cases = [
6231 "Prefix [link. Still link](https://example.com) tail. Next sentence.",
6232 "Prefix [target](<https://example.com/First. Second>) tail. Next sentence.",
6233 "Prefix [text](url \"Title. More\") tail. Next sentence.",
6234 "Prefix  tail. Next sentence.",
6235 "Prefix [ref text. More][ref] tail. Next sentence.",
6236 "Prefix [collapsed. More][] tail. Next sentence.",
6237 "Prefix [[Page name. Title]] tail. Next sentence.",
6238 "Prefix $x. Y$ tail. Next sentence.",
6239 "Prefix $$x. Y$$ tail. Next sentence.",
6240 "Prefix <span title=\"A. B\">x</span> tail. Next sentence.",
6241 "Prefix `code. Still code` tail. Next sentence.",
6242 ];
6243 for text in cases {
6244 let sentences = split_into_sentences(text, None, true);
6245 let (head, tail) = text.rsplit_once(" tail. ").expect("case has a tail");
6246 assert_eq!(
6247 sentences,
6248 vec![format!("{head} tail."), tail.to_string()],
6249 "input {text:?}"
6250 );
6251 }
6252
6253 // A bare `[text]` is a link only when its label is defined. Defined,
6254 // or with the definitions unknown, it is held whole like the rest;
6255 // known undefined, it is prose and the boundary inside it is real.
6256 let text = "Prefix [shortcut. More] tail. Next sentence.";
6257 let whole = vec![
6258 "Prefix [shortcut. More] tail.".to_string(),
6259 "Next sentence.".to_string(),
6260 ];
6261 let defined = HashSet::from(["shortcut. more".to_string()]);
6262 assert_eq!(split_into_sentences(text, Some(&defined), true), whole);
6263 assert_eq!(split_into_sentences(text, None, true), whole);
6264 assert_eq!(
6265 split_into_sentences(text, Some(&HashSet::new()), true),
6266 vec!["Prefix [shortcut.", "More] tail.", "Next sentence."]
6267 );
6268 }
6269
6270 #[test]
6271 fn a_sentence_may_open_with_a_link_or_image() {
6272 // The next sentence's first letter sits behind the link opener; a
6273 // capital there is a capital start. The reflow already emits the text
6274 // before the link as its own line, so the check has to count the same
6275 // boundary or it never asks for that split.
6276 for text in [
6277 "Opening sentence. [First. Second](https://example.com)",
6278 "Opening sentence. ",
6279 "Opening sentence. [[First. Second]]",
6280 "Opening sentence. [[first-note|First. Second]]",
6281 "Opening sentence. [Ref link][ref]",
6282 // A link whose text is an image opens with the image's alt text,
6283 // one construct inside another; each is walked into at its own start.
6284 "Opening sentence. [](url) continues.",
6285 "Opening sentence. [][ref] continues.",
6286 // The nested image may be a reference image; its full and
6287 // collapsed forms are images whether or not the label is defined.
6288 "Opening sentence. [![First image][img]](url) continues.",
6289 "Opening sentence. [![First image][]](url) continues.",
6290 "Opening sentence. [![First image][img]][ref] continues.",
6291 ] {
6292 let (head, tail) = text.split_once(". ").expect("case has a boundary");
6293 assert_eq!(
6294 split_into_sentences(text, None, true),
6295 vec![format!("{head}."), tail.to_string()],
6296 "input {text:?}"
6297 );
6298 }
6299 // A shortcut reference image nested in a link is an image only when
6300 // its label is defined, exactly as at the top level.
6301 let text = "Opening sentence. [![First image]](url) continues.";
6302 let defined = HashSet::from(["first image".to_string()]);
6303 assert_eq!(
6304 split_into_sentences(text, Some(&defined), true),
6305 vec!["Opening sentence.", "[![First image]](url) continues."]
6306 );
6307 assert_eq!(
6308 split_into_sentences(text, Some(&HashSet::new()), true),
6309 vec![text.to_string()],
6310 "an undefined shortcut is bracketed text, and `!` opens no sentence"
6311 );
6312 // The nested image's alt text is what has to be capitalized: the same
6313 // link with a lowercase alt opens no sentence.
6314 assert_eq!(
6315 split_into_sentences(
6316 "Opening sentence. [](url) continues.",
6317 None,
6318 true
6319 ),
6320 vec](url) continues."]
6321 );
6322 // A bare `[text]` whose label is defined is a link too, and its text
6323 // opens the sentence the same way.
6324 let defined = HashSet::from(["smith 2020".to_string()]);
6325 assert_eq!(
6326 split_into_sentences("Claim ends here. [Smith 2020] more text.", Some(&defined), true),
6327 vec!["Claim ends here.", "[Smith 2020] more text."]
6328 );
6329 // Controls: a lowercase link text is no sentence start, and a bracket
6330 // the parse reads as text is not a link opener, so a citation, an
6331 // undefined shortcut, a footnote label or a link left unterminated
6332 // starts no sentence however it is capitalized. The definitions are
6333 // known and empty here, as the rule always supplies them.
6334 let none_defined = HashSet::new();
6335 for text in [
6336 "Opening sentence. [first link](https://example.com) continues.",
6337 "Opening sentence. [[first note]] continues.",
6338 "Opening sentence. [[First Note|first note]] continues.",
6339 "Opening sentence. [[Page continues.",
6340 "Opening sentence. [[First] stray]] continues.",
6341 "Opening sentence.  continues.",
6342 "Opening sentence. [1] is the citation.",
6343 "Opening sentence. [First](unterminated",
6344 "Opening sentence. [First][unterminated",
6345 "Opening sentence. [First] (aside) continues.",
6346 "Claim ends here. [Smith 2020]",
6347 "Claim ends here. [Smith 2020] more text.",
6348 "See the RFC. [RFC] More text.",
6349 "Claim ends here. [^Note] more text.",
6350 ] {
6351 assert_eq!(
6352 split_into_sentences(text, Some(&none_defined), true),
6353 vec![text.to_string()],
6354 "input {text:?}"
6355 );
6356 }
6357 }
6358
6359 #[test]
6360 fn link_opener_is_read_off_the_parse() {
6361 // Length of the opener at the start of `text`, or 0 when the parse
6362 // (with the given definitions) finds no link, image or wikilink there.
6363 let len = |text: &str, defs: Option<&HashSet<String>>| {
6364 let chars: Vec<char> = text.chars().collect();
6365 let char_offsets = char_byte_offsets(&chars);
6366 let NestedStructure { links, .. } = sentence_structure(text, defs);
6367 let st = SentenceText {
6368 text,
6369 chars: &chars,
6370 char_offsets: &char_offsets,
6371 links: &links,
6372 code_spans: &[],
6373 markers: &[],
6374 marker_closers: &[],
6375 paragraph: None,
6376 emphasis: EmphasisSpans::default(),
6377 };
6378 st.link_end_at(0).map_or(0, |end| link_opener_len(&chars, 0, end))
6379 };
6380 let none = HashSet::new();
6381 assert_eq!(len("[text](url)", Some(&none)), 1);
6382 assert_eq!(
6383 len("[text][ref]", Some(&none)),
6384 1,
6385 "a full reference is a link whether or not defined"
6386 );
6387 assert_eq!(len("[text][]", Some(&none)), 1);
6388 assert_eq!(len("", Some(&none)), 2);
6389 assert_eq!(len("[[wiki]]", Some(&none)), 2);
6390 assert_eq!(
6391 len("[[wiki|shown]]", Some(&none)),
6392 7,
6393 "the displayed text starts after the alias pipe"
6394 );
6395 assert_eq!(len("![[img.png|100]]", Some(&none)), 11);
6396 assert_eq!(len("[[wiki|a|b]]", Some(&none)), 7, "the first pipe starts the alias");
6397 assert_eq!(
6398 len("[[wiki|shown]] [[a|b]]", Some(&none)),
6399 7,
6400 "a pipe past the closing `]]` is not this alias"
6401 );
6402 assert_eq!(
6403 len("[a \\] b](url)", Some(&none)),
6404 1,
6405 "an escaped bracket does not close the text"
6406 );
6407 assert_eq!(
6408 len("[](url)", Some(&none)),
6409 1,
6410 "the outer opener is skipped first"
6411 );
6412 // Text to the parse, so no opener: unterminated links, an unclosed or
6413 // malformed wikilink, a shortcut nothing defines, and a footnote
6414 // reference, which the paragraph-level parse has no definition for.
6415 for text in [
6416 "[^1]",
6417 "[text](unterminated",
6418 "[text][unterminated",
6419 "[text] (url)",
6420 "[[wiki",
6421 "[[wiki]",
6422 "[[First] stray]]",
6423 "[Smith 2020]",
6424 "[Smith 2020] (see also)",
6425 "[unclosed",
6426 "!bang",
6427 "text",
6428 ] {
6429 assert_eq!(len(text, Some(&none)), 0, "input {text:?}");
6430 }
6431 // The same shortcut is a link once its label is defined, or when the
6432 // definitions are unknown.
6433 let smith = HashSet::from(["smith 2020".to_string()]);
6434 assert_eq!(len("[Smith 2020]", Some(&smith)), 1);
6435 assert_eq!(len("[Smith 2020]", None), 1);
6436 }
6437
6438 #[test]
6439 fn sentence_per_line_reflow_breaks_before_a_bracket_only_where_the_check_counts() {
6440 // The check counts a boundary before a link, image or wikilink whose
6441 // text starts a sentence and none before a lowercase one, a bare
6442 // citation or a glued link. The reflow has to break at exactly those
6443 // boundaries: a break the check never counts is a fix that keeps
6444 // reporting, and a boundary the reflow ignores is a line it never
6445 // splits. The definitions are known, as the rule always supplies
6446 // them: `[RFC]` alone is a citation, `[Spec]` a defined shortcut link.
6447 let defined = HashSet::from(["spec".to_string()]);
6448 let options = ReflowOptions {
6449 line_length: 120,
6450 sentence_per_line: true,
6451 defined_references: Some(defined.clone()),
6452 ..Default::default()
6453 };
6454 for (text, expected) in [
6455 (
6456 "Claim ends here. [Smith](https://example.com) more text. Second sentence.",
6457 vec more text.",
6460 "Second sentence.",
6461 ],
6462 ),
6463 (
6464 "Wow! [smith](https://example.com) more text. Second sentence.",
6465 vec more text.", "Second sentence."],
6466 ),
6467 (
6468 "Claim ends here. [smith](https://example.com) more text. Second sentence.",
6469 vec more text.",
6471 "Second sentence.",
6472 ],
6473 ),
6474 (
6475 "Claim ends here. [smith][ref] more text. Second sentence.",
6476 vec!["Claim ends here. [smith][ref] more text.", "Second sentence."],
6477 ),
6478 (
6479 "Claim ends here.  more text. Second sentence.",
6480 vec more text.", "Second sentence."],
6481 ),
6482 (
6483 "Claim ends here.[Link](https://example.com) more text. Second sentence.",
6484 vec more text.",
6486 "Second sentence.",
6487 ],
6488 ),
6489 (
6490 "See the RFC. [RFC] More text. Second sentence.",
6491 vec!["See the RFC. [RFC] More text.", "Second sentence."],
6492 ),
6493 (
6494 "See the spec. [Spec] More text. Second sentence.",
6495 vec!["See the spec.", "[Spec] More text.", "Second sentence."],
6496 ),
6497 (
6498 "See the spec. [spec] more text. Second sentence.",
6499 vec!["See the spec. [spec] more text.", "Second sentence."],
6500 ),
6501 (
6502 "Claim ends here. [[page|Second sentence]] continues. Third sentence.",
6503 vec![
6504 "Claim ends here.",
6505 "[[page|Second sentence]] continues.",
6506 "Third sentence.",
6507 ],
6508 ),
6509 (
6510 "Claim ends here. [[Page|second sentence]] continues. Third sentence.",
6511 vec![
6512 "Claim ends here. [[Page|second sentence]] continues.",
6513 "Third sentence.",
6514 ],
6515 ),
6516 ] {
6517 let lines = reflow_line(text, &options);
6518 assert_eq!(lines, expected, "input {text:?}");
6519 // The check counts the same number of sentences on the input as
6520 // the reflow produced lines, and one on each line it produced.
6521 assert_eq!(
6522 split_into_sentences(text, Some(&defined), true).len(),
6523 expected.len(),
6524 "check count for {text:?}"
6525 );
6526 for line in &lines {
6527 assert_eq!(
6528 split_into_sentences(line, Some(&defined), true).len(),
6529 1,
6530 "line {line:?} of {text:?}"
6531 );
6532 }
6533 }
6534 }
6535
6536 #[test]
6537 fn sentence_per_line_reflow_holds_atomic_constructs_whole() {
6538 // The reflow assembles a line one element at a time and re-splits the
6539 // whole line after each text element, so an atomic element already on
6540 // the line is exposed to the splitter along with the text after it.
6541 let options = ReflowOptions {
6542 line_length: 80,
6543 sentence_per_line: true,
6544 ..Default::default()
6545 };
6546 let lines = reflow_line(
6547 "Prefix `code. Still code` and [link. Still link](https://example.com) tail. Next sentence.",
6548 &options,
6549 );
6550 assert_eq!(
6551 lines,
6552 vec tail.".to_string(),
6554 "Next sentence.".to_string(),
6555 ]
6556 );
6557
6558 let lines = reflow_line(
6559 "Prefix  and [target](<https://example.com/First. Second>) tail. Next sentence.",
6560 &options,
6561 );
6562 assert_eq!(
6563 lines,
6564 vec and [target](<https://example.com/First. Second>) tail.".to_string(),
6566 "Next sentence.".to_string(),
6567 ]
6568 );
6569
6570 // Control: a link that carries no boundary of its own leaves the
6571 // surrounding boundaries exactly where they were.
6572 let lines = reflow_line("First one. Then [link](url) second. Third one.", &options);
6573 assert_eq!(
6574 lines,
6575 vec second.".to_string(),
6578 "Third one.".to_string(),
6579 ]
6580 );
6581 }
6582
6583 #[test]
6584 fn test_is_unordered_list_marker() {
6585 // Valid unordered list markers
6586 assert!(is_unordered_list_marker("- item"));
6587 assert!(is_unordered_list_marker("* item"));
6588 assert!(is_unordered_list_marker("+ item"));
6589 assert!(is_unordered_list_marker("-")); // lone marker
6590 assert!(is_unordered_list_marker("*"));
6591 assert!(is_unordered_list_marker("+"));
6592
6593 // Not list markers
6594 assert!(!is_unordered_list_marker("---")); // horizontal rule
6595 assert!(!is_unordered_list_marker("***")); // horizontal rule
6596 assert!(!is_unordered_list_marker("- - -")); // horizontal rule
6597 assert!(!is_unordered_list_marker("* * *")); // horizontal rule
6598 assert!(!is_unordered_list_marker("*emphasis*")); // emphasis, not list
6599 assert!(!is_unordered_list_marker("-word")); // no space after marker
6600 assert!(!is_unordered_list_marker("")); // empty
6601 assert!(!is_unordered_list_marker("text")); // plain text
6602 assert!(!is_unordered_list_marker("# heading")); // heading
6603 }
6604
6605 #[test]
6606 fn test_is_block_boundary() {
6607 // Block boundaries
6608 assert!(is_block_boundary("")); // empty line
6609 assert!(is_block_boundary("# Heading")); // ATX heading
6610 assert!(is_block_boundary("## Level 2")); // ATX heading
6611 assert!(is_block_boundary("```rust")); // code fence
6612 assert!(is_block_boundary("~~~")); // tilde code fence
6613 assert!(is_block_boundary("> quote")); // blockquote
6614 assert!(is_block_boundary("| cell |")); // table
6615 assert!(is_block_boundary("[link]: http://example.com")); // reference def
6616 assert!(is_block_boundary("---")); // horizontal rule
6617 assert!(is_block_boundary("***")); // horizontal rule
6618 assert!(is_block_boundary("- item")); // unordered list
6619 assert!(is_block_boundary("* item")); // unordered list
6620 assert!(is_block_boundary("+ item")); // unordered list
6621 assert!(is_block_boundary("1. item")); // ordered list
6622 assert!(is_block_boundary("10. item")); // ordered list
6623 assert!(is_block_boundary(": definition")); // definition list
6624 assert!(is_block_boundary(":::")); // div marker
6625 assert!(is_block_boundary("::::: {.callout-note}")); // div marker with attrs
6626
6627 // NOT block boundaries (paragraph continuation)
6628 assert!(!is_block_boundary("regular text"));
6629 assert!(!is_block_boundary("*emphasis*")); // emphasis, not list
6630 assert!(!is_block_boundary("[link](url)")); // inline link, not reference def
6631 assert!(!is_block_boundary("some words here"));
6632 }
6633
6634 #[test]
6635 fn test_definition_list_boundary_in_single_line_paragraph() {
6636 // Verifies that a definition list item after a single-line paragraph
6637 // is treated as a block boundary, not merged into the paragraph
6638 let options = ReflowOptions {
6639 line_length: 80,
6640 ..Default::default()
6641 };
6642 let input = "Term\n: Definition of the term";
6643 let result = reflow_markdown(input, &options);
6644 // The definition list marker should remain on its own line
6645 assert!(
6646 result.contains(": Definition"),
6647 "Definition list item should not be merged into previous line. Got: {result:?}"
6648 );
6649 let lines: Vec<&str> = result.lines().collect();
6650 assert_eq!(lines.len(), 2, "Should remain two separate lines. Got: {lines:?}");
6651 assert_eq!(lines[0], "Term");
6652 assert_eq!(lines[1], ": Definition of the term");
6653 }
6654
6655 #[test]
6656 fn test_is_paragraph_boundary() {
6657 // Core block boundary checks are inherited
6658 assert!(is_paragraph_boundary("# Heading", "# Heading"));
6659 assert!(is_paragraph_boundary("- item", "- item"));
6660 assert!(is_paragraph_boundary(":::", ":::"));
6661 assert!(is_paragraph_boundary(": definition", ": definition"));
6662
6663 // Indented code blocks (≥4 spaces or tab)
6664 assert!(is_paragraph_boundary("code", " code"));
6665 assert!(is_paragraph_boundary("code", "\tcode"));
6666
6667 // Table rows via is_potential_table_row
6668 assert!(is_paragraph_boundary("| a | b |", "| a | b |"));
6669 assert!(is_paragraph_boundary("a | b", "a | b")); // pipe-delimited without leading pipe
6670
6671 // Not paragraph boundaries
6672 assert!(!is_paragraph_boundary("regular text", "regular text"));
6673 assert!(!is_paragraph_boundary("text", " text")); // 2-space indent is not code
6674 }
6675
6676 #[test]
6677 fn test_div_marker_boundary_in_reflow_paragraph_at_line() {
6678 // Verifies that div markers (:::) are treated as paragraph boundaries
6679 // in reflow_paragraph_at_line, preventing reflow across div boundaries
6680 let content = "Some paragraph text here.\n\n::: {.callout-note}\nThis is a callout.\n:::\n";
6681 // Line 3 is the div marker — should not be reflowed
6682 let result = reflow_paragraph_at_line(content, 3, 80);
6683 assert!(result.is_none(), "Div marker line should not be reflowed");
6684 }
6685
6686 #[test]
6687 fn starts_block_construct_detects_block_openers() {
6688 // Bullet list markers: marker char followed by space or end
6689 for case in ["- item", "-", "* item", "*", "+ item", "+", "-\titem"] {
6690 assert!(starts_block_construct(case), "bullet: {case:?}");
6691 }
6692 // Ordered list markers: only a list numbered 1 with a non-empty first
6693 // item interrupts a paragraph. Leading zeros keep the number 1.
6694 for case in ["1. item", "1) item", "01. x", "000000001. x", "1.\titem"] {
6695 assert!(starts_block_construct(case), "ordered: {case:?}");
6696 }
6697 // Blockquote: `>` needs no following space
6698 for case in ["> quote", ">quote", ">"] {
6699 assert!(starts_block_construct(case), "blockquote: {case:?}");
6700 }
6701 // ATX headings: 1-6 hashes then space or end
6702 for case in ["# heading", "###### h6", "#", "##"] {
6703 assert!(starts_block_construct(case), "heading: {case:?}");
6704 }
6705 // Code fences: 3+ backticks or tildes
6706 for case in ["```", "```rust", "````", "~~~", "~~~text"] {
6707 assert!(starts_block_construct(case), "fence: {case:?}");
6708 }
6709 // Setext underlines and thematic breaks
6710 for case in ["---", "--", "===", "=", "***", "___", "_ _ _", "- - -"] {
6711 assert!(starts_block_construct(case), "setext/thematic: {case:?}");
6712 }
6713 // Definition-list markers, a colon alone included, and the fenced-div
6714 // marker that shares the character
6715 for case in [": definition", ":\tdefinition", ":", ": ", "::: note"] {
6716 assert!(starts_block_construct(case), "definition list: {case:?}");
6717 }
6718 // Footnote and link-reference definitions: hoisting one to line start
6719 // reclassifies it and can resolve dangling references elsewhere
6720 for case in [
6721 "[^1]: text",
6722 "[^note]:",
6723 "[ref]: http://example.com",
6724 "[wat]: url follows",
6725 ] {
6726 assert!(starts_block_construct(case), "definition: {case:?}");
6727 }
6728 // Block-level HTML tags (rumdl parser's HTML block classification)
6729 for case in ["<div>content", "</div>", "<p>text", "<table>", "<pre>code", "<h1>x"] {
6730 assert!(starts_block_construct(case), "html block: {case:?}");
6731 }
6732 }
6733
6734 #[test]
6735 fn starts_block_construct_allows_ordinary_prose() {
6736 for case in [
6737 "",
6738 "word",
6739 "-5 degrees",
6740 "--flag",
6741 "-item",
6742 "#hashtag",
6743 "####### seven hashes is not a heading",
6744 "1.5 million",
6745 "1234567890. ten digits is not a list marker",
6746 "0000000001. ten digits is not a list marker either",
6747 // A number other than 1 cannot interrupt a paragraph, nor can an
6748 // empty first item, so neither changes the parse at line start.
6749 "2. item",
6750 "7. item",
6751 "0. item",
6752 "42) x",
6753 "123456. item",
6754 "1.",
6755 "1)",
6756 "123456.",
6757 "123456)",
6758 "1.item",
6759 "1:30 pm",
6760 "*emphasis*",
6761 "**bold** text",
6762 "__bold__ text",
6763 "_emphasis_ text",
6764 "`code` span",
6765 "`` double backtick span ``",
6766 "~~strikethrough~~",
6767 "=x",
6768 "== ==",
6769 "(parenthetical)",
6770 "[link](url)",
6771 "[text][ref] more",
6772 "[bracketed] aside",
6773 "[a](b) [ref]: first bracket is a link, not a label",
6774 "[esc\\]: not a close] text",
6775 "<span>inline</span>",
6776 "<b>bold</b>",
6777 "<https://example.com> autolink",
6778 "<mailto:a@b.com>",
6779 "<notarealtag>",
6780 ] {
6781 assert!(!starts_block_construct(case), "prose: {case:?}");
6782 }
6783 }
6784
6785 #[test]
6786 fn merge_block_construct_continuations_merges_marker_led_lines() {
6787 let lines = vec![
6788 "First sentence?".to_string(),
6789 "- looks like a list item".to_string(),
6790 "Second sentence.".to_string(),
6791 ];
6792 assert_eq!(
6793 merge_block_construct_continuations(lines),
6794 vec![
6795 "First sentence? - looks like a list item".to_string(),
6796 "Second sentence.".to_string(),
6797 ]
6798 );
6799
6800 // The first line keeps its position: it replaces the paragraph's
6801 // original start, where the source already established the context.
6802 let lines = vec!["- real list content".to_string(), "continuation".to_string()];
6803 assert_eq!(
6804 merge_block_construct_continuations(lines.clone()),
6805 lines,
6806 "first line must never be merged"
6807 );
6808
6809 // Folding cascades: `1.` alone is inert, but absorbing `[ref]:` makes
6810 // it a list item, so the grown line has to fold back in turn.
6811 let lines = vec!["prose".to_string(), "1.".to_string(), "[ref]:".to_string()];
6812 assert_eq!(
6813 merge_block_construct_continuations(lines),
6814 vec!["prose 1. [ref]:".to_string()],
6815 "a merge that creates an opener must fold again"
6816 );
6817 }
6818
6819 #[test]
6820 fn wrap_never_starts_a_line_with_a_block_marker() {
6821 let options = ReflowOptions {
6822 line_length: 25,
6823 ..Default::default()
6824 };
6825 // The dash lands exactly at the wrap point; the wrapper must break one
6826 // word earlier so the dash stays mid-line.
6827 let lines = reflow_line(
6828 "Some words here and then - a dash clause that wraps around the limit.",
6829 &options,
6830 );
6831 assert_eq!(
6832 lines,
6833 vec![
6834 "Some words here and",
6835 "then - a dash clause that",
6836 "wraps around the limit."
6837 ]
6838 );
6839
6840 // Every marker category must stay mid-line in wrap mode, whatever the width.
6841 for input in [
6842 "Alpha beta gamma delta epsilon - dash clause here to wrap",
6843 "Alpha beta gamma delta epsilon > quote lookalike here to wrap",
6844 "Alpha beta gamma delta epsilon # heading lookalike here to wrap",
6845 "Alpha beta gamma delta epsilon 1. ordered lookalike here to wrap",
6846 "Alpha beta gamma delta epsilon * star clause here to wrap",
6847 "Alpha beta gamma delta epsilon + plus clause here to wrap",
6848 ] {
6849 for width in 10..40 {
6850 let options = ReflowOptions {
6851 line_length: width,
6852 ..Default::default()
6853 };
6854 for line in reflow_line(input, &options) {
6855 assert!(
6856 !starts_block_construct(&line),
6857 "width {width}: wrapped line opens a block construct: {line:?} (input {input:?})"
6858 );
6859 }
6860 }
6861 }
6862 }
6863
6864 #[test]
6865 fn sentence_per_line_keeps_block_markers_mid_line() {
6866 let options = ReflowOptions {
6867 line_length: 80,
6868 sentence_per_line: true,
6869 ..Default::default()
6870 };
6871 // A sentence "starting" with a dash must stay attached to the previous
6872 // sentence instead of becoming a list item (issue #728).
6873 let lines = reflow_line(
6874 "Google Calendar (Can't we get rid of this dependency? - I don't really see the need)",
6875 &options,
6876 );
6877 assert_eq!(
6878 lines,
6879 vec!["Google Calendar (Can't we get rid of this dependency? - I don't really see the need)".to_string()]
6880 );
6881
6882 // Same for heading, blockquote, and ordered-list lookalikes.
6883 let lines = reflow_line("See section 4? # is the marker we use. Fine.", &options);
6884 assert_eq!(lines, vec!["See section 4? # is the marker we use.", "Fine."]);
6885
6886 let lines = reflow_line("Is this a problem? > I quote someone here.", &options);
6887 assert_eq!(lines, vec!["Is this a problem? > I quote someone here."]);
6888
6889 let lines = reflow_line("Another case! 1. Not a list. More text follows here.", &options);
6890 for line in &lines {
6891 assert!(
6892 !starts_block_construct(line),
6893 "sentence-per-line output opens a block construct: {line:?}"
6894 );
6895 }
6896 }
6897
6898 /// Sentence-per-line reflow of `input` under `require-sentence-capital`.
6899 fn strict_sentence_lines(input: &str, require_sentence_capital: bool) -> Vec<String> {
6900 let options = ReflowOptions {
6901 line_length: 80,
6902 sentence_per_line: true,
6903 require_sentence_capital,
6904 ..Default::default()
6905 };
6906 reflow_line(input, &options)
6907 }
6908
6909 #[test]
6910 fn strict_mode_lets_a_sentence_open_with_a_number() {
6911 // `require-sentence-capital` exists to keep `word. lowercase`
6912 // continuations together; a digit is not a lowercase letter, so a
6913 // sentence may open with a count, a year, an ordinal or a time.
6914 for (input, expected) in [
6915 (
6916 "The number of items was 5. 2 of them failed.",
6917 vec!["The number of items was 5.", "2 of them failed."],
6918 ),
6919 (
6920 "Sometimes we have 2. 3 might be here.",
6921 vec!["Sometimes we have 2.", "3 might be here."],
6922 ),
6923 (
6924 "The number of items was 5. 2nd sentence.",
6925 vec!["The number of items was 5.", "2nd sentence."],
6926 ),
6927 (
6928 "Released in 2020. 3 of them failed.",
6929 vec!["Released in 2020.", "3 of them failed."],
6930 ),
6931 (
6932 "First sentence. 2nd sentence.",
6933 vec!["First sentence.", "2nd sentence."],
6934 ),
6935 (
6936 "We met at 6:00 sharp. 6:00 is early.",
6937 vec!["We met at 6:00 sharp.", "6:00 is early."],
6938 ),
6939 ("Pi is 3.14 roughly. Next.", vec!["Pi is 3.14 roughly.", "Next."]),
6940 // A `?` inside a quotation follows the same rule as a period, so a
6941 // digit after the closing quote opens a sentence there too.
6942 (
6943 "A \"Is this a test?\" 2020 was memorable.",
6944 vec!["A \"Is this a test?\"", "2020 was memorable."],
6945 ),
6946 ] {
6947 assert_eq!(strict_sentence_lines(input, true), expected, "input {input:?}");
6948 }
6949
6950 // A lowercase continuation still holds the sentence open, and an
6951 // abbreviation before a number is still an abbreviation.
6952 for input in [
6953 "The count was 5. and that was all.",
6954 "See fig. 3 for details.",
6955 "See no. 5 in the list.",
6956 "See ch. 12 and vol. 3 for more.",
6957 "A \"Is this a test?\" guide to it.",
6958 ] {
6959 assert_eq!(
6960 strict_sentence_lines(input, true),
6961 vec![input.to_string()],
6962 "input {input:?}"
6963 );
6964 }
6965 }
6966
6967 #[test]
6968 fn sentence_never_opens_with_an_ordered_list_marker() {
6969 // An inline enumerator keeps its place after the sentence before it,
6970 // in either mode. Every line the splitter produces ends a sentence,
6971 // and `2. Do that.` under such a line is a list item to MD032 (in any
6972 // document that has a list) and to CommonMark for `1.`, so the
6973 // enumerator is never hoisted to line start; the enumerated text opens
6974 // the next line instead.
6975 for (input, require_capital, expected) in [
6976 (
6977 "Steps: 1. Do this. 2. Do that.",
6978 true,
6979 vec!["Steps: 1.", "Do this. 2.", "Do that."],
6980 ),
6981 (
6982 "First sentence. 1. Do that.",
6983 true,
6984 vec!["First sentence. 1.", "Do that."],
6985 ),
6986 ("Do this! 2. Do that.", true, vec!["Do this! 2.", "Do that."]),
6987 ("Do this. 12) Do that.", true, vec!["Do this. 12) Do that."]),
6988 ("Do this. 2. do that.", true, vec!["Do this. 2. do that."]),
6989 ("Do this. 2. do that.", false, vec!["Do this. 2.", "do that."]),
6990 (
6991 "Twelve. 1234567890. next one here.",
6992 true,
6993 vec!["Twelve. 1234567890. next one here."],
6994 ),
6995 // A number that is not followed by a marker's `.`/`)` and space
6996 // opens a sentence as usual.
6997 ("Do this. 2 more times.", true, vec!["Do this.", "2 more times."]),
6998 ("How many? 2.", true, vec!["How many?", "2."]),
6999 // CJK punctuation needs no space before the next sentence, and the
7000 // marker rule holds after it as well.
7001 ("第一句。2. Do that.", true, vec!["第一句。2.", "Do that."]),
7002 ("第一句。 2) 第二句。", true, vec!["第一句。 2) 第二句。"]),
7003 ("第一句。2 more.", true, vec!["第一句。", "2 more."]),
7004 ("第一句。第二句。", true, vec!["第一句。", "第二句。"]),
7005 ] {
7006 let lines = strict_sentence_lines(input, require_capital);
7007 assert_eq!(lines, expected, "input {input:?}, require capital {require_capital}");
7008 for line in &lines {
7009 let chars: Vec<char> = line.chars().collect();
7010 assert!(
7011 !opens_ordered_list_marker(&chars),
7012 "line opens with an ordered-list marker: {line:?} (input {input:?})"
7013 );
7014 }
7015 }
7016 }
7017
7018 #[test]
7019 fn opens_ordered_list_marker_matches_the_marker_shape() {
7020 let chars = |s: &str| s.chars().collect::<Vec<char>>();
7021 for text in ["2. x", "1) x", "12. x", "1.\tx", "1234567890. x", "0. x"] {
7022 assert!(opens_ordered_list_marker(&chars(text)), "{text:?} is a marker");
7023 }
7024 for text in ["2.x", "2.", "2)", "2 x", "x. y", "", " 2. x", "2.5 x", "-2. x"] {
7025 assert!(!opens_ordered_list_marker(&chars(text)), "{text:?} is not a marker");
7026 }
7027 }
7028
7029 #[test]
7030 fn inline_math_directly_after_display_math_stays_atomic() {
7031 // The inline-math regex's lookbehind `(?<!\$)` is slice-start-sensitive:
7032 // a search anchored at the cursor accepts a `$` whose real predecessor
7033 // is a `$` (the lookbehind sees nothing before the slice), while a
7034 // cached search anchored earlier sees the `$` and rejects it. After
7035 // display math consumes `$$a$$`, the cursor sits directly after a `$`;
7036 // the match cache must re-search there or `$bb cc dd$` degrades to
7037 // plain text and gets wrapped apart, breaking math rendering.
7038 let options = ReflowOptions {
7039 line_length: 8,
7040 ..Default::default()
7041 };
7042 let lines = reflow_line("$$a$$$bb cc dd$ x", &options);
7043 assert_eq!(lines, vec!["$$a$$$bb cc dd$".to_string(), "x".to_string()]);
7044 }
7045
7046 #[test]
7047 fn test_code_span_parsing() {
7048 // 1. Single backtick
7049 let elements = parse_markdown_elements_inner("`code`", false, false, None);
7050 assert_eq!(elements.len(), 1);
7051 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "`"));
7052
7053 // 2. Double backtick
7054 let elements = parse_markdown_elements_inner("``code``", false, false, None);
7055 assert_eq!(elements.len(), 1);
7056 assert!(matches!(&elements[0], Element::Code { content, marker } if content == "code" && marker == "``"));
7057
7058 // 3. Double backtick with single backtick inside
7059 let elements = parse_markdown_elements_inner("``code`inside``", false, false, None);
7060 assert_eq!(elements.len(), 1);
7061 assert!(
7062 matches!(&elements[0], Element::Code { content, marker } if content == "code`inside" && marker == "``")
7063 );
7064
7065 // 4. Spaces inside
7066 let elements = parse_markdown_elements_inner("`` code ``", false, false, None);
7067 assert_eq!(elements.len(), 1);
7068 assert!(matches!(&elements[0], Element::Code { content, marker } if content == " code " && marker == "``"));
7069
7070 // 5. Unclosed backtick (should be parsed as Text)
7071 let elements = parse_markdown_elements_inner("`unclosed", false, false, None);
7072 assert_eq!(elements.len(), 1);
7073 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed"));
7074
7075 // 6. Unclosed backtick followed by a link (the link should be parsed as Link, not Text)
7076 let elements = parse_markdown_elements_inner("`unclosed [link](url)", false, false, None);
7077 // We expect: Text("`unclosed "), Link("[link](url)")
7078 assert_eq!(elements.len(), 2);
7079 assert!(matches!(&elements[0], Element::Text(s) if s == "`unclosed "));
7080 assert!(matches!(&elements[1], Element::Link(s) if s == "[link](url)"));
7081 }
7082
7083 #[test]
7084 fn test_reflow_performance_long_input() {
7085 // Generate a string with many distinct unclosed backtick runs to test worst-case performance.
7086 // E.g., "` `` ` `` ` ...`"
7087 let mut text = String::new();
7088 for i in 1..400 {
7089 let backticks = "`".repeat(i);
7090 text.push_str(&backticks);
7091 text.push(' ');
7092 }
7093
7094 let start = std::time::Instant::now();
7095 let elements = parse_markdown_elements_inner(&text, false, false, None);
7096 let duration = start.elapsed();
7097
7098 // Ensure it completes in under 100ms.
7099 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
7100 assert!(!elements.is_empty());
7101 }
7102
7103 #[test]
7104 fn test_reflow_performance_display_math_heavy() {
7105 // Every consumed `$$a$$` leaves the cursor directly after a `$`. The
7106 // inline-math slice-start probe must run in place at the cursor; a
7107 // suffix rescan there makes this input quadratic (~9s in a debug
7108 // build for these 4000 spans).
7109 let text = "$$a$$".repeat(4000);
7110
7111 let start = std::time::Instant::now();
7112 let elements = parse_markdown_elements_inner(&text, false, false, None);
7113 let duration = start.elapsed();
7114
7115 assert!(duration.as_millis() < 100, "Parsing took too long: {duration:?}");
7116 assert_eq!(elements.len(), 4000);
7117 }
7118
7119 #[test]
7120 fn inline_math_len_at_start_matches_regex_at_slice_start() {
7121 // Exhaustive parity with INLINE_MATH_REGEX over short `$`-soup
7122 // strings: the helper must equal "regex match starting at position 0"
7123 // exactly, since the regex's leading lookbehind is vacuous at a slice
7124 // start. Any drift silently changes which math spans stay atomic.
7125 let alphabet = ['$', 'a', ' '];
7126 let mut inputs: Vec<String> = vec![String::new()];
7127 let mut frontier: Vec<String> = vec![String::new()];
7128 for _ in 0..6 {
7129 let mut longer = Vec::new();
7130 for prefix in &frontier {
7131 for ch in alphabet {
7132 let mut s = prefix.clone();
7133 s.push(ch);
7134 longer.push(s);
7135 }
7136 }
7137 inputs.extend(longer.iter().cloned());
7138 frontier = longer;
7139 }
7140 // Multi-byte content must count bytes, not characters.
7141 inputs.push("$αβ$x".to_string());
7142 inputs.push("$α$$".to_string());
7143
7144 for s in &inputs {
7145 let expected = INLINE_MATH_REGEX
7146 .find(s)
7147 .ok()
7148 .flatten()
7149 .filter(|m| m.start() == 0)
7150 .map(|m| m.end());
7151 assert_eq!(inline_math_len_at_start(s), expected, "input: {s:?}");
7152 }
7153 }
7154
7155 #[test]
7156 fn inline_math_probe_after_dollar_matches_uncached_parse() {
7157 // Expected element lists verified against the uncached parser (the
7158 // parent of the match-cache commit): when a consumed span leaves the
7159 // cursor directly after a `$`, the at-cursor probe must reproduce
7160 // exactly what rescanning the suffix used to find - both the hits
7161 // (the lookbehind is vacuous at the cursor) and the misses.
7162 let cases = [
7163 ("$$a$$$b c$ x", r#"[DisplayMath("a"), InlineMath("b c"), Text(" x")]"#),
7164 (
7165 "$$a$$$b$ $$a$$$b$",
7166 r#"[DisplayMath("a"), InlineMath("b"), Text(" "), DisplayMath("a"), InlineMath("b")]"#,
7167 ),
7168 // Probe hit whose content is only whitespace.
7169 (
7170 "$$a$$$ x $y z$",
7171 r#"[DisplayMath("a"), InlineMath(" x "), Text("y z$")]"#,
7172 ),
7173 // Probe miss: `$$` after the cursor is not inline math.
7174 ("$$a$$$$ x", r#"[DisplayMath("a"), Text("$$ x")]"#),
7175 ("$$a$$$$b$$ x", r#"[DisplayMath("a"), DisplayMath("b"), Text(" x")]"#),
7176 // Probe miss: the trailing lookahead rejects `$c$$`.
7177 (
7178 "$a$$b$$c$$d$ tail",
7179 r#"[Text("$a"), DisplayMath("b"), Text("c$$d$ tail")]"#,
7180 ),
7181 ];
7182 for (input, expected) in cases {
7183 let elements = parse_markdown_elements_inner(input, false, false, None);
7184 assert_eq!(format!("{elements:?}"), expected, "input: {input:?}");
7185 }
7186 }
7187
7188 #[test]
7189 fn test_atomic_spans() {
7190 // --- Emphasis Spans ---
7191 let text_emphasis = "hello **word1 word2**";
7192
7193 let options_disabled = ReflowOptions {
7194 line_length: 18,
7195 atomic_spans: true,
7196 ..Default::default()
7197 };
7198 let lines_disabled = reflow_line(text_emphasis, &options_disabled);
7199 assert_eq!(lines_disabled, vec!["hello", "**word1 word2**"]);
7200
7201 let options_enabled = ReflowOptions {
7202 line_length: 18,
7203 atomic_spans: false,
7204 ..Default::default()
7205 };
7206 let lines_enabled = reflow_line(text_emphasis, &options_enabled);
7207 assert_eq!(lines_enabled, vec!["hello **word1", "word2**"]);
7208
7209 // --- Code Spans ---
7210 let text_code = "hello `word1 word2`";
7211
7212 let lines_code_disabled = reflow_line(text_code, &options_disabled);
7213 assert_eq!(lines_code_disabled, vec!["hello", "`word1 word2`"]);
7214
7215 let lines_code_enabled = reflow_line(text_code, &options_enabled);
7216 assert_eq!(lines_code_enabled, vec!["hello `word1", "word2`"]);
7217
7218 // Test multiple backticks with space padding
7219 let text_code_padding = "hello `` `word1` `word2` ``";
7220 let lines_padding_enabled = reflow_line(text_code_padding, &options_enabled);
7221 assert_eq!(lines_padding_enabled, vec![r#"hello `` `word1`"#, r#"`word2` ``"#]);
7222
7223 // Test atomic span wrapping with attached punctuation (maintainer feedback)
7224 let text_attached = "**one two**,"; // length 12, bold span is 11
7225
7226 // With limit 11, the bold span (11) fits, so it should NOT be split even though the total (12) exceeds 11.
7227 let options_11 = ReflowOptions {
7228 line_length: 11,
7229 atomic_spans: true,
7230 ..Default::default()
7231 };
7232 assert_eq!(reflow_line(text_attached, &options_11), vec!["**one two**,"]);
7233
7234 // With limit 10, the bold span (11) exceeds 10, so it is allowed to be split.
7235 let options_10 = ReflowOptions {
7236 line_length: 10,
7237 atomic_spans: true,
7238 ..Default::default()
7239 };
7240 assert_eq!(reflow_line(text_attached, &options_10), vec!["**one", "two**,"]);
7241 }
7242
7243 #[test]
7244 fn test_emphasis_containing_markers_is_not_split() {
7245 let options = ReflowOptions {
7246 line_length: 5,
7247 atomic_spans: false,
7248 ..Default::default()
7249 };
7250 // Emphasis containing internal markers (e.g. escaped asterisks) should not be split to avoid formatting corruption
7251 let lines = reflow_line(r#"*foo \*bar*"#, &options);
7252 assert_eq!(lines, vec![r#"*foo \*bar*"#.to_string()]);
7253 }
7254
7255 /// The parsed shape of a markdown fragment, normalized the way wrapping is
7256 /// allowed to change it and no further: block/inline structure and
7257 /// code-span contents are compared exactly, while prose whitespace is
7258 /// collapsed, because a wrap only ever swaps a space for a newline.
7259 fn semantic_shape(markdown: &str) -> String {
7260 let mut options = Options::empty();
7261 options.insert(Options::ENABLE_STRIKETHROUGH);
7262 let mut out = String::new();
7263 let push_prose = |out: &mut String, text: &str| {
7264 for c in text.chars() {
7265 if c.is_whitespace() {
7266 if !out.ends_with(char::is_whitespace) {
7267 out.push(' ');
7268 }
7269 } else {
7270 out.push(c);
7271 }
7272 }
7273 };
7274 for event in Parser::new_ext(markdown, options) {
7275 match event {
7276 Event::Text(text) => push_prose(&mut out, &text),
7277 Event::SoftBreak | Event::HardBreak => push_prose(&mut out, " "),
7278 // Interior whitespace in a code span is literal: compare verbatim.
7279 Event::Code(code) => out.push_str(&format!("<code>{code}</code>")),
7280 Event::Start(tag) => out.push_str(&format!("<{tag:?}>")),
7281 Event::End(tag) => out.push_str(&format!("</{tag:?}>")),
7282 other => out.push_str(&format!("{other:?}")),
7283 }
7284 }
7285 out.trim().to_string()
7286 }
7287
7288 #[test]
7289 fn test_wrapping_a_span_never_changes_what_it_parses_to() {
7290 // Breaking a span is only safe if the document still parses the same.
7291 // Cover both settings and several budgets so the break lands in a
7292 // different place in each run.
7293 let corpus = [
7294 "_This is a very, very, very, very, very long line with some `code` inside._",
7295 "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa lambda_",
7296 "**strong text with `code` and more words than fit on one single line**",
7297 "~~struck text with `code` and more words than fit on one single line~~",
7298 "_emphasis with **nested strong that is quite long** and trailing words_",
7299 // Doubly nested spans: the whole content of the outer span is one
7300 // nested span, so there is no prose outside it to break at.
7301 "***A doubly nested bold italic span with more words than fit on a line***",
7302 "___Another doubly nested span with more words than fit on a single line___",
7303 "**_mixed strong then emphasis with more words than fit on a single line_**",
7304 "*__mixed emphasis then strong with more words than fit on a single line__*",
7305 "**~~strong strikethrough with more words than fit on a single line here~~**",
7306 // A marker that belongs to no well-formed span. Breaking at these
7307 // spaces would start a line with `* `, making it a list item.
7308 "**a * b with a stray marker and plenty more words to pass the budget**",
7309 "_foo `a` bar `b` baz qux quux corge grault garply waldo fred plugh xyzzy_",
7310 "text before _a long emphasis with `code` inside of it here_ and after",
7311 "(_a parenthesized long emphasis with `code` inside of it right here_)",
7312 r#"*foo \*bar baz qux quux corge grault garply waldo fred plugh xyzzy*"#,
7313 "_tab\tseparated `a\tb` words spread out over quite a long emphasis span_",
7314 // A link nested in the span: its destination and title are not prose
7315 // and cannot absorb a line break.
7316 "_This has [text](<a b c d e f g h i j k>) and trailing words to wrap._",
7317 "_This has [`code`](<a b c d e f g h i j k>) and trailing words to wrap._",
7318 r#"_See [x](https://example.com "a long link title here") and `code` too._"#,
7319 "_A [link with a long label](https://example.com/path) and `code` here._",
7320 "_An image  plus `code` and more text_",
7321 ];
7322 for text in corpus {
7323 let expected = semantic_shape(text);
7324 for line_length in [20, 30, 40, 80] {
7325 for atomic_spans in [true, false] {
7326 let options = ReflowOptions {
7327 line_length,
7328 atomic_spans,
7329 ..Default::default()
7330 };
7331 let wrapped = reflow_line(text, &options).join("\n");
7332 assert_eq!(
7333 semantic_shape(&wrapped),
7334 expected,
7335 "reflow changed the parse of {text:?} at line_length={line_length} \
7336 atomic_spans={atomic_spans}\n wrapped: {wrapped:?}"
7337 );
7338 }
7339 }
7340 }
7341 }
7342
7343 #[test]
7344 fn test_wrapping_a_span_keeps_whole_the_constructs_pulldown_cannot_see() {
7345 // Wiki links, Hugo shortcodes and math are atomic elements at the top
7346 // level but are invisible to the CommonMark parser, so `semantic_shape`
7347 // cannot catch a break inside one. Assert directly that they survive.
7348 let cases = [
7349 (
7350 "_alpha beta gamma [[a wiki link]] delta epsilon zeta eta_",
7351 "[[a wiki link]]",
7352 ),
7353 (
7354 "_alpha beta gamma {{< foo bar >}} delta epsilon zeta eta_",
7355 "{{< foo bar >}}",
7356 ),
7357 ("_alpha beta gamma $a + b$ delta epsilon zeta eta theta_", "$a + b$"),
7358 ("_alpha beta gamma $$a + b$$ delta epsilon zeta eta theta_", "$$a + b$$"),
7359 ];
7360 for (text, construct) in cases {
7361 for line_length in [12, 20, 30] {
7362 for atomic_spans in [true, false] {
7363 let options = ReflowOptions {
7364 line_length,
7365 atomic_spans,
7366 ..Default::default()
7367 };
7368 let wrapped = reflow_line(text, &options).join("\n");
7369 assert!(
7370 wrapped.contains(construct),
7371 "{construct} was broken at line_length={line_length} \
7372 atomic_spans={atomic_spans}: {wrapped:?}"
7373 );
7374 }
7375 }
7376 }
7377 }
7378
7379 #[test]
7380 fn test_overlong_emphasis_with_nested_code_span_wraps() {
7381 // An emphasis span longer than the whole line budget must still wrap,
7382 // even when it contains a nested code span: keeping it atomic would
7383 // leave a line that can never fit.
7384 let options = ReflowOptions {
7385 line_length: 80,
7386 atomic_spans: true,
7387 ..Default::default()
7388 };
7389 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some `code` inside._";
7390 let lines = reflow_line(text, &options);
7391 assert_eq!(
7392 lines,
7393 vec![
7394 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
7395 "characters with some `code` inside._",
7396 ]
7397 );
7398 }
7399
7400 #[test]
7401 fn test_overlong_emphasis_with_nested_strong_wraps() {
7402 // Same for a nested strong span. The nested span itself stays whole.
7403 let options = ReflowOptions {
7404 line_length: 80,
7405 atomic_spans: true,
7406 ..Default::default()
7407 };
7408 let text = "_This is a very, very, very, very, very, very, very long line that exceeds 80 characters with some **bold** inside._";
7409 let lines = reflow_line(text, &options);
7410 assert_eq!(
7411 lines,
7412 vec![
7413 "_This is a very, very, very, very, very, very, very long line that exceeds 80",
7414 "characters with some **bold** inside._",
7415 ]
7416 );
7417 }
7418
7419 #[test]
7420 fn test_overlong_doubly_nested_span_wraps() {
7421 // The whole content of the outer span is a single nested emphasis span.
7422 // Holding a nested span whole regardless of length left no break point
7423 // anywhere inside, so the line could never be wrapped and MD013 reported
7424 // a violation its own fixer refused to touch.
7425 let options = ReflowOptions {
7426 line_length: 80,
7427 atomic_spans: true,
7428 ..Default::default()
7429 };
7430 let body = "This is a very, very, very, very, very, very, very, very, very, very long line that is emphasised.";
7431 for (open, close) in [
7432 ("***", "***"),
7433 ("___", "___"),
7434 ("**_", "_**"),
7435 ("*__", "__*"),
7436 ("**~~", "~~**"),
7437 ] {
7438 let text = format!("{open}{body}{close}");
7439 assert!(text.len() > options.line_length, "case must start over budget");
7440 let lines = reflow_line(&text, &options);
7441 assert!(
7442 lines.len() > 1,
7443 "{open}...{close} should wrap but stayed on one line: {lines:?}"
7444 );
7445 assert!(
7446 lines.iter().all(|line| line.len() <= options.line_length),
7447 "{open}...{close} left a line over the budget: {lines:?}"
7448 );
7449 assert_eq!(
7450 lines.join(" "),
7451 text,
7452 "{open}...{close} wrapping must only replace a space with a newline"
7453 );
7454 }
7455 }
7456
7457 #[test]
7458 fn test_overlong_span_with_stray_marker_stays_whole() {
7459 // A `*` that belongs to no well-formed span means the content is not
7460 // fully modelled. Breaking at these spaces would put `* ` at the start
7461 // of a line, turning literal text into a list item.
7462 let options = ReflowOptions {
7463 line_length: 40,
7464 atomic_spans: true,
7465 ..Default::default()
7466 };
7467 let text = "**alpha * beta gamma delta epsilon zeta eta theta iota kappa**";
7468 let lines = reflow_line(text, &options);
7469 assert_eq!(lines, vec![text], "stray marker must keep the span whole");
7470 }
7471
7472 #[test]
7473 fn test_overlong_span_never_breaks_inside_a_nested_reference_link() {
7474 // A reference-style link only looks like a link once the document's
7475 // definitions are in scope, so the span's own parse sees plain text and
7476 // used to break inside the label. The top level holds these atomic, and
7477 // an inner span has to agree or `fmt` splits a link in one context and
7478 // not the other.
7479 let options = ReflowOptions {
7480 line_length: 30,
7481 atomic_spans: true,
7482 defined_references: Some(HashSet::from([
7483 "ref".to_string(),
7484 // A bare `[text]` is a link only when its own label is defined.
7485 "one two three four five six seven".to_string(),
7486 ])),
7487 ..Default::default()
7488 };
7489 for (text, link) in [
7490 (
7491 "_**alpha [one two three four five six seven][ref] beta gamma delta**_",
7492 "[one two three four five six seven][ref]",
7493 ),
7494 (
7495 "**alpha [one two three four five six seven][ref] beta gamma delta**",
7496 "[one two three four five six seven][ref]",
7497 ),
7498 (
7499 "_**alpha ![one two three four five six seven][ref] beta gamma delta**_",
7500 "![one two three four five six seven][ref]",
7501 ),
7502 (
7503 "_**alpha [one two three four five six seven][] beta gamma delta**_",
7504 "[one two three four five six seven][]",
7505 ),
7506 (
7507 "_**alpha [one two three four five six seven] beta gamma delta**_",
7508 "[one two three four five six seven]",
7509 ),
7510 ] {
7511 let lines = reflow_line(text, &options);
7512 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
7513 assert!(
7514 lines.iter().any(|line| line.contains(link)),
7515 "{link} must stay on one line: {lines:?}"
7516 );
7517 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
7518 }
7519 }
7520
7521 #[test]
7522 fn test_overlong_span_breaks_inside_an_undefined_shortcut_reference() {
7523 // A bare `[text]` is only a link when its label is defined. With the
7524 // definitions in scope and no match, it is literal prose and breaks like
7525 // any other words, exactly as the top level treats it.
7526 let options = ReflowOptions {
7527 line_length: 30,
7528 atomic_spans: true,
7529 defined_references: Some(HashSet::new()),
7530 ..Default::default()
7531 };
7532 let text = "_**alpha [one two three four five six seven] beta gamma delta**_";
7533 let lines = reflow_line(text, &options);
7534 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
7535 assert!(
7536 !lines
7537 .iter()
7538 .any(|line| line.contains("[one two three four five six seven]")),
7539 "an undefined shortcut is prose and should break: {lines:?}"
7540 );
7541 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
7542 }
7543
7544 #[test]
7545 fn test_overlong_span_never_breaks_inside_a_nested_attr_list() {
7546 // A MkDocs/kramdown attr list carries structural interior whitespace, so
7547 // splitting it rewrites the attributes. The top level holds it whole; an
7548 // inner span has to agree. Only when the flavor is enabled.
7549 let attr = "{.highlight key=\"a b c\"}";
7550 let text = format!("_**alpha beta gamma delta epsilon zeta{attr} eta theta iota kappa**_");
7551 let options = ReflowOptions {
7552 line_length: 20,
7553 atomic_spans: true,
7554 attr_lists: true,
7555 ..Default::default()
7556 };
7557 let lines = reflow_line(&text, &options);
7558 assert!(lines.len() > 1, "over-long span should wrap: {lines:?}");
7559 assert!(
7560 lines.iter().any(|line| line.contains(attr)),
7561 "attr list must stay on one line: {lines:?}"
7562 );
7563 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
7564
7565 // With the flavor off, the same braces are literal prose and break like
7566 // any other words, exactly as the top level treats them.
7567 let plain = ReflowOptions {
7568 attr_lists: false,
7569 ..options
7570 };
7571 let lines = reflow_line(&text, &plain);
7572 assert!(
7573 !lines.iter().any(|line| line.contains(attr)),
7574 "without the flavor the braces are prose and should break: {lines:?}"
7575 );
7576 assert_eq!(lines.join(" "), text, "wrapping must only move line breaks");
7577 }
7578
7579 #[test]
7580 fn test_overlong_emphasis_never_breaks_inside_nested_code_span() {
7581 // Interior whitespace in a code span is literal, so a break inside one
7582 // would rewrite the code. The nested span is a single unbreakable unit
7583 // and its interior survives byte-for-byte.
7584 let options = ReflowOptions {
7585 line_length: 30,
7586 atomic_spans: true,
7587 ..Default::default()
7588 };
7589 let text = "_alpha beta gamma delta epsilon `a b` zeta eta theta iota kappa_";
7590 let lines = reflow_line(text, &options);
7591 assert!(lines.len() > 1, "over-long emphasis should wrap: {lines:?}");
7592 assert!(
7593 lines.iter().any(|line| line.contains("`a b`")),
7594 "nested code span must stay whole with its interior spaces: {lines:?}"
7595 );
7596 for line in &lines {
7597 assert_eq!(
7598 line.matches('`').count() % 2,
7599 0,
7600 "no line may contain half a code span: {line:?}"
7601 );
7602 }
7603 }
7604
7605 #[test]
7606 fn test_definition_list_marker_does_not_start_line() {
7607 let options = ReflowOptions {
7608 line_length: 20,
7609 ..Default::default()
7610 };
7611 // Wrap should not start a line with ": "
7612 let lines = reflow_line("This is a term and : definition here.", &options);
7613 for line in &lines {
7614 assert!(
7615 !line.trim_start().starts_with(": "),
7616 "Wrapped line should not start with definition marker: {line}"
7617 );
7618 }
7619 }
7620
7621 #[test]
7622 fn test_div_marker_does_not_start_line() {
7623 let options = ReflowOptions {
7624 line_length: 20,
7625 ..Default::default()
7626 };
7627 // Wrap should not start a line with ":::"
7628 let lines = reflow_line("This is some text with ::: class marker.", &options);
7629 for line in &lines {
7630 assert!(
7631 !line.trim_start().starts_with(":::"),
7632 "Wrapped line should not start with div marker: {line}"
7633 );
7634 }
7635 }
7636
7637 /// Rewrite `content` with `reflow_markdown`, assert the result renders to
7638 /// the same HTML, and return it.
7639 ///
7640 /// ASCII whitespace is removed from both renderings, because a soft line
7641 /// break renders as a newline where a space rendered as a space. The parse
7642 /// reads wider than the one this module consults, since it enables definition
7643 /// lists as well, so a rewrite that changes what a reader's parser sees is
7644 /// caught even where the module's own parse says nothing.
7645 ///
7646 /// Strikethrough and definition lists are enabled because the reflow reads
7647 /// both as markup. Plain CommonMark renders a definition-list marker as
7648 /// text, which hides a marker the rewrite moved or absorbed.
7649 fn reflow_markdown_preserving_rendering(content: &str, options: &ReflowOptions) -> String {
7650 let render = |text: &str| {
7651 let mut parser_options = Options::empty();
7652 parser_options.insert(Options::ENABLE_STRIKETHROUGH);
7653 parser_options.insert(Options::ENABLE_DEFINITION_LIST);
7654 let mut html = String::new();
7655 pulldown_cmark::html::push_html(&mut html, Parser::new_ext(text, parser_options));
7656 html.retain(|c| !c.is_ascii_whitespace());
7657 html
7658 };
7659 let reflowed = reflow_markdown(content, options);
7660 assert_eq!(
7661 render(content),
7662 render(&reflowed),
7663 "rendering changed for input: {content:?}"
7664 );
7665 reflowed
7666 }
7667
7668 /// A paragraph part never ends at a line break an atomic range of the parse
7669 /// spans.
7670 ///
7671 /// `reflow_markdown` joins a paragraph's lines into parts before reflowing
7672 /// each one, and a sentence ending in the middle of a link, an image, a code
7673 /// span or an HTML tag is no place to end a part: the newline stays inside
7674 /// the construct, where its whitespace is structural.
7675 #[test]
7676 fn a_paragraph_part_never_ends_inside_an_atomic_construct() {
7677 let options = ReflowOptions {
7678 line_length: 0,
7679 sentence_per_line: true,
7680 ..Default::default()
7681 };
7682
7683 let cases = [
7684 // A CJK sentence closed by a bracket, inside a link's text.
7685 ("[(完成。)\n继续。](url)", "[(完成。) 继续。](url)"),
7686 // The same inside a code span, where the whitespace is literal.
7687 ("`(完成。)\n继续。`", "`(完成。) 继续。`"),
7688 // An ASCII sentence inside a link's text. The line joins rather than
7689 // splitting, which is what the single-line path already produces.
7690 ("[Done.\nNext](url)", "[Done. Next](url)"),
7691 // A control: the same sentence outside any construct still splits.
7692 ("(完成。)\n继续。", "(完成。)\n继续。"),
7693 ("Done. Next", "Done.\nNext"),
7694 ];
7695
7696 for (input, expected) in cases {
7697 assert_eq!(
7698 reflow_markdown_preserving_rendering(input, &options),
7699 expected,
7700 "input: {input:?}"
7701 );
7702 }
7703 }
7704
7705 /// A colon alone on a line opens a definition with no text, so a paragraph
7706 /// ends in front of it and the line the author wrote survives the rewrite.
7707 #[test]
7708 fn a_bare_colon_line_ends_the_paragraph_above_it() {
7709 let options = ReflowOptions {
7710 line_length: 0,
7711 sentence_per_line: true,
7712 ..Default::default()
7713 };
7714
7715 for input in ["文章です。\n:", "Done!\n:", "完成。\n:", "Term\n:\nNext term\n: text"] {
7716 assert_eq!(
7717 reflow_markdown_preserving_rendering(input, &options),
7718 input,
7719 "input: {input:?}"
7720 );
7721 }
7722 }
7723
7724 /// A definition needs a term on the line before it in the same block, so
7725 /// the first line of a paragraph is prose whatever it starts with and is
7726 /// split like any prose. A colon-led line with a line of its block before
7727 /// it opens a definition and is left as written.
7728 #[test]
7729 fn a_colon_leading_the_first_line_of_a_paragraph_is_prose() {
7730 let options = ReflowOptions {
7731 line_length: 0,
7732 sentence_per_line: true,
7733 ..Default::default()
7734 };
7735
7736 for (input, expected) in [
7737 (
7738 ":warning: First sentence. Second sentence.",
7739 ":warning: First sentence.\nSecond sentence.",
7740 ),
7741 (
7742 "Term\n\n:warning: First sentence. Second sentence.",
7743 "Term\n\n:warning: First sentence.\nSecond sentence.",
7744 ),
7745 (
7746 "# Heading\n:warning: First sentence. Second sentence.",
7747 "# Heading\n:warning: First sentence.\nSecond sentence.",
7748 ),
7749 ] {
7750 assert_eq!(
7751 reflow_markdown_preserving_rendering(input, &options),
7752 expected,
7753 "input: {input:?}"
7754 );
7755 }
7756 for input in [
7757 "Term\n:warning: First sentence. Second sentence.",
7758 ":warning: First sentence.\n:note: Second sentence.",
7759 "- term\n :warning: First sentence. Second sentence.",
7760 ] {
7761 assert_eq!(
7762 reflow_markdown_preserving_rendering(input, &options),
7763 input,
7764 "input: {input:?}"
7765 );
7766 }
7767 }
7768}