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 if effective_config.reflow_mode != ReflowMode::SentencePerLine {
207 for &line_idx in &candidate_lines {
208 let line_number = line_idx + 1;
209 let line = lines[line_idx];
210
211 let effective_length = self.calculate_effective_length(line);
213
214 let line_limit = effective_config.line_length;
216
217 if effective_length <= line_limit {
219 continue;
220 }
221
222 if ctx.lines[line_idx].in_mkdocstrings {
224 continue;
225 }
226
227 if !effective_config.strict {
229 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
231 continue;
232 }
233
234 if (!effective_config.headings && heading_lines_set.contains(&line_number))
238 || (!effective_config.code_blocks
239 && ctx.line_info(line_number).is_some_and(|info| info.in_code_block))
240 || (!effective_config.tables && table_lines_set.contains(&line_number))
241 || ctx.lines[line_number - 1].blockquote.is_some()
242 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
243 {
244 continue;
245 }
246
247 if self.should_ignore_line(line, &lines, line_idx, ctx) {
249 continue;
250 }
251 }
252
253 let fix = None;
256
257 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
258
259 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
261
262 warnings.push(LintWarning {
263 rule_name: Some(self.name()),
264 message,
265 line: start_line,
266 column: start_col,
267 end_line,
268 end_column: end_col,
269 severity: Severity::Warning,
270 fix,
271 });
272 }
273 }
274
275 if effective_config.reflow {
277 let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, &lines);
278 for pw in paragraph_warnings {
280 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
282 warnings.push(pw);
283 }
284 }
285
286 Ok(warnings)
287 }
288
289 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
290 let warnings = self.check(ctx)?;
293
294 if !warnings.iter().any(|w| w.fix.is_some()) {
296 return Ok(ctx.content.to_string());
297 }
298
299 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
301 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
302 }
303
304 fn as_any(&self) -> &dyn std::any::Any {
305 self
306 }
307
308 fn category(&self) -> RuleCategory {
309 RuleCategory::Whitespace
310 }
311
312 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
313 if ctx.content.is_empty() {
315 return true;
316 }
317
318 if self.config.reflow
320 && (self.config.reflow_mode == ReflowMode::SentencePerLine
321 || self.config.reflow_mode == ReflowMode::Normalize)
322 {
323 return false;
324 }
325
326 if ctx.content.len() <= self.config.line_length {
328 return true;
329 }
330
331 !ctx.lines
333 .iter()
334 .any(|line| line.content.len() > self.config.line_length)
335 }
336
337 fn default_config_section(&self) -> Option<(String, toml::Value)> {
338 let default_config = MD013Config::default();
339 let json_value = serde_json::to_value(&default_config).ok()?;
340 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
341
342 if let toml::Value::Table(table) = toml_value {
343 if !table.is_empty() {
344 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
345 } else {
346 None
347 }
348 } else {
349 None
350 }
351 }
352
353 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
354 let mut aliases = std::collections::HashMap::new();
355 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
356 Some(aliases)
357 }
358
359 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
360 where
361 Self: Sized,
362 {
363 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
364 if rule_config.line_length == 80 {
366 rule_config.line_length = config.global.line_length as usize;
368 }
369 Box::new(Self::from_config_struct(rule_config))
370 }
371}
372
373impl MD013LineLength {
374 fn generate_paragraph_fixes(
376 &self,
377 ctx: &crate::lint_context::LintContext,
378 config: &MD013Config,
379 lines: &[&str],
380 ) -> Vec<LintWarning> {
381 let mut warnings = Vec::new();
382 let line_index = LineIndex::new(ctx.content.to_string());
383
384 let mut i = 0;
385 while i < lines.len() {
386 let line_num = i + 1;
387
388 let should_skip_due_to_line_info = ctx
390 .line_info(line_num)
391 .is_some_and(|info| info.in_code_block || info.in_front_matter || info.in_html_block);
392
393 if should_skip_due_to_line_info
394 || (line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some())
395 || lines[i].trim().starts_with('#')
396 || TableUtils::is_potential_table_row(lines[i])
397 || lines[i].trim().is_empty()
398 || is_horizontal_rule(lines[i].trim())
399 {
400 i += 1;
401 continue;
402 }
403
404 let is_semantic_line = |content: &str| -> bool {
406 let trimmed = content.trim_start();
407 let semantic_markers = [
408 "NOTE:",
409 "WARNING:",
410 "IMPORTANT:",
411 "CAUTION:",
412 "TIP:",
413 "DANGER:",
414 "HINT:",
415 "INFO:",
416 ];
417 semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
418 };
419
420 let is_fence_marker = |content: &str| -> bool {
422 let trimmed = content.trim_start();
423 trimmed.starts_with("```") || trimmed.starts_with("~~~")
424 };
425
426 let trimmed = lines[i].trim();
428 if is_list_item(trimmed) {
429 let list_start = i;
431 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
432 let marker_len = marker.len();
433
434 #[derive(Clone)]
436 enum LineType {
437 Content(String),
438 CodeBlock(String, usize), NestedListItem(String, usize), SemanticLine(String), Empty,
442 }
443
444 let mut actual_indent: Option<usize> = None;
445 let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
446 i += 1;
447
448 while i < lines.len() {
450 let line_info = &ctx.lines[i];
451
452 if line_info.is_blank {
454 if i + 1 < lines.len() {
456 let next_info = &ctx.lines[i + 1];
457
458 if !next_info.is_blank && next_info.indent >= marker_len {
460 list_item_lines.push(LineType::Empty);
462 i += 1;
463 continue;
464 }
465 }
466 break;
468 }
469
470 let indent = line_info.indent;
472
473 if indent >= marker_len {
475 let trimmed = line_info.content.trim();
476
477 if line_info.in_code_block {
479 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
480 i += 1;
481 continue;
482 }
483
484 if is_list_item(trimmed) && indent < marker_len {
488 break;
490 }
491
492 if is_list_item(trimmed) && indent >= marker_len {
497 let has_blank_before = matches!(list_item_lines.last(), Some(LineType::Empty));
499
500 let has_nested_content = list_item_lines.iter().any(|line| {
502 matches!(line, LineType::Content(c) if is_list_item(c.trim()))
503 || matches!(line, LineType::NestedListItem(_, _))
504 });
505
506 if !has_blank_before && !has_nested_content {
507 break;
510 }
511 list_item_lines.push(LineType::NestedListItem(
514 line_info.content[indent..].to_string(),
515 indent,
516 ));
517 i += 1;
518 continue;
519 }
520
521 if indent <= marker_len + 3 {
523 if actual_indent.is_none() {
525 actual_indent = Some(indent);
526 }
527
528 let content = trim_preserving_hard_break(&line_info.content[indent..]);
532
533 if is_fence_marker(&content) {
536 list_item_lines.push(LineType::CodeBlock(content, indent));
537 }
538 else if is_semantic_line(&content) {
540 list_item_lines.push(LineType::SemanticLine(content));
541 } else {
542 list_item_lines.push(LineType::Content(content));
543 }
544 i += 1;
545 } else {
546 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
548 i += 1;
549 }
550 } else {
551 break;
553 }
554 }
555
556 let indent_size = actual_indent.unwrap_or(marker_len);
558 let expected_indent = " ".repeat(indent_size);
559
560 #[derive(Clone)]
562 enum Block {
563 Paragraph(Vec<String>),
564 Code {
565 lines: Vec<(String, usize)>, has_preceding_blank: bool, },
568 NestedList(Vec<(String, usize)>), SemanticLine(String), }
571
572 let mut blocks: Vec<Block> = Vec::new();
573 let mut current_paragraph: Vec<String> = Vec::new();
574 let mut current_code_block: Vec<(String, usize)> = Vec::new();
575 let mut current_nested_list: Vec<(String, usize)> = Vec::new();
576 let mut in_code = false;
577 let mut in_nested_list = false;
578 let mut had_preceding_blank = false; let mut code_block_has_preceding_blank = false; for line in &list_item_lines {
582 match line {
583 LineType::Empty => {
584 if in_code {
585 current_code_block.push((String::new(), 0));
586 } else if in_nested_list {
587 current_nested_list.push((String::new(), 0));
588 } else if !current_paragraph.is_empty() {
589 blocks.push(Block::Paragraph(current_paragraph.clone()));
590 current_paragraph.clear();
591 }
592 had_preceding_blank = true;
594 }
595 LineType::Content(content) => {
596 if in_code {
597 blocks.push(Block::Code {
599 lines: current_code_block.clone(),
600 has_preceding_blank: code_block_has_preceding_blank,
601 });
602 current_code_block.clear();
603 in_code = false;
604 } else if in_nested_list {
605 blocks.push(Block::NestedList(current_nested_list.clone()));
607 current_nested_list.clear();
608 in_nested_list = false;
609 }
610 current_paragraph.push(content.clone());
611 had_preceding_blank = false; }
613 LineType::CodeBlock(content, indent) => {
614 if in_nested_list {
615 blocks.push(Block::NestedList(current_nested_list.clone()));
617 current_nested_list.clear();
618 in_nested_list = false;
619 }
620 if !in_code {
621 if !current_paragraph.is_empty() {
623 blocks.push(Block::Paragraph(current_paragraph.clone()));
624 current_paragraph.clear();
625 }
626 in_code = true;
627 code_block_has_preceding_blank = had_preceding_blank;
629 }
630 current_code_block.push((content.clone(), *indent));
631 had_preceding_blank = false; }
633 LineType::NestedListItem(content, indent) => {
634 if in_code {
635 blocks.push(Block::Code {
637 lines: current_code_block.clone(),
638 has_preceding_blank: code_block_has_preceding_blank,
639 });
640 current_code_block.clear();
641 in_code = false;
642 }
643 if !in_nested_list {
644 if !current_paragraph.is_empty() {
646 blocks.push(Block::Paragraph(current_paragraph.clone()));
647 current_paragraph.clear();
648 }
649 in_nested_list = true;
650 }
651 current_nested_list.push((content.clone(), *indent));
652 had_preceding_blank = false; }
654 LineType::SemanticLine(content) => {
655 if in_code {
657 blocks.push(Block::Code {
658 lines: current_code_block.clone(),
659 has_preceding_blank: code_block_has_preceding_blank,
660 });
661 current_code_block.clear();
662 in_code = false;
663 } else if in_nested_list {
664 blocks.push(Block::NestedList(current_nested_list.clone()));
665 current_nested_list.clear();
666 in_nested_list = false;
667 } else if !current_paragraph.is_empty() {
668 blocks.push(Block::Paragraph(current_paragraph.clone()));
669 current_paragraph.clear();
670 }
671 blocks.push(Block::SemanticLine(content.clone()));
673 had_preceding_blank = false; }
675 }
676 }
677
678 if in_code && !current_code_block.is_empty() {
680 blocks.push(Block::Code {
681 lines: current_code_block,
682 has_preceding_blank: code_block_has_preceding_blank,
683 });
684 } else if in_nested_list && !current_nested_list.is_empty() {
685 blocks.push(Block::NestedList(current_nested_list));
686 } else if !current_paragraph.is_empty() {
687 blocks.push(Block::Paragraph(current_paragraph));
688 }
689
690 let content_lines: Vec<String> = list_item_lines
692 .iter()
693 .filter_map(|line| {
694 if let LineType::Content(s) = line {
695 Some(s.clone())
696 } else {
697 None
698 }
699 })
700 .collect();
701
702 let combined_content = content_lines.join(" ").trim().to_string();
705 let full_line = format!("{marker}{combined_content}");
706
707 let should_normalize = || {
709 let has_nested_lists = blocks.iter().any(|b| matches!(b, Block::NestedList(_)));
712 let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
713 let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
714 let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
715
716 if (has_nested_lists || has_code_blocks || has_semantic_lines) && !has_paragraphs {
718 return false;
719 }
720
721 if has_paragraphs {
723 let paragraph_count = blocks.iter().filter(|b| matches!(b, Block::Paragraph(_))).count();
724 if paragraph_count > 1 {
725 return true;
727 }
728
729 if content_lines.len() > 1 {
731 return true;
732 }
733 }
734
735 false
736 };
737
738 let needs_reflow = match config.reflow_mode {
739 ReflowMode::Normalize => {
740 let combined_length = self.calculate_effective_length(&full_line);
744 if combined_length > config.line_length {
745 true
746 } else {
747 should_normalize()
748 }
749 }
750 ReflowMode::SentencePerLine => {
751 let sentences = split_into_sentences(&combined_content);
753 sentences.len() > 1
754 }
755 ReflowMode::Default => {
756 self.calculate_effective_length(&full_line) > config.line_length
758 }
759 };
760
761 if needs_reflow {
762 let start_range = line_index.whole_line_range(list_start + 1);
763 let end_line = i - 1;
764 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
765 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
766 } else {
767 line_index.whole_line_range(end_line + 1)
768 };
769 let byte_range = start_range.start..end_range.end;
770
771 let reflow_options = crate::utils::text_reflow::ReflowOptions {
773 line_length: config.line_length - indent_size,
774 break_on_sentences: true,
775 preserve_breaks: false,
776 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
777 };
778
779 let mut result: Vec<String> = Vec::new();
780 let mut is_first_block = true;
781
782 for (block_idx, block) in blocks.iter().enumerate() {
783 match block {
784 Block::Paragraph(para_lines) => {
785 let segments = split_into_segments(para_lines);
788
789 for (segment_idx, segment) in segments.iter().enumerate() {
790 let hard_break_type = segment.last().and_then(|line| {
792 let line = line.strip_suffix('\r').unwrap_or(line);
793 if line.ends_with('\\') {
794 Some("\\")
795 } else if line.ends_with(" ") {
796 Some(" ")
797 } else {
798 None
799 }
800 });
801
802 let segment_for_reflow: Vec<String> = segment
804 .iter()
805 .map(|line| {
806 if line.ends_with('\\') {
808 line[..line.len() - 1].trim_end().to_string()
809 } else if line.ends_with(" ") {
810 line[..line.len() - 2].trim_end().to_string()
811 } else {
812 line.clone()
813 }
814 })
815 .collect();
816
817 let segment_text = segment_for_reflow.join(" ").trim().to_string();
818 if !segment_text.is_empty() {
819 let reflowed =
820 crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
821
822 if is_first_block && segment_idx == 0 {
823 result.push(format!("{marker}{}", reflowed[0]));
825 for line in reflowed.iter().skip(1) {
826 result.push(format!("{expected_indent}{line}"));
827 }
828 is_first_block = false;
829 } else {
830 for line in reflowed {
832 result.push(format!("{expected_indent}{line}"));
833 }
834 }
835
836 if let Some(break_marker) = hard_break_type
839 && let Some(last_line) = result.last_mut()
840 {
841 last_line.push_str(break_marker);
842 }
843 }
844 }
845
846 if block_idx < blocks.len() - 1 {
849 let next_block = &blocks[block_idx + 1];
850 let should_add_blank = match next_block {
851 Block::Code {
852 has_preceding_blank, ..
853 } => *has_preceding_blank,
854 _ => true, };
856 if should_add_blank {
857 result.push(String::new());
858 }
859 }
860 }
861 Block::Code {
862 lines: code_lines,
863 has_preceding_blank: _,
864 } => {
865 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
870 if is_first_block && idx == 0 {
871 result.push(format!(
873 "{marker}{}",
874 " ".repeat(orig_indent - marker_len) + content
875 ));
876 is_first_block = false;
877 } else if content.is_empty() {
878 result.push(String::new());
879 } else {
880 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
881 }
882 }
883 }
884 Block::NestedList(nested_items) => {
885 if !is_first_block {
887 result.push(String::new());
888 }
889
890 for (idx, (content, orig_indent)) in nested_items.iter().enumerate() {
891 if is_first_block && idx == 0 {
892 result.push(format!(
894 "{marker}{}",
895 " ".repeat(orig_indent - marker_len) + content
896 ));
897 is_first_block = false;
898 } else if content.is_empty() {
899 result.push(String::new());
900 } else {
901 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
902 }
903 }
904
905 if block_idx < blocks.len() - 1 {
908 let next_block = &blocks[block_idx + 1];
909 let should_add_blank = match next_block {
910 Block::Code {
911 has_preceding_blank, ..
912 } => *has_preceding_blank,
913 _ => true, };
915 if should_add_blank {
916 result.push(String::new());
917 }
918 }
919 }
920 Block::SemanticLine(content) => {
921 if !is_first_block {
924 result.push(String::new());
925 }
926
927 if is_first_block {
928 result.push(format!("{marker}{content}"));
930 is_first_block = false;
931 } else {
932 result.push(format!("{expected_indent}{content}"));
934 }
935
936 if block_idx < blocks.len() - 1 {
939 let next_block = &blocks[block_idx + 1];
940 let should_add_blank = match next_block {
941 Block::Code {
942 has_preceding_blank, ..
943 } => *has_preceding_blank,
944 _ => true, };
946 if should_add_blank {
947 result.push(String::new());
948 }
949 }
950 }
951 }
952 }
953
954 let reflowed_text = result.join("\n");
955
956 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
958 format!("{reflowed_text}\n")
959 } else {
960 reflowed_text
961 };
962
963 let original_text = &ctx.content[byte_range.clone()];
965
966 if original_text != replacement {
968 let message = match config.reflow_mode {
970 ReflowMode::SentencePerLine => {
971 "Line contains multiple sentences (one sentence per line expected)".to_string()
972 }
973 ReflowMode::Normalize => {
974 let combined_length = self.calculate_effective_length(&full_line);
975 if combined_length > config.line_length {
976 format!(
977 "Line length {} exceeds {} characters",
978 combined_length, config.line_length
979 )
980 } else {
981 "Multi-line content can be normalized".to_string()
982 }
983 }
984 ReflowMode::Default => {
985 let combined_length = self.calculate_effective_length(&full_line);
986 format!(
987 "Line length {} exceeds {} characters",
988 combined_length, config.line_length
989 )
990 }
991 };
992
993 warnings.push(LintWarning {
994 rule_name: Some(self.name()),
995 message,
996 line: list_start + 1,
997 column: 1,
998 end_line: end_line + 1,
999 end_column: lines[end_line].len() + 1,
1000 severity: Severity::Warning,
1001 fix: Some(crate::rule::Fix {
1002 range: byte_range,
1003 replacement,
1004 }),
1005 });
1006 }
1007 }
1008 continue;
1009 }
1010
1011 let paragraph_start = i;
1013 let mut paragraph_lines = vec![lines[i]];
1014 i += 1;
1015
1016 while i < lines.len() {
1017 let next_line = lines[i];
1018 let next_line_num = i + 1;
1019 let next_trimmed = next_line.trim();
1020
1021 if next_trimmed.is_empty()
1023 || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
1024 || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
1025 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
1026 || (next_line_num > 0
1027 && next_line_num <= ctx.lines.len()
1028 && ctx.lines[next_line_num - 1].blockquote.is_some())
1029 || next_trimmed.starts_with('#')
1030 || TableUtils::is_potential_table_row(next_line)
1031 || is_list_item(next_trimmed)
1032 || is_horizontal_rule(next_trimmed)
1033 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
1034 {
1035 break;
1036 }
1037
1038 if i > 0 && has_hard_break(lines[i - 1]) {
1040 break;
1042 }
1043
1044 paragraph_lines.push(next_line);
1045 i += 1;
1046 }
1047
1048 let needs_reflow = match config.reflow_mode {
1050 ReflowMode::Normalize => {
1051 paragraph_lines.len() > 1
1053 }
1054 ReflowMode::SentencePerLine => {
1055 paragraph_lines.iter().any(|line| {
1057 let sentences = split_into_sentences(line);
1059 sentences.len() > 1
1060 })
1061 }
1062 ReflowMode::Default => {
1063 paragraph_lines
1065 .iter()
1066 .any(|line| self.calculate_effective_length(line) > config.line_length)
1067 }
1068 };
1069
1070 if needs_reflow {
1071 let start_range = line_index.whole_line_range(paragraph_start + 1);
1074 let end_line = paragraph_start + paragraph_lines.len() - 1;
1075
1076 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1078 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1080 } else {
1081 line_index.whole_line_range(end_line + 1)
1083 };
1084
1085 let byte_range = start_range.start..end_range.end;
1086
1087 let paragraph_text = paragraph_lines.join(" ");
1089
1090 let hard_break_type = paragraph_lines.last().and_then(|line| {
1092 let line = line.strip_suffix('\r').unwrap_or(line);
1093 if line.ends_with('\\') {
1094 Some("\\")
1095 } else if line.ends_with(" ") {
1096 Some(" ")
1097 } else {
1098 None
1099 }
1100 });
1101
1102 let reflow_options = crate::utils::text_reflow::ReflowOptions {
1104 line_length: config.line_length,
1105 break_on_sentences: true,
1106 preserve_breaks: false,
1107 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1108 };
1109 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
1110
1111 if let Some(break_marker) = hard_break_type
1114 && !reflowed.is_empty()
1115 {
1116 let last_idx = reflowed.len() - 1;
1117 if !has_hard_break(&reflowed[last_idx]) {
1118 reflowed[last_idx].push_str(break_marker);
1119 }
1120 }
1121
1122 let reflowed_text = reflowed.join("\n");
1123
1124 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1126 format!("{reflowed_text}\n")
1127 } else {
1128 reflowed_text
1129 };
1130
1131 let original_text = &ctx.content[byte_range.clone()];
1133
1134 if original_text != replacement {
1136 let (warning_line, warning_end_line) = match config.reflow_mode {
1141 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
1142 ReflowMode::SentencePerLine => {
1143 let mut violating_line = paragraph_start;
1145 for (idx, line) in paragraph_lines.iter().enumerate() {
1146 let sentences = split_into_sentences(line);
1147 if sentences.len() > 1 {
1148 violating_line = paragraph_start + idx;
1149 break;
1150 }
1151 }
1152 (violating_line + 1, violating_line + 1)
1153 }
1154 ReflowMode::Default => {
1155 let mut violating_line = paragraph_start;
1157 for (idx, line) in paragraph_lines.iter().enumerate() {
1158 if self.calculate_effective_length(line) > config.line_length {
1159 violating_line = paragraph_start + idx;
1160 break;
1161 }
1162 }
1163 (violating_line + 1, violating_line + 1)
1164 }
1165 };
1166
1167 warnings.push(LintWarning {
1168 rule_name: Some(self.name()),
1169 message: match config.reflow_mode {
1170 ReflowMode::Normalize => format!(
1171 "Paragraph could be normalized to use line length of {} characters",
1172 config.line_length
1173 ),
1174 ReflowMode::SentencePerLine => {
1175 "Line contains multiple sentences (one sentence per line expected)".to_string()
1176 }
1177 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length),
1178 },
1179 line: warning_line,
1180 column: 1,
1181 end_line: warning_end_line,
1182 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
1183 severity: Severity::Warning,
1184 fix: Some(crate::rule::Fix {
1185 range: byte_range,
1186 replacement,
1187 }),
1188 });
1189 }
1190 }
1191 }
1192
1193 warnings
1194 }
1195
1196 fn calculate_effective_length(&self, line: &str) -> usize {
1198 if self.config.strict {
1199 return line.chars().count();
1201 }
1202
1203 let bytes = line.as_bytes();
1205 if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
1206 return line.chars().count();
1207 }
1208
1209 if !line.contains("http") && !line.contains('[') {
1211 return line.chars().count();
1212 }
1213
1214 let mut effective_line = line.to_string();
1215
1216 if line.contains('[') && line.contains("](") {
1219 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
1220 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
1221 && url.as_str().len() > 15
1222 {
1223 let replacement = format!("[{}](url)", text.as_str());
1224 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
1225 }
1226 }
1227 }
1228
1229 if effective_line.contains("http") {
1232 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
1233 let url = url_match.as_str();
1234 if !effective_line.contains(&format!("({url})")) {
1236 let placeholder = "x".repeat(15.min(url.len()));
1239 effective_line = effective_line.replacen(url, &placeholder, 1);
1240 }
1241 }
1242 }
1243
1244 effective_line.chars().count()
1245 }
1246}
1247
1248fn has_hard_break(line: &str) -> bool {
1254 let line = line.strip_suffix('\r').unwrap_or(line);
1255 line.ends_with(" ") || line.ends_with('\\')
1256}
1257
1258fn trim_preserving_hard_break(s: &str) -> String {
1265 let s = s.strip_suffix('\r').unwrap_or(s);
1267
1268 if s.ends_with('\\') {
1270 return s.to_string();
1272 }
1273
1274 if s.ends_with(" ") {
1276 let content_end = s.trim_end().len();
1278 if content_end == 0 {
1279 return String::new();
1281 }
1282 format!("{} ", &s[..content_end])
1284 } else {
1285 s.trim_end().to_string()
1287 }
1288}
1289
1290fn split_into_segments(para_lines: &[String]) -> Vec<Vec<String>> {
1301 let mut segments: Vec<Vec<String>> = Vec::new();
1302 let mut current_segment: Vec<String> = Vec::new();
1303
1304 for line in para_lines {
1305 current_segment.push(line.clone());
1306
1307 if has_hard_break(line) {
1309 segments.push(current_segment.clone());
1310 current_segment.clear();
1311 }
1312 }
1313
1314 if !current_segment.is_empty() {
1316 segments.push(current_segment);
1317 }
1318
1319 segments
1320}
1321
1322fn extract_list_marker_and_content(line: &str) -> (String, String) {
1323 let indent_len = line.len() - line.trim_start().len();
1325 let indent = &line[..indent_len];
1326 let trimmed = &line[indent_len..];
1327
1328 if let Some(rest) = trimmed.strip_prefix("- ") {
1331 return (format!("{indent}- "), trim_preserving_hard_break(rest));
1332 }
1333 if let Some(rest) = trimmed.strip_prefix("* ") {
1334 return (format!("{indent}* "), trim_preserving_hard_break(rest));
1335 }
1336 if let Some(rest) = trimmed.strip_prefix("+ ") {
1337 return (format!("{indent}+ "), trim_preserving_hard_break(rest));
1338 }
1339
1340 let mut chars = trimmed.chars();
1342 let mut marker_content = String::new();
1343
1344 while let Some(c) = chars.next() {
1345 marker_content.push(c);
1346 if c == '.' {
1347 if let Some(next) = chars.next()
1349 && next == ' '
1350 {
1351 marker_content.push(next);
1352 let content = trim_preserving_hard_break(chars.as_str());
1354 return (format!("{indent}{marker_content}"), content);
1355 }
1356 break;
1357 }
1358 }
1359
1360 (String::new(), line.to_string())
1362}
1363
1364fn is_horizontal_rule(line: &str) -> bool {
1366 if line.len() < 3 {
1367 return false;
1368 }
1369 let chars: Vec<char> = line.chars().collect();
1371 if chars.is_empty() {
1372 return false;
1373 }
1374 let first_char = chars[0];
1375 if first_char != '-' && first_char != '_' && first_char != '*' {
1376 return false;
1377 }
1378 for c in &chars {
1380 if *c != first_char && *c != ' ' {
1381 return false;
1382 }
1383 }
1384 chars.iter().filter(|c| **c == first_char).count() >= 3
1386}
1387
1388fn is_numbered_list_item(line: &str) -> bool {
1389 let mut chars = line.chars();
1390 if !chars.next().is_some_and(|c| c.is_numeric()) {
1392 return false;
1393 }
1394 while let Some(c) = chars.next() {
1396 if c == '.' {
1397 return chars.next().is_none_or(|c| c == ' ');
1399 }
1400 if !c.is_numeric() {
1401 return false;
1402 }
1403 }
1404 false
1405}
1406
1407fn is_list_item(line: &str) -> bool {
1408 if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
1410 && line.len() > 1
1411 && line.chars().nth(1) == Some(' ')
1412 {
1413 return true;
1414 }
1415 is_numbered_list_item(line)
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421 use super::*;
1422 use crate::lint_context::LintContext;
1423
1424 #[test]
1425 fn test_default_config() {
1426 let rule = MD013LineLength::default();
1427 assert_eq!(rule.config.line_length, 80);
1428 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
1432 }
1433
1434 #[test]
1435 fn test_custom_config() {
1436 let rule = MD013LineLength::new(100, true, true, false, true);
1437 assert_eq!(rule.config.line_length, 100);
1438 assert!(rule.config.code_blocks);
1439 assert!(rule.config.tables);
1440 assert!(!rule.config.headings);
1441 assert!(rule.config.strict);
1442 }
1443
1444 #[test]
1445 fn test_basic_line_length_violation() {
1446 let rule = MD013LineLength::new(50, false, false, false, false);
1447 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
1448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1449 let result = rule.check(&ctx).unwrap();
1450
1451 assert_eq!(result.len(), 1);
1452 assert!(result[0].message.contains("Line length"));
1453 assert!(result[0].message.contains("exceeds 50 characters"));
1454 }
1455
1456 #[test]
1457 fn test_no_violation_under_limit() {
1458 let rule = MD013LineLength::new(100, false, false, false, false);
1459 let content = "Short line.\nAnother short line.";
1460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1461 let result = rule.check(&ctx).unwrap();
1462
1463 assert_eq!(result.len(), 0);
1464 }
1465
1466 #[test]
1467 fn test_multiple_violations() {
1468 let rule = MD013LineLength::new(30, false, false, false, false);
1469 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
1470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1471 let result = rule.check(&ctx).unwrap();
1472
1473 assert_eq!(result.len(), 2);
1474 assert_eq!(result[0].line, 1);
1475 assert_eq!(result[1].line, 2);
1476 }
1477
1478 #[test]
1479 fn test_code_blocks_exemption() {
1480 let rule = MD013LineLength::new(30, false, false, false, false);
1482 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
1483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1484 let result = rule.check(&ctx).unwrap();
1485
1486 assert_eq!(result.len(), 0);
1487 }
1488
1489 #[test]
1490 fn test_code_blocks_not_exempt_when_configured() {
1491 let rule = MD013LineLength::new(30, true, false, false, false);
1493 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
1494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1495 let result = rule.check(&ctx).unwrap();
1496
1497 assert!(!result.is_empty());
1498 }
1499
1500 #[test]
1501 fn test_heading_checked_when_enabled() {
1502 let rule = MD013LineLength::new(30, false, false, true, false);
1503 let content = "# This is a very long heading that would normally exceed the limit";
1504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1505 let result = rule.check(&ctx).unwrap();
1506
1507 assert_eq!(result.len(), 1);
1508 }
1509
1510 #[test]
1511 fn test_heading_exempt_when_disabled() {
1512 let rule = MD013LineLength::new(30, false, false, false, false);
1513 let content = "# This is a very long heading that should trigger a warning";
1514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1515 let result = rule.check(&ctx).unwrap();
1516
1517 assert_eq!(result.len(), 0);
1518 }
1519
1520 #[test]
1521 fn test_table_checked_when_enabled() {
1522 let rule = MD013LineLength::new(30, false, true, false, false);
1523 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
1524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1525 let result = rule.check(&ctx).unwrap();
1526
1527 assert_eq!(result.len(), 2); }
1529
1530 #[test]
1531 fn test_issue_78_tables_after_fenced_code_blocks() {
1532 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1535
1536```plain
1537some code block longer than 20 chars length
1538```
1539
1540this is a very long line
1541
1542| column A | column B |
1543| -------- | -------- |
1544| `var` | `val` |
1545| value 1 | value 2 |
1546
1547correct length line"#;
1548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1549 let result = rule.check(&ctx).unwrap();
1550
1551 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1553 assert_eq!(result[0].line, 7, "Should flag line 7");
1554 assert!(result[0].message.contains("24 exceeds 20"));
1555 }
1556
1557 #[test]
1558 fn test_issue_78_tables_with_inline_code() {
1559 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
1562| -------- | -------- |
1563| `var with very long name` | `val exceeding limit` |
1564| value 1 | value 2 |
1565
1566This line exceeds limit"#;
1567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1568 let result = rule.check(&ctx).unwrap();
1569
1570 assert_eq!(result.len(), 1, "Should only flag the non-table line");
1572 assert_eq!(result[0].line, 6, "Should flag line 6");
1573 }
1574
1575 #[test]
1576 fn test_issue_78_indented_code_blocks() {
1577 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1580
1581 some code block longer than 20 chars length
1582
1583this is a very long line
1584
1585| column A | column B |
1586| -------- | -------- |
1587| value 1 | value 2 |
1588
1589correct length line"#;
1590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1591 let result = rule.check(&ctx).unwrap();
1592
1593 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1595 assert_eq!(result[0].line, 5, "Should flag line 5");
1596 }
1597
1598 #[test]
1599 fn test_url_exemption() {
1600 let rule = MD013LineLength::new(30, false, false, false, false);
1601 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1603 let result = rule.check(&ctx).unwrap();
1604
1605 assert_eq!(result.len(), 0);
1606 }
1607
1608 #[test]
1609 fn test_image_reference_exemption() {
1610 let rule = MD013LineLength::new(30, false, false, false, false);
1611 let content = "![This is a very long image alt text that exceeds limit][reference]";
1612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1613 let result = rule.check(&ctx).unwrap();
1614
1615 assert_eq!(result.len(), 0);
1616 }
1617
1618 #[test]
1619 fn test_link_reference_exemption() {
1620 let rule = MD013LineLength::new(30, false, false, false, false);
1621 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1623 let result = rule.check(&ctx).unwrap();
1624
1625 assert_eq!(result.len(), 0);
1626 }
1627
1628 #[test]
1629 fn test_strict_mode() {
1630 let rule = MD013LineLength::new(30, false, false, false, true);
1631 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1633 let result = rule.check(&ctx).unwrap();
1634
1635 assert_eq!(result.len(), 1);
1637 }
1638
1639 #[test]
1640 fn test_blockquote_exemption() {
1641 let rule = MD013LineLength::new(30, false, false, false, false);
1642 let content = "> This is a very long line inside a blockquote that should be ignored.";
1643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1644 let result = rule.check(&ctx).unwrap();
1645
1646 assert_eq!(result.len(), 0);
1647 }
1648
1649 #[test]
1650 fn test_setext_heading_underline_exemption() {
1651 let rule = MD013LineLength::new(30, false, false, false, false);
1652 let content = "Heading\n========================================";
1653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1654 let result = rule.check(&ctx).unwrap();
1655
1656 assert_eq!(result.len(), 0);
1658 }
1659
1660 #[test]
1661 fn test_no_fix_without_reflow() {
1662 let rule = MD013LineLength::new(60, false, false, false, false);
1663 let content = "This line has trailing whitespace that makes it too long ";
1664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1665 let result = rule.check(&ctx).unwrap();
1666
1667 assert_eq!(result.len(), 1);
1668 assert!(result[0].fix.is_none());
1670
1671 let fixed = rule.fix(&ctx).unwrap();
1673 assert_eq!(fixed, content);
1674 }
1675
1676 #[test]
1677 fn test_character_vs_byte_counting() {
1678 let rule = MD013LineLength::new(10, false, false, false, false);
1679 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1682 let result = rule.check(&ctx).unwrap();
1683
1684 assert_eq!(result.len(), 1);
1685 assert_eq!(result[0].line, 1);
1686 }
1687
1688 #[test]
1689 fn test_empty_content() {
1690 let rule = MD013LineLength::default();
1691 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1692 let result = rule.check(&ctx).unwrap();
1693
1694 assert_eq!(result.len(), 0);
1695 }
1696
1697 #[test]
1698 fn test_excess_range_calculation() {
1699 let rule = MD013LineLength::new(10, false, false, false, false);
1700 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1702 let result = rule.check(&ctx).unwrap();
1703
1704 assert_eq!(result.len(), 1);
1705 assert_eq!(result[0].column, 11);
1707 assert_eq!(result[0].end_column, 21);
1708 }
1709
1710 #[test]
1711 fn test_html_block_exemption() {
1712 let rule = MD013LineLength::new(30, false, false, false, false);
1713 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1715 let result = rule.check(&ctx).unwrap();
1716
1717 assert_eq!(result.len(), 0);
1719 }
1720
1721 #[test]
1722 fn test_mixed_content() {
1723 let rule = MD013LineLength::new(30, false, false, false, false);
1725 let content = r#"# This heading is very long but should be exempt
1726
1727This regular paragraph line is too long and should trigger.
1728
1729```
1730Code block line that is very long but exempt.
1731```
1732
1733| Table | With very long content |
1734|-------|------------------------|
1735
1736Another long line that should trigger a warning."#;
1737
1738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1739 let result = rule.check(&ctx).unwrap();
1740
1741 assert_eq!(result.len(), 2);
1743 assert_eq!(result[0].line, 3);
1744 assert_eq!(result[1].line, 12);
1745 }
1746
1747 #[test]
1748 fn test_fix_without_reflow_preserves_content() {
1749 let rule = MD013LineLength::new(50, false, false, false, false);
1750 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
1751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1752
1753 let fixed = rule.fix(&ctx).unwrap();
1755 assert_eq!(fixed, content);
1756 }
1757
1758 #[test]
1759 fn test_content_detection() {
1760 let rule = MD013LineLength::default();
1761
1762 let long_line = "a".repeat(100);
1764 let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
1765 assert!(!rule.should_skip(&ctx)); let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1768 assert!(rule.should_skip(&empty_ctx)); }
1770
1771 #[test]
1772 fn test_rule_metadata() {
1773 let rule = MD013LineLength::default();
1774 assert_eq!(rule.name(), "MD013");
1775 assert_eq!(rule.description(), "Line length should not be excessive");
1776 assert_eq!(rule.category(), RuleCategory::Whitespace);
1777 }
1778
1779 #[test]
1780 fn test_url_embedded_in_text() {
1781 let rule = MD013LineLength::new(50, false, false, false, false);
1782
1783 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1786 let result = rule.check(&ctx).unwrap();
1787
1788 assert_eq!(result.len(), 0);
1790 }
1791
1792 #[test]
1793 fn test_multiple_urls_in_line() {
1794 let rule = MD013LineLength::new(50, false, false, false, false);
1795
1796 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1799
1800 let result = rule.check(&ctx).unwrap();
1801
1802 assert_eq!(result.len(), 0);
1804 }
1805
1806 #[test]
1807 fn test_markdown_link_with_long_url() {
1808 let rule = MD013LineLength::new(50, false, false, false, false);
1809
1810 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1813 let result = rule.check(&ctx).unwrap();
1814
1815 assert_eq!(result.len(), 0);
1817 }
1818
1819 #[test]
1820 fn test_line_too_long_even_without_urls() {
1821 let rule = MD013LineLength::new(50, false, false, false, false);
1822
1823 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1826 let result = rule.check(&ctx).unwrap();
1827
1828 assert_eq!(result.len(), 1);
1830 }
1831
1832 #[test]
1833 fn test_strict_mode_counts_urls() {
1834 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";
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1839 let result = rule.check(&ctx).unwrap();
1840
1841 assert_eq!(result.len(), 1);
1843 }
1844
1845 #[test]
1846 fn test_documentation_example_from_md051() {
1847 let rule = MD013LineLength::new(80, false, false, false, false);
1848
1849 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1852 let result = rule.check(&ctx).unwrap();
1853
1854 assert_eq!(result.len(), 0);
1856 }
1857
1858 #[test]
1859 fn test_text_reflow_simple() {
1860 let config = MD013Config {
1861 line_length: 30,
1862 reflow: true,
1863 ..Default::default()
1864 };
1865 let rule = MD013LineLength::from_config_struct(config);
1866
1867 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1869
1870 let fixed = rule.fix(&ctx).unwrap();
1871
1872 for line in fixed.lines() {
1874 assert!(
1875 line.chars().count() <= 30,
1876 "Line too long: {} (len={})",
1877 line,
1878 line.chars().count()
1879 );
1880 }
1881
1882 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1884 let original_words: Vec<&str> = content.split_whitespace().collect();
1885 assert_eq!(fixed_words, original_words);
1886 }
1887
1888 #[test]
1889 fn test_text_reflow_preserves_markdown_elements() {
1890 let config = MD013Config {
1891 line_length: 40,
1892 reflow: true,
1893 ..Default::default()
1894 };
1895 let rule = MD013LineLength::from_config_struct(config);
1896
1897 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1899
1900 let fixed = rule.fix(&ctx).unwrap();
1901
1902 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1904 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1905 assert!(
1906 fixed.contains("[a link](https://example.com)"),
1907 "Link not preserved in: {fixed}"
1908 );
1909
1910 for line in fixed.lines() {
1912 assert!(line.len() <= 40, "Line too long: {line}");
1913 }
1914 }
1915
1916 #[test]
1917 fn test_text_reflow_preserves_code_blocks() {
1918 let config = MD013Config {
1919 line_length: 30,
1920 reflow: true,
1921 ..Default::default()
1922 };
1923 let rule = MD013LineLength::from_config_struct(config);
1924
1925 let content = r#"Here is some text.
1926
1927```python
1928def very_long_function_name_that_exceeds_limit():
1929 return "This should not be wrapped"
1930```
1931
1932More text after code block."#;
1933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1934
1935 let fixed = rule.fix(&ctx).unwrap();
1936
1937 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1939 assert!(fixed.contains("```python"));
1940 assert!(fixed.contains("```"));
1941 }
1942
1943 #[test]
1944 fn test_text_reflow_preserves_lists() {
1945 let config = MD013Config {
1946 line_length: 30,
1947 reflow: true,
1948 ..Default::default()
1949 };
1950 let rule = MD013LineLength::from_config_struct(config);
1951
1952 let content = r#"Here is a list:
1953
19541. First item with a very long line that needs wrapping
19552. Second item is short
19563. Third item also has a long line that exceeds the limit
1957
1958And a bullet list:
1959
1960- Bullet item with very long content that needs wrapping
1961- Short bullet"#;
1962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1963
1964 let fixed = rule.fix(&ctx).unwrap();
1965
1966 assert!(fixed.contains("1. "));
1968 assert!(fixed.contains("2. "));
1969 assert!(fixed.contains("3. "));
1970 assert!(fixed.contains("- "));
1971
1972 let lines: Vec<&str> = fixed.lines().collect();
1974 for (i, line) in lines.iter().enumerate() {
1975 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1976 if i + 1 < lines.len()
1978 && !lines[i + 1].trim().is_empty()
1979 && !lines[i + 1].trim().starts_with(char::is_numeric)
1980 && !lines[i + 1].trim().starts_with("-")
1981 {
1982 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1984 }
1985 } else if line.trim().starts_with("-") {
1986 if i + 1 < lines.len()
1988 && !lines[i + 1].trim().is_empty()
1989 && !lines[i + 1].trim().starts_with(char::is_numeric)
1990 && !lines[i + 1].trim().starts_with("-")
1991 {
1992 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1994 }
1995 }
1996 }
1997 }
1998
1999 #[test]
2000 fn test_issue_83_numbered_list_with_backticks() {
2001 let config = MD013Config {
2003 line_length: 100,
2004 reflow: true,
2005 ..Default::default()
2006 };
2007 let rule = MD013LineLength::from_config_struct(config);
2008
2009 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
2011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2012
2013 let fixed = rule.fix(&ctx).unwrap();
2014
2015 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
2018
2019 assert_eq!(
2020 fixed, expected,
2021 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
2022 );
2023 }
2024
2025 #[test]
2026 fn test_text_reflow_disabled_by_default() {
2027 let rule = MD013LineLength::new(30, false, false, false, false);
2028
2029 let content = "This is a very long line that definitely exceeds thirty characters.";
2030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2031
2032 let fixed = rule.fix(&ctx).unwrap();
2033
2034 assert_eq!(fixed, content);
2037 }
2038
2039 #[test]
2040 fn test_reflow_with_hard_line_breaks() {
2041 let config = MD013Config {
2043 line_length: 40,
2044 reflow: true,
2045 ..Default::default()
2046 };
2047 let rule = MD013LineLength::from_config_struct(config);
2048
2049 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";
2051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2052 let fixed = rule.fix(&ctx).unwrap();
2053
2054 assert!(
2056 fixed.contains(" \n"),
2057 "Hard line break with exactly 2 spaces should be preserved"
2058 );
2059 }
2060
2061 #[test]
2062 fn test_reflow_preserves_reference_links() {
2063 let config = MD013Config {
2064 line_length: 40,
2065 reflow: true,
2066 ..Default::default()
2067 };
2068 let rule = MD013LineLength::from_config_struct(config);
2069
2070 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
2071
2072[ref]: https://example.com";
2073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2074 let fixed = rule.fix(&ctx).unwrap();
2075
2076 assert!(fixed.contains("[reference link][ref]"));
2078 assert!(!fixed.contains("[ reference link]"));
2079 assert!(!fixed.contains("[ref ]"));
2080 }
2081
2082 #[test]
2083 fn test_reflow_with_nested_markdown_elements() {
2084 let config = MD013Config {
2085 line_length: 35,
2086 reflow: true,
2087 ..Default::default()
2088 };
2089 let rule = MD013LineLength::from_config_struct(config);
2090
2091 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
2092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2093 let fixed = rule.fix(&ctx).unwrap();
2094
2095 assert!(fixed.contains("**bold with `code` inside**"));
2097 }
2098
2099 #[test]
2100 fn test_reflow_with_unbalanced_markdown() {
2101 let config = MD013Config {
2103 line_length: 30,
2104 reflow: true,
2105 ..Default::default()
2106 };
2107 let rule = MD013LineLength::from_config_struct(config);
2108
2109 let content = "This has **unbalanced bold that goes on for a very long time without closing";
2110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2111 let fixed = rule.fix(&ctx).unwrap();
2112
2113 assert!(!fixed.is_empty());
2117 for line in fixed.lines() {
2119 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
2120 }
2121 }
2122
2123 #[test]
2124 fn test_reflow_fix_indicator() {
2125 let config = MD013Config {
2127 line_length: 30,
2128 reflow: true,
2129 ..Default::default()
2130 };
2131 let rule = MD013LineLength::from_config_struct(config);
2132
2133 let content = "This is a very long line that definitely exceeds the thirty character limit";
2134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2135 let warnings = rule.check(&ctx).unwrap();
2136
2137 assert!(!warnings.is_empty());
2139 assert!(
2140 warnings[0].fix.is_some(),
2141 "Should provide fix indicator when reflow is true"
2142 );
2143 }
2144
2145 #[test]
2146 fn test_no_fix_indicator_without_reflow() {
2147 let config = MD013Config {
2149 line_length: 30,
2150 reflow: false,
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!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
2162 }
2163
2164 #[test]
2165 fn test_reflow_preserves_all_reference_link_types() {
2166 let config = MD013Config {
2167 line_length: 40,
2168 reflow: true,
2169 ..Default::default()
2170 };
2171 let rule = MD013LineLength::from_config_struct(config);
2172
2173 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
2174
2175[ref]: https://example.com
2176[collapsed]: https://example.com
2177[shortcut]: https://example.com";
2178
2179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2180 let fixed = rule.fix(&ctx).unwrap();
2181
2182 assert!(fixed.contains("[full reference][ref]"));
2184 assert!(fixed.contains("[collapsed][]"));
2185 assert!(fixed.contains("[shortcut]"));
2186 }
2187
2188 #[test]
2189 fn test_reflow_handles_images_correctly() {
2190 let config = MD013Config {
2191 line_length: 40,
2192 reflow: true,
2193 ..Default::default()
2194 };
2195 let rule = MD013LineLength::from_config_struct(config);
2196
2197 let content = "This line has an  that should not be broken when reflowing.";
2198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2199 let fixed = rule.fix(&ctx).unwrap();
2200
2201 assert!(fixed.contains(""));
2203 }
2204
2205 #[test]
2206 fn test_normalize_mode_flags_short_lines() {
2207 let config = MD013Config {
2208 line_length: 100,
2209 reflow: true,
2210 reflow_mode: ReflowMode::Normalize,
2211 ..Default::default()
2212 };
2213 let rule = MD013LineLength::from_config_struct(config);
2214
2215 let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
2217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2218 let warnings = rule.check(&ctx).unwrap();
2219
2220 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2222 assert!(warnings[0].message.contains("normalized"));
2223 }
2224
2225 #[test]
2226 fn test_normalize_mode_combines_short_lines() {
2227 let config = MD013Config {
2228 line_length: 100,
2229 reflow: true,
2230 reflow_mode: ReflowMode::Normalize,
2231 ..Default::default()
2232 };
2233 let rule = MD013LineLength::from_config_struct(config);
2234
2235 let content =
2237 "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
2238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2239 let fixed = rule.fix(&ctx).unwrap();
2240
2241 let lines: Vec<&str> = fixed.lines().collect();
2243 assert_eq!(lines.len(), 1, "Should combine into single line");
2244 assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
2245 }
2246
2247 #[test]
2248 fn test_normalize_mode_preserves_paragraph_breaks() {
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 = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
2258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2259 let fixed = rule.fix(&ctx).unwrap();
2260
2261 assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
2263
2264 let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
2265 assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
2266 }
2267
2268 #[test]
2269 fn test_default_mode_only_fixes_violations() {
2270 let config = MD013Config {
2271 line_length: 100,
2272 reflow: true,
2273 reflow_mode: ReflowMode::Default, ..Default::default()
2275 };
2276 let rule = MD013LineLength::from_config_struct(config);
2277
2278 let content = "This is a short line.\nAnother short line.\nA third short line.";
2280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2281 let warnings = rule.check(&ctx).unwrap();
2282
2283 assert!(warnings.is_empty(), "Should not flag short lines in default mode");
2285
2286 let fixed = rule.fix(&ctx).unwrap();
2288 assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
2289 }
2290
2291 #[test]
2292 fn test_normalize_mode_with_lists() {
2293 let config = MD013Config {
2294 line_length: 80,
2295 reflow: true,
2296 reflow_mode: ReflowMode::Normalize,
2297 ..Default::default()
2298 };
2299 let rule = MD013LineLength::from_config_struct(config);
2300
2301 let content = r#"A paragraph with
2302short lines.
2303
23041. List item with
2305 short lines
23062. Another item"#;
2307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2308 let fixed = rule.fix(&ctx).unwrap();
2309
2310 let lines: Vec<&str> = fixed.lines().collect();
2312 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2313 assert!(fixed.contains("1. "), "Should preserve list markers");
2314 assert!(fixed.contains("2. "), "Should preserve list markers");
2315 }
2316
2317 #[test]
2318 fn test_normalize_mode_with_code_blocks() {
2319 let config = MD013Config {
2320 line_length: 100,
2321 reflow: true,
2322 reflow_mode: ReflowMode::Normalize,
2323 ..Default::default()
2324 };
2325 let rule = MD013LineLength::from_config_struct(config);
2326
2327 let content = r#"A paragraph with
2328short lines.
2329
2330```
2331code block should not be normalized
2332even with short lines
2333```
2334
2335Another paragraph with
2336short lines."#;
2337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2338 let fixed = rule.fix(&ctx).unwrap();
2339
2340 assert!(fixed.contains("code block should not be normalized\neven with short lines"));
2342 let lines: Vec<&str> = fixed.lines().collect();
2344 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2345 }
2346
2347 #[test]
2348 fn test_issue_76_use_case() {
2349 let config = MD013Config {
2351 line_length: 999999, reflow: true,
2353 reflow_mode: ReflowMode::Normalize,
2354 ..Default::default()
2355 };
2356 let rule = MD013LineLength::from_config_struct(config);
2357
2358 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.";
2360
2361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2362
2363 let warnings = rule.check(&ctx).unwrap();
2365 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2366
2367 let fixed = rule.fix(&ctx).unwrap();
2369 let lines: Vec<&str> = fixed.lines().collect();
2370 assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
2371 assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
2372 }
2373
2374 #[test]
2375 fn test_normalize_mode_single_line_unchanged() {
2376 let config = MD013Config {
2378 line_length: 100,
2379 reflow: true,
2380 reflow_mode: ReflowMode::Normalize,
2381 ..Default::default()
2382 };
2383 let rule = MD013LineLength::from_config_struct(config);
2384
2385 let content = "This is a single line that should not be changed.";
2386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2387
2388 let warnings = rule.check(&ctx).unwrap();
2389 assert!(warnings.is_empty(), "Single line should not be flagged");
2390
2391 let fixed = rule.fix(&ctx).unwrap();
2392 assert_eq!(fixed, content, "Single line should remain unchanged");
2393 }
2394
2395 #[test]
2396 fn test_normalize_mode_with_inline_code() {
2397 let config = MD013Config {
2398 line_length: 80,
2399 reflow: true,
2400 reflow_mode: ReflowMode::Normalize,
2401 ..Default::default()
2402 };
2403 let rule = MD013LineLength::from_config_struct(config);
2404
2405 let content =
2406 "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
2407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2408
2409 let warnings = rule.check(&ctx).unwrap();
2410 assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
2411
2412 let fixed = rule.fix(&ctx).unwrap();
2413 assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
2414 assert!(fixed.lines().count() < 3, "Lines should be combined");
2415 }
2416
2417 #[test]
2418 fn test_normalize_mode_with_emphasis() {
2419 let config = MD013Config {
2420 line_length: 100,
2421 reflow: true,
2422 reflow_mode: ReflowMode::Normalize,
2423 ..Default::default()
2424 };
2425 let rule = MD013LineLength::from_config_struct(config);
2426
2427 let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
2428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2429
2430 let fixed = rule.fix(&ctx).unwrap();
2431 assert!(fixed.contains("**bold**"), "Bold should be preserved");
2432 assert!(fixed.contains("*italic*"), "Italic should be preserved");
2433 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2434 }
2435
2436 #[test]
2437 fn test_normalize_mode_respects_hard_breaks() {
2438 let config = MD013Config {
2439 line_length: 100,
2440 reflow: true,
2441 reflow_mode: ReflowMode::Normalize,
2442 ..Default::default()
2443 };
2444 let rule = MD013LineLength::from_config_struct(config);
2445
2446 let content = "First line with hard break \nSecond line after break\nThird line";
2448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2449
2450 let fixed = rule.fix(&ctx).unwrap();
2451 assert!(fixed.contains(" \n"), "Hard break should be preserved");
2453 assert!(
2455 fixed.contains("Second line after break Third line"),
2456 "Lines without hard break should combine"
2457 );
2458 }
2459
2460 #[test]
2461 fn test_normalize_mode_with_links() {
2462 let config = MD013Config {
2463 line_length: 100,
2464 reflow: true,
2465 reflow_mode: ReflowMode::Normalize,
2466 ..Default::default()
2467 };
2468 let rule = MD013LineLength::from_config_struct(config);
2469
2470 let content =
2471 "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
2472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2473
2474 let fixed = rule.fix(&ctx).unwrap();
2475 assert!(
2476 fixed.contains("[link](https://example.com)"),
2477 "Link should be preserved"
2478 );
2479 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2480 }
2481
2482 #[test]
2483 fn test_normalize_mode_empty_lines_between_paragraphs() {
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 = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
2493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2494
2495 let fixed = rule.fix(&ctx).unwrap();
2496 assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
2498 let parts: Vec<&str> = fixed.split("\n\n\n").collect();
2500 assert_eq!(parts.len(), 2, "Should have two parts");
2501 assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
2502 assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
2503 }
2504
2505 #[test]
2506 fn test_normalize_mode_mixed_list_types() {
2507 let config = MD013Config {
2508 line_length: 80,
2509 reflow: true,
2510 reflow_mode: ReflowMode::Normalize,
2511 ..Default::default()
2512 };
2513 let rule = MD013LineLength::from_config_struct(config);
2514
2515 let content = r#"Paragraph before list
2516with multiple lines.
2517
2518- Bullet item
2519* Another bullet
2520+ Plus bullet
2521
25221. Numbered item
25232. Another number
2524
2525Paragraph after list
2526with multiple lines."#;
2527
2528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2529 let fixed = rule.fix(&ctx).unwrap();
2530
2531 assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
2533 assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
2534 assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
2535 assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
2536
2537 assert!(
2539 fixed.starts_with("Paragraph before list with multiple lines."),
2540 "First paragraph should be normalized"
2541 );
2542 assert!(
2543 fixed.ends_with("Paragraph after list with multiple lines."),
2544 "Last paragraph should be normalized"
2545 );
2546 }
2547
2548 #[test]
2549 fn test_normalize_mode_with_horizontal_rules() {
2550 let config = MD013Config {
2551 line_length: 100,
2552 reflow: true,
2553 reflow_mode: ReflowMode::Normalize,
2554 ..Default::default()
2555 };
2556 let rule = MD013LineLength::from_config_struct(config);
2557
2558 let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
2559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2560
2561 let fixed = rule.fix(&ctx).unwrap();
2562 assert!(fixed.contains("---"), "Horizontal rule should be preserved");
2563 assert!(
2564 fixed.contains("Paragraph before horizontal rule."),
2565 "First paragraph normalized"
2566 );
2567 assert!(
2568 fixed.contains("Paragraph after horizontal rule."),
2569 "Second paragraph normalized"
2570 );
2571 }
2572
2573 #[test]
2574 fn test_normalize_mode_with_indented_code() {
2575 let config = MD013Config {
2576 line_length: 100,
2577 reflow: true,
2578 reflow_mode: ReflowMode::Normalize,
2579 ..Default::default()
2580 };
2581 let rule = MD013LineLength::from_config_struct(config);
2582
2583 let content = "Paragraph before\nindented code.\n\n This is indented code\n Should not be normalized\n\nParagraph after\nindented code.";
2584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2585
2586 let fixed = rule.fix(&ctx).unwrap();
2587 assert!(
2588 fixed.contains(" This is indented code\n Should not be normalized"),
2589 "Indented code preserved"
2590 );
2591 assert!(
2592 fixed.contains("Paragraph before indented code."),
2593 "First paragraph normalized"
2594 );
2595 assert!(
2596 fixed.contains("Paragraph after indented code."),
2597 "Second paragraph normalized"
2598 );
2599 }
2600
2601 #[test]
2602 fn test_normalize_mode_disabled_without_reflow() {
2603 let config = MD013Config {
2605 line_length: 100,
2606 reflow: false, reflow_mode: ReflowMode::Normalize,
2608 ..Default::default()
2609 };
2610 let rule = MD013LineLength::from_config_struct(config);
2611
2612 let content = "This is a line\nwith breaks that\nshould not be changed.";
2613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2614
2615 let warnings = rule.check(&ctx).unwrap();
2616 assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2617
2618 let fixed = rule.fix(&ctx).unwrap();
2619 assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2620 }
2621
2622 #[test]
2623 fn test_default_mode_with_long_lines() {
2624 let config = MD013Config {
2627 line_length: 50,
2628 reflow: true,
2629 reflow_mode: ReflowMode::Default,
2630 ..Default::default()
2631 };
2632 let rule = MD013LineLength::from_config_struct(config);
2633
2634 let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2636
2637 let warnings = rule.check(&ctx).unwrap();
2638 assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2639 assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2641
2642 let fixed = rule.fix(&ctx).unwrap();
2643 assert!(
2645 fixed.contains("Short line. This is"),
2646 "Should combine and reflow the paragraph"
2647 );
2648 assert!(
2649 fixed.contains("wrapping. Another short"),
2650 "Should include all paragraph content"
2651 );
2652 }
2653
2654 #[test]
2655 fn test_normalize_vs_default_mode_same_content() {
2656 let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2658
2659 let default_config = MD013Config {
2661 line_length: 100,
2662 reflow: true,
2663 reflow_mode: ReflowMode::Default,
2664 ..Default::default()
2665 };
2666 let default_rule = MD013LineLength::from_config_struct(default_config);
2667 let default_warnings = default_rule.check(&ctx).unwrap();
2668 let default_fixed = default_rule.fix(&ctx).unwrap();
2669
2670 let normalize_config = MD013Config {
2672 line_length: 100,
2673 reflow: true,
2674 reflow_mode: ReflowMode::Normalize,
2675 ..Default::default()
2676 };
2677 let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2678 let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2679 let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2680
2681 assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2683 assert!(
2684 !normalize_warnings.is_empty(),
2685 "Normalize mode should flag multi-line paragraphs"
2686 );
2687
2688 assert_eq!(
2689 default_fixed, content,
2690 "Default mode should not change content without violations"
2691 );
2692 assert_ne!(
2693 normalize_fixed, content,
2694 "Normalize mode should change multi-line paragraphs"
2695 );
2696 assert_eq!(
2697 normalize_fixed.lines().count(),
2698 1,
2699 "Normalize should combine into single line"
2700 );
2701 }
2702
2703 #[test]
2704 fn test_normalize_mode_with_reference_definitions() {
2705 let config = MD013Config {
2706 line_length: 100,
2707 reflow: true,
2708 reflow_mode: ReflowMode::Normalize,
2709 ..Default::default()
2710 };
2711 let rule = MD013LineLength::from_config_struct(config);
2712
2713 let content =
2714 "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2716
2717 let fixed = rule.fix(&ctx).unwrap();
2718 assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2719 assert!(
2720 fixed.contains("[ref]: https://example.com"),
2721 "Reference definition should be preserved"
2722 );
2723 assert!(
2724 fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2725 "Paragraph should be normalized"
2726 );
2727 }
2728
2729 #[test]
2730 fn test_normalize_mode_with_html_comments() {
2731 let config = MD013Config {
2732 line_length: 100,
2733 reflow: true,
2734 reflow_mode: ReflowMode::Normalize,
2735 ..Default::default()
2736 };
2737 let rule = MD013LineLength::from_config_struct(config);
2738
2739 let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2741
2742 let fixed = rule.fix(&ctx).unwrap();
2743 assert!(
2744 fixed.contains("<!-- This is a comment -->"),
2745 "HTML comment should be preserved"
2746 );
2747 assert!(
2748 fixed.contains("Paragraph before HTML comment."),
2749 "First paragraph normalized"
2750 );
2751 assert!(
2752 fixed.contains("Paragraph after HTML comment."),
2753 "Second paragraph normalized"
2754 );
2755 }
2756
2757 #[test]
2758 fn test_normalize_mode_line_starting_with_number() {
2759 let config = MD013Config {
2761 line_length: 100,
2762 reflow: true,
2763 reflow_mode: ReflowMode::Normalize,
2764 ..Default::default()
2765 };
2766 let rule = MD013LineLength::from_config_struct(config);
2767
2768 let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2770
2771 let fixed = rule.fix(&ctx).unwrap();
2772 assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2773 assert!(
2774 fixed.contains("80 characters"),
2775 "Number at start of line should be preserved"
2776 );
2777 }
2778
2779 #[test]
2780 fn test_default_mode_preserves_list_structure() {
2781 let config = MD013Config {
2783 line_length: 80,
2784 reflow: true,
2785 reflow_mode: ReflowMode::Default,
2786 ..Default::default()
2787 };
2788 let rule = MD013LineLength::from_config_struct(config);
2789
2790 let content = r#"- This is a bullet point that has
2791 some text on multiple lines
2792 that should stay separate
2793
27941. Numbered list item with
2795 multiple lines that should
2796 also stay separate"#;
2797
2798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2799 let fixed = rule.fix(&ctx).unwrap();
2800
2801 let lines: Vec<&str> = fixed.lines().collect();
2803 assert_eq!(
2804 lines[0], "- This is a bullet point that has",
2805 "First line should be unchanged"
2806 );
2807 assert_eq!(
2808 lines[1], " some text on multiple lines",
2809 "Continuation should be preserved"
2810 );
2811 assert_eq!(
2812 lines[2], " that should stay separate",
2813 "Second continuation should be preserved"
2814 );
2815 }
2816
2817 #[test]
2818 fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2819 let config = MD013Config {
2821 line_length: 80,
2822 reflow: true,
2823 reflow_mode: ReflowMode::Normalize,
2824 ..Default::default()
2825 };
2826 let rule = MD013LineLength::from_config_struct(config);
2827
2828 let content = r#"- This is a bullet point that has
2829 some text on multiple lines
2830 that should be combined
2831
28321. Numbered list item with
2833 multiple lines that need
2834 to be properly combined
28352. Second item"#;
2836
2837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2838 let fixed = rule.fix(&ctx).unwrap();
2839
2840 assert!(
2842 !fixed.contains("lines that"),
2843 "Should not have double spaces in bullet list"
2844 );
2845 assert!(
2846 !fixed.contains("need to"),
2847 "Should not have double spaces in numbered list"
2848 );
2849
2850 assert!(
2852 fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2853 "Bullet list should be properly combined"
2854 );
2855 assert!(
2856 fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2857 "Numbered list should be properly combined"
2858 );
2859 }
2860
2861 #[test]
2862 fn test_normalize_mode_actual_numbered_list() {
2863 let config = MD013Config {
2865 line_length: 100,
2866 reflow: true,
2867 reflow_mode: ReflowMode::Normalize,
2868 ..Default::default()
2869 };
2870 let rule = MD013LineLength::from_config_struct(config);
2871
2872 let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2874
2875 let fixed = rule.fix(&ctx).unwrap();
2876 assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2877 assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2878 assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2879 assert!(
2880 fixed.starts_with("Paragraph before list with multiple lines."),
2881 "Paragraph should be normalized"
2882 );
2883 }
2884
2885 #[test]
2886 fn test_sentence_per_line_detection() {
2887 let config = MD013Config {
2888 reflow: true,
2889 reflow_mode: ReflowMode::SentencePerLine,
2890 ..Default::default()
2891 };
2892 let rule = MD013LineLength::from_config_struct(config.clone());
2893
2894 let content = "This is sentence one. This is sentence two. And sentence three!";
2896 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2897
2898 assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2900
2901 let result = rule.check(&ctx).unwrap();
2902
2903 assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2904 assert_eq!(
2905 result[0].message,
2906 "Line contains multiple sentences (one sentence per line expected)"
2907 );
2908 }
2909
2910 #[test]
2911 fn test_sentence_per_line_fix() {
2912 let config = MD013Config {
2913 reflow: true,
2914 reflow_mode: ReflowMode::SentencePerLine,
2915 ..Default::default()
2916 };
2917 let rule = MD013LineLength::from_config_struct(config);
2918
2919 let content = "First sentence. Second sentence.";
2920 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2921 let result = rule.check(&ctx).unwrap();
2922
2923 assert!(!result.is_empty(), "Should detect violation");
2924 assert!(result[0].fix.is_some(), "Should provide a fix");
2925
2926 let fix = result[0].fix.as_ref().unwrap();
2927 assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2928 }
2929
2930 #[test]
2931 fn test_sentence_per_line_abbreviations() {
2932 let config = MD013Config {
2933 reflow: true,
2934 reflow_mode: ReflowMode::SentencePerLine,
2935 ..Default::default()
2936 };
2937 let rule = MD013LineLength::from_config_struct(config);
2938
2939 let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2941 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2942 let result = rule.check(&ctx).unwrap();
2943
2944 assert!(
2945 result.is_empty(),
2946 "Should not detect abbreviations as sentence boundaries"
2947 );
2948 }
2949
2950 #[test]
2951 fn test_sentence_per_line_with_markdown() {
2952 let config = MD013Config {
2953 reflow: true,
2954 reflow_mode: ReflowMode::SentencePerLine,
2955 ..Default::default()
2956 };
2957 let rule = MD013LineLength::from_config_struct(config);
2958
2959 let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
2960 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2961 let result = rule.check(&ctx).unwrap();
2962
2963 assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
2964 assert_eq!(result[0].line, 3); }
2966
2967 #[test]
2968 fn test_sentence_per_line_questions_exclamations() {
2969 let config = MD013Config {
2970 reflow: true,
2971 reflow_mode: ReflowMode::SentencePerLine,
2972 ..Default::default()
2973 };
2974 let rule = MD013LineLength::from_config_struct(config);
2975
2976 let content = "Is this a question? Yes it is! And a statement.";
2977 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2978 let result = rule.check(&ctx).unwrap();
2979
2980 assert!(!result.is_empty(), "Should detect sentences with ? and !");
2981
2982 let fix = result[0].fix.as_ref().unwrap();
2983 let lines: Vec<&str> = fix.replacement.trim().lines().collect();
2984 assert_eq!(lines.len(), 3);
2985 assert_eq!(lines[0], "Is this a question?");
2986 assert_eq!(lines[1], "Yes it is!");
2987 assert_eq!(lines[2], "And a statement.");
2988 }
2989
2990 #[test]
2991 fn test_sentence_per_line_in_lists() {
2992 let config = MD013Config {
2993 reflow: true,
2994 reflow_mode: ReflowMode::SentencePerLine,
2995 ..Default::default()
2996 };
2997 let rule = MD013LineLength::from_config_struct(config);
2998
2999 let content = "- List item one. With two sentences.\n- Another item.";
3000 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3001 let result = rule.check(&ctx).unwrap();
3002
3003 assert!(!result.is_empty(), "Should detect sentences in list items");
3004 let fix = result[0].fix.as_ref().unwrap();
3006 assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
3007 }
3008
3009 #[test]
3010 fn test_multi_paragraph_list_item_with_3_space_indent() {
3011 let config = MD013Config {
3012 reflow: true,
3013 reflow_mode: ReflowMode::Normalize,
3014 line_length: 999999,
3015 ..Default::default()
3016 };
3017 let rule = MD013LineLength::from_config_struct(config);
3018
3019 let content = "1. First paragraph\n continuation line.\n\n Second paragraph\n more content.";
3020 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3021 let result = rule.check(&ctx).unwrap();
3022
3023 assert!(!result.is_empty(), "Should detect multi-line paragraphs in list item");
3024 let fix = result[0].fix.as_ref().unwrap();
3025
3026 assert!(
3028 fix.replacement.contains("\n\n"),
3029 "Should preserve blank line between paragraphs"
3030 );
3031 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3032 }
3033
3034 #[test]
3035 fn test_multi_paragraph_list_item_with_4_space_indent() {
3036 let config = MD013Config {
3037 reflow: true,
3038 reflow_mode: ReflowMode::Normalize,
3039 line_length: 999999,
3040 ..Default::default()
3041 };
3042 let rule = MD013LineLength::from_config_struct(config);
3043
3044 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.";
3046 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3047 let result = rule.check(&ctx).unwrap();
3048
3049 assert!(
3050 !result.is_empty(),
3051 "Should detect multi-line paragraphs in list item with 4-space indent"
3052 );
3053 let fix = result[0].fix.as_ref().unwrap();
3054
3055 assert!(
3057 fix.replacement.contains("\n\n"),
3058 "Should preserve blank line between paragraphs"
3059 );
3060 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3061
3062 let lines: Vec<&str> = fix.replacement.split('\n').collect();
3064 let blank_line_idx = lines.iter().position(|l| l.trim().is_empty());
3065 assert!(blank_line_idx.is_some(), "Should have blank line separating paragraphs");
3066 }
3067
3068 #[test]
3069 fn test_multi_paragraph_bullet_list_item() {
3070 let config = MD013Config {
3071 reflow: true,
3072 reflow_mode: ReflowMode::Normalize,
3073 line_length: 999999,
3074 ..Default::default()
3075 };
3076 let rule = MD013LineLength::from_config_struct(config);
3077
3078 let content = "- First paragraph\n continuation.\n\n Second paragraph\n more text.";
3079 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3080 let result = rule.check(&ctx).unwrap();
3081
3082 assert!(!result.is_empty(), "Should detect multi-line paragraphs in bullet list");
3083 let fix = result[0].fix.as_ref().unwrap();
3084
3085 assert!(
3086 fix.replacement.contains("\n\n"),
3087 "Should preserve blank line between paragraphs"
3088 );
3089 assert!(fix.replacement.starts_with("- "), "Should preserve bullet marker");
3090 }
3091
3092 #[test]
3093 fn test_code_block_in_list_item_five_spaces() {
3094 let config = MD013Config {
3095 reflow: true,
3096 reflow_mode: ReflowMode::Normalize,
3097 line_length: 80,
3098 ..Default::default()
3099 };
3100 let rule = MD013LineLength::from_config_struct(config);
3101
3102 let content = "1. First paragraph with some text that should be reflowed.\n\n code_block()\n more_code()\n\n Second paragraph.";
3105 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3106 let result = rule.check(&ctx).unwrap();
3107
3108 if !result.is_empty() {
3109 let fix = result[0].fix.as_ref().unwrap();
3110 assert!(
3112 fix.replacement.contains(" code_block()"),
3113 "Code block should be preserved: {}",
3114 fix.replacement
3115 );
3116 assert!(
3117 fix.replacement.contains(" more_code()"),
3118 "Code block should be preserved: {}",
3119 fix.replacement
3120 );
3121 }
3122 }
3123
3124 #[test]
3125 fn test_fenced_code_block_in_list_item() {
3126 let config = MD013Config {
3127 reflow: true,
3128 reflow_mode: ReflowMode::Normalize,
3129 line_length: 80,
3130 ..Default::default()
3131 };
3132 let rule = MD013LineLength::from_config_struct(config);
3133
3134 let content = "1. First paragraph with some text.\n\n ```rust\n fn foo() {}\n let x = 1;\n ```\n\n Second paragraph.";
3135 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3136 let result = rule.check(&ctx).unwrap();
3137
3138 if !result.is_empty() {
3139 let fix = result[0].fix.as_ref().unwrap();
3140 assert!(
3142 fix.replacement.contains("```rust"),
3143 "Should preserve fence: {}",
3144 fix.replacement
3145 );
3146 assert!(
3147 fix.replacement.contains("fn foo() {}"),
3148 "Should preserve code: {}",
3149 fix.replacement
3150 );
3151 assert!(
3152 fix.replacement.contains("```"),
3153 "Should preserve closing fence: {}",
3154 fix.replacement
3155 );
3156 }
3157 }
3158
3159 #[test]
3160 fn test_mixed_indentation_3_and_4_spaces() {
3161 let config = MD013Config {
3162 reflow: true,
3163 reflow_mode: ReflowMode::Normalize,
3164 line_length: 999999,
3165 ..Default::default()
3166 };
3167 let rule = MD013LineLength::from_config_struct(config);
3168
3169 let content = "1. Text\n 3 space continuation\n 4 space continuation";
3171 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3172 let result = rule.check(&ctx).unwrap();
3173
3174 assert!(!result.is_empty(), "Should detect multi-line list item");
3175 let fix = result[0].fix.as_ref().unwrap();
3176 assert!(
3178 fix.replacement.contains("3 space continuation"),
3179 "Should include 3-space line: {}",
3180 fix.replacement
3181 );
3182 assert!(
3183 fix.replacement.contains("4 space continuation"),
3184 "Should include 4-space line: {}",
3185 fix.replacement
3186 );
3187 }
3188
3189 #[test]
3190 fn test_nested_list_in_multi_paragraph_item() {
3191 let config = MD013Config {
3192 reflow: true,
3193 reflow_mode: ReflowMode::Normalize,
3194 line_length: 999999,
3195 ..Default::default()
3196 };
3197 let rule = MD013LineLength::from_config_struct(config);
3198
3199 let content = "1. First paragraph.\n\n - Nested item\n continuation\n\n Second paragraph.";
3200 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3201 let result = rule.check(&ctx).unwrap();
3202
3203 assert!(!result.is_empty(), "Should detect and reflow parent item");
3205 if let Some(fix) = result[0].fix.as_ref() {
3206 assert!(
3208 fix.replacement.contains("- Nested"),
3209 "Should preserve nested list: {}",
3210 fix.replacement
3211 );
3212 assert!(
3213 fix.replacement.contains("Second paragraph"),
3214 "Should include content after nested list: {}",
3215 fix.replacement
3216 );
3217 }
3218 }
3219
3220 #[test]
3221 fn test_nested_fence_markers_different_types() {
3222 let config = MD013Config {
3223 reflow: true,
3224 reflow_mode: ReflowMode::Normalize,
3225 line_length: 80,
3226 ..Default::default()
3227 };
3228 let rule = MD013LineLength::from_config_struct(config);
3229
3230 let content = "1. Example with nested fences:\n\n ~~~markdown\n This shows ```python\n code = True\n ```\n ~~~\n\n Text after.";
3232 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3233 let result = rule.check(&ctx).unwrap();
3234
3235 if !result.is_empty() {
3236 let fix = result[0].fix.as_ref().unwrap();
3237 assert!(
3239 fix.replacement.contains("```python"),
3240 "Should preserve inner fence: {}",
3241 fix.replacement
3242 );
3243 assert!(
3244 fix.replacement.contains("~~~"),
3245 "Should preserve outer fence: {}",
3246 fix.replacement
3247 );
3248 assert!(
3250 fix.replacement.contains("code = True"),
3251 "Should preserve code: {}",
3252 fix.replacement
3253 );
3254 }
3255 }
3256
3257 #[test]
3258 fn test_nested_fence_markers_same_type() {
3259 let config = MD013Config {
3260 reflow: true,
3261 reflow_mode: ReflowMode::Normalize,
3262 line_length: 80,
3263 ..Default::default()
3264 };
3265 let rule = MD013LineLength::from_config_struct(config);
3266
3267 let content =
3269 "1. Example:\n\n ````markdown\n Shows ```python in code\n ```\n text here\n ````\n\n After.";
3270 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3271 let result = rule.check(&ctx).unwrap();
3272
3273 if !result.is_empty() {
3274 let fix = result[0].fix.as_ref().unwrap();
3275 assert!(
3277 fix.replacement.contains("```python"),
3278 "Should preserve inner fence: {}",
3279 fix.replacement
3280 );
3281 assert!(
3282 fix.replacement.contains("````"),
3283 "Should preserve outer fence: {}",
3284 fix.replacement
3285 );
3286 assert!(
3287 fix.replacement.contains("text here"),
3288 "Should keep text as code: {}",
3289 fix.replacement
3290 );
3291 }
3292 }
3293
3294 #[test]
3295 fn test_sibling_list_item_breaks_parent() {
3296 let config = MD013Config {
3297 reflow: true,
3298 reflow_mode: ReflowMode::Normalize,
3299 line_length: 999999,
3300 ..Default::default()
3301 };
3302 let rule = MD013LineLength::from_config_struct(config);
3303
3304 let content = "1. First item\n continuation.\n2. Second item";
3306 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3307 let result = rule.check(&ctx).unwrap();
3308
3309 if !result.is_empty() {
3311 let fix = result[0].fix.as_ref().unwrap();
3312 assert!(fix.replacement.starts_with("1. "), "Should start with first marker");
3314 assert!(fix.replacement.contains("continuation"), "Should include continuation");
3315 }
3317 }
3318
3319 #[test]
3320 fn test_nested_list_at_continuation_indent_preserved() {
3321 let config = MD013Config {
3322 reflow: true,
3323 reflow_mode: ReflowMode::Normalize,
3324 line_length: 999999,
3325 ..Default::default()
3326 };
3327 let rule = MD013LineLength::from_config_struct(config);
3328
3329 let content = "1. Parent paragraph\n with continuation.\n\n - Nested at 3 spaces\n - Another nested\n\n After nested.";
3331 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3332 let result = rule.check(&ctx).unwrap();
3333
3334 if !result.is_empty() {
3335 let fix = result[0].fix.as_ref().unwrap();
3336 assert!(
3338 fix.replacement.contains("- Nested"),
3339 "Should include first nested item: {}",
3340 fix.replacement
3341 );
3342 assert!(
3343 fix.replacement.contains("- Another"),
3344 "Should include second nested item: {}",
3345 fix.replacement
3346 );
3347 assert!(
3348 fix.replacement.contains("After nested"),
3349 "Should include content after nested list: {}",
3350 fix.replacement
3351 );
3352 }
3353 }
3354}