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