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::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 toml;
13
14pub mod md013_config;
15use md013_config::MD013Config;
16
17#[derive(Clone, Default)]
18pub struct MD013LineLength {
19 config: MD013Config,
20}
21
22impl MD013LineLength {
23 pub fn new(line_length: usize, code_blocks: bool, tables: bool, headings: bool, strict: bool) -> Self {
24 Self {
25 config: MD013Config {
26 line_length,
27 code_blocks,
28 tables,
29 headings,
30 strict,
31 reflow: false,
32 },
33 }
34 }
35
36 pub fn from_config_struct(config: MD013Config) -> Self {
37 Self { config }
38 }
39
40 fn should_ignore_line(
41 &self,
42 line: &str,
43 _lines: &[&str],
44 current_line: usize,
45 structure: &DocumentStructure,
46 ) -> bool {
47 if self.config.strict {
48 return false;
49 }
50
51 let trimmed = line.trim();
53
54 if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
56 return true;
57 }
58
59 if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
61 return true;
62 }
63
64 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
66 return true;
67 }
68
69 if structure.is_in_code_block(current_line + 1)
71 && !trimmed.is_empty()
72 && !line.contains(' ')
73 && !line.contains('\t')
74 {
75 return true;
76 }
77
78 false
79 }
80}
81
82impl Rule for MD013LineLength {
83 fn name(&self) -> &'static str {
84 "MD013"
85 }
86
87 fn description(&self) -> &'static str {
88 "Line length should not be excessive"
89 }
90
91 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
92 let content = ctx.content;
93
94 if content.is_empty() {
96 return Ok(Vec::new());
97 }
98
99 if content.len() <= self.config.line_length {
101 return Ok(Vec::new());
102 }
103
104 let has_long_lines = if !ctx.lines.is_empty() {
106 ctx.lines
107 .iter()
108 .any(|line| line.content.len() > self.config.line_length)
109 } else {
110 let mut max_line_len = 0;
112 let mut current_line_len = 0;
113 for ch in content.chars() {
114 if ch == '\n' {
115 max_line_len = max_line_len.max(current_line_len);
116 current_line_len = 0;
117 } else {
118 current_line_len += 1;
119 }
120 }
121 max_line_len = max_line_len.max(current_line_len);
122 max_line_len > self.config.line_length
123 };
124
125 if !has_long_lines {
126 return Ok(Vec::new());
127 }
128
129 let structure = DocumentStructure::new(content);
131 self.check_with_structure(ctx, &structure)
132 }
133
134 fn check_with_structure(
136 &self,
137 ctx: &crate::lint_context::LintContext,
138 structure: &DocumentStructure,
139 ) -> LintResult {
140 let content = ctx.content;
141 let mut warnings = Vec::new();
142
143 let inline_config = crate::inline_config::InlineConfig::from_content(content);
147 let config_override = inline_config.get_rule_config("MD013");
148
149 let effective_config = if let Some(json_config) = config_override {
151 if let Some(obj) = json_config.as_object() {
152 let mut config = self.config.clone();
153 if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
154 config.line_length = line_length as usize;
155 }
156 if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
157 config.code_blocks = code_blocks;
158 }
159 if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
160 config.tables = tables;
161 }
162 if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
163 config.headings = headings;
164 }
165 if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
166 config.strict = strict;
167 }
168 if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
169 config.reflow = reflow;
170 }
171 config
172 } else {
173 self.config.clone()
174 }
175 } else {
176 self.config.clone()
177 };
178
179 let lines: Vec<&str> = if !ctx.lines.is_empty() {
181 ctx.lines.iter().map(|l| l.content.as_str()).collect()
182 } else {
183 content.lines().collect()
184 };
185
186 let heading_lines_set: std::collections::HashSet<usize> = structure.heading_lines.iter().cloned().collect();
188
189 let table_blocks = TableUtils::find_table_blocks(content, ctx);
191
192 let table_lines_set: std::collections::HashSet<usize> = {
194 let mut table_lines = std::collections::HashSet::new();
195
196 for table in &table_blocks {
197 table_lines.insert(table.header_line + 1); table_lines.insert(table.delimiter_line + 1);
201 for &line in &table.content_lines {
203 table_lines.insert(line + 1); }
205 }
206 table_lines
207 };
208
209 for (line_num, line) in lines.iter().enumerate() {
210 let line_number = line_num + 1;
211
212 let effective_length = self.calculate_effective_length(line);
214
215 let line_limit = effective_config.line_length;
217
218 if effective_length <= line_limit {
220 continue;
221 }
222
223 if !effective_config.strict {
225 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
227 continue;
228 }
229
230 if (!effective_config.headings && heading_lines_set.contains(&line_number))
234 || (!effective_config.code_blocks && structure.is_in_code_block(line_number))
235 || (!effective_config.tables && table_lines_set.contains(&line_number))
236 || structure.is_in_blockquote(line_number)
237 || structure.is_in_html_block(line_number)
238 {
239 continue;
240 }
241
242 if self.should_ignore_line(line, &lines, line_num, structure) {
244 continue;
245 }
246 }
247
248 let fix = if self.config.reflow && !self.should_skip_line_for_fix(line, line_num, structure) {
250 Some(crate::rule::Fix {
253 range: 0..0, replacement: String::new(), })
256 } else {
257 None
258 };
259
260 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
261
262 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
264
265 warnings.push(LintWarning {
266 rule_name: Some(self.name()),
267 message,
268 line: start_line,
269 column: start_col,
270 end_line,
271 end_column: end_col,
272 severity: Severity::Warning,
273 fix,
274 });
275 }
276 Ok(warnings)
277 }
278
279 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
280 if self.config.reflow {
282 let reflow_options = crate::utils::text_reflow::ReflowOptions {
283 line_length: self.config.line_length,
284 break_on_sentences: true,
285 preserve_breaks: false,
286 };
287
288 return Ok(crate::utils::text_reflow::reflow_markdown(ctx.content, &reflow_options));
289 }
290
291 Ok(ctx.content.to_string())
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 should_skip_line_for_fix(&self, line: &str, line_num: usize, structure: &DocumentStructure) -> bool {
363 let line_number = line_num + 1; if structure.is_in_code_block(line_number) {
367 return true;
368 }
369
370 if structure.is_in_html_block(line_number) {
372 return true;
373 }
374
375 if TableUtils::is_potential_table_row(line) {
378 return true;
379 }
380
381 if line.trim().starts_with("http://") || line.trim().starts_with("https://") {
383 return true;
384 }
385
386 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
388 return true;
389 }
390
391 false
392 }
393
394 fn calculate_effective_length(&self, line: &str) -> usize {
396 if self.config.strict {
397 return line.chars().count();
399 }
400
401 if !line.contains("http") && !line.contains('[') {
403 return line.chars().count();
404 }
405
406 let mut effective_line = line.to_string();
407
408 if line.contains('[') && line.contains("](") {
411 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
412 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
413 && url.as_str().len() > 15
414 {
415 let replacement = format!("[{}](url)", text.as_str());
416 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
417 }
418 }
419 }
420
421 if effective_line.contains("http") {
424 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
425 let url = url_match.as_str();
426 if !effective_line.contains(&format!("({url})")) {
428 let placeholder = "x".repeat(15.min(url.len()));
431 effective_line = effective_line.replacen(url, &placeholder, 1);
432 }
433 }
434 }
435
436 effective_line.chars().count()
437 }
438}
439
440impl DocumentStructureExtensions for MD013LineLength {
441 fn has_relevant_elements(
442 &self,
443 ctx: &crate::lint_context::LintContext,
444 _doc_structure: &DocumentStructure,
445 ) -> bool {
446 !ctx.content.is_empty()
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use crate::lint_context::LintContext;
455
456 #[test]
457 fn test_default_config() {
458 let rule = MD013LineLength::default();
459 assert_eq!(rule.config.line_length, 80);
460 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
464 }
465
466 #[test]
467 fn test_custom_config() {
468 let rule = MD013LineLength::new(100, true, true, false, true);
469 assert_eq!(rule.config.line_length, 100);
470 assert!(rule.config.code_blocks);
471 assert!(rule.config.tables);
472 assert!(!rule.config.headings);
473 assert!(rule.config.strict);
474 }
475
476 #[test]
477 fn test_basic_line_length_violation() {
478 let rule = MD013LineLength::new(50, false, false, false, false);
479 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
481 let result = rule.check(&ctx).unwrap();
482
483 assert_eq!(result.len(), 1);
484 assert!(result[0].message.contains("Line length"));
485 assert!(result[0].message.contains("exceeds 50 characters"));
486 }
487
488 #[test]
489 fn test_no_violation_under_limit() {
490 let rule = MD013LineLength::new(100, false, false, false, false);
491 let content = "Short line.\nAnother short line.";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
493 let result = rule.check(&ctx).unwrap();
494
495 assert_eq!(result.len(), 0);
496 }
497
498 #[test]
499 fn test_multiple_violations() {
500 let rule = MD013LineLength::new(30, false, false, false, false);
501 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
503 let result = rule.check(&ctx).unwrap();
504
505 assert_eq!(result.len(), 2);
506 assert_eq!(result[0].line, 1);
507 assert_eq!(result[1].line, 2);
508 }
509
510 #[test]
511 fn test_code_blocks_exemption() {
512 let rule = MD013LineLength::new(30, false, false, false, false);
514 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
516 let result = rule.check(&ctx).unwrap();
517
518 assert_eq!(result.len(), 0);
519 }
520
521 #[test]
522 fn test_code_blocks_not_exempt_when_configured() {
523 let rule = MD013LineLength::new(30, true, false, false, false);
525 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
527 let result = rule.check(&ctx).unwrap();
528
529 assert!(!result.is_empty());
530 }
531
532 #[test]
533 fn test_heading_checked_when_enabled() {
534 let rule = MD013LineLength::new(30, false, false, true, false);
535 let content = "# This is a very long heading that would normally exceed the limit";
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
537 let result = rule.check(&ctx).unwrap();
538
539 assert_eq!(result.len(), 1);
540 }
541
542 #[test]
543 fn test_heading_exempt_when_disabled() {
544 let rule = MD013LineLength::new(30, false, false, false, false);
545 let content = "# This is a very long heading that should trigger a warning";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
547 let result = rule.check(&ctx).unwrap();
548
549 assert_eq!(result.len(), 0);
550 }
551
552 #[test]
553 fn test_table_checked_when_enabled() {
554 let rule = MD013LineLength::new(30, false, true, false, false);
555 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
557 let result = rule.check(&ctx).unwrap();
558
559 assert_eq!(result.len(), 2); }
561
562 #[test]
563 fn test_issue_78_tables_after_fenced_code_blocks() {
564 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
567
568```plain
569some code block longer than 20 chars length
570```
571
572this is a very long line
573
574| column A | column B |
575| -------- | -------- |
576| `var` | `val` |
577| value 1 | value 2 |
578
579correct length line"#;
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
581 let result = rule.check(&ctx).unwrap();
582
583 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
585 assert_eq!(result[0].line, 7, "Should flag line 7");
586 assert!(result[0].message.contains("24 exceeds 20"));
587 }
588
589 #[test]
590 fn test_issue_78_tables_with_inline_code() {
591 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
594| -------- | -------- |
595| `var with very long name` | `val exceeding limit` |
596| value 1 | value 2 |
597
598This line exceeds limit"#;
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
600 let result = rule.check(&ctx).unwrap();
601
602 assert_eq!(result.len(), 1, "Should only flag the non-table line");
604 assert_eq!(result[0].line, 6, "Should flag line 6");
605 }
606
607 #[test]
608 fn test_issue_78_indented_code_blocks() {
609 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
612
613 some code block longer than 20 chars length
614
615this is a very long line
616
617| column A | column B |
618| -------- | -------- |
619| value 1 | value 2 |
620
621correct length line"#;
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
623 let result = rule.check(&ctx).unwrap();
624
625 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
627 assert_eq!(result[0].line, 5, "Should flag line 5");
628 }
629
630 #[test]
631 fn test_url_exemption() {
632 let rule = MD013LineLength::new(30, false, false, false, false);
633 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
635 let result = rule.check(&ctx).unwrap();
636
637 assert_eq!(result.len(), 0);
638 }
639
640 #[test]
641 fn test_image_reference_exemption() {
642 let rule = MD013LineLength::new(30, false, false, false, false);
643 let content = "![This is a very long image alt text that exceeds limit][reference]";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
645 let result = rule.check(&ctx).unwrap();
646
647 assert_eq!(result.len(), 0);
648 }
649
650 #[test]
651 fn test_link_reference_exemption() {
652 let rule = MD013LineLength::new(30, false, false, false, false);
653 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
655 let result = rule.check(&ctx).unwrap();
656
657 assert_eq!(result.len(), 0);
658 }
659
660 #[test]
661 fn test_strict_mode() {
662 let rule = MD013LineLength::new(30, false, false, false, true);
663 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
665 let result = rule.check(&ctx).unwrap();
666
667 assert_eq!(result.len(), 1);
669 }
670
671 #[test]
672 fn test_blockquote_exemption() {
673 let rule = MD013LineLength::new(30, false, false, false, false);
674 let content = "> This is a very long line inside a blockquote that should be ignored.";
675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
676 let result = rule.check(&ctx).unwrap();
677
678 assert_eq!(result.len(), 0);
679 }
680
681 #[test]
682 fn test_setext_heading_underline_exemption() {
683 let rule = MD013LineLength::new(30, false, false, false, false);
684 let content = "Heading\n========================================";
685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
686 let result = rule.check(&ctx).unwrap();
687
688 assert_eq!(result.len(), 0);
690 }
691
692 #[test]
693 fn test_no_fix_without_reflow() {
694 let rule = MD013LineLength::new(60, false, false, false, false);
695 let content = "This line has trailing whitespace that makes it too long ";
696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
697 let result = rule.check(&ctx).unwrap();
698
699 assert_eq!(result.len(), 1);
700 assert!(result[0].fix.is_none());
702
703 let fixed = rule.fix(&ctx).unwrap();
705 assert_eq!(fixed, content);
706 }
707
708 #[test]
709 fn test_character_vs_byte_counting() {
710 let rule = MD013LineLength::new(10, false, false, false, false);
711 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
714 let result = rule.check(&ctx).unwrap();
715
716 assert_eq!(result.len(), 1);
717 assert_eq!(result[0].line, 1);
718 }
719
720 #[test]
721 fn test_empty_content() {
722 let rule = MD013LineLength::default();
723 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
724 let result = rule.check(&ctx).unwrap();
725
726 assert_eq!(result.len(), 0);
727 }
728
729 #[test]
730 fn test_excess_range_calculation() {
731 let rule = MD013LineLength::new(10, false, false, false, false);
732 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
734 let result = rule.check(&ctx).unwrap();
735
736 assert_eq!(result.len(), 1);
737 assert_eq!(result[0].column, 11);
739 assert_eq!(result[0].end_column, 21);
740 }
741
742 #[test]
743 fn test_html_block_exemption() {
744 let rule = MD013LineLength::new(30, false, false, false, false);
745 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
747 let result = rule.check(&ctx).unwrap();
748
749 assert_eq!(result.len(), 0);
751 }
752
753 #[test]
754 fn test_mixed_content() {
755 let rule = MD013LineLength::new(30, false, false, false, false);
757 let content = r#"# This heading is very long but should be exempt
758
759This regular paragraph line is too long and should trigger.
760
761```
762Code block line that is very long but exempt.
763```
764
765| Table | With very long content |
766|-------|------------------------|
767
768Another long line that should trigger a warning."#;
769
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
771 let result = rule.check(&ctx).unwrap();
772
773 assert_eq!(result.len(), 2);
775 assert_eq!(result[0].line, 3);
776 assert_eq!(result[1].line, 12);
777 }
778
779 #[test]
780 fn test_fix_without_reflow_preserves_content() {
781 let rule = MD013LineLength::new(50, false, false, false, false);
782 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
784
785 let fixed = rule.fix(&ctx).unwrap();
787 assert_eq!(fixed, content);
788 }
789
790 #[test]
791 fn test_has_relevant_elements() {
792 let rule = MD013LineLength::default();
793 let structure = DocumentStructure::new("test");
794
795 let ctx = LintContext::new("Some content", crate::config::MarkdownFlavor::Standard);
796 assert!(rule.has_relevant_elements(&ctx, &structure));
797
798 let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
799 assert!(!rule.has_relevant_elements(&empty_ctx, &structure));
800 }
801
802 #[test]
803 fn test_rule_metadata() {
804 let rule = MD013LineLength::default();
805 assert_eq!(rule.name(), "MD013");
806 assert_eq!(rule.description(), "Line length should not be excessive");
807 assert_eq!(rule.category(), RuleCategory::Whitespace);
808 }
809
810 #[test]
811 fn test_url_embedded_in_text() {
812 let rule = MD013LineLength::new(50, false, false, false, false);
813
814 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
817 let result = rule.check(&ctx).unwrap();
818
819 assert_eq!(result.len(), 0);
821 }
822
823 #[test]
824 fn test_multiple_urls_in_line() {
825 let rule = MD013LineLength::new(50, false, false, false, false);
826
827 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
830
831 let result = rule.check(&ctx).unwrap();
832
833 assert_eq!(result.len(), 0);
835 }
836
837 #[test]
838 fn test_markdown_link_with_long_url() {
839 let rule = MD013LineLength::new(50, false, false, false, false);
840
841 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
844 let result = rule.check(&ctx).unwrap();
845
846 assert_eq!(result.len(), 0);
848 }
849
850 #[test]
851 fn test_line_too_long_even_without_urls() {
852 let rule = MD013LineLength::new(50, false, false, false, false);
853
854 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
857 let result = rule.check(&ctx).unwrap();
858
859 assert_eq!(result.len(), 1);
861 }
862
863 #[test]
864 fn test_strict_mode_counts_urls() {
865 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";
869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
870 let result = rule.check(&ctx).unwrap();
871
872 assert_eq!(result.len(), 1);
874 }
875
876 #[test]
877 fn test_documentation_example_from_md051() {
878 let rule = MD013LineLength::new(80, false, false, false, false);
879
880 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
883 let result = rule.check(&ctx).unwrap();
884
885 assert_eq!(result.len(), 0);
887 }
888
889 #[test]
890 fn test_text_reflow_simple() {
891 let config = MD013Config {
892 line_length: 30,
893 reflow: true,
894 ..Default::default()
895 };
896 let rule = MD013LineLength::from_config_struct(config);
897
898 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
900
901 let fixed = rule.fix(&ctx).unwrap();
902
903 for line in fixed.lines() {
905 assert!(
906 line.chars().count() <= 30,
907 "Line too long: {} (len={})",
908 line,
909 line.chars().count()
910 );
911 }
912
913 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
915 let original_words: Vec<&str> = content.split_whitespace().collect();
916 assert_eq!(fixed_words, original_words);
917 }
918
919 #[test]
920 fn test_text_reflow_preserves_markdown_elements() {
921 let config = MD013Config {
922 line_length: 40,
923 reflow: true,
924 ..Default::default()
925 };
926 let rule = MD013LineLength::from_config_struct(config);
927
928 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
930
931 let fixed = rule.fix(&ctx).unwrap();
932
933 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
935 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
936 assert!(
937 fixed.contains("[a link](https://example.com)"),
938 "Link not preserved in: {fixed}"
939 );
940
941 for line in fixed.lines() {
943 assert!(line.len() <= 40, "Line too long: {line}");
944 }
945 }
946
947 #[test]
948 fn test_text_reflow_preserves_code_blocks() {
949 let config = MD013Config {
950 line_length: 30,
951 reflow: true,
952 ..Default::default()
953 };
954 let rule = MD013LineLength::from_config_struct(config);
955
956 let content = r#"Here is some text.
957
958```python
959def very_long_function_name_that_exceeds_limit():
960 return "This should not be wrapped"
961```
962
963More text after code block."#;
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
965
966 let fixed = rule.fix(&ctx).unwrap();
967
968 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
970 assert!(fixed.contains("```python"));
971 assert!(fixed.contains("```"));
972 }
973
974 #[test]
975 fn test_text_reflow_preserves_lists() {
976 let config = MD013Config {
977 line_length: 30,
978 reflow: true,
979 ..Default::default()
980 };
981 let rule = MD013LineLength::from_config_struct(config);
982
983 let content = r#"Here is a list:
984
9851. First item with a very long line that needs wrapping
9862. Second item is short
9873. Third item also has a long line that exceeds the limit
988
989And a bullet list:
990
991- Bullet item with very long content that needs wrapping
992- Short bullet"#;
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
994
995 let fixed = rule.fix(&ctx).unwrap();
996
997 assert!(fixed.contains("1. "));
999 assert!(fixed.contains("2. "));
1000 assert!(fixed.contains("3. "));
1001 assert!(fixed.contains("- "));
1002
1003 let lines: Vec<&str> = fixed.lines().collect();
1005 for (i, line) in lines.iter().enumerate() {
1006 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1007 if i + 1 < lines.len()
1009 && !lines[i + 1].trim().is_empty()
1010 && !lines[i + 1].trim().starts_with(char::is_numeric)
1011 && !lines[i + 1].trim().starts_with("-")
1012 {
1013 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1015 }
1016 } else if line.trim().starts_with("-") {
1017 if i + 1 < lines.len()
1019 && !lines[i + 1].trim().is_empty()
1020 && !lines[i + 1].trim().starts_with(char::is_numeric)
1021 && !lines[i + 1].trim().starts_with("-")
1022 {
1023 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1025 }
1026 }
1027 }
1028 }
1029
1030 #[test]
1031 fn test_issue_83_numbered_list_with_backticks() {
1032 let config = MD013Config {
1034 line_length: 100,
1035 reflow: true,
1036 ..Default::default()
1037 };
1038 let rule = MD013LineLength::from_config_struct(config);
1039
1040 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
1042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1043
1044 let fixed = rule.fix(&ctx).unwrap();
1045
1046 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
1049
1050 assert_eq!(
1051 fixed, expected,
1052 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_text_reflow_disabled_by_default() {
1058 let rule = MD013LineLength::new(30, false, false, false, false);
1059
1060 let content = "This is a very long line that definitely exceeds thirty characters.";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1062
1063 let fixed = rule.fix(&ctx).unwrap();
1064
1065 assert_eq!(fixed, content);
1068 }
1069
1070 #[test]
1071 fn test_reflow_with_hard_line_breaks() {
1072 let config = MD013Config {
1074 line_length: 40,
1075 reflow: true,
1076 ..Default::default()
1077 };
1078 let rule = MD013LineLength::from_config_struct(config);
1079
1080 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";
1082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1083 let fixed = rule.fix(&ctx).unwrap();
1084
1085 assert!(
1087 fixed.contains(" \n"),
1088 "Hard line break with exactly 2 spaces should be preserved"
1089 );
1090 }
1091
1092 #[test]
1093 fn test_reflow_preserves_reference_links() {
1094 let config = MD013Config {
1095 line_length: 40,
1096 reflow: true,
1097 ..Default::default()
1098 };
1099 let rule = MD013LineLength::from_config_struct(config);
1100
1101 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
1102
1103[ref]: https://example.com";
1104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1105 let fixed = rule.fix(&ctx).unwrap();
1106
1107 assert!(fixed.contains("[reference link][ref]"));
1109 assert!(!fixed.contains("[ reference link]"));
1110 assert!(!fixed.contains("[ref ]"));
1111 }
1112
1113 #[test]
1114 fn test_reflow_with_nested_markdown_elements() {
1115 let config = MD013Config {
1116 line_length: 35,
1117 reflow: true,
1118 ..Default::default()
1119 };
1120 let rule = MD013LineLength::from_config_struct(config);
1121
1122 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
1123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1124 let fixed = rule.fix(&ctx).unwrap();
1125
1126 assert!(fixed.contains("**bold with `code` inside**"));
1128 }
1129
1130 #[test]
1131 fn test_reflow_with_unbalanced_markdown() {
1132 let config = MD013Config {
1134 line_length: 30,
1135 reflow: true,
1136 ..Default::default()
1137 };
1138 let rule = MD013LineLength::from_config_struct(config);
1139
1140 let content = "This has **unbalanced bold that goes on for a very long time without closing";
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1142 let fixed = rule.fix(&ctx).unwrap();
1143
1144 assert!(!fixed.is_empty());
1148 for line in fixed.lines() {
1150 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
1151 }
1152 }
1153
1154 #[test]
1155 fn test_reflow_fix_indicator() {
1156 let config = MD013Config {
1158 line_length: 30,
1159 reflow: true,
1160 ..Default::default()
1161 };
1162 let rule = MD013LineLength::from_config_struct(config);
1163
1164 let content = "This is a very long line that definitely exceeds the thirty character limit";
1165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1166 let warnings = rule.check(&ctx).unwrap();
1167
1168 assert!(!warnings.is_empty());
1170 assert!(
1171 warnings[0].fix.is_some(),
1172 "Should provide fix indicator when reflow is true"
1173 );
1174 }
1175
1176 #[test]
1177 fn test_no_fix_indicator_without_reflow() {
1178 let config = MD013Config {
1180 line_length: 30,
1181 reflow: false,
1182 ..Default::default()
1183 };
1184 let rule = MD013LineLength::from_config_struct(config);
1185
1186 let content = "This is a very long line that definitely exceeds the thirty character limit";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1188 let warnings = rule.check(&ctx).unwrap();
1189
1190 assert!(!warnings.is_empty());
1192 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
1193 }
1194
1195 #[test]
1196 fn test_reflow_preserves_all_reference_link_types() {
1197 let config = MD013Config {
1198 line_length: 40,
1199 reflow: true,
1200 ..Default::default()
1201 };
1202 let rule = MD013LineLength::from_config_struct(config);
1203
1204 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
1205
1206[ref]: https://example.com
1207[collapsed]: https://example.com
1208[shortcut]: https://example.com";
1209
1210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1211 let fixed = rule.fix(&ctx).unwrap();
1212
1213 assert!(fixed.contains("[full reference][ref]"));
1215 assert!(fixed.contains("[collapsed][]"));
1216 assert!(fixed.contains("[shortcut]"));
1217 }
1218
1219 #[test]
1220 fn test_reflow_handles_images_correctly() {
1221 let config = MD013Config {
1222 line_length: 40,
1223 reflow: true,
1224 ..Default::default()
1225 };
1226 let rule = MD013LineLength::from_config_struct(config);
1227
1228 let content = "This line has an  that should not be broken when reflowing.";
1229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1230 let fixed = rule.fix(&ctx).unwrap();
1231
1232 assert!(fixed.contains(""));
1234 }
1235}