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 toml;
12
13pub mod md013_config;
14use md013_config::MD013Config;
15
16#[derive(Clone, Default)]
17pub struct MD013LineLength {
18 config: MD013Config,
19}
20
21impl MD013LineLength {
22 pub fn new(line_length: usize, code_blocks: bool, tables: bool, headings: bool, strict: bool) -> Self {
23 Self {
24 config: MD013Config {
25 line_length,
26 code_blocks,
27 tables,
28 headings,
29 strict,
30 reflow: false,
31 },
32 }
33 }
34
35 pub fn from_config_struct(config: MD013Config) -> Self {
36 Self { config }
37 }
38
39 fn is_in_table(lines: &[&str], current_line: usize) -> bool {
40 let current = lines[current_line].trim();
42 if current.starts_with('|') || current.starts_with("|-") {
43 return true;
44 }
45
46 if current_line > 0 && current_line + 1 < lines.len() {
48 let prev = lines[current_line - 1].trim();
49 let next = lines[current_line + 1].trim();
50 if (prev.starts_with('|') || prev.starts_with("|-")) && (next.starts_with('|') || next.starts_with("|-")) {
51 return true;
52 }
53 }
54 false
55 }
56
57 fn should_ignore_line(
58 &self,
59 line: &str,
60 _lines: &[&str],
61 current_line: usize,
62 structure: &DocumentStructure,
63 ) -> bool {
64 if self.config.strict {
65 return false;
66 }
67
68 let trimmed = line.trim();
70
71 if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
73 return true;
74 }
75
76 if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
78 return true;
79 }
80
81 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
83 return true;
84 }
85
86 if structure.is_in_code_block(current_line + 1)
88 && !trimmed.is_empty()
89 && !line.contains(' ')
90 && !line.contains('\t')
91 {
92 return true;
93 }
94
95 false
96 }
97}
98
99impl Rule for MD013LineLength {
100 fn name(&self) -> &'static str {
101 "MD013"
102 }
103
104 fn description(&self) -> &'static str {
105 "Line length should not be excessive"
106 }
107
108 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
109 let content = ctx.content;
110
111 if content.is_empty() {
113 return Ok(Vec::new());
114 }
115
116 if content.len() <= self.config.line_length {
118 return Ok(Vec::new());
119 }
120
121 let has_long_lines = if !ctx.lines.is_empty() {
123 ctx.lines
124 .iter()
125 .any(|line| line.content.len() > self.config.line_length)
126 } else {
127 let mut max_line_len = 0;
129 let mut current_line_len = 0;
130 for ch in content.chars() {
131 if ch == '\n' {
132 max_line_len = max_line_len.max(current_line_len);
133 current_line_len = 0;
134 } else {
135 current_line_len += 1;
136 }
137 }
138 max_line_len = max_line_len.max(current_line_len);
139 max_line_len > self.config.line_length
140 };
141
142 if !has_long_lines {
143 return Ok(Vec::new());
144 }
145
146 let structure = DocumentStructure::new(content);
148 self.check_with_structure(ctx, &structure)
149 }
150
151 fn check_with_structure(
153 &self,
154 ctx: &crate::lint_context::LintContext,
155 structure: &DocumentStructure,
156 ) -> LintResult {
157 let content = ctx.content;
158 let mut warnings = Vec::new();
159
160 let inline_config = crate::inline_config::InlineConfig::from_content(content);
164 let config_override = inline_config.get_rule_config("MD013");
165
166 let effective_config = if let Some(json_config) = config_override {
168 if let Some(obj) = json_config.as_object() {
169 let mut config = self.config.clone();
170 if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
171 config.line_length = line_length as usize;
172 }
173 if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
174 config.code_blocks = code_blocks;
175 }
176 if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
177 config.tables = tables;
178 }
179 if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
180 config.headings = headings;
181 }
182 if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
183 config.strict = strict;
184 }
185 if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
186 config.reflow = reflow;
187 }
188 config
189 } else {
190 self.config.clone()
191 }
192 } else {
193 self.config.clone()
194 };
195
196 let lines: Vec<&str> = if !ctx.lines.is_empty() {
198 ctx.lines.iter().map(|l| l.content.as_str()).collect()
199 } else {
200 content.lines().collect()
201 };
202
203 let heading_lines_set: std::collections::HashSet<usize> = structure.heading_lines.iter().cloned().collect();
205
206 let table_lines_set: std::collections::HashSet<usize> = {
208 let mut table_lines = std::collections::HashSet::new();
209
210 for (i, _line) in lines.iter().enumerate() {
211 let line_number = i + 1;
212
213 let in_code = if !ctx.code_blocks.is_empty() {
215 ctx.code_blocks
216 .iter()
217 .any(|(start, end)| *start <= line_number && line_number <= *end)
218 } else {
219 structure.is_in_code_block(line_number)
220 };
221
222 if !in_code && Self::is_in_table(&lines, i) {
223 table_lines.insert(line_number);
224 }
225 }
226 table_lines
227 };
228
229 for (line_num, line) in lines.iter().enumerate() {
230 let line_number = line_num + 1;
231
232 let effective_length = self.calculate_effective_length(line);
234
235 let line_limit = effective_config.line_length;
237
238 if effective_length <= line_limit {
240 continue;
241 }
242
243 if !effective_config.strict {
245 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
247 continue;
248 }
249
250 if (!effective_config.headings && heading_lines_set.contains(&line_number))
254 || (!effective_config.code_blocks && structure.is_in_code_block(line_number))
255 || (!effective_config.tables && table_lines_set.contains(&line_number))
256 || structure.is_in_blockquote(line_number)
257 || structure.is_in_html_block(line_number)
258 {
259 continue;
260 }
261
262 if self.should_ignore_line(line, &lines, line_num, structure) {
264 continue;
265 }
266 }
267
268 let fix = if self.config.reflow && !self.should_skip_line_for_fix(line, line_num, structure) {
270 Some(crate::rule::Fix {
273 range: 0..0, replacement: String::new(), })
276 } else {
277 None
278 };
279
280 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
281
282 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
284
285 warnings.push(LintWarning {
286 rule_name: Some(self.name()),
287 message,
288 line: start_line,
289 column: start_col,
290 end_line,
291 end_column: end_col,
292 severity: Severity::Warning,
293 fix,
294 });
295 }
296 Ok(warnings)
297 }
298
299 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
300 if self.config.reflow {
302 let reflow_options = crate::utils::text_reflow::ReflowOptions {
303 line_length: self.config.line_length,
304 break_on_sentences: true,
305 preserve_breaks: false,
306 };
307
308 return Ok(crate::utils::text_reflow::reflow_markdown(ctx.content, &reflow_options));
309 }
310
311 Ok(ctx.content.to_string())
313 }
314
315 fn as_any(&self) -> &dyn std::any::Any {
316 self
317 }
318
319 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
320 Some(self)
321 }
322
323 fn category(&self) -> RuleCategory {
324 RuleCategory::Whitespace
325 }
326
327 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
328 if ctx.content.is_empty() {
330 return true;
331 }
332
333 if ctx.content.len() <= self.config.line_length {
335 return true;
336 }
337
338 !ctx.lines
340 .iter()
341 .any(|line| line.content.len() > self.config.line_length)
342 }
343
344 fn default_config_section(&self) -> Option<(String, toml::Value)> {
345 let default_config = MD013Config::default();
346 let json_value = serde_json::to_value(&default_config).ok()?;
347 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
348
349 if let toml::Value::Table(table) = toml_value {
350 if !table.is_empty() {
351 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
352 } else {
353 None
354 }
355 } else {
356 None
357 }
358 }
359
360 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
361 let mut aliases = std::collections::HashMap::new();
362 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
363 Some(aliases)
364 }
365
366 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
367 where
368 Self: Sized,
369 {
370 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
371 if rule_config.line_length == 80 {
373 rule_config.line_length = config.global.line_length as usize;
375 }
376 Box::new(Self::from_config_struct(rule_config))
377 }
378}
379
380impl MD013LineLength {
381 fn should_skip_line_for_fix(&self, line: &str, line_num: usize, structure: &DocumentStructure) -> bool {
383 let line_number = line_num + 1; if structure.is_in_code_block(line_number) {
387 return true;
388 }
389
390 if structure.is_in_html_block(line_number) {
392 return true;
393 }
394
395 if Self::is_in_table(&[line], 0) {
397 return true;
398 }
399
400 if line.trim().starts_with("http://") || line.trim().starts_with("https://") {
402 return true;
403 }
404
405 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
407 return true;
408 }
409
410 false
411 }
412
413 fn calculate_effective_length(&self, line: &str) -> usize {
415 if self.config.strict {
416 return line.chars().count();
418 }
419
420 if !line.contains("http") && !line.contains('[') {
422 return line.chars().count();
423 }
424
425 let mut effective_line = line.to_string();
426
427 if line.contains('[') && line.contains("](") {
430 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
431 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
432 && url.as_str().len() > 15
433 {
434 let replacement = format!("[{}](url)", text.as_str());
435 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
436 }
437 }
438 }
439
440 if effective_line.contains("http") {
443 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
444 let url = url_match.as_str();
445 if !effective_line.contains(&format!("({url})")) {
447 let placeholder = "x".repeat(15.min(url.len()));
450 effective_line = effective_line.replacen(url, &placeholder, 1);
451 }
452 }
453 }
454
455 effective_line.chars().count()
456 }
457}
458
459impl DocumentStructureExtensions for MD013LineLength {
460 fn has_relevant_elements(
461 &self,
462 ctx: &crate::lint_context::LintContext,
463 _doc_structure: &DocumentStructure,
464 ) -> bool {
465 !ctx.content.is_empty()
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::lint_context::LintContext;
474
475 #[test]
476 fn test_default_config() {
477 let rule = MD013LineLength::default();
478 assert_eq!(rule.config.line_length, 80);
479 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
483 }
484
485 #[test]
486 fn test_custom_config() {
487 let rule = MD013LineLength::new(100, true, true, false, true);
488 assert_eq!(rule.config.line_length, 100);
489 assert!(rule.config.code_blocks);
490 assert!(rule.config.tables);
491 assert!(!rule.config.headings);
492 assert!(rule.config.strict);
493 }
494
495 #[test]
496 fn test_basic_line_length_violation() {
497 let rule = MD013LineLength::new(50, false, false, false, false);
498 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
500 let result = rule.check(&ctx).unwrap();
501
502 assert_eq!(result.len(), 1);
503 assert!(result[0].message.contains("Line length"));
504 assert!(result[0].message.contains("exceeds 50 characters"));
505 }
506
507 #[test]
508 fn test_no_violation_under_limit() {
509 let rule = MD013LineLength::new(100, false, false, false, false);
510 let content = "Short line.\nAnother short line.";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512 let result = rule.check(&ctx).unwrap();
513
514 assert_eq!(result.len(), 0);
515 }
516
517 #[test]
518 fn test_multiple_violations() {
519 let rule = MD013LineLength::new(30, false, false, false, false);
520 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
522 let result = rule.check(&ctx).unwrap();
523
524 assert_eq!(result.len(), 2);
525 assert_eq!(result[0].line, 1);
526 assert_eq!(result[1].line, 2);
527 }
528
529 #[test]
530 fn test_code_blocks_exemption() {
531 let rule = MD013LineLength::new(30, false, false, false, false);
533 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
535 let result = rule.check(&ctx).unwrap();
536
537 assert_eq!(result.len(), 0);
538 }
539
540 #[test]
541 fn test_code_blocks_not_exempt_when_configured() {
542 let rule = MD013LineLength::new(30, true, false, false, false);
544 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
546 let result = rule.check(&ctx).unwrap();
547
548 assert!(!result.is_empty());
549 }
550
551 #[test]
552 fn test_heading_checked_when_enabled() {
553 let rule = MD013LineLength::new(30, false, false, true, false);
554 let content = "# This is a very long heading that would normally exceed the limit";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
556 let result = rule.check(&ctx).unwrap();
557
558 assert_eq!(result.len(), 1);
559 }
560
561 #[test]
562 fn test_heading_exempt_when_disabled() {
563 let rule = MD013LineLength::new(30, false, false, false, false);
564 let content = "# This is a very long heading that should trigger a warning";
565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
566 let result = rule.check(&ctx).unwrap();
567
568 assert_eq!(result.len(), 0);
569 }
570
571 #[test]
572 fn test_table_detection() {
573 let lines = vec![
574 "| Column 1 | Column 2 |",
575 "|----------|----------|",
576 "| Value 1 | Value 2 |",
577 ];
578
579 assert!(MD013LineLength::is_in_table(&lines, 0));
580 assert!(MD013LineLength::is_in_table(&lines, 1));
581 assert!(MD013LineLength::is_in_table(&lines, 2));
582 }
583
584 #[test]
585 fn test_table_checked_when_enabled() {
586 let rule = MD013LineLength::new(30, false, true, false, false);
587 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
589 let result = rule.check(&ctx).unwrap();
590
591 assert_eq!(result.len(), 2); }
593
594 #[test]
595 fn test_url_exemption() {
596 let rule = MD013LineLength::new(30, false, false, false, false);
597 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
599 let result = rule.check(&ctx).unwrap();
600
601 assert_eq!(result.len(), 0);
602 }
603
604 #[test]
605 fn test_image_reference_exemption() {
606 let rule = MD013LineLength::new(30, false, false, false, false);
607 let content = "![This is a very long image alt text that exceeds limit][reference]";
608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
609 let result = rule.check(&ctx).unwrap();
610
611 assert_eq!(result.len(), 0);
612 }
613
614 #[test]
615 fn test_link_reference_exemption() {
616 let rule = MD013LineLength::new(30, false, false, false, false);
617 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
619 let result = rule.check(&ctx).unwrap();
620
621 assert_eq!(result.len(), 0);
622 }
623
624 #[test]
625 fn test_strict_mode() {
626 let rule = MD013LineLength::new(30, false, false, false, true);
627 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
629 let result = rule.check(&ctx).unwrap();
630
631 assert_eq!(result.len(), 1);
633 }
634
635 #[test]
636 fn test_blockquote_exemption() {
637 let rule = MD013LineLength::new(30, false, false, false, false);
638 let content = "> This is a very long line inside a blockquote that should be ignored.";
639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
640 let result = rule.check(&ctx).unwrap();
641
642 assert_eq!(result.len(), 0);
643 }
644
645 #[test]
646 fn test_setext_heading_underline_exemption() {
647 let rule = MD013LineLength::new(30, false, false, false, false);
648 let content = "Heading\n========================================";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
650 let result = rule.check(&ctx).unwrap();
651
652 assert_eq!(result.len(), 0);
654 }
655
656 #[test]
657 fn test_no_fix_without_reflow() {
658 let rule = MD013LineLength::new(60, false, false, false, false);
659 let content = "This line has trailing whitespace that makes it too long ";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
661 let result = rule.check(&ctx).unwrap();
662
663 assert_eq!(result.len(), 1);
664 assert!(result[0].fix.is_none());
666
667 let fixed = rule.fix(&ctx).unwrap();
669 assert_eq!(fixed, content);
670 }
671
672 #[test]
673 fn test_character_vs_byte_counting() {
674 let rule = MD013LineLength::new(10, false, false, false, false);
675 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
678 let result = rule.check(&ctx).unwrap();
679
680 assert_eq!(result.len(), 1);
681 assert_eq!(result[0].line, 1);
682 }
683
684 #[test]
685 fn test_empty_content() {
686 let rule = MD013LineLength::default();
687 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
688 let result = rule.check(&ctx).unwrap();
689
690 assert_eq!(result.len(), 0);
691 }
692
693 #[test]
694 fn test_excess_range_calculation() {
695 let rule = MD013LineLength::new(10, false, false, false, false);
696 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
698 let result = rule.check(&ctx).unwrap();
699
700 assert_eq!(result.len(), 1);
701 assert_eq!(result[0].column, 11);
703 assert_eq!(result[0].end_column, 21);
704 }
705
706 #[test]
707 fn test_html_block_exemption() {
708 let rule = MD013LineLength::new(30, false, false, false, false);
709 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
711 let result = rule.check(&ctx).unwrap();
712
713 assert_eq!(result.len(), 0);
715 }
716
717 #[test]
718 fn test_mixed_content() {
719 let rule = MD013LineLength::new(30, false, false, false, false);
721 let content = r#"# This heading is very long but should be exempt
722
723This regular paragraph line is too long and should trigger.
724
725```
726Code block line that is very long but exempt.
727```
728
729| Table | With very long content |
730|-------|------------------------|
731
732Another long line that should trigger a warning."#;
733
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
735 let result = rule.check(&ctx).unwrap();
736
737 assert_eq!(result.len(), 2);
739 assert_eq!(result[0].line, 3);
740 assert_eq!(result[1].line, 12);
741 }
742
743 #[test]
744 fn test_fix_without_reflow_preserves_content() {
745 let rule = MD013LineLength::new(50, false, false, false, false);
746 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
748
749 let fixed = rule.fix(&ctx).unwrap();
751 assert_eq!(fixed, content);
752 }
753
754 #[test]
755 fn test_has_relevant_elements() {
756 let rule = MD013LineLength::default();
757 let structure = DocumentStructure::new("test");
758
759 let ctx = LintContext::new("Some content", crate::config::MarkdownFlavor::Standard);
760 assert!(rule.has_relevant_elements(&ctx, &structure));
761
762 let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
763 assert!(!rule.has_relevant_elements(&empty_ctx, &structure));
764 }
765
766 #[test]
767 fn test_rule_metadata() {
768 let rule = MD013LineLength::default();
769 assert_eq!(rule.name(), "MD013");
770 assert_eq!(rule.description(), "Line length should not be excessive");
771 assert_eq!(rule.category(), RuleCategory::Whitespace);
772 }
773
774 #[test]
775 fn test_url_embedded_in_text() {
776 let rule = MD013LineLength::new(50, false, false, false, false);
777
778 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
781 let result = rule.check(&ctx).unwrap();
782
783 assert_eq!(result.len(), 0);
785 }
786
787 #[test]
788 fn test_multiple_urls_in_line() {
789 let rule = MD013LineLength::new(50, false, false, false, false);
790
791 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
794
795 let result = rule.check(&ctx).unwrap();
796
797 assert_eq!(result.len(), 0);
799 }
800
801 #[test]
802 fn test_markdown_link_with_long_url() {
803 let rule = MD013LineLength::new(50, false, false, false, false);
804
805 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
808 let result = rule.check(&ctx).unwrap();
809
810 assert_eq!(result.len(), 0);
812 }
813
814 #[test]
815 fn test_line_too_long_even_without_urls() {
816 let rule = MD013LineLength::new(50, false, false, false, false);
817
818 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
821 let result = rule.check(&ctx).unwrap();
822
823 assert_eq!(result.len(), 1);
825 }
826
827 #[test]
828 fn test_strict_mode_counts_urls() {
829 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";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
834 let result = rule.check(&ctx).unwrap();
835
836 assert_eq!(result.len(), 1);
838 }
839
840 #[test]
841 fn test_documentation_example_from_md051() {
842 let rule = MD013LineLength::new(80, false, false, false, false);
843
844 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
847 let result = rule.check(&ctx).unwrap();
848
849 assert_eq!(result.len(), 0);
851 }
852
853 #[test]
854 fn test_text_reflow_simple() {
855 let config = MD013Config {
856 line_length: 30,
857 reflow: true,
858 ..Default::default()
859 };
860 let rule = MD013LineLength::from_config_struct(config);
861
862 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
864
865 let fixed = rule.fix(&ctx).unwrap();
866
867 for line in fixed.lines() {
869 assert!(
870 line.chars().count() <= 30,
871 "Line too long: {} (len={})",
872 line,
873 line.chars().count()
874 );
875 }
876
877 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
879 let original_words: Vec<&str> = content.split_whitespace().collect();
880 assert_eq!(fixed_words, original_words);
881 }
882
883 #[test]
884 fn test_text_reflow_preserves_markdown_elements() {
885 let config = MD013Config {
886 line_length: 40,
887 reflow: true,
888 ..Default::default()
889 };
890 let rule = MD013LineLength::from_config_struct(config);
891
892 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
894
895 let fixed = rule.fix(&ctx).unwrap();
896
897 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
899 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
900 assert!(
901 fixed.contains("[a link](https://example.com)"),
902 "Link not preserved in: {fixed}"
903 );
904
905 for line in fixed.lines() {
907 assert!(line.len() <= 40, "Line too long: {line}");
908 }
909 }
910
911 #[test]
912 fn test_text_reflow_preserves_code_blocks() {
913 let config = MD013Config {
914 line_length: 30,
915 reflow: true,
916 ..Default::default()
917 };
918 let rule = MD013LineLength::from_config_struct(config);
919
920 let content = r#"Here is some text.
921
922```python
923def very_long_function_name_that_exceeds_limit():
924 return "This should not be wrapped"
925```
926
927More text after code block."#;
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
929
930 let fixed = rule.fix(&ctx).unwrap();
931
932 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
934 assert!(fixed.contains("```python"));
935 assert!(fixed.contains("```"));
936 }
937
938 #[test]
939 fn test_text_reflow_preserves_lists() {
940 let config = MD013Config {
941 line_length: 30,
942 reflow: true,
943 ..Default::default()
944 };
945 let rule = MD013LineLength::from_config_struct(config);
946
947 let content = r#"Here is a list:
948
9491. First item with a very long line that needs wrapping
9502. Second item is short
9513. Third item also has a long line that exceeds the limit
952
953And a bullet list:
954
955- Bullet item with very long content that needs wrapping
956- Short bullet"#;
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
958
959 let fixed = rule.fix(&ctx).unwrap();
960
961 assert!(fixed.contains("1. "));
963 assert!(fixed.contains("2. "));
964 assert!(fixed.contains("3. "));
965 assert!(fixed.contains("- "));
966
967 let lines: Vec<&str> = fixed.lines().collect();
969 for (i, line) in lines.iter().enumerate() {
970 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
971 if i + 1 < lines.len()
973 && !lines[i + 1].trim().is_empty()
974 && !lines[i + 1].trim().starts_with(char::is_numeric)
975 && !lines[i + 1].trim().starts_with("-")
976 {
977 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
979 }
980 } else if line.trim().starts_with("-") {
981 if i + 1 < lines.len()
983 && !lines[i + 1].trim().is_empty()
984 && !lines[i + 1].trim().starts_with(char::is_numeric)
985 && !lines[i + 1].trim().starts_with("-")
986 {
987 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
989 }
990 }
991 }
992 }
993
994 #[test]
995 fn test_text_reflow_disabled_by_default() {
996 let rule = MD013LineLength::new(30, false, false, false, false);
997
998 let content = "This is a very long line that definitely exceeds thirty characters.";
999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1000
1001 let fixed = rule.fix(&ctx).unwrap();
1002
1003 assert_eq!(fixed, content);
1006 }
1007
1008 #[test]
1009 fn test_reflow_with_hard_line_breaks() {
1010 let config = MD013Config {
1012 line_length: 40,
1013 reflow: true,
1014 ..Default::default()
1015 };
1016 let rule = MD013LineLength::from_config_struct(config);
1017
1018 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";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1021 let fixed = rule.fix(&ctx).unwrap();
1022
1023 assert!(
1025 fixed.contains(" \n"),
1026 "Hard line break with exactly 2 spaces should be preserved"
1027 );
1028 }
1029
1030 #[test]
1031 fn test_reflow_preserves_reference_links() {
1032 let config = MD013Config {
1033 line_length: 40,
1034 reflow: true,
1035 ..Default::default()
1036 };
1037 let rule = MD013LineLength::from_config_struct(config);
1038
1039 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
1040
1041[ref]: https://example.com";
1042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1043 let fixed = rule.fix(&ctx).unwrap();
1044
1045 assert!(fixed.contains("[reference link][ref]"));
1047 assert!(!fixed.contains("[ reference link]"));
1048 assert!(!fixed.contains("[ref ]"));
1049 }
1050
1051 #[test]
1052 fn test_reflow_with_nested_markdown_elements() {
1053 let config = MD013Config {
1054 line_length: 35,
1055 reflow: true,
1056 ..Default::default()
1057 };
1058 let rule = MD013LineLength::from_config_struct(config);
1059
1060 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1062 let fixed = rule.fix(&ctx).unwrap();
1063
1064 assert!(fixed.contains("**bold with `code` inside**"));
1066 }
1067
1068 #[test]
1069 fn test_reflow_with_unbalanced_markdown() {
1070 let config = MD013Config {
1072 line_length: 30,
1073 reflow: true,
1074 ..Default::default()
1075 };
1076 let rule = MD013LineLength::from_config_struct(config);
1077
1078 let content = "This has **unbalanced bold that goes on for a very long time without closing";
1079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1080 let fixed = rule.fix(&ctx).unwrap();
1081
1082 assert!(!fixed.is_empty());
1086 for line in fixed.lines() {
1088 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
1089 }
1090 }
1091
1092 #[test]
1093 fn test_reflow_fix_indicator() {
1094 let config = MD013Config {
1096 line_length: 30,
1097 reflow: true,
1098 ..Default::default()
1099 };
1100 let rule = MD013LineLength::from_config_struct(config);
1101
1102 let content = "This is a very long line that definitely exceeds the thirty character limit";
1103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1104 let warnings = rule.check(&ctx).unwrap();
1105
1106 assert!(!warnings.is_empty());
1108 assert!(
1109 warnings[0].fix.is_some(),
1110 "Should provide fix indicator when reflow is true"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_no_fix_indicator_without_reflow() {
1116 let config = MD013Config {
1118 line_length: 30,
1119 reflow: false,
1120 ..Default::default()
1121 };
1122 let rule = MD013LineLength::from_config_struct(config);
1123
1124 let content = "This is a very long line that definitely exceeds the thirty character limit";
1125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1126 let warnings = rule.check(&ctx).unwrap();
1127
1128 assert!(!warnings.is_empty());
1130 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
1131 }
1132
1133 #[test]
1134 fn test_reflow_preserves_all_reference_link_types() {
1135 let config = MD013Config {
1136 line_length: 40,
1137 reflow: true,
1138 ..Default::default()
1139 };
1140 let rule = MD013LineLength::from_config_struct(config);
1141
1142 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
1143
1144[ref]: https://example.com
1145[collapsed]: https://example.com
1146[shortcut]: https://example.com";
1147
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1149 let fixed = rule.fix(&ctx).unwrap();
1150
1151 assert!(fixed.contains("[full reference][ref]"));
1153 assert!(fixed.contains("[collapsed][]"));
1154 assert!(fixed.contains("[shortcut]"));
1155 }
1156
1157 #[test]
1158 fn test_reflow_handles_images_correctly() {
1159 let config = MD013Config {
1160 line_length: 40,
1161 reflow: true,
1162 ..Default::default()
1163 };
1164 let rule = MD013LineLength::from_config_struct(config);
1165
1166 let content = "This line has an  that should not be broken when reflowing.";
1167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1168 let fixed = rule.fix(&ctx).unwrap();
1169
1170 assert!(fixed.contains(""));
1172 }
1173}