1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
7use crate::utils::range_utils::LineIndex;
8use crate::utils::range_utils::calculate_excess_range;
9use crate::utils::regex_cache::{
10 IMAGE_REF_PATTERN, INLINE_LINK_REGEX as MARKDOWN_LINK_PATTERN, LINK_REF_PATTERN, URL_IN_TEXT, URL_PATTERN,
11};
12use crate::utils::table_utils::TableUtils;
13use crate::utils::text_reflow::split_into_sentences;
14use toml;
15
16pub mod md013_config;
17use md013_config::{MD013Config, 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 strict,
33 reflow: false,
34 reflow_mode: ReflowMode::default(),
35 },
36 }
37 }
38
39 pub fn from_config_struct(config: MD013Config) -> Self {
40 Self { config }
41 }
42
43 fn should_ignore_line(
44 &self,
45 line: &str,
46 _lines: &[&str],
47 current_line: usize,
48 structure: &DocumentStructure,
49 ) -> bool {
50 if self.config.strict {
51 return false;
52 }
53
54 let trimmed = line.trim();
56
57 if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) && URL_PATTERN.is_match(trimmed) {
59 return true;
60 }
61
62 if trimmed.starts_with("![") && trimmed.ends_with(']') && IMAGE_REF_PATTERN.is_match(trimmed) {
64 return true;
65 }
66
67 if trimmed.starts_with('[') && trimmed.contains("]:") && LINK_REF_PATTERN.is_match(trimmed) {
69 return true;
70 }
71
72 if structure.is_in_code_block(current_line + 1)
74 && !trimmed.is_empty()
75 && !line.contains(' ')
76 && !line.contains('\t')
77 {
78 return true;
79 }
80
81 false
82 }
83}
84
85impl Rule for MD013LineLength {
86 fn name(&self) -> &'static str {
87 "MD013"
88 }
89
90 fn description(&self) -> &'static str {
91 "Line length should not be excessive"
92 }
93
94 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
95 let content = ctx.content;
96
97 if self.should_skip(ctx)
100 && !(self.config.reflow
101 && (self.config.reflow_mode == ReflowMode::Normalize
102 || self.config.reflow_mode == ReflowMode::SentencePerLine))
103 {
104 return Ok(Vec::new());
105 }
106
107 let structure = DocumentStructure::new(content);
109 self.check_with_structure(ctx, &structure)
110 }
111
112 fn check_with_structure(
114 &self,
115 ctx: &crate::lint_context::LintContext,
116 structure: &DocumentStructure,
117 ) -> LintResult {
118 let content = ctx.content;
119 let mut warnings = Vec::new();
120
121 let inline_config = crate::inline_config::InlineConfig::from_content(content);
125 let config_override = inline_config.get_rule_config("MD013");
126
127 let effective_config = if let Some(json_config) = config_override {
129 if let Some(obj) = json_config.as_object() {
130 let mut config = self.config.clone();
131 if let Some(line_length) = obj.get("line_length").and_then(|v| v.as_u64()) {
132 config.line_length = line_length as usize;
133 }
134 if let Some(code_blocks) = obj.get("code_blocks").and_then(|v| v.as_bool()) {
135 config.code_blocks = code_blocks;
136 }
137 if let Some(tables) = obj.get("tables").and_then(|v| v.as_bool()) {
138 config.tables = tables;
139 }
140 if let Some(headings) = obj.get("headings").and_then(|v| v.as_bool()) {
141 config.headings = headings;
142 }
143 if let Some(strict) = obj.get("strict").and_then(|v| v.as_bool()) {
144 config.strict = strict;
145 }
146 if let Some(reflow) = obj.get("reflow").and_then(|v| v.as_bool()) {
147 config.reflow = reflow;
148 }
149 if let Some(reflow_mode) = obj.get("reflow_mode").and_then(|v| v.as_str()) {
150 config.reflow_mode = match reflow_mode {
151 "default" => ReflowMode::Default,
152 "normalize" => ReflowMode::Normalize,
153 "sentence-per-line" => ReflowMode::SentencePerLine,
154 _ => ReflowMode::default(),
155 };
156 }
157 config
158 } else {
159 self.config.clone()
160 }
161 } else {
162 self.config.clone()
163 };
164
165 let mut candidate_lines = Vec::new();
167 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
168 if line_info.content.len() > effective_config.line_length {
170 candidate_lines.push(line_idx);
171 }
172 }
173
174 if candidate_lines.is_empty()
176 && !(effective_config.reflow
177 && (effective_config.reflow_mode == ReflowMode::Normalize
178 || effective_config.reflow_mode == ReflowMode::SentencePerLine))
179 {
180 return Ok(warnings);
181 }
182
183 let lines: Vec<&str> = if !ctx.lines.is_empty() {
185 ctx.lines.iter().map(|l| l.content.as_str()).collect()
186 } else {
187 content.lines().collect()
188 };
189
190 let heading_lines_set: std::collections::HashSet<usize> = if !effective_config.headings {
192 structure.heading_lines.iter().cloned().collect()
193 } else {
194 std::collections::HashSet::new()
195 };
196
197 let table_lines_set: std::collections::HashSet<usize> = if !effective_config.tables {
199 let table_blocks = TableUtils::find_table_blocks(content, ctx);
200 let mut table_lines = std::collections::HashSet::new();
201 for table in &table_blocks {
202 table_lines.insert(table.header_line + 1);
203 table_lines.insert(table.delimiter_line + 1);
204 for &line in &table.content_lines {
205 table_lines.insert(line + 1);
206 }
207 }
208 table_lines
209 } else {
210 std::collections::HashSet::new()
211 };
212
213 if effective_config.reflow_mode != ReflowMode::SentencePerLine {
216 for &line_idx in &candidate_lines {
217 let line_number = line_idx + 1;
218 let line = lines[line_idx];
219
220 let effective_length = self.calculate_effective_length(line);
222
223 let line_limit = effective_config.line_length;
225
226 if effective_length <= line_limit {
228 continue;
229 }
230
231 if !effective_config.strict {
233 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
235 continue;
236 }
237
238 if (!effective_config.headings && heading_lines_set.contains(&line_number))
242 || (!effective_config.code_blocks && structure.is_in_code_block(line_number))
243 || (!effective_config.tables && table_lines_set.contains(&line_number))
244 || structure.is_in_blockquote(line_number)
245 || structure.is_in_html_block(line_number)
246 {
247 continue;
248 }
249
250 if self.should_ignore_line(line, &lines, line_idx, structure) {
252 continue;
253 }
254 }
255
256 let fix = None;
259
260 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
261
262 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
264
265 warnings.push(LintWarning {
266 rule_name: Some(self.name()),
267 message,
268 line: start_line,
269 column: start_col,
270 end_line,
271 end_column: end_col,
272 severity: Severity::Warning,
273 fix,
274 });
275 }
276 }
277
278 if effective_config.reflow {
280 let paragraph_warnings = self.generate_paragraph_fixes(ctx, structure, &effective_config, &lines);
281 for pw in paragraph_warnings {
283 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
285 warnings.push(pw);
286 }
287 }
288
289 Ok(warnings)
290 }
291
292 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
293 let warnings = self.check(ctx)?;
296
297 if !warnings.iter().any(|w| w.fix.is_some()) {
299 return Ok(ctx.content.to_string());
300 }
301
302 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
304 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
305 }
306
307 fn as_any(&self) -> &dyn std::any::Any {
308 self
309 }
310
311 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
312 Some(self)
313 }
314
315 fn category(&self) -> RuleCategory {
316 RuleCategory::Whitespace
317 }
318
319 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
320 if ctx.content.is_empty() {
322 return true;
323 }
324
325 if self.config.reflow
327 && (self.config.reflow_mode == ReflowMode::SentencePerLine
328 || self.config.reflow_mode == ReflowMode::Normalize)
329 {
330 return false;
331 }
332
333 if ctx.content.len() <= self.config.line_length {
335 return true;
336 }
337
338 !ctx.lines
340 .iter()
341 .any(|line| line.content.len() > self.config.line_length)
342 }
343
344 fn default_config_section(&self) -> Option<(String, toml::Value)> {
345 let default_config = MD013Config::default();
346 let json_value = serde_json::to_value(&default_config).ok()?;
347 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
348
349 if let toml::Value::Table(table) = toml_value {
350 if !table.is_empty() {
351 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
352 } else {
353 None
354 }
355 } else {
356 None
357 }
358 }
359
360 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
361 let mut aliases = std::collections::HashMap::new();
362 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
363 Some(aliases)
364 }
365
366 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
367 where
368 Self: Sized,
369 {
370 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
371 if rule_config.line_length == 80 {
373 rule_config.line_length = config.global.line_length as usize;
375 }
376 Box::new(Self::from_config_struct(rule_config))
377 }
378}
379
380impl MD013LineLength {
381 fn generate_paragraph_fixes(
383 &self,
384 ctx: &crate::lint_context::LintContext,
385 structure: &DocumentStructure,
386 config: &MD013Config,
387 lines: &[&str],
388 ) -> Vec<LintWarning> {
389 let mut warnings = Vec::new();
390 let line_index = LineIndex::new(ctx.content.to_string());
391
392 let mut i = 0;
393 while i < lines.len() {
394 let line_num = i + 1;
395
396 if structure.is_in_code_block(line_num)
398 || structure.is_in_front_matter(line_num)
399 || structure.is_in_html_block(line_num)
400 || structure.is_in_blockquote(line_num)
401 || lines[i].trim().starts_with('#')
402 || TableUtils::is_potential_table_row(lines[i])
403 || lines[i].trim().is_empty()
404 || is_horizontal_rule(lines[i].trim())
405 {
406 i += 1;
407 continue;
408 }
409
410 let trimmed = lines[i].trim();
412 if is_list_item(trimmed) {
413 let list_start = i;
415 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
416 let indent_size = marker.len();
417 let expected_indent = " ".repeat(indent_size);
418
419 let mut list_item_lines = vec![first_content];
420 i += 1;
421
422 while i < lines.len() {
424 let line = lines[i];
425 if line.starts_with(&expected_indent) && !line.trim().is_empty() {
427 let content_after_indent = &line[indent_size..];
429 if is_list_item(content_after_indent.trim()) {
430 break;
432 }
433 let content = line[indent_size..].to_string();
435 list_item_lines.push(content);
436 i += 1;
437 } else if line.trim().is_empty() {
438 if i + 1 < lines.len() && lines[i + 1].starts_with(&expected_indent) {
441 list_item_lines.push(String::new());
442 i += 1;
443 } else {
444 break;
445 }
446 } else {
447 break;
448 }
449 }
450
451 let contains_code_block = (list_start..i).any(|line_idx| structure.is_in_code_block(line_idx + 1));
453
454 let combined_content = list_item_lines.join(" ").trim().to_string();
456 let full_line = format!("{marker}{combined_content}");
457
458 if !contains_code_block
459 && (self.calculate_effective_length(&full_line) > config.line_length
460 || (config.reflow_mode == ReflowMode::Normalize && list_item_lines.len() > 1)
461 || (config.reflow_mode == ReflowMode::SentencePerLine && {
462 let sentences = split_into_sentences(&combined_content);
464 sentences.len() > 1
465 }))
466 {
467 let start_range = line_index.whole_line_range(list_start + 1);
468 let end_line = i - 1;
469 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
470 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
471 } else {
472 line_index.whole_line_range(end_line + 1)
473 };
474 let byte_range = start_range.start..end_range.end;
475
476 let reflow_options = crate::utils::text_reflow::ReflowOptions {
478 line_length: config.line_length - indent_size,
479 break_on_sentences: true,
480 preserve_breaks: false,
481 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
482 };
483 let reflowed = crate::utils::text_reflow::reflow_line(&combined_content, &reflow_options);
484
485 let mut result = vec![format!("{marker}{}", reflowed[0])];
487 for line in reflowed.iter().skip(1) {
488 result.push(format!("{expected_indent}{line}"));
489 }
490 let reflowed_text = result.join("\n");
491
492 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
494 format!("{reflowed_text}\n")
495 } else {
496 reflowed_text
497 };
498
499 warnings.push(LintWarning {
500 rule_name: Some(self.name()),
501 message: if config.reflow_mode == ReflowMode::SentencePerLine {
502 "Line contains multiple sentences (one sentence per line expected)".to_string()
503 } else {
504 format!(
505 "Line length exceeds {} characters and can be reflowed",
506 config.line_length
507 )
508 },
509 line: list_start + 1,
510 column: 1,
511 end_line: end_line + 1,
512 end_column: lines[end_line].len() + 1,
513 severity: Severity::Warning,
514 fix: Some(crate::rule::Fix {
515 range: byte_range,
516 replacement,
517 }),
518 });
519 }
520 continue;
521 }
522
523 let paragraph_start = i;
525 let mut paragraph_lines = vec![lines[i]];
526 i += 1;
527
528 while i < lines.len() {
529 let next_line = lines[i];
530 let next_line_num = i + 1;
531 let next_trimmed = next_line.trim();
532
533 if next_trimmed.is_empty()
535 || structure.is_in_code_block(next_line_num)
536 || structure.is_in_front_matter(next_line_num)
537 || structure.is_in_html_block(next_line_num)
538 || structure.is_in_blockquote(next_line_num)
539 || next_trimmed.starts_with('#')
540 || TableUtils::is_potential_table_row(next_line)
541 || is_list_item(next_trimmed)
542 || is_horizontal_rule(next_trimmed)
543 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
544 {
545 break;
546 }
547
548 if i > 0 && lines[i - 1].ends_with(" ") {
550 break;
552 }
553
554 paragraph_lines.push(next_line);
555 i += 1;
556 }
557
558 let needs_reflow = match config.reflow_mode {
560 ReflowMode::Normalize => {
561 paragraph_lines.len() > 1
563 }
564 ReflowMode::SentencePerLine => {
565 paragraph_lines.iter().any(|line| {
567 let sentences = split_into_sentences(line);
569 sentences.len() > 1
570 })
571 }
572 ReflowMode::Default => {
573 paragraph_lines
575 .iter()
576 .any(|line| self.calculate_effective_length(line) > config.line_length)
577 }
578 };
579
580 if needs_reflow {
581 let start_range = line_index.whole_line_range(paragraph_start + 1);
584 let end_line = paragraph_start + paragraph_lines.len() - 1;
585
586 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
588 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
590 } else {
591 line_index.whole_line_range(end_line + 1)
593 };
594
595 let byte_range = start_range.start..end_range.end;
596
597 let paragraph_text = paragraph_lines.join(" ");
599
600 let has_hard_break = paragraph_lines.last().is_some_and(|l| l.ends_with(" "));
602
603 let reflow_options = crate::utils::text_reflow::ReflowOptions {
605 line_length: config.line_length,
606 break_on_sentences: true,
607 preserve_breaks: false,
608 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
609 };
610 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
611
612 if has_hard_break && !reflowed.is_empty() {
614 let last_idx = reflowed.len() - 1;
615 if !reflowed[last_idx].ends_with(" ") {
616 reflowed[last_idx].push_str(" ");
617 }
618 }
619
620 let reflowed_text = reflowed.join("\n");
621
622 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
624 format!("{reflowed_text}\n")
625 } else {
626 reflowed_text
627 };
628
629 let (warning_line, warning_end_line) = match config.reflow_mode {
634 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
635 ReflowMode::SentencePerLine => {
636 let mut violating_line = paragraph_start;
638 for (idx, line) in paragraph_lines.iter().enumerate() {
639 let sentences = split_into_sentences(line);
640 if sentences.len() > 1 {
641 violating_line = paragraph_start + idx;
642 break;
643 }
644 }
645 (violating_line + 1, violating_line + 1)
646 }
647 ReflowMode::Default => {
648 let mut violating_line = paragraph_start;
650 for (idx, line) in paragraph_lines.iter().enumerate() {
651 if self.calculate_effective_length(line) > config.line_length {
652 violating_line = paragraph_start + idx;
653 break;
654 }
655 }
656 (violating_line + 1, violating_line + 1)
657 }
658 };
659
660 warnings.push(LintWarning {
661 rule_name: Some(self.name()),
662 message: match config.reflow_mode {
663 ReflowMode::Normalize => format!(
664 "Paragraph could be normalized to use line length of {} characters",
665 config.line_length
666 ),
667 ReflowMode::SentencePerLine => {
668 "Line contains multiple sentences (one sentence per line expected)".to_string()
669 }
670 ReflowMode::Default => format!(
671 "Line length exceeds {} characters and can be reflowed",
672 config.line_length
673 ),
674 },
675 line: warning_line,
676 column: 1,
677 end_line: warning_end_line,
678 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
679 severity: Severity::Warning,
680 fix: Some(crate::rule::Fix {
681 range: byte_range,
682 replacement,
683 }),
684 });
685 }
686 }
687
688 warnings
689 }
690
691 fn calculate_effective_length(&self, line: &str) -> usize {
693 if self.config.strict {
694 return line.chars().count();
696 }
697
698 let bytes = line.as_bytes();
700 if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
701 return line.chars().count();
702 }
703
704 if !line.contains("http") && !line.contains('[') {
706 return line.chars().count();
707 }
708
709 let mut effective_line = line.to_string();
710
711 if line.contains('[') && line.contains("](") {
714 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
715 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
716 && url.as_str().len() > 15
717 {
718 let replacement = format!("[{}](url)", text.as_str());
719 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
720 }
721 }
722 }
723
724 if effective_line.contains("http") {
727 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
728 let url = url_match.as_str();
729 if !effective_line.contains(&format!("({url})")) {
731 let placeholder = "x".repeat(15.min(url.len()));
734 effective_line = effective_line.replacen(url, &placeholder, 1);
735 }
736 }
737 }
738
739 effective_line.chars().count()
740 }
741}
742
743impl DocumentStructureExtensions for MD013LineLength {
744 fn has_relevant_elements(
745 &self,
746 ctx: &crate::lint_context::LintContext,
747 _doc_structure: &DocumentStructure,
748 ) -> bool {
749 !ctx.content.is_empty()
751 }
752}
753
754fn extract_list_marker_and_content(line: &str) -> (String, String) {
756 let indent_len = line.len() - line.trim_start().len();
758 let indent = &line[..indent_len];
759 let trimmed = &line[indent_len..];
760
761 if let Some(rest) = trimmed.strip_prefix("- ") {
763 return (format!("{indent}- "), rest.to_string());
764 }
765 if let Some(rest) = trimmed.strip_prefix("* ") {
766 return (format!("{indent}* "), rest.to_string());
767 }
768 if let Some(rest) = trimmed.strip_prefix("+ ") {
769 return (format!("{indent}+ "), rest.to_string());
770 }
771
772 let mut chars = trimmed.chars();
774 let mut marker_content = String::new();
775
776 while let Some(c) = chars.next() {
777 marker_content.push(c);
778 if c == '.' {
779 if let Some(next) = chars.next()
781 && next == ' '
782 {
783 marker_content.push(next);
784 let content = chars.as_str().to_string();
785 return (format!("{indent}{marker_content}"), content);
786 }
787 break;
788 }
789 }
790
791 (String::new(), line.to_string())
793}
794
795fn is_horizontal_rule(line: &str) -> bool {
797 if line.len() < 3 {
798 return false;
799 }
800 let chars: Vec<char> = line.chars().collect();
802 if chars.is_empty() {
803 return false;
804 }
805 let first_char = chars[0];
806 if first_char != '-' && first_char != '_' && first_char != '*' {
807 return false;
808 }
809 for c in &chars {
811 if *c != first_char && *c != ' ' {
812 return false;
813 }
814 }
815 chars.iter().filter(|c| **c == first_char).count() >= 3
817}
818
819fn is_numbered_list_item(line: &str) -> bool {
820 let mut chars = line.chars();
821 if !chars.next().is_some_and(|c| c.is_numeric()) {
823 return false;
824 }
825 while let Some(c) = chars.next() {
827 if c == '.' {
828 return chars.next().is_none_or(|c| c == ' ');
830 }
831 if !c.is_numeric() {
832 return false;
833 }
834 }
835 false
836}
837
838fn is_list_item(line: &str) -> bool {
839 if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
841 && line.len() > 1
842 && line.chars().nth(1) == Some(' ')
843 {
844 return true;
845 }
846 is_numbered_list_item(line)
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853 use crate::lint_context::LintContext;
854
855 #[test]
856 fn test_default_config() {
857 let rule = MD013LineLength::default();
858 assert_eq!(rule.config.line_length, 80);
859 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
863 }
864
865 #[test]
866 fn test_custom_config() {
867 let rule = MD013LineLength::new(100, true, true, false, true);
868 assert_eq!(rule.config.line_length, 100);
869 assert!(rule.config.code_blocks);
870 assert!(rule.config.tables);
871 assert!(!rule.config.headings);
872 assert!(rule.config.strict);
873 }
874
875 #[test]
876 fn test_basic_line_length_violation() {
877 let rule = MD013LineLength::new(50, false, false, false, false);
878 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
880 let result = rule.check(&ctx).unwrap();
881
882 assert_eq!(result.len(), 1);
883 assert!(result[0].message.contains("Line length"));
884 assert!(result[0].message.contains("exceeds 50 characters"));
885 }
886
887 #[test]
888 fn test_no_violation_under_limit() {
889 let rule = MD013LineLength::new(100, false, false, false, false);
890 let content = "Short line.\nAnother short line.";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
892 let result = rule.check(&ctx).unwrap();
893
894 assert_eq!(result.len(), 0);
895 }
896
897 #[test]
898 fn test_multiple_violations() {
899 let rule = MD013LineLength::new(30, false, false, false, false);
900 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
902 let result = rule.check(&ctx).unwrap();
903
904 assert_eq!(result.len(), 2);
905 assert_eq!(result[0].line, 1);
906 assert_eq!(result[1].line, 2);
907 }
908
909 #[test]
910 fn test_code_blocks_exemption() {
911 let rule = MD013LineLength::new(30, false, false, false, false);
913 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
915 let result = rule.check(&ctx).unwrap();
916
917 assert_eq!(result.len(), 0);
918 }
919
920 #[test]
921 fn test_code_blocks_not_exempt_when_configured() {
922 let rule = MD013LineLength::new(30, true, false, false, false);
924 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
926 let result = rule.check(&ctx).unwrap();
927
928 assert!(!result.is_empty());
929 }
930
931 #[test]
932 fn test_heading_checked_when_enabled() {
933 let rule = MD013LineLength::new(30, false, false, true, false);
934 let content = "# This is a very long heading that would normally exceed the limit";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
936 let result = rule.check(&ctx).unwrap();
937
938 assert_eq!(result.len(), 1);
939 }
940
941 #[test]
942 fn test_heading_exempt_when_disabled() {
943 let rule = MD013LineLength::new(30, false, false, false, false);
944 let content = "# This is a very long heading that should trigger a warning";
945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
946 let result = rule.check(&ctx).unwrap();
947
948 assert_eq!(result.len(), 0);
949 }
950
951 #[test]
952 fn test_table_checked_when_enabled() {
953 let rule = MD013LineLength::new(30, false, true, false, false);
954 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
956 let result = rule.check(&ctx).unwrap();
957
958 assert_eq!(result.len(), 2); }
960
961 #[test]
962 fn test_issue_78_tables_after_fenced_code_blocks() {
963 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
966
967```plain
968some code block longer than 20 chars length
969```
970
971this is a very long line
972
973| column A | column B |
974| -------- | -------- |
975| `var` | `val` |
976| value 1 | value 2 |
977
978correct length line"#;
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
980 let result = rule.check(&ctx).unwrap();
981
982 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
984 assert_eq!(result[0].line, 7, "Should flag line 7");
985 assert!(result[0].message.contains("24 exceeds 20"));
986 }
987
988 #[test]
989 fn test_issue_78_tables_with_inline_code() {
990 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
993| -------- | -------- |
994| `var with very long name` | `val exceeding limit` |
995| value 1 | value 2 |
996
997This line exceeds limit"#;
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
999 let result = rule.check(&ctx).unwrap();
1000
1001 assert_eq!(result.len(), 1, "Should only flag the non-table line");
1003 assert_eq!(result[0].line, 6, "Should flag line 6");
1004 }
1005
1006 #[test]
1007 fn test_issue_78_indented_code_blocks() {
1008 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
1011
1012 some code block longer than 20 chars length
1013
1014this is a very long line
1015
1016| column A | column B |
1017| -------- | -------- |
1018| value 1 | value 2 |
1019
1020correct length line"#;
1021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1022 let result = rule.check(&ctx).unwrap();
1023
1024 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1026 assert_eq!(result[0].line, 5, "Should flag line 5");
1027 }
1028
1029 #[test]
1030 fn test_url_exemption() {
1031 let rule = MD013LineLength::new(30, false, false, false, false);
1032 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1034 let result = rule.check(&ctx).unwrap();
1035
1036 assert_eq!(result.len(), 0);
1037 }
1038
1039 #[test]
1040 fn test_image_reference_exemption() {
1041 let rule = MD013LineLength::new(30, false, false, false, false);
1042 let content = "![This is a very long image alt text that exceeds limit][reference]";
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1044 let result = rule.check(&ctx).unwrap();
1045
1046 assert_eq!(result.len(), 0);
1047 }
1048
1049 #[test]
1050 fn test_link_reference_exemption() {
1051 let rule = MD013LineLength::new(30, false, false, false, false);
1052 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1054 let result = rule.check(&ctx).unwrap();
1055
1056 assert_eq!(result.len(), 0);
1057 }
1058
1059 #[test]
1060 fn test_strict_mode() {
1061 let rule = MD013LineLength::new(30, false, false, false, true);
1062 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1064 let result = rule.check(&ctx).unwrap();
1065
1066 assert_eq!(result.len(), 1);
1068 }
1069
1070 #[test]
1071 fn test_blockquote_exemption() {
1072 let rule = MD013LineLength::new(30, false, false, false, false);
1073 let content = "> This is a very long line inside a blockquote that should be ignored.";
1074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1075 let result = rule.check(&ctx).unwrap();
1076
1077 assert_eq!(result.len(), 0);
1078 }
1079
1080 #[test]
1081 fn test_setext_heading_underline_exemption() {
1082 let rule = MD013LineLength::new(30, false, false, false, false);
1083 let content = "Heading\n========================================";
1084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1085 let result = rule.check(&ctx).unwrap();
1086
1087 assert_eq!(result.len(), 0);
1089 }
1090
1091 #[test]
1092 fn test_no_fix_without_reflow() {
1093 let rule = MD013LineLength::new(60, false, false, false, false);
1094 let content = "This line has trailing whitespace that makes it too long ";
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1096 let result = rule.check(&ctx).unwrap();
1097
1098 assert_eq!(result.len(), 1);
1099 assert!(result[0].fix.is_none());
1101
1102 let fixed = rule.fix(&ctx).unwrap();
1104 assert_eq!(fixed, content);
1105 }
1106
1107 #[test]
1108 fn test_character_vs_byte_counting() {
1109 let rule = MD013LineLength::new(10, false, false, false, false);
1110 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1113 let result = rule.check(&ctx).unwrap();
1114
1115 assert_eq!(result.len(), 1);
1116 assert_eq!(result[0].line, 1);
1117 }
1118
1119 #[test]
1120 fn test_empty_content() {
1121 let rule = MD013LineLength::default();
1122 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1123 let result = rule.check(&ctx).unwrap();
1124
1125 assert_eq!(result.len(), 0);
1126 }
1127
1128 #[test]
1129 fn test_excess_range_calculation() {
1130 let rule = MD013LineLength::new(10, false, false, false, false);
1131 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1133 let result = rule.check(&ctx).unwrap();
1134
1135 assert_eq!(result.len(), 1);
1136 assert_eq!(result[0].column, 11);
1138 assert_eq!(result[0].end_column, 21);
1139 }
1140
1141 #[test]
1142 fn test_html_block_exemption() {
1143 let rule = MD013LineLength::new(30, false, false, false, false);
1144 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1146 let result = rule.check(&ctx).unwrap();
1147
1148 assert_eq!(result.len(), 0);
1150 }
1151
1152 #[test]
1153 fn test_mixed_content() {
1154 let rule = MD013LineLength::new(30, false, false, false, false);
1156 let content = r#"# This heading is very long but should be exempt
1157
1158This regular paragraph line is too long and should trigger.
1159
1160```
1161Code block line that is very long but exempt.
1162```
1163
1164| Table | With very long content |
1165|-------|------------------------|
1166
1167Another long line that should trigger a warning."#;
1168
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1170 let result = rule.check(&ctx).unwrap();
1171
1172 assert_eq!(result.len(), 2);
1174 assert_eq!(result[0].line, 3);
1175 assert_eq!(result[1].line, 12);
1176 }
1177
1178 #[test]
1179 fn test_fix_without_reflow_preserves_content() {
1180 let rule = MD013LineLength::new(50, false, false, false, false);
1181 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1183
1184 let fixed = rule.fix(&ctx).unwrap();
1186 assert_eq!(fixed, content);
1187 }
1188
1189 #[test]
1190 fn test_has_relevant_elements() {
1191 let rule = MD013LineLength::default();
1192 let structure = DocumentStructure::new("test");
1193
1194 let ctx = LintContext::new("Some content", crate::config::MarkdownFlavor::Standard);
1195 assert!(rule.has_relevant_elements(&ctx, &structure));
1196
1197 let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1198 assert!(!rule.has_relevant_elements(&empty_ctx, &structure));
1199 }
1200
1201 #[test]
1202 fn test_rule_metadata() {
1203 let rule = MD013LineLength::default();
1204 assert_eq!(rule.name(), "MD013");
1205 assert_eq!(rule.description(), "Line length should not be excessive");
1206 assert_eq!(rule.category(), RuleCategory::Whitespace);
1207 }
1208
1209 #[test]
1210 fn test_url_embedded_in_text() {
1211 let rule = MD013LineLength::new(50, false, false, false, false);
1212
1213 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1216 let result = rule.check(&ctx).unwrap();
1217
1218 assert_eq!(result.len(), 0);
1220 }
1221
1222 #[test]
1223 fn test_multiple_urls_in_line() {
1224 let rule = MD013LineLength::new(50, false, false, false, false);
1225
1226 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1229
1230 let result = rule.check(&ctx).unwrap();
1231
1232 assert_eq!(result.len(), 0);
1234 }
1235
1236 #[test]
1237 fn test_markdown_link_with_long_url() {
1238 let rule = MD013LineLength::new(50, false, false, false, false);
1239
1240 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1243 let result = rule.check(&ctx).unwrap();
1244
1245 assert_eq!(result.len(), 0);
1247 }
1248
1249 #[test]
1250 fn test_line_too_long_even_without_urls() {
1251 let rule = MD013LineLength::new(50, false, false, false, false);
1252
1253 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1256 let result = rule.check(&ctx).unwrap();
1257
1258 assert_eq!(result.len(), 1);
1260 }
1261
1262 #[test]
1263 fn test_strict_mode_counts_urls() {
1264 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";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1269 let result = rule.check(&ctx).unwrap();
1270
1271 assert_eq!(result.len(), 1);
1273 }
1274
1275 #[test]
1276 fn test_documentation_example_from_md051() {
1277 let rule = MD013LineLength::new(80, false, false, false, false);
1278
1279 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1281 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1282 let result = rule.check(&ctx).unwrap();
1283
1284 assert_eq!(result.len(), 0);
1286 }
1287
1288 #[test]
1289 fn test_text_reflow_simple() {
1290 let config = MD013Config {
1291 line_length: 30,
1292 reflow: true,
1293 ..Default::default()
1294 };
1295 let rule = MD013LineLength::from_config_struct(config);
1296
1297 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1299
1300 let fixed = rule.fix(&ctx).unwrap();
1301
1302 for line in fixed.lines() {
1304 assert!(
1305 line.chars().count() <= 30,
1306 "Line too long: {} (len={})",
1307 line,
1308 line.chars().count()
1309 );
1310 }
1311
1312 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1314 let original_words: Vec<&str> = content.split_whitespace().collect();
1315 assert_eq!(fixed_words, original_words);
1316 }
1317
1318 #[test]
1319 fn test_text_reflow_preserves_markdown_elements() {
1320 let config = MD013Config {
1321 line_length: 40,
1322 reflow: true,
1323 ..Default::default()
1324 };
1325 let rule = MD013LineLength::from_config_struct(config);
1326
1327 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1329
1330 let fixed = rule.fix(&ctx).unwrap();
1331
1332 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1334 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1335 assert!(
1336 fixed.contains("[a link](https://example.com)"),
1337 "Link not preserved in: {fixed}"
1338 );
1339
1340 for line in fixed.lines() {
1342 assert!(line.len() <= 40, "Line too long: {line}");
1343 }
1344 }
1345
1346 #[test]
1347 fn test_text_reflow_preserves_code_blocks() {
1348 let config = MD013Config {
1349 line_length: 30,
1350 reflow: true,
1351 ..Default::default()
1352 };
1353 let rule = MD013LineLength::from_config_struct(config);
1354
1355 let content = r#"Here is some text.
1356
1357```python
1358def very_long_function_name_that_exceeds_limit():
1359 return "This should not be wrapped"
1360```
1361
1362More text after code block."#;
1363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1364
1365 let fixed = rule.fix(&ctx).unwrap();
1366
1367 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1369 assert!(fixed.contains("```python"));
1370 assert!(fixed.contains("```"));
1371 }
1372
1373 #[test]
1374 fn test_text_reflow_preserves_lists() {
1375 let config = MD013Config {
1376 line_length: 30,
1377 reflow: true,
1378 ..Default::default()
1379 };
1380 let rule = MD013LineLength::from_config_struct(config);
1381
1382 let content = r#"Here is a list:
1383
13841. First item with a very long line that needs wrapping
13852. Second item is short
13863. Third item also has a long line that exceeds the limit
1387
1388And a bullet list:
1389
1390- Bullet item with very long content that needs wrapping
1391- Short bullet"#;
1392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1393
1394 let fixed = rule.fix(&ctx).unwrap();
1395
1396 assert!(fixed.contains("1. "));
1398 assert!(fixed.contains("2. "));
1399 assert!(fixed.contains("3. "));
1400 assert!(fixed.contains("- "));
1401
1402 let lines: Vec<&str> = fixed.lines().collect();
1404 for (i, line) in lines.iter().enumerate() {
1405 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1406 if i + 1 < lines.len()
1408 && !lines[i + 1].trim().is_empty()
1409 && !lines[i + 1].trim().starts_with(char::is_numeric)
1410 && !lines[i + 1].trim().starts_with("-")
1411 {
1412 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1414 }
1415 } else if line.trim().starts_with("-") {
1416 if i + 1 < lines.len()
1418 && !lines[i + 1].trim().is_empty()
1419 && !lines[i + 1].trim().starts_with(char::is_numeric)
1420 && !lines[i + 1].trim().starts_with("-")
1421 {
1422 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1424 }
1425 }
1426 }
1427 }
1428
1429 #[test]
1430 fn test_issue_83_numbered_list_with_backticks() {
1431 let config = MD013Config {
1433 line_length: 100,
1434 reflow: true,
1435 ..Default::default()
1436 };
1437 let rule = MD013LineLength::from_config_struct(config);
1438
1439 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
1441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1442
1443 let fixed = rule.fix(&ctx).unwrap();
1444
1445 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
1448
1449 assert_eq!(
1450 fixed, expected,
1451 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
1452 );
1453 }
1454
1455 #[test]
1456 fn test_text_reflow_disabled_by_default() {
1457 let rule = MD013LineLength::new(30, false, false, false, false);
1458
1459 let content = "This is a very long line that definitely exceeds thirty characters.";
1460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1461
1462 let fixed = rule.fix(&ctx).unwrap();
1463
1464 assert_eq!(fixed, content);
1467 }
1468
1469 #[test]
1470 fn test_reflow_with_hard_line_breaks() {
1471 let config = MD013Config {
1473 line_length: 40,
1474 reflow: true,
1475 ..Default::default()
1476 };
1477 let rule = MD013LineLength::from_config_struct(config);
1478
1479 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";
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1482 let fixed = rule.fix(&ctx).unwrap();
1483
1484 assert!(
1486 fixed.contains(" \n"),
1487 "Hard line break with exactly 2 spaces should be preserved"
1488 );
1489 }
1490
1491 #[test]
1492 fn test_reflow_preserves_reference_links() {
1493 let config = MD013Config {
1494 line_length: 40,
1495 reflow: true,
1496 ..Default::default()
1497 };
1498 let rule = MD013LineLength::from_config_struct(config);
1499
1500 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
1501
1502[ref]: https://example.com";
1503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1504 let fixed = rule.fix(&ctx).unwrap();
1505
1506 assert!(fixed.contains("[reference link][ref]"));
1508 assert!(!fixed.contains("[ reference link]"));
1509 assert!(!fixed.contains("[ref ]"));
1510 }
1511
1512 #[test]
1513 fn test_reflow_with_nested_markdown_elements() {
1514 let config = MD013Config {
1515 line_length: 35,
1516 reflow: true,
1517 ..Default::default()
1518 };
1519 let rule = MD013LineLength::from_config_struct(config);
1520
1521 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
1522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1523 let fixed = rule.fix(&ctx).unwrap();
1524
1525 assert!(fixed.contains("**bold with `code` inside**"));
1527 }
1528
1529 #[test]
1530 fn test_reflow_with_unbalanced_markdown() {
1531 let config = MD013Config {
1533 line_length: 30,
1534 reflow: true,
1535 ..Default::default()
1536 };
1537 let rule = MD013LineLength::from_config_struct(config);
1538
1539 let content = "This has **unbalanced bold that goes on for a very long time without closing";
1540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1541 let fixed = rule.fix(&ctx).unwrap();
1542
1543 assert!(!fixed.is_empty());
1547 for line in fixed.lines() {
1549 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
1550 }
1551 }
1552
1553 #[test]
1554 fn test_reflow_fix_indicator() {
1555 let config = MD013Config {
1557 line_length: 30,
1558 reflow: true,
1559 ..Default::default()
1560 };
1561 let rule = MD013LineLength::from_config_struct(config);
1562
1563 let content = "This is a very long line that definitely exceeds the thirty character limit";
1564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1565 let warnings = rule.check(&ctx).unwrap();
1566
1567 assert!(!warnings.is_empty());
1569 assert!(
1570 warnings[0].fix.is_some(),
1571 "Should provide fix indicator when reflow is true"
1572 );
1573 }
1574
1575 #[test]
1576 fn test_no_fix_indicator_without_reflow() {
1577 let config = MD013Config {
1579 line_length: 30,
1580 reflow: false,
1581 ..Default::default()
1582 };
1583 let rule = MD013LineLength::from_config_struct(config);
1584
1585 let content = "This is a very long line that definitely exceeds the thirty character limit";
1586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1587 let warnings = rule.check(&ctx).unwrap();
1588
1589 assert!(!warnings.is_empty());
1591 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
1592 }
1593
1594 #[test]
1595 fn test_reflow_preserves_all_reference_link_types() {
1596 let config = MD013Config {
1597 line_length: 40,
1598 reflow: true,
1599 ..Default::default()
1600 };
1601 let rule = MD013LineLength::from_config_struct(config);
1602
1603 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
1604
1605[ref]: https://example.com
1606[collapsed]: https://example.com
1607[shortcut]: https://example.com";
1608
1609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1610 let fixed = rule.fix(&ctx).unwrap();
1611
1612 assert!(fixed.contains("[full reference][ref]"));
1614 assert!(fixed.contains("[collapsed][]"));
1615 assert!(fixed.contains("[shortcut]"));
1616 }
1617
1618 #[test]
1619 fn test_reflow_handles_images_correctly() {
1620 let config = MD013Config {
1621 line_length: 40,
1622 reflow: true,
1623 ..Default::default()
1624 };
1625 let rule = MD013LineLength::from_config_struct(config);
1626
1627 let content = "This line has an  that should not be broken when reflowing.";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1629 let fixed = rule.fix(&ctx).unwrap();
1630
1631 assert!(fixed.contains(""));
1633 }
1634
1635 #[test]
1636 fn test_normalize_mode_flags_short_lines() {
1637 let config = MD013Config {
1638 line_length: 100,
1639 reflow: true,
1640 reflow_mode: ReflowMode::Normalize,
1641 ..Default::default()
1642 };
1643 let rule = MD013LineLength::from_config_struct(config);
1644
1645 let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
1647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1648 let warnings = rule.check(&ctx).unwrap();
1649
1650 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1652 assert!(warnings[0].message.contains("normalized"));
1653 }
1654
1655 #[test]
1656 fn test_normalize_mode_combines_short_lines() {
1657 let config = MD013Config {
1658 line_length: 100,
1659 reflow: true,
1660 reflow_mode: ReflowMode::Normalize,
1661 ..Default::default()
1662 };
1663 let rule = MD013LineLength::from_config_struct(config);
1664
1665 let content =
1667 "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
1668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1669 let fixed = rule.fix(&ctx).unwrap();
1670
1671 let lines: Vec<&str> = fixed.lines().collect();
1673 assert_eq!(lines.len(), 1, "Should combine into single line");
1674 assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
1675 }
1676
1677 #[test]
1678 fn test_normalize_mode_preserves_paragraph_breaks() {
1679 let config = MD013Config {
1680 line_length: 100,
1681 reflow: true,
1682 reflow_mode: ReflowMode::Normalize,
1683 ..Default::default()
1684 };
1685 let rule = MD013LineLength::from_config_struct(config);
1686
1687 let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
1688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1689 let fixed = rule.fix(&ctx).unwrap();
1690
1691 assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
1693
1694 let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
1695 assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
1696 }
1697
1698 #[test]
1699 fn test_default_mode_only_fixes_violations() {
1700 let config = MD013Config {
1701 line_length: 100,
1702 reflow: true,
1703 reflow_mode: ReflowMode::Default, ..Default::default()
1705 };
1706 let rule = MD013LineLength::from_config_struct(config);
1707
1708 let content = "This is a short line.\nAnother short line.\nA third short line.";
1710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1711 let warnings = rule.check(&ctx).unwrap();
1712
1713 assert!(warnings.is_empty(), "Should not flag short lines in default mode");
1715
1716 let fixed = rule.fix(&ctx).unwrap();
1718 assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
1719 }
1720
1721 #[test]
1722 fn test_normalize_mode_with_lists() {
1723 let config = MD013Config {
1724 line_length: 80,
1725 reflow: true,
1726 reflow_mode: ReflowMode::Normalize,
1727 ..Default::default()
1728 };
1729 let rule = MD013LineLength::from_config_struct(config);
1730
1731 let content = r#"A paragraph with
1732short lines.
1733
17341. List item with
1735 short lines
17362. Another item"#;
1737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1738 let fixed = rule.fix(&ctx).unwrap();
1739
1740 let lines: Vec<&str> = fixed.lines().collect();
1742 assert!(lines[0].len() > 20, "First paragraph should be normalized");
1743 assert!(fixed.contains("1. "), "Should preserve list markers");
1744 assert!(fixed.contains("2. "), "Should preserve list markers");
1745 }
1746
1747 #[test]
1748 fn test_normalize_mode_with_code_blocks() {
1749 let config = MD013Config {
1750 line_length: 100,
1751 reflow: true,
1752 reflow_mode: ReflowMode::Normalize,
1753 ..Default::default()
1754 };
1755 let rule = MD013LineLength::from_config_struct(config);
1756
1757 let content = r#"A paragraph with
1758short lines.
1759
1760```
1761code block should not be normalized
1762even with short lines
1763```
1764
1765Another paragraph with
1766short lines."#;
1767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1768 let fixed = rule.fix(&ctx).unwrap();
1769
1770 assert!(fixed.contains("code block should not be normalized\neven with short lines"));
1772 let lines: Vec<&str> = fixed.lines().collect();
1774 assert!(lines[0].len() > 20, "First paragraph should be normalized");
1775 }
1776
1777 #[test]
1778 fn test_issue_76_use_case() {
1779 let config = MD013Config {
1781 line_length: 999999, reflow: true,
1783 reflow_mode: ReflowMode::Normalize,
1784 ..Default::default()
1785 };
1786 let rule = MD013LineLength::from_config_struct(config);
1787
1788 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.";
1790
1791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1792
1793 let warnings = rule.check(&ctx).unwrap();
1795 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1796
1797 let fixed = rule.fix(&ctx).unwrap();
1799 let lines: Vec<&str> = fixed.lines().collect();
1800 assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
1801 assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
1802 }
1803
1804 #[test]
1805 fn test_normalize_mode_single_line_unchanged() {
1806 let config = MD013Config {
1808 line_length: 100,
1809 reflow: true,
1810 reflow_mode: ReflowMode::Normalize,
1811 ..Default::default()
1812 };
1813 let rule = MD013LineLength::from_config_struct(config);
1814
1815 let content = "This is a single line that should not be changed.";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1817
1818 let warnings = rule.check(&ctx).unwrap();
1819 assert!(warnings.is_empty(), "Single line should not be flagged");
1820
1821 let fixed = rule.fix(&ctx).unwrap();
1822 assert_eq!(fixed, content, "Single line should remain unchanged");
1823 }
1824
1825 #[test]
1826 fn test_normalize_mode_with_inline_code() {
1827 let config = MD013Config {
1828 line_length: 80,
1829 reflow: true,
1830 reflow_mode: ReflowMode::Normalize,
1831 ..Default::default()
1832 };
1833 let rule = MD013LineLength::from_config_struct(config);
1834
1835 let content =
1836 "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
1837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1838
1839 let warnings = rule.check(&ctx).unwrap();
1840 assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
1841
1842 let fixed = rule.fix(&ctx).unwrap();
1843 assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
1844 assert!(fixed.lines().count() < 3, "Lines should be combined");
1845 }
1846
1847 #[test]
1848 fn test_normalize_mode_with_emphasis() {
1849 let config = MD013Config {
1850 line_length: 100,
1851 reflow: true,
1852 reflow_mode: ReflowMode::Normalize,
1853 ..Default::default()
1854 };
1855 let rule = MD013LineLength::from_config_struct(config);
1856
1857 let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
1858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1859
1860 let fixed = rule.fix(&ctx).unwrap();
1861 assert!(fixed.contains("**bold**"), "Bold should be preserved");
1862 assert!(fixed.contains("*italic*"), "Italic should be preserved");
1863 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
1864 }
1865
1866 #[test]
1867 fn test_normalize_mode_respects_hard_breaks() {
1868 let config = MD013Config {
1869 line_length: 100,
1870 reflow: true,
1871 reflow_mode: ReflowMode::Normalize,
1872 ..Default::default()
1873 };
1874 let rule = MD013LineLength::from_config_struct(config);
1875
1876 let content = "First line with hard break \nSecond line after break\nThird line";
1878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1879
1880 let fixed = rule.fix(&ctx).unwrap();
1881 assert!(fixed.contains(" \n"), "Hard break should be preserved");
1883 assert!(
1885 fixed.contains("Second line after break Third line"),
1886 "Lines without hard break should combine"
1887 );
1888 }
1889
1890 #[test]
1891 fn test_normalize_mode_with_links() {
1892 let config = MD013Config {
1893 line_length: 100,
1894 reflow: true,
1895 reflow_mode: ReflowMode::Normalize,
1896 ..Default::default()
1897 };
1898 let rule = MD013LineLength::from_config_struct(config);
1899
1900 let content =
1901 "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
1902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1903
1904 let fixed = rule.fix(&ctx).unwrap();
1905 assert!(
1906 fixed.contains("[link](https://example.com)"),
1907 "Link should be preserved"
1908 );
1909 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
1910 }
1911
1912 #[test]
1913 fn test_normalize_mode_empty_lines_between_paragraphs() {
1914 let config = MD013Config {
1915 line_length: 100,
1916 reflow: true,
1917 reflow_mode: ReflowMode::Normalize,
1918 ..Default::default()
1919 };
1920 let rule = MD013LineLength::from_config_struct(config);
1921
1922 let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
1923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1924
1925 let fixed = rule.fix(&ctx).unwrap();
1926 assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
1928 let parts: Vec<&str> = fixed.split("\n\n\n").collect();
1930 assert_eq!(parts.len(), 2, "Should have two parts");
1931 assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
1932 assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
1933 }
1934
1935 #[test]
1936 fn test_normalize_mode_mixed_list_types() {
1937 let config = MD013Config {
1938 line_length: 80,
1939 reflow: true,
1940 reflow_mode: ReflowMode::Normalize,
1941 ..Default::default()
1942 };
1943 let rule = MD013LineLength::from_config_struct(config);
1944
1945 let content = r#"Paragraph before list
1946with multiple lines.
1947
1948- Bullet item
1949* Another bullet
1950+ Plus bullet
1951
19521. Numbered item
19532. Another number
1954
1955Paragraph after list
1956with multiple lines."#;
1957
1958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1959 let fixed = rule.fix(&ctx).unwrap();
1960
1961 assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
1963 assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
1964 assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
1965 assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
1966
1967 assert!(
1969 fixed.starts_with("Paragraph before list with multiple lines."),
1970 "First paragraph should be normalized"
1971 );
1972 assert!(
1973 fixed.ends_with("Paragraph after list with multiple lines."),
1974 "Last paragraph should be normalized"
1975 );
1976 }
1977
1978 #[test]
1979 fn test_normalize_mode_with_horizontal_rules() {
1980 let config = MD013Config {
1981 line_length: 100,
1982 reflow: true,
1983 reflow_mode: ReflowMode::Normalize,
1984 ..Default::default()
1985 };
1986 let rule = MD013LineLength::from_config_struct(config);
1987
1988 let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
1989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1990
1991 let fixed = rule.fix(&ctx).unwrap();
1992 assert!(fixed.contains("---"), "Horizontal rule should be preserved");
1993 assert!(
1994 fixed.contains("Paragraph before horizontal rule."),
1995 "First paragraph normalized"
1996 );
1997 assert!(
1998 fixed.contains("Paragraph after horizontal rule."),
1999 "Second paragraph normalized"
2000 );
2001 }
2002
2003 #[test]
2004 fn test_normalize_mode_with_indented_code() {
2005 let config = MD013Config {
2006 line_length: 100,
2007 reflow: true,
2008 reflow_mode: ReflowMode::Normalize,
2009 ..Default::default()
2010 };
2011 let rule = MD013LineLength::from_config_struct(config);
2012
2013 let content = "Paragraph before\nindented code.\n\n This is indented code\n Should not be normalized\n\nParagraph after\nindented code.";
2014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2015
2016 let fixed = rule.fix(&ctx).unwrap();
2017 assert!(
2018 fixed.contains(" This is indented code\n Should not be normalized"),
2019 "Indented code preserved"
2020 );
2021 assert!(
2022 fixed.contains("Paragraph before indented code."),
2023 "First paragraph normalized"
2024 );
2025 assert!(
2026 fixed.contains("Paragraph after indented code."),
2027 "Second paragraph normalized"
2028 );
2029 }
2030
2031 #[test]
2032 fn test_normalize_mode_disabled_without_reflow() {
2033 let config = MD013Config {
2035 line_length: 100,
2036 reflow: false, reflow_mode: ReflowMode::Normalize,
2038 ..Default::default()
2039 };
2040 let rule = MD013LineLength::from_config_struct(config);
2041
2042 let content = "This is a line\nwith breaks that\nshould not be changed.";
2043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2044
2045 let warnings = rule.check(&ctx).unwrap();
2046 assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2047
2048 let fixed = rule.fix(&ctx).unwrap();
2049 assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2050 }
2051
2052 #[test]
2053 fn test_default_mode_with_long_lines() {
2054 let config = MD013Config {
2057 line_length: 50,
2058 reflow: true,
2059 reflow_mode: ReflowMode::Default,
2060 ..Default::default()
2061 };
2062 let rule = MD013LineLength::from_config_struct(config);
2063
2064 let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2066
2067 let warnings = rule.check(&ctx).unwrap();
2068 assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2069 assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2071
2072 let fixed = rule.fix(&ctx).unwrap();
2073 assert!(
2075 fixed.contains("Short line. This is"),
2076 "Should combine and reflow the paragraph"
2077 );
2078 assert!(
2079 fixed.contains("wrapping. Another short"),
2080 "Should include all paragraph content"
2081 );
2082 }
2083
2084 #[test]
2085 fn test_normalize_vs_default_mode_same_content() {
2086 let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2088
2089 let default_config = MD013Config {
2091 line_length: 100,
2092 reflow: true,
2093 reflow_mode: ReflowMode::Default,
2094 ..Default::default()
2095 };
2096 let default_rule = MD013LineLength::from_config_struct(default_config);
2097 let default_warnings = default_rule.check(&ctx).unwrap();
2098 let default_fixed = default_rule.fix(&ctx).unwrap();
2099
2100 let normalize_config = MD013Config {
2102 line_length: 100,
2103 reflow: true,
2104 reflow_mode: ReflowMode::Normalize,
2105 ..Default::default()
2106 };
2107 let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2108 let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2109 let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2110
2111 assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2113 assert!(
2114 !normalize_warnings.is_empty(),
2115 "Normalize mode should flag multi-line paragraphs"
2116 );
2117
2118 assert_eq!(
2119 default_fixed, content,
2120 "Default mode should not change content without violations"
2121 );
2122 assert_ne!(
2123 normalize_fixed, content,
2124 "Normalize mode should change multi-line paragraphs"
2125 );
2126 assert_eq!(
2127 normalize_fixed.lines().count(),
2128 1,
2129 "Normalize should combine into single line"
2130 );
2131 }
2132
2133 #[test]
2134 fn test_normalize_mode_with_reference_definitions() {
2135 let config = MD013Config {
2136 line_length: 100,
2137 reflow: true,
2138 reflow_mode: ReflowMode::Normalize,
2139 ..Default::default()
2140 };
2141 let rule = MD013LineLength::from_config_struct(config);
2142
2143 let content =
2144 "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2146
2147 let fixed = rule.fix(&ctx).unwrap();
2148 assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2149 assert!(
2150 fixed.contains("[ref]: https://example.com"),
2151 "Reference definition should be preserved"
2152 );
2153 assert!(
2154 fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2155 "Paragraph should be normalized"
2156 );
2157 }
2158
2159 #[test]
2160 fn test_normalize_mode_with_html_comments() {
2161 let config = MD013Config {
2162 line_length: 100,
2163 reflow: true,
2164 reflow_mode: ReflowMode::Normalize,
2165 ..Default::default()
2166 };
2167 let rule = MD013LineLength::from_config_struct(config);
2168
2169 let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2171
2172 let fixed = rule.fix(&ctx).unwrap();
2173 assert!(
2174 fixed.contains("<!-- This is a comment -->"),
2175 "HTML comment should be preserved"
2176 );
2177 assert!(
2178 fixed.contains("Paragraph before HTML comment."),
2179 "First paragraph normalized"
2180 );
2181 assert!(
2182 fixed.contains("Paragraph after HTML comment."),
2183 "Second paragraph normalized"
2184 );
2185 }
2186
2187 #[test]
2188 fn test_normalize_mode_line_starting_with_number() {
2189 let config = MD013Config {
2191 line_length: 100,
2192 reflow: true,
2193 reflow_mode: ReflowMode::Normalize,
2194 ..Default::default()
2195 };
2196 let rule = MD013LineLength::from_config_struct(config);
2197
2198 let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2200
2201 let fixed = rule.fix(&ctx).unwrap();
2202 assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2203 assert!(
2204 fixed.contains("80 characters"),
2205 "Number at start of line should be preserved"
2206 );
2207 }
2208
2209 #[test]
2210 fn test_default_mode_preserves_list_structure() {
2211 let config = MD013Config {
2213 line_length: 80,
2214 reflow: true,
2215 reflow_mode: ReflowMode::Default,
2216 ..Default::default()
2217 };
2218 let rule = MD013LineLength::from_config_struct(config);
2219
2220 let content = r#"- This is a bullet point that has
2221 some text on multiple lines
2222 that should stay separate
2223
22241. Numbered list item with
2225 multiple lines that should
2226 also stay separate"#;
2227
2228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2229 let fixed = rule.fix(&ctx).unwrap();
2230
2231 let lines: Vec<&str> = fixed.lines().collect();
2233 assert_eq!(
2234 lines[0], "- This is a bullet point that has",
2235 "First line should be unchanged"
2236 );
2237 assert_eq!(
2238 lines[1], " some text on multiple lines",
2239 "Continuation should be preserved"
2240 );
2241 assert_eq!(
2242 lines[2], " that should stay separate",
2243 "Second continuation should be preserved"
2244 );
2245 }
2246
2247 #[test]
2248 fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2249 let config = MD013Config {
2251 line_length: 80,
2252 reflow: true,
2253 reflow_mode: ReflowMode::Normalize,
2254 ..Default::default()
2255 };
2256 let rule = MD013LineLength::from_config_struct(config);
2257
2258 let content = r#"- This is a bullet point that has
2259 some text on multiple lines
2260 that should be combined
2261
22621. Numbered list item with
2263 multiple lines that need
2264 to be properly combined
22652. Second item"#;
2266
2267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2268 let fixed = rule.fix(&ctx).unwrap();
2269
2270 assert!(
2272 !fixed.contains("lines that"),
2273 "Should not have double spaces in bullet list"
2274 );
2275 assert!(
2276 !fixed.contains("need to"),
2277 "Should not have double spaces in numbered list"
2278 );
2279
2280 assert!(
2282 fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2283 "Bullet list should be properly combined"
2284 );
2285 assert!(
2286 fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2287 "Numbered list should be properly combined"
2288 );
2289 }
2290
2291 #[test]
2292 fn test_normalize_mode_actual_numbered_list() {
2293 let config = MD013Config {
2295 line_length: 100,
2296 reflow: true,
2297 reflow_mode: ReflowMode::Normalize,
2298 ..Default::default()
2299 };
2300 let rule = MD013LineLength::from_config_struct(config);
2301
2302 let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2304
2305 let fixed = rule.fix(&ctx).unwrap();
2306 assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2307 assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2308 assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2309 assert!(
2310 fixed.starts_with("Paragraph before list with multiple lines."),
2311 "Paragraph should be normalized"
2312 );
2313 }
2314
2315 #[test]
2316 fn test_sentence_per_line_detection() {
2317 let config = MD013Config {
2318 reflow: true,
2319 reflow_mode: ReflowMode::SentencePerLine,
2320 ..Default::default()
2321 };
2322 let rule = MD013LineLength::from_config_struct(config.clone());
2323
2324 let content = "This is sentence one. This is sentence two. And sentence three!";
2326 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2327
2328 assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2330
2331 let result = rule.check(&ctx).unwrap();
2332
2333 assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2334 assert_eq!(
2335 result[0].message,
2336 "Line contains multiple sentences (one sentence per line expected)"
2337 );
2338 }
2339
2340 #[test]
2341 fn test_sentence_per_line_fix() {
2342 let config = MD013Config {
2343 reflow: true,
2344 reflow_mode: ReflowMode::SentencePerLine,
2345 ..Default::default()
2346 };
2347 let rule = MD013LineLength::from_config_struct(config);
2348
2349 let content = "First sentence. Second sentence.";
2350 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2351 let result = rule.check(&ctx).unwrap();
2352
2353 assert!(!result.is_empty(), "Should detect violation");
2354 assert!(result[0].fix.is_some(), "Should provide a fix");
2355
2356 let fix = result[0].fix.as_ref().unwrap();
2357 assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2358 }
2359
2360 #[test]
2361 fn test_sentence_per_line_abbreviations() {
2362 let config = MD013Config {
2363 reflow: true,
2364 reflow_mode: ReflowMode::SentencePerLine,
2365 ..Default::default()
2366 };
2367 let rule = MD013LineLength::from_config_struct(config);
2368
2369 let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2371 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2372 let result = rule.check(&ctx).unwrap();
2373
2374 assert!(
2375 result.is_empty(),
2376 "Should not detect abbreviations as sentence boundaries"
2377 );
2378 }
2379
2380 #[test]
2381 fn test_sentence_per_line_with_markdown() {
2382 let config = MD013Config {
2383 reflow: true,
2384 reflow_mode: ReflowMode::SentencePerLine,
2385 ..Default::default()
2386 };
2387 let rule = MD013LineLength::from_config_struct(config);
2388
2389 let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
2390 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2391 let result = rule.check(&ctx).unwrap();
2392
2393 assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
2394 assert_eq!(result[0].line, 3); }
2396
2397 #[test]
2398 fn test_sentence_per_line_questions_exclamations() {
2399 let config = MD013Config {
2400 reflow: true,
2401 reflow_mode: ReflowMode::SentencePerLine,
2402 ..Default::default()
2403 };
2404 let rule = MD013LineLength::from_config_struct(config);
2405
2406 let content = "Is this a question? Yes it is! And a statement.";
2407 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2408 let result = rule.check(&ctx).unwrap();
2409
2410 assert!(!result.is_empty(), "Should detect sentences with ? and !");
2411
2412 let fix = result[0].fix.as_ref().unwrap();
2413 let lines: Vec<&str> = fix.replacement.trim().lines().collect();
2414 assert_eq!(lines.len(), 3);
2415 assert_eq!(lines[0], "Is this a question?");
2416 assert_eq!(lines[1], "Yes it is!");
2417 assert_eq!(lines[2], "And a statement.");
2418 }
2419
2420 #[test]
2421 fn test_sentence_per_line_in_lists() {
2422 let config = MD013Config {
2423 reflow: true,
2424 reflow_mode: ReflowMode::SentencePerLine,
2425 ..Default::default()
2426 };
2427 let rule = MD013LineLength::from_config_struct(config);
2428
2429 let content = "- List item one. With two sentences.\n- Another item.";
2430 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2431 let result = rule.check(&ctx).unwrap();
2432
2433 assert!(!result.is_empty(), "Should detect sentences in list items");
2434 let fix = result[0].fix.as_ref().unwrap();
2436 assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
2437 }
2438}