rumdl_lib/rules/md013_line_length.rs
1/// Rule MD013: Line length
2///
3/// See [docs/md013.md](../../docs/md013.md) for full documentation, configuration, and examples.
4use crate::lint_context::column_at;
5use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::mkdocs_admonitions;
7use crate::utils::mkdocs_attr_list::is_standalone_attr_list;
8use crate::utils::mkdocs_snippets::is_snippet_block_delimiter;
9use crate::utils::mkdocs_tabs;
10use crate::utils::range_utils::calculate_excess_range;
11use crate::utils::regex_cache::{IMAGE_REF_PATTERN, LINK_REF_PATTERN, URL_PATTERN};
12use crate::utils::table_utils::TableUtils;
13use crate::utils::text_reflow::{
14 BlockquoteLineData, blockquote_continuation_style, code_span_crosses_last_break, code_span_runs_into_indentation,
15 dominant_blockquote_prefix, is_self_contained_display_math_line, join_sentence_lines, join_soft_break_lines,
16 reflow_blockquote_content, split_into_sentences, trim_breakable_whitespace,
17};
18use pulldown_cmark::LinkType;
19use toml;
20
21mod block_builder;
22mod helpers;
23pub mod md013_config;
24use crate::rules::md030_list_marker_space::MD030Config;
25use crate::utils::html_block::opens_tag_line_html_block;
26use crate::utils::is_template_directive_only;
27use crate::utils::list_indent_shift::is_lazy_continuation;
28use block_builder::{Block, BlockBuilder};
29use helpers::{
30 ends_in_bare_list_marker, extract_list_marker_and_content, has_hard_break, holds_only_container_markers,
31 is_github_alert_marker, is_horizontal_rule, is_html_only_line, is_list_item, is_setext_heading_text_line,
32 is_setext_underline_content, is_standalone_link_or_image_line, is_unwrappable_line, item_owns_lines_from,
33 marker_line_opens_non_paragraph, markers_padded_with_tab, may_be_link_ref_def, source_list_marker,
34 split_into_segments, standalone_link_ends_paragraph, trim_preserving_hard_break,
35};
36pub use md013_config::MD013Config;
37use md013_config::{CjkSoftBreak, LengthMode, ReflowMode};
38
39#[cfg(test)]
40mod tests;
41use unicode_width::UnicodeWidthStr;
42
43#[derive(Clone, Default)]
44pub struct MD013LineLength {
45 pub(crate) config: MD013Config,
46 /// MD030 list-marker spacing, applied when reflowing list items so the rewrite
47 /// uses the configured post-marker spacing rather than a hard-coded single
48 /// space. Defaults to MD030's defaults (a single space everywhere), which
49 /// reproduces the previous behaviour exactly. See [`MD030Config::expected_spaces`].
50 pub(crate) list_spacing: MD030Config,
51}
52
53/// Blockquote paragraph line collected for reflow, with original line index for range computation.
54struct CollectedBlockquoteLine {
55 line_idx: usize,
56 data: BlockquoteLineData,
57}
58
59/// A deliberately conservative MDG step candidate.
60///
61/// Gherkin keywords are localized, so recognizing the word after the marker is
62/// not reliable without the complete dialect table. MDG represents steps as
63/// unordered Markdown list items; withholding reflow from all such items in the
64/// flavor is safer than splitting a step and changing its Gherkin text.
65fn is_potential_mdg_step(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
66 let Some(item) = ctx.list_item_on_line(line_num) else {
67 return false;
68 };
69 !item.is_ordered() && matches!(item.marker_char(), Some('*' | '-' | '+'))
70}
71
72/// One flag per line of the document: whether a GFM reader takes the line for
73/// part of a table, the header line, the delimiter row under it, or a body
74/// row after that, up to a blank line or the next block.
75///
76/// GFM tables need no pipes (`head` over `---:` is a one-cell table), and
77/// rumdl's own table detection, which feeds the table rules, wants pipes and
78/// leaves such tables to the paragraph collectors. This reads the delimiter
79/// row by the GFM grammar instead, in whatever container the line sits:
80/// a delimiter row holds no `>`, so stripping every leading `>` and space
81/// leaves the row itself.
82fn gfm_table_lines(ctx: &crate::lint_context::LintContext) -> Vec<bool> {
83 let lines = &ctx.lines;
84 let text = |idx: usize| lines[idx].content(ctx.content).trim_start_matches([' ', '\t', '>']);
85 // The `>` markers and the width of the whole run of markers and
86 // whitespace a line starts with.
87 let prefix = |idx: usize| {
88 let content = lines[idx].content(ctx.content);
89 let lead = &content[..content.len() - text(idx).len()];
90 (lead.matches('>').count(), lead.len())
91 };
92 let opaque = |idx: usize| {
93 let line = &lines[idx];
94 line.in_code_block || line.in_html_block || line.in_front_matter || line.in_math_block
95 };
96 let mut flags = vec![false; lines.len()];
97 for idx in 1..lines.len() {
98 let header = idx - 1;
99 if flags[header]
100 || opaque(header)
101 || lines[header].heading.is_some()
102 || text(header).is_empty()
103 || !crate::utils::text_reflow::is_table_delimiter_row(text(idx))
104 {
105 continue;
106 }
107 flags[header] = true;
108 flags[idx] = true;
109 // A body row sits in the delimiter row's container: a line with
110 // another number of `>` markers, or short of the delimiter row's
111 // column, is a lazy line, which only a paragraph takes in.
112 let (quotes, column) = prefix(idx);
113 for (body, flag) in flags.iter_mut().enumerate().skip(idx + 1) {
114 let (body_quotes, body_column) = prefix(body);
115 if opaque(body)
116 || lines[body].list_item.is_some()
117 || lines[body].heading.is_some()
118 || text(body).is_empty()
119 || body_quotes != quotes
120 || body_column < column
121 {
122 break;
123 }
124 *flag = true;
125 }
126 }
127 flags
128}
129
130/// Whether line `line_num` (1-based) is touched on either boundary by a code
131/// span crossing more than one line: a span containing the newline that ends
132/// the line before it, or the newline that ends it.
133///
134/// A renderer reads the line break inside a code span as one space, so such a
135/// line is code however it is spelled, and a `$$...$$` expression on it is no
136/// display block. A span that begins and ends on the line itself does not
137/// matter. `flags` holds one entry per line of the document, indexed here by
138/// the 1-based line number, and comes from
139/// [`crate::utils::text_reflow::lines_touching_multiline_code_span`], which
140/// reads code spans on their own rather than alongside math delimiters: a
141/// `$$...$$` pair closes wherever the next `$$` sits, backtick or not, so a
142/// backtick between them that in fact opens a span reaching past the line
143/// needs a parser that isn't also deciding where the math closes.
144fn line_touches_multiline_code_span(flags: &[bool], line_num: usize) -> bool {
145 line_num
146 .checked_sub(1)
147 .and_then(|idx| flags.get(idx))
148 .copied()
149 .unwrap_or(false)
150}
151
152/// How many bytes of indentation to strip from the 0-indexed continuation
153/// line `idx` of a container whose content starts at `content_col`, given
154/// that the line is indented `indent`.
155///
156/// Outside a code span the whole indentation renders as nothing. A code span
157/// crossing into the line keeps every character past the container's content
158/// column, so only the container's own indentation goes.
159fn continuation_indent_to_strip(
160 ctx: &crate::lint_context::LintContext,
161 idx: usize,
162 indent: usize,
163 content_col: usize,
164) -> usize {
165 if ctx.is_in_code_span_byte_with_or_without_math(ctx.lines[idx].byte_offset) {
166 indent.min(content_col)
167 } else {
168 indent
169 }
170}
171
172/// The content column of the deepest list item enclosing the item on the
173/// 0-indexed line `item_idx` that a line indented `indent` still reaches, or 0
174/// when it reaches none.
175///
176/// A lazy continuation line falls short of the innermost item, but it matches
177/// every enclosing item out to that one, so their indentation is container
178/// indentation and never part of a code span crossing into the line.
179fn enclosing_item_content_col_reached(ctx: &crate::lint_context::LintContext, item_idx: usize, indent: usize) -> usize {
180 let mut limit = ctx.lines[item_idx]
181 .list_item
182 .as_ref()
183 .map_or(0, |item| item.marker_column);
184 for j in (0..item_idx).rev() {
185 if limit == 0 {
186 break;
187 }
188 let info = &ctx.lines[j];
189 if let Some(item) = &info.list_item {
190 if item.marker_column < limit {
191 // Enclosing items are met innermost first, and every one
192 // further out has a smaller content column, so the first one
193 // reached is the deepest.
194 if item.content_column <= indent {
195 return item.content_column;
196 }
197 limit = item.marker_column;
198 }
199 } else if !info.is_blank && info.indent < limit && !is_lazy_continuation(ctx, j) {
200 // A line short of the marker column that continues no paragraph
201 // ended every item that could have enclosed this one.
202 break;
203 }
204 }
205 0
206}
207
208/// `trimmed`, the text of the 0-indexed line `idx` with its end trimmed, with
209/// that end restored when a code span crosses the line break after it: the
210/// span keeps every character before the break, so the whitespace there is
211/// code, and no hard break.
212fn restore_code_span_line_end(ctx: &crate::lint_context::LintContext, idx: usize, trimmed: String) -> String {
213 if !line_ends_in_code_span(ctx, idx) {
214 return trimmed;
215 }
216 let raw = source_line_without_cr(ctx, idx);
217 let ending = &raw[raw.trim_end_matches([' ', '\t']).len()..];
218 format!("{}{ending}", trimmed.trim_end_matches([' ', '\t']))
219}
220
221/// The text of the 0-indexed blockquote line `idx` after its `>` markers, as a
222/// block of the quote whose content starts `content_col` bytes into the quote's
223/// content holds it: trimmed at both ends, except where a code span crosses
224/// the line break at either end.
225///
226/// The parsed `content` starts after all of the whitespace following the last
227/// `>`, but the quote itself takes only the one optional space after it, and
228/// the block inside it only up to its content column. A code span crossing
229/// into the line keeps the rest.
230fn blockquote_continuation_line(
231 ctx: &crate::lint_context::LintContext,
232 idx: usize,
233 bq: &crate::lint_context::types::BlockquoteInfo,
234 content_col: usize,
235) -> String {
236 let mut text = trim_preserving_hard_break(&bq.content);
237 if ctx.is_in_code_span_byte_with_or_without_math(ctx.lines[idx].byte_offset) {
238 let indent = &bq.prefix[blockquote_content_start(&bq.prefix)..];
239 text.insert_str(0, &indent[content_col.min(indent.len())..]);
240 }
241 restore_code_span_line_end(ctx, idx, text)
242}
243
244/// Whether the 0-indexed line `idx` opens a list item whose marker is the last
245/// thing on the line. The item's content column is one past the marker, so the
246/// line below it is the item's content only when indented that far; reflow
247/// leaves such a marker line alone and never lets a paragraph run through it.
248/// The parser decides that the marker opens an item (a lone `1.` that cannot
249/// interrupt a paragraph is text); the text decides that nothing follows it.
250fn opens_bare_item(ctx: &crate::lint_context::LintContext, idx: usize) -> bool {
251 ctx.lines.get(idx).is_some_and(|line| line.list_item.is_some())
252 && ends_in_bare_list_marker(ctx.lines[idx].content(ctx.content))
253}
254
255/// Where the quote's own content starts in a blockquote `prefix`: after the
256/// last `>` and the one optional space following it.
257fn blockquote_content_start(prefix: &str) -> usize {
258 let after_marker = prefix.rfind('>').map_or(0, |pos| pos + 1);
259 after_marker + usize::from(prefix[after_marker..].starts_with(' '))
260}
261
262/// Whether a code span crosses the line break ending the 0-indexed line `idx`.
263fn line_ends_in_code_span(ctx: &crate::lint_context::LintContext, idx: usize) -> bool {
264 ctx.is_in_code_span_byte_with_or_without_math(ctx.lines[idx].byte_offset + source_line_without_cr(ctx, idx).len())
265}
266
267fn source_line_without_cr<'a>(ctx: &'a crate::lint_context::LintContext, idx: usize) -> &'a str {
268 let raw = ctx.lines[idx].content(ctx.content);
269 raw.strip_suffix('\r').unwrap_or(raw)
270}
271
272/// Whether any of the lines `start_idx..=end_idx` (0-indexed) is a definition
273/// list's term, a definition's marker line holding its text, or a line of a
274/// definition the parser opens on a colon touching its text.
275///
276/// The container reflow paths join these into prose: a term is absorbed into
277/// the text around it, and a marker loses the spacing that sets the
278/// definition's content column. A definition's later paragraph is prose at
279/// the definition's indentation, which the paths keep, and a list or
280/// blockquote nested inside a definition holds none of these lines, so both
281/// still reflow. The lines of a definition opened on a colon touching its
282/// text are prose holding inline constructs the context does not report, so
283/// they are kept as written too.
284fn holds_definition_list(ctx: &crate::lint_context::LintContext, start_idx: usize, end_idx: usize) -> bool {
285 (start_idx..=end_idx).any(|idx| {
286 let line_num = idx + 1;
287 ctx.is_definition_term(line_num)
288 || ctx.in_colon_definition(line_num)
289 || ctx
290 .definition_text_at(line_num)
291 .is_some_and(|text| text.start_line == line_num && text.marker_prefix_len.is_some())
292 })
293}
294
295/// The span holding `line` among `spans`, which are ordered and do not overlap.
296fn span_containing(spans: &[std::ops::RangeInclusive<usize>], line: usize) -> Option<&std::ops::RangeInclusive<usize>> {
297 let index = spans.partition_point(|span| *span.end() < line);
298 spans.get(index).filter(|span| *span.start() <= line)
299}
300
301impl MD013LineLength {
302 pub fn new(line_length: usize, code_blocks: bool, tables: bool, headings: bool, strict: bool) -> Self {
303 Self {
304 config: MD013Config {
305 line_length: crate::types::LineLength::new(line_length),
306 code_blocks,
307 code_spans: true,
308 tables,
309 headings,
310 math_blocks: true,
311 bracket_display_math: false,
312 paragraphs: true, // Default to true for backwards compatibility
313 blockquotes: true, // Default to true for backwards compatibility
314 strict,
315 stern: false,
316 heading_line_length: None,
317 code_block_line_length: None,
318 reflow: false,
319 reflow_mode: ReflowMode::default(),
320 length_mode: LengthMode::default(),
321 abbreviations: Vec::new(),
322 require_sentence_capital: true,
323 ignore_link_urls: true,
324 atomic_spans: true,
325 reflow_break_link_text: false,
326 reflow_break_code_spans: true,
327 reflow_length_exemptions: false,
328 cjk_soft_break: CjkSoftBreak::default(),
329 },
330 list_spacing: MD030Config::default(),
331 }
332 }
333
334 pub fn from_config_struct(config: MD013Config) -> Self {
335 Self {
336 config,
337 list_spacing: MD030Config::default(),
338 }
339 }
340
341 /// Return a clone with code block checking disabled.
342 /// Used for doc comment linting where code blocks are Rust code managed by rustfmt.
343 pub fn with_code_blocks_disabled(&self) -> Self {
344 let mut clone = self.clone();
345 clone.config.code_blocks = false;
346 clone
347 }
348
349 /// Convert MD013 LengthMode to text_reflow ReflowLengthMode
350 /// Normalized set of reference labels defined in the document.
351 ///
352 /// Passed to reflow so a bare shortcut reference (`[text]`) is treated as an
353 /// atomic link only when its label is actually defined; an undefined
354 /// bracketed run reflows as literal prose.
355 fn defined_reference_labels(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<String> {
356 ctx.reference_definitions()
357 .iter()
358 .map(|d| crate::utils::text_reflow::normalize_reference_label(&d.id))
359 .collect()
360 }
361
362 /// Build the reflow options shared by every MD013 fix path.
363 ///
364 /// `line_length` varies per call site (some subtract a list-marker or
365 /// blockquote prefix); every other field is derived uniformly from the
366 /// effective config and the document flavor. Callers that need a different
367 /// `max_list_continuation_indent` override it via struct update.
368 fn reflow_options(
369 ctx: &crate::lint_context::LintContext,
370 config: &MD013Config,
371 line_length: usize,
372 ) -> crate::utils::text_reflow::ReflowOptions {
373 crate::utils::text_reflow::ReflowOptions {
374 line_length,
375 break_on_sentences: true,
376 preserve_breaks: false,
377 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
378 sentence_pack: config.reflow_mode == ReflowMode::SentencePack,
379 first_line_length: None,
380 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
381 abbreviations: config.abbreviations_for_reflow(),
382 length_mode: config.reflow_length_mode(),
383 attr_lists: ctx.flavor.supports_attr_lists(),
384 myst_roles: ctx.flavor.supports_myst_roles(),
385 require_sentence_capital: config.require_sentence_capital,
386 max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
387 Some(4)
388 } else {
389 None
390 },
391 defined_references: Some(Self::defined_reference_labels(ctx)),
392 atomic_spans: config.atomic_spans,
393 // Container emitters add a continuation prefix. Only the plain
394 // paragraph path can prove that no literal indentation is added.
395 wrap_code_spans: false,
396 break_link_text: config.reflow_break_link_text,
397 length_exemptions: config.length_exemptions_for_reflow(),
398 cjk_soft_break: config.cjk_soft_break,
399 }
400 }
401
402 fn should_ignore_line(
403 &self,
404 line: &str,
405 _lines: &[&str],
406 current_line: usize,
407 ctx: &crate::lint_context::LintContext,
408 ) -> bool {
409 if self.config.strict {
410 return false;
411 }
412
413 // Quick check for common patterns before expensive regex
414 let trimmed = line.trim();
415
416 // Only skip if the entire line is a URL (quick check first)
417 if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
418 return true;
419 }
420
421 // Only skip if the entire line is an image reference (quick check first)
422 if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
423 return true;
424 }
425
426 // Note: link reference definitions are handled as always-exempt (even in strict mode)
427 // in the main check loop, so they don't need to be checked here.
428
429 // Code blocks with long strings (only check if in code block)
430 if ctx.line_info(current_line + 1).is_some_and(|info| info.in_code_block)
431 && !trimmed.is_empty()
432 && !line.contains(' ')
433 && !line.contains('\t')
434 {
435 return true;
436 }
437
438 false
439 }
440
441 /// Check if rule should skip based on provided config (used for inline config support)
442 fn should_skip_with_config(&self, ctx: &crate::lint_context::LintContext, config: &MD013Config) -> bool {
443 // Skip if content is empty
444 if ctx.content.is_empty() {
445 return true;
446 }
447
448 // For sentence-per-line, semantic-line-breaks, or normalize mode, never skip based on line length
449 if config.reflow
450 && (config.reflow_mode == ReflowMode::SentencePerLine
451 || config.reflow_mode == ReflowMode::SentencePack
452 || config.reflow_mode == ReflowMode::SemanticLineBreaks
453 || config.reflow_mode == ReflowMode::Normalize)
454 {
455 return false;
456 }
457
458 // Use the smallest applicable budget across line/heading/code-block
459 // contexts so a stricter context-specific limit doesn't get masked by
460 // the document-wide budget.
461 let min_limit = config.min_effective_line_length();
462 if min_limit.is_unlimited() {
463 return true;
464 }
465 let min_limit_bytes = min_limit.get();
466
467 // Quick check: if total content is shorter than the smallest line limit,
468 // definitely skip.
469 if ctx.content.len() <= min_limit_bytes {
470 return true;
471 }
472
473 // Skip if no line exceeds the smallest applicable limit.
474 !ctx.lines.iter().any(|line| line.byte_len > min_limit_bytes)
475 }
476
477 fn join_paragraph_lines<S: AsRef<str>>(lines: &[S], config: &MD013Config) -> String {
478 if config.reflow_mode == ReflowMode::SentencePerLine {
479 join_sentence_lines(lines, config.cjk_soft_break, &config.abbreviations_for_reflow())
480 } else {
481 join_soft_break_lines(lines, config.cjk_soft_break)
482 }
483 }
484
485 fn normalize_mode_needs_reflow<'a, I>(&self, lines: I, config: &MD013Config) -> bool
486 where
487 I: IntoIterator<Item = &'a str>,
488 {
489 let mut line_count = 0;
490 let check_length = !config.line_length.is_unlimited();
491
492 for line in lines {
493 line_count += 1;
494 if check_length && self.calculate_effective_length(line) > config.line_length.get() {
495 return true;
496 }
497 }
498
499 line_count > 1
500 }
501}
502
503impl Rule for MD013LineLength {
504 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
505 (!self.config.reflow).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(13))
506 }
507
508 fn name(&self) -> &'static str {
509 "MD013"
510 }
511
512 fn description(&self) -> &'static str {
513 "Line length should not be excessive"
514 }
515
516 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
517 // Use pre-parsed inline config from LintContext
518 let config_override = ctx.inline_config().get_rule_config("MD013");
519
520 // Apply configuration override if present
521 let effective_config = if let Some(json_config) = config_override {
522 if let Some(obj) = json_config.as_object() {
523 let mut config = self.config.clone();
524 if let Some(line_length) = obj.get("line_length").and_then(serde_json::Value::as_u64) {
525 config.line_length = crate::types::LineLength::new(line_length as usize);
526 }
527 if let Some(code_blocks) = obj.get("code_blocks").and_then(serde_json::Value::as_bool) {
528 config.code_blocks = code_blocks;
529 }
530 if let Some(code_spans) = obj.get("code_spans").and_then(serde_json::Value::as_bool) {
531 config.code_spans = code_spans;
532 }
533 if let Some(tables) = obj.get("tables").and_then(serde_json::Value::as_bool) {
534 config.tables = tables;
535 }
536 if let Some(headings) = obj.get("headings").and_then(serde_json::Value::as_bool) {
537 config.headings = headings;
538 }
539 if let Some(math_blocks) = obj
540 .get("math_blocks")
541 .or_else(|| obj.get("math-blocks"))
542 .and_then(serde_json::Value::as_bool)
543 {
544 config.math_blocks = math_blocks;
545 }
546 if let Some(enabled) = obj
547 .get("bracket_display_math")
548 .or_else(|| obj.get("bracket-display-math"))
549 .and_then(serde_json::Value::as_bool)
550 {
551 config.bracket_display_math = enabled;
552 }
553 if let Some(blockquotes) = obj.get("blockquotes").and_then(serde_json::Value::as_bool) {
554 config.blockquotes = blockquotes;
555 }
556 if let Some(strict) = obj.get("strict").and_then(serde_json::Value::as_bool) {
557 config.strict = strict;
558 }
559 if let Some(stern) = obj.get("stern").and_then(serde_json::Value::as_bool) {
560 config.stern = stern;
561 }
562 if let Some(v) = obj
563 .get("ignore_link_urls")
564 .or_else(|| obj.get("ignore-link-urls"))
565 .or_else(|| obj.get("semantic_link_understanding"))
566 .or_else(|| obj.get("semantic-link-understanding"))
567 .and_then(serde_json::Value::as_bool)
568 {
569 config.ignore_link_urls = v;
570 }
571 if let Some(reflow) = obj.get("reflow").and_then(serde_json::Value::as_bool) {
572 config.reflow = reflow;
573 }
574 if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
575 config.reflow_mode = match reflow_mode {
576 "default" => ReflowMode::Default,
577 "normalize" => ReflowMode::Normalize,
578 "sentence-per-line" => ReflowMode::SentencePerLine,
579 "sentence-pack" | "sentence_pack" => ReflowMode::SentencePack,
580 "semantic-line-breaks" => ReflowMode::SemanticLineBreaks,
581 _ => ReflowMode::default(),
582 };
583 }
584 config
585 } else {
586 self.config.clone()
587 }
588 } else {
589 self.config.clone()
590 };
591
592 // Fast early return using should_skip with EFFECTIVE config (after inline overrides)
593 // But don't skip if we're in reflow mode with Normalize or SentencePerLine
594 if self.should_skip_with_config(ctx, &effective_config)
595 && !(effective_config.reflow
596 && (effective_config.reflow_mode == ReflowMode::Normalize
597 || effective_config.reflow_mode == ReflowMode::SentencePack
598 || effective_config.reflow_mode == ReflowMode::SentencePerLine
599 || effective_config.reflow_mode == ReflowMode::SemanticLineBreaks))
600 {
601 return Ok(Vec::new());
602 }
603
604 // Direct implementation without DocumentStructure
605 let mut warnings = Vec::new();
606
607 // Special handling: line_length = 0 means "no line length limit"
608 // Skip all line length checks, but still allow reflow if enabled
609 let skip_length_checks = effective_config.line_length.is_unlimited();
610
611 // Pre-filter lines that could be problematic to avoid processing all lines.
612 // Use the smallest applicable budget across line/heading/code-block contexts
613 // so candidates aren't dropped when a stricter context-specific budget applies.
614 let prefilter_limit = effective_config.min_effective_line_length();
615 let prefilter_skip = prefilter_limit.is_unlimited();
616 let mut candidate_lines = Vec::new();
617 if !skip_length_checks && !prefilter_skip {
618 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
619 // Skip front matter - it should never be linted
620 if line_info.in_front_matter {
621 continue;
622 }
623
624 // Quick length check first
625 if line_info.byte_len > prefilter_limit.get() {
626 candidate_lines.push(line_idx);
627 }
628 }
629 }
630
631 // If no candidate lines and not in normalize or sentence-per-line mode, early return
632 if candidate_lines.is_empty()
633 && !(effective_config.reflow
634 && (effective_config.reflow_mode == ReflowMode::Normalize
635 || effective_config.reflow_mode == ReflowMode::SentencePack
636 || effective_config.reflow_mode == ReflowMode::SentencePerLine
637 || effective_config.reflow_mode == ReflowMode::SemanticLineBreaks))
638 {
639 return Ok(warnings);
640 }
641
642 let lines = ctx.raw_lines();
643
644 // Whether a 1-indexed line is part of a heading. A setext heading's text
645 // is the whole paragraph its underline ends, and the parser records the
646 // heading itself only on the last of those lines, so the earlier ones
647 // are recognized through `is_setext_heading_text`. Both are O(1)
648 // per-line fields, so check them directly at each use site instead of
649 // materializing a full-document HashSet (an extra O(n) pass and
650 // allocation on a rule that runs on virtually every file).
651 //
652 // A heading inside a blockquote is recorded apart from the line fields,
653 // so the 1-indexed line spans of those few headings are listed here.
654 let quoted_heading_spans: Vec<std::ops::RangeInclusive<usize>> = ctx
655 .headings()
656 .filter(crate::lint_context::ParsedHeading::is_blockquote)
657 .map(|heading| heading.first_line_num()..=heading.line_num)
658 .collect();
659 let is_heading_line_num = |line_number: usize| -> bool {
660 line_number
661 .checked_sub(1)
662 .and_then(|idx| ctx.lines.get(idx))
663 .is_some_and(|line| line.heading.is_some() || line.is_setext_heading_text)
664 || span_containing("ed_heading_spans, line_number).is_some()
665 };
666
667 // Use pre-computed table blocks from context
668 // We need this for both the table skip check AND the paragraphs check
669 let table_blocks = &ctx.table_blocks;
670 let mut table_lines_set = std::collections::HashSet::new();
671 for table in table_blocks {
672 table_lines_set.insert(table.header_line + 1);
673 table_lines_set.insert(table.delimiter_line + 1);
674 for &line in &table.content_lines {
675 table_lines_set.insert(line + 1);
676 }
677 }
678
679 let defined_references = Self::defined_reference_labels(ctx);
680
681 // Process candidate lines for line length checks
682 'line_loop: for &line_idx in &candidate_lines {
683 let line_number = line_idx + 1;
684 let line = lines[line_idx];
685
686 // Calculate actual line length (used in warning messages)
687 let effective_length = self.calculate_effective_length(line);
688
689 // Pick the context-specific limit: heading > code-block > paragraph.
690 // Headings dominate over code-block context if a setext underline ever
691 // overlaps a fenced range (defensive — these are mutually exclusive in
692 // practice, but the explicit ordering documents intent).
693 let is_heading_line = is_heading_line_num(line_number);
694 let in_code_block = ctx.line_info(line_number).is_some_and(|info| info.in_code_block);
695 let line_limit = if is_heading_line {
696 effective_config.effective_heading_line_length().get()
697 } else if in_code_block {
698 effective_config.effective_code_block_line_length().get()
699 } else {
700 effective_config.line_length.get()
701 };
702
703 // A context-specific limit of 0 means "unlimited for this context".
704 if line_limit == 0 {
705 continue;
706 }
707
708 // Stern mode: like default, but the trailing-token forgiveness is
709 // disabled — a line with whitespace that exceeds the limit is a
710 // violation even if the excess is the final token. The "unwrappable"
711 // line exemption (single token, optionally prefixed by # or >) is
712 // still honored. Strict overrides stern entirely.
713 if effective_config.stern && !effective_config.strict && is_unwrappable_line(line) {
714 continue;
715 }
716
717 // Trailing-token forgiveness: only in default mode (not strict, not stern).
718 // If the line only exceeds the limit because of a long token at the end
719 // (URL, link chain, identifier), it passes. This matches markdownlint's
720 // behavior: line.replace(/\S*$/u, "#")
721 let check_length = if effective_config.strict || effective_config.stern {
722 effective_length
723 } else {
724 match line.rfind(char::is_whitespace) {
725 Some(pos) => {
726 let ws_char = line[pos..].chars().next().unwrap();
727 let prefix_end = pos + ws_char.len_utf8();
728 self.calculate_string_length(&line[..prefix_end]) + 1
729 }
730 None => 1, // No whitespace — entire line is a single token
731 }
732 };
733
734 // Skip lines where the check length is within the limit
735 if check_length <= line_limit {
736 continue;
737 }
738
739 // Ignore inline link/image URLs: suppress when excess comes entirely from inline URLs.
740 // Disabled by `strict` (all forgiveness off) and by `ignore_link_urls = false`
741 // (count link/image URLs toward the line length so such lines are flagged).
742 if !effective_config.strict && effective_config.ignore_link_urls {
743 let length_without_urls = self.length_without_inline_link_urls(effective_length, line_number, ctx);
744 if length_without_urls <= line_limit {
745 continue;
746 }
747 }
748
749 // Inline code spans cannot be wrapped, so reflow cannot shorten a line
750 // whose excess length is one. When code-span checking is disabled,
751 // suppress a violation that would fit once inline code spans are excluded.
752 if !effective_config.code_spans {
753 let code_span_width: usize = ctx
754 .source_code_spans()
755 .iter()
756 .filter(|span| span.line == line_number && span.end_line == line_number)
757 .map(|span| self.calculate_string_length(&ctx.content[span.byte_offset..span.byte_end]))
758 .sum();
759 if effective_length.saturating_sub(code_span_width) <= line_limit {
760 continue;
761 }
762 }
763
764 // Skip mkdocstrings and pymdown blocks (already handled by LintContext)
765 if ctx.lines[line_idx].in_mkdocstrings || ctx.lines[line_idx].in_pymdown_block {
766 continue;
767 }
768
769 // Skip MyST comments (% comment) — structural lines, not prose
770 if ctx.lines[line_idx].is_myst_comment {
771 continue;
772 }
773
774 // Link reference definitions are always exempt, even in strict mode.
775 // There's no way to shorten them without breaking the URL.
776 // Also check after stripping list markers, since list items may
777 // contain link ref defs as their content.
778 {
779 let trimmed = line.trim();
780 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
781 continue;
782 }
783 if is_list_item(trimmed) {
784 let (_, content) = extract_list_marker_and_content(trimmed);
785 let content_trimmed = content.trim();
786 if content_trimmed.starts_with('[')
787 && content_trimmed.contains("]:")
788 && LINK_REF_PATTERN.is_match(content_trimmed)
789 {
790 continue;
791 }
792 }
793 }
794
795 // Skip various block types efficiently
796 if !effective_config.strict {
797 // Lines whose only content is a link/image are exempt.
798 // After stripping list markers, blockquote markers, and emphasis,
799 // if only a link or image remains, there is no way to shorten it.
800 if is_standalone_link_or_image_line(ctx, line_number) {
801 continue;
802 }
803
804 // Lines consisting entirely of HTML tags are exempt.
805 // Badge lines, images with attributes, and similar inline HTML
806 // are long due to URLs in attributes and can't be meaningfully shortened.
807 if is_html_only_line(line) {
808 continue;
809 }
810
811 // Skip setext heading underlines
812 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
813 continue;
814 }
815
816 // Skip block elements according to config flags
817 // The flags mean: true = check these elements, false = skip these elements
818 // So we skip when the flag is FALSE and the line is in that element type
819 if (!effective_config.headings && is_heading_line_num(line_number))
820 || (!effective_config.code_blocks
821 && ctx.line_info(line_number).is_some_and(|info| info.in_code_block))
822 || (!effective_config.tables && table_lines_set.contains(&line_number))
823 || (!effective_config.math_blocks && self.line_is_display_math(line_number, ctx, &effective_config))
824 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
825 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
826 || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block)
827 || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_expression)
828 || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_block)
829 || ctx.line_info(line_number).is_some_and(|info| info.in_mdx_comment)
830 || ctx.line_info(line_number).is_some_and(|info| info.in_pymdown_block)
831 {
832 continue;
833 }
834
835 // Check if this is a paragraph/regular text line
836 // If paragraphs = false, skip lines that are NOT in special blocks
837 // Blockquote content is treated as paragraph text, so it's not
838 // included in the special blocks list here.
839 if !effective_config.paragraphs {
840 let is_special_block = is_heading_line_num(line_number)
841 || ctx.line_info(line_number).is_some_and(|info| info.in_code_block)
842 || table_lines_set.contains(&line_number)
843 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
844 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
845 || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block)
846 || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_expression)
847 || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_block)
848 || ctx.line_info(line_number).is_some_and(|info| info.in_mdx_comment)
849 || ctx
850 .line_info(line_number)
851 .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container);
852
853 // Skip regular paragraph text when paragraphs = false
854 if !is_special_block {
855 continue;
856 }
857 }
858
859 // Skip blockquote lines when blockquotes = false.
860 // Also skip lazy continuation lines that belong to a blockquote
861 // (lines without `>` prefix that follow a blockquote line).
862 if !effective_config.blockquotes {
863 if ctx.lines[line_number - 1].blockquote.is_some() {
864 continue;
865 }
866 // Check for lazy continuation: scan backwards through
867 // non-blank lines to find if this paragraph started with
868 // a blockquote marker
869 if !line.trim().is_empty() {
870 let mut scan = line_number.saturating_sub(2);
871 loop {
872 if ctx.lines[scan].blockquote.is_some() {
873 // Found a blockquote ancestor — this is a lazy continuation
874 continue 'line_loop;
875 }
876 if lines[scan].trim().is_empty() || scan == 0 {
877 break;
878 }
879 scan -= 1;
880 }
881 }
882 }
883
884 // Skip lines that are only a URL, image ref, or link ref
885 if self.should_ignore_line(line, lines, line_idx, ctx) {
886 continue;
887 }
888 }
889
890 // In sentence-per-line mode, check if this is a single long sentence
891 // If so, emit a warning without a fix (user must manually rephrase)
892 if effective_config.reflow_mode == ReflowMode::SentencePerLine {
893 let sentences = split_into_sentences(
894 line.trim(),
895 Some(&defined_references),
896 effective_config.require_sentence_capital,
897 effective_config.cjk_soft_break,
898 );
899 if sentences.len() == 1 {
900 // Single sentence that's too long - warn but don't auto-fix
901 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
902
903 let (start_line, start_col, end_line, end_col) =
904 calculate_excess_range(line_number, line, line_limit);
905
906 warnings.push(LintWarning {
907 rule_name: Some(self.name().to_string()),
908 message,
909 line: start_line,
910 column: start_col,
911 end_line,
912 end_column: end_col,
913 severity: Severity::Warning,
914 fix: None, // No auto-fix for long single sentences
915 });
916 continue;
917 }
918 // Multiple sentences will be handled by paragraph-based reflow
919 continue;
920 }
921
922 // In semantic-line-breaks mode, skip per-line checks —
923 // all reflow is handled at the paragraph level with cascading splits
924 if effective_config.reflow_mode == ReflowMode::SemanticLineBreaks {
925 continue;
926 }
927 // Sentence packing is checked against the actual paragraph replacement.
928 // An indivisible sentence may exceed the soft budget without a warning.
929 if effective_config.reflow
930 && effective_config.reflow_mode == ReflowMode::SentencePack
931 && !is_heading_line
932 && !in_code_block
933 && !table_lines_set.contains(&line_number)
934 && !self.line_is_display_math(line_number, ctx, &effective_config)
935 {
936 continue;
937 }
938
939 // Don't provide fix for individual lines when reflow is enabled
940 // Paragraph-based fixes will be handled separately
941 let fix = None;
942
943 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
944
945 // Calculate precise character range for the excess portion
946 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
947
948 warnings.push(LintWarning {
949 rule_name: Some(self.name().to_string()),
950 message,
951 line: start_line,
952 column: start_col,
953 end_line,
954 end_column: end_col,
955 severity: Severity::Warning,
956 fix,
957 });
958 }
959
960 // If reflow is enabled, generate paragraph-based fixes
961 if effective_config.reflow {
962 let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, lines);
963 let table_lines = gfm_table_lines(ctx);
964 // Merge paragraph warnings with line warnings, removing duplicates
965 for mut pw in paragraph_warnings {
966 if ctx.flavor == crate::config::MarkdownFlavor::MDG
967 && (pw.line..=pw.end_line).any(|line_number| is_potential_mdg_step(ctx, line_number))
968 {
969 // Keep reporting the excessive line, but do not offer a fix
970 // whose replacement would split a Gherkin step across lines.
971 pw.fix = None;
972 }
973 if (pw.line..=pw.end_line).any(|line_number| {
974 let idx = line_number - 1;
975 table_lines.get(idx).copied().unwrap_or(false) && !ctx.lines[idx].in_table_block
976 }) {
977 // A table rumdl's table detection does not see is no
978 // paragraph to reformat: rewrapping its header or joining
979 // its rows would turn it back into prose, or into a
980 // different table. The line warnings stand as they would
981 // for any table.
982 continue;
983 }
984 // A paragraph warning that touches a disabled line is dropped by the
985 // inline-config filter, since its fix would rewrite that line. It is
986 // still emitted so the filter credits the disable comment, and the
987 // per-line warnings stay: those on disabled lines are filtered as
988 // usual and an over-long enabled line is still reported.
989 let touches_disabled_line = (pw.line..=pw.end_line)
990 .any(|line_number| ctx.inline_config().is_rule_disabled(self.name(), line_number));
991 if !touches_disabled_line {
992 // Remove any line warnings that overlap with this paragraph
993 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
994 }
995 warnings.push(pw);
996 }
997 }
998
999 Ok(warnings)
1000 }
1001
1002 fn fixes_from_diagnostics(&self) -> bool {
1003 true
1004 }
1005
1006 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1007 // For CLI usage, apply fixes from warnings
1008 // LSP will use the warning-based fixes directly
1009 let warnings = self.check(ctx)?;
1010 let warnings =
1011 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1012
1013 // If there are no fixes, return content unchanged
1014 if !warnings.iter().any(|w| w.fix.is_some()) {
1015 return Ok(ctx.content.to_string());
1016 }
1017
1018 // Apply warning-based fixes
1019 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1020 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
1021 }
1022
1023 fn as_any(&self) -> &dyn std::any::Any {
1024 self
1025 }
1026
1027 fn category(&self) -> RuleCategory {
1028 RuleCategory::Whitespace
1029 }
1030
1031 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1032 self.should_skip_with_config(ctx, &self.config)
1033 }
1034
1035 crate::impl_rule_config_sections!(MD013Config);
1036
1037 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
1038 let mut aliases = std::collections::HashMap::new();
1039 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
1040 aliases.insert("strict_sentences".to_string(), "require-sentence-capital".to_string());
1041 aliases.insert("strict-sentences".to_string(), "require-sentence-capital".to_string());
1042 // Kept in step with the `alias` attributes on `MD013Config::ignore_link_urls`.
1043 // Serde accepts these spellings, so a config using one is honored; without
1044 // them here the key validator reports a documented, working key as unknown.
1045 aliases.insert(
1046 "semantic-link-understanding".to_string(),
1047 "ignore-link-urls".to_string(),
1048 );
1049 aliases.insert(
1050 "semantic_link_understanding".to_string(),
1051 "ignore-link-urls".to_string(),
1052 );
1053 Some(aliases)
1054 }
1055
1056 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1057 where
1058 Self: Sized,
1059 {
1060 let rule_config = MD013Config::from_document_config(config);
1061 let mut rule = Self::from_config_struct(rule_config);
1062 // Pull list-marker spacing from MD030 (via the shared serde config loader)
1063 // so reflow rewrites list items with the configured spacing rather than a
1064 // hard-coded single space.
1065 rule.list_spacing = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
1066 Box::new(rule)
1067 }
1068}
1069
1070impl MD013LineLength {
1071 /// True when `line_num` (1-indexed) sits inside a `$$` span covering more
1072 /// than one line, as seen by the byte-level math parser.
1073 fn line_in_multiline_math_span(&self, line_num: usize, ctx: &crate::lint_context::LintContext) -> bool {
1074 ctx.math_spans()
1075 .iter()
1076 .any(|span| span.is_display && span.end_line > span.line && (span.line..=span.end_line).contains(&line_num))
1077 }
1078
1079 /// True when `line_num` (1-indexed) holds nothing but display math, whether
1080 /// that is one line of a multi-line block, a delimiter line, or a whole line
1081 /// that is a single complete `$$...$$` span. This is what `math-blocks =
1082 /// false` exempts from the length check.
1083 ///
1084 /// rumdl models math twice and the two models miss different containers, so
1085 /// this consults both. `math_spans()` is byte-level and sees a block opened on
1086 /// a list marker line (`- $$`), which the line-level map cannot because that
1087 /// line does not begin with `$$`. `LineInfo::in_math_block` is line-level and
1088 /// sees a four-space-indented block inside a footnote, which the byte-level
1089 /// parser reads as an indented code block. Taking the union only ever adds
1090 /// coverage: neither signal fires on ordinary prose, and an unmatched `$$`
1091 /// opener is flagged by neither, so a stray delimiter cannot exempt the rest
1092 /// of the document.
1093 fn line_is_display_math(
1094 &self,
1095 line_num: usize,
1096 ctx: &crate::lint_context::LintContext,
1097 config: &MD013Config,
1098 ) -> bool {
1099 self.line_holds_only_multiline_math(line_num, ctx)
1100 || ctx.line_info(line_num).is_some_and(|info| info.in_math_block)
1101 || (config.bracket_display_math
1102 && ctx
1103 .bracket_display_math_lines()
1104 .multiline
1105 .get(line_num.saturating_sub(1))
1106 .copied()
1107 .unwrap_or(false))
1108 || (config.bracket_display_math
1109 && ctx
1110 .bracket_display_math_lines()
1111 .standalone
1112 .get(line_num.saturating_sub(1))
1113 .copied()
1114 .unwrap_or(false))
1115 }
1116
1117 /// True when a multi-line `$$` span covers `line_num` (1-indexed) and the line
1118 /// holds nothing besides that math.
1119 ///
1120 /// A delimiter line can carry Markdown outside the delimiter: `$$ trailing
1121 /// prose` closes a block and then continues in prose, and `leading prose $$`
1122 /// opens one at the end of a sentence. That prose is ordinary text and counts
1123 /// toward the line's length like any other, so exempting the whole line would
1124 /// hide arbitrarily long prose behind a delimiter. The line-level map already
1125 /// leaves such mixed lines unflagged; this is the byte-level half of the same
1126 /// judgement.
1127 fn line_holds_only_multiline_math(&self, line_num: usize, ctx: &crate::lint_context::LintContext) -> bool {
1128 let Some(info) = ctx.line_info(line_num) else {
1129 return false;
1130 };
1131 let line = info.content(ctx.content);
1132
1133 ctx.math_spans().iter().any(|span| {
1134 if !span.is_display || span.end_line <= span.line || !(span.line..=span.end_line).contains(&line_num) {
1135 return false;
1136 }
1137 if line_num == span.line {
1138 let before = line.get(..span.byte_offset.saturating_sub(info.byte_offset));
1139 if !before.is_none_or(|before| Self::only_structure_precedes_math(before, info)) {
1140 return false;
1141 }
1142 }
1143 if line_num == span.end_line {
1144 let after = line.get(span.byte_end.saturating_sub(info.byte_offset)..);
1145 if !after.is_none_or(|after| after.trim().is_empty()) {
1146 return false;
1147 }
1148 }
1149 true
1150 })
1151 }
1152
1153 /// True when the text before a block's opening delimiter is only the structure
1154 /// the block sits in: indentation, a blockquote marker, or the list marker
1155 /// introducing it. Such a block still owns its whole line.
1156 fn only_structure_precedes_math(before: &str, info: &crate::lint_context::LineInfo) -> bool {
1157 let after_marker =
1158 crate::utils::blockquote::parse_blockquote_prefix(before).map_or(before, |prefix| prefix.content);
1159 if after_marker.trim().is_empty() {
1160 return true;
1161 }
1162 info.list_item.as_ref().is_some_and(|item| {
1163 let mut chars = before.chars();
1164 chars.by_ref().take(item.content_column).count() == item.content_column && chars.as_str().trim().is_empty()
1165 })
1166 }
1167
1168 /// True when `line_num` (1-indexed) falls inside a display-math block that
1169 /// spans more than one line.
1170 ///
1171 /// Line breaks carry meaning inside such a block: a TeX `%` comment runs to
1172 /// the end of its line, so joining the lines pulls whatever followed on later
1173 /// lines into the comment and drops it from the rendered equation, which can
1174 /// also leave an environment unclosed. Reflow therefore leaves these blocks
1175 /// alone regardless of the `math_blocks` setting, which governs only whether
1176 /// their length is reported.
1177 ///
1178 /// This is `line_is_display_math` minus the case of a whole line that is one
1179 /// complete `$$...$$` span, which
1180 /// [`crate::utils::text_reflow::is_self_contained_display_math_line`]
1181 /// recognizes. Such a line is a block of its own: it has no internal line
1182 /// breaks to lose, and reflow keeps it on the line it was written on
1183 /// through that recognizer rather than through this one.
1184 fn line_in_multiline_math_block(
1185 &self,
1186 line_num: usize,
1187 ctx: &crate::lint_context::LintContext,
1188 config: &MD013Config,
1189 ) -> bool {
1190 self.line_in_multiline_math_span(line_num, ctx)
1191 || ctx.line_info(line_num).is_some_and(|info| {
1192 info.in_math_block && !is_self_contained_display_math_line(info.content(ctx.content))
1193 })
1194 || (config.bracket_display_math
1195 && ctx
1196 .bracket_display_math_lines()
1197 .multiline
1198 .get(line_num.saturating_sub(1))
1199 .copied()
1200 .unwrap_or(false))
1201 }
1202
1203 fn line_is_standalone_bracket_math(
1204 &self,
1205 line_num: usize,
1206 ctx: &crate::lint_context::LintContext,
1207 config: &MD013Config,
1208 ) -> bool {
1209 config.bracket_display_math
1210 && ctx
1211 .bracket_display_math_lines()
1212 .standalone
1213 .get(line_num.saturating_sub(1))
1214 .copied()
1215 .unwrap_or(false)
1216 }
1217
1218 /// True when `line_num` (1-based) sits inside a structure whose lines must be
1219 /// preserved verbatim (code block, front matter, HTML/JSX/MDX block, MkDocs
1220 /// container, div marker, multi-line math block, ...). Used to keep blockquote
1221 /// reflow from touching quoted-looking text embedded in such structures.
1222 fn line_in_verbatim_context(
1223 &self,
1224 line_num: usize,
1225 ctx: &crate::lint_context::LintContext,
1226 config: &MD013Config,
1227 ) -> bool {
1228 if self.line_in_multiline_math_block(line_num, ctx, config) {
1229 return true;
1230 }
1231 ctx.line_info(line_num).is_some_and(|info| {
1232 info.in_code_block
1233 || info.in_front_matter
1234 || info.in_html_block
1235 || info.in_html_comment
1236 || info.in_esm_block
1237 || info.in_jsx_expression
1238 || info.in_jsx_block
1239 || info.in_mdx_comment
1240 || info.in_mkdocstrings
1241 || info.in_pymdown_block
1242 || info.in_mkdocs_container()
1243 || info.is_div_marker
1244 })
1245 }
1246
1247 fn is_blockquote_content_boundary(
1248 &self,
1249 content: &str,
1250 line_num: usize,
1251 ctx: &crate::lint_context::LintContext,
1252 config: &MD013Config,
1253 ) -> bool {
1254 // A code span still open at the end of the line above proves the
1255 // parser read on into this one, whatever it starts with.
1256 if line_num >= 2 && line_ends_in_code_span(ctx, line_num - 2) {
1257 return false;
1258 }
1259
1260 let trimmed = content.trim();
1261
1262 trimmed.is_empty()
1263 || self.line_in_verbatim_context(line_num, ctx, config)
1264 || trimmed.starts_with('#')
1265 || trimmed.starts_with("```")
1266 || trimmed.starts_with("~~~")
1267 || trimmed.starts_with('>')
1268 || TableUtils::is_potential_table_row_with_flavor(content, ctx.flavor)
1269 // A GFM table's rows run on without pipes until a blank line or
1270 // another block, so a quoted row can hold nothing a table needs.
1271 || ctx.lines.get(line_num - 1).is_some_and(|line| line.in_table_block)
1272 || is_list_item(trimmed)
1273 || opens_bare_item(ctx, line_num - 1)
1274 || is_horizontal_rule(content)
1275 // A setext underline ends the quoted paragraph. The text decides
1276 // it: under quoted paragraph text the run is an underline, a dash
1277 // run anywhere else is a thematic break, and stopping at an equals
1278 // run that is neither only leaves text unreflowed.
1279 || is_setext_underline_content(content)
1280 || (trimmed.starts_with('[') && content.contains("]:"))
1281 || is_template_directive_only(content)
1282 || is_standalone_attr_list(content)
1283 || is_snippet_block_delimiter(content)
1284 || is_github_alert_marker(trimmed)
1285 || is_html_only_line(content)
1286 || self.line_is_standalone_bracket_math(line_num, ctx, config)
1287 || standalone_link_ends_paragraph(ctx, line_num, config)
1288 }
1289
1290 fn generate_blockquote_paragraph_fix(
1291 &self,
1292 ctx: &crate::lint_context::LintContext,
1293 config: &MD013Config,
1294 lines: &[&str],
1295 start_idx: usize,
1296 line_ending: &str,
1297 ) -> (Option<LintWarning>, usize) {
1298 let Some(start_bq) = ctx.lines.get(start_idx).and_then(|line| line.blockquote.as_deref()) else {
1299 return (None, start_idx + 1);
1300 };
1301 let target_level = start_bq.nesting_level;
1302 let defined_references = Self::defined_reference_labels(ctx);
1303
1304 let mut collected: Vec<CollectedBlockquoteLine> = Vec::new();
1305 let mut i = start_idx;
1306
1307 while i < lines.len() {
1308 if let Some(last) = collected.last()
1309 && has_hard_break(&last.data.content)
1310 && !line_ends_in_code_span(ctx, last.line_idx)
1311 {
1312 break;
1313 }
1314
1315 let line_num = i + 1;
1316 if line_num > ctx.lines.len() {
1317 break;
1318 }
1319
1320 if lines[i].trim().is_empty() {
1321 break;
1322 }
1323
1324 let line_bq = ctx.lines[i].blockquote.as_deref();
1325 if let Some(bq) = line_bq {
1326 if bq.nesting_level != target_level {
1327 break;
1328 }
1329
1330 if self.is_blockquote_content_boundary(&bq.content, line_num, ctx, config) {
1331 break;
1332 }
1333
1334 collected.push(CollectedBlockquoteLine {
1335 line_idx: i,
1336 data: BlockquoteLineData::explicit(blockquote_continuation_line(ctx, i, bq, 0), bq.prefix.clone()),
1337 });
1338 i += 1;
1339 continue;
1340 }
1341
1342 let lazy_content = lines[i].trim_start();
1343 if self.is_blockquote_content_boundary(lazy_content, line_num, ctx, config) {
1344 break;
1345 }
1346
1347 // A lazy line has no container indentation of its own, so a code
1348 // span crossing into it keeps all of its leading whitespace.
1349 let strip = continuation_indent_to_strip(ctx, i, lines[i].len() - lazy_content.len(), 0);
1350 collected.push(CollectedBlockquoteLine {
1351 line_idx: i,
1352 data: BlockquoteLineData::lazy(restore_code_span_line_end(
1353 ctx,
1354 i,
1355 trim_preserving_hard_break(&lines[i][strip..]),
1356 )),
1357 });
1358 i += 1;
1359 }
1360
1361 if collected.is_empty() {
1362 return (None, start_idx + 1);
1363 }
1364
1365 let next_idx = i;
1366 let paragraph_start = collected[0].line_idx;
1367 let end_line = collected[collected.len() - 1].line_idx;
1368 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|l| l.data.clone()).collect();
1369 let pieces: Vec<&str> = line_data.iter().map(|d| d.content.as_str()).collect();
1370 if code_span_runs_into_indentation(&pieces) {
1371 return (None, next_idx);
1372 }
1373 let paragraph_text = Self::join_paragraph_lines(&pieces, config);
1374
1375 // A colon-led line with a line of the paragraph before it opens a
1376 // definition, and joining the lines would flatten the definition list
1377 // into prose. The paragraph's first line is prose whatever it starts
1378 // with, since a definition needs a term on the line before it.
1379 let contains_definition_list = line_data
1380 .iter()
1381 .skip(1)
1382 .any(|d| crate::utils::text_reflow::is_definition_list_marker(&d.content));
1383 if contains_definition_list || holds_definition_list(ctx, paragraph_start, end_line) {
1384 return (None, next_idx);
1385 }
1386
1387 let contains_snippets = line_data.iter().any(|d| is_snippet_block_delimiter(&d.content));
1388 if contains_snippets {
1389 return (None, next_idx);
1390 }
1391
1392 let needs_reflow = match config.reflow_mode {
1393 ReflowMode::SentencePack => true,
1394 ReflowMode::Normalize => {
1395 self.normalize_mode_needs_reflow(line_data.iter().map(|d| d.content.as_str()), config)
1396 }
1397 ReflowMode::SentencePerLine => {
1398 let sentences = split_into_sentences(
1399 ¶graph_text,
1400 Some(&defined_references),
1401 config.require_sentence_capital,
1402 config.cjk_soft_break,
1403 );
1404 sentences.len() > 1 || line_data.len() > 1
1405 }
1406 ReflowMode::SemanticLineBreaks => {
1407 let sentences = split_into_sentences(
1408 ¶graph_text,
1409 Some(&defined_references),
1410 config.require_sentence_capital,
1411 config.cjk_soft_break,
1412 );
1413 sentences.len() > 1
1414 || line_data.len() > 1
1415 || collected
1416 .iter()
1417 .any(|l| self.calculate_effective_length(lines[l.line_idx]) > config.line_length.get())
1418 }
1419 ReflowMode::Default => collected
1420 .iter()
1421 .any(|l| self.calculate_effective_length(lines[l.line_idx]) > config.line_length.get()),
1422 };
1423
1424 if !needs_reflow {
1425 return (None, next_idx);
1426 }
1427
1428 let fallback_prefix = start_bq.prefix.clone();
1429 // Text under a marker with nothing after it is inside the item or outside
1430 // it according to how far its first line is indented. Rewrapping under
1431 // the prefix most lines happen to share could move the first line across
1432 // that boundary, so the first line's prefix is kept.
1433 let explicit_prefix = if paragraph_start > 0 && opens_bare_item(ctx, paragraph_start - 1) {
1434 line_data[0].prefix.clone().unwrap_or(fallback_prefix)
1435 } else {
1436 dominant_blockquote_prefix(&line_data, &fallback_prefix)
1437 };
1438 let continuation_style = blockquote_continuation_style(&line_data);
1439
1440 let reflow_line_length = if config.line_length.is_unlimited() {
1441 usize::MAX
1442 } else {
1443 config
1444 .line_length
1445 .get()
1446 .saturating_sub(self.calculate_string_length(&explicit_prefix))
1447 .max(1)
1448 };
1449
1450 let mut reflow_options = Self::reflow_options(ctx, config, reflow_line_length);
1451 if config.reflow_mode == ReflowMode::SentencePack
1452 && continuation_style == crate::utils::text_reflow::BlockquoteContinuationStyle::Lazy
1453 && !config.line_length.is_unlimited()
1454 {
1455 reflow_options.line_length = config.line_length.get();
1456 reflow_options.first_line_length = Some(reflow_line_length);
1457 }
1458
1459 let reflowed_with_style =
1460 reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &reflow_options);
1461
1462 if reflowed_with_style.is_empty() {
1463 return (None, next_idx);
1464 }
1465
1466 let reflowed_text = reflowed_with_style.join(line_ending);
1467
1468 let start_range = ctx.whole_line_byte_range(paragraph_start + 1);
1469 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1470 ctx.line_text_byte_range(end_line + 1, 1, lines[end_line].len() + 1)
1471 } else {
1472 ctx.whole_line_byte_range(end_line + 1)
1473 };
1474 let byte_range = start_range.start..end_range.end;
1475
1476 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1477 format!("{reflowed_text}{line_ending}")
1478 } else {
1479 reflowed_text
1480 };
1481
1482 let original_text = &ctx.content[byte_range.clone()];
1483 if original_text == replacement {
1484 return (None, next_idx);
1485 }
1486
1487 let (warning_line, warning_end_line) = match config.reflow_mode {
1488 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
1489 ReflowMode::SentencePerLine | ReflowMode::SemanticLineBreaks | ReflowMode::SentencePack => {
1490 (paragraph_start + 1, end_line + 1)
1491 }
1492 ReflowMode::Default => {
1493 let violating_line = collected
1494 .iter()
1495 .find(|line| self.calculate_effective_length(lines[line.line_idx]) > config.line_length.get())
1496 .map_or(paragraph_start + 1, |line| line.line_idx + 1);
1497 (violating_line, violating_line)
1498 }
1499 };
1500
1501 let warning = LintWarning {
1502 rule_name: Some(self.name().to_string()),
1503 message: match config.reflow_mode {
1504 ReflowMode::SentencePack => "Paragraph should pack complete sentences".to_string(),
1505 ReflowMode::Normalize => format!(
1506 "Paragraph could be normalized to use line length of {} characters",
1507 config.line_length.get()
1508 ),
1509 ReflowMode::SentencePerLine => {
1510 let num_sentences = split_into_sentences(
1511 ¶graph_text,
1512 Some(&defined_references),
1513 config.require_sentence_capital,
1514 config.cjk_soft_break,
1515 )
1516 .len();
1517 if line_data.len() == 1 {
1518 format!("Line contains {num_sentences} sentences (one sentence per line required)")
1519 } else {
1520 let num_lines = line_data.len();
1521 format!(
1522 "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
1523 )
1524 }
1525 }
1526 ReflowMode::SemanticLineBreaks => {
1527 let num_sentences = split_into_sentences(
1528 ¶graph_text,
1529 Some(&defined_references),
1530 config.require_sentence_capital,
1531 config.cjk_soft_break,
1532 )
1533 .len();
1534 format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
1535 }
1536 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
1537 },
1538 line: warning_line,
1539 column: 1,
1540 end_line: warning_end_line,
1541 end_column: lines[warning_end_line.saturating_sub(1)].chars().count() + 1,
1542 severity: Severity::Warning,
1543 fix: Some(crate::rule::Fix::new(byte_range, replacement)),
1544 };
1545
1546 (Some(warning), next_idx)
1547 }
1548
1549 /// Reflow a single list item that lives inside a blockquote.
1550 ///
1551 /// The blockquote paragraph reflow treats a list marker as a content boundary,
1552 /// so `> - long item ...` is never wrapped even though the identical top-level
1553 /// item is. This handles that case: it collects one tight, prose-only list item
1554 /// at the starting blockquote level, reflows the item body to the configured
1555 /// width, and re-emits it with the blockquote prefix preserved and continuation
1556 /// lines aligned under the list content.
1557 ///
1558 /// Sibling and nested list items end collection and are reflowed independently
1559 /// by the caller's outer loop (a nested item carries its indent folded into the
1560 /// blockquote prefix, so it reflows correctly on its own). Items that are not
1561 /// simple tight prose - those embedding a code block, table, fence, or hard
1562 /// break - are left untouched (`None`) and the cursor still advances past the
1563 /// whole item so its inner lines are never reprocessed as loose prose.
1564 fn generate_blockquote_list_item_fix(
1565 &self,
1566 ctx: &crate::lint_context::LintContext,
1567 config: &MD013Config,
1568 lines: &[&str],
1569 start_idx: usize,
1570 line_ending: &str,
1571 // One entry per document line, true where a code span crosses one of the
1572 // line's boundaries. The caller reads them off one pass over the document
1573 // and shares them across every item, so an item costs no pass of its own.
1574 code_span_touches: &[bool],
1575 ) -> (Option<LintWarning>, usize) {
1576 use crate::utils::blockquote::effective_indent_in_blockquote;
1577
1578 let Some(start_bq) = ctx.lines.get(start_idx).and_then(|line| line.blockquote.as_deref()) else {
1579 return (None, start_idx + 1);
1580 };
1581
1582 let defined_references = Self::defined_reference_labels(ctx);
1583
1584 // A `>`-prefixed line can be marked as a blockquote even inside a fenced code
1585 // block (or other verbatim structure); such content must never be reflowed.
1586 if self.line_in_verbatim_context(start_idx + 1, ctx, config) {
1587 return (None, start_idx + 1);
1588 }
1589
1590 let target_level = start_bq.nesting_level;
1591
1592 // The marker line carries the canonical blockquote prefix: its content begins
1593 // with the list marker, so no list indent has been folded into the prefix.
1594 let bq_prefix = start_bq.prefix.clone();
1595
1596 // A thematic break opens with what looks like a bullet marker (`- - -`).
1597 // It is not a list item, and reflowing it as prose destroys the break.
1598 // The top-level reflow path applies the same exemption.
1599 if is_horizontal_rule(&start_bq.content) {
1600 return (None, start_idx + 1);
1601 }
1602
1603 let (marker, first_body) = extract_list_marker_and_content(&start_bq.content);
1604 if marker.is_empty() {
1605 return (None, start_idx + 1);
1606 }
1607 let marker_width = marker.chars().count();
1608
1609 // Continuation lines of a checkbox item align under the bullet+checkbox, but
1610 // are recognized from the bullet width, matching the top-level list reflow.
1611 let base_marker_width = ["[ ] ", "[x] ", "[X] "]
1612 .iter()
1613 .find_map(|cb| marker.find(*cb))
1614 .unwrap_or(marker_width);
1615
1616 // Collect the item: the marker line plus its tight prose continuation lines.
1617 // `end_idx` always tracks the last consumed line so the cursor advances past
1618 // the entire item, even when it turns out to be too complex to reflow safely.
1619 // The item's content column within the quote's content, which a code
1620 // span crossing into a continuation line leaves in place.
1621 let item_content_col = ctx.lines[start_idx].list_item.as_ref().map_or(0, |item| {
1622 item.content_column
1623 .saturating_sub(blockquote_content_start(&start_bq.prefix))
1624 });
1625 let ends_with_hard_break = |piece: &str, idx: usize| has_hard_break(piece) && !line_ends_in_code_span(ctx, idx);
1626 let first_piece = restore_code_span_line_end(ctx, start_idx, trim_preserving_hard_break(&first_body));
1627 let mut simple = !ends_with_hard_break(&first_piece, start_idx);
1628 let mut body_pieces: Vec<String> = vec![first_piece];
1629 let mut end_idx = start_idx;
1630 let mut i = start_idx + 1;
1631
1632 while i < lines.len() {
1633 let Some(bq) = ctx.lines[i].blockquote.as_deref() else {
1634 // A blank line ends the item.
1635 if lines[i].trim().is_empty() {
1636 break;
1637 }
1638 // A lazy continuation (no `>` marker) is too ambiguous to reflow
1639 // safely. Consume the whole lazy run into this item's span and leave
1640 // the item untouched, so the caller does not reflow the continuation
1641 // in isolation and leave the marker line partially fixed.
1642 simple = false;
1643 while i < lines.len() && ctx.lines[i].blockquote.is_none() && !lines[i].trim().is_empty() {
1644 end_idx = i;
1645 i += 1;
1646 }
1647 break;
1648 };
1649 if bq.nesting_level != target_level {
1650 break;
1651 }
1652
1653 let content = bq.content.as_str();
1654 if content.trim().is_empty() {
1655 // Blank quoted line ends the tight paragraph. A following indented
1656 // paragraph (loose item) is reflowed on its own by the prose path.
1657 break;
1658 }
1659
1660 // A code span still open at the end of the line above proves the
1661 // parser read on into this line, so it is text of this item however
1662 // it is spelled and however far it is indented.
1663 let continues_code_span = line_ends_in_code_span(ctx, i - 1);
1664 let eff_indent = effective_indent_in_blockquote(lines[i], target_level, 0);
1665 if eff_indent < base_marker_width && !continues_code_span {
1666 // Dedented: a sibling list item or text outside this item. Stop here
1667 // and let the outer loop classify it.
1668 break;
1669 }
1670 if is_list_item(content) && !continues_code_span {
1671 // A nested list item: its own item, handled independently. A
1672 // line a code span runs on into is text of this item instead.
1673 break;
1674 }
1675
1676 // An embedded structure (code block, table, fence, nested quote, ...)
1677 // means the item is not simple prose: keep consuming so the cursor clears
1678 // the whole structure, but do not produce a fix.
1679 if self.is_blockquote_content_boundary(content, i + 1, ctx, config) {
1680 simple = false;
1681 }
1682
1683 // A line short of the item's content column continues its paragraph
1684 // lazily, and the item takes none of its indentation.
1685 let content_col_reached = if eff_indent >= item_content_col {
1686 item_content_col
1687 } else {
1688 0
1689 };
1690 let piece = blockquote_continuation_line(ctx, i, bq, content_col_reached);
1691 if ends_with_hard_break(&piece, i) {
1692 simple = false;
1693 }
1694 body_pieces.push(piece);
1695 end_idx = i;
1696 i += 1;
1697 }
1698
1699 let next_idx = end_idx + 1;
1700
1701 if !simple || holds_definition_list(ctx, start_idx, end_idx) || code_span_runs_into_indentation(&body_pieces) {
1702 return (None, next_idx);
1703 }
1704
1705 let exceeds_limit =
1706 || (start_idx..=end_idx).any(|idx| self.calculate_effective_length(lines[idx]) > config.line_length.get());
1707 let body_text = Self::join_paragraph_lines(&body_pieces, config);
1708 let body_text = trim_breakable_whitespace(&body_text);
1709
1710 // A body line that is one whole `$$...$$` expression renders as a display
1711 // block, so it holds a line of its own and the prose on either side of it
1712 // is reflowed separately. So does a link reference definition opening the
1713 // body, which ends at its line: text joined onto its destination would turn
1714 // it into visible text. Each segment carries whether it is such a line,
1715 // kept as written. A line touched by a code span crossing one of its
1716 // boundaries is code, not a display block. The pieces are the consecutive
1717 // lines from `start_idx`, one piece each.
1718 let body_segments: Vec<(bool, Vec<&str>)> = {
1719 let mut segments: Vec<(bool, Vec<&str>)> = Vec::new();
1720 let mut current: Vec<&str> = Vec::new();
1721 let mut in_leading_definitions = true;
1722 for (offset, piece) in body_pieces.iter().enumerate() {
1723 in_leading_definitions &= may_be_link_ref_def(piece);
1724 let is_display_math = (is_self_contained_display_math_line(piece)
1725 || self.line_is_standalone_bracket_math(start_idx + offset + 1, ctx, config))
1726 && !line_touches_multiline_code_span(code_span_touches, start_idx + offset + 1);
1727 if in_leading_definitions || is_display_math {
1728 if !current.is_empty() {
1729 segments.push((false, std::mem::take(&mut current)));
1730 }
1731 segments.push((true, vec![piece.trim_start()]));
1732 } else {
1733 current.push(piece.as_str());
1734 }
1735 }
1736 if !current.is_empty() {
1737 segments.push((false, current));
1738 }
1739 segments
1740 };
1741 let holds_own_line = body_segments.iter().any(|(own_line, _)| *own_line);
1742
1743 // Some bodies cannot be shortened and must stay verbatim, matching the
1744 // exemptions the top-level list reflow applies: link reference definitions
1745 // always, and (in non-strict mode) standalone links/images and HTML-only
1746 // lines. Reflowing a link reference definition would split it after the
1747 // colon/URL and break the definition.
1748 let is_link_ref_def =
1749 body_text.starts_with('[') && body_text.contains("]:") && LINK_REF_PATTERN.is_match(body_text);
1750 let raw_marker_line = lines[start_idx];
1751 let body_is_unwrappable = is_link_ref_def
1752 || standalone_link_ends_paragraph(ctx, start_idx + 1, config)
1753 || (!config.strict && is_html_only_line(raw_marker_line));
1754 if body_is_unwrappable {
1755 return (None, next_idx);
1756 }
1757
1758 let needs_reflow = match config.reflow_mode {
1759 ReflowMode::SentencePack => true,
1760 ReflowMode::Normalize => body_pieces.len() > 1 || exceeds_limit(),
1761 ReflowMode::Default => exceeds_limit(),
1762 ReflowMode::SentencePerLine => {
1763 split_into_sentences(
1764 body_text,
1765 Some(&defined_references),
1766 config.require_sentence_capital,
1767 config.cjk_soft_break,
1768 )
1769 .len()
1770 > 1
1771 || body_pieces.len() > 1
1772 }
1773 ReflowMode::SemanticLineBreaks => {
1774 split_into_sentences(
1775 body_text,
1776 Some(&defined_references),
1777 config.require_sentence_capital,
1778 config.cjk_soft_break,
1779 )
1780 .len()
1781 > 1
1782 || exceeds_limit()
1783 }
1784 };
1785 if !needs_reflow {
1786 return (None, next_idx);
1787 }
1788
1789 // Apply MD030 list-marker spacing in the spacing-normalizing modes, mirroring
1790 // the top-level list reflow: derive the post-marker spacing from MD030 and let
1791 // the continuation indent follow the resulting content width. Default MD030 (a
1792 // single space) leaves the marker unchanged. MkDocs keeps its rigid indent.
1793 let (marker, marker_width) = if matches!(config.reflow_mode, ReflowMode::Default | ReflowMode::Normalize)
1794 && !ctx.flavor.requires_strict_list_indent()
1795 {
1796 let is_ordered = marker.starts_with(|c: char| c.is_ascii_digit());
1797 // Bullet/number portion only (e.g. `-`, `1.`); the checkbox is content.
1798 let bullet = marker.split(' ').next().unwrap_or("");
1799 let bullet_len = bullet.chars().count();
1800 let checkbox_tail = &marker[base_marker_width..];
1801 // Decide single- vs multi-line spacing from the rewritten shape. This path
1802 // only handles a single tight prose paragraph (structural or multi-paragraph
1803 // items set `simple = false` and bail out above), so the emitted item stays
1804 // multi-line solely when the joined body wraps past one line. A multi-line
1805 // *source* that collapses onto the marker line must use MD030's single-line
1806 // spacing, matching the top-level reflow. The wrap test measures at the
1807 // single-line content column so it does not depend on the spacing chosen here.
1808 let single_col = self.calculate_string_length(&bq_prefix)
1809 + bullet_len
1810 + self.list_spacing.expected_spaces(is_ordered, false, bullet_len)
1811 + checkbox_tail.chars().count();
1812 // A display-math line or a definition always holds a line of its own,
1813 // so an item that carries one spans several lines whatever the joined
1814 // body measures.
1815 let is_multi = holds_own_line
1816 || (!body_text.is_empty()
1817 && self.calculate_effective_length(&format!("{}{body_text}", " ".repeat(single_col)))
1818 > config.line_length.effective_limit());
1819 let spaces = self.list_spacing.expected_spaces(is_ordered, is_multi, bullet_len);
1820 let new_marker = format!("{bullet}{}{checkbox_tail}", " ".repeat(spaces));
1821 let width = new_marker.chars().count();
1822 (new_marker, width)
1823 } else {
1824 (marker, marker_width)
1825 };
1826
1827 // The fix rewrites only this paragraph, so every later line of the quote
1828 // keeps its indentation. A marker that moves the item's content column past
1829 // one of them changes the block that line belongs to: `> 7. b` beside
1830 // `> - item` opens a list after the item, and beside `> - item` a list
1831 // nested in it. Such an item keeps its marker as written.
1832 let (marker, marker_width) = match source_list_marker(&start_bq.content) {
1833 Some(source) => {
1834 let source_col = if item_content_col > 0 {
1835 item_content_col
1836 } else {
1837 source.content_col
1838 };
1839 let (low, high) = (source_col.min(marker_width), source_col.max(marker_width));
1840 let moves_a_later_line = ctx.lines[next_idx..]
1841 .iter()
1842 .zip(&lines[next_idx..])
1843 .map_while(|(info, line)| {
1844 info.blockquote
1845 .as_deref()
1846 .filter(|bq| bq.nesting_level >= target_level)
1847 .map(|bq| (bq, line))
1848 })
1849 .any(|(bq, line)| {
1850 let indent = effective_indent_in_blockquote(line, target_level, 0);
1851 bq.nesting_level == target_level
1852 && !bq.content.trim().is_empty()
1853 && (low..high).contains(&indent)
1854 });
1855 if moves_a_later_line {
1856 (source.text, source.content_col)
1857 } else {
1858 (marker, marker_width)
1859 }
1860 }
1861 None => (marker, marker_width),
1862 };
1863
1864 let prefix_width = self.calculate_string_length(&bq_prefix) + self.calculate_string_length(&marker);
1865 let reflow_line_length = if config.line_length.is_unlimited() {
1866 usize::MAX
1867 } else {
1868 config.line_length.get().saturating_sub(prefix_width).max(1)
1869 };
1870
1871 let reflow_options = Self::reflow_options(ctx, config, reflow_line_length);
1872
1873 // A segment holding a line of its own is emitted as written; a prose
1874 // segment is joined and reflowed on its own, so the prose above and below
1875 // it wraps within its own paragraph.
1876 let mut reflowed: Vec<String> = Vec::new();
1877 let mut rewritten_sentence_count = 0;
1878 for (own_line, segment) in &body_segments {
1879 if *own_line {
1880 reflowed.push(segment[0].to_string());
1881 continue;
1882 }
1883 let segment_text = Self::join_paragraph_lines(segment, config);
1884 let segment_text = trim_breakable_whitespace(&segment_text);
1885 if segment_text.is_empty() {
1886 continue;
1887 }
1888 let segment_reflowed = crate::utils::text_reflow::reflow_line(segment_text, &reflow_options);
1889 if matches!(
1890 config.reflow_mode,
1891 ReflowMode::SentencePerLine | ReflowMode::SemanticLineBreaks
1892 ) && segment_reflowed.iter().map(String::as_str).ne(segment.iter().copied())
1893 {
1894 rewritten_sentence_count += split_into_sentences(
1895 segment_text,
1896 Some(&defined_references),
1897 config.require_sentence_capital,
1898 config.cjk_soft_break,
1899 )
1900 .len();
1901 }
1902 reflowed.extend(segment_reflowed);
1903 }
1904 if reflowed.is_empty() {
1905 return (None, next_idx);
1906 }
1907
1908 let continuation_indent = " ".repeat(marker_width);
1909 let reflowed_text = reflowed
1910 .iter()
1911 .enumerate()
1912 .map(|(idx, line)| {
1913 if idx == 0 {
1914 format!("{bq_prefix}{marker}{line}")
1915 } else {
1916 format!("{bq_prefix}{continuation_indent}{line}")
1917 }
1918 })
1919 .collect::<Vec<_>>()
1920 .join(line_ending);
1921
1922 let start_range = ctx.whole_line_byte_range(start_idx + 1);
1923 let end_range = if end_idx == lines.len() - 1 && !ctx.content.ends_with('\n') {
1924 ctx.line_text_byte_range(end_idx + 1, 1, lines[end_idx].len() + 1)
1925 } else {
1926 ctx.whole_line_byte_range(end_idx + 1)
1927 };
1928 let byte_range = start_range.start..end_range.end;
1929
1930 let replacement = if end_idx < lines.len() - 1 || ctx.content.ends_with('\n') {
1931 format!("{reflowed_text}{line_ending}")
1932 } else {
1933 reflowed_text
1934 };
1935
1936 let original_text = &ctx.content[byte_range.clone()];
1937 if original_text == replacement {
1938 return (None, next_idx);
1939 }
1940
1941 let message = match config.reflow_mode {
1942 ReflowMode::SentencePack => "List item should pack complete sentences".to_string(),
1943 ReflowMode::Normalize => format!(
1944 "Paragraph could be normalized to use line length of {} characters",
1945 config.line_length.get()
1946 ),
1947 ReflowMode::SentencePerLine => {
1948 let num_sentences = rewritten_sentence_count;
1949 format!("List item should have one sentence per line (found {num_sentences} sentences)")
1950 }
1951 ReflowMode::SemanticLineBreaks => {
1952 let num_sentences = rewritten_sentence_count;
1953 format!("List item should use semantic line breaks ({num_sentences} sentences)")
1954 }
1955 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
1956 };
1957
1958 let warning = LintWarning {
1959 rule_name: Some(self.name().to_string()),
1960 message,
1961 line: start_idx + 1,
1962 column: 1,
1963 end_line: end_idx + 1,
1964 end_column: lines[end_idx].chars().count() + 1,
1965 severity: Severity::Warning,
1966 fix: Some(crate::rule::Fix::new(byte_range, replacement)),
1967 };
1968
1969 (Some(warning), next_idx)
1970 }
1971
1972 /// Generate paragraph-based fixes
1973 fn generate_paragraph_fixes(
1974 &self,
1975 ctx: &crate::lint_context::LintContext,
1976 config: &MD013Config,
1977 lines: &[&str],
1978 ) -> Vec<LintWarning> {
1979 let mut warnings = Vec::new();
1980 let defined_references = Self::defined_reference_labels(ctx);
1981 // A line touched by a code span crossing one of its boundaries is code
1982 // however it is spelled, so a `$$...$$` expression on one is no display
1983 // block.
1984 let code_span_touches = crate::utils::text_reflow::lines_touching_multiline_code_span(ctx.content);
1985
1986 // The line-based list index can end an item early at table-like lazy
1987 // continuation text. CommonMark still owns later indented paragraphs
1988 // in that item, so retain their indentation using the parsed ranges.
1989 let mut parsed_items = Vec::new();
1990 if ctx.flavor == crate::config::MarkdownFlavor::Standard {
1991 let mut depth = 0usize;
1992 for (event, range) in pulldown_cmark::Parser::new_ext(
1993 ctx.content,
1994 crate::utils::parser_options::rumdl_parser_options_for(ctx.content),
1995 )
1996 .into_offset_iter()
1997 {
1998 match event {
1999 pulldown_cmark::Event::Start(pulldown_cmark::Tag::Item) => {
2000 if depth == 0 {
2001 parsed_items.push(range);
2002 }
2003 depth += 1;
2004 }
2005 pulldown_cmark::Event::End(pulldown_cmark::TagEnd::Item) => depth -= 1,
2006 _ => {}
2007 }
2008 }
2009 }
2010 let in_list = |line_idx: usize| {
2011 let offset = ctx.lines[line_idx].byte_offset;
2012 let idx = parsed_items.partition_point(|range| range.start <= offset);
2013 ctx.is_in_list_block(line_idx + 1) || (idx > 0 && parsed_items[idx - 1].contains(&offset))
2014 };
2015
2016 // Detect the content's line ending style to preserve it in replacements.
2017 // The LSP receives content from editors which may use CRLF (Windows).
2018 // Replacements must match the original line endings to avoid false positives.
2019 let line_ending = crate::utils::line_ending::detect_line_ending(ctx.content);
2020
2021 // A setext heading inside a blockquote is a heading, not a paragraph:
2022 // its text lines, as 1-indexed spans, are left as written for the same
2023 // reason as an unquoted one. The underline ends a quoted paragraph on its
2024 // own, so no span covers it.
2025 let quoted_setext_spans: Vec<std::ops::RangeInclusive<usize>> = ctx
2026 .headings()
2027 .filter(|heading| heading.is_blockquote() && heading.is_setext())
2028 .map(|heading| heading.first_line_num()..=heading.line_num)
2029 .collect();
2030
2031 let mut i = 0;
2032 while i < lines.len() {
2033 let line_num = i + 1;
2034
2035 if let Some(span) = span_containing("ed_setext_spans, line_num) {
2036 i = *span.end();
2037 continue;
2038 }
2039
2040 // No paragraph starts on a line a code span runs on into. A
2041 // collector that leaves the paragraph above as written stops short
2042 // of the rest of it, and reflowing that rest on its own would
2043 // rewrite the whitespace the span holds.
2044 if i > 0 && line_ends_in_code_span(ctx, i - 1) {
2045 i += 1;
2046 continue;
2047 }
2048
2049 // Handle blockquote paragraphs with style-preserving reflow.
2050 // Skip blockquotes when blockquotes=false or paragraphs=false
2051 if line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some() {
2052 if !config.blockquotes || !config.paragraphs {
2053 // Skip past all blockquote lines (explicit and lazy continuations).
2054 // A lazy continuation is a non-blank line without `>` that follows
2055 // a blockquote line and isn't a structural element.
2056 let mut saw_explicit_bq = false;
2057 while i < lines.len() && i < ctx.lines.len() {
2058 if ctx.lines[i].blockquote.is_some() {
2059 saw_explicit_bq = true;
2060 i += 1;
2061 } else if saw_explicit_bq
2062 && !lines[i].trim().is_empty()
2063 && !lines[i].trim_start().starts_with('#')
2064 && !lines[i].trim_start().starts_with('>')
2065 {
2066 // Lazy continuation of preceding blockquote
2067 i += 1;
2068 } else {
2069 break;
2070 }
2071 }
2072 continue;
2073 }
2074 // A list item inside the blockquote needs list-aware reflow (marker +
2075 // continuation indent); plain prose goes through the paragraph path.
2076 // A marker with nothing after it has no text to reflow; the line
2077 // below is reflowed on its own, so it stays where it was written.
2078 if opens_bare_item(ctx, i) {
2079 i += 1;
2080 continue;
2081 }
2082 let is_bq_list_item = ctx.lines[i]
2083 .blockquote
2084 .as_deref()
2085 .is_some_and(|bq| is_list_item(&bq.content));
2086 let (warning, next_idx) = if is_bq_list_item {
2087 self.generate_blockquote_list_item_fix(ctx, config, lines, i, line_ending, &code_span_touches)
2088 } else {
2089 self.generate_blockquote_paragraph_fix(ctx, config, lines, i, line_ending)
2090 };
2091 if let Some(warning) = warning {
2092 warnings.push(warning);
2093 }
2094 i = next_idx;
2095 continue;
2096 }
2097
2098 // Skip special structures (but NOT MkDocs containers - those get special handling)
2099 let should_skip_due_to_line_info = ctx.line_info(line_num).is_some_and(|info| {
2100 info.in_code_block
2101 || info.in_front_matter
2102 || info.in_html_block
2103 || info.in_html_comment
2104 || info.in_esm_block
2105 || info.in_jsx_expression
2106 || info.in_jsx_block
2107 || info.in_mdx_comment
2108 || info.in_mkdocstrings
2109 || info.in_pymdown_block
2110 || info.in_table_block
2111 });
2112
2113 // Skip link reference definitions but NOT footnote definitions.
2114 // Footnote definitions (`[^id]: prose`) contain reflowable text,
2115 // while link reference definitions (`[ref]: URL`) contain URLs
2116 // that cannot be shortened.
2117 let is_link_ref_def = may_be_link_ref_def(lines[i]);
2118
2119 // A setext heading is a heading, not a paragraph: skip every line of
2120 // its text and its underline together, the way an ATX heading is
2121 // skipped just below. Reflowing any part rewrites the document's
2122 // structure - joining the underline onto the text demotes the
2123 // heading to prose, and rewrapping the text moves words across the
2124 // heading boundary. The text spans the whole paragraph the underline
2125 // ends, so walk the flag to the end of it; the line after the last
2126 // text line is the underline.
2127 if is_setext_heading_text_line(ctx, line_num) {
2128 while i < lines.len() && is_setext_heading_text_line(ctx, i + 1) {
2129 i += 1;
2130 }
2131 i += 1;
2132 continue;
2133 }
2134
2135 // A line of an HTML block is HTML wherever the block opens, a list
2136 // item's marker line included, and that item holds no paragraph
2137 // to reflow until the block ends.
2138 if should_skip_due_to_line_info
2139 || ctx.line_holds_html_block(i)
2140 || lines[i].trim().starts_with('#')
2141 || TableUtils::is_potential_table_row_with_flavor(lines[i], ctx.flavor)
2142 || lines[i].trim().is_empty()
2143 || is_horizontal_rule(lines[i])
2144 || is_template_directive_only(lines[i])
2145 || is_link_ref_def
2146 || ctx.line_info(line_num).is_some_and(|info| info.is_div_marker)
2147 || is_html_only_line(lines[i])
2148 || standalone_link_ends_paragraph(ctx, line_num, config)
2149 {
2150 i += 1;
2151 continue;
2152 }
2153
2154 // Handle footnote definitions: `[^id]: prose text that can be reflowed`
2155 // Supports multi-paragraph footnotes with code blocks, blockquotes,
2156 // tables, and lists preserved verbatim.
2157 // Validate structure: must start with `[^`, contain `]:`, and the ID
2158 // must not contain `[` or `]` (prevents false matches on nested brackets)
2159 if lines[i].trim().starts_with("[^") && lines[i].contains("]:") && {
2160 let after_caret = &lines[i].trim()[2..];
2161 after_caret
2162 .find("]:")
2163 .is_some_and(|pos| pos > 0 && !after_caret[..pos].contains(['[', ']']))
2164 } {
2165 let footnote_start = i;
2166 let line = lines[i];
2167
2168 // Extract the prefix `[^id]:`
2169 let Some(colon_pos) = line.find("]:") else {
2170 i += 1;
2171 continue;
2172 };
2173 let prefix_end = colon_pos + 2;
2174 let prefix = &line[..prefix_end];
2175
2176 // Content starts after `]: ` (with optional space)
2177 let content_start = if line[prefix_end..].starts_with(' ') {
2178 prefix_end + 1
2179 } else {
2180 prefix_end
2181 };
2182 let first_content = &line[content_start..];
2183
2184 // CommonMark footnotes use 4-space continuation indent
2185 const FN_INDENT: usize = 4;
2186
2187 // --- Line classification for footnote content ---
2188 #[derive(Debug, Clone)]
2189 enum FnLineType {
2190 Content(String, usize), // text, 0-based line index
2191 Verbatim(String, usize), // preserved text, original indent
2192 Empty,
2193 }
2194
2195 // Helper: compute visual indent (tabs = 4 spaces)
2196 let visual_indent = |s: &str| -> usize {
2197 s.chars()
2198 .take_while(|c| c.is_whitespace())
2199 .map(|c| if c == '\t' { 4 } else { 1 })
2200 .sum::<usize>()
2201 };
2202
2203 // Helper: check if a trimmed line is a fence marker (homogeneous chars)
2204 let is_fence = |s: &str| -> bool {
2205 let t = s.trim();
2206 let fence_char = t.chars().next();
2207 matches!(fence_char, Some('`') | Some('~'))
2208 && t.chars().take_while(|&c| c == fence_char.unwrap()).count() >= 3
2209 };
2210
2211 // Helper: check if a trimmed line is a setext underline
2212 let is_setext_underline = |s: &str| -> bool {
2213 let t = s.trim();
2214 !t.is_empty()
2215 && (t.chars().all(|c| c == '=' || c == ' ') || t.chars().all(|c| c == '-' || c == ' '))
2216 && t.contains(['=', '-'])
2217 };
2218
2219 // Deferred body: `[^id]:\n content` — first line has no content,
2220 // actual content starts on the next indented line
2221 let deferred_body = first_content.trim().is_empty();
2222
2223 // Collect all lines belonging to this footnote definition
2224 let mut fn_lines: Vec<FnLineType> = Vec::new();
2225 if !deferred_body {
2226 fn_lines.push(FnLineType::Content(first_content.to_string(), footnote_start));
2227 }
2228 let mut last_consumed = i;
2229 i += 1;
2230
2231 // Strip only the footnote continuation indent, preserving
2232 // internal indentation (e.g., code block body indent)
2233 let strip_fn_indent = |s: &str| -> String {
2234 let mut chars = s.chars();
2235 let mut stripped = 0;
2236 while stripped < FN_INDENT {
2237 match chars.next() {
2238 Some('\t') => stripped += 4,
2239 Some(c) if c.is_whitespace() => stripped += 1,
2240 _ => break,
2241 }
2242 }
2243 chars.as_str().to_string()
2244 };
2245
2246 let mut in_fenced_code = false;
2247 let mut consecutive_blanks = 0u32;
2248
2249 while i < lines.len() {
2250 let next = lines[i];
2251 let next_trimmed = next.trim();
2252
2253 // Blank line handling
2254 if next_trimmed.is_empty() {
2255 consecutive_blanks += 1;
2256 // 2+ consecutive blanks terminate the footnote
2257 if consecutive_blanks >= 2 {
2258 break;
2259 }
2260
2261 // Inside a fenced code block, blank lines are part of the code
2262 if in_fenced_code {
2263 consecutive_blanks = 0; // Don't count blanks inside code blocks
2264 fn_lines.push(FnLineType::Verbatim(String::new(), 0));
2265 last_consumed = i;
2266 i += 1;
2267 continue;
2268 }
2269
2270 // Peek ahead: if next non-blank line is indented >= FN_INDENT,
2271 // this blank is an internal paragraph separator
2272 if i + 1 < lines.len() {
2273 let peek = lines[i + 1];
2274 let peek_indent = visual_indent(peek);
2275 if !peek.trim().is_empty() && peek_indent >= FN_INDENT {
2276 fn_lines.push(FnLineType::Empty);
2277 last_consumed = i;
2278 i += 1;
2279 continue;
2280 }
2281 }
2282 // No valid continuation after blank — end of footnote
2283 break;
2284 }
2285
2286 consecutive_blanks = 0;
2287 let indent = visual_indent(next);
2288
2289 // Not indented enough: end of footnote, unless a code span
2290 // still open at the end of the line above runs on into this
2291 // one, which makes it lazy text of the same paragraph.
2292 let continues_code_span = line_ends_in_code_span(ctx, i - 1);
2293 if indent < FN_INDENT && !continues_code_span {
2294 break;
2295 }
2296
2297 // Inside a fenced code block: everything is verbatim until closing fence
2298 if in_fenced_code {
2299 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2300 if is_fence(next_trimmed) {
2301 in_fenced_code = false;
2302 }
2303 last_consumed = i;
2304 i += 1;
2305 continue;
2306 }
2307
2308 // The checks below stop at what starts a block of its own.
2309 // A code span still open at the end of the line above proves
2310 // the parser read on into this one, so it is prose however it
2311 // is spelled.
2312 if !continues_code_span {
2313 // Fence opener — start verbatim code block
2314 if is_fence(next_trimmed) {
2315 in_fenced_code = true;
2316 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2317 last_consumed = i;
2318 i += 1;
2319 continue;
2320 }
2321
2322 // A multi-line display-math block is verbatim: its line breaks
2323 // carry meaning (see `line_in_multiline_math_block`).
2324 if self.line_in_multiline_math_block(i + 1, ctx, config) {
2325 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2326 last_consumed = i;
2327 i += 1;
2328 continue;
2329 }
2330
2331 // Indented code block: indent >= FN_INDENT + 4 (= 8 spaces)
2332 if indent >= FN_INDENT + 4 {
2333 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2334 last_consumed = i;
2335 i += 1;
2336 continue;
2337 }
2338
2339 // Structural content that must be preserved verbatim
2340 if next_trimmed.starts_with('#')
2341 || is_list_item(next_trimmed)
2342 || next_trimmed.starts_with('>')
2343 || TableUtils::is_potential_table_row_with_flavor(next_trimmed, ctx.flavor)
2344 || is_setext_underline(next_trimmed)
2345 || is_horizontal_rule(next_trimmed)
2346 || crate::utils::mkdocs_footnotes::is_footnote_definition(next_trimmed)
2347 {
2348 // Preserve verbatim: blockquotes, tables, lists, setext
2349 // underlines, and horizontal rules inside the footnote
2350 if next_trimmed.starts_with('>')
2351 || TableUtils::is_potential_table_row_with_flavor(next_trimmed, ctx.flavor)
2352 || is_list_item(next_trimmed)
2353 || is_setext_underline(next_trimmed)
2354 || is_horizontal_rule(next_trimmed)
2355 {
2356 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2357 last_consumed = i;
2358 i += 1;
2359 continue;
2360 }
2361 // Headings, new footnote defs, link refs — end the footnote
2362 break;
2363 }
2364
2365 // Link reference definitions inside footnotes are not reflowable
2366 if next_trimmed.starts_with('[')
2367 && !next_trimmed.starts_with("[^")
2368 && next_trimmed.contains("]:")
2369 && LINK_REF_PATTERN.is_match(next_trimmed)
2370 {
2371 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2372 last_consumed = i;
2373 i += 1;
2374 continue;
2375 }
2376
2377 // HTML-only lines inside footnotes are not reflowable
2378 if is_html_only_line(next_trimmed) {
2379 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
2380 last_consumed = i;
2381 i += 1;
2382 continue;
2383 }
2384 }
2385
2386 // Regular prose content. A code span crossing into the line
2387 // keeps the indentation past the footnote's own, which leaves
2388 // the footnote as written; a lazy line has none taken off. A
2389 // hard break at the end stays, so the paragraph keeps it.
2390 let text = if !ctx.is_in_code_span_byte_with_or_without_math(ctx.lines[i].byte_offset) {
2391 trim_preserving_hard_break(next.trim_start())
2392 } else if indent >= FN_INDENT {
2393 trim_preserving_hard_break(&strip_fn_indent(next))
2394 } else {
2395 trim_preserving_hard_break(next)
2396 };
2397 fn_lines.push(FnLineType::Content(restore_code_span_line_end(ctx, i, text), i));
2398 last_consumed = i;
2399 i += 1;
2400 }
2401
2402 // Nothing collected or only empty lines
2403 if fn_lines.iter().all(|l| matches!(l, FnLineType::Empty)) || fn_lines.is_empty() {
2404 continue;
2405 }
2406
2407 // The footnote is rebuilt with a fixed indent and its paragraphs
2408 // joined, which a definition list inside it does not survive.
2409 if holds_definition_list(ctx, footnote_start, last_consumed) {
2410 continue;
2411 }
2412 // This collector trims prose line ends. Leave hard-break footnotes
2413 // intact rather than losing their break markers while packing.
2414 if config.reflow_mode == ReflowMode::SentencePack
2415 && lines[footnote_start..=last_consumed]
2416 .iter()
2417 .any(|line| has_hard_break(line))
2418 {
2419 continue;
2420 }
2421
2422 // --- Group into blocks ---
2423 #[derive(Debug)]
2424 enum FnBlock {
2425 Paragraph(Vec<(String, usize)>), // (text, 1-based line number)
2426 Verbatim(Vec<(String, usize)>), // (content, indent) preserved as-is
2427 }
2428
2429 // Each block carries whether a blank line comes before it in the
2430 // source. Only those get one in the rebuild: a line right after a
2431 // paragraph that cannot interrupt it (`7. b`, `[a]: b`, `<span>`)
2432 // is text of that paragraph, which a blank line would cut off.
2433 let mut blocks: Vec<(bool, FnBlock)> = Vec::new();
2434 let mut current_para: Vec<(String, usize)> = Vec::new();
2435 let mut current_verbatim: Vec<(String, usize)> = Vec::new();
2436 let mut blank_before = false;
2437
2438 for fl in &fn_lines {
2439 match fl {
2440 FnLineType::Content(s, idx) => {
2441 if !current_verbatim.is_empty() {
2442 let verbatim = std::mem::take(&mut current_verbatim);
2443 blocks.push((std::mem::take(&mut blank_before), FnBlock::Verbatim(verbatim)));
2444 }
2445 current_para.push((s.clone(), idx + 1));
2446 }
2447 FnLineType::Verbatim(s, indent) => {
2448 if !current_para.is_empty() {
2449 let para = std::mem::take(&mut current_para);
2450 blocks.push((std::mem::take(&mut blank_before), FnBlock::Paragraph(para)));
2451 }
2452 current_verbatim.push((s.clone(), *indent));
2453 }
2454 FnLineType::Empty => {
2455 if !current_para.is_empty() {
2456 let para = std::mem::take(&mut current_para);
2457 blocks.push((blank_before, FnBlock::Paragraph(para)));
2458 }
2459 if !current_verbatim.is_empty() {
2460 let verbatim = std::mem::take(&mut current_verbatim);
2461 blocks.push((blank_before, FnBlock::Verbatim(verbatim)));
2462 }
2463 blank_before = true;
2464 }
2465 }
2466 }
2467 if !current_para.is_empty() {
2468 blocks.push((blank_before, FnBlock::Paragraph(current_para)));
2469 }
2470 if !current_verbatim.is_empty() {
2471 blocks.push((blank_before, FnBlock::Verbatim(current_verbatim)));
2472 }
2473 let span_runs_into_indentation = |block: &FnBlock| match block {
2474 FnBlock::Paragraph(lines) => {
2475 code_span_runs_into_indentation(&lines.iter().map(|(text, _)| text).collect::<Vec<_>>())
2476 }
2477 FnBlock::Verbatim(_) => false,
2478 };
2479 if blocks.iter().any(|(_, block)| span_runs_into_indentation(block)) {
2480 continue;
2481 }
2482
2483 // --- Reflow paragraphs and reconstruct ---
2484 let prefix_display_width = prefix.chars().count() + 1; // +1 for space
2485 let reflow_line_length = if config.line_length.is_unlimited() {
2486 usize::MAX
2487 } else if config.reflow_mode == ReflowMode::SentencePack {
2488 config.line_length.get().saturating_sub(FN_INDENT).max(1)
2489 } else {
2490 config
2491 .line_length
2492 .get()
2493 .saturating_sub(FN_INDENT.max(prefix_display_width))
2494 .max(20)
2495 };
2496 // Footnote continuation uses a fixed 4-space indent, so list
2497 // continuation capping does not apply here.
2498 let mut reflow_options = crate::utils::text_reflow::ReflowOptions {
2499 max_list_continuation_indent: None,
2500 ..Self::reflow_options(ctx, config, reflow_line_length)
2501 };
2502
2503 let indent_str = " ".repeat(FN_INDENT);
2504 let mut result_lines: Vec<String> = Vec::new();
2505 let mut is_first_block = true;
2506
2507 for (blank_before, block) in &blocks {
2508 match block {
2509 FnBlock::Paragraph(para_lines) => {
2510 // A hard break ends a line of the paragraph wherever
2511 // reflow puts it, so the text between two of them is
2512 // reflowed on its own and the break follows its last
2513 // line. Trailing spaces a code span carries across the
2514 // line break are code, not a hard break.
2515 let ends_with_hard_break = |line: &str, line_num: usize| {
2516 has_hard_break(line) && !line_ends_in_code_span(ctx, line_num - 1)
2517 };
2518 let mut reflowed: Vec<String> = Vec::new();
2519 for (segment_idx, segment) in split_into_segments(para_lines, ends_with_hard_break)
2520 .into_iter()
2521 .enumerate()
2522 {
2523 let break_marker = segment.last().and_then(|(line, line_num)| {
2524 if !ends_with_hard_break(line, *line_num) {
2525 None
2526 } else if line.ends_with('\\') {
2527 Some("\\")
2528 } else {
2529 Some(" ")
2530 }
2531 });
2532 let texts: Vec<&str> = segment
2533 .iter()
2534 .map(|(line, line_num)| {
2535 if ends_with_hard_break(line, *line_num) {
2536 line.strip_suffix('\\').unwrap_or(line).trim_end_matches([' ', '\t'])
2537 } else {
2538 line.as_str()
2539 }
2540 })
2541 .collect();
2542 let segment_text = Self::join_paragraph_lines(&texts, config);
2543 let segment_text = trim_breakable_whitespace(&segment_text);
2544 if segment_text.is_empty() {
2545 continue;
2546 }
2547 reflow_options.first_line_length = if config.reflow_mode == ReflowMode::SentencePack
2548 && is_first_block
2549 && segment_idx == 0
2550 && !config.line_length.is_unlimited()
2551 {
2552 Some(
2553 config
2554 .line_length
2555 .effective_limit()
2556 .saturating_sub(self.calculate_string_length(prefix) + 1)
2557 .max(1),
2558 )
2559 } else {
2560 None
2561 };
2562 reflowed.extend(crate::utils::text_reflow::reflow_line(segment_text, &reflow_options));
2563 if let Some(break_marker) = break_marker
2564 && let Some(last) = reflowed.last_mut()
2565 {
2566 last.push_str(break_marker);
2567 }
2568 }
2569 if reflowed.is_empty() {
2570 continue;
2571 }
2572
2573 if *blank_before && !result_lines.is_empty() {
2574 result_lines.push(String::new());
2575 }
2576
2577 for (idx, rline) in reflowed.iter().enumerate() {
2578 if is_first_block && idx == 0 {
2579 result_lines.push(format!("{prefix} {rline}"));
2580 } else {
2581 result_lines.push(format!("{indent_str}{rline}"));
2582 }
2583 }
2584 is_first_block = false;
2585 }
2586 FnBlock::Verbatim(verb_lines) => {
2587 if *blank_before && !result_lines.is_empty() {
2588 result_lines.push(String::new());
2589 }
2590
2591 if is_first_block {
2592 // Verbatim as first block in a deferred-body footnote
2593 if deferred_body {
2594 result_lines.push(prefix.to_string());
2595 }
2596 is_first_block = false;
2597 }
2598 for (content, _orig_indent) in verb_lines {
2599 result_lines.push(format!("{indent_str}{content}"));
2600 }
2601 }
2602 }
2603 }
2604
2605 // If nothing was produced, skip
2606 if result_lines.is_empty() {
2607 continue;
2608 }
2609
2610 let reflowed_text = result_lines.join(line_ending);
2611
2612 // Calculate byte range using last_consumed
2613 let start_range = ctx.whole_line_byte_range(footnote_start + 1);
2614 let end_range = if last_consumed == lines.len() - 1 && !ctx.content.ends_with('\n') {
2615 ctx.line_text_byte_range(last_consumed + 1, 1, lines[last_consumed].len() + 1)
2616 } else {
2617 ctx.whole_line_byte_range(last_consumed + 1)
2618 };
2619 let byte_range = start_range.start..end_range.end;
2620
2621 let replacement = if last_consumed < lines.len() - 1 || ctx.content.ends_with('\n') {
2622 format!("{reflowed_text}{line_ending}")
2623 } else {
2624 reflowed_text
2625 };
2626
2627 let original_text = &ctx.content[byte_range.clone()];
2628 let max_length = (footnote_start..=last_consumed)
2629 .map(|idx| self.calculate_effective_length(lines[idx]))
2630 .max()
2631 .unwrap_or(0);
2632 let line_limit = if config.line_length.is_unlimited() {
2633 usize::MAX
2634 } else {
2635 config.line_length.get()
2636 };
2637 if original_text != replacement
2638 && (matches!(
2639 config.reflow_mode,
2640 ReflowMode::SentencePerLine | ReflowMode::SentencePack | ReflowMode::SemanticLineBreaks
2641 ) || max_length > line_limit)
2642 {
2643 warnings.push(LintWarning {
2644 rule_name: Some(self.name().to_string()),
2645 message: if config.reflow_mode == ReflowMode::SentencePack {
2646 "Footnote should pack complete sentences".to_string()
2647 } else if config.reflow_mode == ReflowMode::SentencePerLine {
2648 "Footnote should have one sentence per line".to_string()
2649 } else if config.reflow_mode == ReflowMode::SemanticLineBreaks {
2650 "Footnote should use semantic line breaks".to_string()
2651 } else {
2652 format!(
2653 "Line length {} exceeds {} characters",
2654 max_length,
2655 config.line_length.get()
2656 )
2657 },
2658 line: footnote_start + 1,
2659 column: 1,
2660 end_line: last_consumed + 1,
2661 end_column: lines[last_consumed].chars().count() + 1,
2662 severity: Severity::Warning,
2663 fix: Some(crate::rule::Fix::new(byte_range, replacement)),
2664 });
2665 }
2666 continue;
2667 }
2668
2669 // Handle MkDocs container content (admonitions and tabs) with indent-preserving reflow
2670 if ctx
2671 .line_info(line_num)
2672 .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container)
2673 {
2674 // Skip admonition/tab marker lines — only reflow their indented content
2675 let current_line = lines[i];
2676 if mkdocs_admonitions::is_admonition_start(current_line) || mkdocs_tabs::is_tab_marker(current_line) {
2677 i += 1;
2678 continue;
2679 }
2680
2681 let container_start = i;
2682
2683 // Detect the actual indent level from the first content line
2684 // (supports nested admonitions with 8+ spaces). Python-Markdown
2685 // expands a tab to the next multiple of four columns, so the indent
2686 // is measured in columns and re-emitted as that many spaces.
2687 let first_line = lines[i];
2688 let base_prefix = &first_line[..first_line.len() - first_line.trim_start_matches([' ', '\t']).len()];
2689 let base_indent_len = column_at(first_line, base_prefix.len());
2690 let base_indent: String = " ".repeat(base_indent_len);
2691
2692 // Collect consecutive MkDocs container paragraph lines
2693 let mut container_lines: Vec<&str> = Vec::new();
2694 while i < lines.len() {
2695 let current_line_num = i + 1;
2696 let line_info = ctx.line_info(current_line_num);
2697
2698 // Stop if we leave the MkDocs container
2699 if !line_info.is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container) {
2700 break;
2701 }
2702
2703 let line = lines[i];
2704
2705 // Stop at paragraph boundaries within the container
2706 if line.trim().is_empty() {
2707 break;
2708 }
2709
2710 // Skip list items, code blocks, headings, HTML-only lines within containers
2711 if is_list_item(line.trim())
2712 || line.trim().starts_with("```")
2713 || line.trim().starts_with("~~~")
2714 || line.trim().starts_with('#')
2715 || is_html_only_line(line)
2716 {
2717 break;
2718 }
2719
2720 container_lines.push(line);
2721 i += 1;
2722 }
2723
2724 if container_lines.is_empty() {
2725 // Must advance i to avoid infinite loop when we encounter
2726 // non-paragraph content (code block, list, heading, empty line)
2727 // at the start of an MkDocs container
2728 i += 1;
2729 continue;
2730 }
2731
2732 // Strip the base indent from each line and join for reflow
2733 let stripped_lines: Vec<&str> = container_lines
2734 .iter()
2735 .map(|line| {
2736 if let Some(rest) = line.strip_prefix(base_prefix) {
2737 rest
2738 } else {
2739 line.trim_start()
2740 }
2741 })
2742 .collect();
2743 if code_span_runs_into_indentation(&stripped_lines) {
2744 continue;
2745 }
2746 let paragraph_text = Self::join_paragraph_lines(&stripped_lines, config);
2747
2748 // Check if reflow is needed
2749 let needs_reflow = match config.reflow_mode {
2750 ReflowMode::SentencePack => true,
2751 ReflowMode::Normalize => self.normalize_mode_needs_reflow(container_lines.iter().copied(), config),
2752 ReflowMode::SentencePerLine => {
2753 let sentences = split_into_sentences(
2754 ¶graph_text,
2755 Some(&defined_references),
2756 config.require_sentence_capital,
2757 config.cjk_soft_break,
2758 );
2759 sentences.len() > 1 || container_lines.len() > 1
2760 }
2761 ReflowMode::SemanticLineBreaks => {
2762 let sentences = split_into_sentences(
2763 ¶graph_text,
2764 Some(&defined_references),
2765 config.require_sentence_capital,
2766 config.cjk_soft_break,
2767 );
2768 sentences.len() > 1
2769 || container_lines.len() > 1
2770 || container_lines
2771 .iter()
2772 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
2773 }
2774 ReflowMode::Default => container_lines
2775 .iter()
2776 .any(|line| self.calculate_effective_length(line) > config.line_length.get()),
2777 };
2778
2779 if !needs_reflow {
2780 continue;
2781 }
2782
2783 // Calculate byte range for this container paragraph
2784 let start_range = ctx.whole_line_byte_range(container_start + 1);
2785 let end_line = container_start + container_lines.len() - 1;
2786 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
2787 ctx.line_text_byte_range(end_line + 1, 1, lines[end_line].len() + 1)
2788 } else {
2789 ctx.whole_line_byte_range(end_line + 1)
2790 };
2791 let byte_range = start_range.start..end_range.end;
2792
2793 // Reflow with adjusted line length (accounting for the 4-space indent)
2794 let reflow_line_length = if config.line_length.is_unlimited() {
2795 usize::MAX
2796 } else {
2797 config.line_length.get().saturating_sub(base_indent_len).max(1)
2798 };
2799 let reflow_options = Self::reflow_options(ctx, config, reflow_line_length);
2800 let reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
2801
2802 // Re-add the 4-space indent to each reflowed line
2803 let reflowed_with_indent: Vec<String> =
2804 reflowed.iter().map(|line| format!("{base_indent}{line}")).collect();
2805 let reflowed_text = reflowed_with_indent.join(line_ending);
2806
2807 // Preserve trailing newline
2808 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
2809 format!("{reflowed_text}{line_ending}")
2810 } else {
2811 reflowed_text
2812 };
2813
2814 // Only generate a warning if the replacement is different
2815 let original_text = &ctx.content[byte_range.clone()];
2816 if original_text != replacement {
2817 warnings.push(LintWarning {
2818 rule_name: Some(self.name().to_string()),
2819 message: if config.reflow_mode == ReflowMode::SentencePack {
2820 "Paragraph should pack complete sentences".to_string()
2821 } else {
2822 format!(
2823 "Line length {} exceeds {} characters (in MkDocs container)",
2824 container_lines.iter().map(|l| l.len()).max().unwrap_or(0),
2825 config.line_length.get()
2826 )
2827 },
2828 line: container_start + 1,
2829 column: 1,
2830 end_line: end_line + 1,
2831 end_column: lines[end_line].chars().count() + 1,
2832 severity: Severity::Warning,
2833 fix: Some(crate::rule::Fix::new(byte_range, replacement)),
2834 });
2835 }
2836 continue;
2837 }
2838
2839 // Helper function to detect semantic line markers
2840 let is_semantic_line = |content: &str| -> bool {
2841 let trimmed = content.trim_start();
2842 let semantic_markers = [
2843 "NOTE:",
2844 "WARNING:",
2845 "IMPORTANT:",
2846 "CAUTION:",
2847 "TIP:",
2848 "DANGER:",
2849 "HINT:",
2850 "INFO:",
2851 ];
2852 semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
2853 };
2854
2855 // Helper function to detect fence markers (opening or closing)
2856 let is_fence_marker = |content: &str| -> bool {
2857 let trimmed = content.trim_start();
2858 trimmed.starts_with("```") || trimmed.starts_with("~~~")
2859 };
2860
2861 // A list item whose marker line holds nothing but the marker has no
2862 // text to reflow there. The line after it is the item's content only
2863 // when indented to the content column; otherwise the item is empty
2864 // and that line starts a block of its own. Either way it is reflowed
2865 // on its own, and never joined onto the marker.
2866 let trimmed = lines[i].trim();
2867 if opens_bare_item(ctx, i) {
2868 i += 1;
2869 continue;
2870 }
2871
2872 // Check if this is a list item - handle it specially
2873 if is_list_item(trimmed) {
2874 // Collect the entire list item including continuation lines
2875 let list_start = i;
2876 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
2877 let marker_len = marker.len();
2878 // The normalized marker above is what gets re-emitted; the source marker
2879 // is where the item's content actually starts. Nested blocks move by the
2880 // difference between the two content columns, so the shift must be
2881 // measured against the source, not against the normalized width.
2882 let source_marker = source_list_marker(lines[i]);
2883 let source_content_col = source_marker.as_ref().map_or(marker_len, |m| m.content_col);
2884 // Where the parser puts the item's content, which is what a
2885 // continuation line's indentation is measured against.
2886 let item_content_col = ctx.lines[i]
2887 .list_item
2888 .as_ref()
2889 .map_or(source_content_col, |item| item.content_column);
2890 let first_content = restore_code_span_line_end(ctx, i, first_content);
2891
2892 // Checkbox ([ ]/[x]/[X]) is inline content, not part of the list marker.
2893 // Use the base bullet/number marker width for continuation recognition
2894 // so that continuation lines at 2+ spaces are collected for "- [ ] " items.
2895 let base_marker_len = if marker.contains("[ ] ") || marker.contains("[x] ") || marker.contains("[X] ") {
2896 marker.find('[').unwrap_or(marker_len)
2897 } else {
2898 marker_len
2899 };
2900
2901 // MkDocs flavor requires at least 4 spaces for list continuation
2902 // after a blank line (multi-paragraph list items). For non-blank
2903 // continuation (lines directly following the marker line), use
2904 // the natural marker width so that 2-space indent is recognized.
2905 //
2906 // The marker above keeps one padding space, and the item's content
2907 // starts past all of it, so a line is measured against the column
2908 // the padding as written reaches. A line short of that column
2909 // after a blank line is not in the item.
2910 let item_indent = ctx.lines[i].indent;
2911 let source_padding = item_content_col.saturating_sub(base_marker_len);
2912 // The thresholds above are byte offsets into the marker line. A
2913 // tab there reaches further than its one byte, so a line belongs
2914 // to the item only when its indentation reaches as many columns
2915 // as the content does. The bytes must reach the content as well:
2916 // lines are stripped and re-emitted by bytes, so a tab-indented
2917 // line that reaches it only by width is left to the outer loop,
2918 // which keeps it as written.
2919 let tab_widening = ctx.lines[i]
2920 .list_item
2921 .as_ref()
2922 .map_or(0, |item| column_at(lines[i], item.content_column) - item.content_column);
2923 let min_continuation_indent = if ctx.flavor.requires_strict_list_indent() {
2924 // Use 4-space relative indent from the list item's nesting level
2925 item_indent + (base_marker_len - item_indent).max(4)
2926 } else {
2927 marker_len + source_padding
2928 };
2929 let content_continuation_indent = base_marker_len + source_padding;
2930 let min_continuation_column = min_continuation_indent + tab_widening;
2931 let content_continuation_column = content_continuation_indent + tab_widening;
2932
2933 // Track lines and their types (content, code block, fence, nested list)
2934 #[derive(Clone)]
2935 enum LineType {
2936 Content(String, usize), // content and 1-indexed line number
2937 CodeBlock(String, usize), // content and original indent
2938 SemanticLine(String), // Lines starting with NOTE:, WARNING:, etc that should stay separate
2939 SnippetLine(String), // MkDocs Snippets delimiters (-8<-) that must stay on their own line
2940 DivMarker(String), // Quarto/Pandoc div markers (::: opening or closing)
2941 AdmonitionHeader(String, usize), // header text (e.g. "!!! note") and original indent
2942 AdmonitionContent(String, usize), // body content text and original indent
2943 Table(String, usize), // GFM table row, preserved verbatim with original indent
2944 Empty,
2945 }
2946
2947 let start_idx = i;
2948 // A marker line holding a second marker opens an item of its own,
2949 // and the line info describes only the outer one. The inner
2950 // item's content column is the indentation a code span crossing
2951 // into a continuation line leaves out, so it cannot be told here.
2952 let opens_nested_item = is_list_item(first_content.trim_start());
2953 // A marker line whose content is one whole `$$...$$` expression
2954 // renders as a display block, so it keeps the line it was written
2955 // on and the prose under it starts a paragraph of its own. The
2956 // code-block carrier re-emits it unchanged after the marker.
2957 //
2958 // The marker keeps one padding space and leaves the rest at the
2959 // head of the content, and the carrier writes the marker as the
2960 // author spelled it, padding included. The carrier therefore
2961 // holds the content with its leading whitespace off, so the
2962 // padding is written once.
2963 //
2964 // A marker that cannot interrupt a paragraph, such as an
2965 // ordered one not starting at one, can sit inside a code span
2966 // the paragraph above opened, and its content is code then.
2967 // The marker line can just as well be the one that opens the
2968 // span, closing on a line still to come.
2969 let mut list_item_lines: Vec<LineType> = if (is_self_contained_display_math_line(&first_content)
2970 || self.line_is_standalone_bracket_math(i + 1, ctx, config))
2971 && !line_touches_multiline_code_span(&code_span_touches, i + 1)
2972 {
2973 vec![LineType::CodeBlock(first_content.trim_start().to_string(), marker_len)]
2974 } else if marker_len == base_marker_len && marker_line_opens_non_paragraph(&first_content) {
2975 // A heading, thematic break or reference definition on the
2976 // marker line is written back as it is, and the prose under
2977 // it is a paragraph of its own. After a task checkbox the
2978 // text is inline, so none of them can open there.
2979 vec![LineType::CodeBlock(first_content.trim_start().to_string(), marker_len)]
2980 } else {
2981 vec![LineType::Content(first_content, i + 1)]
2982 };
2983 i += 1;
2984 let mut leave_as_written = false;
2985
2986 // Collect continuation lines using ctx.lines for metadata
2987 while i < lines.len() {
2988 let line_info = &ctx.lines[i];
2989
2990 // An invalid fence can be a lazy continuation of this
2991 // paragraph. Re-indenting or joining it changes how GFM
2992 // renderers recognize neighboring bare autolinks. Keep
2993 // the item as written unless the line continues a code span.
2994 if !line_info.in_code_block
2995 && line_info.visual_indent < content_continuation_column
2996 && is_fence_marker(lines[i])
2997 && !line_ends_in_code_span(ctx, i - 1)
2998 {
2999 leave_as_written = true;
3000 }
3001
3002 // Use pre-computed is_blank from ctx
3003 if line_info.is_blank {
3004 // Empty line - check if next line is indented (part of list item)
3005 if i + 1 < lines.len() {
3006 let next_info = &ctx.lines[i + 1];
3007
3008 // Check if next line is indented enough to be continuation
3009 if !next_info.is_blank
3010 && next_info.indent >= min_continuation_indent
3011 && next_info.visual_indent >= min_continuation_column
3012 {
3013 // This blank line is between paragraphs/blocks in the list item
3014 list_item_lines.push(LineType::Empty);
3015 i += 1;
3016 continue;
3017 }
3018 }
3019 // No indented line after blank, end of list item
3020 break;
3021 }
3022
3023 // Use pre-computed indent from ctx
3024 let indent = line_info.indent;
3025
3026 // Valid continuation must be indented at least content_continuation_indent.
3027 // For non-blank continuation, use marker_len (e.g. 2 for "- ").
3028 // MkDocs strict 4-space requirement applies only after blank lines.
3029 if indent >= content_continuation_indent && line_info.visual_indent >= content_continuation_column {
3030 // A line kept on its own is re-indented with spaces, and
3031 // implementations disagree on how much of a tab the
3032 // item's indentation consumes, so an item holding a line
3033 // indented with a tab is left as written.
3034 leave_as_written |= line_info.content(ctx.content)[..indent].contains('\t');
3035 let trimmed = line_info.content(ctx.content).trim();
3036
3037 // Check for MkDocs admonition lines inside list items BEFORE
3038 // checking in_code_block. Lines inside code blocks within
3039 // admonitions have both in_admonition and in_code_block set;
3040 // admonition membership takes priority so the entire admonition
3041 // structure (including embedded code blocks) is preserved.
3042 if line_info.in_admonition {
3043 let raw_content = line_info.content(ctx.content);
3044 if mkdocs_admonitions::is_admonition_start(raw_content) {
3045 let header_text = raw_content[indent..].trim_end_matches([' ', '\t']).to_string();
3046 list_item_lines.push(LineType::AdmonitionHeader(header_text, indent));
3047 } else {
3048 let body_text = raw_content[indent..].trim_end_matches([' ', '\t']).to_string();
3049 list_item_lines.push(LineType::AdmonitionContent(body_text, indent));
3050 }
3051 i += 1;
3052 continue;
3053 }
3054
3055 // Use pre-computed in_code_block from ctx
3056 if line_info.in_code_block {
3057 list_item_lines.push(LineType::CodeBlock(
3058 line_info.content(ctx.content)[indent..].to_string(),
3059 indent,
3060 ));
3061 i += 1;
3062 continue;
3063 }
3064
3065 // A multi-line display-math block inside the item is verbatim:
3066 // its line breaks carry meaning (see
3067 // `line_in_multiline_math_block`), so reuse the code-block
3068 // carrier to re-emit it unchanged. A reader without math
3069 // can see a code span crossing into this line from the
3070 // prose above, and reflowing that prose on its own would
3071 // rewrite the whitespace the span holds.
3072 if self.line_in_multiline_math_block(i + 1, ctx, config) {
3073 leave_as_written |= line_ends_in_code_span(ctx, i - 1);
3074 list_item_lines.push(LineType::CodeBlock(
3075 line_info.content(ctx.content)[indent..].to_string(),
3076 indent,
3077 ));
3078 i += 1;
3079 continue;
3080 }
3081
3082 // A blockquote nested inside the list item is reflowed by the
3083 // blockquote-aware path (it preserves the `>` prefix, including the
3084 // list indent), not as list-item prose. Collecting it as Content
3085 // would strip the markers and reflow `>` as words, collapsing the
3086 // blank `>` line and dropping `>` from wrapped continuations. End
3087 // the item here so the outer loop routes the blockquote line to
3088 // generate_blockquote_paragraph_fix. Uncollect a pending blank so
3089 // the separator between the list prose and the blockquote survives.
3090 if line_info.blockquote.is_some() {
3091 if matches!(list_item_lines.last(), Some(LineType::Empty)) {
3092 list_item_lines.pop();
3093 i -= 1;
3094 }
3095 break;
3096 }
3097
3098 // Check if this is a SIBLING list item (breaks parent)
3099 // Nested lists are indented >= marker_len and are PART of the parent item
3100 // Siblings are at indent < marker_len (at or before parent marker)
3101 // The parser also recognizes an item whose marker line holds
3102 // nothing else, which `is_list_item` does not. A line a code
3103 // span runs on into continues the paragraph that span is in,
3104 // whatever marker it starts with.
3105 let starts_item = !line_ends_in_code_span(ctx, i - 1)
3106 && (is_list_item(trimmed) || line_info.list_item.is_some());
3107 if starts_item && indent < marker_len {
3108 // This is a sibling item at same or higher level - end parent item
3109 break;
3110 }
3111
3112 // Nested list items are always processed independently
3113 // by the outer loop, so break when we encounter one.
3114 // If a blank line was collected before this, uncollect it
3115 // so the outer loop preserves the blank between parent and nested.
3116 if starts_item && indent >= marker_len {
3117 if matches!(list_item_lines.last(), Some(LineType::Empty)) {
3118 list_item_lines.pop();
3119 i -= 1;
3120 }
3121 break;
3122 }
3123
3124 // Normal continuation vs indented code block.
3125 // Use min_continuation_indent for the threshold since
3126 // code blocks start 4 spaces beyond the expected content
3127 // level (which is min_continuation_indent for MkDocs).
3128 // An indented code block cannot interrupt a paragraph, so
3129 // a line under the item's prose continues that paragraph
3130 // however deep it is indented.
3131 if indent <= min_continuation_indent + 3
3132 || matches!(list_item_lines.last(), Some(LineType::Content(..)))
3133 {
3134 // Extract content (remove indentation and trailing whitespace)
3135 // Preserve hard breaks (2 trailing spaces) while removing excessive whitespace
3136 // See: https://github.com/rvben/rumdl/issues/76
3137 let strip = continuation_indent_to_strip(ctx, i, indent, item_content_col);
3138 let content = restore_code_span_line_end(
3139 ctx,
3140 i,
3141 trim_preserving_hard_break(&line_info.content(ctx.content)[strip..]),
3142 );
3143
3144 // An underline under the item's paragraph text makes
3145 // that text a setext heading. The parser records no
3146 // heading for one whose text starts on the marker
3147 // line, so the collector cannot tell its lines from
3148 // prose, and rewrapping them would end the heading
3149 // elsewhere or join the underline into its text.
3150 leave_as_written |= is_setext_underline_content(&content)
3151 && matches!(list_item_lines.last(), Some(LineType::Content(..)));
3152
3153 // A line a code span crosses into or out of is text of
3154 // the paragraph that span is in, whatever block its
3155 // text alone would open. Kept on its own line, it
3156 // would be written back past an indentation the span
3157 // already holds part of.
3158 if line_ends_in_code_span(ctx, i - 1) || line_ends_in_code_span(ctx, i) {
3159 list_item_lines.push(LineType::Content(content, i + 1));
3160 }
3161 // Check if this is a div marker (::: opening or closing)
3162 // These must be preserved on their own line, not merged into paragraphs
3163 else if line_info.is_div_marker {
3164 list_item_lines.push(LineType::DivMarker(content));
3165 }
3166 // A fence marker opens or closes a code block, a
3167 // heading is a block of its own, its setext text lines
3168 // and underline included, and a line that is one whole
3169 // `$$...$$` expression renders as a display block.
3170 // Each keeps the line it was written on, so the
3171 // code-block carrier re-emits it unchanged between the
3172 // prose above and below. A line touched by a code span
3173 // crossing one of its boundaries is code, not such a
3174 // block.
3175 else if is_fence_marker(&content)
3176 || line_info.heading.is_some()
3177 || line_info.is_setext_heading_text
3178 || (is_setext_underline_content(&content) && ctx.lines[i - 1].is_setext_heading_text)
3179 || ((is_self_contained_display_math_line(&content)
3180 || self.line_is_standalone_bracket_math(i + 1, ctx, config))
3181 && !line_touches_multiline_code_span(&code_span_touches, i + 1))
3182 {
3183 list_item_lines.push(LineType::CodeBlock(content, indent));
3184 }
3185 // Check if this is a semantic line (NOTE:, WARNING:, etc.).
3186 else if is_semantic_line(&content) {
3187 list_item_lines.push(LineType::SemanticLine(content));
3188 }
3189 // Check if this is a snippet block delimiter (-8<- or --8<--)
3190 // These must be preserved on their own lines for MkDocs Snippets extension
3191 else if is_snippet_block_delimiter(&content) {
3192 list_item_lines.push(LineType::SnippetLine(content));
3193 }
3194 // Check if this is a GFM table row. Tables nested inside list
3195 // items must be preserved verbatim — joining them with prose
3196 // breaks the column structure.
3197 //
3198 // `is_potential_table_row` is intentionally permissive at the
3199 // row level: any line with `|` and 2+ cells qualifies. To avoid
3200 // misclassifying prose continuation lines that contain a literal
3201 // pipe (e.g. "use grep | sort to ..."), require one of:
3202 // - the row is pipe-bordered (`| ... |`), the canonical form
3203 // for tables nested in lists; or
3204 // - the next line is a delimiter row (this is a header); or
3205 // - the previous classified line was already a Table (this is
3206 // a continuation row).
3207 //
3208 // A row the table detection found is a row whatever it holds:
3209 // GFM runs the rows on without pipes until a blank line or
3210 // another block.
3211 else if line_info.in_table_block
3212 || TableUtils::is_potential_table_row_with_flavor(&content, ctx.flavor) && {
3213 let pipe_bordered =
3214 content.trim().starts_with('|') && content.trim().ends_with('|');
3215 let next_is_delim = ctx
3216 .lines
3217 .get(i + 1)
3218 .is_some_and(|next| TableUtils::is_delimiter_row(next.content(ctx.content)));
3219 let prev_was_table = matches!(list_item_lines.last(), Some(LineType::Table(..)));
3220 pipe_bordered || next_is_delim || prev_was_table
3221 }
3222 {
3223 list_item_lines.push(LineType::Table(content, indent));
3224 } else {
3225 list_item_lines.push(LineType::Content(content, i + 1));
3226 }
3227 i += 1;
3228 } else {
3229 // indent >= min_continuation_indent + 4: indented code block
3230 list_item_lines.push(LineType::CodeBlock(
3231 line_info.content(ctx.content)[indent..].to_string(),
3232 indent,
3233 ));
3234 i += 1;
3235 }
3236 } else if matches!(list_item_lines.last(), Some(LineType::Content(..)))
3237 && line_info.visual_indent < content_continuation_column
3238 && is_lazy_continuation(ctx, i)
3239 {
3240 // A lazy continuation line continues the item's open
3241 // paragraph, so it is reflowed with it. Reflowed on its own at
3242 // its source column, a wrap could start a line with a marker
3243 // such as `2)` that opens a list there, where no paragraph of
3244 // the matched containers is open for it to continue. Only the
3245 // indentation of the enclosing items the line still reaches is
3246 // stripped, so a code span running into the rest leaves the
3247 // item as written.
3248 //
3249 // A lazy line that is one whole tag would open a type-7
3250 // HTML block outside the item. Such a block cannot
3251 // interrupt a paragraph, so CommonMark keeps the line in
3252 // the item, but markdown-rs and comrak end the item before
3253 // it. Indenting the line or joining it into the paragraph
3254 // changes how one of the two readings renders, so the item
3255 // is left as written.
3256 leave_as_written |= opens_tag_line_html_block(line_info.content(ctx.content).trim_start());
3257 let strip = continuation_indent_to_strip(
3258 ctx,
3259 i,
3260 indent,
3261 enclosing_item_content_col_reached(ctx, list_start, indent),
3262 );
3263 let content = restore_code_span_line_end(
3264 ctx,
3265 i,
3266 trim_preserving_hard_break(&line_info.content(ctx.content)[strip..]),
3267 );
3268 list_item_lines.push(LineType::Content(content, i + 1));
3269 i += 1;
3270 } else {
3271 // Not indented enough, end of list item
3272 break;
3273 }
3274 }
3275 // A code span crossing out of the item's last line runs on into
3276 // a line the item does not collect. Its paragraph continues past
3277 // where the item ends, and reflowing the part inside the item
3278 // would rewrite the whitespace the span holds.
3279 leave_as_written |= line_ends_in_code_span(ctx, i - 1);
3280 leave_as_written |= opens_nested_item && (start_idx..i - 1).any(|idx| line_ends_in_code_span(ctx, idx));
3281 // Each run of content lines is one paragraph of the item.
3282 //
3283 // When a tab pads a marker on the item's first line, the
3284 // readers disagree on how far past the content column each
3285 // later line sits, so a line one of them takes for a block of
3286 // its own (a quote, a heading) is paragraph text to the other.
3287 // A code span then crosses into that line in one reading
3288 // only, and reflowing the paragraph above it would rewrite
3289 // the whitespace the span holds there.
3290 let readers_split_blocks = markers_padded_with_tab(lines[start_idx]);
3291 leave_as_written |= list_item_lines
3292 .chunk_by(|a, b| matches!((a, b), (LineType::Content(..), LineType::Content(..))))
3293 .any(|run| {
3294 let mut texts: Vec<&str> = run
3295 .iter()
3296 .filter_map(|line| match line {
3297 LineType::Content(text, _) => Some(text.as_str()),
3298 _ => None,
3299 })
3300 .collect();
3301 if code_span_runs_into_indentation(&texts) {
3302 return true;
3303 }
3304 let Some(LineType::Content(_, last_line_num)) = run.last() else {
3305 return false;
3306 };
3307 let next_line = lines
3308 .get(*last_line_num)
3309 .map(|line| line.trim_start_matches([' ', '\t']));
3310 readers_split_blocks
3311 && next_line.is_some_and(|next| {
3312 texts.push(next);
3313 !next.is_empty() && code_span_crosses_last_break(&texts)
3314 })
3315 });
3316 // The item a marker line opens inside this one has a content
3317 // column of its own. A paragraph after a blank line belongs to
3318 // that inner item when it reaches the inner column and to this
3319 // item when it does not, so it is written back at the column of
3320 // the item it is in. Paragraphs of both items cannot share one
3321 // indentation, so an item holding both is left as written.
3322 // Held as the inner marker's width, which is how far the inner
3323 // column sits past this item's, and the indentation the first
3324 // such paragraph was written at.
3325 let nested_paragraph = source_marker.as_ref().filter(|_| opens_nested_item).and_then(|outer| {
3326 let inner = source_list_marker(&lines[start_idx][outer.text.len()..])?;
3327 let nested_col = outer.content_col + inner.content_col;
3328 let indents: Vec<usize> = list_item_lines
3329 .iter()
3330 .skip_while(|line| !matches!(line, LineType::Empty))
3331 .filter_map(|line| match line {
3332 LineType::Content(_, line_num) => Some(ctx.lines[line_num - 1].indent),
3333 _ => None,
3334 })
3335 .collect();
3336 leave_as_written |= indents.iter().any(|&indent| indent < nested_col)
3337 && indents.iter().any(|&indent| indent >= nested_col);
3338 let first = *indents.first()?;
3339 (first >= nested_col).then_some((inner.content_col, first))
3340 });
3341 if leave_as_written {
3342 // Left as written, the item renders as its author saw it.
3343 continue;
3344 }
3345
3346 // Determine the output continuation indent.
3347 // Normalize/Default modes canonicalize to min_continuation_indent
3348 // (fixing over-indented continuation). Semantic/SentencePerLine
3349 // modes preserve the user's actual indent since they only fix
3350 // line breaking, not indentation.
3351 let indent_size = match config.reflow_mode {
3352 ReflowMode::SemanticLineBreaks | ReflowMode::SentencePerLine | ReflowMode::SentencePack => {
3353 // Find indent of the first plain text continuation line,
3354 // skipping the marker line (index 0), nested list items,
3355 // code blocks, blank lines, and lazy lines, whose indent
3356 // falls short of the item's content. Every paragraph of
3357 // the item is written at this indent, and one after a
3358 // blank line indented four or more past the content is
3359 // a code block, so a continuation line indented that
3360 // deep sets no indent.
3361 list_item_lines
3362 .iter()
3363 .skip(1)
3364 .find_map(|lt| match lt {
3365 LineType::Content(_, line_num)
3366 if (content_continuation_indent..content_continuation_column + 4)
3367 .contains(&ctx.lines[line_num - 1].indent) =>
3368 {
3369 Some(ctx.lines[line_num - 1].indent)
3370 }
3371 _ => None,
3372 })
3373 .unwrap_or(min_continuation_indent)
3374 }
3375 _ => min_continuation_indent,
3376 };
3377 let indent_size = match (config.reflow_mode, nested_paragraph) {
3378 (
3379 ReflowMode::SemanticLineBreaks | ReflowMode::SentencePerLine | ReflowMode::SentencePack,
3380 Some((_, written)),
3381 ) => written,
3382 _ => indent_size,
3383 };
3384 // For checkbox items in mkdocs flavor, enforce minimum indent so
3385 // continuation lines use the structural list indent (4), not the
3386 // content-aligned indent (6) which Python-Markdown doesn't support
3387 let has_checkbox = base_marker_len < marker_len;
3388 let indent_size = if has_checkbox && ctx.flavor.requires_strict_list_indent() {
3389 indent_size.max(min_continuation_indent)
3390 } else {
3391 indent_size
3392 };
3393
3394 // Split list_item_lines into blocks (paragraphs, code blocks, nested lists, semantic lines, and HTML blocks)
3395 let mut builder = BlockBuilder::new_with_start_line(start_idx + 1);
3396 for line in &list_item_lines {
3397 match line {
3398 LineType::Empty => builder.feed_blank_line(),
3399 LineType::Content(content, line_num) => {
3400 builder.feed_content(content, ctx.lines[line_num - 1].indent)
3401 }
3402 LineType::CodeBlock(content, indent) => builder.feed_code_line(content, *indent),
3403 LineType::SemanticLine(content) => builder.feed_semantic_line(content),
3404 LineType::SnippetLine(content) => builder.feed_snippet_line(content),
3405 LineType::DivMarker(content) => builder.feed_div_marker(content),
3406 LineType::AdmonitionHeader(header_text, indent) => {
3407 builder.feed_admonition_header(header_text, *indent)
3408 }
3409 LineType::AdmonitionContent(content, indent) => {
3410 builder.feed_admonition_content(content, *indent)
3411 }
3412 LineType::Table(content, indent) => builder.feed_table_line(content, *indent),
3413 }
3414 }
3415 let blocks = builder.finalize();
3416
3417 // Helper: check if a line (raw source or stripped content) is exempt
3418 // from line-length checks. Link reference definitions are always exempt;
3419 // standalone link/image lines are exempt when strict mode is off.
3420 // Also checks content after stripping list markers, since list item
3421 // continuation lines may contain link ref defs.
3422 let is_exempt_line = |raw_line: &str, line_num: usize| -> bool {
3423 let trimmed = raw_line.trim();
3424 // Link reference definitions: always exempt
3425 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
3426 return true;
3427 }
3428 // Also check after stripping list markers (for list item content)
3429 if is_list_item(trimmed) {
3430 let (_, content) = extract_list_marker_and_content(trimmed);
3431 let content_trimmed = content.trim();
3432 if content_trimmed.starts_with('[')
3433 && content_trimmed.contains("]:")
3434 && LINK_REF_PATTERN.is_match(content_trimmed)
3435 {
3436 return true;
3437 }
3438 }
3439 // Standalone link/image lines: exempt when not strict
3440 if standalone_link_ends_paragraph(ctx, line_num, config) {
3441 return true;
3442 }
3443 // HTML-only lines: exempt when not strict
3444 if !config.strict && is_html_only_line(raw_line) {
3445 return true;
3446 }
3447 false
3448 };
3449
3450 // Check if reflowing is needed (only for content paragraphs, not code blocks or nested lists)
3451 // Exclude link reference definitions and standalone link lines from content
3452 // so they don't pollute combined_content or trigger false reflow.
3453 let content_lines: Vec<String> = list_item_lines
3454 .iter()
3455 .filter_map(|line| {
3456 if let LineType::Content(s, line_num) = line {
3457 if is_exempt_line(s, *line_num) {
3458 return None;
3459 }
3460 Some(s.clone())
3461 } else {
3462 None
3463 }
3464 })
3465 .collect();
3466
3467 // Check if we need to reflow this list item
3468 // We check the combined content to see if it exceeds length limits
3469 let combined_content =
3470 trim_breakable_whitespace(&Self::join_paragraph_lines(&content_lines, config)).to_string();
3471
3472 // Helper to check if we should reflow in normalize mode
3473 let should_normalize = || {
3474 // Don't normalize if the list item only contains nested lists, code blocks, or semantic lines
3475 // DO normalize if it has plain text content that spans multiple lines
3476 let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
3477 let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine { .. }));
3478 let has_snippet_lines = blocks.iter().any(|b| matches!(b, Block::SnippetLine { .. }));
3479 let has_div_markers = blocks.iter().any(|b| matches!(b, Block::DivMarker { .. }));
3480 let has_admonitions = blocks.iter().any(|b| matches!(b, Block::Admonition { .. }));
3481 let has_tables = blocks.iter().any(|b| matches!(b, Block::Table { .. }));
3482 let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph { .. }));
3483
3484 // If we have structural blocks but no paragraphs, don't normalize
3485 if (has_code_blocks
3486 || has_semantic_lines
3487 || has_snippet_lines
3488 || has_div_markers
3489 || has_admonitions
3490 || has_tables)
3491 && !has_paragraphs
3492 {
3493 return false;
3494 }
3495
3496 // If we have paragraphs, check if they span multiple lines or there are multiple blocks
3497 if has_paragraphs {
3498 // Count only paragraphs that contain at least one non-exempt line.
3499 // Paragraphs consisting entirely of link ref defs or standalone links
3500 // should not trigger normalization.
3501 let paragraph_count = blocks
3502 .iter()
3503 .filter(|b| {
3504 if let Block::Paragraph { lines: para_lines, .. } = b {
3505 !para_lines
3506 .iter()
3507 .all(|(line, line_num)| is_exempt_line(line, *line_num))
3508 } else {
3509 false
3510 }
3511 })
3512 .count();
3513 if paragraph_count > 1 {
3514 // Multiple non-exempt paragraph blocks should be normalized
3515 return true;
3516 }
3517
3518 // Single paragraph block: normalize if it has multiple content lines
3519 if content_lines.len() > 1 {
3520 return true;
3521 }
3522 }
3523
3524 false
3525 };
3526
3527 // Integrate MD030 list-marker spacing (and MD007's text-aligned
3528 // continuation). In Default/Normalize modes — the modes that already
3529 // canonicalize spacing/indent — derive the number of spaces after the
3530 // marker from the configured MD030 values instead of forcing a single
3531 // space, then align continuation lines to the resulting content column.
3532 // Sentence/Semantic modes only adjust line breaks, so they keep the
3533 // marker spacing and indentation already present in the source.
3534 //
3535 // Re-spacing the marker moves the item's content column, so it is only
3536 // safe when this fix rewrites every line the item owns. Nested lists,
3537 // nested blockquotes, and blocks after a tab-indented continuation are
3538 // left to the outer loop and keep their source indentation; an item that owns
3539 // any of them keeps its source marker and content column, and MD030
3540 // re-spaces it together with everything it owns.
3541 // The MkDocs flavor enforces a rigid structural indent (4 spaces,
3542 // capped via max_list_continuation_indent) that Python-Markdown
3543 // requires; leave its specialized handling untouched.
3544 //
3545 // A content column moved left can reach the first line after the
3546 // item, which stopped short of the written column and so ended
3547 // the item. That line would join the item, so the marker keeps
3548 // its spacing then.
3549 //
3550 // A tab after the marker reaches the next tab stop for
3551 // CommonMark, while markdown-rs counts it as one space of
3552 // padding. Only the marker as written keeps both readings of
3553 // where the item's lines belong, so it is kept.
3554 let rewrites_whole_item = !item_owns_lines_from(ctx, lines, list_start, i);
3555 let next_line_column = ctx.lines[i..]
3556 .iter()
3557 .find(|line| !line.is_blank)
3558 .map(|line| line.visual_indent);
3559 let respaced = if matches!(config.reflow_mode, ReflowMode::Default | ReflowMode::Normalize)
3560 && !ctx.flavor.requires_strict_list_indent()
3561 && rewrites_whole_item
3562 && let Some(li) = ctx.lines[list_start].list_item.as_deref()
3563 {
3564 let bullet_len = li.marker.len();
3565 // The checkbox (e.g. `[ ] `) is content, not part of the list
3566 // marker MD030 governs; carry it over verbatim after the spacing.
3567 let checkbox_tail = marker[base_marker_len..].to_string();
3568 let indent_prefix = &marker[..item_indent];
3569
3570 // Decide single- vs multi-line spacing from the *rewritten* shape,
3571 // not the source. A multi-line source is not enough: plain prose
3572 // continuation collapses onto the marker line during reflow, so a
3573 // two-line bullet that fits becomes a single physical line and must
3574 // use MD030's single-line spacing (otherwise MD013 emits a result
3575 // that MD030 immediately rewrites). The emitted item stays
3576 // multi-line only when reflow cannot collapse it:
3577 // - the prose wraps past the line length, or
3578 // - a structural block remains (code, table, admonition, semantic
3579 // line, snippet, div marker, HTML) that is not joinable prose, or
3580 // - more than one paragraph remains (blank-separated).
3581 // The wrap test uses the single-line content column so it is
3582 // independent of the spacing we are about to choose (avoiding a
3583 // circular result). `ol-align-column` ignores this flag entirely in
3584 // expected_spaces(), so ordered lists are unaffected.
3585 //
3586 // This is the rewritten-shape counterpart of MD030's
3587 // `is_multi_line_list_item` (which keys off the *source*). The two
3588 // are related but technically distinct and intentionally separate;
3589 // if the notion of "multi-line" changes in one, revisit the other.
3590 let single_col = item_indent
3591 + bullet_len
3592 + self.list_spacing.expected_spaces(li.is_ordered, false, bullet_len)
3593 + checkbox_tail.len();
3594 let prose_wraps = !combined_content.is_empty()
3595 && self.calculate_effective_length(&format!("{}{combined_content}", " ".repeat(single_col)))
3596 > config.line_length.effective_limit();
3597 let has_structural_block = blocks.iter().any(|b| !matches!(b, Block::Paragraph { .. }));
3598 let multiple_paragraphs =
3599 blocks.iter().filter(|b| matches!(b, Block::Paragraph { .. })).count() > 1;
3600 let is_multi = prose_wraps || has_structural_block || multiple_paragraphs;
3601
3602 let spaces = self.list_spacing.expected_spaces(li.is_ordered, is_multi, bullet_len);
3603 let new_marker = format!("{indent_prefix}{}{}{checkbox_tail}", li.marker, " ".repeat(spaces));
3604 let new_col = new_marker.chars().count();
3605 let shift = new_col as isize - source_content_col as isize;
3606 Some((new_marker, new_col, shift))
3607 } else {
3608 None
3609 }
3610 .filter(|(_, new_col, _)| {
3611 tab_widening == 0
3612 && next_line_column.is_none_or(|column| !(*new_col..source_content_col).contains(&column))
3613 });
3614 let (marker, indent_size, code_indent_shift) = respaced.unwrap_or_else(|| {
3615 // The item is emitted exactly as written and the lines it owns
3616 // never move. Re-emitting the normalized marker here would narrow
3617 // the content column while leaving those lines behind. MkDocs
3618 // keeps its structural continuation indent; elsewhere a line
3619 // stays inside the item at the column the parser gives it.
3620 let marker = source_marker.map_or(marker, |m| m.text);
3621 let indent_size = if matches!(config.reflow_mode, ReflowMode::Default | ReflowMode::Normalize)
3622 && !ctx.flavor.requires_strict_list_indent()
3623 {
3624 item_content_col + tab_widening
3625 } else {
3626 indent_size
3627 };
3628 (marker, indent_size, 0isize)
3629 });
3630 let indent_size = match (config.reflow_mode, nested_paragraph) {
3631 (ReflowMode::Default | ReflowMode::Normalize, Some((inner_marker_width, _))) => {
3632 indent_size + inner_marker_width
3633 }
3634 _ => indent_size,
3635 };
3636 let expected_indent = " ".repeat(indent_size);
3637
3638 // A colon-led line with a line of its paragraph before it opens a
3639 // definition and makes the item a definition list. Joining the
3640 // lines flattens that into prose, so the item is left as the
3641 // author wrote it. The first line of each paragraph is prose
3642 // whatever it starts with, since a definition needs a term on
3643 // the line before it. Content lines arrive with the item's own
3644 // indentation already off, which is what the marker's
3645 // indentation is counted from.
3646 let contains_definition_list = blocks.iter().any(|block| match block {
3647 Block::Paragraph { lines: para_lines, .. } => para_lines
3648 .iter()
3649 .skip(1)
3650 .any(|(line, _)| crate::utils::text_reflow::is_definition_list_marker(line)),
3651 _ => false,
3652 });
3653
3654 // Whether a run of prose lines between hard breaks, written as one
3655 // line, stays within the line length. The run is measured with
3656 // the prefix and the hard break it is emitted with: the marker
3657 // for the first run of the item, the continuation indent for the
3658 // others.
3659 let run_fits_on_one_line = |segment: &[(String, usize)], starts_item: bool| -> bool {
3660 if config.line_length.is_unlimited() {
3661 return true;
3662 }
3663 let ends_with_hard_break = |line: &str, line_num: usize| {
3664 has_hard_break(line) && !line_ends_in_code_span(ctx, line_num - 1)
3665 };
3666 let mut hard_break_width = 0;
3667 let segment_lines: Vec<String> = segment
3668 .iter()
3669 .map(|(line, line_num)| {
3670 let line = line.strip_suffix('\r').unwrap_or(line);
3671 if !ends_with_hard_break(line, *line_num) {
3672 line.to_string()
3673 } else if let Some(text) = line.strip_suffix('\\') {
3674 hard_break_width = 1;
3675 text.trim_end_matches([' ', '\t']).to_string()
3676 } else if let Some(text) = line.strip_suffix(" ") {
3677 hard_break_width = 2;
3678 text.trim_end_matches([' ', '\t']).to_string()
3679 } else {
3680 line.to_string()
3681 }
3682 })
3683 .collect();
3684 let joined =
3685 trim_breakable_whitespace(&Self::join_paragraph_lines(&segment_lines, config)).to_string();
3686 let prefix_width = if starts_item {
3687 self.calculate_string_length(&marker)
3688 } else {
3689 indent_size
3690 };
3691 self.calculate_effective_length(&joined) + prefix_width + hard_break_width
3692 <= config.line_length.get()
3693 };
3694
3695 // How many leading lines of a paragraph are kept as written: all
3696 // of them when every line is exempt, else the link reference
3697 // definitions opening it. After a task checkbox the item's first
3698 // line is inline text, so it is never a definition.
3699 let item_opens_with_checkbox = marker_len > base_marker_len;
3700 let verbatim_line_count = |block_idx: usize, para_lines: &[(String, usize)]| -> usize {
3701 if para_lines
3702 .iter()
3703 .all(|(line, line_num)| is_exempt_line(line, *line_num))
3704 {
3705 para_lines.len()
3706 } else {
3707 let skip_first = item_opens_with_checkbox && block_idx == 0;
3708 para_lines
3709 .iter()
3710 .enumerate()
3711 .take_while(|(idx, (line, _))| !(skip_first && *idx == 0) && may_be_link_ref_def(line))
3712 .count()
3713 }
3714 };
3715
3716 // A run of prose lines between hard breaks that spans several
3717 // physical lines. The reflow joins the runs that fit on one line
3718 // and writes the others back as they were, so an item with no
3719 // fitting run produces no change and no warning. Link reference
3720 // definitions opening a paragraph are kept as written and never
3721 // joined.
3722 let has_wrapped_prose_run = || {
3723 let ends_with_hard_break = |line: &str, line_num: usize| {
3724 has_hard_break(line) && !line_ends_in_code_span(ctx, line_num - 1)
3725 };
3726 blocks.iter().enumerate().any(|(block_idx, block)| {
3727 let Block::Paragraph { lines: para_lines, .. } = block else {
3728 return false;
3729 };
3730 let verbatim = verbatim_line_count(block_idx, para_lines);
3731 split_into_segments(¶_lines[verbatim..], ends_with_hard_break)
3732 .iter()
3733 .any(|segment| segment.len() > 1)
3734 })
3735 };
3736
3737 let needs_reflow = !contains_definition_list
3738 && !holds_definition_list(ctx, list_start, i - 1)
3739 && match config.reflow_mode {
3740 ReflowMode::SentencePack => true,
3741 ReflowMode::Normalize => {
3742 // Only reflow if:
3743 // 1. Any non-exempt paragraph, when joined, exceeds the limit, OR
3744 // 2. Any admonition content line exceeds the limit, OR
3745 // 3. The list item should be normalized (has multi-line plain text)
3746 let any_paragraph_exceeds = blocks.iter().any(|block| match block {
3747 Block::Paragraph { lines: para_lines, .. } => {
3748 if para_lines
3749 .iter()
3750 .all(|(line, line_num)| is_exempt_line(line, *line_num))
3751 {
3752 return false;
3753 }
3754 let joined = Self::join_paragraph_lines(
3755 ¶_lines.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(),
3756 config,
3757 );
3758 let with_marker =
3759 format!("{}{}", " ".repeat(indent_size), trim_breakable_whitespace(&joined));
3760 self.calculate_effective_length(&with_marker) > config.line_length.get()
3761 }
3762 Block::Admonition {
3763 content_lines,
3764 header_indent,
3765 ..
3766 } => content_lines.iter().any(|(content, indent)| {
3767 if content.is_empty() {
3768 return false;
3769 }
3770 let with_indent = format!("{}{}", " ".repeat(*indent.max(header_indent)), content);
3771 self.calculate_effective_length(&with_indent) > config.line_length.get()
3772 }),
3773 _ => false,
3774 });
3775 if any_paragraph_exceeds {
3776 true
3777 } else {
3778 should_normalize()
3779 }
3780 }
3781 ReflowMode::SentencePerLine => {
3782 // Check if list item has multiple sentences
3783 let sentences = split_into_sentences(
3784 &combined_content,
3785 Some(&defined_references),
3786 config.require_sentence_capital,
3787 config.cjk_soft_break,
3788 );
3789 sentences.len() > 1 || has_wrapped_prose_run()
3790 }
3791 ReflowMode::SemanticLineBreaks => {
3792 let sentences = split_into_sentences(
3793 &combined_content,
3794 Some(&defined_references),
3795 config.require_sentence_capital,
3796 config.cjk_soft_break,
3797 );
3798 sentences.len() > 1
3799 || (list_start..i).any(|line_idx| {
3800 let line = lines[line_idx];
3801 let trimmed = line.trim();
3802 if trimmed.is_empty() || is_exempt_line(line, line_idx + 1) {
3803 return false;
3804 }
3805 self.calculate_effective_length(line) > config.line_length.get()
3806 })
3807 }
3808 ReflowMode::Default => {
3809 // In default mode, only reflow if any individual non-exempt line exceeds limit
3810 (list_start..i).any(|line_idx| {
3811 let line = lines[line_idx];
3812 let trimmed = line.trim();
3813 // Skip blank lines and exempt lines
3814 if trimmed.is_empty() || is_exempt_line(line, line_idx + 1) {
3815 return false;
3816 }
3817 self.calculate_effective_length(line) > config.line_length.get()
3818 })
3819 }
3820 };
3821
3822 if needs_reflow {
3823 let start_range = ctx.whole_line_byte_range(list_start + 1);
3824 let end_line = i - 1;
3825 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
3826 ctx.line_text_byte_range(end_line + 1, 1, lines[end_line].len() + 1)
3827 } else {
3828 ctx.whole_line_byte_range(end_line + 1)
3829 };
3830 let byte_range = start_range.start..end_range.end;
3831
3832 // Reflow each block (paragraphs only, preserve code blocks)
3833 // When line_length = 0 (no limit), use a very large value for reflow
3834 let reflow_line_length = if config.line_length.is_unlimited() {
3835 usize::MAX
3836 } else {
3837 config.line_length.get().saturating_sub(indent_size).max(1)
3838 };
3839 let mut reflow_options = Self::reflow_options(ctx, config, reflow_line_length);
3840
3841 let mut result: Vec<String> = Vec::new();
3842 let mut is_first_block = true;
3843
3844 for (block_idx, block) in blocks.iter().enumerate() {
3845 match block {
3846 Block::Paragraph { lines: para_lines, .. } => {
3847 // A paragraph whose lines are all exempt (link ref defs,
3848 // standalone links) is kept as written, since reflowing
3849 // would corrupt link ref defs. Link reference definitions
3850 // opening a paragraph are kept as written too: a definition
3851 // ends at its line, so text joined onto its destination
3852 // would turn it into visible text. Only what follows is
3853 // reflowed.
3854 let verbatim = verbatim_line_count(block_idx, para_lines);
3855 let (verbatim_lines, prose_lines) = para_lines.split_at(verbatim);
3856
3857 for (line, _) in verbatim_lines {
3858 if is_first_block {
3859 // The item's first line still carries the
3860 // marker padding past its first space, which
3861 // `marker` already accounts for.
3862 result.push(format!("{marker}{}", line.trim_start()));
3863 is_first_block = false;
3864 } else {
3865 result.push(format!("{expected_indent}{line}"));
3866 }
3867 }
3868 if !prose_lines.is_empty() {
3869 // Split the paragraph into segments at hard break boundaries
3870 // Each segment can be reflowed independently
3871 // Trailing spaces that a code span carries across
3872 // the line break are code, not a hard break.
3873 let ends_with_hard_break = |line: &str, line_num: usize| {
3874 has_hard_break(line) && !line_ends_in_code_span(ctx, line_num - 1)
3875 };
3876 let segments = split_into_segments(prose_lines, ends_with_hard_break);
3877
3878 for (segment_idx, segment) in segments.iter().enumerate() {
3879 // Check if this segment ends with a hard break and what type
3880 let hard_break_type = segment.last().and_then(|(line, line_num)| {
3881 let line = line.strip_suffix('\r').unwrap_or(line);
3882 if !ends_with_hard_break(line, *line_num) {
3883 None
3884 } else if line.ends_with('\\') {
3885 Some("\\")
3886 } else if line.ends_with(" ") {
3887 Some(" ")
3888 } else {
3889 None
3890 }
3891 });
3892
3893 // Join and reflow the segment (removing the hard break marker for processing)
3894 let segment_for_reflow: Vec<String> = segment
3895 .iter()
3896 .map(|(line, line_num)| {
3897 // Strip hard break marker (2 spaces or backslash) for reflow processing
3898 if !ends_with_hard_break(line, *line_num) {
3899 line.clone()
3900 } else if line.ends_with('\\') {
3901 line[..line.len() - 1].trim_end_matches([' ', '\t']).to_string()
3902 } else if line.ends_with(" ") {
3903 line[..line.len() - 2].trim_end_matches([' ', '\t']).to_string()
3904 } else {
3905 line.clone()
3906 }
3907 })
3908 .collect();
3909
3910 let segment_text = trim_breakable_whitespace(&Self::join_paragraph_lines(
3911 &segment_for_reflow,
3912 config,
3913 ))
3914 .to_string();
3915 // A single sentence over several lines that would not
3916 // fit on one line stays as written, hard break and all.
3917 let starts_item = is_first_block && segment_idx == 0;
3918 if config.reflow_mode == ReflowMode::SentencePerLine
3919 && segment.len() > 1
3920 && !run_fits_on_one_line(segment, starts_item)
3921 && split_into_sentences(
3922 &segment_text,
3923 Some(&defined_references),
3924 config.require_sentence_capital,
3925 config.cjk_soft_break,
3926 )
3927 .len()
3928 == 1
3929 {
3930 for (_, line_num) in segment {
3931 let written = ctx.lines[line_num - 1].content(ctx.content);
3932 result.push(written.strip_suffix('\r').unwrap_or(written).to_string());
3933 }
3934 is_first_block = false;
3935 } else if !segment_text.is_empty() {
3936 reflow_options.first_line_length = if config.reflow_mode
3937 == ReflowMode::SentencePack
3938 && is_first_block
3939 && segment_idx == 0
3940 && !config.line_length.is_unlimited()
3941 {
3942 Some(
3943 config
3944 .line_length
3945 .effective_limit()
3946 .saturating_sub(self.calculate_string_length(&marker))
3947 .max(1),
3948 )
3949 } else {
3950 None
3951 };
3952 let mut reflowed =
3953 crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
3954
3955 if is_first_block && segment_idx == 0 {
3956 // An item's text can open containers of its own
3957 // (`- > quote`, `- - nested`). A line holding only
3958 // their markers opens them empty, and the text
3959 // after it then sits outside them, so the markers
3960 // keep their first word even when it overflows.
3961 while reflowed.len() > 1 && holds_only_container_markers(&reflowed[0]) {
3962 let next = reflowed.remove(1);
3963 reflowed[0] = format!("{} {next}", reflowed[0]);
3964 }
3965 // First segment of first block starts with marker
3966 result.push(format!("{marker}{}", reflowed[0]));
3967 for line in reflowed.iter().skip(1) {
3968 result.push(format!("{expected_indent}{line}"));
3969 }
3970 is_first_block = false;
3971 } else {
3972 // Subsequent segments
3973 for line in reflowed {
3974 result.push(format!("{expected_indent}{line}"));
3975 }
3976 }
3977
3978 // If this segment had a hard break, add it back to the last line
3979 // Preserve the original hard break format (backslash or two spaces)
3980 if let Some(break_marker) = hard_break_type
3981 && let Some(last_line) = result.last_mut()
3982 {
3983 last_line.push_str(break_marker);
3984 }
3985 }
3986 }
3987 }
3988
3989 // Separate the next block as the source did. Only add a
3990 // blank line if not already ending with one (avoids double blanks).
3991 if blocks.get(block_idx + 1).is_some_and(Block::wants_preceding_blank)
3992 && result.last().is_none_or(|s: &String| !s.is_empty())
3993 {
3994 result.push(String::new());
3995 }
3996 }
3997 Block::Code {
3998 lines: code_lines,
3999 has_preceding_blank: _,
4000 } => {
4001 // Preserve code blocks as-is with original indentation
4002 // NOTE: Blank line before code block is handled by the previous block
4003 // (see paragraph block's logic above)
4004
4005 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
4006 if is_first_block && idx == 0 {
4007 // First line of first block gets marker
4008 result.push(format!(
4009 "{marker}{}",
4010 " ".repeat(orig_indent - marker_len) + content.as_str()
4011 ));
4012 is_first_block = false;
4013 } else if content.is_empty() {
4014 result.push(String::new());
4015 } else {
4016 // Shift nested code with the marker so it stays
4017 // aligned under content when MD030 widens spacing.
4018 result.push(format!(
4019 "{}{}",
4020 " ".repeat((*orig_indent as isize + code_indent_shift).max(0) as usize),
4021 content
4022 ));
4023 }
4024 }
4025 }
4026 Block::SemanticLine {
4027 content,
4028 has_preceding_blank,
4029 has_following_blank,
4030 } => {
4031 // Preserve semantic lines (NOTE:, WARNING:, etc.) as-is on their own line.
4032 // Without a blank line around it the marker line continues the
4033 // paragraph it sits in, so a blank line is written only where the
4034 // source had one; adding one would split that paragraph in two.
4035 if *has_preceding_blank
4036 && !is_first_block
4037 && result.last().is_none_or(|s: &String| !s.is_empty())
4038 {
4039 result.push(String::new());
4040 }
4041
4042 if is_first_block {
4043 // First block starts with marker
4044 result.push(format!("{marker}{content}"));
4045 is_first_block = false;
4046 } else {
4047 // Subsequent blocks use expected indent
4048 result.push(format!("{expected_indent}{content}"));
4049 }
4050
4051 if *has_following_blank && block_idx < blocks.len() - 1 {
4052 result.push(String::new());
4053 }
4054 }
4055 Block::SnippetLine { content, .. } | Block::DivMarker { content, .. } => {
4056 // Snippet delimiters (-8<-) and div markers (::: opening or
4057 // closing) keep their own line, and the blank line the
4058 // source had after one.
4059 if is_first_block {
4060 result.push(format!("{marker}{content}"));
4061 is_first_block = false;
4062 } else {
4063 result.push(format!("{expected_indent}{content}"));
4064 }
4065 if blocks.get(block_idx + 1).is_some_and(Block::wants_preceding_blank) {
4066 result.push(String::new());
4067 }
4068 }
4069 Block::Html {
4070 lines: html_lines,
4071 has_preceding_blank: _,
4072 } => {
4073 // HTML text keeps its indentation past the item's content
4074 // column, which is part of the block (inside a `<pre>`,
4075 // visibly), so it moves with the marker like code does.
4076 // NOTE: Blank line before HTML block is handled by the previous block
4077
4078 for (idx, (line, orig_indent)) in html_lines.iter().enumerate() {
4079 if is_first_block && idx == 0 {
4080 // First line of first block gets marker
4081 result.push(format!("{marker}{line}"));
4082 is_first_block = false;
4083 } else if line.is_empty() {
4084 // Preserve blank lines inside HTML blocks
4085 result.push(String::new());
4086 } else {
4087 result.push(format!(
4088 "{}{line}",
4089 " ".repeat((*orig_indent as isize + code_indent_shift).max(0) as usize)
4090 ));
4091 }
4092 }
4093
4094 // Add blank line after HTML block if there's a next block.
4095 // Only add if not already ending with one (avoids double blanks
4096 // when the HTML block itself contained a trailing blank line).
4097 if blocks.get(block_idx + 1).is_some_and(Block::wants_preceding_blank)
4098 && result.last().is_none_or(|s: &String| !s.is_empty())
4099 {
4100 result.push(String::new());
4101 }
4102 }
4103 Block::Table {
4104 lines: table_lines,
4105 has_preceding_blank: _,
4106 } => {
4107 // Preserve table rows verbatim with their original indentation.
4108 // Reflowing rows would corrupt column alignment and inject `|`
4109 // characters mid-paragraph (issue #590).
4110 // The leading blank line is emitted by the previous block.
4111 for (idx, (content, orig_indent)) in table_lines.iter().enumerate() {
4112 if is_first_block && idx == 0 {
4113 // First line of first block gets the list marker
4114 result.push(format!(
4115 "{marker}{}",
4116 " ".repeat(orig_indent.saturating_sub(marker_len)) + content.as_str()
4117 ));
4118 is_first_block = false;
4119 } else {
4120 // Shift nested table rows with the marker so they
4121 // stay aligned when MD030 widens marker spacing.
4122 result.push(format!(
4123 "{}{}",
4124 " ".repeat((*orig_indent as isize + code_indent_shift).max(0) as usize),
4125 content
4126 ));
4127 }
4128 }
4129
4130 // Add blank line after table block if there's a next block.
4131 if blocks.get(block_idx + 1).is_some_and(Block::wants_preceding_blank)
4132 && result.last().is_none_or(|s: &String| !s.is_empty())
4133 {
4134 result.push(String::new());
4135 }
4136 }
4137 Block::Admonition {
4138 header,
4139 header_indent,
4140 content_lines: admon_lines,
4141 } => {
4142 // Reconstruct admonition block with header at original indent
4143 // and body content reflowed to fit within the line length limit
4144
4145 // Add blank line before admonition if not first block
4146 if !is_first_block && result.last().is_none_or(|s: &String| !s.is_empty()) {
4147 result.push(String::new());
4148 }
4149
4150 // Output the header at its original indent
4151 let header_indent_str = " ".repeat(*header_indent);
4152 if is_first_block {
4153 result.push(format!(
4154 "{marker}{}",
4155 " ".repeat(header_indent.saturating_sub(marker_len)) + header.as_str()
4156 ));
4157 is_first_block = false;
4158 } else {
4159 result.push(format!("{header_indent_str}{header}"));
4160 }
4161
4162 // Derive body indent from the first non-empty content line's
4163 // stored indent, falling back to header_indent + 4 for
4164 // empty-body admonitions
4165 let body_indent = admon_lines
4166 .iter()
4167 .find(|(content, _)| !content.is_empty())
4168 .map_or(header_indent + 4, |(_, indent)| *indent);
4169 let body_indent_str = " ".repeat(body_indent);
4170
4171 // Segment body content into code blocks (verbatim) and
4172 // text paragraphs (reflowable), separated by blank lines.
4173 // Code lines store (content, orig_indent) to reconstruct
4174 // internal indentation relative to body_indent.
4175 enum AdmonSegment {
4176 Text(Vec<String>),
4177 Code(Vec<(String, usize)>),
4178 }
4179
4180 let mut segments: Vec<AdmonSegment> = Vec::new();
4181 let mut current_text: Vec<String> = Vec::new();
4182 let mut current_code: Vec<(String, usize)> = Vec::new();
4183 let mut in_admon_code = false;
4184 // Track the opening fence character so closing fences
4185 // must match (backticks close backticks, tildes close tildes)
4186 let mut fence_char: char = '`';
4187
4188 // Opening fences: ``` or ~~~ followed by optional info string
4189 let get_opening_fence = |s: &str| -> Option<(char, usize)> {
4190 let t = s.trim_start();
4191 if t.starts_with("```") {
4192 Some(('`', t.bytes().take_while(|&b| b == b'`').count()))
4193 } else if t.starts_with("~~~") {
4194 Some(('~', t.bytes().take_while(|&b| b == b'~').count()))
4195 } else {
4196 None
4197 }
4198 };
4199 // Closing fences: ONLY fence chars + optional trailing spaces
4200 let get_closing_fence = |s: &str| -> Option<(char, usize)> {
4201 let t = s.trim();
4202 if t.starts_with("```") && t.bytes().all(|b| b == b'`') {
4203 Some(('`', t.len()))
4204 } else if t.starts_with("~~~") && t.bytes().all(|b| b == b'~') {
4205 Some(('~', t.len()))
4206 } else {
4207 None
4208 }
4209 };
4210 let mut fence_len: usize = 3;
4211
4212 for (content, orig_indent) in admon_lines {
4213 if in_admon_code {
4214 // Closing fence must use the same character, be
4215 // at least as long, and have no info string
4216 if let Some((ch, len)) = get_closing_fence(content)
4217 && ch == fence_char
4218 && len >= fence_len
4219 {
4220 current_code.push((content.clone(), *orig_indent));
4221 in_admon_code = false;
4222 segments.push(AdmonSegment::Code(std::mem::take(&mut current_code)));
4223 continue;
4224 }
4225 current_code.push((content.clone(), *orig_indent));
4226 } else if let Some((ch, len)) = get_opening_fence(content) {
4227 if !current_text.is_empty() {
4228 segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
4229 }
4230 in_admon_code = true;
4231 fence_char = ch;
4232 fence_len = len;
4233 current_code.push((content.clone(), *orig_indent));
4234 } else if content.is_empty() {
4235 if !current_text.is_empty() {
4236 segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
4237 }
4238 } else {
4239 current_text.push(content.clone());
4240 }
4241 }
4242 if in_admon_code && !current_code.is_empty() {
4243 segments.push(AdmonSegment::Code(std::mem::take(&mut current_code)));
4244 }
4245 if !current_text.is_empty() {
4246 segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
4247 }
4248
4249 // Build reflow options once for all text segments
4250 let admon_reflow_length = if config.line_length.is_unlimited() {
4251 usize::MAX
4252 } else {
4253 config.line_length.get().saturating_sub(body_indent).max(1)
4254 };
4255
4256 let admon_reflow_options = Self::reflow_options(ctx, config, admon_reflow_length);
4257
4258 // Output each segment
4259 for segment in &segments {
4260 // Blank line before each segment (after the header or previous segment)
4261 result.push(String::new());
4262
4263 match segment {
4264 AdmonSegment::Code(lines) => {
4265 for (line, orig_indent) in lines {
4266 if line.is_empty() {
4267 // Preserve blank lines inside code blocks
4268 result.push(String::new());
4269 } else {
4270 // Reconstruct with body_indent + any extra
4271 // indentation the line had beyond body_indent
4272 let extra = orig_indent.saturating_sub(body_indent);
4273 let indent_str = " ".repeat(body_indent + extra);
4274 result.push(format!("{indent_str}{line}"));
4275 }
4276 }
4277 }
4278 AdmonSegment::Text(lines) => {
4279 let paragraph_text =
4280 trim_breakable_whitespace(&Self::join_paragraph_lines(lines, config))
4281 .to_string();
4282 if paragraph_text.is_empty() {
4283 continue;
4284 }
4285 let reflowed = crate::utils::text_reflow::reflow_line(
4286 ¶graph_text,
4287 &admon_reflow_options,
4288 );
4289 for line in &reflowed {
4290 result.push(format!("{body_indent_str}{line}"));
4291 }
4292 }
4293 }
4294 }
4295
4296 // Add blank line after admonition if there's a next block
4297 if blocks.get(block_idx + 1).is_some_and(Block::wants_preceding_blank)
4298 && result.last().is_none_or(|s: &String| !s.is_empty())
4299 {
4300 result.push(String::new());
4301 }
4302 }
4303 }
4304 }
4305
4306 let reflowed_text = result.join(line_ending);
4307
4308 // Preserve trailing newline
4309 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
4310 format!("{reflowed_text}{line_ending}")
4311 } else {
4312 reflowed_text
4313 };
4314
4315 // Get the original text to compare
4316 let original_text = &ctx.content[byte_range.clone()];
4317
4318 // Physical-line-length scan, shared by the Normalize-mode gate and its
4319 // message. The list-item reflow preserves code blocks, HTML blocks,
4320 // admonition headers, fence markers, semantic markers, and snippet/div
4321 // markers verbatim; only paragraph content and admonition bodies are
4322 // restructured. Only those lines drive the length warning, so that
4323 // preserved-but-overlong content does not keep the paragraph-level
4324 // warning alive when the reflow would not fix that line.
4325 let should_count_for_length = |line_idx: usize| -> bool {
4326 let line = lines[line_idx];
4327 let trimmed = line.trim();
4328 if trimmed.is_empty() || is_exempt_line(line, line_idx + 1) {
4329 return false;
4330 }
4331 let info = &ctx.lines[line_idx];
4332 if info.in_code_block || info.in_html_block {
4333 return false;
4334 }
4335 if info.in_admonition && mkdocs_admonitions::is_admonition_start(line) {
4336 return false;
4337 }
4338 if is_fence_marker(line) || is_semantic_line(line) {
4339 return false;
4340 }
4341 if is_snippet_block_delimiter(line) {
4342 return false;
4343 }
4344 if line.trim_start().starts_with(":::") {
4345 return false;
4346 }
4347 true
4348 };
4349 let max_physical_length = (list_start..i)
4350 .filter(|&idx| should_count_for_length(idx))
4351 .map(|idx| self.calculate_effective_length(lines[idx]))
4352 .max()
4353 .unwrap_or(0);
4354 // `line-length = 0` means "no limit", so no physical line can be
4355 // "over"; the message below then describes a structural join rather
4356 // than a length violation.
4357 let any_paragraph_line_over =
4358 !config.line_length.is_unlimited() && max_physical_length > config.line_length.get();
4359
4360 // Normalize mode reflows list-item prose just like paragraphs:
4361 // joining continuation lines and re-wrapping to `line-length`.
4362 // `prose_changed` is true only when the reflow alters the words or
4363 // line breaks, not when it would merely re-indent continuation
4364 // lines or trim trailing whitespace. Comparing the texts with each
4365 // line's leading and trailing whitespace removed isolates "did the
4366 // words/line breaks change" from "did the surrounding whitespace
4367 // change". Continuation indentation is MD077's responsibility and
4368 // trailing whitespace is MD009's; an MD013 warning for either would
4369 // both duplicate those rules and resurface a persistent advisory on
4370 // already-fitting items that users disable MD013 fixing to avoid.
4371 let prose_changed = {
4372 let stripped = |text: &str| text.lines().map(str::trim).collect::<Vec<_>>().join("\n");
4373 stripped(original_text) != stripped(&replacement)
4374 };
4375 // Warn when the reflow rewraps prose (the normalize feature for
4376 // list items), or when a physical line genuinely exceeds the limit
4377 // and the reflow can change something (a true length violation,
4378 // even if all that changes is the continuation indent). A line that
4379 // is already optimal in both respects produces no warning.
4380 let gate_ok = prose_changed || (any_paragraph_line_over && original_text != replacement);
4381 if gate_ok {
4382 // Generate an appropriate message based on why reflow is needed
4383 let message = match config.reflow_mode {
4384 ReflowMode::SentencePack => "List item should pack complete sentences".to_string(),
4385 ReflowMode::SentencePerLine => {
4386 let num_sentences = split_into_sentences(
4387 &combined_content,
4388 Some(&defined_references),
4389 config.require_sentence_capital,
4390 config.cjk_soft_break,
4391 )
4392 .len();
4393 let num_lines = content_lines.len();
4394 if num_lines == 1 {
4395 // Single line with multiple sentences
4396 format!("Line contains {num_sentences} sentences (one sentence per line required)")
4397 } else {
4398 // Multiple lines - could be split sentences or mixed
4399 format!(
4400 "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
4401 )
4402 }
4403 }
4404 ReflowMode::SemanticLineBreaks => {
4405 let num_sentences = split_into_sentences(
4406 &combined_content,
4407 Some(&defined_references),
4408 config.require_sentence_capital,
4409 config.cjk_soft_break,
4410 )
4411 .len();
4412 format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
4413 }
4414 ReflowMode::Normalize => {
4415 // When a physical line genuinely exceeds the limit, report
4416 // it as a length violation. Otherwise the reflow is a
4417 // structural normalization (joining/re-wrapping multi-line
4418 // content that already fits), mirroring the paragraph path.
4419 if any_paragraph_line_over {
4420 format!(
4421 "Line length {} exceeds {} characters",
4422 max_physical_length,
4423 config.line_length.get()
4424 )
4425 } else {
4426 format!(
4427 "List item could be normalized to use line length of {} characters",
4428 config.line_length.get()
4429 )
4430 }
4431 }
4432 ReflowMode::Default => {
4433 // Report the actual longest non-exempt line, not the combined content
4434 let max_length = (list_start..i)
4435 .filter(|&line_idx| {
4436 let line = lines[line_idx];
4437 let trimmed = line.trim();
4438 !trimmed.is_empty() && !is_exempt_line(line, line_idx + 1)
4439 })
4440 .map(|line_idx| self.calculate_effective_length(lines[line_idx]))
4441 .max()
4442 .unwrap_or(0);
4443 format!(
4444 "Line length {} exceeds {} characters",
4445 max_length,
4446 config.line_length.get()
4447 )
4448 }
4449 };
4450
4451 warnings.push(LintWarning {
4452 rule_name: Some(self.name().to_string()),
4453 message,
4454 line: list_start + 1,
4455 column: 1,
4456 end_line: end_line + 1,
4457 end_column: lines[end_line].chars().count() + 1,
4458 severity: Severity::Warning,
4459 fix: Some(crate::rule::Fix::new(byte_range, replacement)),
4460 });
4461 }
4462 }
4463 continue;
4464 }
4465
4466 // A definition's text reflows below, inside the definition. The rest
4467 // of a definition list is left as written: a term is one line of its
4468 // own, and a line holding a definition's marker but none of its text
4469 // opens a block the definition nests (a list, a quote, a code block)
4470 // or leaves the text for the lines below it.
4471 let definition_text = ctx.definition_text_at(line_num).cloned();
4472 if definition_text.is_none() && ctx.is_in_definition_list(line_num) {
4473 i += 1;
4474 continue;
4475 }
4476
4477 // Found start of a paragraph - collect all lines in it
4478 let paragraph_start = i;
4479 let mut paragraph_lines = vec![lines[i]];
4480 i += 1;
4481
4482 while i < lines.len() {
4483 let next_line = lines[i];
4484 let next_line_num = i + 1;
4485 let next_trimmed = next_line.trim();
4486
4487 // The checks below read one line at a time, and some of what
4488 // they stop at only looks like a block start. A code span still
4489 // open at the end of the line above proves the parser read on,
4490 // and reflowing the paragraph without the rest of the span
4491 // would rewrite the whitespace the span holds.
4492 if line_ends_in_code_span(ctx, i - 1) {
4493 paragraph_lines.push(next_line);
4494 i += 1;
4495 continue;
4496 }
4497
4498 // Stop at paragraph boundaries
4499 if next_trimmed.is_empty()
4500 || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
4501 || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
4502 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
4503 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_comment)
4504 || ctx.line_holds_html_block(i)
4505 // Retain source boundaries before fence-like lines, even
4506 // when a backtick in the info string makes the fence invalid.
4507 // Joining these lines can change neighboring GFM autolinks.
4508 || (ctx.lines[i].visual_indent <= 3
4509 && (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~")))
4510 || ctx.line_info(next_line_num).is_some_and(|info| info.in_esm_block)
4511 || ctx.line_info(next_line_num).is_some_and(|info| info.in_jsx_expression)
4512 || ctx.line_info(next_line_num).is_some_and(|info| info.in_jsx_block)
4513 || ctx.line_info(next_line_num).is_some_and(|info| info.in_mdx_comment)
4514 || ctx
4515 .line_info(next_line_num)
4516 .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container)
4517 || (next_line_num > 0
4518 && next_line_num <= ctx.lines.len()
4519 && ctx.lines[next_line_num - 1].blockquote.is_some())
4520 || next_trimmed.starts_with('#')
4521 // A setext heading ends the paragraph before it. The flag is
4522 // set on every line of the heading's text, so the paragraph
4523 // stops at the first of them and absorbs no heading text.
4524 // That also protects the underline, which can only follow the
4525 // last text line, so absorbing the construct and joining it
4526 // into prose is unreachable from here. `is_horizontal_rule`
4527 // below catches a `---` underline only by coincidence (3+
4528 // dashes are also a thematic break); `=` and short `-` runs
4529 // have no such overlap.
4530 || is_setext_heading_text_line(ctx, next_line_num)
4531 || TableUtils::is_potential_table_row_with_flavor(next_line, ctx.flavor)
4532 || opens_bare_item(ctx, i)
4533 || (is_list_item(next_trimmed)
4534 && (in_list(paragraph_start) || {
4535 // Only an ordered marker numbered one interrupts a
4536 // top-level paragraph. Treating a wrapped `7.` or
4537 // `819)` as a new item adds list indentation inside
4538 // any code span that continues onto the next line.
4539 let digits = next_trimmed.bytes().take_while(u8::is_ascii_digit).count();
4540 digits == 0 || next_trimmed[..digits].trim_start_matches('0') == "1"
4541 }))
4542 || is_horizontal_rule(next_line)
4543 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
4544 || is_template_directive_only(next_line)
4545 || is_standalone_attr_list(next_line)
4546 || is_snippet_block_delimiter(next_line)
4547 || ctx.line_info(next_line_num).is_some_and(|info| info.is_div_marker)
4548 || is_html_only_line(next_line)
4549 || self.line_in_multiline_math_block(next_line_num, ctx, config)
4550 // A line that is one whole `$$...$$` expression renders as a
4551 // display block, so it ends the paragraph above it and is
4552 // reflowed on its own. A line touched by a code span crossing
4553 // one of its boundaries is code, not such a block.
4554 || ((is_self_contained_display_math_line(next_line)
4555 || self.line_is_standalone_bracket_math(next_line_num, ctx, config))
4556 && !line_touches_multiline_code_span(&code_span_touches, next_line_num))
4557 || standalone_link_ends_paragraph(ctx, next_line_num, config)
4558 // A definition's text ends where the parser ends it.
4559 || definition_text
4560 .as_ref()
4561 .is_some_and(|text| next_line_num > text.end_line)
4562 {
4563 break;
4564 }
4565
4566 // Check if the previous line ends with a hard break (2+ spaces or backslash)
4567 if i > 0 && has_hard_break(lines[i - 1]) {
4568 // Don't include lines after hard breaks in the same paragraph
4569 break;
4570 }
4571
4572 paragraph_lines.push(next_line);
4573 i += 1;
4574 }
4575
4576 // Compute the common leading indent of all non-empty paragraph lines,
4577 // but only when those lines are structurally inside a list block.
4578 // Indented continuation lines that follow a nested list arrive here
4579 // with their structural indentation intact (e.g. 2 spaces for a
4580 // top-level list item). Stripping the indent before reflow and
4581 // re-applying it afterward prevents the fixer from moving those
4582 // lines to column 0.
4583 //
4584 // The list-block guard is essential: top-level paragraphs that happen
4585 // to start with spaces (insignificant in Markdown) must NOT have those
4586 // spaces preserved or injected by the fixer.
4587 //
4588 // A lazy line falls short of the content of the item the paragraph
4589 // starts in, so its indentation does not count: the reflowed lines
4590 // belong to the item.
4591 //
4592 // Text under a marker with nothing after it is inside the item or
4593 // outside it according to how far its first line is indented, so
4594 // the first line's own indentation is kept for every line. A shared
4595 // indent taken from the other lines, which may be lazy or sit in
4596 // another item of a marker chain, could move the first line across
4597 // that boundary.
4598 let common_indent: String = if paragraph_start > 0 && opens_bare_item(ctx, paragraph_start - 1) {
4599 let first = paragraph_lines[0];
4600 first[..first.len() - first.trim_start().len()].to_string()
4601 } else if in_list(paragraph_start) {
4602 let indent_of = |l: &str| l.len() - l.trim_start().len();
4603 let item_content_col = ctx.lines[..paragraph_start].iter().rev().find_map(|info| {
4604 info.list_item
4605 .as_ref()
4606 // Even an empty marker without source padding has a
4607 // content column one space beyond the marker. Lazy
4608 // lines short of it cannot reduce the paragraph indent.
4609 .map(|item| item.content_column.max(item.marker_column + item.marker.len() + 1))
4610 .filter(|&col| col <= indent_of(paragraph_lines[0]))
4611 });
4612 let min_len = paragraph_lines
4613 .iter()
4614 .filter(|l| !l.trim().is_empty())
4615 .map(|l| indent_of(l))
4616 .filter(|&indent| item_content_col.is_none_or(|col| indent >= col))
4617 .min()
4618 .unwrap_or(0);
4619 paragraph_lines
4620 .iter()
4621 .find(|l| !l.trim().is_empty())
4622 .map(|l| l[..min_len].to_string())
4623 .unwrap_or_default()
4624 } else {
4625 String::new()
4626 };
4627
4628 // What the reflowed lines are written after. A definition's text
4629 // starting on its marker line keeps the marker and the spacing the
4630 // author gave it, and its other lines are indented to where the text
4631 // starts, which is the definition's content column, or to four
4632 // columns when that is less. Text starting on a later line, a later
4633 // paragraph of the definition or the part after a hard break, keeps
4634 // the indentation its first line has. Everything else takes the
4635 // common indent: the first line and the rest alike.
4636 let (first_prefix, rest_indent) = match &definition_text {
4637 Some(text) => {
4638 let indent_width = |line: &str| {
4639 crate::utils::calculate_indentation_width_default(&line[..line.len() - line.trim_start().len()])
4640 };
4641 let text_lines = &lines[text.start_line - 1..text.end_line];
4642 let marker_prefix = text.marker_prefix_len.map(|len| &text_lines[0][..len]);
4643 // The colon takes one column, and a tab after it reaches the
4644 // next tab stop. The other lines take at least four columns
4645 // even after a narrower marker (`: text`): they continue the
4646 // paragraph at any indentation in CommonMark, but
4647 // Python-Markdown keeps a line indented less than four in the
4648 // definition only when no other definition follows it
4649 // directly, and otherwise reads the text as terms of a new
4650 // list.
4651 let continuation = match marker_prefix {
4652 Some(prefix) => {
4653 crate::utils::calculate_indentation_width_default(&prefix.replacen(':', " ", 1)).max(4)
4654 }
4655 None => indent_width(text_lines[0]),
4656 };
4657 // A hard break splits the text into parts reflowed one at a
4658 // time. With a line after the break indented less than the
4659 // continuation, reflowing one part would leave the parts
4660 // indented differently, and Python-Markdown ends the
4661 // definition at the first line indented less than the lines
4662 // before it, so the text is left as written.
4663 let split_by_hard_break = text_lines[..text_lines.len() - 1]
4664 .iter()
4665 .any(|line| has_hard_break(line));
4666 if split_by_hard_break && text_lines[1..].iter().any(|line| indent_width(line) < continuation) {
4667 i = paragraph_start + paragraph_lines.len();
4668 continue;
4669 }
4670 match marker_prefix.filter(|_| text.start_line == paragraph_start + 1) {
4671 Some(prefix) => (prefix.to_string(), " ".repeat(continuation)),
4672 None => {
4673 let first_line = lines[paragraph_start];
4674 let indent = &first_line[..first_line.len() - first_line.trim_start().len()];
4675 (indent.to_string(), indent.to_string())
4676 }
4677 }
4678 }
4679 None => (common_indent.clone(), common_indent.clone()),
4680 };
4681
4682 // Combine paragraph lines into a single string for processing.
4683 // This must be done BEFORE the needs_reflow check for sentence-per-line mode.
4684 let stripped: Vec<&str> = if definition_text.is_some() {
4685 paragraph_lines
4686 .iter()
4687 .enumerate()
4688 .map(|(idx, l)| {
4689 if idx == 0 {
4690 &l[first_prefix.len()..]
4691 } else {
4692 l.trim_start()
4693 }
4694 })
4695 .collect()
4696 } else {
4697 paragraph_lines
4698 .iter()
4699 .map(|l| {
4700 if l.starts_with(common_indent.as_str()) {
4701 &l[common_indent.len()..]
4702 } else {
4703 l.trim_start()
4704 }
4705 })
4706 .collect()
4707 };
4708 if code_span_runs_into_indentation(&stripped) {
4709 i = paragraph_start + paragraph_lines.len();
4710 continue;
4711 }
4712 let paragraph_text = Self::join_paragraph_lines(&stripped, config);
4713
4714 // A colon-led line with a line of the paragraph before it opens a
4715 // definition, and joining the lines would flatten the definition
4716 // list into prose, so the paragraph is skipped. Its first line is
4717 // prose whatever it starts with, since a definition needs a term on
4718 // the line before it. The indentation a marker is allowed is
4719 // counted from the block's own content, so a list item's
4720 // indentation comes off first. A paragraph holding a line of a
4721 // definition the parser opens on a colon touching its text is
4722 // skipped too when its inline constructs differ between the two
4723 // readings, since the context reports only the parser's.
4724 let contains_definition_list = paragraph_lines.iter().skip(1).any(|line| {
4725 let content = line.strip_prefix(rest_indent.as_str()).unwrap_or(line.trim_start());
4726 crate::utils::text_reflow::is_definition_list_marker(content)
4727 }) || (paragraph_start..paragraph_start + paragraph_lines.len())
4728 .any(|idx| ctx.in_colon_definition(idx + 1));
4729
4730 if contains_definition_list {
4731 // Don't reflow definition lists - skip this paragraph
4732 i = paragraph_start + paragraph_lines.len();
4733 continue;
4734 }
4735
4736 // Skip reflowing if this paragraph contains MkDocs Snippets markers
4737 // Snippets blocks (-8<- ... -8<-) should be preserved exactly
4738 let contains_snippets = paragraph_lines.iter().any(|line| is_snippet_block_delimiter(line));
4739
4740 if contains_snippets {
4741 // Don't reflow Snippets blocks - skip this paragraph
4742 i = paragraph_start + paragraph_lines.len();
4743 continue;
4744 }
4745
4746 // Leave a line of a multi-line display-math block as it is, where
4747 // joining lines would corrupt the equation (see
4748 // `line_in_multiline_math_block`). Only the first line has to be
4749 // asked about: such a line ends the paragraph above it, so a
4750 // paragraph reaching here holds one only when it starts on one.
4751 //
4752 // Only that line is passed over, not the rest of what was collected
4753 // with it: prose written directly under the closing delimiter is an
4754 // ordinary paragraph and still reflows.
4755 if self.line_in_multiline_math_block(paragraph_start + 1, ctx, config) {
4756 i = paragraph_start + 1;
4757 continue;
4758 }
4759
4760 // A line that is one whole `$$...$$` expression is a display block:
4761 // it keeps the line it was written on, and the prose under it is an
4762 // ordinary paragraph that still reflows. The line above ended at
4763 // this one, so a paragraph reaching here holds one only when it
4764 // starts on one. A paragraph can start on a line touched by a code
4765 // span crossing one of its boundaries, under a hard break the span
4766 // holds, and its first line is code then.
4767 if (is_self_contained_display_math_line(lines[paragraph_start])
4768 || self.line_is_standalone_bracket_math(paragraph_start + 1, ctx, config))
4769 && !line_touches_multiline_code_span(&code_span_touches, paragraph_start + 1)
4770 {
4771 i = paragraph_start + 1;
4772 continue;
4773 }
4774
4775 // Check if this paragraph needs reflowing
4776 let needs_reflow = match config.reflow_mode {
4777 ReflowMode::SentencePack => true,
4778 ReflowMode::Normalize => self.normalize_mode_needs_reflow(paragraph_lines.iter().copied(), config),
4779 ReflowMode::SentencePerLine => {
4780 // In sentence-per-line mode, check if the JOINED paragraph has multiple sentences
4781 // Note: we check the joined text because sentences can span multiple lines
4782 let sentences = split_into_sentences(
4783 ¶graph_text,
4784 Some(&defined_references),
4785 config.require_sentence_capital,
4786 config.cjk_soft_break,
4787 );
4788
4789 // Always reflow if multiple sentences on one line
4790 if sentences.len() > 1 {
4791 true
4792 } else if paragraph_lines.len() > 1 {
4793 // For single-sentence paragraphs spanning multiple lines:
4794 // Reflow if they COULD fit on one line (respecting line-length constraint)
4795 if config.line_length.is_unlimited() {
4796 // No line-length constraint - always join single sentences
4797 true
4798 } else {
4799 // Only join if it fits within line-length.
4800 // paragraph_text has the prefix stripped, so add back
4801 // the prefix of the first line, the one line the
4802 // joined sentence is written on.
4803 let effective_length =
4804 self.calculate_effective_length(¶graph_text) + first_prefix.len();
4805 effective_length <= config.line_length.get()
4806 }
4807 } else {
4808 false
4809 }
4810 }
4811 ReflowMode::SemanticLineBreaks => {
4812 let sentences = split_into_sentences(
4813 ¶graph_text,
4814 Some(&defined_references),
4815 config.require_sentence_capital,
4816 config.cjk_soft_break,
4817 );
4818 // Reflow if multiple sentences, multiple lines, or any line exceeds limit
4819 sentences.len() > 1
4820 || paragraph_lines.len() > 1
4821 || paragraph_lines
4822 .iter()
4823 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
4824 }
4825 ReflowMode::Default => {
4826 // In default mode, only reflow if lines exceed limit
4827 paragraph_lines
4828 .iter()
4829 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
4830 }
4831 };
4832
4833 if needs_reflow {
4834 // Calculate byte range for this paragraph
4835 // Use whole_line_range for each line and combine
4836 let start_range = ctx.whole_line_byte_range(paragraph_start + 1);
4837 let end_line = paragraph_start + paragraph_lines.len() - 1;
4838
4839 // For the last line, we want to preserve any trailing newline
4840 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
4841 // Last line without trailing newline - use line_text_range
4842 ctx.line_text_byte_range(end_line + 1, 1, lines[end_line].len() + 1)
4843 } else {
4844 // Not the last line or has trailing newline - use whole_line_range
4845 ctx.whole_line_byte_range(end_line + 1)
4846 };
4847
4848 let byte_range = start_range.start..end_range.end;
4849
4850 // Check if the paragraph ends with a hard break and what type
4851 let hard_break_type = paragraph_lines.last().and_then(|line| {
4852 let line = line.strip_suffix('\r').unwrap_or(line);
4853 if line.ends_with('\\') {
4854 Some("\\")
4855 } else if line.ends_with(" ") {
4856 Some(" ")
4857 } else {
4858 None
4859 }
4860 });
4861
4862 // Reflow the paragraph
4863 // When line_length = 0 (no limit), use a very large value for reflow
4864 let reflow_line_length = if config.line_length.is_unlimited() {
4865 usize::MAX
4866 } else {
4867 config.line_length.get().saturating_sub(rest_indent.len()).max(1)
4868 };
4869 let mut reflow_options = Self::reflow_options(ctx, config, reflow_line_length);
4870 reflow_options.wrap_code_spans =
4871 config.reflow_break_code_spans && first_prefix.is_empty() && rest_indent.is_empty();
4872 if config.reflow_mode == ReflowMode::SentencePack && !config.line_length.is_unlimited() {
4873 reflow_options.first_line_length = Some(
4874 config
4875 .line_length
4876 .effective_limit()
4877 .saturating_sub(self.calculate_string_length(&first_prefix))
4878 .max(1),
4879 );
4880 }
4881 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
4882
4883 // Re-apply the prefix to each non-empty reflowed line so that the
4884 // replacement preserves the original structural indentation.
4885 for (idx, line) in reflowed.iter_mut().enumerate() {
4886 let prefix = if idx == 0 { &first_prefix } else { &rest_indent };
4887 if !line.is_empty() && !prefix.is_empty() {
4888 *line = format!("{prefix}{line}");
4889 }
4890 }
4891
4892 // If the original paragraph ended with a hard break, preserve it
4893 // Preserve the original hard break format (backslash or two spaces)
4894 if let Some(break_marker) = hard_break_type
4895 && !reflowed.is_empty()
4896 {
4897 let last_idx = reflowed.len() - 1;
4898 if !has_hard_break(&reflowed[last_idx]) {
4899 reflowed[last_idx].push_str(break_marker);
4900 }
4901 }
4902
4903 let reflowed_text = reflowed.join(line_ending);
4904
4905 // Preserve trailing newline if the original paragraph had one
4906 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
4907 format!("{reflowed_text}{line_ending}")
4908 } else {
4909 reflowed_text
4910 };
4911
4912 // Get the original text to compare
4913 let original_text = &ctx.content[byte_range.clone()];
4914
4915 // Only generate a warning if the replacement is different from the original
4916 if original_text != replacement {
4917 // Determine which line ranges and messages to report based on the reflow mode.
4918 let warnings_to_report: Vec<(usize, usize, String)> = match config.reflow_mode {
4919 ReflowMode::SentencePack => vec![(
4920 paragraph_start + 1,
4921 end_line + 1,
4922 "Paragraph should pack complete sentences".to_string(),
4923 )],
4924 ReflowMode::Default => {
4925 // In default mode, report a warning for *every* line in the paragraph
4926 // that exceeds the limit. Each warning will carry the same paragraph-level
4927 // fix, making all of them auto-fixable.
4928 paragraph_lines
4929 .iter()
4930 .enumerate()
4931 .filter(|(_, line)| self.calculate_effective_length(line) > config.line_length.get())
4932 .map(|(idx, _)| {
4933 let violating_line = paragraph_start + idx + 1;
4934 (
4935 violating_line,
4936 violating_line,
4937 format!("Line length exceeds {} characters", config.line_length.get()),
4938 )
4939 })
4940 .collect()
4941 }
4942 ReflowMode::Normalize => {
4943 // In normalize mode, report the whole paragraph as needing normalization.
4944 vec![(
4945 paragraph_start + 1,
4946 end_line + 1,
4947 format!(
4948 "Paragraph could be normalized to use line length of {} characters",
4949 config.line_length.get()
4950 ),
4951 )]
4952 }
4953 ReflowMode::SentencePerLine => {
4954 // In sentence-per-line mode, highlight the entire paragraph that needs reformatting.
4955 let num_sentences = split_into_sentences(
4956 ¶graph_text,
4957 Some(&defined_references),
4958 config.require_sentence_capital,
4959 config.cjk_soft_break,
4960 )
4961 .len();
4962 let message = if paragraph_lines.len() == 1 {
4963 // Single line with multiple sentences
4964 format!("Line contains {num_sentences} sentences (one sentence per line required)")
4965 } else {
4966 // Multiple lines - could be split sentences or mixed
4967 let num_lines = paragraph_lines.len();
4968 format!(
4969 "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
4970 )
4971 };
4972 vec![(paragraph_start + 1, paragraph_start + paragraph_lines.len(), message)]
4973 }
4974 ReflowMode::SemanticLineBreaks => {
4975 // In semantic-line-breaks mode, highlight the entire paragraph.
4976 let num_sentences = split_into_sentences(
4977 ¶graph_text,
4978 Some(&defined_references),
4979 config.require_sentence_capital,
4980 config.cjk_soft_break,
4981 )
4982 .len();
4983 vec![(
4984 paragraph_start + 1,
4985 paragraph_start + paragraph_lines.len(),
4986 format!("Paragraph should use semantic line breaks ({num_sentences} sentences)"),
4987 )]
4988 }
4989 };
4990
4991 // Generate the actual lint warnings. All warnings for this paragraph
4992 // share the same paragraph-level fix.
4993 for (w_start, w_end, msg) in warnings_to_report {
4994 warnings.push(LintWarning {
4995 rule_name: Some(self.name().to_string()),
4996 message: msg,
4997 line: w_start,
4998 column: 1,
4999 end_line: w_end,
5000 end_column: lines[w_end.saturating_sub(1)].chars().count() + 1,
5001 severity: Severity::Warning,
5002 fix: Some(crate::rule::Fix::new(byte_range.clone(), replacement.clone())),
5003 });
5004 }
5005 }
5006 }
5007 }
5008
5009 warnings
5010 }
5011
5012 /// Calculate string length based on the configured length mode
5013 fn calculate_string_length(&self, s: &str) -> usize {
5014 match self.config.length_mode {
5015 LengthMode::Chars => s.chars().count(),
5016 LengthMode::Visual => s.width(),
5017 LengthMode::Bytes => s.len(),
5018 }
5019 }
5020
5021 /// Calculate effective line length
5022 ///
5023 /// Returns the actual display length of the line using the configured length mode.
5024 fn calculate_effective_length(&self, line: &str) -> usize {
5025 self.calculate_string_length(line)
5026 }
5027
5028 /// Calculate line length with inline link/image URLs removed.
5029 ///
5030 /// For each inline link `[text](url)` or image `` on the line,
5031 /// computes the "savings" from removing the URL portion (keeping only `[text]`
5032 /// or `![alt]`). Returns `effective_length - total_savings`.
5033 ///
5034 /// Handles nested constructs (e.g., `[](url)`) by only counting the
5035 /// outermost construct to avoid double-counting.
5036 fn length_without_inline_link_urls(
5037 &self,
5038 effective_length: usize,
5039 line_number: usize,
5040 ctx: &crate::lint_context::LintContext,
5041 ) -> usize {
5042 let line_range = ctx.line_content_byte_range(line_number);
5043 let line_byte_end = line_range.end;
5044
5045 // Collect inline links/images on this line: (byte_offset, byte_end, text_only_display_len)
5046 let mut constructs: Vec<(usize, usize, usize)> = Vec::new();
5047
5048 for link in ctx.links_on_line(line_number) {
5049 if link.is_reference {
5050 continue;
5051 }
5052 if !matches!(link.link_type, LinkType::Inline) {
5053 continue;
5054 }
5055 if link.byte_end > line_byte_end {
5056 continue;
5057 }
5058 let text_only_len = 2 + self.calculate_string_length(&link.text);
5059 constructs.push((link.byte_offset, link.byte_end, text_only_len));
5060 }
5061
5062 for image in ctx.images_on_line(line_number) {
5063 if image.is_reference {
5064 continue;
5065 }
5066 if !matches!(image.link_type, LinkType::Inline) {
5067 continue;
5068 }
5069 if image.byte_end > line_byte_end {
5070 continue;
5071 }
5072 let text_only_len = 3 + self.calculate_string_length(&image.alt_text);
5073 constructs.push((image.byte_offset, image.byte_end, text_only_len));
5074 }
5075
5076 if constructs.is_empty() {
5077 return effective_length;
5078 }
5079
5080 // Sort by byte offset to handle overlapping/nested constructs
5081 constructs.sort_by_key(|&(start, _, _)| start);
5082
5083 let mut total_savings: usize = 0;
5084 let mut last_end: usize = 0;
5085
5086 for (start, end, text_only_len) in &constructs {
5087 // Skip constructs nested inside a previously counted one
5088 if *start < last_end {
5089 continue;
5090 }
5091 // Full construct length in configured length mode
5092 let full_source = &ctx.content[*start..*end];
5093 let full_len = self.calculate_string_length(full_source);
5094 total_savings += full_len.saturating_sub(*text_only_len);
5095 last_end = *end;
5096 }
5097
5098 effective_length.saturating_sub(total_savings)
5099 }
5100}