rumdl_lib/rules/
md013_line_length.rs

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