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.line_info(current_line + 1).is_some_and(|info| info.in_code_block)
73 && !trimmed.is_empty()
74 && !line.contains(' ')
75 && !line.contains('\t')
76 {
77 return true;
78 }
79
80 false
81 }
82}
83
84impl Rule for MD013LineLength {
85 fn name(&self) -> &'static str {
86 "MD013"
87 }
88
89 fn description(&self) -> &'static str {
90 "Line length should not be excessive"
91 }
92
93 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
94 let content = ctx.content;
95
96 if self.should_skip(ctx)
99 && !(self.config.reflow
100 && (self.config.reflow_mode == ReflowMode::Normalize
101 || self.config.reflow_mode == ReflowMode::SentencePerLine))
102 {
103 return Ok(Vec::new());
104 }
105
106 let mut warnings = Vec::new();
108
109 let inline_config = crate::inline_config::InlineConfig::from_content(content);
111 let config_override = inline_config.get_rule_config("MD013");
112
113 let effective_config = if let Some(json_config) = config_override {
115 if let Some(obj) = json_config.as_object() {
116 let mut config = self.config.clone();
117 if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
118 config.line_length = line_length as usize;
119 }
120 if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
121 config.code_blocks = code_blocks;
122 }
123 if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
124 config.tables = tables;
125 }
126 if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
127 config.headings = headings;
128 }
129 if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
130 config.strict = strict;
131 }
132 if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
133 config.reflow = reflow;
134 }
135 if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
136 config.reflow_mode = match reflow_mode {
137 "default" => ReflowMode::Default,
138 "normalize" => ReflowMode::Normalize,
139 "sentence-per-line" => ReflowMode::SentencePerLine,
140 _ => ReflowMode::default(),
141 };
142 }
143 config
144 } else {
145 self.config.clone()
146 }
147 } else {
148 self.config.clone()
149 };
150
151 let mut candidate_lines = Vec::new();
153 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
154 if line_info.content.len() > effective_config.line_length {
156 candidate_lines.push(line_idx);
157 }
158 }
159
160 if candidate_lines.is_empty()
162 && !(effective_config.reflow
163 && (effective_config.reflow_mode == ReflowMode::Normalize
164 || effective_config.reflow_mode == ReflowMode::SentencePerLine))
165 {
166 return Ok(warnings);
167 }
168
169 let lines: Vec<&str> = if !ctx.lines.is_empty() {
171 ctx.lines.iter().map(|l| l.content.as_str()).collect()
172 } else {
173 content.lines().collect()
174 };
175
176 let heading_lines_set: std::collections::HashSet<usize> = if !effective_config.headings {
178 ctx.lines
179 .iter()
180 .enumerate()
181 .filter(|(_, line)| line.heading.is_some())
182 .map(|(idx, _)| idx + 1)
183 .collect()
184 } else {
185 std::collections::HashSet::new()
186 };
187
188 let table_lines_set: std::collections::HashSet<usize> = if !effective_config.tables {
190 let table_blocks = TableUtils::find_table_blocks(content, ctx);
191 let mut table_lines = std::collections::HashSet::new();
192 for table in &table_blocks {
193 table_lines.insert(table.header_line + 1);
194 table_lines.insert(table.delimiter_line + 1);
195 for &line in &table.content_lines {
196 table_lines.insert(line + 1);
197 }
198 }
199 table_lines
200 } else {
201 std::collections::HashSet::new()
202 };
203
204 for &line_idx in &candidate_lines {
206 let line_number = line_idx + 1;
207 let line = lines[line_idx];
208
209 let effective_length = self.calculate_effective_length(line);
211
212 let line_limit = effective_config.line_length;
214
215 if effective_length <= line_limit {
217 continue;
218 }
219
220 if ctx.lines[line_idx].in_mkdocstrings {
222 continue;
223 }
224
225 if !effective_config.strict {
227 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
229 continue;
230 }
231
232 if (!effective_config.headings && heading_lines_set.contains(&line_number))
236 || (!effective_config.code_blocks
237 && ctx.line_info(line_number).is_some_and(|info| info.in_code_block))
238 || (!effective_config.tables && table_lines_set.contains(&line_number))
239 || ctx.lines[line_number - 1].blockquote.is_some()
240 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
241 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
242 {
243 continue;
244 }
245
246 if self.should_ignore_line(line, &lines, line_idx, ctx) {
248 continue;
249 }
250 }
251
252 if effective_config.reflow_mode == ReflowMode::SentencePerLine {
255 let sentences = split_into_sentences(line.trim());
256 if sentences.len() == 1 {
257 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
259
260 let (start_line, start_col, end_line, end_col) =
261 calculate_excess_range(line_number, line, line_limit);
262
263 warnings.push(LintWarning {
264 rule_name: Some(self.name().to_string()),
265 message,
266 line: start_line,
267 column: start_col,
268 end_line,
269 end_column: end_col,
270 severity: Severity::Warning,
271 fix: None, });
273 continue;
274 }
275 continue;
277 }
278
279 let fix = None;
282
283 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
284
285 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
287
288 warnings.push(LintWarning {
289 rule_name: Some(self.name().to_string()),
290 message,
291 line: start_line,
292 column: start_col,
293 end_line,
294 end_column: end_col,
295 severity: Severity::Warning,
296 fix,
297 });
298 }
299
300 if effective_config.reflow {
302 let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, &lines);
303 for pw in paragraph_warnings {
305 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
307 warnings.push(pw);
308 }
309 }
310
311 Ok(warnings)
312 }
313
314 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
315 let warnings = self.check(ctx)?;
318
319 if !warnings.iter().any(|w| w.fix.is_some()) {
321 return Ok(ctx.content.to_string());
322 }
323
324 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
326 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
327 }
328
329 fn as_any(&self) -> &dyn std::any::Any {
330 self
331 }
332
333 fn category(&self) -> RuleCategory {
334 RuleCategory::Whitespace
335 }
336
337 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
338 if ctx.content.is_empty() {
340 return true;
341 }
342
343 if self.config.reflow
345 && (self.config.reflow_mode == ReflowMode::SentencePerLine
346 || self.config.reflow_mode == ReflowMode::Normalize)
347 {
348 return false;
349 }
350
351 if ctx.content.len() <= self.config.line_length {
353 return true;
354 }
355
356 !ctx.lines
358 .iter()
359 .any(|line| line.content.len() > self.config.line_length)
360 }
361
362 fn default_config_section(&self) -> Option<(String, toml::Value)> {
363 let default_config = MD013Config::default();
364 let json_value = serde_json::to_value(&default_config).ok()?;
365 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
366
367 if let toml::Value::Table(table) = toml_value {
368 if !table.is_empty() {
369 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
370 } else {
371 None
372 }
373 } else {
374 None
375 }
376 }
377
378 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
379 let mut aliases = std::collections::HashMap::new();
380 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
381 Some(aliases)
382 }
383
384 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
385 where
386 Self: Sized,
387 {
388 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
389 if rule_config.line_length == 80 {
391 rule_config.line_length = config.global.line_length as usize;
393 }
394 Box::new(Self::from_config_struct(rule_config))
395 }
396}
397
398impl MD013LineLength {
399 fn generate_paragraph_fixes(
401 &self,
402 ctx: &crate::lint_context::LintContext,
403 config: &MD013Config,
404 lines: &[&str],
405 ) -> Vec<LintWarning> {
406 let mut warnings = Vec::new();
407 let line_index = LineIndex::new(ctx.content.to_string());
408
409 let mut i = 0;
410 while i < lines.len() {
411 let line_num = i + 1;
412
413 let should_skip_due_to_line_info = ctx.line_info(line_num).is_some_and(|info| {
415 info.in_code_block || info.in_front_matter || info.in_html_block || info.in_html_comment
416 });
417
418 if should_skip_due_to_line_info
419 || (line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some())
420 || lines[i].trim().starts_with('#')
421 || TableUtils::is_potential_table_row(lines[i])
422 || lines[i].trim().is_empty()
423 || is_horizontal_rule(lines[i].trim())
424 {
425 i += 1;
426 continue;
427 }
428
429 let is_semantic_line = |content: &str| -> bool {
431 let trimmed = content.trim_start();
432 let semantic_markers = [
433 "NOTE:",
434 "WARNING:",
435 "IMPORTANT:",
436 "CAUTION:",
437 "TIP:",
438 "DANGER:",
439 "HINT:",
440 "INFO:",
441 ];
442 semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
443 };
444
445 let is_fence_marker = |content: &str| -> bool {
447 let trimmed = content.trim_start();
448 trimmed.starts_with("```") || trimmed.starts_with("~~~")
449 };
450
451 let trimmed = lines[i].trim();
453 if is_list_item(trimmed) {
454 let list_start = i;
456 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
457 let marker_len = marker.len();
458
459 #[derive(Clone)]
461 enum LineType {
462 Content(String),
463 CodeBlock(String, usize), NestedListItem(String, usize), SemanticLine(String), Empty,
467 }
468
469 let mut actual_indent: Option<usize> = None;
470 let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
471 i += 1;
472
473 while i < lines.len() {
475 let line_info = &ctx.lines[i];
476
477 if line_info.is_blank {
479 if i + 1 < lines.len() {
481 let next_info = &ctx.lines[i + 1];
482
483 if !next_info.is_blank && next_info.indent >= marker_len {
485 list_item_lines.push(LineType::Empty);
487 i += 1;
488 continue;
489 }
490 }
491 break;
493 }
494
495 let indent = line_info.indent;
497
498 if indent >= marker_len {
500 let trimmed = line_info.content.trim();
501
502 if line_info.in_code_block {
504 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
505 i += 1;
506 continue;
507 }
508
509 if is_list_item(trimmed) && indent < marker_len {
513 break;
515 }
516
517 if is_list_item(trimmed) && indent >= marker_len {
522 let has_blank_before = matches!(list_item_lines.last(), Some(LineType::Empty));
524
525 let has_nested_content = list_item_lines.iter().any(|line| {
527 matches!(line, LineType::Content(c) if is_list_item(c.trim()))
528 || matches!(line, LineType::NestedListItem(_, _))
529 });
530
531 if !has_blank_before && !has_nested_content {
532 break;
535 }
536 list_item_lines.push(LineType::NestedListItem(
539 line_info.content[indent..].to_string(),
540 indent,
541 ));
542 i += 1;
543 continue;
544 }
545
546 if indent <= marker_len + 3 {
548 if actual_indent.is_none() {
550 actual_indent = Some(indent);
551 }
552
553 let content = trim_preserving_hard_break(&line_info.content[indent..]);
557
558 if is_fence_marker(&content) {
561 list_item_lines.push(LineType::CodeBlock(content, indent));
562 }
563 else if is_semantic_line(&content) {
565 list_item_lines.push(LineType::SemanticLine(content));
566 } else {
567 list_item_lines.push(LineType::Content(content));
568 }
569 i += 1;
570 } else {
571 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
573 i += 1;
574 }
575 } else {
576 break;
578 }
579 }
580
581 let indent_size = actual_indent.unwrap_or(marker_len);
583 let expected_indent = " ".repeat(indent_size);
584
585 #[derive(Clone)]
587 enum Block {
588 Paragraph(Vec<String>),
589 Code {
590 lines: Vec<(String, usize)>, has_preceding_blank: bool, },
593 NestedList(Vec<(String, usize)>), SemanticLine(String), }
596
597 let mut blocks: Vec<Block> = Vec::new();
598 let mut current_paragraph: Vec<String> = Vec::new();
599 let mut current_code_block: Vec<(String, usize)> = Vec::new();
600 let mut current_nested_list: Vec<(String, usize)> = Vec::new();
601 let mut in_code = false;
602 let mut in_nested_list = false;
603 let mut had_preceding_blank = false; let mut code_block_has_preceding_blank = false; for line in &list_item_lines {
607 match line {
608 LineType::Empty => {
609 if in_code {
610 current_code_block.push((String::new(), 0));
611 } else if in_nested_list {
612 current_nested_list.push((String::new(), 0));
613 } else if !current_paragraph.is_empty() {
614 blocks.push(Block::Paragraph(current_paragraph.clone()));
615 current_paragraph.clear();
616 }
617 had_preceding_blank = true;
619 }
620 LineType::Content(content) => {
621 if in_code {
622 blocks.push(Block::Code {
624 lines: current_code_block.clone(),
625 has_preceding_blank: code_block_has_preceding_blank,
626 });
627 current_code_block.clear();
628 in_code = false;
629 } else if in_nested_list {
630 blocks.push(Block::NestedList(current_nested_list.clone()));
632 current_nested_list.clear();
633 in_nested_list = false;
634 }
635 current_paragraph.push(content.clone());
636 had_preceding_blank = false; }
638 LineType::CodeBlock(content, indent) => {
639 if in_nested_list {
640 blocks.push(Block::NestedList(current_nested_list.clone()));
642 current_nested_list.clear();
643 in_nested_list = false;
644 }
645 if !in_code {
646 if !current_paragraph.is_empty() {
648 blocks.push(Block::Paragraph(current_paragraph.clone()));
649 current_paragraph.clear();
650 }
651 in_code = true;
652 code_block_has_preceding_blank = had_preceding_blank;
654 }
655 current_code_block.push((content.clone(), *indent));
656 had_preceding_blank = false; }
658 LineType::NestedListItem(content, indent) => {
659 if in_code {
660 blocks.push(Block::Code {
662 lines: current_code_block.clone(),
663 has_preceding_blank: code_block_has_preceding_blank,
664 });
665 current_code_block.clear();
666 in_code = false;
667 }
668 if !in_nested_list {
669 if !current_paragraph.is_empty() {
671 blocks.push(Block::Paragraph(current_paragraph.clone()));
672 current_paragraph.clear();
673 }
674 in_nested_list = true;
675 }
676 current_nested_list.push((content.clone(), *indent));
677 had_preceding_blank = false; }
679 LineType::SemanticLine(content) => {
680 if in_code {
682 blocks.push(Block::Code {
683 lines: current_code_block.clone(),
684 has_preceding_blank: code_block_has_preceding_blank,
685 });
686 current_code_block.clear();
687 in_code = false;
688 } else if in_nested_list {
689 blocks.push(Block::NestedList(current_nested_list.clone()));
690 current_nested_list.clear();
691 in_nested_list = false;
692 } else if !current_paragraph.is_empty() {
693 blocks.push(Block::Paragraph(current_paragraph.clone()));
694 current_paragraph.clear();
695 }
696 blocks.push(Block::SemanticLine(content.clone()));
698 had_preceding_blank = false; }
700 }
701 }
702
703 if in_code && !current_code_block.is_empty() {
705 blocks.push(Block::Code {
706 lines: current_code_block,
707 has_preceding_blank: code_block_has_preceding_blank,
708 });
709 } else if in_nested_list && !current_nested_list.is_empty() {
710 blocks.push(Block::NestedList(current_nested_list));
711 } else if !current_paragraph.is_empty() {
712 blocks.push(Block::Paragraph(current_paragraph));
713 }
714
715 let content_lines: Vec<String> = list_item_lines
717 .iter()
718 .filter_map(|line| {
719 if let LineType::Content(s) = line {
720 Some(s.clone())
721 } else {
722 None
723 }
724 })
725 .collect();
726
727 let combined_content = content_lines.join(" ").trim().to_string();
730 let full_line = format!("{marker}{combined_content}");
731
732 let should_normalize = || {
734 let has_nested_lists = blocks.iter().any(|b| matches!(b, Block::NestedList(_)));
737 let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
738 let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
739 let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
740
741 if (has_nested_lists || has_code_blocks || has_semantic_lines) && !has_paragraphs {
743 return false;
744 }
745
746 if has_paragraphs {
748 let paragraph_count = blocks.iter().filter(|b| matches!(b, Block::Paragraph(_))).count();
749 if paragraph_count > 1 {
750 return true;
752 }
753
754 if content_lines.len() > 1 {
756 return true;
757 }
758 }
759
760 false
761 };
762
763 let needs_reflow = match config.reflow_mode {
764 ReflowMode::Normalize => {
765 let combined_length = self.calculate_effective_length(&full_line);
769 if combined_length > config.line_length {
770 true
771 } else {
772 should_normalize()
773 }
774 }
775 ReflowMode::SentencePerLine => {
776 let sentences = split_into_sentences(&combined_content);
778 sentences.len() > 1
779 }
780 ReflowMode::Default => {
781 self.calculate_effective_length(&full_line) > config.line_length
783 }
784 };
785
786 if needs_reflow {
787 let start_range = line_index.whole_line_range(list_start + 1);
788 let end_line = i - 1;
789 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
790 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
791 } else {
792 line_index.whole_line_range(end_line + 1)
793 };
794 let byte_range = start_range.start..end_range.end;
795
796 let reflow_options = crate::utils::text_reflow::ReflowOptions {
798 line_length: config.line_length - indent_size,
799 break_on_sentences: true,
800 preserve_breaks: false,
801 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
802 };
803
804 let mut result: Vec<String> = Vec::new();
805 let mut is_first_block = true;
806
807 for (block_idx, block) in blocks.iter().enumerate() {
808 match block {
809 Block::Paragraph(para_lines) => {
810 let segments = split_into_segments(para_lines);
813
814 for (segment_idx, segment) in segments.iter().enumerate() {
815 let hard_break_type = segment.last().and_then(|line| {
817 let line = line.strip_suffix('\r').unwrap_or(line);
818 if line.ends_with('\\') {
819 Some("\\")
820 } else if line.ends_with(" ") {
821 Some(" ")
822 } else {
823 None
824 }
825 });
826
827 let segment_for_reflow: Vec<String> = segment
829 .iter()
830 .map(|line| {
831 if line.ends_with('\\') {
833 line[..line.len() - 1].trim_end().to_string()
834 } else if line.ends_with(" ") {
835 line[..line.len() - 2].trim_end().to_string()
836 } else {
837 line.clone()
838 }
839 })
840 .collect();
841
842 let segment_text = segment_for_reflow.join(" ").trim().to_string();
843 if !segment_text.is_empty() {
844 let reflowed =
845 crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
846
847 if is_first_block && segment_idx == 0 {
848 result.push(format!("{marker}{}", reflowed[0]));
850 for line in reflowed.iter().skip(1) {
851 result.push(format!("{expected_indent}{line}"));
852 }
853 is_first_block = false;
854 } else {
855 for line in reflowed {
857 result.push(format!("{expected_indent}{line}"));
858 }
859 }
860
861 if let Some(break_marker) = hard_break_type
864 && let Some(last_line) = result.last_mut()
865 {
866 last_line.push_str(break_marker);
867 }
868 }
869 }
870
871 if block_idx < blocks.len() - 1 {
874 let next_block = &blocks[block_idx + 1];
875 let should_add_blank = match next_block {
876 Block::Code {
877 has_preceding_blank, ..
878 } => *has_preceding_blank,
879 _ => true, };
881 if should_add_blank {
882 result.push(String::new());
883 }
884 }
885 }
886 Block::Code {
887 lines: code_lines,
888 has_preceding_blank: _,
889 } => {
890 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
895 if is_first_block && idx == 0 {
896 result.push(format!(
898 "{marker}{}",
899 " ".repeat(orig_indent - marker_len) + content
900 ));
901 is_first_block = false;
902 } else if content.is_empty() {
903 result.push(String::new());
904 } else {
905 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
906 }
907 }
908 }
909 Block::NestedList(nested_items) => {
910 if !is_first_block {
912 result.push(String::new());
913 }
914
915 for (idx, (content, orig_indent)) in nested_items.iter().enumerate() {
916 if is_first_block && idx == 0 {
917 result.push(format!(
919 "{marker}{}",
920 " ".repeat(orig_indent - marker_len) + content
921 ));
922 is_first_block = false;
923 } else if content.is_empty() {
924 result.push(String::new());
925 } else {
926 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
927 }
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 Block::SemanticLine(content) => {
946 if !is_first_block {
949 result.push(String::new());
950 }
951
952 if is_first_block {
953 result.push(format!("{marker}{content}"));
955 is_first_block = false;
956 } else {
957 result.push(format!("{expected_indent}{content}"));
959 }
960
961 if block_idx < blocks.len() - 1 {
964 let next_block = &blocks[block_idx + 1];
965 let should_add_blank = match next_block {
966 Block::Code {
967 has_preceding_blank, ..
968 } => *has_preceding_blank,
969 _ => true, };
971 if should_add_blank {
972 result.push(String::new());
973 }
974 }
975 }
976 }
977 }
978
979 let reflowed_text = result.join("\n");
980
981 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
983 format!("{reflowed_text}\n")
984 } else {
985 reflowed_text
986 };
987
988 let original_text = &ctx.content[byte_range.clone()];
990
991 if original_text != replacement {
993 let message = match config.reflow_mode {
995 ReflowMode::SentencePerLine => "Line contains multiple sentences".to_string(),
996 ReflowMode::Normalize => {
997 let combined_length = self.calculate_effective_length(&full_line);
998 if combined_length > config.line_length {
999 format!(
1000 "Line length {} exceeds {} characters",
1001 combined_length, config.line_length
1002 )
1003 } else {
1004 "Multi-line content can be normalized".to_string()
1005 }
1006 }
1007 ReflowMode::Default => {
1008 let combined_length = self.calculate_effective_length(&full_line);
1009 format!(
1010 "Line length {} exceeds {} characters",
1011 combined_length, config.line_length
1012 )
1013 }
1014 };
1015
1016 warnings.push(LintWarning {
1017 rule_name: Some(self.name().to_string()),
1018 message,
1019 line: list_start + 1,
1020 column: 1,
1021 end_line: end_line + 1,
1022 end_column: lines[end_line].len() + 1,
1023 severity: Severity::Warning,
1024 fix: Some(crate::rule::Fix {
1025 range: byte_range,
1026 replacement,
1027 }),
1028 });
1029 }
1030 }
1031 continue;
1032 }
1033
1034 let paragraph_start = i;
1036 let mut paragraph_lines = vec![lines[i]];
1037 i += 1;
1038
1039 while i < lines.len() {
1040 let next_line = lines[i];
1041 let next_line_num = i + 1;
1042 let next_trimmed = next_line.trim();
1043
1044 if next_trimmed.is_empty()
1046 || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
1047 || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
1048 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
1049 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_comment)
1050 || (next_line_num > 0
1051 && next_line_num <= ctx.lines.len()
1052 && ctx.lines[next_line_num - 1].blockquote.is_some())
1053 || next_trimmed.starts_with('#')
1054 || TableUtils::is_potential_table_row(next_line)
1055 || is_list_item(next_trimmed)
1056 || is_horizontal_rule(next_trimmed)
1057 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
1058 {
1059 break;
1060 }
1061
1062 if i > 0 && has_hard_break(lines[i - 1]) {
1064 break;
1066 }
1067
1068 paragraph_lines.push(next_line);
1069 i += 1;
1070 }
1071
1072 let needs_reflow = match config.reflow_mode {
1074 ReflowMode::Normalize => {
1075 paragraph_lines.len() > 1
1077 }
1078 ReflowMode::SentencePerLine => {
1079 paragraph_lines.iter().any(|line| {
1081 let sentences = split_into_sentences(line);
1083 sentences.len() > 1
1084 })
1085 }
1086 ReflowMode::Default => {
1087 paragraph_lines
1089 .iter()
1090 .any(|line| self.calculate_effective_length(line) > config.line_length)
1091 }
1092 };
1093
1094 if needs_reflow {
1095 let start_range = line_index.whole_line_range(paragraph_start + 1);
1098 let end_line = paragraph_start + paragraph_lines.len() - 1;
1099
1100 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1102 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1104 } else {
1105 line_index.whole_line_range(end_line + 1)
1107 };
1108
1109 let byte_range = start_range.start..end_range.end;
1110
1111 let paragraph_text = paragraph_lines.join(" ");
1113
1114 let hard_break_type = paragraph_lines.last().and_then(|line| {
1116 let line = line.strip_suffix('\r').unwrap_or(line);
1117 if line.ends_with('\\') {
1118 Some("\\")
1119 } else if line.ends_with(" ") {
1120 Some(" ")
1121 } else {
1122 None
1123 }
1124 });
1125
1126 let reflow_options = crate::utils::text_reflow::ReflowOptions {
1128 line_length: config.line_length,
1129 break_on_sentences: true,
1130 preserve_breaks: false,
1131 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1132 };
1133 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
1134
1135 if let Some(break_marker) = hard_break_type
1138 && !reflowed.is_empty()
1139 {
1140 let last_idx = reflowed.len() - 1;
1141 if !has_hard_break(&reflowed[last_idx]) {
1142 reflowed[last_idx].push_str(break_marker);
1143 }
1144 }
1145
1146 let reflowed_text = reflowed.join("\n");
1147
1148 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1150 format!("{reflowed_text}\n")
1151 } else {
1152 reflowed_text
1153 };
1154
1155 let original_text = &ctx.content[byte_range.clone()];
1157
1158 if original_text != replacement {
1160 let (warning_line, warning_end_line) = match config.reflow_mode {
1165 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
1166 ReflowMode::SentencePerLine => {
1167 let mut violating_line = paragraph_start;
1169 for (idx, line) in paragraph_lines.iter().enumerate() {
1170 let sentences = split_into_sentences(line);
1171 if sentences.len() > 1 {
1172 violating_line = paragraph_start + idx;
1173 break;
1174 }
1175 }
1176 (violating_line + 1, violating_line + 1)
1177 }
1178 ReflowMode::Default => {
1179 let mut violating_line = paragraph_start;
1181 for (idx, line) in paragraph_lines.iter().enumerate() {
1182 if self.calculate_effective_length(line) > config.line_length {
1183 violating_line = paragraph_start + idx;
1184 break;
1185 }
1186 }
1187 (violating_line + 1, violating_line + 1)
1188 }
1189 };
1190
1191 warnings.push(LintWarning {
1192 rule_name: Some(self.name().to_string()),
1193 message: match config.reflow_mode {
1194 ReflowMode::Normalize => format!(
1195 "Paragraph could be normalized to use line length of {} characters",
1196 config.line_length
1197 ),
1198 ReflowMode::SentencePerLine => "Line contains multiple sentences".to_string(),
1199 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length),
1200 },
1201 line: warning_line,
1202 column: 1,
1203 end_line: warning_end_line,
1204 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
1205 severity: Severity::Warning,
1206 fix: Some(crate::rule::Fix {
1207 range: byte_range,
1208 replacement,
1209 }),
1210 });
1211 }
1212 }
1213 }
1214
1215 warnings
1216 }
1217
1218 fn calculate_effective_length(&self, line: &str) -> usize {
1220 if self.config.strict {
1221 return line.chars().count();
1223 }
1224
1225 let bytes = line.as_bytes();
1227 if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
1228 return line.chars().count();
1229 }
1230
1231 if !line.contains("http") && !line.contains('[') {
1233 return line.chars().count();
1234 }
1235
1236 let mut effective_line = line.to_string();
1237
1238 if line.contains('[') && line.contains("](") {
1241 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
1242 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
1243 && url.as_str().len() > 15
1244 {
1245 let replacement = format!("[{}](url)", text.as_str());
1246 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
1247 }
1248 }
1249 }
1250
1251 if effective_line.contains("http") {
1254 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
1255 let url = url_match.as_str();
1256 if !effective_line.contains(&format!("({url})")) {
1258 let placeholder = "x".repeat(15.min(url.len()));
1261 effective_line = effective_line.replacen(url, &placeholder, 1);
1262 }
1263 }
1264 }
1265
1266 effective_line.chars().count()
1267 }
1268}
1269
1270fn has_hard_break(line: &str) -> bool {
1276 let line = line.strip_suffix('\r').unwrap_or(line);
1277 line.ends_with(" ") || line.ends_with('\\')
1278}
1279
1280fn trim_preserving_hard_break(s: &str) -> String {
1287 let s = s.strip_suffix('\r').unwrap_or(s);
1289
1290 if s.ends_with('\\') {
1292 return s.to_string();
1294 }
1295
1296 if s.ends_with(" ") {
1298 let content_end = s.trim_end().len();
1300 if content_end == 0 {
1301 return String::new();
1303 }
1304 format!("{} ", &s[..content_end])
1306 } else {
1307 s.trim_end().to_string()
1309 }
1310}
1311
1312fn split_into_segments(para_lines: &[String]) -> Vec<Vec<String>> {
1323 let mut segments: Vec<Vec<String>> = Vec::new();
1324 let mut current_segment: Vec<String> = Vec::new();
1325
1326 for line in para_lines {
1327 current_segment.push(line.clone());
1328
1329 if has_hard_break(line) {
1331 segments.push(current_segment.clone());
1332 current_segment.clear();
1333 }
1334 }
1335
1336 if !current_segment.is_empty() {
1338 segments.push(current_segment);
1339 }
1340
1341 segments
1342}
1343
1344fn extract_list_marker_and_content(line: &str) -> (String, String) {
1345 let indent_len = line.len() - line.trim_start().len();
1347 let indent = &line[..indent_len];
1348 let trimmed = &line[indent_len..];
1349
1350 if let Some(rest) = trimmed.strip_prefix("- ") {
1353 return (format!("{indent}- "), trim_preserving_hard_break(rest));
1354 }
1355 if let Some(rest) = trimmed.strip_prefix("* ") {
1356 return (format!("{indent}* "), trim_preserving_hard_break(rest));
1357 }
1358 if let Some(rest) = trimmed.strip_prefix("+ ") {
1359 return (format!("{indent}+ "), trim_preserving_hard_break(rest));
1360 }
1361
1362 let mut chars = trimmed.chars();
1364 let mut marker_content = String::new();
1365
1366 while let Some(c) = chars.next() {
1367 marker_content.push(c);
1368 if c == '.' {
1369 if let Some(next) = chars.next()
1371 && next == ' '
1372 {
1373 marker_content.push(next);
1374 let content = trim_preserving_hard_break(chars.as_str());
1376 return (format!("{indent}{marker_content}"), content);
1377 }
1378 break;
1379 }
1380 }
1381
1382 (String::new(), line.to_string())
1384}
1385
1386fn is_horizontal_rule(line: &str) -> bool {
1388 if line.len() < 3 {
1389 return false;
1390 }
1391 let chars: Vec<char> = line.chars().collect();
1393 if chars.is_empty() {
1394 return false;
1395 }
1396 let first_char = chars[0];
1397 if first_char != '-' && first_char != '_' && first_char != '*' {
1398 return false;
1399 }
1400 for c in &chars {
1402 if *c != first_char && *c != ' ' {
1403 return false;
1404 }
1405 }
1406 chars.iter().filter(|c| **c == first_char).count() >= 3
1408}
1409
1410fn is_numbered_list_item(line: &str) -> bool {
1411 let mut chars = line.chars();
1412 if !chars.next().is_some_and(|c| c.is_numeric()) {
1414 return false;
1415 }
1416 while let Some(c) = chars.next() {
1418 if c == '.' {
1419 return chars.next().is_none_or(|c| c == ' ');
1421 }
1422 if !c.is_numeric() {
1423 return false;
1424 }
1425 }
1426 false
1427}
1428
1429fn is_list_item(line: &str) -> bool {
1430 if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
1432 && line.len() > 1
1433 && line.chars().nth(1) == Some(' ')
1434 {
1435 return true;
1436 }
1437 is_numbered_list_item(line)
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443 use super::*;
1444 use crate::lint_context::LintContext;
1445
1446 #[test]
1447 fn test_default_config() {
1448 let rule = MD013LineLength::default();
1449 assert_eq!(rule.config.line_length, 80);
1450 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
1454 }
1455
1456 #[test]
1457 fn test_custom_config() {
1458 let rule = MD013LineLength::new(100, true, true, false, true);
1459 assert_eq!(rule.config.line_length, 100);
1460 assert!(rule.config.code_blocks);
1461 assert!(rule.config.tables);
1462 assert!(!rule.config.headings);
1463 assert!(rule.config.strict);
1464 }
1465
1466 #[test]
1467 fn test_basic_line_length_violation() {
1468 let rule = MD013LineLength::new(50, false, false, false, false);
1469 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
1470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1471 let result = rule.check(&ctx).unwrap();
1472
1473 assert_eq!(result.len(), 1);
1474 assert!(result[0].message.contains("Line length"));
1475 assert!(result[0].message.contains("exceeds 50 characters"));
1476 }
1477
1478 #[test]
1479 fn test_no_violation_under_limit() {
1480 let rule = MD013LineLength::new(100, false, false, false, false);
1481 let content = "Short line.\nAnother short line.";
1482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1483 let result = rule.check(&ctx).unwrap();
1484
1485 assert_eq!(result.len(), 0);
1486 }
1487
1488 #[test]
1489 fn test_multiple_violations() {
1490 let rule = MD013LineLength::new(30, false, false, false, false);
1491 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
1492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1493 let result = rule.check(&ctx).unwrap();
1494
1495 assert_eq!(result.len(), 2);
1496 assert_eq!(result[0].line, 1);
1497 assert_eq!(result[1].line, 2);
1498 }
1499
1500 #[test]
1501 fn test_code_blocks_exemption() {
1502 let rule = MD013LineLength::new(30, false, false, false, false);
1504 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
1505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1506 let result = rule.check(&ctx).unwrap();
1507
1508 assert_eq!(result.len(), 0);
1509 }
1510
1511 #[test]
1512 fn test_code_blocks_not_exempt_when_configured() {
1513 let rule = MD013LineLength::new(30, true, false, false, false);
1515 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
1516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1517 let result = rule.check(&ctx).unwrap();
1518
1519 assert!(!result.is_empty());
1520 }
1521
1522 #[test]
1523 fn test_heading_checked_when_enabled() {
1524 let rule = MD013LineLength::new(30, false, false, true, false);
1525 let content = "# This is a very long heading that would normally exceed the limit";
1526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1527 let result = rule.check(&ctx).unwrap();
1528
1529 assert_eq!(result.len(), 1);
1530 }
1531
1532 #[test]
1533 fn test_heading_exempt_when_disabled() {
1534 let rule = MD013LineLength::new(30, false, false, false, false);
1535 let content = "# This is a very long heading that should trigger a warning";
1536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1537 let result = rule.check(&ctx).unwrap();
1538
1539 assert_eq!(result.len(), 0);
1540 }
1541
1542 #[test]
1543 fn test_table_checked_when_enabled() {
1544 let rule = MD013LineLength::new(30, false, true, false, false);
1545 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
1546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1547 let result = rule.check(&ctx).unwrap();
1548
1549 assert_eq!(result.len(), 2); }
1551
1552 #[test]
1553 fn test_issue_78_tables_after_fenced_code_blocks() {
1554 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1557
1558```plain
1559some code block longer than 20 chars length
1560```
1561
1562this is a very long line
1563
1564| column A | column B |
1565| -------- | -------- |
1566| `var` | `val` |
1567| value 1 | value 2 |
1568
1569correct length line"#;
1570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1571 let result = rule.check(&ctx).unwrap();
1572
1573 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1575 assert_eq!(result[0].line, 7, "Should flag line 7");
1576 assert!(result[0].message.contains("24 exceeds 20"));
1577 }
1578
1579 #[test]
1580 fn test_issue_78_tables_with_inline_code() {
1581 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
1584| -------- | -------- |
1585| `var with very long name` | `val exceeding limit` |
1586| value 1 | value 2 |
1587
1588This line exceeds limit"#;
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1590 let result = rule.check(&ctx).unwrap();
1591
1592 assert_eq!(result.len(), 1, "Should only flag the non-table line");
1594 assert_eq!(result[0].line, 6, "Should flag line 6");
1595 }
1596
1597 #[test]
1598 fn test_issue_78_indented_code_blocks() {
1599 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1602
1603 some code block longer than 20 chars length
1604
1605this is a very long line
1606
1607| column A | column B |
1608| -------- | -------- |
1609| value 1 | value 2 |
1610
1611correct length line"#;
1612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1613 let result = rule.check(&ctx).unwrap();
1614
1615 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1617 assert_eq!(result[0].line, 5, "Should flag line 5");
1618 }
1619
1620 #[test]
1621 fn test_url_exemption() {
1622 let rule = MD013LineLength::new(30, false, false, false, false);
1623 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1625 let result = rule.check(&ctx).unwrap();
1626
1627 assert_eq!(result.len(), 0);
1628 }
1629
1630 #[test]
1631 fn test_image_reference_exemption() {
1632 let rule = MD013LineLength::new(30, false, false, false, false);
1633 let content = "![This is a very long image alt text that exceeds limit][reference]";
1634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1635 let result = rule.check(&ctx).unwrap();
1636
1637 assert_eq!(result.len(), 0);
1638 }
1639
1640 #[test]
1641 fn test_link_reference_exemption() {
1642 let rule = MD013LineLength::new(30, false, false, false, false);
1643 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1645 let result = rule.check(&ctx).unwrap();
1646
1647 assert_eq!(result.len(), 0);
1648 }
1649
1650 #[test]
1651 fn test_strict_mode() {
1652 let rule = MD013LineLength::new(30, false, false, false, true);
1653 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1655 let result = rule.check(&ctx).unwrap();
1656
1657 assert_eq!(result.len(), 1);
1659 }
1660
1661 #[test]
1662 fn test_blockquote_exemption() {
1663 let rule = MD013LineLength::new(30, false, false, false, false);
1664 let content = "> This is a very long line inside a blockquote that should be ignored.";
1665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1666 let result = rule.check(&ctx).unwrap();
1667
1668 assert_eq!(result.len(), 0);
1669 }
1670
1671 #[test]
1672 fn test_setext_heading_underline_exemption() {
1673 let rule = MD013LineLength::new(30, false, false, false, false);
1674 let content = "Heading\n========================================";
1675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1676 let result = rule.check(&ctx).unwrap();
1677
1678 assert_eq!(result.len(), 0);
1680 }
1681
1682 #[test]
1683 fn test_no_fix_without_reflow() {
1684 let rule = MD013LineLength::new(60, false, false, false, false);
1685 let content = "This line has trailing whitespace that makes it too long ";
1686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1687 let result = rule.check(&ctx).unwrap();
1688
1689 assert_eq!(result.len(), 1);
1690 assert!(result[0].fix.is_none());
1692
1693 let fixed = rule.fix(&ctx).unwrap();
1695 assert_eq!(fixed, content);
1696 }
1697
1698 #[test]
1699 fn test_character_vs_byte_counting() {
1700 let rule = MD013LineLength::new(10, false, false, false, false);
1701 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1704 let result = rule.check(&ctx).unwrap();
1705
1706 assert_eq!(result.len(), 1);
1707 assert_eq!(result[0].line, 1);
1708 }
1709
1710 #[test]
1711 fn test_empty_content() {
1712 let rule = MD013LineLength::default();
1713 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1714 let result = rule.check(&ctx).unwrap();
1715
1716 assert_eq!(result.len(), 0);
1717 }
1718
1719 #[test]
1720 fn test_excess_range_calculation() {
1721 let rule = MD013LineLength::new(10, false, false, false, false);
1722 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1724 let result = rule.check(&ctx).unwrap();
1725
1726 assert_eq!(result.len(), 1);
1727 assert_eq!(result[0].column, 11);
1729 assert_eq!(result[0].end_column, 21);
1730 }
1731
1732 #[test]
1733 fn test_html_block_exemption() {
1734 let rule = MD013LineLength::new(30, false, false, false, false);
1735 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1737 let result = rule.check(&ctx).unwrap();
1738
1739 assert_eq!(result.len(), 0);
1741 }
1742
1743 #[test]
1744 fn test_mixed_content() {
1745 let rule = MD013LineLength::new(30, false, false, false, false);
1747 let content = r#"# This heading is very long but should be exempt
1748
1749This regular paragraph line is too long and should trigger.
1750
1751```
1752Code block line that is very long but exempt.
1753```
1754
1755| Table | With very long content |
1756|-------|------------------------|
1757
1758Another long line that should trigger a warning."#;
1759
1760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1761 let result = rule.check(&ctx).unwrap();
1762
1763 assert_eq!(result.len(), 2);
1765 assert_eq!(result[0].line, 3);
1766 assert_eq!(result[1].line, 12);
1767 }
1768
1769 #[test]
1770 fn test_fix_without_reflow_preserves_content() {
1771 let rule = MD013LineLength::new(50, false, false, false, false);
1772 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
1773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1774
1775 let fixed = rule.fix(&ctx).unwrap();
1777 assert_eq!(fixed, content);
1778 }
1779
1780 #[test]
1781 fn test_content_detection() {
1782 let rule = MD013LineLength::default();
1783
1784 let long_line = "a".repeat(100);
1786 let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
1787 assert!(!rule.should_skip(&ctx)); let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1790 assert!(rule.should_skip(&empty_ctx)); }
1792
1793 #[test]
1794 fn test_rule_metadata() {
1795 let rule = MD013LineLength::default();
1796 assert_eq!(rule.name(), "MD013");
1797 assert_eq!(rule.description(), "Line length should not be excessive");
1798 assert_eq!(rule.category(), RuleCategory::Whitespace);
1799 }
1800
1801 #[test]
1802 fn test_url_embedded_in_text() {
1803 let rule = MD013LineLength::new(50, false, false, false, false);
1804
1805 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1808 let result = rule.check(&ctx).unwrap();
1809
1810 assert_eq!(result.len(), 0);
1812 }
1813
1814 #[test]
1815 fn test_multiple_urls_in_line() {
1816 let rule = MD013LineLength::new(50, false, false, false, false);
1817
1818 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1821
1822 let result = rule.check(&ctx).unwrap();
1823
1824 assert_eq!(result.len(), 0);
1826 }
1827
1828 #[test]
1829 fn test_markdown_link_with_long_url() {
1830 let rule = MD013LineLength::new(50, false, false, false, false);
1831
1832 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1835 let result = rule.check(&ctx).unwrap();
1836
1837 assert_eq!(result.len(), 0);
1839 }
1840
1841 #[test]
1842 fn test_line_too_long_even_without_urls() {
1843 let rule = MD013LineLength::new(50, false, false, false, false);
1844
1845 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1848 let result = rule.check(&ctx).unwrap();
1849
1850 assert_eq!(result.len(), 1);
1852 }
1853
1854 #[test]
1855 fn test_strict_mode_counts_urls() {
1856 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";
1860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1861 let result = rule.check(&ctx).unwrap();
1862
1863 assert_eq!(result.len(), 1);
1865 }
1866
1867 #[test]
1868 fn test_documentation_example_from_md051() {
1869 let rule = MD013LineLength::new(80, false, false, false, false);
1870
1871 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1874 let result = rule.check(&ctx).unwrap();
1875
1876 assert_eq!(result.len(), 0);
1878 }
1879
1880 #[test]
1881 fn test_text_reflow_simple() {
1882 let config = MD013Config {
1883 line_length: 30,
1884 reflow: true,
1885 ..Default::default()
1886 };
1887 let rule = MD013LineLength::from_config_struct(config);
1888
1889 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1891
1892 let fixed = rule.fix(&ctx).unwrap();
1893
1894 for line in fixed.lines() {
1896 assert!(
1897 line.chars().count() <= 30,
1898 "Line too long: {} (len={})",
1899 line,
1900 line.chars().count()
1901 );
1902 }
1903
1904 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1906 let original_words: Vec<&str> = content.split_whitespace().collect();
1907 assert_eq!(fixed_words, original_words);
1908 }
1909
1910 #[test]
1911 fn test_text_reflow_preserves_markdown_elements() {
1912 let config = MD013Config {
1913 line_length: 40,
1914 reflow: true,
1915 ..Default::default()
1916 };
1917 let rule = MD013LineLength::from_config_struct(config);
1918
1919 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1921
1922 let fixed = rule.fix(&ctx).unwrap();
1923
1924 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1926 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1927 assert!(
1928 fixed.contains("[a link](https://example.com)"),
1929 "Link not preserved in: {fixed}"
1930 );
1931
1932 for line in fixed.lines() {
1934 assert!(line.len() <= 40, "Line too long: {line}");
1935 }
1936 }
1937
1938 #[test]
1939 fn test_text_reflow_preserves_code_blocks() {
1940 let config = MD013Config {
1941 line_length: 30,
1942 reflow: true,
1943 ..Default::default()
1944 };
1945 let rule = MD013LineLength::from_config_struct(config);
1946
1947 let content = r#"Here is some text.
1948
1949```python
1950def very_long_function_name_that_exceeds_limit():
1951 return "This should not be wrapped"
1952```
1953
1954More text after code block."#;
1955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1956
1957 let fixed = rule.fix(&ctx).unwrap();
1958
1959 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1961 assert!(fixed.contains("```python"));
1962 assert!(fixed.contains("```"));
1963 }
1964
1965 #[test]
1966 fn test_text_reflow_preserves_lists() {
1967 let config = MD013Config {
1968 line_length: 30,
1969 reflow: true,
1970 ..Default::default()
1971 };
1972 let rule = MD013LineLength::from_config_struct(config);
1973
1974 let content = r#"Here is a list:
1975
19761. First item with a very long line that needs wrapping
19772. Second item is short
19783. Third item also has a long line that exceeds the limit
1979
1980And a bullet list:
1981
1982- Bullet item with very long content that needs wrapping
1983- Short bullet"#;
1984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1985
1986 let fixed = rule.fix(&ctx).unwrap();
1987
1988 assert!(fixed.contains("1. "));
1990 assert!(fixed.contains("2. "));
1991 assert!(fixed.contains("3. "));
1992 assert!(fixed.contains("- "));
1993
1994 let lines: Vec<&str> = fixed.lines().collect();
1996 for (i, line) in lines.iter().enumerate() {
1997 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1998 if i + 1 < lines.len()
2000 && !lines[i + 1].trim().is_empty()
2001 && !lines[i + 1].trim().starts_with(char::is_numeric)
2002 && !lines[i + 1].trim().starts_with("-")
2003 {
2004 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
2006 }
2007 } else if line.trim().starts_with("-") {
2008 if i + 1 < lines.len()
2010 && !lines[i + 1].trim().is_empty()
2011 && !lines[i + 1].trim().starts_with(char::is_numeric)
2012 && !lines[i + 1].trim().starts_with("-")
2013 {
2014 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
2016 }
2017 }
2018 }
2019 }
2020
2021 #[test]
2022 fn test_issue_83_numbered_list_with_backticks() {
2023 let config = MD013Config {
2025 line_length: 100,
2026 reflow: true,
2027 ..Default::default()
2028 };
2029 let rule = MD013LineLength::from_config_struct(config);
2030
2031 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
2033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2034
2035 let fixed = rule.fix(&ctx).unwrap();
2036
2037 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
2040
2041 assert_eq!(
2042 fixed, expected,
2043 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
2044 );
2045 }
2046
2047 #[test]
2048 fn test_text_reflow_disabled_by_default() {
2049 let rule = MD013LineLength::new(30, false, false, false, false);
2050
2051 let content = "This is a very long line that definitely exceeds thirty characters.";
2052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2053
2054 let fixed = rule.fix(&ctx).unwrap();
2055
2056 assert_eq!(fixed, content);
2059 }
2060
2061 #[test]
2062 fn test_reflow_with_hard_line_breaks() {
2063 let config = MD013Config {
2065 line_length: 40,
2066 reflow: true,
2067 ..Default::default()
2068 };
2069 let rule = MD013LineLength::from_config_struct(config);
2070
2071 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";
2073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2074 let fixed = rule.fix(&ctx).unwrap();
2075
2076 assert!(
2078 fixed.contains(" \n"),
2079 "Hard line break with exactly 2 spaces should be preserved"
2080 );
2081 }
2082
2083 #[test]
2084 fn test_reflow_preserves_reference_links() {
2085 let config = MD013Config {
2086 line_length: 40,
2087 reflow: true,
2088 ..Default::default()
2089 };
2090 let rule = MD013LineLength::from_config_struct(config);
2091
2092 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
2093
2094[ref]: https://example.com";
2095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2096 let fixed = rule.fix(&ctx).unwrap();
2097
2098 assert!(fixed.contains("[reference link][ref]"));
2100 assert!(!fixed.contains("[ reference link]"));
2101 assert!(!fixed.contains("[ref ]"));
2102 }
2103
2104 #[test]
2105 fn test_reflow_with_nested_markdown_elements() {
2106 let config = MD013Config {
2107 line_length: 35,
2108 reflow: true,
2109 ..Default::default()
2110 };
2111 let rule = MD013LineLength::from_config_struct(config);
2112
2113 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
2114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2115 let fixed = rule.fix(&ctx).unwrap();
2116
2117 assert!(fixed.contains("**bold with `code` inside**"));
2119 }
2120
2121 #[test]
2122 fn test_reflow_with_unbalanced_markdown() {
2123 let config = MD013Config {
2125 line_length: 30,
2126 reflow: true,
2127 ..Default::default()
2128 };
2129 let rule = MD013LineLength::from_config_struct(config);
2130
2131 let content = "This has **unbalanced bold that goes on for a very long time without closing";
2132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2133 let fixed = rule.fix(&ctx).unwrap();
2134
2135 assert!(!fixed.is_empty());
2139 for line in fixed.lines() {
2141 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
2142 }
2143 }
2144
2145 #[test]
2146 fn test_reflow_fix_indicator() {
2147 let config = MD013Config {
2149 line_length: 30,
2150 reflow: true,
2151 ..Default::default()
2152 };
2153 let rule = MD013LineLength::from_config_struct(config);
2154
2155 let content = "This is a very long line that definitely exceeds the thirty character limit";
2156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2157 let warnings = rule.check(&ctx).unwrap();
2158
2159 assert!(!warnings.is_empty());
2161 assert!(
2162 warnings[0].fix.is_some(),
2163 "Should provide fix indicator when reflow is true"
2164 );
2165 }
2166
2167 #[test]
2168 fn test_no_fix_indicator_without_reflow() {
2169 let config = MD013Config {
2171 line_length: 30,
2172 reflow: false,
2173 ..Default::default()
2174 };
2175 let rule = MD013LineLength::from_config_struct(config);
2176
2177 let content = "This is a very long line that definitely exceeds the thirty character limit";
2178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2179 let warnings = rule.check(&ctx).unwrap();
2180
2181 assert!(!warnings.is_empty());
2183 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
2184 }
2185
2186 #[test]
2187 fn test_reflow_preserves_all_reference_link_types() {
2188 let config = MD013Config {
2189 line_length: 40,
2190 reflow: true,
2191 ..Default::default()
2192 };
2193 let rule = MD013LineLength::from_config_struct(config);
2194
2195 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
2196
2197[ref]: https://example.com
2198[collapsed]: https://example.com
2199[shortcut]: https://example.com";
2200
2201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2202 let fixed = rule.fix(&ctx).unwrap();
2203
2204 assert!(fixed.contains("[full reference][ref]"));
2206 assert!(fixed.contains("[collapsed][]"));
2207 assert!(fixed.contains("[shortcut]"));
2208 }
2209
2210 #[test]
2211 fn test_reflow_handles_images_correctly() {
2212 let config = MD013Config {
2213 line_length: 40,
2214 reflow: true,
2215 ..Default::default()
2216 };
2217 let rule = MD013LineLength::from_config_struct(config);
2218
2219 let content = "This line has an  that should not be broken when reflowing.";
2220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2221 let fixed = rule.fix(&ctx).unwrap();
2222
2223 assert!(fixed.contains(""));
2225 }
2226
2227 #[test]
2228 fn test_normalize_mode_flags_short_lines() {
2229 let config = MD013Config {
2230 line_length: 100,
2231 reflow: true,
2232 reflow_mode: ReflowMode::Normalize,
2233 ..Default::default()
2234 };
2235 let rule = MD013LineLength::from_config_struct(config);
2236
2237 let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
2239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2240 let warnings = rule.check(&ctx).unwrap();
2241
2242 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2244 assert!(warnings[0].message.contains("normalized"));
2245 }
2246
2247 #[test]
2248 fn test_normalize_mode_combines_short_lines() {
2249 let config = MD013Config {
2250 line_length: 100,
2251 reflow: true,
2252 reflow_mode: ReflowMode::Normalize,
2253 ..Default::default()
2254 };
2255 let rule = MD013LineLength::from_config_struct(config);
2256
2257 let content =
2259 "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
2260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2261 let fixed = rule.fix(&ctx).unwrap();
2262
2263 let lines: Vec<&str> = fixed.lines().collect();
2265 assert_eq!(lines.len(), 1, "Should combine into single line");
2266 assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
2267 }
2268
2269 #[test]
2270 fn test_normalize_mode_preserves_paragraph_breaks() {
2271 let config = MD013Config {
2272 line_length: 100,
2273 reflow: true,
2274 reflow_mode: ReflowMode::Normalize,
2275 ..Default::default()
2276 };
2277 let rule = MD013LineLength::from_config_struct(config);
2278
2279 let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
2280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2281 let fixed = rule.fix(&ctx).unwrap();
2282
2283 assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
2285
2286 let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
2287 assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
2288 }
2289
2290 #[test]
2291 fn test_default_mode_only_fixes_violations() {
2292 let config = MD013Config {
2293 line_length: 100,
2294 reflow: true,
2295 reflow_mode: ReflowMode::Default, ..Default::default()
2297 };
2298 let rule = MD013LineLength::from_config_struct(config);
2299
2300 let content = "This is a short line.\nAnother short line.\nA third short line.";
2302 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2303 let warnings = rule.check(&ctx).unwrap();
2304
2305 assert!(warnings.is_empty(), "Should not flag short lines in default mode");
2307
2308 let fixed = rule.fix(&ctx).unwrap();
2310 assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
2311 }
2312
2313 #[test]
2314 fn test_normalize_mode_with_lists() {
2315 let config = MD013Config {
2316 line_length: 80,
2317 reflow: true,
2318 reflow_mode: ReflowMode::Normalize,
2319 ..Default::default()
2320 };
2321 let rule = MD013LineLength::from_config_struct(config);
2322
2323 let content = r#"A paragraph with
2324short lines.
2325
23261. List item with
2327 short lines
23282. Another item"#;
2329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2330 let fixed = rule.fix(&ctx).unwrap();
2331
2332 let lines: Vec<&str> = fixed.lines().collect();
2334 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2335 assert!(fixed.contains("1. "), "Should preserve list markers");
2336 assert!(fixed.contains("2. "), "Should preserve list markers");
2337 }
2338
2339 #[test]
2340 fn test_normalize_mode_with_code_blocks() {
2341 let config = MD013Config {
2342 line_length: 100,
2343 reflow: true,
2344 reflow_mode: ReflowMode::Normalize,
2345 ..Default::default()
2346 };
2347 let rule = MD013LineLength::from_config_struct(config);
2348
2349 let content = r#"A paragraph with
2350short lines.
2351
2352```
2353code block should not be normalized
2354even with short lines
2355```
2356
2357Another paragraph with
2358short lines."#;
2359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2360 let fixed = rule.fix(&ctx).unwrap();
2361
2362 assert!(fixed.contains("code block should not be normalized\neven with short lines"));
2364 let lines: Vec<&str> = fixed.lines().collect();
2366 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2367 }
2368
2369 #[test]
2370 fn test_issue_76_use_case() {
2371 let config = MD013Config {
2373 line_length: 999999, reflow: true,
2375 reflow_mode: ReflowMode::Normalize,
2376 ..Default::default()
2377 };
2378 let rule = MD013LineLength::from_config_struct(config);
2379
2380 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.";
2382
2383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2384
2385 let warnings = rule.check(&ctx).unwrap();
2387 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2388
2389 let fixed = rule.fix(&ctx).unwrap();
2391 let lines: Vec<&str> = fixed.lines().collect();
2392 assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
2393 assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
2394 }
2395
2396 #[test]
2397 fn test_normalize_mode_single_line_unchanged() {
2398 let config = MD013Config {
2400 line_length: 100,
2401 reflow: true,
2402 reflow_mode: ReflowMode::Normalize,
2403 ..Default::default()
2404 };
2405 let rule = MD013LineLength::from_config_struct(config);
2406
2407 let content = "This is a single line that should not be changed.";
2408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2409
2410 let warnings = rule.check(&ctx).unwrap();
2411 assert!(warnings.is_empty(), "Single line should not be flagged");
2412
2413 let fixed = rule.fix(&ctx).unwrap();
2414 assert_eq!(fixed, content, "Single line should remain unchanged");
2415 }
2416
2417 #[test]
2418 fn test_normalize_mode_with_inline_code() {
2419 let config = MD013Config {
2420 line_length: 80,
2421 reflow: true,
2422 reflow_mode: ReflowMode::Normalize,
2423 ..Default::default()
2424 };
2425 let rule = MD013LineLength::from_config_struct(config);
2426
2427 let content =
2428 "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
2429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2430
2431 let warnings = rule.check(&ctx).unwrap();
2432 assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
2433
2434 let fixed = rule.fix(&ctx).unwrap();
2435 assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
2436 assert!(fixed.lines().count() < 3, "Lines should be combined");
2437 }
2438
2439 #[test]
2440 fn test_normalize_mode_with_emphasis() {
2441 let config = MD013Config {
2442 line_length: 100,
2443 reflow: true,
2444 reflow_mode: ReflowMode::Normalize,
2445 ..Default::default()
2446 };
2447 let rule = MD013LineLength::from_config_struct(config);
2448
2449 let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
2450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2451
2452 let fixed = rule.fix(&ctx).unwrap();
2453 assert!(fixed.contains("**bold**"), "Bold should be preserved");
2454 assert!(fixed.contains("*italic*"), "Italic should be preserved");
2455 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2456 }
2457
2458 #[test]
2459 fn test_normalize_mode_respects_hard_breaks() {
2460 let config = MD013Config {
2461 line_length: 100,
2462 reflow: true,
2463 reflow_mode: ReflowMode::Normalize,
2464 ..Default::default()
2465 };
2466 let rule = MD013LineLength::from_config_struct(config);
2467
2468 let content = "First line with hard break \nSecond line after break\nThird line";
2470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2471
2472 let fixed = rule.fix(&ctx).unwrap();
2473 assert!(fixed.contains(" \n"), "Hard break should be preserved");
2475 assert!(
2477 fixed.contains("Second line after break Third line"),
2478 "Lines without hard break should combine"
2479 );
2480 }
2481
2482 #[test]
2483 fn test_normalize_mode_with_links() {
2484 let config = MD013Config {
2485 line_length: 100,
2486 reflow: true,
2487 reflow_mode: ReflowMode::Normalize,
2488 ..Default::default()
2489 };
2490 let rule = MD013LineLength::from_config_struct(config);
2491
2492 let content =
2493 "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
2494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2495
2496 let fixed = rule.fix(&ctx).unwrap();
2497 assert!(
2498 fixed.contains("[link](https://example.com)"),
2499 "Link should be preserved"
2500 );
2501 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2502 }
2503
2504 #[test]
2505 fn test_normalize_mode_empty_lines_between_paragraphs() {
2506 let config = MD013Config {
2507 line_length: 100,
2508 reflow: true,
2509 reflow_mode: ReflowMode::Normalize,
2510 ..Default::default()
2511 };
2512 let rule = MD013LineLength::from_config_struct(config);
2513
2514 let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
2515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2516
2517 let fixed = rule.fix(&ctx).unwrap();
2518 assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
2520 let parts: Vec<&str> = fixed.split("\n\n\n").collect();
2522 assert_eq!(parts.len(), 2, "Should have two parts");
2523 assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
2524 assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
2525 }
2526
2527 #[test]
2528 fn test_normalize_mode_mixed_list_types() {
2529 let config = MD013Config {
2530 line_length: 80,
2531 reflow: true,
2532 reflow_mode: ReflowMode::Normalize,
2533 ..Default::default()
2534 };
2535 let rule = MD013LineLength::from_config_struct(config);
2536
2537 let content = r#"Paragraph before list
2538with multiple lines.
2539
2540- Bullet item
2541* Another bullet
2542+ Plus bullet
2543
25441. Numbered item
25452. Another number
2546
2547Paragraph after list
2548with multiple lines."#;
2549
2550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2551 let fixed = rule.fix(&ctx).unwrap();
2552
2553 assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
2555 assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
2556 assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
2557 assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
2558
2559 assert!(
2561 fixed.starts_with("Paragraph before list with multiple lines."),
2562 "First paragraph should be normalized"
2563 );
2564 assert!(
2565 fixed.ends_with("Paragraph after list with multiple lines."),
2566 "Last paragraph should be normalized"
2567 );
2568 }
2569
2570 #[test]
2571 fn test_normalize_mode_with_horizontal_rules() {
2572 let config = MD013Config {
2573 line_length: 100,
2574 reflow: true,
2575 reflow_mode: ReflowMode::Normalize,
2576 ..Default::default()
2577 };
2578 let rule = MD013LineLength::from_config_struct(config);
2579
2580 let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
2581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2582
2583 let fixed = rule.fix(&ctx).unwrap();
2584 assert!(fixed.contains("---"), "Horizontal rule should be preserved");
2585 assert!(
2586 fixed.contains("Paragraph before horizontal rule."),
2587 "First paragraph normalized"
2588 );
2589 assert!(
2590 fixed.contains("Paragraph after horizontal rule."),
2591 "Second paragraph normalized"
2592 );
2593 }
2594
2595 #[test]
2596 fn test_normalize_mode_with_indented_code() {
2597 let config = MD013Config {
2598 line_length: 100,
2599 reflow: true,
2600 reflow_mode: ReflowMode::Normalize,
2601 ..Default::default()
2602 };
2603 let rule = MD013LineLength::from_config_struct(config);
2604
2605 let content = "Paragraph before\nindented code.\n\n This is indented code\n Should not be normalized\n\nParagraph after\nindented code.";
2606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2607
2608 let fixed = rule.fix(&ctx).unwrap();
2609 assert!(
2610 fixed.contains(" This is indented code\n Should not be normalized"),
2611 "Indented code preserved"
2612 );
2613 assert!(
2614 fixed.contains("Paragraph before indented code."),
2615 "First paragraph normalized"
2616 );
2617 assert!(
2618 fixed.contains("Paragraph after indented code."),
2619 "Second paragraph normalized"
2620 );
2621 }
2622
2623 #[test]
2624 fn test_normalize_mode_disabled_without_reflow() {
2625 let config = MD013Config {
2627 line_length: 100,
2628 reflow: false, reflow_mode: ReflowMode::Normalize,
2630 ..Default::default()
2631 };
2632 let rule = MD013LineLength::from_config_struct(config);
2633
2634 let content = "This is a line\nwith breaks that\nshould not be changed.";
2635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2636
2637 let warnings = rule.check(&ctx).unwrap();
2638 assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2639
2640 let fixed = rule.fix(&ctx).unwrap();
2641 assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2642 }
2643
2644 #[test]
2645 fn test_default_mode_with_long_lines() {
2646 let config = MD013Config {
2649 line_length: 50,
2650 reflow: true,
2651 reflow_mode: ReflowMode::Default,
2652 ..Default::default()
2653 };
2654 let rule = MD013LineLength::from_config_struct(config);
2655
2656 let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2658
2659 let warnings = rule.check(&ctx).unwrap();
2660 assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2661 assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2663
2664 let fixed = rule.fix(&ctx).unwrap();
2665 assert!(
2667 fixed.contains("Short line. This is"),
2668 "Should combine and reflow the paragraph"
2669 );
2670 assert!(
2671 fixed.contains("wrapping. Another short"),
2672 "Should include all paragraph content"
2673 );
2674 }
2675
2676 #[test]
2677 fn test_normalize_vs_default_mode_same_content() {
2678 let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2680
2681 let default_config = MD013Config {
2683 line_length: 100,
2684 reflow: true,
2685 reflow_mode: ReflowMode::Default,
2686 ..Default::default()
2687 };
2688 let default_rule = MD013LineLength::from_config_struct(default_config);
2689 let default_warnings = default_rule.check(&ctx).unwrap();
2690 let default_fixed = default_rule.fix(&ctx).unwrap();
2691
2692 let normalize_config = MD013Config {
2694 line_length: 100,
2695 reflow: true,
2696 reflow_mode: ReflowMode::Normalize,
2697 ..Default::default()
2698 };
2699 let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2700 let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2701 let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2702
2703 assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2705 assert!(
2706 !normalize_warnings.is_empty(),
2707 "Normalize mode should flag multi-line paragraphs"
2708 );
2709
2710 assert_eq!(
2711 default_fixed, content,
2712 "Default mode should not change content without violations"
2713 );
2714 assert_ne!(
2715 normalize_fixed, content,
2716 "Normalize mode should change multi-line paragraphs"
2717 );
2718 assert_eq!(
2719 normalize_fixed.lines().count(),
2720 1,
2721 "Normalize should combine into single line"
2722 );
2723 }
2724
2725 #[test]
2726 fn test_normalize_mode_with_reference_definitions() {
2727 let config = MD013Config {
2728 line_length: 100,
2729 reflow: true,
2730 reflow_mode: ReflowMode::Normalize,
2731 ..Default::default()
2732 };
2733 let rule = MD013LineLength::from_config_struct(config);
2734
2735 let content =
2736 "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2738
2739 let fixed = rule.fix(&ctx).unwrap();
2740 assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2741 assert!(
2742 fixed.contains("[ref]: https://example.com"),
2743 "Reference definition should be preserved"
2744 );
2745 assert!(
2746 fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2747 "Paragraph should be normalized"
2748 );
2749 }
2750
2751 #[test]
2752 fn test_normalize_mode_with_html_comments() {
2753 let config = MD013Config {
2754 line_length: 100,
2755 reflow: true,
2756 reflow_mode: ReflowMode::Normalize,
2757 ..Default::default()
2758 };
2759 let rule = MD013LineLength::from_config_struct(config);
2760
2761 let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2763
2764 let fixed = rule.fix(&ctx).unwrap();
2765 assert!(
2766 fixed.contains("<!-- This is a comment -->"),
2767 "HTML comment should be preserved"
2768 );
2769 assert!(
2770 fixed.contains("Paragraph before HTML comment."),
2771 "First paragraph normalized"
2772 );
2773 assert!(
2774 fixed.contains("Paragraph after HTML comment."),
2775 "Second paragraph normalized"
2776 );
2777 }
2778
2779 #[test]
2780 fn test_normalize_mode_line_starting_with_number() {
2781 let config = MD013Config {
2783 line_length: 100,
2784 reflow: true,
2785 reflow_mode: ReflowMode::Normalize,
2786 ..Default::default()
2787 };
2788 let rule = MD013LineLength::from_config_struct(config);
2789
2790 let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2792
2793 let fixed = rule.fix(&ctx).unwrap();
2794 assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2795 assert!(
2796 fixed.contains("80 characters"),
2797 "Number at start of line should be preserved"
2798 );
2799 }
2800
2801 #[test]
2802 fn test_default_mode_preserves_list_structure() {
2803 let config = MD013Config {
2805 line_length: 80,
2806 reflow: true,
2807 reflow_mode: ReflowMode::Default,
2808 ..Default::default()
2809 };
2810 let rule = MD013LineLength::from_config_struct(config);
2811
2812 let content = r#"- This is a bullet point that has
2813 some text on multiple lines
2814 that should stay separate
2815
28161. Numbered list item with
2817 multiple lines that should
2818 also stay separate"#;
2819
2820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2821 let fixed = rule.fix(&ctx).unwrap();
2822
2823 let lines: Vec<&str> = fixed.lines().collect();
2825 assert_eq!(
2826 lines[0], "- This is a bullet point that has",
2827 "First line should be unchanged"
2828 );
2829 assert_eq!(
2830 lines[1], " some text on multiple lines",
2831 "Continuation should be preserved"
2832 );
2833 assert_eq!(
2834 lines[2], " that should stay separate",
2835 "Second continuation should be preserved"
2836 );
2837 }
2838
2839 #[test]
2840 fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2841 let config = MD013Config {
2843 line_length: 80,
2844 reflow: true,
2845 reflow_mode: ReflowMode::Normalize,
2846 ..Default::default()
2847 };
2848 let rule = MD013LineLength::from_config_struct(config);
2849
2850 let content = r#"- This is a bullet point that has
2851 some text on multiple lines
2852 that should be combined
2853
28541. Numbered list item with
2855 multiple lines that need
2856 to be properly combined
28572. Second item"#;
2858
2859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2860 let fixed = rule.fix(&ctx).unwrap();
2861
2862 assert!(
2864 !fixed.contains("lines that"),
2865 "Should not have double spaces in bullet list"
2866 );
2867 assert!(
2868 !fixed.contains("need to"),
2869 "Should not have double spaces in numbered list"
2870 );
2871
2872 assert!(
2874 fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2875 "Bullet list should be properly combined"
2876 );
2877 assert!(
2878 fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2879 "Numbered list should be properly combined"
2880 );
2881 }
2882
2883 #[test]
2884 fn test_normalize_mode_actual_numbered_list() {
2885 let config = MD013Config {
2887 line_length: 100,
2888 reflow: true,
2889 reflow_mode: ReflowMode::Normalize,
2890 ..Default::default()
2891 };
2892 let rule = MD013LineLength::from_config_struct(config);
2893
2894 let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2896
2897 let fixed = rule.fix(&ctx).unwrap();
2898 assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2899 assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2900 assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2901 assert!(
2902 fixed.starts_with("Paragraph before list with multiple lines."),
2903 "Paragraph should be normalized"
2904 );
2905 }
2906
2907 #[test]
2908 fn test_sentence_per_line_detection() {
2909 let config = MD013Config {
2910 reflow: true,
2911 reflow_mode: ReflowMode::SentencePerLine,
2912 ..Default::default()
2913 };
2914 let rule = MD013LineLength::from_config_struct(config.clone());
2915
2916 let content = "This is sentence one. This is sentence two. And sentence three!";
2918 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2919
2920 assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2922
2923 let result = rule.check(&ctx).unwrap();
2924
2925 assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2926 assert_eq!(result[0].message, "Line contains multiple sentences");
2927 }
2928
2929 #[test]
2930 fn test_sentence_per_line_fix() {
2931 let config = MD013Config {
2932 reflow: true,
2933 reflow_mode: ReflowMode::SentencePerLine,
2934 ..Default::default()
2935 };
2936 let rule = MD013LineLength::from_config_struct(config);
2937
2938 let content = "First sentence. Second sentence.";
2939 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2940 let result = rule.check(&ctx).unwrap();
2941
2942 assert!(!result.is_empty(), "Should detect violation");
2943 assert!(result[0].fix.is_some(), "Should provide a fix");
2944
2945 let fix = result[0].fix.as_ref().unwrap();
2946 assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2947 }
2948
2949 #[test]
2950 fn test_sentence_per_line_abbreviations() {
2951 let config = MD013Config {
2952 reflow: true,
2953 reflow_mode: ReflowMode::SentencePerLine,
2954 ..Default::default()
2955 };
2956 let rule = MD013LineLength::from_config_struct(config);
2957
2958 let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2960 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2961 let result = rule.check(&ctx).unwrap();
2962
2963 assert!(
2964 result.is_empty(),
2965 "Should not detect abbreviations as sentence boundaries"
2966 );
2967 }
2968
2969 #[test]
2970 fn test_sentence_per_line_with_markdown() {
2971 let config = MD013Config {
2972 reflow: true,
2973 reflow_mode: ReflowMode::SentencePerLine,
2974 ..Default::default()
2975 };
2976 let rule = MD013LineLength::from_config_struct(config);
2977
2978 let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
2979 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2980 let result = rule.check(&ctx).unwrap();
2981
2982 assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
2983 assert_eq!(result[0].line, 3); }
2985
2986 #[test]
2987 fn test_sentence_per_line_questions_exclamations() {
2988 let config = MD013Config {
2989 reflow: true,
2990 reflow_mode: ReflowMode::SentencePerLine,
2991 ..Default::default()
2992 };
2993 let rule = MD013LineLength::from_config_struct(config);
2994
2995 let content = "Is this a question? Yes it is! And a statement.";
2996 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2997 let result = rule.check(&ctx).unwrap();
2998
2999 assert!(!result.is_empty(), "Should detect sentences with ? and !");
3000
3001 let fix = result[0].fix.as_ref().unwrap();
3002 let lines: Vec<&str> = fix.replacement.trim().lines().collect();
3003 assert_eq!(lines.len(), 3);
3004 assert_eq!(lines[0], "Is this a question?");
3005 assert_eq!(lines[1], "Yes it is!");
3006 assert_eq!(lines[2], "And a statement.");
3007 }
3008
3009 #[test]
3010 fn test_sentence_per_line_in_lists() {
3011 let config = MD013Config {
3012 reflow: true,
3013 reflow_mode: ReflowMode::SentencePerLine,
3014 ..Default::default()
3015 };
3016 let rule = MD013LineLength::from_config_struct(config);
3017
3018 let content = "- List item one. With two sentences.\n- Another item.";
3019 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3020 let result = rule.check(&ctx).unwrap();
3021
3022 assert!(!result.is_empty(), "Should detect sentences in list items");
3023 let fix = result[0].fix.as_ref().unwrap();
3025 assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
3026 }
3027
3028 #[test]
3029 fn test_multi_paragraph_list_item_with_3_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. First paragraph\n continuation line.\n\n Second paragraph\n more content.";
3039 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3040 let result = rule.check(&ctx).unwrap();
3041
3042 assert!(!result.is_empty(), "Should detect multi-line paragraphs in list item");
3043 let fix = result[0].fix.as_ref().unwrap();
3044
3045 assert!(
3047 fix.replacement.contains("\n\n"),
3048 "Should preserve blank line between paragraphs"
3049 );
3050 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3051 }
3052
3053 #[test]
3054 fn test_multi_paragraph_list_item_with_4_space_indent() {
3055 let config = MD013Config {
3056 reflow: true,
3057 reflow_mode: ReflowMode::Normalize,
3058 line_length: 999999,
3059 ..Default::default()
3060 };
3061 let rule = MD013LineLength::from_config_struct(config);
3062
3063 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.";
3065 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3066 let result = rule.check(&ctx).unwrap();
3067
3068 assert!(
3069 !result.is_empty(),
3070 "Should detect multi-line paragraphs in list item with 4-space indent"
3071 );
3072 let fix = result[0].fix.as_ref().unwrap();
3073
3074 assert!(
3076 fix.replacement.contains("\n\n"),
3077 "Should preserve blank line between paragraphs"
3078 );
3079 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3080
3081 let lines: Vec<&str> = fix.replacement.split('\n').collect();
3083 let blank_line_idx = lines.iter().position(|l| l.trim().is_empty());
3084 assert!(blank_line_idx.is_some(), "Should have blank line separating paragraphs");
3085 }
3086
3087 #[test]
3088 fn test_multi_paragraph_bullet_list_item() {
3089 let config = MD013Config {
3090 reflow: true,
3091 reflow_mode: ReflowMode::Normalize,
3092 line_length: 999999,
3093 ..Default::default()
3094 };
3095 let rule = MD013LineLength::from_config_struct(config);
3096
3097 let content = "- First paragraph\n continuation.\n\n Second paragraph\n more text.";
3098 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3099 let result = rule.check(&ctx).unwrap();
3100
3101 assert!(!result.is_empty(), "Should detect multi-line paragraphs in bullet list");
3102 let fix = result[0].fix.as_ref().unwrap();
3103
3104 assert!(
3105 fix.replacement.contains("\n\n"),
3106 "Should preserve blank line between paragraphs"
3107 );
3108 assert!(fix.replacement.starts_with("- "), "Should preserve bullet marker");
3109 }
3110
3111 #[test]
3112 fn test_code_block_in_list_item_five_spaces() {
3113 let config = MD013Config {
3114 reflow: true,
3115 reflow_mode: ReflowMode::Normalize,
3116 line_length: 80,
3117 ..Default::default()
3118 };
3119 let rule = MD013LineLength::from_config_struct(config);
3120
3121 let content = "1. First paragraph with some text that should be reflowed.\n\n code_block()\n more_code()\n\n Second paragraph.";
3124 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3125 let result = rule.check(&ctx).unwrap();
3126
3127 if !result.is_empty() {
3128 let fix = result[0].fix.as_ref().unwrap();
3129 assert!(
3131 fix.replacement.contains(" code_block()"),
3132 "Code block should be preserved: {}",
3133 fix.replacement
3134 );
3135 assert!(
3136 fix.replacement.contains(" more_code()"),
3137 "Code block should be preserved: {}",
3138 fix.replacement
3139 );
3140 }
3141 }
3142
3143 #[test]
3144 fn test_fenced_code_block_in_list_item() {
3145 let config = MD013Config {
3146 reflow: true,
3147 reflow_mode: ReflowMode::Normalize,
3148 line_length: 80,
3149 ..Default::default()
3150 };
3151 let rule = MD013LineLength::from_config_struct(config);
3152
3153 let content = "1. First paragraph with some text.\n\n ```rust\n fn foo() {}\n let x = 1;\n ```\n\n Second paragraph.";
3154 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3155 let result = rule.check(&ctx).unwrap();
3156
3157 if !result.is_empty() {
3158 let fix = result[0].fix.as_ref().unwrap();
3159 assert!(
3161 fix.replacement.contains("```rust"),
3162 "Should preserve fence: {}",
3163 fix.replacement
3164 );
3165 assert!(
3166 fix.replacement.contains("fn foo() {}"),
3167 "Should preserve code: {}",
3168 fix.replacement
3169 );
3170 assert!(
3171 fix.replacement.contains("```"),
3172 "Should preserve closing fence: {}",
3173 fix.replacement
3174 );
3175 }
3176 }
3177
3178 #[test]
3179 fn test_mixed_indentation_3_and_4_spaces() {
3180 let config = MD013Config {
3181 reflow: true,
3182 reflow_mode: ReflowMode::Normalize,
3183 line_length: 999999,
3184 ..Default::default()
3185 };
3186 let rule = MD013LineLength::from_config_struct(config);
3187
3188 let content = "1. Text\n 3 space continuation\n 4 space continuation";
3190 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3191 let result = rule.check(&ctx).unwrap();
3192
3193 assert!(!result.is_empty(), "Should detect multi-line list item");
3194 let fix = result[0].fix.as_ref().unwrap();
3195 assert!(
3197 fix.replacement.contains("3 space continuation"),
3198 "Should include 3-space line: {}",
3199 fix.replacement
3200 );
3201 assert!(
3202 fix.replacement.contains("4 space continuation"),
3203 "Should include 4-space line: {}",
3204 fix.replacement
3205 );
3206 }
3207
3208 #[test]
3209 fn test_nested_list_in_multi_paragraph_item() {
3210 let config = MD013Config {
3211 reflow: true,
3212 reflow_mode: ReflowMode::Normalize,
3213 line_length: 999999,
3214 ..Default::default()
3215 };
3216 let rule = MD013LineLength::from_config_struct(config);
3217
3218 let content = "1. First paragraph.\n\n - Nested item\n continuation\n\n Second paragraph.";
3219 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3220 let result = rule.check(&ctx).unwrap();
3221
3222 assert!(!result.is_empty(), "Should detect and reflow parent item");
3224 if let Some(fix) = result[0].fix.as_ref() {
3225 assert!(
3227 fix.replacement.contains("- Nested"),
3228 "Should preserve nested list: {}",
3229 fix.replacement
3230 );
3231 assert!(
3232 fix.replacement.contains("Second paragraph"),
3233 "Should include content after nested list: {}",
3234 fix.replacement
3235 );
3236 }
3237 }
3238
3239 #[test]
3240 fn test_nested_fence_markers_different_types() {
3241 let config = MD013Config {
3242 reflow: true,
3243 reflow_mode: ReflowMode::Normalize,
3244 line_length: 80,
3245 ..Default::default()
3246 };
3247 let rule = MD013LineLength::from_config_struct(config);
3248
3249 let content = "1. Example with nested fences:\n\n ~~~markdown\n This shows ```python\n code = True\n ```\n ~~~\n\n Text after.";
3251 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3252 let result = rule.check(&ctx).unwrap();
3253
3254 if !result.is_empty() {
3255 let fix = result[0].fix.as_ref().unwrap();
3256 assert!(
3258 fix.replacement.contains("```python"),
3259 "Should preserve inner fence: {}",
3260 fix.replacement
3261 );
3262 assert!(
3263 fix.replacement.contains("~~~"),
3264 "Should preserve outer fence: {}",
3265 fix.replacement
3266 );
3267 assert!(
3269 fix.replacement.contains("code = True"),
3270 "Should preserve code: {}",
3271 fix.replacement
3272 );
3273 }
3274 }
3275
3276 #[test]
3277 fn test_nested_fence_markers_same_type() {
3278 let config = MD013Config {
3279 reflow: true,
3280 reflow_mode: ReflowMode::Normalize,
3281 line_length: 80,
3282 ..Default::default()
3283 };
3284 let rule = MD013LineLength::from_config_struct(config);
3285
3286 let content =
3288 "1. Example:\n\n ````markdown\n Shows ```python in code\n ```\n text here\n ````\n\n After.";
3289 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3290 let result = rule.check(&ctx).unwrap();
3291
3292 if !result.is_empty() {
3293 let fix = result[0].fix.as_ref().unwrap();
3294 assert!(
3296 fix.replacement.contains("```python"),
3297 "Should preserve inner fence: {}",
3298 fix.replacement
3299 );
3300 assert!(
3301 fix.replacement.contains("````"),
3302 "Should preserve outer fence: {}",
3303 fix.replacement
3304 );
3305 assert!(
3306 fix.replacement.contains("text here"),
3307 "Should keep text as code: {}",
3308 fix.replacement
3309 );
3310 }
3311 }
3312
3313 #[test]
3314 fn test_sibling_list_item_breaks_parent() {
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. First item\n continuation.\n2. Second item";
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() {
3330 let fix = result[0].fix.as_ref().unwrap();
3331 assert!(fix.replacement.starts_with("1. "), "Should start with first marker");
3333 assert!(fix.replacement.contains("continuation"), "Should include continuation");
3334 }
3336 }
3337
3338 #[test]
3339 fn test_nested_list_at_continuation_indent_preserved() {
3340 let config = MD013Config {
3341 reflow: true,
3342 reflow_mode: ReflowMode::Normalize,
3343 line_length: 999999,
3344 ..Default::default()
3345 };
3346 let rule = MD013LineLength::from_config_struct(config);
3347
3348 let content = "1. Parent paragraph\n with continuation.\n\n - Nested at 3 spaces\n - Another nested\n\n After nested.";
3350 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3351 let result = rule.check(&ctx).unwrap();
3352
3353 if !result.is_empty() {
3354 let fix = result[0].fix.as_ref().unwrap();
3355 assert!(
3357 fix.replacement.contains("- Nested"),
3358 "Should include first nested item: {}",
3359 fix.replacement
3360 );
3361 assert!(
3362 fix.replacement.contains("- Another"),
3363 "Should include second nested item: {}",
3364 fix.replacement
3365 );
3366 assert!(
3367 fix.replacement.contains("After nested"),
3368 "Should include content after nested list: {}",
3369 fix.replacement
3370 );
3371 }
3372 }
3373}