1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
7use crate::utils::range_utils::LineIndex;
8use crate::utils::range_utils::calculate_excess_range;
9use crate::utils::regex_cache::{
10 IMAGE_REF_PATTERN, INLINE_LINK_REGEX as MARKDOWN_LINK_PATTERN, LINK_REF_PATTERN, URL_IN_TEXT, URL_PATTERN,
11};
12use crate::utils::table_utils::TableUtils;
13use 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 structure: &DocumentStructure,
48 ) -> bool {
49 if self.config.strict {
50 return false;
51 }
52
53 let trimmed = line.trim();
55
56 if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
58 return true;
59 }
60
61 if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
63 return true;
64 }
65
66 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
68 return true;
69 }
70
71 if structure.is_in_code_block(current_line + 1)
73 && !trimmed.is_empty()
74 && !line.contains(' ')
75 && !line.contains('\t')
76 {
77 return true;
78 }
79
80 false
81 }
82}
83
84impl Rule for MD013LineLength {
85 fn name(&self) -> &'static str {
86 "MD013"
87 }
88
89 fn description(&self) -> &'static str {
90 "Line length should not be excessive"
91 }
92
93 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
94 let content = ctx.content;
95
96 if self.should_skip(ctx) && !(self.config.reflow && self.config.reflow_mode == ReflowMode::Normalize) {
98 return Ok(Vec::new());
99 }
100
101 let structure = DocumentStructure::new(content);
103 self.check_with_structure(ctx, &structure)
104 }
105
106 fn check_with_structure(
108 &self,
109 ctx: &crate::lint_context::LintContext,
110 structure: &DocumentStructure,
111 ) -> LintResult {
112 let content = ctx.content;
113 let mut warnings = Vec::new();
114
115 let inline_config = crate::inline_config::InlineConfig::from_content(content);
119 let config_override = inline_config.get_rule_config("MD013");
120
121 let effective_config = if let Some(json_config) = config_override {
123 if let Some(obj) = json_config.as_object() {
124 let mut config = self.config.clone();
125 if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
126 config.line_length = line_length as usize;
127 }
128 if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
129 config.code_blocks = code_blocks;
130 }
131 if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
132 config.tables = tables;
133 }
134 if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
135 config.headings = headings;
136 }
137 if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
138 config.strict = strict;
139 }
140 if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
141 config.reflow = reflow;
142 }
143 if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
144 config.reflow_mode = match reflow_mode {
145 "default" => ReflowMode::Default,
146 "normalize" => ReflowMode::Normalize,
147 _ => ReflowMode::default(),
148 };
149 }
150 config
151 } else {
152 self.config.clone()
153 }
154 } else {
155 self.config.clone()
156 };
157
158 let mut candidate_lines = Vec::new();
160 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
161 if line_info.content.len() > effective_config.line_length {
163 candidate_lines.push(line_idx);
164 }
165 }
166
167 if candidate_lines.is_empty()
169 && !(effective_config.reflow && effective_config.reflow_mode == ReflowMode::Normalize)
170 {
171 return Ok(warnings);
172 }
173
174 let lines: Vec<&str> = if !ctx.lines.is_empty() {
176 ctx.lines.iter().map(|l| l.content.as_str()).collect()
177 } else {
178 content.lines().collect()
179 };
180
181 let heading_lines_set: std::collections::HashSet<usize> = if !effective_config.headings {
183 structure.heading_lines.iter().cloned().collect()
184 } else {
185 std::collections::HashSet::new()
186 };
187
188 let table_lines_set: std::collections::HashSet<usize> = if !effective_config.tables {
190 let table_blocks = TableUtils::find_table_blocks(content, ctx);
191 let mut table_lines = std::collections::HashSet::new();
192 for table in &table_blocks {
193 table_lines.insert(table.header_line + 1);
194 table_lines.insert(table.delimiter_line + 1);
195 for &line in &table.content_lines {
196 table_lines.insert(line + 1);
197 }
198 }
199 table_lines
200 } else {
201 std::collections::HashSet::new()
202 };
203
204 for &line_idx in &candidate_lines {
206 let line_number = line_idx + 1;
207 let line = lines[line_idx];
208
209 let effective_length = self.calculate_effective_length(line);
211
212 let line_limit = effective_config.line_length;
214
215 if effective_length <= line_limit {
217 continue;
218 }
219
220 if !effective_config.strict {
222 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
224 continue;
225 }
226
227 if (!effective_config.headings && heading_lines_set.contains(&line_number))
231 || (!effective_config.code_blocks && structure.is_in_code_block(line_number))
232 || (!effective_config.tables && table_lines_set.contains(&line_number))
233 || structure.is_in_blockquote(line_number)
234 || structure.is_in_html_block(line_number)
235 {
236 continue;
237 }
238
239 if self.should_ignore_line(line, &lines, line_idx, structure) {
241 continue;
242 }
243 }
244
245 let fix = None;
248
249 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
250
251 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
253
254 warnings.push(LintWarning {
255 rule_name: Some(self.name()),
256 message,
257 line: start_line,
258 column: start_col,
259 end_line,
260 end_column: end_col,
261 severity: Severity::Warning,
262 fix,
263 });
264 }
265
266 if effective_config.reflow {
268 let paragraph_warnings = self.generate_paragraph_fixes(ctx, structure, &effective_config, &lines);
269 for pw in paragraph_warnings {
271 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
273 warnings.push(pw);
274 }
275 }
276
277 Ok(warnings)
278 }
279
280 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
281 let warnings = self.check(ctx)?;
284
285 if !warnings.iter().any(|w| w.fix.is_some()) {
287 return Ok(ctx.content.to_string());
288 }
289
290 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
292 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
293 }
294
295 fn as_any(&self) -> &dyn std::any::Any {
296 self
297 }
298
299 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
300 Some(self)
301 }
302
303 fn category(&self) -> RuleCategory {
304 RuleCategory::Whitespace
305 }
306
307 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
308 if ctx.content.is_empty() {
310 return true;
311 }
312
313 if ctx.content.len() <= self.config.line_length {
315 return true;
316 }
317
318 !ctx.lines
320 .iter()
321 .any(|line| line.content.len() > self.config.line_length)
322 }
323
324 fn default_config_section(&self) -> Option<(String, toml::Value)> {
325 let default_config = MD013Config::default();
326 let json_value = serde_json::to_value(&default_config).ok()?;
327 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
328
329 if let toml::Value::Table(table) = toml_value {
330 if !table.is_empty() {
331 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
332 } else {
333 None
334 }
335 } else {
336 None
337 }
338 }
339
340 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
341 let mut aliases = std::collections::HashMap::new();
342 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
343 Some(aliases)
344 }
345
346 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
347 where
348 Self: Sized,
349 {
350 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
351 if rule_config.line_length == 80 {
353 rule_config.line_length = config.global.line_length as usize;
355 }
356 Box::new(Self::from_config_struct(rule_config))
357 }
358}
359
360impl MD013LineLength {
361 fn generate_paragraph_fixes(
363 &self,
364 ctx: &crate::lint_context::LintContext,
365 structure: &DocumentStructure,
366 config: &MD013Config,
367 lines: &[&str],
368 ) -> Vec<LintWarning> {
369 let mut warnings = Vec::new();
370 let line_index = LineIndex::new(ctx.content.to_string());
371
372 let mut i = 0;
373 while i < lines.len() {
374 let line_num = i + 1;
375
376 if structure.is_in_code_block(line_num)
378 || structure.is_in_html_block(line_num)
379 || structure.is_in_blockquote(line_num)
380 || lines[i].trim().starts_with('#')
381 || TableUtils::is_potential_table_row(lines[i])
382 || lines[i].trim().is_empty()
383 || is_horizontal_rule(lines[i].trim())
384 {
385 i += 1;
386 continue;
387 }
388
389 let trimmed = lines[i].trim();
391 if is_list_item(trimmed) {
392 let list_start = i;
394 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
395 let indent_size = marker.len();
396 let expected_indent = " ".repeat(indent_size);
397
398 let mut list_item_lines = vec![first_content];
399 i += 1;
400
401 while i < lines.len() {
403 let line = lines[i];
404 if line.starts_with(&expected_indent) && !line.trim().is_empty() {
406 let content_after_indent = &line[indent_size..];
408 if is_list_item(content_after_indent.trim()) {
409 break;
411 }
412 let content = line[indent_size..].to_string();
414 list_item_lines.push(content);
415 i += 1;
416 } else if line.trim().is_empty() {
417 if i + 1 < lines.len() && lines[i + 1].starts_with(&expected_indent) {
420 list_item_lines.push(String::new());
421 i += 1;
422 } else {
423 break;
424 }
425 } else {
426 break;
427 }
428 }
429
430 let combined_content = list_item_lines.join(" ").trim().to_string();
432 let full_line = format!("{marker}{combined_content}");
433
434 if self.calculate_effective_length(&full_line) > config.line_length
435 || (config.reflow_mode == ReflowMode::Normalize && list_item_lines.len() > 1)
436 {
437 let start_range = line_index.whole_line_range(list_start + 1);
438 let end_line = i - 1;
439 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
440 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
441 } else {
442 line_index.whole_line_range(end_line + 1)
443 };
444 let byte_range = start_range.start..end_range.end;
445
446 let reflow_options = crate::utils::text_reflow::ReflowOptions {
448 line_length: config.line_length - indent_size,
449 break_on_sentences: true,
450 preserve_breaks: false,
451 };
452 let reflowed = crate::utils::text_reflow::reflow_line(&combined_content, &reflow_options);
453
454 let mut result = vec![format!("{marker}{}", reflowed[0])];
456 for line in reflowed.iter().skip(1) {
457 result.push(format!("{expected_indent}{line}"));
458 }
459 let reflowed_text = result.join("\n");
460
461 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
463 format!("{reflowed_text}\n")
464 } else {
465 reflowed_text
466 };
467
468 warnings.push(LintWarning {
469 rule_name: Some(self.name()),
470 message: format!(
471 "Line length exceeds {} characters and can be reflowed",
472 config.line_length
473 ),
474 line: list_start + 1,
475 column: 1,
476 end_line: end_line + 1,
477 end_column: lines[end_line].len() + 1,
478 severity: Severity::Warning,
479 fix: Some(crate::rule::Fix {
480 range: byte_range,
481 replacement,
482 }),
483 });
484 }
485 continue;
486 }
487
488 let paragraph_start = i;
490 let mut paragraph_lines = vec![lines[i]];
491 i += 1;
492
493 while i < lines.len() {
494 let next_line = lines[i];
495 let next_line_num = i + 1;
496 let next_trimmed = next_line.trim();
497
498 if next_trimmed.is_empty()
500 || structure.is_in_code_block(next_line_num)
501 || structure.is_in_html_block(next_line_num)
502 || structure.is_in_blockquote(next_line_num)
503 || next_trimmed.starts_with('#')
504 || TableUtils::is_potential_table_row(next_line)
505 || is_list_item(next_trimmed)
506 || is_horizontal_rule(next_trimmed)
507 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
508 {
509 break;
510 }
511
512 if i > 0 && lines[i - 1].ends_with(" ") {
514 break;
516 }
517
518 paragraph_lines.push(next_line);
519 i += 1;
520 }
521
522 let needs_reflow = if config.reflow_mode == ReflowMode::Normalize {
524 paragraph_lines.len() > 1
526 } else {
527 paragraph_lines
529 .iter()
530 .any(|line| self.calculate_effective_length(line) > config.line_length)
531 };
532
533 if needs_reflow {
534 let start_range = line_index.whole_line_range(paragraph_start + 1);
537 let end_line = paragraph_start + paragraph_lines.len() - 1;
538
539 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
541 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
543 } else {
544 line_index.whole_line_range(end_line + 1)
546 };
547
548 let byte_range = start_range.start..end_range.end;
549
550 let paragraph_text = paragraph_lines.join(" ");
552
553 let has_hard_break = paragraph_lines.last().is_some_and(|l| l.ends_with(" "));
555
556 let reflow_options = crate::utils::text_reflow::ReflowOptions {
558 line_length: config.line_length,
559 break_on_sentences: true,
560 preserve_breaks: false,
561 };
562 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
563
564 if has_hard_break && !reflowed.is_empty() {
566 let last_idx = reflowed.len() - 1;
567 if !reflowed[last_idx].ends_with(" ") {
568 reflowed[last_idx].push_str(" ");
569 }
570 }
571
572 let reflowed_text = reflowed.join("\n");
573
574 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
576 format!("{reflowed_text}\n")
577 } else {
578 reflowed_text
579 };
580
581 let (warning_line, warning_end_line) = if config.reflow_mode == ReflowMode::Normalize {
585 (paragraph_start + 1, end_line + 1)
586 } else {
587 let mut violating_line = paragraph_start;
589 for (idx, line) in paragraph_lines.iter().enumerate() {
590 if self.calculate_effective_length(line) > config.line_length {
591 violating_line = paragraph_start + idx;
592 break;
593 }
594 }
595 (violating_line + 1, violating_line + 1)
596 };
597
598 warnings.push(LintWarning {
599 rule_name: Some(self.name()),
600 message: if config.reflow_mode == ReflowMode::Normalize {
601 format!(
602 "Paragraph could be normalized to use line length of {} characters",
603 config.line_length
604 )
605 } else {
606 format!(
607 "Line length exceeds {} characters and can be reflowed",
608 config.line_length
609 )
610 },
611 line: warning_line,
612 column: 1,
613 end_line: warning_end_line,
614 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
615 severity: Severity::Warning,
616 fix: Some(crate::rule::Fix {
617 range: byte_range,
618 replacement,
619 }),
620 });
621 }
622 }
623
624 warnings
625 }
626
627 fn calculate_effective_length(&self, line: &str) -> usize {
629 if self.config.strict {
630 return line.chars().count();
632 }
633
634 let bytes = line.as_bytes();
636 if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
637 return line.chars().count();
638 }
639
640 if !line.contains("http") && !line.contains('[') {
642 return line.chars().count();
643 }
644
645 let mut effective_line = line.to_string();
646
647 if line.contains('[') && line.contains("](") {
650 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
651 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
652 && url.as_str().len() > 15
653 {
654 let replacement = format!("[{}](url)", text.as_str());
655 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
656 }
657 }
658 }
659
660 if effective_line.contains("http") {
663 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
664 let url = url_match.as_str();
665 if !effective_line.contains(&format!("({url})")) {
667 let placeholder = "x".repeat(15.min(url.len()));
670 effective_line = effective_line.replacen(url, &placeholder, 1);
671 }
672 }
673 }
674
675 effective_line.chars().count()
676 }
677}
678
679impl DocumentStructureExtensions for MD013LineLength {
680 fn has_relevant_elements(
681 &self,
682 ctx: &crate::lint_context::LintContext,
683 _doc_structure: &DocumentStructure,
684 ) -> bool {
685 !ctx.content.is_empty()
687 }
688}
689
690fn extract_list_marker_and_content(line: &str) -> (String, String) {
692 let indent_len = line.len() - line.trim_start().len();
694 let indent = &line[..indent_len];
695 let trimmed = &line[indent_len..];
696
697 if let Some(rest) = trimmed.strip_prefix("- ") {
699 return (format!("{indent}- "), rest.to_string());
700 }
701 if let Some(rest) = trimmed.strip_prefix("* ") {
702 return (format!("{indent}* "), rest.to_string());
703 }
704 if let Some(rest) = trimmed.strip_prefix("+ ") {
705 return (format!("{indent}+ "), rest.to_string());
706 }
707
708 let mut chars = trimmed.chars();
710 let mut marker_content = String::new();
711
712 while let Some(c) = chars.next() {
713 marker_content.push(c);
714 if c == '.' {
715 if let Some(next) = chars.next()
717 && next == ' '
718 {
719 marker_content.push(next);
720 let content = chars.as_str().to_string();
721 return (format!("{indent}{marker_content}"), content);
722 }
723 break;
724 }
725 }
726
727 (String::new(), line.to_string())
729}
730
731fn is_horizontal_rule(line: &str) -> bool {
733 if line.len() < 3 {
734 return false;
735 }
736 let chars: Vec<char> = line.chars().collect();
738 if chars.is_empty() {
739 return false;
740 }
741 let first_char = chars[0];
742 if first_char != '-' && first_char != '_' && first_char != '*' {
743 return false;
744 }
745 for c in &chars {
747 if *c != first_char && *c != ' ' {
748 return false;
749 }
750 }
751 chars.iter().filter(|c| **c == first_char).count() >= 3
753}
754
755fn is_numbered_list_item(line: &str) -> bool {
756 let mut chars = line.chars();
757 if !chars.next().is_some_and(|c| c.is_numeric()) {
759 return false;
760 }
761 while let Some(c) = chars.next() {
763 if c == '.' {
764 return chars.next().is_none_or(|c| c == ' ');
766 }
767 if !c.is_numeric() {
768 return false;
769 }
770 }
771 false
772}
773
774fn is_list_item(line: &str) -> bool {
775 if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
777 && line.len() > 1
778 && line.chars().nth(1) == Some(' ')
779 {
780 return true;
781 }
782 is_numbered_list_item(line)
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789 use crate::lint_context::LintContext;
790
791 #[test]
792 fn test_default_config() {
793 let rule = MD013LineLength::default();
794 assert_eq!(rule.config.line_length, 80);
795 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
799 }
800
801 #[test]
802 fn test_custom_config() {
803 let rule = MD013LineLength::new(100, true, true, false, true);
804 assert_eq!(rule.config.line_length, 100);
805 assert!(rule.config.code_blocks);
806 assert!(rule.config.tables);
807 assert!(!rule.config.headings);
808 assert!(rule.config.strict);
809 }
810
811 #[test]
812 fn test_basic_line_length_violation() {
813 let rule = MD013LineLength::new(50, false, false, false, false);
814 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
816 let result = rule.check(&ctx).unwrap();
817
818 assert_eq!(result.len(), 1);
819 assert!(result[0].message.contains("Line length"));
820 assert!(result[0].message.contains("exceeds 50 characters"));
821 }
822
823 #[test]
824 fn test_no_violation_under_limit() {
825 let rule = MD013LineLength::new(100, false, false, false, false);
826 let content = "Short line.\nAnother short line.";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
828 let result = rule.check(&ctx).unwrap();
829
830 assert_eq!(result.len(), 0);
831 }
832
833 #[test]
834 fn test_multiple_violations() {
835 let rule = MD013LineLength::new(30, false, false, false, false);
836 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
838 let result = rule.check(&ctx).unwrap();
839
840 assert_eq!(result.len(), 2);
841 assert_eq!(result[0].line, 1);
842 assert_eq!(result[1].line, 2);
843 }
844
845 #[test]
846 fn test_code_blocks_exemption() {
847 let rule = MD013LineLength::new(30, false, false, false, false);
849 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
851 let result = rule.check(&ctx).unwrap();
852
853 assert_eq!(result.len(), 0);
854 }
855
856 #[test]
857 fn test_code_blocks_not_exempt_when_configured() {
858 let rule = MD013LineLength::new(30, true, false, false, false);
860 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
862 let result = rule.check(&ctx).unwrap();
863
864 assert!(!result.is_empty());
865 }
866
867 #[test]
868 fn test_heading_checked_when_enabled() {
869 let rule = MD013LineLength::new(30, false, false, true, false);
870 let content = "# This is a very long heading that would normally exceed the limit";
871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
872 let result = rule.check(&ctx).unwrap();
873
874 assert_eq!(result.len(), 1);
875 }
876
877 #[test]
878 fn test_heading_exempt_when_disabled() {
879 let rule = MD013LineLength::new(30, false, false, false, false);
880 let content = "# This is a very long heading that should trigger a warning";
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
882 let result = rule.check(&ctx).unwrap();
883
884 assert_eq!(result.len(), 0);
885 }
886
887 #[test]
888 fn test_table_checked_when_enabled() {
889 let rule = MD013LineLength::new(30, false, true, false, false);
890 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
892 let result = rule.check(&ctx).unwrap();
893
894 assert_eq!(result.len(), 2); }
896
897 #[test]
898 fn test_issue_78_tables_after_fenced_code_blocks() {
899 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
902
903```plain
904some code block longer than 20 chars length
905```
906
907this is a very long line
908
909| column A | column B |
910| -------- | -------- |
911| `var` | `val` |
912| value 1 | value 2 |
913
914correct length line"#;
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
916 let result = rule.check(&ctx).unwrap();
917
918 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
920 assert_eq!(result[0].line, 7, "Should flag line 7");
921 assert!(result[0].message.contains("24 exceeds 20"));
922 }
923
924 #[test]
925 fn test_issue_78_tables_with_inline_code() {
926 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
929| -------- | -------- |
930| `var with very long name` | `val exceeding limit` |
931| value 1 | value 2 |
932
933This line exceeds limit"#;
934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
935 let result = rule.check(&ctx).unwrap();
936
937 assert_eq!(result.len(), 1, "Should only flag the non-table line");
939 assert_eq!(result[0].line, 6, "Should flag line 6");
940 }
941
942 #[test]
943 fn test_issue_78_indented_code_blocks() {
944 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
947
948 some code block longer than 20 chars length
949
950this is a very long line
951
952| column A | column B |
953| -------- | -------- |
954| value 1 | value 2 |
955
956correct length line"#;
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
958 let result = rule.check(&ctx).unwrap();
959
960 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
962 assert_eq!(result[0].line, 5, "Should flag line 5");
963 }
964
965 #[test]
966 fn test_url_exemption() {
967 let rule = MD013LineLength::new(30, false, false, false, false);
968 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
970 let result = rule.check(&ctx).unwrap();
971
972 assert_eq!(result.len(), 0);
973 }
974
975 #[test]
976 fn test_image_reference_exemption() {
977 let rule = MD013LineLength::new(30, false, false, false, false);
978 let content = "![This is a very long image alt text that exceeds limit][reference]";
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
980 let result = rule.check(&ctx).unwrap();
981
982 assert_eq!(result.len(), 0);
983 }
984
985 #[test]
986 fn test_link_reference_exemption() {
987 let rule = MD013LineLength::new(30, false, false, false, false);
988 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
990 let result = rule.check(&ctx).unwrap();
991
992 assert_eq!(result.len(), 0);
993 }
994
995 #[test]
996 fn test_strict_mode() {
997 let rule = MD013LineLength::new(30, false, false, false, true);
998 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1000 let result = rule.check(&ctx).unwrap();
1001
1002 assert_eq!(result.len(), 1);
1004 }
1005
1006 #[test]
1007 fn test_blockquote_exemption() {
1008 let rule = MD013LineLength::new(30, false, false, false, false);
1009 let content = "> This is a very long line inside a blockquote that should be ignored.";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1011 let result = rule.check(&ctx).unwrap();
1012
1013 assert_eq!(result.len(), 0);
1014 }
1015
1016 #[test]
1017 fn test_setext_heading_underline_exemption() {
1018 let rule = MD013LineLength::new(30, false, false, false, false);
1019 let content = "Heading\n========================================";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1021 let result = rule.check(&ctx).unwrap();
1022
1023 assert_eq!(result.len(), 0);
1025 }
1026
1027 #[test]
1028 fn test_no_fix_without_reflow() {
1029 let rule = MD013LineLength::new(60, false, false, false, false);
1030 let content = "This line has trailing whitespace that makes it too long ";
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1032 let result = rule.check(&ctx).unwrap();
1033
1034 assert_eq!(result.len(), 1);
1035 assert!(result[0].fix.is_none());
1037
1038 let fixed = rule.fix(&ctx).unwrap();
1040 assert_eq!(fixed, content);
1041 }
1042
1043 #[test]
1044 fn test_character_vs_byte_counting() {
1045 let rule = MD013LineLength::new(10, false, false, false, false);
1046 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1049 let result = rule.check(&ctx).unwrap();
1050
1051 assert_eq!(result.len(), 1);
1052 assert_eq!(result[0].line, 1);
1053 }
1054
1055 #[test]
1056 fn test_empty_content() {
1057 let rule = MD013LineLength::default();
1058 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1059 let result = rule.check(&ctx).unwrap();
1060
1061 assert_eq!(result.len(), 0);
1062 }
1063
1064 #[test]
1065 fn test_excess_range_calculation() {
1066 let rule = MD013LineLength::new(10, false, false, false, false);
1067 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1069 let result = rule.check(&ctx).unwrap();
1070
1071 assert_eq!(result.len(), 1);
1072 assert_eq!(result[0].column, 11);
1074 assert_eq!(result[0].end_column, 21);
1075 }
1076
1077 #[test]
1078 fn test_html_block_exemption() {
1079 let rule = MD013LineLength::new(30, false, false, false, false);
1080 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1082 let result = rule.check(&ctx).unwrap();
1083
1084 assert_eq!(result.len(), 0);
1086 }
1087
1088 #[test]
1089 fn test_mixed_content() {
1090 let rule = MD013LineLength::new(30, false, false, false, false);
1092 let content = r#"# This heading is very long but should be exempt
1093
1094This regular paragraph line is too long and should trigger.
1095
1096```
1097Code block line that is very long but exempt.
1098```
1099
1100| Table | With very long content |
1101|-------|------------------------|
1102
1103Another long line that should trigger a warning."#;
1104
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1106 let result = rule.check(&ctx).unwrap();
1107
1108 assert_eq!(result.len(), 2);
1110 assert_eq!(result[0].line, 3);
1111 assert_eq!(result[1].line, 12);
1112 }
1113
1114 #[test]
1115 fn test_fix_without_reflow_preserves_content() {
1116 let rule = MD013LineLength::new(50, false, false, false, false);
1117 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
1118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1119
1120 let fixed = rule.fix(&ctx).unwrap();
1122 assert_eq!(fixed, content);
1123 }
1124
1125 #[test]
1126 fn test_has_relevant_elements() {
1127 let rule = MD013LineLength::default();
1128 let structure = DocumentStructure::new("test");
1129
1130 let ctx = LintContext::new("Some content", crate::config::MarkdownFlavor::Standard);
1131 assert!(rule.has_relevant_elements(&ctx, &structure));
1132
1133 let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1134 assert!(!rule.has_relevant_elements(&empty_ctx, &structure));
1135 }
1136
1137 #[test]
1138 fn test_rule_metadata() {
1139 let rule = MD013LineLength::default();
1140 assert_eq!(rule.name(), "MD013");
1141 assert_eq!(rule.description(), "Line length should not be excessive");
1142 assert_eq!(rule.category(), RuleCategory::Whitespace);
1143 }
1144
1145 #[test]
1146 fn test_url_embedded_in_text() {
1147 let rule = MD013LineLength::new(50, false, false, false, false);
1148
1149 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1152 let result = rule.check(&ctx).unwrap();
1153
1154 assert_eq!(result.len(), 0);
1156 }
1157
1158 #[test]
1159 fn test_multiple_urls_in_line() {
1160 let rule = MD013LineLength::new(50, false, false, false, false);
1161
1162 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1165
1166 let result = rule.check(&ctx).unwrap();
1167
1168 assert_eq!(result.len(), 0);
1170 }
1171
1172 #[test]
1173 fn test_markdown_link_with_long_url() {
1174 let rule = MD013LineLength::new(50, false, false, false, false);
1175
1176 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1179 let result = rule.check(&ctx).unwrap();
1180
1181 assert_eq!(result.len(), 0);
1183 }
1184
1185 #[test]
1186 fn test_line_too_long_even_without_urls() {
1187 let rule = MD013LineLength::new(50, false, false, false, false);
1188
1189 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1192 let result = rule.check(&ctx).unwrap();
1193
1194 assert_eq!(result.len(), 1);
1196 }
1197
1198 #[test]
1199 fn test_strict_mode_counts_urls() {
1200 let rule = MD013LineLength::new(50, false, false, false, true); let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1205 let result = rule.check(&ctx).unwrap();
1206
1207 assert_eq!(result.len(), 1);
1209 }
1210
1211 #[test]
1212 fn test_documentation_example_from_md051() {
1213 let rule = MD013LineLength::new(80, false, false, false, false);
1214
1215 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1218 let result = rule.check(&ctx).unwrap();
1219
1220 assert_eq!(result.len(), 0);
1222 }
1223
1224 #[test]
1225 fn test_text_reflow_simple() {
1226 let config = MD013Config {
1227 line_length: 30,
1228 reflow: true,
1229 ..Default::default()
1230 };
1231 let rule = MD013LineLength::from_config_struct(config);
1232
1233 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1235
1236 let fixed = rule.fix(&ctx).unwrap();
1237
1238 for line in fixed.lines() {
1240 assert!(
1241 line.chars().count() <= 30,
1242 "Line too long: {} (len={})",
1243 line,
1244 line.chars().count()
1245 );
1246 }
1247
1248 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1250 let original_words: Vec<&str> = content.split_whitespace().collect();
1251 assert_eq!(fixed_words, original_words);
1252 }
1253
1254 #[test]
1255 fn test_text_reflow_preserves_markdown_elements() {
1256 let config = MD013Config {
1257 line_length: 40,
1258 reflow: true,
1259 ..Default::default()
1260 };
1261 let rule = MD013LineLength::from_config_struct(config);
1262
1263 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1265
1266 let fixed = rule.fix(&ctx).unwrap();
1267
1268 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1270 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1271 assert!(
1272 fixed.contains("[a link](https://example.com)"),
1273 "Link not preserved in: {fixed}"
1274 );
1275
1276 for line in fixed.lines() {
1278 assert!(line.len() <= 40, "Line too long: {line}");
1279 }
1280 }
1281
1282 #[test]
1283 fn test_text_reflow_preserves_code_blocks() {
1284 let config = MD013Config {
1285 line_length: 30,
1286 reflow: true,
1287 ..Default::default()
1288 };
1289 let rule = MD013LineLength::from_config_struct(config);
1290
1291 let content = r#"Here is some text.
1292
1293```python
1294def very_long_function_name_that_exceeds_limit():
1295 return "This should not be wrapped"
1296```
1297
1298More text after code block."#;
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1300
1301 let fixed = rule.fix(&ctx).unwrap();
1302
1303 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1305 assert!(fixed.contains("```python"));
1306 assert!(fixed.contains("```"));
1307 }
1308
1309 #[test]
1310 fn test_text_reflow_preserves_lists() {
1311 let config = MD013Config {
1312 line_length: 30,
1313 reflow: true,
1314 ..Default::default()
1315 };
1316 let rule = MD013LineLength::from_config_struct(config);
1317
1318 let content = r#"Here is a list:
1319
13201. First item with a very long line that needs wrapping
13212. Second item is short
13223. Third item also has a long line that exceeds the limit
1323
1324And a bullet list:
1325
1326- Bullet item with very long content that needs wrapping
1327- Short bullet"#;
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1329
1330 let fixed = rule.fix(&ctx).unwrap();
1331
1332 assert!(fixed.contains("1. "));
1334 assert!(fixed.contains("2. "));
1335 assert!(fixed.contains("3. "));
1336 assert!(fixed.contains("- "));
1337
1338 let lines: Vec<&str> = fixed.lines().collect();
1340 for (i, line) in lines.iter().enumerate() {
1341 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1342 if i + 1 < lines.len()
1344 && !lines[i + 1].trim().is_empty()
1345 && !lines[i + 1].trim().starts_with(char::is_numeric)
1346 && !lines[i + 1].trim().starts_with("-")
1347 {
1348 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1350 }
1351 } else if line.trim().starts_with("-") {
1352 if i + 1 < lines.len()
1354 && !lines[i + 1].trim().is_empty()
1355 && !lines[i + 1].trim().starts_with(char::is_numeric)
1356 && !lines[i + 1].trim().starts_with("-")
1357 {
1358 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1360 }
1361 }
1362 }
1363 }
1364
1365 #[test]
1366 fn test_issue_83_numbered_list_with_backticks() {
1367 let config = MD013Config {
1369 line_length: 100,
1370 reflow: true,
1371 ..Default::default()
1372 };
1373 let rule = MD013LineLength::from_config_struct(config);
1374
1375 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1378
1379 let fixed = rule.fix(&ctx).unwrap();
1380
1381 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
1384
1385 assert_eq!(
1386 fixed, expected,
1387 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_text_reflow_disabled_by_default() {
1393 let rule = MD013LineLength::new(30, false, false, false, false);
1394
1395 let content = "This is a very long line that definitely exceeds thirty characters.";
1396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1397
1398 let fixed = rule.fix(&ctx).unwrap();
1399
1400 assert_eq!(fixed, content);
1403 }
1404
1405 #[test]
1406 fn test_reflow_with_hard_line_breaks() {
1407 let config = MD013Config {
1409 line_length: 40,
1410 reflow: true,
1411 ..Default::default()
1412 };
1413 let rule = MD013LineLength::from_config_struct(config);
1414
1415 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";
1417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1418 let fixed = rule.fix(&ctx).unwrap();
1419
1420 assert!(
1422 fixed.contains(" \n"),
1423 "Hard line break with exactly 2 spaces should be preserved"
1424 );
1425 }
1426
1427 #[test]
1428 fn test_reflow_preserves_reference_links() {
1429 let config = MD013Config {
1430 line_length: 40,
1431 reflow: true,
1432 ..Default::default()
1433 };
1434 let rule = MD013LineLength::from_config_struct(config);
1435
1436 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
1437
1438[ref]: https://example.com";
1439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1440 let fixed = rule.fix(&ctx).unwrap();
1441
1442 assert!(fixed.contains("[reference link][ref]"));
1444 assert!(!fixed.contains("[ reference link]"));
1445 assert!(!fixed.contains("[ref ]"));
1446 }
1447
1448 #[test]
1449 fn test_reflow_with_nested_markdown_elements() {
1450 let config = MD013Config {
1451 line_length: 35,
1452 reflow: true,
1453 ..Default::default()
1454 };
1455 let rule = MD013LineLength::from_config_struct(config);
1456
1457 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
1458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1459 let fixed = rule.fix(&ctx).unwrap();
1460
1461 assert!(fixed.contains("**bold with `code` inside**"));
1463 }
1464
1465 #[test]
1466 fn test_reflow_with_unbalanced_markdown() {
1467 let config = MD013Config {
1469 line_length: 30,
1470 reflow: true,
1471 ..Default::default()
1472 };
1473 let rule = MD013LineLength::from_config_struct(config);
1474
1475 let content = "This has **unbalanced bold that goes on for a very long time without closing";
1476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1477 let fixed = rule.fix(&ctx).unwrap();
1478
1479 assert!(!fixed.is_empty());
1483 for line in fixed.lines() {
1485 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
1486 }
1487 }
1488
1489 #[test]
1490 fn test_reflow_fix_indicator() {
1491 let config = MD013Config {
1493 line_length: 30,
1494 reflow: true,
1495 ..Default::default()
1496 };
1497 let rule = MD013LineLength::from_config_struct(config);
1498
1499 let content = "This is a very long line that definitely exceeds the thirty character limit";
1500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1501 let warnings = rule.check(&ctx).unwrap();
1502
1503 assert!(!warnings.is_empty());
1505 assert!(
1506 warnings[0].fix.is_some(),
1507 "Should provide fix indicator when reflow is true"
1508 );
1509 }
1510
1511 #[test]
1512 fn test_no_fix_indicator_without_reflow() {
1513 let config = MD013Config {
1515 line_length: 30,
1516 reflow: false,
1517 ..Default::default()
1518 };
1519 let rule = MD013LineLength::from_config_struct(config);
1520
1521 let content = "This is a very long line that definitely exceeds the thirty character limit";
1522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1523 let warnings = rule.check(&ctx).unwrap();
1524
1525 assert!(!warnings.is_empty());
1527 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
1528 }
1529
1530 #[test]
1531 fn test_reflow_preserves_all_reference_link_types() {
1532 let config = MD013Config {
1533 line_length: 40,
1534 reflow: true,
1535 ..Default::default()
1536 };
1537 let rule = MD013LineLength::from_config_struct(config);
1538
1539 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
1540
1541[ref]: https://example.com
1542[collapsed]: https://example.com
1543[shortcut]: https://example.com";
1544
1545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1546 let fixed = rule.fix(&ctx).unwrap();
1547
1548 assert!(fixed.contains("[full reference][ref]"));
1550 assert!(fixed.contains("[collapsed][]"));
1551 assert!(fixed.contains("[shortcut]"));
1552 }
1553
1554 #[test]
1555 fn test_reflow_handles_images_correctly() {
1556 let config = MD013Config {
1557 line_length: 40,
1558 reflow: true,
1559 ..Default::default()
1560 };
1561 let rule = MD013LineLength::from_config_struct(config);
1562
1563 let content = "This line has an  that should not be broken when reflowing.";
1564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1565 let fixed = rule.fix(&ctx).unwrap();
1566
1567 assert!(fixed.contains(""));
1569 }
1570
1571 #[test]
1572 fn test_normalize_mode_flags_short_lines() {
1573 let config = MD013Config {
1574 line_length: 100,
1575 reflow: true,
1576 reflow_mode: ReflowMode::Normalize,
1577 ..Default::default()
1578 };
1579 let rule = MD013LineLength::from_config_struct(config);
1580
1581 let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
1583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1584 let warnings = rule.check(&ctx).unwrap();
1585
1586 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1588 assert!(warnings[0].message.contains("normalized"));
1589 }
1590
1591 #[test]
1592 fn test_normalize_mode_combines_short_lines() {
1593 let config = MD013Config {
1594 line_length: 100,
1595 reflow: true,
1596 reflow_mode: ReflowMode::Normalize,
1597 ..Default::default()
1598 };
1599 let rule = MD013LineLength::from_config_struct(config);
1600
1601 let content =
1603 "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
1604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1605 let fixed = rule.fix(&ctx).unwrap();
1606
1607 let lines: Vec<&str> = fixed.lines().collect();
1609 assert_eq!(lines.len(), 1, "Should combine into single line");
1610 assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
1611 }
1612
1613 #[test]
1614 fn test_normalize_mode_preserves_paragraph_breaks() {
1615 let config = MD013Config {
1616 line_length: 100,
1617 reflow: true,
1618 reflow_mode: ReflowMode::Normalize,
1619 ..Default::default()
1620 };
1621 let rule = MD013LineLength::from_config_struct(config);
1622
1623 let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
1624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1625 let fixed = rule.fix(&ctx).unwrap();
1626
1627 assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
1629
1630 let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
1631 assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
1632 }
1633
1634 #[test]
1635 fn test_default_mode_only_fixes_violations() {
1636 let config = MD013Config {
1637 line_length: 100,
1638 reflow: true,
1639 reflow_mode: ReflowMode::Default, ..Default::default()
1641 };
1642 let rule = MD013LineLength::from_config_struct(config);
1643
1644 let content = "This is a short line.\nAnother short line.\nA third short line.";
1646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1647 let warnings = rule.check(&ctx).unwrap();
1648
1649 assert!(warnings.is_empty(), "Should not flag short lines in default mode");
1651
1652 let fixed = rule.fix(&ctx).unwrap();
1654 assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
1655 }
1656
1657 #[test]
1658 fn test_normalize_mode_with_lists() {
1659 let config = MD013Config {
1660 line_length: 80,
1661 reflow: true,
1662 reflow_mode: ReflowMode::Normalize,
1663 ..Default::default()
1664 };
1665 let rule = MD013LineLength::from_config_struct(config);
1666
1667 let content = r#"A paragraph with
1668short lines.
1669
16701. List item with
1671 short lines
16722. Another item"#;
1673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1674 let fixed = rule.fix(&ctx).unwrap();
1675
1676 let lines: Vec<&str> = fixed.lines().collect();
1678 assert!(lines[0].len() > 20, "First paragraph should be normalized");
1679 assert!(fixed.contains("1. "), "Should preserve list markers");
1680 assert!(fixed.contains("2. "), "Should preserve list markers");
1681 }
1682
1683 #[test]
1684 fn test_normalize_mode_with_code_blocks() {
1685 let config = MD013Config {
1686 line_length: 100,
1687 reflow: true,
1688 reflow_mode: ReflowMode::Normalize,
1689 ..Default::default()
1690 };
1691 let rule = MD013LineLength::from_config_struct(config);
1692
1693 let content = r#"A paragraph with
1694short lines.
1695
1696```
1697code block should not be normalized
1698even with short lines
1699```
1700
1701Another paragraph with
1702short lines."#;
1703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1704 let fixed = rule.fix(&ctx).unwrap();
1705
1706 assert!(fixed.contains("code block should not be normalized\neven with short lines"));
1708 let lines: Vec<&str> = fixed.lines().collect();
1710 assert!(lines[0].len() > 20, "First paragraph should be normalized");
1711 }
1712
1713 #[test]
1714 fn test_issue_76_use_case() {
1715 let config = MD013Config {
1717 line_length: 999999, reflow: true,
1719 reflow_mode: ReflowMode::Normalize,
1720 ..Default::default()
1721 };
1722 let rule = MD013LineLength::from_config_struct(config);
1723
1724 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.";
1726
1727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1728
1729 let warnings = rule.check(&ctx).unwrap();
1731 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1732
1733 let fixed = rule.fix(&ctx).unwrap();
1735 let lines: Vec<&str> = fixed.lines().collect();
1736 assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
1737 assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
1738 }
1739
1740 #[test]
1741 fn test_normalize_mode_single_line_unchanged() {
1742 let config = MD013Config {
1744 line_length: 100,
1745 reflow: true,
1746 reflow_mode: ReflowMode::Normalize,
1747 ..Default::default()
1748 };
1749 let rule = MD013LineLength::from_config_struct(config);
1750
1751 let content = "This is a single line that should not be changed.";
1752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1753
1754 let warnings = rule.check(&ctx).unwrap();
1755 assert!(warnings.is_empty(), "Single line should not be flagged");
1756
1757 let fixed = rule.fix(&ctx).unwrap();
1758 assert_eq!(fixed, content, "Single line should remain unchanged");
1759 }
1760
1761 #[test]
1762 fn test_normalize_mode_with_inline_code() {
1763 let config = MD013Config {
1764 line_length: 80,
1765 reflow: true,
1766 reflow_mode: ReflowMode::Normalize,
1767 ..Default::default()
1768 };
1769 let rule = MD013LineLength::from_config_struct(config);
1770
1771 let content =
1772 "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
1773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1774
1775 let warnings = rule.check(&ctx).unwrap();
1776 assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
1777
1778 let fixed = rule.fix(&ctx).unwrap();
1779 assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
1780 assert!(fixed.lines().count() < 3, "Lines should be combined");
1781 }
1782
1783 #[test]
1784 fn test_normalize_mode_with_emphasis() {
1785 let config = MD013Config {
1786 line_length: 100,
1787 reflow: true,
1788 reflow_mode: ReflowMode::Normalize,
1789 ..Default::default()
1790 };
1791 let rule = MD013LineLength::from_config_struct(config);
1792
1793 let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
1794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1795
1796 let fixed = rule.fix(&ctx).unwrap();
1797 assert!(fixed.contains("**bold**"), "Bold should be preserved");
1798 assert!(fixed.contains("*italic*"), "Italic should be preserved");
1799 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
1800 }
1801
1802 #[test]
1803 fn test_normalize_mode_respects_hard_breaks() {
1804 let config = MD013Config {
1805 line_length: 100,
1806 reflow: true,
1807 reflow_mode: ReflowMode::Normalize,
1808 ..Default::default()
1809 };
1810 let rule = MD013LineLength::from_config_struct(config);
1811
1812 let content = "First line with hard break \nSecond line after break\nThird line";
1814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1815
1816 let fixed = rule.fix(&ctx).unwrap();
1817 assert!(fixed.contains(" \n"), "Hard break should be preserved");
1819 assert!(
1821 fixed.contains("Second line after break Third line"),
1822 "Lines without hard break should combine"
1823 );
1824 }
1825
1826 #[test]
1827 fn test_normalize_mode_with_links() {
1828 let config = MD013Config {
1829 line_length: 100,
1830 reflow: true,
1831 reflow_mode: ReflowMode::Normalize,
1832 ..Default::default()
1833 };
1834 let rule = MD013LineLength::from_config_struct(config);
1835
1836 let content =
1837 "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1839
1840 let fixed = rule.fix(&ctx).unwrap();
1841 assert!(
1842 fixed.contains("[link](https://example.com)"),
1843 "Link should be preserved"
1844 );
1845 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
1846 }
1847
1848 #[test]
1849 fn test_normalize_mode_empty_lines_between_paragraphs() {
1850 let config = MD013Config {
1851 line_length: 100,
1852 reflow: true,
1853 reflow_mode: ReflowMode::Normalize,
1854 ..Default::default()
1855 };
1856 let rule = MD013LineLength::from_config_struct(config);
1857
1858 let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
1859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1860
1861 let fixed = rule.fix(&ctx).unwrap();
1862 assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
1864 let parts: Vec<&str> = fixed.split("\n\n\n").collect();
1866 assert_eq!(parts.len(), 2, "Should have two parts");
1867 assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
1868 assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
1869 }
1870
1871 #[test]
1872 fn test_normalize_mode_mixed_list_types() {
1873 let config = MD013Config {
1874 line_length: 80,
1875 reflow: true,
1876 reflow_mode: ReflowMode::Normalize,
1877 ..Default::default()
1878 };
1879 let rule = MD013LineLength::from_config_struct(config);
1880
1881 let content = r#"Paragraph before list
1882with multiple lines.
1883
1884- Bullet item
1885* Another bullet
1886+ Plus bullet
1887
18881. Numbered item
18892. Another number
1890
1891Paragraph after list
1892with multiple lines."#;
1893
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1895 let fixed = rule.fix(&ctx).unwrap();
1896
1897 assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
1899 assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
1900 assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
1901 assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
1902
1903 assert!(
1905 fixed.starts_with("Paragraph before list with multiple lines."),
1906 "First paragraph should be normalized"
1907 );
1908 assert!(
1909 fixed.ends_with("Paragraph after list with multiple lines."),
1910 "Last paragraph should be normalized"
1911 );
1912 }
1913
1914 #[test]
1915 fn test_normalize_mode_with_horizontal_rules() {
1916 let config = MD013Config {
1917 line_length: 100,
1918 reflow: true,
1919 reflow_mode: ReflowMode::Normalize,
1920 ..Default::default()
1921 };
1922 let rule = MD013LineLength::from_config_struct(config);
1923
1924 let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
1925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1926
1927 let fixed = rule.fix(&ctx).unwrap();
1928 assert!(fixed.contains("---"), "Horizontal rule should be preserved");
1929 assert!(
1930 fixed.contains("Paragraph before horizontal rule."),
1931 "First paragraph normalized"
1932 );
1933 assert!(
1934 fixed.contains("Paragraph after horizontal rule."),
1935 "Second paragraph normalized"
1936 );
1937 }
1938
1939 #[test]
1940 fn test_normalize_mode_with_indented_code() {
1941 let config = MD013Config {
1942 line_length: 100,
1943 reflow: true,
1944 reflow_mode: ReflowMode::Normalize,
1945 ..Default::default()
1946 };
1947 let rule = MD013LineLength::from_config_struct(config);
1948
1949 let content = "Paragraph before\nindented code.\n\n This is indented code\n Should not be normalized\n\nParagraph after\nindented code.";
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1951
1952 let fixed = rule.fix(&ctx).unwrap();
1953 assert!(
1954 fixed.contains(" This is indented code\n Should not be normalized"),
1955 "Indented code preserved"
1956 );
1957 assert!(
1958 fixed.contains("Paragraph before indented code."),
1959 "First paragraph normalized"
1960 );
1961 assert!(
1962 fixed.contains("Paragraph after indented code."),
1963 "Second paragraph normalized"
1964 );
1965 }
1966
1967 #[test]
1968 fn test_normalize_mode_disabled_without_reflow() {
1969 let config = MD013Config {
1971 line_length: 100,
1972 reflow: false, reflow_mode: ReflowMode::Normalize,
1974 ..Default::default()
1975 };
1976 let rule = MD013LineLength::from_config_struct(config);
1977
1978 let content = "This is a line\nwith breaks that\nshould not be changed.";
1979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1980
1981 let warnings = rule.check(&ctx).unwrap();
1982 assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
1983
1984 let fixed = rule.fix(&ctx).unwrap();
1985 assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
1986 }
1987
1988 #[test]
1989 fn test_default_mode_with_long_lines() {
1990 let config = MD013Config {
1993 line_length: 50,
1994 reflow: true,
1995 reflow_mode: ReflowMode::Default,
1996 ..Default::default()
1997 };
1998 let rule = MD013LineLength::from_config_struct(config);
1999
2000 let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2002
2003 let warnings = rule.check(&ctx).unwrap();
2004 assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2005 assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2007
2008 let fixed = rule.fix(&ctx).unwrap();
2009 assert!(
2011 fixed.contains("Short line. This is"),
2012 "Should combine and reflow the paragraph"
2013 );
2014 assert!(
2015 fixed.contains("wrapping. Another short"),
2016 "Should include all paragraph content"
2017 );
2018 }
2019
2020 #[test]
2021 fn test_normalize_vs_default_mode_same_content() {
2022 let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2024
2025 let default_config = MD013Config {
2027 line_length: 100,
2028 reflow: true,
2029 reflow_mode: ReflowMode::Default,
2030 ..Default::default()
2031 };
2032 let default_rule = MD013LineLength::from_config_struct(default_config);
2033 let default_warnings = default_rule.check(&ctx).unwrap();
2034 let default_fixed = default_rule.fix(&ctx).unwrap();
2035
2036 let normalize_config = MD013Config {
2038 line_length: 100,
2039 reflow: true,
2040 reflow_mode: ReflowMode::Normalize,
2041 ..Default::default()
2042 };
2043 let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2044 let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2045 let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2046
2047 assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2049 assert!(
2050 !normalize_warnings.is_empty(),
2051 "Normalize mode should flag multi-line paragraphs"
2052 );
2053
2054 assert_eq!(
2055 default_fixed, content,
2056 "Default mode should not change content without violations"
2057 );
2058 assert_ne!(
2059 normalize_fixed, content,
2060 "Normalize mode should change multi-line paragraphs"
2061 );
2062 assert_eq!(
2063 normalize_fixed.lines().count(),
2064 1,
2065 "Normalize should combine into single line"
2066 );
2067 }
2068
2069 #[test]
2070 fn test_normalize_mode_with_reference_definitions() {
2071 let config = MD013Config {
2072 line_length: 100,
2073 reflow: true,
2074 reflow_mode: ReflowMode::Normalize,
2075 ..Default::default()
2076 };
2077 let rule = MD013LineLength::from_config_struct(config);
2078
2079 let content =
2080 "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2082
2083 let fixed = rule.fix(&ctx).unwrap();
2084 assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2085 assert!(
2086 fixed.contains("[ref]: https://example.com"),
2087 "Reference definition should be preserved"
2088 );
2089 assert!(
2090 fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2091 "Paragraph should be normalized"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_normalize_mode_with_html_comments() {
2097 let config = MD013Config {
2098 line_length: 100,
2099 reflow: true,
2100 reflow_mode: ReflowMode::Normalize,
2101 ..Default::default()
2102 };
2103 let rule = MD013LineLength::from_config_struct(config);
2104
2105 let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2107
2108 let fixed = rule.fix(&ctx).unwrap();
2109 assert!(
2110 fixed.contains("<!-- This is a comment -->"),
2111 "HTML comment should be preserved"
2112 );
2113 assert!(
2114 fixed.contains("Paragraph before HTML comment."),
2115 "First paragraph normalized"
2116 );
2117 assert!(
2118 fixed.contains("Paragraph after HTML comment."),
2119 "Second paragraph normalized"
2120 );
2121 }
2122
2123 #[test]
2124 fn test_normalize_mode_line_starting_with_number() {
2125 let config = MD013Config {
2127 line_length: 100,
2128 reflow: true,
2129 reflow_mode: ReflowMode::Normalize,
2130 ..Default::default()
2131 };
2132 let rule = MD013LineLength::from_config_struct(config);
2133
2134 let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2136
2137 let fixed = rule.fix(&ctx).unwrap();
2138 assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2139 assert!(
2140 fixed.contains("80 characters"),
2141 "Number at start of line should be preserved"
2142 );
2143 }
2144
2145 #[test]
2146 fn test_default_mode_preserves_list_structure() {
2147 let config = MD013Config {
2149 line_length: 80,
2150 reflow: true,
2151 reflow_mode: ReflowMode::Default,
2152 ..Default::default()
2153 };
2154 let rule = MD013LineLength::from_config_struct(config);
2155
2156 let content = r#"- This is a bullet point that has
2157 some text on multiple lines
2158 that should stay separate
2159
21601. Numbered list item with
2161 multiple lines that should
2162 also stay separate"#;
2163
2164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2165 let fixed = rule.fix(&ctx).unwrap();
2166
2167 let lines: Vec<&str> = fixed.lines().collect();
2169 assert_eq!(
2170 lines[0], "- This is a bullet point that has",
2171 "First line should be unchanged"
2172 );
2173 assert_eq!(
2174 lines[1], " some text on multiple lines",
2175 "Continuation should be preserved"
2176 );
2177 assert_eq!(
2178 lines[2], " that should stay separate",
2179 "Second continuation should be preserved"
2180 );
2181 }
2182
2183 #[test]
2184 fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2185 let config = MD013Config {
2187 line_length: 80,
2188 reflow: true,
2189 reflow_mode: ReflowMode::Normalize,
2190 ..Default::default()
2191 };
2192 let rule = MD013LineLength::from_config_struct(config);
2193
2194 let content = r#"- This is a bullet point that has
2195 some text on multiple lines
2196 that should be combined
2197
21981. Numbered list item with
2199 multiple lines that need
2200 to be properly combined
22012. Second item"#;
2202
2203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2204 let fixed = rule.fix(&ctx).unwrap();
2205
2206 assert!(
2208 !fixed.contains("lines that"),
2209 "Should not have double spaces in bullet list"
2210 );
2211 assert!(
2212 !fixed.contains("need to"),
2213 "Should not have double spaces in numbered list"
2214 );
2215
2216 assert!(
2218 fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2219 "Bullet list should be properly combined"
2220 );
2221 assert!(
2222 fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2223 "Numbered list should be properly combined"
2224 );
2225 }
2226
2227 #[test]
2228 fn test_normalize_mode_actual_numbered_list() {
2229 let config = MD013Config {
2231 line_length: 100,
2232 reflow: true,
2233 reflow_mode: ReflowMode::Normalize,
2234 ..Default::default()
2235 };
2236 let rule = MD013LineLength::from_config_struct(config);
2237
2238 let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2240
2241 let fixed = rule.fix(&ctx).unwrap();
2242 assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2243 assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2244 assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2245 assert!(
2246 fixed.starts_with("Paragraph before list with multiple lines."),
2247 "Paragraph should be normalized"
2248 );
2249 }
2250}