rumdl_lib/rules/
md013_line_length.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::range_utils::LineIndex;
7use crate::utils::range_utils::calculate_excess_range;
8use crate::utils::regex_cache::{
9    IMAGE_REF_PATTERN, INLINE_LINK_REGEX as MARKDOWN_LINK_PATTERN, LINK_REF_PATTERN, URL_IN_TEXT, URL_PATTERN,
10};
11use crate::utils::table_utils::TableUtils;
12use crate::utils::text_reflow::split_into_sentences;
13use toml;
14
15pub mod md013_config;
16use md013_config::{MD013Config, ReflowMode};
17
18#[derive(Clone, Default)]
19pub struct MD013LineLength {
20    pub(crate) config: MD013Config,
21}
22
23impl MD013LineLength {
24    pub fn new(line_length: usize, code_blocks: bool, tables: bool, headings: bool, strict: bool) -> Self {
25        Self {
26            config: MD013Config {
27                line_length,
28                code_blocks,
29                tables,
30                headings,
31                strict,
32                reflow: false,
33                reflow_mode: ReflowMode::default(),
34            },
35        }
36    }
37
38    pub fn from_config_struct(config: MD013Config) -> Self {
39        Self { config }
40    }
41
42    fn should_ignore_line(
43        &self,
44        line: &str,
45        _lines: &[&str],
46        current_line: usize,
47        ctx: &crate::lint_context::LintContext,
48    ) -> bool {
49        if self.config.strict {
50            return false;
51        }
52
53        // Quick check for common patterns before expensive regex
54        let trimmed = line.trim();
55
56        // Only skip if the entire line is a URL (quick check first)
57        if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
58            return true;
59        }
60
61        // Only skip if the entire line is an image reference (quick check first)
62        if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
63            return true;
64        }
65
66        // Only skip if the entire line is a link reference (quick check first)
67        if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
68            return true;
69        }
70
71        // Code blocks with long strings (only check if in code block)
72        if ctx.is_in_code_block(current_line + 1) && !trimmed.is_empty() && !line.contains(' ') && !line.contains('\t')
73        {
74            return true;
75        }
76
77        false
78    }
79}
80
81impl Rule for MD013LineLength {
82    fn name(&self) -> &'static str {
83        "MD013"
84    }
85
86    fn description(&self) -> &'static str {
87        "Line length should not be excessive"
88    }
89
90    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
91        let content = ctx.content;
92
93        // Fast early return using should_skip
94        // But don't skip if we're in reflow mode with Normalize or SentencePerLine
95        if self.should_skip(ctx)
96            && !(self.config.reflow
97                && (self.config.reflow_mode == ReflowMode::Normalize
98                    || self.config.reflow_mode == ReflowMode::SentencePerLine))
99        {
100            return Ok(Vec::new());
101        }
102
103        // Direct implementation without DocumentStructure
104        let mut warnings = Vec::new();
105
106        // Check for inline configuration overrides
107        let inline_config = crate::inline_config::InlineConfig::from_content(content);
108        let config_override = inline_config.get_rule_config("MD013");
109
110        // Apply configuration override if present
111        let effective_config = if let Some(json_config) = config_override {
112            if let Some(obj) = json_config.as_object() {
113                let mut config = self.config.clone();
114                if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
115                    config.line_length = line_length as usize;
116                }
117                if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
118                    config.code_blocks = code_blocks;
119                }
120                if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
121                    config.tables = tables;
122                }
123                if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
124                    config.headings = headings;
125                }
126                if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
127                    config.strict = strict;
128                }
129                if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
130                    config.reflow = reflow;
131                }
132                if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
133                    config.reflow_mode = match reflow_mode {
134                        "default" => ReflowMode::Default,
135                        "normalize" => ReflowMode::Normalize,
136                        "sentence-per-line" => ReflowMode::SentencePerLine,
137                        _ => ReflowMode::default(),
138                    };
139                }
140                config
141            } else {
142                self.config.clone()
143            }
144        } else {
145            self.config.clone()
146        };
147
148        // Pre-filter lines that could be problematic to avoid processing all lines
149        let mut candidate_lines = Vec::new();
150        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
151            // Quick length check first
152            if line_info.content.len() > effective_config.line_length {
153                candidate_lines.push(line_idx);
154            }
155        }
156
157        // If no candidate lines and not in normalize or sentence-per-line mode, early return
158        if candidate_lines.is_empty()
159            && !(effective_config.reflow
160                && (effective_config.reflow_mode == ReflowMode::Normalize
161                    || effective_config.reflow_mode == ReflowMode::SentencePerLine))
162        {
163            return Ok(warnings);
164        }
165
166        // Use ctx.lines if available for better performance
167        let lines: Vec<&str> = if !ctx.lines.is_empty() {
168            ctx.lines.iter().map(|l| l.content.as_str()).collect()
169        } else {
170            content.lines().collect()
171        };
172
173        // Create a quick lookup set for heading lines (only if needed)
174        let heading_lines_set: std::collections::HashSet<usize> = if !effective_config.headings {
175            ctx.lines
176                .iter()
177                .enumerate()
178                .filter(|(_, line)| line.heading.is_some())
179                .map(|(idx, _)| idx + 1)
180                .collect()
181        } else {
182            std::collections::HashSet::new()
183        };
184
185        // Use TableUtils to find all table blocks (only if needed)
186        let table_lines_set: std::collections::HashSet<usize> = if !effective_config.tables {
187            let table_blocks = TableUtils::find_table_blocks(content, ctx);
188            let mut table_lines = std::collections::HashSet::new();
189            for table in &table_blocks {
190                table_lines.insert(table.header_line + 1);
191                table_lines.insert(table.delimiter_line + 1);
192                for &line in &table.content_lines {
193                    table_lines.insert(line + 1);
194                }
195            }
196            table_lines
197        } else {
198            std::collections::HashSet::new()
199        };
200
201        // Only process candidate lines that were pre-filtered
202        // Skip line length checks entirely in sentence-per-line mode
203        if effective_config.reflow_mode != ReflowMode::SentencePerLine {
204            for &line_idx in &candidate_lines {
205                let line_number = line_idx + 1;
206                let line = lines[line_idx];
207
208                // Calculate effective length excluding unbreakable URLs
209                let effective_length = self.calculate_effective_length(line);
210
211                // Use single line length limit for all content
212                let line_limit = effective_config.line_length;
213
214                // Skip short lines immediately (double-check after effective length calculation)
215                if effective_length <= line_limit {
216                    continue;
217                }
218
219                // Skip various block types efficiently
220                if !effective_config.strict {
221                    // Skip setext heading underlines
222                    if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
223                        continue;
224                    }
225
226                    // Skip block elements according to config flags
227                    // The flags mean: true = check these elements, false = skip these elements
228                    // So we skip when the flag is FALSE and the line is in that element type
229                    if (!effective_config.headings && heading_lines_set.contains(&line_number))
230                        || (!effective_config.code_blocks && ctx.is_in_code_block(line_number))
231                        || (!effective_config.tables && table_lines_set.contains(&line_number))
232                        || ctx.lines[line_number - 1].blockquote.is_some()
233                        || ctx.is_in_html_block(line_number)
234                    {
235                        continue;
236                    }
237
238                    // Skip lines that are only a URL, image ref, or link ref
239                    if self.should_ignore_line(line, &lines, line_idx, ctx) {
240                        continue;
241                    }
242                }
243
244                // Don't provide fix for individual lines when reflow is enabled
245                // Paragraph-based fixes will be handled separately
246                let fix = None;
247
248                let message = format!("Line length {effective_length} exceeds {line_limit} characters");
249
250                // Calculate precise character range for the excess portion
251                let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
252
253                warnings.push(LintWarning {
254                    rule_name: Some(self.name()),
255                    message,
256                    line: start_line,
257                    column: start_col,
258                    end_line,
259                    end_column: end_col,
260                    severity: Severity::Warning,
261                    fix,
262                });
263            }
264        }
265
266        // If reflow is enabled, generate paragraph-based fixes
267        if effective_config.reflow {
268            let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, &lines);
269            // Merge paragraph warnings with line warnings, removing duplicates
270            for pw in paragraph_warnings {
271                // Remove any line warnings that overlap with this paragraph
272                warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
273                warnings.push(pw);
274            }
275        }
276
277        Ok(warnings)
278    }
279
280    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
281        // For CLI usage, apply fixes from warnings
282        // LSP will use the warning-based fixes directly
283        let warnings = self.check(ctx)?;
284
285        // If there are no fixes, return content unchanged
286        if !warnings.iter().any(|w| w.fix.is_some()) {
287            return Ok(ctx.content.to_string());
288        }
289
290        // Apply warning-based fixes
291        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
292            .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
293    }
294
295    fn as_any(&self) -> &dyn std::any::Any {
296        self
297    }
298
299    fn category(&self) -> RuleCategory {
300        RuleCategory::Whitespace
301    }
302
303    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
304        // Skip if content is empty
305        if ctx.content.is_empty() {
306            return true;
307        }
308
309        // For sentence-per-line or normalize mode, never skip based on line length
310        if self.config.reflow
311            && (self.config.reflow_mode == ReflowMode::SentencePerLine
312                || self.config.reflow_mode == ReflowMode::Normalize)
313        {
314            return false;
315        }
316
317        // Quick check: if total content is shorter than line limit, definitely skip
318        if ctx.content.len() <= self.config.line_length {
319            return true;
320        }
321
322        // Use more efficient check - any() with early termination instead of all()
323        !ctx.lines
324            .iter()
325            .any(|line| line.content.len() > self.config.line_length)
326    }
327
328    fn default_config_section(&self) -> Option<(String, toml::Value)> {
329        let default_config = MD013Config::default();
330        let json_value = serde_json::to_value(&default_config).ok()?;
331        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
332
333        if let toml::Value::Table(table) = toml_value {
334            if !table.is_empty() {
335                Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
336            } else {
337                None
338            }
339        } else {
340            None
341        }
342    }
343
344    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
345        let mut aliases = std::collections::HashMap::new();
346        aliases.insert("enable_reflow".to_string(), "reflow".to_string());
347        Some(aliases)
348    }
349
350    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
351    where
352        Self: Sized,
353    {
354        let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
355        // Special handling for line_length from global config
356        if rule_config.line_length == 80 {
357            // default value
358            rule_config.line_length = config.global.line_length as usize;
359        }
360        Box::new(Self::from_config_struct(rule_config))
361    }
362}
363
364impl MD013LineLength {
365    /// Generate paragraph-based fixes
366    fn generate_paragraph_fixes(
367        &self,
368        ctx: &crate::lint_context::LintContext,
369        config: &MD013Config,
370        lines: &[&str],
371    ) -> Vec<LintWarning> {
372        let mut warnings = Vec::new();
373        let line_index = LineIndex::new(ctx.content.to_string());
374
375        let mut i = 0;
376        while i < lines.len() {
377            let line_num = i + 1;
378
379            // Skip special structures
380            if ctx.is_in_code_block(line_num)
381                || ctx.is_in_front_matter(line_num)
382                || ctx.is_in_html_block(line_num)
383                || (line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some())
384                || lines[i].trim().starts_with('#')
385                || TableUtils::is_potential_table_row(lines[i])
386                || lines[i].trim().is_empty()
387                || is_horizontal_rule(lines[i].trim())
388            {
389                i += 1;
390                continue;
391            }
392
393            // Check if this is a list item - handle it specially
394            let trimmed = lines[i].trim();
395            if is_list_item(trimmed) {
396                // Collect the entire list item including continuation lines
397                let list_start = i;
398                let (marker, first_content) = extract_list_marker_and_content(lines[i]);
399                let indent_size = marker.len();
400                let expected_indent = " ".repeat(indent_size);
401
402                let mut list_item_lines = vec![first_content];
403                i += 1;
404
405                // Collect continuation lines (lines that are indented to match the list marker)
406                while i < lines.len() {
407                    let line = lines[i];
408                    // Check if this line is indented as a continuation
409                    if line.starts_with(&expected_indent) && !line.trim().is_empty() {
410                        // Check if this is actually a nested list item
411                        let content_after_indent = &line[indent_size..];
412                        if is_list_item(content_after_indent.trim()) {
413                            // This is a nested list item, not a continuation
414                            break;
415                        }
416                        // This is a continuation line
417                        let content = line[indent_size..].to_string();
418                        list_item_lines.push(content);
419                        i += 1;
420                    } else if line.trim().is_empty() {
421                        // Empty line might be part of the list item
422                        // Check if the next line is also indented
423                        if i + 1 < lines.len() && lines[i + 1].starts_with(&expected_indent) {
424                            list_item_lines.push(String::new());
425                            i += 1;
426                        } else {
427                            break;
428                        }
429                    } else {
430                        break;
431                    }
432                }
433
434                // Check if any lines in this list item are within a code block
435                let contains_code_block = (list_start..i).any(|line_idx| ctx.is_in_code_block(line_idx + 1));
436
437                // Now check if this list item needs reflowing
438                let combined_content = list_item_lines.join(" ").trim().to_string();
439                let full_line = format!("{marker}{combined_content}");
440
441                if !contains_code_block
442                    && (self.calculate_effective_length(&full_line) > config.line_length
443                        || (config.reflow_mode == ReflowMode::Normalize && list_item_lines.len() > 1)
444                        || (config.reflow_mode == ReflowMode::SentencePerLine && {
445                            // Check if list item has multiple sentences
446                            let sentences = split_into_sentences(&combined_content);
447                            sentences.len() > 1
448                        }))
449                {
450                    let start_range = line_index.whole_line_range(list_start + 1);
451                    let end_line = i - 1;
452                    let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
453                        line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
454                    } else {
455                        line_index.whole_line_range(end_line + 1)
456                    };
457                    let byte_range = start_range.start..end_range.end;
458
459                    // Reflow the content part
460                    let reflow_options = crate::utils::text_reflow::ReflowOptions {
461                        line_length: config.line_length - indent_size,
462                        break_on_sentences: true,
463                        preserve_breaks: false,
464                        sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
465                    };
466                    let reflowed = crate::utils::text_reflow::reflow_line(&combined_content, &reflow_options);
467
468                    // Rebuild with proper indentation
469                    let mut result = vec![format!("{marker}{}", reflowed[0])];
470                    for line in reflowed.iter().skip(1) {
471                        result.push(format!("{expected_indent}{line}"));
472                    }
473                    let reflowed_text = result.join("\n");
474
475                    // Preserve trailing newline
476                    let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
477                        format!("{reflowed_text}\n")
478                    } else {
479                        reflowed_text
480                    };
481
482                    warnings.push(LintWarning {
483                        rule_name: Some(self.name()),
484                        message: if config.reflow_mode == ReflowMode::SentencePerLine {
485                            "Line contains multiple sentences (one sentence per line expected)".to_string()
486                        } else {
487                            format!(
488                                "Line length exceeds {} characters and can be reflowed",
489                                config.line_length
490                            )
491                        },
492                        line: list_start + 1,
493                        column: 1,
494                        end_line: end_line + 1,
495                        end_column: lines[end_line].len() + 1,
496                        severity: Severity::Warning,
497                        fix: Some(crate::rule::Fix {
498                            range: byte_range,
499                            replacement,
500                        }),
501                    });
502                }
503                continue;
504            }
505
506            // Found start of a paragraph - collect all lines in it
507            let paragraph_start = i;
508            let mut paragraph_lines = vec![lines[i]];
509            i += 1;
510
511            while i < lines.len() {
512                let next_line = lines[i];
513                let next_line_num = i + 1;
514                let next_trimmed = next_line.trim();
515
516                // Stop at paragraph boundaries
517                if next_trimmed.is_empty()
518                    || ctx.is_in_code_block(next_line_num)
519                    || ctx.is_in_front_matter(next_line_num)
520                    || ctx.is_in_html_block(next_line_num)
521                    || (next_line_num > 0
522                        && next_line_num <= ctx.lines.len()
523                        && ctx.lines[next_line_num - 1].blockquote.is_some())
524                    || next_trimmed.starts_with('#')
525                    || TableUtils::is_potential_table_row(next_line)
526                    || is_list_item(next_trimmed)
527                    || is_horizontal_rule(next_trimmed)
528                    || (next_trimmed.starts_with('[') && next_line.contains("]:"))
529                {
530                    break;
531                }
532
533                // Check if the previous line ends with a hard break (2+ spaces)
534                if i > 0 && lines[i - 1].ends_with("  ") {
535                    // Don't include lines after hard breaks in the same paragraph
536                    break;
537                }
538
539                paragraph_lines.push(next_line);
540                i += 1;
541            }
542
543            // Check if this paragraph needs reflowing
544            let needs_reflow = match config.reflow_mode {
545                ReflowMode::Normalize => {
546                    // In normalize mode, reflow multi-line paragraphs
547                    paragraph_lines.len() > 1
548                }
549                ReflowMode::SentencePerLine => {
550                    // In sentence-per-line mode, check if any line has multiple sentences
551                    paragraph_lines.iter().any(|line| {
552                        // Count sentences in this line
553                        let sentences = split_into_sentences(line);
554                        sentences.len() > 1
555                    })
556                }
557                ReflowMode::Default => {
558                    // In default mode, only reflow if lines exceed limit
559                    paragraph_lines
560                        .iter()
561                        .any(|line| self.calculate_effective_length(line) > config.line_length)
562                }
563            };
564
565            if needs_reflow {
566                // Calculate byte range for this paragraph
567                // Use whole_line_range for each line and combine
568                let start_range = line_index.whole_line_range(paragraph_start + 1);
569                let end_line = paragraph_start + paragraph_lines.len() - 1;
570
571                // For the last line, we want to preserve any trailing newline
572                let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
573                    // Last line without trailing newline - use line_text_range
574                    line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
575                } else {
576                    // Not the last line or has trailing newline - use whole_line_range
577                    line_index.whole_line_range(end_line + 1)
578                };
579
580                let byte_range = start_range.start..end_range.end;
581
582                // Combine paragraph lines into a single string for reflowing
583                let paragraph_text = paragraph_lines.join(" ");
584
585                // Check if the paragraph ends with a hard break
586                let has_hard_break = paragraph_lines.last().is_some_and(|l| l.ends_with("  "));
587
588                // Reflow the paragraph
589                let reflow_options = crate::utils::text_reflow::ReflowOptions {
590                    line_length: config.line_length,
591                    break_on_sentences: true,
592                    preserve_breaks: false,
593                    sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
594                };
595                let mut reflowed = crate::utils::text_reflow::reflow_line(&paragraph_text, &reflow_options);
596
597                // If the original paragraph ended with a hard break, preserve it
598                if has_hard_break && !reflowed.is_empty() {
599                    let last_idx = reflowed.len() - 1;
600                    if !reflowed[last_idx].ends_with("  ") {
601                        reflowed[last_idx].push_str("  ");
602                    }
603                }
604
605                let reflowed_text = reflowed.join("\n");
606
607                // Preserve trailing newline if the original paragraph had one
608                let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
609                    format!("{reflowed_text}\n")
610                } else {
611                    reflowed_text
612                };
613
614                // Create warning with actual fix
615                // In default mode, report the specific line that violates
616                // In normalize mode, report the whole paragraph
617                // In sentence-per-line mode, report lines with multiple sentences
618                let (warning_line, warning_end_line) = match config.reflow_mode {
619                    ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
620                    ReflowMode::SentencePerLine => {
621                        // Find the first line with multiple sentences
622                        let mut violating_line = paragraph_start;
623                        for (idx, line) in paragraph_lines.iter().enumerate() {
624                            let sentences = split_into_sentences(line);
625                            if sentences.len() > 1 {
626                                violating_line = paragraph_start + idx;
627                                break;
628                            }
629                        }
630                        (violating_line + 1, violating_line + 1)
631                    }
632                    ReflowMode::Default => {
633                        // Find the first line that exceeds the limit
634                        let mut violating_line = paragraph_start;
635                        for (idx, line) in paragraph_lines.iter().enumerate() {
636                            if self.calculate_effective_length(line) > config.line_length {
637                                violating_line = paragraph_start + idx;
638                                break;
639                            }
640                        }
641                        (violating_line + 1, violating_line + 1)
642                    }
643                };
644
645                warnings.push(LintWarning {
646                    rule_name: Some(self.name()),
647                    message: match config.reflow_mode {
648                        ReflowMode::Normalize => format!(
649                            "Paragraph could be normalized to use line length of {} characters",
650                            config.line_length
651                        ),
652                        ReflowMode::SentencePerLine => {
653                            "Line contains multiple sentences (one sentence per line expected)".to_string()
654                        }
655                        ReflowMode::Default => format!(
656                            "Line length exceeds {} characters and can be reflowed",
657                            config.line_length
658                        ),
659                    },
660                    line: warning_line,
661                    column: 1,
662                    end_line: warning_end_line,
663                    end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
664                    severity: Severity::Warning,
665                    fix: Some(crate::rule::Fix {
666                        range: byte_range,
667                        replacement,
668                    }),
669                });
670            }
671        }
672
673        warnings
674    }
675
676    /// Calculate effective line length excluding unbreakable URLs
677    fn calculate_effective_length(&self, line: &str) -> usize {
678        if self.config.strict {
679            // In strict mode, count everything
680            return line.chars().count();
681        }
682
683        // Quick byte-level check: if line doesn't contain "http" or "[", it can't have URLs or markdown links
684        let bytes = line.as_bytes();
685        if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
686            return line.chars().count();
687        }
688
689        // More precise check for URLs and links
690        if !line.contains("http") && !line.contains('[') {
691            return line.chars().count();
692        }
693
694        let mut effective_line = line.to_string();
695
696        // First handle markdown links to avoid double-counting URLs
697        // Pattern: [text](very-long-url) -> [text](url)
698        if line.contains('[') && line.contains("](") {
699            for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
700                if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
701                    && url.as_str().len() > 15
702                {
703                    let replacement = format!("[{}](url)", text.as_str());
704                    effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
705                }
706            }
707        }
708
709        // Then replace bare URLs with a placeholder of reasonable length
710        // This allows lines with long URLs to pass if the rest of the content is reasonable
711        if effective_line.contains("http") {
712            for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
713                let url = url_match.as_str();
714                // Skip if this URL is already part of a markdown link we handled
715                if !effective_line.contains(&format!("({url})")) {
716                    // Replace URL with placeholder that represents a "reasonable" URL length
717                    // Using 15 chars as a reasonable URL placeholder (e.g., "https://ex.com")
718                    let placeholder = "x".repeat(15.min(url.len()));
719                    effective_line = effective_line.replacen(url, &placeholder, 1);
720                }
721            }
722        }
723
724        effective_line.chars().count()
725    }
726}
727
728/// Extract list marker and content from a list item
729fn extract_list_marker_and_content(line: &str) -> (String, String) {
730    // First, find the leading indentation
731    let indent_len = line.len() - line.trim_start().len();
732    let indent = &line[..indent_len];
733    let trimmed = &line[indent_len..];
734
735    // Handle bullet lists
736    if let Some(rest) = trimmed.strip_prefix("- ") {
737        return (format!("{indent}- "), rest.to_string());
738    }
739    if let Some(rest) = trimmed.strip_prefix("* ") {
740        return (format!("{indent}* "), rest.to_string());
741    }
742    if let Some(rest) = trimmed.strip_prefix("+ ") {
743        return (format!("{indent}+ "), rest.to_string());
744    }
745
746    // Handle numbered lists on trimmed content
747    let mut chars = trimmed.chars();
748    let mut marker_content = String::new();
749
750    while let Some(c) = chars.next() {
751        marker_content.push(c);
752        if c == '.' {
753            // Check if next char is a space
754            if let Some(next) = chars.next()
755                && next == ' '
756            {
757                marker_content.push(next);
758                let content = chars.as_str().to_string();
759                return (format!("{indent}{marker_content}"), content);
760            }
761            break;
762        }
763    }
764
765    // Fallback - shouldn't happen if is_list_item was correct
766    (String::new(), line.to_string())
767}
768
769// Helper functions
770fn is_horizontal_rule(line: &str) -> bool {
771    if line.len() < 3 {
772        return false;
773    }
774    // Check if line consists only of -, _, or * characters (at least 3)
775    let chars: Vec<char> = line.chars().collect();
776    if chars.is_empty() {
777        return false;
778    }
779    let first_char = chars[0];
780    if first_char != '-' && first_char != '_' && first_char != '*' {
781        return false;
782    }
783    // All characters should be the same (allowing spaces between)
784    for c in &chars {
785        if *c != first_char && *c != ' ' {
786            return false;
787        }
788    }
789    // Must have at least 3 of the marker character
790    chars.iter().filter(|c| **c == first_char).count() >= 3
791}
792
793fn is_numbered_list_item(line: &str) -> bool {
794    let mut chars = line.chars();
795    // Must start with a digit
796    if !chars.next().is_some_and(|c| c.is_numeric()) {
797        return false;
798    }
799    // Can have more digits
800    while let Some(c) = chars.next() {
801        if c == '.' {
802            // After period, must have a space or be end of line
803            return chars.next().is_none_or(|c| c == ' ');
804        }
805        if !c.is_numeric() {
806            return false;
807        }
808    }
809    false
810}
811
812fn is_list_item(line: &str) -> bool {
813    // Bullet lists
814    if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
815        && line.len() > 1
816        && line.chars().nth(1) == Some(' ')
817    {
818        return true;
819    }
820    // Numbered lists
821    is_numbered_list_item(line)
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827    use crate::lint_context::LintContext;
828
829    #[test]
830    fn test_default_config() {
831        let rule = MD013LineLength::default();
832        assert_eq!(rule.config.line_length, 80);
833        assert!(rule.config.code_blocks); // Default is true
834        assert!(rule.config.tables); // Default is true
835        assert!(rule.config.headings); // Default is true
836        assert!(!rule.config.strict);
837    }
838
839    #[test]
840    fn test_custom_config() {
841        let rule = MD013LineLength::new(100, true, true, false, true);
842        assert_eq!(rule.config.line_length, 100);
843        assert!(rule.config.code_blocks);
844        assert!(rule.config.tables);
845        assert!(!rule.config.headings);
846        assert!(rule.config.strict);
847    }
848
849    #[test]
850    fn test_basic_line_length_violation() {
851        let rule = MD013LineLength::new(50, false, false, false, false);
852        let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
853        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
854        let result = rule.check(&ctx).unwrap();
855
856        assert_eq!(result.len(), 1);
857        assert!(result[0].message.contains("Line length"));
858        assert!(result[0].message.contains("exceeds 50 characters"));
859    }
860
861    #[test]
862    fn test_no_violation_under_limit() {
863        let rule = MD013LineLength::new(100, false, false, false, false);
864        let content = "Short line.\nAnother short line.";
865        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
866        let result = rule.check(&ctx).unwrap();
867
868        assert_eq!(result.len(), 0);
869    }
870
871    #[test]
872    fn test_multiple_violations() {
873        let rule = MD013LineLength::new(30, false, false, false, false);
874        let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
875        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
876        let result = rule.check(&ctx).unwrap();
877
878        assert_eq!(result.len(), 2);
879        assert_eq!(result[0].line, 1);
880        assert_eq!(result[1].line, 2);
881    }
882
883    #[test]
884    fn test_code_blocks_exemption() {
885        // With code_blocks = false, code blocks should be skipped
886        let rule = MD013LineLength::new(30, false, false, false, false);
887        let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
889        let result = rule.check(&ctx).unwrap();
890
891        assert_eq!(result.len(), 0);
892    }
893
894    #[test]
895    fn test_code_blocks_not_exempt_when_configured() {
896        // With code_blocks = true, code blocks should be checked
897        let rule = MD013LineLength::new(30, true, false, false, false);
898        let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
899        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
900        let result = rule.check(&ctx).unwrap();
901
902        assert!(!result.is_empty());
903    }
904
905    #[test]
906    fn test_heading_checked_when_enabled() {
907        let rule = MD013LineLength::new(30, false, false, true, false);
908        let content = "# This is a very long heading that would normally exceed the limit";
909        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
910        let result = rule.check(&ctx).unwrap();
911
912        assert_eq!(result.len(), 1);
913    }
914
915    #[test]
916    fn test_heading_exempt_when_disabled() {
917        let rule = MD013LineLength::new(30, false, false, false, false);
918        let content = "# This is a very long heading that should trigger a warning";
919        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
920        let result = rule.check(&ctx).unwrap();
921
922        assert_eq!(result.len(), 0);
923    }
924
925    #[test]
926    fn test_table_checked_when_enabled() {
927        let rule = MD013LineLength::new(30, false, true, false, false);
928        let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
930        let result = rule.check(&ctx).unwrap();
931
932        assert_eq!(result.len(), 2); // Both table lines exceed limit
933    }
934
935    #[test]
936    fn test_issue_78_tables_after_fenced_code_blocks() {
937        // Test for GitHub issue #78 - tables with tables=false after fenced code blocks
938        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
939        let content = r#"# heading
940
941```plain
942some code block longer than 20 chars length
943```
944
945this is a very long line
946
947| column A | column B |
948| -------- | -------- |
949| `var` | `val` |
950| value 1 | value 2 |
951
952correct length line"#;
953        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
954        let result = rule.check(&ctx).unwrap();
955
956        // Should only flag line 7 ("this is a very long line"), not the table lines
957        assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
958        assert_eq!(result[0].line, 7, "Should flag line 7");
959        assert!(result[0].message.contains("24 exceeds 20"));
960    }
961
962    #[test]
963    fn test_issue_78_tables_with_inline_code() {
964        // Test that tables with inline code (backticks) are properly detected as tables
965        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
966        let content = r#"| column A | column B |
967| -------- | -------- |
968| `var with very long name` | `val exceeding limit` |
969| value 1 | value 2 |
970
971This line exceeds limit"#;
972        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
973        let result = rule.check(&ctx).unwrap();
974
975        // Should only flag the last line, not the table lines
976        assert_eq!(result.len(), 1, "Should only flag the non-table line");
977        assert_eq!(result[0].line, 6, "Should flag line 6");
978    }
979
980    #[test]
981    fn test_issue_78_indented_code_blocks() {
982        // Test with indented code blocks instead of fenced
983        let rule = MD013LineLength::new(20, false, false, false, false); // tables=false
984        let content = r#"# heading
985
986    some code block longer than 20 chars length
987
988this is a very long line
989
990| column A | column B |
991| -------- | -------- |
992| value 1 | value 2 |
993
994correct length line"#;
995        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
996        let result = rule.check(&ctx).unwrap();
997
998        // Should only flag line 5 ("this is a very long line"), not the table lines
999        assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1000        assert_eq!(result[0].line, 5, "Should flag line 5");
1001    }
1002
1003    #[test]
1004    fn test_url_exemption() {
1005        let rule = MD013LineLength::new(30, false, false, false, false);
1006        let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1007        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1008        let result = rule.check(&ctx).unwrap();
1009
1010        assert_eq!(result.len(), 0);
1011    }
1012
1013    #[test]
1014    fn test_image_reference_exemption() {
1015        let rule = MD013LineLength::new(30, false, false, false, false);
1016        let content = "![This is a very long image alt text that exceeds limit][reference]";
1017        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1018        let result = rule.check(&ctx).unwrap();
1019
1020        assert_eq!(result.len(), 0);
1021    }
1022
1023    #[test]
1024    fn test_link_reference_exemption() {
1025        let rule = MD013LineLength::new(30, false, false, false, false);
1026        let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1027        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1028        let result = rule.check(&ctx).unwrap();
1029
1030        assert_eq!(result.len(), 0);
1031    }
1032
1033    #[test]
1034    fn test_strict_mode() {
1035        let rule = MD013LineLength::new(30, false, false, false, true);
1036        let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1037        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1038        let result = rule.check(&ctx).unwrap();
1039
1040        // In strict mode, even URLs trigger warnings
1041        assert_eq!(result.len(), 1);
1042    }
1043
1044    #[test]
1045    fn test_blockquote_exemption() {
1046        let rule = MD013LineLength::new(30, false, false, false, false);
1047        let content = "> This is a very long line inside a blockquote that should be ignored.";
1048        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1049        let result = rule.check(&ctx).unwrap();
1050
1051        assert_eq!(result.len(), 0);
1052    }
1053
1054    #[test]
1055    fn test_setext_heading_underline_exemption() {
1056        let rule = MD013LineLength::new(30, false, false, false, false);
1057        let content = "Heading\n========================================";
1058        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1059        let result = rule.check(&ctx).unwrap();
1060
1061        // The underline should be exempt
1062        assert_eq!(result.len(), 0);
1063    }
1064
1065    #[test]
1066    fn test_no_fix_without_reflow() {
1067        let rule = MD013LineLength::new(60, false, false, false, false);
1068        let content = "This line has trailing whitespace that makes it too long      ";
1069        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1070        let result = rule.check(&ctx).unwrap();
1071
1072        assert_eq!(result.len(), 1);
1073        // Without reflow, no fix is provided
1074        assert!(result[0].fix.is_none());
1075
1076        // Fix method returns content unchanged
1077        let fixed = rule.fix(&ctx).unwrap();
1078        assert_eq!(fixed, content);
1079    }
1080
1081    #[test]
1082    fn test_character_vs_byte_counting() {
1083        let rule = MD013LineLength::new(10, false, false, false, false);
1084        // Unicode characters should count as 1 character each
1085        let content = "你好世界这是测试文字超过限制"; // 14 characters
1086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1087        let result = rule.check(&ctx).unwrap();
1088
1089        assert_eq!(result.len(), 1);
1090        assert_eq!(result[0].line, 1);
1091    }
1092
1093    #[test]
1094    fn test_empty_content() {
1095        let rule = MD013LineLength::default();
1096        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1097        let result = rule.check(&ctx).unwrap();
1098
1099        assert_eq!(result.len(), 0);
1100    }
1101
1102    #[test]
1103    fn test_excess_range_calculation() {
1104        let rule = MD013LineLength::new(10, false, false, false, false);
1105        let content = "12345678901234567890"; // 20 chars, limit is 10
1106        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1107        let result = rule.check(&ctx).unwrap();
1108
1109        assert_eq!(result.len(), 1);
1110        // The warning should highlight from character 11 onwards
1111        assert_eq!(result[0].column, 11);
1112        assert_eq!(result[0].end_column, 21);
1113    }
1114
1115    #[test]
1116    fn test_html_block_exemption() {
1117        let rule = MD013LineLength::new(30, false, false, false, false);
1118        let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1119        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1120        let result = rule.check(&ctx).unwrap();
1121
1122        // HTML blocks should be exempt
1123        assert_eq!(result.len(), 0);
1124    }
1125
1126    #[test]
1127    fn test_mixed_content() {
1128        // code_blocks=false, tables=false, headings=false (all skipped/exempt)
1129        let rule = MD013LineLength::new(30, false, false, false, false);
1130        let content = r#"# This heading is very long but should be exempt
1131
1132This regular paragraph line is too long and should trigger.
1133
1134```
1135Code block line that is very long but exempt.
1136```
1137
1138| Table | With very long content |
1139|-------|------------------------|
1140
1141Another long line that should trigger a warning."#;
1142
1143        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1144        let result = rule.check(&ctx).unwrap();
1145
1146        // Should have warnings for the two regular paragraph lines only
1147        assert_eq!(result.len(), 2);
1148        assert_eq!(result[0].line, 3);
1149        assert_eq!(result[1].line, 12);
1150    }
1151
1152    #[test]
1153    fn test_fix_without_reflow_preserves_content() {
1154        let rule = MD013LineLength::new(50, false, false, false, false);
1155        let content = "Line 1\nThis line has trailing spaces and is too long      \nLine 3";
1156        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1157
1158        // Without reflow, content is unchanged
1159        let fixed = rule.fix(&ctx).unwrap();
1160        assert_eq!(fixed, content);
1161    }
1162
1163    #[test]
1164    fn test_content_detection() {
1165        let rule = MD013LineLength::default();
1166
1167        // Use a line longer than default line_length (80) to ensure it's not skipped
1168        let long_line = "a".repeat(100);
1169        let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
1170        assert!(!rule.should_skip(&ctx)); // Should not skip processing when there's long content
1171
1172        let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1173        assert!(rule.should_skip(&empty_ctx)); // Should skip processing when content is empty
1174    }
1175
1176    #[test]
1177    fn test_rule_metadata() {
1178        let rule = MD013LineLength::default();
1179        assert_eq!(rule.name(), "MD013");
1180        assert_eq!(rule.description(), "Line length should not be excessive");
1181        assert_eq!(rule.category(), RuleCategory::Whitespace);
1182    }
1183
1184    #[test]
1185    fn test_url_embedded_in_text() {
1186        let rule = MD013LineLength::new(50, false, false, false, false);
1187
1188        // This line would be 85 chars, but only ~45 without the URL
1189        let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1190        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1191        let result = rule.check(&ctx).unwrap();
1192
1193        // Should not flag because effective length (with URL placeholder) is under 50
1194        assert_eq!(result.len(), 0);
1195    }
1196
1197    #[test]
1198    fn test_multiple_urls_in_line() {
1199        let rule = MD013LineLength::new(50, false, false, false, false);
1200
1201        // Line with multiple URLs
1202        let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1203        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1204
1205        let result = rule.check(&ctx).unwrap();
1206
1207        // Should not flag because effective length is reasonable
1208        assert_eq!(result.len(), 0);
1209    }
1210
1211    #[test]
1212    fn test_markdown_link_with_long_url() {
1213        let rule = MD013LineLength::new(50, false, false, false, false);
1214
1215        // Markdown link with very long URL
1216        let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1217        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1218        let result = rule.check(&ctx).unwrap();
1219
1220        // Should not flag because effective length counts link as short
1221        assert_eq!(result.len(), 0);
1222    }
1223
1224    #[test]
1225    fn test_line_too_long_even_without_urls() {
1226        let rule = MD013LineLength::new(50, false, false, false, false);
1227
1228        // Line that's too long even after URL exclusion
1229        let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1230        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1231        let result = rule.check(&ctx).unwrap();
1232
1233        // Should flag because even with URL placeholder, line is too long
1234        assert_eq!(result.len(), 1);
1235    }
1236
1237    #[test]
1238    fn test_strict_mode_counts_urls() {
1239        let rule = MD013LineLength::new(50, false, false, false, true); // strict=true
1240
1241        // Same line that passes in non-strict mode
1242        let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1243        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1244        let result = rule.check(&ctx).unwrap();
1245
1246        // In strict mode, should flag because full URL is counted
1247        assert_eq!(result.len(), 1);
1248    }
1249
1250    #[test]
1251    fn test_documentation_example_from_md051() {
1252        let rule = MD013LineLength::new(80, false, false, false, false);
1253
1254        // This is the actual line from md051.md that was causing issues
1255        let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1256        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1257        let result = rule.check(&ctx).unwrap();
1258
1259        // Should not flag because the URL is in a markdown link
1260        assert_eq!(result.len(), 0);
1261    }
1262
1263    #[test]
1264    fn test_text_reflow_simple() {
1265        let config = MD013Config {
1266            line_length: 30,
1267            reflow: true,
1268            ..Default::default()
1269        };
1270        let rule = MD013LineLength::from_config_struct(config);
1271
1272        let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1273        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1274
1275        let fixed = rule.fix(&ctx).unwrap();
1276
1277        // Verify all lines are under 30 chars
1278        for line in fixed.lines() {
1279            assert!(
1280                line.chars().count() <= 30,
1281                "Line too long: {} (len={})",
1282                line,
1283                line.chars().count()
1284            );
1285        }
1286
1287        // Verify content is preserved
1288        let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1289        let original_words: Vec<&str> = content.split_whitespace().collect();
1290        assert_eq!(fixed_words, original_words);
1291    }
1292
1293    #[test]
1294    fn test_text_reflow_preserves_markdown_elements() {
1295        let config = MD013Config {
1296            line_length: 40,
1297            reflow: true,
1298            ..Default::default()
1299        };
1300        let rule = MD013LineLength::from_config_struct(config);
1301
1302        let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1303        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1304
1305        let fixed = rule.fix(&ctx).unwrap();
1306
1307        // Verify markdown elements are preserved
1308        assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1309        assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1310        assert!(
1311            fixed.contains("[a link](https://example.com)"),
1312            "Link not preserved in: {fixed}"
1313        );
1314
1315        // Verify all lines are under 40 chars
1316        for line in fixed.lines() {
1317            assert!(line.len() <= 40, "Line too long: {line}");
1318        }
1319    }
1320
1321    #[test]
1322    fn test_text_reflow_preserves_code_blocks() {
1323        let config = MD013Config {
1324            line_length: 30,
1325            reflow: true,
1326            ..Default::default()
1327        };
1328        let rule = MD013LineLength::from_config_struct(config);
1329
1330        let content = r#"Here is some text.
1331
1332```python
1333def very_long_function_name_that_exceeds_limit():
1334    return "This should not be wrapped"
1335```
1336
1337More text after code block."#;
1338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1339
1340        let fixed = rule.fix(&ctx).unwrap();
1341
1342        // Verify code block is preserved
1343        assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1344        assert!(fixed.contains("```python"));
1345        assert!(fixed.contains("```"));
1346    }
1347
1348    #[test]
1349    fn test_text_reflow_preserves_lists() {
1350        let config = MD013Config {
1351            line_length: 30,
1352            reflow: true,
1353            ..Default::default()
1354        };
1355        let rule = MD013LineLength::from_config_struct(config);
1356
1357        let content = r#"Here is a list:
1358
13591. First item with a very long line that needs wrapping
13602. Second item is short
13613. Third item also has a long line that exceeds the limit
1362
1363And a bullet list:
1364
1365- Bullet item with very long content that needs wrapping
1366- Short bullet"#;
1367        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1368
1369        let fixed = rule.fix(&ctx).unwrap();
1370
1371        // Verify list structure is preserved
1372        assert!(fixed.contains("1. "));
1373        assert!(fixed.contains("2. "));
1374        assert!(fixed.contains("3. "));
1375        assert!(fixed.contains("- "));
1376
1377        // Verify proper indentation for wrapped lines
1378        let lines: Vec<&str> = fixed.lines().collect();
1379        for (i, line) in lines.iter().enumerate() {
1380            if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1381                // Check if next line is a continuation (should be indented with 3 spaces for numbered lists)
1382                if i + 1 < lines.len()
1383                    && !lines[i + 1].trim().is_empty()
1384                    && !lines[i + 1].trim().starts_with(char::is_numeric)
1385                    && !lines[i + 1].trim().starts_with("-")
1386                {
1387                    // Numbered list continuation lines should have 3 spaces
1388                    assert!(lines[i + 1].starts_with("   ") || lines[i + 1].trim().is_empty());
1389                }
1390            } else if line.trim().starts_with("-") {
1391                // Check if next line is a continuation (should be indented with 2 spaces for dash lists)
1392                if i + 1 < lines.len()
1393                    && !lines[i + 1].trim().is_empty()
1394                    && !lines[i + 1].trim().starts_with(char::is_numeric)
1395                    && !lines[i + 1].trim().starts_with("-")
1396                {
1397                    // Dash list continuation lines should have 2 spaces
1398                    assert!(lines[i + 1].starts_with("  ") || lines[i + 1].trim().is_empty());
1399                }
1400            }
1401        }
1402    }
1403
1404    #[test]
1405    fn test_issue_83_numbered_list_with_backticks() {
1406        // Test for issue #83: enable_reflow was incorrectly handling numbered lists
1407        let config = MD013Config {
1408            line_length: 100,
1409            reflow: true,
1410            ..Default::default()
1411        };
1412        let rule = MD013LineLength::from_config_struct(config);
1413
1414        // The exact case from issue #83
1415        let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
1416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1417
1418        let fixed = rule.fix(&ctx).unwrap();
1419
1420        // The expected output: properly wrapped at 100 chars with correct list formatting
1421        // After the fix, it correctly accounts for "1. " (3 chars) leaving 97 for content
1422        let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n   `00000000000000000002.manifest` in this example.";
1423
1424        assert_eq!(
1425            fixed, expected,
1426            "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
1427        );
1428    }
1429
1430    #[test]
1431    fn test_text_reflow_disabled_by_default() {
1432        let rule = MD013LineLength::new(30, false, false, false, false);
1433
1434        let content = "This is a very long line that definitely exceeds thirty characters.";
1435        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1436
1437        let fixed = rule.fix(&ctx).unwrap();
1438
1439        // Without reflow enabled, it should only trim whitespace (if any)
1440        // Since there's no trailing whitespace, content should be unchanged
1441        assert_eq!(fixed, content);
1442    }
1443
1444    #[test]
1445    fn test_reflow_with_hard_line_breaks() {
1446        // Test that lines with exactly 2 trailing spaces are preserved as hard breaks
1447        let config = MD013Config {
1448            line_length: 40,
1449            reflow: true,
1450            ..Default::default()
1451        };
1452        let rule = MD013LineLength::from_config_struct(config);
1453
1454        // Test with exactly 2 spaces (hard line break)
1455        let content = "This line has a hard break at the end  \nAnd this continues on the next line that is also quite long and needs wrapping";
1456        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1457        let fixed = rule.fix(&ctx).unwrap();
1458
1459        // Should preserve the hard line break (2 spaces)
1460        assert!(
1461            fixed.contains("  \n"),
1462            "Hard line break with exactly 2 spaces should be preserved"
1463        );
1464    }
1465
1466    #[test]
1467    fn test_reflow_preserves_reference_links() {
1468        let config = MD013Config {
1469            line_length: 40,
1470            reflow: true,
1471            ..Default::default()
1472        };
1473        let rule = MD013LineLength::from_config_struct(config);
1474
1475        let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
1476
1477[ref]: https://example.com";
1478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1479        let fixed = rule.fix(&ctx).unwrap();
1480
1481        // Reference link should remain intact
1482        assert!(fixed.contains("[reference link][ref]"));
1483        assert!(!fixed.contains("[ reference link]"));
1484        assert!(!fixed.contains("[ref ]"));
1485    }
1486
1487    #[test]
1488    fn test_reflow_with_nested_markdown_elements() {
1489        let config = MD013Config {
1490            line_length: 35,
1491            reflow: true,
1492            ..Default::default()
1493        };
1494        let rule = MD013LineLength::from_config_struct(config);
1495
1496        let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
1497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1498        let fixed = rule.fix(&ctx).unwrap();
1499
1500        // Nested elements should be preserved
1501        assert!(fixed.contains("**bold with `code` inside**"));
1502    }
1503
1504    #[test]
1505    fn test_reflow_with_unbalanced_markdown() {
1506        // Test edge case with unbalanced markdown
1507        let config = MD013Config {
1508            line_length: 30,
1509            reflow: true,
1510            ..Default::default()
1511        };
1512        let rule = MD013LineLength::from_config_struct(config);
1513
1514        let content = "This has **unbalanced bold that goes on for a very long time without closing";
1515        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1516        let fixed = rule.fix(&ctx).unwrap();
1517
1518        // Should handle gracefully without panic
1519        // The text reflow handles unbalanced markdown by treating it as a bold element
1520        // Check that the content is properly reflowed without panic
1521        assert!(!fixed.is_empty());
1522        // Verify the content is wrapped to 30 chars
1523        for line in fixed.lines() {
1524            assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
1525        }
1526    }
1527
1528    #[test]
1529    fn test_reflow_fix_indicator() {
1530        // Test that reflow provides fix indicators
1531        let config = MD013Config {
1532            line_length: 30,
1533            reflow: true,
1534            ..Default::default()
1535        };
1536        let rule = MD013LineLength::from_config_struct(config);
1537
1538        let content = "This is a very long line that definitely exceeds the thirty character limit";
1539        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1540        let warnings = rule.check(&ctx).unwrap();
1541
1542        // Should have a fix indicator when reflow is true
1543        assert!(!warnings.is_empty());
1544        assert!(
1545            warnings[0].fix.is_some(),
1546            "Should provide fix indicator when reflow is true"
1547        );
1548    }
1549
1550    #[test]
1551    fn test_no_fix_indicator_without_reflow() {
1552        // Test that without reflow, no fix is provided
1553        let config = MD013Config {
1554            line_length: 30,
1555            reflow: false,
1556            ..Default::default()
1557        };
1558        let rule = MD013LineLength::from_config_struct(config);
1559
1560        let content = "This is a very long line that definitely exceeds the thirty character limit";
1561        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1562        let warnings = rule.check(&ctx).unwrap();
1563
1564        // Should NOT have a fix indicator when reflow is false
1565        assert!(!warnings.is_empty());
1566        assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
1567    }
1568
1569    #[test]
1570    fn test_reflow_preserves_all_reference_link_types() {
1571        let config = MD013Config {
1572            line_length: 40,
1573            reflow: true,
1574            ..Default::default()
1575        };
1576        let rule = MD013LineLength::from_config_struct(config);
1577
1578        let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
1579
1580[ref]: https://example.com
1581[collapsed]: https://example.com
1582[shortcut]: https://example.com";
1583
1584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1585        let fixed = rule.fix(&ctx).unwrap();
1586
1587        // All reference link types should be preserved
1588        assert!(fixed.contains("[full reference][ref]"));
1589        assert!(fixed.contains("[collapsed][]"));
1590        assert!(fixed.contains("[shortcut]"));
1591    }
1592
1593    #[test]
1594    fn test_reflow_handles_images_correctly() {
1595        let config = MD013Config {
1596            line_length: 40,
1597            reflow: true,
1598            ..Default::default()
1599        };
1600        let rule = MD013LineLength::from_config_struct(config);
1601
1602        let content = "This line has an ![image alt text](https://example.com/image.png) that should not be broken when reflowing.";
1603        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1604        let fixed = rule.fix(&ctx).unwrap();
1605
1606        // Image should remain intact
1607        assert!(fixed.contains("![image alt text](https://example.com/image.png)"));
1608    }
1609
1610    #[test]
1611    fn test_normalize_mode_flags_short_lines() {
1612        let config = MD013Config {
1613            line_length: 100,
1614            reflow: true,
1615            reflow_mode: ReflowMode::Normalize,
1616            ..Default::default()
1617        };
1618        let rule = MD013LineLength::from_config_struct(config);
1619
1620        // Content with short lines that could be combined
1621        let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
1622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1623        let warnings = rule.check(&ctx).unwrap();
1624
1625        // Should flag the paragraph as needing normalization
1626        assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1627        assert!(warnings[0].message.contains("normalized"));
1628    }
1629
1630    #[test]
1631    fn test_normalize_mode_combines_short_lines() {
1632        let config = MD013Config {
1633            line_length: 100,
1634            reflow: true,
1635            reflow_mode: ReflowMode::Normalize,
1636            ..Default::default()
1637        };
1638        let rule = MD013LineLength::from_config_struct(config);
1639
1640        // Content with short lines that should be combined
1641        let content =
1642            "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
1643        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1644        let fixed = rule.fix(&ctx).unwrap();
1645
1646        // Should combine into a single line since it's under 100 chars total
1647        let lines: Vec<&str> = fixed.lines().collect();
1648        assert_eq!(lines.len(), 1, "Should combine into single line");
1649        assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
1650    }
1651
1652    #[test]
1653    fn test_normalize_mode_preserves_paragraph_breaks() {
1654        let config = MD013Config {
1655            line_length: 100,
1656            reflow: true,
1657            reflow_mode: ReflowMode::Normalize,
1658            ..Default::default()
1659        };
1660        let rule = MD013LineLength::from_config_struct(config);
1661
1662        let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
1663        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1664        let fixed = rule.fix(&ctx).unwrap();
1665
1666        // Should preserve paragraph breaks (empty lines)
1667        assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
1668
1669        let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
1670        assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
1671    }
1672
1673    #[test]
1674    fn test_default_mode_only_fixes_violations() {
1675        let config = MD013Config {
1676            line_length: 100,
1677            reflow: true,
1678            reflow_mode: ReflowMode::Default, // Default mode
1679            ..Default::default()
1680        };
1681        let rule = MD013LineLength::from_config_struct(config);
1682
1683        // Content with short lines that are NOT violations
1684        let content = "This is a short line.\nAnother short line.\nA third short line.";
1685        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1686        let warnings = rule.check(&ctx).unwrap();
1687
1688        // Should NOT flag anything in default mode
1689        assert!(warnings.is_empty(), "Should not flag short lines in default mode");
1690
1691        // Fix should preserve the short lines
1692        let fixed = rule.fix(&ctx).unwrap();
1693        assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
1694    }
1695
1696    #[test]
1697    fn test_normalize_mode_with_lists() {
1698        let config = MD013Config {
1699            line_length: 80,
1700            reflow: true,
1701            reflow_mode: ReflowMode::Normalize,
1702            ..Default::default()
1703        };
1704        let rule = MD013LineLength::from_config_struct(config);
1705
1706        let content = r#"A paragraph with
1707short lines.
1708
17091. List item with
1710   short lines
17112. Another item"#;
1712        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1713        let fixed = rule.fix(&ctx).unwrap();
1714
1715        // Should normalize the paragraph but preserve list structure
1716        let lines: Vec<&str> = fixed.lines().collect();
1717        assert!(lines[0].len() > 20, "First paragraph should be normalized");
1718        assert!(fixed.contains("1. "), "Should preserve list markers");
1719        assert!(fixed.contains("2. "), "Should preserve list markers");
1720    }
1721
1722    #[test]
1723    fn test_normalize_mode_with_code_blocks() {
1724        let config = MD013Config {
1725            line_length: 100,
1726            reflow: true,
1727            reflow_mode: ReflowMode::Normalize,
1728            ..Default::default()
1729        };
1730        let rule = MD013LineLength::from_config_struct(config);
1731
1732        let content = r#"A paragraph with
1733short lines.
1734
1735```
1736code block should not be normalized
1737even with short lines
1738```
1739
1740Another paragraph with
1741short lines."#;
1742        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1743        let fixed = rule.fix(&ctx).unwrap();
1744
1745        // Code block should be preserved as-is
1746        assert!(fixed.contains("code block should not be normalized\neven with short lines"));
1747        // But paragraphs should be normalized
1748        let lines: Vec<&str> = fixed.lines().collect();
1749        assert!(lines[0].len() > 20, "First paragraph should be normalized");
1750    }
1751
1752    #[test]
1753    fn test_issue_76_use_case() {
1754        // This tests the exact use case from issue #76
1755        let config = MD013Config {
1756            line_length: 999999, // Set absurdly high
1757            reflow: true,
1758            reflow_mode: ReflowMode::Normalize,
1759            ..Default::default()
1760        };
1761        let rule = MD013LineLength::from_config_struct(config);
1762
1763        // Content with manual line breaks at 80 characters (typical markdown)
1764        let content = "We've decided to eliminate line-breaks in paragraphs. The obvious solution is\nto disable MD013, and call it good. However, that doesn't deal with the\nexisting content's line-breaks. My initial thought was to set line_length to\n999999 and enable_reflow, but realised after doing so, that it never triggers\nthe error, so nothing happens.";
1765
1766        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1767
1768        // Should flag for normalization even though no lines exceed limit
1769        let warnings = rule.check(&ctx).unwrap();
1770        assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1771
1772        // Should combine into a single line
1773        let fixed = rule.fix(&ctx).unwrap();
1774        let lines: Vec<&str> = fixed.lines().collect();
1775        assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
1776        assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
1777    }
1778
1779    #[test]
1780    fn test_normalize_mode_single_line_unchanged() {
1781        // Single lines should not be flagged or changed
1782        let config = MD013Config {
1783            line_length: 100,
1784            reflow: true,
1785            reflow_mode: ReflowMode::Normalize,
1786            ..Default::default()
1787        };
1788        let rule = MD013LineLength::from_config_struct(config);
1789
1790        let content = "This is a single line that should not be changed.";
1791        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1792
1793        let warnings = rule.check(&ctx).unwrap();
1794        assert!(warnings.is_empty(), "Single line should not be flagged");
1795
1796        let fixed = rule.fix(&ctx).unwrap();
1797        assert_eq!(fixed, content, "Single line should remain unchanged");
1798    }
1799
1800    #[test]
1801    fn test_normalize_mode_with_inline_code() {
1802        let config = MD013Config {
1803            line_length: 80,
1804            reflow: true,
1805            reflow_mode: ReflowMode::Normalize,
1806            ..Default::default()
1807        };
1808        let rule = MD013LineLength::from_config_struct(config);
1809
1810        let content =
1811            "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
1812        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1813
1814        let warnings = rule.check(&ctx).unwrap();
1815        assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
1816
1817        let fixed = rule.fix(&ctx).unwrap();
1818        assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
1819        assert!(fixed.lines().count() < 3, "Lines should be combined");
1820    }
1821
1822    #[test]
1823    fn test_normalize_mode_with_emphasis() {
1824        let config = MD013Config {
1825            line_length: 100,
1826            reflow: true,
1827            reflow_mode: ReflowMode::Normalize,
1828            ..Default::default()
1829        };
1830        let rule = MD013LineLength::from_config_struct(config);
1831
1832        let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
1833        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1834
1835        let fixed = rule.fix(&ctx).unwrap();
1836        assert!(fixed.contains("**bold**"), "Bold should be preserved");
1837        assert!(fixed.contains("*italic*"), "Italic should be preserved");
1838        assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
1839    }
1840
1841    #[test]
1842    fn test_normalize_mode_respects_hard_breaks() {
1843        let config = MD013Config {
1844            line_length: 100,
1845            reflow: true,
1846            reflow_mode: ReflowMode::Normalize,
1847            ..Default::default()
1848        };
1849        let rule = MD013LineLength::from_config_struct(config);
1850
1851        // Two spaces at end of line = hard break
1852        let content = "First line with hard break  \nSecond line after break\nThird line";
1853        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1854
1855        let fixed = rule.fix(&ctx).unwrap();
1856        // Hard break should be preserved
1857        assert!(fixed.contains("  \n"), "Hard break should be preserved");
1858        // But lines without hard break should be combined
1859        assert!(
1860            fixed.contains("Second line after break Third line"),
1861            "Lines without hard break should combine"
1862        );
1863    }
1864
1865    #[test]
1866    fn test_normalize_mode_with_links() {
1867        let config = MD013Config {
1868            line_length: 100,
1869            reflow: true,
1870            reflow_mode: ReflowMode::Normalize,
1871            ..Default::default()
1872        };
1873        let rule = MD013LineLength::from_config_struct(config);
1874
1875        let content =
1876            "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
1877        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1878
1879        let fixed = rule.fix(&ctx).unwrap();
1880        assert!(
1881            fixed.contains("[link](https://example.com)"),
1882            "Link should be preserved"
1883        );
1884        assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
1885    }
1886
1887    #[test]
1888    fn test_normalize_mode_empty_lines_between_paragraphs() {
1889        let config = MD013Config {
1890            line_length: 100,
1891            reflow: true,
1892            reflow_mode: ReflowMode::Normalize,
1893            ..Default::default()
1894        };
1895        let rule = MD013LineLength::from_config_struct(config);
1896
1897        let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
1898        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1899
1900        let fixed = rule.fix(&ctx).unwrap();
1901        // Multiple empty lines should be preserved
1902        assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
1903        // Each paragraph should be normalized
1904        let parts: Vec<&str> = fixed.split("\n\n\n").collect();
1905        assert_eq!(parts.len(), 2, "Should have two parts");
1906        assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
1907        assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
1908    }
1909
1910    #[test]
1911    fn test_normalize_mode_mixed_list_types() {
1912        let config = MD013Config {
1913            line_length: 80,
1914            reflow: true,
1915            reflow_mode: ReflowMode::Normalize,
1916            ..Default::default()
1917        };
1918        let rule = MD013LineLength::from_config_struct(config);
1919
1920        let content = r#"Paragraph before list
1921with multiple lines.
1922
1923- Bullet item
1924* Another bullet
1925+ Plus bullet
1926
19271. Numbered item
19282. Another number
1929
1930Paragraph after list
1931with multiple lines."#;
1932
1933        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1934        let fixed = rule.fix(&ctx).unwrap();
1935
1936        // Lists should be preserved
1937        assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
1938        assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
1939        assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
1940        assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
1941
1942        // But paragraphs should be normalized
1943        assert!(
1944            fixed.starts_with("Paragraph before list with multiple lines."),
1945            "First paragraph should be normalized"
1946        );
1947        assert!(
1948            fixed.ends_with("Paragraph after list with multiple lines."),
1949            "Last paragraph should be normalized"
1950        );
1951    }
1952
1953    #[test]
1954    fn test_normalize_mode_with_horizontal_rules() {
1955        let config = MD013Config {
1956            line_length: 100,
1957            reflow: true,
1958            reflow_mode: ReflowMode::Normalize,
1959            ..Default::default()
1960        };
1961        let rule = MD013LineLength::from_config_struct(config);
1962
1963        let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
1964        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1965
1966        let fixed = rule.fix(&ctx).unwrap();
1967        assert!(fixed.contains("---"), "Horizontal rule should be preserved");
1968        assert!(
1969            fixed.contains("Paragraph before horizontal rule."),
1970            "First paragraph normalized"
1971        );
1972        assert!(
1973            fixed.contains("Paragraph after horizontal rule."),
1974            "Second paragraph normalized"
1975        );
1976    }
1977
1978    #[test]
1979    fn test_normalize_mode_with_indented_code() {
1980        let config = MD013Config {
1981            line_length: 100,
1982            reflow: true,
1983            reflow_mode: ReflowMode::Normalize,
1984            ..Default::default()
1985        };
1986        let rule = MD013LineLength::from_config_struct(config);
1987
1988        let content = "Paragraph before\nindented code.\n\n    This is indented code\n    Should not be normalized\n\nParagraph after\nindented code.";
1989        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1990
1991        let fixed = rule.fix(&ctx).unwrap();
1992        assert!(
1993            fixed.contains("    This is indented code\n    Should not be normalized"),
1994            "Indented code preserved"
1995        );
1996        assert!(
1997            fixed.contains("Paragraph before indented code."),
1998            "First paragraph normalized"
1999        );
2000        assert!(
2001            fixed.contains("Paragraph after indented code."),
2002            "Second paragraph normalized"
2003        );
2004    }
2005
2006    #[test]
2007    fn test_normalize_mode_disabled_without_reflow() {
2008        // Normalize mode should have no effect if reflow is disabled
2009        let config = MD013Config {
2010            line_length: 100,
2011            reflow: false, // Disabled
2012            reflow_mode: ReflowMode::Normalize,
2013            ..Default::default()
2014        };
2015        let rule = MD013LineLength::from_config_struct(config);
2016
2017        let content = "This is a line\nwith breaks that\nshould not be changed.";
2018        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2019
2020        let warnings = rule.check(&ctx).unwrap();
2021        assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2022
2023        let fixed = rule.fix(&ctx).unwrap();
2024        assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2025    }
2026
2027    #[test]
2028    fn test_default_mode_with_long_lines() {
2029        // Default mode should fix paragraphs that contain lines exceeding limit
2030        // The paragraph-based approach treats consecutive lines as a unit
2031        let config = MD013Config {
2032            line_length: 50,
2033            reflow: true,
2034            reflow_mode: ReflowMode::Default,
2035            ..Default::default()
2036        };
2037        let rule = MD013LineLength::from_config_struct(config);
2038
2039        let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2040        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2041
2042        let warnings = rule.check(&ctx).unwrap();
2043        assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2044        // The warning reports the line that violates in default mode
2045        assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2046
2047        let fixed = rule.fix(&ctx).unwrap();
2048        // The paragraph gets reflowed as a unit
2049        assert!(
2050            fixed.contains("Short line. This is"),
2051            "Should combine and reflow the paragraph"
2052        );
2053        assert!(
2054            fixed.contains("wrapping. Another short"),
2055            "Should include all paragraph content"
2056        );
2057    }
2058
2059    #[test]
2060    fn test_normalize_vs_default_mode_same_content() {
2061        let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2062        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2063
2064        // Test default mode
2065        let default_config = MD013Config {
2066            line_length: 100,
2067            reflow: true,
2068            reflow_mode: ReflowMode::Default,
2069            ..Default::default()
2070        };
2071        let default_rule = MD013LineLength::from_config_struct(default_config);
2072        let default_warnings = default_rule.check(&ctx).unwrap();
2073        let default_fixed = default_rule.fix(&ctx).unwrap();
2074
2075        // Test normalize mode
2076        let normalize_config = MD013Config {
2077            line_length: 100,
2078            reflow: true,
2079            reflow_mode: ReflowMode::Normalize,
2080            ..Default::default()
2081        };
2082        let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2083        let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2084        let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2085
2086        // Verify different behavior
2087        assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2088        assert!(
2089            !normalize_warnings.is_empty(),
2090            "Normalize mode should flag multi-line paragraphs"
2091        );
2092
2093        assert_eq!(
2094            default_fixed, content,
2095            "Default mode should not change content without violations"
2096        );
2097        assert_ne!(
2098            normalize_fixed, content,
2099            "Normalize mode should change multi-line paragraphs"
2100        );
2101        assert_eq!(
2102            normalize_fixed.lines().count(),
2103            1,
2104            "Normalize should combine into single line"
2105        );
2106    }
2107
2108    #[test]
2109    fn test_normalize_mode_with_reference_definitions() {
2110        let config = MD013Config {
2111            line_length: 100,
2112            reflow: true,
2113            reflow_mode: ReflowMode::Normalize,
2114            ..Default::default()
2115        };
2116        let rule = MD013LineLength::from_config_struct(config);
2117
2118        let content =
2119            "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2120        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2121
2122        let fixed = rule.fix(&ctx).unwrap();
2123        assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2124        assert!(
2125            fixed.contains("[ref]: https://example.com"),
2126            "Reference definition should be preserved"
2127        );
2128        assert!(
2129            fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2130            "Paragraph should be normalized"
2131        );
2132    }
2133
2134    #[test]
2135    fn test_normalize_mode_with_html_comments() {
2136        let config = MD013Config {
2137            line_length: 100,
2138            reflow: true,
2139            reflow_mode: ReflowMode::Normalize,
2140            ..Default::default()
2141        };
2142        let rule = MD013LineLength::from_config_struct(config);
2143
2144        let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2145        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2146
2147        let fixed = rule.fix(&ctx).unwrap();
2148        assert!(
2149            fixed.contains("<!-- This is a comment -->"),
2150            "HTML comment should be preserved"
2151        );
2152        assert!(
2153            fixed.contains("Paragraph before HTML comment."),
2154            "First paragraph normalized"
2155        );
2156        assert!(
2157            fixed.contains("Paragraph after HTML comment."),
2158            "Second paragraph normalized"
2159        );
2160    }
2161
2162    #[test]
2163    fn test_normalize_mode_line_starting_with_number() {
2164        // Regression test for the bug we fixed where "80 characters" was treated as a list
2165        let config = MD013Config {
2166            line_length: 100,
2167            reflow: true,
2168            reflow_mode: ReflowMode::Normalize,
2169            ..Default::default()
2170        };
2171        let rule = MD013LineLength::from_config_struct(config);
2172
2173        let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2175
2176        let fixed = rule.fix(&ctx).unwrap();
2177        assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2178        assert!(
2179            fixed.contains("80 characters"),
2180            "Number at start of line should be preserved"
2181        );
2182    }
2183
2184    #[test]
2185    fn test_default_mode_preserves_list_structure() {
2186        // In default mode, list continuation lines should be preserved
2187        let config = MD013Config {
2188            line_length: 80,
2189            reflow: true,
2190            reflow_mode: ReflowMode::Default,
2191            ..Default::default()
2192        };
2193        let rule = MD013LineLength::from_config_struct(config);
2194
2195        let content = r#"- This is a bullet point that has
2196  some text on multiple lines
2197  that should stay separate
2198
21991. Numbered list item with
2200   multiple lines that should
2201   also stay separate"#;
2202
2203        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2204        let fixed = rule.fix(&ctx).unwrap();
2205
2206        // In default mode, the structure should be preserved
2207        let lines: Vec<&str> = fixed.lines().collect();
2208        assert_eq!(
2209            lines[0], "- This is a bullet point that has",
2210            "First line should be unchanged"
2211        );
2212        assert_eq!(
2213            lines[1], "  some text on multiple lines",
2214            "Continuation should be preserved"
2215        );
2216        assert_eq!(
2217            lines[2], "  that should stay separate",
2218            "Second continuation should be preserved"
2219        );
2220    }
2221
2222    #[test]
2223    fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2224        // Test that multi-line list items don't get extra spaces when normalized
2225        let config = MD013Config {
2226            line_length: 80,
2227            reflow: true,
2228            reflow_mode: ReflowMode::Normalize,
2229            ..Default::default()
2230        };
2231        let rule = MD013LineLength::from_config_struct(config);
2232
2233        let content = r#"- This is a bullet point that has
2234  some text on multiple lines
2235  that should be combined
2236
22371. Numbered list item with
2238   multiple lines that need
2239   to be properly combined
22402. Second item"#;
2241
2242        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2243        let fixed = rule.fix(&ctx).unwrap();
2244
2245        // Check that there are no extra spaces in the combined list items
2246        assert!(
2247            !fixed.contains("lines  that"),
2248            "Should not have double spaces in bullet list"
2249        );
2250        assert!(
2251            !fixed.contains("need  to"),
2252            "Should not have double spaces in numbered list"
2253        );
2254
2255        // Check that the list items are properly combined
2256        assert!(
2257            fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2258            "Bullet list should be properly combined"
2259        );
2260        assert!(
2261            fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2262            "Numbered list should be properly combined"
2263        );
2264    }
2265
2266    #[test]
2267    fn test_normalize_mode_actual_numbered_list() {
2268        // Ensure actual numbered lists are still detected correctly
2269        let config = MD013Config {
2270            line_length: 100,
2271            reflow: true,
2272            reflow_mode: ReflowMode::Normalize,
2273            ..Default::default()
2274        };
2275        let rule = MD013LineLength::from_config_struct(config);
2276
2277        let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2278        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2279
2280        let fixed = rule.fix(&ctx).unwrap();
2281        assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2282        assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2283        assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2284        assert!(
2285            fixed.starts_with("Paragraph before list with multiple lines."),
2286            "Paragraph should be normalized"
2287        );
2288    }
2289
2290    #[test]
2291    fn test_sentence_per_line_detection() {
2292        let config = MD013Config {
2293            reflow: true,
2294            reflow_mode: ReflowMode::SentencePerLine,
2295            ..Default::default()
2296        };
2297        let rule = MD013LineLength::from_config_struct(config.clone());
2298
2299        // Test detection of multiple sentences
2300        let content = "This is sentence one. This is sentence two. And sentence three!";
2301        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2302
2303        // Debug: check if should_skip returns false
2304        assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2305
2306        let result = rule.check(&ctx).unwrap();
2307
2308        assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2309        assert_eq!(
2310            result[0].message,
2311            "Line contains multiple sentences (one sentence per line expected)"
2312        );
2313    }
2314
2315    #[test]
2316    fn test_sentence_per_line_fix() {
2317        let config = MD013Config {
2318            reflow: true,
2319            reflow_mode: ReflowMode::SentencePerLine,
2320            ..Default::default()
2321        };
2322        let rule = MD013LineLength::from_config_struct(config);
2323
2324        let content = "First sentence. Second sentence.";
2325        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2326        let result = rule.check(&ctx).unwrap();
2327
2328        assert!(!result.is_empty(), "Should detect violation");
2329        assert!(result[0].fix.is_some(), "Should provide a fix");
2330
2331        let fix = result[0].fix.as_ref().unwrap();
2332        assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2333    }
2334
2335    #[test]
2336    fn test_sentence_per_line_abbreviations() {
2337        let config = MD013Config {
2338            reflow: true,
2339            reflow_mode: ReflowMode::SentencePerLine,
2340            ..Default::default()
2341        };
2342        let rule = MD013LineLength::from_config_struct(config);
2343
2344        // Should NOT trigger on abbreviations
2345        let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2346        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2347        let result = rule.check(&ctx).unwrap();
2348
2349        assert!(
2350            result.is_empty(),
2351            "Should not detect abbreviations as sentence boundaries"
2352        );
2353    }
2354
2355    #[test]
2356    fn test_sentence_per_line_with_markdown() {
2357        let config = MD013Config {
2358            reflow: true,
2359            reflow_mode: ReflowMode::SentencePerLine,
2360            ..Default::default()
2361        };
2362        let rule = MD013LineLength::from_config_struct(config);
2363
2364        let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
2365        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2366        let result = rule.check(&ctx).unwrap();
2367
2368        assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
2369        assert_eq!(result[0].line, 3); // Third line has the violation
2370    }
2371
2372    #[test]
2373    fn test_sentence_per_line_questions_exclamations() {
2374        let config = MD013Config {
2375            reflow: true,
2376            reflow_mode: ReflowMode::SentencePerLine,
2377            ..Default::default()
2378        };
2379        let rule = MD013LineLength::from_config_struct(config);
2380
2381        let content = "Is this a question? Yes it is! And a statement.";
2382        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2383        let result = rule.check(&ctx).unwrap();
2384
2385        assert!(!result.is_empty(), "Should detect sentences with ? and !");
2386
2387        let fix = result[0].fix.as_ref().unwrap();
2388        let lines: Vec<&str> = fix.replacement.trim().lines().collect();
2389        assert_eq!(lines.len(), 3);
2390        assert_eq!(lines[0], "Is this a question?");
2391        assert_eq!(lines[1], "Yes it is!");
2392        assert_eq!(lines[2], "And a statement.");
2393    }
2394
2395    #[test]
2396    fn test_sentence_per_line_in_lists() {
2397        let config = MD013Config {
2398            reflow: true,
2399            reflow_mode: ReflowMode::SentencePerLine,
2400            ..Default::default()
2401        };
2402        let rule = MD013LineLength::from_config_struct(config);
2403
2404        let content = "- List item one. With two sentences.\n- Another item.";
2405        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2406        let result = rule.check(&ctx).unwrap();
2407
2408        assert!(!result.is_empty(), "Should detect sentences in list items");
2409        // The fix should preserve list formatting
2410        let fix = result[0].fix.as_ref().unwrap();
2411        assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
2412    }
2413}