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 paragraphs: true, strict,
33 reflow: false,
34 reflow_mode: ReflowMode::default(),
35 },
36 }
37 }
38
39 pub fn from_config_struct(config: MD013Config) -> Self {
40 Self { config }
41 }
42
43 fn should_ignore_line(
44 &self,
45 line: &str,
46 _lines: &[&str],
47 current_line: usize,
48 ctx: &crate::lint_context::LintContext,
49 ) -> bool {
50 if self.config.strict {
51 return false;
52 }
53
54 let trimmed = line.trim();
56
57 if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
59 return true;
60 }
61
62 if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
64 return true;
65 }
66
67 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
69 return true;
70 }
71
72 if ctx.line_info(current_line + 1).is_some_and(|info| info.in_code_block)
74 && !trimmed.is_empty()
75 && !line.contains(' ')
76 && !line.contains('\t')
77 {
78 return true;
79 }
80
81 false
82 }
83}
84
85impl Rule for MD013LineLength {
86 fn name(&self) -> &'static str {
87 "MD013"
88 }
89
90 fn description(&self) -> &'static str {
91 "Line length should not be excessive"
92 }
93
94 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
95 let content = ctx.content;
96
97 if self.should_skip(ctx)
100 && !(self.config.reflow
101 && (self.config.reflow_mode == ReflowMode::Normalize
102 || self.config.reflow_mode == ReflowMode::SentencePerLine))
103 {
104 return Ok(Vec::new());
105 }
106
107 let mut warnings = Vec::new();
109
110 let inline_config = crate::inline_config::InlineConfig::from_content(content);
112 let config_override = inline_config.get_rule_config("MD013");
113
114 let effective_config = if let Some(json_config) = config_override {
116 if let Some(obj) = json_config.as_object() {
117 let mut config = self.config.clone();
118 if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
119 config.line_length = line_length as usize;
120 }
121 if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
122 config.code_blocks = code_blocks;
123 }
124 if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
125 config.tables = tables;
126 }
127 if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
128 config.headings = headings;
129 }
130 if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
131 config.strict = strict;
132 }
133 if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
134 config.reflow = reflow;
135 }
136 if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
137 config.reflow_mode = match reflow_mode {
138 "default" => ReflowMode::Default,
139 "normalize" => ReflowMode::Normalize,
140 "sentence-per-line" => ReflowMode::SentencePerLine,
141 _ => ReflowMode::default(),
142 };
143 }
144 config
145 } else {
146 self.config.clone()
147 }
148 } else {
149 self.config.clone()
150 };
151
152 let mut candidate_lines = Vec::new();
154 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
155 if line_info.content.len() > effective_config.line_length {
157 candidate_lines.push(line_idx);
158 }
159 }
160
161 if candidate_lines.is_empty()
163 && !(effective_config.reflow
164 && (effective_config.reflow_mode == ReflowMode::Normalize
165 || effective_config.reflow_mode == ReflowMode::SentencePerLine))
166 {
167 return Ok(warnings);
168 }
169
170 let lines: Vec<&str> = if !ctx.lines.is_empty() {
172 ctx.lines.iter().map(|l| l.content.as_str()).collect()
173 } else {
174 content.lines().collect()
175 };
176
177 let heading_lines_set: std::collections::HashSet<usize> = ctx
180 .lines
181 .iter()
182 .enumerate()
183 .filter(|(_, line)| line.heading.is_some())
184 .map(|(idx, _)| idx + 1)
185 .collect();
186
187 let table_blocks = TableUtils::find_table_blocks(content, ctx);
190 let mut table_lines_set = std::collections::HashSet::new();
191 for table in &table_blocks {
192 table_lines_set.insert(table.header_line + 1);
193 table_lines_set.insert(table.delimiter_line + 1);
194 for &line in &table.content_lines {
195 table_lines_set.insert(line + 1);
196 }
197 }
198
199 for &line_idx in &candidate_lines {
201 let line_number = line_idx + 1;
202 let line = lines[line_idx];
203
204 let effective_length = self.calculate_effective_length(line);
206
207 let line_limit = effective_config.line_length;
209
210 if effective_length <= line_limit {
212 continue;
213 }
214
215 if ctx.lines[line_idx].in_mkdocstrings {
217 continue;
218 }
219
220 if !effective_config.strict {
222 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
224 continue;
225 }
226
227 if (!effective_config.headings && heading_lines_set.contains(&line_number))
231 || (!effective_config.code_blocks
232 && ctx.line_info(line_number).is_some_and(|info| info.in_code_block))
233 || (!effective_config.tables && table_lines_set.contains(&line_number))
234 || ctx.lines[line_number - 1].blockquote.is_some()
235 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
236 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
237 {
238 continue;
239 }
240
241 if !effective_config.paragraphs {
244 let is_special_block = heading_lines_set.contains(&line_number)
245 || ctx.line_info(line_number).is_some_and(|info| info.in_code_block)
246 || table_lines_set.contains(&line_number)
247 || ctx.lines[line_number - 1].blockquote.is_some()
248 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
249 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment);
250
251 if !is_special_block {
253 continue;
254 }
255 }
256
257 if self.should_ignore_line(line, &lines, line_idx, ctx) {
259 continue;
260 }
261 }
262
263 if effective_config.reflow_mode == ReflowMode::SentencePerLine {
266 let sentences = split_into_sentences(line.trim());
267 if sentences.len() == 1 {
268 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
270
271 let (start_line, start_col, end_line, end_col) =
272 calculate_excess_range(line_number, line, line_limit);
273
274 warnings.push(LintWarning {
275 rule_name: Some(self.name().to_string()),
276 message,
277 line: start_line,
278 column: start_col,
279 end_line,
280 end_column: end_col,
281 severity: Severity::Warning,
282 fix: None, });
284 continue;
285 }
286 continue;
288 }
289
290 let fix = None;
293
294 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
295
296 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
298
299 warnings.push(LintWarning {
300 rule_name: Some(self.name().to_string()),
301 message,
302 line: start_line,
303 column: start_col,
304 end_line,
305 end_column: end_col,
306 severity: Severity::Warning,
307 fix,
308 });
309 }
310
311 if effective_config.reflow {
313 let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, &lines);
314 for pw in paragraph_warnings {
316 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
318 warnings.push(pw);
319 }
320 }
321
322 Ok(warnings)
323 }
324
325 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
326 let warnings = self.check(ctx)?;
329
330 if !warnings.iter().any(|w| w.fix.is_some()) {
332 return Ok(ctx.content.to_string());
333 }
334
335 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
337 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
338 }
339
340 fn as_any(&self) -> &dyn std::any::Any {
341 self
342 }
343
344 fn category(&self) -> RuleCategory {
345 RuleCategory::Whitespace
346 }
347
348 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
349 if ctx.content.is_empty() {
351 return true;
352 }
353
354 if self.config.reflow
356 && (self.config.reflow_mode == ReflowMode::SentencePerLine
357 || self.config.reflow_mode == ReflowMode::Normalize)
358 {
359 return false;
360 }
361
362 if ctx.content.len() <= self.config.line_length {
364 return true;
365 }
366
367 !ctx.lines
369 .iter()
370 .any(|line| line.content.len() > self.config.line_length)
371 }
372
373 fn default_config_section(&self) -> Option<(String, toml::Value)> {
374 let default_config = MD013Config::default();
375 let json_value = serde_json::to_value(&default_config).ok()?;
376 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
377
378 if let toml::Value::Table(table) = toml_value {
379 if !table.is_empty() {
380 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
381 } else {
382 None
383 }
384 } else {
385 None
386 }
387 }
388
389 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
390 let mut aliases = std::collections::HashMap::new();
391 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
392 Some(aliases)
393 }
394
395 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
396 where
397 Self: Sized,
398 {
399 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
400 if rule_config.line_length == 80 {
402 rule_config.line_length = config.global.line_length as usize;
404 }
405 Box::new(Self::from_config_struct(rule_config))
406 }
407}
408
409impl MD013LineLength {
410 fn generate_paragraph_fixes(
412 &self,
413 ctx: &crate::lint_context::LintContext,
414 config: &MD013Config,
415 lines: &[&str],
416 ) -> Vec<LintWarning> {
417 let mut warnings = Vec::new();
418 let line_index = LineIndex::new(ctx.content.to_string());
419
420 let mut i = 0;
421 while i < lines.len() {
422 let line_num = i + 1;
423
424 let should_skip_due_to_line_info = ctx.line_info(line_num).is_some_and(|info| {
426 info.in_code_block || info.in_front_matter || info.in_html_block || info.in_html_comment
427 });
428
429 if should_skip_due_to_line_info
430 || (line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some())
431 || lines[i].trim().starts_with('#')
432 || TableUtils::is_potential_table_row(lines[i])
433 || lines[i].trim().is_empty()
434 || is_horizontal_rule(lines[i].trim())
435 {
436 i += 1;
437 continue;
438 }
439
440 let is_semantic_line = |content: &str| -> bool {
442 let trimmed = content.trim_start();
443 let semantic_markers = [
444 "NOTE:",
445 "WARNING:",
446 "IMPORTANT:",
447 "CAUTION:",
448 "TIP:",
449 "DANGER:",
450 "HINT:",
451 "INFO:",
452 ];
453 semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
454 };
455
456 let is_fence_marker = |content: &str| -> bool {
458 let trimmed = content.trim_start();
459 trimmed.starts_with("```") || trimmed.starts_with("~~~")
460 };
461
462 let trimmed = lines[i].trim();
464 if is_list_item(trimmed) {
465 let list_start = i;
467 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
468 let marker_len = marker.len();
469
470 #[derive(Clone)]
472 enum LineType {
473 Content(String),
474 CodeBlock(String, usize), NestedListItem(String, usize), SemanticLine(String), Empty,
478 }
479
480 let mut actual_indent: Option<usize> = None;
481 let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
482 i += 1;
483
484 while i < lines.len() {
486 let line_info = &ctx.lines[i];
487
488 if line_info.is_blank {
490 if i + 1 < lines.len() {
492 let next_info = &ctx.lines[i + 1];
493
494 if !next_info.is_blank && next_info.indent >= marker_len {
496 list_item_lines.push(LineType::Empty);
498 i += 1;
499 continue;
500 }
501 }
502 break;
504 }
505
506 let indent = line_info.indent;
508
509 if indent >= marker_len {
511 let trimmed = line_info.content.trim();
512
513 if line_info.in_code_block {
515 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
516 i += 1;
517 continue;
518 }
519
520 if is_list_item(trimmed) && indent < marker_len {
524 break;
526 }
527
528 if is_list_item(trimmed) && indent >= marker_len {
533 let has_blank_before = matches!(list_item_lines.last(), Some(LineType::Empty));
535
536 let has_nested_content = list_item_lines.iter().any(|line| {
538 matches!(line, LineType::Content(c) if is_list_item(c.trim()))
539 || matches!(line, LineType::NestedListItem(_, _))
540 });
541
542 if !has_blank_before && !has_nested_content {
543 break;
546 }
547 list_item_lines.push(LineType::NestedListItem(
550 line_info.content[indent..].to_string(),
551 indent,
552 ));
553 i += 1;
554 continue;
555 }
556
557 if indent <= marker_len + 3 {
559 if actual_indent.is_none() {
561 actual_indent = Some(indent);
562 }
563
564 let content = trim_preserving_hard_break(&line_info.content[indent..]);
568
569 if is_fence_marker(&content) {
572 list_item_lines.push(LineType::CodeBlock(content, indent));
573 }
574 else if is_semantic_line(&content) {
576 list_item_lines.push(LineType::SemanticLine(content));
577 } else {
578 list_item_lines.push(LineType::Content(content));
579 }
580 i += 1;
581 } else {
582 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
584 i += 1;
585 }
586 } else {
587 break;
589 }
590 }
591
592 let indent_size = actual_indent.unwrap_or(marker_len);
594 let expected_indent = " ".repeat(indent_size);
595
596 #[derive(Clone)]
598 enum Block {
599 Paragraph(Vec<String>),
600 Code {
601 lines: Vec<(String, usize)>, has_preceding_blank: bool, },
604 NestedList(Vec<(String, usize)>), SemanticLine(String), }
607
608 let mut blocks: Vec<Block> = Vec::new();
609 let mut current_paragraph: Vec<String> = Vec::new();
610 let mut current_code_block: Vec<(String, usize)> = Vec::new();
611 let mut current_nested_list: Vec<(String, usize)> = Vec::new();
612 let mut in_code = false;
613 let mut in_nested_list = false;
614 let mut had_preceding_blank = false; let mut code_block_has_preceding_blank = false; for line in &list_item_lines {
618 match line {
619 LineType::Empty => {
620 if in_code {
621 current_code_block.push((String::new(), 0));
622 } else if in_nested_list {
623 current_nested_list.push((String::new(), 0));
624 } else if !current_paragraph.is_empty() {
625 blocks.push(Block::Paragraph(current_paragraph.clone()));
626 current_paragraph.clear();
627 }
628 had_preceding_blank = true;
630 }
631 LineType::Content(content) => {
632 if in_code {
633 blocks.push(Block::Code {
635 lines: current_code_block.clone(),
636 has_preceding_blank: code_block_has_preceding_blank,
637 });
638 current_code_block.clear();
639 in_code = false;
640 } else if in_nested_list {
641 blocks.push(Block::NestedList(current_nested_list.clone()));
643 current_nested_list.clear();
644 in_nested_list = false;
645 }
646 current_paragraph.push(content.clone());
647 had_preceding_blank = false; }
649 LineType::CodeBlock(content, indent) => {
650 if in_nested_list {
651 blocks.push(Block::NestedList(current_nested_list.clone()));
653 current_nested_list.clear();
654 in_nested_list = false;
655 }
656 if !in_code {
657 if !current_paragraph.is_empty() {
659 blocks.push(Block::Paragraph(current_paragraph.clone()));
660 current_paragraph.clear();
661 }
662 in_code = true;
663 code_block_has_preceding_blank = had_preceding_blank;
665 }
666 current_code_block.push((content.clone(), *indent));
667 had_preceding_blank = false; }
669 LineType::NestedListItem(content, indent) => {
670 if in_code {
671 blocks.push(Block::Code {
673 lines: current_code_block.clone(),
674 has_preceding_blank: code_block_has_preceding_blank,
675 });
676 current_code_block.clear();
677 in_code = false;
678 }
679 if !in_nested_list {
680 if !current_paragraph.is_empty() {
682 blocks.push(Block::Paragraph(current_paragraph.clone()));
683 current_paragraph.clear();
684 }
685 in_nested_list = true;
686 }
687 current_nested_list.push((content.clone(), *indent));
688 had_preceding_blank = false; }
690 LineType::SemanticLine(content) => {
691 if in_code {
693 blocks.push(Block::Code {
694 lines: current_code_block.clone(),
695 has_preceding_blank: code_block_has_preceding_blank,
696 });
697 current_code_block.clear();
698 in_code = false;
699 } else if in_nested_list {
700 blocks.push(Block::NestedList(current_nested_list.clone()));
701 current_nested_list.clear();
702 in_nested_list = false;
703 } else if !current_paragraph.is_empty() {
704 blocks.push(Block::Paragraph(current_paragraph.clone()));
705 current_paragraph.clear();
706 }
707 blocks.push(Block::SemanticLine(content.clone()));
709 had_preceding_blank = false; }
711 }
712 }
713
714 if in_code && !current_code_block.is_empty() {
716 blocks.push(Block::Code {
717 lines: current_code_block,
718 has_preceding_blank: code_block_has_preceding_blank,
719 });
720 } else if in_nested_list && !current_nested_list.is_empty() {
721 blocks.push(Block::NestedList(current_nested_list));
722 } else if !current_paragraph.is_empty() {
723 blocks.push(Block::Paragraph(current_paragraph));
724 }
725
726 let content_lines: Vec<String> = list_item_lines
728 .iter()
729 .filter_map(|line| {
730 if let LineType::Content(s) = line {
731 Some(s.clone())
732 } else {
733 None
734 }
735 })
736 .collect();
737
738 let combined_content = content_lines.join(" ").trim().to_string();
741 let full_line = format!("{marker}{combined_content}");
742
743 let should_normalize = || {
745 let has_nested_lists = blocks.iter().any(|b| matches!(b, Block::NestedList(_)));
748 let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
749 let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
750 let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
751
752 if (has_nested_lists || has_code_blocks || has_semantic_lines) && !has_paragraphs {
754 return false;
755 }
756
757 if has_paragraphs {
759 let paragraph_count = blocks.iter().filter(|b| matches!(b, Block::Paragraph(_))).count();
760 if paragraph_count > 1 {
761 return true;
763 }
764
765 if content_lines.len() > 1 {
767 return true;
768 }
769 }
770
771 false
772 };
773
774 let needs_reflow = match config.reflow_mode {
775 ReflowMode::Normalize => {
776 let combined_length = self.calculate_effective_length(&full_line);
780 if combined_length > config.line_length {
781 true
782 } else {
783 should_normalize()
784 }
785 }
786 ReflowMode::SentencePerLine => {
787 let sentences = split_into_sentences(&combined_content);
789 sentences.len() > 1
790 }
791 ReflowMode::Default => {
792 self.calculate_effective_length(&full_line) > config.line_length
794 }
795 };
796
797 if needs_reflow {
798 let start_range = line_index.whole_line_range(list_start + 1);
799 let end_line = i - 1;
800 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
801 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
802 } else {
803 line_index.whole_line_range(end_line + 1)
804 };
805 let byte_range = start_range.start..end_range.end;
806
807 let reflow_options = crate::utils::text_reflow::ReflowOptions {
809 line_length: config.line_length - indent_size,
810 break_on_sentences: true,
811 preserve_breaks: false,
812 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
813 };
814
815 let mut result: Vec<String> = Vec::new();
816 let mut is_first_block = true;
817
818 for (block_idx, block) in blocks.iter().enumerate() {
819 match block {
820 Block::Paragraph(para_lines) => {
821 let segments = split_into_segments(para_lines);
824
825 for (segment_idx, segment) in segments.iter().enumerate() {
826 let hard_break_type = segment.last().and_then(|line| {
828 let line = line.strip_suffix('\r').unwrap_or(line);
829 if line.ends_with('\\') {
830 Some("\\")
831 } else if line.ends_with(" ") {
832 Some(" ")
833 } else {
834 None
835 }
836 });
837
838 let segment_for_reflow: Vec<String> = segment
840 .iter()
841 .map(|line| {
842 if line.ends_with('\\') {
844 line[..line.len() - 1].trim_end().to_string()
845 } else if line.ends_with(" ") {
846 line[..line.len() - 2].trim_end().to_string()
847 } else {
848 line.clone()
849 }
850 })
851 .collect();
852
853 let segment_text = segment_for_reflow.join(" ").trim().to_string();
854 if !segment_text.is_empty() {
855 let reflowed =
856 crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
857
858 if is_first_block && segment_idx == 0 {
859 result.push(format!("{marker}{}", reflowed[0]));
861 for line in reflowed.iter().skip(1) {
862 result.push(format!("{expected_indent}{line}"));
863 }
864 is_first_block = false;
865 } else {
866 for line in reflowed {
868 result.push(format!("{expected_indent}{line}"));
869 }
870 }
871
872 if let Some(break_marker) = hard_break_type
875 && let Some(last_line) = result.last_mut()
876 {
877 last_line.push_str(break_marker);
878 }
879 }
880 }
881
882 if block_idx < blocks.len() - 1 {
885 let next_block = &blocks[block_idx + 1];
886 let should_add_blank = match next_block {
887 Block::Code {
888 has_preceding_blank, ..
889 } => *has_preceding_blank,
890 _ => true, };
892 if should_add_blank {
893 result.push(String::new());
894 }
895 }
896 }
897 Block::Code {
898 lines: code_lines,
899 has_preceding_blank: _,
900 } => {
901 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
906 if is_first_block && idx == 0 {
907 result.push(format!(
909 "{marker}{}",
910 " ".repeat(orig_indent - marker_len) + content
911 ));
912 is_first_block = false;
913 } else if content.is_empty() {
914 result.push(String::new());
915 } else {
916 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
917 }
918 }
919 }
920 Block::NestedList(nested_items) => {
921 if !is_first_block {
923 result.push(String::new());
924 }
925
926 for (idx, (content, orig_indent)) in nested_items.iter().enumerate() {
927 if is_first_block && idx == 0 {
928 result.push(format!(
930 "{marker}{}",
931 " ".repeat(orig_indent - marker_len) + content
932 ));
933 is_first_block = false;
934 } else if content.is_empty() {
935 result.push(String::new());
936 } else {
937 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
938 }
939 }
940
941 if block_idx < blocks.len() - 1 {
944 let next_block = &blocks[block_idx + 1];
945 let should_add_blank = match next_block {
946 Block::Code {
947 has_preceding_blank, ..
948 } => *has_preceding_blank,
949 _ => true, };
951 if should_add_blank {
952 result.push(String::new());
953 }
954 }
955 }
956 Block::SemanticLine(content) => {
957 if !is_first_block {
960 result.push(String::new());
961 }
962
963 if is_first_block {
964 result.push(format!("{marker}{content}"));
966 is_first_block = false;
967 } else {
968 result.push(format!("{expected_indent}{content}"));
970 }
971
972 if block_idx < blocks.len() - 1 {
975 let next_block = &blocks[block_idx + 1];
976 let should_add_blank = match next_block {
977 Block::Code {
978 has_preceding_blank, ..
979 } => *has_preceding_blank,
980 _ => true, };
982 if should_add_blank {
983 result.push(String::new());
984 }
985 }
986 }
987 }
988 }
989
990 let reflowed_text = result.join("\n");
991
992 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
994 format!("{reflowed_text}\n")
995 } else {
996 reflowed_text
997 };
998
999 let original_text = &ctx.content[byte_range.clone()];
1001
1002 if original_text != replacement {
1004 let message = match config.reflow_mode {
1006 ReflowMode::SentencePerLine => "Line contains multiple sentences".to_string(),
1007 ReflowMode::Normalize => {
1008 let combined_length = self.calculate_effective_length(&full_line);
1009 if combined_length > config.line_length {
1010 format!(
1011 "Line length {} exceeds {} characters",
1012 combined_length, config.line_length
1013 )
1014 } else {
1015 "Multi-line content can be normalized".to_string()
1016 }
1017 }
1018 ReflowMode::Default => {
1019 let combined_length = self.calculate_effective_length(&full_line);
1020 format!(
1021 "Line length {} exceeds {} characters",
1022 combined_length, config.line_length
1023 )
1024 }
1025 };
1026
1027 warnings.push(LintWarning {
1028 rule_name: Some(self.name().to_string()),
1029 message,
1030 line: list_start + 1,
1031 column: 1,
1032 end_line: end_line + 1,
1033 end_column: lines[end_line].len() + 1,
1034 severity: Severity::Warning,
1035 fix: Some(crate::rule::Fix {
1036 range: byte_range,
1037 replacement,
1038 }),
1039 });
1040 }
1041 }
1042 continue;
1043 }
1044
1045 let paragraph_start = i;
1047 let mut paragraph_lines = vec![lines[i]];
1048 i += 1;
1049
1050 while i < lines.len() {
1051 let next_line = lines[i];
1052 let next_line_num = i + 1;
1053 let next_trimmed = next_line.trim();
1054
1055 if next_trimmed.is_empty()
1057 || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
1058 || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
1059 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
1060 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_comment)
1061 || (next_line_num > 0
1062 && next_line_num <= ctx.lines.len()
1063 && ctx.lines[next_line_num - 1].blockquote.is_some())
1064 || next_trimmed.starts_with('#')
1065 || TableUtils::is_potential_table_row(next_line)
1066 || is_list_item(next_trimmed)
1067 || is_horizontal_rule(next_trimmed)
1068 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
1069 {
1070 break;
1071 }
1072
1073 if i > 0 && has_hard_break(lines[i - 1]) {
1075 break;
1077 }
1078
1079 paragraph_lines.push(next_line);
1080 i += 1;
1081 }
1082
1083 let needs_reflow = match config.reflow_mode {
1085 ReflowMode::Normalize => {
1086 paragraph_lines.len() > 1
1088 }
1089 ReflowMode::SentencePerLine => {
1090 paragraph_lines.iter().any(|line| {
1092 let sentences = split_into_sentences(line);
1094 sentences.len() > 1
1095 })
1096 }
1097 ReflowMode::Default => {
1098 paragraph_lines
1100 .iter()
1101 .any(|line| self.calculate_effective_length(line) > config.line_length)
1102 }
1103 };
1104
1105 if needs_reflow {
1106 let start_range = line_index.whole_line_range(paragraph_start + 1);
1109 let end_line = paragraph_start + paragraph_lines.len() - 1;
1110
1111 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1113 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1115 } else {
1116 line_index.whole_line_range(end_line + 1)
1118 };
1119
1120 let byte_range = start_range.start..end_range.end;
1121
1122 let paragraph_text = paragraph_lines.join(" ");
1124
1125 let hard_break_type = paragraph_lines.last().and_then(|line| {
1127 let line = line.strip_suffix('\r').unwrap_or(line);
1128 if line.ends_with('\\') {
1129 Some("\\")
1130 } else if line.ends_with(" ") {
1131 Some(" ")
1132 } else {
1133 None
1134 }
1135 });
1136
1137 let reflow_options = crate::utils::text_reflow::ReflowOptions {
1139 line_length: config.line_length,
1140 break_on_sentences: true,
1141 preserve_breaks: false,
1142 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1143 };
1144 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
1145
1146 if let Some(break_marker) = hard_break_type
1149 && !reflowed.is_empty()
1150 {
1151 let last_idx = reflowed.len() - 1;
1152 if !has_hard_break(&reflowed[last_idx]) {
1153 reflowed[last_idx].push_str(break_marker);
1154 }
1155 }
1156
1157 let reflowed_text = reflowed.join("\n");
1158
1159 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1161 format!("{reflowed_text}\n")
1162 } else {
1163 reflowed_text
1164 };
1165
1166 let original_text = &ctx.content[byte_range.clone()];
1168
1169 if original_text != replacement {
1171 let (warning_line, warning_end_line) = match config.reflow_mode {
1176 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
1177 ReflowMode::SentencePerLine => {
1178 let mut violating_line = paragraph_start;
1180 for (idx, line) in paragraph_lines.iter().enumerate() {
1181 let sentences = split_into_sentences(line);
1182 if sentences.len() > 1 {
1183 violating_line = paragraph_start + idx;
1184 break;
1185 }
1186 }
1187 (violating_line + 1, violating_line + 1)
1188 }
1189 ReflowMode::Default => {
1190 let mut violating_line = paragraph_start;
1192 for (idx, line) in paragraph_lines.iter().enumerate() {
1193 if self.calculate_effective_length(line) > config.line_length {
1194 violating_line = paragraph_start + idx;
1195 break;
1196 }
1197 }
1198 (violating_line + 1, violating_line + 1)
1199 }
1200 };
1201
1202 warnings.push(LintWarning {
1203 rule_name: Some(self.name().to_string()),
1204 message: match config.reflow_mode {
1205 ReflowMode::Normalize => format!(
1206 "Paragraph could be normalized to use line length of {} characters",
1207 config.line_length
1208 ),
1209 ReflowMode::SentencePerLine => "Line contains multiple sentences".to_string(),
1210 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length),
1211 },
1212 line: warning_line,
1213 column: 1,
1214 end_line: warning_end_line,
1215 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
1216 severity: Severity::Warning,
1217 fix: Some(crate::rule::Fix {
1218 range: byte_range,
1219 replacement,
1220 }),
1221 });
1222 }
1223 }
1224 }
1225
1226 warnings
1227 }
1228
1229 fn calculate_effective_length(&self, line: &str) -> usize {
1231 if self.config.strict {
1232 return line.chars().count();
1234 }
1235
1236 let bytes = line.as_bytes();
1238 if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
1239 return line.chars().count();
1240 }
1241
1242 if !line.contains("http") && !line.contains('[') {
1244 return line.chars().count();
1245 }
1246
1247 let mut effective_line = line.to_string();
1248
1249 if line.contains('[') && line.contains("](") {
1252 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
1253 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
1254 && url.as_str().len() > 15
1255 {
1256 let replacement = format!("[{}](url)", text.as_str());
1257 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
1258 }
1259 }
1260 }
1261
1262 if effective_line.contains("http") {
1265 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
1266 let url = url_match.as_str();
1267 if !effective_line.contains(&format!("({url})")) {
1269 let placeholder = "x".repeat(15.min(url.len()));
1272 effective_line = effective_line.replacen(url, &placeholder, 1);
1273 }
1274 }
1275 }
1276
1277 effective_line.chars().count()
1278 }
1279}
1280
1281fn has_hard_break(line: &str) -> bool {
1287 let line = line.strip_suffix('\r').unwrap_or(line);
1288 line.ends_with(" ") || line.ends_with('\\')
1289}
1290
1291fn trim_preserving_hard_break(s: &str) -> String {
1298 let s = s.strip_suffix('\r').unwrap_or(s);
1300
1301 if s.ends_with('\\') {
1303 return s.to_string();
1305 }
1306
1307 if s.ends_with(" ") {
1309 let content_end = s.trim_end().len();
1311 if content_end == 0 {
1312 return String::new();
1314 }
1315 format!("{} ", &s[..content_end])
1317 } else {
1318 s.trim_end().to_string()
1320 }
1321}
1322
1323fn split_into_segments(para_lines: &[String]) -> Vec<Vec<String>> {
1334 let mut segments: Vec<Vec<String>> = Vec::new();
1335 let mut current_segment: Vec<String> = Vec::new();
1336
1337 for line in para_lines {
1338 current_segment.push(line.clone());
1339
1340 if has_hard_break(line) {
1342 segments.push(current_segment.clone());
1343 current_segment.clear();
1344 }
1345 }
1346
1347 if !current_segment.is_empty() {
1349 segments.push(current_segment);
1350 }
1351
1352 segments
1353}
1354
1355fn extract_list_marker_and_content(line: &str) -> (String, String) {
1356 let indent_len = line.len() - line.trim_start().len();
1358 let indent = &line[..indent_len];
1359 let trimmed = &line[indent_len..];
1360
1361 if let Some(rest) = trimmed.strip_prefix("- ") {
1364 return (format!("{indent}- "), trim_preserving_hard_break(rest));
1365 }
1366 if let Some(rest) = trimmed.strip_prefix("* ") {
1367 return (format!("{indent}* "), trim_preserving_hard_break(rest));
1368 }
1369 if let Some(rest) = trimmed.strip_prefix("+ ") {
1370 return (format!("{indent}+ "), trim_preserving_hard_break(rest));
1371 }
1372
1373 let mut chars = trimmed.chars();
1375 let mut marker_content = String::new();
1376
1377 while let Some(c) = chars.next() {
1378 marker_content.push(c);
1379 if c == '.' {
1380 if let Some(next) = chars.next()
1382 && next == ' '
1383 {
1384 marker_content.push(next);
1385 let content = trim_preserving_hard_break(chars.as_str());
1387 return (format!("{indent}{marker_content}"), content);
1388 }
1389 break;
1390 }
1391 }
1392
1393 (String::new(), line.to_string())
1395}
1396
1397fn is_horizontal_rule(line: &str) -> bool {
1399 if line.len() < 3 {
1400 return false;
1401 }
1402 let chars: Vec<char> = line.chars().collect();
1404 if chars.is_empty() {
1405 return false;
1406 }
1407 let first_char = chars[0];
1408 if first_char != '-' && first_char != '_' && first_char != '*' {
1409 return false;
1410 }
1411 for c in &chars {
1413 if *c != first_char && *c != ' ' {
1414 return false;
1415 }
1416 }
1417 chars.iter().filter(|c| **c == first_char).count() >= 3
1419}
1420
1421fn is_numbered_list_item(line: &str) -> bool {
1422 let mut chars = line.chars();
1423 if !chars.next().is_some_and(|c| c.is_numeric()) {
1425 return false;
1426 }
1427 while let Some(c) = chars.next() {
1429 if c == '.' {
1430 return chars.next().is_none_or(|c| c == ' ');
1432 }
1433 if !c.is_numeric() {
1434 return false;
1435 }
1436 }
1437 false
1438}
1439
1440fn is_list_item(line: &str) -> bool {
1441 if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
1443 && line.len() > 1
1444 && line.chars().nth(1) == Some(' ')
1445 {
1446 return true;
1447 }
1448 is_numbered_list_item(line)
1450}
1451
1452#[cfg(test)]
1453mod tests {
1454 use super::*;
1455 use crate::lint_context::LintContext;
1456
1457 #[test]
1458 fn test_default_config() {
1459 let rule = MD013LineLength::default();
1460 assert_eq!(rule.config.line_length, 80);
1461 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
1465 }
1466
1467 #[test]
1468 fn test_custom_config() {
1469 let rule = MD013LineLength::new(100, true, true, false, true);
1470 assert_eq!(rule.config.line_length, 100);
1471 assert!(rule.config.code_blocks);
1472 assert!(rule.config.tables);
1473 assert!(!rule.config.headings);
1474 assert!(rule.config.strict);
1475 }
1476
1477 #[test]
1478 fn test_basic_line_length_violation() {
1479 let rule = MD013LineLength::new(50, false, false, false, false);
1480 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1482 let result = rule.check(&ctx).unwrap();
1483
1484 assert_eq!(result.len(), 1);
1485 assert!(result[0].message.contains("Line length"));
1486 assert!(result[0].message.contains("exceeds 50 characters"));
1487 }
1488
1489 #[test]
1490 fn test_no_violation_under_limit() {
1491 let rule = MD013LineLength::new(100, false, false, false, false);
1492 let content = "Short line.\nAnother short line.";
1493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1494 let result = rule.check(&ctx).unwrap();
1495
1496 assert_eq!(result.len(), 0);
1497 }
1498
1499 #[test]
1500 fn test_multiple_violations() {
1501 let rule = MD013LineLength::new(30, false, false, false, false);
1502 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
1503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1504 let result = rule.check(&ctx).unwrap();
1505
1506 assert_eq!(result.len(), 2);
1507 assert_eq!(result[0].line, 1);
1508 assert_eq!(result[1].line, 2);
1509 }
1510
1511 #[test]
1512 fn test_code_blocks_exemption() {
1513 let rule = MD013LineLength::new(30, false, false, false, false);
1515 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
1516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1517 let result = rule.check(&ctx).unwrap();
1518
1519 assert_eq!(result.len(), 0);
1520 }
1521
1522 #[test]
1523 fn test_code_blocks_not_exempt_when_configured() {
1524 let rule = MD013LineLength::new(30, true, false, false, false);
1526 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1528 let result = rule.check(&ctx).unwrap();
1529
1530 assert!(!result.is_empty());
1531 }
1532
1533 #[test]
1534 fn test_heading_checked_when_enabled() {
1535 let rule = MD013LineLength::new(30, false, false, true, false);
1536 let content = "# This is a very long heading that would normally exceed the limit";
1537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1538 let result = rule.check(&ctx).unwrap();
1539
1540 assert_eq!(result.len(), 1);
1541 }
1542
1543 #[test]
1544 fn test_heading_exempt_when_disabled() {
1545 let rule = MD013LineLength::new(30, false, false, false, false);
1546 let content = "# This is a very long heading that should trigger a warning";
1547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1548 let result = rule.check(&ctx).unwrap();
1549
1550 assert_eq!(result.len(), 0);
1551 }
1552
1553 #[test]
1554 fn test_table_checked_when_enabled() {
1555 let rule = MD013LineLength::new(30, false, true, false, false);
1556 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
1557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1558 let result = rule.check(&ctx).unwrap();
1559
1560 assert_eq!(result.len(), 2); }
1562
1563 #[test]
1564 fn test_issue_78_tables_after_fenced_code_blocks() {
1565 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1568
1569```plain
1570some code block longer than 20 chars length
1571```
1572
1573this is a very long line
1574
1575| column A | column B |
1576| -------- | -------- |
1577| `var` | `val` |
1578| value 1 | value 2 |
1579
1580correct length line"#;
1581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1582 let result = rule.check(&ctx).unwrap();
1583
1584 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1586 assert_eq!(result[0].line, 7, "Should flag line 7");
1587 assert!(result[0].message.contains("24 exceeds 20"));
1588 }
1589
1590 #[test]
1591 fn test_issue_78_tables_with_inline_code() {
1592 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
1595| -------- | -------- |
1596| `var with very long name` | `val exceeding limit` |
1597| value 1 | value 2 |
1598
1599This line exceeds limit"#;
1600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1601 let result = rule.check(&ctx).unwrap();
1602
1603 assert_eq!(result.len(), 1, "Should only flag the non-table line");
1605 assert_eq!(result[0].line, 6, "Should flag line 6");
1606 }
1607
1608 #[test]
1609 fn test_issue_78_indented_code_blocks() {
1610 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1613
1614 some code block longer than 20 chars length
1615
1616this is a very long line
1617
1618| column A | column B |
1619| -------- | -------- |
1620| value 1 | value 2 |
1621
1622correct length line"#;
1623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1624 let result = rule.check(&ctx).unwrap();
1625
1626 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1628 assert_eq!(result[0].line, 5, "Should flag line 5");
1629 }
1630
1631 #[test]
1632 fn test_url_exemption() {
1633 let rule = MD013LineLength::new(30, false, false, false, false);
1634 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1636 let result = rule.check(&ctx).unwrap();
1637
1638 assert_eq!(result.len(), 0);
1639 }
1640
1641 #[test]
1642 fn test_image_reference_exemption() {
1643 let rule = MD013LineLength::new(30, false, false, false, false);
1644 let content = "![This is a very long image alt text that exceeds limit][reference]";
1645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1646 let result = rule.check(&ctx).unwrap();
1647
1648 assert_eq!(result.len(), 0);
1649 }
1650
1651 #[test]
1652 fn test_link_reference_exemption() {
1653 let rule = MD013LineLength::new(30, false, false, false, false);
1654 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1656 let result = rule.check(&ctx).unwrap();
1657
1658 assert_eq!(result.len(), 0);
1659 }
1660
1661 #[test]
1662 fn test_strict_mode() {
1663 let rule = MD013LineLength::new(30, false, false, false, true);
1664 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1666 let result = rule.check(&ctx).unwrap();
1667
1668 assert_eq!(result.len(), 1);
1670 }
1671
1672 #[test]
1673 fn test_blockquote_exemption() {
1674 let rule = MD013LineLength::new(30, false, false, false, false);
1675 let content = "> This is a very long line inside a blockquote that should be ignored.";
1676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1677 let result = rule.check(&ctx).unwrap();
1678
1679 assert_eq!(result.len(), 0);
1680 }
1681
1682 #[test]
1683 fn test_setext_heading_underline_exemption() {
1684 let rule = MD013LineLength::new(30, false, false, false, false);
1685 let content = "Heading\n========================================";
1686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1687 let result = rule.check(&ctx).unwrap();
1688
1689 assert_eq!(result.len(), 0);
1691 }
1692
1693 #[test]
1694 fn test_no_fix_without_reflow() {
1695 let rule = MD013LineLength::new(60, false, false, false, false);
1696 let content = "This line has trailing whitespace that makes it too long ";
1697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1698 let result = rule.check(&ctx).unwrap();
1699
1700 assert_eq!(result.len(), 1);
1701 assert!(result[0].fix.is_none());
1703
1704 let fixed = rule.fix(&ctx).unwrap();
1706 assert_eq!(fixed, content);
1707 }
1708
1709 #[test]
1710 fn test_character_vs_byte_counting() {
1711 let rule = MD013LineLength::new(10, false, false, false, false);
1712 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1715 let result = rule.check(&ctx).unwrap();
1716
1717 assert_eq!(result.len(), 1);
1718 assert_eq!(result[0].line, 1);
1719 }
1720
1721 #[test]
1722 fn test_empty_content() {
1723 let rule = MD013LineLength::default();
1724 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1725 let result = rule.check(&ctx).unwrap();
1726
1727 assert_eq!(result.len(), 0);
1728 }
1729
1730 #[test]
1731 fn test_excess_range_calculation() {
1732 let rule = MD013LineLength::new(10, false, false, false, false);
1733 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1735 let result = rule.check(&ctx).unwrap();
1736
1737 assert_eq!(result.len(), 1);
1738 assert_eq!(result[0].column, 11);
1740 assert_eq!(result[0].end_column, 21);
1741 }
1742
1743 #[test]
1744 fn test_html_block_exemption() {
1745 let rule = MD013LineLength::new(30, false, false, false, false);
1746 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1748 let result = rule.check(&ctx).unwrap();
1749
1750 assert_eq!(result.len(), 0);
1752 }
1753
1754 #[test]
1755 fn test_mixed_content() {
1756 let rule = MD013LineLength::new(30, false, false, false, false);
1758 let content = r#"# This heading is very long but should be exempt
1759
1760This regular paragraph line is too long and should trigger.
1761
1762```
1763Code block line that is very long but exempt.
1764```
1765
1766| Table | With very long content |
1767|-------|------------------------|
1768
1769Another long line that should trigger a warning."#;
1770
1771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1772 let result = rule.check(&ctx).unwrap();
1773
1774 assert_eq!(result.len(), 2);
1776 assert_eq!(result[0].line, 3);
1777 assert_eq!(result[1].line, 12);
1778 }
1779
1780 #[test]
1781 fn test_fix_without_reflow_preserves_content() {
1782 let rule = MD013LineLength::new(50, false, false, false, false);
1783 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
1784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1785
1786 let fixed = rule.fix(&ctx).unwrap();
1788 assert_eq!(fixed, content);
1789 }
1790
1791 #[test]
1792 fn test_content_detection() {
1793 let rule = MD013LineLength::default();
1794
1795 let long_line = "a".repeat(100);
1797 let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
1798 assert!(!rule.should_skip(&ctx)); let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1801 assert!(rule.should_skip(&empty_ctx)); }
1803
1804 #[test]
1805 fn test_rule_metadata() {
1806 let rule = MD013LineLength::default();
1807 assert_eq!(rule.name(), "MD013");
1808 assert_eq!(rule.description(), "Line length should not be excessive");
1809 assert_eq!(rule.category(), RuleCategory::Whitespace);
1810 }
1811
1812 #[test]
1813 fn test_url_embedded_in_text() {
1814 let rule = MD013LineLength::new(50, false, false, false, false);
1815
1816 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1819 let result = rule.check(&ctx).unwrap();
1820
1821 assert_eq!(result.len(), 0);
1823 }
1824
1825 #[test]
1826 fn test_multiple_urls_in_line() {
1827 let rule = MD013LineLength::new(50, false, false, false, false);
1828
1829 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1832
1833 let result = rule.check(&ctx).unwrap();
1834
1835 assert_eq!(result.len(), 0);
1837 }
1838
1839 #[test]
1840 fn test_markdown_link_with_long_url() {
1841 let rule = MD013LineLength::new(50, false, false, false, false);
1842
1843 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1846 let result = rule.check(&ctx).unwrap();
1847
1848 assert_eq!(result.len(), 0);
1850 }
1851
1852 #[test]
1853 fn test_line_too_long_even_without_urls() {
1854 let rule = MD013LineLength::new(50, false, false, false, false);
1855
1856 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1859 let result = rule.check(&ctx).unwrap();
1860
1861 assert_eq!(result.len(), 1);
1863 }
1864
1865 #[test]
1866 fn test_strict_mode_counts_urls() {
1867 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";
1871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1872 let result = rule.check(&ctx).unwrap();
1873
1874 assert_eq!(result.len(), 1);
1876 }
1877
1878 #[test]
1879 fn test_documentation_example_from_md051() {
1880 let rule = MD013LineLength::new(80, false, false, false, false);
1881
1882 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1885 let result = rule.check(&ctx).unwrap();
1886
1887 assert_eq!(result.len(), 0);
1889 }
1890
1891 #[test]
1892 fn test_text_reflow_simple() {
1893 let config = MD013Config {
1894 line_length: 30,
1895 reflow: true,
1896 ..Default::default()
1897 };
1898 let rule = MD013LineLength::from_config_struct(config);
1899
1900 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1902
1903 let fixed = rule.fix(&ctx).unwrap();
1904
1905 for line in fixed.lines() {
1907 assert!(
1908 line.chars().count() <= 30,
1909 "Line too long: {} (len={})",
1910 line,
1911 line.chars().count()
1912 );
1913 }
1914
1915 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1917 let original_words: Vec<&str> = content.split_whitespace().collect();
1918 assert_eq!(fixed_words, original_words);
1919 }
1920
1921 #[test]
1922 fn test_text_reflow_preserves_markdown_elements() {
1923 let config = MD013Config {
1924 line_length: 40,
1925 reflow: true,
1926 ..Default::default()
1927 };
1928 let rule = MD013LineLength::from_config_struct(config);
1929
1930 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1932
1933 let fixed = rule.fix(&ctx).unwrap();
1934
1935 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1937 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1938 assert!(
1939 fixed.contains("[a link](https://example.com)"),
1940 "Link not preserved in: {fixed}"
1941 );
1942
1943 for line in fixed.lines() {
1945 assert!(line.len() <= 40, "Line too long: {line}");
1946 }
1947 }
1948
1949 #[test]
1950 fn test_text_reflow_preserves_code_blocks() {
1951 let config = MD013Config {
1952 line_length: 30,
1953 reflow: true,
1954 ..Default::default()
1955 };
1956 let rule = MD013LineLength::from_config_struct(config);
1957
1958 let content = r#"Here is some text.
1959
1960```python
1961def very_long_function_name_that_exceeds_limit():
1962 return "This should not be wrapped"
1963```
1964
1965More text after code block."#;
1966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1967
1968 let fixed = rule.fix(&ctx).unwrap();
1969
1970 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1972 assert!(fixed.contains("```python"));
1973 assert!(fixed.contains("```"));
1974 }
1975
1976 #[test]
1977 fn test_text_reflow_preserves_lists() {
1978 let config = MD013Config {
1979 line_length: 30,
1980 reflow: true,
1981 ..Default::default()
1982 };
1983 let rule = MD013LineLength::from_config_struct(config);
1984
1985 let content = r#"Here is a list:
1986
19871. First item with a very long line that needs wrapping
19882. Second item is short
19893. Third item also has a long line that exceeds the limit
1990
1991And a bullet list:
1992
1993- Bullet item with very long content that needs wrapping
1994- Short bullet"#;
1995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1996
1997 let fixed = rule.fix(&ctx).unwrap();
1998
1999 assert!(fixed.contains("1. "));
2001 assert!(fixed.contains("2. "));
2002 assert!(fixed.contains("3. "));
2003 assert!(fixed.contains("- "));
2004
2005 let lines: Vec<&str> = fixed.lines().collect();
2007 for (i, line) in lines.iter().enumerate() {
2008 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
2009 if i + 1 < lines.len()
2011 && !lines[i + 1].trim().is_empty()
2012 && !lines[i + 1].trim().starts_with(char::is_numeric)
2013 && !lines[i + 1].trim().starts_with("-")
2014 {
2015 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
2017 }
2018 } else if line.trim().starts_with("-") {
2019 if i + 1 < lines.len()
2021 && !lines[i + 1].trim().is_empty()
2022 && !lines[i + 1].trim().starts_with(char::is_numeric)
2023 && !lines[i + 1].trim().starts_with("-")
2024 {
2025 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
2027 }
2028 }
2029 }
2030 }
2031
2032 #[test]
2033 fn test_issue_83_numbered_list_with_backticks() {
2034 let config = MD013Config {
2036 line_length: 100,
2037 reflow: true,
2038 ..Default::default()
2039 };
2040 let rule = MD013LineLength::from_config_struct(config);
2041
2042 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
2044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2045
2046 let fixed = rule.fix(&ctx).unwrap();
2047
2048 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
2051
2052 assert_eq!(
2053 fixed, expected,
2054 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
2055 );
2056 }
2057
2058 #[test]
2059 fn test_text_reflow_disabled_by_default() {
2060 let rule = MD013LineLength::new(30, false, false, false, false);
2061
2062 let content = "This is a very long line that definitely exceeds thirty characters.";
2063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2064
2065 let fixed = rule.fix(&ctx).unwrap();
2066
2067 assert_eq!(fixed, content);
2070 }
2071
2072 #[test]
2073 fn test_reflow_with_hard_line_breaks() {
2074 let config = MD013Config {
2076 line_length: 40,
2077 reflow: true,
2078 ..Default::default()
2079 };
2080 let rule = MD013LineLength::from_config_struct(config);
2081
2082 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";
2084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2085 let fixed = rule.fix(&ctx).unwrap();
2086
2087 assert!(
2089 fixed.contains(" \n"),
2090 "Hard line break with exactly 2 spaces should be preserved"
2091 );
2092 }
2093
2094 #[test]
2095 fn test_reflow_preserves_reference_links() {
2096 let config = MD013Config {
2097 line_length: 40,
2098 reflow: true,
2099 ..Default::default()
2100 };
2101 let rule = MD013LineLength::from_config_struct(config);
2102
2103 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
2104
2105[ref]: https://example.com";
2106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2107 let fixed = rule.fix(&ctx).unwrap();
2108
2109 assert!(fixed.contains("[reference link][ref]"));
2111 assert!(!fixed.contains("[ reference link]"));
2112 assert!(!fixed.contains("[ref ]"));
2113 }
2114
2115 #[test]
2116 fn test_reflow_with_nested_markdown_elements() {
2117 let config = MD013Config {
2118 line_length: 35,
2119 reflow: true,
2120 ..Default::default()
2121 };
2122 let rule = MD013LineLength::from_config_struct(config);
2123
2124 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
2125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2126 let fixed = rule.fix(&ctx).unwrap();
2127
2128 assert!(fixed.contains("**bold with `code` inside**"));
2130 }
2131
2132 #[test]
2133 fn test_reflow_with_unbalanced_markdown() {
2134 let config = MD013Config {
2136 line_length: 30,
2137 reflow: true,
2138 ..Default::default()
2139 };
2140 let rule = MD013LineLength::from_config_struct(config);
2141
2142 let content = "This has **unbalanced bold that goes on for a very long time without closing";
2143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2144 let fixed = rule.fix(&ctx).unwrap();
2145
2146 assert!(!fixed.is_empty());
2150 for line in fixed.lines() {
2152 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
2153 }
2154 }
2155
2156 #[test]
2157 fn test_reflow_fix_indicator() {
2158 let config = MD013Config {
2160 line_length: 30,
2161 reflow: true,
2162 ..Default::default()
2163 };
2164 let rule = MD013LineLength::from_config_struct(config);
2165
2166 let content = "This is a very long line that definitely exceeds the thirty character limit";
2167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2168 let warnings = rule.check(&ctx).unwrap();
2169
2170 assert!(!warnings.is_empty());
2172 assert!(
2173 warnings[0].fix.is_some(),
2174 "Should provide fix indicator when reflow is true"
2175 );
2176 }
2177
2178 #[test]
2179 fn test_no_fix_indicator_without_reflow() {
2180 let config = MD013Config {
2182 line_length: 30,
2183 reflow: false,
2184 ..Default::default()
2185 };
2186 let rule = MD013LineLength::from_config_struct(config);
2187
2188 let content = "This is a very long line that definitely exceeds the thirty character limit";
2189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2190 let warnings = rule.check(&ctx).unwrap();
2191
2192 assert!(!warnings.is_empty());
2194 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
2195 }
2196
2197 #[test]
2198 fn test_reflow_preserves_all_reference_link_types() {
2199 let config = MD013Config {
2200 line_length: 40,
2201 reflow: true,
2202 ..Default::default()
2203 };
2204 let rule = MD013LineLength::from_config_struct(config);
2205
2206 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
2207
2208[ref]: https://example.com
2209[collapsed]: https://example.com
2210[shortcut]: https://example.com";
2211
2212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2213 let fixed = rule.fix(&ctx).unwrap();
2214
2215 assert!(fixed.contains("[full reference][ref]"));
2217 assert!(fixed.contains("[collapsed][]"));
2218 assert!(fixed.contains("[shortcut]"));
2219 }
2220
2221 #[test]
2222 fn test_reflow_handles_images_correctly() {
2223 let config = MD013Config {
2224 line_length: 40,
2225 reflow: true,
2226 ..Default::default()
2227 };
2228 let rule = MD013LineLength::from_config_struct(config);
2229
2230 let content = "This line has an  that should not be broken when reflowing.";
2231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2232 let fixed = rule.fix(&ctx).unwrap();
2233
2234 assert!(fixed.contains(""));
2236 }
2237
2238 #[test]
2239 fn test_normalize_mode_flags_short_lines() {
2240 let config = MD013Config {
2241 line_length: 100,
2242 reflow: true,
2243 reflow_mode: ReflowMode::Normalize,
2244 ..Default::default()
2245 };
2246 let rule = MD013LineLength::from_config_struct(config);
2247
2248 let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
2250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2251 let warnings = rule.check(&ctx).unwrap();
2252
2253 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2255 assert!(warnings[0].message.contains("normalized"));
2256 }
2257
2258 #[test]
2259 fn test_normalize_mode_combines_short_lines() {
2260 let config = MD013Config {
2261 line_length: 100,
2262 reflow: true,
2263 reflow_mode: ReflowMode::Normalize,
2264 ..Default::default()
2265 };
2266 let rule = MD013LineLength::from_config_struct(config);
2267
2268 let content =
2270 "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
2271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2272 let fixed = rule.fix(&ctx).unwrap();
2273
2274 let lines: Vec<&str> = fixed.lines().collect();
2276 assert_eq!(lines.len(), 1, "Should combine into single line");
2277 assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
2278 }
2279
2280 #[test]
2281 fn test_normalize_mode_preserves_paragraph_breaks() {
2282 let config = MD013Config {
2283 line_length: 100,
2284 reflow: true,
2285 reflow_mode: ReflowMode::Normalize,
2286 ..Default::default()
2287 };
2288 let rule = MD013LineLength::from_config_struct(config);
2289
2290 let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
2291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2292 let fixed = rule.fix(&ctx).unwrap();
2293
2294 assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
2296
2297 let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
2298 assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
2299 }
2300
2301 #[test]
2302 fn test_default_mode_only_fixes_violations() {
2303 let config = MD013Config {
2304 line_length: 100,
2305 reflow: true,
2306 reflow_mode: ReflowMode::Default, ..Default::default()
2308 };
2309 let rule = MD013LineLength::from_config_struct(config);
2310
2311 let content = "This is a short line.\nAnother short line.\nA third short line.";
2313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2314 let warnings = rule.check(&ctx).unwrap();
2315
2316 assert!(warnings.is_empty(), "Should not flag short lines in default mode");
2318
2319 let fixed = rule.fix(&ctx).unwrap();
2321 assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
2322 }
2323
2324 #[test]
2325 fn test_normalize_mode_with_lists() {
2326 let config = MD013Config {
2327 line_length: 80,
2328 reflow: true,
2329 reflow_mode: ReflowMode::Normalize,
2330 ..Default::default()
2331 };
2332 let rule = MD013LineLength::from_config_struct(config);
2333
2334 let content = r#"A paragraph with
2335short lines.
2336
23371. List item with
2338 short lines
23392. Another item"#;
2340 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2341 let fixed = rule.fix(&ctx).unwrap();
2342
2343 let lines: Vec<&str> = fixed.lines().collect();
2345 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2346 assert!(fixed.contains("1. "), "Should preserve list markers");
2347 assert!(fixed.contains("2. "), "Should preserve list markers");
2348 }
2349
2350 #[test]
2351 fn test_normalize_mode_with_code_blocks() {
2352 let config = MD013Config {
2353 line_length: 100,
2354 reflow: true,
2355 reflow_mode: ReflowMode::Normalize,
2356 ..Default::default()
2357 };
2358 let rule = MD013LineLength::from_config_struct(config);
2359
2360 let content = r#"A paragraph with
2361short lines.
2362
2363```
2364code block should not be normalized
2365even with short lines
2366```
2367
2368Another paragraph with
2369short lines."#;
2370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2371 let fixed = rule.fix(&ctx).unwrap();
2372
2373 assert!(fixed.contains("code block should not be normalized\neven with short lines"));
2375 let lines: Vec<&str> = fixed.lines().collect();
2377 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2378 }
2379
2380 #[test]
2381 fn test_issue_76_use_case() {
2382 let config = MD013Config {
2384 line_length: 999999, reflow: true,
2386 reflow_mode: ReflowMode::Normalize,
2387 ..Default::default()
2388 };
2389 let rule = MD013LineLength::from_config_struct(config);
2390
2391 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.";
2393
2394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2395
2396 let warnings = rule.check(&ctx).unwrap();
2398 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2399
2400 let fixed = rule.fix(&ctx).unwrap();
2402 let lines: Vec<&str> = fixed.lines().collect();
2403 assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
2404 assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
2405 }
2406
2407 #[test]
2408 fn test_normalize_mode_single_line_unchanged() {
2409 let config = MD013Config {
2411 line_length: 100,
2412 reflow: true,
2413 reflow_mode: ReflowMode::Normalize,
2414 ..Default::default()
2415 };
2416 let rule = MD013LineLength::from_config_struct(config);
2417
2418 let content = "This is a single line that should not be changed.";
2419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2420
2421 let warnings = rule.check(&ctx).unwrap();
2422 assert!(warnings.is_empty(), "Single line should not be flagged");
2423
2424 let fixed = rule.fix(&ctx).unwrap();
2425 assert_eq!(fixed, content, "Single line should remain unchanged");
2426 }
2427
2428 #[test]
2429 fn test_normalize_mode_with_inline_code() {
2430 let config = MD013Config {
2431 line_length: 80,
2432 reflow: true,
2433 reflow_mode: ReflowMode::Normalize,
2434 ..Default::default()
2435 };
2436 let rule = MD013LineLength::from_config_struct(config);
2437
2438 let content =
2439 "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
2440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2441
2442 let warnings = rule.check(&ctx).unwrap();
2443 assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
2444
2445 let fixed = rule.fix(&ctx).unwrap();
2446 assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
2447 assert!(fixed.lines().count() < 3, "Lines should be combined");
2448 }
2449
2450 #[test]
2451 fn test_normalize_mode_with_emphasis() {
2452 let config = MD013Config {
2453 line_length: 100,
2454 reflow: true,
2455 reflow_mode: ReflowMode::Normalize,
2456 ..Default::default()
2457 };
2458 let rule = MD013LineLength::from_config_struct(config);
2459
2460 let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
2461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2462
2463 let fixed = rule.fix(&ctx).unwrap();
2464 assert!(fixed.contains("**bold**"), "Bold should be preserved");
2465 assert!(fixed.contains("*italic*"), "Italic should be preserved");
2466 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2467 }
2468
2469 #[test]
2470 fn test_normalize_mode_respects_hard_breaks() {
2471 let config = MD013Config {
2472 line_length: 100,
2473 reflow: true,
2474 reflow_mode: ReflowMode::Normalize,
2475 ..Default::default()
2476 };
2477 let rule = MD013LineLength::from_config_struct(config);
2478
2479 let content = "First line with hard break \nSecond line after break\nThird line";
2481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2482
2483 let fixed = rule.fix(&ctx).unwrap();
2484 assert!(fixed.contains(" \n"), "Hard break should be preserved");
2486 assert!(
2488 fixed.contains("Second line after break Third line"),
2489 "Lines without hard break should combine"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_normalize_mode_with_links() {
2495 let config = MD013Config {
2496 line_length: 100,
2497 reflow: true,
2498 reflow_mode: ReflowMode::Normalize,
2499 ..Default::default()
2500 };
2501 let rule = MD013LineLength::from_config_struct(config);
2502
2503 let content =
2504 "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
2505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2506
2507 let fixed = rule.fix(&ctx).unwrap();
2508 assert!(
2509 fixed.contains("[link](https://example.com)"),
2510 "Link should be preserved"
2511 );
2512 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2513 }
2514
2515 #[test]
2516 fn test_normalize_mode_empty_lines_between_paragraphs() {
2517 let config = MD013Config {
2518 line_length: 100,
2519 reflow: true,
2520 reflow_mode: ReflowMode::Normalize,
2521 ..Default::default()
2522 };
2523 let rule = MD013LineLength::from_config_struct(config);
2524
2525 let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
2526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2527
2528 let fixed = rule.fix(&ctx).unwrap();
2529 assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
2531 let parts: Vec<&str> = fixed.split("\n\n\n").collect();
2533 assert_eq!(parts.len(), 2, "Should have two parts");
2534 assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
2535 assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
2536 }
2537
2538 #[test]
2539 fn test_normalize_mode_mixed_list_types() {
2540 let config = MD013Config {
2541 line_length: 80,
2542 reflow: true,
2543 reflow_mode: ReflowMode::Normalize,
2544 ..Default::default()
2545 };
2546 let rule = MD013LineLength::from_config_struct(config);
2547
2548 let content = r#"Paragraph before list
2549with multiple lines.
2550
2551- Bullet item
2552* Another bullet
2553+ Plus bullet
2554
25551. Numbered item
25562. Another number
2557
2558Paragraph after list
2559with multiple lines."#;
2560
2561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2562 let fixed = rule.fix(&ctx).unwrap();
2563
2564 assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
2566 assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
2567 assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
2568 assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
2569
2570 assert!(
2572 fixed.starts_with("Paragraph before list with multiple lines."),
2573 "First paragraph should be normalized"
2574 );
2575 assert!(
2576 fixed.ends_with("Paragraph after list with multiple lines."),
2577 "Last paragraph should be normalized"
2578 );
2579 }
2580
2581 #[test]
2582 fn test_normalize_mode_with_horizontal_rules() {
2583 let config = MD013Config {
2584 line_length: 100,
2585 reflow: true,
2586 reflow_mode: ReflowMode::Normalize,
2587 ..Default::default()
2588 };
2589 let rule = MD013LineLength::from_config_struct(config);
2590
2591 let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
2592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2593
2594 let fixed = rule.fix(&ctx).unwrap();
2595 assert!(fixed.contains("---"), "Horizontal rule should be preserved");
2596 assert!(
2597 fixed.contains("Paragraph before horizontal rule."),
2598 "First paragraph normalized"
2599 );
2600 assert!(
2601 fixed.contains("Paragraph after horizontal rule."),
2602 "Second paragraph normalized"
2603 );
2604 }
2605
2606 #[test]
2607 fn test_normalize_mode_with_indented_code() {
2608 let config = MD013Config {
2609 line_length: 100,
2610 reflow: true,
2611 reflow_mode: ReflowMode::Normalize,
2612 ..Default::default()
2613 };
2614 let rule = MD013LineLength::from_config_struct(config);
2615
2616 let content = "Paragraph before\nindented code.\n\n This is indented code\n Should not be normalized\n\nParagraph after\nindented code.";
2617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2618
2619 let fixed = rule.fix(&ctx).unwrap();
2620 assert!(
2621 fixed.contains(" This is indented code\n Should not be normalized"),
2622 "Indented code preserved"
2623 );
2624 assert!(
2625 fixed.contains("Paragraph before indented code."),
2626 "First paragraph normalized"
2627 );
2628 assert!(
2629 fixed.contains("Paragraph after indented code."),
2630 "Second paragraph normalized"
2631 );
2632 }
2633
2634 #[test]
2635 fn test_normalize_mode_disabled_without_reflow() {
2636 let config = MD013Config {
2638 line_length: 100,
2639 reflow: false, reflow_mode: ReflowMode::Normalize,
2641 ..Default::default()
2642 };
2643 let rule = MD013LineLength::from_config_struct(config);
2644
2645 let content = "This is a line\nwith breaks that\nshould not be changed.";
2646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2647
2648 let warnings = rule.check(&ctx).unwrap();
2649 assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2650
2651 let fixed = rule.fix(&ctx).unwrap();
2652 assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2653 }
2654
2655 #[test]
2656 fn test_default_mode_with_long_lines() {
2657 let config = MD013Config {
2660 line_length: 50,
2661 reflow: true,
2662 reflow_mode: ReflowMode::Default,
2663 ..Default::default()
2664 };
2665 let rule = MD013LineLength::from_config_struct(config);
2666
2667 let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2669
2670 let warnings = rule.check(&ctx).unwrap();
2671 assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2672 assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2674
2675 let fixed = rule.fix(&ctx).unwrap();
2676 assert!(
2678 fixed.contains("Short line. This is"),
2679 "Should combine and reflow the paragraph"
2680 );
2681 assert!(
2682 fixed.contains("wrapping. Another short"),
2683 "Should include all paragraph content"
2684 );
2685 }
2686
2687 #[test]
2688 fn test_normalize_vs_default_mode_same_content() {
2689 let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2691
2692 let default_config = MD013Config {
2694 line_length: 100,
2695 reflow: true,
2696 reflow_mode: ReflowMode::Default,
2697 ..Default::default()
2698 };
2699 let default_rule = MD013LineLength::from_config_struct(default_config);
2700 let default_warnings = default_rule.check(&ctx).unwrap();
2701 let default_fixed = default_rule.fix(&ctx).unwrap();
2702
2703 let normalize_config = MD013Config {
2705 line_length: 100,
2706 reflow: true,
2707 reflow_mode: ReflowMode::Normalize,
2708 ..Default::default()
2709 };
2710 let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2711 let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2712 let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2713
2714 assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2716 assert!(
2717 !normalize_warnings.is_empty(),
2718 "Normalize mode should flag multi-line paragraphs"
2719 );
2720
2721 assert_eq!(
2722 default_fixed, content,
2723 "Default mode should not change content without violations"
2724 );
2725 assert_ne!(
2726 normalize_fixed, content,
2727 "Normalize mode should change multi-line paragraphs"
2728 );
2729 assert_eq!(
2730 normalize_fixed.lines().count(),
2731 1,
2732 "Normalize should combine into single line"
2733 );
2734 }
2735
2736 #[test]
2737 fn test_normalize_mode_with_reference_definitions() {
2738 let config = MD013Config {
2739 line_length: 100,
2740 reflow: true,
2741 reflow_mode: ReflowMode::Normalize,
2742 ..Default::default()
2743 };
2744 let rule = MD013LineLength::from_config_struct(config);
2745
2746 let content =
2747 "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2749
2750 let fixed = rule.fix(&ctx).unwrap();
2751 assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2752 assert!(
2753 fixed.contains("[ref]: https://example.com"),
2754 "Reference definition should be preserved"
2755 );
2756 assert!(
2757 fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2758 "Paragraph should be normalized"
2759 );
2760 }
2761
2762 #[test]
2763 fn test_normalize_mode_with_html_comments() {
2764 let config = MD013Config {
2765 line_length: 100,
2766 reflow: true,
2767 reflow_mode: ReflowMode::Normalize,
2768 ..Default::default()
2769 };
2770 let rule = MD013LineLength::from_config_struct(config);
2771
2772 let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2774
2775 let fixed = rule.fix(&ctx).unwrap();
2776 assert!(
2777 fixed.contains("<!-- This is a comment -->"),
2778 "HTML comment should be preserved"
2779 );
2780 assert!(
2781 fixed.contains("Paragraph before HTML comment."),
2782 "First paragraph normalized"
2783 );
2784 assert!(
2785 fixed.contains("Paragraph after HTML comment."),
2786 "Second paragraph normalized"
2787 );
2788 }
2789
2790 #[test]
2791 fn test_normalize_mode_line_starting_with_number() {
2792 let config = MD013Config {
2794 line_length: 100,
2795 reflow: true,
2796 reflow_mode: ReflowMode::Normalize,
2797 ..Default::default()
2798 };
2799 let rule = MD013LineLength::from_config_struct(config);
2800
2801 let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2803
2804 let fixed = rule.fix(&ctx).unwrap();
2805 assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2806 assert!(
2807 fixed.contains("80 characters"),
2808 "Number at start of line should be preserved"
2809 );
2810 }
2811
2812 #[test]
2813 fn test_default_mode_preserves_list_structure() {
2814 let config = MD013Config {
2816 line_length: 80,
2817 reflow: true,
2818 reflow_mode: ReflowMode::Default,
2819 ..Default::default()
2820 };
2821 let rule = MD013LineLength::from_config_struct(config);
2822
2823 let content = r#"- This is a bullet point that has
2824 some text on multiple lines
2825 that should stay separate
2826
28271. Numbered list item with
2828 multiple lines that should
2829 also stay separate"#;
2830
2831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2832 let fixed = rule.fix(&ctx).unwrap();
2833
2834 let lines: Vec<&str> = fixed.lines().collect();
2836 assert_eq!(
2837 lines[0], "- This is a bullet point that has",
2838 "First line should be unchanged"
2839 );
2840 assert_eq!(
2841 lines[1], " some text on multiple lines",
2842 "Continuation should be preserved"
2843 );
2844 assert_eq!(
2845 lines[2], " that should stay separate",
2846 "Second continuation should be preserved"
2847 );
2848 }
2849
2850 #[test]
2851 fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2852 let config = MD013Config {
2854 line_length: 80,
2855 reflow: true,
2856 reflow_mode: ReflowMode::Normalize,
2857 ..Default::default()
2858 };
2859 let rule = MD013LineLength::from_config_struct(config);
2860
2861 let content = r#"- This is a bullet point that has
2862 some text on multiple lines
2863 that should be combined
2864
28651. Numbered list item with
2866 multiple lines that need
2867 to be properly combined
28682. Second item"#;
2869
2870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2871 let fixed = rule.fix(&ctx).unwrap();
2872
2873 assert!(
2875 !fixed.contains("lines that"),
2876 "Should not have double spaces in bullet list"
2877 );
2878 assert!(
2879 !fixed.contains("need to"),
2880 "Should not have double spaces in numbered list"
2881 );
2882
2883 assert!(
2885 fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2886 "Bullet list should be properly combined"
2887 );
2888 assert!(
2889 fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2890 "Numbered list should be properly combined"
2891 );
2892 }
2893
2894 #[test]
2895 fn test_normalize_mode_actual_numbered_list() {
2896 let config = MD013Config {
2898 line_length: 100,
2899 reflow: true,
2900 reflow_mode: ReflowMode::Normalize,
2901 ..Default::default()
2902 };
2903 let rule = MD013LineLength::from_config_struct(config);
2904
2905 let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2907
2908 let fixed = rule.fix(&ctx).unwrap();
2909 assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2910 assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2911 assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2912 assert!(
2913 fixed.starts_with("Paragraph before list with multiple lines."),
2914 "Paragraph should be normalized"
2915 );
2916 }
2917
2918 #[test]
2919 fn test_sentence_per_line_detection() {
2920 let config = MD013Config {
2921 reflow: true,
2922 reflow_mode: ReflowMode::SentencePerLine,
2923 ..Default::default()
2924 };
2925 let rule = MD013LineLength::from_config_struct(config.clone());
2926
2927 let content = "This is sentence one. This is sentence two. And sentence three!";
2929 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2930
2931 assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2933
2934 let result = rule.check(&ctx).unwrap();
2935
2936 assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2937 assert_eq!(result[0].message, "Line contains multiple sentences");
2938 }
2939
2940 #[test]
2941 fn test_sentence_per_line_fix() {
2942 let config = MD013Config {
2943 reflow: true,
2944 reflow_mode: ReflowMode::SentencePerLine,
2945 ..Default::default()
2946 };
2947 let rule = MD013LineLength::from_config_struct(config);
2948
2949 let content = "First sentence. Second sentence.";
2950 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2951 let result = rule.check(&ctx).unwrap();
2952
2953 assert!(!result.is_empty(), "Should detect violation");
2954 assert!(result[0].fix.is_some(), "Should provide a fix");
2955
2956 let fix = result[0].fix.as_ref().unwrap();
2957 assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2958 }
2959
2960 #[test]
2961 fn test_sentence_per_line_abbreviations() {
2962 let config = MD013Config {
2963 reflow: true,
2964 reflow_mode: ReflowMode::SentencePerLine,
2965 ..Default::default()
2966 };
2967 let rule = MD013LineLength::from_config_struct(config);
2968
2969 let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2971 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2972 let result = rule.check(&ctx).unwrap();
2973
2974 assert!(
2975 result.is_empty(),
2976 "Should not detect abbreviations as sentence boundaries"
2977 );
2978 }
2979
2980 #[test]
2981 fn test_sentence_per_line_with_markdown() {
2982 let config = MD013Config {
2983 reflow: true,
2984 reflow_mode: ReflowMode::SentencePerLine,
2985 ..Default::default()
2986 };
2987 let rule = MD013LineLength::from_config_struct(config);
2988
2989 let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
2990 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2991 let result = rule.check(&ctx).unwrap();
2992
2993 assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
2994 assert_eq!(result[0].line, 3); }
2996
2997 #[test]
2998 fn test_sentence_per_line_questions_exclamations() {
2999 let config = MD013Config {
3000 reflow: true,
3001 reflow_mode: ReflowMode::SentencePerLine,
3002 ..Default::default()
3003 };
3004 let rule = MD013LineLength::from_config_struct(config);
3005
3006 let content = "Is this a question? Yes it is! And a statement.";
3007 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3008 let result = rule.check(&ctx).unwrap();
3009
3010 assert!(!result.is_empty(), "Should detect sentences with ? and !");
3011
3012 let fix = result[0].fix.as_ref().unwrap();
3013 let lines: Vec<&str> = fix.replacement.trim().lines().collect();
3014 assert_eq!(lines.len(), 3);
3015 assert_eq!(lines[0], "Is this a question?");
3016 assert_eq!(lines[1], "Yes it is!");
3017 assert_eq!(lines[2], "And a statement.");
3018 }
3019
3020 #[test]
3021 fn test_sentence_per_line_in_lists() {
3022 let config = MD013Config {
3023 reflow: true,
3024 reflow_mode: ReflowMode::SentencePerLine,
3025 ..Default::default()
3026 };
3027 let rule = MD013LineLength::from_config_struct(config);
3028
3029 let content = "- List item one. With two sentences.\n- Another item.";
3030 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3031 let result = rule.check(&ctx).unwrap();
3032
3033 assert!(!result.is_empty(), "Should detect sentences in list items");
3034 let fix = result[0].fix.as_ref().unwrap();
3036 assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
3037 }
3038
3039 #[test]
3040 fn test_multi_paragraph_list_item_with_3_space_indent() {
3041 let config = MD013Config {
3042 reflow: true,
3043 reflow_mode: ReflowMode::Normalize,
3044 line_length: 999999,
3045 ..Default::default()
3046 };
3047 let rule = MD013LineLength::from_config_struct(config);
3048
3049 let content = "1. First paragraph\n continuation line.\n\n Second paragraph\n more content.";
3050 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3051 let result = rule.check(&ctx).unwrap();
3052
3053 assert!(!result.is_empty(), "Should detect multi-line paragraphs in list item");
3054 let fix = result[0].fix.as_ref().unwrap();
3055
3056 assert!(
3058 fix.replacement.contains("\n\n"),
3059 "Should preserve blank line between paragraphs"
3060 );
3061 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3062 }
3063
3064 #[test]
3065 fn test_multi_paragraph_list_item_with_4_space_indent() {
3066 let config = MD013Config {
3067 reflow: true,
3068 reflow_mode: ReflowMode::Normalize,
3069 line_length: 999999,
3070 ..Default::default()
3071 };
3072 let rule = MD013LineLength::from_config_struct(config);
3073
3074 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.";
3076 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3077 let result = rule.check(&ctx).unwrap();
3078
3079 assert!(
3080 !result.is_empty(),
3081 "Should detect multi-line paragraphs in list item with 4-space indent"
3082 );
3083 let fix = result[0].fix.as_ref().unwrap();
3084
3085 assert!(
3087 fix.replacement.contains("\n\n"),
3088 "Should preserve blank line between paragraphs"
3089 );
3090 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3091
3092 let lines: Vec<&str> = fix.replacement.split('\n').collect();
3094 let blank_line_idx = lines.iter().position(|l| l.trim().is_empty());
3095 assert!(blank_line_idx.is_some(), "Should have blank line separating paragraphs");
3096 }
3097
3098 #[test]
3099 fn test_multi_paragraph_bullet_list_item() {
3100 let config = MD013Config {
3101 reflow: true,
3102 reflow_mode: ReflowMode::Normalize,
3103 line_length: 999999,
3104 ..Default::default()
3105 };
3106 let rule = MD013LineLength::from_config_struct(config);
3107
3108 let content = "- First paragraph\n continuation.\n\n Second paragraph\n more text.";
3109 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3110 let result = rule.check(&ctx).unwrap();
3111
3112 assert!(!result.is_empty(), "Should detect multi-line paragraphs in bullet list");
3113 let fix = result[0].fix.as_ref().unwrap();
3114
3115 assert!(
3116 fix.replacement.contains("\n\n"),
3117 "Should preserve blank line between paragraphs"
3118 );
3119 assert!(fix.replacement.starts_with("- "), "Should preserve bullet marker");
3120 }
3121
3122 #[test]
3123 fn test_code_block_in_list_item_five_spaces() {
3124 let config = MD013Config {
3125 reflow: true,
3126 reflow_mode: ReflowMode::Normalize,
3127 line_length: 80,
3128 ..Default::default()
3129 };
3130 let rule = MD013LineLength::from_config_struct(config);
3131
3132 let content = "1. First paragraph with some text that should be reflowed.\n\n code_block()\n more_code()\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(" code_block()"),
3143 "Code block should be preserved: {}",
3144 fix.replacement
3145 );
3146 assert!(
3147 fix.replacement.contains(" more_code()"),
3148 "Code block should be preserved: {}",
3149 fix.replacement
3150 );
3151 }
3152 }
3153
3154 #[test]
3155 fn test_fenced_code_block_in_list_item() {
3156 let config = MD013Config {
3157 reflow: true,
3158 reflow_mode: ReflowMode::Normalize,
3159 line_length: 80,
3160 ..Default::default()
3161 };
3162 let rule = MD013LineLength::from_config_struct(config);
3163
3164 let content = "1. First paragraph with some text.\n\n ```rust\n fn foo() {}\n let x = 1;\n ```\n\n Second paragraph.";
3165 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3166 let result = rule.check(&ctx).unwrap();
3167
3168 if !result.is_empty() {
3169 let fix = result[0].fix.as_ref().unwrap();
3170 assert!(
3172 fix.replacement.contains("```rust"),
3173 "Should preserve fence: {}",
3174 fix.replacement
3175 );
3176 assert!(
3177 fix.replacement.contains("fn foo() {}"),
3178 "Should preserve code: {}",
3179 fix.replacement
3180 );
3181 assert!(
3182 fix.replacement.contains("```"),
3183 "Should preserve closing fence: {}",
3184 fix.replacement
3185 );
3186 }
3187 }
3188
3189 #[test]
3190 fn test_mixed_indentation_3_and_4_spaces() {
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. Text\n 3 space continuation\n 4 space continuation";
3201 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3202 let result = rule.check(&ctx).unwrap();
3203
3204 assert!(!result.is_empty(), "Should detect multi-line list item");
3205 let fix = result[0].fix.as_ref().unwrap();
3206 assert!(
3208 fix.replacement.contains("3 space continuation"),
3209 "Should include 3-space line: {}",
3210 fix.replacement
3211 );
3212 assert!(
3213 fix.replacement.contains("4 space continuation"),
3214 "Should include 4-space line: {}",
3215 fix.replacement
3216 );
3217 }
3218
3219 #[test]
3220 fn test_nested_list_in_multi_paragraph_item() {
3221 let config = MD013Config {
3222 reflow: true,
3223 reflow_mode: ReflowMode::Normalize,
3224 line_length: 999999,
3225 ..Default::default()
3226 };
3227 let rule = MD013LineLength::from_config_struct(config);
3228
3229 let content = "1. First paragraph.\n\n - Nested item\n continuation\n\n Second paragraph.";
3230 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3231 let result = rule.check(&ctx).unwrap();
3232
3233 assert!(!result.is_empty(), "Should detect and reflow parent item");
3235 if let Some(fix) = result[0].fix.as_ref() {
3236 assert!(
3238 fix.replacement.contains("- Nested"),
3239 "Should preserve nested list: {}",
3240 fix.replacement
3241 );
3242 assert!(
3243 fix.replacement.contains("Second paragraph"),
3244 "Should include content after nested list: {}",
3245 fix.replacement
3246 );
3247 }
3248 }
3249
3250 #[test]
3251 fn test_nested_fence_markers_different_types() {
3252 let config = MD013Config {
3253 reflow: true,
3254 reflow_mode: ReflowMode::Normalize,
3255 line_length: 80,
3256 ..Default::default()
3257 };
3258 let rule = MD013LineLength::from_config_struct(config);
3259
3260 let content = "1. Example with nested fences:\n\n ~~~markdown\n This shows ```python\n code = True\n ```\n ~~~\n\n Text after.";
3262 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3263 let result = rule.check(&ctx).unwrap();
3264
3265 if !result.is_empty() {
3266 let fix = result[0].fix.as_ref().unwrap();
3267 assert!(
3269 fix.replacement.contains("```python"),
3270 "Should preserve inner fence: {}",
3271 fix.replacement
3272 );
3273 assert!(
3274 fix.replacement.contains("~~~"),
3275 "Should preserve outer fence: {}",
3276 fix.replacement
3277 );
3278 assert!(
3280 fix.replacement.contains("code = True"),
3281 "Should preserve code: {}",
3282 fix.replacement
3283 );
3284 }
3285 }
3286
3287 #[test]
3288 fn test_nested_fence_markers_same_type() {
3289 let config = MD013Config {
3290 reflow: true,
3291 reflow_mode: ReflowMode::Normalize,
3292 line_length: 80,
3293 ..Default::default()
3294 };
3295 let rule = MD013LineLength::from_config_struct(config);
3296
3297 let content =
3299 "1. Example:\n\n ````markdown\n Shows ```python in code\n ```\n text here\n ````\n\n After.";
3300 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3301 let result = rule.check(&ctx).unwrap();
3302
3303 if !result.is_empty() {
3304 let fix = result[0].fix.as_ref().unwrap();
3305 assert!(
3307 fix.replacement.contains("```python"),
3308 "Should preserve inner fence: {}",
3309 fix.replacement
3310 );
3311 assert!(
3312 fix.replacement.contains("````"),
3313 "Should preserve outer fence: {}",
3314 fix.replacement
3315 );
3316 assert!(
3317 fix.replacement.contains("text here"),
3318 "Should keep text as code: {}",
3319 fix.replacement
3320 );
3321 }
3322 }
3323
3324 #[test]
3325 fn test_sibling_list_item_breaks_parent() {
3326 let config = MD013Config {
3327 reflow: true,
3328 reflow_mode: ReflowMode::Normalize,
3329 line_length: 999999,
3330 ..Default::default()
3331 };
3332 let rule = MD013LineLength::from_config_struct(config);
3333
3334 let content = "1. First item\n continuation.\n2. Second item";
3336 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3337 let result = rule.check(&ctx).unwrap();
3338
3339 if !result.is_empty() {
3341 let fix = result[0].fix.as_ref().unwrap();
3342 assert!(fix.replacement.starts_with("1. "), "Should start with first marker");
3344 assert!(fix.replacement.contains("continuation"), "Should include continuation");
3345 }
3347 }
3348
3349 #[test]
3350 fn test_nested_list_at_continuation_indent_preserved() {
3351 let config = MD013Config {
3352 reflow: true,
3353 reflow_mode: ReflowMode::Normalize,
3354 line_length: 999999,
3355 ..Default::default()
3356 };
3357 let rule = MD013LineLength::from_config_struct(config);
3358
3359 let content = "1. Parent paragraph\n with continuation.\n\n - Nested at 3 spaces\n - Another nested\n\n After nested.";
3361 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3362 let result = rule.check(&ctx).unwrap();
3363
3364 if !result.is_empty() {
3365 let fix = result[0].fix.as_ref().unwrap();
3366 assert!(
3368 fix.replacement.contains("- Nested"),
3369 "Should include first nested item: {}",
3370 fix.replacement
3371 );
3372 assert!(
3373 fix.replacement.contains("- Another"),
3374 "Should include second nested item: {}",
3375 fix.replacement
3376 );
3377 assert!(
3378 fix.replacement.contains("After nested"),
3379 "Should include content after nested list: {}",
3380 fix.replacement
3381 );
3382 }
3383 }
3384
3385 #[test]
3386 fn test_paragraphs_false_skips_regular_text() {
3387 let config = MD013Config {
3389 line_length: 50,
3390 paragraphs: false, code_blocks: true,
3392 tables: true,
3393 headings: true,
3394 strict: false,
3395 reflow: false,
3396 reflow_mode: ReflowMode::default(),
3397 };
3398 let rule = MD013LineLength::from_config_struct(config);
3399
3400 let content =
3401 "This is a very long line of regular text that exceeds fifty characters and should not trigger a warning.";
3402 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3403 let result = rule.check(&ctx).unwrap();
3404
3405 assert_eq!(
3407 result.len(),
3408 0,
3409 "Should not warn about long paragraph text when paragraphs=false"
3410 );
3411 }
3412
3413 #[test]
3414 fn test_paragraphs_false_still_checks_code_blocks() {
3415 let config = MD013Config {
3417 line_length: 50,
3418 paragraphs: false, code_blocks: true, tables: true,
3421 headings: true,
3422 strict: false,
3423 reflow: false,
3424 reflow_mode: ReflowMode::default(),
3425 };
3426 let rule = MD013LineLength::from_config_struct(config);
3427
3428 let content = r#"```
3429This is a very long line in a code block that exceeds fifty characters.
3430```"#;
3431 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3432 let result = rule.check(&ctx).unwrap();
3433
3434 assert_eq!(
3436 result.len(),
3437 1,
3438 "Should warn about long lines in code blocks even when paragraphs=false"
3439 );
3440 }
3441
3442 #[test]
3443 fn test_paragraphs_false_still_checks_headings() {
3444 let config = MD013Config {
3446 line_length: 50,
3447 paragraphs: false, code_blocks: true,
3449 tables: true,
3450 headings: true, strict: false,
3452 reflow: false,
3453 reflow_mode: ReflowMode::default(),
3454 };
3455 let rule = MD013LineLength::from_config_struct(config);
3456
3457 let content = "# This is a very long heading that exceeds fifty characters and should trigger a warning";
3458 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3459 let result = rule.check(&ctx).unwrap();
3460
3461 assert_eq!(
3463 result.len(),
3464 1,
3465 "Should warn about long headings even when paragraphs=false"
3466 );
3467 }
3468
3469 #[test]
3470 fn test_paragraphs_false_with_reflow_sentence_per_line() {
3471 let config = MD013Config {
3473 line_length: 80,
3474 paragraphs: false,
3475 code_blocks: true,
3476 tables: true,
3477 headings: false,
3478 strict: false,
3479 reflow: true,
3480 reflow_mode: ReflowMode::SentencePerLine,
3481 };
3482 let rule = MD013LineLength::from_config_struct(config);
3483
3484 let content = "This is a very long sentence that exceeds eighty characters and contains important information that should not be flagged.";
3485 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3486 let result = rule.check(&ctx).unwrap();
3487
3488 assert_eq!(
3490 result.len(),
3491 0,
3492 "Should not warn about long sentences when paragraphs=false"
3493 );
3494 }
3495
3496 #[test]
3497 fn test_paragraphs_true_checks_regular_text() {
3498 let config = MD013Config {
3500 line_length: 50,
3501 paragraphs: true, code_blocks: true,
3503 tables: true,
3504 headings: true,
3505 strict: false,
3506 reflow: false,
3507 reflow_mode: ReflowMode::default(),
3508 };
3509 let rule = MD013LineLength::from_config_struct(config);
3510
3511 let content = "This is a very long line of regular text that exceeds fifty characters.";
3512 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3513 let result = rule.check(&ctx).unwrap();
3514
3515 assert_eq!(
3517 result.len(),
3518 1,
3519 "Should warn about long paragraph text when paragraphs=true"
3520 );
3521 }
3522}