rumdl_lib/rules/md013_line_length/mod.rs
1/// Rule MD013: Line length
2///
3/// See [docs/md013.md](../../docs/md013.md) for full documentation, configuration, and examples.
4use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
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::LineIndex;
11use crate::utils::range_utils::calculate_excess_range;
12use crate::utils::regex_cache::{IMAGE_REF_PATTERN, LINK_REF_PATTERN, URL_PATTERN};
13use crate::utils::table_utils::TableUtils;
14use crate::utils::text_reflow::{
15 BlockquoteLineData, ReflowLengthMode, blockquote_continuation_style, dominant_blockquote_prefix,
16 reflow_blockquote_content, split_into_sentences,
17};
18use pulldown_cmark::LinkType;
19use toml;
20
21mod helpers;
22pub mod md013_config;
23use crate::utils::is_template_directive_only;
24use helpers::{
25 extract_list_marker_and_content, has_hard_break, is_github_alert_marker, is_horizontal_rule, is_html_only_line,
26 is_list_item, is_standalone_link_or_image_line, split_into_segments, trim_preserving_hard_break,
27};
28pub use md013_config::MD013Config;
29use md013_config::{LengthMode, ReflowMode};
30
31#[cfg(test)]
32mod tests;
33use unicode_width::UnicodeWidthStr;
34
35#[derive(Clone, Default)]
36pub struct MD013LineLength {
37 pub(crate) config: MD013Config,
38}
39
40/// Blockquote paragraph line collected for reflow, with original line index for range computation.
41struct CollectedBlockquoteLine {
42 line_idx: usize,
43 data: BlockquoteLineData,
44}
45
46impl MD013LineLength {
47 pub fn new(line_length: usize, code_blocks: bool, tables: bool, headings: bool, strict: bool) -> Self {
48 Self {
49 config: MD013Config {
50 line_length: crate::types::LineLength::new(line_length),
51 code_blocks,
52 tables,
53 headings,
54 paragraphs: true, // Default to true for backwards compatibility
55 blockquotes: true, // Default to true for backwards compatibility
56 strict,
57 reflow: false,
58 reflow_mode: ReflowMode::default(),
59 length_mode: LengthMode::default(),
60 abbreviations: Vec::new(),
61 require_sentence_capital: true,
62 },
63 }
64 }
65
66 pub fn from_config_struct(config: MD013Config) -> Self {
67 Self { config }
68 }
69
70 /// Return a clone with code block checking disabled.
71 /// Used for doc comment linting where code blocks are Rust code managed by rustfmt.
72 pub fn with_code_blocks_disabled(&self) -> Self {
73 let mut clone = self.clone();
74 clone.config.code_blocks = false;
75 clone
76 }
77
78 /// Convert MD013 LengthMode to text_reflow ReflowLengthMode
79 fn reflow_length_mode(&self) -> ReflowLengthMode {
80 match self.config.length_mode {
81 LengthMode::Chars => ReflowLengthMode::Chars,
82 LengthMode::Visual => ReflowLengthMode::Visual,
83 LengthMode::Bytes => ReflowLengthMode::Bytes,
84 }
85 }
86
87 fn should_ignore_line(
88 &self,
89 line: &str,
90 _lines: &[&str],
91 current_line: usize,
92 ctx: &crate::lint_context::LintContext,
93 ) -> bool {
94 if self.config.strict {
95 return false;
96 }
97
98 // Quick check for common patterns before expensive regex
99 let trimmed = line.trim();
100
101 // Only skip if the entire line is a URL (quick check first)
102 if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
103 return true;
104 }
105
106 // Only skip if the entire line is an image reference (quick check first)
107 if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
108 return true;
109 }
110
111 // Note: link reference definitions are handled as always-exempt (even in strict mode)
112 // in the main check loop, so they don't need to be checked here.
113
114 // Code blocks with long strings (only check if in code block)
115 if ctx.line_info(current_line + 1).is_some_and(|info| info.in_code_block)
116 && !trimmed.is_empty()
117 && !line.contains(' ')
118 && !line.contains('\t')
119 {
120 return true;
121 }
122
123 false
124 }
125
126 /// Check if rule should skip based on provided config (used for inline config support)
127 fn should_skip_with_config(&self, ctx: &crate::lint_context::LintContext, config: &MD013Config) -> bool {
128 // Skip if content is empty
129 if ctx.content.is_empty() {
130 return true;
131 }
132
133 // For sentence-per-line, semantic-line-breaks, or normalize mode, never skip based on line length
134 if config.reflow
135 && (config.reflow_mode == ReflowMode::SentencePerLine
136 || config.reflow_mode == ReflowMode::SemanticLineBreaks
137 || config.reflow_mode == ReflowMode::Normalize)
138 {
139 return false;
140 }
141
142 // Quick check: if total content is shorter than line limit, definitely skip
143 if ctx.content.len() <= config.line_length.get() {
144 return true;
145 }
146
147 // Skip if no line exceeds the limit
148 !ctx.lines.iter().any(|line| line.byte_len > config.line_length.get())
149 }
150
151 fn normalize_mode_needs_reflow<'a, I>(&self, lines: I, config: &MD013Config) -> bool
152 where
153 I: IntoIterator<Item = &'a str>,
154 {
155 let mut line_count = 0;
156 let check_length = !config.line_length.is_unlimited();
157
158 for line in lines {
159 line_count += 1;
160 if check_length && self.calculate_effective_length(line) > config.line_length.get() {
161 return true;
162 }
163 }
164
165 line_count > 1
166 }
167}
168
169impl Rule for MD013LineLength {
170 fn name(&self) -> &'static str {
171 "MD013"
172 }
173
174 fn description(&self) -> &'static str {
175 "Line length should not be excessive"
176 }
177
178 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
179 // Use pre-parsed inline config from LintContext
180 let config_override = ctx.inline_config().get_rule_config("MD013");
181
182 // Apply configuration override if present
183 let effective_config = if let Some(json_config) = config_override {
184 if let Some(obj) = json_config.as_object() {
185 let mut config = self.config.clone();
186 if let Some(line_length) = obj.get("line_length").and_then(serde_json::Value::as_u64) {
187 config.line_length = crate::types::LineLength::new(line_length as usize);
188 }
189 if let Some(code_blocks) = obj.get("code_blocks").and_then(serde_json::Value::as_bool) {
190 config.code_blocks = code_blocks;
191 }
192 if let Some(tables) = obj.get("tables").and_then(serde_json::Value::as_bool) {
193 config.tables = tables;
194 }
195 if let Some(headings) = obj.get("headings").and_then(serde_json::Value::as_bool) {
196 config.headings = headings;
197 }
198 if let Some(blockquotes) = obj.get("blockquotes").and_then(serde_json::Value::as_bool) {
199 config.blockquotes = blockquotes;
200 }
201 if let Some(strict) = obj.get("strict").and_then(serde_json::Value::as_bool) {
202 config.strict = strict;
203 }
204 if let Some(reflow) = obj.get("reflow").and_then(serde_json::Value::as_bool) {
205 config.reflow = reflow;
206 }
207 if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
208 config.reflow_mode = match reflow_mode {
209 "default" => ReflowMode::Default,
210 "normalize" => ReflowMode::Normalize,
211 "sentence-per-line" => ReflowMode::SentencePerLine,
212 "semantic-line-breaks" => ReflowMode::SemanticLineBreaks,
213 _ => ReflowMode::default(),
214 };
215 }
216 config
217 } else {
218 self.config.clone()
219 }
220 } else {
221 self.config.clone()
222 };
223
224 // Fast early return using should_skip with EFFECTIVE config (after inline overrides)
225 // But don't skip if we're in reflow mode with Normalize or SentencePerLine
226 if self.should_skip_with_config(ctx, &effective_config)
227 && !(effective_config.reflow
228 && (effective_config.reflow_mode == ReflowMode::Normalize
229 || effective_config.reflow_mode == ReflowMode::SentencePerLine
230 || effective_config.reflow_mode == ReflowMode::SemanticLineBreaks))
231 {
232 return Ok(Vec::new());
233 }
234
235 // Direct implementation without DocumentStructure
236 let mut warnings = Vec::new();
237
238 // Special handling: line_length = 0 means "no line length limit"
239 // Skip all line length checks, but still allow reflow if enabled
240 let skip_length_checks = effective_config.line_length.is_unlimited();
241
242 // Pre-filter lines that could be problematic to avoid processing all lines
243 let mut candidate_lines = Vec::new();
244 if !skip_length_checks {
245 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
246 // Skip front matter - it should never be linted
247 if line_info.in_front_matter {
248 continue;
249 }
250
251 // Quick length check first
252 if line_info.byte_len > effective_config.line_length.get() {
253 candidate_lines.push(line_idx);
254 }
255 }
256 }
257
258 // If no candidate lines and not in normalize or sentence-per-line mode, early return
259 if candidate_lines.is_empty()
260 && !(effective_config.reflow
261 && (effective_config.reflow_mode == ReflowMode::Normalize
262 || effective_config.reflow_mode == ReflowMode::SentencePerLine
263 || effective_config.reflow_mode == ReflowMode::SemanticLineBreaks))
264 {
265 return Ok(warnings);
266 }
267
268 let lines = ctx.raw_lines();
269
270 // Create a quick lookup set for heading lines
271 // We need this for both the heading skip check AND the paragraphs check
272 let heading_lines_set: std::collections::HashSet<usize> = ctx
273 .lines
274 .iter()
275 .enumerate()
276 .filter(|(_, line)| line.heading.is_some())
277 .map(|(idx, _)| idx + 1)
278 .collect();
279
280 // Use pre-computed table blocks from context
281 // We need this for both the table skip check AND the paragraphs check
282 let table_blocks = &ctx.table_blocks;
283 let mut table_lines_set = std::collections::HashSet::new();
284 for table in table_blocks {
285 table_lines_set.insert(table.header_line + 1);
286 table_lines_set.insert(table.delimiter_line + 1);
287 for &line in &table.content_lines {
288 table_lines_set.insert(line + 1);
289 }
290 }
291
292 // Process candidate lines for line length checks
293 'line_loop: for &line_idx in &candidate_lines {
294 let line_number = line_idx + 1;
295 let line = lines[line_idx];
296
297 // Calculate actual line length (used in warning messages)
298 let effective_length = self.calculate_effective_length(line);
299
300 // Use single line length limit for all content
301 let line_limit = effective_config.line_length.get();
302
303 // In non-strict mode, forgive the trailing non-whitespace run.
304 // If the line only exceeds the limit because of a long token at the end
305 // (URL, link chain, identifier), it passes. This matches markdownlint's
306 // behavior: line.replace(/\S*$/u, "#")
307 let check_length = if effective_config.strict {
308 effective_length
309 } else {
310 match line.rfind(char::is_whitespace) {
311 Some(pos) => {
312 let ws_char = line[pos..].chars().next().unwrap();
313 let prefix_end = pos + ws_char.len_utf8();
314 self.calculate_string_length(&line[..prefix_end]) + 1
315 }
316 None => 1, // No whitespace — entire line is a single token
317 }
318 };
319
320 // Skip lines where the check length is within the limit
321 if check_length <= line_limit {
322 continue;
323 }
324
325 // Semantic link understanding: suppress when excess comes entirely from inline URLs
326 if !effective_config.strict {
327 let text_only_length = self.calculate_text_only_length(effective_length, line_number, ctx);
328 if text_only_length <= line_limit {
329 continue;
330 }
331 }
332
333 // Skip mkdocstrings and pymdown blocks (already handled by LintContext)
334 if ctx.lines[line_idx].in_mkdocstrings || ctx.lines[line_idx].in_pymdown_block {
335 continue;
336 }
337
338 // Link reference definitions are always exempt, even in strict mode.
339 // There's no way to shorten them without breaking the URL.
340 // Also check after stripping list markers, since list items may
341 // contain link ref defs as their content.
342 {
343 let trimmed = line.trim();
344 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
345 continue;
346 }
347 if is_list_item(trimmed) {
348 let (_, content) = extract_list_marker_and_content(trimmed);
349 let content_trimmed = content.trim();
350 if content_trimmed.starts_with('[')
351 && content_trimmed.contains("]:")
352 && LINK_REF_PATTERN.is_match(content_trimmed)
353 {
354 continue;
355 }
356 }
357 }
358
359 // Skip various block types efficiently
360 if !effective_config.strict {
361 // Lines whose only content is a link/image are exempt.
362 // After stripping list markers, blockquote markers, and emphasis,
363 // if only a link or image remains, there is no way to shorten it.
364 if is_standalone_link_or_image_line(line) {
365 continue;
366 }
367
368 // Lines consisting entirely of HTML tags are exempt.
369 // Badge lines, images with attributes, and similar inline HTML
370 // are long due to URLs in attributes and can't be meaningfully shortened.
371 if is_html_only_line(line) {
372 continue;
373 }
374
375 // Skip setext heading underlines
376 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
377 continue;
378 }
379
380 // Skip block elements according to config flags
381 // The flags mean: true = check these elements, false = skip these elements
382 // So we skip when the flag is FALSE and the line is in that element type
383 if (!effective_config.headings && heading_lines_set.contains(&line_number))
384 || (!effective_config.code_blocks
385 && ctx.line_info(line_number).is_some_and(|info| info.in_code_block))
386 || (!effective_config.tables && table_lines_set.contains(&line_number))
387 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
388 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
389 || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block)
390 || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_expression)
391 || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_block)
392 || ctx.line_info(line_number).is_some_and(|info| info.in_mdx_comment)
393 || ctx.line_info(line_number).is_some_and(|info| info.in_pymdown_block)
394 {
395 continue;
396 }
397
398 // Check if this is a paragraph/regular text line
399 // If paragraphs = false, skip lines that are NOT in special blocks
400 // Blockquote content is treated as paragraph text, so it's not
401 // included in the special blocks list here.
402 if !effective_config.paragraphs {
403 let is_special_block = heading_lines_set.contains(&line_number)
404 || ctx.line_info(line_number).is_some_and(|info| info.in_code_block)
405 || table_lines_set.contains(&line_number)
406 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
407 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
408 || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block)
409 || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_expression)
410 || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_block)
411 || ctx.line_info(line_number).is_some_and(|info| info.in_mdx_comment)
412 || ctx
413 .line_info(line_number)
414 .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container);
415
416 // Skip regular paragraph text when paragraphs = false
417 if !is_special_block {
418 continue;
419 }
420 }
421
422 // Skip blockquote lines when blockquotes = false.
423 // Also skip lazy continuation lines that belong to a blockquote
424 // (lines without `>` prefix that follow a blockquote line).
425 if !effective_config.blockquotes {
426 if ctx.lines[line_number - 1].blockquote.is_some() {
427 continue;
428 }
429 // Check for lazy continuation: scan backwards through
430 // non-blank lines to find if this paragraph started with
431 // a blockquote marker
432 if !line.trim().is_empty() {
433 let mut scan = line_number.saturating_sub(2);
434 loop {
435 if ctx.lines[scan].blockquote.is_some() {
436 // Found a blockquote ancestor — this is a lazy continuation
437 continue 'line_loop;
438 }
439 if lines[scan].trim().is_empty() || scan == 0 {
440 break;
441 }
442 scan -= 1;
443 }
444 }
445 }
446
447 // Skip lines that are only a URL, image ref, or link ref
448 if self.should_ignore_line(line, lines, line_idx, ctx) {
449 continue;
450 }
451 }
452
453 // In sentence-per-line mode, check if this is a single long sentence
454 // If so, emit a warning without a fix (user must manually rephrase)
455 if effective_config.reflow_mode == ReflowMode::SentencePerLine {
456 let sentences = split_into_sentences(line.trim());
457 if sentences.len() == 1 {
458 // Single sentence that's too long - warn but don't auto-fix
459 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
460
461 let (start_line, start_col, end_line, end_col) =
462 calculate_excess_range(line_number, line, line_limit);
463
464 warnings.push(LintWarning {
465 rule_name: Some(self.name().to_string()),
466 message,
467 line: start_line,
468 column: start_col,
469 end_line,
470 end_column: end_col,
471 severity: Severity::Warning,
472 fix: None, // No auto-fix for long single sentences
473 });
474 continue;
475 }
476 // Multiple sentences will be handled by paragraph-based reflow
477 continue;
478 }
479
480 // In semantic-line-breaks mode, skip per-line checks —
481 // all reflow is handled at the paragraph level with cascading splits
482 if effective_config.reflow_mode == ReflowMode::SemanticLineBreaks {
483 continue;
484 }
485
486 // Don't provide fix for individual lines when reflow is enabled
487 // Paragraph-based fixes will be handled separately
488 let fix = None;
489
490 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
491
492 // Calculate precise character range for the excess portion
493 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
494
495 warnings.push(LintWarning {
496 rule_name: Some(self.name().to_string()),
497 message,
498 line: start_line,
499 column: start_col,
500 end_line,
501 end_column: end_col,
502 severity: Severity::Warning,
503 fix,
504 });
505 }
506
507 // If reflow is enabled, generate paragraph-based fixes
508 if effective_config.reflow {
509 let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, lines);
510 // Merge paragraph warnings with line warnings, removing duplicates
511 for pw in paragraph_warnings {
512 // Remove any line warnings that overlap with this paragraph
513 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
514 warnings.push(pw);
515 }
516 }
517
518 Ok(warnings)
519 }
520
521 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
522 // For CLI usage, apply fixes from warnings
523 // LSP will use the warning-based fixes directly
524 let warnings = self.check(ctx)?;
525 let warnings =
526 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
527
528 // If there are no fixes, return content unchanged
529 if !warnings.iter().any(|w| w.fix.is_some()) {
530 return Ok(ctx.content.to_string());
531 }
532
533 // Apply warning-based fixes
534 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
535 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
536 }
537
538 fn as_any(&self) -> &dyn std::any::Any {
539 self
540 }
541
542 fn category(&self) -> RuleCategory {
543 RuleCategory::Whitespace
544 }
545
546 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
547 self.should_skip_with_config(ctx, &self.config)
548 }
549
550 fn default_config_section(&self) -> Option<(String, toml::Value)> {
551 let default_config = MD013Config::default();
552 let json_value = serde_json::to_value(&default_config).ok()?;
553 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
554
555 if let toml::Value::Table(table) = toml_value {
556 if !table.is_empty() {
557 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
558 } else {
559 None
560 }
561 } else {
562 None
563 }
564 }
565
566 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
567 let mut aliases = std::collections::HashMap::new();
568 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
569 aliases.insert("strict_sentences".to_string(), "require-sentence-capital".to_string());
570 aliases.insert("strict-sentences".to_string(), "require-sentence-capital".to_string());
571 Some(aliases)
572 }
573
574 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
575 where
576 Self: Sized,
577 {
578 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
579 // Use global line_length if rule-specific config still has default value
580 if rule_config.line_length.get() == 80 {
581 rule_config.line_length = config.global.line_length;
582 }
583 Box::new(Self::from_config_struct(rule_config))
584 }
585}
586
587impl MD013LineLength {
588 fn is_blockquote_content_boundary(
589 &self,
590 content: &str,
591 line_num: usize,
592 ctx: &crate::lint_context::LintContext,
593 ) -> bool {
594 let trimmed = content.trim();
595
596 trimmed.is_empty()
597 || ctx.line_info(line_num).is_some_and(|info| {
598 info.in_code_block
599 || info.in_front_matter
600 || info.in_html_block
601 || info.in_html_comment
602 || info.in_esm_block
603 || info.in_jsx_expression
604 || info.in_jsx_block
605 || info.in_mdx_comment
606 || info.in_mkdocstrings
607 || info.in_pymdown_block
608 || info.in_mkdocs_container()
609 || info.is_div_marker
610 })
611 || trimmed.starts_with('#')
612 || trimmed.starts_with("```")
613 || trimmed.starts_with("~~~")
614 || trimmed.starts_with('>')
615 || TableUtils::is_potential_table_row(content)
616 || is_list_item(trimmed)
617 || is_horizontal_rule(content)
618 || (trimmed.starts_with('[') && content.contains("]:"))
619 || is_template_directive_only(content)
620 || is_standalone_attr_list(content)
621 || is_snippet_block_delimiter(content)
622 || is_github_alert_marker(trimmed)
623 || is_html_only_line(content)
624 }
625
626 fn generate_blockquote_paragraph_fix(
627 &self,
628 ctx: &crate::lint_context::LintContext,
629 config: &MD013Config,
630 lines: &[&str],
631 line_index: &LineIndex,
632 start_idx: usize,
633 line_ending: &str,
634 ) -> (Option<LintWarning>, usize) {
635 let Some(start_bq) = ctx.lines.get(start_idx).and_then(|line| line.blockquote.as_deref()) else {
636 return (None, start_idx + 1);
637 };
638 let target_level = start_bq.nesting_level;
639
640 let mut collected: Vec<CollectedBlockquoteLine> = Vec::new();
641 let mut i = start_idx;
642
643 while i < lines.len() {
644 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].data.content) {
645 break;
646 }
647
648 let line_num = i + 1;
649 if line_num > ctx.lines.len() {
650 break;
651 }
652
653 if lines[i].trim().is_empty() {
654 break;
655 }
656
657 let line_bq = ctx.lines[i].blockquote.as_deref();
658 if let Some(bq) = line_bq {
659 if bq.nesting_level != target_level {
660 break;
661 }
662
663 if self.is_blockquote_content_boundary(&bq.content, line_num, ctx) {
664 break;
665 }
666
667 collected.push(CollectedBlockquoteLine {
668 line_idx: i,
669 data: BlockquoteLineData::explicit(trim_preserving_hard_break(&bq.content), bq.prefix.clone()),
670 });
671 i += 1;
672 continue;
673 }
674
675 let lazy_content = lines[i].trim_start();
676 if self.is_blockquote_content_boundary(lazy_content, line_num, ctx) {
677 break;
678 }
679
680 collected.push(CollectedBlockquoteLine {
681 line_idx: i,
682 data: BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content)),
683 });
684 i += 1;
685 }
686
687 if collected.is_empty() {
688 return (None, start_idx + 1);
689 }
690
691 let next_idx = i;
692 let paragraph_start = collected[0].line_idx;
693 let end_line = collected[collected.len() - 1].line_idx;
694 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|l| l.data.clone()).collect();
695 let paragraph_text = line_data
696 .iter()
697 .map(|d| d.content.as_str())
698 .collect::<Vec<_>>()
699 .join(" ");
700
701 let contains_definition_list = line_data
702 .iter()
703 .any(|d| crate::utils::is_definition_list_item(&d.content));
704 if contains_definition_list {
705 return (None, next_idx);
706 }
707
708 let contains_snippets = line_data.iter().any(|d| is_snippet_block_delimiter(&d.content));
709 if contains_snippets {
710 return (None, next_idx);
711 }
712
713 let needs_reflow = match config.reflow_mode {
714 ReflowMode::Normalize => {
715 self.normalize_mode_needs_reflow(line_data.iter().map(|d| d.content.as_str()), config)
716 }
717 ReflowMode::SentencePerLine => {
718 let sentences = split_into_sentences(¶graph_text);
719 sentences.len() > 1 || line_data.len() > 1
720 }
721 ReflowMode::SemanticLineBreaks => {
722 let sentences = split_into_sentences(¶graph_text);
723 sentences.len() > 1
724 || line_data.len() > 1
725 || collected
726 .iter()
727 .any(|l| self.calculate_effective_length(lines[l.line_idx]) > config.line_length.get())
728 }
729 ReflowMode::Default => collected
730 .iter()
731 .any(|l| self.calculate_effective_length(lines[l.line_idx]) > config.line_length.get()),
732 };
733
734 if !needs_reflow {
735 return (None, next_idx);
736 }
737
738 let fallback_prefix = start_bq.prefix.clone();
739 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
740 let continuation_style = blockquote_continuation_style(&line_data);
741
742 let reflow_line_length = if config.line_length.is_unlimited() {
743 usize::MAX
744 } else {
745 config
746 .line_length
747 .get()
748 .saturating_sub(self.calculate_string_length(&explicit_prefix))
749 .max(1)
750 };
751
752 let reflow_options = crate::utils::text_reflow::ReflowOptions {
753 line_length: reflow_line_length,
754 break_on_sentences: true,
755 preserve_breaks: false,
756 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
757 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
758 abbreviations: config.abbreviations_for_reflow(),
759 length_mode: self.reflow_length_mode(),
760 attr_lists: ctx.flavor.supports_attr_lists(),
761 require_sentence_capital: config.require_sentence_capital,
762 max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
763 Some(4)
764 } else {
765 None
766 },
767 };
768
769 let reflowed_with_style =
770 reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &reflow_options);
771
772 if reflowed_with_style.is_empty() {
773 return (None, next_idx);
774 }
775
776 let reflowed_text = reflowed_with_style.join(line_ending);
777
778 let start_range = line_index.whole_line_range(paragraph_start + 1);
779 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
780 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
781 } else {
782 line_index.whole_line_range(end_line + 1)
783 };
784 let byte_range = start_range.start..end_range.end;
785
786 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
787 format!("{reflowed_text}{line_ending}")
788 } else {
789 reflowed_text
790 };
791
792 let original_text = &ctx.content[byte_range.clone()];
793 if original_text == replacement {
794 return (None, next_idx);
795 }
796
797 let (warning_line, warning_end_line) = match config.reflow_mode {
798 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
799 ReflowMode::SentencePerLine | ReflowMode::SemanticLineBreaks => (paragraph_start + 1, end_line + 1),
800 ReflowMode::Default => {
801 let violating_line = collected
802 .iter()
803 .find(|line| self.calculate_effective_length(lines[line.line_idx]) > config.line_length.get())
804 .map_or(paragraph_start + 1, |line| line.line_idx + 1);
805 (violating_line, violating_line)
806 }
807 };
808
809 let warning = LintWarning {
810 rule_name: Some(self.name().to_string()),
811 message: match config.reflow_mode {
812 ReflowMode::Normalize => format!(
813 "Paragraph could be normalized to use line length of {} characters",
814 config.line_length.get()
815 ),
816 ReflowMode::SentencePerLine => {
817 let num_sentences = split_into_sentences(¶graph_text).len();
818 if line_data.len() == 1 {
819 format!("Line contains {num_sentences} sentences (one sentence per line required)")
820 } else {
821 let num_lines = line_data.len();
822 format!(
823 "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
824 )
825 }
826 }
827 ReflowMode::SemanticLineBreaks => {
828 let num_sentences = split_into_sentences(¶graph_text).len();
829 format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
830 }
831 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
832 },
833 line: warning_line,
834 column: 1,
835 end_line: warning_end_line,
836 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
837 severity: Severity::Warning,
838 fix: Some(crate::rule::Fix::new(byte_range, replacement)),
839 };
840
841 (Some(warning), next_idx)
842 }
843
844 /// Generate paragraph-based fixes
845 fn generate_paragraph_fixes(
846 &self,
847 ctx: &crate::lint_context::LintContext,
848 config: &MD013Config,
849 lines: &[&str],
850 ) -> Vec<LintWarning> {
851 let mut warnings = Vec::new();
852 let line_index = LineIndex::new(ctx.content);
853
854 // Detect the content's line ending style to preserve it in replacements.
855 // The LSP receives content from editors which may use CRLF (Windows).
856 // Replacements must match the original line endings to avoid false positives.
857 let line_ending = crate::utils::line_ending::detect_line_ending(ctx.content);
858
859 let mut i = 0;
860 while i < lines.len() {
861 let line_num = i + 1;
862
863 // Handle blockquote paragraphs with style-preserving reflow.
864 // Skip blockquotes when blockquotes=false or paragraphs=false
865 if line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some() {
866 if !config.blockquotes || !config.paragraphs {
867 // Skip past all blockquote lines (explicit and lazy continuations).
868 // A lazy continuation is a non-blank line without `>` that follows
869 // a blockquote line and isn't a structural element.
870 let mut saw_explicit_bq = false;
871 while i < lines.len() && i < ctx.lines.len() {
872 if ctx.lines[i].blockquote.is_some() {
873 saw_explicit_bq = true;
874 i += 1;
875 } else if saw_explicit_bq
876 && !lines[i].trim().is_empty()
877 && !lines[i].trim_start().starts_with('#')
878 && !lines[i].trim_start().starts_with('>')
879 {
880 // Lazy continuation of preceding blockquote
881 i += 1;
882 } else {
883 break;
884 }
885 }
886 continue;
887 }
888 let (warning, next_idx) =
889 self.generate_blockquote_paragraph_fix(ctx, config, lines, &line_index, i, line_ending);
890 if let Some(warning) = warning {
891 warnings.push(warning);
892 }
893 i = next_idx;
894 continue;
895 }
896
897 // Skip special structures (but NOT MkDocs containers - those get special handling)
898 let should_skip_due_to_line_info = ctx.line_info(line_num).is_some_and(|info| {
899 info.in_code_block
900 || info.in_front_matter
901 || info.in_html_block
902 || info.in_html_comment
903 || info.in_esm_block
904 || info.in_jsx_expression
905 || info.in_jsx_block
906 || info.in_mdx_comment
907 || info.in_mkdocstrings
908 || info.in_pymdown_block
909 });
910
911 // Skip link reference definitions but NOT footnote definitions.
912 // Footnote definitions (`[^id]: prose`) contain reflowable text,
913 // while link reference definitions (`[ref]: URL`) contain URLs
914 // that cannot be shortened.
915 let is_link_ref_def =
916 lines[i].trim().starts_with('[') && !lines[i].trim().starts_with("[^") && lines[i].contains("]:");
917
918 if should_skip_due_to_line_info
919 || lines[i].trim().starts_with('#')
920 || TableUtils::is_potential_table_row(lines[i])
921 || lines[i].trim().is_empty()
922 || is_horizontal_rule(lines[i])
923 || is_template_directive_only(lines[i])
924 || is_link_ref_def
925 || ctx.line_info(line_num).is_some_and(|info| info.is_div_marker)
926 || is_html_only_line(lines[i])
927 {
928 i += 1;
929 continue;
930 }
931
932 // Handle footnote definitions: `[^id]: prose text that can be reflowed`
933 // Supports multi-paragraph footnotes with code blocks, blockquotes,
934 // tables, and lists preserved verbatim.
935 // Validate structure: must start with `[^`, contain `]:`, and the ID
936 // must not contain `[` or `]` (prevents false matches on nested brackets)
937 if lines[i].trim().starts_with("[^") && lines[i].contains("]:") && {
938 let after_caret = &lines[i].trim()[2..];
939 after_caret
940 .find("]:")
941 .is_some_and(|pos| pos > 0 && !after_caret[..pos].contains(['[', ']']))
942 } {
943 let footnote_start = i;
944 let line = lines[i];
945
946 // Extract the prefix `[^id]:`
947 let Some(colon_pos) = line.find("]:") else {
948 i += 1;
949 continue;
950 };
951 let prefix_end = colon_pos + 2;
952 let prefix = &line[..prefix_end];
953
954 // Content starts after `]: ` (with optional space)
955 let content_start = if line[prefix_end..].starts_with(' ') {
956 prefix_end + 1
957 } else {
958 prefix_end
959 };
960 let first_content = &line[content_start..];
961
962 // CommonMark footnotes use 4-space continuation indent
963 const FN_INDENT: usize = 4;
964
965 // --- Line classification for footnote content ---
966 #[derive(Debug, Clone)]
967 enum FnLineType {
968 Content(String),
969 Verbatim(String, usize), // preserved text, original indent
970 Empty,
971 }
972
973 // Helper: compute visual indent (tabs = 4 spaces)
974 let visual_indent = |s: &str| -> usize {
975 s.chars()
976 .take_while(|c| c.is_whitespace())
977 .map(|c| if c == '\t' { 4 } else { 1 })
978 .sum::<usize>()
979 };
980
981 // Helper: check if a trimmed line is a fence marker (homogeneous chars)
982 let is_fence = |s: &str| -> bool {
983 let t = s.trim();
984 let fence_char = t.chars().next();
985 matches!(fence_char, Some('`') | Some('~'))
986 && t.chars().take_while(|&c| c == fence_char.unwrap()).count() >= 3
987 };
988
989 // Helper: check if a trimmed line is a setext underline
990 let is_setext_underline = |s: &str| -> bool {
991 let t = s.trim();
992 !t.is_empty()
993 && (t.chars().all(|c| c == '=' || c == ' ') || t.chars().all(|c| c == '-' || c == ' '))
994 && t.contains(['=', '-'])
995 };
996
997 // Deferred body: `[^id]:\n content` — first line has no content,
998 // actual content starts on the next indented line
999 let deferred_body = first_content.trim().is_empty();
1000
1001 // Collect all lines belonging to this footnote definition
1002 let mut fn_lines: Vec<FnLineType> = Vec::new();
1003 if !deferred_body {
1004 fn_lines.push(FnLineType::Content(first_content.to_string()));
1005 }
1006 let mut last_consumed = i;
1007 i += 1;
1008
1009 // Strip only the footnote continuation indent, preserving
1010 // internal indentation (e.g., code block body indent)
1011 let strip_fn_indent = |s: &str| -> String {
1012 let mut chars = s.chars();
1013 let mut stripped = 0;
1014 while stripped < FN_INDENT {
1015 match chars.next() {
1016 Some('\t') => stripped += 4,
1017 Some(c) if c.is_whitespace() => stripped += 1,
1018 _ => break,
1019 }
1020 }
1021 chars.as_str().to_string()
1022 };
1023
1024 let mut in_fenced_code = false;
1025 let mut consecutive_blanks = 0u32;
1026
1027 while i < lines.len() {
1028 let next = lines[i];
1029 let next_trimmed = next.trim();
1030
1031 // Blank line handling
1032 if next_trimmed.is_empty() {
1033 consecutive_blanks += 1;
1034 // 2+ consecutive blanks terminate the footnote
1035 if consecutive_blanks >= 2 {
1036 break;
1037 }
1038
1039 // Inside a fenced code block, blank lines are part of the code
1040 if in_fenced_code {
1041 consecutive_blanks = 0; // Don't count blanks inside code blocks
1042 fn_lines.push(FnLineType::Verbatim(String::new(), 0));
1043 last_consumed = i;
1044 i += 1;
1045 continue;
1046 }
1047
1048 // Peek ahead: if next non-blank line is indented >= FN_INDENT,
1049 // this blank is an internal paragraph separator
1050 if i + 1 < lines.len() {
1051 let peek = lines[i + 1];
1052 let peek_indent = visual_indent(peek);
1053 if !peek.trim().is_empty() && peek_indent >= FN_INDENT {
1054 fn_lines.push(FnLineType::Empty);
1055 last_consumed = i;
1056 i += 1;
1057 continue;
1058 }
1059 }
1060 // No valid continuation after blank — end of footnote
1061 break;
1062 }
1063
1064 consecutive_blanks = 0;
1065 let indent = visual_indent(next);
1066
1067 // Not indented enough — end of footnote
1068 if indent < FN_INDENT {
1069 break;
1070 }
1071
1072 // Inside a fenced code block: everything is verbatim until closing fence
1073 if in_fenced_code {
1074 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1075 if is_fence(next_trimmed) {
1076 in_fenced_code = false;
1077 }
1078 last_consumed = i;
1079 i += 1;
1080 continue;
1081 }
1082
1083 // Fence opener — start verbatim code block
1084 if is_fence(next_trimmed) {
1085 in_fenced_code = true;
1086 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1087 last_consumed = i;
1088 i += 1;
1089 continue;
1090 }
1091
1092 // Indented code block: indent >= FN_INDENT + 4 (= 8 spaces)
1093 if indent >= FN_INDENT + 4 {
1094 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1095 last_consumed = i;
1096 i += 1;
1097 continue;
1098 }
1099
1100 // Structural content that must be preserved verbatim
1101 if next_trimmed.starts_with('#')
1102 || is_list_item(next_trimmed)
1103 || next_trimmed.starts_with('>')
1104 || TableUtils::is_potential_table_row(next_trimmed)
1105 || is_setext_underline(next_trimmed)
1106 || is_horizontal_rule(next_trimmed)
1107 || crate::utils::mkdocs_footnotes::is_footnote_definition(next_trimmed)
1108 {
1109 // Preserve verbatim: blockquotes, tables, lists, setext
1110 // underlines, and horizontal rules inside the footnote
1111 if next_trimmed.starts_with('>')
1112 || TableUtils::is_potential_table_row(next_trimmed)
1113 || is_list_item(next_trimmed)
1114 || is_setext_underline(next_trimmed)
1115 || is_horizontal_rule(next_trimmed)
1116 {
1117 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1118 last_consumed = i;
1119 i += 1;
1120 continue;
1121 }
1122 // Headings, new footnote defs, link refs — end the footnote
1123 break;
1124 }
1125
1126 // Link reference definitions inside footnotes are not reflowable
1127 if next_trimmed.starts_with('[')
1128 && !next_trimmed.starts_with("[^")
1129 && next_trimmed.contains("]:")
1130 && LINK_REF_PATTERN.is_match(next_trimmed)
1131 {
1132 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1133 last_consumed = i;
1134 i += 1;
1135 continue;
1136 }
1137
1138 // HTML-only lines inside footnotes are not reflowable
1139 if is_html_only_line(next_trimmed) {
1140 fn_lines.push(FnLineType::Verbatim(strip_fn_indent(next), indent));
1141 last_consumed = i;
1142 i += 1;
1143 continue;
1144 }
1145
1146 // Regular prose content
1147 fn_lines.push(FnLineType::Content(next_trimmed.to_string()));
1148 last_consumed = i;
1149 i += 1;
1150 }
1151
1152 // Nothing collected or only empty lines
1153 if fn_lines.iter().all(|l| matches!(l, FnLineType::Empty)) || fn_lines.is_empty() {
1154 continue;
1155 }
1156
1157 // --- Group into blocks ---
1158 #[derive(Debug)]
1159 enum FnBlock {
1160 Paragraph(Vec<String>),
1161 Verbatim(Vec<(String, usize)>), // (content, indent) preserved as-is
1162 }
1163
1164 let mut blocks: Vec<FnBlock> = Vec::new();
1165 let mut current_para: Vec<String> = Vec::new();
1166 let mut current_verbatim: Vec<(String, usize)> = Vec::new();
1167
1168 for fl in &fn_lines {
1169 match fl {
1170 FnLineType::Content(s) => {
1171 if !current_verbatim.is_empty() {
1172 blocks.push(FnBlock::Verbatim(std::mem::take(&mut current_verbatim)));
1173 }
1174 current_para.push(s.clone());
1175 }
1176 FnLineType::Verbatim(s, indent) => {
1177 if !current_para.is_empty() {
1178 blocks.push(FnBlock::Paragraph(std::mem::take(&mut current_para)));
1179 }
1180 current_verbatim.push((s.clone(), *indent));
1181 }
1182 FnLineType::Empty => {
1183 if !current_para.is_empty() {
1184 blocks.push(FnBlock::Paragraph(std::mem::take(&mut current_para)));
1185 }
1186 if !current_verbatim.is_empty() {
1187 blocks.push(FnBlock::Verbatim(std::mem::take(&mut current_verbatim)));
1188 }
1189 }
1190 }
1191 }
1192 if !current_para.is_empty() {
1193 blocks.push(FnBlock::Paragraph(current_para));
1194 }
1195 if !current_verbatim.is_empty() {
1196 blocks.push(FnBlock::Verbatim(current_verbatim));
1197 }
1198
1199 // --- Reflow paragraphs and reconstruct ---
1200 let prefix_display_width = prefix.chars().count() + 1; // +1 for space
1201 let reflow_line_length = if config.line_length.is_unlimited() {
1202 usize::MAX
1203 } else {
1204 config
1205 .line_length
1206 .get()
1207 .saturating_sub(FN_INDENT.max(prefix_display_width))
1208 .max(20)
1209 };
1210 let reflow_options = crate::utils::text_reflow::ReflowOptions {
1211 line_length: reflow_line_length,
1212 break_on_sentences: true,
1213 preserve_breaks: false,
1214 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1215 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
1216 abbreviations: config.abbreviations_for_reflow(),
1217 length_mode: self.reflow_length_mode(),
1218 attr_lists: ctx.flavor.supports_attr_lists(),
1219 require_sentence_capital: config.require_sentence_capital,
1220 max_list_continuation_indent: None,
1221 };
1222
1223 let indent_str = " ".repeat(FN_INDENT);
1224 let mut result_lines: Vec<String> = Vec::new();
1225 let mut is_first_block = true;
1226
1227 for block in &blocks {
1228 match block {
1229 FnBlock::Paragraph(para_lines) => {
1230 let paragraph_text = para_lines.join(" ");
1231 let paragraph_text = paragraph_text.trim();
1232 if paragraph_text.is_empty() {
1233 continue;
1234 }
1235
1236 let reflowed = crate::utils::text_reflow::reflow_line(paragraph_text, &reflow_options);
1237 if reflowed.is_empty() {
1238 continue;
1239 }
1240
1241 // Blank line separator between blocks
1242 if !result_lines.is_empty() {
1243 result_lines.push(String::new());
1244 }
1245
1246 for (idx, rline) in reflowed.iter().enumerate() {
1247 if is_first_block && idx == 0 {
1248 result_lines.push(format!("{prefix} {rline}"));
1249 } else {
1250 result_lines.push(format!("{indent_str}{rline}"));
1251 }
1252 }
1253 is_first_block = false;
1254 }
1255 FnBlock::Verbatim(verb_lines) => {
1256 // Blank line separator between blocks
1257 if !result_lines.is_empty() {
1258 result_lines.push(String::new());
1259 }
1260
1261 if is_first_block {
1262 // Verbatim as first block in a deferred-body footnote
1263 if deferred_body {
1264 result_lines.push(prefix.to_string());
1265 }
1266 is_first_block = false;
1267 }
1268 for (content, _orig_indent) in verb_lines {
1269 result_lines.push(format!("{indent_str}{content}"));
1270 }
1271 }
1272 }
1273 }
1274
1275 // If nothing was produced, skip
1276 if result_lines.is_empty() {
1277 continue;
1278 }
1279
1280 let reflowed_text = result_lines.join(line_ending);
1281
1282 // Calculate byte range using last_consumed
1283 let start_range = line_index.whole_line_range(footnote_start + 1);
1284 let end_range = if last_consumed == lines.len() - 1 && !ctx.content.ends_with('\n') {
1285 line_index.line_text_range(last_consumed + 1, 1, lines[last_consumed].len() + 1)
1286 } else {
1287 line_index.whole_line_range(last_consumed + 1)
1288 };
1289 let byte_range = start_range.start..end_range.end;
1290
1291 let replacement = if last_consumed < lines.len() - 1 || ctx.content.ends_with('\n') {
1292 format!("{reflowed_text}{line_ending}")
1293 } else {
1294 reflowed_text
1295 };
1296
1297 let original_text = &ctx.content[byte_range.clone()];
1298 let max_length = (footnote_start..=last_consumed)
1299 .map(|idx| self.calculate_effective_length(lines[idx]))
1300 .max()
1301 .unwrap_or(0);
1302 let line_limit = if config.line_length.is_unlimited() {
1303 usize::MAX
1304 } else {
1305 config.line_length.get()
1306 };
1307 if original_text != replacement && max_length > line_limit {
1308 warnings.push(LintWarning {
1309 rule_name: Some(self.name().to_string()),
1310 message: format!(
1311 "Line length {} exceeds {} characters",
1312 max_length,
1313 config.line_length.get()
1314 ),
1315 line: footnote_start + 1,
1316 column: 1,
1317 end_line: last_consumed + 1,
1318 end_column: lines[last_consumed].len() + 1,
1319 severity: Severity::Warning,
1320 fix: Some(crate::rule::Fix::new(byte_range, replacement)),
1321 });
1322 }
1323 continue;
1324 }
1325
1326 // Handle MkDocs container content (admonitions and tabs) with indent-preserving reflow
1327 if ctx
1328 .line_info(line_num)
1329 .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container)
1330 {
1331 // Skip admonition/tab marker lines — only reflow their indented content
1332 let current_line = lines[i];
1333 if mkdocs_admonitions::is_admonition_start(current_line) || mkdocs_tabs::is_tab_marker(current_line) {
1334 i += 1;
1335 continue;
1336 }
1337
1338 let container_start = i;
1339
1340 // Detect the actual indent level from the first content line
1341 // (supports nested admonitions with 8+ spaces)
1342 let first_line = lines[i];
1343 let base_indent_len = first_line.len() - first_line.trim_start().len();
1344 let base_indent: String = " ".repeat(base_indent_len);
1345
1346 // Collect consecutive MkDocs container paragraph lines
1347 let mut container_lines: Vec<&str> = Vec::new();
1348 while i < lines.len() {
1349 let current_line_num = i + 1;
1350 let line_info = ctx.line_info(current_line_num);
1351
1352 // Stop if we leave the MkDocs container
1353 if !line_info.is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container) {
1354 break;
1355 }
1356
1357 let line = lines[i];
1358
1359 // Stop at paragraph boundaries within the container
1360 if line.trim().is_empty() {
1361 break;
1362 }
1363
1364 // Skip list items, code blocks, headings, HTML-only lines within containers
1365 if is_list_item(line.trim())
1366 || line.trim().starts_with("```")
1367 || line.trim().starts_with("~~~")
1368 || line.trim().starts_with('#')
1369 || is_html_only_line(line)
1370 {
1371 break;
1372 }
1373
1374 container_lines.push(line);
1375 i += 1;
1376 }
1377
1378 if container_lines.is_empty() {
1379 // Must advance i to avoid infinite loop when we encounter
1380 // non-paragraph content (code block, list, heading, empty line)
1381 // at the start of an MkDocs container
1382 i += 1;
1383 continue;
1384 }
1385
1386 // Strip the base indent from each line and join for reflow
1387 let stripped_lines: Vec<&str> = container_lines
1388 .iter()
1389 .map(|line| {
1390 if line.starts_with(&base_indent) {
1391 &line[base_indent_len..]
1392 } else {
1393 line.trim_start()
1394 }
1395 })
1396 .collect();
1397 let paragraph_text = stripped_lines.join(" ");
1398
1399 // Check if reflow is needed
1400 let needs_reflow = match config.reflow_mode {
1401 ReflowMode::Normalize => self.normalize_mode_needs_reflow(container_lines.iter().copied(), config),
1402 ReflowMode::SentencePerLine => {
1403 let sentences = split_into_sentences(¶graph_text);
1404 sentences.len() > 1 || container_lines.len() > 1
1405 }
1406 ReflowMode::SemanticLineBreaks => {
1407 let sentences = split_into_sentences(¶graph_text);
1408 sentences.len() > 1
1409 || container_lines.len() > 1
1410 || container_lines
1411 .iter()
1412 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
1413 }
1414 ReflowMode::Default => container_lines
1415 .iter()
1416 .any(|line| self.calculate_effective_length(line) > config.line_length.get()),
1417 };
1418
1419 if !needs_reflow {
1420 continue;
1421 }
1422
1423 // Calculate byte range for this container paragraph
1424 let start_range = line_index.whole_line_range(container_start + 1);
1425 let end_line = container_start + container_lines.len() - 1;
1426 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1427 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1428 } else {
1429 line_index.whole_line_range(end_line + 1)
1430 };
1431 let byte_range = start_range.start..end_range.end;
1432
1433 // Reflow with adjusted line length (accounting for the 4-space indent)
1434 let reflow_line_length = if config.line_length.is_unlimited() {
1435 usize::MAX
1436 } else {
1437 config.line_length.get().saturating_sub(base_indent_len).max(1)
1438 };
1439 let reflow_options = crate::utils::text_reflow::ReflowOptions {
1440 line_length: reflow_line_length,
1441 break_on_sentences: true,
1442 preserve_breaks: false,
1443 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1444 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
1445 abbreviations: config.abbreviations_for_reflow(),
1446 length_mode: self.reflow_length_mode(),
1447 attr_lists: ctx.flavor.supports_attr_lists(),
1448 require_sentence_capital: config.require_sentence_capital,
1449 max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
1450 Some(4)
1451 } else {
1452 None
1453 },
1454 };
1455 let reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
1456
1457 // Re-add the 4-space indent to each reflowed line
1458 let reflowed_with_indent: Vec<String> =
1459 reflowed.iter().map(|line| format!("{base_indent}{line}")).collect();
1460 let reflowed_text = reflowed_with_indent.join(line_ending);
1461
1462 // Preserve trailing newline
1463 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1464 format!("{reflowed_text}{line_ending}")
1465 } else {
1466 reflowed_text
1467 };
1468
1469 // Only generate a warning if the replacement is different
1470 let original_text = &ctx.content[byte_range.clone()];
1471 if original_text != replacement {
1472 warnings.push(LintWarning {
1473 rule_name: Some(self.name().to_string()),
1474 message: format!(
1475 "Line length {} exceeds {} characters (in MkDocs container)",
1476 container_lines.iter().map(|l| l.len()).max().unwrap_or(0),
1477 config.line_length.get()
1478 ),
1479 line: container_start + 1,
1480 column: 1,
1481 end_line: end_line + 1,
1482 end_column: lines[end_line].len() + 1,
1483 severity: Severity::Warning,
1484 fix: Some(crate::rule::Fix::new(byte_range, replacement)),
1485 });
1486 }
1487 continue;
1488 }
1489
1490 // Helper function to detect semantic line markers
1491 let is_semantic_line = |content: &str| -> bool {
1492 let trimmed = content.trim_start();
1493 let semantic_markers = [
1494 "NOTE:",
1495 "WARNING:",
1496 "IMPORTANT:",
1497 "CAUTION:",
1498 "TIP:",
1499 "DANGER:",
1500 "HINT:",
1501 "INFO:",
1502 ];
1503 semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
1504 };
1505
1506 // Helper function to detect fence markers (opening or closing)
1507 let is_fence_marker = |content: &str| -> bool {
1508 let trimmed = content.trim_start();
1509 trimmed.starts_with("```") || trimmed.starts_with("~~~")
1510 };
1511
1512 // Check if this is a list item - handle it specially
1513 let trimmed = lines[i].trim();
1514 if is_list_item(trimmed) {
1515 // Collect the entire list item including continuation lines
1516 let list_start = i;
1517 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
1518 let marker_len = marker.len();
1519
1520 // Checkbox ([ ]/[x]/[X]) is inline content, not part of the list marker.
1521 // Use the base bullet/number marker width for continuation recognition
1522 // so that continuation lines at 2+ spaces are collected for "- [ ] " items.
1523 let base_marker_len = if marker.contains("[ ] ") || marker.contains("[x] ") || marker.contains("[X] ") {
1524 marker.find('[').unwrap_or(marker_len)
1525 } else {
1526 marker_len
1527 };
1528
1529 // MkDocs flavor requires at least 4 spaces for list continuation
1530 // after a blank line (multi-paragraph list items). For non-blank
1531 // continuation (lines directly following the marker line), use
1532 // the natural marker width so that 2-space indent is recognized.
1533 let item_indent = ctx.lines[i].indent;
1534 let min_continuation_indent = if ctx.flavor.requires_strict_list_indent() {
1535 // Use 4-space relative indent from the list item's nesting level
1536 item_indent + (base_marker_len - item_indent).max(4)
1537 } else {
1538 marker_len
1539 };
1540 let content_continuation_indent = base_marker_len;
1541
1542 // Track lines and their types (content, code block, fence, nested list)
1543 #[derive(Clone)]
1544 enum LineType {
1545 Content(String),
1546 CodeBlock(String, usize), // content and original indent
1547 SemanticLine(String), // Lines starting with NOTE:, WARNING:, etc that should stay separate
1548 SnippetLine(String), // MkDocs Snippets delimiters (-8<-) that must stay on their own line
1549 DivMarker(String), // Quarto/Pandoc div markers (::: opening or closing)
1550 AdmonitionHeader(String, usize), // header text (e.g. "!!! note") and original indent
1551 AdmonitionContent(String, usize), // body content text and original indent
1552 Empty,
1553 }
1554
1555 let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
1556 i += 1;
1557
1558 // Collect continuation lines using ctx.lines for metadata
1559 while i < lines.len() {
1560 let line_info = &ctx.lines[i];
1561
1562 // Use pre-computed is_blank from ctx
1563 if line_info.is_blank {
1564 // Empty line - check if next line is indented (part of list item)
1565 if i + 1 < lines.len() {
1566 let next_info = &ctx.lines[i + 1];
1567
1568 // Check if next line is indented enough to be continuation
1569 if !next_info.is_blank && next_info.indent >= min_continuation_indent {
1570 // This blank line is between paragraphs/blocks in the list item
1571 list_item_lines.push(LineType::Empty);
1572 i += 1;
1573 continue;
1574 }
1575 }
1576 // No indented line after blank, end of list item
1577 break;
1578 }
1579
1580 // Use pre-computed indent from ctx
1581 let indent = line_info.indent;
1582
1583 // Valid continuation must be indented at least content_continuation_indent.
1584 // For non-blank continuation, use marker_len (e.g. 2 for "- ").
1585 // MkDocs strict 4-space requirement applies only after blank lines.
1586 if indent >= content_continuation_indent {
1587 let trimmed = line_info.content(ctx.content).trim();
1588
1589 // Check for MkDocs admonition lines inside list items BEFORE
1590 // checking in_code_block. Lines inside code blocks within
1591 // admonitions have both in_admonition and in_code_block set;
1592 // admonition membership takes priority so the entire admonition
1593 // structure (including embedded code blocks) is preserved.
1594 if line_info.in_admonition {
1595 let raw_content = line_info.content(ctx.content);
1596 if mkdocs_admonitions::is_admonition_start(raw_content) {
1597 let header_text = raw_content[indent..].trim_end().to_string();
1598 list_item_lines.push(LineType::AdmonitionHeader(header_text, indent));
1599 } else {
1600 let body_text = raw_content[indent..].trim_end().to_string();
1601 list_item_lines.push(LineType::AdmonitionContent(body_text, indent));
1602 }
1603 i += 1;
1604 continue;
1605 }
1606
1607 // Use pre-computed in_code_block from ctx
1608 if line_info.in_code_block {
1609 list_item_lines.push(LineType::CodeBlock(
1610 line_info.content(ctx.content)[indent..].to_string(),
1611 indent,
1612 ));
1613 i += 1;
1614 continue;
1615 }
1616
1617 // Check if this is a SIBLING list item (breaks parent)
1618 // Nested lists are indented >= marker_len and are PART of the parent item
1619 // Siblings are at indent < marker_len (at or before parent marker)
1620 if is_list_item(trimmed) && indent < marker_len {
1621 // This is a sibling item at same or higher level - end parent item
1622 break;
1623 }
1624
1625 // Nested list items are always processed independently
1626 // by the outer loop, so break when we encounter one.
1627 // If a blank line was collected before this, uncollect it
1628 // so the outer loop preserves the blank between parent and nested.
1629 if is_list_item(trimmed) && indent >= marker_len {
1630 if matches!(list_item_lines.last(), Some(LineType::Empty)) {
1631 list_item_lines.pop();
1632 i -= 1;
1633 }
1634 break;
1635 }
1636
1637 // Normal continuation vs indented code block.
1638 // Use min_continuation_indent for the threshold since
1639 // code blocks start 4 spaces beyond the expected content
1640 // level (which is min_continuation_indent for MkDocs).
1641 if indent <= min_continuation_indent + 3 {
1642 // Extract content (remove indentation and trailing whitespace)
1643 // Preserve hard breaks (2 trailing spaces) while removing excessive whitespace
1644 // See: https://github.com/rvben/rumdl/issues/76
1645 let content = trim_preserving_hard_break(&line_info.content(ctx.content)[indent..]);
1646
1647 // Check if this is a div marker (::: opening or closing)
1648 // These must be preserved on their own line, not merged into paragraphs
1649 if line_info.is_div_marker {
1650 list_item_lines.push(LineType::DivMarker(content));
1651 }
1652 // Check if this is a fence marker (opening or closing)
1653 // These should be treated as code block lines, not paragraph content
1654 else if is_fence_marker(&content) {
1655 list_item_lines.push(LineType::CodeBlock(content, indent));
1656 }
1657 // Check if this is a semantic line (NOTE:, WARNING:, etc.)
1658 else if is_semantic_line(&content) {
1659 list_item_lines.push(LineType::SemanticLine(content));
1660 }
1661 // Check if this is a snippet block delimiter (-8<- or --8<--)
1662 // These must be preserved on their own lines for MkDocs Snippets extension
1663 else if is_snippet_block_delimiter(&content) {
1664 list_item_lines.push(LineType::SnippetLine(content));
1665 } else {
1666 list_item_lines.push(LineType::Content(content));
1667 }
1668 i += 1;
1669 } else {
1670 // indent >= min_continuation_indent + 4: indented code block
1671 list_item_lines.push(LineType::CodeBlock(
1672 line_info.content(ctx.content)[indent..].to_string(),
1673 indent,
1674 ));
1675 i += 1;
1676 }
1677 } else {
1678 // Not indented enough, end of list item
1679 break;
1680 }
1681 }
1682
1683 // Determine the output continuation indent.
1684 // Normalize/Default modes canonicalize to min_continuation_indent
1685 // (fixing over-indented continuation). Semantic/SentencePerLine
1686 // modes preserve the user's actual indent since they only fix
1687 // line breaking, not indentation.
1688 let indent_size = match config.reflow_mode {
1689 ReflowMode::SemanticLineBreaks | ReflowMode::SentencePerLine => {
1690 // Find indent of the first plain text continuation line,
1691 // skipping the marker line (index 0), nested list items,
1692 // code blocks, and blank lines.
1693 list_item_lines
1694 .iter()
1695 .enumerate()
1696 .skip(1)
1697 .find_map(|(k, lt)| {
1698 if matches!(lt, LineType::Content(_)) {
1699 Some(ctx.lines[list_start + k].indent)
1700 } else {
1701 None
1702 }
1703 })
1704 .unwrap_or(min_continuation_indent)
1705 }
1706 _ => min_continuation_indent,
1707 };
1708 // For checkbox items in mkdocs flavor, enforce minimum indent so
1709 // continuation lines use the structural list indent (4), not the
1710 // content-aligned indent (6) which Python-Markdown doesn't support
1711 let has_checkbox = base_marker_len < marker_len;
1712 let indent_size = if has_checkbox && ctx.flavor.requires_strict_list_indent() {
1713 indent_size.max(min_continuation_indent)
1714 } else {
1715 indent_size
1716 };
1717 let expected_indent = " ".repeat(indent_size);
1718
1719 // Split list_item_lines into blocks (paragraphs, code blocks, nested lists, semantic lines, and HTML blocks)
1720 #[derive(Clone)]
1721 enum Block {
1722 Paragraph(Vec<String>),
1723 Code {
1724 lines: Vec<(String, usize)>, // (content, indent) pairs
1725 has_preceding_blank: bool, // Whether there was a blank line before this block
1726 },
1727 SemanticLine(String), // Semantic markers like NOTE:, WARNING: that stay on their own line
1728 SnippetLine(String), // MkDocs Snippets delimiter that stays on its own line without extra spacing
1729 DivMarker(String), // Quarto/Pandoc div marker (::: opening or closing) preserved on its own line
1730 Html {
1731 lines: Vec<String>, // HTML content preserved exactly as-is
1732 has_preceding_blank: bool, // Whether there was a blank line before this block
1733 },
1734 Admonition {
1735 header: String, // e.g. "!!! note" or "??? warning \"Title\""
1736 header_indent: usize, // original indent of the header line
1737 content_lines: Vec<(String, usize)>, // (text, original_indent) pairs for body lines
1738 },
1739 }
1740
1741 // HTML tag detection helpers
1742 // Block-level HTML tags that should trigger HTML block detection
1743 const BLOCK_LEVEL_TAGS: &[&str] = &[
1744 "div",
1745 "details",
1746 "summary",
1747 "section",
1748 "article",
1749 "header",
1750 "footer",
1751 "nav",
1752 "aside",
1753 "main",
1754 "table",
1755 "thead",
1756 "tbody",
1757 "tfoot",
1758 "tr",
1759 "td",
1760 "th",
1761 "ul",
1762 "ol",
1763 "li",
1764 "dl",
1765 "dt",
1766 "dd",
1767 "pre",
1768 "blockquote",
1769 "figure",
1770 "figcaption",
1771 "form",
1772 "fieldset",
1773 "legend",
1774 "hr",
1775 "p",
1776 "h1",
1777 "h2",
1778 "h3",
1779 "h4",
1780 "h5",
1781 "h6",
1782 "style",
1783 "script",
1784 "noscript",
1785 ];
1786
1787 fn is_block_html_opening_tag(line: &str) -> Option<String> {
1788 let trimmed = line.trim();
1789
1790 // Check for HTML comments
1791 if trimmed.starts_with("<!--") {
1792 return Some("!--".to_string());
1793 }
1794
1795 // Check for opening tags
1796 if trimmed.starts_with('<') && !trimmed.starts_with("</") && !trimmed.starts_with("<!") {
1797 // Extract tag name from <tagname ...> or <tagname>
1798 let after_bracket = &trimmed[1..];
1799 if let Some(end) = after_bracket.find(|c: char| c.is_whitespace() || c == '>' || c == '/') {
1800 let tag_name = after_bracket[..end].to_lowercase();
1801
1802 // Only treat as block if it's a known block-level tag
1803 if BLOCK_LEVEL_TAGS.contains(&tag_name.as_str()) {
1804 return Some(tag_name);
1805 }
1806 }
1807 }
1808 None
1809 }
1810
1811 fn is_html_closing_tag(line: &str, tag_name: &str) -> bool {
1812 let trimmed = line.trim();
1813
1814 // Special handling for HTML comments
1815 if tag_name == "!--" {
1816 return trimmed.ends_with("-->");
1817 }
1818
1819 // Check for closing tags: </tagname> or </tagname ...>
1820 trimmed.starts_with(&format!("</{tag_name}>"))
1821 || trimmed.starts_with(&format!("</{tag_name} "))
1822 || (trimmed.starts_with("</") && trimmed[2..].trim_start().starts_with(tag_name))
1823 }
1824
1825 fn is_self_closing_tag(line: &str) -> bool {
1826 let trimmed = line.trim();
1827 trimmed.ends_with("/>")
1828 }
1829
1830 let mut blocks: Vec<Block> = Vec::new();
1831 let mut current_paragraph: Vec<String> = Vec::new();
1832 let mut current_code_block: Vec<(String, usize)> = Vec::new();
1833 let mut current_html_block: Vec<String> = Vec::new();
1834 let mut html_tag_stack: Vec<String> = Vec::new();
1835 let mut in_code = false;
1836 let mut in_html_block = false;
1837 let mut had_preceding_blank = false; // Track if we just saw an empty line
1838 let mut code_block_has_preceding_blank = false; // Track blank before current code block
1839 let mut html_block_has_preceding_blank = false; // Track blank before current HTML block
1840
1841 // Track admonition context for block building
1842 let mut in_admonition_block = false;
1843 let mut admonition_header: Option<(String, usize)> = None; // (header_text, indent)
1844 let mut admonition_content: Vec<(String, usize)> = Vec::new();
1845
1846 // Flush any pending admonition block into `blocks`
1847 let flush_admonition = |blocks: &mut Vec<Block>,
1848 in_admonition: &mut bool,
1849 header: &mut Option<(String, usize)>,
1850 content: &mut Vec<(String, usize)>| {
1851 if *in_admonition {
1852 if let Some((h, hi)) = header.take() {
1853 blocks.push(Block::Admonition {
1854 header: h,
1855 header_indent: hi,
1856 content_lines: std::mem::take(content),
1857 });
1858 }
1859 *in_admonition = false;
1860 }
1861 };
1862
1863 for line in &list_item_lines {
1864 match line {
1865 LineType::Empty => {
1866 if in_admonition_block {
1867 // Blank lines inside admonitions separate paragraphs within the body
1868 admonition_content.push((String::new(), 0));
1869 } else if in_code {
1870 current_code_block.push((String::new(), 0));
1871 } else if in_html_block {
1872 // Allow blank lines inside HTML blocks
1873 current_html_block.push(String::new());
1874 } else if !current_paragraph.is_empty() {
1875 blocks.push(Block::Paragraph(current_paragraph.clone()));
1876 current_paragraph.clear();
1877 }
1878 // Mark that we saw a blank line
1879 had_preceding_blank = true;
1880 }
1881 LineType::Content(content) => {
1882 flush_admonition(
1883 &mut blocks,
1884 &mut in_admonition_block,
1885 &mut admonition_header,
1886 &mut admonition_content,
1887 );
1888 // Check if we're currently in an HTML block
1889 if in_html_block {
1890 current_html_block.push(content.clone());
1891
1892 // Check if this line closes any open HTML tags
1893 if let Some(last_tag) = html_tag_stack.last() {
1894 if is_html_closing_tag(content, last_tag) {
1895 html_tag_stack.pop();
1896
1897 // If stack is empty, HTML block is complete
1898 if html_tag_stack.is_empty() {
1899 blocks.push(Block::Html {
1900 lines: current_html_block.clone(),
1901 has_preceding_blank: html_block_has_preceding_blank,
1902 });
1903 current_html_block.clear();
1904 in_html_block = false;
1905 }
1906 } else if let Some(new_tag) = is_block_html_opening_tag(content) {
1907 // Nested opening tag within HTML block
1908 if !is_self_closing_tag(content) {
1909 html_tag_stack.push(new_tag);
1910 }
1911 }
1912 }
1913 had_preceding_blank = false;
1914 } else {
1915 // Not in HTML block - check if this line starts one
1916 if let Some(tag_name) = is_block_html_opening_tag(content) {
1917 // Flush current paragraph before starting HTML block
1918 if in_code {
1919 blocks.push(Block::Code {
1920 lines: current_code_block.clone(),
1921 has_preceding_blank: code_block_has_preceding_blank,
1922 });
1923 current_code_block.clear();
1924 in_code = false;
1925 } else if !current_paragraph.is_empty() {
1926 blocks.push(Block::Paragraph(current_paragraph.clone()));
1927 current_paragraph.clear();
1928 }
1929
1930 // Start new HTML block
1931 in_html_block = true;
1932 html_block_has_preceding_blank = had_preceding_blank;
1933 current_html_block.push(content.clone());
1934
1935 // Check if it's self-closing or needs a closing tag
1936 if is_self_closing_tag(content) {
1937 // Self-closing tag - complete the HTML block immediately
1938 blocks.push(Block::Html {
1939 lines: current_html_block.clone(),
1940 has_preceding_blank: html_block_has_preceding_blank,
1941 });
1942 current_html_block.clear();
1943 in_html_block = false;
1944 } else {
1945 // Regular opening tag - push to stack
1946 html_tag_stack.push(tag_name);
1947 }
1948 } else {
1949 // Regular content line - add to paragraph
1950 if in_code {
1951 // Switching from code to content
1952 blocks.push(Block::Code {
1953 lines: current_code_block.clone(),
1954 has_preceding_blank: code_block_has_preceding_blank,
1955 });
1956 current_code_block.clear();
1957 in_code = false;
1958 }
1959 current_paragraph.push(content.clone());
1960 }
1961 had_preceding_blank = false; // Reset after content
1962 }
1963 }
1964 LineType::CodeBlock(content, indent) => {
1965 flush_admonition(
1966 &mut blocks,
1967 &mut in_admonition_block,
1968 &mut admonition_header,
1969 &mut admonition_content,
1970 );
1971 if in_html_block {
1972 // Switching from HTML block to code (shouldn't happen normally, but handle it)
1973 blocks.push(Block::Html {
1974 lines: current_html_block.clone(),
1975 has_preceding_blank: html_block_has_preceding_blank,
1976 });
1977 current_html_block.clear();
1978 html_tag_stack.clear();
1979 in_html_block = false;
1980 }
1981 if !in_code {
1982 // Switching from content to code
1983 if !current_paragraph.is_empty() {
1984 blocks.push(Block::Paragraph(current_paragraph.clone()));
1985 current_paragraph.clear();
1986 }
1987 in_code = true;
1988 // Record whether there was a blank line before this code block
1989 code_block_has_preceding_blank = had_preceding_blank;
1990 }
1991 current_code_block.push((content.clone(), *indent));
1992 had_preceding_blank = false; // Reset after code
1993 }
1994 LineType::SemanticLine(content) => {
1995 // Semantic lines are standalone - flush any current block and add as separate block
1996 flush_admonition(
1997 &mut blocks,
1998 &mut in_admonition_block,
1999 &mut admonition_header,
2000 &mut admonition_content,
2001 );
2002 if in_code {
2003 blocks.push(Block::Code {
2004 lines: current_code_block.clone(),
2005 has_preceding_blank: code_block_has_preceding_blank,
2006 });
2007 current_code_block.clear();
2008 in_code = false;
2009 } else if in_html_block {
2010 blocks.push(Block::Html {
2011 lines: current_html_block.clone(),
2012 has_preceding_blank: html_block_has_preceding_blank,
2013 });
2014 current_html_block.clear();
2015 html_tag_stack.clear();
2016 in_html_block = false;
2017 } else if !current_paragraph.is_empty() {
2018 blocks.push(Block::Paragraph(current_paragraph.clone()));
2019 current_paragraph.clear();
2020 }
2021 // Add semantic line as its own block
2022 blocks.push(Block::SemanticLine(content.clone()));
2023 had_preceding_blank = false; // Reset after semantic line
2024 }
2025 LineType::SnippetLine(content) => {
2026 // Snippet delimiters (-8<-) are standalone - flush any current block and add as separate block
2027 // Unlike semantic lines, snippet lines don't add extra blank lines around them
2028 flush_admonition(
2029 &mut blocks,
2030 &mut in_admonition_block,
2031 &mut admonition_header,
2032 &mut admonition_content,
2033 );
2034 if in_code {
2035 blocks.push(Block::Code {
2036 lines: current_code_block.clone(),
2037 has_preceding_blank: code_block_has_preceding_blank,
2038 });
2039 current_code_block.clear();
2040 in_code = false;
2041 } else if in_html_block {
2042 blocks.push(Block::Html {
2043 lines: current_html_block.clone(),
2044 has_preceding_blank: html_block_has_preceding_blank,
2045 });
2046 current_html_block.clear();
2047 html_tag_stack.clear();
2048 in_html_block = false;
2049 } else if !current_paragraph.is_empty() {
2050 blocks.push(Block::Paragraph(current_paragraph.clone()));
2051 current_paragraph.clear();
2052 }
2053 // Add snippet line as its own block
2054 blocks.push(Block::SnippetLine(content.clone()));
2055 had_preceding_blank = false;
2056 }
2057 LineType::DivMarker(content) => {
2058 // Div markers (::: opening or closing) are standalone structural delimiters
2059 // Flush any current block and add as separate block
2060 flush_admonition(
2061 &mut blocks,
2062 &mut in_admonition_block,
2063 &mut admonition_header,
2064 &mut admonition_content,
2065 );
2066 if in_code {
2067 blocks.push(Block::Code {
2068 lines: current_code_block.clone(),
2069 has_preceding_blank: code_block_has_preceding_blank,
2070 });
2071 current_code_block.clear();
2072 in_code = false;
2073 } else if in_html_block {
2074 blocks.push(Block::Html {
2075 lines: current_html_block.clone(),
2076 has_preceding_blank: html_block_has_preceding_blank,
2077 });
2078 current_html_block.clear();
2079 html_tag_stack.clear();
2080 in_html_block = false;
2081 } else if !current_paragraph.is_empty() {
2082 blocks.push(Block::Paragraph(current_paragraph.clone()));
2083 current_paragraph.clear();
2084 }
2085 blocks.push(Block::DivMarker(content.clone()));
2086 had_preceding_blank = false;
2087 }
2088 LineType::AdmonitionHeader(header_text, indent) => {
2089 flush_admonition(
2090 &mut blocks,
2091 &mut in_admonition_block,
2092 &mut admonition_header,
2093 &mut admonition_content,
2094 );
2095 // Flush other current blocks
2096 if in_code {
2097 blocks.push(Block::Code {
2098 lines: current_code_block.clone(),
2099 has_preceding_blank: code_block_has_preceding_blank,
2100 });
2101 current_code_block.clear();
2102 in_code = false;
2103 } else if in_html_block {
2104 blocks.push(Block::Html {
2105 lines: current_html_block.clone(),
2106 has_preceding_blank: html_block_has_preceding_blank,
2107 });
2108 current_html_block.clear();
2109 html_tag_stack.clear();
2110 in_html_block = false;
2111 } else if !current_paragraph.is_empty() {
2112 blocks.push(Block::Paragraph(current_paragraph.clone()));
2113 current_paragraph.clear();
2114 }
2115 // Start new admonition block
2116 in_admonition_block = true;
2117 admonition_header = Some((header_text.clone(), *indent));
2118 admonition_content.clear();
2119 had_preceding_blank = false;
2120 }
2121 LineType::AdmonitionContent(content, indent) => {
2122 if in_admonition_block {
2123 // Add to current admonition body
2124 admonition_content.push((content.clone(), *indent));
2125 } else {
2126 // Admonition content without a header should not happen,
2127 // but treat it as regular content to avoid data loss
2128 current_paragraph.push(content.clone());
2129 }
2130 had_preceding_blank = false;
2131 }
2132 }
2133 }
2134
2135 // Push all remaining pending blocks independently
2136 flush_admonition(
2137 &mut blocks,
2138 &mut in_admonition_block,
2139 &mut admonition_header,
2140 &mut admonition_content,
2141 );
2142 if in_code && !current_code_block.is_empty() {
2143 blocks.push(Block::Code {
2144 lines: current_code_block,
2145 has_preceding_blank: code_block_has_preceding_blank,
2146 });
2147 }
2148 if in_html_block && !current_html_block.is_empty() {
2149 blocks.push(Block::Html {
2150 lines: current_html_block,
2151 has_preceding_blank: html_block_has_preceding_blank,
2152 });
2153 }
2154 if !current_paragraph.is_empty() {
2155 blocks.push(Block::Paragraph(current_paragraph));
2156 }
2157
2158 // Helper: check if a line (raw source or stripped content) is exempt
2159 // from line-length checks. Link reference definitions are always exempt;
2160 // standalone link/image lines are exempt when strict mode is off.
2161 // Also checks content after stripping list markers, since list item
2162 // continuation lines may contain link ref defs.
2163 let is_exempt_line = |raw_line: &str| -> bool {
2164 let trimmed = raw_line.trim();
2165 // Link reference definitions: always exempt
2166 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
2167 return true;
2168 }
2169 // Also check after stripping list markers (for list item content)
2170 if is_list_item(trimmed) {
2171 let (_, content) = extract_list_marker_and_content(trimmed);
2172 let content_trimmed = content.trim();
2173 if content_trimmed.starts_with('[')
2174 && content_trimmed.contains("]:")
2175 && LINK_REF_PATTERN.is_match(content_trimmed)
2176 {
2177 return true;
2178 }
2179 }
2180 // Standalone link/image lines: exempt when not strict
2181 if !config.strict && is_standalone_link_or_image_line(raw_line) {
2182 return true;
2183 }
2184 // HTML-only lines: exempt when not strict
2185 if !config.strict && is_html_only_line(raw_line) {
2186 return true;
2187 }
2188 false
2189 };
2190
2191 // Check if reflowing is needed (only for content paragraphs, not code blocks or nested lists)
2192 // Exclude link reference definitions and standalone link lines from content
2193 // so they don't pollute combined_content or trigger false reflow.
2194 let content_lines: Vec<String> = list_item_lines
2195 .iter()
2196 .filter_map(|line| {
2197 if let LineType::Content(s) = line {
2198 if is_exempt_line(s) {
2199 return None;
2200 }
2201 Some(s.clone())
2202 } else {
2203 None
2204 }
2205 })
2206 .collect();
2207
2208 // Check if we need to reflow this list item
2209 // We check the combined content to see if it exceeds length limits
2210 let combined_content = content_lines.join(" ").trim().to_string();
2211
2212 // Helper to check if we should reflow in normalize mode
2213 let should_normalize = || {
2214 // Don't normalize if the list item only contains nested lists, code blocks, or semantic lines
2215 // DO normalize if it has plain text content that spans multiple lines
2216 let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
2217 let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
2218 let has_snippet_lines = blocks.iter().any(|b| matches!(b, Block::SnippetLine(_)));
2219 let has_div_markers = blocks.iter().any(|b| matches!(b, Block::DivMarker(_)));
2220 let has_admonitions = blocks.iter().any(|b| matches!(b, Block::Admonition { .. }));
2221 let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
2222
2223 // If we have structural blocks but no paragraphs, don't normalize
2224 if (has_code_blocks
2225 || has_semantic_lines
2226 || has_snippet_lines
2227 || has_div_markers
2228 || has_admonitions)
2229 && !has_paragraphs
2230 {
2231 return false;
2232 }
2233
2234 // If we have paragraphs, check if they span multiple lines or there are multiple blocks
2235 if has_paragraphs {
2236 // Count only paragraphs that contain at least one non-exempt line.
2237 // Paragraphs consisting entirely of link ref defs or standalone links
2238 // should not trigger normalization.
2239 let paragraph_count = blocks
2240 .iter()
2241 .filter(|b| {
2242 if let Block::Paragraph(para_lines) = b {
2243 !para_lines.iter().all(|line| is_exempt_line(line))
2244 } else {
2245 false
2246 }
2247 })
2248 .count();
2249 if paragraph_count > 1 {
2250 // Multiple non-exempt paragraph blocks should be normalized
2251 return true;
2252 }
2253
2254 // Single paragraph block: normalize if it has multiple content lines
2255 if content_lines.len() > 1 {
2256 return true;
2257 }
2258 }
2259
2260 false
2261 };
2262
2263 let needs_reflow = match config.reflow_mode {
2264 ReflowMode::Normalize => {
2265 // Only reflow if:
2266 // 1. Any non-exempt paragraph, when joined, exceeds the limit, OR
2267 // 2. Any admonition content line exceeds the limit, OR
2268 // 3. The list item should be normalized (has multi-line plain text)
2269 let any_paragraph_exceeds = blocks.iter().any(|block| match block {
2270 Block::Paragraph(para_lines) => {
2271 if para_lines.iter().all(|line| is_exempt_line(line)) {
2272 return false;
2273 }
2274 let joined = para_lines.join(" ");
2275 let with_marker = format!("{}{}", " ".repeat(indent_size), joined.trim());
2276 self.calculate_effective_length(&with_marker) > config.line_length.get()
2277 }
2278 Block::Admonition {
2279 content_lines,
2280 header_indent,
2281 ..
2282 } => content_lines.iter().any(|(content, indent)| {
2283 if content.is_empty() {
2284 return false;
2285 }
2286 let with_indent = format!("{}{}", " ".repeat(*indent.max(header_indent)), content);
2287 self.calculate_effective_length(&with_indent) > config.line_length.get()
2288 }),
2289 _ => false,
2290 });
2291 if any_paragraph_exceeds {
2292 true
2293 } else {
2294 should_normalize()
2295 }
2296 }
2297 ReflowMode::SentencePerLine => {
2298 // Check if list item has multiple sentences
2299 let sentences = split_into_sentences(&combined_content);
2300 sentences.len() > 1
2301 }
2302 ReflowMode::SemanticLineBreaks => {
2303 let sentences = split_into_sentences(&combined_content);
2304 sentences.len() > 1
2305 || (list_start..i).any(|line_idx| {
2306 let line = lines[line_idx];
2307 let trimmed = line.trim();
2308 if trimmed.is_empty() || is_exempt_line(line) {
2309 return false;
2310 }
2311 self.calculate_effective_length(line) > config.line_length.get()
2312 })
2313 }
2314 ReflowMode::Default => {
2315 // In default mode, only reflow if any individual non-exempt line exceeds limit
2316 (list_start..i).any(|line_idx| {
2317 let line = lines[line_idx];
2318 let trimmed = line.trim();
2319 // Skip blank lines and exempt lines
2320 if trimmed.is_empty() || is_exempt_line(line) {
2321 return false;
2322 }
2323 self.calculate_effective_length(line) > config.line_length.get()
2324 })
2325 }
2326 };
2327
2328 if needs_reflow {
2329 let start_range = line_index.whole_line_range(list_start + 1);
2330 let end_line = i - 1;
2331 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
2332 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
2333 } else {
2334 line_index.whole_line_range(end_line + 1)
2335 };
2336 let byte_range = start_range.start..end_range.end;
2337
2338 // Reflow each block (paragraphs only, preserve code blocks)
2339 // When line_length = 0 (no limit), use a very large value for reflow
2340 let reflow_line_length = if config.line_length.is_unlimited() {
2341 usize::MAX
2342 } else {
2343 config.line_length.get().saturating_sub(indent_size).max(1)
2344 };
2345 let reflow_options = crate::utils::text_reflow::ReflowOptions {
2346 line_length: reflow_line_length,
2347 break_on_sentences: true,
2348 preserve_breaks: false,
2349 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
2350 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
2351 abbreviations: config.abbreviations_for_reflow(),
2352 length_mode: self.reflow_length_mode(),
2353 attr_lists: ctx.flavor.supports_attr_lists(),
2354 require_sentence_capital: config.require_sentence_capital,
2355 max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
2356 Some(4)
2357 } else {
2358 None
2359 },
2360 };
2361
2362 let mut result: Vec<String> = Vec::new();
2363 let mut is_first_block = true;
2364
2365 for (block_idx, block) in blocks.iter().enumerate() {
2366 match block {
2367 Block::Paragraph(para_lines) => {
2368 // If every line in this paragraph is exempt (link ref defs,
2369 // standalone links), preserve the paragraph verbatim instead
2370 // of reflowing it. Reflowing would corrupt link ref defs.
2371 let all_exempt = para_lines.iter().all(|line| is_exempt_line(line));
2372
2373 if all_exempt {
2374 for (idx, line) in para_lines.iter().enumerate() {
2375 if is_first_block && idx == 0 {
2376 result.push(format!("{marker}{line}"));
2377 is_first_block = false;
2378 } else {
2379 result.push(format!("{expected_indent}{line}"));
2380 }
2381 }
2382 } else {
2383 // Split the paragraph into segments at hard break boundaries
2384 // Each segment can be reflowed independently
2385 let segments = split_into_segments(para_lines);
2386
2387 for (segment_idx, segment) in segments.iter().enumerate() {
2388 // Check if this segment ends with a hard break and what type
2389 let hard_break_type = segment.last().and_then(|line| {
2390 let line = line.strip_suffix('\r').unwrap_or(line);
2391 if line.ends_with('\\') {
2392 Some("\\")
2393 } else if line.ends_with(" ") {
2394 Some(" ")
2395 } else {
2396 None
2397 }
2398 });
2399
2400 // Join and reflow the segment (removing the hard break marker for processing)
2401 let segment_for_reflow: Vec<String> = segment
2402 .iter()
2403 .map(|line| {
2404 // Strip hard break marker (2 spaces or backslash) for reflow processing
2405 if line.ends_with('\\') {
2406 line[..line.len() - 1].trim_end().to_string()
2407 } else if line.ends_with(" ") {
2408 line[..line.len() - 2].trim_end().to_string()
2409 } else {
2410 line.clone()
2411 }
2412 })
2413 .collect();
2414
2415 let segment_text = segment_for_reflow.join(" ").trim().to_string();
2416 if !segment_text.is_empty() {
2417 let reflowed =
2418 crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
2419
2420 if is_first_block && segment_idx == 0 {
2421 // First segment of first block starts with marker
2422 result.push(format!("{marker}{}", reflowed[0]));
2423 for line in reflowed.iter().skip(1) {
2424 result.push(format!("{expected_indent}{line}"));
2425 }
2426 is_first_block = false;
2427 } else {
2428 // Subsequent segments
2429 for line in reflowed {
2430 result.push(format!("{expected_indent}{line}"));
2431 }
2432 }
2433
2434 // If this segment had a hard break, add it back to the last line
2435 // Preserve the original hard break format (backslash or two spaces)
2436 if let Some(break_marker) = hard_break_type
2437 && let Some(last_line) = result.last_mut()
2438 {
2439 last_line.push_str(break_marker);
2440 }
2441 }
2442 }
2443 }
2444
2445 // Add blank line after paragraph block if there's a next block.
2446 // Check if next block is a code block that doesn't want a preceding blank.
2447 // Also don't add blank lines before snippet lines (they should stay tight).
2448 // Only add if not already ending with one (avoids double blanks).
2449 if block_idx < blocks.len() - 1 {
2450 let next_block = &blocks[block_idx + 1];
2451 let should_add_blank = match next_block {
2452 Block::Code {
2453 has_preceding_blank, ..
2454 } => *has_preceding_blank,
2455 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2456 _ => true, // For all other blocks, add blank line
2457 };
2458 if should_add_blank && result.last().is_none_or(|s: &String| !s.is_empty()) {
2459 result.push(String::new());
2460 }
2461 }
2462 }
2463 Block::Code {
2464 lines: code_lines,
2465 has_preceding_blank: _,
2466 } => {
2467 // Preserve code blocks as-is with original indentation
2468 // NOTE: Blank line before code block is handled by the previous block
2469 // (see paragraph block's logic above)
2470
2471 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
2472 if is_first_block && idx == 0 {
2473 // First line of first block gets marker
2474 result.push(format!(
2475 "{marker}{}",
2476 " ".repeat(orig_indent - marker_len) + content
2477 ));
2478 is_first_block = false;
2479 } else if content.is_empty() {
2480 result.push(String::new());
2481 } else {
2482 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
2483 }
2484 }
2485 }
2486 Block::SemanticLine(content) => {
2487 // Preserve semantic lines (NOTE:, WARNING:, etc.) as-is on their own line.
2488 // Only add blank before if not already ending with one.
2489 if !is_first_block && result.last().is_none_or(|s: &String| !s.is_empty()) {
2490 result.push(String::new());
2491 }
2492
2493 if is_first_block {
2494 // First block starts with marker
2495 result.push(format!("{marker}{content}"));
2496 is_first_block = false;
2497 } else {
2498 // Subsequent blocks use expected indent
2499 result.push(format!("{expected_indent}{content}"));
2500 }
2501
2502 // Add blank line after semantic line if there's a next block.
2503 // Only add if not already ending with one.
2504 if block_idx < blocks.len() - 1 {
2505 let next_block = &blocks[block_idx + 1];
2506 let should_add_blank = match next_block {
2507 Block::Code {
2508 has_preceding_blank, ..
2509 } => *has_preceding_blank,
2510 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2511 _ => true, // For all other blocks, add blank line
2512 };
2513 if should_add_blank && result.last().is_none_or(|s: &String| !s.is_empty()) {
2514 result.push(String::new());
2515 }
2516 }
2517 }
2518 Block::SnippetLine(content) => {
2519 // Preserve snippet delimiters (-8<-) as-is on their own line
2520 // Unlike semantic lines, snippet lines don't add extra blank lines
2521 if is_first_block {
2522 // First block starts with marker
2523 result.push(format!("{marker}{content}"));
2524 is_first_block = false;
2525 } else {
2526 // Subsequent blocks use expected indent
2527 result.push(format!("{expected_indent}{content}"));
2528 }
2529 // No blank lines added before or after snippet delimiters
2530 }
2531 Block::DivMarker(content) => {
2532 // Preserve div markers (::: opening or closing) as-is on their own line
2533 if is_first_block {
2534 result.push(format!("{marker}{content}"));
2535 is_first_block = false;
2536 } else {
2537 result.push(format!("{expected_indent}{content}"));
2538 }
2539 }
2540 Block::Html {
2541 lines: html_lines,
2542 has_preceding_blank: _,
2543 } => {
2544 // Preserve HTML blocks exactly as-is with original indentation
2545 // NOTE: Blank line before HTML block is handled by the previous block
2546
2547 for (idx, line) in html_lines.iter().enumerate() {
2548 if is_first_block && idx == 0 {
2549 // First line of first block gets marker
2550 result.push(format!("{marker}{line}"));
2551 is_first_block = false;
2552 } else if line.is_empty() {
2553 // Preserve blank lines inside HTML blocks
2554 result.push(String::new());
2555 } else {
2556 // Preserve lines with their original content (already includes indentation)
2557 result.push(format!("{expected_indent}{line}"));
2558 }
2559 }
2560
2561 // Add blank line after HTML block if there's a next block.
2562 // Only add if not already ending with one (avoids double blanks
2563 // when the HTML block itself contained a trailing blank line).
2564 if block_idx < blocks.len() - 1 {
2565 let next_block = &blocks[block_idx + 1];
2566 let should_add_blank = match next_block {
2567 Block::Code {
2568 has_preceding_blank, ..
2569 } => *has_preceding_blank,
2570 Block::Html {
2571 has_preceding_blank, ..
2572 } => *has_preceding_blank,
2573 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2574 _ => true, // For all other blocks, add blank line
2575 };
2576 if should_add_blank && result.last().is_none_or(|s: &String| !s.is_empty()) {
2577 result.push(String::new());
2578 }
2579 }
2580 }
2581 Block::Admonition {
2582 header,
2583 header_indent,
2584 content_lines: admon_lines,
2585 } => {
2586 // Reconstruct admonition block with header at original indent
2587 // and body content reflowed to fit within the line length limit
2588
2589 // Add blank line before admonition if not first block
2590 if !is_first_block && result.last().is_none_or(|s: &String| !s.is_empty()) {
2591 result.push(String::new());
2592 }
2593
2594 // Output the header at its original indent
2595 let header_indent_str = " ".repeat(*header_indent);
2596 if is_first_block {
2597 result.push(format!(
2598 "{marker}{}",
2599 " ".repeat(header_indent.saturating_sub(marker_len)) + header
2600 ));
2601 is_first_block = false;
2602 } else {
2603 result.push(format!("{header_indent_str}{header}"));
2604 }
2605
2606 // Derive body indent from the first non-empty content line's
2607 // stored indent, falling back to header_indent + 4 for
2608 // empty-body admonitions
2609 let body_indent = admon_lines
2610 .iter()
2611 .find(|(content, _)| !content.is_empty())
2612 .map_or(header_indent + 4, |(_, indent)| *indent);
2613 let body_indent_str = " ".repeat(body_indent);
2614
2615 // Segment body content into code blocks (verbatim) and
2616 // text paragraphs (reflowable), separated by blank lines.
2617 // Code lines store (content, orig_indent) to reconstruct
2618 // internal indentation relative to body_indent.
2619 enum AdmonSegment {
2620 Text(Vec<String>),
2621 Code(Vec<(String, usize)>),
2622 }
2623
2624 let mut segments: Vec<AdmonSegment> = Vec::new();
2625 let mut current_text: Vec<String> = Vec::new();
2626 let mut current_code: Vec<(String, usize)> = Vec::new();
2627 let mut in_admon_code = false;
2628 // Track the opening fence character so closing fences
2629 // must match (backticks close backticks, tildes close tildes)
2630 let mut fence_char: char = '`';
2631
2632 // Opening fences: ``` or ~~~ followed by optional info string
2633 let get_opening_fence = |s: &str| -> Option<(char, usize)> {
2634 let t = s.trim_start();
2635 if t.starts_with("```") {
2636 Some(('`', t.bytes().take_while(|&b| b == b'`').count()))
2637 } else if t.starts_with("~~~") {
2638 Some(('~', t.bytes().take_while(|&b| b == b'~').count()))
2639 } else {
2640 None
2641 }
2642 };
2643 // Closing fences: ONLY fence chars + optional trailing spaces
2644 let get_closing_fence = |s: &str| -> Option<(char, usize)> {
2645 let t = s.trim();
2646 if t.starts_with("```") && t.bytes().all(|b| b == b'`') {
2647 Some(('`', t.len()))
2648 } else if t.starts_with("~~~") && t.bytes().all(|b| b == b'~') {
2649 Some(('~', t.len()))
2650 } else {
2651 None
2652 }
2653 };
2654 let mut fence_len: usize = 3;
2655
2656 for (content, orig_indent) in admon_lines {
2657 if in_admon_code {
2658 // Closing fence must use the same character, be
2659 // at least as long, and have no info string
2660 if let Some((ch, len)) = get_closing_fence(content)
2661 && ch == fence_char
2662 && len >= fence_len
2663 {
2664 current_code.push((content.clone(), *orig_indent));
2665 in_admon_code = false;
2666 segments.push(AdmonSegment::Code(std::mem::take(&mut current_code)));
2667 continue;
2668 }
2669 current_code.push((content.clone(), *orig_indent));
2670 } else if let Some((ch, len)) = get_opening_fence(content) {
2671 if !current_text.is_empty() {
2672 segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
2673 }
2674 in_admon_code = true;
2675 fence_char = ch;
2676 fence_len = len;
2677 current_code.push((content.clone(), *orig_indent));
2678 } else if content.is_empty() {
2679 if !current_text.is_empty() {
2680 segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
2681 }
2682 } else {
2683 current_text.push(content.clone());
2684 }
2685 }
2686 if in_admon_code && !current_code.is_empty() {
2687 segments.push(AdmonSegment::Code(std::mem::take(&mut current_code)));
2688 }
2689 if !current_text.is_empty() {
2690 segments.push(AdmonSegment::Text(std::mem::take(&mut current_text)));
2691 }
2692
2693 // Build reflow options once for all text segments
2694 let admon_reflow_length = if config.line_length.is_unlimited() {
2695 usize::MAX
2696 } else {
2697 config.line_length.get().saturating_sub(body_indent).max(1)
2698 };
2699
2700 let admon_reflow_options = crate::utils::text_reflow::ReflowOptions {
2701 line_length: admon_reflow_length,
2702 break_on_sentences: true,
2703 preserve_breaks: false,
2704 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
2705 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
2706 abbreviations: config.abbreviations_for_reflow(),
2707 length_mode: self.reflow_length_mode(),
2708 attr_lists: ctx.flavor.supports_attr_lists(),
2709 require_sentence_capital: config.require_sentence_capital,
2710 max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
2711 Some(4)
2712 } else {
2713 None
2714 },
2715 };
2716
2717 // Output each segment
2718 for segment in &segments {
2719 // Blank line before each segment (after the header or previous segment)
2720 result.push(String::new());
2721
2722 match segment {
2723 AdmonSegment::Code(lines) => {
2724 for (line, orig_indent) in lines {
2725 if line.is_empty() {
2726 // Preserve blank lines inside code blocks
2727 result.push(String::new());
2728 } else {
2729 // Reconstruct with body_indent + any extra
2730 // indentation the line had beyond body_indent
2731 let extra = orig_indent.saturating_sub(body_indent);
2732 let indent_str = " ".repeat(body_indent + extra);
2733 result.push(format!("{indent_str}{line}"));
2734 }
2735 }
2736 }
2737 AdmonSegment::Text(lines) => {
2738 let paragraph_text = lines.join(" ").trim().to_string();
2739 if paragraph_text.is_empty() {
2740 continue;
2741 }
2742 let reflowed = crate::utils::text_reflow::reflow_line(
2743 ¶graph_text,
2744 &admon_reflow_options,
2745 );
2746 for line in &reflowed {
2747 result.push(format!("{body_indent_str}{line}"));
2748 }
2749 }
2750 }
2751 }
2752
2753 // Add blank line after admonition if there's a next block
2754 if block_idx < blocks.len() - 1 {
2755 let next_block = &blocks[block_idx + 1];
2756 let should_add_blank = match next_block {
2757 Block::Code {
2758 has_preceding_blank, ..
2759 } => *has_preceding_blank,
2760 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2761 _ => true,
2762 };
2763 if should_add_blank && result.last().is_none_or(|s: &String| !s.is_empty()) {
2764 result.push(String::new());
2765 }
2766 }
2767 }
2768 }
2769 }
2770
2771 let reflowed_text = result.join(line_ending);
2772
2773 // Preserve trailing newline
2774 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
2775 format!("{reflowed_text}{line_ending}")
2776 } else {
2777 reflowed_text
2778 };
2779
2780 // Get the original text to compare
2781 let original_text = &ctx.content[byte_range.clone()];
2782
2783 // Physical-line-length scan, shared by the Normalize-mode gate and its
2784 // message. The list-item reflow preserves code blocks, HTML blocks,
2785 // admonition headers, fence markers, semantic markers, and snippet/div
2786 // markers verbatim; only paragraph content and admonition bodies are
2787 // restructured. Only those lines drive the length warning, so that
2788 // preserved-but-overlong content does not keep the paragraph-level
2789 // warning alive when the reflow would not fix that line.
2790 let should_count_for_length = |line_idx: usize| -> bool {
2791 let line = lines[line_idx];
2792 let trimmed = line.trim();
2793 if trimmed.is_empty() || is_exempt_line(line) {
2794 return false;
2795 }
2796 let info = &ctx.lines[line_idx];
2797 if info.in_code_block || info.in_html_block {
2798 return false;
2799 }
2800 if info.in_admonition && mkdocs_admonitions::is_admonition_start(line) {
2801 return false;
2802 }
2803 if is_fence_marker(line) || is_semantic_line(line) {
2804 return false;
2805 }
2806 if is_snippet_block_delimiter(line) {
2807 return false;
2808 }
2809 if line.trim_start().starts_with(":::") {
2810 return false;
2811 }
2812 true
2813 };
2814 let max_physical_length = (list_start..i)
2815 .filter(|&idx| should_count_for_length(idx))
2816 .map(|idx| self.calculate_effective_length(lines[idx]))
2817 .max()
2818 .unwrap_or(0);
2819 // `line-length = 0` means "no limit" — never emit a length warning in
2820 // that configuration, even when the reflow would restructure content.
2821 let any_paragraph_line_over =
2822 !config.line_length.is_unlimited() && max_physical_length > config.line_length.get();
2823
2824 // Only generate a warning if the replacement is different from the original.
2825 // For Normalize mode, additionally require that at least one physical
2826 // content line exceeds the limit — otherwise the reflow is a cosmetic
2827 // change (continuation-indent normalization or joining/splitting
2828 // already-fitting multi-line paragraphs) and should not be reported as
2829 // a length violation. SentencePerLine and SemanticLineBreaks modes warn
2830 // on sentence structure, not length, so they ignore this gate.
2831 let gate_ok = match config.reflow_mode {
2832 ReflowMode::Normalize => original_text != replacement && any_paragraph_line_over,
2833 _ => original_text != replacement,
2834 };
2835 if gate_ok {
2836 // Generate an appropriate message based on why reflow is needed
2837 let message = match config.reflow_mode {
2838 ReflowMode::SentencePerLine => {
2839 let num_sentences = split_into_sentences(&combined_content).len();
2840 let num_lines = content_lines.len();
2841 if num_lines == 1 {
2842 // Single line with multiple sentences
2843 format!("Line contains {num_sentences} sentences (one sentence per line required)")
2844 } else {
2845 // Multiple lines - could be split sentences or mixed
2846 format!(
2847 "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
2848 )
2849 }
2850 }
2851 ReflowMode::SemanticLineBreaks => {
2852 let num_sentences = split_into_sentences(&combined_content).len();
2853 format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
2854 }
2855 ReflowMode::Normalize => {
2856 // Report the physical line length of the longest over-limit
2857 // content line. The gate above guarantees at least one.
2858 format!(
2859 "Line length {} exceeds {} characters",
2860 max_physical_length,
2861 config.line_length.get()
2862 )
2863 }
2864 ReflowMode::Default => {
2865 // Report the actual longest non-exempt line, not the combined content
2866 let max_length = (list_start..i)
2867 .filter(|&line_idx| {
2868 let line = lines[line_idx];
2869 let trimmed = line.trim();
2870 !trimmed.is_empty() && !is_exempt_line(line)
2871 })
2872 .map(|line_idx| self.calculate_effective_length(lines[line_idx]))
2873 .max()
2874 .unwrap_or(0);
2875 format!(
2876 "Line length {} exceeds {} characters",
2877 max_length,
2878 config.line_length.get()
2879 )
2880 }
2881 };
2882
2883 warnings.push(LintWarning {
2884 rule_name: Some(self.name().to_string()),
2885 message,
2886 line: list_start + 1,
2887 column: 1,
2888 end_line: end_line + 1,
2889 end_column: lines[end_line].len() + 1,
2890 severity: Severity::Warning,
2891 fix: Some(crate::rule::Fix::new(byte_range, replacement)),
2892 });
2893 }
2894 }
2895 continue;
2896 }
2897
2898 // Found start of a paragraph - collect all lines in it
2899 let paragraph_start = i;
2900 let mut paragraph_lines = vec![lines[i]];
2901 i += 1;
2902
2903 while i < lines.len() {
2904 let next_line = lines[i];
2905 let next_line_num = i + 1;
2906 let next_trimmed = next_line.trim();
2907
2908 // Stop at paragraph boundaries
2909 if next_trimmed.is_empty()
2910 || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
2911 || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
2912 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
2913 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_comment)
2914 || ctx.line_info(next_line_num).is_some_and(|info| info.in_esm_block)
2915 || ctx.line_info(next_line_num).is_some_and(|info| info.in_jsx_expression)
2916 || ctx.line_info(next_line_num).is_some_and(|info| info.in_jsx_block)
2917 || ctx.line_info(next_line_num).is_some_and(|info| info.in_mdx_comment)
2918 || ctx
2919 .line_info(next_line_num)
2920 .is_some_and(super::super::lint_context::types::LineInfo::in_mkdocs_container)
2921 || (next_line_num > 0
2922 && next_line_num <= ctx.lines.len()
2923 && ctx.lines[next_line_num - 1].blockquote.is_some())
2924 || next_trimmed.starts_with('#')
2925 || TableUtils::is_potential_table_row(next_line)
2926 || is_list_item(next_trimmed)
2927 || is_horizontal_rule(next_line)
2928 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
2929 || is_template_directive_only(next_line)
2930 || is_standalone_attr_list(next_line)
2931 || is_snippet_block_delimiter(next_line)
2932 || ctx.line_info(next_line_num).is_some_and(|info| info.is_div_marker)
2933 || is_html_only_line(next_line)
2934 {
2935 break;
2936 }
2937
2938 // Check if the previous line ends with a hard break (2+ spaces or backslash)
2939 if i > 0 && has_hard_break(lines[i - 1]) {
2940 // Don't include lines after hard breaks in the same paragraph
2941 break;
2942 }
2943
2944 paragraph_lines.push(next_line);
2945 i += 1;
2946 }
2947
2948 // Compute the common leading indent of all non-empty paragraph lines,
2949 // but only when those lines are structurally inside a list block.
2950 // Indented continuation lines that follow a nested list arrive here
2951 // with their structural indentation intact (e.g. 2 spaces for a
2952 // top-level list item). Stripping the indent before reflow and
2953 // re-applying it afterward prevents the fixer from moving those
2954 // lines to column 0.
2955 //
2956 // The list-block guard is essential: top-level paragraphs that happen
2957 // to start with spaces (insignificant in Markdown) must NOT have those
2958 // spaces preserved or injected by the fixer.
2959 let common_indent: String = if ctx.is_in_list_block(paragraph_start + 1) {
2960 let min_len = paragraph_lines
2961 .iter()
2962 .filter(|l| !l.trim().is_empty())
2963 .map(|l| l.len() - l.trim_start().len())
2964 .min()
2965 .unwrap_or(0);
2966 paragraph_lines
2967 .iter()
2968 .find(|l| !l.trim().is_empty())
2969 .map(|l| l[..min_len].to_string())
2970 .unwrap_or_default()
2971 } else {
2972 String::new()
2973 };
2974
2975 // Combine paragraph lines into a single string for processing.
2976 // This must be done BEFORE the needs_reflow check for sentence-per-line mode.
2977 let paragraph_text = if common_indent.is_empty() {
2978 paragraph_lines.join(" ")
2979 } else {
2980 paragraph_lines
2981 .iter()
2982 .map(|l| {
2983 if l.starts_with(common_indent.as_str()) {
2984 &l[common_indent.len()..]
2985 } else {
2986 l.trim_start()
2987 }
2988 })
2989 .collect::<Vec<_>>()
2990 .join(" ")
2991 };
2992
2993 // Skip reflowing if this paragraph contains definition list items
2994 // Definition lists are multi-line structures that should not be joined
2995 let contains_definition_list = paragraph_lines
2996 .iter()
2997 .any(|line| crate::utils::is_definition_list_item(line));
2998
2999 if contains_definition_list {
3000 // Don't reflow definition lists - skip this paragraph
3001 i = paragraph_start + paragraph_lines.len();
3002 continue;
3003 }
3004
3005 // Skip reflowing if this paragraph contains MkDocs Snippets markers
3006 // Snippets blocks (-8<- ... -8<-) should be preserved exactly
3007 let contains_snippets = paragraph_lines.iter().any(|line| is_snippet_block_delimiter(line));
3008
3009 if contains_snippets {
3010 // Don't reflow Snippets blocks - skip this paragraph
3011 i = paragraph_start + paragraph_lines.len();
3012 continue;
3013 }
3014
3015 // Check if this paragraph needs reflowing
3016 let needs_reflow = match config.reflow_mode {
3017 ReflowMode::Normalize => self.normalize_mode_needs_reflow(paragraph_lines.iter().copied(), config),
3018 ReflowMode::SentencePerLine => {
3019 // In sentence-per-line mode, check if the JOINED paragraph has multiple sentences
3020 // Note: we check the joined text because sentences can span multiple lines
3021 let sentences = split_into_sentences(¶graph_text);
3022
3023 // Always reflow if multiple sentences on one line
3024 if sentences.len() > 1 {
3025 true
3026 } else if paragraph_lines.len() > 1 {
3027 // For single-sentence paragraphs spanning multiple lines:
3028 // Reflow if they COULD fit on one line (respecting line-length constraint)
3029 if config.line_length.is_unlimited() {
3030 // No line-length constraint - always join single sentences
3031 true
3032 } else {
3033 // Only join if it fits within line-length.
3034 // paragraph_text has the common indent stripped, so add it
3035 // back to get the true output length before comparing.
3036 let effective_length =
3037 self.calculate_effective_length(¶graph_text) + common_indent.len();
3038 effective_length <= config.line_length.get()
3039 }
3040 } else {
3041 false
3042 }
3043 }
3044 ReflowMode::SemanticLineBreaks => {
3045 let sentences = split_into_sentences(¶graph_text);
3046 // Reflow if multiple sentences, multiple lines, or any line exceeds limit
3047 sentences.len() > 1
3048 || paragraph_lines.len() > 1
3049 || paragraph_lines
3050 .iter()
3051 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
3052 }
3053 ReflowMode::Default => {
3054 // In default mode, only reflow if lines exceed limit
3055 paragraph_lines
3056 .iter()
3057 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
3058 }
3059 };
3060
3061 if needs_reflow {
3062 // Calculate byte range for this paragraph
3063 // Use whole_line_range for each line and combine
3064 let start_range = line_index.whole_line_range(paragraph_start + 1);
3065 let end_line = paragraph_start + paragraph_lines.len() - 1;
3066
3067 // For the last line, we want to preserve any trailing newline
3068 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
3069 // Last line without trailing newline - use line_text_range
3070 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
3071 } else {
3072 // Not the last line or has trailing newline - use whole_line_range
3073 line_index.whole_line_range(end_line + 1)
3074 };
3075
3076 let byte_range = start_range.start..end_range.end;
3077
3078 // Check if the paragraph ends with a hard break and what type
3079 let hard_break_type = paragraph_lines.last().and_then(|line| {
3080 let line = line.strip_suffix('\r').unwrap_or(line);
3081 if line.ends_with('\\') {
3082 Some("\\")
3083 } else if line.ends_with(" ") {
3084 Some(" ")
3085 } else {
3086 None
3087 }
3088 });
3089
3090 // Reflow the paragraph
3091 // When line_length = 0 (no limit), use a very large value for reflow
3092 let reflow_line_length = if config.line_length.is_unlimited() {
3093 usize::MAX
3094 } else {
3095 config.line_length.get()
3096 };
3097 let reflow_options = crate::utils::text_reflow::ReflowOptions {
3098 line_length: reflow_line_length,
3099 break_on_sentences: true,
3100 preserve_breaks: false,
3101 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
3102 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
3103 abbreviations: config.abbreviations_for_reflow(),
3104 length_mode: self.reflow_length_mode(),
3105 attr_lists: ctx.flavor.supports_attr_lists(),
3106 require_sentence_capital: config.require_sentence_capital,
3107 max_list_continuation_indent: if ctx.flavor.requires_strict_list_indent() {
3108 Some(4)
3109 } else {
3110 None
3111 },
3112 };
3113 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
3114
3115 // Re-apply the common indent to each non-empty reflowed line so
3116 // that the replacement preserves the original structural indentation.
3117 if !common_indent.is_empty() {
3118 for line in &mut reflowed {
3119 if !line.is_empty() {
3120 *line = format!("{common_indent}{line}");
3121 }
3122 }
3123 }
3124
3125 // If the original paragraph ended with a hard break, preserve it
3126 // Preserve the original hard break format (backslash or two spaces)
3127 if let Some(break_marker) = hard_break_type
3128 && !reflowed.is_empty()
3129 {
3130 let last_idx = reflowed.len() - 1;
3131 if !has_hard_break(&reflowed[last_idx]) {
3132 reflowed[last_idx].push_str(break_marker);
3133 }
3134 }
3135
3136 let reflowed_text = reflowed.join(line_ending);
3137
3138 // Preserve trailing newline if the original paragraph had one
3139 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
3140 format!("{reflowed_text}{line_ending}")
3141 } else {
3142 reflowed_text
3143 };
3144
3145 // Get the original text to compare
3146 let original_text = &ctx.content[byte_range.clone()];
3147
3148 // Only generate a warning if the replacement is different from the original
3149 if original_text != replacement {
3150 // Create warning with actual fix
3151 // In default mode, report the specific line that violates
3152 // In normalize mode, report the whole paragraph
3153 // In sentence-per-line mode, report the entire paragraph
3154 let (warning_line, warning_end_line) = match config.reflow_mode {
3155 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
3156 ReflowMode::SentencePerLine | ReflowMode::SemanticLineBreaks => {
3157 // Highlight the entire paragraph that needs reformatting
3158 (paragraph_start + 1, paragraph_start + paragraph_lines.len())
3159 }
3160 ReflowMode::Default => {
3161 // Find the first line that exceeds the limit
3162 let mut violating_line = paragraph_start;
3163 for (idx, line) in paragraph_lines.iter().enumerate() {
3164 if self.calculate_effective_length(line) > config.line_length.get() {
3165 violating_line = paragraph_start + idx;
3166 break;
3167 }
3168 }
3169 (violating_line + 1, violating_line + 1)
3170 }
3171 };
3172
3173 warnings.push(LintWarning {
3174 rule_name: Some(self.name().to_string()),
3175 message: match config.reflow_mode {
3176 ReflowMode::Normalize => format!(
3177 "Paragraph could be normalized to use line length of {} characters",
3178 config.line_length.get()
3179 ),
3180 ReflowMode::SentencePerLine => {
3181 let num_sentences = split_into_sentences(¶graph_text).len();
3182 if paragraph_lines.len() == 1 {
3183 // Single line with multiple sentences
3184 format!("Line contains {num_sentences} sentences (one sentence per line required)")
3185 } else {
3186 let num_lines = paragraph_lines.len();
3187 // Multiple lines - could be split sentences or mixed
3188 format!("Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)")
3189 }
3190 },
3191 ReflowMode::SemanticLineBreaks => {
3192 let num_sentences = split_into_sentences(¶graph_text).len();
3193 format!(
3194 "Paragraph should use semantic line breaks ({num_sentences} sentences)"
3195 )
3196 },
3197 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
3198 },
3199 line: warning_line,
3200 column: 1,
3201 end_line: warning_end_line,
3202 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
3203 severity: Severity::Warning,
3204 fix: Some(crate::rule::Fix::new(byte_range, replacement)),
3205 });
3206 }
3207 }
3208 }
3209
3210 warnings
3211 }
3212
3213 /// Calculate string length based on the configured length mode
3214 fn calculate_string_length(&self, s: &str) -> usize {
3215 match self.config.length_mode {
3216 LengthMode::Chars => s.chars().count(),
3217 LengthMode::Visual => s.width(),
3218 LengthMode::Bytes => s.len(),
3219 }
3220 }
3221
3222 /// Calculate effective line length
3223 ///
3224 /// Returns the actual display length of the line using the configured length mode.
3225 fn calculate_effective_length(&self, line: &str) -> usize {
3226 self.calculate_string_length(line)
3227 }
3228
3229 /// Calculate line length with inline link/image URLs removed.
3230 ///
3231 /// For each inline link `[text](url)` or image `` on the line,
3232 /// computes the "savings" from removing the URL portion (keeping only `[text]`
3233 /// or `![alt]`). Returns `effective_length - total_savings`.
3234 ///
3235 /// Handles nested constructs (e.g., `[](url)`) by only counting the
3236 /// outermost construct to avoid double-counting.
3237 fn calculate_text_only_length(
3238 &self,
3239 effective_length: usize,
3240 line_number: usize,
3241 ctx: &crate::lint_context::LintContext,
3242 ) -> usize {
3243 let line_range = ctx.line_index.line_content_range(line_number);
3244 let line_byte_end = line_range.end;
3245
3246 // Collect inline links/images on this line: (byte_offset, byte_end, text_only_display_len)
3247 let mut constructs: Vec<(usize, usize, usize)> = Vec::new();
3248
3249 // Binary search: links are sorted by byte_offset, so link.line is non-decreasing
3250 let link_start = ctx.links.partition_point(|l| l.line < line_number);
3251 for link in &ctx.links[link_start..] {
3252 if link.line != line_number {
3253 break;
3254 }
3255 if link.is_reference {
3256 continue;
3257 }
3258 if !matches!(link.link_type, LinkType::Inline) {
3259 continue;
3260 }
3261 if link.byte_end > line_byte_end {
3262 continue;
3263 }
3264 let text_only_len = 2 + self.calculate_string_length(&link.text);
3265 constructs.push((link.byte_offset, link.byte_end, text_only_len));
3266 }
3267
3268 let img_start = ctx.images.partition_point(|i| i.line < line_number);
3269 for image in &ctx.images[img_start..] {
3270 if image.line != line_number {
3271 break;
3272 }
3273 if image.is_reference {
3274 continue;
3275 }
3276 if !matches!(image.link_type, LinkType::Inline) {
3277 continue;
3278 }
3279 if image.byte_end > line_byte_end {
3280 continue;
3281 }
3282 let text_only_len = 3 + self.calculate_string_length(&image.alt_text);
3283 constructs.push((image.byte_offset, image.byte_end, text_only_len));
3284 }
3285
3286 if constructs.is_empty() {
3287 return effective_length;
3288 }
3289
3290 // Sort by byte offset to handle overlapping/nested constructs
3291 constructs.sort_by_key(|&(start, _, _)| start);
3292
3293 let mut total_savings: usize = 0;
3294 let mut last_end: usize = 0;
3295
3296 for (start, end, text_only_len) in &constructs {
3297 // Skip constructs nested inside a previously counted one
3298 if *start < last_end {
3299 continue;
3300 }
3301 // Full construct length in configured length mode
3302 let full_source = &ctx.content[*start..*end];
3303 let full_len = self.calculate_string_length(full_source);
3304 total_savings += full_len.saturating_sub(*text_only_len);
3305 last_end = *end;
3306 }
3307
3308 effective_length.saturating_sub(total_savings)
3309 }
3310}