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 skip_length_checks = effective_config.line_length == 0;
155
156 let mut candidate_lines = Vec::new();
158 if !skip_length_checks {
159 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
160 if line_info.content.len() > effective_config.line_length {
162 candidate_lines.push(line_idx);
163 }
164 }
165 }
166
167 if candidate_lines.is_empty()
169 && !(effective_config.reflow
170 && (effective_config.reflow_mode == ReflowMode::Normalize
171 || effective_config.reflow_mode == ReflowMode::SentencePerLine))
172 {
173 return Ok(warnings);
174 }
175
176 let lines: Vec<&str> = if !ctx.lines.is_empty() {
178 ctx.lines.iter().map(|l| l.content.as_str()).collect()
179 } else {
180 content.lines().collect()
181 };
182
183 let heading_lines_set: std::collections::HashSet<usize> = ctx
186 .lines
187 .iter()
188 .enumerate()
189 .filter(|(_, line)| line.heading.is_some())
190 .map(|(idx, _)| idx + 1)
191 .collect();
192
193 let table_blocks = TableUtils::find_table_blocks(content, ctx);
196 let mut table_lines_set = std::collections::HashSet::new();
197 for table in &table_blocks {
198 table_lines_set.insert(table.header_line + 1);
199 table_lines_set.insert(table.delimiter_line + 1);
200 for &line in &table.content_lines {
201 table_lines_set.insert(line + 1);
202 }
203 }
204
205 for &line_idx in &candidate_lines {
207 let line_number = line_idx + 1;
208 let line = lines[line_idx];
209
210 let effective_length = self.calculate_effective_length(line);
212
213 let line_limit = effective_config.line_length;
215
216 if effective_length <= line_limit {
218 continue;
219 }
220
221 if ctx.lines[line_idx].in_mkdocstrings {
223 continue;
224 }
225
226 if !effective_config.strict {
228 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
230 continue;
231 }
232
233 if (!effective_config.headings && heading_lines_set.contains(&line_number))
237 || (!effective_config.code_blocks
238 && ctx.line_info(line_number).is_some_and(|info| info.in_code_block))
239 || (!effective_config.tables && table_lines_set.contains(&line_number))
240 || ctx.lines[line_number - 1].blockquote.is_some()
241 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
242 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
243 {
244 continue;
245 }
246
247 if !effective_config.paragraphs {
250 let is_special_block = heading_lines_set.contains(&line_number)
251 || ctx.line_info(line_number).is_some_and(|info| info.in_code_block)
252 || table_lines_set.contains(&line_number)
253 || ctx.lines[line_number - 1].blockquote.is_some()
254 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
255 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment);
256
257 if !is_special_block {
259 continue;
260 }
261 }
262
263 if self.should_ignore_line(line, &lines, line_idx, ctx) {
265 continue;
266 }
267 }
268
269 if effective_config.reflow_mode == ReflowMode::SentencePerLine {
272 let sentences = split_into_sentences(line.trim());
273 if sentences.len() == 1 {
274 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
276
277 let (start_line, start_col, end_line, end_col) =
278 calculate_excess_range(line_number, line, line_limit);
279
280 warnings.push(LintWarning {
281 rule_name: Some(self.name().to_string()),
282 message,
283 line: start_line,
284 column: start_col,
285 end_line,
286 end_column: end_col,
287 severity: Severity::Warning,
288 fix: None, });
290 continue;
291 }
292 continue;
294 }
295
296 let fix = None;
299
300 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
301
302 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
304
305 warnings.push(LintWarning {
306 rule_name: Some(self.name().to_string()),
307 message,
308 line: start_line,
309 column: start_col,
310 end_line,
311 end_column: end_col,
312 severity: Severity::Warning,
313 fix,
314 });
315 }
316
317 if effective_config.reflow {
319 let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, &lines);
320 for pw in paragraph_warnings {
322 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
324 warnings.push(pw);
325 }
326 }
327
328 Ok(warnings)
329 }
330
331 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
332 let warnings = self.check(ctx)?;
335
336 if !warnings.iter().any(|w| w.fix.is_some()) {
338 return Ok(ctx.content.to_string());
339 }
340
341 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
343 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
344 }
345
346 fn as_any(&self) -> &dyn std::any::Any {
347 self
348 }
349
350 fn category(&self) -> RuleCategory {
351 RuleCategory::Whitespace
352 }
353
354 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
355 if ctx.content.is_empty() {
357 return true;
358 }
359
360 if self.config.reflow
362 && (self.config.reflow_mode == ReflowMode::SentencePerLine
363 || self.config.reflow_mode == ReflowMode::Normalize)
364 {
365 return false;
366 }
367
368 if ctx.content.len() <= self.config.line_length {
370 return true;
371 }
372
373 !ctx.lines
375 .iter()
376 .any(|line| line.content.len() > self.config.line_length)
377 }
378
379 fn default_config_section(&self) -> Option<(String, toml::Value)> {
380 let default_config = MD013Config::default();
381 let json_value = serde_json::to_value(&default_config).ok()?;
382 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
383
384 if let toml::Value::Table(table) = toml_value {
385 if !table.is_empty() {
386 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
387 } else {
388 None
389 }
390 } else {
391 None
392 }
393 }
394
395 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
396 let mut aliases = std::collections::HashMap::new();
397 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
398 Some(aliases)
399 }
400
401 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
402 where
403 Self: Sized,
404 {
405 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
406 if rule_config.line_length == 80 {
408 rule_config.line_length = config.global.line_length as usize;
410 }
411 Box::new(Self::from_config_struct(rule_config))
412 }
413}
414
415impl MD013LineLength {
416 fn generate_paragraph_fixes(
418 &self,
419 ctx: &crate::lint_context::LintContext,
420 config: &MD013Config,
421 lines: &[&str],
422 ) -> Vec<LintWarning> {
423 let mut warnings = Vec::new();
424 let line_index = LineIndex::new(ctx.content.to_string());
425
426 let mut i = 0;
427 while i < lines.len() {
428 let line_num = i + 1;
429
430 let should_skip_due_to_line_info = ctx.line_info(line_num).is_some_and(|info| {
432 info.in_code_block || info.in_front_matter || info.in_html_block || info.in_html_comment
433 });
434
435 if should_skip_due_to_line_info
436 || (line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some())
437 || lines[i].trim().starts_with('#')
438 || TableUtils::is_potential_table_row(lines[i])
439 || lines[i].trim().is_empty()
440 || is_horizontal_rule(lines[i].trim())
441 {
442 i += 1;
443 continue;
444 }
445
446 let is_semantic_line = |content: &str| -> bool {
448 let trimmed = content.trim_start();
449 let semantic_markers = [
450 "NOTE:",
451 "WARNING:",
452 "IMPORTANT:",
453 "CAUTION:",
454 "TIP:",
455 "DANGER:",
456 "HINT:",
457 "INFO:",
458 ];
459 semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
460 };
461
462 let is_fence_marker = |content: &str| -> bool {
464 let trimmed = content.trim_start();
465 trimmed.starts_with("```") || trimmed.starts_with("~~~")
466 };
467
468 let trimmed = lines[i].trim();
470 if is_list_item(trimmed) {
471 let list_start = i;
473 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
474 let marker_len = marker.len();
475
476 #[derive(Clone)]
478 enum LineType {
479 Content(String),
480 CodeBlock(String, usize), NestedListItem(String, usize), SemanticLine(String), Empty,
484 }
485
486 let mut actual_indent: Option<usize> = None;
487 let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
488 i += 1;
489
490 while i < lines.len() {
492 let line_info = &ctx.lines[i];
493
494 if line_info.is_blank {
496 if i + 1 < lines.len() {
498 let next_info = &ctx.lines[i + 1];
499
500 if !next_info.is_blank && next_info.indent >= marker_len {
502 list_item_lines.push(LineType::Empty);
504 i += 1;
505 continue;
506 }
507 }
508 break;
510 }
511
512 let indent = line_info.indent;
514
515 if indent >= marker_len {
517 let trimmed = line_info.content.trim();
518
519 if line_info.in_code_block {
521 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
522 i += 1;
523 continue;
524 }
525
526 if is_list_item(trimmed) && indent < marker_len {
530 break;
532 }
533
534 if is_list_item(trimmed) && indent >= marker_len {
539 let has_blank_before = matches!(list_item_lines.last(), Some(LineType::Empty));
541
542 let has_nested_content = list_item_lines.iter().any(|line| {
544 matches!(line, LineType::Content(c) if is_list_item(c.trim()))
545 || matches!(line, LineType::NestedListItem(_, _))
546 });
547
548 if !has_blank_before && !has_nested_content {
549 break;
552 }
553 list_item_lines.push(LineType::NestedListItem(
556 line_info.content[indent..].to_string(),
557 indent,
558 ));
559 i += 1;
560 continue;
561 }
562
563 if indent <= marker_len + 3 {
565 if actual_indent.is_none() {
567 actual_indent = Some(indent);
568 }
569
570 let content = trim_preserving_hard_break(&line_info.content[indent..]);
574
575 if is_fence_marker(&content) {
578 list_item_lines.push(LineType::CodeBlock(content, indent));
579 }
580 else if is_semantic_line(&content) {
582 list_item_lines.push(LineType::SemanticLine(content));
583 } else {
584 list_item_lines.push(LineType::Content(content));
585 }
586 i += 1;
587 } else {
588 list_item_lines.push(LineType::CodeBlock(line_info.content[indent..].to_string(), indent));
590 i += 1;
591 }
592 } else {
593 break;
595 }
596 }
597
598 let indent_size = actual_indent.unwrap_or(marker_len);
600 let expected_indent = " ".repeat(indent_size);
601
602 #[derive(Clone)]
604 enum Block {
605 Paragraph(Vec<String>),
606 Code {
607 lines: Vec<(String, usize)>, has_preceding_blank: bool, },
610 NestedList(Vec<(String, usize)>), SemanticLine(String), }
613
614 let mut blocks: Vec<Block> = Vec::new();
615 let mut current_paragraph: Vec<String> = Vec::new();
616 let mut current_code_block: Vec<(String, usize)> = Vec::new();
617 let mut current_nested_list: Vec<(String, usize)> = Vec::new();
618 let mut in_code = false;
619 let mut in_nested_list = false;
620 let mut had_preceding_blank = false; let mut code_block_has_preceding_blank = false; for line in &list_item_lines {
624 match line {
625 LineType::Empty => {
626 if in_code {
627 current_code_block.push((String::new(), 0));
628 } else if in_nested_list {
629 current_nested_list.push((String::new(), 0));
630 } else if !current_paragraph.is_empty() {
631 blocks.push(Block::Paragraph(current_paragraph.clone()));
632 current_paragraph.clear();
633 }
634 had_preceding_blank = true;
636 }
637 LineType::Content(content) => {
638 if in_code {
639 blocks.push(Block::Code {
641 lines: current_code_block.clone(),
642 has_preceding_blank: code_block_has_preceding_blank,
643 });
644 current_code_block.clear();
645 in_code = false;
646 } else if in_nested_list {
647 blocks.push(Block::NestedList(current_nested_list.clone()));
649 current_nested_list.clear();
650 in_nested_list = false;
651 }
652 current_paragraph.push(content.clone());
653 had_preceding_blank = false; }
655 LineType::CodeBlock(content, indent) => {
656 if in_nested_list {
657 blocks.push(Block::NestedList(current_nested_list.clone()));
659 current_nested_list.clear();
660 in_nested_list = false;
661 }
662 if !in_code {
663 if !current_paragraph.is_empty() {
665 blocks.push(Block::Paragraph(current_paragraph.clone()));
666 current_paragraph.clear();
667 }
668 in_code = true;
669 code_block_has_preceding_blank = had_preceding_blank;
671 }
672 current_code_block.push((content.clone(), *indent));
673 had_preceding_blank = false; }
675 LineType::NestedListItem(content, indent) => {
676 if in_code {
677 blocks.push(Block::Code {
679 lines: current_code_block.clone(),
680 has_preceding_blank: code_block_has_preceding_blank,
681 });
682 current_code_block.clear();
683 in_code = false;
684 }
685 if !in_nested_list {
686 if !current_paragraph.is_empty() {
688 blocks.push(Block::Paragraph(current_paragraph.clone()));
689 current_paragraph.clear();
690 }
691 in_nested_list = true;
692 }
693 current_nested_list.push((content.clone(), *indent));
694 had_preceding_blank = false; }
696 LineType::SemanticLine(content) => {
697 if in_code {
699 blocks.push(Block::Code {
700 lines: current_code_block.clone(),
701 has_preceding_blank: code_block_has_preceding_blank,
702 });
703 current_code_block.clear();
704 in_code = false;
705 } else if in_nested_list {
706 blocks.push(Block::NestedList(current_nested_list.clone()));
707 current_nested_list.clear();
708 in_nested_list = false;
709 } else if !current_paragraph.is_empty() {
710 blocks.push(Block::Paragraph(current_paragraph.clone()));
711 current_paragraph.clear();
712 }
713 blocks.push(Block::SemanticLine(content.clone()));
715 had_preceding_blank = false; }
717 }
718 }
719
720 if in_code && !current_code_block.is_empty() {
722 blocks.push(Block::Code {
723 lines: current_code_block,
724 has_preceding_blank: code_block_has_preceding_blank,
725 });
726 } else if in_nested_list && !current_nested_list.is_empty() {
727 blocks.push(Block::NestedList(current_nested_list));
728 } else if !current_paragraph.is_empty() {
729 blocks.push(Block::Paragraph(current_paragraph));
730 }
731
732 let content_lines: Vec<String> = list_item_lines
734 .iter()
735 .filter_map(|line| {
736 if let LineType::Content(s) = line {
737 Some(s.clone())
738 } else {
739 None
740 }
741 })
742 .collect();
743
744 let combined_content = content_lines.join(" ").trim().to_string();
747 let full_line = format!("{marker}{combined_content}");
748
749 let should_normalize = || {
751 let has_nested_lists = blocks.iter().any(|b| matches!(b, Block::NestedList(_)));
754 let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
755 let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
756 let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
757
758 if (has_nested_lists || has_code_blocks || has_semantic_lines) && !has_paragraphs {
760 return false;
761 }
762
763 if has_paragraphs {
765 let paragraph_count = blocks.iter().filter(|b| matches!(b, Block::Paragraph(_))).count();
766 if paragraph_count > 1 {
767 return true;
769 }
770
771 if content_lines.len() > 1 {
773 return true;
774 }
775 }
776
777 false
778 };
779
780 let needs_reflow = match config.reflow_mode {
781 ReflowMode::Normalize => {
782 let combined_length = self.calculate_effective_length(&full_line);
786 if combined_length > config.line_length {
787 true
788 } else {
789 should_normalize()
790 }
791 }
792 ReflowMode::SentencePerLine => {
793 let sentences = split_into_sentences(&combined_content);
795 sentences.len() > 1
796 }
797 ReflowMode::Default => {
798 self.calculate_effective_length(&full_line) > config.line_length
800 }
801 };
802
803 if needs_reflow {
804 let start_range = line_index.whole_line_range(list_start + 1);
805 let end_line = i - 1;
806 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
807 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
808 } else {
809 line_index.whole_line_range(end_line + 1)
810 };
811 let byte_range = start_range.start..end_range.end;
812
813 let reflow_line_length = if config.line_length == 0 {
816 usize::MAX
817 } else {
818 config.line_length.saturating_sub(indent_size).max(1)
819 };
820 let reflow_options = crate::utils::text_reflow::ReflowOptions {
821 line_length: reflow_line_length,
822 break_on_sentences: true,
823 preserve_breaks: false,
824 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
825 };
826
827 let mut result: Vec<String> = Vec::new();
828 let mut is_first_block = true;
829
830 for (block_idx, block) in blocks.iter().enumerate() {
831 match block {
832 Block::Paragraph(para_lines) => {
833 let segments = split_into_segments(para_lines);
836
837 for (segment_idx, segment) in segments.iter().enumerate() {
838 let hard_break_type = segment.last().and_then(|line| {
840 let line = line.strip_suffix('\r').unwrap_or(line);
841 if line.ends_with('\\') {
842 Some("\\")
843 } else if line.ends_with(" ") {
844 Some(" ")
845 } else {
846 None
847 }
848 });
849
850 let segment_for_reflow: Vec<String> = segment
852 .iter()
853 .map(|line| {
854 if line.ends_with('\\') {
856 line[..line.len() - 1].trim_end().to_string()
857 } else if line.ends_with(" ") {
858 line[..line.len() - 2].trim_end().to_string()
859 } else {
860 line.clone()
861 }
862 })
863 .collect();
864
865 let segment_text = segment_for_reflow.join(" ").trim().to_string();
866 if !segment_text.is_empty() {
867 let reflowed =
868 crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
869
870 if is_first_block && segment_idx == 0 {
871 result.push(format!("{marker}{}", reflowed[0]));
873 for line in reflowed.iter().skip(1) {
874 result.push(format!("{expected_indent}{line}"));
875 }
876 is_first_block = false;
877 } else {
878 for line in reflowed {
880 result.push(format!("{expected_indent}{line}"));
881 }
882 }
883
884 if let Some(break_marker) = hard_break_type
887 && let Some(last_line) = result.last_mut()
888 {
889 last_line.push_str(break_marker);
890 }
891 }
892 }
893
894 if block_idx < blocks.len() - 1 {
897 let next_block = &blocks[block_idx + 1];
898 let should_add_blank = match next_block {
899 Block::Code {
900 has_preceding_blank, ..
901 } => *has_preceding_blank,
902 _ => true, };
904 if should_add_blank {
905 result.push(String::new());
906 }
907 }
908 }
909 Block::Code {
910 lines: code_lines,
911 has_preceding_blank: _,
912 } => {
913 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
918 if is_first_block && idx == 0 {
919 result.push(format!(
921 "{marker}{}",
922 " ".repeat(orig_indent - marker_len) + content
923 ));
924 is_first_block = false;
925 } else if content.is_empty() {
926 result.push(String::new());
927 } else {
928 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
929 }
930 }
931 }
932 Block::NestedList(nested_items) => {
933 if !is_first_block {
935 result.push(String::new());
936 }
937
938 for (idx, (content, orig_indent)) in nested_items.iter().enumerate() {
939 if is_first_block && idx == 0 {
940 result.push(format!(
942 "{marker}{}",
943 " ".repeat(orig_indent - marker_len) + content
944 ));
945 is_first_block = false;
946 } else if content.is_empty() {
947 result.push(String::new());
948 } else {
949 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
950 }
951 }
952
953 if block_idx < blocks.len() - 1 {
956 let next_block = &blocks[block_idx + 1];
957 let should_add_blank = match next_block {
958 Block::Code {
959 has_preceding_blank, ..
960 } => *has_preceding_blank,
961 _ => true, };
963 if should_add_blank {
964 result.push(String::new());
965 }
966 }
967 }
968 Block::SemanticLine(content) => {
969 if !is_first_block {
972 result.push(String::new());
973 }
974
975 if is_first_block {
976 result.push(format!("{marker}{content}"));
978 is_first_block = false;
979 } else {
980 result.push(format!("{expected_indent}{content}"));
982 }
983
984 if block_idx < blocks.len() - 1 {
987 let next_block = &blocks[block_idx + 1];
988 let should_add_blank = match next_block {
989 Block::Code {
990 has_preceding_blank, ..
991 } => *has_preceding_blank,
992 _ => true, };
994 if should_add_blank {
995 result.push(String::new());
996 }
997 }
998 }
999 }
1000 }
1001
1002 let reflowed_text = result.join("\n");
1003
1004 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1006 format!("{reflowed_text}\n")
1007 } else {
1008 reflowed_text
1009 };
1010
1011 let original_text = &ctx.content[byte_range.clone()];
1013
1014 if original_text != replacement {
1016 let message = match config.reflow_mode {
1018 ReflowMode::SentencePerLine => "Line contains multiple sentences".to_string(),
1019 ReflowMode::Normalize => {
1020 let combined_length = self.calculate_effective_length(&full_line);
1021 if combined_length > config.line_length {
1022 format!(
1023 "Line length {} exceeds {} characters",
1024 combined_length, config.line_length
1025 )
1026 } else {
1027 "Multi-line content can be normalized".to_string()
1028 }
1029 }
1030 ReflowMode::Default => {
1031 let combined_length = self.calculate_effective_length(&full_line);
1032 format!(
1033 "Line length {} exceeds {} characters",
1034 combined_length, config.line_length
1035 )
1036 }
1037 };
1038
1039 warnings.push(LintWarning {
1040 rule_name: Some(self.name().to_string()),
1041 message,
1042 line: list_start + 1,
1043 column: 1,
1044 end_line: end_line + 1,
1045 end_column: lines[end_line].len() + 1,
1046 severity: Severity::Warning,
1047 fix: Some(crate::rule::Fix {
1048 range: byte_range,
1049 replacement,
1050 }),
1051 });
1052 }
1053 }
1054 continue;
1055 }
1056
1057 let paragraph_start = i;
1059 let mut paragraph_lines = vec![lines[i]];
1060 i += 1;
1061
1062 while i < lines.len() {
1063 let next_line = lines[i];
1064 let next_line_num = i + 1;
1065 let next_trimmed = next_line.trim();
1066
1067 if next_trimmed.is_empty()
1069 || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
1070 || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
1071 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
1072 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_comment)
1073 || (next_line_num > 0
1074 && next_line_num <= ctx.lines.len()
1075 && ctx.lines[next_line_num - 1].blockquote.is_some())
1076 || next_trimmed.starts_with('#')
1077 || TableUtils::is_potential_table_row(next_line)
1078 || is_list_item(next_trimmed)
1079 || is_horizontal_rule(next_trimmed)
1080 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
1081 {
1082 break;
1083 }
1084
1085 if i > 0 && has_hard_break(lines[i - 1]) {
1087 break;
1089 }
1090
1091 paragraph_lines.push(next_line);
1092 i += 1;
1093 }
1094
1095 let needs_reflow = match config.reflow_mode {
1097 ReflowMode::Normalize => {
1098 paragraph_lines.len() > 1
1100 }
1101 ReflowMode::SentencePerLine => {
1102 paragraph_lines.iter().any(|line| {
1104 let sentences = split_into_sentences(line);
1106 sentences.len() > 1
1107 })
1108 }
1109 ReflowMode::Default => {
1110 paragraph_lines
1112 .iter()
1113 .any(|line| self.calculate_effective_length(line) > config.line_length)
1114 }
1115 };
1116
1117 if needs_reflow {
1118 let start_range = line_index.whole_line_range(paragraph_start + 1);
1121 let end_line = paragraph_start + paragraph_lines.len() - 1;
1122
1123 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1125 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1127 } else {
1128 line_index.whole_line_range(end_line + 1)
1130 };
1131
1132 let byte_range = start_range.start..end_range.end;
1133
1134 let paragraph_text = paragraph_lines.join(" ");
1136
1137 let hard_break_type = paragraph_lines.last().and_then(|line| {
1139 let line = line.strip_suffix('\r').unwrap_or(line);
1140 if line.ends_with('\\') {
1141 Some("\\")
1142 } else if line.ends_with(" ") {
1143 Some(" ")
1144 } else {
1145 None
1146 }
1147 });
1148
1149 let reflow_line_length = if config.line_length == 0 {
1152 usize::MAX
1153 } else {
1154 config.line_length
1155 };
1156 let reflow_options = crate::utils::text_reflow::ReflowOptions {
1157 line_length: reflow_line_length,
1158 break_on_sentences: true,
1159 preserve_breaks: false,
1160 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1161 };
1162 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
1163
1164 if let Some(break_marker) = hard_break_type
1167 && !reflowed.is_empty()
1168 {
1169 let last_idx = reflowed.len() - 1;
1170 if !has_hard_break(&reflowed[last_idx]) {
1171 reflowed[last_idx].push_str(break_marker);
1172 }
1173 }
1174
1175 let reflowed_text = reflowed.join("\n");
1176
1177 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1179 format!("{reflowed_text}\n")
1180 } else {
1181 reflowed_text
1182 };
1183
1184 let original_text = &ctx.content[byte_range.clone()];
1186
1187 if original_text != replacement {
1189 let (warning_line, warning_end_line) = match config.reflow_mode {
1194 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
1195 ReflowMode::SentencePerLine => {
1196 let mut violating_line = paragraph_start;
1198 for (idx, line) in paragraph_lines.iter().enumerate() {
1199 let sentences = split_into_sentences(line);
1200 if sentences.len() > 1 {
1201 violating_line = paragraph_start + idx;
1202 break;
1203 }
1204 }
1205 (violating_line + 1, violating_line + 1)
1206 }
1207 ReflowMode::Default => {
1208 let mut violating_line = paragraph_start;
1210 for (idx, line) in paragraph_lines.iter().enumerate() {
1211 if self.calculate_effective_length(line) > config.line_length {
1212 violating_line = paragraph_start + idx;
1213 break;
1214 }
1215 }
1216 (violating_line + 1, violating_line + 1)
1217 }
1218 };
1219
1220 warnings.push(LintWarning {
1221 rule_name: Some(self.name().to_string()),
1222 message: match config.reflow_mode {
1223 ReflowMode::Normalize => format!(
1224 "Paragraph could be normalized to use line length of {} characters",
1225 config.line_length
1226 ),
1227 ReflowMode::SentencePerLine => "Line contains multiple sentences".to_string(),
1228 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length),
1229 },
1230 line: warning_line,
1231 column: 1,
1232 end_line: warning_end_line,
1233 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
1234 severity: Severity::Warning,
1235 fix: Some(crate::rule::Fix {
1236 range: byte_range,
1237 replacement,
1238 }),
1239 });
1240 }
1241 }
1242 }
1243
1244 warnings
1245 }
1246
1247 fn calculate_effective_length(&self, line: &str) -> usize {
1249 if self.config.strict {
1250 return line.chars().count();
1252 }
1253
1254 let bytes = line.as_bytes();
1256 if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
1257 return line.chars().count();
1258 }
1259
1260 if !line.contains("http") && !line.contains('[') {
1262 return line.chars().count();
1263 }
1264
1265 let mut effective_line = line.to_string();
1266
1267 if line.contains('[') && line.contains("](") {
1270 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
1271 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
1272 && url.as_str().len() > 15
1273 {
1274 let replacement = format!("[{}](url)", text.as_str());
1275 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
1276 }
1277 }
1278 }
1279
1280 if effective_line.contains("http") {
1283 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
1284 let url = url_match.as_str();
1285 if !effective_line.contains(&format!("({url})")) {
1287 let placeholder = "x".repeat(15.min(url.len()));
1290 effective_line = effective_line.replacen(url, &placeholder, 1);
1291 }
1292 }
1293 }
1294
1295 effective_line.chars().count()
1296 }
1297}
1298
1299fn has_hard_break(line: &str) -> bool {
1305 let line = line.strip_suffix('\r').unwrap_or(line);
1306 line.ends_with(" ") || line.ends_with('\\')
1307}
1308
1309fn trim_preserving_hard_break(s: &str) -> String {
1316 let s = s.strip_suffix('\r').unwrap_or(s);
1318
1319 if s.ends_with('\\') {
1321 return s.to_string();
1323 }
1324
1325 if s.ends_with(" ") {
1327 let content_end = s.trim_end().len();
1329 if content_end == 0 {
1330 return String::new();
1332 }
1333 format!("{} ", &s[..content_end])
1335 } else {
1336 s.trim_end().to_string()
1338 }
1339}
1340
1341fn split_into_segments(para_lines: &[String]) -> Vec<Vec<String>> {
1352 let mut segments: Vec<Vec<String>> = Vec::new();
1353 let mut current_segment: Vec<String> = Vec::new();
1354
1355 for line in para_lines {
1356 current_segment.push(line.clone());
1357
1358 if has_hard_break(line) {
1360 segments.push(current_segment.clone());
1361 current_segment.clear();
1362 }
1363 }
1364
1365 if !current_segment.is_empty() {
1367 segments.push(current_segment);
1368 }
1369
1370 segments
1371}
1372
1373fn extract_list_marker_and_content(line: &str) -> (String, String) {
1374 let indent_len = line.len() - line.trim_start().len();
1376 let indent = &line[..indent_len];
1377 let trimmed = &line[indent_len..];
1378
1379 if let Some(rest) = trimmed.strip_prefix("- ") {
1382 return (format!("{indent}- "), trim_preserving_hard_break(rest));
1383 }
1384 if let Some(rest) = trimmed.strip_prefix("* ") {
1385 return (format!("{indent}* "), trim_preserving_hard_break(rest));
1386 }
1387 if let Some(rest) = trimmed.strip_prefix("+ ") {
1388 return (format!("{indent}+ "), trim_preserving_hard_break(rest));
1389 }
1390
1391 let mut chars = trimmed.chars();
1393 let mut marker_content = String::new();
1394
1395 while let Some(c) = chars.next() {
1396 marker_content.push(c);
1397 if c == '.' {
1398 if let Some(next) = chars.next()
1400 && next == ' '
1401 {
1402 marker_content.push(next);
1403 let content = trim_preserving_hard_break(chars.as_str());
1405 return (format!("{indent}{marker_content}"), content);
1406 }
1407 break;
1408 }
1409 }
1410
1411 (String::new(), line.to_string())
1413}
1414
1415fn is_horizontal_rule(line: &str) -> bool {
1417 if line.len() < 3 {
1418 return false;
1419 }
1420 let chars: Vec<char> = line.chars().collect();
1422 if chars.is_empty() {
1423 return false;
1424 }
1425 let first_char = chars[0];
1426 if first_char != '-' && first_char != '_' && first_char != '*' {
1427 return false;
1428 }
1429 for c in &chars {
1431 if *c != first_char && *c != ' ' {
1432 return false;
1433 }
1434 }
1435 chars.iter().filter(|c| **c == first_char).count() >= 3
1437}
1438
1439fn is_numbered_list_item(line: &str) -> bool {
1440 let mut chars = line.chars();
1441 if !chars.next().is_some_and(|c| c.is_numeric()) {
1443 return false;
1444 }
1445 while let Some(c) = chars.next() {
1447 if c == '.' {
1448 return chars.next().is_none_or(|c| c == ' ');
1450 }
1451 if !c.is_numeric() {
1452 return false;
1453 }
1454 }
1455 false
1456}
1457
1458fn is_list_item(line: &str) -> bool {
1459 if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
1461 && line.len() > 1
1462 && line.chars().nth(1) == Some(' ')
1463 {
1464 return true;
1465 }
1466 is_numbered_list_item(line)
1468}
1469
1470#[cfg(test)]
1471mod tests {
1472 use super::*;
1473 use crate::lint_context::LintContext;
1474
1475 #[test]
1476 fn test_default_config() {
1477 let rule = MD013LineLength::default();
1478 assert_eq!(rule.config.line_length, 80);
1479 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
1483 }
1484
1485 #[test]
1486 fn test_custom_config() {
1487 let rule = MD013LineLength::new(100, true, true, false, true);
1488 assert_eq!(rule.config.line_length, 100);
1489 assert!(rule.config.code_blocks);
1490 assert!(rule.config.tables);
1491 assert!(!rule.config.headings);
1492 assert!(rule.config.strict);
1493 }
1494
1495 #[test]
1496 fn test_basic_line_length_violation() {
1497 let rule = MD013LineLength::new(50, false, false, false, false);
1498 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
1499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1500 let result = rule.check(&ctx).unwrap();
1501
1502 assert_eq!(result.len(), 1);
1503 assert!(result[0].message.contains("Line length"));
1504 assert!(result[0].message.contains("exceeds 50 characters"));
1505 }
1506
1507 #[test]
1508 fn test_no_violation_under_limit() {
1509 let rule = MD013LineLength::new(100, false, false, false, false);
1510 let content = "Short line.\nAnother short line.";
1511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1512 let result = rule.check(&ctx).unwrap();
1513
1514 assert_eq!(result.len(), 0);
1515 }
1516
1517 #[test]
1518 fn test_multiple_violations() {
1519 let rule = MD013LineLength::new(30, false, false, false, false);
1520 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
1521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1522 let result = rule.check(&ctx).unwrap();
1523
1524 assert_eq!(result.len(), 2);
1525 assert_eq!(result[0].line, 1);
1526 assert_eq!(result[1].line, 2);
1527 }
1528
1529 #[test]
1530 fn test_code_blocks_exemption() {
1531 let rule = MD013LineLength::new(30, false, false, false, false);
1533 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1535 let result = rule.check(&ctx).unwrap();
1536
1537 assert_eq!(result.len(), 0);
1538 }
1539
1540 #[test]
1541 fn test_code_blocks_not_exempt_when_configured() {
1542 let rule = MD013LineLength::new(30, true, false, false, false);
1544 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
1545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1546 let result = rule.check(&ctx).unwrap();
1547
1548 assert!(!result.is_empty());
1549 }
1550
1551 #[test]
1552 fn test_heading_checked_when_enabled() {
1553 let rule = MD013LineLength::new(30, false, false, true, false);
1554 let content = "# This is a very long heading that would normally exceed the limit";
1555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1556 let result = rule.check(&ctx).unwrap();
1557
1558 assert_eq!(result.len(), 1);
1559 }
1560
1561 #[test]
1562 fn test_heading_exempt_when_disabled() {
1563 let rule = MD013LineLength::new(30, false, false, false, false);
1564 let content = "# This is a very long heading that should trigger a warning";
1565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1566 let result = rule.check(&ctx).unwrap();
1567
1568 assert_eq!(result.len(), 0);
1569 }
1570
1571 #[test]
1572 fn test_table_checked_when_enabled() {
1573 let rule = MD013LineLength::new(30, false, true, false, false);
1574 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
1575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1576 let result = rule.check(&ctx).unwrap();
1577
1578 assert_eq!(result.len(), 2); }
1580
1581 #[test]
1582 fn test_issue_78_tables_after_fenced_code_blocks() {
1583 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1586
1587```plain
1588some code block longer than 20 chars length
1589```
1590
1591this is a very long line
1592
1593| column A | column B |
1594| -------- | -------- |
1595| `var` | `val` |
1596| value 1 | value 2 |
1597
1598correct length line"#;
1599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1600 let result = rule.check(&ctx).unwrap();
1601
1602 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1604 assert_eq!(result[0].line, 7, "Should flag line 7");
1605 assert!(result[0].message.contains("24 exceeds 20"));
1606 }
1607
1608 #[test]
1609 fn test_issue_78_tables_with_inline_code() {
1610 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
1613| -------- | -------- |
1614| `var with very long name` | `val exceeding limit` |
1615| value 1 | value 2 |
1616
1617This line exceeds limit"#;
1618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1619 let result = rule.check(&ctx).unwrap();
1620
1621 assert_eq!(result.len(), 1, "Should only flag the non-table line");
1623 assert_eq!(result[0].line, 6, "Should flag line 6");
1624 }
1625
1626 #[test]
1627 fn test_issue_78_indented_code_blocks() {
1628 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1631
1632 some code block longer than 20 chars length
1633
1634this is a very long line
1635
1636| column A | column B |
1637| -------- | -------- |
1638| value 1 | value 2 |
1639
1640correct length line"#;
1641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1642 let result = rule.check(&ctx).unwrap();
1643
1644 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1646 assert_eq!(result[0].line, 5, "Should flag line 5");
1647 }
1648
1649 #[test]
1650 fn test_url_exemption() {
1651 let rule = MD013LineLength::new(30, false, false, false, false);
1652 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1654 let result = rule.check(&ctx).unwrap();
1655
1656 assert_eq!(result.len(), 0);
1657 }
1658
1659 #[test]
1660 fn test_image_reference_exemption() {
1661 let rule = MD013LineLength::new(30, false, false, false, false);
1662 let content = "![This is a very long image alt text that exceeds limit][reference]";
1663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1664 let result = rule.check(&ctx).unwrap();
1665
1666 assert_eq!(result.len(), 0);
1667 }
1668
1669 #[test]
1670 fn test_link_reference_exemption() {
1671 let rule = MD013LineLength::new(30, false, false, false, false);
1672 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1674 let result = rule.check(&ctx).unwrap();
1675
1676 assert_eq!(result.len(), 0);
1677 }
1678
1679 #[test]
1680 fn test_strict_mode() {
1681 let rule = MD013LineLength::new(30, false, false, false, true);
1682 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1684 let result = rule.check(&ctx).unwrap();
1685
1686 assert_eq!(result.len(), 1);
1688 }
1689
1690 #[test]
1691 fn test_blockquote_exemption() {
1692 let rule = MD013LineLength::new(30, false, false, false, false);
1693 let content = "> This is a very long line inside a blockquote that should be ignored.";
1694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1695 let result = rule.check(&ctx).unwrap();
1696
1697 assert_eq!(result.len(), 0);
1698 }
1699
1700 #[test]
1701 fn test_setext_heading_underline_exemption() {
1702 let rule = MD013LineLength::new(30, false, false, false, false);
1703 let content = "Heading\n========================================";
1704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1705 let result = rule.check(&ctx).unwrap();
1706
1707 assert_eq!(result.len(), 0);
1709 }
1710
1711 #[test]
1712 fn test_no_fix_without_reflow() {
1713 let rule = MD013LineLength::new(60, false, false, false, false);
1714 let content = "This line has trailing whitespace that makes it too long ";
1715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1716 let result = rule.check(&ctx).unwrap();
1717
1718 assert_eq!(result.len(), 1);
1719 assert!(result[0].fix.is_none());
1721
1722 let fixed = rule.fix(&ctx).unwrap();
1724 assert_eq!(fixed, content);
1725 }
1726
1727 #[test]
1728 fn test_character_vs_byte_counting() {
1729 let rule = MD013LineLength::new(10, false, false, false, false);
1730 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1733 let result = rule.check(&ctx).unwrap();
1734
1735 assert_eq!(result.len(), 1);
1736 assert_eq!(result[0].line, 1);
1737 }
1738
1739 #[test]
1740 fn test_empty_content() {
1741 let rule = MD013LineLength::default();
1742 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1743 let result = rule.check(&ctx).unwrap();
1744
1745 assert_eq!(result.len(), 0);
1746 }
1747
1748 #[test]
1749 fn test_excess_range_calculation() {
1750 let rule = MD013LineLength::new(10, false, false, false, false);
1751 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1753 let result = rule.check(&ctx).unwrap();
1754
1755 assert_eq!(result.len(), 1);
1756 assert_eq!(result[0].column, 11);
1758 assert_eq!(result[0].end_column, 21);
1759 }
1760
1761 #[test]
1762 fn test_html_block_exemption() {
1763 let rule = MD013LineLength::new(30, false, false, false, false);
1764 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1766 let result = rule.check(&ctx).unwrap();
1767
1768 assert_eq!(result.len(), 0);
1770 }
1771
1772 #[test]
1773 fn test_mixed_content() {
1774 let rule = MD013LineLength::new(30, false, false, false, false);
1776 let content = r#"# This heading is very long but should be exempt
1777
1778This regular paragraph line is too long and should trigger.
1779
1780```
1781Code block line that is very long but exempt.
1782```
1783
1784| Table | With very long content |
1785|-------|------------------------|
1786
1787Another long line that should trigger a warning."#;
1788
1789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1790 let result = rule.check(&ctx).unwrap();
1791
1792 assert_eq!(result.len(), 2);
1794 assert_eq!(result[0].line, 3);
1795 assert_eq!(result[1].line, 12);
1796 }
1797
1798 #[test]
1799 fn test_fix_without_reflow_preserves_content() {
1800 let rule = MD013LineLength::new(50, false, false, false, false);
1801 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1803
1804 let fixed = rule.fix(&ctx).unwrap();
1806 assert_eq!(fixed, content);
1807 }
1808
1809 #[test]
1810 fn test_content_detection() {
1811 let rule = MD013LineLength::default();
1812
1813 let long_line = "a".repeat(100);
1815 let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
1816 assert!(!rule.should_skip(&ctx)); let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1819 assert!(rule.should_skip(&empty_ctx)); }
1821
1822 #[test]
1823 fn test_rule_metadata() {
1824 let rule = MD013LineLength::default();
1825 assert_eq!(rule.name(), "MD013");
1826 assert_eq!(rule.description(), "Line length should not be excessive");
1827 assert_eq!(rule.category(), RuleCategory::Whitespace);
1828 }
1829
1830 #[test]
1831 fn test_url_embedded_in_text() {
1832 let rule = MD013LineLength::new(50, false, false, false, false);
1833
1834 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1837 let result = rule.check(&ctx).unwrap();
1838
1839 assert_eq!(result.len(), 0);
1841 }
1842
1843 #[test]
1844 fn test_multiple_urls_in_line() {
1845 let rule = MD013LineLength::new(50, false, false, false, false);
1846
1847 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1850
1851 let result = rule.check(&ctx).unwrap();
1852
1853 assert_eq!(result.len(), 0);
1855 }
1856
1857 #[test]
1858 fn test_markdown_link_with_long_url() {
1859 let rule = MD013LineLength::new(50, false, false, false, false);
1860
1861 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1864 let result = rule.check(&ctx).unwrap();
1865
1866 assert_eq!(result.len(), 0);
1868 }
1869
1870 #[test]
1871 fn test_line_too_long_even_without_urls() {
1872 let rule = MD013LineLength::new(50, false, false, false, false);
1873
1874 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1877 let result = rule.check(&ctx).unwrap();
1878
1879 assert_eq!(result.len(), 1);
1881 }
1882
1883 #[test]
1884 fn test_strict_mode_counts_urls() {
1885 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";
1889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1890 let result = rule.check(&ctx).unwrap();
1891
1892 assert_eq!(result.len(), 1);
1894 }
1895
1896 #[test]
1897 fn test_documentation_example_from_md051() {
1898 let rule = MD013LineLength::new(80, false, false, false, false);
1899
1900 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1903 let result = rule.check(&ctx).unwrap();
1904
1905 assert_eq!(result.len(), 0);
1907 }
1908
1909 #[test]
1910 fn test_text_reflow_simple() {
1911 let config = MD013Config {
1912 line_length: 30,
1913 reflow: true,
1914 ..Default::default()
1915 };
1916 let rule = MD013LineLength::from_config_struct(config);
1917
1918 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1920
1921 let fixed = rule.fix(&ctx).unwrap();
1922
1923 for line in fixed.lines() {
1925 assert!(
1926 line.chars().count() <= 30,
1927 "Line too long: {} (len={})",
1928 line,
1929 line.chars().count()
1930 );
1931 }
1932
1933 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1935 let original_words: Vec<&str> = content.split_whitespace().collect();
1936 assert_eq!(fixed_words, original_words);
1937 }
1938
1939 #[test]
1940 fn test_text_reflow_preserves_markdown_elements() {
1941 let config = MD013Config {
1942 line_length: 40,
1943 reflow: true,
1944 ..Default::default()
1945 };
1946 let rule = MD013LineLength::from_config_struct(config);
1947
1948 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1950
1951 let fixed = rule.fix(&ctx).unwrap();
1952
1953 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1955 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1956 assert!(
1957 fixed.contains("[a link](https://example.com)"),
1958 "Link not preserved in: {fixed}"
1959 );
1960
1961 for line in fixed.lines() {
1963 assert!(line.len() <= 40, "Line too long: {line}");
1964 }
1965 }
1966
1967 #[test]
1968 fn test_text_reflow_preserves_code_blocks() {
1969 let config = MD013Config {
1970 line_length: 30,
1971 reflow: true,
1972 ..Default::default()
1973 };
1974 let rule = MD013LineLength::from_config_struct(config);
1975
1976 let content = r#"Here is some text.
1977
1978```python
1979def very_long_function_name_that_exceeds_limit():
1980 return "This should not be wrapped"
1981```
1982
1983More text after code block."#;
1984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1985
1986 let fixed = rule.fix(&ctx).unwrap();
1987
1988 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1990 assert!(fixed.contains("```python"));
1991 assert!(fixed.contains("```"));
1992 }
1993
1994 #[test]
1995 fn test_text_reflow_preserves_lists() {
1996 let config = MD013Config {
1997 line_length: 30,
1998 reflow: true,
1999 ..Default::default()
2000 };
2001 let rule = MD013LineLength::from_config_struct(config);
2002
2003 let content = r#"Here is a list:
2004
20051. First item with a very long line that needs wrapping
20062. Second item is short
20073. Third item also has a long line that exceeds the limit
2008
2009And a bullet list:
2010
2011- Bullet item with very long content that needs wrapping
2012- Short bullet"#;
2013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2014
2015 let fixed = rule.fix(&ctx).unwrap();
2016
2017 assert!(fixed.contains("1. "));
2019 assert!(fixed.contains("2. "));
2020 assert!(fixed.contains("3. "));
2021 assert!(fixed.contains("- "));
2022
2023 let lines: Vec<&str> = fixed.lines().collect();
2025 for (i, line) in lines.iter().enumerate() {
2026 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
2027 if i + 1 < lines.len()
2029 && !lines[i + 1].trim().is_empty()
2030 && !lines[i + 1].trim().starts_with(char::is_numeric)
2031 && !lines[i + 1].trim().starts_with("-")
2032 {
2033 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
2035 }
2036 } else if line.trim().starts_with("-") {
2037 if i + 1 < lines.len()
2039 && !lines[i + 1].trim().is_empty()
2040 && !lines[i + 1].trim().starts_with(char::is_numeric)
2041 && !lines[i + 1].trim().starts_with("-")
2042 {
2043 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
2045 }
2046 }
2047 }
2048 }
2049
2050 #[test]
2051 fn test_issue_83_numbered_list_with_backticks() {
2052 let config = MD013Config {
2054 line_length: 100,
2055 reflow: true,
2056 ..Default::default()
2057 };
2058 let rule = MD013LineLength::from_config_struct(config);
2059
2060 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
2062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2063
2064 let fixed = rule.fix(&ctx).unwrap();
2065
2066 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
2069
2070 assert_eq!(
2071 fixed, expected,
2072 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
2073 );
2074 }
2075
2076 #[test]
2077 fn test_text_reflow_disabled_by_default() {
2078 let rule = MD013LineLength::new(30, false, false, false, false);
2079
2080 let content = "This is a very long line that definitely exceeds thirty characters.";
2081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2082
2083 let fixed = rule.fix(&ctx).unwrap();
2084
2085 assert_eq!(fixed, content);
2088 }
2089
2090 #[test]
2091 fn test_reflow_with_hard_line_breaks() {
2092 let config = MD013Config {
2094 line_length: 40,
2095 reflow: true,
2096 ..Default::default()
2097 };
2098 let rule = MD013LineLength::from_config_struct(config);
2099
2100 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";
2102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2103 let fixed = rule.fix(&ctx).unwrap();
2104
2105 assert!(
2107 fixed.contains(" \n"),
2108 "Hard line break with exactly 2 spaces should be preserved"
2109 );
2110 }
2111
2112 #[test]
2113 fn test_reflow_preserves_reference_links() {
2114 let config = MD013Config {
2115 line_length: 40,
2116 reflow: true,
2117 ..Default::default()
2118 };
2119 let rule = MD013LineLength::from_config_struct(config);
2120
2121 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
2122
2123[ref]: https://example.com";
2124 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2125 let fixed = rule.fix(&ctx).unwrap();
2126
2127 assert!(fixed.contains("[reference link][ref]"));
2129 assert!(!fixed.contains("[ reference link]"));
2130 assert!(!fixed.contains("[ref ]"));
2131 }
2132
2133 #[test]
2134 fn test_reflow_with_nested_markdown_elements() {
2135 let config = MD013Config {
2136 line_length: 35,
2137 reflow: true,
2138 ..Default::default()
2139 };
2140 let rule = MD013LineLength::from_config_struct(config);
2141
2142 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
2143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2144 let fixed = rule.fix(&ctx).unwrap();
2145
2146 assert!(fixed.contains("**bold with `code` inside**"));
2148 }
2149
2150 #[test]
2151 fn test_reflow_with_unbalanced_markdown() {
2152 let config = MD013Config {
2154 line_length: 30,
2155 reflow: true,
2156 ..Default::default()
2157 };
2158 let rule = MD013LineLength::from_config_struct(config);
2159
2160 let content = "This has **unbalanced bold that goes on for a very long time without closing";
2161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2162 let fixed = rule.fix(&ctx).unwrap();
2163
2164 assert!(!fixed.is_empty());
2168 for line in fixed.lines() {
2170 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
2171 }
2172 }
2173
2174 #[test]
2175 fn test_reflow_fix_indicator() {
2176 let config = MD013Config {
2178 line_length: 30,
2179 reflow: true,
2180 ..Default::default()
2181 };
2182 let rule = MD013LineLength::from_config_struct(config);
2183
2184 let content = "This is a very long line that definitely exceeds the thirty character limit";
2185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2186 let warnings = rule.check(&ctx).unwrap();
2187
2188 assert!(!warnings.is_empty());
2190 assert!(
2191 warnings[0].fix.is_some(),
2192 "Should provide fix indicator when reflow is true"
2193 );
2194 }
2195
2196 #[test]
2197 fn test_no_fix_indicator_without_reflow() {
2198 let config = MD013Config {
2200 line_length: 30,
2201 reflow: false,
2202 ..Default::default()
2203 };
2204 let rule = MD013LineLength::from_config_struct(config);
2205
2206 let content = "This is a very long line that definitely exceeds the thirty character limit";
2207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2208 let warnings = rule.check(&ctx).unwrap();
2209
2210 assert!(!warnings.is_empty());
2212 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
2213 }
2214
2215 #[test]
2216 fn test_reflow_preserves_all_reference_link_types() {
2217 let config = MD013Config {
2218 line_length: 40,
2219 reflow: true,
2220 ..Default::default()
2221 };
2222 let rule = MD013LineLength::from_config_struct(config);
2223
2224 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
2225
2226[ref]: https://example.com
2227[collapsed]: https://example.com
2228[shortcut]: https://example.com";
2229
2230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2231 let fixed = rule.fix(&ctx).unwrap();
2232
2233 assert!(fixed.contains("[full reference][ref]"));
2235 assert!(fixed.contains("[collapsed][]"));
2236 assert!(fixed.contains("[shortcut]"));
2237 }
2238
2239 #[test]
2240 fn test_reflow_handles_images_correctly() {
2241 let config = MD013Config {
2242 line_length: 40,
2243 reflow: true,
2244 ..Default::default()
2245 };
2246 let rule = MD013LineLength::from_config_struct(config);
2247
2248 let content = "This line has an  that should not be broken when reflowing.";
2249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2250 let fixed = rule.fix(&ctx).unwrap();
2251
2252 assert!(fixed.contains(""));
2254 }
2255
2256 #[test]
2257 fn test_normalize_mode_flags_short_lines() {
2258 let config = MD013Config {
2259 line_length: 100,
2260 reflow: true,
2261 reflow_mode: ReflowMode::Normalize,
2262 ..Default::default()
2263 };
2264 let rule = MD013LineLength::from_config_struct(config);
2265
2266 let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
2268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2269 let warnings = rule.check(&ctx).unwrap();
2270
2271 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2273 assert!(warnings[0].message.contains("normalized"));
2274 }
2275
2276 #[test]
2277 fn test_normalize_mode_combines_short_lines() {
2278 let config = MD013Config {
2279 line_length: 100,
2280 reflow: true,
2281 reflow_mode: ReflowMode::Normalize,
2282 ..Default::default()
2283 };
2284 let rule = MD013LineLength::from_config_struct(config);
2285
2286 let content =
2288 "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
2289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2290 let fixed = rule.fix(&ctx).unwrap();
2291
2292 let lines: Vec<&str> = fixed.lines().collect();
2294 assert_eq!(lines.len(), 1, "Should combine into single line");
2295 assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
2296 }
2297
2298 #[test]
2299 fn test_normalize_mode_preserves_paragraph_breaks() {
2300 let config = MD013Config {
2301 line_length: 100,
2302 reflow: true,
2303 reflow_mode: ReflowMode::Normalize,
2304 ..Default::default()
2305 };
2306 let rule = MD013LineLength::from_config_struct(config);
2307
2308 let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
2309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2310 let fixed = rule.fix(&ctx).unwrap();
2311
2312 assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
2314
2315 let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
2316 assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
2317 }
2318
2319 #[test]
2320 fn test_default_mode_only_fixes_violations() {
2321 let config = MD013Config {
2322 line_length: 100,
2323 reflow: true,
2324 reflow_mode: ReflowMode::Default, ..Default::default()
2326 };
2327 let rule = MD013LineLength::from_config_struct(config);
2328
2329 let content = "This is a short line.\nAnother short line.\nA third short line.";
2331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2332 let warnings = rule.check(&ctx).unwrap();
2333
2334 assert!(warnings.is_empty(), "Should not flag short lines in default mode");
2336
2337 let fixed = rule.fix(&ctx).unwrap();
2339 assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
2340 }
2341
2342 #[test]
2343 fn test_normalize_mode_with_lists() {
2344 let config = MD013Config {
2345 line_length: 80,
2346 reflow: true,
2347 reflow_mode: ReflowMode::Normalize,
2348 ..Default::default()
2349 };
2350 let rule = MD013LineLength::from_config_struct(config);
2351
2352 let content = r#"A paragraph with
2353short lines.
2354
23551. List item with
2356 short lines
23572. Another item"#;
2358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2359 let fixed = rule.fix(&ctx).unwrap();
2360
2361 let lines: Vec<&str> = fixed.lines().collect();
2363 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2364 assert!(fixed.contains("1. "), "Should preserve list markers");
2365 assert!(fixed.contains("2. "), "Should preserve list markers");
2366 }
2367
2368 #[test]
2369 fn test_normalize_mode_with_code_blocks() {
2370 let config = MD013Config {
2371 line_length: 100,
2372 reflow: true,
2373 reflow_mode: ReflowMode::Normalize,
2374 ..Default::default()
2375 };
2376 let rule = MD013LineLength::from_config_struct(config);
2377
2378 let content = r#"A paragraph with
2379short lines.
2380
2381```
2382code block should not be normalized
2383even with short lines
2384```
2385
2386Another paragraph with
2387short lines."#;
2388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2389 let fixed = rule.fix(&ctx).unwrap();
2390
2391 assert!(fixed.contains("code block should not be normalized\neven with short lines"));
2393 let lines: Vec<&str> = fixed.lines().collect();
2395 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2396 }
2397
2398 #[test]
2399 fn test_issue_76_use_case() {
2400 let config = MD013Config {
2402 line_length: 999999, reflow: true,
2404 reflow_mode: ReflowMode::Normalize,
2405 ..Default::default()
2406 };
2407 let rule = MD013LineLength::from_config_struct(config);
2408
2409 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.";
2411
2412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2413
2414 let warnings = rule.check(&ctx).unwrap();
2416 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2417
2418 let fixed = rule.fix(&ctx).unwrap();
2420 let lines: Vec<&str> = fixed.lines().collect();
2421 assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
2422 assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
2423 }
2424
2425 #[test]
2426 fn test_normalize_mode_single_line_unchanged() {
2427 let config = MD013Config {
2429 line_length: 100,
2430 reflow: true,
2431 reflow_mode: ReflowMode::Normalize,
2432 ..Default::default()
2433 };
2434 let rule = MD013LineLength::from_config_struct(config);
2435
2436 let content = "This is a single line that should not be changed.";
2437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2438
2439 let warnings = rule.check(&ctx).unwrap();
2440 assert!(warnings.is_empty(), "Single line should not be flagged");
2441
2442 let fixed = rule.fix(&ctx).unwrap();
2443 assert_eq!(fixed, content, "Single line should remain unchanged");
2444 }
2445
2446 #[test]
2447 fn test_normalize_mode_with_inline_code() {
2448 let config = MD013Config {
2449 line_length: 80,
2450 reflow: true,
2451 reflow_mode: ReflowMode::Normalize,
2452 ..Default::default()
2453 };
2454 let rule = MD013LineLength::from_config_struct(config);
2455
2456 let content =
2457 "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
2458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2459
2460 let warnings = rule.check(&ctx).unwrap();
2461 assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
2462
2463 let fixed = rule.fix(&ctx).unwrap();
2464 assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
2465 assert!(fixed.lines().count() < 3, "Lines should be combined");
2466 }
2467
2468 #[test]
2469 fn test_normalize_mode_with_emphasis() {
2470 let config = MD013Config {
2471 line_length: 100,
2472 reflow: true,
2473 reflow_mode: ReflowMode::Normalize,
2474 ..Default::default()
2475 };
2476 let rule = MD013LineLength::from_config_struct(config);
2477
2478 let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
2479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2480
2481 let fixed = rule.fix(&ctx).unwrap();
2482 assert!(fixed.contains("**bold**"), "Bold should be preserved");
2483 assert!(fixed.contains("*italic*"), "Italic should be preserved");
2484 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2485 }
2486
2487 #[test]
2488 fn test_normalize_mode_respects_hard_breaks() {
2489 let config = MD013Config {
2490 line_length: 100,
2491 reflow: true,
2492 reflow_mode: ReflowMode::Normalize,
2493 ..Default::default()
2494 };
2495 let rule = MD013LineLength::from_config_struct(config);
2496
2497 let content = "First line with hard break \nSecond line after break\nThird line";
2499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2500
2501 let fixed = rule.fix(&ctx).unwrap();
2502 assert!(fixed.contains(" \n"), "Hard break should be preserved");
2504 assert!(
2506 fixed.contains("Second line after break Third line"),
2507 "Lines without hard break should combine"
2508 );
2509 }
2510
2511 #[test]
2512 fn test_normalize_mode_with_links() {
2513 let config = MD013Config {
2514 line_length: 100,
2515 reflow: true,
2516 reflow_mode: ReflowMode::Normalize,
2517 ..Default::default()
2518 };
2519 let rule = MD013LineLength::from_config_struct(config);
2520
2521 let content =
2522 "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
2523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2524
2525 let fixed = rule.fix(&ctx).unwrap();
2526 assert!(
2527 fixed.contains("[link](https://example.com)"),
2528 "Link should be preserved"
2529 );
2530 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2531 }
2532
2533 #[test]
2534 fn test_normalize_mode_empty_lines_between_paragraphs() {
2535 let config = MD013Config {
2536 line_length: 100,
2537 reflow: true,
2538 reflow_mode: ReflowMode::Normalize,
2539 ..Default::default()
2540 };
2541 let rule = MD013LineLength::from_config_struct(config);
2542
2543 let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
2544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2545
2546 let fixed = rule.fix(&ctx).unwrap();
2547 assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
2549 let parts: Vec<&str> = fixed.split("\n\n\n").collect();
2551 assert_eq!(parts.len(), 2, "Should have two parts");
2552 assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
2553 assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
2554 }
2555
2556 #[test]
2557 fn test_normalize_mode_mixed_list_types() {
2558 let config = MD013Config {
2559 line_length: 80,
2560 reflow: true,
2561 reflow_mode: ReflowMode::Normalize,
2562 ..Default::default()
2563 };
2564 let rule = MD013LineLength::from_config_struct(config);
2565
2566 let content = r#"Paragraph before list
2567with multiple lines.
2568
2569- Bullet item
2570* Another bullet
2571+ Plus bullet
2572
25731. Numbered item
25742. Another number
2575
2576Paragraph after list
2577with multiple lines."#;
2578
2579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2580 let fixed = rule.fix(&ctx).unwrap();
2581
2582 assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
2584 assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
2585 assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
2586 assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
2587
2588 assert!(
2590 fixed.starts_with("Paragraph before list with multiple lines."),
2591 "First paragraph should be normalized"
2592 );
2593 assert!(
2594 fixed.ends_with("Paragraph after list with multiple lines."),
2595 "Last paragraph should be normalized"
2596 );
2597 }
2598
2599 #[test]
2600 fn test_normalize_mode_with_horizontal_rules() {
2601 let config = MD013Config {
2602 line_length: 100,
2603 reflow: true,
2604 reflow_mode: ReflowMode::Normalize,
2605 ..Default::default()
2606 };
2607 let rule = MD013LineLength::from_config_struct(config);
2608
2609 let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
2610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2611
2612 let fixed = rule.fix(&ctx).unwrap();
2613 assert!(fixed.contains("---"), "Horizontal rule should be preserved");
2614 assert!(
2615 fixed.contains("Paragraph before horizontal rule."),
2616 "First paragraph normalized"
2617 );
2618 assert!(
2619 fixed.contains("Paragraph after horizontal rule."),
2620 "Second paragraph normalized"
2621 );
2622 }
2623
2624 #[test]
2625 fn test_normalize_mode_with_indented_code() {
2626 let config = MD013Config {
2627 line_length: 100,
2628 reflow: true,
2629 reflow_mode: ReflowMode::Normalize,
2630 ..Default::default()
2631 };
2632 let rule = MD013LineLength::from_config_struct(config);
2633
2634 let content = "Paragraph before\nindented code.\n\n This is indented code\n Should not be normalized\n\nParagraph after\nindented code.";
2635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2636
2637 let fixed = rule.fix(&ctx).unwrap();
2638 assert!(
2639 fixed.contains(" This is indented code\n Should not be normalized"),
2640 "Indented code preserved"
2641 );
2642 assert!(
2643 fixed.contains("Paragraph before indented code."),
2644 "First paragraph normalized"
2645 );
2646 assert!(
2647 fixed.contains("Paragraph after indented code."),
2648 "Second paragraph normalized"
2649 );
2650 }
2651
2652 #[test]
2653 fn test_normalize_mode_disabled_without_reflow() {
2654 let config = MD013Config {
2656 line_length: 100,
2657 reflow: false, reflow_mode: ReflowMode::Normalize,
2659 ..Default::default()
2660 };
2661 let rule = MD013LineLength::from_config_struct(config);
2662
2663 let content = "This is a line\nwith breaks that\nshould not be changed.";
2664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2665
2666 let warnings = rule.check(&ctx).unwrap();
2667 assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2668
2669 let fixed = rule.fix(&ctx).unwrap();
2670 assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2671 }
2672
2673 #[test]
2674 fn test_default_mode_with_long_lines() {
2675 let config = MD013Config {
2678 line_length: 50,
2679 reflow: true,
2680 reflow_mode: ReflowMode::Default,
2681 ..Default::default()
2682 };
2683 let rule = MD013LineLength::from_config_struct(config);
2684
2685 let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2687
2688 let warnings = rule.check(&ctx).unwrap();
2689 assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2690 assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2692
2693 let fixed = rule.fix(&ctx).unwrap();
2694 assert!(
2696 fixed.contains("Short line. This is"),
2697 "Should combine and reflow the paragraph"
2698 );
2699 assert!(
2700 fixed.contains("wrapping. Another short"),
2701 "Should include all paragraph content"
2702 );
2703 }
2704
2705 #[test]
2706 fn test_normalize_vs_default_mode_same_content() {
2707 let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2709
2710 let default_config = MD013Config {
2712 line_length: 100,
2713 reflow: true,
2714 reflow_mode: ReflowMode::Default,
2715 ..Default::default()
2716 };
2717 let default_rule = MD013LineLength::from_config_struct(default_config);
2718 let default_warnings = default_rule.check(&ctx).unwrap();
2719 let default_fixed = default_rule.fix(&ctx).unwrap();
2720
2721 let normalize_config = MD013Config {
2723 line_length: 100,
2724 reflow: true,
2725 reflow_mode: ReflowMode::Normalize,
2726 ..Default::default()
2727 };
2728 let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2729 let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2730 let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2731
2732 assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2734 assert!(
2735 !normalize_warnings.is_empty(),
2736 "Normalize mode should flag multi-line paragraphs"
2737 );
2738
2739 assert_eq!(
2740 default_fixed, content,
2741 "Default mode should not change content without violations"
2742 );
2743 assert_ne!(
2744 normalize_fixed, content,
2745 "Normalize mode should change multi-line paragraphs"
2746 );
2747 assert_eq!(
2748 normalize_fixed.lines().count(),
2749 1,
2750 "Normalize should combine into single line"
2751 );
2752 }
2753
2754 #[test]
2755 fn test_normalize_mode_with_reference_definitions() {
2756 let config = MD013Config {
2757 line_length: 100,
2758 reflow: true,
2759 reflow_mode: ReflowMode::Normalize,
2760 ..Default::default()
2761 };
2762 let rule = MD013LineLength::from_config_struct(config);
2763
2764 let content =
2765 "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2767
2768 let fixed = rule.fix(&ctx).unwrap();
2769 assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2770 assert!(
2771 fixed.contains("[ref]: https://example.com"),
2772 "Reference definition should be preserved"
2773 );
2774 assert!(
2775 fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2776 "Paragraph should be normalized"
2777 );
2778 }
2779
2780 #[test]
2781 fn test_normalize_mode_with_html_comments() {
2782 let config = MD013Config {
2783 line_length: 100,
2784 reflow: true,
2785 reflow_mode: ReflowMode::Normalize,
2786 ..Default::default()
2787 };
2788 let rule = MD013LineLength::from_config_struct(config);
2789
2790 let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2792
2793 let fixed = rule.fix(&ctx).unwrap();
2794 assert!(
2795 fixed.contains("<!-- This is a comment -->"),
2796 "HTML comment should be preserved"
2797 );
2798 assert!(
2799 fixed.contains("Paragraph before HTML comment."),
2800 "First paragraph normalized"
2801 );
2802 assert!(
2803 fixed.contains("Paragraph after HTML comment."),
2804 "Second paragraph normalized"
2805 );
2806 }
2807
2808 #[test]
2809 fn test_normalize_mode_line_starting_with_number() {
2810 let config = MD013Config {
2812 line_length: 100,
2813 reflow: true,
2814 reflow_mode: ReflowMode::Normalize,
2815 ..Default::default()
2816 };
2817 let rule = MD013LineLength::from_config_struct(config);
2818
2819 let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2821
2822 let fixed = rule.fix(&ctx).unwrap();
2823 assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2824 assert!(
2825 fixed.contains("80 characters"),
2826 "Number at start of line should be preserved"
2827 );
2828 }
2829
2830 #[test]
2831 fn test_default_mode_preserves_list_structure() {
2832 let config = MD013Config {
2834 line_length: 80,
2835 reflow: true,
2836 reflow_mode: ReflowMode::Default,
2837 ..Default::default()
2838 };
2839 let rule = MD013LineLength::from_config_struct(config);
2840
2841 let content = r#"- This is a bullet point that has
2842 some text on multiple lines
2843 that should stay separate
2844
28451. Numbered list item with
2846 multiple lines that should
2847 also stay separate"#;
2848
2849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2850 let fixed = rule.fix(&ctx).unwrap();
2851
2852 let lines: Vec<&str> = fixed.lines().collect();
2854 assert_eq!(
2855 lines[0], "- This is a bullet point that has",
2856 "First line should be unchanged"
2857 );
2858 assert_eq!(
2859 lines[1], " some text on multiple lines",
2860 "Continuation should be preserved"
2861 );
2862 assert_eq!(
2863 lines[2], " that should stay separate",
2864 "Second continuation should be preserved"
2865 );
2866 }
2867
2868 #[test]
2869 fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2870 let config = MD013Config {
2872 line_length: 80,
2873 reflow: true,
2874 reflow_mode: ReflowMode::Normalize,
2875 ..Default::default()
2876 };
2877 let rule = MD013LineLength::from_config_struct(config);
2878
2879 let content = r#"- This is a bullet point that has
2880 some text on multiple lines
2881 that should be combined
2882
28831. Numbered list item with
2884 multiple lines that need
2885 to be properly combined
28862. Second item"#;
2887
2888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2889 let fixed = rule.fix(&ctx).unwrap();
2890
2891 assert!(
2893 !fixed.contains("lines that"),
2894 "Should not have double spaces in bullet list"
2895 );
2896 assert!(
2897 !fixed.contains("need to"),
2898 "Should not have double spaces in numbered list"
2899 );
2900
2901 assert!(
2903 fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2904 "Bullet list should be properly combined"
2905 );
2906 assert!(
2907 fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2908 "Numbered list should be properly combined"
2909 );
2910 }
2911
2912 #[test]
2913 fn test_normalize_mode_actual_numbered_list() {
2914 let config = MD013Config {
2916 line_length: 100,
2917 reflow: true,
2918 reflow_mode: ReflowMode::Normalize,
2919 ..Default::default()
2920 };
2921 let rule = MD013LineLength::from_config_struct(config);
2922
2923 let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2925
2926 let fixed = rule.fix(&ctx).unwrap();
2927 assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2928 assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2929 assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2930 assert!(
2931 fixed.starts_with("Paragraph before list with multiple lines."),
2932 "Paragraph should be normalized"
2933 );
2934 }
2935
2936 #[test]
2937 fn test_sentence_per_line_detection() {
2938 let config = MD013Config {
2939 reflow: true,
2940 reflow_mode: ReflowMode::SentencePerLine,
2941 ..Default::default()
2942 };
2943 let rule = MD013LineLength::from_config_struct(config.clone());
2944
2945 let content = "This is sentence one. This is sentence two. And sentence three!";
2947 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2948
2949 assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2951
2952 let result = rule.check(&ctx).unwrap();
2953
2954 assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2955 assert_eq!(result[0].message, "Line contains multiple sentences");
2956 }
2957
2958 #[test]
2959 fn test_sentence_per_line_fix() {
2960 let config = MD013Config {
2961 reflow: true,
2962 reflow_mode: ReflowMode::SentencePerLine,
2963 ..Default::default()
2964 };
2965 let rule = MD013LineLength::from_config_struct(config);
2966
2967 let content = "First sentence. Second sentence.";
2968 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2969 let result = rule.check(&ctx).unwrap();
2970
2971 assert!(!result.is_empty(), "Should detect violation");
2972 assert!(result[0].fix.is_some(), "Should provide a fix");
2973
2974 let fix = result[0].fix.as_ref().unwrap();
2975 assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2976 }
2977
2978 #[test]
2979 fn test_sentence_per_line_abbreviations() {
2980 let config = MD013Config {
2981 reflow: true,
2982 reflow_mode: ReflowMode::SentencePerLine,
2983 ..Default::default()
2984 };
2985 let rule = MD013LineLength::from_config_struct(config);
2986
2987 let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2989 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2990 let result = rule.check(&ctx).unwrap();
2991
2992 assert!(
2993 result.is_empty(),
2994 "Should not detect abbreviations as sentence boundaries"
2995 );
2996 }
2997
2998 #[test]
2999 fn test_sentence_per_line_with_markdown() {
3000 let config = MD013Config {
3001 reflow: true,
3002 reflow_mode: ReflowMode::SentencePerLine,
3003 ..Default::default()
3004 };
3005 let rule = MD013LineLength::from_config_struct(config);
3006
3007 let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
3008 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3009 let result = rule.check(&ctx).unwrap();
3010
3011 assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
3012 assert_eq!(result[0].line, 3); }
3014
3015 #[test]
3016 fn test_sentence_per_line_questions_exclamations() {
3017 let config = MD013Config {
3018 reflow: true,
3019 reflow_mode: ReflowMode::SentencePerLine,
3020 ..Default::default()
3021 };
3022 let rule = MD013LineLength::from_config_struct(config);
3023
3024 let content = "Is this a question? Yes it is! And a statement.";
3025 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3026 let result = rule.check(&ctx).unwrap();
3027
3028 assert!(!result.is_empty(), "Should detect sentences with ? and !");
3029
3030 let fix = result[0].fix.as_ref().unwrap();
3031 let lines: Vec<&str> = fix.replacement.trim().lines().collect();
3032 assert_eq!(lines.len(), 3);
3033 assert_eq!(lines[0], "Is this a question?");
3034 assert_eq!(lines[1], "Yes it is!");
3035 assert_eq!(lines[2], "And a statement.");
3036 }
3037
3038 #[test]
3039 fn test_sentence_per_line_in_lists() {
3040 let config = MD013Config {
3041 reflow: true,
3042 reflow_mode: ReflowMode::SentencePerLine,
3043 ..Default::default()
3044 };
3045 let rule = MD013LineLength::from_config_struct(config);
3046
3047 let content = "- List item one. With two sentences.\n- Another item.";
3048 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3049 let result = rule.check(&ctx).unwrap();
3050
3051 assert!(!result.is_empty(), "Should detect sentences in list items");
3052 let fix = result[0].fix.as_ref().unwrap();
3054 assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
3055 }
3056
3057 #[test]
3058 fn test_multi_paragraph_list_item_with_3_space_indent() {
3059 let config = MD013Config {
3060 reflow: true,
3061 reflow_mode: ReflowMode::Normalize,
3062 line_length: 999999,
3063 ..Default::default()
3064 };
3065 let rule = MD013LineLength::from_config_struct(config);
3066
3067 let content = "1. First paragraph\n continuation line.\n\n Second paragraph\n more content.";
3068 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3069 let result = rule.check(&ctx).unwrap();
3070
3071 assert!(!result.is_empty(), "Should detect multi-line paragraphs in list item");
3072 let fix = result[0].fix.as_ref().unwrap();
3073
3074 assert!(
3076 fix.replacement.contains("\n\n"),
3077 "Should preserve blank line between paragraphs"
3078 );
3079 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3080 }
3081
3082 #[test]
3083 fn test_multi_paragraph_list_item_with_4_space_indent() {
3084 let config = MD013Config {
3085 reflow: true,
3086 reflow_mode: ReflowMode::Normalize,
3087 line_length: 999999,
3088 ..Default::default()
3089 };
3090 let rule = MD013LineLength::from_config_struct(config);
3091
3092 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.";
3094 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3095 let result = rule.check(&ctx).unwrap();
3096
3097 assert!(
3098 !result.is_empty(),
3099 "Should detect multi-line paragraphs in list item with 4-space indent"
3100 );
3101 let fix = result[0].fix.as_ref().unwrap();
3102
3103 assert!(
3105 fix.replacement.contains("\n\n"),
3106 "Should preserve blank line between paragraphs"
3107 );
3108 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3109
3110 let lines: Vec<&str> = fix.replacement.split('\n').collect();
3112 let blank_line_idx = lines.iter().position(|l| l.trim().is_empty());
3113 assert!(blank_line_idx.is_some(), "Should have blank line separating paragraphs");
3114 }
3115
3116 #[test]
3117 fn test_multi_paragraph_bullet_list_item() {
3118 let config = MD013Config {
3119 reflow: true,
3120 reflow_mode: ReflowMode::Normalize,
3121 line_length: 999999,
3122 ..Default::default()
3123 };
3124 let rule = MD013LineLength::from_config_struct(config);
3125
3126 let content = "- First paragraph\n continuation.\n\n Second paragraph\n more text.";
3127 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3128 let result = rule.check(&ctx).unwrap();
3129
3130 assert!(!result.is_empty(), "Should detect multi-line paragraphs in bullet list");
3131 let fix = result[0].fix.as_ref().unwrap();
3132
3133 assert!(
3134 fix.replacement.contains("\n\n"),
3135 "Should preserve blank line between paragraphs"
3136 );
3137 assert!(fix.replacement.starts_with("- "), "Should preserve bullet marker");
3138 }
3139
3140 #[test]
3141 fn test_code_block_in_list_item_five_spaces() {
3142 let config = MD013Config {
3143 reflow: true,
3144 reflow_mode: ReflowMode::Normalize,
3145 line_length: 80,
3146 ..Default::default()
3147 };
3148 let rule = MD013LineLength::from_config_struct(config);
3149
3150 let content = "1. First paragraph with some text that should be reflowed.\n\n code_block()\n more_code()\n\n Second paragraph.";
3153 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3154 let result = rule.check(&ctx).unwrap();
3155
3156 if !result.is_empty() {
3157 let fix = result[0].fix.as_ref().unwrap();
3158 assert!(
3160 fix.replacement.contains(" code_block()"),
3161 "Code block should be preserved: {}",
3162 fix.replacement
3163 );
3164 assert!(
3165 fix.replacement.contains(" more_code()"),
3166 "Code block should be preserved: {}",
3167 fix.replacement
3168 );
3169 }
3170 }
3171
3172 #[test]
3173 fn test_fenced_code_block_in_list_item() {
3174 let config = MD013Config {
3175 reflow: true,
3176 reflow_mode: ReflowMode::Normalize,
3177 line_length: 80,
3178 ..Default::default()
3179 };
3180 let rule = MD013LineLength::from_config_struct(config);
3181
3182 let content = "1. First paragraph with some text.\n\n ```rust\n fn foo() {}\n let x = 1;\n ```\n\n Second paragraph.";
3183 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3184 let result = rule.check(&ctx).unwrap();
3185
3186 if !result.is_empty() {
3187 let fix = result[0].fix.as_ref().unwrap();
3188 assert!(
3190 fix.replacement.contains("```rust"),
3191 "Should preserve fence: {}",
3192 fix.replacement
3193 );
3194 assert!(
3195 fix.replacement.contains("fn foo() {}"),
3196 "Should preserve code: {}",
3197 fix.replacement
3198 );
3199 assert!(
3200 fix.replacement.contains("```"),
3201 "Should preserve closing fence: {}",
3202 fix.replacement
3203 );
3204 }
3205 }
3206
3207 #[test]
3208 fn test_mixed_indentation_3_and_4_spaces() {
3209 let config = MD013Config {
3210 reflow: true,
3211 reflow_mode: ReflowMode::Normalize,
3212 line_length: 999999,
3213 ..Default::default()
3214 };
3215 let rule = MD013LineLength::from_config_struct(config);
3216
3217 let content = "1. Text\n 3 space continuation\n 4 space continuation";
3219 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3220 let result = rule.check(&ctx).unwrap();
3221
3222 assert!(!result.is_empty(), "Should detect multi-line list item");
3223 let fix = result[0].fix.as_ref().unwrap();
3224 assert!(
3226 fix.replacement.contains("3 space continuation"),
3227 "Should include 3-space line: {}",
3228 fix.replacement
3229 );
3230 assert!(
3231 fix.replacement.contains("4 space continuation"),
3232 "Should include 4-space line: {}",
3233 fix.replacement
3234 );
3235 }
3236
3237 #[test]
3238 fn test_nested_list_in_multi_paragraph_item() {
3239 let config = MD013Config {
3240 reflow: true,
3241 reflow_mode: ReflowMode::Normalize,
3242 line_length: 999999,
3243 ..Default::default()
3244 };
3245 let rule = MD013LineLength::from_config_struct(config);
3246
3247 let content = "1. First paragraph.\n\n - Nested item\n continuation\n\n Second paragraph.";
3248 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3249 let result = rule.check(&ctx).unwrap();
3250
3251 assert!(!result.is_empty(), "Should detect and reflow parent item");
3253 if let Some(fix) = result[0].fix.as_ref() {
3254 assert!(
3256 fix.replacement.contains("- Nested"),
3257 "Should preserve nested list: {}",
3258 fix.replacement
3259 );
3260 assert!(
3261 fix.replacement.contains("Second paragraph"),
3262 "Should include content after nested list: {}",
3263 fix.replacement
3264 );
3265 }
3266 }
3267
3268 #[test]
3269 fn test_nested_fence_markers_different_types() {
3270 let config = MD013Config {
3271 reflow: true,
3272 reflow_mode: ReflowMode::Normalize,
3273 line_length: 80,
3274 ..Default::default()
3275 };
3276 let rule = MD013LineLength::from_config_struct(config);
3277
3278 let content = "1. Example with nested fences:\n\n ~~~markdown\n This shows ```python\n code = True\n ```\n ~~~\n\n Text after.";
3280 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3281 let result = rule.check(&ctx).unwrap();
3282
3283 if !result.is_empty() {
3284 let fix = result[0].fix.as_ref().unwrap();
3285 assert!(
3287 fix.replacement.contains("```python"),
3288 "Should preserve inner fence: {}",
3289 fix.replacement
3290 );
3291 assert!(
3292 fix.replacement.contains("~~~"),
3293 "Should preserve outer fence: {}",
3294 fix.replacement
3295 );
3296 assert!(
3298 fix.replacement.contains("code = True"),
3299 "Should preserve code: {}",
3300 fix.replacement
3301 );
3302 }
3303 }
3304
3305 #[test]
3306 fn test_nested_fence_markers_same_type() {
3307 let config = MD013Config {
3308 reflow: true,
3309 reflow_mode: ReflowMode::Normalize,
3310 line_length: 80,
3311 ..Default::default()
3312 };
3313 let rule = MD013LineLength::from_config_struct(config);
3314
3315 let content =
3317 "1. Example:\n\n ````markdown\n Shows ```python in code\n ```\n text here\n ````\n\n After.";
3318 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3319 let result = rule.check(&ctx).unwrap();
3320
3321 if !result.is_empty() {
3322 let fix = result[0].fix.as_ref().unwrap();
3323 assert!(
3325 fix.replacement.contains("```python"),
3326 "Should preserve inner fence: {}",
3327 fix.replacement
3328 );
3329 assert!(
3330 fix.replacement.contains("````"),
3331 "Should preserve outer fence: {}",
3332 fix.replacement
3333 );
3334 assert!(
3335 fix.replacement.contains("text here"),
3336 "Should keep text as code: {}",
3337 fix.replacement
3338 );
3339 }
3340 }
3341
3342 #[test]
3343 fn test_sibling_list_item_breaks_parent() {
3344 let config = MD013Config {
3345 reflow: true,
3346 reflow_mode: ReflowMode::Normalize,
3347 line_length: 999999,
3348 ..Default::default()
3349 };
3350 let rule = MD013LineLength::from_config_struct(config);
3351
3352 let content = "1. First item\n continuation.\n2. Second item";
3354 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3355 let result = rule.check(&ctx).unwrap();
3356
3357 if !result.is_empty() {
3359 let fix = result[0].fix.as_ref().unwrap();
3360 assert!(fix.replacement.starts_with("1. "), "Should start with first marker");
3362 assert!(fix.replacement.contains("continuation"), "Should include continuation");
3363 }
3365 }
3366
3367 #[test]
3368 fn test_nested_list_at_continuation_indent_preserved() {
3369 let config = MD013Config {
3370 reflow: true,
3371 reflow_mode: ReflowMode::Normalize,
3372 line_length: 999999,
3373 ..Default::default()
3374 };
3375 let rule = MD013LineLength::from_config_struct(config);
3376
3377 let content = "1. Parent paragraph\n with continuation.\n\n - Nested at 3 spaces\n - Another nested\n\n After nested.";
3379 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3380 let result = rule.check(&ctx).unwrap();
3381
3382 if !result.is_empty() {
3383 let fix = result[0].fix.as_ref().unwrap();
3384 assert!(
3386 fix.replacement.contains("- Nested"),
3387 "Should include first nested item: {}",
3388 fix.replacement
3389 );
3390 assert!(
3391 fix.replacement.contains("- Another"),
3392 "Should include second nested item: {}",
3393 fix.replacement
3394 );
3395 assert!(
3396 fix.replacement.contains("After nested"),
3397 "Should include content after nested list: {}",
3398 fix.replacement
3399 );
3400 }
3401 }
3402
3403 #[test]
3404 fn test_paragraphs_false_skips_regular_text() {
3405 let config = MD013Config {
3407 line_length: 50,
3408 paragraphs: false, code_blocks: true,
3410 tables: true,
3411 headings: true,
3412 strict: false,
3413 reflow: false,
3414 reflow_mode: ReflowMode::default(),
3415 };
3416 let rule = MD013LineLength::from_config_struct(config);
3417
3418 let content =
3419 "This is a very long line of regular text that exceeds fifty characters and should not trigger a warning.";
3420 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3421 let result = rule.check(&ctx).unwrap();
3422
3423 assert_eq!(
3425 result.len(),
3426 0,
3427 "Should not warn about long paragraph text when paragraphs=false"
3428 );
3429 }
3430
3431 #[test]
3432 fn test_paragraphs_false_still_checks_code_blocks() {
3433 let config = MD013Config {
3435 line_length: 50,
3436 paragraphs: false, code_blocks: true, tables: true,
3439 headings: true,
3440 strict: false,
3441 reflow: false,
3442 reflow_mode: ReflowMode::default(),
3443 };
3444 let rule = MD013LineLength::from_config_struct(config);
3445
3446 let content = r#"```
3447This is a very long line in a code block that exceeds fifty characters.
3448```"#;
3449 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3450 let result = rule.check(&ctx).unwrap();
3451
3452 assert_eq!(
3454 result.len(),
3455 1,
3456 "Should warn about long lines in code blocks even when paragraphs=false"
3457 );
3458 }
3459
3460 #[test]
3461 fn test_paragraphs_false_still_checks_headings() {
3462 let config = MD013Config {
3464 line_length: 50,
3465 paragraphs: false, code_blocks: true,
3467 tables: true,
3468 headings: true, strict: false,
3470 reflow: false,
3471 reflow_mode: ReflowMode::default(),
3472 };
3473 let rule = MD013LineLength::from_config_struct(config);
3474
3475 let content = "# This is a very long heading that exceeds fifty characters and should trigger a warning";
3476 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3477 let result = rule.check(&ctx).unwrap();
3478
3479 assert_eq!(
3481 result.len(),
3482 1,
3483 "Should warn about long headings even when paragraphs=false"
3484 );
3485 }
3486
3487 #[test]
3488 fn test_paragraphs_false_with_reflow_sentence_per_line() {
3489 let config = MD013Config {
3491 line_length: 80,
3492 paragraphs: false,
3493 code_blocks: true,
3494 tables: true,
3495 headings: false,
3496 strict: false,
3497 reflow: true,
3498 reflow_mode: ReflowMode::SentencePerLine,
3499 };
3500 let rule = MD013LineLength::from_config_struct(config);
3501
3502 let content = "This is a very long sentence that exceeds eighty characters and contains important information that should not be flagged.";
3503 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3504 let result = rule.check(&ctx).unwrap();
3505
3506 assert_eq!(
3508 result.len(),
3509 0,
3510 "Should not warn about long sentences when paragraphs=false"
3511 );
3512 }
3513
3514 #[test]
3515 fn test_paragraphs_true_checks_regular_text() {
3516 let config = MD013Config {
3518 line_length: 50,
3519 paragraphs: true, code_blocks: true,
3521 tables: true,
3522 headings: true,
3523 strict: false,
3524 reflow: false,
3525 reflow_mode: ReflowMode::default(),
3526 };
3527 let rule = MD013LineLength::from_config_struct(config);
3528
3529 let content = "This is a very long line of regular text that exceeds fifty characters.";
3530 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3531 let result = rule.check(&ctx).unwrap();
3532
3533 assert_eq!(
3535 result.len(),
3536 1,
3537 "Should warn about long paragraph text when paragraphs=true"
3538 );
3539 }
3540
3541 #[test]
3542 fn test_line_length_zero_disables_all_checks() {
3543 let config = MD013Config {
3545 line_length: 0, paragraphs: true,
3547 code_blocks: true,
3548 tables: true,
3549 headings: true,
3550 strict: false,
3551 reflow: false,
3552 reflow_mode: ReflowMode::default(),
3553 };
3554 let rule = MD013LineLength::from_config_struct(config);
3555
3556 let content = "This is a very very very very very very very very very very very very very very very very very very very very very very very very long line that would normally trigger MD013.";
3557 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3558 let result = rule.check(&ctx).unwrap();
3559
3560 assert_eq!(
3562 result.len(),
3563 0,
3564 "Should not warn about any line length when line_length = 0"
3565 );
3566 }
3567
3568 #[test]
3569 fn test_line_length_zero_with_headings() {
3570 let config = MD013Config {
3572 line_length: 0, paragraphs: true,
3574 code_blocks: true,
3575 tables: true,
3576 headings: true, strict: false,
3578 reflow: false,
3579 reflow_mode: ReflowMode::default(),
3580 };
3581 let rule = MD013LineLength::from_config_struct(config);
3582
3583 let content = "# This is a very very very very very very very very very very very very very very very very very very very very very long heading";
3584 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3585 let result = rule.check(&ctx).unwrap();
3586
3587 assert_eq!(
3589 result.len(),
3590 0,
3591 "Should not warn about heading line length when line_length = 0"
3592 );
3593 }
3594
3595 #[test]
3596 fn test_line_length_zero_with_code_blocks() {
3597 let config = MD013Config {
3599 line_length: 0, paragraphs: true,
3601 code_blocks: true, tables: true,
3603 headings: true,
3604 strict: false,
3605 reflow: false,
3606 reflow_mode: ReflowMode::default(),
3607 };
3608 let rule = MD013LineLength::from_config_struct(config);
3609
3610 let content = "```\nThis is a very very very very very very very very very very very very very very very very very very very very very long code line\n```";
3611 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3612 let result = rule.check(&ctx).unwrap();
3613
3614 assert_eq!(
3616 result.len(),
3617 0,
3618 "Should not warn about code block line length when line_length = 0"
3619 );
3620 }
3621
3622 #[test]
3623 fn test_line_length_zero_with_sentence_per_line_reflow() {
3624 let config = MD013Config {
3626 line_length: 0, paragraphs: true,
3628 code_blocks: true,
3629 tables: true,
3630 headings: true,
3631 strict: false,
3632 reflow: true,
3633 reflow_mode: ReflowMode::SentencePerLine,
3634 };
3635 let rule = MD013LineLength::from_config_struct(config);
3636
3637 let content = "This is sentence one. This is sentence two. This is sentence three.";
3638 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3639 let result = rule.check(&ctx).unwrap();
3640
3641 assert_eq!(result.len(), 1, "Should provide reflow fix for multiple sentences");
3643 assert!(result[0].fix.is_some(), "Should have a fix available");
3644 }
3645
3646 #[test]
3647 fn test_line_length_zero_config_parsing() {
3648 let toml_str = r#"
3650 line-length = 0
3651 paragraphs = true
3652 reflow = true
3653 reflow-mode = "sentence-per-line"
3654 "#;
3655 let config: MD013Config = toml::from_str(toml_str).unwrap();
3656 assert_eq!(config.line_length, 0, "Should parse line_length = 0");
3657 assert!(config.paragraphs);
3658 assert!(config.reflow);
3659 assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
3660 }
3661}