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