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_list_item,
26 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 strict,
56 reflow: false,
57 reflow_mode: ReflowMode::default(),
58 length_mode: LengthMode::default(),
59 abbreviations: Vec::new(),
60 },
61 }
62 }
63
64 pub fn from_config_struct(config: MD013Config) -> Self {
65 Self { config }
66 }
67
68 /// Convert MD013 LengthMode to text_reflow ReflowLengthMode
69 fn reflow_length_mode(&self) -> ReflowLengthMode {
70 match self.config.length_mode {
71 LengthMode::Chars => ReflowLengthMode::Chars,
72 LengthMode::Visual => ReflowLengthMode::Visual,
73 LengthMode::Bytes => ReflowLengthMode::Bytes,
74 }
75 }
76
77 fn should_ignore_line(
78 &self,
79 line: &str,
80 _lines: &[&str],
81 current_line: usize,
82 ctx: &crate::lint_context::LintContext,
83 ) -> bool {
84 if self.config.strict {
85 return false;
86 }
87
88 // Quick check for common patterns before expensive regex
89 let trimmed = line.trim();
90
91 // Only skip if the entire line is a URL (quick check first)
92 if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
93 return true;
94 }
95
96 // Only skip if the entire line is an image reference (quick check first)
97 if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
98 return true;
99 }
100
101 // Note: link reference definitions are handled as always-exempt (even in strict mode)
102 // in the main check loop, so they don't need to be checked here.
103
104 // Code blocks with long strings (only check if in code block)
105 if ctx.line_info(current_line + 1).is_some_and(|info| info.in_code_block)
106 && !trimmed.is_empty()
107 && !line.contains(' ')
108 && !line.contains('\t')
109 {
110 return true;
111 }
112
113 false
114 }
115
116 /// Check if rule should skip based on provided config (used for inline config support)
117 fn should_skip_with_config(&self, ctx: &crate::lint_context::LintContext, config: &MD013Config) -> bool {
118 // Skip if content is empty
119 if ctx.content.is_empty() {
120 return true;
121 }
122
123 // For sentence-per-line, semantic-line-breaks, or normalize mode, never skip based on line length
124 if config.reflow
125 && (config.reflow_mode == ReflowMode::SentencePerLine
126 || config.reflow_mode == ReflowMode::SemanticLineBreaks
127 || config.reflow_mode == ReflowMode::Normalize)
128 {
129 return false;
130 }
131
132 // Quick check: if total content is shorter than line limit, definitely skip
133 if ctx.content.len() <= config.line_length.get() {
134 return true;
135 }
136
137 // Skip if no line exceeds the limit
138 !ctx.lines.iter().any(|line| line.byte_len > config.line_length.get())
139 }
140}
141
142impl Rule for MD013LineLength {
143 fn name(&self) -> &'static str {
144 "MD013"
145 }
146
147 fn description(&self) -> &'static str {
148 "Line length should not be excessive"
149 }
150
151 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
152 // Use pre-parsed inline config from LintContext
153 let config_override = ctx.inline_config().get_rule_config("MD013");
154
155 // Apply configuration override if present
156 let effective_config = if let Some(json_config) = config_override {
157 if let Some(obj) = json_config.as_object() {
158 let mut config = self.config.clone();
159 if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
160 config.line_length = crate::types::LineLength::new(line_length as usize);
161 }
162 if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
163 config.code_blocks = code_blocks;
164 }
165 if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
166 config.tables = tables;
167 }
168 if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
169 config.headings = headings;
170 }
171 if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
172 config.strict = strict;
173 }
174 if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
175 config.reflow = reflow;
176 }
177 if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
178 config.reflow_mode = match reflow_mode {
179 "default" => ReflowMode::Default,
180 "normalize" => ReflowMode::Normalize,
181 "sentence-per-line" => ReflowMode::SentencePerLine,
182 "semantic-line-breaks" => ReflowMode::SemanticLineBreaks,
183 _ => ReflowMode::default(),
184 };
185 }
186 config
187 } else {
188 self.config.clone()
189 }
190 } else {
191 self.config.clone()
192 };
193
194 // Fast early return using should_skip with EFFECTIVE config (after inline overrides)
195 // But don't skip if we're in reflow mode with Normalize or SentencePerLine
196 if self.should_skip_with_config(ctx, &effective_config)
197 && !(effective_config.reflow
198 && (effective_config.reflow_mode == ReflowMode::Normalize
199 || effective_config.reflow_mode == ReflowMode::SentencePerLine
200 || effective_config.reflow_mode == ReflowMode::SemanticLineBreaks))
201 {
202 return Ok(Vec::new());
203 }
204
205 // Direct implementation without DocumentStructure
206 let mut warnings = Vec::new();
207
208 // Special handling: line_length = 0 means "no line length limit"
209 // Skip all line length checks, but still allow reflow if enabled
210 let skip_length_checks = effective_config.line_length.is_unlimited();
211
212 // Pre-filter lines that could be problematic to avoid processing all lines
213 let mut candidate_lines = Vec::new();
214 if !skip_length_checks {
215 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
216 // Skip front matter - it should never be linted
217 if line_info.in_front_matter {
218 continue;
219 }
220
221 // Quick length check first
222 if line_info.byte_len > effective_config.line_length.get() {
223 candidate_lines.push(line_idx);
224 }
225 }
226 }
227
228 // If no candidate lines and not in normalize or sentence-per-line mode, early return
229 if candidate_lines.is_empty()
230 && !(effective_config.reflow
231 && (effective_config.reflow_mode == ReflowMode::Normalize
232 || effective_config.reflow_mode == ReflowMode::SentencePerLine
233 || effective_config.reflow_mode == ReflowMode::SemanticLineBreaks))
234 {
235 return Ok(warnings);
236 }
237
238 let lines = ctx.raw_lines();
239
240 // Create a quick lookup set for heading lines
241 // We need this for both the heading skip check AND the paragraphs check
242 let heading_lines_set: std::collections::HashSet<usize> = ctx
243 .lines
244 .iter()
245 .enumerate()
246 .filter(|(_, line)| line.heading.is_some())
247 .map(|(idx, _)| idx + 1)
248 .collect();
249
250 // Use pre-computed table blocks from context
251 // We need this for both the table skip check AND the paragraphs check
252 let table_blocks = &ctx.table_blocks;
253 let mut table_lines_set = std::collections::HashSet::new();
254 for table in table_blocks {
255 table_lines_set.insert(table.header_line + 1);
256 table_lines_set.insert(table.delimiter_line + 1);
257 for &line in &table.content_lines {
258 table_lines_set.insert(line + 1);
259 }
260 }
261
262 // Process candidate lines for line length checks
263 for &line_idx in &candidate_lines {
264 let line_number = line_idx + 1;
265 let line = lines[line_idx];
266
267 // Calculate actual line length (used in warning messages)
268 let effective_length = self.calculate_effective_length(line);
269
270 // Use single line length limit for all content
271 let line_limit = effective_config.line_length.get();
272
273 // In non-strict mode, forgive the trailing non-whitespace run.
274 // If the line only exceeds the limit because of a long token at the end
275 // (URL, link chain, identifier), it passes. This matches markdownlint's
276 // behavior: line.replace(/\S*$/u, "#")
277 let check_length = if effective_config.strict {
278 effective_length
279 } else {
280 match line.rfind(char::is_whitespace) {
281 Some(pos) => {
282 let ws_char = line[pos..].chars().next().unwrap();
283 let prefix_end = pos + ws_char.len_utf8();
284 self.calculate_string_length(&line[..prefix_end]) + 1
285 }
286 None => 1, // No whitespace — entire line is a single token
287 }
288 };
289
290 // Skip lines where the check length is within the limit
291 if check_length <= line_limit {
292 continue;
293 }
294
295 // Semantic link understanding: suppress when excess comes entirely from inline URLs
296 if !effective_config.strict {
297 let text_only_length = self.calculate_text_only_length(effective_length, line_number, ctx);
298 if text_only_length <= line_limit {
299 continue;
300 }
301 }
302
303 // Skip mkdocstrings blocks (already handled by LintContext)
304 if ctx.lines[line_idx].in_mkdocstrings {
305 continue;
306 }
307
308 // Link reference definitions are always exempt, even in strict mode.
309 // There's no way to shorten them without breaking the URL.
310 // Also check after stripping list markers, since list items may
311 // contain link ref defs as their content.
312 {
313 let trimmed = line.trim();
314 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
315 continue;
316 }
317 if is_list_item(trimmed) {
318 let (_, content) = extract_list_marker_and_content(trimmed);
319 let content_trimmed = content.trim();
320 if content_trimmed.starts_with('[')
321 && content_trimmed.contains("]:")
322 && LINK_REF_PATTERN.is_match(content_trimmed)
323 {
324 continue;
325 }
326 }
327 }
328
329 // Skip various block types efficiently
330 if !effective_config.strict {
331 // Lines whose only content is a link/image are exempt.
332 // After stripping list markers, blockquote markers, and emphasis,
333 // if only a link or image remains, there is no way to shorten it.
334 if is_standalone_link_or_image_line(line) {
335 continue;
336 }
337
338 // Skip setext heading underlines
339 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
340 continue;
341 }
342
343 // Skip block elements according to config flags
344 // The flags mean: true = check these elements, false = skip these elements
345 // So we skip when the flag is FALSE and the line is in that element type
346 if (!effective_config.headings && heading_lines_set.contains(&line_number))
347 || (!effective_config.code_blocks
348 && ctx.line_info(line_number).is_some_and(|info| info.in_code_block))
349 || (!effective_config.tables && table_lines_set.contains(&line_number))
350 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
351 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
352 || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block)
353 || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_expression)
354 || ctx.line_info(line_number).is_some_and(|info| info.in_mdx_comment)
355 {
356 continue;
357 }
358
359 // Check if this is a paragraph/regular text line
360 // If paragraphs = false, skip lines that are NOT in special blocks
361 if !effective_config.paragraphs {
362 let is_special_block = heading_lines_set.contains(&line_number)
363 || ctx.line_info(line_number).is_some_and(|info| info.in_code_block)
364 || table_lines_set.contains(&line_number)
365 || ctx.lines[line_number - 1].blockquote.is_some()
366 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
367 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
368 || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block)
369 || ctx.line_info(line_number).is_some_and(|info| info.in_jsx_expression)
370 || ctx.line_info(line_number).is_some_and(|info| info.in_mdx_comment)
371 || ctx
372 .line_info(line_number)
373 .is_some_and(|info| info.in_mkdocs_container());
374
375 // Skip regular paragraph text when paragraphs = false
376 if !is_special_block {
377 continue;
378 }
379 }
380
381 // Skip lines that are only a URL, image ref, or link ref
382 if self.should_ignore_line(line, lines, line_idx, ctx) {
383 continue;
384 }
385 }
386
387 // In sentence-per-line mode, check if this is a single long sentence
388 // If so, emit a warning without a fix (user must manually rephrase)
389 if effective_config.reflow_mode == ReflowMode::SentencePerLine {
390 let sentences = split_into_sentences(line.trim());
391 if sentences.len() == 1 {
392 // Single sentence that's too long - warn but don't auto-fix
393 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
394
395 let (start_line, start_col, end_line, end_col) =
396 calculate_excess_range(line_number, line, line_limit);
397
398 warnings.push(LintWarning {
399 rule_name: Some(self.name().to_string()),
400 message,
401 line: start_line,
402 column: start_col,
403 end_line,
404 end_column: end_col,
405 severity: Severity::Warning,
406 fix: None, // No auto-fix for long single sentences
407 });
408 continue;
409 }
410 // Multiple sentences will be handled by paragraph-based reflow
411 continue;
412 }
413
414 // In semantic-line-breaks mode, skip per-line checks —
415 // all reflow is handled at the paragraph level with cascading splits
416 if effective_config.reflow_mode == ReflowMode::SemanticLineBreaks {
417 continue;
418 }
419
420 // Don't provide fix for individual lines when reflow is enabled
421 // Paragraph-based fixes will be handled separately
422 let fix = None;
423
424 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
425
426 // Calculate precise character range for the excess portion
427 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
428
429 warnings.push(LintWarning {
430 rule_name: Some(self.name().to_string()),
431 message,
432 line: start_line,
433 column: start_col,
434 end_line,
435 end_column: end_col,
436 severity: Severity::Warning,
437 fix,
438 });
439 }
440
441 // If reflow is enabled, generate paragraph-based fixes
442 if effective_config.reflow {
443 let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, lines);
444 // Merge paragraph warnings with line warnings, removing duplicates
445 for pw in paragraph_warnings {
446 // Remove any line warnings that overlap with this paragraph
447 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
448 warnings.push(pw);
449 }
450 }
451
452 Ok(warnings)
453 }
454
455 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
456 // For CLI usage, apply fixes from warnings
457 // LSP will use the warning-based fixes directly
458 let warnings = self.check(ctx)?;
459
460 // If there are no fixes, return content unchanged
461 if !warnings.iter().any(|w| w.fix.is_some()) {
462 return Ok(ctx.content.to_string());
463 }
464
465 // Apply warning-based fixes
466 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
467 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
468 }
469
470 fn as_any(&self) -> &dyn std::any::Any {
471 self
472 }
473
474 fn category(&self) -> RuleCategory {
475 RuleCategory::Whitespace
476 }
477
478 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
479 self.should_skip_with_config(ctx, &self.config)
480 }
481
482 fn default_config_section(&self) -> Option<(String, toml::Value)> {
483 let default_config = MD013Config::default();
484 let json_value = serde_json::to_value(&default_config).ok()?;
485 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
486
487 if let toml::Value::Table(table) = toml_value {
488 if !table.is_empty() {
489 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
490 } else {
491 None
492 }
493 } else {
494 None
495 }
496 }
497
498 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
499 let mut aliases = std::collections::HashMap::new();
500 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
501 Some(aliases)
502 }
503
504 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
505 where
506 Self: Sized,
507 {
508 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
509 // Use global line_length if rule-specific config still has default value
510 if rule_config.line_length.get() == 80 {
511 rule_config.line_length = config.global.line_length;
512 }
513 Box::new(Self::from_config_struct(rule_config))
514 }
515}
516
517impl MD013LineLength {
518 fn is_blockquote_content_boundary(
519 &self,
520 content: &str,
521 line_num: usize,
522 ctx: &crate::lint_context::LintContext,
523 ) -> bool {
524 let trimmed = content.trim();
525
526 trimmed.is_empty()
527 || ctx.line_info(line_num).is_some_and(|info| {
528 info.in_code_block
529 || info.in_front_matter
530 || info.in_html_block
531 || info.in_html_comment
532 || info.in_esm_block
533 || info.in_jsx_expression
534 || info.in_mdx_comment
535 || info.in_mkdocstrings
536 || info.in_mkdocs_container()
537 || info.is_div_marker
538 })
539 || trimmed.starts_with('#')
540 || trimmed.starts_with("```")
541 || trimmed.starts_with("~~~")
542 || trimmed.starts_with('>')
543 || TableUtils::is_potential_table_row(content)
544 || is_list_item(trimmed)
545 || is_horizontal_rule(trimmed)
546 || (trimmed.starts_with('[') && content.contains("]:"))
547 || is_template_directive_only(content)
548 || is_standalone_attr_list(content)
549 || is_snippet_block_delimiter(content)
550 || is_github_alert_marker(trimmed)
551 }
552
553 fn generate_blockquote_paragraph_fix(
554 &self,
555 ctx: &crate::lint_context::LintContext,
556 config: &MD013Config,
557 lines: &[&str],
558 line_index: &LineIndex,
559 start_idx: usize,
560 line_ending: &str,
561 ) -> (Option<LintWarning>, usize) {
562 let Some(start_bq) = ctx.lines.get(start_idx).and_then(|line| line.blockquote.as_deref()) else {
563 return (None, start_idx + 1);
564 };
565 let target_level = start_bq.nesting_level;
566
567 let mut collected: Vec<CollectedBlockquoteLine> = Vec::new();
568 let mut i = start_idx;
569
570 while i < lines.len() {
571 if !collected.is_empty() && has_hard_break(&collected[collected.len() - 1].data.content) {
572 break;
573 }
574
575 let line_num = i + 1;
576 if line_num > ctx.lines.len() {
577 break;
578 }
579
580 if lines[i].trim().is_empty() {
581 break;
582 }
583
584 let line_bq = ctx.lines[i].blockquote.as_deref();
585 if let Some(bq) = line_bq {
586 if bq.nesting_level != target_level {
587 break;
588 }
589
590 if self.is_blockquote_content_boundary(&bq.content, line_num, ctx) {
591 break;
592 }
593
594 collected.push(CollectedBlockquoteLine {
595 line_idx: i,
596 data: BlockquoteLineData::explicit(trim_preserving_hard_break(&bq.content), bq.prefix.clone()),
597 });
598 i += 1;
599 continue;
600 }
601
602 let lazy_content = lines[i].trim_start();
603 if self.is_blockquote_content_boundary(lazy_content, line_num, ctx) {
604 break;
605 }
606
607 collected.push(CollectedBlockquoteLine {
608 line_idx: i,
609 data: BlockquoteLineData::lazy(trim_preserving_hard_break(lazy_content)),
610 });
611 i += 1;
612 }
613
614 if collected.is_empty() {
615 return (None, start_idx + 1);
616 }
617
618 let next_idx = i;
619 let paragraph_start = collected[0].line_idx;
620 let end_line = collected[collected.len() - 1].line_idx;
621 let line_data: Vec<BlockquoteLineData> = collected.iter().map(|l| l.data.clone()).collect();
622 let paragraph_text = line_data
623 .iter()
624 .map(|d| d.content.as_str())
625 .collect::<Vec<_>>()
626 .join(" ");
627
628 let contains_definition_list = line_data
629 .iter()
630 .any(|d| crate::utils::is_definition_list_item(&d.content));
631 if contains_definition_list {
632 return (None, next_idx);
633 }
634
635 let contains_snippets = line_data.iter().any(|d| is_snippet_block_delimiter(&d.content));
636 if contains_snippets {
637 return (None, next_idx);
638 }
639
640 let needs_reflow = match config.reflow_mode {
641 ReflowMode::Normalize => line_data.len() > 1,
642 ReflowMode::SentencePerLine => {
643 let sentences = split_into_sentences(¶graph_text);
644 sentences.len() > 1 || line_data.len() > 1
645 }
646 ReflowMode::SemanticLineBreaks => {
647 let sentences = split_into_sentences(¶graph_text);
648 sentences.len() > 1
649 || line_data.len() > 1
650 || collected
651 .iter()
652 .any(|l| self.calculate_effective_length(lines[l.line_idx]) > config.line_length.get())
653 }
654 ReflowMode::Default => collected
655 .iter()
656 .any(|l| self.calculate_effective_length(lines[l.line_idx]) > config.line_length.get()),
657 };
658
659 if !needs_reflow {
660 return (None, next_idx);
661 }
662
663 let fallback_prefix = start_bq.prefix.clone();
664 let explicit_prefix = dominant_blockquote_prefix(&line_data, &fallback_prefix);
665 let continuation_style = blockquote_continuation_style(&line_data);
666
667 let reflow_line_length = if config.line_length.is_unlimited() {
668 usize::MAX
669 } else {
670 config
671 .line_length
672 .get()
673 .saturating_sub(self.calculate_string_length(&explicit_prefix))
674 .max(1)
675 };
676
677 let reflow_options = crate::utils::text_reflow::ReflowOptions {
678 line_length: reflow_line_length,
679 break_on_sentences: true,
680 preserve_breaks: false,
681 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
682 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
683 abbreviations: config.abbreviations_for_reflow(),
684 length_mode: self.reflow_length_mode(),
685 };
686
687 let reflowed_with_style =
688 reflow_blockquote_content(&line_data, &explicit_prefix, continuation_style, &reflow_options);
689
690 if reflowed_with_style.is_empty() {
691 return (None, next_idx);
692 }
693
694 let reflowed_text = reflowed_with_style.join(line_ending);
695
696 let start_range = line_index.whole_line_range(paragraph_start + 1);
697 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
698 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
699 } else {
700 line_index.whole_line_range(end_line + 1)
701 };
702 let byte_range = start_range.start..end_range.end;
703
704 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
705 format!("{reflowed_text}{line_ending}")
706 } else {
707 reflowed_text
708 };
709
710 let original_text = &ctx.content[byte_range.clone()];
711 if original_text == replacement {
712 return (None, next_idx);
713 }
714
715 let (warning_line, warning_end_line) = match config.reflow_mode {
716 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
717 ReflowMode::SentencePerLine | ReflowMode::SemanticLineBreaks => (paragraph_start + 1, end_line + 1),
718 ReflowMode::Default => {
719 let violating_line = collected
720 .iter()
721 .find(|line| self.calculate_effective_length(lines[line.line_idx]) > config.line_length.get())
722 .map(|line| line.line_idx + 1)
723 .unwrap_or(paragraph_start + 1);
724 (violating_line, violating_line)
725 }
726 };
727
728 let warning = LintWarning {
729 rule_name: Some(self.name().to_string()),
730 message: match config.reflow_mode {
731 ReflowMode::Normalize => format!(
732 "Paragraph could be normalized to use line length of {} characters",
733 config.line_length.get()
734 ),
735 ReflowMode::SentencePerLine => {
736 let num_sentences = split_into_sentences(¶graph_text).len();
737 if line_data.len() == 1 {
738 format!("Line contains {num_sentences} sentences (one sentence per line required)")
739 } else {
740 let num_lines = line_data.len();
741 format!(
742 "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
743 )
744 }
745 }
746 ReflowMode::SemanticLineBreaks => {
747 let num_sentences = split_into_sentences(¶graph_text).len();
748 format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
749 }
750 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
751 },
752 line: warning_line,
753 column: 1,
754 end_line: warning_end_line,
755 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
756 severity: Severity::Warning,
757 fix: Some(crate::rule::Fix {
758 range: byte_range,
759 replacement,
760 }),
761 };
762
763 (Some(warning), next_idx)
764 }
765
766 /// Generate paragraph-based fixes
767 fn generate_paragraph_fixes(
768 &self,
769 ctx: &crate::lint_context::LintContext,
770 config: &MD013Config,
771 lines: &[&str],
772 ) -> Vec<LintWarning> {
773 let mut warnings = Vec::new();
774 let line_index = LineIndex::new(ctx.content);
775
776 // Detect the content's line ending style to preserve it in replacements.
777 // The LSP receives content from editors which may use CRLF (Windows).
778 // Replacements must match the original line endings to avoid false positives.
779 let line_ending = crate::utils::line_ending::detect_line_ending(ctx.content);
780
781 let mut i = 0;
782 while i < lines.len() {
783 let line_num = i + 1;
784
785 // Handle blockquote paragraphs with style-preserving reflow.
786 if line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some() {
787 let (warning, next_idx) =
788 self.generate_blockquote_paragraph_fix(ctx, config, lines, &line_index, i, line_ending);
789 if let Some(warning) = warning {
790 warnings.push(warning);
791 }
792 i = next_idx;
793 continue;
794 }
795
796 // Skip special structures (but NOT MkDocs containers - those get special handling)
797 let should_skip_due_to_line_info = ctx.line_info(line_num).is_some_and(|info| {
798 info.in_code_block
799 || info.in_front_matter
800 || info.in_html_block
801 || info.in_html_comment
802 || info.in_esm_block
803 || info.in_jsx_expression
804 || info.in_mdx_comment
805 || info.in_mkdocstrings
806 });
807
808 if should_skip_due_to_line_info
809 || lines[i].trim().starts_with('#')
810 || TableUtils::is_potential_table_row(lines[i])
811 || lines[i].trim().is_empty()
812 || is_horizontal_rule(lines[i].trim())
813 || is_template_directive_only(lines[i])
814 || (lines[i].trim().starts_with('[') && lines[i].contains("]:"))
815 || ctx.line_info(line_num).is_some_and(|info| info.is_div_marker)
816 {
817 i += 1;
818 continue;
819 }
820
821 // Handle MkDocs container content (admonitions and tabs) with indent-preserving reflow
822 if ctx.line_info(line_num).is_some_and(|info| info.in_mkdocs_container()) {
823 // Skip admonition/tab marker lines — only reflow their indented content
824 let current_line = lines[i];
825 if mkdocs_admonitions::is_admonition_start(current_line) || mkdocs_tabs::is_tab_marker(current_line) {
826 i += 1;
827 continue;
828 }
829
830 let container_start = i;
831
832 // Detect the actual indent level from the first content line
833 // (supports nested admonitions with 8+ spaces)
834 let first_line = lines[i];
835 let base_indent_len = first_line.len() - first_line.trim_start().len();
836 let base_indent: String = " ".repeat(base_indent_len);
837
838 // Collect consecutive MkDocs container paragraph lines
839 let mut container_lines: Vec<&str> = Vec::new();
840 while i < lines.len() {
841 let current_line_num = i + 1;
842 let line_info = ctx.line_info(current_line_num);
843
844 // Stop if we leave the MkDocs container
845 if !line_info.is_some_and(|info| info.in_mkdocs_container()) {
846 break;
847 }
848
849 let line = lines[i];
850
851 // Stop at paragraph boundaries within the container
852 if line.trim().is_empty() {
853 break;
854 }
855
856 // Skip list items, code blocks, headings within containers
857 if is_list_item(line.trim())
858 || line.trim().starts_with("```")
859 || line.trim().starts_with("~~~")
860 || line.trim().starts_with('#')
861 {
862 break;
863 }
864
865 container_lines.push(line);
866 i += 1;
867 }
868
869 if container_lines.is_empty() {
870 // Must advance i to avoid infinite loop when we encounter
871 // non-paragraph content (code block, list, heading, empty line)
872 // at the start of an MkDocs container
873 i += 1;
874 continue;
875 }
876
877 // Strip the base indent from each line and join for reflow
878 let stripped_lines: Vec<&str> = container_lines
879 .iter()
880 .map(|line| {
881 if line.starts_with(&base_indent) {
882 &line[base_indent_len..]
883 } else {
884 line.trim_start()
885 }
886 })
887 .collect();
888 let paragraph_text = stripped_lines.join(" ");
889
890 // Check if reflow is needed
891 let needs_reflow = match config.reflow_mode {
892 ReflowMode::Normalize => container_lines.len() > 1,
893 ReflowMode::SentencePerLine => {
894 let sentences = split_into_sentences(¶graph_text);
895 sentences.len() > 1 || container_lines.len() > 1
896 }
897 ReflowMode::SemanticLineBreaks => {
898 let sentences = split_into_sentences(¶graph_text);
899 sentences.len() > 1
900 || container_lines.len() > 1
901 || container_lines
902 .iter()
903 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
904 }
905 ReflowMode::Default => container_lines
906 .iter()
907 .any(|line| self.calculate_effective_length(line) > config.line_length.get()),
908 };
909
910 if !needs_reflow {
911 continue;
912 }
913
914 // Calculate byte range for this container paragraph
915 let start_range = line_index.whole_line_range(container_start + 1);
916 let end_line = container_start + container_lines.len() - 1;
917 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
918 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
919 } else {
920 line_index.whole_line_range(end_line + 1)
921 };
922 let byte_range = start_range.start..end_range.end;
923
924 // Reflow with adjusted line length (accounting for the 4-space indent)
925 let reflow_line_length = if config.line_length.is_unlimited() {
926 usize::MAX
927 } else {
928 config.line_length.get().saturating_sub(base_indent_len).max(1)
929 };
930 let reflow_options = crate::utils::text_reflow::ReflowOptions {
931 line_length: reflow_line_length,
932 break_on_sentences: true,
933 preserve_breaks: false,
934 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
935 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
936 abbreviations: config.abbreviations_for_reflow(),
937 length_mode: self.reflow_length_mode(),
938 };
939 let reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
940
941 // Re-add the 4-space indent to each reflowed line
942 let reflowed_with_indent: Vec<String> =
943 reflowed.iter().map(|line| format!("{base_indent}{line}")).collect();
944 let reflowed_text = reflowed_with_indent.join(line_ending);
945
946 // Preserve trailing newline
947 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
948 format!("{reflowed_text}{line_ending}")
949 } else {
950 reflowed_text
951 };
952
953 // Only generate a warning if the replacement is different
954 let original_text = &ctx.content[byte_range.clone()];
955 if original_text != replacement {
956 warnings.push(LintWarning {
957 rule_name: Some(self.name().to_string()),
958 message: format!(
959 "Line length {} exceeds {} characters (in MkDocs container)",
960 container_lines.iter().map(|l| l.len()).max().unwrap_or(0),
961 config.line_length.get()
962 ),
963 line: container_start + 1,
964 column: 1,
965 end_line: end_line + 1,
966 end_column: lines[end_line].len() + 1,
967 severity: Severity::Warning,
968 fix: Some(crate::rule::Fix {
969 range: byte_range,
970 replacement,
971 }),
972 });
973 }
974 continue;
975 }
976
977 // Helper function to detect semantic line markers
978 let is_semantic_line = |content: &str| -> bool {
979 let trimmed = content.trim_start();
980 let semantic_markers = [
981 "NOTE:",
982 "WARNING:",
983 "IMPORTANT:",
984 "CAUTION:",
985 "TIP:",
986 "DANGER:",
987 "HINT:",
988 "INFO:",
989 ];
990 semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
991 };
992
993 // Helper function to detect fence markers (opening or closing)
994 let is_fence_marker = |content: &str| -> bool {
995 let trimmed = content.trim_start();
996 trimmed.starts_with("```") || trimmed.starts_with("~~~")
997 };
998
999 // Check if this is a list item - handle it specially
1000 let trimmed = lines[i].trim();
1001 if is_list_item(trimmed) {
1002 // Collect the entire list item including continuation lines
1003 let list_start = i;
1004 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
1005 let marker_len = marker.len();
1006
1007 // Track lines and their types (content, code block, fence, nested list)
1008 #[derive(Clone)]
1009 enum LineType {
1010 Content(String),
1011 CodeBlock(String, usize), // content and original indent
1012 NestedListItem(String, usize), // full line content and original indent
1013 SemanticLine(String), // Lines starting with NOTE:, WARNING:, etc that should stay separate
1014 SnippetLine(String), // MkDocs Snippets delimiters (-8<-) that must stay on their own line
1015 DivMarker(String), // Quarto/Pandoc div markers (::: opening or closing)
1016 AdmonitionHeader(String, usize), // header text (e.g. "!!! note") and original indent
1017 AdmonitionContent(String, usize), // body content text and original indent
1018 Empty,
1019 }
1020
1021 let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
1022 i += 1;
1023
1024 // Collect continuation lines using ctx.lines for metadata
1025 while i < lines.len() {
1026 let line_info = &ctx.lines[i];
1027
1028 // Use pre-computed is_blank from ctx
1029 if line_info.is_blank {
1030 // Empty line - check if next line is indented (part of list item)
1031 if i + 1 < lines.len() {
1032 let next_info = &ctx.lines[i + 1];
1033
1034 // Check if next line is indented enough to be continuation
1035 if !next_info.is_blank && next_info.indent >= marker_len {
1036 // This blank line is between paragraphs/blocks in the list item
1037 list_item_lines.push(LineType::Empty);
1038 i += 1;
1039 continue;
1040 }
1041 }
1042 // No indented line after blank, end of list item
1043 break;
1044 }
1045
1046 // Use pre-computed indent from ctx
1047 let indent = line_info.indent;
1048
1049 // Valid continuation must be indented at least marker_len
1050 if indent >= marker_len {
1051 let trimmed = line_info.content(ctx.content).trim();
1052
1053 // Use pre-computed in_code_block from ctx
1054 if line_info.in_code_block {
1055 list_item_lines.push(LineType::CodeBlock(
1056 line_info.content(ctx.content)[indent..].to_string(),
1057 indent,
1058 ));
1059 i += 1;
1060 continue;
1061 }
1062
1063 // Check for MkDocs admonition lines inside list items.
1064 // The flavor detection marks these with in_admonition, so we
1065 // can classify them as admonition header or body content.
1066 if line_info.in_admonition {
1067 let raw_content = line_info.content(ctx.content);
1068 if mkdocs_admonitions::is_admonition_start(raw_content) {
1069 let header_text = raw_content[indent..].trim_end().to_string();
1070 list_item_lines.push(LineType::AdmonitionHeader(header_text, indent));
1071 } else {
1072 let body_text = raw_content[indent..].trim_end().to_string();
1073 list_item_lines.push(LineType::AdmonitionContent(body_text, indent));
1074 }
1075 i += 1;
1076 continue;
1077 }
1078
1079 // Check if this is a SIBLING list item (breaks parent)
1080 // Nested lists are indented >= marker_len and are PART of the parent item
1081 // Siblings are at indent < marker_len (at or before parent marker)
1082 if is_list_item(trimmed) && indent < marker_len {
1083 // This is a sibling item at same or higher level - end parent item
1084 break;
1085 }
1086
1087 // Check if this is a NESTED list item marker
1088 // Nested lists should be processed separately UNLESS they're part of a
1089 // multi-paragraph list item (indicated by a blank line before them OR
1090 // it's a continuation of an already-started nested list)
1091 if is_list_item(trimmed) && indent >= marker_len {
1092 // Check if there was a blank line before this (multi-paragraph context)
1093 let has_blank_before = matches!(list_item_lines.last(), Some(LineType::Empty));
1094
1095 // Check if we've already seen nested list content (another nested item)
1096 let has_nested_content = list_item_lines.iter().any(|line| {
1097 matches!(line, LineType::Content(c) if is_list_item(c.trim()))
1098 || matches!(line, LineType::NestedListItem(_, _))
1099 });
1100
1101 if !has_blank_before && !has_nested_content {
1102 // Single-paragraph context with no prior nested items: starts a new item
1103 // End parent collection; nested list will be processed next
1104 break;
1105 }
1106 // else: multi-paragraph context or continuation of nested list, keep collecting
1107 // Mark this as a nested list item to preserve its structure
1108 list_item_lines.push(LineType::NestedListItem(
1109 line_info.content(ctx.content)[indent..].to_string(),
1110 indent,
1111 ));
1112 i += 1;
1113 continue;
1114 }
1115
1116 // Normal continuation: marker_len to marker_len+3
1117 if indent <= marker_len + 3 {
1118 // Extract content (remove indentation and trailing whitespace)
1119 // Preserve hard breaks (2 trailing spaces) while removing excessive whitespace
1120 // See: https://github.com/rvben/rumdl/issues/76
1121 let content = trim_preserving_hard_break(&line_info.content(ctx.content)[indent..]);
1122
1123 // Check if this is a div marker (::: opening or closing)
1124 // These must be preserved on their own line, not merged into paragraphs
1125 if line_info.is_div_marker {
1126 list_item_lines.push(LineType::DivMarker(content));
1127 }
1128 // Check if this is a fence marker (opening or closing)
1129 // These should be treated as code block lines, not paragraph content
1130 else if is_fence_marker(&content) {
1131 list_item_lines.push(LineType::CodeBlock(content, indent));
1132 }
1133 // Check if this is a semantic line (NOTE:, WARNING:, etc.)
1134 else if is_semantic_line(&content) {
1135 list_item_lines.push(LineType::SemanticLine(content));
1136 }
1137 // Check if this is a snippet block delimiter (-8<- or --8<--)
1138 // These must be preserved on their own lines for MkDocs Snippets extension
1139 else if is_snippet_block_delimiter(&content) {
1140 list_item_lines.push(LineType::SnippetLine(content));
1141 } else {
1142 list_item_lines.push(LineType::Content(content));
1143 }
1144 i += 1;
1145 } else {
1146 // indent >= marker_len + 4: indented code block
1147 list_item_lines.push(LineType::CodeBlock(
1148 line_info.content(ctx.content)[indent..].to_string(),
1149 indent,
1150 ));
1151 i += 1;
1152 }
1153 } else {
1154 // Not indented enough, end of list item
1155 break;
1156 }
1157 }
1158
1159 let indent_size = marker_len;
1160 let expected_indent = " ".repeat(indent_size);
1161
1162 // Split list_item_lines into blocks (paragraphs, code blocks, nested lists, semantic lines, and HTML blocks)
1163 #[derive(Clone)]
1164 enum Block {
1165 Paragraph(Vec<String>),
1166 Code {
1167 lines: Vec<(String, usize)>, // (content, indent) pairs
1168 has_preceding_blank: bool, // Whether there was a blank line before this block
1169 },
1170 NestedList(Vec<(String, usize)>), // (content, indent) pairs for nested list items
1171 SemanticLine(String), // Semantic markers like NOTE:, WARNING: that stay on their own line
1172 SnippetLine(String), // MkDocs Snippets delimiter that stays on its own line without extra spacing
1173 DivMarker(String), // Quarto/Pandoc div marker (::: opening or closing) preserved on its own line
1174 Html {
1175 lines: Vec<String>, // HTML content preserved exactly as-is
1176 has_preceding_blank: bool, // Whether there was a blank line before this block
1177 },
1178 Admonition {
1179 header: String, // e.g. "!!! note" or "??? warning \"Title\""
1180 header_indent: usize, // original indent of the header line
1181 content_lines: Vec<(String, usize)>, // (text, original_indent) pairs for body lines
1182 },
1183 }
1184
1185 // HTML tag detection helpers
1186 // Block-level HTML tags that should trigger HTML block detection
1187 const BLOCK_LEVEL_TAGS: &[&str] = &[
1188 "div",
1189 "details",
1190 "summary",
1191 "section",
1192 "article",
1193 "header",
1194 "footer",
1195 "nav",
1196 "aside",
1197 "main",
1198 "table",
1199 "thead",
1200 "tbody",
1201 "tfoot",
1202 "tr",
1203 "td",
1204 "th",
1205 "ul",
1206 "ol",
1207 "li",
1208 "dl",
1209 "dt",
1210 "dd",
1211 "pre",
1212 "blockquote",
1213 "figure",
1214 "figcaption",
1215 "form",
1216 "fieldset",
1217 "legend",
1218 "hr",
1219 "p",
1220 "h1",
1221 "h2",
1222 "h3",
1223 "h4",
1224 "h5",
1225 "h6",
1226 "style",
1227 "script",
1228 "noscript",
1229 ];
1230
1231 fn is_block_html_opening_tag(line: &str) -> Option<String> {
1232 let trimmed = line.trim();
1233
1234 // Check for HTML comments
1235 if trimmed.starts_with("<!--") {
1236 return Some("!--".to_string());
1237 }
1238
1239 // Check for opening tags
1240 if trimmed.starts_with('<') && !trimmed.starts_with("</") && !trimmed.starts_with("<!") {
1241 // Extract tag name from <tagname ...> or <tagname>
1242 let after_bracket = &trimmed[1..];
1243 if let Some(end) = after_bracket.find(|c: char| c.is_whitespace() || c == '>' || c == '/') {
1244 let tag_name = after_bracket[..end].to_lowercase();
1245
1246 // Only treat as block if it's a known block-level tag
1247 if BLOCK_LEVEL_TAGS.contains(&tag_name.as_str()) {
1248 return Some(tag_name);
1249 }
1250 }
1251 }
1252 None
1253 }
1254
1255 fn is_html_closing_tag(line: &str, tag_name: &str) -> bool {
1256 let trimmed = line.trim();
1257
1258 // Special handling for HTML comments
1259 if tag_name == "!--" {
1260 return trimmed.ends_with("-->");
1261 }
1262
1263 // Check for closing tags: </tagname> or </tagname ...>
1264 trimmed.starts_with(&format!("</{tag_name}>"))
1265 || trimmed.starts_with(&format!("</{tag_name} "))
1266 || (trimmed.starts_with("</") && trimmed[2..].trim_start().starts_with(tag_name))
1267 }
1268
1269 fn is_self_closing_tag(line: &str) -> bool {
1270 let trimmed = line.trim();
1271 trimmed.ends_with("/>")
1272 }
1273
1274 let mut blocks: Vec<Block> = Vec::new();
1275 let mut current_paragraph: Vec<String> = Vec::new();
1276 let mut current_code_block: Vec<(String, usize)> = Vec::new();
1277 let mut current_nested_list: Vec<(String, usize)> = Vec::new();
1278 let mut current_html_block: Vec<String> = Vec::new();
1279 let mut html_tag_stack: Vec<String> = Vec::new();
1280 let mut in_code = false;
1281 let mut in_nested_list = false;
1282 let mut in_html_block = false;
1283 let mut had_preceding_blank = false; // Track if we just saw an empty line
1284 let mut code_block_has_preceding_blank = false; // Track blank before current code block
1285 let mut html_block_has_preceding_blank = false; // Track blank before current HTML block
1286
1287 // Track admonition context for block building
1288 let mut in_admonition_block = false;
1289 let mut admonition_header: Option<(String, usize)> = None; // (header_text, indent)
1290 let mut admonition_content: Vec<(String, usize)> = Vec::new();
1291
1292 // Flush any pending admonition block into `blocks`
1293 let flush_admonition = |blocks: &mut Vec<Block>,
1294 in_admonition: &mut bool,
1295 header: &mut Option<(String, usize)>,
1296 content: &mut Vec<(String, usize)>| {
1297 if *in_admonition {
1298 if let Some((h, hi)) = header.take() {
1299 blocks.push(Block::Admonition {
1300 header: h,
1301 header_indent: hi,
1302 content_lines: std::mem::take(content),
1303 });
1304 }
1305 *in_admonition = false;
1306 }
1307 };
1308
1309 for line in &list_item_lines {
1310 match line {
1311 LineType::Empty => {
1312 if in_admonition_block {
1313 // Blank lines inside admonitions separate paragraphs within the body
1314 admonition_content.push((String::new(), 0));
1315 } else if in_code {
1316 current_code_block.push((String::new(), 0));
1317 } else if in_nested_list {
1318 current_nested_list.push((String::new(), 0));
1319 } else if in_html_block {
1320 // Allow blank lines inside HTML blocks
1321 current_html_block.push(String::new());
1322 } else if !current_paragraph.is_empty() {
1323 blocks.push(Block::Paragraph(current_paragraph.clone()));
1324 current_paragraph.clear();
1325 }
1326 // Mark that we saw a blank line
1327 had_preceding_blank = true;
1328 }
1329 LineType::Content(content) => {
1330 flush_admonition(
1331 &mut blocks,
1332 &mut in_admonition_block,
1333 &mut admonition_header,
1334 &mut admonition_content,
1335 );
1336 // Check if we're currently in an HTML block
1337 if in_html_block {
1338 current_html_block.push(content.clone());
1339
1340 // Check if this line closes any open HTML tags
1341 if let Some(last_tag) = html_tag_stack.last() {
1342 if is_html_closing_tag(content, last_tag) {
1343 html_tag_stack.pop();
1344
1345 // If stack is empty, HTML block is complete
1346 if html_tag_stack.is_empty() {
1347 blocks.push(Block::Html {
1348 lines: current_html_block.clone(),
1349 has_preceding_blank: html_block_has_preceding_blank,
1350 });
1351 current_html_block.clear();
1352 in_html_block = false;
1353 }
1354 } else if let Some(new_tag) = is_block_html_opening_tag(content) {
1355 // Nested opening tag within HTML block
1356 if !is_self_closing_tag(content) {
1357 html_tag_stack.push(new_tag);
1358 }
1359 }
1360 }
1361 had_preceding_blank = false;
1362 } else {
1363 // Not in HTML block - check if this line starts one
1364 if let Some(tag_name) = is_block_html_opening_tag(content) {
1365 // Flush current paragraph before starting HTML block
1366 if in_code {
1367 blocks.push(Block::Code {
1368 lines: current_code_block.clone(),
1369 has_preceding_blank: code_block_has_preceding_blank,
1370 });
1371 current_code_block.clear();
1372 in_code = false;
1373 } else if in_nested_list {
1374 blocks.push(Block::NestedList(current_nested_list.clone()));
1375 current_nested_list.clear();
1376 in_nested_list = false;
1377 } else if !current_paragraph.is_empty() {
1378 blocks.push(Block::Paragraph(current_paragraph.clone()));
1379 current_paragraph.clear();
1380 }
1381
1382 // Start new HTML block
1383 in_html_block = true;
1384 html_block_has_preceding_blank = had_preceding_blank;
1385 current_html_block.push(content.clone());
1386
1387 // Check if it's self-closing or needs a closing tag
1388 if is_self_closing_tag(content) {
1389 // Self-closing tag - complete the HTML block immediately
1390 blocks.push(Block::Html {
1391 lines: current_html_block.clone(),
1392 has_preceding_blank: html_block_has_preceding_blank,
1393 });
1394 current_html_block.clear();
1395 in_html_block = false;
1396 } else {
1397 // Regular opening tag - push to stack
1398 html_tag_stack.push(tag_name);
1399 }
1400 } else {
1401 // Regular content line - add to paragraph
1402 if in_code {
1403 // Switching from code to content
1404 blocks.push(Block::Code {
1405 lines: current_code_block.clone(),
1406 has_preceding_blank: code_block_has_preceding_blank,
1407 });
1408 current_code_block.clear();
1409 in_code = false;
1410 } else if in_nested_list {
1411 // Switching from nested list to content
1412 blocks.push(Block::NestedList(current_nested_list.clone()));
1413 current_nested_list.clear();
1414 in_nested_list = false;
1415 }
1416 current_paragraph.push(content.clone());
1417 }
1418 had_preceding_blank = false; // Reset after content
1419 }
1420 }
1421 LineType::CodeBlock(content, indent) => {
1422 flush_admonition(
1423 &mut blocks,
1424 &mut in_admonition_block,
1425 &mut admonition_header,
1426 &mut admonition_content,
1427 );
1428 if in_nested_list {
1429 // Switching from nested list to code
1430 blocks.push(Block::NestedList(current_nested_list.clone()));
1431 current_nested_list.clear();
1432 in_nested_list = false;
1433 } else if in_html_block {
1434 // Switching from HTML block to code (shouldn't happen normally, but handle it)
1435 blocks.push(Block::Html {
1436 lines: current_html_block.clone(),
1437 has_preceding_blank: html_block_has_preceding_blank,
1438 });
1439 current_html_block.clear();
1440 html_tag_stack.clear();
1441 in_html_block = false;
1442 }
1443 if !in_code {
1444 // Switching from content to code
1445 if !current_paragraph.is_empty() {
1446 blocks.push(Block::Paragraph(current_paragraph.clone()));
1447 current_paragraph.clear();
1448 }
1449 in_code = true;
1450 // Record whether there was a blank line before this code block
1451 code_block_has_preceding_blank = had_preceding_blank;
1452 }
1453 current_code_block.push((content.clone(), *indent));
1454 had_preceding_blank = false; // Reset after code
1455 }
1456 LineType::NestedListItem(content, indent) => {
1457 flush_admonition(
1458 &mut blocks,
1459 &mut in_admonition_block,
1460 &mut admonition_header,
1461 &mut admonition_content,
1462 );
1463 if in_code {
1464 // Switching from code to nested list
1465 blocks.push(Block::Code {
1466 lines: current_code_block.clone(),
1467 has_preceding_blank: code_block_has_preceding_blank,
1468 });
1469 current_code_block.clear();
1470 in_code = false;
1471 } else if in_html_block {
1472 // Switching from HTML block to nested list (shouldn't happen normally, but handle it)
1473 blocks.push(Block::Html {
1474 lines: current_html_block.clone(),
1475 has_preceding_blank: html_block_has_preceding_blank,
1476 });
1477 current_html_block.clear();
1478 html_tag_stack.clear();
1479 in_html_block = false;
1480 }
1481 if !in_nested_list {
1482 // Switching from content to nested list
1483 if !current_paragraph.is_empty() {
1484 blocks.push(Block::Paragraph(current_paragraph.clone()));
1485 current_paragraph.clear();
1486 }
1487 in_nested_list = true;
1488 }
1489 current_nested_list.push((content.clone(), *indent));
1490 had_preceding_blank = false; // Reset after nested list
1491 }
1492 LineType::SemanticLine(content) => {
1493 // Semantic lines are standalone - flush any current block and add as separate block
1494 flush_admonition(
1495 &mut blocks,
1496 &mut in_admonition_block,
1497 &mut admonition_header,
1498 &mut admonition_content,
1499 );
1500 if in_code {
1501 blocks.push(Block::Code {
1502 lines: current_code_block.clone(),
1503 has_preceding_blank: code_block_has_preceding_blank,
1504 });
1505 current_code_block.clear();
1506 in_code = false;
1507 } else if in_nested_list {
1508 blocks.push(Block::NestedList(current_nested_list.clone()));
1509 current_nested_list.clear();
1510 in_nested_list = false;
1511 } else if in_html_block {
1512 blocks.push(Block::Html {
1513 lines: current_html_block.clone(),
1514 has_preceding_blank: html_block_has_preceding_blank,
1515 });
1516 current_html_block.clear();
1517 html_tag_stack.clear();
1518 in_html_block = false;
1519 } else if !current_paragraph.is_empty() {
1520 blocks.push(Block::Paragraph(current_paragraph.clone()));
1521 current_paragraph.clear();
1522 }
1523 // Add semantic line as its own block
1524 blocks.push(Block::SemanticLine(content.clone()));
1525 had_preceding_blank = false; // Reset after semantic line
1526 }
1527 LineType::SnippetLine(content) => {
1528 // Snippet delimiters (-8<-) are standalone - flush any current block and add as separate block
1529 // Unlike semantic lines, snippet lines don't add extra blank lines around them
1530 flush_admonition(
1531 &mut blocks,
1532 &mut in_admonition_block,
1533 &mut admonition_header,
1534 &mut admonition_content,
1535 );
1536 if in_code {
1537 blocks.push(Block::Code {
1538 lines: current_code_block.clone(),
1539 has_preceding_blank: code_block_has_preceding_blank,
1540 });
1541 current_code_block.clear();
1542 in_code = false;
1543 } else if in_nested_list {
1544 blocks.push(Block::NestedList(current_nested_list.clone()));
1545 current_nested_list.clear();
1546 in_nested_list = false;
1547 } else if in_html_block {
1548 blocks.push(Block::Html {
1549 lines: current_html_block.clone(),
1550 has_preceding_blank: html_block_has_preceding_blank,
1551 });
1552 current_html_block.clear();
1553 html_tag_stack.clear();
1554 in_html_block = false;
1555 } else if !current_paragraph.is_empty() {
1556 blocks.push(Block::Paragraph(current_paragraph.clone()));
1557 current_paragraph.clear();
1558 }
1559 // Add snippet line as its own block
1560 blocks.push(Block::SnippetLine(content.clone()));
1561 had_preceding_blank = false;
1562 }
1563 LineType::DivMarker(content) => {
1564 // Div markers (::: opening or closing) are standalone structural delimiters
1565 // Flush any current block and add as separate block
1566 flush_admonition(
1567 &mut blocks,
1568 &mut in_admonition_block,
1569 &mut admonition_header,
1570 &mut admonition_content,
1571 );
1572 if in_code {
1573 blocks.push(Block::Code {
1574 lines: current_code_block.clone(),
1575 has_preceding_blank: code_block_has_preceding_blank,
1576 });
1577 current_code_block.clear();
1578 in_code = false;
1579 } else if in_nested_list {
1580 blocks.push(Block::NestedList(current_nested_list.clone()));
1581 current_nested_list.clear();
1582 in_nested_list = false;
1583 } else if in_html_block {
1584 blocks.push(Block::Html {
1585 lines: current_html_block.clone(),
1586 has_preceding_blank: html_block_has_preceding_blank,
1587 });
1588 current_html_block.clear();
1589 html_tag_stack.clear();
1590 in_html_block = false;
1591 } else if !current_paragraph.is_empty() {
1592 blocks.push(Block::Paragraph(current_paragraph.clone()));
1593 current_paragraph.clear();
1594 }
1595 blocks.push(Block::DivMarker(content.clone()));
1596 had_preceding_blank = false;
1597 }
1598 LineType::AdmonitionHeader(header_text, indent) => {
1599 flush_admonition(
1600 &mut blocks,
1601 &mut in_admonition_block,
1602 &mut admonition_header,
1603 &mut admonition_content,
1604 );
1605 // Flush other current blocks
1606 if in_code {
1607 blocks.push(Block::Code {
1608 lines: current_code_block.clone(),
1609 has_preceding_blank: code_block_has_preceding_blank,
1610 });
1611 current_code_block.clear();
1612 in_code = false;
1613 } else if in_nested_list {
1614 blocks.push(Block::NestedList(current_nested_list.clone()));
1615 current_nested_list.clear();
1616 in_nested_list = false;
1617 } else if in_html_block {
1618 blocks.push(Block::Html {
1619 lines: current_html_block.clone(),
1620 has_preceding_blank: html_block_has_preceding_blank,
1621 });
1622 current_html_block.clear();
1623 html_tag_stack.clear();
1624 in_html_block = false;
1625 } else if !current_paragraph.is_empty() {
1626 blocks.push(Block::Paragraph(current_paragraph.clone()));
1627 current_paragraph.clear();
1628 }
1629 // Start new admonition block
1630 in_admonition_block = true;
1631 admonition_header = Some((header_text.clone(), *indent));
1632 admonition_content.clear();
1633 had_preceding_blank = false;
1634 }
1635 LineType::AdmonitionContent(content, indent) => {
1636 if in_admonition_block {
1637 // Add to current admonition body
1638 admonition_content.push((content.clone(), *indent));
1639 } else {
1640 // Admonition content without a header should not happen,
1641 // but treat it as regular content to avoid data loss
1642 current_paragraph.push(content.clone());
1643 }
1644 had_preceding_blank = false;
1645 }
1646 }
1647 }
1648
1649 // Push all remaining pending blocks independently
1650 flush_admonition(
1651 &mut blocks,
1652 &mut in_admonition_block,
1653 &mut admonition_header,
1654 &mut admonition_content,
1655 );
1656 if in_code && !current_code_block.is_empty() {
1657 blocks.push(Block::Code {
1658 lines: current_code_block,
1659 has_preceding_blank: code_block_has_preceding_blank,
1660 });
1661 }
1662 if in_nested_list && !current_nested_list.is_empty() {
1663 blocks.push(Block::NestedList(current_nested_list));
1664 }
1665 if in_html_block && !current_html_block.is_empty() {
1666 blocks.push(Block::Html {
1667 lines: current_html_block,
1668 has_preceding_blank: html_block_has_preceding_blank,
1669 });
1670 }
1671 if !current_paragraph.is_empty() {
1672 blocks.push(Block::Paragraph(current_paragraph));
1673 }
1674
1675 // Helper: check if a line (raw source or stripped content) is exempt
1676 // from line-length checks. Link reference definitions are always exempt;
1677 // standalone link/image lines are exempt when strict mode is off.
1678 // Also checks content after stripping list markers, since list item
1679 // continuation lines may contain link ref defs.
1680 let is_exempt_line = |raw_line: &str| -> bool {
1681 let trimmed = raw_line.trim();
1682 // Link reference definitions: always exempt
1683 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
1684 return true;
1685 }
1686 // Also check after stripping list markers (for list item content)
1687 if is_list_item(trimmed) {
1688 let (_, content) = extract_list_marker_and_content(trimmed);
1689 let content_trimmed = content.trim();
1690 if content_trimmed.starts_with('[')
1691 && content_trimmed.contains("]:")
1692 && LINK_REF_PATTERN.is_match(content_trimmed)
1693 {
1694 return true;
1695 }
1696 }
1697 // Standalone link/image lines: exempt when not strict
1698 if !config.strict && is_standalone_link_or_image_line(raw_line) {
1699 return true;
1700 }
1701 false
1702 };
1703
1704 // Check if reflowing is needed (only for content paragraphs, not code blocks or nested lists)
1705 // Exclude link reference definitions and standalone link lines from content
1706 // so they don't pollute combined_content or trigger false reflow.
1707 let content_lines: Vec<String> = list_item_lines
1708 .iter()
1709 .filter_map(|line| {
1710 if let LineType::Content(s) = line {
1711 if is_exempt_line(s) {
1712 return None;
1713 }
1714 Some(s.clone())
1715 } else {
1716 None
1717 }
1718 })
1719 .collect();
1720
1721 // Check if we need to reflow this list item
1722 // We check the combined content to see if it exceeds length limits
1723 let combined_content = content_lines.join(" ").trim().to_string();
1724
1725 // Helper to check if we should reflow in normalize mode
1726 let should_normalize = || {
1727 // Don't normalize if the list item only contains nested lists, code blocks, or semantic lines
1728 // DO normalize if it has plain text content that spans multiple lines
1729 let has_nested_lists = blocks.iter().any(|b| matches!(b, Block::NestedList(_)));
1730 let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
1731 let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
1732 let has_snippet_lines = blocks.iter().any(|b| matches!(b, Block::SnippetLine(_)));
1733 let has_div_markers = blocks.iter().any(|b| matches!(b, Block::DivMarker(_)));
1734 let has_admonitions = blocks.iter().any(|b| matches!(b, Block::Admonition { .. }));
1735 let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
1736
1737 // If we have structural blocks but no paragraphs, don't normalize
1738 if (has_nested_lists
1739 || has_code_blocks
1740 || has_semantic_lines
1741 || has_snippet_lines
1742 || has_div_markers
1743 || has_admonitions)
1744 && !has_paragraphs
1745 {
1746 return false;
1747 }
1748
1749 // If we have paragraphs, check if they span multiple lines or there are multiple blocks
1750 if has_paragraphs {
1751 // Count only paragraphs that contain at least one non-exempt line.
1752 // Paragraphs consisting entirely of link ref defs or standalone links
1753 // should not trigger normalization.
1754 let paragraph_count = blocks
1755 .iter()
1756 .filter(|b| {
1757 if let Block::Paragraph(para_lines) = b {
1758 !para_lines.iter().all(|line| is_exempt_line(line))
1759 } else {
1760 false
1761 }
1762 })
1763 .count();
1764 if paragraph_count > 1 {
1765 // Multiple non-exempt paragraph blocks should be normalized
1766 return true;
1767 }
1768
1769 // Single paragraph block: normalize if it has multiple content lines
1770 if content_lines.len() > 1 {
1771 return true;
1772 }
1773 }
1774
1775 false
1776 };
1777
1778 let needs_reflow = match config.reflow_mode {
1779 ReflowMode::Normalize => {
1780 // Only reflow if:
1781 // 1. Any non-exempt paragraph, when joined, exceeds the limit, OR
1782 // 2. Any admonition content line exceeds the limit, OR
1783 // 3. The list item should be normalized (has multi-line plain text)
1784 let any_paragraph_exceeds = blocks.iter().any(|block| match block {
1785 Block::Paragraph(para_lines) => {
1786 if para_lines.iter().all(|line| is_exempt_line(line)) {
1787 return false;
1788 }
1789 let joined = para_lines.join(" ");
1790 let with_marker = format!("{}{}", " ".repeat(marker_len), joined.trim());
1791 self.calculate_effective_length(&with_marker) > config.line_length.get()
1792 }
1793 Block::Admonition {
1794 content_lines,
1795 header_indent,
1796 ..
1797 } => content_lines.iter().any(|(content, indent)| {
1798 if content.is_empty() {
1799 return false;
1800 }
1801 let with_indent = format!("{}{}", " ".repeat(*indent.max(header_indent)), content);
1802 self.calculate_effective_length(&with_indent) > config.line_length.get()
1803 }),
1804 _ => false,
1805 });
1806 if any_paragraph_exceeds {
1807 true
1808 } else {
1809 should_normalize()
1810 }
1811 }
1812 ReflowMode::SentencePerLine => {
1813 // Check if list item has multiple sentences
1814 let sentences = split_into_sentences(&combined_content);
1815 sentences.len() > 1
1816 }
1817 ReflowMode::SemanticLineBreaks => {
1818 let sentences = split_into_sentences(&combined_content);
1819 sentences.len() > 1
1820 || (list_start..i).any(|line_idx| {
1821 let line = lines[line_idx];
1822 let trimmed = line.trim();
1823 if trimmed.is_empty() || is_exempt_line(line) {
1824 return false;
1825 }
1826 self.calculate_effective_length(line) > config.line_length.get()
1827 })
1828 }
1829 ReflowMode::Default => {
1830 // In default mode, only reflow if any individual non-exempt line exceeds limit
1831 (list_start..i).any(|line_idx| {
1832 let line = lines[line_idx];
1833 let trimmed = line.trim();
1834 // Skip blank lines and exempt lines
1835 if trimmed.is_empty() || is_exempt_line(line) {
1836 return false;
1837 }
1838 self.calculate_effective_length(line) > config.line_length.get()
1839 })
1840 }
1841 };
1842
1843 if needs_reflow {
1844 let start_range = line_index.whole_line_range(list_start + 1);
1845 let end_line = i - 1;
1846 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1847 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1848 } else {
1849 line_index.whole_line_range(end_line + 1)
1850 };
1851 let byte_range = start_range.start..end_range.end;
1852
1853 // Reflow each block (paragraphs only, preserve code blocks)
1854 // When line_length = 0 (no limit), use a very large value for reflow
1855 let reflow_line_length = if config.line_length.is_unlimited() {
1856 usize::MAX
1857 } else {
1858 config.line_length.get().saturating_sub(indent_size).max(1)
1859 };
1860 let reflow_options = crate::utils::text_reflow::ReflowOptions {
1861 line_length: reflow_line_length,
1862 break_on_sentences: true,
1863 preserve_breaks: false,
1864 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1865 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
1866 abbreviations: config.abbreviations_for_reflow(),
1867 length_mode: self.reflow_length_mode(),
1868 };
1869
1870 let mut result: Vec<String> = Vec::new();
1871 let mut is_first_block = true;
1872
1873 for (block_idx, block) in blocks.iter().enumerate() {
1874 match block {
1875 Block::Paragraph(para_lines) => {
1876 // If every line in this paragraph is exempt (link ref defs,
1877 // standalone links), preserve the paragraph verbatim instead
1878 // of reflowing it. Reflowing would corrupt link ref defs.
1879 let all_exempt = para_lines.iter().all(|line| is_exempt_line(line));
1880
1881 if all_exempt {
1882 for (idx, line) in para_lines.iter().enumerate() {
1883 if is_first_block && idx == 0 {
1884 result.push(format!("{marker}{line}"));
1885 is_first_block = false;
1886 } else {
1887 result.push(format!("{expected_indent}{line}"));
1888 }
1889 }
1890 } else {
1891 // Split the paragraph into segments at hard break boundaries
1892 // Each segment can be reflowed independently
1893 let segments = split_into_segments(para_lines);
1894
1895 for (segment_idx, segment) in segments.iter().enumerate() {
1896 // Check if this segment ends with a hard break and what type
1897 let hard_break_type = segment.last().and_then(|line| {
1898 let line = line.strip_suffix('\r').unwrap_or(line);
1899 if line.ends_with('\\') {
1900 Some("\\")
1901 } else if line.ends_with(" ") {
1902 Some(" ")
1903 } else {
1904 None
1905 }
1906 });
1907
1908 // Join and reflow the segment (removing the hard break marker for processing)
1909 let segment_for_reflow: Vec<String> = segment
1910 .iter()
1911 .map(|line| {
1912 // Strip hard break marker (2 spaces or backslash) for reflow processing
1913 if line.ends_with('\\') {
1914 line[..line.len() - 1].trim_end().to_string()
1915 } else if line.ends_with(" ") {
1916 line[..line.len() - 2].trim_end().to_string()
1917 } else {
1918 line.clone()
1919 }
1920 })
1921 .collect();
1922
1923 let segment_text = segment_for_reflow.join(" ").trim().to_string();
1924 if !segment_text.is_empty() {
1925 let reflowed =
1926 crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
1927
1928 if is_first_block && segment_idx == 0 {
1929 // First segment of first block starts with marker
1930 result.push(format!("{marker}{}", reflowed[0]));
1931 for line in reflowed.iter().skip(1) {
1932 result.push(format!("{expected_indent}{line}"));
1933 }
1934 is_first_block = false;
1935 } else {
1936 // Subsequent segments
1937 for line in reflowed {
1938 result.push(format!("{expected_indent}{line}"));
1939 }
1940 }
1941
1942 // If this segment had a hard break, add it back to the last line
1943 // Preserve the original hard break format (backslash or two spaces)
1944 if let Some(break_marker) = hard_break_type
1945 && let Some(last_line) = result.last_mut()
1946 {
1947 last_line.push_str(break_marker);
1948 }
1949 }
1950 }
1951 }
1952
1953 // Add blank line after paragraph block if there's a next block.
1954 // Check if next block is a code block that doesn't want a preceding blank.
1955 // Also don't add blank lines before snippet lines (they should stay tight).
1956 // Only add if not already ending with one (avoids double blanks).
1957 if block_idx < blocks.len() - 1 {
1958 let next_block = &blocks[block_idx + 1];
1959 let should_add_blank = match next_block {
1960 Block::Code {
1961 has_preceding_blank, ..
1962 } => *has_preceding_blank,
1963 Block::SnippetLine(_) | Block::DivMarker(_) => false,
1964 _ => true, // For all other blocks, add blank line
1965 };
1966 if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
1967 {
1968 result.push(String::new());
1969 }
1970 }
1971 }
1972 Block::Code {
1973 lines: code_lines,
1974 has_preceding_blank: _,
1975 } => {
1976 // Preserve code blocks as-is with original indentation
1977 // NOTE: Blank line before code block is handled by the previous block
1978 // (see paragraph block's logic above)
1979
1980 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
1981 if is_first_block && idx == 0 {
1982 // First line of first block gets marker
1983 result.push(format!(
1984 "{marker}{}",
1985 " ".repeat(orig_indent - marker_len) + content
1986 ));
1987 is_first_block = false;
1988 } else if content.is_empty() {
1989 result.push(String::new());
1990 } else {
1991 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
1992 }
1993 }
1994 }
1995 Block::NestedList(nested_items) => {
1996 // Preserve nested list items as-is with original indentation.
1997 // Only add blank before if not already ending with one (avoids
1998 // double blanks when the preceding block already added one).
1999 if !is_first_block && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true) {
2000 result.push(String::new());
2001 }
2002
2003 for (idx, (content, orig_indent)) in nested_items.iter().enumerate() {
2004 if is_first_block && idx == 0 {
2005 // First line of first block gets marker
2006 result.push(format!(
2007 "{marker}{}",
2008 " ".repeat(orig_indent - marker_len) + content
2009 ));
2010 is_first_block = false;
2011 } else if content.is_empty() {
2012 result.push(String::new());
2013 } else {
2014 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
2015 }
2016 }
2017
2018 // Add blank line after nested list if there's a next block.
2019 // Only add if not already ending with one (avoids double blanks
2020 // when the last nested item was already a blank line).
2021 if block_idx < blocks.len() - 1 {
2022 let next_block = &blocks[block_idx + 1];
2023 let should_add_blank = match next_block {
2024 Block::Code {
2025 has_preceding_blank, ..
2026 } => *has_preceding_blank,
2027 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2028 _ => true, // For all other blocks, add blank line
2029 };
2030 if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2031 {
2032 result.push(String::new());
2033 }
2034 }
2035 }
2036 Block::SemanticLine(content) => {
2037 // Preserve semantic lines (NOTE:, WARNING:, etc.) as-is on their own line.
2038 // Only add blank before if not already ending with one.
2039 if !is_first_block && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true) {
2040 result.push(String::new());
2041 }
2042
2043 if is_first_block {
2044 // First block starts with marker
2045 result.push(format!("{marker}{content}"));
2046 is_first_block = false;
2047 } else {
2048 // Subsequent blocks use expected indent
2049 result.push(format!("{expected_indent}{content}"));
2050 }
2051
2052 // Add blank line after semantic line if there's a next block.
2053 // Only add if not already ending with one.
2054 if block_idx < blocks.len() - 1 {
2055 let next_block = &blocks[block_idx + 1];
2056 let should_add_blank = match next_block {
2057 Block::Code {
2058 has_preceding_blank, ..
2059 } => *has_preceding_blank,
2060 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2061 _ => true, // For all other blocks, add blank line
2062 };
2063 if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2064 {
2065 result.push(String::new());
2066 }
2067 }
2068 }
2069 Block::SnippetLine(content) => {
2070 // Preserve snippet delimiters (-8<-) as-is on their own line
2071 // Unlike semantic lines, snippet lines don't add extra blank lines
2072 if is_first_block {
2073 // First block starts with marker
2074 result.push(format!("{marker}{content}"));
2075 is_first_block = false;
2076 } else {
2077 // Subsequent blocks use expected indent
2078 result.push(format!("{expected_indent}{content}"));
2079 }
2080 // No blank lines added before or after snippet delimiters
2081 }
2082 Block::DivMarker(content) => {
2083 // Preserve div markers (::: opening or closing) as-is on their own line
2084 if is_first_block {
2085 result.push(format!("{marker}{content}"));
2086 is_first_block = false;
2087 } else {
2088 result.push(format!("{expected_indent}{content}"));
2089 }
2090 }
2091 Block::Html {
2092 lines: html_lines,
2093 has_preceding_blank: _,
2094 } => {
2095 // Preserve HTML blocks exactly as-is with original indentation
2096 // NOTE: Blank line before HTML block is handled by the previous block
2097
2098 for (idx, line) in html_lines.iter().enumerate() {
2099 if is_first_block && idx == 0 {
2100 // First line of first block gets marker
2101 result.push(format!("{marker}{line}"));
2102 is_first_block = false;
2103 } else if line.is_empty() {
2104 // Preserve blank lines inside HTML blocks
2105 result.push(String::new());
2106 } else {
2107 // Preserve lines with their original content (already includes indentation)
2108 result.push(format!("{expected_indent}{line}"));
2109 }
2110 }
2111
2112 // Add blank line after HTML block if there's a next block.
2113 // Only add if not already ending with one (avoids double blanks
2114 // when the HTML block itself contained a trailing blank line).
2115 if block_idx < blocks.len() - 1 {
2116 let next_block = &blocks[block_idx + 1];
2117 let should_add_blank = match next_block {
2118 Block::Code {
2119 has_preceding_blank, ..
2120 } => *has_preceding_blank,
2121 Block::Html {
2122 has_preceding_blank, ..
2123 } => *has_preceding_blank,
2124 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2125 _ => true, // For all other blocks, add blank line
2126 };
2127 if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2128 {
2129 result.push(String::new());
2130 }
2131 }
2132 }
2133 Block::Admonition {
2134 header,
2135 header_indent,
2136 content_lines: admon_lines,
2137 } => {
2138 // Reconstruct admonition block with header at original indent
2139 // and body content reflowed to fit within the line length limit
2140
2141 // Add blank line before admonition if not first block
2142 if !is_first_block && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true) {
2143 result.push(String::new());
2144 }
2145
2146 // Output the header at its original indent
2147 let header_indent_str = " ".repeat(*header_indent);
2148 if is_first_block {
2149 result.push(format!(
2150 "{marker}{}",
2151 " ".repeat(header_indent.saturating_sub(marker_len)) + header
2152 ));
2153 is_first_block = false;
2154 } else {
2155 result.push(format!("{header_indent_str}{header}"));
2156 }
2157
2158 // Derive body indent from the first non-empty content line's
2159 // stored indent, falling back to header_indent + 4 for
2160 // empty-body admonitions
2161 let body_indent = admon_lines
2162 .iter()
2163 .find(|(content, _)| !content.is_empty())
2164 .map(|(_, indent)| *indent)
2165 .unwrap_or(header_indent + 4);
2166 let body_indent_str = " ".repeat(body_indent);
2167
2168 // Collect body content into paragraphs separated by blank lines
2169 let mut body_paragraphs: Vec<Vec<String>> = Vec::new();
2170 let mut current_para: Vec<String> = Vec::new();
2171
2172 for (content, _orig_indent) in admon_lines {
2173 if content.is_empty() {
2174 if !current_para.is_empty() {
2175 body_paragraphs.push(current_para.clone());
2176 current_para.clear();
2177 }
2178 } else {
2179 current_para.push(content.clone());
2180 }
2181 }
2182 if !current_para.is_empty() {
2183 body_paragraphs.push(current_para);
2184 }
2185
2186 // Reflow each paragraph in the body
2187 for paragraph in &body_paragraphs {
2188 // Add blank line before each paragraph (including the first, after the header)
2189 result.push(String::new());
2190
2191 let paragraph_text = paragraph.join(" ").trim().to_string();
2192 if paragraph_text.is_empty() {
2193 continue;
2194 }
2195
2196 // Reflow with adjusted line length
2197 let admon_reflow_length = if config.line_length.is_unlimited() {
2198 usize::MAX
2199 } else {
2200 config.line_length.get().saturating_sub(body_indent).max(1)
2201 };
2202
2203 let admon_reflow_options = crate::utils::text_reflow::ReflowOptions {
2204 line_length: admon_reflow_length,
2205 break_on_sentences: true,
2206 preserve_breaks: false,
2207 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
2208 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
2209 abbreviations: config.abbreviations_for_reflow(),
2210 length_mode: self.reflow_length_mode(),
2211 };
2212
2213 let reflowed =
2214 crate::utils::text_reflow::reflow_line(¶graph_text, &admon_reflow_options);
2215 for line in &reflowed {
2216 result.push(format!("{body_indent_str}{line}"));
2217 }
2218 }
2219
2220 // Add blank line after admonition if there's a next block
2221 if block_idx < blocks.len() - 1 {
2222 let next_block = &blocks[block_idx + 1];
2223 let should_add_blank = match next_block {
2224 Block::Code {
2225 has_preceding_blank, ..
2226 } => *has_preceding_blank,
2227 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2228 _ => true,
2229 };
2230 if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2231 {
2232 result.push(String::new());
2233 }
2234 }
2235 }
2236 }
2237 }
2238
2239 let reflowed_text = result.join(line_ending);
2240
2241 // Preserve trailing newline
2242 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
2243 format!("{reflowed_text}{line_ending}")
2244 } else {
2245 reflowed_text
2246 };
2247
2248 // Get the original text to compare
2249 let original_text = &ctx.content[byte_range.clone()];
2250
2251 // Only generate a warning if the replacement is different from the original
2252 if original_text != replacement {
2253 // Generate an appropriate message based on why reflow is needed
2254 let message = match config.reflow_mode {
2255 ReflowMode::SentencePerLine => {
2256 let num_sentences = split_into_sentences(&combined_content).len();
2257 let num_lines = content_lines.len();
2258 if num_lines == 1 {
2259 // Single line with multiple sentences
2260 format!("Line contains {num_sentences} sentences (one sentence per line required)")
2261 } else {
2262 // Multiple lines - could be split sentences or mixed
2263 format!(
2264 "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
2265 )
2266 }
2267 }
2268 ReflowMode::SemanticLineBreaks => {
2269 let num_sentences = split_into_sentences(&combined_content).len();
2270 format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
2271 }
2272 ReflowMode::Normalize => {
2273 // Find the longest non-exempt paragraph when joined
2274 let max_para_length = blocks
2275 .iter()
2276 .filter_map(|block| {
2277 if let Block::Paragraph(para_lines) = block {
2278 if para_lines.iter().all(|line| is_exempt_line(line)) {
2279 return None;
2280 }
2281 let joined = para_lines.join(" ");
2282 let with_indent = format!("{}{}", " ".repeat(marker_len), joined.trim());
2283 Some(self.calculate_effective_length(&with_indent))
2284 } else {
2285 None
2286 }
2287 })
2288 .max()
2289 .unwrap_or(0);
2290 if max_para_length > config.line_length.get() {
2291 format!(
2292 "Line length {} exceeds {} characters",
2293 max_para_length,
2294 config.line_length.get()
2295 )
2296 } else {
2297 "Multi-line content can be normalized".to_string()
2298 }
2299 }
2300 ReflowMode::Default => {
2301 // Report the actual longest non-exempt line, not the combined content
2302 let max_length = (list_start..i)
2303 .filter(|&line_idx| {
2304 let line = lines[line_idx];
2305 let trimmed = line.trim();
2306 !trimmed.is_empty() && !is_exempt_line(line)
2307 })
2308 .map(|line_idx| self.calculate_effective_length(lines[line_idx]))
2309 .max()
2310 .unwrap_or(0);
2311 format!(
2312 "Line length {} exceeds {} characters",
2313 max_length,
2314 config.line_length.get()
2315 )
2316 }
2317 };
2318
2319 warnings.push(LintWarning {
2320 rule_name: Some(self.name().to_string()),
2321 message,
2322 line: list_start + 1,
2323 column: 1,
2324 end_line: end_line + 1,
2325 end_column: lines[end_line].len() + 1,
2326 severity: Severity::Warning,
2327 fix: Some(crate::rule::Fix {
2328 range: byte_range,
2329 replacement,
2330 }),
2331 });
2332 }
2333 }
2334 continue;
2335 }
2336
2337 // Found start of a paragraph - collect all lines in it
2338 let paragraph_start = i;
2339 let mut paragraph_lines = vec![lines[i]];
2340 i += 1;
2341
2342 while i < lines.len() {
2343 let next_line = lines[i];
2344 let next_line_num = i + 1;
2345 let next_trimmed = next_line.trim();
2346
2347 // Stop at paragraph boundaries
2348 if next_trimmed.is_empty()
2349 || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
2350 || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
2351 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
2352 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_comment)
2353 || ctx.line_info(next_line_num).is_some_and(|info| info.in_esm_block)
2354 || ctx.line_info(next_line_num).is_some_and(|info| info.in_jsx_expression)
2355 || ctx.line_info(next_line_num).is_some_and(|info| info.in_mdx_comment)
2356 || ctx
2357 .line_info(next_line_num)
2358 .is_some_and(|info| info.in_mkdocs_container())
2359 || (next_line_num > 0
2360 && next_line_num <= ctx.lines.len()
2361 && ctx.lines[next_line_num - 1].blockquote.is_some())
2362 || next_trimmed.starts_with('#')
2363 || TableUtils::is_potential_table_row(next_line)
2364 || is_list_item(next_trimmed)
2365 || is_horizontal_rule(next_trimmed)
2366 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
2367 || is_template_directive_only(next_line)
2368 || is_standalone_attr_list(next_line)
2369 || is_snippet_block_delimiter(next_line)
2370 || ctx.line_info(next_line_num).is_some_and(|info| info.is_div_marker)
2371 {
2372 break;
2373 }
2374
2375 // Check if the previous line ends with a hard break (2+ spaces or backslash)
2376 if i > 0 && has_hard_break(lines[i - 1]) {
2377 // Don't include lines after hard breaks in the same paragraph
2378 break;
2379 }
2380
2381 paragraph_lines.push(next_line);
2382 i += 1;
2383 }
2384
2385 // Combine paragraph lines into a single string for processing
2386 // This must be done BEFORE the needs_reflow check for sentence-per-line mode
2387 let paragraph_text = paragraph_lines.join(" ");
2388
2389 // Skip reflowing if this paragraph contains definition list items
2390 // Definition lists are multi-line structures that should not be joined
2391 let contains_definition_list = paragraph_lines
2392 .iter()
2393 .any(|line| crate::utils::is_definition_list_item(line));
2394
2395 if contains_definition_list {
2396 // Don't reflow definition lists - skip this paragraph
2397 i = paragraph_start + paragraph_lines.len();
2398 continue;
2399 }
2400
2401 // Skip reflowing if this paragraph contains MkDocs Snippets markers
2402 // Snippets blocks (-8<- ... -8<-) should be preserved exactly
2403 let contains_snippets = paragraph_lines.iter().any(|line| is_snippet_block_delimiter(line));
2404
2405 if contains_snippets {
2406 // Don't reflow Snippets blocks - skip this paragraph
2407 i = paragraph_start + paragraph_lines.len();
2408 continue;
2409 }
2410
2411 // Check if this paragraph needs reflowing
2412 let needs_reflow = match config.reflow_mode {
2413 ReflowMode::Normalize => {
2414 // In normalize mode, reflow multi-line paragraphs
2415 paragraph_lines.len() > 1
2416 }
2417 ReflowMode::SentencePerLine => {
2418 // In sentence-per-line mode, check if the JOINED paragraph has multiple sentences
2419 // Note: we check the joined text because sentences can span multiple lines
2420 let sentences = split_into_sentences(¶graph_text);
2421
2422 // Always reflow if multiple sentences on one line
2423 if sentences.len() > 1 {
2424 true
2425 } else if paragraph_lines.len() > 1 {
2426 // For single-sentence paragraphs spanning multiple lines:
2427 // Reflow if they COULD fit on one line (respecting line-length constraint)
2428 if config.line_length.is_unlimited() {
2429 // No line-length constraint - always join single sentences
2430 true
2431 } else {
2432 // Only join if it fits within line-length
2433 let effective_length = self.calculate_effective_length(¶graph_text);
2434 effective_length <= config.line_length.get()
2435 }
2436 } else {
2437 false
2438 }
2439 }
2440 ReflowMode::SemanticLineBreaks => {
2441 let sentences = split_into_sentences(¶graph_text);
2442 // Reflow if multiple sentences, multiple lines, or any line exceeds limit
2443 sentences.len() > 1
2444 || paragraph_lines.len() > 1
2445 || paragraph_lines
2446 .iter()
2447 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
2448 }
2449 ReflowMode::Default => {
2450 // In default mode, only reflow if lines exceed limit
2451 paragraph_lines
2452 .iter()
2453 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
2454 }
2455 };
2456
2457 if needs_reflow {
2458 // Calculate byte range for this paragraph
2459 // Use whole_line_range for each line and combine
2460 let start_range = line_index.whole_line_range(paragraph_start + 1);
2461 let end_line = paragraph_start + paragraph_lines.len() - 1;
2462
2463 // For the last line, we want to preserve any trailing newline
2464 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
2465 // Last line without trailing newline - use line_text_range
2466 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
2467 } else {
2468 // Not the last line or has trailing newline - use whole_line_range
2469 line_index.whole_line_range(end_line + 1)
2470 };
2471
2472 let byte_range = start_range.start..end_range.end;
2473
2474 // Check if the paragraph ends with a hard break and what type
2475 let hard_break_type = paragraph_lines.last().and_then(|line| {
2476 let line = line.strip_suffix('\r').unwrap_or(line);
2477 if line.ends_with('\\') {
2478 Some("\\")
2479 } else if line.ends_with(" ") {
2480 Some(" ")
2481 } else {
2482 None
2483 }
2484 });
2485
2486 // Reflow the paragraph
2487 // When line_length = 0 (no limit), use a very large value for reflow
2488 let reflow_line_length = if config.line_length.is_unlimited() {
2489 usize::MAX
2490 } else {
2491 config.line_length.get()
2492 };
2493 let reflow_options = crate::utils::text_reflow::ReflowOptions {
2494 line_length: reflow_line_length,
2495 break_on_sentences: true,
2496 preserve_breaks: false,
2497 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
2498 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
2499 abbreviations: config.abbreviations_for_reflow(),
2500 length_mode: self.reflow_length_mode(),
2501 };
2502 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
2503
2504 // If the original paragraph ended with a hard break, preserve it
2505 // Preserve the original hard break format (backslash or two spaces)
2506 if let Some(break_marker) = hard_break_type
2507 && !reflowed.is_empty()
2508 {
2509 let last_idx = reflowed.len() - 1;
2510 if !has_hard_break(&reflowed[last_idx]) {
2511 reflowed[last_idx].push_str(break_marker);
2512 }
2513 }
2514
2515 let reflowed_text = reflowed.join(line_ending);
2516
2517 // Preserve trailing newline if the original paragraph had one
2518 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
2519 format!("{reflowed_text}{line_ending}")
2520 } else {
2521 reflowed_text
2522 };
2523
2524 // Get the original text to compare
2525 let original_text = &ctx.content[byte_range.clone()];
2526
2527 // Only generate a warning if the replacement is different from the original
2528 if original_text != replacement {
2529 // Create warning with actual fix
2530 // In default mode, report the specific line that violates
2531 // In normalize mode, report the whole paragraph
2532 // In sentence-per-line mode, report the entire paragraph
2533 let (warning_line, warning_end_line) = match config.reflow_mode {
2534 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
2535 ReflowMode::SentencePerLine | ReflowMode::SemanticLineBreaks => {
2536 // Highlight the entire paragraph that needs reformatting
2537 (paragraph_start + 1, paragraph_start + paragraph_lines.len())
2538 }
2539 ReflowMode::Default => {
2540 // Find the first line that exceeds the limit
2541 let mut violating_line = paragraph_start;
2542 for (idx, line) in paragraph_lines.iter().enumerate() {
2543 if self.calculate_effective_length(line) > config.line_length.get() {
2544 violating_line = paragraph_start + idx;
2545 break;
2546 }
2547 }
2548 (violating_line + 1, violating_line + 1)
2549 }
2550 };
2551
2552 warnings.push(LintWarning {
2553 rule_name: Some(self.name().to_string()),
2554 message: match config.reflow_mode {
2555 ReflowMode::Normalize => format!(
2556 "Paragraph could be normalized to use line length of {} characters",
2557 config.line_length.get()
2558 ),
2559 ReflowMode::SentencePerLine => {
2560 let num_sentences = split_into_sentences(¶graph_text).len();
2561 if paragraph_lines.len() == 1 {
2562 // Single line with multiple sentences
2563 format!("Line contains {num_sentences} sentences (one sentence per line required)")
2564 } else {
2565 let num_lines = paragraph_lines.len();
2566 // Multiple lines - could be split sentences or mixed
2567 format!("Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)")
2568 }
2569 },
2570 ReflowMode::SemanticLineBreaks => {
2571 let num_sentences = split_into_sentences(¶graph_text).len();
2572 format!(
2573 "Paragraph should use semantic line breaks ({num_sentences} sentences)"
2574 )
2575 },
2576 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
2577 },
2578 line: warning_line,
2579 column: 1,
2580 end_line: warning_end_line,
2581 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
2582 severity: Severity::Warning,
2583 fix: Some(crate::rule::Fix {
2584 range: byte_range,
2585 replacement,
2586 }),
2587 });
2588 }
2589 }
2590 }
2591
2592 warnings
2593 }
2594
2595 /// Calculate string length based on the configured length mode
2596 fn calculate_string_length(&self, s: &str) -> usize {
2597 match self.config.length_mode {
2598 LengthMode::Chars => s.chars().count(),
2599 LengthMode::Visual => s.width(),
2600 LengthMode::Bytes => s.len(),
2601 }
2602 }
2603
2604 /// Calculate effective line length
2605 ///
2606 /// Returns the actual display length of the line using the configured length mode.
2607 fn calculate_effective_length(&self, line: &str) -> usize {
2608 self.calculate_string_length(line)
2609 }
2610
2611 /// Calculate line length with inline link/image URLs removed.
2612 ///
2613 /// For each inline link `[text](url)` or image `` on the line,
2614 /// computes the "savings" from removing the URL portion (keeping only `[text]`
2615 /// or `![alt]`). Returns `effective_length - total_savings`.
2616 ///
2617 /// Handles nested constructs (e.g., `[](url)`) by only counting the
2618 /// outermost construct to avoid double-counting.
2619 fn calculate_text_only_length(
2620 &self,
2621 effective_length: usize,
2622 line_number: usize,
2623 ctx: &crate::lint_context::LintContext,
2624 ) -> usize {
2625 let line_range = ctx.line_index.line_content_range(line_number);
2626 let line_byte_end = line_range.end;
2627
2628 // Collect inline links/images on this line: (byte_offset, byte_end, text_only_display_len)
2629 let mut constructs: Vec<(usize, usize, usize)> = Vec::new();
2630
2631 for link in &ctx.links {
2632 if link.line != line_number || link.is_reference {
2633 continue;
2634 }
2635 if !matches!(link.link_type, LinkType::Inline) {
2636 continue;
2637 }
2638 // Skip cross-line links
2639 if link.byte_end > line_byte_end {
2640 continue;
2641 }
2642 // `[text]` in configured length mode
2643 let text_only_len = 2 + self.calculate_string_length(&link.text);
2644 constructs.push((link.byte_offset, link.byte_end, text_only_len));
2645 }
2646
2647 for image in &ctx.images {
2648 if image.line != line_number || image.is_reference {
2649 continue;
2650 }
2651 if !matches!(image.link_type, LinkType::Inline) {
2652 continue;
2653 }
2654 // Skip cross-line images
2655 if image.byte_end > line_byte_end {
2656 continue;
2657 }
2658 // `![alt]` in configured length mode
2659 let text_only_len = 3 + self.calculate_string_length(&image.alt_text);
2660 constructs.push((image.byte_offset, image.byte_end, text_only_len));
2661 }
2662
2663 if constructs.is_empty() {
2664 return effective_length;
2665 }
2666
2667 // Sort by byte offset to handle overlapping/nested constructs
2668 constructs.sort_by_key(|&(start, _, _)| start);
2669
2670 let mut total_savings: usize = 0;
2671 let mut last_end: usize = 0;
2672
2673 for (start, end, text_only_len) in &constructs {
2674 // Skip constructs nested inside a previously counted one
2675 if *start < last_end {
2676 continue;
2677 }
2678 // Full construct length in configured length mode
2679 let full_source = &ctx.content[*start..*end];
2680 let full_len = self.calculate_string_length(full_source);
2681 total_savings += full_len.saturating_sub(*text_only_len);
2682 last_end = *end;
2683 }
2684
2685 effective_length.saturating_sub(total_savings)
2686 }
2687}