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