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