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 // MkDocs flavor requires at least 4 spaces for list continuation
1008 // after a blank line (multi-paragraph list items). For non-blank
1009 // continuation (lines directly following the marker line), use
1010 // the natural marker width so that 2-space indent is recognized.
1011 let min_continuation_indent = if ctx.flavor.requires_strict_list_indent() {
1012 marker_len.max(4)
1013 } else {
1014 marker_len
1015 };
1016 let content_continuation_indent = marker_len;
1017
1018 // Track lines and their types (content, code block, fence, nested list)
1019 #[derive(Clone)]
1020 enum LineType {
1021 Content(String),
1022 CodeBlock(String, usize), // content and original indent
1023 NestedListItem(String, usize), // full line content and original indent
1024 SemanticLine(String), // Lines starting with NOTE:, WARNING:, etc that should stay separate
1025 SnippetLine(String), // MkDocs Snippets delimiters (-8<-) that must stay on their own line
1026 DivMarker(String), // Quarto/Pandoc div markers (::: opening or closing)
1027 AdmonitionHeader(String, usize), // header text (e.g. "!!! note") and original indent
1028 AdmonitionContent(String, usize), // body content text and original indent
1029 Empty,
1030 }
1031
1032 let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
1033 i += 1;
1034
1035 // Collect continuation lines using ctx.lines for metadata
1036 while i < lines.len() {
1037 let line_info = &ctx.lines[i];
1038
1039 // Use pre-computed is_blank from ctx
1040 if line_info.is_blank {
1041 // Empty line - check if next line is indented (part of list item)
1042 if i + 1 < lines.len() {
1043 let next_info = &ctx.lines[i + 1];
1044
1045 // Check if next line is indented enough to be continuation
1046 if !next_info.is_blank && next_info.indent >= min_continuation_indent {
1047 // This blank line is between paragraphs/blocks in the list item
1048 list_item_lines.push(LineType::Empty);
1049 i += 1;
1050 continue;
1051 }
1052 }
1053 // No indented line after blank, end of list item
1054 break;
1055 }
1056
1057 // Use pre-computed indent from ctx
1058 let indent = line_info.indent;
1059
1060 // Valid continuation must be indented at least content_continuation_indent.
1061 // For non-blank continuation, use marker_len (e.g. 2 for "- ").
1062 // MkDocs strict 4-space requirement applies only after blank lines.
1063 if indent >= content_continuation_indent {
1064 let trimmed = line_info.content(ctx.content).trim();
1065
1066 // Use pre-computed in_code_block from ctx
1067 if line_info.in_code_block {
1068 list_item_lines.push(LineType::CodeBlock(
1069 line_info.content(ctx.content)[indent..].to_string(),
1070 indent,
1071 ));
1072 i += 1;
1073 continue;
1074 }
1075
1076 // Check for MkDocs admonition lines inside list items.
1077 // The flavor detection marks these with in_admonition, so we
1078 // can classify them as admonition header or body content.
1079 if line_info.in_admonition {
1080 let raw_content = line_info.content(ctx.content);
1081 if mkdocs_admonitions::is_admonition_start(raw_content) {
1082 let header_text = raw_content[indent..].trim_end().to_string();
1083 list_item_lines.push(LineType::AdmonitionHeader(header_text, indent));
1084 } else {
1085 let body_text = raw_content[indent..].trim_end().to_string();
1086 list_item_lines.push(LineType::AdmonitionContent(body_text, indent));
1087 }
1088 i += 1;
1089 continue;
1090 }
1091
1092 // Check if this is a SIBLING list item (breaks parent)
1093 // Nested lists are indented >= marker_len and are PART of the parent item
1094 // Siblings are at indent < marker_len (at or before parent marker)
1095 if is_list_item(trimmed) && indent < marker_len {
1096 // This is a sibling item at same or higher level - end parent item
1097 break;
1098 }
1099
1100 // Check if this is a NESTED list item marker
1101 // Nested lists should be processed separately UNLESS they're part of a
1102 // multi-paragraph list item (indicated by a blank line before them OR
1103 // it's a continuation of an already-started nested list)
1104 if is_list_item(trimmed) && indent >= marker_len {
1105 // Check if there was a blank line before this (multi-paragraph context)
1106 let has_blank_before = matches!(list_item_lines.last(), Some(LineType::Empty));
1107
1108 // Check if we've already seen nested list content (another nested item)
1109 let has_nested_content = list_item_lines.iter().any(|line| {
1110 matches!(line, LineType::Content(c) if is_list_item(c.trim()))
1111 || matches!(line, LineType::NestedListItem(_, _))
1112 });
1113
1114 if !has_blank_before && !has_nested_content {
1115 // Single-paragraph context with no prior nested items: starts a new item
1116 // End parent collection; nested list will be processed next
1117 break;
1118 }
1119 // else: multi-paragraph context or continuation of nested list, keep collecting
1120 // Mark this as a nested list item to preserve its structure
1121 list_item_lines.push(LineType::NestedListItem(
1122 line_info.content(ctx.content)[indent..].to_string(),
1123 indent,
1124 ));
1125 i += 1;
1126 continue;
1127 }
1128
1129 // Normal continuation vs indented code block.
1130 // Use min_continuation_indent for the threshold since
1131 // code blocks start 4 spaces beyond the expected content
1132 // level (which is min_continuation_indent for MkDocs).
1133 if indent <= min_continuation_indent + 3 {
1134 // Extract content (remove indentation and trailing whitespace)
1135 // Preserve hard breaks (2 trailing spaces) while removing excessive whitespace
1136 // See: https://github.com/rvben/rumdl/issues/76
1137 let content = trim_preserving_hard_break(&line_info.content(ctx.content)[indent..]);
1138
1139 // Check if this is a div marker (::: opening or closing)
1140 // These must be preserved on their own line, not merged into paragraphs
1141 if line_info.is_div_marker {
1142 list_item_lines.push(LineType::DivMarker(content));
1143 }
1144 // Check if this is a fence marker (opening or closing)
1145 // These should be treated as code block lines, not paragraph content
1146 else if is_fence_marker(&content) {
1147 list_item_lines.push(LineType::CodeBlock(content, indent));
1148 }
1149 // Check if this is a semantic line (NOTE:, WARNING:, etc.)
1150 else if is_semantic_line(&content) {
1151 list_item_lines.push(LineType::SemanticLine(content));
1152 }
1153 // Check if this is a snippet block delimiter (-8<- or --8<--)
1154 // These must be preserved on their own lines for MkDocs Snippets extension
1155 else if is_snippet_block_delimiter(&content) {
1156 list_item_lines.push(LineType::SnippetLine(content));
1157 } else {
1158 list_item_lines.push(LineType::Content(content));
1159 }
1160 i += 1;
1161 } else {
1162 // indent >= min_continuation_indent + 4: indented code block
1163 list_item_lines.push(LineType::CodeBlock(
1164 line_info.content(ctx.content)[indent..].to_string(),
1165 indent,
1166 ));
1167 i += 1;
1168 }
1169 } else {
1170 // Not indented enough, end of list item
1171 break;
1172 }
1173 }
1174
1175 // Determine the output continuation indent.
1176 // Normalize/Default modes canonicalize to min_continuation_indent
1177 // (fixing over-indented continuation). Semantic/SentencePerLine
1178 // modes preserve the user's actual indent since they only fix
1179 // line breaking, not indentation.
1180 let indent_size = match config.reflow_mode {
1181 ReflowMode::SemanticLineBreaks | ReflowMode::SentencePerLine => {
1182 // Find indent of the first plain text continuation line,
1183 // skipping the marker line (index 0), nested list items,
1184 // code blocks, and blank lines.
1185 list_item_lines
1186 .iter()
1187 .enumerate()
1188 .skip(1)
1189 .find_map(|(k, lt)| {
1190 if matches!(lt, LineType::Content(_)) {
1191 Some(ctx.lines[list_start + k].indent)
1192 } else {
1193 None
1194 }
1195 })
1196 .unwrap_or(min_continuation_indent)
1197 }
1198 _ => min_continuation_indent,
1199 };
1200 let expected_indent = " ".repeat(indent_size);
1201
1202 // Split list_item_lines into blocks (paragraphs, code blocks, nested lists, semantic lines, and HTML blocks)
1203 #[derive(Clone)]
1204 enum Block {
1205 Paragraph(Vec<String>),
1206 Code {
1207 lines: Vec<(String, usize)>, // (content, indent) pairs
1208 has_preceding_blank: bool, // Whether there was a blank line before this block
1209 },
1210 NestedList(Vec<(String, usize)>), // (content, indent) pairs for nested list items
1211 SemanticLine(String), // Semantic markers like NOTE:, WARNING: that stay on their own line
1212 SnippetLine(String), // MkDocs Snippets delimiter that stays on its own line without extra spacing
1213 DivMarker(String), // Quarto/Pandoc div marker (::: opening or closing) preserved on its own line
1214 Html {
1215 lines: Vec<String>, // HTML content preserved exactly as-is
1216 has_preceding_blank: bool, // Whether there was a blank line before this block
1217 },
1218 Admonition {
1219 header: String, // e.g. "!!! note" or "??? warning \"Title\""
1220 header_indent: usize, // original indent of the header line
1221 content_lines: Vec<(String, usize)>, // (text, original_indent) pairs for body lines
1222 },
1223 }
1224
1225 // HTML tag detection helpers
1226 // Block-level HTML tags that should trigger HTML block detection
1227 const BLOCK_LEVEL_TAGS: &[&str] = &[
1228 "div",
1229 "details",
1230 "summary",
1231 "section",
1232 "article",
1233 "header",
1234 "footer",
1235 "nav",
1236 "aside",
1237 "main",
1238 "table",
1239 "thead",
1240 "tbody",
1241 "tfoot",
1242 "tr",
1243 "td",
1244 "th",
1245 "ul",
1246 "ol",
1247 "li",
1248 "dl",
1249 "dt",
1250 "dd",
1251 "pre",
1252 "blockquote",
1253 "figure",
1254 "figcaption",
1255 "form",
1256 "fieldset",
1257 "legend",
1258 "hr",
1259 "p",
1260 "h1",
1261 "h2",
1262 "h3",
1263 "h4",
1264 "h5",
1265 "h6",
1266 "style",
1267 "script",
1268 "noscript",
1269 ];
1270
1271 fn is_block_html_opening_tag(line: &str) -> Option<String> {
1272 let trimmed = line.trim();
1273
1274 // Check for HTML comments
1275 if trimmed.starts_with("<!--") {
1276 return Some("!--".to_string());
1277 }
1278
1279 // Check for opening tags
1280 if trimmed.starts_with('<') && !trimmed.starts_with("</") && !trimmed.starts_with("<!") {
1281 // Extract tag name from <tagname ...> or <tagname>
1282 let after_bracket = &trimmed[1..];
1283 if let Some(end) = after_bracket.find(|c: char| c.is_whitespace() || c == '>' || c == '/') {
1284 let tag_name = after_bracket[..end].to_lowercase();
1285
1286 // Only treat as block if it's a known block-level tag
1287 if BLOCK_LEVEL_TAGS.contains(&tag_name.as_str()) {
1288 return Some(tag_name);
1289 }
1290 }
1291 }
1292 None
1293 }
1294
1295 fn is_html_closing_tag(line: &str, tag_name: &str) -> bool {
1296 let trimmed = line.trim();
1297
1298 // Special handling for HTML comments
1299 if tag_name == "!--" {
1300 return trimmed.ends_with("-->");
1301 }
1302
1303 // Check for closing tags: </tagname> or </tagname ...>
1304 trimmed.starts_with(&format!("</{tag_name}>"))
1305 || trimmed.starts_with(&format!("</{tag_name} "))
1306 || (trimmed.starts_with("</") && trimmed[2..].trim_start().starts_with(tag_name))
1307 }
1308
1309 fn is_self_closing_tag(line: &str) -> bool {
1310 let trimmed = line.trim();
1311 trimmed.ends_with("/>")
1312 }
1313
1314 let mut blocks: Vec<Block> = Vec::new();
1315 let mut current_paragraph: Vec<String> = Vec::new();
1316 let mut current_code_block: Vec<(String, usize)> = Vec::new();
1317 let mut current_nested_list: Vec<(String, usize)> = Vec::new();
1318 let mut current_html_block: Vec<String> = Vec::new();
1319 let mut html_tag_stack: Vec<String> = Vec::new();
1320 let mut in_code = false;
1321 let mut in_nested_list = false;
1322 let mut in_html_block = false;
1323 let mut had_preceding_blank = false; // Track if we just saw an empty line
1324 let mut code_block_has_preceding_blank = false; // Track blank before current code block
1325 let mut html_block_has_preceding_blank = false; // Track blank before current HTML block
1326
1327 // Track admonition context for block building
1328 let mut in_admonition_block = false;
1329 let mut admonition_header: Option<(String, usize)> = None; // (header_text, indent)
1330 let mut admonition_content: Vec<(String, usize)> = Vec::new();
1331
1332 // Flush any pending admonition block into `blocks`
1333 let flush_admonition = |blocks: &mut Vec<Block>,
1334 in_admonition: &mut bool,
1335 header: &mut Option<(String, usize)>,
1336 content: &mut Vec<(String, usize)>| {
1337 if *in_admonition {
1338 if let Some((h, hi)) = header.take() {
1339 blocks.push(Block::Admonition {
1340 header: h,
1341 header_indent: hi,
1342 content_lines: std::mem::take(content),
1343 });
1344 }
1345 *in_admonition = false;
1346 }
1347 };
1348
1349 for line in &list_item_lines {
1350 match line {
1351 LineType::Empty => {
1352 if in_admonition_block {
1353 // Blank lines inside admonitions separate paragraphs within the body
1354 admonition_content.push((String::new(), 0));
1355 } else if in_code {
1356 current_code_block.push((String::new(), 0));
1357 } else if in_nested_list {
1358 current_nested_list.push((String::new(), 0));
1359 } else if in_html_block {
1360 // Allow blank lines inside HTML blocks
1361 current_html_block.push(String::new());
1362 } else if !current_paragraph.is_empty() {
1363 blocks.push(Block::Paragraph(current_paragraph.clone()));
1364 current_paragraph.clear();
1365 }
1366 // Mark that we saw a blank line
1367 had_preceding_blank = true;
1368 }
1369 LineType::Content(content) => {
1370 flush_admonition(
1371 &mut blocks,
1372 &mut in_admonition_block,
1373 &mut admonition_header,
1374 &mut admonition_content,
1375 );
1376 // Check if we're currently in an HTML block
1377 if in_html_block {
1378 current_html_block.push(content.clone());
1379
1380 // Check if this line closes any open HTML tags
1381 if let Some(last_tag) = html_tag_stack.last() {
1382 if is_html_closing_tag(content, last_tag) {
1383 html_tag_stack.pop();
1384
1385 // If stack is empty, HTML block is complete
1386 if html_tag_stack.is_empty() {
1387 blocks.push(Block::Html {
1388 lines: current_html_block.clone(),
1389 has_preceding_blank: html_block_has_preceding_blank,
1390 });
1391 current_html_block.clear();
1392 in_html_block = false;
1393 }
1394 } else if let Some(new_tag) = is_block_html_opening_tag(content) {
1395 // Nested opening tag within HTML block
1396 if !is_self_closing_tag(content) {
1397 html_tag_stack.push(new_tag);
1398 }
1399 }
1400 }
1401 had_preceding_blank = false;
1402 } else {
1403 // Not in HTML block - check if this line starts one
1404 if let Some(tag_name) = is_block_html_opening_tag(content) {
1405 // Flush current paragraph before starting HTML block
1406 if in_code {
1407 blocks.push(Block::Code {
1408 lines: current_code_block.clone(),
1409 has_preceding_blank: code_block_has_preceding_blank,
1410 });
1411 current_code_block.clear();
1412 in_code = false;
1413 } else if in_nested_list {
1414 blocks.push(Block::NestedList(current_nested_list.clone()));
1415 current_nested_list.clear();
1416 in_nested_list = false;
1417 } else if !current_paragraph.is_empty() {
1418 blocks.push(Block::Paragraph(current_paragraph.clone()));
1419 current_paragraph.clear();
1420 }
1421
1422 // Start new HTML block
1423 in_html_block = true;
1424 html_block_has_preceding_blank = had_preceding_blank;
1425 current_html_block.push(content.clone());
1426
1427 // Check if it's self-closing or needs a closing tag
1428 if is_self_closing_tag(content) {
1429 // Self-closing tag - complete the HTML block immediately
1430 blocks.push(Block::Html {
1431 lines: current_html_block.clone(),
1432 has_preceding_blank: html_block_has_preceding_blank,
1433 });
1434 current_html_block.clear();
1435 in_html_block = false;
1436 } else {
1437 // Regular opening tag - push to stack
1438 html_tag_stack.push(tag_name);
1439 }
1440 } else {
1441 // Regular content line - add to paragraph
1442 if in_code {
1443 // Switching from code to content
1444 blocks.push(Block::Code {
1445 lines: current_code_block.clone(),
1446 has_preceding_blank: code_block_has_preceding_blank,
1447 });
1448 current_code_block.clear();
1449 in_code = false;
1450 } else if in_nested_list {
1451 // Switching from nested list to content
1452 blocks.push(Block::NestedList(current_nested_list.clone()));
1453 current_nested_list.clear();
1454 in_nested_list = false;
1455 }
1456 current_paragraph.push(content.clone());
1457 }
1458 had_preceding_blank = false; // Reset after content
1459 }
1460 }
1461 LineType::CodeBlock(content, indent) => {
1462 flush_admonition(
1463 &mut blocks,
1464 &mut in_admonition_block,
1465 &mut admonition_header,
1466 &mut admonition_content,
1467 );
1468 if in_nested_list {
1469 // Switching from nested list to code
1470 blocks.push(Block::NestedList(current_nested_list.clone()));
1471 current_nested_list.clear();
1472 in_nested_list = false;
1473 } else if in_html_block {
1474 // Switching from HTML block to code (shouldn't happen normally, but handle it)
1475 blocks.push(Block::Html {
1476 lines: current_html_block.clone(),
1477 has_preceding_blank: html_block_has_preceding_blank,
1478 });
1479 current_html_block.clear();
1480 html_tag_stack.clear();
1481 in_html_block = false;
1482 }
1483 if !in_code {
1484 // Switching from content to code
1485 if !current_paragraph.is_empty() {
1486 blocks.push(Block::Paragraph(current_paragraph.clone()));
1487 current_paragraph.clear();
1488 }
1489 in_code = true;
1490 // Record whether there was a blank line before this code block
1491 code_block_has_preceding_blank = had_preceding_blank;
1492 }
1493 current_code_block.push((content.clone(), *indent));
1494 had_preceding_blank = false; // Reset after code
1495 }
1496 LineType::NestedListItem(content, indent) => {
1497 flush_admonition(
1498 &mut blocks,
1499 &mut in_admonition_block,
1500 &mut admonition_header,
1501 &mut admonition_content,
1502 );
1503 if in_code {
1504 // Switching from code to nested list
1505 blocks.push(Block::Code {
1506 lines: current_code_block.clone(),
1507 has_preceding_blank: code_block_has_preceding_blank,
1508 });
1509 current_code_block.clear();
1510 in_code = false;
1511 } else if in_html_block {
1512 // Switching from HTML block to nested list (shouldn't happen normally, but handle it)
1513 blocks.push(Block::Html {
1514 lines: current_html_block.clone(),
1515 has_preceding_blank: html_block_has_preceding_blank,
1516 });
1517 current_html_block.clear();
1518 html_tag_stack.clear();
1519 in_html_block = false;
1520 }
1521 if !in_nested_list {
1522 // Switching from content to nested list
1523 if !current_paragraph.is_empty() {
1524 blocks.push(Block::Paragraph(current_paragraph.clone()));
1525 current_paragraph.clear();
1526 }
1527 in_nested_list = true;
1528 }
1529 current_nested_list.push((content.clone(), *indent));
1530 had_preceding_blank = false; // Reset after nested list
1531 }
1532 LineType::SemanticLine(content) => {
1533 // Semantic lines are standalone - flush any current block and add as separate block
1534 flush_admonition(
1535 &mut blocks,
1536 &mut in_admonition_block,
1537 &mut admonition_header,
1538 &mut admonition_content,
1539 );
1540 if in_code {
1541 blocks.push(Block::Code {
1542 lines: current_code_block.clone(),
1543 has_preceding_blank: code_block_has_preceding_blank,
1544 });
1545 current_code_block.clear();
1546 in_code = false;
1547 } else if in_nested_list {
1548 blocks.push(Block::NestedList(current_nested_list.clone()));
1549 current_nested_list.clear();
1550 in_nested_list = false;
1551 } else if in_html_block {
1552 blocks.push(Block::Html {
1553 lines: current_html_block.clone(),
1554 has_preceding_blank: html_block_has_preceding_blank,
1555 });
1556 current_html_block.clear();
1557 html_tag_stack.clear();
1558 in_html_block = false;
1559 } else if !current_paragraph.is_empty() {
1560 blocks.push(Block::Paragraph(current_paragraph.clone()));
1561 current_paragraph.clear();
1562 }
1563 // Add semantic line as its own block
1564 blocks.push(Block::SemanticLine(content.clone()));
1565 had_preceding_blank = false; // Reset after semantic line
1566 }
1567 LineType::SnippetLine(content) => {
1568 // Snippet delimiters (-8<-) are standalone - flush any current block and add as separate block
1569 // Unlike semantic lines, snippet lines don't add extra blank lines around them
1570 flush_admonition(
1571 &mut blocks,
1572 &mut in_admonition_block,
1573 &mut admonition_header,
1574 &mut admonition_content,
1575 );
1576 if in_code {
1577 blocks.push(Block::Code {
1578 lines: current_code_block.clone(),
1579 has_preceding_blank: code_block_has_preceding_blank,
1580 });
1581 current_code_block.clear();
1582 in_code = false;
1583 } else if in_nested_list {
1584 blocks.push(Block::NestedList(current_nested_list.clone()));
1585 current_nested_list.clear();
1586 in_nested_list = false;
1587 } else if in_html_block {
1588 blocks.push(Block::Html {
1589 lines: current_html_block.clone(),
1590 has_preceding_blank: html_block_has_preceding_blank,
1591 });
1592 current_html_block.clear();
1593 html_tag_stack.clear();
1594 in_html_block = false;
1595 } else if !current_paragraph.is_empty() {
1596 blocks.push(Block::Paragraph(current_paragraph.clone()));
1597 current_paragraph.clear();
1598 }
1599 // Add snippet line as its own block
1600 blocks.push(Block::SnippetLine(content.clone()));
1601 had_preceding_blank = false;
1602 }
1603 LineType::DivMarker(content) => {
1604 // Div markers (::: opening or closing) are standalone structural delimiters
1605 // Flush any current block and add as separate block
1606 flush_admonition(
1607 &mut blocks,
1608 &mut in_admonition_block,
1609 &mut admonition_header,
1610 &mut admonition_content,
1611 );
1612 if in_code {
1613 blocks.push(Block::Code {
1614 lines: current_code_block.clone(),
1615 has_preceding_blank: code_block_has_preceding_blank,
1616 });
1617 current_code_block.clear();
1618 in_code = false;
1619 } else if in_nested_list {
1620 blocks.push(Block::NestedList(current_nested_list.clone()));
1621 current_nested_list.clear();
1622 in_nested_list = false;
1623 } else if in_html_block {
1624 blocks.push(Block::Html {
1625 lines: current_html_block.clone(),
1626 has_preceding_blank: html_block_has_preceding_blank,
1627 });
1628 current_html_block.clear();
1629 html_tag_stack.clear();
1630 in_html_block = false;
1631 } else if !current_paragraph.is_empty() {
1632 blocks.push(Block::Paragraph(current_paragraph.clone()));
1633 current_paragraph.clear();
1634 }
1635 blocks.push(Block::DivMarker(content.clone()));
1636 had_preceding_blank = false;
1637 }
1638 LineType::AdmonitionHeader(header_text, indent) => {
1639 flush_admonition(
1640 &mut blocks,
1641 &mut in_admonition_block,
1642 &mut admonition_header,
1643 &mut admonition_content,
1644 );
1645 // Flush other current blocks
1646 if in_code {
1647 blocks.push(Block::Code {
1648 lines: current_code_block.clone(),
1649 has_preceding_blank: code_block_has_preceding_blank,
1650 });
1651 current_code_block.clear();
1652 in_code = false;
1653 } else if in_nested_list {
1654 blocks.push(Block::NestedList(current_nested_list.clone()));
1655 current_nested_list.clear();
1656 in_nested_list = false;
1657 } else if in_html_block {
1658 blocks.push(Block::Html {
1659 lines: current_html_block.clone(),
1660 has_preceding_blank: html_block_has_preceding_blank,
1661 });
1662 current_html_block.clear();
1663 html_tag_stack.clear();
1664 in_html_block = false;
1665 } else if !current_paragraph.is_empty() {
1666 blocks.push(Block::Paragraph(current_paragraph.clone()));
1667 current_paragraph.clear();
1668 }
1669 // Start new admonition block
1670 in_admonition_block = true;
1671 admonition_header = Some((header_text.clone(), *indent));
1672 admonition_content.clear();
1673 had_preceding_blank = false;
1674 }
1675 LineType::AdmonitionContent(content, indent) => {
1676 if in_admonition_block {
1677 // Add to current admonition body
1678 admonition_content.push((content.clone(), *indent));
1679 } else {
1680 // Admonition content without a header should not happen,
1681 // but treat it as regular content to avoid data loss
1682 current_paragraph.push(content.clone());
1683 }
1684 had_preceding_blank = false;
1685 }
1686 }
1687 }
1688
1689 // Push all remaining pending blocks independently
1690 flush_admonition(
1691 &mut blocks,
1692 &mut in_admonition_block,
1693 &mut admonition_header,
1694 &mut admonition_content,
1695 );
1696 if in_code && !current_code_block.is_empty() {
1697 blocks.push(Block::Code {
1698 lines: current_code_block,
1699 has_preceding_blank: code_block_has_preceding_blank,
1700 });
1701 }
1702 if in_nested_list && !current_nested_list.is_empty() {
1703 blocks.push(Block::NestedList(current_nested_list));
1704 }
1705 if in_html_block && !current_html_block.is_empty() {
1706 blocks.push(Block::Html {
1707 lines: current_html_block,
1708 has_preceding_blank: html_block_has_preceding_blank,
1709 });
1710 }
1711 if !current_paragraph.is_empty() {
1712 blocks.push(Block::Paragraph(current_paragraph));
1713 }
1714
1715 // Helper: check if a line (raw source or stripped content) is exempt
1716 // from line-length checks. Link reference definitions are always exempt;
1717 // standalone link/image lines are exempt when strict mode is off.
1718 // Also checks content after stripping list markers, since list item
1719 // continuation lines may contain link ref defs.
1720 let is_exempt_line = |raw_line: &str| -> bool {
1721 let trimmed = raw_line.trim();
1722 // Link reference definitions: always exempt
1723 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
1724 return true;
1725 }
1726 // Also check after stripping list markers (for list item content)
1727 if is_list_item(trimmed) {
1728 let (_, content) = extract_list_marker_and_content(trimmed);
1729 let content_trimmed = content.trim();
1730 if content_trimmed.starts_with('[')
1731 && content_trimmed.contains("]:")
1732 && LINK_REF_PATTERN.is_match(content_trimmed)
1733 {
1734 return true;
1735 }
1736 }
1737 // Standalone link/image lines: exempt when not strict
1738 if !config.strict && is_standalone_link_or_image_line(raw_line) {
1739 return true;
1740 }
1741 false
1742 };
1743
1744 // Check if reflowing is needed (only for content paragraphs, not code blocks or nested lists)
1745 // Exclude link reference definitions and standalone link lines from content
1746 // so they don't pollute combined_content or trigger false reflow.
1747 let content_lines: Vec<String> = list_item_lines
1748 .iter()
1749 .filter_map(|line| {
1750 if let LineType::Content(s) = line {
1751 if is_exempt_line(s) {
1752 return None;
1753 }
1754 Some(s.clone())
1755 } else {
1756 None
1757 }
1758 })
1759 .collect();
1760
1761 // Check if we need to reflow this list item
1762 // We check the combined content to see if it exceeds length limits
1763 let combined_content = content_lines.join(" ").trim().to_string();
1764
1765 // Helper to check if we should reflow in normalize mode
1766 let should_normalize = || {
1767 // Don't normalize if the list item only contains nested lists, code blocks, or semantic lines
1768 // DO normalize if it has plain text content that spans multiple lines
1769 let has_nested_lists = blocks.iter().any(|b| matches!(b, Block::NestedList(_)));
1770 let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
1771 let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
1772 let has_snippet_lines = blocks.iter().any(|b| matches!(b, Block::SnippetLine(_)));
1773 let has_div_markers = blocks.iter().any(|b| matches!(b, Block::DivMarker(_)));
1774 let has_admonitions = blocks.iter().any(|b| matches!(b, Block::Admonition { .. }));
1775 let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
1776
1777 // If we have structural blocks but no paragraphs, don't normalize
1778 if (has_nested_lists
1779 || has_code_blocks
1780 || has_semantic_lines
1781 || has_snippet_lines
1782 || has_div_markers
1783 || has_admonitions)
1784 && !has_paragraphs
1785 {
1786 return false;
1787 }
1788
1789 // If we have paragraphs, check if they span multiple lines or there are multiple blocks
1790 if has_paragraphs {
1791 // Count only paragraphs that contain at least one non-exempt line.
1792 // Paragraphs consisting entirely of link ref defs or standalone links
1793 // should not trigger normalization.
1794 let paragraph_count = blocks
1795 .iter()
1796 .filter(|b| {
1797 if let Block::Paragraph(para_lines) = b {
1798 !para_lines.iter().all(|line| is_exempt_line(line))
1799 } else {
1800 false
1801 }
1802 })
1803 .count();
1804 if paragraph_count > 1 {
1805 // Multiple non-exempt paragraph blocks should be normalized
1806 return true;
1807 }
1808
1809 // Single paragraph block: normalize if it has multiple content lines
1810 if content_lines.len() > 1 {
1811 return true;
1812 }
1813 }
1814
1815 false
1816 };
1817
1818 let needs_reflow = match config.reflow_mode {
1819 ReflowMode::Normalize => {
1820 // Only reflow if:
1821 // 1. Any non-exempt paragraph, when joined, exceeds the limit, OR
1822 // 2. Any admonition content line exceeds the limit, OR
1823 // 3. The list item should be normalized (has multi-line plain text)
1824 let any_paragraph_exceeds = blocks.iter().any(|block| match block {
1825 Block::Paragraph(para_lines) => {
1826 if para_lines.iter().all(|line| is_exempt_line(line)) {
1827 return false;
1828 }
1829 let joined = para_lines.join(" ");
1830 let with_marker = format!("{}{}", " ".repeat(indent_size), joined.trim());
1831 self.calculate_effective_length(&with_marker) > config.line_length.get()
1832 }
1833 Block::Admonition {
1834 content_lines,
1835 header_indent,
1836 ..
1837 } => content_lines.iter().any(|(content, indent)| {
1838 if content.is_empty() {
1839 return false;
1840 }
1841 let with_indent = format!("{}{}", " ".repeat(*indent.max(header_indent)), content);
1842 self.calculate_effective_length(&with_indent) > config.line_length.get()
1843 }),
1844 _ => false,
1845 });
1846 if any_paragraph_exceeds {
1847 true
1848 } else {
1849 should_normalize()
1850 }
1851 }
1852 ReflowMode::SentencePerLine => {
1853 // Check if list item has multiple sentences
1854 let sentences = split_into_sentences(&combined_content);
1855 sentences.len() > 1
1856 }
1857 ReflowMode::SemanticLineBreaks => {
1858 let sentences = split_into_sentences(&combined_content);
1859 sentences.len() > 1
1860 || (list_start..i).any(|line_idx| {
1861 let line = lines[line_idx];
1862 let trimmed = line.trim();
1863 if trimmed.is_empty() || is_exempt_line(line) {
1864 return false;
1865 }
1866 self.calculate_effective_length(line) > config.line_length.get()
1867 })
1868 }
1869 ReflowMode::Default => {
1870 // In default mode, only reflow if any individual non-exempt line exceeds limit
1871 (list_start..i).any(|line_idx| {
1872 let line = lines[line_idx];
1873 let trimmed = line.trim();
1874 // Skip blank lines and exempt lines
1875 if trimmed.is_empty() || is_exempt_line(line) {
1876 return false;
1877 }
1878 self.calculate_effective_length(line) > config.line_length.get()
1879 })
1880 }
1881 };
1882
1883 if needs_reflow {
1884 let start_range = line_index.whole_line_range(list_start + 1);
1885 let end_line = i - 1;
1886 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1887 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1888 } else {
1889 line_index.whole_line_range(end_line + 1)
1890 };
1891 let byte_range = start_range.start..end_range.end;
1892
1893 // Reflow each block (paragraphs only, preserve code blocks)
1894 // When line_length = 0 (no limit), use a very large value for reflow
1895 let reflow_line_length = if config.line_length.is_unlimited() {
1896 usize::MAX
1897 } else {
1898 config.line_length.get().saturating_sub(indent_size).max(1)
1899 };
1900 let reflow_options = crate::utils::text_reflow::ReflowOptions {
1901 line_length: reflow_line_length,
1902 break_on_sentences: true,
1903 preserve_breaks: false,
1904 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1905 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
1906 abbreviations: config.abbreviations_for_reflow(),
1907 length_mode: self.reflow_length_mode(),
1908 };
1909
1910 let mut result: Vec<String> = Vec::new();
1911 let mut is_first_block = true;
1912
1913 for (block_idx, block) in blocks.iter().enumerate() {
1914 match block {
1915 Block::Paragraph(para_lines) => {
1916 // If every line in this paragraph is exempt (link ref defs,
1917 // standalone links), preserve the paragraph verbatim instead
1918 // of reflowing it. Reflowing would corrupt link ref defs.
1919 let all_exempt = para_lines.iter().all(|line| is_exempt_line(line));
1920
1921 if all_exempt {
1922 for (idx, line) in para_lines.iter().enumerate() {
1923 if is_first_block && idx == 0 {
1924 result.push(format!("{marker}{line}"));
1925 is_first_block = false;
1926 } else {
1927 result.push(format!("{expected_indent}{line}"));
1928 }
1929 }
1930 } else {
1931 // Split the paragraph into segments at hard break boundaries
1932 // Each segment can be reflowed independently
1933 let segments = split_into_segments(para_lines);
1934
1935 for (segment_idx, segment) in segments.iter().enumerate() {
1936 // Check if this segment ends with a hard break and what type
1937 let hard_break_type = segment.last().and_then(|line| {
1938 let line = line.strip_suffix('\r').unwrap_or(line);
1939 if line.ends_with('\\') {
1940 Some("\\")
1941 } else if line.ends_with(" ") {
1942 Some(" ")
1943 } else {
1944 None
1945 }
1946 });
1947
1948 // Join and reflow the segment (removing the hard break marker for processing)
1949 let segment_for_reflow: Vec<String> = segment
1950 .iter()
1951 .map(|line| {
1952 // Strip hard break marker (2 spaces or backslash) for reflow processing
1953 if line.ends_with('\\') {
1954 line[..line.len() - 1].trim_end().to_string()
1955 } else if line.ends_with(" ") {
1956 line[..line.len() - 2].trim_end().to_string()
1957 } else {
1958 line.clone()
1959 }
1960 })
1961 .collect();
1962
1963 let segment_text = segment_for_reflow.join(" ").trim().to_string();
1964 if !segment_text.is_empty() {
1965 let reflowed =
1966 crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
1967
1968 if is_first_block && segment_idx == 0 {
1969 // First segment of first block starts with marker
1970 result.push(format!("{marker}{}", reflowed[0]));
1971 for line in reflowed.iter().skip(1) {
1972 result.push(format!("{expected_indent}{line}"));
1973 }
1974 is_first_block = false;
1975 } else {
1976 // Subsequent segments
1977 for line in reflowed {
1978 result.push(format!("{expected_indent}{line}"));
1979 }
1980 }
1981
1982 // If this segment had a hard break, add it back to the last line
1983 // Preserve the original hard break format (backslash or two spaces)
1984 if let Some(break_marker) = hard_break_type
1985 && let Some(last_line) = result.last_mut()
1986 {
1987 last_line.push_str(break_marker);
1988 }
1989 }
1990 }
1991 }
1992
1993 // Add blank line after paragraph block if there's a next block.
1994 // Check if next block is a code block that doesn't want a preceding blank.
1995 // Also don't add blank lines before snippet lines (they should stay tight).
1996 // Only add if not already ending with one (avoids double blanks).
1997 if block_idx < blocks.len() - 1 {
1998 let next_block = &blocks[block_idx + 1];
1999 let should_add_blank = match next_block {
2000 Block::Code {
2001 has_preceding_blank, ..
2002 } => *has_preceding_blank,
2003 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2004 _ => true, // For all other blocks, add blank line
2005 };
2006 if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2007 {
2008 result.push(String::new());
2009 }
2010 }
2011 }
2012 Block::Code {
2013 lines: code_lines,
2014 has_preceding_blank: _,
2015 } => {
2016 // Preserve code blocks as-is with original indentation
2017 // NOTE: Blank line before code block is handled by the previous block
2018 // (see paragraph block's logic above)
2019
2020 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
2021 if is_first_block && idx == 0 {
2022 // First line of first block gets marker
2023 result.push(format!(
2024 "{marker}{}",
2025 " ".repeat(orig_indent - marker_len) + content
2026 ));
2027 is_first_block = false;
2028 } else if content.is_empty() {
2029 result.push(String::new());
2030 } else {
2031 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
2032 }
2033 }
2034 }
2035 Block::NestedList(nested_items) => {
2036 // Preserve nested list items as-is with original indentation.
2037 // Only add blank before if not already ending with one (avoids
2038 // double blanks when the preceding block already added 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 for (idx, (content, orig_indent)) in nested_items.iter().enumerate() {
2044 if is_first_block && idx == 0 {
2045 // First line of first block gets marker
2046 result.push(format!(
2047 "{marker}{}",
2048 " ".repeat(orig_indent - marker_len) + content
2049 ));
2050 is_first_block = false;
2051 } else if content.is_empty() {
2052 result.push(String::new());
2053 } else {
2054 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
2055 }
2056 }
2057
2058 // Add blank line after nested list if there's a next block.
2059 // Only add if not already ending with one (avoids double blanks
2060 // when the last nested item was already a blank line).
2061 if block_idx < blocks.len() - 1 {
2062 let next_block = &blocks[block_idx + 1];
2063 let should_add_blank = match next_block {
2064 Block::Code {
2065 has_preceding_blank, ..
2066 } => *has_preceding_blank,
2067 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2068 _ => true, // For all other blocks, add blank line
2069 };
2070 if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2071 {
2072 result.push(String::new());
2073 }
2074 }
2075 }
2076 Block::SemanticLine(content) => {
2077 // Preserve semantic lines (NOTE:, WARNING:, etc.) as-is on their own line.
2078 // Only add blank before if not already ending with one.
2079 if !is_first_block && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true) {
2080 result.push(String::new());
2081 }
2082
2083 if is_first_block {
2084 // First block starts with marker
2085 result.push(format!("{marker}{content}"));
2086 is_first_block = false;
2087 } else {
2088 // Subsequent blocks use expected indent
2089 result.push(format!("{expected_indent}{content}"));
2090 }
2091
2092 // Add blank line after semantic line if there's a next block.
2093 // Only add if not already ending with one.
2094 if block_idx < blocks.len() - 1 {
2095 let next_block = &blocks[block_idx + 1];
2096 let should_add_blank = match next_block {
2097 Block::Code {
2098 has_preceding_blank, ..
2099 } => *has_preceding_blank,
2100 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2101 _ => true, // For all other blocks, add blank line
2102 };
2103 if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2104 {
2105 result.push(String::new());
2106 }
2107 }
2108 }
2109 Block::SnippetLine(content) => {
2110 // Preserve snippet delimiters (-8<-) as-is on their own line
2111 // Unlike semantic lines, snippet lines don't add extra blank lines
2112 if is_first_block {
2113 // First block starts with marker
2114 result.push(format!("{marker}{content}"));
2115 is_first_block = false;
2116 } else {
2117 // Subsequent blocks use expected indent
2118 result.push(format!("{expected_indent}{content}"));
2119 }
2120 // No blank lines added before or after snippet delimiters
2121 }
2122 Block::DivMarker(content) => {
2123 // Preserve div markers (::: opening or closing) as-is on their own line
2124 if is_first_block {
2125 result.push(format!("{marker}{content}"));
2126 is_first_block = false;
2127 } else {
2128 result.push(format!("{expected_indent}{content}"));
2129 }
2130 }
2131 Block::Html {
2132 lines: html_lines,
2133 has_preceding_blank: _,
2134 } => {
2135 // Preserve HTML blocks exactly as-is with original indentation
2136 // NOTE: Blank line before HTML block is handled by the previous block
2137
2138 for (idx, line) in html_lines.iter().enumerate() {
2139 if is_first_block && idx == 0 {
2140 // First line of first block gets marker
2141 result.push(format!("{marker}{line}"));
2142 is_first_block = false;
2143 } else if line.is_empty() {
2144 // Preserve blank lines inside HTML blocks
2145 result.push(String::new());
2146 } else {
2147 // Preserve lines with their original content (already includes indentation)
2148 result.push(format!("{expected_indent}{line}"));
2149 }
2150 }
2151
2152 // Add blank line after HTML block if there's a next block.
2153 // Only add if not already ending with one (avoids double blanks
2154 // when the HTML block itself contained a trailing blank line).
2155 if block_idx < blocks.len() - 1 {
2156 let next_block = &blocks[block_idx + 1];
2157 let should_add_blank = match next_block {
2158 Block::Code {
2159 has_preceding_blank, ..
2160 } => *has_preceding_blank,
2161 Block::Html {
2162 has_preceding_blank, ..
2163 } => *has_preceding_blank,
2164 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2165 _ => true, // For all other blocks, add blank line
2166 };
2167 if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2168 {
2169 result.push(String::new());
2170 }
2171 }
2172 }
2173 Block::Admonition {
2174 header,
2175 header_indent,
2176 content_lines: admon_lines,
2177 } => {
2178 // Reconstruct admonition block with header at original indent
2179 // and body content reflowed to fit within the line length limit
2180
2181 // Add blank line before admonition if not first block
2182 if !is_first_block && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true) {
2183 result.push(String::new());
2184 }
2185
2186 // Output the header at its original indent
2187 let header_indent_str = " ".repeat(*header_indent);
2188 if is_first_block {
2189 result.push(format!(
2190 "{marker}{}",
2191 " ".repeat(header_indent.saturating_sub(marker_len)) + header
2192 ));
2193 is_first_block = false;
2194 } else {
2195 result.push(format!("{header_indent_str}{header}"));
2196 }
2197
2198 // Derive body indent from the first non-empty content line's
2199 // stored indent, falling back to header_indent + 4 for
2200 // empty-body admonitions
2201 let body_indent = admon_lines
2202 .iter()
2203 .find(|(content, _)| !content.is_empty())
2204 .map(|(_, indent)| *indent)
2205 .unwrap_or(header_indent + 4);
2206 let body_indent_str = " ".repeat(body_indent);
2207
2208 // Collect body content into paragraphs separated by blank lines
2209 let mut body_paragraphs: Vec<Vec<String>> = Vec::new();
2210 let mut current_para: Vec<String> = Vec::new();
2211
2212 for (content, _orig_indent) in admon_lines {
2213 if content.is_empty() {
2214 if !current_para.is_empty() {
2215 body_paragraphs.push(current_para.clone());
2216 current_para.clear();
2217 }
2218 } else {
2219 current_para.push(content.clone());
2220 }
2221 }
2222 if !current_para.is_empty() {
2223 body_paragraphs.push(current_para);
2224 }
2225
2226 // Reflow each paragraph in the body
2227 for paragraph in &body_paragraphs {
2228 // Add blank line before each paragraph (including the first, after the header)
2229 result.push(String::new());
2230
2231 let paragraph_text = paragraph.join(" ").trim().to_string();
2232 if paragraph_text.is_empty() {
2233 continue;
2234 }
2235
2236 // Reflow with adjusted line length
2237 let admon_reflow_length = if config.line_length.is_unlimited() {
2238 usize::MAX
2239 } else {
2240 config.line_length.get().saturating_sub(body_indent).max(1)
2241 };
2242
2243 let admon_reflow_options = crate::utils::text_reflow::ReflowOptions {
2244 line_length: admon_reflow_length,
2245 break_on_sentences: true,
2246 preserve_breaks: false,
2247 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
2248 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
2249 abbreviations: config.abbreviations_for_reflow(),
2250 length_mode: self.reflow_length_mode(),
2251 };
2252
2253 let reflowed =
2254 crate::utils::text_reflow::reflow_line(¶graph_text, &admon_reflow_options);
2255 for line in &reflowed {
2256 result.push(format!("{body_indent_str}{line}"));
2257 }
2258 }
2259
2260 // Add blank line after admonition if there's a next block
2261 if block_idx < blocks.len() - 1 {
2262 let next_block = &blocks[block_idx + 1];
2263 let should_add_blank = match next_block {
2264 Block::Code {
2265 has_preceding_blank, ..
2266 } => *has_preceding_blank,
2267 Block::SnippetLine(_) | Block::DivMarker(_) => false,
2268 _ => true,
2269 };
2270 if should_add_blank && result.last().map(|s: &String| !s.is_empty()).unwrap_or(true)
2271 {
2272 result.push(String::new());
2273 }
2274 }
2275 }
2276 }
2277 }
2278
2279 let reflowed_text = result.join(line_ending);
2280
2281 // Preserve trailing newline
2282 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
2283 format!("{reflowed_text}{line_ending}")
2284 } else {
2285 reflowed_text
2286 };
2287
2288 // Get the original text to compare
2289 let original_text = &ctx.content[byte_range.clone()];
2290
2291 // Only generate a warning if the replacement is different from the original
2292 if original_text != replacement {
2293 // Generate an appropriate message based on why reflow is needed
2294 let message = match config.reflow_mode {
2295 ReflowMode::SentencePerLine => {
2296 let num_sentences = split_into_sentences(&combined_content).len();
2297 let num_lines = content_lines.len();
2298 if num_lines == 1 {
2299 // Single line with multiple sentences
2300 format!("Line contains {num_sentences} sentences (one sentence per line required)")
2301 } else {
2302 // Multiple lines - could be split sentences or mixed
2303 format!(
2304 "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
2305 )
2306 }
2307 }
2308 ReflowMode::SemanticLineBreaks => {
2309 let num_sentences = split_into_sentences(&combined_content).len();
2310 format!("Paragraph should use semantic line breaks ({num_sentences} sentences)")
2311 }
2312 ReflowMode::Normalize => {
2313 // Find the longest non-exempt paragraph when joined
2314 let max_para_length = blocks
2315 .iter()
2316 .filter_map(|block| {
2317 if let Block::Paragraph(para_lines) = block {
2318 if para_lines.iter().all(|line| is_exempt_line(line)) {
2319 return None;
2320 }
2321 let joined = para_lines.join(" ");
2322 let with_indent = format!("{}{}", " ".repeat(indent_size), joined.trim());
2323 Some(self.calculate_effective_length(&with_indent))
2324 } else {
2325 None
2326 }
2327 })
2328 .max()
2329 .unwrap_or(0);
2330 if max_para_length > config.line_length.get() {
2331 format!(
2332 "Line length {} exceeds {} characters",
2333 max_para_length,
2334 config.line_length.get()
2335 )
2336 } else {
2337 "Multi-line content can be normalized".to_string()
2338 }
2339 }
2340 ReflowMode::Default => {
2341 // Report the actual longest non-exempt line, not the combined content
2342 let max_length = (list_start..i)
2343 .filter(|&line_idx| {
2344 let line = lines[line_idx];
2345 let trimmed = line.trim();
2346 !trimmed.is_empty() && !is_exempt_line(line)
2347 })
2348 .map(|line_idx| self.calculate_effective_length(lines[line_idx]))
2349 .max()
2350 .unwrap_or(0);
2351 format!(
2352 "Line length {} exceeds {} characters",
2353 max_length,
2354 config.line_length.get()
2355 )
2356 }
2357 };
2358
2359 warnings.push(LintWarning {
2360 rule_name: Some(self.name().to_string()),
2361 message,
2362 line: list_start + 1,
2363 column: 1,
2364 end_line: end_line + 1,
2365 end_column: lines[end_line].len() + 1,
2366 severity: Severity::Warning,
2367 fix: Some(crate::rule::Fix {
2368 range: byte_range,
2369 replacement,
2370 }),
2371 });
2372 }
2373 }
2374 continue;
2375 }
2376
2377 // Found start of a paragraph - collect all lines in it
2378 let paragraph_start = i;
2379 let mut paragraph_lines = vec![lines[i]];
2380 i += 1;
2381
2382 while i < lines.len() {
2383 let next_line = lines[i];
2384 let next_line_num = i + 1;
2385 let next_trimmed = next_line.trim();
2386
2387 // Stop at paragraph boundaries
2388 if next_trimmed.is_empty()
2389 || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
2390 || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
2391 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
2392 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_comment)
2393 || ctx.line_info(next_line_num).is_some_and(|info| info.in_esm_block)
2394 || ctx.line_info(next_line_num).is_some_and(|info| info.in_jsx_expression)
2395 || ctx.line_info(next_line_num).is_some_and(|info| info.in_mdx_comment)
2396 || ctx
2397 .line_info(next_line_num)
2398 .is_some_and(|info| info.in_mkdocs_container())
2399 || (next_line_num > 0
2400 && next_line_num <= ctx.lines.len()
2401 && ctx.lines[next_line_num - 1].blockquote.is_some())
2402 || next_trimmed.starts_with('#')
2403 || TableUtils::is_potential_table_row(next_line)
2404 || is_list_item(next_trimmed)
2405 || is_horizontal_rule(next_trimmed)
2406 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
2407 || is_template_directive_only(next_line)
2408 || is_standalone_attr_list(next_line)
2409 || is_snippet_block_delimiter(next_line)
2410 || ctx.line_info(next_line_num).is_some_and(|info| info.is_div_marker)
2411 {
2412 break;
2413 }
2414
2415 // Check if the previous line ends with a hard break (2+ spaces or backslash)
2416 if i > 0 && has_hard_break(lines[i - 1]) {
2417 // Don't include lines after hard breaks in the same paragraph
2418 break;
2419 }
2420
2421 paragraph_lines.push(next_line);
2422 i += 1;
2423 }
2424
2425 // Combine paragraph lines into a single string for processing
2426 // This must be done BEFORE the needs_reflow check for sentence-per-line mode
2427 let paragraph_text = paragraph_lines.join(" ");
2428
2429 // Skip reflowing if this paragraph contains definition list items
2430 // Definition lists are multi-line structures that should not be joined
2431 let contains_definition_list = paragraph_lines
2432 .iter()
2433 .any(|line| crate::utils::is_definition_list_item(line));
2434
2435 if contains_definition_list {
2436 // Don't reflow definition lists - skip this paragraph
2437 i = paragraph_start + paragraph_lines.len();
2438 continue;
2439 }
2440
2441 // Skip reflowing if this paragraph contains MkDocs Snippets markers
2442 // Snippets blocks (-8<- ... -8<-) should be preserved exactly
2443 let contains_snippets = paragraph_lines.iter().any(|line| is_snippet_block_delimiter(line));
2444
2445 if contains_snippets {
2446 // Don't reflow Snippets blocks - skip this paragraph
2447 i = paragraph_start + paragraph_lines.len();
2448 continue;
2449 }
2450
2451 // Check if this paragraph needs reflowing
2452 let needs_reflow = match config.reflow_mode {
2453 ReflowMode::Normalize => {
2454 // In normalize mode, reflow multi-line paragraphs
2455 paragraph_lines.len() > 1
2456 }
2457 ReflowMode::SentencePerLine => {
2458 // In sentence-per-line mode, check if the JOINED paragraph has multiple sentences
2459 // Note: we check the joined text because sentences can span multiple lines
2460 let sentences = split_into_sentences(¶graph_text);
2461
2462 // Always reflow if multiple sentences on one line
2463 if sentences.len() > 1 {
2464 true
2465 } else if paragraph_lines.len() > 1 {
2466 // For single-sentence paragraphs spanning multiple lines:
2467 // Reflow if they COULD fit on one line (respecting line-length constraint)
2468 if config.line_length.is_unlimited() {
2469 // No line-length constraint - always join single sentences
2470 true
2471 } else {
2472 // Only join if it fits within line-length
2473 let effective_length = self.calculate_effective_length(¶graph_text);
2474 effective_length <= config.line_length.get()
2475 }
2476 } else {
2477 false
2478 }
2479 }
2480 ReflowMode::SemanticLineBreaks => {
2481 let sentences = split_into_sentences(¶graph_text);
2482 // Reflow if multiple sentences, multiple lines, or any line exceeds limit
2483 sentences.len() > 1
2484 || paragraph_lines.len() > 1
2485 || paragraph_lines
2486 .iter()
2487 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
2488 }
2489 ReflowMode::Default => {
2490 // In default mode, only reflow if lines exceed limit
2491 paragraph_lines
2492 .iter()
2493 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
2494 }
2495 };
2496
2497 if needs_reflow {
2498 // Calculate byte range for this paragraph
2499 // Use whole_line_range for each line and combine
2500 let start_range = line_index.whole_line_range(paragraph_start + 1);
2501 let end_line = paragraph_start + paragraph_lines.len() - 1;
2502
2503 // For the last line, we want to preserve any trailing newline
2504 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
2505 // Last line without trailing newline - use line_text_range
2506 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
2507 } else {
2508 // Not the last line or has trailing newline - use whole_line_range
2509 line_index.whole_line_range(end_line + 1)
2510 };
2511
2512 let byte_range = start_range.start..end_range.end;
2513
2514 // Check if the paragraph ends with a hard break and what type
2515 let hard_break_type = paragraph_lines.last().and_then(|line| {
2516 let line = line.strip_suffix('\r').unwrap_or(line);
2517 if line.ends_with('\\') {
2518 Some("\\")
2519 } else if line.ends_with(" ") {
2520 Some(" ")
2521 } else {
2522 None
2523 }
2524 });
2525
2526 // Reflow the paragraph
2527 // When line_length = 0 (no limit), use a very large value for reflow
2528 let reflow_line_length = if config.line_length.is_unlimited() {
2529 usize::MAX
2530 } else {
2531 config.line_length.get()
2532 };
2533 let reflow_options = crate::utils::text_reflow::ReflowOptions {
2534 line_length: reflow_line_length,
2535 break_on_sentences: true,
2536 preserve_breaks: false,
2537 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
2538 semantic_line_breaks: config.reflow_mode == ReflowMode::SemanticLineBreaks,
2539 abbreviations: config.abbreviations_for_reflow(),
2540 length_mode: self.reflow_length_mode(),
2541 };
2542 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
2543
2544 // If the original paragraph ended with a hard break, preserve it
2545 // Preserve the original hard break format (backslash or two spaces)
2546 if let Some(break_marker) = hard_break_type
2547 && !reflowed.is_empty()
2548 {
2549 let last_idx = reflowed.len() - 1;
2550 if !has_hard_break(&reflowed[last_idx]) {
2551 reflowed[last_idx].push_str(break_marker);
2552 }
2553 }
2554
2555 let reflowed_text = reflowed.join(line_ending);
2556
2557 // Preserve trailing newline if the original paragraph had one
2558 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
2559 format!("{reflowed_text}{line_ending}")
2560 } else {
2561 reflowed_text
2562 };
2563
2564 // Get the original text to compare
2565 let original_text = &ctx.content[byte_range.clone()];
2566
2567 // Only generate a warning if the replacement is different from the original
2568 if original_text != replacement {
2569 // Create warning with actual fix
2570 // In default mode, report the specific line that violates
2571 // In normalize mode, report the whole paragraph
2572 // In sentence-per-line mode, report the entire paragraph
2573 let (warning_line, warning_end_line) = match config.reflow_mode {
2574 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
2575 ReflowMode::SentencePerLine | ReflowMode::SemanticLineBreaks => {
2576 // Highlight the entire paragraph that needs reformatting
2577 (paragraph_start + 1, paragraph_start + paragraph_lines.len())
2578 }
2579 ReflowMode::Default => {
2580 // Find the first line that exceeds the limit
2581 let mut violating_line = paragraph_start;
2582 for (idx, line) in paragraph_lines.iter().enumerate() {
2583 if self.calculate_effective_length(line) > config.line_length.get() {
2584 violating_line = paragraph_start + idx;
2585 break;
2586 }
2587 }
2588 (violating_line + 1, violating_line + 1)
2589 }
2590 };
2591
2592 warnings.push(LintWarning {
2593 rule_name: Some(self.name().to_string()),
2594 message: match config.reflow_mode {
2595 ReflowMode::Normalize => format!(
2596 "Paragraph could be normalized to use line length of {} characters",
2597 config.line_length.get()
2598 ),
2599 ReflowMode::SentencePerLine => {
2600 let num_sentences = split_into_sentences(¶graph_text).len();
2601 if paragraph_lines.len() == 1 {
2602 // Single line with multiple sentences
2603 format!("Line contains {num_sentences} sentences (one sentence per line required)")
2604 } else {
2605 let num_lines = paragraph_lines.len();
2606 // Multiple lines - could be split sentences or mixed
2607 format!("Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)")
2608 }
2609 },
2610 ReflowMode::SemanticLineBreaks => {
2611 let num_sentences = split_into_sentences(¶graph_text).len();
2612 format!(
2613 "Paragraph should use semantic line breaks ({num_sentences} sentences)"
2614 )
2615 },
2616 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
2617 },
2618 line: warning_line,
2619 column: 1,
2620 end_line: warning_end_line,
2621 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
2622 severity: Severity::Warning,
2623 fix: Some(crate::rule::Fix {
2624 range: byte_range,
2625 replacement,
2626 }),
2627 });
2628 }
2629 }
2630 }
2631
2632 warnings
2633 }
2634
2635 /// Calculate string length based on the configured length mode
2636 fn calculate_string_length(&self, s: &str) -> usize {
2637 match self.config.length_mode {
2638 LengthMode::Chars => s.chars().count(),
2639 LengthMode::Visual => s.width(),
2640 LengthMode::Bytes => s.len(),
2641 }
2642 }
2643
2644 /// Calculate effective line length
2645 ///
2646 /// Returns the actual display length of the line using the configured length mode.
2647 fn calculate_effective_length(&self, line: &str) -> usize {
2648 self.calculate_string_length(line)
2649 }
2650
2651 /// Calculate line length with inline link/image URLs removed.
2652 ///
2653 /// For each inline link `[text](url)` or image `` on the line,
2654 /// computes the "savings" from removing the URL portion (keeping only `[text]`
2655 /// or `![alt]`). Returns `effective_length - total_savings`.
2656 ///
2657 /// Handles nested constructs (e.g., `[](url)`) by only counting the
2658 /// outermost construct to avoid double-counting.
2659 fn calculate_text_only_length(
2660 &self,
2661 effective_length: usize,
2662 line_number: usize,
2663 ctx: &crate::lint_context::LintContext,
2664 ) -> usize {
2665 let line_range = ctx.line_index.line_content_range(line_number);
2666 let line_byte_end = line_range.end;
2667
2668 // Collect inline links/images on this line: (byte_offset, byte_end, text_only_display_len)
2669 let mut constructs: Vec<(usize, usize, usize)> = Vec::new();
2670
2671 for link in &ctx.links {
2672 if link.line != line_number || link.is_reference {
2673 continue;
2674 }
2675 if !matches!(link.link_type, LinkType::Inline) {
2676 continue;
2677 }
2678 // Skip cross-line links
2679 if link.byte_end > line_byte_end {
2680 continue;
2681 }
2682 // `[text]` in configured length mode
2683 let text_only_len = 2 + self.calculate_string_length(&link.text);
2684 constructs.push((link.byte_offset, link.byte_end, text_only_len));
2685 }
2686
2687 for image in &ctx.images {
2688 if image.line != line_number || image.is_reference {
2689 continue;
2690 }
2691 if !matches!(image.link_type, LinkType::Inline) {
2692 continue;
2693 }
2694 // Skip cross-line images
2695 if image.byte_end > line_byte_end {
2696 continue;
2697 }
2698 // `![alt]` in configured length mode
2699 let text_only_len = 3 + self.calculate_string_length(&image.alt_text);
2700 constructs.push((image.byte_offset, image.byte_end, text_only_len));
2701 }
2702
2703 if constructs.is_empty() {
2704 return effective_length;
2705 }
2706
2707 // Sort by byte offset to handle overlapping/nested constructs
2708 constructs.sort_by_key(|&(start, _, _)| start);
2709
2710 let mut total_savings: usize = 0;
2711 let mut last_end: usize = 0;
2712
2713 for (start, end, text_only_len) in &constructs {
2714 // Skip constructs nested inside a previously counted one
2715 if *start < last_end {
2716 continue;
2717 }
2718 // Full construct length in configured length mode
2719 let full_source = &ctx.content[*start..*end];
2720 let full_len = self.calculate_string_length(full_source);
2721 total_savings += full_len.saturating_sub(*text_only_len);
2722 last_end = *end;
2723 }
2724
2725 effective_length.saturating_sub(total_savings)
2726 }
2727}