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 is_semantic_line = |content: &str| -> bool {
400 let trimmed = content.trim_start();
401 let semantic_markers = [
402 "NOTE:",
403 "WARNING:",
404 "IMPORTANT:",
405 "CAUTION:",
406 "TIP:",
407 "DANGER:",
408 "HINT:",
409 "INFO:",
410 ];
411 semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
412 };
413
414 let is_fence_marker = |content: &str| -> bool {
416 let trimmed = content.trim_start();
417 trimmed.starts_with("```") || trimmed.starts_with("~~~")
418 };
419
420 let trimmed = lines[i].trim();
422 if is_list_item(trimmed) {
423 let list_start = i;
425 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
426 let marker_len = marker.len();
427
428 #[derive(Clone)]
430 enum LineType {
431 Content(String),
432 CodeBlock(String, usize), NestedListItem(String, usize), SemanticLine(String), Empty,
436 }
437
438 let mut actual_indent: Option<usize> = None;
439 let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
440 i += 1;
441
442 while i < lines.len() {
444 let line_info = &ctx.lines[i];
445
446 if line_info.is_blank {
448 if i + 1 < lines.len() {
450 let next_info = &ctx.lines[i + 1];
451
452 if !next_info.is_blank && next_info.indent >= marker_len {
454 list_item_lines.push(LineType::Empty);
456 i += 1;
457 continue;
458 }
459 }
460 break;
462 }
463
464 let indent = line_info.indent;
466
467 if indent >= marker_len {
469 let trimmed = line_info.content.trim();
470
471 if line_info.in_code_block {
473 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
474 i += 1;
475 continue;
476 }
477
478 if is_list_item(trimmed) && indent < marker_len {
482 break;
484 }
485
486 if is_list_item(trimmed) && indent >= marker_len {
491 let has_blank_before = matches!(list_item_lines.last(), Some(LineType::Empty));
493
494 let has_nested_content = list_item_lines.iter().any(|line| {
496 matches!(line, LineType::Content(c) if is_list_item(c.trim()))
497 || matches!(line, LineType::NestedListItem(_, _))
498 });
499
500 if !has_blank_before && !has_nested_content {
501 break;
504 }
505 list_item_lines.push(LineType::NestedListItem(
508 line_info.content[indent..].to_string(),
509 indent,
510 ));
511 i += 1;
512 continue;
513 }
514
515 if indent <= marker_len + 3 {
517 if actual_indent.is_none() {
519 actual_indent = Some(indent);
520 }
521
522 let content = trim_preserving_hard_break(&line_info.content[indent..]);
526
527 if is_fence_marker(&content) {
530 list_item_lines.push(LineType::CodeBlock(content, indent));
531 }
532 else if is_semantic_line(&content) {
534 list_item_lines.push(LineType::SemanticLine(content));
535 } else {
536 list_item_lines.push(LineType::Content(content));
537 }
538 i += 1;
539 } else {
540 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
542 i += 1;
543 }
544 } else {
545 break;
547 }
548 }
549
550 let indent_size = actual_indent.unwrap_or(marker_len);
552 let expected_indent = " ".repeat(indent_size);
553
554 #[derive(Clone)]
556 enum Block {
557 Paragraph(Vec<String>),
558 Code {
559 lines: Vec<(String, usize)>, has_preceding_blank: bool, },
562 NestedList(Vec<(String, usize)>), SemanticLine(String), }
565
566 let mut blocks: Vec<Block> = Vec::new();
567 let mut current_paragraph: Vec<String> = Vec::new();
568 let mut current_code_block: Vec<(String, usize)> = Vec::new();
569 let mut current_nested_list: Vec<(String, usize)> = Vec::new();
570 let mut in_code = false;
571 let mut in_nested_list = false;
572 let mut had_preceding_blank = false; let mut code_block_has_preceding_blank = false; for line in &list_item_lines {
576 match line {
577 LineType::Empty => {
578 if in_code {
579 current_code_block.push((String::new(), 0));
580 } else if in_nested_list {
581 current_nested_list.push((String::new(), 0));
582 } else if !current_paragraph.is_empty() {
583 blocks.push(Block::Paragraph(current_paragraph.clone()));
584 current_paragraph.clear();
585 }
586 had_preceding_blank = true;
588 }
589 LineType::Content(content) => {
590 if in_code {
591 blocks.push(Block::Code {
593 lines: current_code_block.clone(),
594 has_preceding_blank: code_block_has_preceding_blank,
595 });
596 current_code_block.clear();
597 in_code = false;
598 } else if in_nested_list {
599 blocks.push(Block::NestedList(current_nested_list.clone()));
601 current_nested_list.clear();
602 in_nested_list = false;
603 }
604 current_paragraph.push(content.clone());
605 had_preceding_blank = false; }
607 LineType::CodeBlock(content, indent) => {
608 if in_nested_list {
609 blocks.push(Block::NestedList(current_nested_list.clone()));
611 current_nested_list.clear();
612 in_nested_list = false;
613 }
614 if !in_code {
615 if !current_paragraph.is_empty() {
617 blocks.push(Block::Paragraph(current_paragraph.clone()));
618 current_paragraph.clear();
619 }
620 in_code = true;
621 code_block_has_preceding_blank = had_preceding_blank;
623 }
624 current_code_block.push((content.clone(), *indent));
625 had_preceding_blank = false; }
627 LineType::NestedListItem(content, indent) => {
628 if in_code {
629 blocks.push(Block::Code {
631 lines: current_code_block.clone(),
632 has_preceding_blank: code_block_has_preceding_blank,
633 });
634 current_code_block.clear();
635 in_code = false;
636 }
637 if !in_nested_list {
638 if !current_paragraph.is_empty() {
640 blocks.push(Block::Paragraph(current_paragraph.clone()));
641 current_paragraph.clear();
642 }
643 in_nested_list = true;
644 }
645 current_nested_list.push((content.clone(), *indent));
646 had_preceding_blank = false; }
648 LineType::SemanticLine(content) => {
649 if in_code {
651 blocks.push(Block::Code {
652 lines: current_code_block.clone(),
653 has_preceding_blank: code_block_has_preceding_blank,
654 });
655 current_code_block.clear();
656 in_code = false;
657 } else if in_nested_list {
658 blocks.push(Block::NestedList(current_nested_list.clone()));
659 current_nested_list.clear();
660 in_nested_list = false;
661 } else if !current_paragraph.is_empty() {
662 blocks.push(Block::Paragraph(current_paragraph.clone()));
663 current_paragraph.clear();
664 }
665 blocks.push(Block::SemanticLine(content.clone()));
667 had_preceding_blank = false; }
669 }
670 }
671
672 if in_code && !current_code_block.is_empty() {
674 blocks.push(Block::Code {
675 lines: current_code_block,
676 has_preceding_blank: code_block_has_preceding_blank,
677 });
678 } else if in_nested_list && !current_nested_list.is_empty() {
679 blocks.push(Block::NestedList(current_nested_list));
680 } else if !current_paragraph.is_empty() {
681 blocks.push(Block::Paragraph(current_paragraph));
682 }
683
684 let content_lines: Vec<String> = list_item_lines
686 .iter()
687 .filter_map(|line| {
688 if let LineType::Content(s) = line {
689 Some(s.clone())
690 } else {
691 None
692 }
693 })
694 .collect();
695
696 let combined_content = content_lines.join(" ").trim().to_string();
699 let full_line = format!("{marker}{combined_content}");
700
701 let should_normalize = || {
703 let has_nested_lists = blocks.iter().any(|b| matches!(b, Block::NestedList(_)));
706 let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
707 let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
708 let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
709
710 if (has_nested_lists || has_code_blocks || has_semantic_lines) && !has_paragraphs {
712 return false;
713 }
714
715 if has_paragraphs {
717 let paragraph_count = blocks.iter().filter(|b| matches!(b, Block::Paragraph(_))).count();
718 if paragraph_count > 1 {
719 return true;
721 }
722
723 if content_lines.len() > 1 {
725 return true;
726 }
727 }
728
729 false
730 };
731
732 let needs_reflow = match config.reflow_mode {
733 ReflowMode::Normalize => {
734 let combined_length = self.calculate_effective_length(&full_line);
738 if combined_length > config.line_length {
739 true
740 } else {
741 should_normalize()
742 }
743 }
744 ReflowMode::SentencePerLine => {
745 let sentences = split_into_sentences(&combined_content);
747 sentences.len() > 1
748 }
749 ReflowMode::Default => {
750 self.calculate_effective_length(&full_line) > config.line_length
752 }
753 };
754
755 if needs_reflow {
756 let start_range = line_index.whole_line_range(list_start + 1);
757 let end_line = i - 1;
758 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
759 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
760 } else {
761 line_index.whole_line_range(end_line + 1)
762 };
763 let byte_range = start_range.start..end_range.end;
764
765 let reflow_options = crate::utils::text_reflow::ReflowOptions {
767 line_length: config.line_length - indent_size,
768 break_on_sentences: true,
769 preserve_breaks: false,
770 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
771 };
772
773 let mut result: Vec<String> = Vec::new();
774 let mut is_first_block = true;
775
776 for (block_idx, block) in blocks.iter().enumerate() {
777 match block {
778 Block::Paragraph(para_lines) => {
779 let segments = split_into_segments(para_lines);
782
783 for (segment_idx, segment) in segments.iter().enumerate() {
784 let hard_break_type = segment.last().and_then(|line| {
786 let line = line.strip_suffix('\r').unwrap_or(line);
787 if line.ends_with('\\') {
788 Some("\\")
789 } else if line.ends_with(" ") {
790 Some(" ")
791 } else {
792 None
793 }
794 });
795
796 let segment_for_reflow: Vec<String> = segment
798 .iter()
799 .map(|line| {
800 if line.ends_with('\\') {
802 line[..line.len() - 1].trim_end().to_string()
803 } else if line.ends_with(" ") {
804 line[..line.len() - 2].trim_end().to_string()
805 } else {
806 line.clone()
807 }
808 })
809 .collect();
810
811 let segment_text = segment_for_reflow.join(" ").trim().to_string();
812 if !segment_text.is_empty() {
813 let reflowed =
814 crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
815
816 if is_first_block && segment_idx == 0 {
817 result.push(format!("{marker}{}", reflowed[0]));
819 for line in reflowed.iter().skip(1) {
820 result.push(format!("{expected_indent}{line}"));
821 }
822 is_first_block = false;
823 } else {
824 for line in reflowed {
826 result.push(format!("{expected_indent}{line}"));
827 }
828 }
829
830 if let Some(break_marker) = hard_break_type
833 && let Some(last_line) = result.last_mut()
834 {
835 last_line.push_str(break_marker);
836 }
837 }
838 }
839
840 if block_idx < blocks.len() - 1 {
843 let next_block = &blocks[block_idx + 1];
844 let should_add_blank = match next_block {
845 Block::Code {
846 has_preceding_blank, ..
847 } => *has_preceding_blank,
848 _ => true, };
850 if should_add_blank {
851 result.push(String::new());
852 }
853 }
854 }
855 Block::Code {
856 lines: code_lines,
857 has_preceding_blank: _,
858 } => {
859 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
864 if is_first_block && idx == 0 {
865 result.push(format!(
867 "{marker}{}",
868 " ".repeat(orig_indent - marker_len) + content
869 ));
870 is_first_block = false;
871 } else if content.is_empty() {
872 result.push(String::new());
873 } else {
874 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
875 }
876 }
877 }
878 Block::NestedList(nested_items) => {
879 if !is_first_block {
881 result.push(String::new());
882 }
883
884 for (idx, (content, orig_indent)) in nested_items.iter().enumerate() {
885 if is_first_block && idx == 0 {
886 result.push(format!(
888 "{marker}{}",
889 " ".repeat(orig_indent - marker_len) + content
890 ));
891 is_first_block = false;
892 } else if content.is_empty() {
893 result.push(String::new());
894 } else {
895 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
896 }
897 }
898
899 if block_idx < blocks.len() - 1 {
902 let next_block = &blocks[block_idx + 1];
903 let should_add_blank = match next_block {
904 Block::Code {
905 has_preceding_blank, ..
906 } => *has_preceding_blank,
907 _ => true, };
909 if should_add_blank {
910 result.push(String::new());
911 }
912 }
913 }
914 Block::SemanticLine(content) => {
915 if !is_first_block {
918 result.push(String::new());
919 }
920
921 if is_first_block {
922 result.push(format!("{marker}{content}"));
924 is_first_block = false;
925 } else {
926 result.push(format!("{expected_indent}{content}"));
928 }
929
930 if block_idx < blocks.len() - 1 {
933 let next_block = &blocks[block_idx + 1];
934 let should_add_blank = match next_block {
935 Block::Code {
936 has_preceding_blank, ..
937 } => *has_preceding_blank,
938 _ => true, };
940 if should_add_blank {
941 result.push(String::new());
942 }
943 }
944 }
945 }
946 }
947
948 let reflowed_text = result.join("\n");
949
950 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
952 format!("{reflowed_text}\n")
953 } else {
954 reflowed_text
955 };
956
957 let original_text = &ctx.content[byte_range.clone()];
959
960 if original_text != replacement {
962 let message = match config.reflow_mode {
964 ReflowMode::SentencePerLine => {
965 "Line contains multiple sentences (one sentence per line expected)".to_string()
966 }
967 ReflowMode::Normalize => {
968 let combined_length = self.calculate_effective_length(&full_line);
969 if combined_length > config.line_length {
970 format!(
971 "Line length {} exceeds {} characters",
972 combined_length, config.line_length
973 )
974 } else {
975 "Multi-line content can be normalized".to_string()
976 }
977 }
978 ReflowMode::Default => {
979 let combined_length = self.calculate_effective_length(&full_line);
980 format!(
981 "Line length {} exceeds {} characters",
982 combined_length, config.line_length
983 )
984 }
985 };
986
987 warnings.push(LintWarning {
988 rule_name: Some(self.name()),
989 message,
990 line: list_start + 1,
991 column: 1,
992 end_line: end_line + 1,
993 end_column: lines[end_line].len() + 1,
994 severity: Severity::Warning,
995 fix: Some(crate::rule::Fix {
996 range: byte_range,
997 replacement,
998 }),
999 });
1000 }
1001 }
1002 continue;
1003 }
1004
1005 let paragraph_start = i;
1007 let mut paragraph_lines = vec![lines[i]];
1008 i += 1;
1009
1010 while i < lines.len() {
1011 let next_line = lines[i];
1012 let next_line_num = i + 1;
1013 let next_trimmed = next_line.trim();
1014
1015 if next_trimmed.is_empty()
1017 || ctx.is_in_code_block(next_line_num)
1018 || ctx.is_in_front_matter(next_line_num)
1019 || ctx.is_in_html_block(next_line_num)
1020 || (next_line_num > 0
1021 && next_line_num <= ctx.lines.len()
1022 && ctx.lines[next_line_num - 1].blockquote.is_some())
1023 || next_trimmed.starts_with('#')
1024 || TableUtils::is_potential_table_row(next_line)
1025 || is_list_item(next_trimmed)
1026 || is_horizontal_rule(next_trimmed)
1027 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
1028 {
1029 break;
1030 }
1031
1032 if i > 0 && has_hard_break(lines[i - 1]) {
1034 break;
1036 }
1037
1038 paragraph_lines.push(next_line);
1039 i += 1;
1040 }
1041
1042 let needs_reflow = match config.reflow_mode {
1044 ReflowMode::Normalize => {
1045 paragraph_lines.len() > 1
1047 }
1048 ReflowMode::SentencePerLine => {
1049 paragraph_lines.iter().any(|line| {
1051 let sentences = split_into_sentences(line);
1053 sentences.len() > 1
1054 })
1055 }
1056 ReflowMode::Default => {
1057 paragraph_lines
1059 .iter()
1060 .any(|line| self.calculate_effective_length(line) > config.line_length)
1061 }
1062 };
1063
1064 if needs_reflow {
1065 let start_range = line_index.whole_line_range(paragraph_start + 1);
1068 let end_line = paragraph_start + paragraph_lines.len() - 1;
1069
1070 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1072 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1074 } else {
1075 line_index.whole_line_range(end_line + 1)
1077 };
1078
1079 let byte_range = start_range.start..end_range.end;
1080
1081 let paragraph_text = paragraph_lines.join(" ");
1083
1084 let hard_break_type = paragraph_lines.last().and_then(|line| {
1086 let line = line.strip_suffix('\r').unwrap_or(line);
1087 if line.ends_with('\\') {
1088 Some("\\")
1089 } else if line.ends_with(" ") {
1090 Some(" ")
1091 } else {
1092 None
1093 }
1094 });
1095
1096 let reflow_options = crate::utils::text_reflow::ReflowOptions {
1098 line_length: config.line_length,
1099 break_on_sentences: true,
1100 preserve_breaks: false,
1101 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1102 };
1103 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
1104
1105 if let Some(break_marker) = hard_break_type
1108 && !reflowed.is_empty()
1109 {
1110 let last_idx = reflowed.len() - 1;
1111 if !has_hard_break(&reflowed[last_idx]) {
1112 reflowed[last_idx].push_str(break_marker);
1113 }
1114 }
1115
1116 let reflowed_text = reflowed.join("\n");
1117
1118 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1120 format!("{reflowed_text}\n")
1121 } else {
1122 reflowed_text
1123 };
1124
1125 let original_text = &ctx.content[byte_range.clone()];
1127
1128 if original_text != replacement {
1130 let (warning_line, warning_end_line) = match config.reflow_mode {
1135 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
1136 ReflowMode::SentencePerLine => {
1137 let mut violating_line = paragraph_start;
1139 for (idx, line) in paragraph_lines.iter().enumerate() {
1140 let sentences = split_into_sentences(line);
1141 if sentences.len() > 1 {
1142 violating_line = paragraph_start + idx;
1143 break;
1144 }
1145 }
1146 (violating_line + 1, violating_line + 1)
1147 }
1148 ReflowMode::Default => {
1149 let mut violating_line = paragraph_start;
1151 for (idx, line) in paragraph_lines.iter().enumerate() {
1152 if self.calculate_effective_length(line) > config.line_length {
1153 violating_line = paragraph_start + idx;
1154 break;
1155 }
1156 }
1157 (violating_line + 1, violating_line + 1)
1158 }
1159 };
1160
1161 warnings.push(LintWarning {
1162 rule_name: Some(self.name()),
1163 message: match config.reflow_mode {
1164 ReflowMode::Normalize => format!(
1165 "Paragraph could be normalized to use line length of {} characters",
1166 config.line_length
1167 ),
1168 ReflowMode::SentencePerLine => {
1169 "Line contains multiple sentences (one sentence per line expected)".to_string()
1170 }
1171 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length),
1172 },
1173 line: warning_line,
1174 column: 1,
1175 end_line: warning_end_line,
1176 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
1177 severity: Severity::Warning,
1178 fix: Some(crate::rule::Fix {
1179 range: byte_range,
1180 replacement,
1181 }),
1182 });
1183 }
1184 }
1185 }
1186
1187 warnings
1188 }
1189
1190 fn calculate_effective_length(&self, line: &str) -> usize {
1192 if self.config.strict {
1193 return line.chars().count();
1195 }
1196
1197 let bytes = line.as_bytes();
1199 if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
1200 return line.chars().count();
1201 }
1202
1203 if !line.contains("http") && !line.contains('[') {
1205 return line.chars().count();
1206 }
1207
1208 let mut effective_line = line.to_string();
1209
1210 if line.contains('[') && line.contains("](") {
1213 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
1214 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
1215 && url.as_str().len() > 15
1216 {
1217 let replacement = format!("[{}](url)", text.as_str());
1218 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
1219 }
1220 }
1221 }
1222
1223 if effective_line.contains("http") {
1226 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
1227 let url = url_match.as_str();
1228 if !effective_line.contains(&format!("({url})")) {
1230 let placeholder = "x".repeat(15.min(url.len()));
1233 effective_line = effective_line.replacen(url, &placeholder, 1);
1234 }
1235 }
1236 }
1237
1238 effective_line.chars().count()
1239 }
1240}
1241
1242fn has_hard_break(line: &str) -> bool {
1248 let line = line.strip_suffix('\r').unwrap_or(line);
1249 line.ends_with(" ") || line.ends_with('\\')
1250}
1251
1252fn trim_preserving_hard_break(s: &str) -> String {
1259 let s = s.strip_suffix('\r').unwrap_or(s);
1261
1262 if s.ends_with('\\') {
1264 return s.to_string();
1266 }
1267
1268 if s.ends_with(" ") {
1270 let content_end = s.trim_end().len();
1272 if content_end == 0 {
1273 return String::new();
1275 }
1276 format!("{} ", &s[..content_end])
1278 } else {
1279 s.trim_end().to_string()
1281 }
1282}
1283
1284fn split_into_segments(para_lines: &[String]) -> Vec<Vec<String>> {
1295 let mut segments: Vec<Vec<String>> = Vec::new();
1296 let mut current_segment: Vec<String> = Vec::new();
1297
1298 for line in para_lines {
1299 current_segment.push(line.clone());
1300
1301 if has_hard_break(line) {
1303 segments.push(current_segment.clone());
1304 current_segment.clear();
1305 }
1306 }
1307
1308 if !current_segment.is_empty() {
1310 segments.push(current_segment);
1311 }
1312
1313 segments
1314}
1315
1316fn extract_list_marker_and_content(line: &str) -> (String, String) {
1317 let indent_len = line.len() - line.trim_start().len();
1319 let indent = &line[..indent_len];
1320 let trimmed = &line[indent_len..];
1321
1322 if let Some(rest) = trimmed.strip_prefix("- ") {
1325 return (format!("{indent}- "), trim_preserving_hard_break(rest));
1326 }
1327 if let Some(rest) = trimmed.strip_prefix("* ") {
1328 return (format!("{indent}* "), trim_preserving_hard_break(rest));
1329 }
1330 if let Some(rest) = trimmed.strip_prefix("+ ") {
1331 return (format!("{indent}+ "), trim_preserving_hard_break(rest));
1332 }
1333
1334 let mut chars = trimmed.chars();
1336 let mut marker_content = String::new();
1337
1338 while let Some(c) = chars.next() {
1339 marker_content.push(c);
1340 if c == '.' {
1341 if let Some(next) = chars.next()
1343 && next == ' '
1344 {
1345 marker_content.push(next);
1346 let content = trim_preserving_hard_break(chars.as_str());
1348 return (format!("{indent}{marker_content}"), content);
1349 }
1350 break;
1351 }
1352 }
1353
1354 (String::new(), line.to_string())
1356}
1357
1358fn is_horizontal_rule(line: &str) -> bool {
1360 if line.len() < 3 {
1361 return false;
1362 }
1363 let chars: Vec<char> = line.chars().collect();
1365 if chars.is_empty() {
1366 return false;
1367 }
1368 let first_char = chars[0];
1369 if first_char != '-' && first_char != '_' && first_char != '*' {
1370 return false;
1371 }
1372 for c in &chars {
1374 if *c != first_char && *c != ' ' {
1375 return false;
1376 }
1377 }
1378 chars.iter().filter(|c| **c == first_char).count() >= 3
1380}
1381
1382fn is_numbered_list_item(line: &str) -> bool {
1383 let mut chars = line.chars();
1384 if !chars.next().is_some_and(|c| c.is_numeric()) {
1386 return false;
1387 }
1388 while let Some(c) = chars.next() {
1390 if c == '.' {
1391 return chars.next().is_none_or(|c| c == ' ');
1393 }
1394 if !c.is_numeric() {
1395 return false;
1396 }
1397 }
1398 false
1399}
1400
1401fn is_list_item(line: &str) -> bool {
1402 if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
1404 && line.len() > 1
1405 && line.chars().nth(1) == Some(' ')
1406 {
1407 return true;
1408 }
1409 is_numbered_list_item(line)
1411}
1412
1413#[cfg(test)]
1414mod tests {
1415 use super::*;
1416 use crate::lint_context::LintContext;
1417
1418 #[test]
1419 fn test_default_config() {
1420 let rule = MD013LineLength::default();
1421 assert_eq!(rule.config.line_length, 80);
1422 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
1426 }
1427
1428 #[test]
1429 fn test_custom_config() {
1430 let rule = MD013LineLength::new(100, true, true, false, true);
1431 assert_eq!(rule.config.line_length, 100);
1432 assert!(rule.config.code_blocks);
1433 assert!(rule.config.tables);
1434 assert!(!rule.config.headings);
1435 assert!(rule.config.strict);
1436 }
1437
1438 #[test]
1439 fn test_basic_line_length_violation() {
1440 let rule = MD013LineLength::new(50, false, false, false, false);
1441 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
1442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1443 let result = rule.check(&ctx).unwrap();
1444
1445 assert_eq!(result.len(), 1);
1446 assert!(result[0].message.contains("Line length"));
1447 assert!(result[0].message.contains("exceeds 50 characters"));
1448 }
1449
1450 #[test]
1451 fn test_no_violation_under_limit() {
1452 let rule = MD013LineLength::new(100, false, false, false, false);
1453 let content = "Short line.\nAnother short line.";
1454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1455 let result = rule.check(&ctx).unwrap();
1456
1457 assert_eq!(result.len(), 0);
1458 }
1459
1460 #[test]
1461 fn test_multiple_violations() {
1462 let rule = MD013LineLength::new(30, false, false, false, false);
1463 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
1464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1465 let result = rule.check(&ctx).unwrap();
1466
1467 assert_eq!(result.len(), 2);
1468 assert_eq!(result[0].line, 1);
1469 assert_eq!(result[1].line, 2);
1470 }
1471
1472 #[test]
1473 fn test_code_blocks_exemption() {
1474 let rule = MD013LineLength::new(30, false, false, false, false);
1476 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
1477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1478 let result = rule.check(&ctx).unwrap();
1479
1480 assert_eq!(result.len(), 0);
1481 }
1482
1483 #[test]
1484 fn test_code_blocks_not_exempt_when_configured() {
1485 let rule = MD013LineLength::new(30, true, false, false, false);
1487 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
1488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1489 let result = rule.check(&ctx).unwrap();
1490
1491 assert!(!result.is_empty());
1492 }
1493
1494 #[test]
1495 fn test_heading_checked_when_enabled() {
1496 let rule = MD013LineLength::new(30, false, false, true, false);
1497 let content = "# This is a very long heading that would normally exceed the limit";
1498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1499 let result = rule.check(&ctx).unwrap();
1500
1501 assert_eq!(result.len(), 1);
1502 }
1503
1504 #[test]
1505 fn test_heading_exempt_when_disabled() {
1506 let rule = MD013LineLength::new(30, false, false, false, false);
1507 let content = "# This is a very long heading that should trigger a warning";
1508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1509 let result = rule.check(&ctx).unwrap();
1510
1511 assert_eq!(result.len(), 0);
1512 }
1513
1514 #[test]
1515 fn test_table_checked_when_enabled() {
1516 let rule = MD013LineLength::new(30, false, true, false, false);
1517 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
1518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1519 let result = rule.check(&ctx).unwrap();
1520
1521 assert_eq!(result.len(), 2); }
1523
1524 #[test]
1525 fn test_issue_78_tables_after_fenced_code_blocks() {
1526 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1529
1530```plain
1531some code block longer than 20 chars length
1532```
1533
1534this is a very long line
1535
1536| column A | column B |
1537| -------- | -------- |
1538| `var` | `val` |
1539| value 1 | value 2 |
1540
1541correct length line"#;
1542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1543 let result = rule.check(&ctx).unwrap();
1544
1545 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1547 assert_eq!(result[0].line, 7, "Should flag line 7");
1548 assert!(result[0].message.contains("24 exceeds 20"));
1549 }
1550
1551 #[test]
1552 fn test_issue_78_tables_with_inline_code() {
1553 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
1556| -------- | -------- |
1557| `var with very long name` | `val exceeding limit` |
1558| value 1 | value 2 |
1559
1560This line exceeds limit"#;
1561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1562 let result = rule.check(&ctx).unwrap();
1563
1564 assert_eq!(result.len(), 1, "Should only flag the non-table line");
1566 assert_eq!(result[0].line, 6, "Should flag line 6");
1567 }
1568
1569 #[test]
1570 fn test_issue_78_indented_code_blocks() {
1571 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1574
1575 some code block longer than 20 chars length
1576
1577this is a very long line
1578
1579| column A | column B |
1580| -------- | -------- |
1581| value 1 | value 2 |
1582
1583correct length line"#;
1584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1585 let result = rule.check(&ctx).unwrap();
1586
1587 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1589 assert_eq!(result[0].line, 5, "Should flag line 5");
1590 }
1591
1592 #[test]
1593 fn test_url_exemption() {
1594 let rule = MD013LineLength::new(30, false, false, false, false);
1595 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1597 let result = rule.check(&ctx).unwrap();
1598
1599 assert_eq!(result.len(), 0);
1600 }
1601
1602 #[test]
1603 fn test_image_reference_exemption() {
1604 let rule = MD013LineLength::new(30, false, false, false, false);
1605 let content = "![This is a very long image alt text that exceeds limit][reference]";
1606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1607 let result = rule.check(&ctx).unwrap();
1608
1609 assert_eq!(result.len(), 0);
1610 }
1611
1612 #[test]
1613 fn test_link_reference_exemption() {
1614 let rule = MD013LineLength::new(30, false, false, false, false);
1615 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1617 let result = rule.check(&ctx).unwrap();
1618
1619 assert_eq!(result.len(), 0);
1620 }
1621
1622 #[test]
1623 fn test_strict_mode() {
1624 let rule = MD013LineLength::new(30, false, false, false, true);
1625 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1627 let result = rule.check(&ctx).unwrap();
1628
1629 assert_eq!(result.len(), 1);
1631 }
1632
1633 #[test]
1634 fn test_blockquote_exemption() {
1635 let rule = MD013LineLength::new(30, false, false, false, false);
1636 let content = "> This is a very long line inside a blockquote that should be ignored.";
1637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1638 let result = rule.check(&ctx).unwrap();
1639
1640 assert_eq!(result.len(), 0);
1641 }
1642
1643 #[test]
1644 fn test_setext_heading_underline_exemption() {
1645 let rule = MD013LineLength::new(30, false, false, false, false);
1646 let content = "Heading\n========================================";
1647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1648 let result = rule.check(&ctx).unwrap();
1649
1650 assert_eq!(result.len(), 0);
1652 }
1653
1654 #[test]
1655 fn test_no_fix_without_reflow() {
1656 let rule = MD013LineLength::new(60, false, false, false, false);
1657 let content = "This line has trailing whitespace that makes it too long ";
1658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1659 let result = rule.check(&ctx).unwrap();
1660
1661 assert_eq!(result.len(), 1);
1662 assert!(result[0].fix.is_none());
1664
1665 let fixed = rule.fix(&ctx).unwrap();
1667 assert_eq!(fixed, content);
1668 }
1669
1670 #[test]
1671 fn test_character_vs_byte_counting() {
1672 let rule = MD013LineLength::new(10, false, false, false, false);
1673 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1676 let result = rule.check(&ctx).unwrap();
1677
1678 assert_eq!(result.len(), 1);
1679 assert_eq!(result[0].line, 1);
1680 }
1681
1682 #[test]
1683 fn test_empty_content() {
1684 let rule = MD013LineLength::default();
1685 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1686 let result = rule.check(&ctx).unwrap();
1687
1688 assert_eq!(result.len(), 0);
1689 }
1690
1691 #[test]
1692 fn test_excess_range_calculation() {
1693 let rule = MD013LineLength::new(10, false, false, false, false);
1694 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1696 let result = rule.check(&ctx).unwrap();
1697
1698 assert_eq!(result.len(), 1);
1699 assert_eq!(result[0].column, 11);
1701 assert_eq!(result[0].end_column, 21);
1702 }
1703
1704 #[test]
1705 fn test_html_block_exemption() {
1706 let rule = MD013LineLength::new(30, false, false, false, false);
1707 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1709 let result = rule.check(&ctx).unwrap();
1710
1711 assert_eq!(result.len(), 0);
1713 }
1714
1715 #[test]
1716 fn test_mixed_content() {
1717 let rule = MD013LineLength::new(30, false, false, false, false);
1719 let content = r#"# This heading is very long but should be exempt
1720
1721This regular paragraph line is too long and should trigger.
1722
1723```
1724Code block line that is very long but exempt.
1725```
1726
1727| Table | With very long content |
1728|-------|------------------------|
1729
1730Another long line that should trigger a warning."#;
1731
1732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1733 let result = rule.check(&ctx).unwrap();
1734
1735 assert_eq!(result.len(), 2);
1737 assert_eq!(result[0].line, 3);
1738 assert_eq!(result[1].line, 12);
1739 }
1740
1741 #[test]
1742 fn test_fix_without_reflow_preserves_content() {
1743 let rule = MD013LineLength::new(50, false, false, false, false);
1744 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
1745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1746
1747 let fixed = rule.fix(&ctx).unwrap();
1749 assert_eq!(fixed, content);
1750 }
1751
1752 #[test]
1753 fn test_content_detection() {
1754 let rule = MD013LineLength::default();
1755
1756 let long_line = "a".repeat(100);
1758 let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
1759 assert!(!rule.should_skip(&ctx)); let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1762 assert!(rule.should_skip(&empty_ctx)); }
1764
1765 #[test]
1766 fn test_rule_metadata() {
1767 let rule = MD013LineLength::default();
1768 assert_eq!(rule.name(), "MD013");
1769 assert_eq!(rule.description(), "Line length should not be excessive");
1770 assert_eq!(rule.category(), RuleCategory::Whitespace);
1771 }
1772
1773 #[test]
1774 fn test_url_embedded_in_text() {
1775 let rule = MD013LineLength::new(50, false, false, false, false);
1776
1777 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1780 let result = rule.check(&ctx).unwrap();
1781
1782 assert_eq!(result.len(), 0);
1784 }
1785
1786 #[test]
1787 fn test_multiple_urls_in_line() {
1788 let rule = MD013LineLength::new(50, false, false, false, false);
1789
1790 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1793
1794 let result = rule.check(&ctx).unwrap();
1795
1796 assert_eq!(result.len(), 0);
1798 }
1799
1800 #[test]
1801 fn test_markdown_link_with_long_url() {
1802 let rule = MD013LineLength::new(50, false, false, false, false);
1803
1804 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1807 let result = rule.check(&ctx).unwrap();
1808
1809 assert_eq!(result.len(), 0);
1811 }
1812
1813 #[test]
1814 fn test_line_too_long_even_without_urls() {
1815 let rule = MD013LineLength::new(50, false, false, false, false);
1816
1817 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1820 let result = rule.check(&ctx).unwrap();
1821
1822 assert_eq!(result.len(), 1);
1824 }
1825
1826 #[test]
1827 fn test_strict_mode_counts_urls() {
1828 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";
1832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1833 let result = rule.check(&ctx).unwrap();
1834
1835 assert_eq!(result.len(), 1);
1837 }
1838
1839 #[test]
1840 fn test_documentation_example_from_md051() {
1841 let rule = MD013LineLength::new(80, false, false, false, false);
1842
1843 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1846 let result = rule.check(&ctx).unwrap();
1847
1848 assert_eq!(result.len(), 0);
1850 }
1851
1852 #[test]
1853 fn test_text_reflow_simple() {
1854 let config = MD013Config {
1855 line_length: 30,
1856 reflow: true,
1857 ..Default::default()
1858 };
1859 let rule = MD013LineLength::from_config_struct(config);
1860
1861 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1863
1864 let fixed = rule.fix(&ctx).unwrap();
1865
1866 for line in fixed.lines() {
1868 assert!(
1869 line.chars().count() <= 30,
1870 "Line too long: {} (len={})",
1871 line,
1872 line.chars().count()
1873 );
1874 }
1875
1876 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1878 let original_words: Vec<&str> = content.split_whitespace().collect();
1879 assert_eq!(fixed_words, original_words);
1880 }
1881
1882 #[test]
1883 fn test_text_reflow_preserves_markdown_elements() {
1884 let config = MD013Config {
1885 line_length: 40,
1886 reflow: true,
1887 ..Default::default()
1888 };
1889 let rule = MD013LineLength::from_config_struct(config);
1890
1891 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1893
1894 let fixed = rule.fix(&ctx).unwrap();
1895
1896 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1898 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1899 assert!(
1900 fixed.contains("[a link](https://example.com)"),
1901 "Link not preserved in: {fixed}"
1902 );
1903
1904 for line in fixed.lines() {
1906 assert!(line.len() <= 40, "Line too long: {line}");
1907 }
1908 }
1909
1910 #[test]
1911 fn test_text_reflow_preserves_code_blocks() {
1912 let config = MD013Config {
1913 line_length: 30,
1914 reflow: true,
1915 ..Default::default()
1916 };
1917 let rule = MD013LineLength::from_config_struct(config);
1918
1919 let content = r#"Here is some text.
1920
1921```python
1922def very_long_function_name_that_exceeds_limit():
1923 return "This should not be wrapped"
1924```
1925
1926More text after code block."#;
1927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1928
1929 let fixed = rule.fix(&ctx).unwrap();
1930
1931 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1933 assert!(fixed.contains("```python"));
1934 assert!(fixed.contains("```"));
1935 }
1936
1937 #[test]
1938 fn test_text_reflow_preserves_lists() {
1939 let config = MD013Config {
1940 line_length: 30,
1941 reflow: true,
1942 ..Default::default()
1943 };
1944 let rule = MD013LineLength::from_config_struct(config);
1945
1946 let content = r#"Here is a list:
1947
19481. First item with a very long line that needs wrapping
19492. Second item is short
19503. Third item also has a long line that exceeds the limit
1951
1952And a bullet list:
1953
1954- Bullet item with very long content that needs wrapping
1955- Short bullet"#;
1956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1957
1958 let fixed = rule.fix(&ctx).unwrap();
1959
1960 assert!(fixed.contains("1. "));
1962 assert!(fixed.contains("2. "));
1963 assert!(fixed.contains("3. "));
1964 assert!(fixed.contains("- "));
1965
1966 let lines: Vec<&str> = fixed.lines().collect();
1968 for (i, line) in lines.iter().enumerate() {
1969 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1970 if i + 1 < lines.len()
1972 && !lines[i + 1].trim().is_empty()
1973 && !lines[i + 1].trim().starts_with(char::is_numeric)
1974 && !lines[i + 1].trim().starts_with("-")
1975 {
1976 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1978 }
1979 } else if line.trim().starts_with("-") {
1980 if i + 1 < lines.len()
1982 && !lines[i + 1].trim().is_empty()
1983 && !lines[i + 1].trim().starts_with(char::is_numeric)
1984 && !lines[i + 1].trim().starts_with("-")
1985 {
1986 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1988 }
1989 }
1990 }
1991 }
1992
1993 #[test]
1994 fn test_issue_83_numbered_list_with_backticks() {
1995 let config = MD013Config {
1997 line_length: 100,
1998 reflow: true,
1999 ..Default::default()
2000 };
2001 let rule = MD013LineLength::from_config_struct(config);
2002
2003 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
2005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2006
2007 let fixed = rule.fix(&ctx).unwrap();
2008
2009 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
2012
2013 assert_eq!(
2014 fixed, expected,
2015 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
2016 );
2017 }
2018
2019 #[test]
2020 fn test_text_reflow_disabled_by_default() {
2021 let rule = MD013LineLength::new(30, false, false, false, false);
2022
2023 let content = "This is a very long line that definitely exceeds thirty characters.";
2024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2025
2026 let fixed = rule.fix(&ctx).unwrap();
2027
2028 assert_eq!(fixed, content);
2031 }
2032
2033 #[test]
2034 fn test_reflow_with_hard_line_breaks() {
2035 let config = MD013Config {
2037 line_length: 40,
2038 reflow: true,
2039 ..Default::default()
2040 };
2041 let rule = MD013LineLength::from_config_struct(config);
2042
2043 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";
2045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2046 let fixed = rule.fix(&ctx).unwrap();
2047
2048 assert!(
2050 fixed.contains(" \n"),
2051 "Hard line break with exactly 2 spaces should be preserved"
2052 );
2053 }
2054
2055 #[test]
2056 fn test_reflow_preserves_reference_links() {
2057 let config = MD013Config {
2058 line_length: 40,
2059 reflow: true,
2060 ..Default::default()
2061 };
2062 let rule = MD013LineLength::from_config_struct(config);
2063
2064 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
2065
2066[ref]: https://example.com";
2067 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2068 let fixed = rule.fix(&ctx).unwrap();
2069
2070 assert!(fixed.contains("[reference link][ref]"));
2072 assert!(!fixed.contains("[ reference link]"));
2073 assert!(!fixed.contains("[ref ]"));
2074 }
2075
2076 #[test]
2077 fn test_reflow_with_nested_markdown_elements() {
2078 let config = MD013Config {
2079 line_length: 35,
2080 reflow: true,
2081 ..Default::default()
2082 };
2083 let rule = MD013LineLength::from_config_struct(config);
2084
2085 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
2086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2087 let fixed = rule.fix(&ctx).unwrap();
2088
2089 assert!(fixed.contains("**bold with `code` inside**"));
2091 }
2092
2093 #[test]
2094 fn test_reflow_with_unbalanced_markdown() {
2095 let config = MD013Config {
2097 line_length: 30,
2098 reflow: true,
2099 ..Default::default()
2100 };
2101 let rule = MD013LineLength::from_config_struct(config);
2102
2103 let content = "This has **unbalanced bold that goes on for a very long time without closing";
2104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2105 let fixed = rule.fix(&ctx).unwrap();
2106
2107 assert!(!fixed.is_empty());
2111 for line in fixed.lines() {
2113 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
2114 }
2115 }
2116
2117 #[test]
2118 fn test_reflow_fix_indicator() {
2119 let config = MD013Config {
2121 line_length: 30,
2122 reflow: true,
2123 ..Default::default()
2124 };
2125 let rule = MD013LineLength::from_config_struct(config);
2126
2127 let content = "This is a very long line that definitely exceeds the thirty character limit";
2128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2129 let warnings = rule.check(&ctx).unwrap();
2130
2131 assert!(!warnings.is_empty());
2133 assert!(
2134 warnings[0].fix.is_some(),
2135 "Should provide fix indicator when reflow is true"
2136 );
2137 }
2138
2139 #[test]
2140 fn test_no_fix_indicator_without_reflow() {
2141 let config = MD013Config {
2143 line_length: 30,
2144 reflow: false,
2145 ..Default::default()
2146 };
2147 let rule = MD013LineLength::from_config_struct(config);
2148
2149 let content = "This is a very long line that definitely exceeds the thirty character limit";
2150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2151 let warnings = rule.check(&ctx).unwrap();
2152
2153 assert!(!warnings.is_empty());
2155 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
2156 }
2157
2158 #[test]
2159 fn test_reflow_preserves_all_reference_link_types() {
2160 let config = MD013Config {
2161 line_length: 40,
2162 reflow: true,
2163 ..Default::default()
2164 };
2165 let rule = MD013LineLength::from_config_struct(config);
2166
2167 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
2168
2169[ref]: https://example.com
2170[collapsed]: https://example.com
2171[shortcut]: https://example.com";
2172
2173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2174 let fixed = rule.fix(&ctx).unwrap();
2175
2176 assert!(fixed.contains("[full reference][ref]"));
2178 assert!(fixed.contains("[collapsed][]"));
2179 assert!(fixed.contains("[shortcut]"));
2180 }
2181
2182 #[test]
2183 fn test_reflow_handles_images_correctly() {
2184 let config = MD013Config {
2185 line_length: 40,
2186 reflow: true,
2187 ..Default::default()
2188 };
2189 let rule = MD013LineLength::from_config_struct(config);
2190
2191 let content = "This line has an  that should not be broken when reflowing.";
2192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2193 let fixed = rule.fix(&ctx).unwrap();
2194
2195 assert!(fixed.contains(""));
2197 }
2198
2199 #[test]
2200 fn test_normalize_mode_flags_short_lines() {
2201 let config = MD013Config {
2202 line_length: 100,
2203 reflow: true,
2204 reflow_mode: ReflowMode::Normalize,
2205 ..Default::default()
2206 };
2207 let rule = MD013LineLength::from_config_struct(config);
2208
2209 let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
2211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2212 let warnings = rule.check(&ctx).unwrap();
2213
2214 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2216 assert!(warnings[0].message.contains("normalized"));
2217 }
2218
2219 #[test]
2220 fn test_normalize_mode_combines_short_lines() {
2221 let config = MD013Config {
2222 line_length: 100,
2223 reflow: true,
2224 reflow_mode: ReflowMode::Normalize,
2225 ..Default::default()
2226 };
2227 let rule = MD013LineLength::from_config_struct(config);
2228
2229 let content =
2231 "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
2232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2233 let fixed = rule.fix(&ctx).unwrap();
2234
2235 let lines: Vec<&str> = fixed.lines().collect();
2237 assert_eq!(lines.len(), 1, "Should combine into single line");
2238 assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
2239 }
2240
2241 #[test]
2242 fn test_normalize_mode_preserves_paragraph_breaks() {
2243 let config = MD013Config {
2244 line_length: 100,
2245 reflow: true,
2246 reflow_mode: ReflowMode::Normalize,
2247 ..Default::default()
2248 };
2249 let rule = MD013LineLength::from_config_struct(config);
2250
2251 let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
2252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2253 let fixed = rule.fix(&ctx).unwrap();
2254
2255 assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
2257
2258 let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
2259 assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
2260 }
2261
2262 #[test]
2263 fn test_default_mode_only_fixes_violations() {
2264 let config = MD013Config {
2265 line_length: 100,
2266 reflow: true,
2267 reflow_mode: ReflowMode::Default, ..Default::default()
2269 };
2270 let rule = MD013LineLength::from_config_struct(config);
2271
2272 let content = "This is a short line.\nAnother short line.\nA third short line.";
2274 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2275 let warnings = rule.check(&ctx).unwrap();
2276
2277 assert!(warnings.is_empty(), "Should not flag short lines in default mode");
2279
2280 let fixed = rule.fix(&ctx).unwrap();
2282 assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
2283 }
2284
2285 #[test]
2286 fn test_normalize_mode_with_lists() {
2287 let config = MD013Config {
2288 line_length: 80,
2289 reflow: true,
2290 reflow_mode: ReflowMode::Normalize,
2291 ..Default::default()
2292 };
2293 let rule = MD013LineLength::from_config_struct(config);
2294
2295 let content = r#"A paragraph with
2296short lines.
2297
22981. List item with
2299 short lines
23002. Another item"#;
2301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2302 let fixed = rule.fix(&ctx).unwrap();
2303
2304 let lines: Vec<&str> = fixed.lines().collect();
2306 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2307 assert!(fixed.contains("1. "), "Should preserve list markers");
2308 assert!(fixed.contains("2. "), "Should preserve list markers");
2309 }
2310
2311 #[test]
2312 fn test_normalize_mode_with_code_blocks() {
2313 let config = MD013Config {
2314 line_length: 100,
2315 reflow: true,
2316 reflow_mode: ReflowMode::Normalize,
2317 ..Default::default()
2318 };
2319 let rule = MD013LineLength::from_config_struct(config);
2320
2321 let content = r#"A paragraph with
2322short lines.
2323
2324```
2325code block should not be normalized
2326even with short lines
2327```
2328
2329Another paragraph with
2330short lines."#;
2331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2332 let fixed = rule.fix(&ctx).unwrap();
2333
2334 assert!(fixed.contains("code block should not be normalized\neven with short lines"));
2336 let lines: Vec<&str> = fixed.lines().collect();
2338 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2339 }
2340
2341 #[test]
2342 fn test_issue_76_use_case() {
2343 let config = MD013Config {
2345 line_length: 999999, reflow: true,
2347 reflow_mode: ReflowMode::Normalize,
2348 ..Default::default()
2349 };
2350 let rule = MD013LineLength::from_config_struct(config);
2351
2352 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.";
2354
2355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2356
2357 let warnings = rule.check(&ctx).unwrap();
2359 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2360
2361 let fixed = rule.fix(&ctx).unwrap();
2363 let lines: Vec<&str> = fixed.lines().collect();
2364 assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
2365 assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
2366 }
2367
2368 #[test]
2369 fn test_normalize_mode_single_line_unchanged() {
2370 let config = MD013Config {
2372 line_length: 100,
2373 reflow: true,
2374 reflow_mode: ReflowMode::Normalize,
2375 ..Default::default()
2376 };
2377 let rule = MD013LineLength::from_config_struct(config);
2378
2379 let content = "This is a single line that should not be changed.";
2380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2381
2382 let warnings = rule.check(&ctx).unwrap();
2383 assert!(warnings.is_empty(), "Single line should not be flagged");
2384
2385 let fixed = rule.fix(&ctx).unwrap();
2386 assert_eq!(fixed, content, "Single line should remain unchanged");
2387 }
2388
2389 #[test]
2390 fn test_normalize_mode_with_inline_code() {
2391 let config = MD013Config {
2392 line_length: 80,
2393 reflow: true,
2394 reflow_mode: ReflowMode::Normalize,
2395 ..Default::default()
2396 };
2397 let rule = MD013LineLength::from_config_struct(config);
2398
2399 let content =
2400 "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
2401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2402
2403 let warnings = rule.check(&ctx).unwrap();
2404 assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
2405
2406 let fixed = rule.fix(&ctx).unwrap();
2407 assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
2408 assert!(fixed.lines().count() < 3, "Lines should be combined");
2409 }
2410
2411 #[test]
2412 fn test_normalize_mode_with_emphasis() {
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 = "This has **bold** and\n*italic* text that\nshould be preserved.";
2422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2423
2424 let fixed = rule.fix(&ctx).unwrap();
2425 assert!(fixed.contains("**bold**"), "Bold should be preserved");
2426 assert!(fixed.contains("*italic*"), "Italic should be preserved");
2427 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2428 }
2429
2430 #[test]
2431 fn test_normalize_mode_respects_hard_breaks() {
2432 let config = MD013Config {
2433 line_length: 100,
2434 reflow: true,
2435 reflow_mode: ReflowMode::Normalize,
2436 ..Default::default()
2437 };
2438 let rule = MD013LineLength::from_config_struct(config);
2439
2440 let content = "First line with hard break \nSecond line after break\nThird line";
2442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2443
2444 let fixed = rule.fix(&ctx).unwrap();
2445 assert!(fixed.contains(" \n"), "Hard break should be preserved");
2447 assert!(
2449 fixed.contains("Second line after break Third line"),
2450 "Lines without hard break should combine"
2451 );
2452 }
2453
2454 #[test]
2455 fn test_normalize_mode_with_links() {
2456 let config = MD013Config {
2457 line_length: 100,
2458 reflow: true,
2459 reflow_mode: ReflowMode::Normalize,
2460 ..Default::default()
2461 };
2462 let rule = MD013LineLength::from_config_struct(config);
2463
2464 let content =
2465 "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
2466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2467
2468 let fixed = rule.fix(&ctx).unwrap();
2469 assert!(
2470 fixed.contains("[link](https://example.com)"),
2471 "Link should be preserved"
2472 );
2473 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2474 }
2475
2476 #[test]
2477 fn test_normalize_mode_empty_lines_between_paragraphs() {
2478 let config = MD013Config {
2479 line_length: 100,
2480 reflow: true,
2481 reflow_mode: ReflowMode::Normalize,
2482 ..Default::default()
2483 };
2484 let rule = MD013LineLength::from_config_struct(config);
2485
2486 let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
2487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2488
2489 let fixed = rule.fix(&ctx).unwrap();
2490 assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
2492 let parts: Vec<&str> = fixed.split("\n\n\n").collect();
2494 assert_eq!(parts.len(), 2, "Should have two parts");
2495 assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
2496 assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
2497 }
2498
2499 #[test]
2500 fn test_normalize_mode_mixed_list_types() {
2501 let config = MD013Config {
2502 line_length: 80,
2503 reflow: true,
2504 reflow_mode: ReflowMode::Normalize,
2505 ..Default::default()
2506 };
2507 let rule = MD013LineLength::from_config_struct(config);
2508
2509 let content = r#"Paragraph before list
2510with multiple lines.
2511
2512- Bullet item
2513* Another bullet
2514+ Plus bullet
2515
25161. Numbered item
25172. Another number
2518
2519Paragraph after list
2520with multiple lines."#;
2521
2522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2523 let fixed = rule.fix(&ctx).unwrap();
2524
2525 assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
2527 assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
2528 assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
2529 assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
2530
2531 assert!(
2533 fixed.starts_with("Paragraph before list with multiple lines."),
2534 "First paragraph should be normalized"
2535 );
2536 assert!(
2537 fixed.ends_with("Paragraph after list with multiple lines."),
2538 "Last paragraph should be normalized"
2539 );
2540 }
2541
2542 #[test]
2543 fn test_normalize_mode_with_horizontal_rules() {
2544 let config = MD013Config {
2545 line_length: 100,
2546 reflow: true,
2547 reflow_mode: ReflowMode::Normalize,
2548 ..Default::default()
2549 };
2550 let rule = MD013LineLength::from_config_struct(config);
2551
2552 let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
2553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2554
2555 let fixed = rule.fix(&ctx).unwrap();
2556 assert!(fixed.contains("---"), "Horizontal rule should be preserved");
2557 assert!(
2558 fixed.contains("Paragraph before horizontal rule."),
2559 "First paragraph normalized"
2560 );
2561 assert!(
2562 fixed.contains("Paragraph after horizontal rule."),
2563 "Second paragraph normalized"
2564 );
2565 }
2566
2567 #[test]
2568 fn test_normalize_mode_with_indented_code() {
2569 let config = MD013Config {
2570 line_length: 100,
2571 reflow: true,
2572 reflow_mode: ReflowMode::Normalize,
2573 ..Default::default()
2574 };
2575 let rule = MD013LineLength::from_config_struct(config);
2576
2577 let content = "Paragraph before\nindented code.\n\n This is indented code\n Should not be normalized\n\nParagraph after\nindented code.";
2578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2579
2580 let fixed = rule.fix(&ctx).unwrap();
2581 assert!(
2582 fixed.contains(" This is indented code\n Should not be normalized"),
2583 "Indented code preserved"
2584 );
2585 assert!(
2586 fixed.contains("Paragraph before indented code."),
2587 "First paragraph normalized"
2588 );
2589 assert!(
2590 fixed.contains("Paragraph after indented code."),
2591 "Second paragraph normalized"
2592 );
2593 }
2594
2595 #[test]
2596 fn test_normalize_mode_disabled_without_reflow() {
2597 let config = MD013Config {
2599 line_length: 100,
2600 reflow: false, reflow_mode: ReflowMode::Normalize,
2602 ..Default::default()
2603 };
2604 let rule = MD013LineLength::from_config_struct(config);
2605
2606 let content = "This is a line\nwith breaks that\nshould not be changed.";
2607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2608
2609 let warnings = rule.check(&ctx).unwrap();
2610 assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2611
2612 let fixed = rule.fix(&ctx).unwrap();
2613 assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2614 }
2615
2616 #[test]
2617 fn test_default_mode_with_long_lines() {
2618 let config = MD013Config {
2621 line_length: 50,
2622 reflow: true,
2623 reflow_mode: ReflowMode::Default,
2624 ..Default::default()
2625 };
2626 let rule = MD013LineLength::from_config_struct(config);
2627
2628 let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2630
2631 let warnings = rule.check(&ctx).unwrap();
2632 assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2633 assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2635
2636 let fixed = rule.fix(&ctx).unwrap();
2637 assert!(
2639 fixed.contains("Short line. This is"),
2640 "Should combine and reflow the paragraph"
2641 );
2642 assert!(
2643 fixed.contains("wrapping. Another short"),
2644 "Should include all paragraph content"
2645 );
2646 }
2647
2648 #[test]
2649 fn test_normalize_vs_default_mode_same_content() {
2650 let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2652
2653 let default_config = MD013Config {
2655 line_length: 100,
2656 reflow: true,
2657 reflow_mode: ReflowMode::Default,
2658 ..Default::default()
2659 };
2660 let default_rule = MD013LineLength::from_config_struct(default_config);
2661 let default_warnings = default_rule.check(&ctx).unwrap();
2662 let default_fixed = default_rule.fix(&ctx).unwrap();
2663
2664 let normalize_config = MD013Config {
2666 line_length: 100,
2667 reflow: true,
2668 reflow_mode: ReflowMode::Normalize,
2669 ..Default::default()
2670 };
2671 let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2672 let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2673 let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2674
2675 assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2677 assert!(
2678 !normalize_warnings.is_empty(),
2679 "Normalize mode should flag multi-line paragraphs"
2680 );
2681
2682 assert_eq!(
2683 default_fixed, content,
2684 "Default mode should not change content without violations"
2685 );
2686 assert_ne!(
2687 normalize_fixed, content,
2688 "Normalize mode should change multi-line paragraphs"
2689 );
2690 assert_eq!(
2691 normalize_fixed.lines().count(),
2692 1,
2693 "Normalize should combine into single line"
2694 );
2695 }
2696
2697 #[test]
2698 fn test_normalize_mode_with_reference_definitions() {
2699 let config = MD013Config {
2700 line_length: 100,
2701 reflow: true,
2702 reflow_mode: ReflowMode::Normalize,
2703 ..Default::default()
2704 };
2705 let rule = MD013LineLength::from_config_struct(config);
2706
2707 let content =
2708 "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2710
2711 let fixed = rule.fix(&ctx).unwrap();
2712 assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2713 assert!(
2714 fixed.contains("[ref]: https://example.com"),
2715 "Reference definition should be preserved"
2716 );
2717 assert!(
2718 fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2719 "Paragraph should be normalized"
2720 );
2721 }
2722
2723 #[test]
2724 fn test_normalize_mode_with_html_comments() {
2725 let config = MD013Config {
2726 line_length: 100,
2727 reflow: true,
2728 reflow_mode: ReflowMode::Normalize,
2729 ..Default::default()
2730 };
2731 let rule = MD013LineLength::from_config_struct(config);
2732
2733 let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2735
2736 let fixed = rule.fix(&ctx).unwrap();
2737 assert!(
2738 fixed.contains("<!-- This is a comment -->"),
2739 "HTML comment should be preserved"
2740 );
2741 assert!(
2742 fixed.contains("Paragraph before HTML comment."),
2743 "First paragraph normalized"
2744 );
2745 assert!(
2746 fixed.contains("Paragraph after HTML comment."),
2747 "Second paragraph normalized"
2748 );
2749 }
2750
2751 #[test]
2752 fn test_normalize_mode_line_starting_with_number() {
2753 let config = MD013Config {
2755 line_length: 100,
2756 reflow: true,
2757 reflow_mode: ReflowMode::Normalize,
2758 ..Default::default()
2759 };
2760 let rule = MD013LineLength::from_config_struct(config);
2761
2762 let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2764
2765 let fixed = rule.fix(&ctx).unwrap();
2766 assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2767 assert!(
2768 fixed.contains("80 characters"),
2769 "Number at start of line should be preserved"
2770 );
2771 }
2772
2773 #[test]
2774 fn test_default_mode_preserves_list_structure() {
2775 let config = MD013Config {
2777 line_length: 80,
2778 reflow: true,
2779 reflow_mode: ReflowMode::Default,
2780 ..Default::default()
2781 };
2782 let rule = MD013LineLength::from_config_struct(config);
2783
2784 let content = r#"- This is a bullet point that has
2785 some text on multiple lines
2786 that should stay separate
2787
27881. Numbered list item with
2789 multiple lines that should
2790 also stay separate"#;
2791
2792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2793 let fixed = rule.fix(&ctx).unwrap();
2794
2795 let lines: Vec<&str> = fixed.lines().collect();
2797 assert_eq!(
2798 lines[0], "- This is a bullet point that has",
2799 "First line should be unchanged"
2800 );
2801 assert_eq!(
2802 lines[1], " some text on multiple lines",
2803 "Continuation should be preserved"
2804 );
2805 assert_eq!(
2806 lines[2], " that should stay separate",
2807 "Second continuation should be preserved"
2808 );
2809 }
2810
2811 #[test]
2812 fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2813 let config = MD013Config {
2815 line_length: 80,
2816 reflow: true,
2817 reflow_mode: ReflowMode::Normalize,
2818 ..Default::default()
2819 };
2820 let rule = MD013LineLength::from_config_struct(config);
2821
2822 let content = r#"- This is a bullet point that has
2823 some text on multiple lines
2824 that should be combined
2825
28261. Numbered list item with
2827 multiple lines that need
2828 to be properly combined
28292. Second item"#;
2830
2831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2832 let fixed = rule.fix(&ctx).unwrap();
2833
2834 assert!(
2836 !fixed.contains("lines that"),
2837 "Should not have double spaces in bullet list"
2838 );
2839 assert!(
2840 !fixed.contains("need to"),
2841 "Should not have double spaces in numbered list"
2842 );
2843
2844 assert!(
2846 fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2847 "Bullet list should be properly combined"
2848 );
2849 assert!(
2850 fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2851 "Numbered list should be properly combined"
2852 );
2853 }
2854
2855 #[test]
2856 fn test_normalize_mode_actual_numbered_list() {
2857 let config = MD013Config {
2859 line_length: 100,
2860 reflow: true,
2861 reflow_mode: ReflowMode::Normalize,
2862 ..Default::default()
2863 };
2864 let rule = MD013LineLength::from_config_struct(config);
2865
2866 let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2868
2869 let fixed = rule.fix(&ctx).unwrap();
2870 assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2871 assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2872 assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2873 assert!(
2874 fixed.starts_with("Paragraph before list with multiple lines."),
2875 "Paragraph should be normalized"
2876 );
2877 }
2878
2879 #[test]
2880 fn test_sentence_per_line_detection() {
2881 let config = MD013Config {
2882 reflow: true,
2883 reflow_mode: ReflowMode::SentencePerLine,
2884 ..Default::default()
2885 };
2886 let rule = MD013LineLength::from_config_struct(config.clone());
2887
2888 let content = "This is sentence one. This is sentence two. And sentence three!";
2890 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2891
2892 assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2894
2895 let result = rule.check(&ctx).unwrap();
2896
2897 assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2898 assert_eq!(
2899 result[0].message,
2900 "Line contains multiple sentences (one sentence per line expected)"
2901 );
2902 }
2903
2904 #[test]
2905 fn test_sentence_per_line_fix() {
2906 let config = MD013Config {
2907 reflow: true,
2908 reflow_mode: ReflowMode::SentencePerLine,
2909 ..Default::default()
2910 };
2911 let rule = MD013LineLength::from_config_struct(config);
2912
2913 let content = "First sentence. Second sentence.";
2914 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2915 let result = rule.check(&ctx).unwrap();
2916
2917 assert!(!result.is_empty(), "Should detect violation");
2918 assert!(result[0].fix.is_some(), "Should provide a fix");
2919
2920 let fix = result[0].fix.as_ref().unwrap();
2921 assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2922 }
2923
2924 #[test]
2925 fn test_sentence_per_line_abbreviations() {
2926 let config = MD013Config {
2927 reflow: true,
2928 reflow_mode: ReflowMode::SentencePerLine,
2929 ..Default::default()
2930 };
2931 let rule = MD013LineLength::from_config_struct(config);
2932
2933 let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2935 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2936 let result = rule.check(&ctx).unwrap();
2937
2938 assert!(
2939 result.is_empty(),
2940 "Should not detect abbreviations as sentence boundaries"
2941 );
2942 }
2943
2944 #[test]
2945 fn test_sentence_per_line_with_markdown() {
2946 let config = MD013Config {
2947 reflow: true,
2948 reflow_mode: ReflowMode::SentencePerLine,
2949 ..Default::default()
2950 };
2951 let rule = MD013LineLength::from_config_struct(config);
2952
2953 let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
2954 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2955 let result = rule.check(&ctx).unwrap();
2956
2957 assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
2958 assert_eq!(result[0].line, 3); }
2960
2961 #[test]
2962 fn test_sentence_per_line_questions_exclamations() {
2963 let config = MD013Config {
2964 reflow: true,
2965 reflow_mode: ReflowMode::SentencePerLine,
2966 ..Default::default()
2967 };
2968 let rule = MD013LineLength::from_config_struct(config);
2969
2970 let content = "Is this a question? Yes it is! And a statement.";
2971 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2972 let result = rule.check(&ctx).unwrap();
2973
2974 assert!(!result.is_empty(), "Should detect sentences with ? and !");
2975
2976 let fix = result[0].fix.as_ref().unwrap();
2977 let lines: Vec<&str> = fix.replacement.trim().lines().collect();
2978 assert_eq!(lines.len(), 3);
2979 assert_eq!(lines[0], "Is this a question?");
2980 assert_eq!(lines[1], "Yes it is!");
2981 assert_eq!(lines[2], "And a statement.");
2982 }
2983
2984 #[test]
2985 fn test_sentence_per_line_in_lists() {
2986 let config = MD013Config {
2987 reflow: true,
2988 reflow_mode: ReflowMode::SentencePerLine,
2989 ..Default::default()
2990 };
2991 let rule = MD013LineLength::from_config_struct(config);
2992
2993 let content = "- List item one. With two sentences.\n- Another item.";
2994 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2995 let result = rule.check(&ctx).unwrap();
2996
2997 assert!(!result.is_empty(), "Should detect sentences in list items");
2998 let fix = result[0].fix.as_ref().unwrap();
3000 assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
3001 }
3002
3003 #[test]
3004 fn test_multi_paragraph_list_item_with_3_space_indent() {
3005 let config = MD013Config {
3006 reflow: true,
3007 reflow_mode: ReflowMode::Normalize,
3008 line_length: 999999,
3009 ..Default::default()
3010 };
3011 let rule = MD013LineLength::from_config_struct(config);
3012
3013 let content = "1. First paragraph\n continuation line.\n\n Second paragraph\n more content.";
3014 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3015 let result = rule.check(&ctx).unwrap();
3016
3017 assert!(!result.is_empty(), "Should detect multi-line paragraphs in list item");
3018 let fix = result[0].fix.as_ref().unwrap();
3019
3020 assert!(
3022 fix.replacement.contains("\n\n"),
3023 "Should preserve blank line between paragraphs"
3024 );
3025 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3026 }
3027
3028 #[test]
3029 fn test_multi_paragraph_list_item_with_4_space_indent() {
3030 let config = MD013Config {
3031 reflow: true,
3032 reflow_mode: ReflowMode::Normalize,
3033 line_length: 999999,
3034 ..Default::default()
3035 };
3036 let rule = MD013LineLength::from_config_struct(config);
3037
3038 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.";
3040 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3041 let result = rule.check(&ctx).unwrap();
3042
3043 assert!(
3044 !result.is_empty(),
3045 "Should detect multi-line paragraphs in list item with 4-space indent"
3046 );
3047 let fix = result[0].fix.as_ref().unwrap();
3048
3049 assert!(
3051 fix.replacement.contains("\n\n"),
3052 "Should preserve blank line between paragraphs"
3053 );
3054 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3055
3056 let lines: Vec<&str> = fix.replacement.split('\n').collect();
3058 let blank_line_idx = lines.iter().position(|l| l.trim().is_empty());
3059 assert!(blank_line_idx.is_some(), "Should have blank line separating paragraphs");
3060 }
3061
3062 #[test]
3063 fn test_multi_paragraph_bullet_list_item() {
3064 let config = MD013Config {
3065 reflow: true,
3066 reflow_mode: ReflowMode::Normalize,
3067 line_length: 999999,
3068 ..Default::default()
3069 };
3070 let rule = MD013LineLength::from_config_struct(config);
3071
3072 let content = "- First paragraph\n continuation.\n\n Second paragraph\n more text.";
3073 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3074 let result = rule.check(&ctx).unwrap();
3075
3076 assert!(!result.is_empty(), "Should detect multi-line paragraphs in bullet list");
3077 let fix = result[0].fix.as_ref().unwrap();
3078
3079 assert!(
3080 fix.replacement.contains("\n\n"),
3081 "Should preserve blank line between paragraphs"
3082 );
3083 assert!(fix.replacement.starts_with("- "), "Should preserve bullet marker");
3084 }
3085
3086 #[test]
3087 fn test_code_block_in_list_item_five_spaces() {
3088 let config = MD013Config {
3089 reflow: true,
3090 reflow_mode: ReflowMode::Normalize,
3091 line_length: 80,
3092 ..Default::default()
3093 };
3094 let rule = MD013LineLength::from_config_struct(config);
3095
3096 let content = "1. First paragraph with some text that should be reflowed.\n\n code_block()\n more_code()\n\n Second paragraph.";
3099 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3100 let result = rule.check(&ctx).unwrap();
3101
3102 if !result.is_empty() {
3103 let fix = result[0].fix.as_ref().unwrap();
3104 assert!(
3106 fix.replacement.contains(" code_block()"),
3107 "Code block should be preserved: {}",
3108 fix.replacement
3109 );
3110 assert!(
3111 fix.replacement.contains(" more_code()"),
3112 "Code block should be preserved: {}",
3113 fix.replacement
3114 );
3115 }
3116 }
3117
3118 #[test]
3119 fn test_fenced_code_block_in_list_item() {
3120 let config = MD013Config {
3121 reflow: true,
3122 reflow_mode: ReflowMode::Normalize,
3123 line_length: 80,
3124 ..Default::default()
3125 };
3126 let rule = MD013LineLength::from_config_struct(config);
3127
3128 let content = "1. First paragraph with some text.\n\n ```rust\n fn foo() {}\n let x = 1;\n ```\n\n Second paragraph.";
3129 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3130 let result = rule.check(&ctx).unwrap();
3131
3132 if !result.is_empty() {
3133 let fix = result[0].fix.as_ref().unwrap();
3134 assert!(
3136 fix.replacement.contains("```rust"),
3137 "Should preserve fence: {}",
3138 fix.replacement
3139 );
3140 assert!(
3141 fix.replacement.contains("fn foo() {}"),
3142 "Should preserve code: {}",
3143 fix.replacement
3144 );
3145 assert!(
3146 fix.replacement.contains("```"),
3147 "Should preserve closing fence: {}",
3148 fix.replacement
3149 );
3150 }
3151 }
3152
3153 #[test]
3154 fn test_mixed_indentation_3_and_4_spaces() {
3155 let config = MD013Config {
3156 reflow: true,
3157 reflow_mode: ReflowMode::Normalize,
3158 line_length: 999999,
3159 ..Default::default()
3160 };
3161 let rule = MD013LineLength::from_config_struct(config);
3162
3163 let content = "1. Text\n 3 space continuation\n 4 space continuation";
3165 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3166 let result = rule.check(&ctx).unwrap();
3167
3168 assert!(!result.is_empty(), "Should detect multi-line list item");
3169 let fix = result[0].fix.as_ref().unwrap();
3170 assert!(
3172 fix.replacement.contains("3 space continuation"),
3173 "Should include 3-space line: {}",
3174 fix.replacement
3175 );
3176 assert!(
3177 fix.replacement.contains("4 space continuation"),
3178 "Should include 4-space line: {}",
3179 fix.replacement
3180 );
3181 }
3182
3183 #[test]
3184 fn test_nested_list_in_multi_paragraph_item() {
3185 let config = MD013Config {
3186 reflow: true,
3187 reflow_mode: ReflowMode::Normalize,
3188 line_length: 999999,
3189 ..Default::default()
3190 };
3191 let rule = MD013LineLength::from_config_struct(config);
3192
3193 let content = "1. First paragraph.\n\n - Nested item\n continuation\n\n Second paragraph.";
3194 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3195 let result = rule.check(&ctx).unwrap();
3196
3197 assert!(!result.is_empty(), "Should detect and reflow parent item");
3199 if let Some(fix) = result[0].fix.as_ref() {
3200 assert!(
3202 fix.replacement.contains("- Nested"),
3203 "Should preserve nested list: {}",
3204 fix.replacement
3205 );
3206 assert!(
3207 fix.replacement.contains("Second paragraph"),
3208 "Should include content after nested list: {}",
3209 fix.replacement
3210 );
3211 }
3212 }
3213
3214 #[test]
3215 fn test_nested_fence_markers_different_types() {
3216 let config = MD013Config {
3217 reflow: true,
3218 reflow_mode: ReflowMode::Normalize,
3219 line_length: 80,
3220 ..Default::default()
3221 };
3222 let rule = MD013LineLength::from_config_struct(config);
3223
3224 let content = "1. Example with nested fences:\n\n ~~~markdown\n This shows ```python\n code = True\n ```\n ~~~\n\n Text after.";
3226 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3227 let result = rule.check(&ctx).unwrap();
3228
3229 if !result.is_empty() {
3230 let fix = result[0].fix.as_ref().unwrap();
3231 assert!(
3233 fix.replacement.contains("```python"),
3234 "Should preserve inner fence: {}",
3235 fix.replacement
3236 );
3237 assert!(
3238 fix.replacement.contains("~~~"),
3239 "Should preserve outer fence: {}",
3240 fix.replacement
3241 );
3242 assert!(
3244 fix.replacement.contains("code = True"),
3245 "Should preserve code: {}",
3246 fix.replacement
3247 );
3248 }
3249 }
3250
3251 #[test]
3252 fn test_nested_fence_markers_same_type() {
3253 let config = MD013Config {
3254 reflow: true,
3255 reflow_mode: ReflowMode::Normalize,
3256 line_length: 80,
3257 ..Default::default()
3258 };
3259 let rule = MD013LineLength::from_config_struct(config);
3260
3261 let content =
3263 "1. Example:\n\n ````markdown\n Shows ```python in code\n ```\n text here\n ````\n\n After.";
3264 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3265 let result = rule.check(&ctx).unwrap();
3266
3267 if !result.is_empty() {
3268 let fix = result[0].fix.as_ref().unwrap();
3269 assert!(
3271 fix.replacement.contains("```python"),
3272 "Should preserve inner fence: {}",
3273 fix.replacement
3274 );
3275 assert!(
3276 fix.replacement.contains("````"),
3277 "Should preserve outer fence: {}",
3278 fix.replacement
3279 );
3280 assert!(
3281 fix.replacement.contains("text here"),
3282 "Should keep text as code: {}",
3283 fix.replacement
3284 );
3285 }
3286 }
3287
3288 #[test]
3289 fn test_sibling_list_item_breaks_parent() {
3290 let config = MD013Config {
3291 reflow: true,
3292 reflow_mode: ReflowMode::Normalize,
3293 line_length: 999999,
3294 ..Default::default()
3295 };
3296 let rule = MD013LineLength::from_config_struct(config);
3297
3298 let content = "1. First item\n continuation.\n2. Second item";
3300 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3301 let result = rule.check(&ctx).unwrap();
3302
3303 if !result.is_empty() {
3305 let fix = result[0].fix.as_ref().unwrap();
3306 assert!(fix.replacement.starts_with("1. "), "Should start with first marker");
3308 assert!(fix.replacement.contains("continuation"), "Should include continuation");
3309 }
3311 }
3312
3313 #[test]
3314 fn test_nested_list_at_continuation_indent_preserved() {
3315 let config = MD013Config {
3316 reflow: true,
3317 reflow_mode: ReflowMode::Normalize,
3318 line_length: 999999,
3319 ..Default::default()
3320 };
3321 let rule = MD013LineLength::from_config_struct(config);
3322
3323 let content = "1. Parent paragraph\n with continuation.\n\n - Nested at 3 spaces\n - Another nested\n\n After nested.";
3325 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3326 let result = rule.check(&ctx).unwrap();
3327
3328 if !result.is_empty() {
3329 let fix = result[0].fix.as_ref().unwrap();
3330 assert!(
3332 fix.replacement.contains("- Nested"),
3333 "Should include first nested item: {}",
3334 fix.replacement
3335 );
3336 assert!(
3337 fix.replacement.contains("- Another"),
3338 "Should include second nested item: {}",
3339 fix.replacement
3340 );
3341 assert!(
3342 fix.replacement.contains("After nested"),
3343 "Should include content after nested list: {}",
3344 fix.replacement
3345 );
3346 }
3347 }
3348}