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