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