1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::range_utils::LineIndex;
7use crate::utils::range_utils::calculate_excess_range;
8use crate::utils::regex_cache::{
9 IMAGE_REF_PATTERN, INLINE_LINK_REGEX as MARKDOWN_LINK_PATTERN, LINK_REF_PATTERN, URL_IN_TEXT, URL_PATTERN,
10};
11use crate::utils::table_utils::TableUtils;
12use crate::utils::text_reflow::split_into_sentences;
13use toml;
14
15pub mod md013_config;
16use md013_config::{MD013Config, ReflowMode};
17
18#[derive(Clone, Default)]
19pub struct MD013LineLength {
20 pub(crate) config: MD013Config,
21}
22
23impl MD013LineLength {
24 pub fn new(line_length: usize, code_blocks: bool, tables: bool, headings: bool, strict: bool) -> Self {
25 Self {
26 config: MD013Config {
27 line_length,
28 code_blocks,
29 tables,
30 headings,
31 strict,
32 reflow: false,
33 reflow_mode: ReflowMode::default(),
34 },
35 }
36 }
37
38 pub fn from_config_struct(config: MD013Config) -> Self {
39 Self { config }
40 }
41
42 fn should_ignore_line(
43 &self,
44 line: &str,
45 _lines: &[&str],
46 current_line: usize,
47 ctx: &crate::lint_context::LintContext,
48 ) -> bool {
49 if self.config.strict {
50 return false;
51 }
52
53 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 ctx.is_in_code_block(current_line + 1) && !trimmed.is_empty() && !line.contains(' ') && !line.contains('\t')
73 {
74 return true;
75 }
76
77 false
78 }
79}
80
81impl Rule for MD013LineLength {
82 fn name(&self) -> &'static str {
83 "MD013"
84 }
85
86 fn description(&self) -> &'static str {
87 "Line length should not be excessive"
88 }
89
90 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
91 let content = ctx.content;
92
93 if self.should_skip(ctx)
96 && !(self.config.reflow
97 && (self.config.reflow_mode == ReflowMode::Normalize
98 || self.config.reflow_mode == ReflowMode::SentencePerLine))
99 {
100 return Ok(Vec::new());
101 }
102
103 let mut warnings = Vec::new();
105
106 let inline_config = crate::inline_config::InlineConfig::from_content(content);
108 let config_override = inline_config.get_rule_config("MD013");
109
110 let effective_config = if let Some(json_config) = config_override {
112 if let Some(obj) = json_config.as_object() {
113 let mut config = self.config.clone();
114 if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
115 config.line_length = line_length as usize;
116 }
117 if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
118 config.code_blocks = code_blocks;
119 }
120 if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
121 config.tables = tables;
122 }
123 if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
124 config.headings = headings;
125 }
126 if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
127 config.strict = strict;
128 }
129 if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
130 config.reflow = reflow;
131 }
132 if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
133 config.reflow_mode = match reflow_mode {
134 "default" => ReflowMode::Default,
135 "normalize" => ReflowMode::Normalize,
136 "sentence-per-line" => ReflowMode::SentencePerLine,
137 _ => ReflowMode::default(),
138 };
139 }
140 config
141 } else {
142 self.config.clone()
143 }
144 } else {
145 self.config.clone()
146 };
147
148 let mut candidate_lines = Vec::new();
150 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
151 if line_info.content.len() > effective_config.line_length {
153 candidate_lines.push(line_idx);
154 }
155 }
156
157 if candidate_lines.is_empty()
159 && !(effective_config.reflow
160 && (effective_config.reflow_mode == ReflowMode::Normalize
161 || effective_config.reflow_mode == ReflowMode::SentencePerLine))
162 {
163 return Ok(warnings);
164 }
165
166 let lines: Vec<&str> = if !ctx.lines.is_empty() {
168 ctx.lines.iter().map(|l| l.content.as_str()).collect()
169 } else {
170 content.lines().collect()
171 };
172
173 let heading_lines_set: std::collections::HashSet<usize> = if !effective_config.headings {
175 ctx.lines
176 .iter()
177 .enumerate()
178 .filter(|(_, line)| line.heading.is_some())
179 .map(|(idx, _)| idx + 1)
180 .collect()
181 } else {
182 std::collections::HashSet::new()
183 };
184
185 let table_lines_set: std::collections::HashSet<usize> = if !effective_config.tables {
187 let table_blocks = TableUtils::find_table_blocks(content, ctx);
188 let mut table_lines = std::collections::HashSet::new();
189 for table in &table_blocks {
190 table_lines.insert(table.header_line + 1);
191 table_lines.insert(table.delimiter_line + 1);
192 for &line in &table.content_lines {
193 table_lines.insert(line + 1);
194 }
195 }
196 table_lines
197 } else {
198 std::collections::HashSet::new()
199 };
200
201 if effective_config.reflow_mode != ReflowMode::SentencePerLine {
204 for &line_idx in &candidate_lines {
205 let line_number = line_idx + 1;
206 let line = lines[line_idx];
207
208 let effective_length = self.calculate_effective_length(line);
210
211 let line_limit = effective_config.line_length;
213
214 if effective_length <= line_limit {
216 continue;
217 }
218
219 if ctx.lines[line_idx].in_mkdocstrings {
221 continue;
222 }
223
224 if !effective_config.strict {
226 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
228 continue;
229 }
230
231 if (!effective_config.headings && heading_lines_set.contains(&line_number))
235 || (!effective_config.code_blocks && ctx.is_in_code_block(line_number))
236 || (!effective_config.tables && table_lines_set.contains(&line_number))
237 || ctx.lines[line_number - 1].blockquote.is_some()
238 || ctx.is_in_html_block(line_number)
239 {
240 continue;
241 }
242
243 if self.should_ignore_line(line, &lines, line_idx, ctx) {
245 continue;
246 }
247 }
248
249 let fix = None;
252
253 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
254
255 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
257
258 warnings.push(LintWarning {
259 rule_name: Some(self.name()),
260 message,
261 line: start_line,
262 column: start_col,
263 end_line,
264 end_column: end_col,
265 severity: Severity::Warning,
266 fix,
267 });
268 }
269 }
270
271 if effective_config.reflow {
273 let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, &lines);
274 for pw in paragraph_warnings {
276 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
278 warnings.push(pw);
279 }
280 }
281
282 Ok(warnings)
283 }
284
285 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
286 let warnings = self.check(ctx)?;
289
290 if !warnings.iter().any(|w| w.fix.is_some()) {
292 return Ok(ctx.content.to_string());
293 }
294
295 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
297 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
298 }
299
300 fn as_any(&self) -> &dyn std::any::Any {
301 self
302 }
303
304 fn category(&self) -> RuleCategory {
305 RuleCategory::Whitespace
306 }
307
308 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
309 if ctx.content.is_empty() {
311 return true;
312 }
313
314 if self.config.reflow
316 && (self.config.reflow_mode == ReflowMode::SentencePerLine
317 || self.config.reflow_mode == ReflowMode::Normalize)
318 {
319 return false;
320 }
321
322 if ctx.content.len() <= self.config.line_length {
324 return true;
325 }
326
327 !ctx.lines
329 .iter()
330 .any(|line| line.content.len() > self.config.line_length)
331 }
332
333 fn default_config_section(&self) -> Option<(String, toml::Value)> {
334 let default_config = MD013Config::default();
335 let json_value = serde_json::to_value(&default_config).ok()?;
336 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
337
338 if let toml::Value::Table(table) = toml_value {
339 if !table.is_empty() {
340 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
341 } else {
342 None
343 }
344 } else {
345 None
346 }
347 }
348
349 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
350 let mut aliases = std::collections::HashMap::new();
351 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
352 Some(aliases)
353 }
354
355 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
356 where
357 Self: Sized,
358 {
359 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
360 if rule_config.line_length == 80 {
362 rule_config.line_length = config.global.line_length as usize;
364 }
365 Box::new(Self::from_config_struct(rule_config))
366 }
367}
368
369impl MD013LineLength {
370 fn generate_paragraph_fixes(
372 &self,
373 ctx: &crate::lint_context::LintContext,
374 config: &MD013Config,
375 lines: &[&str],
376 ) -> Vec<LintWarning> {
377 let mut warnings = Vec::new();
378 let line_index = LineIndex::new(ctx.content.to_string());
379
380 let mut i = 0;
381 while i < lines.len() {
382 let line_num = i + 1;
383
384 if ctx.is_in_code_block(line_num)
386 || ctx.is_in_front_matter(line_num)
387 || ctx.is_in_html_block(line_num)
388 || (line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some())
389 || lines[i].trim().starts_with('#')
390 || TableUtils::is_potential_table_row(lines[i])
391 || lines[i].trim().is_empty()
392 || is_horizontal_rule(lines[i].trim())
393 {
394 i += 1;
395 continue;
396 }
397
398 let trimmed = lines[i].trim();
400 if is_list_item(trimmed) {
401 let list_start = i;
403 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
404 let marker_len = marker.len();
405
406 #[derive(Clone)]
408 enum LineType {
409 Content(String),
410 CodeBlock(String, usize), Empty,
412 }
413
414 let mut actual_indent: Option<usize> = None;
415 let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
416 i += 1;
417
418 while i < lines.len() {
420 let line_info = &ctx.lines[i];
421
422 if line_info.is_blank {
424 if i + 1 < lines.len() {
426 let next_info = &ctx.lines[i + 1];
427
428 if !next_info.is_blank && next_info.indent >= marker_len {
430 list_item_lines.push(LineType::Empty);
432 i += 1;
433 continue;
434 }
435 }
436 break;
438 }
439
440 let indent = line_info.indent;
442
443 if indent >= marker_len {
445 let trimmed = line_info.content.trim();
446
447 if line_info.in_code_block {
449 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
450 i += 1;
451 continue;
452 }
453
454 if is_list_item(trimmed) && indent < marker_len {
458 break;
460 }
461
462 if is_list_item(trimmed) && indent >= marker_len {
467 let has_blank_before = matches!(list_item_lines.last(), Some(LineType::Empty));
469
470 let has_nested_content = list_item_lines
472 .iter()
473 .any(|line| matches!(line, LineType::Content(c) if is_list_item(c.trim())));
474
475 if !has_blank_before && !has_nested_content {
476 break;
479 }
480 }
482
483 if indent <= marker_len + 3 {
485 if actual_indent.is_none() {
487 actual_indent = Some(indent);
488 }
489
490 let content = line_info.content[indent..].to_string();
492 list_item_lines.push(LineType::Content(content));
493 i += 1;
494 } else {
495 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
497 i += 1;
498 }
499 } else {
500 break;
502 }
503 }
504
505 let indent_size = actual_indent.unwrap_or(marker_len);
507 let expected_indent = " ".repeat(indent_size);
508
509 #[derive(Clone)]
511 enum Block {
512 Paragraph(Vec<String>),
513 CodeBlock(Vec<(String, usize)>), }
515
516 let mut blocks: Vec<Block> = Vec::new();
517 let mut current_paragraph: Vec<String> = Vec::new();
518 let mut current_code_block: Vec<(String, usize)> = Vec::new();
519 let mut in_code = false;
520
521 for line in &list_item_lines {
522 match line {
523 LineType::Empty => {
524 if in_code {
525 current_code_block.push((String::new(), 0));
526 } else if !current_paragraph.is_empty() {
527 blocks.push(Block::Paragraph(current_paragraph.clone()));
528 current_paragraph.clear();
529 }
530 }
531 LineType::Content(content) => {
532 if in_code {
533 blocks.push(Block::CodeBlock(current_code_block.clone()));
535 current_code_block.clear();
536 in_code = false;
537 }
538 current_paragraph.push(content.clone());
539 }
540 LineType::CodeBlock(content, indent) => {
541 if !in_code {
542 if !current_paragraph.is_empty() {
544 blocks.push(Block::Paragraph(current_paragraph.clone()));
545 current_paragraph.clear();
546 }
547 in_code = true;
548 }
549 current_code_block.push((content.clone(), *indent));
550 }
551 }
552 }
553
554 if in_code && !current_code_block.is_empty() {
556 blocks.push(Block::CodeBlock(current_code_block));
557 } else if !current_paragraph.is_empty() {
558 blocks.push(Block::Paragraph(current_paragraph));
559 }
560
561 let content_lines: Vec<String> = list_item_lines
563 .iter()
564 .filter_map(|line| {
565 if let LineType::Content(s) = line {
566 Some(s.clone())
567 } else {
568 None
569 }
570 })
571 .collect();
572
573 let combined_content = content_lines.join(" ").trim().to_string();
574 let full_line = format!("{marker}{combined_content}");
575
576 let needs_reflow = self.calculate_effective_length(&full_line) > config.line_length
577 || (config.reflow_mode == ReflowMode::Normalize && content_lines.len() > 1)
578 || (config.reflow_mode == ReflowMode::SentencePerLine && {
579 let sentences = split_into_sentences(&combined_content);
581 sentences.len() > 1
582 });
583
584 if needs_reflow {
585 let start_range = line_index.whole_line_range(list_start + 1);
586 let end_line = i - 1;
587 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
588 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
589 } else {
590 line_index.whole_line_range(end_line + 1)
591 };
592 let byte_range = start_range.start..end_range.end;
593
594 let reflow_options = crate::utils::text_reflow::ReflowOptions {
596 line_length: config.line_length - indent_size,
597 break_on_sentences: true,
598 preserve_breaks: false,
599 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
600 };
601
602 let mut result: Vec<String> = Vec::new();
603 let mut is_first_block = true;
604
605 for block in &blocks {
606 match block {
607 Block::Paragraph(para_lines) => {
608 let paragraph_text = para_lines.join(" ").trim().to_string();
609 if !paragraph_text.is_empty() {
610 let reflowed =
611 crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
612
613 if is_first_block {
614 result.push(format!("{marker}{}", reflowed[0]));
616 for line in reflowed.iter().skip(1) {
617 result.push(format!("{expected_indent}{line}"));
618 }
619 is_first_block = false;
620 } else {
621 result.push(String::new());
623 for line in reflowed {
624 result.push(format!("{expected_indent}{line}"));
625 }
626 }
627 }
628 }
629 Block::CodeBlock(code_lines) => {
630 if !is_first_block {
632 result.push(String::new());
633 }
634
635 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
636 if is_first_block && idx == 0 {
637 result.push(format!(
639 "{marker}{}",
640 " ".repeat(orig_indent - marker_len) + content
641 ));
642 is_first_block = false;
643 } else if content.is_empty() {
644 result.push(String::new());
645 } else {
646 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
647 }
648 }
649 }
650 }
651 }
652
653 let reflowed_text = result.join("\n");
654
655 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
657 format!("{reflowed_text}\n")
658 } else {
659 reflowed_text
660 };
661
662 let original_text = &ctx.content[byte_range.clone()];
664
665 if original_text != replacement {
667 warnings.push(LintWarning {
668 rule_name: Some(self.name()),
669 message: if config.reflow_mode == ReflowMode::SentencePerLine {
670 "Line contains multiple sentences (one sentence per line expected)".to_string()
671 } else {
672 format!("Line length exceeds {} characters", config.line_length)
673 },
674 line: list_start + 1,
675 column: 1,
676 end_line: end_line + 1,
677 end_column: lines[end_line].len() + 1,
678 severity: Severity::Warning,
679 fix: Some(crate::rule::Fix {
680 range: byte_range,
681 replacement,
682 }),
683 });
684 }
685 }
686 continue;
687 }
688
689 let paragraph_start = i;
691 let mut paragraph_lines = vec![lines[i]];
692 i += 1;
693
694 while i < lines.len() {
695 let next_line = lines[i];
696 let next_line_num = i + 1;
697 let next_trimmed = next_line.trim();
698
699 if next_trimmed.is_empty()
701 || ctx.is_in_code_block(next_line_num)
702 || ctx.is_in_front_matter(next_line_num)
703 || ctx.is_in_html_block(next_line_num)
704 || (next_line_num > 0
705 && next_line_num <= ctx.lines.len()
706 && ctx.lines[next_line_num - 1].blockquote.is_some())
707 || next_trimmed.starts_with('#')
708 || TableUtils::is_potential_table_row(next_line)
709 || is_list_item(next_trimmed)
710 || is_horizontal_rule(next_trimmed)
711 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
712 {
713 break;
714 }
715
716 if i > 0 && lines[i - 1].ends_with(" ") {
718 break;
720 }
721
722 paragraph_lines.push(next_line);
723 i += 1;
724 }
725
726 let needs_reflow = match config.reflow_mode {
728 ReflowMode::Normalize => {
729 paragraph_lines.len() > 1
731 }
732 ReflowMode::SentencePerLine => {
733 paragraph_lines.iter().any(|line| {
735 let sentences = split_into_sentences(line);
737 sentences.len() > 1
738 })
739 }
740 ReflowMode::Default => {
741 paragraph_lines
743 .iter()
744 .any(|line| self.calculate_effective_length(line) > config.line_length)
745 }
746 };
747
748 if needs_reflow {
749 let start_range = line_index.whole_line_range(paragraph_start + 1);
752 let end_line = paragraph_start + paragraph_lines.len() - 1;
753
754 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
756 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
758 } else {
759 line_index.whole_line_range(end_line + 1)
761 };
762
763 let byte_range = start_range.start..end_range.end;
764
765 let paragraph_text = paragraph_lines.join(" ");
767
768 let has_hard_break = paragraph_lines.last().is_some_and(|l| l.ends_with(" "));
770
771 let reflow_options = crate::utils::text_reflow::ReflowOptions {
773 line_length: config.line_length,
774 break_on_sentences: true,
775 preserve_breaks: false,
776 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
777 };
778 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
779
780 if has_hard_break && !reflowed.is_empty() {
782 let last_idx = reflowed.len() - 1;
783 if !reflowed[last_idx].ends_with(" ") {
784 reflowed[last_idx].push_str(" ");
785 }
786 }
787
788 let reflowed_text = reflowed.join("\n");
789
790 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
792 format!("{reflowed_text}\n")
793 } else {
794 reflowed_text
795 };
796
797 let original_text = &ctx.content[byte_range.clone()];
799
800 if original_text != replacement {
802 let (warning_line, warning_end_line) = match config.reflow_mode {
807 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
808 ReflowMode::SentencePerLine => {
809 let mut violating_line = paragraph_start;
811 for (idx, line) in paragraph_lines.iter().enumerate() {
812 let sentences = split_into_sentences(line);
813 if sentences.len() > 1 {
814 violating_line = paragraph_start + idx;
815 break;
816 }
817 }
818 (violating_line + 1, violating_line + 1)
819 }
820 ReflowMode::Default => {
821 let mut violating_line = paragraph_start;
823 for (idx, line) in paragraph_lines.iter().enumerate() {
824 if self.calculate_effective_length(line) > config.line_length {
825 violating_line = paragraph_start + idx;
826 break;
827 }
828 }
829 (violating_line + 1, violating_line + 1)
830 }
831 };
832
833 warnings.push(LintWarning {
834 rule_name: Some(self.name()),
835 message: match config.reflow_mode {
836 ReflowMode::Normalize => format!(
837 "Paragraph could be normalized to use line length of {} characters",
838 config.line_length
839 ),
840 ReflowMode::SentencePerLine => {
841 "Line contains multiple sentences (one sentence per line expected)".to_string()
842 }
843 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length),
844 },
845 line: warning_line,
846 column: 1,
847 end_line: warning_end_line,
848 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
849 severity: Severity::Warning,
850 fix: Some(crate::rule::Fix {
851 range: byte_range,
852 replacement,
853 }),
854 });
855 }
856 }
857 }
858
859 warnings
860 }
861
862 fn calculate_effective_length(&self, line: &str) -> usize {
864 if self.config.strict {
865 return line.chars().count();
867 }
868
869 let bytes = line.as_bytes();
871 if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
872 return line.chars().count();
873 }
874
875 if !line.contains("http") && !line.contains('[') {
877 return line.chars().count();
878 }
879
880 let mut effective_line = line.to_string();
881
882 if line.contains('[') && line.contains("](") {
885 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
886 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
887 && url.as_str().len() > 15
888 {
889 let replacement = format!("[{}](url)", text.as_str());
890 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
891 }
892 }
893 }
894
895 if effective_line.contains("http") {
898 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
899 let url = url_match.as_str();
900 if !effective_line.contains(&format!("({url})")) {
902 let placeholder = "x".repeat(15.min(url.len()));
905 effective_line = effective_line.replacen(url, &placeholder, 1);
906 }
907 }
908 }
909
910 effective_line.chars().count()
911 }
912}
913
914fn extract_list_marker_and_content(line: &str) -> (String, String) {
916 let indent_len = line.len() - line.trim_start().len();
918 let indent = &line[..indent_len];
919 let trimmed = &line[indent_len..];
920
921 if let Some(rest) = trimmed.strip_prefix("- ") {
923 return (format!("{indent}- "), rest.to_string());
924 }
925 if let Some(rest) = trimmed.strip_prefix("* ") {
926 return (format!("{indent}* "), rest.to_string());
927 }
928 if let Some(rest) = trimmed.strip_prefix("+ ") {
929 return (format!("{indent}+ "), rest.to_string());
930 }
931
932 let mut chars = trimmed.chars();
934 let mut marker_content = String::new();
935
936 while let Some(c) = chars.next() {
937 marker_content.push(c);
938 if c == '.' {
939 if let Some(next) = chars.next()
941 && next == ' '
942 {
943 marker_content.push(next);
944 let content = chars.as_str().to_string();
945 return (format!("{indent}{marker_content}"), content);
946 }
947 break;
948 }
949 }
950
951 (String::new(), line.to_string())
953}
954
955fn is_horizontal_rule(line: &str) -> bool {
957 if line.len() < 3 {
958 return false;
959 }
960 let chars: Vec<char> = line.chars().collect();
962 if chars.is_empty() {
963 return false;
964 }
965 let first_char = chars[0];
966 if first_char != '-' && first_char != '_' && first_char != '*' {
967 return false;
968 }
969 for c in &chars {
971 if *c != first_char && *c != ' ' {
972 return false;
973 }
974 }
975 chars.iter().filter(|c| **c == first_char).count() >= 3
977}
978
979fn is_numbered_list_item(line: &str) -> bool {
980 let mut chars = line.chars();
981 if !chars.next().is_some_and(|c| c.is_numeric()) {
983 return false;
984 }
985 while let Some(c) = chars.next() {
987 if c == '.' {
988 return chars.next().is_none_or(|c| c == ' ');
990 }
991 if !c.is_numeric() {
992 return false;
993 }
994 }
995 false
996}
997
998fn is_list_item(line: &str) -> bool {
999 if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
1001 && line.len() > 1
1002 && line.chars().nth(1) == Some(' ')
1003 {
1004 return true;
1005 }
1006 is_numbered_list_item(line)
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013 use crate::lint_context::LintContext;
1014
1015 #[test]
1016 fn test_default_config() {
1017 let rule = MD013LineLength::default();
1018 assert_eq!(rule.config.line_length, 80);
1019 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
1023 }
1024
1025 #[test]
1026 fn test_custom_config() {
1027 let rule = MD013LineLength::new(100, true, true, false, true);
1028 assert_eq!(rule.config.line_length, 100);
1029 assert!(rule.config.code_blocks);
1030 assert!(rule.config.tables);
1031 assert!(!rule.config.headings);
1032 assert!(rule.config.strict);
1033 }
1034
1035 #[test]
1036 fn test_basic_line_length_violation() {
1037 let rule = MD013LineLength::new(50, false, false, false, false);
1038 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1040 let result = rule.check(&ctx).unwrap();
1041
1042 assert_eq!(result.len(), 1);
1043 assert!(result[0].message.contains("Line length"));
1044 assert!(result[0].message.contains("exceeds 50 characters"));
1045 }
1046
1047 #[test]
1048 fn test_no_violation_under_limit() {
1049 let rule = MD013LineLength::new(100, false, false, false, false);
1050 let content = "Short line.\nAnother short line.";
1051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1052 let result = rule.check(&ctx).unwrap();
1053
1054 assert_eq!(result.len(), 0);
1055 }
1056
1057 #[test]
1058 fn test_multiple_violations() {
1059 let rule = MD013LineLength::new(30, false, false, false, false);
1060 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1062 let result = rule.check(&ctx).unwrap();
1063
1064 assert_eq!(result.len(), 2);
1065 assert_eq!(result[0].line, 1);
1066 assert_eq!(result[1].line, 2);
1067 }
1068
1069 #[test]
1070 fn test_code_blocks_exemption() {
1071 let rule = MD013LineLength::new(30, false, false, false, false);
1073 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
1074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1075 let result = rule.check(&ctx).unwrap();
1076
1077 assert_eq!(result.len(), 0);
1078 }
1079
1080 #[test]
1081 fn test_code_blocks_not_exempt_when_configured() {
1082 let rule = MD013LineLength::new(30, true, false, false, false);
1084 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
1085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1086 let result = rule.check(&ctx).unwrap();
1087
1088 assert!(!result.is_empty());
1089 }
1090
1091 #[test]
1092 fn test_heading_checked_when_enabled() {
1093 let rule = MD013LineLength::new(30, false, false, true, false);
1094 let content = "# This is a very long heading that would normally exceed the limit";
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1096 let result = rule.check(&ctx).unwrap();
1097
1098 assert_eq!(result.len(), 1);
1099 }
1100
1101 #[test]
1102 fn test_heading_exempt_when_disabled() {
1103 let rule = MD013LineLength::new(30, false, false, false, false);
1104 let content = "# This is a very long heading that should trigger a warning";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1106 let result = rule.check(&ctx).unwrap();
1107
1108 assert_eq!(result.len(), 0);
1109 }
1110
1111 #[test]
1112 fn test_table_checked_when_enabled() {
1113 let rule = MD013LineLength::new(30, false, true, false, false);
1114 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
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
1121 #[test]
1122 fn test_issue_78_tables_after_fenced_code_blocks() {
1123 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1126
1127```plain
1128some code block longer than 20 chars length
1129```
1130
1131this is a very long line
1132
1133| column A | column B |
1134| -------- | -------- |
1135| `var` | `val` |
1136| value 1 | value 2 |
1137
1138correct length line"#;
1139 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1140 let result = rule.check(&ctx).unwrap();
1141
1142 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1144 assert_eq!(result[0].line, 7, "Should flag line 7");
1145 assert!(result[0].message.contains("24 exceeds 20"));
1146 }
1147
1148 #[test]
1149 fn test_issue_78_tables_with_inline_code() {
1150 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
1153| -------- | -------- |
1154| `var with very long name` | `val exceeding limit` |
1155| value 1 | value 2 |
1156
1157This line exceeds limit"#;
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1159 let result = rule.check(&ctx).unwrap();
1160
1161 assert_eq!(result.len(), 1, "Should only flag the non-table line");
1163 assert_eq!(result[0].line, 6, "Should flag line 6");
1164 }
1165
1166 #[test]
1167 fn test_issue_78_indented_code_blocks() {
1168 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1171
1172 some code block longer than 20 chars length
1173
1174this is a very long line
1175
1176| column A | column B |
1177| -------- | -------- |
1178| value 1 | value 2 |
1179
1180correct length line"#;
1181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1182 let result = rule.check(&ctx).unwrap();
1183
1184 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1186 assert_eq!(result[0].line, 5, "Should flag line 5");
1187 }
1188
1189 #[test]
1190 fn test_url_exemption() {
1191 let rule = MD013LineLength::new(30, false, false, false, false);
1192 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1194 let result = rule.check(&ctx).unwrap();
1195
1196 assert_eq!(result.len(), 0);
1197 }
1198
1199 #[test]
1200 fn test_image_reference_exemption() {
1201 let rule = MD013LineLength::new(30, false, false, false, false);
1202 let content = "![This is a very long image alt text that exceeds limit][reference]";
1203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1204 let result = rule.check(&ctx).unwrap();
1205
1206 assert_eq!(result.len(), 0);
1207 }
1208
1209 #[test]
1210 fn test_link_reference_exemption() {
1211 let rule = MD013LineLength::new(30, false, false, false, false);
1212 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1214 let result = rule.check(&ctx).unwrap();
1215
1216 assert_eq!(result.len(), 0);
1217 }
1218
1219 #[test]
1220 fn test_strict_mode() {
1221 let rule = MD013LineLength::new(30, false, false, false, true);
1222 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1224 let result = rule.check(&ctx).unwrap();
1225
1226 assert_eq!(result.len(), 1);
1228 }
1229
1230 #[test]
1231 fn test_blockquote_exemption() {
1232 let rule = MD013LineLength::new(30, false, false, false, false);
1233 let content = "> This is a very long line inside a blockquote that should be ignored.";
1234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1235 let result = rule.check(&ctx).unwrap();
1236
1237 assert_eq!(result.len(), 0);
1238 }
1239
1240 #[test]
1241 fn test_setext_heading_underline_exemption() {
1242 let rule = MD013LineLength::new(30, false, false, false, false);
1243 let content = "Heading\n========================================";
1244 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1245 let result = rule.check(&ctx).unwrap();
1246
1247 assert_eq!(result.len(), 0);
1249 }
1250
1251 #[test]
1252 fn test_no_fix_without_reflow() {
1253 let rule = MD013LineLength::new(60, false, false, false, false);
1254 let content = "This line has trailing whitespace that makes it too long ";
1255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1256 let result = rule.check(&ctx).unwrap();
1257
1258 assert_eq!(result.len(), 1);
1259 assert!(result[0].fix.is_none());
1261
1262 let fixed = rule.fix(&ctx).unwrap();
1264 assert_eq!(fixed, content);
1265 }
1266
1267 #[test]
1268 fn test_character_vs_byte_counting() {
1269 let rule = MD013LineLength::new(10, false, false, false, false);
1270 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1273 let result = rule.check(&ctx).unwrap();
1274
1275 assert_eq!(result.len(), 1);
1276 assert_eq!(result[0].line, 1);
1277 }
1278
1279 #[test]
1280 fn test_empty_content() {
1281 let rule = MD013LineLength::default();
1282 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1283 let result = rule.check(&ctx).unwrap();
1284
1285 assert_eq!(result.len(), 0);
1286 }
1287
1288 #[test]
1289 fn test_excess_range_calculation() {
1290 let rule = MD013LineLength::new(10, false, false, false, false);
1291 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1293 let result = rule.check(&ctx).unwrap();
1294
1295 assert_eq!(result.len(), 1);
1296 assert_eq!(result[0].column, 11);
1298 assert_eq!(result[0].end_column, 21);
1299 }
1300
1301 #[test]
1302 fn test_html_block_exemption() {
1303 let rule = MD013LineLength::new(30, false, false, false, false);
1304 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1306 let result = rule.check(&ctx).unwrap();
1307
1308 assert_eq!(result.len(), 0);
1310 }
1311
1312 #[test]
1313 fn test_mixed_content() {
1314 let rule = MD013LineLength::new(30, false, false, false, false);
1316 let content = r#"# This heading is very long but should be exempt
1317
1318This regular paragraph line is too long and should trigger.
1319
1320```
1321Code block line that is very long but exempt.
1322```
1323
1324| Table | With very long content |
1325|-------|------------------------|
1326
1327Another long line that should trigger a warning."#;
1328
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1330 let result = rule.check(&ctx).unwrap();
1331
1332 assert_eq!(result.len(), 2);
1334 assert_eq!(result[0].line, 3);
1335 assert_eq!(result[1].line, 12);
1336 }
1337
1338 #[test]
1339 fn test_fix_without_reflow_preserves_content() {
1340 let rule = MD013LineLength::new(50, false, false, false, false);
1341 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
1342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1343
1344 let fixed = rule.fix(&ctx).unwrap();
1346 assert_eq!(fixed, content);
1347 }
1348
1349 #[test]
1350 fn test_content_detection() {
1351 let rule = MD013LineLength::default();
1352
1353 let long_line = "a".repeat(100);
1355 let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
1356 assert!(!rule.should_skip(&ctx)); let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1359 assert!(rule.should_skip(&empty_ctx)); }
1361
1362 #[test]
1363 fn test_rule_metadata() {
1364 let rule = MD013LineLength::default();
1365 assert_eq!(rule.name(), "MD013");
1366 assert_eq!(rule.description(), "Line length should not be excessive");
1367 assert_eq!(rule.category(), RuleCategory::Whitespace);
1368 }
1369
1370 #[test]
1371 fn test_url_embedded_in_text() {
1372 let rule = MD013LineLength::new(50, false, false, false, false);
1373
1374 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1377 let result = rule.check(&ctx).unwrap();
1378
1379 assert_eq!(result.len(), 0);
1381 }
1382
1383 #[test]
1384 fn test_multiple_urls_in_line() {
1385 let rule = MD013LineLength::new(50, false, false, false, false);
1386
1387 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1390
1391 let result = rule.check(&ctx).unwrap();
1392
1393 assert_eq!(result.len(), 0);
1395 }
1396
1397 #[test]
1398 fn test_markdown_link_with_long_url() {
1399 let rule = MD013LineLength::new(50, false, false, false, false);
1400
1401 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1404 let result = rule.check(&ctx).unwrap();
1405
1406 assert_eq!(result.len(), 0);
1408 }
1409
1410 #[test]
1411 fn test_line_too_long_even_without_urls() {
1412 let rule = MD013LineLength::new(50, false, false, false, false);
1413
1414 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1417 let result = rule.check(&ctx).unwrap();
1418
1419 assert_eq!(result.len(), 1);
1421 }
1422
1423 #[test]
1424 fn test_strict_mode_counts_urls() {
1425 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";
1429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1430 let result = rule.check(&ctx).unwrap();
1431
1432 assert_eq!(result.len(), 1);
1434 }
1435
1436 #[test]
1437 fn test_documentation_example_from_md051() {
1438 let rule = MD013LineLength::new(80, false, false, false, false);
1439
1440 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1443 let result = rule.check(&ctx).unwrap();
1444
1445 assert_eq!(result.len(), 0);
1447 }
1448
1449 #[test]
1450 fn test_text_reflow_simple() {
1451 let config = MD013Config {
1452 line_length: 30,
1453 reflow: true,
1454 ..Default::default()
1455 };
1456 let rule = MD013LineLength::from_config_struct(config);
1457
1458 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1460
1461 let fixed = rule.fix(&ctx).unwrap();
1462
1463 for line in fixed.lines() {
1465 assert!(
1466 line.chars().count() <= 30,
1467 "Line too long: {} (len={})",
1468 line,
1469 line.chars().count()
1470 );
1471 }
1472
1473 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1475 let original_words: Vec<&str> = content.split_whitespace().collect();
1476 assert_eq!(fixed_words, original_words);
1477 }
1478
1479 #[test]
1480 fn test_text_reflow_preserves_markdown_elements() {
1481 let config = MD013Config {
1482 line_length: 40,
1483 reflow: true,
1484 ..Default::default()
1485 };
1486 let rule = MD013LineLength::from_config_struct(config);
1487
1488 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1490
1491 let fixed = rule.fix(&ctx).unwrap();
1492
1493 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1495 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1496 assert!(
1497 fixed.contains("[a link](https://example.com)"),
1498 "Link not preserved in: {fixed}"
1499 );
1500
1501 for line in fixed.lines() {
1503 assert!(line.len() <= 40, "Line too long: {line}");
1504 }
1505 }
1506
1507 #[test]
1508 fn test_text_reflow_preserves_code_blocks() {
1509 let config = MD013Config {
1510 line_length: 30,
1511 reflow: true,
1512 ..Default::default()
1513 };
1514 let rule = MD013LineLength::from_config_struct(config);
1515
1516 let content = r#"Here is some text.
1517
1518```python
1519def very_long_function_name_that_exceeds_limit():
1520 return "This should not be wrapped"
1521```
1522
1523More text after code block."#;
1524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1525
1526 let fixed = rule.fix(&ctx).unwrap();
1527
1528 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1530 assert!(fixed.contains("```python"));
1531 assert!(fixed.contains("```"));
1532 }
1533
1534 #[test]
1535 fn test_text_reflow_preserves_lists() {
1536 let config = MD013Config {
1537 line_length: 30,
1538 reflow: true,
1539 ..Default::default()
1540 };
1541 let rule = MD013LineLength::from_config_struct(config);
1542
1543 let content = r#"Here is a list:
1544
15451. First item with a very long line that needs wrapping
15462. Second item is short
15473. Third item also has a long line that exceeds the limit
1548
1549And a bullet list:
1550
1551- Bullet item with very long content that needs wrapping
1552- Short bullet"#;
1553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1554
1555 let fixed = rule.fix(&ctx).unwrap();
1556
1557 assert!(fixed.contains("1. "));
1559 assert!(fixed.contains("2. "));
1560 assert!(fixed.contains("3. "));
1561 assert!(fixed.contains("- "));
1562
1563 let lines: Vec<&str> = fixed.lines().collect();
1565 for (i, line) in lines.iter().enumerate() {
1566 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1567 if i + 1 < lines.len()
1569 && !lines[i + 1].trim().is_empty()
1570 && !lines[i + 1].trim().starts_with(char::is_numeric)
1571 && !lines[i + 1].trim().starts_with("-")
1572 {
1573 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1575 }
1576 } else if line.trim().starts_with("-") {
1577 if i + 1 < lines.len()
1579 && !lines[i + 1].trim().is_empty()
1580 && !lines[i + 1].trim().starts_with(char::is_numeric)
1581 && !lines[i + 1].trim().starts_with("-")
1582 {
1583 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1585 }
1586 }
1587 }
1588 }
1589
1590 #[test]
1591 fn test_issue_83_numbered_list_with_backticks() {
1592 let config = MD013Config {
1594 line_length: 100,
1595 reflow: true,
1596 ..Default::default()
1597 };
1598 let rule = MD013LineLength::from_config_struct(config);
1599
1600 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
1602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1603
1604 let fixed = rule.fix(&ctx).unwrap();
1605
1606 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
1609
1610 assert_eq!(
1611 fixed, expected,
1612 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
1613 );
1614 }
1615
1616 #[test]
1617 fn test_text_reflow_disabled_by_default() {
1618 let rule = MD013LineLength::new(30, false, false, false, false);
1619
1620 let content = "This is a very long line that definitely exceeds thirty characters.";
1621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1622
1623 let fixed = rule.fix(&ctx).unwrap();
1624
1625 assert_eq!(fixed, content);
1628 }
1629
1630 #[test]
1631 fn test_reflow_with_hard_line_breaks() {
1632 let config = MD013Config {
1634 line_length: 40,
1635 reflow: true,
1636 ..Default::default()
1637 };
1638 let rule = MD013LineLength::from_config_struct(config);
1639
1640 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";
1642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1643 let fixed = rule.fix(&ctx).unwrap();
1644
1645 assert!(
1647 fixed.contains(" \n"),
1648 "Hard line break with exactly 2 spaces should be preserved"
1649 );
1650 }
1651
1652 #[test]
1653 fn test_reflow_preserves_reference_links() {
1654 let config = MD013Config {
1655 line_length: 40,
1656 reflow: true,
1657 ..Default::default()
1658 };
1659 let rule = MD013LineLength::from_config_struct(config);
1660
1661 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
1662
1663[ref]: https://example.com";
1664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1665 let fixed = rule.fix(&ctx).unwrap();
1666
1667 assert!(fixed.contains("[reference link][ref]"));
1669 assert!(!fixed.contains("[ reference link]"));
1670 assert!(!fixed.contains("[ref ]"));
1671 }
1672
1673 #[test]
1674 fn test_reflow_with_nested_markdown_elements() {
1675 let config = MD013Config {
1676 line_length: 35,
1677 reflow: true,
1678 ..Default::default()
1679 };
1680 let rule = MD013LineLength::from_config_struct(config);
1681
1682 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
1683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1684 let fixed = rule.fix(&ctx).unwrap();
1685
1686 assert!(fixed.contains("**bold with `code` inside**"));
1688 }
1689
1690 #[test]
1691 fn test_reflow_with_unbalanced_markdown() {
1692 let config = MD013Config {
1694 line_length: 30,
1695 reflow: true,
1696 ..Default::default()
1697 };
1698 let rule = MD013LineLength::from_config_struct(config);
1699
1700 let content = "This has **unbalanced bold that goes on for a very long time without closing";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1702 let fixed = rule.fix(&ctx).unwrap();
1703
1704 assert!(!fixed.is_empty());
1708 for line in fixed.lines() {
1710 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
1711 }
1712 }
1713
1714 #[test]
1715 fn test_reflow_fix_indicator() {
1716 let config = MD013Config {
1718 line_length: 30,
1719 reflow: true,
1720 ..Default::default()
1721 };
1722 let rule = MD013LineLength::from_config_struct(config);
1723
1724 let content = "This is a very long line that definitely exceeds the thirty character limit";
1725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1726 let warnings = rule.check(&ctx).unwrap();
1727
1728 assert!(!warnings.is_empty());
1730 assert!(
1731 warnings[0].fix.is_some(),
1732 "Should provide fix indicator when reflow is true"
1733 );
1734 }
1735
1736 #[test]
1737 fn test_no_fix_indicator_without_reflow() {
1738 let config = MD013Config {
1740 line_length: 30,
1741 reflow: false,
1742 ..Default::default()
1743 };
1744 let rule = MD013LineLength::from_config_struct(config);
1745
1746 let content = "This is a very long line that definitely exceeds the thirty character limit";
1747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1748 let warnings = rule.check(&ctx).unwrap();
1749
1750 assert!(!warnings.is_empty());
1752 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
1753 }
1754
1755 #[test]
1756 fn test_reflow_preserves_all_reference_link_types() {
1757 let config = MD013Config {
1758 line_length: 40,
1759 reflow: true,
1760 ..Default::default()
1761 };
1762 let rule = MD013LineLength::from_config_struct(config);
1763
1764 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
1765
1766[ref]: https://example.com
1767[collapsed]: https://example.com
1768[shortcut]: https://example.com";
1769
1770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1771 let fixed = rule.fix(&ctx).unwrap();
1772
1773 assert!(fixed.contains("[full reference][ref]"));
1775 assert!(fixed.contains("[collapsed][]"));
1776 assert!(fixed.contains("[shortcut]"));
1777 }
1778
1779 #[test]
1780 fn test_reflow_handles_images_correctly() {
1781 let config = MD013Config {
1782 line_length: 40,
1783 reflow: true,
1784 ..Default::default()
1785 };
1786 let rule = MD013LineLength::from_config_struct(config);
1787
1788 let content = "This line has an  that should not be broken when reflowing.";
1789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1790 let fixed = rule.fix(&ctx).unwrap();
1791
1792 assert!(fixed.contains(""));
1794 }
1795
1796 #[test]
1797 fn test_normalize_mode_flags_short_lines() {
1798 let config = MD013Config {
1799 line_length: 100,
1800 reflow: true,
1801 reflow_mode: ReflowMode::Normalize,
1802 ..Default::default()
1803 };
1804 let rule = MD013LineLength::from_config_struct(config);
1805
1806 let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
1808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1809 let warnings = rule.check(&ctx).unwrap();
1810
1811 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1813 assert!(warnings[0].message.contains("normalized"));
1814 }
1815
1816 #[test]
1817 fn test_normalize_mode_combines_short_lines() {
1818 let config = MD013Config {
1819 line_length: 100,
1820 reflow: true,
1821 reflow_mode: ReflowMode::Normalize,
1822 ..Default::default()
1823 };
1824 let rule = MD013LineLength::from_config_struct(config);
1825
1826 let content =
1828 "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
1829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1830 let fixed = rule.fix(&ctx).unwrap();
1831
1832 let lines: Vec<&str> = fixed.lines().collect();
1834 assert_eq!(lines.len(), 1, "Should combine into single line");
1835 assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
1836 }
1837
1838 #[test]
1839 fn test_normalize_mode_preserves_paragraph_breaks() {
1840 let config = MD013Config {
1841 line_length: 100,
1842 reflow: true,
1843 reflow_mode: ReflowMode::Normalize,
1844 ..Default::default()
1845 };
1846 let rule = MD013LineLength::from_config_struct(config);
1847
1848 let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
1849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1850 let fixed = rule.fix(&ctx).unwrap();
1851
1852 assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
1854
1855 let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
1856 assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
1857 }
1858
1859 #[test]
1860 fn test_default_mode_only_fixes_violations() {
1861 let config = MD013Config {
1862 line_length: 100,
1863 reflow: true,
1864 reflow_mode: ReflowMode::Default, ..Default::default()
1866 };
1867 let rule = MD013LineLength::from_config_struct(config);
1868
1869 let content = "This is a short line.\nAnother short line.\nA third short line.";
1871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1872 let warnings = rule.check(&ctx).unwrap();
1873
1874 assert!(warnings.is_empty(), "Should not flag short lines in default mode");
1876
1877 let fixed = rule.fix(&ctx).unwrap();
1879 assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
1880 }
1881
1882 #[test]
1883 fn test_normalize_mode_with_lists() {
1884 let config = MD013Config {
1885 line_length: 80,
1886 reflow: true,
1887 reflow_mode: ReflowMode::Normalize,
1888 ..Default::default()
1889 };
1890 let rule = MD013LineLength::from_config_struct(config);
1891
1892 let content = r#"A paragraph with
1893short lines.
1894
18951. List item with
1896 short lines
18972. Another item"#;
1898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1899 let fixed = rule.fix(&ctx).unwrap();
1900
1901 let lines: Vec<&str> = fixed.lines().collect();
1903 assert!(lines[0].len() > 20, "First paragraph should be normalized");
1904 assert!(fixed.contains("1. "), "Should preserve list markers");
1905 assert!(fixed.contains("2. "), "Should preserve list markers");
1906 }
1907
1908 #[test]
1909 fn test_normalize_mode_with_code_blocks() {
1910 let config = MD013Config {
1911 line_length: 100,
1912 reflow: true,
1913 reflow_mode: ReflowMode::Normalize,
1914 ..Default::default()
1915 };
1916 let rule = MD013LineLength::from_config_struct(config);
1917
1918 let content = r#"A paragraph with
1919short lines.
1920
1921```
1922code block should not be normalized
1923even with short lines
1924```
1925
1926Another paragraph with
1927short lines."#;
1928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1929 let fixed = rule.fix(&ctx).unwrap();
1930
1931 assert!(fixed.contains("code block should not be normalized\neven with short lines"));
1933 let lines: Vec<&str> = fixed.lines().collect();
1935 assert!(lines[0].len() > 20, "First paragraph should be normalized");
1936 }
1937
1938 #[test]
1939 fn test_issue_76_use_case() {
1940 let config = MD013Config {
1942 line_length: 999999, reflow: true,
1944 reflow_mode: ReflowMode::Normalize,
1945 ..Default::default()
1946 };
1947 let rule = MD013LineLength::from_config_struct(config);
1948
1949 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.";
1951
1952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1953
1954 let warnings = rule.check(&ctx).unwrap();
1956 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1957
1958 let fixed = rule.fix(&ctx).unwrap();
1960 let lines: Vec<&str> = fixed.lines().collect();
1961 assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
1962 assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
1963 }
1964
1965 #[test]
1966 fn test_normalize_mode_single_line_unchanged() {
1967 let config = MD013Config {
1969 line_length: 100,
1970 reflow: true,
1971 reflow_mode: ReflowMode::Normalize,
1972 ..Default::default()
1973 };
1974 let rule = MD013LineLength::from_config_struct(config);
1975
1976 let content = "This is a single line that should not be changed.";
1977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1978
1979 let warnings = rule.check(&ctx).unwrap();
1980 assert!(warnings.is_empty(), "Single line should not be flagged");
1981
1982 let fixed = rule.fix(&ctx).unwrap();
1983 assert_eq!(fixed, content, "Single line should remain unchanged");
1984 }
1985
1986 #[test]
1987 fn test_normalize_mode_with_inline_code() {
1988 let config = MD013Config {
1989 line_length: 80,
1990 reflow: true,
1991 reflow_mode: ReflowMode::Normalize,
1992 ..Default::default()
1993 };
1994 let rule = MD013LineLength::from_config_struct(config);
1995
1996 let content =
1997 "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
1998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1999
2000 let warnings = rule.check(&ctx).unwrap();
2001 assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
2002
2003 let fixed = rule.fix(&ctx).unwrap();
2004 assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
2005 assert!(fixed.lines().count() < 3, "Lines should be combined");
2006 }
2007
2008 #[test]
2009 fn test_normalize_mode_with_emphasis() {
2010 let config = MD013Config {
2011 line_length: 100,
2012 reflow: true,
2013 reflow_mode: ReflowMode::Normalize,
2014 ..Default::default()
2015 };
2016 let rule = MD013LineLength::from_config_struct(config);
2017
2018 let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
2019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2020
2021 let fixed = rule.fix(&ctx).unwrap();
2022 assert!(fixed.contains("**bold**"), "Bold should be preserved");
2023 assert!(fixed.contains("*italic*"), "Italic should be preserved");
2024 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2025 }
2026
2027 #[test]
2028 fn test_normalize_mode_respects_hard_breaks() {
2029 let config = MD013Config {
2030 line_length: 100,
2031 reflow: true,
2032 reflow_mode: ReflowMode::Normalize,
2033 ..Default::default()
2034 };
2035 let rule = MD013LineLength::from_config_struct(config);
2036
2037 let content = "First line with hard break \nSecond line after break\nThird line";
2039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2040
2041 let fixed = rule.fix(&ctx).unwrap();
2042 assert!(fixed.contains(" \n"), "Hard break should be preserved");
2044 assert!(
2046 fixed.contains("Second line after break Third line"),
2047 "Lines without hard break should combine"
2048 );
2049 }
2050
2051 #[test]
2052 fn test_normalize_mode_with_links() {
2053 let config = MD013Config {
2054 line_length: 100,
2055 reflow: true,
2056 reflow_mode: ReflowMode::Normalize,
2057 ..Default::default()
2058 };
2059 let rule = MD013LineLength::from_config_struct(config);
2060
2061 let content =
2062 "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
2063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2064
2065 let fixed = rule.fix(&ctx).unwrap();
2066 assert!(
2067 fixed.contains("[link](https://example.com)"),
2068 "Link should be preserved"
2069 );
2070 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2071 }
2072
2073 #[test]
2074 fn test_normalize_mode_empty_lines_between_paragraphs() {
2075 let config = MD013Config {
2076 line_length: 100,
2077 reflow: true,
2078 reflow_mode: ReflowMode::Normalize,
2079 ..Default::default()
2080 };
2081 let rule = MD013LineLength::from_config_struct(config);
2082
2083 let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
2084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2085
2086 let fixed = rule.fix(&ctx).unwrap();
2087 assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
2089 let parts: Vec<&str> = fixed.split("\n\n\n").collect();
2091 assert_eq!(parts.len(), 2, "Should have two parts");
2092 assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
2093 assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
2094 }
2095
2096 #[test]
2097 fn test_normalize_mode_mixed_list_types() {
2098 let config = MD013Config {
2099 line_length: 80,
2100 reflow: true,
2101 reflow_mode: ReflowMode::Normalize,
2102 ..Default::default()
2103 };
2104 let rule = MD013LineLength::from_config_struct(config);
2105
2106 let content = r#"Paragraph before list
2107with multiple lines.
2108
2109- Bullet item
2110* Another bullet
2111+ Plus bullet
2112
21131. Numbered item
21142. Another number
2115
2116Paragraph after list
2117with multiple lines."#;
2118
2119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2120 let fixed = rule.fix(&ctx).unwrap();
2121
2122 assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
2124 assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
2125 assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
2126 assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
2127
2128 assert!(
2130 fixed.starts_with("Paragraph before list with multiple lines."),
2131 "First paragraph should be normalized"
2132 );
2133 assert!(
2134 fixed.ends_with("Paragraph after list with multiple lines."),
2135 "Last paragraph should be normalized"
2136 );
2137 }
2138
2139 #[test]
2140 fn test_normalize_mode_with_horizontal_rules() {
2141 let config = MD013Config {
2142 line_length: 100,
2143 reflow: true,
2144 reflow_mode: ReflowMode::Normalize,
2145 ..Default::default()
2146 };
2147 let rule = MD013LineLength::from_config_struct(config);
2148
2149 let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
2150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2151
2152 let fixed = rule.fix(&ctx).unwrap();
2153 assert!(fixed.contains("---"), "Horizontal rule should be preserved");
2154 assert!(
2155 fixed.contains("Paragraph before horizontal rule."),
2156 "First paragraph normalized"
2157 );
2158 assert!(
2159 fixed.contains("Paragraph after horizontal rule."),
2160 "Second paragraph normalized"
2161 );
2162 }
2163
2164 #[test]
2165 fn test_normalize_mode_with_indented_code() {
2166 let config = MD013Config {
2167 line_length: 100,
2168 reflow: true,
2169 reflow_mode: ReflowMode::Normalize,
2170 ..Default::default()
2171 };
2172 let rule = MD013LineLength::from_config_struct(config);
2173
2174 let content = "Paragraph before\nindented code.\n\n This is indented code\n Should not be normalized\n\nParagraph after\nindented code.";
2175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2176
2177 let fixed = rule.fix(&ctx).unwrap();
2178 assert!(
2179 fixed.contains(" This is indented code\n Should not be normalized"),
2180 "Indented code preserved"
2181 );
2182 assert!(
2183 fixed.contains("Paragraph before indented code."),
2184 "First paragraph normalized"
2185 );
2186 assert!(
2187 fixed.contains("Paragraph after indented code."),
2188 "Second paragraph normalized"
2189 );
2190 }
2191
2192 #[test]
2193 fn test_normalize_mode_disabled_without_reflow() {
2194 let config = MD013Config {
2196 line_length: 100,
2197 reflow: false, reflow_mode: ReflowMode::Normalize,
2199 ..Default::default()
2200 };
2201 let rule = MD013LineLength::from_config_struct(config);
2202
2203 let content = "This is a line\nwith breaks that\nshould not be changed.";
2204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2205
2206 let warnings = rule.check(&ctx).unwrap();
2207 assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2208
2209 let fixed = rule.fix(&ctx).unwrap();
2210 assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2211 }
2212
2213 #[test]
2214 fn test_default_mode_with_long_lines() {
2215 let config = MD013Config {
2218 line_length: 50,
2219 reflow: true,
2220 reflow_mode: ReflowMode::Default,
2221 ..Default::default()
2222 };
2223 let rule = MD013LineLength::from_config_struct(config);
2224
2225 let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2227
2228 let warnings = rule.check(&ctx).unwrap();
2229 assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2230 assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2232
2233 let fixed = rule.fix(&ctx).unwrap();
2234 assert!(
2236 fixed.contains("Short line. This is"),
2237 "Should combine and reflow the paragraph"
2238 );
2239 assert!(
2240 fixed.contains("wrapping. Another short"),
2241 "Should include all paragraph content"
2242 );
2243 }
2244
2245 #[test]
2246 fn test_normalize_vs_default_mode_same_content() {
2247 let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2249
2250 let default_config = MD013Config {
2252 line_length: 100,
2253 reflow: true,
2254 reflow_mode: ReflowMode::Default,
2255 ..Default::default()
2256 };
2257 let default_rule = MD013LineLength::from_config_struct(default_config);
2258 let default_warnings = default_rule.check(&ctx).unwrap();
2259 let default_fixed = default_rule.fix(&ctx).unwrap();
2260
2261 let normalize_config = MD013Config {
2263 line_length: 100,
2264 reflow: true,
2265 reflow_mode: ReflowMode::Normalize,
2266 ..Default::default()
2267 };
2268 let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2269 let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2270 let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2271
2272 assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2274 assert!(
2275 !normalize_warnings.is_empty(),
2276 "Normalize mode should flag multi-line paragraphs"
2277 );
2278
2279 assert_eq!(
2280 default_fixed, content,
2281 "Default mode should not change content without violations"
2282 );
2283 assert_ne!(
2284 normalize_fixed, content,
2285 "Normalize mode should change multi-line paragraphs"
2286 );
2287 assert_eq!(
2288 normalize_fixed.lines().count(),
2289 1,
2290 "Normalize should combine into single line"
2291 );
2292 }
2293
2294 #[test]
2295 fn test_normalize_mode_with_reference_definitions() {
2296 let config = MD013Config {
2297 line_length: 100,
2298 reflow: true,
2299 reflow_mode: ReflowMode::Normalize,
2300 ..Default::default()
2301 };
2302 let rule = MD013LineLength::from_config_struct(config);
2303
2304 let content =
2305 "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2307
2308 let fixed = rule.fix(&ctx).unwrap();
2309 assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2310 assert!(
2311 fixed.contains("[ref]: https://example.com"),
2312 "Reference definition should be preserved"
2313 );
2314 assert!(
2315 fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2316 "Paragraph should be normalized"
2317 );
2318 }
2319
2320 #[test]
2321 fn test_normalize_mode_with_html_comments() {
2322 let config = MD013Config {
2323 line_length: 100,
2324 reflow: true,
2325 reflow_mode: ReflowMode::Normalize,
2326 ..Default::default()
2327 };
2328 let rule = MD013LineLength::from_config_struct(config);
2329
2330 let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2332
2333 let fixed = rule.fix(&ctx).unwrap();
2334 assert!(
2335 fixed.contains("<!-- This is a comment -->"),
2336 "HTML comment should be preserved"
2337 );
2338 assert!(
2339 fixed.contains("Paragraph before HTML comment."),
2340 "First paragraph normalized"
2341 );
2342 assert!(
2343 fixed.contains("Paragraph after HTML comment."),
2344 "Second paragraph normalized"
2345 );
2346 }
2347
2348 #[test]
2349 fn test_normalize_mode_line_starting_with_number() {
2350 let config = MD013Config {
2352 line_length: 100,
2353 reflow: true,
2354 reflow_mode: ReflowMode::Normalize,
2355 ..Default::default()
2356 };
2357 let rule = MD013LineLength::from_config_struct(config);
2358
2359 let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2361
2362 let fixed = rule.fix(&ctx).unwrap();
2363 assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2364 assert!(
2365 fixed.contains("80 characters"),
2366 "Number at start of line should be preserved"
2367 );
2368 }
2369
2370 #[test]
2371 fn test_default_mode_preserves_list_structure() {
2372 let config = MD013Config {
2374 line_length: 80,
2375 reflow: true,
2376 reflow_mode: ReflowMode::Default,
2377 ..Default::default()
2378 };
2379 let rule = MD013LineLength::from_config_struct(config);
2380
2381 let content = r#"- This is a bullet point that has
2382 some text on multiple lines
2383 that should stay separate
2384
23851. Numbered list item with
2386 multiple lines that should
2387 also stay separate"#;
2388
2389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2390 let fixed = rule.fix(&ctx).unwrap();
2391
2392 let lines: Vec<&str> = fixed.lines().collect();
2394 assert_eq!(
2395 lines[0], "- This is a bullet point that has",
2396 "First line should be unchanged"
2397 );
2398 assert_eq!(
2399 lines[1], " some text on multiple lines",
2400 "Continuation should be preserved"
2401 );
2402 assert_eq!(
2403 lines[2], " that should stay separate",
2404 "Second continuation should be preserved"
2405 );
2406 }
2407
2408 #[test]
2409 fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2410 let config = MD013Config {
2412 line_length: 80,
2413 reflow: true,
2414 reflow_mode: ReflowMode::Normalize,
2415 ..Default::default()
2416 };
2417 let rule = MD013LineLength::from_config_struct(config);
2418
2419 let content = r#"- This is a bullet point that has
2420 some text on multiple lines
2421 that should be combined
2422
24231. Numbered list item with
2424 multiple lines that need
2425 to be properly combined
24262. Second item"#;
2427
2428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2429 let fixed = rule.fix(&ctx).unwrap();
2430
2431 assert!(
2433 !fixed.contains("lines that"),
2434 "Should not have double spaces in bullet list"
2435 );
2436 assert!(
2437 !fixed.contains("need to"),
2438 "Should not have double spaces in numbered list"
2439 );
2440
2441 assert!(
2443 fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2444 "Bullet list should be properly combined"
2445 );
2446 assert!(
2447 fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2448 "Numbered list should be properly combined"
2449 );
2450 }
2451
2452 #[test]
2453 fn test_normalize_mode_actual_numbered_list() {
2454 let config = MD013Config {
2456 line_length: 100,
2457 reflow: true,
2458 reflow_mode: ReflowMode::Normalize,
2459 ..Default::default()
2460 };
2461 let rule = MD013LineLength::from_config_struct(config);
2462
2463 let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2465
2466 let fixed = rule.fix(&ctx).unwrap();
2467 assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2468 assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2469 assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2470 assert!(
2471 fixed.starts_with("Paragraph before list with multiple lines."),
2472 "Paragraph should be normalized"
2473 );
2474 }
2475
2476 #[test]
2477 fn test_sentence_per_line_detection() {
2478 let config = MD013Config {
2479 reflow: true,
2480 reflow_mode: ReflowMode::SentencePerLine,
2481 ..Default::default()
2482 };
2483 let rule = MD013LineLength::from_config_struct(config.clone());
2484
2485 let content = "This is sentence one. This is sentence two. And sentence three!";
2487 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2488
2489 assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2491
2492 let result = rule.check(&ctx).unwrap();
2493
2494 assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2495 assert_eq!(
2496 result[0].message,
2497 "Line contains multiple sentences (one sentence per line expected)"
2498 );
2499 }
2500
2501 #[test]
2502 fn test_sentence_per_line_fix() {
2503 let config = MD013Config {
2504 reflow: true,
2505 reflow_mode: ReflowMode::SentencePerLine,
2506 ..Default::default()
2507 };
2508 let rule = MD013LineLength::from_config_struct(config);
2509
2510 let content = "First sentence. Second sentence.";
2511 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2512 let result = rule.check(&ctx).unwrap();
2513
2514 assert!(!result.is_empty(), "Should detect violation");
2515 assert!(result[0].fix.is_some(), "Should provide a fix");
2516
2517 let fix = result[0].fix.as_ref().unwrap();
2518 assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2519 }
2520
2521 #[test]
2522 fn test_sentence_per_line_abbreviations() {
2523 let config = MD013Config {
2524 reflow: true,
2525 reflow_mode: ReflowMode::SentencePerLine,
2526 ..Default::default()
2527 };
2528 let rule = MD013LineLength::from_config_struct(config);
2529
2530 let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2532 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2533 let result = rule.check(&ctx).unwrap();
2534
2535 assert!(
2536 result.is_empty(),
2537 "Should not detect abbreviations as sentence boundaries"
2538 );
2539 }
2540
2541 #[test]
2542 fn test_sentence_per_line_with_markdown() {
2543 let config = MD013Config {
2544 reflow: true,
2545 reflow_mode: ReflowMode::SentencePerLine,
2546 ..Default::default()
2547 };
2548 let rule = MD013LineLength::from_config_struct(config);
2549
2550 let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
2551 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2552 let result = rule.check(&ctx).unwrap();
2553
2554 assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
2555 assert_eq!(result[0].line, 3); }
2557
2558 #[test]
2559 fn test_sentence_per_line_questions_exclamations() {
2560 let config = MD013Config {
2561 reflow: true,
2562 reflow_mode: ReflowMode::SentencePerLine,
2563 ..Default::default()
2564 };
2565 let rule = MD013LineLength::from_config_struct(config);
2566
2567 let content = "Is this a question? Yes it is! And a statement.";
2568 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2569 let result = rule.check(&ctx).unwrap();
2570
2571 assert!(!result.is_empty(), "Should detect sentences with ? and !");
2572
2573 let fix = result[0].fix.as_ref().unwrap();
2574 let lines: Vec<&str> = fix.replacement.trim().lines().collect();
2575 assert_eq!(lines.len(), 3);
2576 assert_eq!(lines[0], "Is this a question?");
2577 assert_eq!(lines[1], "Yes it is!");
2578 assert_eq!(lines[2], "And a statement.");
2579 }
2580
2581 #[test]
2582 fn test_sentence_per_line_in_lists() {
2583 let config = MD013Config {
2584 reflow: true,
2585 reflow_mode: ReflowMode::SentencePerLine,
2586 ..Default::default()
2587 };
2588 let rule = MD013LineLength::from_config_struct(config);
2589
2590 let content = "- List item one. With two sentences.\n- Another item.";
2591 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2592 let result = rule.check(&ctx).unwrap();
2593
2594 assert!(!result.is_empty(), "Should detect sentences in list items");
2595 let fix = result[0].fix.as_ref().unwrap();
2597 assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
2598 }
2599
2600 #[test]
2601 fn test_multi_paragraph_list_item_with_3_space_indent() {
2602 let config = MD013Config {
2603 reflow: true,
2604 reflow_mode: ReflowMode::Normalize,
2605 line_length: 999999,
2606 ..Default::default()
2607 };
2608 let rule = MD013LineLength::from_config_struct(config);
2609
2610 let content = "1. First paragraph\n continuation line.\n\n Second paragraph\n more content.";
2611 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2612 let result = rule.check(&ctx).unwrap();
2613
2614 assert!(!result.is_empty(), "Should detect multi-line paragraphs in list item");
2615 let fix = result[0].fix.as_ref().unwrap();
2616
2617 assert!(
2619 fix.replacement.contains("\n\n"),
2620 "Should preserve blank line between paragraphs"
2621 );
2622 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
2623 }
2624
2625 #[test]
2626 fn test_multi_paragraph_list_item_with_4_space_indent() {
2627 let config = MD013Config {
2628 reflow: true,
2629 reflow_mode: ReflowMode::Normalize,
2630 line_length: 999999,
2631 ..Default::default()
2632 };
2633 let rule = MD013LineLength::from_config_struct(config);
2634
2635 let content = "1. It **generated an application template**. There's a lot of files and\n configurations required to build a native installer, above and\n beyond the code of your actual application.\n\n If you're not happy with the template provided by Briefcase, you can\n provide your own.";
2637 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2638 let result = rule.check(&ctx).unwrap();
2639
2640 assert!(
2641 !result.is_empty(),
2642 "Should detect multi-line paragraphs in list item with 4-space indent"
2643 );
2644 let fix = result[0].fix.as_ref().unwrap();
2645
2646 assert!(
2648 fix.replacement.contains("\n\n"),
2649 "Should preserve blank line between paragraphs"
2650 );
2651 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
2652
2653 let lines: Vec<&str> = fix.replacement.split('\n').collect();
2655 let blank_line_idx = lines.iter().position(|l| l.trim().is_empty());
2656 assert!(blank_line_idx.is_some(), "Should have blank line separating paragraphs");
2657 }
2658
2659 #[test]
2660 fn test_multi_paragraph_bullet_list_item() {
2661 let config = MD013Config {
2662 reflow: true,
2663 reflow_mode: ReflowMode::Normalize,
2664 line_length: 999999,
2665 ..Default::default()
2666 };
2667 let rule = MD013LineLength::from_config_struct(config);
2668
2669 let content = "- First paragraph\n continuation.\n\n Second paragraph\n more text.";
2670 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2671 let result = rule.check(&ctx).unwrap();
2672
2673 assert!(!result.is_empty(), "Should detect multi-line paragraphs in bullet list");
2674 let fix = result[0].fix.as_ref().unwrap();
2675
2676 assert!(
2677 fix.replacement.contains("\n\n"),
2678 "Should preserve blank line between paragraphs"
2679 );
2680 assert!(fix.replacement.starts_with("- "), "Should preserve bullet marker");
2681 }
2682
2683 #[test]
2684 fn test_code_block_in_list_item_five_spaces() {
2685 let config = MD013Config {
2686 reflow: true,
2687 reflow_mode: ReflowMode::Normalize,
2688 line_length: 80,
2689 ..Default::default()
2690 };
2691 let rule = MD013LineLength::from_config_struct(config);
2692
2693 let content = "1. First paragraph with some text that should be reflowed.\n\n code_block()\n more_code()\n\n Second paragraph.";
2696 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2697 let result = rule.check(&ctx).unwrap();
2698
2699 if !result.is_empty() {
2700 let fix = result[0].fix.as_ref().unwrap();
2701 assert!(
2703 fix.replacement.contains(" code_block()"),
2704 "Code block should be preserved: {}",
2705 fix.replacement
2706 );
2707 assert!(
2708 fix.replacement.contains(" more_code()"),
2709 "Code block should be preserved: {}",
2710 fix.replacement
2711 );
2712 }
2713 }
2714
2715 #[test]
2716 fn test_fenced_code_block_in_list_item() {
2717 let config = MD013Config {
2718 reflow: true,
2719 reflow_mode: ReflowMode::Normalize,
2720 line_length: 80,
2721 ..Default::default()
2722 };
2723 let rule = MD013LineLength::from_config_struct(config);
2724
2725 let content = "1. First paragraph with some text.\n\n ```rust\n fn foo() {}\n let x = 1;\n ```\n\n Second paragraph.";
2726 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2727 let result = rule.check(&ctx).unwrap();
2728
2729 if !result.is_empty() {
2730 let fix = result[0].fix.as_ref().unwrap();
2731 assert!(
2733 fix.replacement.contains("```rust"),
2734 "Should preserve fence: {}",
2735 fix.replacement
2736 );
2737 assert!(
2738 fix.replacement.contains("fn foo() {}"),
2739 "Should preserve code: {}",
2740 fix.replacement
2741 );
2742 assert!(
2743 fix.replacement.contains("```"),
2744 "Should preserve closing fence: {}",
2745 fix.replacement
2746 );
2747 }
2748 }
2749
2750 #[test]
2751 fn test_mixed_indentation_3_and_4_spaces() {
2752 let config = MD013Config {
2753 reflow: true,
2754 reflow_mode: ReflowMode::Normalize,
2755 line_length: 999999,
2756 ..Default::default()
2757 };
2758 let rule = MD013LineLength::from_config_struct(config);
2759
2760 let content = "1. Text\n 3 space continuation\n 4 space continuation";
2762 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2763 let result = rule.check(&ctx).unwrap();
2764
2765 assert!(!result.is_empty(), "Should detect multi-line list item");
2766 let fix = result[0].fix.as_ref().unwrap();
2767 assert!(
2769 fix.replacement.contains("3 space continuation"),
2770 "Should include 3-space line: {}",
2771 fix.replacement
2772 );
2773 assert!(
2774 fix.replacement.contains("4 space continuation"),
2775 "Should include 4-space line: {}",
2776 fix.replacement
2777 );
2778 }
2779
2780 #[test]
2781 fn test_nested_list_in_multi_paragraph_item() {
2782 let config = MD013Config {
2783 reflow: true,
2784 reflow_mode: ReflowMode::Normalize,
2785 line_length: 999999,
2786 ..Default::default()
2787 };
2788 let rule = MD013LineLength::from_config_struct(config);
2789
2790 let content = "1. First paragraph.\n\n - Nested item\n continuation\n\n Second paragraph.";
2791 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2792 let result = rule.check(&ctx).unwrap();
2793
2794 assert!(!result.is_empty(), "Should detect and reflow parent item");
2796 if let Some(fix) = result[0].fix.as_ref() {
2797 assert!(
2799 fix.replacement.contains("- Nested"),
2800 "Should preserve nested list: {}",
2801 fix.replacement
2802 );
2803 assert!(
2804 fix.replacement.contains("Second paragraph"),
2805 "Should include content after nested list: {}",
2806 fix.replacement
2807 );
2808 }
2809 }
2810
2811 #[test]
2812 fn test_nested_fence_markers_different_types() {
2813 let config = MD013Config {
2814 reflow: true,
2815 reflow_mode: ReflowMode::Normalize,
2816 line_length: 80,
2817 ..Default::default()
2818 };
2819 let rule = MD013LineLength::from_config_struct(config);
2820
2821 let content = "1. Example with nested fences:\n\n ~~~markdown\n This shows ```python\n code = True\n ```\n ~~~\n\n Text after.";
2823 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2824 let result = rule.check(&ctx).unwrap();
2825
2826 if !result.is_empty() {
2827 let fix = result[0].fix.as_ref().unwrap();
2828 assert!(
2830 fix.replacement.contains("```python"),
2831 "Should preserve inner fence: {}",
2832 fix.replacement
2833 );
2834 assert!(
2835 fix.replacement.contains("~~~"),
2836 "Should preserve outer fence: {}",
2837 fix.replacement
2838 );
2839 assert!(
2841 fix.replacement.contains("code = True"),
2842 "Should preserve code: {}",
2843 fix.replacement
2844 );
2845 }
2846 }
2847
2848 #[test]
2849 fn test_nested_fence_markers_same_type() {
2850 let config = MD013Config {
2851 reflow: true,
2852 reflow_mode: ReflowMode::Normalize,
2853 line_length: 80,
2854 ..Default::default()
2855 };
2856 let rule = MD013LineLength::from_config_struct(config);
2857
2858 let content =
2860 "1. Example:\n\n ````markdown\n Shows ```python in code\n ```\n text here\n ````\n\n After.";
2861 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2862 let result = rule.check(&ctx).unwrap();
2863
2864 if !result.is_empty() {
2865 let fix = result[0].fix.as_ref().unwrap();
2866 assert!(
2868 fix.replacement.contains("```python"),
2869 "Should preserve inner fence: {}",
2870 fix.replacement
2871 );
2872 assert!(
2873 fix.replacement.contains("````"),
2874 "Should preserve outer fence: {}",
2875 fix.replacement
2876 );
2877 assert!(
2878 fix.replacement.contains("text here"),
2879 "Should keep text as code: {}",
2880 fix.replacement
2881 );
2882 }
2883 }
2884
2885 #[test]
2886 fn test_sibling_list_item_breaks_parent() {
2887 let config = MD013Config {
2888 reflow: true,
2889 reflow_mode: ReflowMode::Normalize,
2890 line_length: 999999,
2891 ..Default::default()
2892 };
2893 let rule = MD013LineLength::from_config_struct(config);
2894
2895 let content = "1. First item\n continuation.\n2. Second item";
2897 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2898 let result = rule.check(&ctx).unwrap();
2899
2900 if !result.is_empty() {
2902 let fix = result[0].fix.as_ref().unwrap();
2903 assert!(fix.replacement.starts_with("1. "), "Should start with first marker");
2905 assert!(fix.replacement.contains("continuation"), "Should include continuation");
2906 }
2908 }
2909
2910 #[test]
2911 fn test_nested_list_at_continuation_indent_preserved() {
2912 let config = MD013Config {
2913 reflow: true,
2914 reflow_mode: ReflowMode::Normalize,
2915 line_length: 999999,
2916 ..Default::default()
2917 };
2918 let rule = MD013LineLength::from_config_struct(config);
2919
2920 let content = "1. Parent paragraph\n with continuation.\n\n - Nested at 3 spaces\n - Another nested\n\n After nested.";
2922 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2923 let result = rule.check(&ctx).unwrap();
2924
2925 if !result.is_empty() {
2926 let fix = result[0].fix.as_ref().unwrap();
2927 assert!(
2929 fix.replacement.contains("- Nested"),
2930 "Should include first nested item: {}",
2931 fix.replacement
2932 );
2933 assert!(
2934 fix.replacement.contains("- Another"),
2935 "Should include second nested item: {}",
2936 fix.replacement
2937 );
2938 assert!(
2939 fix.replacement.contains("After nested"),
2940 "Should include content after nested list: {}",
2941 fix.replacement
2942 );
2943 }
2944 }
2945}