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 = trim_preserving_hard_break(&line_info.content[indent..]);
494 list_item_lines.push(LineType::Content(content));
495 i += 1;
496 } else {
497 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
499 i += 1;
500 }
501 } else {
502 break;
504 }
505 }
506
507 let indent_size = actual_indent.unwrap_or(marker_len);
509 let expected_indent = " ".repeat(indent_size);
510
511 #[derive(Clone)]
513 enum Block {
514 Paragraph(Vec<String>),
515 CodeBlock(Vec<(String, usize)>), }
517
518 let mut blocks: Vec<Block> = Vec::new();
519 let mut current_paragraph: Vec<String> = Vec::new();
520 let mut current_code_block: Vec<(String, usize)> = Vec::new();
521 let mut in_code = false;
522
523 for line in &list_item_lines {
524 match line {
525 LineType::Empty => {
526 if in_code {
527 current_code_block.push((String::new(), 0));
528 } else if !current_paragraph.is_empty() {
529 blocks.push(Block::Paragraph(current_paragraph.clone()));
530 current_paragraph.clear();
531 }
532 }
533 LineType::Content(content) => {
534 if in_code {
535 blocks.push(Block::CodeBlock(current_code_block.clone()));
537 current_code_block.clear();
538 in_code = false;
539 }
540 current_paragraph.push(content.clone());
541 }
542 LineType::CodeBlock(content, indent) => {
543 if !in_code {
544 if !current_paragraph.is_empty() {
546 blocks.push(Block::Paragraph(current_paragraph.clone()));
547 current_paragraph.clear();
548 }
549 in_code = true;
550 }
551 current_code_block.push((content.clone(), *indent));
552 }
553 }
554 }
555
556 if in_code && !current_code_block.is_empty() {
558 blocks.push(Block::CodeBlock(current_code_block));
559 } else if !current_paragraph.is_empty() {
560 blocks.push(Block::Paragraph(current_paragraph));
561 }
562
563 let content_lines: Vec<String> = list_item_lines
565 .iter()
566 .filter_map(|line| {
567 if let LineType::Content(s) = line {
568 Some(s.clone())
569 } else {
570 None
571 }
572 })
573 .collect();
574
575 let combined_content = content_lines.join(" ").trim().to_string();
578 let full_line = format!("{marker}{combined_content}");
579
580 let needs_reflow = self.calculate_effective_length(&full_line) > config.line_length
581 || (config.reflow_mode == ReflowMode::Normalize && content_lines.len() > 1)
582 || (config.reflow_mode == ReflowMode::SentencePerLine && {
583 let sentences = split_into_sentences(&combined_content);
585 sentences.len() > 1
586 });
587
588 if needs_reflow {
589 let start_range = line_index.whole_line_range(list_start + 1);
590 let end_line = i - 1;
591 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
592 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
593 } else {
594 line_index.whole_line_range(end_line + 1)
595 };
596 let byte_range = start_range.start..end_range.end;
597
598 let reflow_options = crate::utils::text_reflow::ReflowOptions {
600 line_length: config.line_length - indent_size,
601 break_on_sentences: true,
602 preserve_breaks: false,
603 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
604 };
605
606 let mut result: Vec<String> = Vec::new();
607 let mut is_first_block = true;
608
609 for (block_idx, block) in blocks.iter().enumerate() {
610 match block {
611 Block::Paragraph(para_lines) => {
612 let segments = split_into_segments(para_lines);
615
616 for (segment_idx, segment) in segments.iter().enumerate() {
617 let has_hard_break = segment.last().is_some_and(|line| line.ends_with(" "));
619
620 let segment_for_reflow: Vec<String> = segment
622 .iter()
623 .map(|line| {
624 if line.ends_with(" ") {
626 line[..line.len() - 2].trim_end().to_string()
627 } else {
628 line.clone()
629 }
630 })
631 .collect();
632
633 let segment_text = segment_for_reflow.join(" ").trim().to_string();
634 if !segment_text.is_empty() {
635 let reflowed =
636 crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
637
638 if is_first_block && segment_idx == 0 {
639 result.push(format!("{marker}{}", reflowed[0]));
641 for line in reflowed.iter().skip(1) {
642 result.push(format!("{expected_indent}{line}"));
643 }
644 is_first_block = false;
645 } else {
646 for line in reflowed {
648 result.push(format!("{expected_indent}{line}"));
649 }
650 }
651
652 if has_hard_break && let Some(last_line) = result.last_mut() {
654 last_line.push_str(" ");
655 }
656 }
657 }
658
659 if block_idx < blocks.len() - 1 {
661 result.push(String::new());
662 }
663 }
664 Block::CodeBlock(code_lines) => {
665 if !is_first_block {
667 result.push(String::new());
668 }
669
670 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
671 if is_first_block && idx == 0 {
672 result.push(format!(
674 "{marker}{}",
675 " ".repeat(orig_indent - marker_len) + content
676 ));
677 is_first_block = false;
678 } else if content.is_empty() {
679 result.push(String::new());
680 } else {
681 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
682 }
683 }
684 }
685 }
686 }
687
688 let reflowed_text = result.join("\n");
689
690 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
692 format!("{reflowed_text}\n")
693 } else {
694 reflowed_text
695 };
696
697 let original_text = &ctx.content[byte_range.clone()];
699
700 if original_text != replacement {
702 warnings.push(LintWarning {
703 rule_name: Some(self.name()),
704 message: if config.reflow_mode == ReflowMode::SentencePerLine {
705 "Line contains multiple sentences (one sentence per line expected)".to_string()
706 } else {
707 format!("Line length exceeds {} characters", config.line_length)
708 },
709 line: list_start + 1,
710 column: 1,
711 end_line: end_line + 1,
712 end_column: lines[end_line].len() + 1,
713 severity: Severity::Warning,
714 fix: Some(crate::rule::Fix {
715 range: byte_range,
716 replacement,
717 }),
718 });
719 }
720 }
721 continue;
722 }
723
724 let paragraph_start = i;
726 let mut paragraph_lines = vec![lines[i]];
727 i += 1;
728
729 while i < lines.len() {
730 let next_line = lines[i];
731 let next_line_num = i + 1;
732 let next_trimmed = next_line.trim();
733
734 if next_trimmed.is_empty()
736 || ctx.is_in_code_block(next_line_num)
737 || ctx.is_in_front_matter(next_line_num)
738 || ctx.is_in_html_block(next_line_num)
739 || (next_line_num > 0
740 && next_line_num <= ctx.lines.len()
741 && ctx.lines[next_line_num - 1].blockquote.is_some())
742 || next_trimmed.starts_with('#')
743 || TableUtils::is_potential_table_row(next_line)
744 || is_list_item(next_trimmed)
745 || is_horizontal_rule(next_trimmed)
746 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
747 {
748 break;
749 }
750
751 if i > 0 && lines[i - 1].ends_with(" ") {
753 break;
755 }
756
757 paragraph_lines.push(next_line);
758 i += 1;
759 }
760
761 let needs_reflow = match config.reflow_mode {
763 ReflowMode::Normalize => {
764 paragraph_lines.len() > 1
766 }
767 ReflowMode::SentencePerLine => {
768 paragraph_lines.iter().any(|line| {
770 let sentences = split_into_sentences(line);
772 sentences.len() > 1
773 })
774 }
775 ReflowMode::Default => {
776 paragraph_lines
778 .iter()
779 .any(|line| self.calculate_effective_length(line) > config.line_length)
780 }
781 };
782
783 if needs_reflow {
784 let start_range = line_index.whole_line_range(paragraph_start + 1);
787 let end_line = paragraph_start + paragraph_lines.len() - 1;
788
789 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
791 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
793 } else {
794 line_index.whole_line_range(end_line + 1)
796 };
797
798 let byte_range = start_range.start..end_range.end;
799
800 let paragraph_text = paragraph_lines.join(" ");
802
803 let has_hard_break = paragraph_lines.last().is_some_and(|l| l.ends_with(" "));
805
806 let reflow_options = crate::utils::text_reflow::ReflowOptions {
808 line_length: config.line_length,
809 break_on_sentences: true,
810 preserve_breaks: false,
811 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
812 };
813 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
814
815 if has_hard_break && !reflowed.is_empty() {
817 let last_idx = reflowed.len() - 1;
818 if !reflowed[last_idx].ends_with(" ") {
819 reflowed[last_idx].push_str(" ");
820 }
821 }
822
823 let reflowed_text = reflowed.join("\n");
824
825 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
827 format!("{reflowed_text}\n")
828 } else {
829 reflowed_text
830 };
831
832 let original_text = &ctx.content[byte_range.clone()];
834
835 if original_text != replacement {
837 let (warning_line, warning_end_line) = match config.reflow_mode {
842 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
843 ReflowMode::SentencePerLine => {
844 let mut violating_line = paragraph_start;
846 for (idx, line) in paragraph_lines.iter().enumerate() {
847 let sentences = split_into_sentences(line);
848 if sentences.len() > 1 {
849 violating_line = paragraph_start + idx;
850 break;
851 }
852 }
853 (violating_line + 1, violating_line + 1)
854 }
855 ReflowMode::Default => {
856 let mut violating_line = paragraph_start;
858 for (idx, line) in paragraph_lines.iter().enumerate() {
859 if self.calculate_effective_length(line) > config.line_length {
860 violating_line = paragraph_start + idx;
861 break;
862 }
863 }
864 (violating_line + 1, violating_line + 1)
865 }
866 };
867
868 warnings.push(LintWarning {
869 rule_name: Some(self.name()),
870 message: match config.reflow_mode {
871 ReflowMode::Normalize => format!(
872 "Paragraph could be normalized to use line length of {} characters",
873 config.line_length
874 ),
875 ReflowMode::SentencePerLine => {
876 "Line contains multiple sentences (one sentence per line expected)".to_string()
877 }
878 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length),
879 },
880 line: warning_line,
881 column: 1,
882 end_line: warning_end_line,
883 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
884 severity: Severity::Warning,
885 fix: Some(crate::rule::Fix {
886 range: byte_range,
887 replacement,
888 }),
889 });
890 }
891 }
892 }
893
894 warnings
895 }
896
897 fn calculate_effective_length(&self, line: &str) -> usize {
899 if self.config.strict {
900 return line.chars().count();
902 }
903
904 let bytes = line.as_bytes();
906 if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
907 return line.chars().count();
908 }
909
910 if !line.contains("http") && !line.contains('[') {
912 return line.chars().count();
913 }
914
915 let mut effective_line = line.to_string();
916
917 if line.contains('[') && line.contains("](") {
920 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
921 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
922 && url.as_str().len() > 15
923 {
924 let replacement = format!("[{}](url)", text.as_str());
925 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
926 }
927 }
928 }
929
930 if effective_line.contains("http") {
933 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
934 let url = url_match.as_str();
935 if !effective_line.contains(&format!("({url})")) {
937 let placeholder = "x".repeat(15.min(url.len()));
940 effective_line = effective_line.replacen(url, &placeholder, 1);
941 }
942 }
943 }
944
945 effective_line.chars().count()
946 }
947}
948
949fn trim_preserving_hard_break(s: &str) -> String {
953 let s = s.strip_suffix('\r').unwrap_or(s);
955
956 if s.ends_with(" ") {
958 let content_end = s.trim_end().len();
960 if content_end == 0 {
961 return String::new();
963 }
964 format!("{} ", &s[..content_end])
966 } else {
967 s.trim_end().to_string()
969 }
970}
971
972fn split_into_segments(para_lines: &[String]) -> Vec<Vec<String>> {
983 let mut segments: Vec<Vec<String>> = Vec::new();
984 let mut current_segment: Vec<String> = Vec::new();
985
986 for line in para_lines {
987 current_segment.push(line.clone());
988
989 if line.ends_with(" ") {
991 segments.push(current_segment.clone());
992 current_segment.clear();
993 }
994 }
995
996 if !current_segment.is_empty() {
998 segments.push(current_segment);
999 }
1000
1001 segments
1002}
1003
1004fn extract_list_marker_and_content(line: &str) -> (String, String) {
1005 let indent_len = line.len() - line.trim_start().len();
1007 let indent = &line[..indent_len];
1008 let trimmed = &line[indent_len..];
1009
1010 if let Some(rest) = trimmed.strip_prefix("- ") {
1013 return (format!("{indent}- "), trim_preserving_hard_break(rest));
1014 }
1015 if let Some(rest) = trimmed.strip_prefix("* ") {
1016 return (format!("{indent}* "), trim_preserving_hard_break(rest));
1017 }
1018 if let Some(rest) = trimmed.strip_prefix("+ ") {
1019 return (format!("{indent}+ "), trim_preserving_hard_break(rest));
1020 }
1021
1022 let mut chars = trimmed.chars();
1024 let mut marker_content = String::new();
1025
1026 while let Some(c) = chars.next() {
1027 marker_content.push(c);
1028 if c == '.' {
1029 if let Some(next) = chars.next()
1031 && next == ' '
1032 {
1033 marker_content.push(next);
1034 let content = trim_preserving_hard_break(chars.as_str());
1036 return (format!("{indent}{marker_content}"), content);
1037 }
1038 break;
1039 }
1040 }
1041
1042 (String::new(), line.to_string())
1044}
1045
1046fn is_horizontal_rule(line: &str) -> bool {
1048 if line.len() < 3 {
1049 return false;
1050 }
1051 let chars: Vec<char> = line.chars().collect();
1053 if chars.is_empty() {
1054 return false;
1055 }
1056 let first_char = chars[0];
1057 if first_char != '-' && first_char != '_' && first_char != '*' {
1058 return false;
1059 }
1060 for c in &chars {
1062 if *c != first_char && *c != ' ' {
1063 return false;
1064 }
1065 }
1066 chars.iter().filter(|c| **c == first_char).count() >= 3
1068}
1069
1070fn is_numbered_list_item(line: &str) -> bool {
1071 let mut chars = line.chars();
1072 if !chars.next().is_some_and(|c| c.is_numeric()) {
1074 return false;
1075 }
1076 while let Some(c) = chars.next() {
1078 if c == '.' {
1079 return chars.next().is_none_or(|c| c == ' ');
1081 }
1082 if !c.is_numeric() {
1083 return false;
1084 }
1085 }
1086 false
1087}
1088
1089fn is_list_item(line: &str) -> bool {
1090 if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
1092 && line.len() > 1
1093 && line.chars().nth(1) == Some(' ')
1094 {
1095 return true;
1096 }
1097 is_numbered_list_item(line)
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use super::*;
1104 use crate::lint_context::LintContext;
1105
1106 #[test]
1107 fn test_default_config() {
1108 let rule = MD013LineLength::default();
1109 assert_eq!(rule.config.line_length, 80);
1110 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
1114 }
1115
1116 #[test]
1117 fn test_custom_config() {
1118 let rule = MD013LineLength::new(100, true, true, false, true);
1119 assert_eq!(rule.config.line_length, 100);
1120 assert!(rule.config.code_blocks);
1121 assert!(rule.config.tables);
1122 assert!(!rule.config.headings);
1123 assert!(rule.config.strict);
1124 }
1125
1126 #[test]
1127 fn test_basic_line_length_violation() {
1128 let rule = MD013LineLength::new(50, false, false, false, false);
1129 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1131 let result = rule.check(&ctx).unwrap();
1132
1133 assert_eq!(result.len(), 1);
1134 assert!(result[0].message.contains("Line length"));
1135 assert!(result[0].message.contains("exceeds 50 characters"));
1136 }
1137
1138 #[test]
1139 fn test_no_violation_under_limit() {
1140 let rule = MD013LineLength::new(100, false, false, false, false);
1141 let content = "Short line.\nAnother short line.";
1142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1143 let result = rule.check(&ctx).unwrap();
1144
1145 assert_eq!(result.len(), 0);
1146 }
1147
1148 #[test]
1149 fn test_multiple_violations() {
1150 let rule = MD013LineLength::new(30, false, false, false, false);
1151 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
1152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1153 let result = rule.check(&ctx).unwrap();
1154
1155 assert_eq!(result.len(), 2);
1156 assert_eq!(result[0].line, 1);
1157 assert_eq!(result[1].line, 2);
1158 }
1159
1160 #[test]
1161 fn test_code_blocks_exemption() {
1162 let rule = MD013LineLength::new(30, false, false, false, false);
1164 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
1165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1166 let result = rule.check(&ctx).unwrap();
1167
1168 assert_eq!(result.len(), 0);
1169 }
1170
1171 #[test]
1172 fn test_code_blocks_not_exempt_when_configured() {
1173 let rule = MD013LineLength::new(30, true, false, false, false);
1175 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
1176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1177 let result = rule.check(&ctx).unwrap();
1178
1179 assert!(!result.is_empty());
1180 }
1181
1182 #[test]
1183 fn test_heading_checked_when_enabled() {
1184 let rule = MD013LineLength::new(30, false, false, true, false);
1185 let content = "# This is a very long heading that would normally exceed the limit";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1187 let result = rule.check(&ctx).unwrap();
1188
1189 assert_eq!(result.len(), 1);
1190 }
1191
1192 #[test]
1193 fn test_heading_exempt_when_disabled() {
1194 let rule = MD013LineLength::new(30, false, false, false, false);
1195 let content = "# This is a very long heading that should trigger a warning";
1196 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1197 let result = rule.check(&ctx).unwrap();
1198
1199 assert_eq!(result.len(), 0);
1200 }
1201
1202 #[test]
1203 fn test_table_checked_when_enabled() {
1204 let rule = MD013LineLength::new(30, false, true, false, false);
1205 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
1206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1207 let result = rule.check(&ctx).unwrap();
1208
1209 assert_eq!(result.len(), 2); }
1211
1212 #[test]
1213 fn test_issue_78_tables_after_fenced_code_blocks() {
1214 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1217
1218```plain
1219some code block longer than 20 chars length
1220```
1221
1222this is a very long line
1223
1224| column A | column B |
1225| -------- | -------- |
1226| `var` | `val` |
1227| value 1 | value 2 |
1228
1229correct length line"#;
1230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1231 let result = rule.check(&ctx).unwrap();
1232
1233 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1235 assert_eq!(result[0].line, 7, "Should flag line 7");
1236 assert!(result[0].message.contains("24 exceeds 20"));
1237 }
1238
1239 #[test]
1240 fn test_issue_78_tables_with_inline_code() {
1241 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
1244| -------- | -------- |
1245| `var with very long name` | `val exceeding limit` |
1246| value 1 | value 2 |
1247
1248This line exceeds limit"#;
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1250 let result = rule.check(&ctx).unwrap();
1251
1252 assert_eq!(result.len(), 1, "Should only flag the non-table line");
1254 assert_eq!(result[0].line, 6, "Should flag line 6");
1255 }
1256
1257 #[test]
1258 fn test_issue_78_indented_code_blocks() {
1259 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1262
1263 some code block longer than 20 chars length
1264
1265this is a very long line
1266
1267| column A | column B |
1268| -------- | -------- |
1269| value 1 | value 2 |
1270
1271correct length line"#;
1272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1273 let result = rule.check(&ctx).unwrap();
1274
1275 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1277 assert_eq!(result[0].line, 5, "Should flag line 5");
1278 }
1279
1280 #[test]
1281 fn test_url_exemption() {
1282 let rule = MD013LineLength::new(30, false, false, false, false);
1283 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1285 let result = rule.check(&ctx).unwrap();
1286
1287 assert_eq!(result.len(), 0);
1288 }
1289
1290 #[test]
1291 fn test_image_reference_exemption() {
1292 let rule = MD013LineLength::new(30, false, false, false, false);
1293 let content = "![This is a very long image alt text that exceeds limit][reference]";
1294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1295 let result = rule.check(&ctx).unwrap();
1296
1297 assert_eq!(result.len(), 0);
1298 }
1299
1300 #[test]
1301 fn test_link_reference_exemption() {
1302 let rule = MD013LineLength::new(30, false, false, false, false);
1303 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1304 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1305 let result = rule.check(&ctx).unwrap();
1306
1307 assert_eq!(result.len(), 0);
1308 }
1309
1310 #[test]
1311 fn test_strict_mode() {
1312 let rule = MD013LineLength::new(30, false, false, false, true);
1313 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1315 let result = rule.check(&ctx).unwrap();
1316
1317 assert_eq!(result.len(), 1);
1319 }
1320
1321 #[test]
1322 fn test_blockquote_exemption() {
1323 let rule = MD013LineLength::new(30, false, false, false, false);
1324 let content = "> This is a very long line inside a blockquote that should be ignored.";
1325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1326 let result = rule.check(&ctx).unwrap();
1327
1328 assert_eq!(result.len(), 0);
1329 }
1330
1331 #[test]
1332 fn test_setext_heading_underline_exemption() {
1333 let rule = MD013LineLength::new(30, false, false, false, false);
1334 let content = "Heading\n========================================";
1335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1336 let result = rule.check(&ctx).unwrap();
1337
1338 assert_eq!(result.len(), 0);
1340 }
1341
1342 #[test]
1343 fn test_no_fix_without_reflow() {
1344 let rule = MD013LineLength::new(60, false, false, false, false);
1345 let content = "This line has trailing whitespace that makes it too long ";
1346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1347 let result = rule.check(&ctx).unwrap();
1348
1349 assert_eq!(result.len(), 1);
1350 assert!(result[0].fix.is_none());
1352
1353 let fixed = rule.fix(&ctx).unwrap();
1355 assert_eq!(fixed, content);
1356 }
1357
1358 #[test]
1359 fn test_character_vs_byte_counting() {
1360 let rule = MD013LineLength::new(10, false, false, false, false);
1361 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1364 let result = rule.check(&ctx).unwrap();
1365
1366 assert_eq!(result.len(), 1);
1367 assert_eq!(result[0].line, 1);
1368 }
1369
1370 #[test]
1371 fn test_empty_content() {
1372 let rule = MD013LineLength::default();
1373 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1374 let result = rule.check(&ctx).unwrap();
1375
1376 assert_eq!(result.len(), 0);
1377 }
1378
1379 #[test]
1380 fn test_excess_range_calculation() {
1381 let rule = MD013LineLength::new(10, false, false, false, false);
1382 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1384 let result = rule.check(&ctx).unwrap();
1385
1386 assert_eq!(result.len(), 1);
1387 assert_eq!(result[0].column, 11);
1389 assert_eq!(result[0].end_column, 21);
1390 }
1391
1392 #[test]
1393 fn test_html_block_exemption() {
1394 let rule = MD013LineLength::new(30, false, false, false, false);
1395 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1397 let result = rule.check(&ctx).unwrap();
1398
1399 assert_eq!(result.len(), 0);
1401 }
1402
1403 #[test]
1404 fn test_mixed_content() {
1405 let rule = MD013LineLength::new(30, false, false, false, false);
1407 let content = r#"# This heading is very long but should be exempt
1408
1409This regular paragraph line is too long and should trigger.
1410
1411```
1412Code block line that is very long but exempt.
1413```
1414
1415| Table | With very long content |
1416|-------|------------------------|
1417
1418Another long line that should trigger a warning."#;
1419
1420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1421 let result = rule.check(&ctx).unwrap();
1422
1423 assert_eq!(result.len(), 2);
1425 assert_eq!(result[0].line, 3);
1426 assert_eq!(result[1].line, 12);
1427 }
1428
1429 #[test]
1430 fn test_fix_without_reflow_preserves_content() {
1431 let rule = MD013LineLength::new(50, false, false, false, false);
1432 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
1433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1434
1435 let fixed = rule.fix(&ctx).unwrap();
1437 assert_eq!(fixed, content);
1438 }
1439
1440 #[test]
1441 fn test_content_detection() {
1442 let rule = MD013LineLength::default();
1443
1444 let long_line = "a".repeat(100);
1446 let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
1447 assert!(!rule.should_skip(&ctx)); let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1450 assert!(rule.should_skip(&empty_ctx)); }
1452
1453 #[test]
1454 fn test_rule_metadata() {
1455 let rule = MD013LineLength::default();
1456 assert_eq!(rule.name(), "MD013");
1457 assert_eq!(rule.description(), "Line length should not be excessive");
1458 assert_eq!(rule.category(), RuleCategory::Whitespace);
1459 }
1460
1461 #[test]
1462 fn test_url_embedded_in_text() {
1463 let rule = MD013LineLength::new(50, false, false, false, false);
1464
1465 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1468 let result = rule.check(&ctx).unwrap();
1469
1470 assert_eq!(result.len(), 0);
1472 }
1473
1474 #[test]
1475 fn test_multiple_urls_in_line() {
1476 let rule = MD013LineLength::new(50, false, false, false, false);
1477
1478 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1481
1482 let result = rule.check(&ctx).unwrap();
1483
1484 assert_eq!(result.len(), 0);
1486 }
1487
1488 #[test]
1489 fn test_markdown_link_with_long_url() {
1490 let rule = MD013LineLength::new(50, false, false, false, false);
1491
1492 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1495 let result = rule.check(&ctx).unwrap();
1496
1497 assert_eq!(result.len(), 0);
1499 }
1500
1501 #[test]
1502 fn test_line_too_long_even_without_urls() {
1503 let rule = MD013LineLength::new(50, false, false, false, false);
1504
1505 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1508 let result = rule.check(&ctx).unwrap();
1509
1510 assert_eq!(result.len(), 1);
1512 }
1513
1514 #[test]
1515 fn test_strict_mode_counts_urls() {
1516 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";
1520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1521 let result = rule.check(&ctx).unwrap();
1522
1523 assert_eq!(result.len(), 1);
1525 }
1526
1527 #[test]
1528 fn test_documentation_example_from_md051() {
1529 let rule = MD013LineLength::new(80, false, false, false, false);
1530
1531 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1534 let result = rule.check(&ctx).unwrap();
1535
1536 assert_eq!(result.len(), 0);
1538 }
1539
1540 #[test]
1541 fn test_text_reflow_simple() {
1542 let config = MD013Config {
1543 line_length: 30,
1544 reflow: true,
1545 ..Default::default()
1546 };
1547 let rule = MD013LineLength::from_config_struct(config);
1548
1549 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1551
1552 let fixed = rule.fix(&ctx).unwrap();
1553
1554 for line in fixed.lines() {
1556 assert!(
1557 line.chars().count() <= 30,
1558 "Line too long: {} (len={})",
1559 line,
1560 line.chars().count()
1561 );
1562 }
1563
1564 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1566 let original_words: Vec<&str> = content.split_whitespace().collect();
1567 assert_eq!(fixed_words, original_words);
1568 }
1569
1570 #[test]
1571 fn test_text_reflow_preserves_markdown_elements() {
1572 let config = MD013Config {
1573 line_length: 40,
1574 reflow: true,
1575 ..Default::default()
1576 };
1577 let rule = MD013LineLength::from_config_struct(config);
1578
1579 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1581
1582 let fixed = rule.fix(&ctx).unwrap();
1583
1584 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1586 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1587 assert!(
1588 fixed.contains("[a link](https://example.com)"),
1589 "Link not preserved in: {fixed}"
1590 );
1591
1592 for line in fixed.lines() {
1594 assert!(line.len() <= 40, "Line too long: {line}");
1595 }
1596 }
1597
1598 #[test]
1599 fn test_text_reflow_preserves_code_blocks() {
1600 let config = MD013Config {
1601 line_length: 30,
1602 reflow: true,
1603 ..Default::default()
1604 };
1605 let rule = MD013LineLength::from_config_struct(config);
1606
1607 let content = r#"Here is some text.
1608
1609```python
1610def very_long_function_name_that_exceeds_limit():
1611 return "This should not be wrapped"
1612```
1613
1614More text after code block."#;
1615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1616
1617 let fixed = rule.fix(&ctx).unwrap();
1618
1619 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1621 assert!(fixed.contains("```python"));
1622 assert!(fixed.contains("```"));
1623 }
1624
1625 #[test]
1626 fn test_text_reflow_preserves_lists() {
1627 let config = MD013Config {
1628 line_length: 30,
1629 reflow: true,
1630 ..Default::default()
1631 };
1632 let rule = MD013LineLength::from_config_struct(config);
1633
1634 let content = r#"Here is a list:
1635
16361. First item with a very long line that needs wrapping
16372. Second item is short
16383. Third item also has a long line that exceeds the limit
1639
1640And a bullet list:
1641
1642- Bullet item with very long content that needs wrapping
1643- Short bullet"#;
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1645
1646 let fixed = rule.fix(&ctx).unwrap();
1647
1648 assert!(fixed.contains("1. "));
1650 assert!(fixed.contains("2. "));
1651 assert!(fixed.contains("3. "));
1652 assert!(fixed.contains("- "));
1653
1654 let lines: Vec<&str> = fixed.lines().collect();
1656 for (i, line) in lines.iter().enumerate() {
1657 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1658 if i + 1 < lines.len()
1660 && !lines[i + 1].trim().is_empty()
1661 && !lines[i + 1].trim().starts_with(char::is_numeric)
1662 && !lines[i + 1].trim().starts_with("-")
1663 {
1664 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1666 }
1667 } else if line.trim().starts_with("-") {
1668 if i + 1 < lines.len()
1670 && !lines[i + 1].trim().is_empty()
1671 && !lines[i + 1].trim().starts_with(char::is_numeric)
1672 && !lines[i + 1].trim().starts_with("-")
1673 {
1674 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1676 }
1677 }
1678 }
1679 }
1680
1681 #[test]
1682 fn test_issue_83_numbered_list_with_backticks() {
1683 let config = MD013Config {
1685 line_length: 100,
1686 reflow: true,
1687 ..Default::default()
1688 };
1689 let rule = MD013LineLength::from_config_struct(config);
1690
1691 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
1693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1694
1695 let fixed = rule.fix(&ctx).unwrap();
1696
1697 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
1700
1701 assert_eq!(
1702 fixed, expected,
1703 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
1704 );
1705 }
1706
1707 #[test]
1708 fn test_text_reflow_disabled_by_default() {
1709 let rule = MD013LineLength::new(30, false, false, false, false);
1710
1711 let content = "This is a very long line that definitely exceeds thirty characters.";
1712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1713
1714 let fixed = rule.fix(&ctx).unwrap();
1715
1716 assert_eq!(fixed, content);
1719 }
1720
1721 #[test]
1722 fn test_reflow_with_hard_line_breaks() {
1723 let config = MD013Config {
1725 line_length: 40,
1726 reflow: true,
1727 ..Default::default()
1728 };
1729 let rule = MD013LineLength::from_config_struct(config);
1730
1731 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";
1733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1734 let fixed = rule.fix(&ctx).unwrap();
1735
1736 assert!(
1738 fixed.contains(" \n"),
1739 "Hard line break with exactly 2 spaces should be preserved"
1740 );
1741 }
1742
1743 #[test]
1744 fn test_reflow_preserves_reference_links() {
1745 let config = MD013Config {
1746 line_length: 40,
1747 reflow: true,
1748 ..Default::default()
1749 };
1750 let rule = MD013LineLength::from_config_struct(config);
1751
1752 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
1753
1754[ref]: https://example.com";
1755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1756 let fixed = rule.fix(&ctx).unwrap();
1757
1758 assert!(fixed.contains("[reference link][ref]"));
1760 assert!(!fixed.contains("[ reference link]"));
1761 assert!(!fixed.contains("[ref ]"));
1762 }
1763
1764 #[test]
1765 fn test_reflow_with_nested_markdown_elements() {
1766 let config = MD013Config {
1767 line_length: 35,
1768 reflow: true,
1769 ..Default::default()
1770 };
1771 let rule = MD013LineLength::from_config_struct(config);
1772
1773 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
1774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1775 let fixed = rule.fix(&ctx).unwrap();
1776
1777 assert!(fixed.contains("**bold with `code` inside**"));
1779 }
1780
1781 #[test]
1782 fn test_reflow_with_unbalanced_markdown() {
1783 let config = MD013Config {
1785 line_length: 30,
1786 reflow: true,
1787 ..Default::default()
1788 };
1789 let rule = MD013LineLength::from_config_struct(config);
1790
1791 let content = "This has **unbalanced bold that goes on for a very long time without closing";
1792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1793 let fixed = rule.fix(&ctx).unwrap();
1794
1795 assert!(!fixed.is_empty());
1799 for line in fixed.lines() {
1801 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
1802 }
1803 }
1804
1805 #[test]
1806 fn test_reflow_fix_indicator() {
1807 let config = MD013Config {
1809 line_length: 30,
1810 reflow: true,
1811 ..Default::default()
1812 };
1813 let rule = MD013LineLength::from_config_struct(config);
1814
1815 let content = "This is a very long line that definitely exceeds the thirty character limit";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1817 let warnings = rule.check(&ctx).unwrap();
1818
1819 assert!(!warnings.is_empty());
1821 assert!(
1822 warnings[0].fix.is_some(),
1823 "Should provide fix indicator when reflow is true"
1824 );
1825 }
1826
1827 #[test]
1828 fn test_no_fix_indicator_without_reflow() {
1829 let config = MD013Config {
1831 line_length: 30,
1832 reflow: false,
1833 ..Default::default()
1834 };
1835 let rule = MD013LineLength::from_config_struct(config);
1836
1837 let content = "This is a very long line that definitely exceeds the thirty character limit";
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1839 let warnings = rule.check(&ctx).unwrap();
1840
1841 assert!(!warnings.is_empty());
1843 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
1844 }
1845
1846 #[test]
1847 fn test_reflow_preserves_all_reference_link_types() {
1848 let config = MD013Config {
1849 line_length: 40,
1850 reflow: true,
1851 ..Default::default()
1852 };
1853 let rule = MD013LineLength::from_config_struct(config);
1854
1855 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
1856
1857[ref]: https://example.com
1858[collapsed]: https://example.com
1859[shortcut]: https://example.com";
1860
1861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1862 let fixed = rule.fix(&ctx).unwrap();
1863
1864 assert!(fixed.contains("[full reference][ref]"));
1866 assert!(fixed.contains("[collapsed][]"));
1867 assert!(fixed.contains("[shortcut]"));
1868 }
1869
1870 #[test]
1871 fn test_reflow_handles_images_correctly() {
1872 let config = MD013Config {
1873 line_length: 40,
1874 reflow: true,
1875 ..Default::default()
1876 };
1877 let rule = MD013LineLength::from_config_struct(config);
1878
1879 let content = "This line has an  that should not be broken when reflowing.";
1880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1881 let fixed = rule.fix(&ctx).unwrap();
1882
1883 assert!(fixed.contains(""));
1885 }
1886
1887 #[test]
1888 fn test_normalize_mode_flags_short_lines() {
1889 let config = MD013Config {
1890 line_length: 100,
1891 reflow: true,
1892 reflow_mode: ReflowMode::Normalize,
1893 ..Default::default()
1894 };
1895 let rule = MD013LineLength::from_config_struct(config);
1896
1897 let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
1899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1900 let warnings = rule.check(&ctx).unwrap();
1901
1902 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1904 assert!(warnings[0].message.contains("normalized"));
1905 }
1906
1907 #[test]
1908 fn test_normalize_mode_combines_short_lines() {
1909 let config = MD013Config {
1910 line_length: 100,
1911 reflow: true,
1912 reflow_mode: ReflowMode::Normalize,
1913 ..Default::default()
1914 };
1915 let rule = MD013LineLength::from_config_struct(config);
1916
1917 let content =
1919 "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
1920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1921 let fixed = rule.fix(&ctx).unwrap();
1922
1923 let lines: Vec<&str> = fixed.lines().collect();
1925 assert_eq!(lines.len(), 1, "Should combine into single line");
1926 assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
1927 }
1928
1929 #[test]
1930 fn test_normalize_mode_preserves_paragraph_breaks() {
1931 let config = MD013Config {
1932 line_length: 100,
1933 reflow: true,
1934 reflow_mode: ReflowMode::Normalize,
1935 ..Default::default()
1936 };
1937 let rule = MD013LineLength::from_config_struct(config);
1938
1939 let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
1940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1941 let fixed = rule.fix(&ctx).unwrap();
1942
1943 assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
1945
1946 let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
1947 assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
1948 }
1949
1950 #[test]
1951 fn test_default_mode_only_fixes_violations() {
1952 let config = MD013Config {
1953 line_length: 100,
1954 reflow: true,
1955 reflow_mode: ReflowMode::Default, ..Default::default()
1957 };
1958 let rule = MD013LineLength::from_config_struct(config);
1959
1960 let content = "This is a short line.\nAnother short line.\nA third short line.";
1962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1963 let warnings = rule.check(&ctx).unwrap();
1964
1965 assert!(warnings.is_empty(), "Should not flag short lines in default mode");
1967
1968 let fixed = rule.fix(&ctx).unwrap();
1970 assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
1971 }
1972
1973 #[test]
1974 fn test_normalize_mode_with_lists() {
1975 let config = MD013Config {
1976 line_length: 80,
1977 reflow: true,
1978 reflow_mode: ReflowMode::Normalize,
1979 ..Default::default()
1980 };
1981 let rule = MD013LineLength::from_config_struct(config);
1982
1983 let content = r#"A paragraph with
1984short lines.
1985
19861. List item with
1987 short lines
19882. Another item"#;
1989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1990 let fixed = rule.fix(&ctx).unwrap();
1991
1992 let lines: Vec<&str> = fixed.lines().collect();
1994 assert!(lines[0].len() > 20, "First paragraph should be normalized");
1995 assert!(fixed.contains("1. "), "Should preserve list markers");
1996 assert!(fixed.contains("2. "), "Should preserve list markers");
1997 }
1998
1999 #[test]
2000 fn test_normalize_mode_with_code_blocks() {
2001 let config = MD013Config {
2002 line_length: 100,
2003 reflow: true,
2004 reflow_mode: ReflowMode::Normalize,
2005 ..Default::default()
2006 };
2007 let rule = MD013LineLength::from_config_struct(config);
2008
2009 let content = r#"A paragraph with
2010short lines.
2011
2012```
2013code block should not be normalized
2014even with short lines
2015```
2016
2017Another paragraph with
2018short lines."#;
2019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2020 let fixed = rule.fix(&ctx).unwrap();
2021
2022 assert!(fixed.contains("code block should not be normalized\neven with short lines"));
2024 let lines: Vec<&str> = fixed.lines().collect();
2026 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2027 }
2028
2029 #[test]
2030 fn test_issue_76_use_case() {
2031 let config = MD013Config {
2033 line_length: 999999, reflow: true,
2035 reflow_mode: ReflowMode::Normalize,
2036 ..Default::default()
2037 };
2038 let rule = MD013LineLength::from_config_struct(config);
2039
2040 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.";
2042
2043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2044
2045 let warnings = rule.check(&ctx).unwrap();
2047 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2048
2049 let fixed = rule.fix(&ctx).unwrap();
2051 let lines: Vec<&str> = fixed.lines().collect();
2052 assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
2053 assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
2054 }
2055
2056 #[test]
2057 fn test_normalize_mode_single_line_unchanged() {
2058 let config = MD013Config {
2060 line_length: 100,
2061 reflow: true,
2062 reflow_mode: ReflowMode::Normalize,
2063 ..Default::default()
2064 };
2065 let rule = MD013LineLength::from_config_struct(config);
2066
2067 let content = "This is a single line that should not be changed.";
2068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2069
2070 let warnings = rule.check(&ctx).unwrap();
2071 assert!(warnings.is_empty(), "Single line should not be flagged");
2072
2073 let fixed = rule.fix(&ctx).unwrap();
2074 assert_eq!(fixed, content, "Single line should remain unchanged");
2075 }
2076
2077 #[test]
2078 fn test_normalize_mode_with_inline_code() {
2079 let config = MD013Config {
2080 line_length: 80,
2081 reflow: true,
2082 reflow_mode: ReflowMode::Normalize,
2083 ..Default::default()
2084 };
2085 let rule = MD013LineLength::from_config_struct(config);
2086
2087 let content =
2088 "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
2089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2090
2091 let warnings = rule.check(&ctx).unwrap();
2092 assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
2093
2094 let fixed = rule.fix(&ctx).unwrap();
2095 assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
2096 assert!(fixed.lines().count() < 3, "Lines should be combined");
2097 }
2098
2099 #[test]
2100 fn test_normalize_mode_with_emphasis() {
2101 let config = MD013Config {
2102 line_length: 100,
2103 reflow: true,
2104 reflow_mode: ReflowMode::Normalize,
2105 ..Default::default()
2106 };
2107 let rule = MD013LineLength::from_config_struct(config);
2108
2109 let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
2110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2111
2112 let fixed = rule.fix(&ctx).unwrap();
2113 assert!(fixed.contains("**bold**"), "Bold should be preserved");
2114 assert!(fixed.contains("*italic*"), "Italic should be preserved");
2115 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2116 }
2117
2118 #[test]
2119 fn test_normalize_mode_respects_hard_breaks() {
2120 let config = MD013Config {
2121 line_length: 100,
2122 reflow: true,
2123 reflow_mode: ReflowMode::Normalize,
2124 ..Default::default()
2125 };
2126 let rule = MD013LineLength::from_config_struct(config);
2127
2128 let content = "First line with hard break \nSecond line after break\nThird line";
2130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2131
2132 let fixed = rule.fix(&ctx).unwrap();
2133 assert!(fixed.contains(" \n"), "Hard break should be preserved");
2135 assert!(
2137 fixed.contains("Second line after break Third line"),
2138 "Lines without hard break should combine"
2139 );
2140 }
2141
2142 #[test]
2143 fn test_normalize_mode_with_links() {
2144 let config = MD013Config {
2145 line_length: 100,
2146 reflow: true,
2147 reflow_mode: ReflowMode::Normalize,
2148 ..Default::default()
2149 };
2150 let rule = MD013LineLength::from_config_struct(config);
2151
2152 let content =
2153 "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
2154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2155
2156 let fixed = rule.fix(&ctx).unwrap();
2157 assert!(
2158 fixed.contains("[link](https://example.com)"),
2159 "Link should be preserved"
2160 );
2161 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2162 }
2163
2164 #[test]
2165 fn test_normalize_mode_empty_lines_between_paragraphs() {
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 = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
2175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2176
2177 let fixed = rule.fix(&ctx).unwrap();
2178 assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
2180 let parts: Vec<&str> = fixed.split("\n\n\n").collect();
2182 assert_eq!(parts.len(), 2, "Should have two parts");
2183 assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
2184 assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
2185 }
2186
2187 #[test]
2188 fn test_normalize_mode_mixed_list_types() {
2189 let config = MD013Config {
2190 line_length: 80,
2191 reflow: true,
2192 reflow_mode: ReflowMode::Normalize,
2193 ..Default::default()
2194 };
2195 let rule = MD013LineLength::from_config_struct(config);
2196
2197 let content = r#"Paragraph before list
2198with multiple lines.
2199
2200- Bullet item
2201* Another bullet
2202+ Plus bullet
2203
22041. Numbered item
22052. Another number
2206
2207Paragraph after list
2208with multiple lines."#;
2209
2210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2211 let fixed = rule.fix(&ctx).unwrap();
2212
2213 assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
2215 assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
2216 assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
2217 assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
2218
2219 assert!(
2221 fixed.starts_with("Paragraph before list with multiple lines."),
2222 "First paragraph should be normalized"
2223 );
2224 assert!(
2225 fixed.ends_with("Paragraph after list with multiple lines."),
2226 "Last paragraph should be normalized"
2227 );
2228 }
2229
2230 #[test]
2231 fn test_normalize_mode_with_horizontal_rules() {
2232 let config = MD013Config {
2233 line_length: 100,
2234 reflow: true,
2235 reflow_mode: ReflowMode::Normalize,
2236 ..Default::default()
2237 };
2238 let rule = MD013LineLength::from_config_struct(config);
2239
2240 let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
2241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2242
2243 let fixed = rule.fix(&ctx).unwrap();
2244 assert!(fixed.contains("---"), "Horizontal rule should be preserved");
2245 assert!(
2246 fixed.contains("Paragraph before horizontal rule."),
2247 "First paragraph normalized"
2248 );
2249 assert!(
2250 fixed.contains("Paragraph after horizontal rule."),
2251 "Second paragraph normalized"
2252 );
2253 }
2254
2255 #[test]
2256 fn test_normalize_mode_with_indented_code() {
2257 let config = MD013Config {
2258 line_length: 100,
2259 reflow: true,
2260 reflow_mode: ReflowMode::Normalize,
2261 ..Default::default()
2262 };
2263 let rule = MD013LineLength::from_config_struct(config);
2264
2265 let content = "Paragraph before\nindented code.\n\n This is indented code\n Should not be normalized\n\nParagraph after\nindented code.";
2266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2267
2268 let fixed = rule.fix(&ctx).unwrap();
2269 assert!(
2270 fixed.contains(" This is indented code\n Should not be normalized"),
2271 "Indented code preserved"
2272 );
2273 assert!(
2274 fixed.contains("Paragraph before indented code."),
2275 "First paragraph normalized"
2276 );
2277 assert!(
2278 fixed.contains("Paragraph after indented code."),
2279 "Second paragraph normalized"
2280 );
2281 }
2282
2283 #[test]
2284 fn test_normalize_mode_disabled_without_reflow() {
2285 let config = MD013Config {
2287 line_length: 100,
2288 reflow: false, reflow_mode: ReflowMode::Normalize,
2290 ..Default::default()
2291 };
2292 let rule = MD013LineLength::from_config_struct(config);
2293
2294 let content = "This is a line\nwith breaks that\nshould not be changed.";
2295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2296
2297 let warnings = rule.check(&ctx).unwrap();
2298 assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2299
2300 let fixed = rule.fix(&ctx).unwrap();
2301 assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2302 }
2303
2304 #[test]
2305 fn test_default_mode_with_long_lines() {
2306 let config = MD013Config {
2309 line_length: 50,
2310 reflow: true,
2311 reflow_mode: ReflowMode::Default,
2312 ..Default::default()
2313 };
2314 let rule = MD013LineLength::from_config_struct(config);
2315
2316 let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2318
2319 let warnings = rule.check(&ctx).unwrap();
2320 assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2321 assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2323
2324 let fixed = rule.fix(&ctx).unwrap();
2325 assert!(
2327 fixed.contains("Short line. This is"),
2328 "Should combine and reflow the paragraph"
2329 );
2330 assert!(
2331 fixed.contains("wrapping. Another short"),
2332 "Should include all paragraph content"
2333 );
2334 }
2335
2336 #[test]
2337 fn test_normalize_vs_default_mode_same_content() {
2338 let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2340
2341 let default_config = MD013Config {
2343 line_length: 100,
2344 reflow: true,
2345 reflow_mode: ReflowMode::Default,
2346 ..Default::default()
2347 };
2348 let default_rule = MD013LineLength::from_config_struct(default_config);
2349 let default_warnings = default_rule.check(&ctx).unwrap();
2350 let default_fixed = default_rule.fix(&ctx).unwrap();
2351
2352 let normalize_config = MD013Config {
2354 line_length: 100,
2355 reflow: true,
2356 reflow_mode: ReflowMode::Normalize,
2357 ..Default::default()
2358 };
2359 let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2360 let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2361 let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2362
2363 assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2365 assert!(
2366 !normalize_warnings.is_empty(),
2367 "Normalize mode should flag multi-line paragraphs"
2368 );
2369
2370 assert_eq!(
2371 default_fixed, content,
2372 "Default mode should not change content without violations"
2373 );
2374 assert_ne!(
2375 normalize_fixed, content,
2376 "Normalize mode should change multi-line paragraphs"
2377 );
2378 assert_eq!(
2379 normalize_fixed.lines().count(),
2380 1,
2381 "Normalize should combine into single line"
2382 );
2383 }
2384
2385 #[test]
2386 fn test_normalize_mode_with_reference_definitions() {
2387 let config = MD013Config {
2388 line_length: 100,
2389 reflow: true,
2390 reflow_mode: ReflowMode::Normalize,
2391 ..Default::default()
2392 };
2393 let rule = MD013LineLength::from_config_struct(config);
2394
2395 let content =
2396 "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2398
2399 let fixed = rule.fix(&ctx).unwrap();
2400 assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2401 assert!(
2402 fixed.contains("[ref]: https://example.com"),
2403 "Reference definition should be preserved"
2404 );
2405 assert!(
2406 fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2407 "Paragraph should be normalized"
2408 );
2409 }
2410
2411 #[test]
2412 fn test_normalize_mode_with_html_comments() {
2413 let config = MD013Config {
2414 line_length: 100,
2415 reflow: true,
2416 reflow_mode: ReflowMode::Normalize,
2417 ..Default::default()
2418 };
2419 let rule = MD013LineLength::from_config_struct(config);
2420
2421 let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2423
2424 let fixed = rule.fix(&ctx).unwrap();
2425 assert!(
2426 fixed.contains("<!-- This is a comment -->"),
2427 "HTML comment should be preserved"
2428 );
2429 assert!(
2430 fixed.contains("Paragraph before HTML comment."),
2431 "First paragraph normalized"
2432 );
2433 assert!(
2434 fixed.contains("Paragraph after HTML comment."),
2435 "Second paragraph normalized"
2436 );
2437 }
2438
2439 #[test]
2440 fn test_normalize_mode_line_starting_with_number() {
2441 let config = MD013Config {
2443 line_length: 100,
2444 reflow: true,
2445 reflow_mode: ReflowMode::Normalize,
2446 ..Default::default()
2447 };
2448 let rule = MD013LineLength::from_config_struct(config);
2449
2450 let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2452
2453 let fixed = rule.fix(&ctx).unwrap();
2454 assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2455 assert!(
2456 fixed.contains("80 characters"),
2457 "Number at start of line should be preserved"
2458 );
2459 }
2460
2461 #[test]
2462 fn test_default_mode_preserves_list_structure() {
2463 let config = MD013Config {
2465 line_length: 80,
2466 reflow: true,
2467 reflow_mode: ReflowMode::Default,
2468 ..Default::default()
2469 };
2470 let rule = MD013LineLength::from_config_struct(config);
2471
2472 let content = r#"- This is a bullet point that has
2473 some text on multiple lines
2474 that should stay separate
2475
24761. Numbered list item with
2477 multiple lines that should
2478 also stay separate"#;
2479
2480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2481 let fixed = rule.fix(&ctx).unwrap();
2482
2483 let lines: Vec<&str> = fixed.lines().collect();
2485 assert_eq!(
2486 lines[0], "- This is a bullet point that has",
2487 "First line should be unchanged"
2488 );
2489 assert_eq!(
2490 lines[1], " some text on multiple lines",
2491 "Continuation should be preserved"
2492 );
2493 assert_eq!(
2494 lines[2], " that should stay separate",
2495 "Second continuation should be preserved"
2496 );
2497 }
2498
2499 #[test]
2500 fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2501 let config = MD013Config {
2503 line_length: 80,
2504 reflow: true,
2505 reflow_mode: ReflowMode::Normalize,
2506 ..Default::default()
2507 };
2508 let rule = MD013LineLength::from_config_struct(config);
2509
2510 let content = r#"- This is a bullet point that has
2511 some text on multiple lines
2512 that should be combined
2513
25141. Numbered list item with
2515 multiple lines that need
2516 to be properly combined
25172. Second item"#;
2518
2519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2520 let fixed = rule.fix(&ctx).unwrap();
2521
2522 assert!(
2524 !fixed.contains("lines that"),
2525 "Should not have double spaces in bullet list"
2526 );
2527 assert!(
2528 !fixed.contains("need to"),
2529 "Should not have double spaces in numbered list"
2530 );
2531
2532 assert!(
2534 fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2535 "Bullet list should be properly combined"
2536 );
2537 assert!(
2538 fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2539 "Numbered list should be properly combined"
2540 );
2541 }
2542
2543 #[test]
2544 fn test_normalize_mode_actual_numbered_list() {
2545 let config = MD013Config {
2547 line_length: 100,
2548 reflow: true,
2549 reflow_mode: ReflowMode::Normalize,
2550 ..Default::default()
2551 };
2552 let rule = MD013LineLength::from_config_struct(config);
2553
2554 let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2556
2557 let fixed = rule.fix(&ctx).unwrap();
2558 assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2559 assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2560 assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2561 assert!(
2562 fixed.starts_with("Paragraph before list with multiple lines."),
2563 "Paragraph should be normalized"
2564 );
2565 }
2566
2567 #[test]
2568 fn test_sentence_per_line_detection() {
2569 let config = MD013Config {
2570 reflow: true,
2571 reflow_mode: ReflowMode::SentencePerLine,
2572 ..Default::default()
2573 };
2574 let rule = MD013LineLength::from_config_struct(config.clone());
2575
2576 let content = "This is sentence one. This is sentence two. And sentence three!";
2578 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2579
2580 assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2582
2583 let result = rule.check(&ctx).unwrap();
2584
2585 assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2586 assert_eq!(
2587 result[0].message,
2588 "Line contains multiple sentences (one sentence per line expected)"
2589 );
2590 }
2591
2592 #[test]
2593 fn test_sentence_per_line_fix() {
2594 let config = MD013Config {
2595 reflow: true,
2596 reflow_mode: ReflowMode::SentencePerLine,
2597 ..Default::default()
2598 };
2599 let rule = MD013LineLength::from_config_struct(config);
2600
2601 let content = "First sentence. Second sentence.";
2602 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2603 let result = rule.check(&ctx).unwrap();
2604
2605 assert!(!result.is_empty(), "Should detect violation");
2606 assert!(result[0].fix.is_some(), "Should provide a fix");
2607
2608 let fix = result[0].fix.as_ref().unwrap();
2609 assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2610 }
2611
2612 #[test]
2613 fn test_sentence_per_line_abbreviations() {
2614 let config = MD013Config {
2615 reflow: true,
2616 reflow_mode: ReflowMode::SentencePerLine,
2617 ..Default::default()
2618 };
2619 let rule = MD013LineLength::from_config_struct(config);
2620
2621 let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2623 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2624 let result = rule.check(&ctx).unwrap();
2625
2626 assert!(
2627 result.is_empty(),
2628 "Should not detect abbreviations as sentence boundaries"
2629 );
2630 }
2631
2632 #[test]
2633 fn test_sentence_per_line_with_markdown() {
2634 let config = MD013Config {
2635 reflow: true,
2636 reflow_mode: ReflowMode::SentencePerLine,
2637 ..Default::default()
2638 };
2639 let rule = MD013LineLength::from_config_struct(config);
2640
2641 let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
2642 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2643 let result = rule.check(&ctx).unwrap();
2644
2645 assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
2646 assert_eq!(result[0].line, 3); }
2648
2649 #[test]
2650 fn test_sentence_per_line_questions_exclamations() {
2651 let config = MD013Config {
2652 reflow: true,
2653 reflow_mode: ReflowMode::SentencePerLine,
2654 ..Default::default()
2655 };
2656 let rule = MD013LineLength::from_config_struct(config);
2657
2658 let content = "Is this a question? Yes it is! And a statement.";
2659 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2660 let result = rule.check(&ctx).unwrap();
2661
2662 assert!(!result.is_empty(), "Should detect sentences with ? and !");
2663
2664 let fix = result[0].fix.as_ref().unwrap();
2665 let lines: Vec<&str> = fix.replacement.trim().lines().collect();
2666 assert_eq!(lines.len(), 3);
2667 assert_eq!(lines[0], "Is this a question?");
2668 assert_eq!(lines[1], "Yes it is!");
2669 assert_eq!(lines[2], "And a statement.");
2670 }
2671
2672 #[test]
2673 fn test_sentence_per_line_in_lists() {
2674 let config = MD013Config {
2675 reflow: true,
2676 reflow_mode: ReflowMode::SentencePerLine,
2677 ..Default::default()
2678 };
2679 let rule = MD013LineLength::from_config_struct(config);
2680
2681 let content = "- List item one. With two sentences.\n- Another item.";
2682 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2683 let result = rule.check(&ctx).unwrap();
2684
2685 assert!(!result.is_empty(), "Should detect sentences in list items");
2686 let fix = result[0].fix.as_ref().unwrap();
2688 assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
2689 }
2690
2691 #[test]
2692 fn test_multi_paragraph_list_item_with_3_space_indent() {
2693 let config = MD013Config {
2694 reflow: true,
2695 reflow_mode: ReflowMode::Normalize,
2696 line_length: 999999,
2697 ..Default::default()
2698 };
2699 let rule = MD013LineLength::from_config_struct(config);
2700
2701 let content = "1. First paragraph\n continuation line.\n\n Second paragraph\n more content.";
2702 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2703 let result = rule.check(&ctx).unwrap();
2704
2705 assert!(!result.is_empty(), "Should detect multi-line paragraphs in list item");
2706 let fix = result[0].fix.as_ref().unwrap();
2707
2708 assert!(
2710 fix.replacement.contains("\n\n"),
2711 "Should preserve blank line between paragraphs"
2712 );
2713 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
2714 }
2715
2716 #[test]
2717 fn test_multi_paragraph_list_item_with_4_space_indent() {
2718 let config = MD013Config {
2719 reflow: true,
2720 reflow_mode: ReflowMode::Normalize,
2721 line_length: 999999,
2722 ..Default::default()
2723 };
2724 let rule = MD013LineLength::from_config_struct(config);
2725
2726 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.";
2728 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2729 let result = rule.check(&ctx).unwrap();
2730
2731 assert!(
2732 !result.is_empty(),
2733 "Should detect multi-line paragraphs in list item with 4-space indent"
2734 );
2735 let fix = result[0].fix.as_ref().unwrap();
2736
2737 assert!(
2739 fix.replacement.contains("\n\n"),
2740 "Should preserve blank line between paragraphs"
2741 );
2742 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
2743
2744 let lines: Vec<&str> = fix.replacement.split('\n').collect();
2746 let blank_line_idx = lines.iter().position(|l| l.trim().is_empty());
2747 assert!(blank_line_idx.is_some(), "Should have blank line separating paragraphs");
2748 }
2749
2750 #[test]
2751 fn test_multi_paragraph_bullet_list_item() {
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 = "- First paragraph\n continuation.\n\n Second paragraph\n more text.";
2761 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2762 let result = rule.check(&ctx).unwrap();
2763
2764 assert!(!result.is_empty(), "Should detect multi-line paragraphs in bullet list");
2765 let fix = result[0].fix.as_ref().unwrap();
2766
2767 assert!(
2768 fix.replacement.contains("\n\n"),
2769 "Should preserve blank line between paragraphs"
2770 );
2771 assert!(fix.replacement.starts_with("- "), "Should preserve bullet marker");
2772 }
2773
2774 #[test]
2775 fn test_code_block_in_list_item_five_spaces() {
2776 let config = MD013Config {
2777 reflow: true,
2778 reflow_mode: ReflowMode::Normalize,
2779 line_length: 80,
2780 ..Default::default()
2781 };
2782 let rule = MD013LineLength::from_config_struct(config);
2783
2784 let content = "1. First paragraph with some text that should be reflowed.\n\n code_block()\n more_code()\n\n Second paragraph.";
2787 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2788 let result = rule.check(&ctx).unwrap();
2789
2790 if !result.is_empty() {
2791 let fix = result[0].fix.as_ref().unwrap();
2792 assert!(
2794 fix.replacement.contains(" code_block()"),
2795 "Code block should be preserved: {}",
2796 fix.replacement
2797 );
2798 assert!(
2799 fix.replacement.contains(" more_code()"),
2800 "Code block should be preserved: {}",
2801 fix.replacement
2802 );
2803 }
2804 }
2805
2806 #[test]
2807 fn test_fenced_code_block_in_list_item() {
2808 let config = MD013Config {
2809 reflow: true,
2810 reflow_mode: ReflowMode::Normalize,
2811 line_length: 80,
2812 ..Default::default()
2813 };
2814 let rule = MD013LineLength::from_config_struct(config);
2815
2816 let content = "1. First paragraph with some text.\n\n ```rust\n fn foo() {}\n let x = 1;\n ```\n\n Second paragraph.";
2817 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2818 let result = rule.check(&ctx).unwrap();
2819
2820 if !result.is_empty() {
2821 let fix = result[0].fix.as_ref().unwrap();
2822 assert!(
2824 fix.replacement.contains("```rust"),
2825 "Should preserve fence: {}",
2826 fix.replacement
2827 );
2828 assert!(
2829 fix.replacement.contains("fn foo() {}"),
2830 "Should preserve code: {}",
2831 fix.replacement
2832 );
2833 assert!(
2834 fix.replacement.contains("```"),
2835 "Should preserve closing fence: {}",
2836 fix.replacement
2837 );
2838 }
2839 }
2840
2841 #[test]
2842 fn test_mixed_indentation_3_and_4_spaces() {
2843 let config = MD013Config {
2844 reflow: true,
2845 reflow_mode: ReflowMode::Normalize,
2846 line_length: 999999,
2847 ..Default::default()
2848 };
2849 let rule = MD013LineLength::from_config_struct(config);
2850
2851 let content = "1. Text\n 3 space continuation\n 4 space continuation";
2853 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2854 let result = rule.check(&ctx).unwrap();
2855
2856 assert!(!result.is_empty(), "Should detect multi-line list item");
2857 let fix = result[0].fix.as_ref().unwrap();
2858 assert!(
2860 fix.replacement.contains("3 space continuation"),
2861 "Should include 3-space line: {}",
2862 fix.replacement
2863 );
2864 assert!(
2865 fix.replacement.contains("4 space continuation"),
2866 "Should include 4-space line: {}",
2867 fix.replacement
2868 );
2869 }
2870
2871 #[test]
2872 fn test_nested_list_in_multi_paragraph_item() {
2873 let config = MD013Config {
2874 reflow: true,
2875 reflow_mode: ReflowMode::Normalize,
2876 line_length: 999999,
2877 ..Default::default()
2878 };
2879 let rule = MD013LineLength::from_config_struct(config);
2880
2881 let content = "1. First paragraph.\n\n - Nested item\n continuation\n\n Second paragraph.";
2882 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2883 let result = rule.check(&ctx).unwrap();
2884
2885 assert!(!result.is_empty(), "Should detect and reflow parent item");
2887 if let Some(fix) = result[0].fix.as_ref() {
2888 assert!(
2890 fix.replacement.contains("- Nested"),
2891 "Should preserve nested list: {}",
2892 fix.replacement
2893 );
2894 assert!(
2895 fix.replacement.contains("Second paragraph"),
2896 "Should include content after nested list: {}",
2897 fix.replacement
2898 );
2899 }
2900 }
2901
2902 #[test]
2903 fn test_nested_fence_markers_different_types() {
2904 let config = MD013Config {
2905 reflow: true,
2906 reflow_mode: ReflowMode::Normalize,
2907 line_length: 80,
2908 ..Default::default()
2909 };
2910 let rule = MD013LineLength::from_config_struct(config);
2911
2912 let content = "1. Example with nested fences:\n\n ~~~markdown\n This shows ```python\n code = True\n ```\n ~~~\n\n Text after.";
2914 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2915 let result = rule.check(&ctx).unwrap();
2916
2917 if !result.is_empty() {
2918 let fix = result[0].fix.as_ref().unwrap();
2919 assert!(
2921 fix.replacement.contains("```python"),
2922 "Should preserve inner fence: {}",
2923 fix.replacement
2924 );
2925 assert!(
2926 fix.replacement.contains("~~~"),
2927 "Should preserve outer fence: {}",
2928 fix.replacement
2929 );
2930 assert!(
2932 fix.replacement.contains("code = True"),
2933 "Should preserve code: {}",
2934 fix.replacement
2935 );
2936 }
2937 }
2938
2939 #[test]
2940 fn test_nested_fence_markers_same_type() {
2941 let config = MD013Config {
2942 reflow: true,
2943 reflow_mode: ReflowMode::Normalize,
2944 line_length: 80,
2945 ..Default::default()
2946 };
2947 let rule = MD013LineLength::from_config_struct(config);
2948
2949 let content =
2951 "1. Example:\n\n ````markdown\n Shows ```python in code\n ```\n text here\n ````\n\n After.";
2952 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2953 let result = rule.check(&ctx).unwrap();
2954
2955 if !result.is_empty() {
2956 let fix = result[0].fix.as_ref().unwrap();
2957 assert!(
2959 fix.replacement.contains("```python"),
2960 "Should preserve inner fence: {}",
2961 fix.replacement
2962 );
2963 assert!(
2964 fix.replacement.contains("````"),
2965 "Should preserve outer fence: {}",
2966 fix.replacement
2967 );
2968 assert!(
2969 fix.replacement.contains("text here"),
2970 "Should keep text as code: {}",
2971 fix.replacement
2972 );
2973 }
2974 }
2975
2976 #[test]
2977 fn test_sibling_list_item_breaks_parent() {
2978 let config = MD013Config {
2979 reflow: true,
2980 reflow_mode: ReflowMode::Normalize,
2981 line_length: 999999,
2982 ..Default::default()
2983 };
2984 let rule = MD013LineLength::from_config_struct(config);
2985
2986 let content = "1. First item\n continuation.\n2. Second item";
2988 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2989 let result = rule.check(&ctx).unwrap();
2990
2991 if !result.is_empty() {
2993 let fix = result[0].fix.as_ref().unwrap();
2994 assert!(fix.replacement.starts_with("1. "), "Should start with first marker");
2996 assert!(fix.replacement.contains("continuation"), "Should include continuation");
2997 }
2999 }
3000
3001 #[test]
3002 fn test_nested_list_at_continuation_indent_preserved() {
3003 let config = MD013Config {
3004 reflow: true,
3005 reflow_mode: ReflowMode::Normalize,
3006 line_length: 999999,
3007 ..Default::default()
3008 };
3009 let rule = MD013LineLength::from_config_struct(config);
3010
3011 let content = "1. Parent paragraph\n with continuation.\n\n - Nested at 3 spaces\n - Another nested\n\n After nested.";
3013 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3014 let result = rule.check(&ctx).unwrap();
3015
3016 if !result.is_empty() {
3017 let fix = result[0].fix.as_ref().unwrap();
3018 assert!(
3020 fix.replacement.contains("- Nested"),
3021 "Should include first nested item: {}",
3022 fix.replacement
3023 );
3024 assert!(
3025 fix.replacement.contains("- Another"),
3026 "Should include second nested item: {}",
3027 fix.replacement
3028 );
3029 assert!(
3030 fix.replacement.contains("After nested"),
3031 "Should include content after nested list: {}",
3032 fix.replacement
3033 );
3034 }
3035 }
3036}