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