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