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;
16pub use md013_config::MD013Config;
17use md013_config::ReflowMode;
18
19#[derive(Clone, Default)]
20pub struct MD013LineLength {
21 pub(crate) config: MD013Config,
22}
23
24impl MD013LineLength {
25 pub fn new(line_length: usize, code_blocks: bool, tables: bool, headings: bool, strict: bool) -> Self {
26 Self {
27 config: MD013Config {
28 line_length: crate::types::LineLength::new(line_length),
29 code_blocks,
30 tables,
31 headings,
32 paragraphs: true, strict,
34 reflow: false,
35 reflow_mode: ReflowMode::default(),
36 },
37 }
38 }
39
40 pub fn from_config_struct(config: MD013Config) -> Self {
41 Self { config }
42 }
43
44 fn should_ignore_line(
45 &self,
46 line: &str,
47 _lines: &[&str],
48 current_line: usize,
49 ctx: &crate::lint_context::LintContext,
50 ) -> bool {
51 if self.config.strict {
52 return false;
53 }
54
55 let trimmed = line.trim();
57
58 if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
60 return true;
61 }
62
63 if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
65 return true;
66 }
67
68 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
70 return true;
71 }
72
73 if ctx.line_info(current_line + 1).is_some_and(|info| info.in_code_block)
75 && !trimmed.is_empty()
76 && !line.contains(' ')
77 && !line.contains('\t')
78 {
79 return true;
80 }
81
82 false
83 }
84}
85
86impl Rule for MD013LineLength {
87 fn name(&self) -> &'static str {
88 "MD013"
89 }
90
91 fn description(&self) -> &'static str {
92 "Line length should not be excessive"
93 }
94
95 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
96 let content = ctx.content;
97
98 if self.should_skip(ctx)
101 && !(self.config.reflow
102 && (self.config.reflow_mode == ReflowMode::Normalize
103 || self.config.reflow_mode == ReflowMode::SentencePerLine))
104 {
105 return Ok(Vec::new());
106 }
107
108 let mut warnings = Vec::new();
110
111 let inline_config = crate::inline_config::InlineConfig::from_content(content);
113 let config_override = inline_config.get_rule_config("MD013");
114
115 let effective_config = if let Some(json_config) = config_override {
117 if let Some(obj) = json_config.as_object() {
118 let mut config = self.config.clone();
119 if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
120 config.line_length = crate::types::LineLength::new(line_length as usize);
121 }
122 if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
123 config.code_blocks = code_blocks;
124 }
125 if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
126 config.tables = tables;
127 }
128 if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
129 config.headings = headings;
130 }
131 if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
132 config.strict = strict;
133 }
134 if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
135 config.reflow = reflow;
136 }
137 if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
138 config.reflow_mode = match reflow_mode {
139 "default" => ReflowMode::Default,
140 "normalize" => ReflowMode::Normalize,
141 "sentence-per-line" => ReflowMode::SentencePerLine,
142 _ => ReflowMode::default(),
143 };
144 }
145 config
146 } else {
147 self.config.clone()
148 }
149 } else {
150 self.config.clone()
151 };
152
153 let skip_length_checks = effective_config.line_length.is_unlimited();
156
157 let mut candidate_lines = Vec::new();
159 if !skip_length_checks {
160 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
161 if line_info.in_front_matter {
163 continue;
164 }
165
166 if line_info.byte_len > effective_config.line_length.get() {
168 candidate_lines.push(line_idx);
169 }
170 }
171 }
172
173 if candidate_lines.is_empty()
175 && !(effective_config.reflow
176 && (effective_config.reflow_mode == ReflowMode::Normalize
177 || effective_config.reflow_mode == ReflowMode::SentencePerLine))
178 {
179 return Ok(warnings);
180 }
181
182 let lines: Vec<&str> = if !ctx.lines.is_empty() {
184 ctx.lines.iter().map(|l| l.content(ctx.content)).collect()
185 } else {
186 content.lines().collect()
187 };
188
189 let heading_lines_set: std::collections::HashSet<usize> = ctx
192 .lines
193 .iter()
194 .enumerate()
195 .filter(|(_, line)| line.heading.is_some())
196 .map(|(idx, _)| idx + 1)
197 .collect();
198
199 let table_blocks = &ctx.table_blocks;
202 let mut table_lines_set = std::collections::HashSet::new();
203 for table in table_blocks {
204 table_lines_set.insert(table.header_line + 1);
205 table_lines_set.insert(table.delimiter_line + 1);
206 for &line in &table.content_lines {
207 table_lines_set.insert(line + 1);
208 }
209 }
210
211 for &line_idx in &candidate_lines {
213 let line_number = line_idx + 1;
214 let line = lines[line_idx];
215
216 let effective_length = self.calculate_effective_length(line);
218
219 let line_limit = effective_config.line_length.get();
221
222 if effective_length <= line_limit {
224 continue;
225 }
226
227 if ctx.lines[line_idx].in_mkdocstrings {
229 continue;
230 }
231
232 if !effective_config.strict {
234 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
236 continue;
237 }
238
239 if (!effective_config.headings && heading_lines_set.contains(&line_number))
243 || (!effective_config.code_blocks
244 && ctx.line_info(line_number).is_some_and(|info| info.in_code_block))
245 || (!effective_config.tables && table_lines_set.contains(&line_number))
246 || ctx.lines[line_number - 1].blockquote.is_some()
247 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
248 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
249 || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block)
250 {
251 continue;
252 }
253
254 if !effective_config.paragraphs {
257 let is_special_block = heading_lines_set.contains(&line_number)
258 || ctx.line_info(line_number).is_some_and(|info| info.in_code_block)
259 || table_lines_set.contains(&line_number)
260 || ctx.lines[line_number - 1].blockquote.is_some()
261 || ctx.line_info(line_number).is_some_and(|info| info.in_html_block)
262 || ctx.line_info(line_number).is_some_and(|info| info.in_html_comment)
263 || ctx.line_info(line_number).is_some_and(|info| info.in_esm_block);
264
265 if !is_special_block {
267 continue;
268 }
269 }
270
271 if self.should_ignore_line(line, &lines, line_idx, ctx) {
273 continue;
274 }
275 }
276
277 if effective_config.reflow_mode == ReflowMode::SentencePerLine {
280 let sentences = split_into_sentences(line.trim());
281 if sentences.len() == 1 {
282 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
284
285 let (start_line, start_col, end_line, end_col) =
286 calculate_excess_range(line_number, line, line_limit);
287
288 warnings.push(LintWarning {
289 rule_name: Some(self.name().to_string()),
290 message,
291 line: start_line,
292 column: start_col,
293 end_line,
294 end_column: end_col,
295 severity: Severity::Warning,
296 fix: None, });
298 continue;
299 }
300 continue;
302 }
303
304 let fix = None;
307
308 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
309
310 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
312
313 warnings.push(LintWarning {
314 rule_name: Some(self.name().to_string()),
315 message,
316 line: start_line,
317 column: start_col,
318 end_line,
319 end_column: end_col,
320 severity: Severity::Warning,
321 fix,
322 });
323 }
324
325 if effective_config.reflow {
327 let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, &lines);
328 for pw in paragraph_warnings {
330 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
332 warnings.push(pw);
333 }
334 }
335
336 Ok(warnings)
337 }
338
339 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
340 let warnings = self.check(ctx)?;
343
344 if !warnings.iter().any(|w| w.fix.is_some()) {
346 return Ok(ctx.content.to_string());
347 }
348
349 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
351 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
352 }
353
354 fn as_any(&self) -> &dyn std::any::Any {
355 self
356 }
357
358 fn category(&self) -> RuleCategory {
359 RuleCategory::Whitespace
360 }
361
362 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
363 if ctx.content.is_empty() {
365 return true;
366 }
367
368 if self.config.reflow
370 && (self.config.reflow_mode == ReflowMode::SentencePerLine
371 || self.config.reflow_mode == ReflowMode::Normalize)
372 {
373 return false;
374 }
375
376 if ctx.content.len() <= self.config.line_length.get() {
378 return true;
379 }
380
381 !ctx.lines
383 .iter()
384 .any(|line| line.byte_len > self.config.line_length.get())
385 }
386
387 fn default_config_section(&self) -> Option<(String, toml::Value)> {
388 let default_config = MD013Config::default();
389 let json_value = serde_json::to_value(&default_config).ok()?;
390 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
391
392 if let toml::Value::Table(table) = toml_value {
393 if !table.is_empty() {
394 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
395 } else {
396 None
397 }
398 } else {
399 None
400 }
401 }
402
403 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
404 let mut aliases = std::collections::HashMap::new();
405 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
406 Some(aliases)
407 }
408
409 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
410 where
411 Self: Sized,
412 {
413 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
414 if rule_config.line_length.get() == 80 {
416 rule_config.line_length = crate::types::LineLength::new(config.global.line_length as usize);
418 }
419 Box::new(Self::from_config_struct(rule_config))
420 }
421}
422
423impl MD013LineLength {
424 fn generate_paragraph_fixes(
426 &self,
427 ctx: &crate::lint_context::LintContext,
428 config: &MD013Config,
429 lines: &[&str],
430 ) -> Vec<LintWarning> {
431 let mut warnings = Vec::new();
432 let line_index = LineIndex::new(ctx.content);
433
434 let mut i = 0;
435 while i < lines.len() {
436 let line_num = i + 1;
437
438 let should_skip_due_to_line_info = ctx.line_info(line_num).is_some_and(|info| {
440 info.in_code_block
441 || info.in_front_matter
442 || info.in_html_block
443 || info.in_html_comment
444 || info.in_esm_block
445 });
446
447 if should_skip_due_to_line_info
448 || (line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some())
449 || lines[i].trim().starts_with('#')
450 || TableUtils::is_potential_table_row(lines[i])
451 || lines[i].trim().is_empty()
452 || is_horizontal_rule(lines[i].trim())
453 || is_template_directive_only(lines[i])
454 {
455 i += 1;
456 continue;
457 }
458
459 let is_semantic_line = |content: &str| -> bool {
461 let trimmed = content.trim_start();
462 let semantic_markers = [
463 "NOTE:",
464 "WARNING:",
465 "IMPORTANT:",
466 "CAUTION:",
467 "TIP:",
468 "DANGER:",
469 "HINT:",
470 "INFO:",
471 ];
472 semantic_markers.iter().any(|marker| trimmed.starts_with(marker))
473 };
474
475 let is_fence_marker = |content: &str| -> bool {
477 let trimmed = content.trim_start();
478 trimmed.starts_with("```") || trimmed.starts_with("~~~")
479 };
480
481 let trimmed = lines[i].trim();
483 if is_list_item(trimmed) {
484 let list_start = i;
486 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
487 let marker_len = marker.len();
488
489 #[derive(Clone)]
491 enum LineType {
492 Content(String),
493 CodeBlock(String, usize), NestedListItem(String, usize), SemanticLine(String), Empty,
497 }
498
499 let mut actual_indent: Option<usize> = None;
500 let mut list_item_lines: Vec<LineType> = vec![LineType::Content(first_content)];
501 i += 1;
502
503 while i < lines.len() {
505 let line_info = &ctx.lines[i];
506
507 if line_info.is_blank {
509 if i + 1 < lines.len() {
511 let next_info = &ctx.lines[i + 1];
512
513 if !next_info.is_blank && next_info.indent >= marker_len {
515 list_item_lines.push(LineType::Empty);
517 i += 1;
518 continue;
519 }
520 }
521 break;
523 }
524
525 let indent = line_info.indent;
527
528 if indent >= marker_len {
530 let trimmed = line_info.content(ctx.content).trim();
531
532 if line_info.in_code_block {
534 list_item_lines.push(LineType::CodeBlock(
535 line_info.content(ctx.content)[indent..].to_string(),
536 indent,
537 ));
538 i += 1;
539 continue;
540 }
541
542 if is_list_item(trimmed) && indent < marker_len {
546 break;
548 }
549
550 if is_list_item(trimmed) && indent >= marker_len {
555 let has_blank_before = matches!(list_item_lines.last(), Some(LineType::Empty));
557
558 let has_nested_content = list_item_lines.iter().any(|line| {
560 matches!(line, LineType::Content(c) if is_list_item(c.trim()))
561 || matches!(line, LineType::NestedListItem(_, _))
562 });
563
564 if !has_blank_before && !has_nested_content {
565 break;
568 }
569 list_item_lines.push(LineType::NestedListItem(
572 line_info.content(ctx.content)[indent..].to_string(),
573 indent,
574 ));
575 i += 1;
576 continue;
577 }
578
579 if indent <= marker_len + 3 {
581 if actual_indent.is_none() {
583 actual_indent = Some(indent);
584 }
585
586 let content = trim_preserving_hard_break(&line_info.content(ctx.content)[indent..]);
590
591 if is_fence_marker(&content) {
594 list_item_lines.push(LineType::CodeBlock(content, indent));
595 }
596 else if is_semantic_line(&content) {
598 list_item_lines.push(LineType::SemanticLine(content));
599 } else {
600 list_item_lines.push(LineType::Content(content));
601 }
602 i += 1;
603 } else {
604 list_item_lines.push(LineType::CodeBlock(
606 line_info.content(ctx.content)[indent..].to_string(),
607 indent,
608 ));
609 i += 1;
610 }
611 } else {
612 break;
614 }
615 }
616
617 let indent_size = actual_indent.unwrap_or(marker_len);
619 let expected_indent = " ".repeat(indent_size);
620
621 #[derive(Clone)]
623 enum Block {
624 Paragraph(Vec<String>),
625 Code {
626 lines: Vec<(String, usize)>, has_preceding_blank: bool, },
629 NestedList(Vec<(String, usize)>), SemanticLine(String), Html {
632 lines: Vec<String>, has_preceding_blank: bool, },
635 }
636
637 const BLOCK_LEVEL_TAGS: &[&str] = &[
640 "div",
641 "details",
642 "summary",
643 "section",
644 "article",
645 "header",
646 "footer",
647 "nav",
648 "aside",
649 "main",
650 "table",
651 "thead",
652 "tbody",
653 "tfoot",
654 "tr",
655 "td",
656 "th",
657 "ul",
658 "ol",
659 "li",
660 "dl",
661 "dt",
662 "dd",
663 "pre",
664 "blockquote",
665 "figure",
666 "figcaption",
667 "form",
668 "fieldset",
669 "legend",
670 "hr",
671 "p",
672 "h1",
673 "h2",
674 "h3",
675 "h4",
676 "h5",
677 "h6",
678 "style",
679 "script",
680 "noscript",
681 ];
682
683 fn is_block_html_opening_tag(line: &str) -> Option<String> {
684 let trimmed = line.trim();
685
686 if trimmed.starts_with("<!--") {
688 return Some("!--".to_string());
689 }
690
691 if trimmed.starts_with('<') && !trimmed.starts_with("</") && !trimmed.starts_with("<!") {
693 let after_bracket = &trimmed[1..];
695 if let Some(end) = after_bracket.find(|c: char| c.is_whitespace() || c == '>' || c == '/') {
696 let tag_name = after_bracket[..end].to_lowercase();
697
698 if BLOCK_LEVEL_TAGS.contains(&tag_name.as_str()) {
700 return Some(tag_name);
701 }
702 }
703 }
704 None
705 }
706
707 fn is_html_closing_tag(line: &str, tag_name: &str) -> bool {
708 let trimmed = line.trim();
709
710 if tag_name == "!--" {
712 return trimmed.ends_with("-->");
713 }
714
715 trimmed.starts_with(&format!("</{tag_name}>"))
717 || trimmed.starts_with(&format!("</{tag_name} "))
718 || (trimmed.starts_with("</") && trimmed[2..].trim_start().starts_with(tag_name))
719 }
720
721 fn is_self_closing_tag(line: &str) -> bool {
722 let trimmed = line.trim();
723 trimmed.ends_with("/>")
724 }
725
726 let mut blocks: Vec<Block> = Vec::new();
727 let mut current_paragraph: Vec<String> = Vec::new();
728 let mut current_code_block: Vec<(String, usize)> = Vec::new();
729 let mut current_nested_list: Vec<(String, usize)> = Vec::new();
730 let mut current_html_block: Vec<String> = Vec::new();
731 let mut html_tag_stack: Vec<String> = Vec::new();
732 let mut in_code = false;
733 let mut in_nested_list = false;
734 let mut in_html_block = false;
735 let mut had_preceding_blank = false; let mut code_block_has_preceding_blank = false; let mut html_block_has_preceding_blank = false; for line in &list_item_lines {
740 match line {
741 LineType::Empty => {
742 if in_code {
743 current_code_block.push((String::new(), 0));
744 } else if in_nested_list {
745 current_nested_list.push((String::new(), 0));
746 } else if in_html_block {
747 current_html_block.push(String::new());
749 } else if !current_paragraph.is_empty() {
750 blocks.push(Block::Paragraph(current_paragraph.clone()));
751 current_paragraph.clear();
752 }
753 had_preceding_blank = true;
755 }
756 LineType::Content(content) => {
757 if in_html_block {
759 current_html_block.push(content.clone());
760
761 if let Some(last_tag) = html_tag_stack.last() {
763 if is_html_closing_tag(content, last_tag) {
764 html_tag_stack.pop();
765
766 if html_tag_stack.is_empty() {
768 blocks.push(Block::Html {
769 lines: current_html_block.clone(),
770 has_preceding_blank: html_block_has_preceding_blank,
771 });
772 current_html_block.clear();
773 in_html_block = false;
774 }
775 } else if let Some(new_tag) = is_block_html_opening_tag(content) {
776 if !is_self_closing_tag(content) {
778 html_tag_stack.push(new_tag);
779 }
780 }
781 }
782 had_preceding_blank = false;
783 } else {
784 if let Some(tag_name) = is_block_html_opening_tag(content) {
786 if in_code {
788 blocks.push(Block::Code {
789 lines: current_code_block.clone(),
790 has_preceding_blank: code_block_has_preceding_blank,
791 });
792 current_code_block.clear();
793 in_code = false;
794 } else if in_nested_list {
795 blocks.push(Block::NestedList(current_nested_list.clone()));
796 current_nested_list.clear();
797 in_nested_list = false;
798 } else if !current_paragraph.is_empty() {
799 blocks.push(Block::Paragraph(current_paragraph.clone()));
800 current_paragraph.clear();
801 }
802
803 in_html_block = true;
805 html_block_has_preceding_blank = had_preceding_blank;
806 current_html_block.push(content.clone());
807
808 if is_self_closing_tag(content) {
810 blocks.push(Block::Html {
812 lines: current_html_block.clone(),
813 has_preceding_blank: html_block_has_preceding_blank,
814 });
815 current_html_block.clear();
816 in_html_block = false;
817 } else {
818 html_tag_stack.push(tag_name);
820 }
821 } else {
822 if in_code {
824 blocks.push(Block::Code {
826 lines: current_code_block.clone(),
827 has_preceding_blank: code_block_has_preceding_blank,
828 });
829 current_code_block.clear();
830 in_code = false;
831 } else if in_nested_list {
832 blocks.push(Block::NestedList(current_nested_list.clone()));
834 current_nested_list.clear();
835 in_nested_list = false;
836 }
837 current_paragraph.push(content.clone());
838 }
839 had_preceding_blank = false; }
841 }
842 LineType::CodeBlock(content, indent) => {
843 if in_nested_list {
844 blocks.push(Block::NestedList(current_nested_list.clone()));
846 current_nested_list.clear();
847 in_nested_list = false;
848 } else if in_html_block {
849 blocks.push(Block::Html {
851 lines: current_html_block.clone(),
852 has_preceding_blank: html_block_has_preceding_blank,
853 });
854 current_html_block.clear();
855 html_tag_stack.clear();
856 in_html_block = false;
857 }
858 if !in_code {
859 if !current_paragraph.is_empty() {
861 blocks.push(Block::Paragraph(current_paragraph.clone()));
862 current_paragraph.clear();
863 }
864 in_code = true;
865 code_block_has_preceding_blank = had_preceding_blank;
867 }
868 current_code_block.push((content.clone(), *indent));
869 had_preceding_blank = false; }
871 LineType::NestedListItem(content, indent) => {
872 if in_code {
873 blocks.push(Block::Code {
875 lines: current_code_block.clone(),
876 has_preceding_blank: code_block_has_preceding_blank,
877 });
878 current_code_block.clear();
879 in_code = false;
880 } else if in_html_block {
881 blocks.push(Block::Html {
883 lines: current_html_block.clone(),
884 has_preceding_blank: html_block_has_preceding_blank,
885 });
886 current_html_block.clear();
887 html_tag_stack.clear();
888 in_html_block = false;
889 }
890 if !in_nested_list {
891 if !current_paragraph.is_empty() {
893 blocks.push(Block::Paragraph(current_paragraph.clone()));
894 current_paragraph.clear();
895 }
896 in_nested_list = true;
897 }
898 current_nested_list.push((content.clone(), *indent));
899 had_preceding_blank = false; }
901 LineType::SemanticLine(content) => {
902 if in_code {
904 blocks.push(Block::Code {
905 lines: current_code_block.clone(),
906 has_preceding_blank: code_block_has_preceding_blank,
907 });
908 current_code_block.clear();
909 in_code = false;
910 } else if in_nested_list {
911 blocks.push(Block::NestedList(current_nested_list.clone()));
912 current_nested_list.clear();
913 in_nested_list = false;
914 } else if in_html_block {
915 blocks.push(Block::Html {
916 lines: current_html_block.clone(),
917 has_preceding_blank: html_block_has_preceding_blank,
918 });
919 current_html_block.clear();
920 html_tag_stack.clear();
921 in_html_block = false;
922 } else if !current_paragraph.is_empty() {
923 blocks.push(Block::Paragraph(current_paragraph.clone()));
924 current_paragraph.clear();
925 }
926 blocks.push(Block::SemanticLine(content.clone()));
928 had_preceding_blank = false; }
930 }
931 }
932
933 if in_code && !current_code_block.is_empty() {
935 blocks.push(Block::Code {
936 lines: current_code_block,
937 has_preceding_blank: code_block_has_preceding_blank,
938 });
939 } else if in_nested_list && !current_nested_list.is_empty() {
940 blocks.push(Block::NestedList(current_nested_list));
941 } else if in_html_block && !current_html_block.is_empty() {
942 blocks.push(Block::Html {
945 lines: current_html_block,
946 has_preceding_blank: html_block_has_preceding_blank,
947 });
948 } else if !current_paragraph.is_empty() {
949 blocks.push(Block::Paragraph(current_paragraph));
950 }
951
952 let content_lines: Vec<String> = list_item_lines
954 .iter()
955 .filter_map(|line| {
956 if let LineType::Content(s) = line {
957 Some(s.clone())
958 } else {
959 None
960 }
961 })
962 .collect();
963
964 let combined_content = content_lines.join(" ").trim().to_string();
967 let full_line = format!("{marker}{combined_content}");
968
969 let should_normalize = || {
971 let has_nested_lists = blocks.iter().any(|b| matches!(b, Block::NestedList(_)));
974 let has_code_blocks = blocks.iter().any(|b| matches!(b, Block::Code { .. }));
975 let has_semantic_lines = blocks.iter().any(|b| matches!(b, Block::SemanticLine(_)));
976 let has_paragraphs = blocks.iter().any(|b| matches!(b, Block::Paragraph(_)));
977
978 if (has_nested_lists || has_code_blocks || has_semantic_lines) && !has_paragraphs {
980 return false;
981 }
982
983 if has_paragraphs {
985 let paragraph_count = blocks.iter().filter(|b| matches!(b, Block::Paragraph(_))).count();
986 if paragraph_count > 1 {
987 return true;
989 }
990
991 if content_lines.len() > 1 {
993 return true;
994 }
995 }
996
997 false
998 };
999
1000 let needs_reflow = match config.reflow_mode {
1001 ReflowMode::Normalize => {
1002 let combined_length = self.calculate_effective_length(&full_line);
1006 if combined_length > config.line_length.get() {
1007 true
1008 } else {
1009 should_normalize()
1010 }
1011 }
1012 ReflowMode::SentencePerLine => {
1013 let sentences = split_into_sentences(&combined_content);
1015 sentences.len() > 1
1016 }
1017 ReflowMode::Default => {
1018 self.calculate_effective_length(&full_line) > config.line_length.get()
1020 }
1021 };
1022
1023 if needs_reflow {
1024 let start_range = line_index.whole_line_range(list_start + 1);
1025 let end_line = i - 1;
1026 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1027 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1028 } else {
1029 line_index.whole_line_range(end_line + 1)
1030 };
1031 let byte_range = start_range.start..end_range.end;
1032
1033 let reflow_line_length = if config.line_length.is_unlimited() {
1036 usize::MAX
1037 } else {
1038 config.line_length.get().saturating_sub(indent_size).max(1)
1039 };
1040 let reflow_options = crate::utils::text_reflow::ReflowOptions {
1041 line_length: reflow_line_length,
1042 break_on_sentences: true,
1043 preserve_breaks: false,
1044 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1045 };
1046
1047 let mut result: Vec<String> = Vec::new();
1048 let mut is_first_block = true;
1049
1050 for (block_idx, block) in blocks.iter().enumerate() {
1051 match block {
1052 Block::Paragraph(para_lines) => {
1053 let segments = split_into_segments(para_lines);
1056
1057 for (segment_idx, segment) in segments.iter().enumerate() {
1058 let hard_break_type = segment.last().and_then(|line| {
1060 let line = line.strip_suffix('\r').unwrap_or(line);
1061 if line.ends_with('\\') {
1062 Some("\\")
1063 } else if line.ends_with(" ") {
1064 Some(" ")
1065 } else {
1066 None
1067 }
1068 });
1069
1070 let segment_for_reflow: Vec<String> = segment
1072 .iter()
1073 .map(|line| {
1074 if line.ends_with('\\') {
1076 line[..line.len() - 1].trim_end().to_string()
1077 } else if line.ends_with(" ") {
1078 line[..line.len() - 2].trim_end().to_string()
1079 } else {
1080 line.clone()
1081 }
1082 })
1083 .collect();
1084
1085 let segment_text = segment_for_reflow.join(" ").trim().to_string();
1086 if !segment_text.is_empty() {
1087 let reflowed =
1088 crate::utils::text_reflow::reflow_line(&segment_text, &reflow_options);
1089
1090 if is_first_block && segment_idx == 0 {
1091 result.push(format!("{marker}{}", reflowed[0]));
1093 for line in reflowed.iter().skip(1) {
1094 result.push(format!("{expected_indent}{line}"));
1095 }
1096 is_first_block = false;
1097 } else {
1098 for line in reflowed {
1100 result.push(format!("{expected_indent}{line}"));
1101 }
1102 }
1103
1104 if let Some(break_marker) = hard_break_type
1107 && let Some(last_line) = result.last_mut()
1108 {
1109 last_line.push_str(break_marker);
1110 }
1111 }
1112 }
1113
1114 if block_idx < blocks.len() - 1 {
1117 let next_block = &blocks[block_idx + 1];
1118 let should_add_blank = match next_block {
1119 Block::Code {
1120 has_preceding_blank, ..
1121 } => *has_preceding_blank,
1122 _ => true, };
1124 if should_add_blank {
1125 result.push(String::new());
1126 }
1127 }
1128 }
1129 Block::Code {
1130 lines: code_lines,
1131 has_preceding_blank: _,
1132 } => {
1133 for (idx, (content, orig_indent)) in code_lines.iter().enumerate() {
1138 if is_first_block && idx == 0 {
1139 result.push(format!(
1141 "{marker}{}",
1142 " ".repeat(orig_indent - marker_len) + content
1143 ));
1144 is_first_block = false;
1145 } else if content.is_empty() {
1146 result.push(String::new());
1147 } else {
1148 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
1149 }
1150 }
1151 }
1152 Block::NestedList(nested_items) => {
1153 if !is_first_block {
1155 result.push(String::new());
1156 }
1157
1158 for (idx, (content, orig_indent)) in nested_items.iter().enumerate() {
1159 if is_first_block && idx == 0 {
1160 result.push(format!(
1162 "{marker}{}",
1163 " ".repeat(orig_indent - marker_len) + content
1164 ));
1165 is_first_block = false;
1166 } else if content.is_empty() {
1167 result.push(String::new());
1168 } else {
1169 result.push(format!("{}{}", " ".repeat(*orig_indent), content));
1170 }
1171 }
1172
1173 if block_idx < blocks.len() - 1 {
1176 let next_block = &blocks[block_idx + 1];
1177 let should_add_blank = match next_block {
1178 Block::Code {
1179 has_preceding_blank, ..
1180 } => *has_preceding_blank,
1181 _ => true, };
1183 if should_add_blank {
1184 result.push(String::new());
1185 }
1186 }
1187 }
1188 Block::SemanticLine(content) => {
1189 if !is_first_block {
1192 result.push(String::new());
1193 }
1194
1195 if is_first_block {
1196 result.push(format!("{marker}{content}"));
1198 is_first_block = false;
1199 } else {
1200 result.push(format!("{expected_indent}{content}"));
1202 }
1203
1204 if block_idx < blocks.len() - 1 {
1207 let next_block = &blocks[block_idx + 1];
1208 let should_add_blank = match next_block {
1209 Block::Code {
1210 has_preceding_blank, ..
1211 } => *has_preceding_blank,
1212 _ => true, };
1214 if should_add_blank {
1215 result.push(String::new());
1216 }
1217 }
1218 }
1219 Block::Html {
1220 lines: html_lines,
1221 has_preceding_blank: _,
1222 } => {
1223 for (idx, line) in html_lines.iter().enumerate() {
1227 if is_first_block && idx == 0 {
1228 result.push(format!("{marker}{line}"));
1230 is_first_block = false;
1231 } else if line.is_empty() {
1232 result.push(String::new());
1234 } else {
1235 result.push(format!("{expected_indent}{line}"));
1237 }
1238 }
1239
1240 if block_idx < blocks.len() - 1 {
1242 let next_block = &blocks[block_idx + 1];
1243 let should_add_blank = match next_block {
1244 Block::Code {
1245 has_preceding_blank, ..
1246 } => *has_preceding_blank,
1247 Block::Html {
1248 has_preceding_blank, ..
1249 } => *has_preceding_blank,
1250 _ => true, };
1252 if should_add_blank {
1253 result.push(String::new());
1254 }
1255 }
1256 }
1257 }
1258 }
1259
1260 let reflowed_text = result.join("\n");
1261
1262 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1264 format!("{reflowed_text}\n")
1265 } else {
1266 reflowed_text
1267 };
1268
1269 let original_text = &ctx.content[byte_range.clone()];
1271
1272 if original_text != replacement {
1274 let message = match config.reflow_mode {
1276 ReflowMode::SentencePerLine => {
1277 let num_sentences = split_into_sentences(&combined_content).len();
1278 let num_lines = content_lines.len();
1279 if num_lines == 1 {
1280 format!("Line contains {num_sentences} sentences (one sentence per line required)")
1282 } else {
1283 format!(
1285 "Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)"
1286 )
1287 }
1288 }
1289 ReflowMode::Normalize => {
1290 let combined_length = self.calculate_effective_length(&full_line);
1291 if combined_length > config.line_length.get() {
1292 format!(
1293 "Line length {} exceeds {} characters",
1294 combined_length,
1295 config.line_length.get()
1296 )
1297 } else {
1298 "Multi-line content can be normalized".to_string()
1299 }
1300 }
1301 ReflowMode::Default => {
1302 let combined_length = self.calculate_effective_length(&full_line);
1303 format!(
1304 "Line length {} exceeds {} characters",
1305 combined_length,
1306 config.line_length.get()
1307 )
1308 }
1309 };
1310
1311 warnings.push(LintWarning {
1312 rule_name: Some(self.name().to_string()),
1313 message,
1314 line: list_start + 1,
1315 column: 1,
1316 end_line: end_line + 1,
1317 end_column: lines[end_line].len() + 1,
1318 severity: Severity::Warning,
1319 fix: Some(crate::rule::Fix {
1320 range: byte_range,
1321 replacement,
1322 }),
1323 });
1324 }
1325 }
1326 continue;
1327 }
1328
1329 let paragraph_start = i;
1331 let mut paragraph_lines = vec![lines[i]];
1332 i += 1;
1333
1334 while i < lines.len() {
1335 let next_line = lines[i];
1336 let next_line_num = i + 1;
1337 let next_trimmed = next_line.trim();
1338
1339 if next_trimmed.is_empty()
1341 || ctx.line_info(next_line_num).is_some_and(|info| info.in_code_block)
1342 || ctx.line_info(next_line_num).is_some_and(|info| info.in_front_matter)
1343 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_block)
1344 || ctx.line_info(next_line_num).is_some_and(|info| info.in_html_comment)
1345 || ctx.line_info(next_line_num).is_some_and(|info| info.in_esm_block)
1346 || (next_line_num > 0
1347 && next_line_num <= ctx.lines.len()
1348 && ctx.lines[next_line_num - 1].blockquote.is_some())
1349 || next_trimmed.starts_with('#')
1350 || TableUtils::is_potential_table_row(next_line)
1351 || is_list_item(next_trimmed)
1352 || is_horizontal_rule(next_trimmed)
1353 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
1354 || is_template_directive_only(next_line)
1355 {
1356 break;
1357 }
1358
1359 if i > 0 && has_hard_break(lines[i - 1]) {
1361 break;
1363 }
1364
1365 paragraph_lines.push(next_line);
1366 i += 1;
1367 }
1368
1369 let paragraph_text = paragraph_lines.join(" ");
1372
1373 let contains_definition_list = paragraph_lines
1376 .iter()
1377 .any(|line| crate::utils::is_definition_list_item(line));
1378
1379 if contains_definition_list {
1380 i = paragraph_start + paragraph_lines.len();
1382 continue;
1383 }
1384
1385 let needs_reflow = match config.reflow_mode {
1387 ReflowMode::Normalize => {
1388 paragraph_lines.len() > 1
1390 }
1391 ReflowMode::SentencePerLine => {
1392 let sentences = split_into_sentences(¶graph_text);
1395
1396 if sentences.len() > 1 {
1398 true
1399 } else if paragraph_lines.len() > 1 {
1400 if config.line_length.is_unlimited() {
1403 true
1405 } else {
1406 let effective_length = self.calculate_effective_length(¶graph_text);
1408 effective_length <= config.line_length.get()
1409 }
1410 } else {
1411 false
1412 }
1413 }
1414 ReflowMode::Default => {
1415 paragraph_lines
1417 .iter()
1418 .any(|line| self.calculate_effective_length(line) > config.line_length.get())
1419 }
1420 };
1421
1422 if needs_reflow {
1423 let start_range = line_index.whole_line_range(paragraph_start + 1);
1426 let end_line = paragraph_start + paragraph_lines.len() - 1;
1427
1428 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
1430 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
1432 } else {
1433 line_index.whole_line_range(end_line + 1)
1435 };
1436
1437 let byte_range = start_range.start..end_range.end;
1438
1439 let hard_break_type = paragraph_lines.last().and_then(|line| {
1441 let line = line.strip_suffix('\r').unwrap_or(line);
1442 if line.ends_with('\\') {
1443 Some("\\")
1444 } else if line.ends_with(" ") {
1445 Some(" ")
1446 } else {
1447 None
1448 }
1449 });
1450
1451 let reflow_line_length = if config.line_length.is_unlimited() {
1454 usize::MAX
1455 } else {
1456 config.line_length.get()
1457 };
1458 let reflow_options = crate::utils::text_reflow::ReflowOptions {
1459 line_length: reflow_line_length,
1460 break_on_sentences: true,
1461 preserve_breaks: false,
1462 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
1463 };
1464 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
1465
1466 if let Some(break_marker) = hard_break_type
1469 && !reflowed.is_empty()
1470 {
1471 let last_idx = reflowed.len() - 1;
1472 if !has_hard_break(&reflowed[last_idx]) {
1473 reflowed[last_idx].push_str(break_marker);
1474 }
1475 }
1476
1477 let reflowed_text = reflowed.join("\n");
1478
1479 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
1481 format!("{reflowed_text}\n")
1482 } else {
1483 reflowed_text
1484 };
1485
1486 let original_text = &ctx.content[byte_range.clone()];
1488
1489 if original_text != replacement {
1491 let (warning_line, warning_end_line) = match config.reflow_mode {
1496 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
1497 ReflowMode::SentencePerLine => {
1498 (paragraph_start + 1, paragraph_start + paragraph_lines.len())
1500 }
1501 ReflowMode::Default => {
1502 let mut violating_line = paragraph_start;
1504 for (idx, line) in paragraph_lines.iter().enumerate() {
1505 if self.calculate_effective_length(line) > config.line_length.get() {
1506 violating_line = paragraph_start + idx;
1507 break;
1508 }
1509 }
1510 (violating_line + 1, violating_line + 1)
1511 }
1512 };
1513
1514 warnings.push(LintWarning {
1515 rule_name: Some(self.name().to_string()),
1516 message: match config.reflow_mode {
1517 ReflowMode::Normalize => format!(
1518 "Paragraph could be normalized to use line length of {} characters",
1519 config.line_length.get()
1520 ),
1521 ReflowMode::SentencePerLine => {
1522 let num_sentences = split_into_sentences(¶graph_text).len();
1523 if paragraph_lines.len() == 1 {
1524 format!("Line contains {num_sentences} sentences (one sentence per line required)")
1526 } else {
1527 let num_lines = paragraph_lines.len();
1528 format!("Paragraph should have one sentence per line (found {num_sentences} sentences across {num_lines} lines)")
1530 }
1531 },
1532 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length.get()),
1533 },
1534 line: warning_line,
1535 column: 1,
1536 end_line: warning_end_line,
1537 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
1538 severity: Severity::Warning,
1539 fix: Some(crate::rule::Fix {
1540 range: byte_range,
1541 replacement,
1542 }),
1543 });
1544 }
1545 }
1546 }
1547
1548 warnings
1549 }
1550
1551 fn calculate_effective_length(&self, line: &str) -> usize {
1553 if self.config.strict {
1554 return line.chars().count();
1556 }
1557
1558 let bytes = line.as_bytes();
1560 if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
1561 return line.chars().count();
1562 }
1563
1564 if !line.contains("http") && !line.contains('[') {
1566 return line.chars().count();
1567 }
1568
1569 let mut effective_line = line.to_string();
1570
1571 if line.contains('[') && line.contains("](") {
1574 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
1575 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
1576 && url.as_str().len() > 15
1577 {
1578 let replacement = format!("[{}](url)", text.as_str());
1579 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
1580 }
1581 }
1582 }
1583
1584 if effective_line.contains("http") {
1587 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
1588 let url = url_match.as_str();
1589 if !effective_line.contains(&format!("({url})")) {
1591 let placeholder = "x".repeat(15.min(url.len()));
1594 effective_line = effective_line.replacen(url, &placeholder, 1);
1595 }
1596 }
1597 }
1598
1599 effective_line.chars().count()
1600 }
1601}
1602
1603fn has_hard_break(line: &str) -> bool {
1609 let line = line.strip_suffix('\r').unwrap_or(line);
1610 line.ends_with(" ") || line.ends_with('\\')
1611}
1612
1613fn trim_preserving_hard_break(s: &str) -> String {
1620 let s = s.strip_suffix('\r').unwrap_or(s);
1622
1623 if s.ends_with('\\') {
1625 return s.to_string();
1627 }
1628
1629 if s.ends_with(" ") {
1631 let content_end = s.trim_end().len();
1633 if content_end == 0 {
1634 return String::new();
1636 }
1637 format!("{} ", &s[..content_end])
1639 } else {
1640 s.trim_end().to_string()
1642 }
1643}
1644
1645fn split_into_segments(para_lines: &[String]) -> Vec<Vec<String>> {
1656 let mut segments: Vec<Vec<String>> = Vec::new();
1657 let mut current_segment: Vec<String> = Vec::new();
1658
1659 for line in para_lines {
1660 current_segment.push(line.clone());
1661
1662 if has_hard_break(line) {
1664 segments.push(current_segment.clone());
1665 current_segment.clear();
1666 }
1667 }
1668
1669 if !current_segment.is_empty() {
1671 segments.push(current_segment);
1672 }
1673
1674 segments
1675}
1676
1677fn extract_list_marker_and_content(line: &str) -> (String, String) {
1678 let indent_len = line.len() - line.trim_start().len();
1680 let indent = &line[..indent_len];
1681 let trimmed = &line[indent_len..];
1682
1683 if let Some(rest) = trimmed.strip_prefix("- ") {
1686 return (format!("{indent}- "), trim_preserving_hard_break(rest));
1687 }
1688 if let Some(rest) = trimmed.strip_prefix("* ") {
1689 return (format!("{indent}* "), trim_preserving_hard_break(rest));
1690 }
1691 if let Some(rest) = trimmed.strip_prefix("+ ") {
1692 return (format!("{indent}+ "), trim_preserving_hard_break(rest));
1693 }
1694
1695 let mut chars = trimmed.chars();
1697 let mut marker_content = String::new();
1698
1699 while let Some(c) = chars.next() {
1700 marker_content.push(c);
1701 if c == '.' {
1702 if let Some(next) = chars.next()
1704 && next == ' '
1705 {
1706 marker_content.push(next);
1707 let content = trim_preserving_hard_break(chars.as_str());
1709 return (format!("{indent}{marker_content}"), content);
1710 }
1711 break;
1712 }
1713 }
1714
1715 (String::new(), line.to_string())
1717}
1718
1719fn is_horizontal_rule(line: &str) -> bool {
1721 if line.len() < 3 {
1722 return false;
1723 }
1724 let chars: Vec<char> = line.chars().collect();
1726 if chars.is_empty() {
1727 return false;
1728 }
1729 let first_char = chars[0];
1730 if first_char != '-' && first_char != '_' && first_char != '*' {
1731 return false;
1732 }
1733 for c in &chars {
1735 if *c != first_char && *c != ' ' {
1736 return false;
1737 }
1738 }
1739 chars.iter().filter(|c| **c == first_char).count() >= 3
1741}
1742
1743fn is_numbered_list_item(line: &str) -> bool {
1744 let mut chars = line.chars();
1745 if !chars.next().is_some_and(|c| c.is_numeric()) {
1747 return false;
1748 }
1749 while let Some(c) = chars.next() {
1751 if c == '.' {
1752 return chars.next().is_none_or(|c| c == ' ');
1754 }
1755 if !c.is_numeric() {
1756 return false;
1757 }
1758 }
1759 false
1760}
1761
1762fn is_list_item(line: &str) -> bool {
1763 if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
1765 && line.len() > 1
1766 && line.chars().nth(1) == Some(' ')
1767 {
1768 return true;
1769 }
1770 is_numbered_list_item(line)
1772}
1773
1774fn is_template_directive_only(line: &str) -> bool {
1784 let trimmed = line.trim();
1785
1786 if trimmed.is_empty() {
1788 return false;
1789 }
1790
1791 if trimmed.starts_with("{{") && trimmed.ends_with("}}") {
1794 return true;
1795 }
1796
1797 if trimmed.starts_with("{%") && trimmed.ends_with("%}") {
1799 return true;
1800 }
1801
1802 false
1803}
1804
1805#[cfg(test)]
1806mod tests {
1807 use super::*;
1808 use crate::config::MarkdownFlavor;
1809 use crate::lint_context::LintContext;
1810
1811 #[test]
1812 fn test_default_config() {
1813 let rule = MD013LineLength::default();
1814 assert_eq!(rule.config.line_length.get(), 80);
1815 assert!(rule.config.code_blocks); assert!(!rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
1819 }
1820
1821 #[test]
1822 fn test_custom_config() {
1823 let rule = MD013LineLength::new(100, true, true, false, true);
1824 assert_eq!(rule.config.line_length.get(), 100);
1825 assert!(rule.config.code_blocks);
1826 assert!(rule.config.tables);
1827 assert!(!rule.config.headings);
1828 assert!(rule.config.strict);
1829 }
1830
1831 #[test]
1832 fn test_basic_line_length_violation() {
1833 let rule = MD013LineLength::new(50, false, false, false, false);
1834 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
1835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1836 let result = rule.check(&ctx).unwrap();
1837
1838 assert_eq!(result.len(), 1);
1839 assert!(result[0].message.contains("Line length"));
1840 assert!(result[0].message.contains("exceeds 50 characters"));
1841 }
1842
1843 #[test]
1844 fn test_no_violation_under_limit() {
1845 let rule = MD013LineLength::new(100, false, false, false, false);
1846 let content = "Short line.\nAnother short line.";
1847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1848 let result = rule.check(&ctx).unwrap();
1849
1850 assert_eq!(result.len(), 0);
1851 }
1852
1853 #[test]
1854 fn test_multiple_violations() {
1855 let rule = MD013LineLength::new(30, false, false, false, false);
1856 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
1857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1858 let result = rule.check(&ctx).unwrap();
1859
1860 assert_eq!(result.len(), 2);
1861 assert_eq!(result[0].line, 1);
1862 assert_eq!(result[1].line, 2);
1863 }
1864
1865 #[test]
1866 fn test_no_lint_front_matter() {
1867 let rule = MD013LineLength::new(80, false, false, false, false);
1868
1869 let content = "---\ntitle: This is a very long title that exceeds eighty characters and should not trigger MD013\nauthor: Another very long line in YAML front matter that exceeds the eighty character limit\n---\n\n# Heading\n\nThis is a very long line in actual content that exceeds eighty characters and SHOULD trigger MD013.\n";
1871
1872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1873 let result = rule.check(&ctx).unwrap();
1874
1875 assert_eq!(result.len(), 1);
1877 assert_eq!(result[0].line, 8); let content_toml = "+++\ntitle = \"This is a very long title in TOML that exceeds eighty characters and should not trigger MD013\"\nauthor = \"Another very long line in TOML front matter that exceeds the eighty character limit\"\n+++\n\n# Heading\n\nThis is a very long line in actual content that exceeds eighty characters and SHOULD trigger MD013.\n";
1881
1882 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard);
1883 let result_toml = rule.check(&ctx_toml).unwrap();
1884
1885 assert_eq!(result_toml.len(), 1);
1887 assert_eq!(result_toml[0].line, 8); }
1889
1890 #[test]
1891 fn test_code_blocks_exemption() {
1892 let rule = MD013LineLength::new(30, false, false, false, false);
1894 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
1895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1896 let result = rule.check(&ctx).unwrap();
1897
1898 assert_eq!(result.len(), 0);
1899 }
1900
1901 #[test]
1902 fn test_code_blocks_not_exempt_when_configured() {
1903 let rule = MD013LineLength::new(30, true, false, false, false);
1905 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
1906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1907 let result = rule.check(&ctx).unwrap();
1908
1909 assert!(!result.is_empty());
1910 }
1911
1912 #[test]
1913 fn test_heading_checked_when_enabled() {
1914 let rule = MD013LineLength::new(30, false, false, true, false);
1915 let content = "# This is a very long heading that would normally exceed the limit";
1916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1917 let result = rule.check(&ctx).unwrap();
1918
1919 assert_eq!(result.len(), 1);
1920 }
1921
1922 #[test]
1923 fn test_heading_exempt_when_disabled() {
1924 let rule = MD013LineLength::new(30, false, false, false, false);
1925 let content = "# This is a very long heading that should trigger a warning";
1926 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1927 let result = rule.check(&ctx).unwrap();
1928
1929 assert_eq!(result.len(), 0);
1930 }
1931
1932 #[test]
1933 fn test_table_checked_when_enabled() {
1934 let rule = MD013LineLength::new(30, false, true, false, false);
1935 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
1936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1937 let result = rule.check(&ctx).unwrap();
1938
1939 assert_eq!(result.len(), 2); }
1941
1942 #[test]
1943 fn test_issue_78_tables_after_fenced_code_blocks() {
1944 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1947
1948```plain
1949some code block longer than 20 chars length
1950```
1951
1952this is a very long line
1953
1954| column A | column B |
1955| -------- | -------- |
1956| `var` | `val` |
1957| value 1 | value 2 |
1958
1959correct length line"#;
1960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1961 let result = rule.check(&ctx).unwrap();
1962
1963 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1965 assert_eq!(result[0].line, 7, "Should flag line 7");
1966 assert!(result[0].message.contains("24 exceeds 20"));
1967 }
1968
1969 #[test]
1970 fn test_issue_78_tables_with_inline_code() {
1971 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
1974| -------- | -------- |
1975| `var with very long name` | `val exceeding limit` |
1976| value 1 | value 2 |
1977
1978This line exceeds limit"#;
1979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1980 let result = rule.check(&ctx).unwrap();
1981
1982 assert_eq!(result.len(), 1, "Should only flag the non-table line");
1984 assert_eq!(result[0].line, 6, "Should flag line 6");
1985 }
1986
1987 #[test]
1988 fn test_issue_78_indented_code_blocks() {
1989 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1992
1993 some code block longer than 20 chars length
1994
1995this is a very long line
1996
1997| column A | column B |
1998| -------- | -------- |
1999| value 1 | value 2 |
2000
2001correct length line"#;
2002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2003 let result = rule.check(&ctx).unwrap();
2004
2005 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
2007 assert_eq!(result[0].line, 5, "Should flag line 5");
2008 }
2009
2010 #[test]
2011 fn test_url_exemption() {
2012 let rule = MD013LineLength::new(30, false, false, false, false);
2013 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
2014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2015 let result = rule.check(&ctx).unwrap();
2016
2017 assert_eq!(result.len(), 0);
2018 }
2019
2020 #[test]
2021 fn test_image_reference_exemption() {
2022 let rule = MD013LineLength::new(30, false, false, false, false);
2023 let content = "![This is a very long image alt text that exceeds limit][reference]";
2024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2025 let result = rule.check(&ctx).unwrap();
2026
2027 assert_eq!(result.len(), 0);
2028 }
2029
2030 #[test]
2031 fn test_link_reference_exemption() {
2032 let rule = MD013LineLength::new(30, false, false, false, false);
2033 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
2034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2035 let result = rule.check(&ctx).unwrap();
2036
2037 assert_eq!(result.len(), 0);
2038 }
2039
2040 #[test]
2041 fn test_strict_mode() {
2042 let rule = MD013LineLength::new(30, false, false, false, true);
2043 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
2044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2045 let result = rule.check(&ctx).unwrap();
2046
2047 assert_eq!(result.len(), 1);
2049 }
2050
2051 #[test]
2052 fn test_blockquote_exemption() {
2053 let rule = MD013LineLength::new(30, false, false, false, false);
2054 let content = "> This is a very long line inside a blockquote that should be ignored.";
2055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2056 let result = rule.check(&ctx).unwrap();
2057
2058 assert_eq!(result.len(), 0);
2059 }
2060
2061 #[test]
2062 fn test_setext_heading_underline_exemption() {
2063 let rule = MD013LineLength::new(30, false, false, false, false);
2064 let content = "Heading\n========================================";
2065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2066 let result = rule.check(&ctx).unwrap();
2067
2068 assert_eq!(result.len(), 0);
2070 }
2071
2072 #[test]
2073 fn test_no_fix_without_reflow() {
2074 let rule = MD013LineLength::new(60, false, false, false, false);
2075 let content = "This line has trailing whitespace that makes it too long ";
2076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2077 let result = rule.check(&ctx).unwrap();
2078
2079 assert_eq!(result.len(), 1);
2080 assert!(result[0].fix.is_none());
2082
2083 let fixed = rule.fix(&ctx).unwrap();
2085 assert_eq!(fixed, content);
2086 }
2087
2088 #[test]
2089 fn test_character_vs_byte_counting() {
2090 let rule = MD013LineLength::new(10, false, false, false, false);
2091 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2094 let result = rule.check(&ctx).unwrap();
2095
2096 assert_eq!(result.len(), 1);
2097 assert_eq!(result[0].line, 1);
2098 }
2099
2100 #[test]
2101 fn test_empty_content() {
2102 let rule = MD013LineLength::default();
2103 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
2104 let result = rule.check(&ctx).unwrap();
2105
2106 assert_eq!(result.len(), 0);
2107 }
2108
2109 #[test]
2110 fn test_excess_range_calculation() {
2111 let rule = MD013LineLength::new(10, false, false, false, false);
2112 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2114 let result = rule.check(&ctx).unwrap();
2115
2116 assert_eq!(result.len(), 1);
2117 assert_eq!(result[0].column, 11);
2119 assert_eq!(result[0].end_column, 21);
2120 }
2121
2122 #[test]
2123 fn test_html_block_exemption() {
2124 let rule = MD013LineLength::new(30, false, false, false, false);
2125 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
2126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2127 let result = rule.check(&ctx).unwrap();
2128
2129 assert_eq!(result.len(), 0);
2131 }
2132
2133 #[test]
2134 fn test_mixed_content() {
2135 let rule = MD013LineLength::new(30, false, false, false, false);
2137 let content = r#"# This heading is very long but should be exempt
2138
2139This regular paragraph line is too long and should trigger.
2140
2141```
2142Code block line that is very long but exempt.
2143```
2144
2145| Table | With very long content |
2146|-------|------------------------|
2147
2148Another long line that should trigger a warning."#;
2149
2150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2151 let result = rule.check(&ctx).unwrap();
2152
2153 assert_eq!(result.len(), 2);
2155 assert_eq!(result[0].line, 3);
2156 assert_eq!(result[1].line, 12);
2157 }
2158
2159 #[test]
2160 fn test_fix_without_reflow_preserves_content() {
2161 let rule = MD013LineLength::new(50, false, false, false, false);
2162 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
2163 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2164
2165 let fixed = rule.fix(&ctx).unwrap();
2167 assert_eq!(fixed, content);
2168 }
2169
2170 #[test]
2171 fn test_content_detection() {
2172 let rule = MD013LineLength::default();
2173
2174 let long_line = "a".repeat(100);
2176 let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
2177 assert!(!rule.should_skip(&ctx)); let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
2180 assert!(rule.should_skip(&empty_ctx)); }
2182
2183 #[test]
2184 fn test_rule_metadata() {
2185 let rule = MD013LineLength::default();
2186 assert_eq!(rule.name(), "MD013");
2187 assert_eq!(rule.description(), "Line length should not be excessive");
2188 assert_eq!(rule.category(), RuleCategory::Whitespace);
2189 }
2190
2191 #[test]
2192 fn test_url_embedded_in_text() {
2193 let rule = MD013LineLength::new(50, false, false, false, false);
2194
2195 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
2197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2198 let result = rule.check(&ctx).unwrap();
2199
2200 assert_eq!(result.len(), 0);
2202 }
2203
2204 #[test]
2205 fn test_multiple_urls_in_line() {
2206 let rule = MD013LineLength::new(50, false, false, false, false);
2207
2208 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
2210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2211
2212 let result = rule.check(&ctx).unwrap();
2213
2214 assert_eq!(result.len(), 0);
2216 }
2217
2218 #[test]
2219 fn test_markdown_link_with_long_url() {
2220 let rule = MD013LineLength::new(50, false, false, false, false);
2221
2222 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
2224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2225 let result = rule.check(&ctx).unwrap();
2226
2227 assert_eq!(result.len(), 0);
2229 }
2230
2231 #[test]
2232 fn test_line_too_long_even_without_urls() {
2233 let rule = MD013LineLength::new(50, false, false, false, false);
2234
2235 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
2237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2238 let result = rule.check(&ctx).unwrap();
2239
2240 assert_eq!(result.len(), 1);
2242 }
2243
2244 #[test]
2245 fn test_strict_mode_counts_urls() {
2246 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";
2250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2251 let result = rule.check(&ctx).unwrap();
2252
2253 assert_eq!(result.len(), 1);
2255 }
2256
2257 #[test]
2258 fn test_documentation_example_from_md051() {
2259 let rule = MD013LineLength::new(80, false, false, false, false);
2260
2261 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
2263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2264 let result = rule.check(&ctx).unwrap();
2265
2266 assert_eq!(result.len(), 0);
2268 }
2269
2270 #[test]
2271 fn test_text_reflow_simple() {
2272 let config = MD013Config {
2273 line_length: crate::types::LineLength::from_const(30),
2274 reflow: true,
2275 ..Default::default()
2276 };
2277 let rule = MD013LineLength::from_config_struct(config);
2278
2279 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
2280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2281
2282 let fixed = rule.fix(&ctx).unwrap();
2283
2284 for line in fixed.lines() {
2286 assert!(
2287 line.chars().count() <= 30,
2288 "Line too long: {} (len={})",
2289 line,
2290 line.chars().count()
2291 );
2292 }
2293
2294 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
2296 let original_words: Vec<&str> = content.split_whitespace().collect();
2297 assert_eq!(fixed_words, original_words);
2298 }
2299
2300 #[test]
2301 fn test_text_reflow_preserves_markdown_elements() {
2302 let config = MD013Config {
2303 line_length: crate::types::LineLength::from_const(40),
2304 reflow: true,
2305 ..Default::default()
2306 };
2307 let rule = MD013LineLength::from_config_struct(config);
2308
2309 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
2310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2311
2312 let fixed = rule.fix(&ctx).unwrap();
2313
2314 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
2316 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
2317 assert!(
2318 fixed.contains("[a link](https://example.com)"),
2319 "Link not preserved in: {fixed}"
2320 );
2321
2322 for line in fixed.lines() {
2324 assert!(line.len() <= 40, "Line too long: {line}");
2325 }
2326 }
2327
2328 #[test]
2329 fn test_text_reflow_preserves_code_blocks() {
2330 let config = MD013Config {
2331 line_length: crate::types::LineLength::from_const(30),
2332 reflow: true,
2333 ..Default::default()
2334 };
2335 let rule = MD013LineLength::from_config_struct(config);
2336
2337 let content = r#"Here is some text.
2338
2339```python
2340def very_long_function_name_that_exceeds_limit():
2341 return "This should not be wrapped"
2342```
2343
2344More text after code block."#;
2345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2346
2347 let fixed = rule.fix(&ctx).unwrap();
2348
2349 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
2351 assert!(fixed.contains("```python"));
2352 assert!(fixed.contains("```"));
2353 }
2354
2355 #[test]
2356 fn test_text_reflow_preserves_lists() {
2357 let config = MD013Config {
2358 line_length: crate::types::LineLength::from_const(30),
2359 reflow: true,
2360 ..Default::default()
2361 };
2362 let rule = MD013LineLength::from_config_struct(config);
2363
2364 let content = r#"Here is a list:
2365
23661. First item with a very long line that needs wrapping
23672. Second item is short
23683. Third item also has a long line that exceeds the limit
2369
2370And a bullet list:
2371
2372- Bullet item with very long content that needs wrapping
2373- Short bullet"#;
2374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2375
2376 let fixed = rule.fix(&ctx).unwrap();
2377
2378 assert!(fixed.contains("1. "));
2380 assert!(fixed.contains("2. "));
2381 assert!(fixed.contains("3. "));
2382 assert!(fixed.contains("- "));
2383
2384 let lines: Vec<&str> = fixed.lines().collect();
2386 for (i, line) in lines.iter().enumerate() {
2387 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
2388 if i + 1 < lines.len()
2390 && !lines[i + 1].trim().is_empty()
2391 && !lines[i + 1].trim().starts_with(char::is_numeric)
2392 && !lines[i + 1].trim().starts_with("-")
2393 {
2394 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
2396 }
2397 } else if line.trim().starts_with("-") {
2398 if i + 1 < lines.len()
2400 && !lines[i + 1].trim().is_empty()
2401 && !lines[i + 1].trim().starts_with(char::is_numeric)
2402 && !lines[i + 1].trim().starts_with("-")
2403 {
2404 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
2406 }
2407 }
2408 }
2409 }
2410
2411 #[test]
2412 fn test_issue_83_numbered_list_with_backticks() {
2413 let config = MD013Config {
2415 line_length: crate::types::LineLength::from_const(100),
2416 reflow: true,
2417 ..Default::default()
2418 };
2419 let rule = MD013LineLength::from_config_struct(config);
2420
2421 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
2423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2424
2425 let fixed = rule.fix(&ctx).unwrap();
2426
2427 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
2430
2431 assert_eq!(
2432 fixed, expected,
2433 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
2434 );
2435 }
2436
2437 #[test]
2438 fn test_text_reflow_disabled_by_default() {
2439 let rule = MD013LineLength::new(30, false, false, false, false);
2440
2441 let content = "This is a very long line that definitely exceeds thirty characters.";
2442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2443
2444 let fixed = rule.fix(&ctx).unwrap();
2445
2446 assert_eq!(fixed, content);
2449 }
2450
2451 #[test]
2452 fn test_reflow_with_hard_line_breaks() {
2453 let config = MD013Config {
2455 line_length: crate::types::LineLength::from_const(40),
2456 reflow: true,
2457 ..Default::default()
2458 };
2459 let rule = MD013LineLength::from_config_struct(config);
2460
2461 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";
2463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2464 let fixed = rule.fix(&ctx).unwrap();
2465
2466 assert!(
2468 fixed.contains(" \n"),
2469 "Hard line break with exactly 2 spaces should be preserved"
2470 );
2471 }
2472
2473 #[test]
2474 fn test_reflow_preserves_reference_links() {
2475 let config = MD013Config {
2476 line_length: crate::types::LineLength::from_const(40),
2477 reflow: true,
2478 ..Default::default()
2479 };
2480 let rule = MD013LineLength::from_config_struct(config);
2481
2482 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
2483
2484[ref]: https://example.com";
2485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2486 let fixed = rule.fix(&ctx).unwrap();
2487
2488 assert!(fixed.contains("[reference link][ref]"));
2490 assert!(!fixed.contains("[ reference link]"));
2491 assert!(!fixed.contains("[ref ]"));
2492 }
2493
2494 #[test]
2495 fn test_reflow_with_nested_markdown_elements() {
2496 let config = MD013Config {
2497 line_length: crate::types::LineLength::from_const(35),
2498 reflow: true,
2499 ..Default::default()
2500 };
2501 let rule = MD013LineLength::from_config_struct(config);
2502
2503 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
2504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2505 let fixed = rule.fix(&ctx).unwrap();
2506
2507 assert!(fixed.contains("**bold with `code` inside**"));
2509 }
2510
2511 #[test]
2512 fn test_reflow_with_unbalanced_markdown() {
2513 let config = MD013Config {
2515 line_length: crate::types::LineLength::from_const(30),
2516 reflow: true,
2517 ..Default::default()
2518 };
2519 let rule = MD013LineLength::from_config_struct(config);
2520
2521 let content = "This has **unbalanced bold that goes on for a very long time without closing";
2522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2523 let fixed = rule.fix(&ctx).unwrap();
2524
2525 assert!(!fixed.is_empty());
2529 for line in fixed.lines() {
2531 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
2532 }
2533 }
2534
2535 #[test]
2536 fn test_reflow_fix_indicator() {
2537 let config = MD013Config {
2539 line_length: crate::types::LineLength::from_const(30),
2540 reflow: true,
2541 ..Default::default()
2542 };
2543 let rule = MD013LineLength::from_config_struct(config);
2544
2545 let content = "This is a very long line that definitely exceeds the thirty character limit";
2546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2547 let warnings = rule.check(&ctx).unwrap();
2548
2549 assert!(!warnings.is_empty());
2551 assert!(
2552 warnings[0].fix.is_some(),
2553 "Should provide fix indicator when reflow is true"
2554 );
2555 }
2556
2557 #[test]
2558 fn test_no_fix_indicator_without_reflow() {
2559 let config = MD013Config {
2561 line_length: crate::types::LineLength::from_const(30),
2562 reflow: false,
2563 ..Default::default()
2564 };
2565 let rule = MD013LineLength::from_config_struct(config);
2566
2567 let content = "This is a very long line that definitely exceeds the thirty character limit";
2568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2569 let warnings = rule.check(&ctx).unwrap();
2570
2571 assert!(!warnings.is_empty());
2573 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
2574 }
2575
2576 #[test]
2577 fn test_reflow_preserves_all_reference_link_types() {
2578 let config = MD013Config {
2579 line_length: crate::types::LineLength::from_const(40),
2580 reflow: true,
2581 ..Default::default()
2582 };
2583 let rule = MD013LineLength::from_config_struct(config);
2584
2585 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
2586
2587[ref]: https://example.com
2588[collapsed]: https://example.com
2589[shortcut]: https://example.com";
2590
2591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2592 let fixed = rule.fix(&ctx).unwrap();
2593
2594 assert!(fixed.contains("[full reference][ref]"));
2596 assert!(fixed.contains("[collapsed][]"));
2597 assert!(fixed.contains("[shortcut]"));
2598 }
2599
2600 #[test]
2601 fn test_reflow_handles_images_correctly() {
2602 let config = MD013Config {
2603 line_length: crate::types::LineLength::from_const(40),
2604 reflow: true,
2605 ..Default::default()
2606 };
2607 let rule = MD013LineLength::from_config_struct(config);
2608
2609 let content = "This line has an  that should not be broken when reflowing.";
2610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2611 let fixed = rule.fix(&ctx).unwrap();
2612
2613 assert!(fixed.contains(""));
2615 }
2616
2617 #[test]
2618 fn test_normalize_mode_flags_short_lines() {
2619 let config = MD013Config {
2620 line_length: crate::types::LineLength::from_const(100),
2621 reflow: true,
2622 reflow_mode: ReflowMode::Normalize,
2623 ..Default::default()
2624 };
2625 let rule = MD013LineLength::from_config_struct(config);
2626
2627 let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
2629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2630 let warnings = rule.check(&ctx).unwrap();
2631
2632 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2634 assert!(warnings[0].message.contains("normalized"));
2635 }
2636
2637 #[test]
2638 fn test_normalize_mode_combines_short_lines() {
2639 let config = MD013Config {
2640 line_length: crate::types::LineLength::from_const(100),
2641 reflow: true,
2642 reflow_mode: ReflowMode::Normalize,
2643 ..Default::default()
2644 };
2645 let rule = MD013LineLength::from_config_struct(config);
2646
2647 let content =
2649 "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
2650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2651 let fixed = rule.fix(&ctx).unwrap();
2652
2653 let lines: Vec<&str> = fixed.lines().collect();
2655 assert_eq!(lines.len(), 1, "Should combine into single line");
2656 assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
2657 }
2658
2659 #[test]
2660 fn test_normalize_mode_preserves_paragraph_breaks() {
2661 let config = MD013Config {
2662 line_length: crate::types::LineLength::from_const(100),
2663 reflow: true,
2664 reflow_mode: ReflowMode::Normalize,
2665 ..Default::default()
2666 };
2667 let rule = MD013LineLength::from_config_struct(config);
2668
2669 let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
2670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2671 let fixed = rule.fix(&ctx).unwrap();
2672
2673 assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
2675
2676 let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
2677 assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
2678 }
2679
2680 #[test]
2681 fn test_default_mode_only_fixes_violations() {
2682 let config = MD013Config {
2683 line_length: crate::types::LineLength::from_const(100),
2684 reflow: true,
2685 reflow_mode: ReflowMode::Default, ..Default::default()
2687 };
2688 let rule = MD013LineLength::from_config_struct(config);
2689
2690 let content = "This is a short line.\nAnother short line.\nA third short line.";
2692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2693 let warnings = rule.check(&ctx).unwrap();
2694
2695 assert!(warnings.is_empty(), "Should not flag short lines in default mode");
2697
2698 let fixed = rule.fix(&ctx).unwrap();
2700 assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
2701 }
2702
2703 #[test]
2704 fn test_normalize_mode_with_lists() {
2705 let config = MD013Config {
2706 line_length: crate::types::LineLength::from_const(80),
2707 reflow: true,
2708 reflow_mode: ReflowMode::Normalize,
2709 ..Default::default()
2710 };
2711 let rule = MD013LineLength::from_config_struct(config);
2712
2713 let content = r#"A paragraph with
2714short lines.
2715
27161. List item with
2717 short lines
27182. Another item"#;
2719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2720 let fixed = rule.fix(&ctx).unwrap();
2721
2722 let lines: Vec<&str> = fixed.lines().collect();
2724 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2725 assert!(fixed.contains("1. "), "Should preserve list markers");
2726 assert!(fixed.contains("2. "), "Should preserve list markers");
2727 }
2728
2729 #[test]
2730 fn test_normalize_mode_with_code_blocks() {
2731 let config = MD013Config {
2732 line_length: crate::types::LineLength::from_const(100),
2733 reflow: true,
2734 reflow_mode: ReflowMode::Normalize,
2735 ..Default::default()
2736 };
2737 let rule = MD013LineLength::from_config_struct(config);
2738
2739 let content = r#"A paragraph with
2740short lines.
2741
2742```
2743code block should not be normalized
2744even with short lines
2745```
2746
2747Another paragraph with
2748short lines."#;
2749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2750 let fixed = rule.fix(&ctx).unwrap();
2751
2752 assert!(fixed.contains("code block should not be normalized\neven with short lines"));
2754 let lines: Vec<&str> = fixed.lines().collect();
2756 assert!(lines[0].len() > 20, "First paragraph should be normalized");
2757 }
2758
2759 #[test]
2760 fn test_issue_76_use_case() {
2761 let config = MD013Config {
2763 line_length: crate::types::LineLength::from_const(999999), reflow: true,
2765 reflow_mode: ReflowMode::Normalize,
2766 ..Default::default()
2767 };
2768 let rule = MD013LineLength::from_config_struct(config);
2769
2770 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.";
2772
2773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2774
2775 let warnings = rule.check(&ctx).unwrap();
2777 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
2778
2779 let fixed = rule.fix(&ctx).unwrap();
2781 let lines: Vec<&str> = fixed.lines().collect();
2782 assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
2783 assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
2784 }
2785
2786 #[test]
2787 fn test_normalize_mode_single_line_unchanged() {
2788 let config = MD013Config {
2790 line_length: crate::types::LineLength::from_const(100),
2791 reflow: true,
2792 reflow_mode: ReflowMode::Normalize,
2793 ..Default::default()
2794 };
2795 let rule = MD013LineLength::from_config_struct(config);
2796
2797 let content = "This is a single line that should not be changed.";
2798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2799
2800 let warnings = rule.check(&ctx).unwrap();
2801 assert!(warnings.is_empty(), "Single line should not be flagged");
2802
2803 let fixed = rule.fix(&ctx).unwrap();
2804 assert_eq!(fixed, content, "Single line should remain unchanged");
2805 }
2806
2807 #[test]
2808 fn test_normalize_mode_with_inline_code() {
2809 let config = MD013Config {
2810 line_length: crate::types::LineLength::from_const(80),
2811 reflow: true,
2812 reflow_mode: ReflowMode::Normalize,
2813 ..Default::default()
2814 };
2815 let rule = MD013LineLength::from_config_struct(config);
2816
2817 let content =
2818 "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
2819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2820
2821 let warnings = rule.check(&ctx).unwrap();
2822 assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
2823
2824 let fixed = rule.fix(&ctx).unwrap();
2825 assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
2826 assert!(fixed.lines().count() < 3, "Lines should be combined");
2827 }
2828
2829 #[test]
2830 fn test_normalize_mode_with_emphasis() {
2831 let config = MD013Config {
2832 line_length: crate::types::LineLength::from_const(100),
2833 reflow: true,
2834 reflow_mode: ReflowMode::Normalize,
2835 ..Default::default()
2836 };
2837 let rule = MD013LineLength::from_config_struct(config);
2838
2839 let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
2840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2841
2842 let fixed = rule.fix(&ctx).unwrap();
2843 assert!(fixed.contains("**bold**"), "Bold should be preserved");
2844 assert!(fixed.contains("*italic*"), "Italic should be preserved");
2845 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2846 }
2847
2848 #[test]
2849 fn test_normalize_mode_respects_hard_breaks() {
2850 let config = MD013Config {
2851 line_length: crate::types::LineLength::from_const(100),
2852 reflow: true,
2853 reflow_mode: ReflowMode::Normalize,
2854 ..Default::default()
2855 };
2856 let rule = MD013LineLength::from_config_struct(config);
2857
2858 let content = "First line with hard break \nSecond line after break\nThird line";
2860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2861
2862 let fixed = rule.fix(&ctx).unwrap();
2863 assert!(fixed.contains(" \n"), "Hard break should be preserved");
2865 assert!(
2867 fixed.contains("Second line after break Third line"),
2868 "Lines without hard break should combine"
2869 );
2870 }
2871
2872 #[test]
2873 fn test_normalize_mode_with_links() {
2874 let config = MD013Config {
2875 line_length: crate::types::LineLength::from_const(100),
2876 reflow: true,
2877 reflow_mode: ReflowMode::Normalize,
2878 ..Default::default()
2879 };
2880 let rule = MD013LineLength::from_config_struct(config);
2881
2882 let content =
2883 "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
2884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2885
2886 let fixed = rule.fix(&ctx).unwrap();
2887 assert!(
2888 fixed.contains("[link](https://example.com)"),
2889 "Link should be preserved"
2890 );
2891 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
2892 }
2893
2894 #[test]
2895 fn test_normalize_mode_empty_lines_between_paragraphs() {
2896 let config = MD013Config {
2897 line_length: crate::types::LineLength::from_const(100),
2898 reflow: true,
2899 reflow_mode: ReflowMode::Normalize,
2900 ..Default::default()
2901 };
2902 let rule = MD013LineLength::from_config_struct(config);
2903
2904 let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
2905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2906
2907 let fixed = rule.fix(&ctx).unwrap();
2908 assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
2910 let parts: Vec<&str> = fixed.split("\n\n\n").collect();
2912 assert_eq!(parts.len(), 2, "Should have two parts");
2913 assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
2914 assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
2915 }
2916
2917 #[test]
2918 fn test_normalize_mode_mixed_list_types() {
2919 let config = MD013Config {
2920 line_length: crate::types::LineLength::from_const(80),
2921 reflow: true,
2922 reflow_mode: ReflowMode::Normalize,
2923 ..Default::default()
2924 };
2925 let rule = MD013LineLength::from_config_struct(config);
2926
2927 let content = r#"Paragraph before list
2928with multiple lines.
2929
2930- Bullet item
2931* Another bullet
2932+ Plus bullet
2933
29341. Numbered item
29352. Another number
2936
2937Paragraph after list
2938with multiple lines."#;
2939
2940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2941 let fixed = rule.fix(&ctx).unwrap();
2942
2943 assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
2945 assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
2946 assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
2947 assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
2948
2949 assert!(
2951 fixed.starts_with("Paragraph before list with multiple lines."),
2952 "First paragraph should be normalized"
2953 );
2954 assert!(
2955 fixed.ends_with("Paragraph after list with multiple lines."),
2956 "Last paragraph should be normalized"
2957 );
2958 }
2959
2960 #[test]
2961 fn test_normalize_mode_with_horizontal_rules() {
2962 let config = MD013Config {
2963 line_length: crate::types::LineLength::from_const(100),
2964 reflow: true,
2965 reflow_mode: ReflowMode::Normalize,
2966 ..Default::default()
2967 };
2968 let rule = MD013LineLength::from_config_struct(config);
2969
2970 let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
2971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2972
2973 let fixed = rule.fix(&ctx).unwrap();
2974 assert!(fixed.contains("---"), "Horizontal rule should be preserved");
2975 assert!(
2976 fixed.contains("Paragraph before horizontal rule."),
2977 "First paragraph normalized"
2978 );
2979 assert!(
2980 fixed.contains("Paragraph after horizontal rule."),
2981 "Second paragraph normalized"
2982 );
2983 }
2984
2985 #[test]
2986 fn test_normalize_mode_with_indented_code() {
2987 let config = MD013Config {
2988 line_length: crate::types::LineLength::from_const(100),
2989 reflow: true,
2990 reflow_mode: ReflowMode::Normalize,
2991 ..Default::default()
2992 };
2993 let rule = MD013LineLength::from_config_struct(config);
2994
2995 let content = "Paragraph before\nindented code.\n\n This is indented code\n Should not be normalized\n\nParagraph after\nindented code.";
2996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2997
2998 let fixed = rule.fix(&ctx).unwrap();
2999 assert!(
3000 fixed.contains(" This is indented code\n Should not be normalized"),
3001 "Indented code preserved"
3002 );
3003 assert!(
3004 fixed.contains("Paragraph before indented code."),
3005 "First paragraph normalized"
3006 );
3007 assert!(
3008 fixed.contains("Paragraph after indented code."),
3009 "Second paragraph normalized"
3010 );
3011 }
3012
3013 #[test]
3014 fn test_normalize_mode_disabled_without_reflow() {
3015 let config = MD013Config {
3017 line_length: crate::types::LineLength::from_const(100),
3018 reflow: false, reflow_mode: ReflowMode::Normalize,
3020 ..Default::default()
3021 };
3022 let rule = MD013LineLength::from_config_struct(config);
3023
3024 let content = "This is a line\nwith breaks that\nshould not be changed.";
3025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3026
3027 let warnings = rule.check(&ctx).unwrap();
3028 assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
3029
3030 let fixed = rule.fix(&ctx).unwrap();
3031 assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
3032 }
3033
3034 #[test]
3035 fn test_default_mode_with_long_lines() {
3036 let config = MD013Config {
3039 line_length: crate::types::LineLength::from_const(50),
3040 reflow: true,
3041 reflow_mode: ReflowMode::Default,
3042 ..Default::default()
3043 };
3044 let rule = MD013LineLength::from_config_struct(config);
3045
3046 let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
3047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3048
3049 let warnings = rule.check(&ctx).unwrap();
3050 assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
3051 assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
3053
3054 let fixed = rule.fix(&ctx).unwrap();
3055 assert!(
3057 fixed.contains("Short line. This is"),
3058 "Should combine and reflow the paragraph"
3059 );
3060 assert!(
3061 fixed.contains("wrapping. Another short"),
3062 "Should include all paragraph content"
3063 );
3064 }
3065
3066 #[test]
3067 fn test_normalize_vs_default_mode_same_content() {
3068 let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
3069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3070
3071 let default_config = MD013Config {
3073 line_length: crate::types::LineLength::from_const(100),
3074 reflow: true,
3075 reflow_mode: ReflowMode::Default,
3076 ..Default::default()
3077 };
3078 let default_rule = MD013LineLength::from_config_struct(default_config);
3079 let default_warnings = default_rule.check(&ctx).unwrap();
3080 let default_fixed = default_rule.fix(&ctx).unwrap();
3081
3082 let normalize_config = MD013Config {
3084 line_length: crate::types::LineLength::from_const(100),
3085 reflow: true,
3086 reflow_mode: ReflowMode::Normalize,
3087 ..Default::default()
3088 };
3089 let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
3090 let normalize_warnings = normalize_rule.check(&ctx).unwrap();
3091 let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
3092
3093 assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
3095 assert!(
3096 !normalize_warnings.is_empty(),
3097 "Normalize mode should flag multi-line paragraphs"
3098 );
3099
3100 assert_eq!(
3101 default_fixed, content,
3102 "Default mode should not change content without violations"
3103 );
3104 assert_ne!(
3105 normalize_fixed, content,
3106 "Normalize mode should change multi-line paragraphs"
3107 );
3108 assert_eq!(
3109 normalize_fixed.lines().count(),
3110 1,
3111 "Normalize should combine into single line"
3112 );
3113 }
3114
3115 #[test]
3116 fn test_normalize_mode_with_reference_definitions() {
3117 let config = MD013Config {
3118 line_length: crate::types::LineLength::from_const(100),
3119 reflow: true,
3120 reflow_mode: ReflowMode::Normalize,
3121 ..Default::default()
3122 };
3123 let rule = MD013LineLength::from_config_struct(config);
3124
3125 let content =
3126 "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
3127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3128
3129 let fixed = rule.fix(&ctx).unwrap();
3130 assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
3131 assert!(
3132 fixed.contains("[ref]: https://example.com"),
3133 "Reference definition should be preserved"
3134 );
3135 assert!(
3136 fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
3137 "Paragraph should be normalized"
3138 );
3139 }
3140
3141 #[test]
3142 fn test_normalize_mode_with_html_comments() {
3143 let config = MD013Config {
3144 line_length: crate::types::LineLength::from_const(100),
3145 reflow: true,
3146 reflow_mode: ReflowMode::Normalize,
3147 ..Default::default()
3148 };
3149 let rule = MD013LineLength::from_config_struct(config);
3150
3151 let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
3152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3153
3154 let fixed = rule.fix(&ctx).unwrap();
3155 assert!(
3156 fixed.contains("<!-- This is a comment -->"),
3157 "HTML comment should be preserved"
3158 );
3159 assert!(
3160 fixed.contains("Paragraph before HTML comment."),
3161 "First paragraph normalized"
3162 );
3163 assert!(
3164 fixed.contains("Paragraph after HTML comment."),
3165 "Second paragraph normalized"
3166 );
3167 }
3168
3169 #[test]
3170 fn test_normalize_mode_line_starting_with_number() {
3171 let config = MD013Config {
3173 line_length: crate::types::LineLength::from_const(100),
3174 reflow: true,
3175 reflow_mode: ReflowMode::Normalize,
3176 ..Default::default()
3177 };
3178 let rule = MD013LineLength::from_config_struct(config);
3179
3180 let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
3181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3182
3183 let fixed = rule.fix(&ctx).unwrap();
3184 assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
3185 assert!(
3186 fixed.contains("80 characters"),
3187 "Number at start of line should be preserved"
3188 );
3189 }
3190
3191 #[test]
3192 fn test_default_mode_preserves_list_structure() {
3193 let config = MD013Config {
3195 line_length: crate::types::LineLength::from_const(80),
3196 reflow: true,
3197 reflow_mode: ReflowMode::Default,
3198 ..Default::default()
3199 };
3200 let rule = MD013LineLength::from_config_struct(config);
3201
3202 let content = r#"- This is a bullet point that has
3203 some text on multiple lines
3204 that should stay separate
3205
32061. Numbered list item with
3207 multiple lines that should
3208 also stay separate"#;
3209
3210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3211 let fixed = rule.fix(&ctx).unwrap();
3212
3213 let lines: Vec<&str> = fixed.lines().collect();
3215 assert_eq!(
3216 lines[0], "- This is a bullet point that has",
3217 "First line should be unchanged"
3218 );
3219 assert_eq!(
3220 lines[1], " some text on multiple lines",
3221 "Continuation should be preserved"
3222 );
3223 assert_eq!(
3224 lines[2], " that should stay separate",
3225 "Second continuation should be preserved"
3226 );
3227 }
3228
3229 #[test]
3230 fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
3231 let config = MD013Config {
3233 line_length: crate::types::LineLength::from_const(80),
3234 reflow: true,
3235 reflow_mode: ReflowMode::Normalize,
3236 ..Default::default()
3237 };
3238 let rule = MD013LineLength::from_config_struct(config);
3239
3240 let content = r#"- This is a bullet point that has
3241 some text on multiple lines
3242 that should be combined
3243
32441. Numbered list item with
3245 multiple lines that need
3246 to be properly combined
32472. Second item"#;
3248
3249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3250 let fixed = rule.fix(&ctx).unwrap();
3251
3252 assert!(
3254 !fixed.contains("lines that"),
3255 "Should not have double spaces in bullet list"
3256 );
3257 assert!(
3258 !fixed.contains("need to"),
3259 "Should not have double spaces in numbered list"
3260 );
3261
3262 assert!(
3264 fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
3265 "Bullet list should be properly combined"
3266 );
3267 assert!(
3268 fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
3269 "Numbered list should be properly combined"
3270 );
3271 }
3272
3273 #[test]
3274 fn test_normalize_mode_actual_numbered_list() {
3275 let config = MD013Config {
3277 line_length: crate::types::LineLength::from_const(100),
3278 reflow: true,
3279 reflow_mode: ReflowMode::Normalize,
3280 ..Default::default()
3281 };
3282 let rule = MD013LineLength::from_config_struct(config);
3283
3284 let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
3285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3286
3287 let fixed = rule.fix(&ctx).unwrap();
3288 assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
3289 assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
3290 assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
3291 assert!(
3292 fixed.starts_with("Paragraph before list with multiple lines."),
3293 "Paragraph should be normalized"
3294 );
3295 }
3296
3297 #[test]
3298 fn test_sentence_per_line_detection() {
3299 let config = MD013Config {
3300 reflow: true,
3301 reflow_mode: ReflowMode::SentencePerLine,
3302 ..Default::default()
3303 };
3304 let rule = MD013LineLength::from_config_struct(config.clone());
3305
3306 let content = "This is sentence one. This is sentence two. And sentence three!";
3308 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3309
3310 assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
3312
3313 let result = rule.check(&ctx).unwrap();
3314
3315 assert!(!result.is_empty(), "Should detect multiple sentences on one line");
3316 assert_eq!(
3317 result[0].message,
3318 "Line contains 3 sentences (one sentence per line required)"
3319 );
3320 }
3321
3322 #[test]
3323 fn test_sentence_per_line_fix() {
3324 let config = MD013Config {
3325 reflow: true,
3326 reflow_mode: ReflowMode::SentencePerLine,
3327 ..Default::default()
3328 };
3329 let rule = MD013LineLength::from_config_struct(config);
3330
3331 let content = "First sentence. Second sentence.";
3332 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3333 let result = rule.check(&ctx).unwrap();
3334
3335 assert!(!result.is_empty(), "Should detect violation");
3336 assert!(result[0].fix.is_some(), "Should provide a fix");
3337
3338 let fix = result[0].fix.as_ref().unwrap();
3339 assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
3340 }
3341
3342 #[test]
3343 fn test_sentence_per_line_abbreviations() {
3344 let config = MD013Config {
3345 reflow: true,
3346 reflow_mode: ReflowMode::SentencePerLine,
3347 ..Default::default()
3348 };
3349 let rule = MD013LineLength::from_config_struct(config);
3350
3351 let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
3353 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3354 let result = rule.check(&ctx).unwrap();
3355
3356 assert!(
3357 result.is_empty(),
3358 "Should not detect abbreviations as sentence boundaries"
3359 );
3360 }
3361
3362 #[test]
3363 fn test_sentence_per_line_with_markdown() {
3364 let config = MD013Config {
3365 reflow: true,
3366 reflow_mode: ReflowMode::SentencePerLine,
3367 ..Default::default()
3368 };
3369 let rule = MD013LineLength::from_config_struct(config);
3370
3371 let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
3372 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3373 let result = rule.check(&ctx).unwrap();
3374
3375 assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
3376 assert_eq!(result[0].line, 3); }
3378
3379 #[test]
3380 fn test_sentence_per_line_questions_exclamations() {
3381 let config = MD013Config {
3382 reflow: true,
3383 reflow_mode: ReflowMode::SentencePerLine,
3384 ..Default::default()
3385 };
3386 let rule = MD013LineLength::from_config_struct(config);
3387
3388 let content = "Is this a question? Yes it is! And a statement.";
3389 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3390 let result = rule.check(&ctx).unwrap();
3391
3392 assert!(!result.is_empty(), "Should detect sentences with ? and !");
3393
3394 let fix = result[0].fix.as_ref().unwrap();
3395 let lines: Vec<&str> = fix.replacement.trim().lines().collect();
3396 assert_eq!(lines.len(), 3);
3397 assert_eq!(lines[0], "Is this a question?");
3398 assert_eq!(lines[1], "Yes it is!");
3399 assert_eq!(lines[2], "And a statement.");
3400 }
3401
3402 #[test]
3403 fn test_sentence_per_line_in_lists() {
3404 let config = MD013Config {
3405 reflow: true,
3406 reflow_mode: ReflowMode::SentencePerLine,
3407 ..Default::default()
3408 };
3409 let rule = MD013LineLength::from_config_struct(config);
3410
3411 let content = "- List item one. With two sentences.\n- Another item.";
3412 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3413 let result = rule.check(&ctx).unwrap();
3414
3415 assert!(!result.is_empty(), "Should detect sentences in list items");
3416 let fix = result[0].fix.as_ref().unwrap();
3418 assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
3419 }
3420
3421 #[test]
3422 fn test_multi_paragraph_list_item_with_3_space_indent() {
3423 let config = MD013Config {
3424 reflow: true,
3425 reflow_mode: ReflowMode::Normalize,
3426 line_length: crate::types::LineLength::from_const(999999),
3427 ..Default::default()
3428 };
3429 let rule = MD013LineLength::from_config_struct(config);
3430
3431 let content = "1. First paragraph\n continuation line.\n\n Second paragraph\n more content.";
3432 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3433 let result = rule.check(&ctx).unwrap();
3434
3435 assert!(!result.is_empty(), "Should detect multi-line paragraphs in list item");
3436 let fix = result[0].fix.as_ref().unwrap();
3437
3438 assert!(
3440 fix.replacement.contains("\n\n"),
3441 "Should preserve blank line between paragraphs"
3442 );
3443 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3444 }
3445
3446 #[test]
3447 fn test_multi_paragraph_list_item_with_4_space_indent() {
3448 let config = MD013Config {
3449 reflow: true,
3450 reflow_mode: ReflowMode::Normalize,
3451 line_length: crate::types::LineLength::from_const(999999),
3452 ..Default::default()
3453 };
3454 let rule = MD013LineLength::from_config_struct(config);
3455
3456 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.";
3458 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3459 let result = rule.check(&ctx).unwrap();
3460
3461 assert!(
3462 !result.is_empty(),
3463 "Should detect multi-line paragraphs in list item with 4-space indent"
3464 );
3465 let fix = result[0].fix.as_ref().unwrap();
3466
3467 assert!(
3469 fix.replacement.contains("\n\n"),
3470 "Should preserve blank line between paragraphs"
3471 );
3472 assert!(fix.replacement.starts_with("1. "), "Should preserve list marker");
3473
3474 let lines: Vec<&str> = fix.replacement.split('\n').collect();
3476 let blank_line_idx = lines.iter().position(|l| l.trim().is_empty());
3477 assert!(blank_line_idx.is_some(), "Should have blank line separating paragraphs");
3478 }
3479
3480 #[test]
3481 fn test_multi_paragraph_bullet_list_item() {
3482 let config = MD013Config {
3483 reflow: true,
3484 reflow_mode: ReflowMode::Normalize,
3485 line_length: crate::types::LineLength::from_const(999999),
3486 ..Default::default()
3487 };
3488 let rule = MD013LineLength::from_config_struct(config);
3489
3490 let content = "- First paragraph\n continuation.\n\n Second paragraph\n more text.";
3491 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3492 let result = rule.check(&ctx).unwrap();
3493
3494 assert!(!result.is_empty(), "Should detect multi-line paragraphs in bullet list");
3495 let fix = result[0].fix.as_ref().unwrap();
3496
3497 assert!(
3498 fix.replacement.contains("\n\n"),
3499 "Should preserve blank line between paragraphs"
3500 );
3501 assert!(fix.replacement.starts_with("- "), "Should preserve bullet marker");
3502 }
3503
3504 #[test]
3505 fn test_code_block_in_list_item_five_spaces() {
3506 let config = MD013Config {
3507 reflow: true,
3508 reflow_mode: ReflowMode::Normalize,
3509 line_length: crate::types::LineLength::from_const(80),
3510 ..Default::default()
3511 };
3512 let rule = MD013LineLength::from_config_struct(config);
3513
3514 let content = "1. First paragraph with some text that should be reflowed.\n\n code_block()\n more_code()\n\n Second paragraph.";
3517 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3518 let result = rule.check(&ctx).unwrap();
3519
3520 if !result.is_empty() {
3521 let fix = result[0].fix.as_ref().unwrap();
3522 assert!(
3524 fix.replacement.contains(" code_block()"),
3525 "Code block should be preserved: {}",
3526 fix.replacement
3527 );
3528 assert!(
3529 fix.replacement.contains(" more_code()"),
3530 "Code block should be preserved: {}",
3531 fix.replacement
3532 );
3533 }
3534 }
3535
3536 #[test]
3537 fn test_fenced_code_block_in_list_item() {
3538 let config = MD013Config {
3539 reflow: true,
3540 reflow_mode: ReflowMode::Normalize,
3541 line_length: crate::types::LineLength::from_const(80),
3542 ..Default::default()
3543 };
3544 let rule = MD013LineLength::from_config_struct(config);
3545
3546 let content = "1. First paragraph with some text.\n\n ```rust\n fn foo() {}\n let x = 1;\n ```\n\n Second paragraph.";
3547 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3548 let result = rule.check(&ctx).unwrap();
3549
3550 if !result.is_empty() {
3551 let fix = result[0].fix.as_ref().unwrap();
3552 assert!(
3554 fix.replacement.contains("```rust"),
3555 "Should preserve fence: {}",
3556 fix.replacement
3557 );
3558 assert!(
3559 fix.replacement.contains("fn foo() {}"),
3560 "Should preserve code: {}",
3561 fix.replacement
3562 );
3563 assert!(
3564 fix.replacement.contains("```"),
3565 "Should preserve closing fence: {}",
3566 fix.replacement
3567 );
3568 }
3569 }
3570
3571 #[test]
3572 fn test_mixed_indentation_3_and_4_spaces() {
3573 let config = MD013Config {
3574 reflow: true,
3575 reflow_mode: ReflowMode::Normalize,
3576 line_length: crate::types::LineLength::from_const(999999),
3577 ..Default::default()
3578 };
3579 let rule = MD013LineLength::from_config_struct(config);
3580
3581 let content = "1. Text\n 3 space continuation\n 4 space continuation";
3583 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3584 let result = rule.check(&ctx).unwrap();
3585
3586 assert!(!result.is_empty(), "Should detect multi-line list item");
3587 let fix = result[0].fix.as_ref().unwrap();
3588 assert!(
3590 fix.replacement.contains("3 space continuation"),
3591 "Should include 3-space line: {}",
3592 fix.replacement
3593 );
3594 assert!(
3595 fix.replacement.contains("4 space continuation"),
3596 "Should include 4-space line: {}",
3597 fix.replacement
3598 );
3599 }
3600
3601 #[test]
3602 fn test_nested_list_in_multi_paragraph_item() {
3603 let config = MD013Config {
3604 reflow: true,
3605 reflow_mode: ReflowMode::Normalize,
3606 line_length: crate::types::LineLength::from_const(999999),
3607 ..Default::default()
3608 };
3609 let rule = MD013LineLength::from_config_struct(config);
3610
3611 let content = "1. First paragraph.\n\n - Nested item\n continuation\n\n Second paragraph.";
3612 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3613 let result = rule.check(&ctx).unwrap();
3614
3615 assert!(!result.is_empty(), "Should detect and reflow parent item");
3617 if let Some(fix) = result[0].fix.as_ref() {
3618 assert!(
3620 fix.replacement.contains("- Nested"),
3621 "Should preserve nested list: {}",
3622 fix.replacement
3623 );
3624 assert!(
3625 fix.replacement.contains("Second paragraph"),
3626 "Should include content after nested list: {}",
3627 fix.replacement
3628 );
3629 }
3630 }
3631
3632 #[test]
3633 fn test_nested_fence_markers_different_types() {
3634 let config = MD013Config {
3635 reflow: true,
3636 reflow_mode: ReflowMode::Normalize,
3637 line_length: crate::types::LineLength::from_const(80),
3638 ..Default::default()
3639 };
3640 let rule = MD013LineLength::from_config_struct(config);
3641
3642 let content = "1. Example with nested fences:\n\n ~~~markdown\n This shows ```python\n code = True\n ```\n ~~~\n\n Text after.";
3644 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3645 let result = rule.check(&ctx).unwrap();
3646
3647 if !result.is_empty() {
3648 let fix = result[0].fix.as_ref().unwrap();
3649 assert!(
3651 fix.replacement.contains("```python"),
3652 "Should preserve inner fence: {}",
3653 fix.replacement
3654 );
3655 assert!(
3656 fix.replacement.contains("~~~"),
3657 "Should preserve outer fence: {}",
3658 fix.replacement
3659 );
3660 assert!(
3662 fix.replacement.contains("code = True"),
3663 "Should preserve code: {}",
3664 fix.replacement
3665 );
3666 }
3667 }
3668
3669 #[test]
3670 fn test_nested_fence_markers_same_type() {
3671 let config = MD013Config {
3672 reflow: true,
3673 reflow_mode: ReflowMode::Normalize,
3674 line_length: crate::types::LineLength::from_const(80),
3675 ..Default::default()
3676 };
3677 let rule = MD013LineLength::from_config_struct(config);
3678
3679 let content =
3681 "1. Example:\n\n ````markdown\n Shows ```python in code\n ```\n text here\n ````\n\n After.";
3682 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3683 let result = rule.check(&ctx).unwrap();
3684
3685 if !result.is_empty() {
3686 let fix = result[0].fix.as_ref().unwrap();
3687 assert!(
3689 fix.replacement.contains("```python"),
3690 "Should preserve inner fence: {}",
3691 fix.replacement
3692 );
3693 assert!(
3694 fix.replacement.contains("````"),
3695 "Should preserve outer fence: {}",
3696 fix.replacement
3697 );
3698 assert!(
3699 fix.replacement.contains("text here"),
3700 "Should keep text as code: {}",
3701 fix.replacement
3702 );
3703 }
3704 }
3705
3706 #[test]
3707 fn test_sibling_list_item_breaks_parent() {
3708 let config = MD013Config {
3709 reflow: true,
3710 reflow_mode: ReflowMode::Normalize,
3711 line_length: crate::types::LineLength::from_const(999999),
3712 ..Default::default()
3713 };
3714 let rule = MD013LineLength::from_config_struct(config);
3715
3716 let content = "1. First item\n continuation.\n2. Second item";
3718 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3719 let result = rule.check(&ctx).unwrap();
3720
3721 if !result.is_empty() {
3723 let fix = result[0].fix.as_ref().unwrap();
3724 assert!(fix.replacement.starts_with("1. "), "Should start with first marker");
3726 assert!(fix.replacement.contains("continuation"), "Should include continuation");
3727 }
3729 }
3730
3731 #[test]
3732 fn test_nested_list_at_continuation_indent_preserved() {
3733 let config = MD013Config {
3734 reflow: true,
3735 reflow_mode: ReflowMode::Normalize,
3736 line_length: crate::types::LineLength::from_const(999999),
3737 ..Default::default()
3738 };
3739 let rule = MD013LineLength::from_config_struct(config);
3740
3741 let content = "1. Parent paragraph\n with continuation.\n\n - Nested at 3 spaces\n - Another nested\n\n After nested.";
3743 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3744 let result = rule.check(&ctx).unwrap();
3745
3746 if !result.is_empty() {
3747 let fix = result[0].fix.as_ref().unwrap();
3748 assert!(
3750 fix.replacement.contains("- Nested"),
3751 "Should include first nested item: {}",
3752 fix.replacement
3753 );
3754 assert!(
3755 fix.replacement.contains("- Another"),
3756 "Should include second nested item: {}",
3757 fix.replacement
3758 );
3759 assert!(
3760 fix.replacement.contains("After nested"),
3761 "Should include content after nested list: {}",
3762 fix.replacement
3763 );
3764 }
3765 }
3766
3767 #[test]
3768 fn test_paragraphs_false_skips_regular_text() {
3769 let config = MD013Config {
3771 line_length: crate::types::LineLength::from_const(50),
3772 paragraphs: false, code_blocks: true,
3774 tables: true,
3775 headings: true,
3776 strict: false,
3777 reflow: false,
3778 reflow_mode: ReflowMode::default(),
3779 };
3780 let rule = MD013LineLength::from_config_struct(config);
3781
3782 let content =
3783 "This is a very long line of regular text that exceeds fifty characters and should not trigger a warning.";
3784 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3785 let result = rule.check(&ctx).unwrap();
3786
3787 assert_eq!(
3789 result.len(),
3790 0,
3791 "Should not warn about long paragraph text when paragraphs=false"
3792 );
3793 }
3794
3795 #[test]
3796 fn test_paragraphs_false_still_checks_code_blocks() {
3797 let config = MD013Config {
3799 line_length: crate::types::LineLength::from_const(50),
3800 paragraphs: false, code_blocks: true, tables: true,
3803 headings: true,
3804 strict: false,
3805 reflow: false,
3806 reflow_mode: ReflowMode::default(),
3807 };
3808 let rule = MD013LineLength::from_config_struct(config);
3809
3810 let content = r#"```
3811This is a very long line in a code block that exceeds fifty characters.
3812```"#;
3813 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3814 let result = rule.check(&ctx).unwrap();
3815
3816 assert_eq!(
3818 result.len(),
3819 1,
3820 "Should warn about long lines in code blocks even when paragraphs=false"
3821 );
3822 }
3823
3824 #[test]
3825 fn test_paragraphs_false_still_checks_headings() {
3826 let config = MD013Config {
3828 line_length: crate::types::LineLength::from_const(50),
3829 paragraphs: false, code_blocks: true,
3831 tables: true,
3832 headings: true, strict: false,
3834 reflow: false,
3835 reflow_mode: ReflowMode::default(),
3836 };
3837 let rule = MD013LineLength::from_config_struct(config);
3838
3839 let content = "# This is a very long heading that exceeds fifty characters and should trigger a warning";
3840 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3841 let result = rule.check(&ctx).unwrap();
3842
3843 assert_eq!(
3845 result.len(),
3846 1,
3847 "Should warn about long headings even when paragraphs=false"
3848 );
3849 }
3850
3851 #[test]
3852 fn test_paragraphs_false_with_reflow_sentence_per_line() {
3853 let config = MD013Config {
3855 line_length: crate::types::LineLength::from_const(80),
3856 paragraphs: false,
3857 code_blocks: true,
3858 tables: true,
3859 headings: false,
3860 strict: false,
3861 reflow: true,
3862 reflow_mode: ReflowMode::SentencePerLine,
3863 };
3864 let rule = MD013LineLength::from_config_struct(config);
3865
3866 let content = "This is a very long sentence that exceeds eighty characters and contains important information that should not be flagged.";
3867 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3868 let result = rule.check(&ctx).unwrap();
3869
3870 assert_eq!(
3872 result.len(),
3873 0,
3874 "Should not warn about long sentences when paragraphs=false"
3875 );
3876 }
3877
3878 #[test]
3879 fn test_paragraphs_true_checks_regular_text() {
3880 let config = MD013Config {
3882 line_length: crate::types::LineLength::from_const(50),
3883 paragraphs: true, code_blocks: true,
3885 tables: true,
3886 headings: true,
3887 strict: false,
3888 reflow: false,
3889 reflow_mode: ReflowMode::default(),
3890 };
3891 let rule = MD013LineLength::from_config_struct(config);
3892
3893 let content = "This is a very long line of regular text that exceeds fifty characters.";
3894 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3895 let result = rule.check(&ctx).unwrap();
3896
3897 assert_eq!(
3899 result.len(),
3900 1,
3901 "Should warn about long paragraph text when paragraphs=true"
3902 );
3903 }
3904
3905 #[test]
3906 fn test_line_length_zero_disables_all_checks() {
3907 let config = MD013Config {
3909 line_length: crate::types::LineLength::from_const(0), paragraphs: true,
3911 code_blocks: true,
3912 tables: true,
3913 headings: true,
3914 strict: false,
3915 reflow: false,
3916 reflow_mode: ReflowMode::default(),
3917 };
3918 let rule = MD013LineLength::from_config_struct(config);
3919
3920 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.";
3921 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3922 let result = rule.check(&ctx).unwrap();
3923
3924 assert_eq!(
3926 result.len(),
3927 0,
3928 "Should not warn about any line length when line_length = 0"
3929 );
3930 }
3931
3932 #[test]
3933 fn test_line_length_zero_with_headings() {
3934 let config = MD013Config {
3936 line_length: crate::types::LineLength::from_const(0), paragraphs: true,
3938 code_blocks: true,
3939 tables: true,
3940 headings: true, strict: false,
3942 reflow: false,
3943 reflow_mode: ReflowMode::default(),
3944 };
3945 let rule = MD013LineLength::from_config_struct(config);
3946
3947 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";
3948 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3949 let result = rule.check(&ctx).unwrap();
3950
3951 assert_eq!(
3953 result.len(),
3954 0,
3955 "Should not warn about heading line length when line_length = 0"
3956 );
3957 }
3958
3959 #[test]
3960 fn test_line_length_zero_with_code_blocks() {
3961 let config = MD013Config {
3963 line_length: crate::types::LineLength::from_const(0), paragraphs: true,
3965 code_blocks: true, tables: true,
3967 headings: true,
3968 strict: false,
3969 reflow: false,
3970 reflow_mode: ReflowMode::default(),
3971 };
3972 let rule = MD013LineLength::from_config_struct(config);
3973
3974 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```";
3975 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
3976 let result = rule.check(&ctx).unwrap();
3977
3978 assert_eq!(
3980 result.len(),
3981 0,
3982 "Should not warn about code block line length when line_length = 0"
3983 );
3984 }
3985
3986 #[test]
3987 fn test_line_length_zero_with_sentence_per_line_reflow() {
3988 let config = MD013Config {
3990 line_length: crate::types::LineLength::from_const(0), paragraphs: true,
3992 code_blocks: true,
3993 tables: true,
3994 headings: true,
3995 strict: false,
3996 reflow: true,
3997 reflow_mode: ReflowMode::SentencePerLine,
3998 };
3999 let rule = MD013LineLength::from_config_struct(config);
4000
4001 let content = "This is sentence one. This is sentence two. This is sentence three.";
4002 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
4003 let result = rule.check(&ctx).unwrap();
4004
4005 assert_eq!(result.len(), 1, "Should provide reflow fix for multiple sentences");
4007 assert!(result[0].fix.is_some(), "Should have a fix available");
4008 }
4009
4010 #[test]
4011 fn test_line_length_zero_config_parsing() {
4012 let toml_str = r#"
4014 line-length = 0
4015 paragraphs = true
4016 reflow = true
4017 reflow-mode = "sentence-per-line"
4018 "#;
4019 let config: MD013Config = toml::from_str(toml_str).unwrap();
4020 assert_eq!(config.line_length.get(), 0, "Should parse line_length = 0");
4021 assert!(config.line_length.is_unlimited(), "Should be unlimited");
4022 assert!(config.paragraphs);
4023 assert!(config.reflow);
4024 assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
4025 }
4026
4027 #[test]
4028 fn test_template_directives_as_paragraph_boundaries() {
4029 let content = r#"Some regular text here.
4031
4032{{#tabs }}
4033{{#tab name="Tab 1" }}
4034
4035More text in the tab.
4036
4037{{#endtab }}
4038{{#tabs }}
4039
4040Final paragraph.
4041"#;
4042
4043 let ctx = LintContext::new(content, MarkdownFlavor::Standard);
4044 let config = MD013Config {
4045 line_length: crate::types::LineLength::from_const(80),
4046 code_blocks: true,
4047 tables: true,
4048 headings: true,
4049 paragraphs: true,
4050 strict: false,
4051 reflow: true,
4052 reflow_mode: ReflowMode::SentencePerLine,
4053 };
4054 let rule = MD013LineLength::from_config_struct(config);
4055 let result = rule.check(&ctx).unwrap();
4056
4057 for warning in &result {
4060 assert!(
4061 !warning.message.contains("multiple sentences"),
4062 "Template directives should not trigger 'multiple sentences' warning. Got: {}",
4063 warning.message
4064 );
4065 }
4066 }
4067
4068 #[test]
4069 fn test_template_directive_detection() {
4070 assert!(is_template_directive_only("{{#tabs }}"));
4072 assert!(is_template_directive_only("{{#endtab }}"));
4073 assert!(is_template_directive_only("{{variable}}"));
4074 assert!(is_template_directive_only(" {{#tabs }} "));
4075
4076 assert!(is_template_directive_only("{% for item in items %}"));
4078 assert!(is_template_directive_only("{%endfor%}"));
4079 assert!(is_template_directive_only(" {% if condition %} "));
4080
4081 assert!(!is_template_directive_only("This is {{variable}} in text"));
4083 assert!(!is_template_directive_only("{{incomplete"));
4084 assert!(!is_template_directive_only("incomplete}}"));
4085 assert!(!is_template_directive_only(""));
4086 assert!(!is_template_directive_only(" "));
4087 assert!(!is_template_directive_only("Regular text"));
4088 }
4089
4090 #[test]
4091 fn test_mixed_content_with_templates() {
4092 let content = "This has {{variable}} in the middle.";
4094 assert!(!is_template_directive_only(content));
4095
4096 let content2 = "Start {{#something}} end";
4097 assert!(!is_template_directive_only(content2));
4098 }
4099}