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 ctx.lines[line_idx].in_mkdocstrings {
221 continue;
222 }
223
224 if !effective_config.strict {
226 if !line.trim().is_empty() && line.trim().chars().all(|c| c == '=' || c == '-') {
228 continue;
229 }
230
231 if (!effective_config.headings && heading_lines_set.contains(&line_number))
235 || (!effective_config.code_blocks && ctx.is_in_code_block(line_number))
236 || (!effective_config.tables && table_lines_set.contains(&line_number))
237 || ctx.lines[line_number - 1].blockquote.is_some()
238 || ctx.is_in_html_block(line_number)
239 {
240 continue;
241 }
242
243 if self.should_ignore_line(line, &lines, line_idx, ctx) {
245 continue;
246 }
247 }
248
249 let fix = None;
252
253 let message = format!("Line length {effective_length} exceeds {line_limit} characters");
254
255 let (start_line, start_col, end_line, end_col) = calculate_excess_range(line_number, line, line_limit);
257
258 warnings.push(LintWarning {
259 rule_name: Some(self.name()),
260 message,
261 line: start_line,
262 column: start_col,
263 end_line,
264 end_column: end_col,
265 severity: Severity::Warning,
266 fix,
267 });
268 }
269 }
270
271 if effective_config.reflow {
273 let paragraph_warnings = self.generate_paragraph_fixes(ctx, &effective_config, &lines);
274 for pw in paragraph_warnings {
276 warnings.retain(|w| w.line < pw.line || w.line > pw.end_line);
278 warnings.push(pw);
279 }
280 }
281
282 Ok(warnings)
283 }
284
285 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
286 let warnings = self.check(ctx)?;
289
290 if !warnings.iter().any(|w| w.fix.is_some()) {
292 return Ok(ctx.content.to_string());
293 }
294
295 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
297 .map_err(|e| LintError::FixFailed(format!("Failed to apply fixes: {e}")))
298 }
299
300 fn as_any(&self) -> &dyn std::any::Any {
301 self
302 }
303
304 fn category(&self) -> RuleCategory {
305 RuleCategory::Whitespace
306 }
307
308 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
309 if ctx.content.is_empty() {
311 return true;
312 }
313
314 if self.config.reflow
316 && (self.config.reflow_mode == ReflowMode::SentencePerLine
317 || self.config.reflow_mode == ReflowMode::Normalize)
318 {
319 return false;
320 }
321
322 if ctx.content.len() <= self.config.line_length {
324 return true;
325 }
326
327 !ctx.lines
329 .iter()
330 .any(|line| line.content.len() > self.config.line_length)
331 }
332
333 fn default_config_section(&self) -> Option<(String, toml::Value)> {
334 let default_config = MD013Config::default();
335 let json_value = serde_json::to_value(&default_config).ok()?;
336 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
337
338 if let toml::Value::Table(table) = toml_value {
339 if !table.is_empty() {
340 Some((MD013Config::RULE_NAME.to_string(), toml::Value::Table(table)))
341 } else {
342 None
343 }
344 } else {
345 None
346 }
347 }
348
349 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
350 let mut aliases = std::collections::HashMap::new();
351 aliases.insert("enable_reflow".to_string(), "reflow".to_string());
352 Some(aliases)
353 }
354
355 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
356 where
357 Self: Sized,
358 {
359 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
360 if rule_config.line_length == 80 {
362 rule_config.line_length = config.global.line_length as usize;
364 }
365 Box::new(Self::from_config_struct(rule_config))
366 }
367}
368
369impl MD013LineLength {
370 fn generate_paragraph_fixes(
372 &self,
373 ctx: &crate::lint_context::LintContext,
374 config: &MD013Config,
375 lines: &[&str],
376 ) -> Vec<LintWarning> {
377 let mut warnings = Vec::new();
378 let line_index = LineIndex::new(ctx.content.to_string());
379
380 let mut i = 0;
381 while i < lines.len() {
382 let line_num = i + 1;
383
384 if ctx.is_in_code_block(line_num)
386 || ctx.is_in_front_matter(line_num)
387 || ctx.is_in_html_block(line_num)
388 || (line_num > 0 && line_num <= ctx.lines.len() && ctx.lines[line_num - 1].blockquote.is_some())
389 || lines[i].trim().starts_with('#')
390 || TableUtils::is_potential_table_row(lines[i])
391 || lines[i].trim().is_empty()
392 || is_horizontal_rule(lines[i].trim())
393 {
394 i += 1;
395 continue;
396 }
397
398 let trimmed = lines[i].trim();
400 if is_list_item(trimmed) {
401 let list_start = i;
403 let (marker, first_content) = extract_list_marker_and_content(lines[i]);
404 let indent_size = marker.len();
405 let expected_indent = " ".repeat(indent_size);
406
407 let mut list_item_lines = vec![first_content];
408 i += 1;
409
410 while i < lines.len() {
412 let line = lines[i];
413 if line.starts_with(&expected_indent) && !line.trim().is_empty() {
415 let content_after_indent = &line[indent_size..];
417 if is_list_item(content_after_indent.trim()) {
418 break;
420 }
421 let content = line[indent_size..].to_string();
423 list_item_lines.push(content);
424 i += 1;
425 } else if line.trim().is_empty() {
426 if i + 1 < lines.len() && lines[i + 1].starts_with(&expected_indent) {
429 list_item_lines.push(String::new());
430 i += 1;
431 } else {
432 break;
433 }
434 } else {
435 break;
436 }
437 }
438
439 let contains_code_block = (list_start..i).any(|line_idx| ctx.is_in_code_block(line_idx + 1));
441
442 let combined_content = list_item_lines.join(" ").trim().to_string();
444 let full_line = format!("{marker}{combined_content}");
445
446 if !contains_code_block
447 && (self.calculate_effective_length(&full_line) > config.line_length
448 || (config.reflow_mode == ReflowMode::Normalize && list_item_lines.len() > 1)
449 || (config.reflow_mode == ReflowMode::SentencePerLine && {
450 let sentences = split_into_sentences(&combined_content);
452 sentences.len() > 1
453 }))
454 {
455 let start_range = line_index.whole_line_range(list_start + 1);
456 let end_line = i - 1;
457 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
458 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
459 } else {
460 line_index.whole_line_range(end_line + 1)
461 };
462 let byte_range = start_range.start..end_range.end;
463
464 let reflow_options = crate::utils::text_reflow::ReflowOptions {
466 line_length: config.line_length - indent_size,
467 break_on_sentences: true,
468 preserve_breaks: false,
469 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
470 };
471 let reflowed = crate::utils::text_reflow::reflow_line(&combined_content, &reflow_options);
472
473 let mut result = vec![format!("{marker}{}", reflowed[0])];
475 for line in reflowed.iter().skip(1) {
476 result.push(format!("{expected_indent}{line}"));
477 }
478 let reflowed_text = result.join("\n");
479
480 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
482 format!("{reflowed_text}\n")
483 } else {
484 reflowed_text
485 };
486
487 let original_text = &ctx.content[byte_range.clone()];
489
490 if original_text != replacement {
492 warnings.push(LintWarning {
493 rule_name: Some(self.name()),
494 message: if config.reflow_mode == ReflowMode::SentencePerLine {
495 "Line contains multiple sentences (one sentence per line expected)".to_string()
496 } else {
497 format!("Line length exceeds {} characters", config.line_length)
498 },
499 line: list_start + 1,
500 column: 1,
501 end_line: end_line + 1,
502 end_column: lines[end_line].len() + 1,
503 severity: Severity::Warning,
504 fix: Some(crate::rule::Fix {
505 range: byte_range,
506 replacement,
507 }),
508 });
509 }
510 }
511 continue;
512 }
513
514 let paragraph_start = i;
516 let mut paragraph_lines = vec![lines[i]];
517 i += 1;
518
519 while i < lines.len() {
520 let next_line = lines[i];
521 let next_line_num = i + 1;
522 let next_trimmed = next_line.trim();
523
524 if next_trimmed.is_empty()
526 || ctx.is_in_code_block(next_line_num)
527 || ctx.is_in_front_matter(next_line_num)
528 || ctx.is_in_html_block(next_line_num)
529 || (next_line_num > 0
530 && next_line_num <= ctx.lines.len()
531 && ctx.lines[next_line_num - 1].blockquote.is_some())
532 || next_trimmed.starts_with('#')
533 || TableUtils::is_potential_table_row(next_line)
534 || is_list_item(next_trimmed)
535 || is_horizontal_rule(next_trimmed)
536 || (next_trimmed.starts_with('[') && next_line.contains("]:"))
537 {
538 break;
539 }
540
541 if i > 0 && lines[i - 1].ends_with(" ") {
543 break;
545 }
546
547 paragraph_lines.push(next_line);
548 i += 1;
549 }
550
551 let needs_reflow = match config.reflow_mode {
553 ReflowMode::Normalize => {
554 paragraph_lines.len() > 1
556 }
557 ReflowMode::SentencePerLine => {
558 paragraph_lines.iter().any(|line| {
560 let sentences = split_into_sentences(line);
562 sentences.len() > 1
563 })
564 }
565 ReflowMode::Default => {
566 paragraph_lines
568 .iter()
569 .any(|line| self.calculate_effective_length(line) > config.line_length)
570 }
571 };
572
573 if needs_reflow {
574 let start_range = line_index.whole_line_range(paragraph_start + 1);
577 let end_line = paragraph_start + paragraph_lines.len() - 1;
578
579 let end_range = if end_line == lines.len() - 1 && !ctx.content.ends_with('\n') {
581 line_index.line_text_range(end_line + 1, 1, lines[end_line].len() + 1)
583 } else {
584 line_index.whole_line_range(end_line + 1)
586 };
587
588 let byte_range = start_range.start..end_range.end;
589
590 let paragraph_text = paragraph_lines.join(" ");
592
593 let has_hard_break = paragraph_lines.last().is_some_and(|l| l.ends_with(" "));
595
596 let reflow_options = crate::utils::text_reflow::ReflowOptions {
598 line_length: config.line_length,
599 break_on_sentences: true,
600 preserve_breaks: false,
601 sentence_per_line: config.reflow_mode == ReflowMode::SentencePerLine,
602 };
603 let mut reflowed = crate::utils::text_reflow::reflow_line(¶graph_text, &reflow_options);
604
605 if has_hard_break && !reflowed.is_empty() {
607 let last_idx = reflowed.len() - 1;
608 if !reflowed[last_idx].ends_with(" ") {
609 reflowed[last_idx].push_str(" ");
610 }
611 }
612
613 let reflowed_text = reflowed.join("\n");
614
615 let replacement = if end_line < lines.len() - 1 || ctx.content.ends_with('\n') {
617 format!("{reflowed_text}\n")
618 } else {
619 reflowed_text
620 };
621
622 let original_text = &ctx.content[byte_range.clone()];
624
625 if original_text != replacement {
627 let (warning_line, warning_end_line) = match config.reflow_mode {
632 ReflowMode::Normalize => (paragraph_start + 1, end_line + 1),
633 ReflowMode::SentencePerLine => {
634 let mut violating_line = paragraph_start;
636 for (idx, line) in paragraph_lines.iter().enumerate() {
637 let sentences = split_into_sentences(line);
638 if sentences.len() > 1 {
639 violating_line = paragraph_start + idx;
640 break;
641 }
642 }
643 (violating_line + 1, violating_line + 1)
644 }
645 ReflowMode::Default => {
646 let mut violating_line = paragraph_start;
648 for (idx, line) in paragraph_lines.iter().enumerate() {
649 if self.calculate_effective_length(line) > config.line_length {
650 violating_line = paragraph_start + idx;
651 break;
652 }
653 }
654 (violating_line + 1, violating_line + 1)
655 }
656 };
657
658 warnings.push(LintWarning {
659 rule_name: Some(self.name()),
660 message: match config.reflow_mode {
661 ReflowMode::Normalize => format!(
662 "Paragraph could be normalized to use line length of {} characters",
663 config.line_length
664 ),
665 ReflowMode::SentencePerLine => {
666 "Line contains multiple sentences (one sentence per line expected)".to_string()
667 }
668 ReflowMode::Default => format!("Line length exceeds {} characters", config.line_length),
669 },
670 line: warning_line,
671 column: 1,
672 end_line: warning_end_line,
673 end_column: lines[warning_end_line.saturating_sub(1)].len() + 1,
674 severity: Severity::Warning,
675 fix: Some(crate::rule::Fix {
676 range: byte_range,
677 replacement,
678 }),
679 });
680 }
681 }
682 }
683
684 warnings
685 }
686
687 fn calculate_effective_length(&self, line: &str) -> usize {
689 if self.config.strict {
690 return line.chars().count();
692 }
693
694 let bytes = line.as_bytes();
696 if !bytes.contains(&b'h') && !bytes.contains(&b'[') {
697 return line.chars().count();
698 }
699
700 if !line.contains("http") && !line.contains('[') {
702 return line.chars().count();
703 }
704
705 let mut effective_line = line.to_string();
706
707 if line.contains('[') && line.contains("](") {
710 for cap in MARKDOWN_LINK_PATTERN.captures_iter(&effective_line.clone()) {
711 if let (Some(full_match), Some(text), Some(url)) = (cap.get(0), cap.get(1), cap.get(2))
712 && url.as_str().len() > 15
713 {
714 let replacement = format!("[{}](url)", text.as_str());
715 effective_line = effective_line.replacen(full_match.as_str(), &replacement, 1);
716 }
717 }
718 }
719
720 if effective_line.contains("http") {
723 for url_match in URL_IN_TEXT.find_iter(&effective_line.clone()) {
724 let url = url_match.as_str();
725 if !effective_line.contains(&format!("({url})")) {
727 let placeholder = "x".repeat(15.min(url.len()));
730 effective_line = effective_line.replacen(url, &placeholder, 1);
731 }
732 }
733 }
734
735 effective_line.chars().count()
736 }
737}
738
739fn extract_list_marker_and_content(line: &str) -> (String, String) {
741 let indent_len = line.len() - line.trim_start().len();
743 let indent = &line[..indent_len];
744 let trimmed = &line[indent_len..];
745
746 if let Some(rest) = trimmed.strip_prefix("- ") {
748 return (format!("{indent}- "), rest.to_string());
749 }
750 if let Some(rest) = trimmed.strip_prefix("* ") {
751 return (format!("{indent}* "), rest.to_string());
752 }
753 if let Some(rest) = trimmed.strip_prefix("+ ") {
754 return (format!("{indent}+ "), rest.to_string());
755 }
756
757 let mut chars = trimmed.chars();
759 let mut marker_content = String::new();
760
761 while let Some(c) = chars.next() {
762 marker_content.push(c);
763 if c == '.' {
764 if let Some(next) = chars.next()
766 && next == ' '
767 {
768 marker_content.push(next);
769 let content = chars.as_str().to_string();
770 return (format!("{indent}{marker_content}"), content);
771 }
772 break;
773 }
774 }
775
776 (String::new(), line.to_string())
778}
779
780fn is_horizontal_rule(line: &str) -> bool {
782 if line.len() < 3 {
783 return false;
784 }
785 let chars: Vec<char> = line.chars().collect();
787 if chars.is_empty() {
788 return false;
789 }
790 let first_char = chars[0];
791 if first_char != '-' && first_char != '_' && first_char != '*' {
792 return false;
793 }
794 for c in &chars {
796 if *c != first_char && *c != ' ' {
797 return false;
798 }
799 }
800 chars.iter().filter(|c| **c == first_char).count() >= 3
802}
803
804fn is_numbered_list_item(line: &str) -> bool {
805 let mut chars = line.chars();
806 if !chars.next().is_some_and(|c| c.is_numeric()) {
808 return false;
809 }
810 while let Some(c) = chars.next() {
812 if c == '.' {
813 return chars.next().is_none_or(|c| c == ' ');
815 }
816 if !c.is_numeric() {
817 return false;
818 }
819 }
820 false
821}
822
823fn is_list_item(line: &str) -> bool {
824 if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
826 && line.len() > 1
827 && line.chars().nth(1) == Some(' ')
828 {
829 return true;
830 }
831 is_numbered_list_item(line)
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838 use crate::lint_context::LintContext;
839
840 #[test]
841 fn test_default_config() {
842 let rule = MD013LineLength::default();
843 assert_eq!(rule.config.line_length, 80);
844 assert!(rule.config.code_blocks); assert!(rule.config.tables); assert!(rule.config.headings); assert!(!rule.config.strict);
848 }
849
850 #[test]
851 fn test_custom_config() {
852 let rule = MD013LineLength::new(100, true, true, false, true);
853 assert_eq!(rule.config.line_length, 100);
854 assert!(rule.config.code_blocks);
855 assert!(rule.config.tables);
856 assert!(!rule.config.headings);
857 assert!(rule.config.strict);
858 }
859
860 #[test]
861 fn test_basic_line_length_violation() {
862 let rule = MD013LineLength::new(50, false, false, false, false);
863 let content = "This is a line that is definitely longer than fifty characters and should trigger a warning.";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
865 let result = rule.check(&ctx).unwrap();
866
867 assert_eq!(result.len(), 1);
868 assert!(result[0].message.contains("Line length"));
869 assert!(result[0].message.contains("exceeds 50 characters"));
870 }
871
872 #[test]
873 fn test_no_violation_under_limit() {
874 let rule = MD013LineLength::new(100, false, false, false, false);
875 let content = "Short line.\nAnother short line.";
876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
877 let result = rule.check(&ctx).unwrap();
878
879 assert_eq!(result.len(), 0);
880 }
881
882 #[test]
883 fn test_multiple_violations() {
884 let rule = MD013LineLength::new(30, false, false, false, false);
885 let content = "This line is definitely longer than thirty chars.\nThis is also a line that exceeds the limit.\nShort line.";
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
887 let result = rule.check(&ctx).unwrap();
888
889 assert_eq!(result.len(), 2);
890 assert_eq!(result[0].line, 1);
891 assert_eq!(result[1].line, 2);
892 }
893
894 #[test]
895 fn test_code_blocks_exemption() {
896 let rule = MD013LineLength::new(30, false, false, false, false);
898 let content = "```\nThis is a very long line inside a code block that should be ignored.\n```";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
900 let result = rule.check(&ctx).unwrap();
901
902 assert_eq!(result.len(), 0);
903 }
904
905 #[test]
906 fn test_code_blocks_not_exempt_when_configured() {
907 let rule = MD013LineLength::new(30, true, false, false, false);
909 let content = "```\nThis is a very long line inside a code block that should NOT be ignored.\n```";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
911 let result = rule.check(&ctx).unwrap();
912
913 assert!(!result.is_empty());
914 }
915
916 #[test]
917 fn test_heading_checked_when_enabled() {
918 let rule = MD013LineLength::new(30, false, false, true, false);
919 let content = "# This is a very long heading that would normally exceed the limit";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
921 let result = rule.check(&ctx).unwrap();
922
923 assert_eq!(result.len(), 1);
924 }
925
926 #[test]
927 fn test_heading_exempt_when_disabled() {
928 let rule = MD013LineLength::new(30, false, false, false, false);
929 let content = "# This is a very long heading that should trigger a warning";
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
931 let result = rule.check(&ctx).unwrap();
932
933 assert_eq!(result.len(), 0);
934 }
935
936 #[test]
937 fn test_table_checked_when_enabled() {
938 let rule = MD013LineLength::new(30, false, true, false, false);
939 let content = "| This is a very long table header | Another long column header |\n|-----------------------------------|-------------------------------|";
940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
941 let result = rule.check(&ctx).unwrap();
942
943 assert_eq!(result.len(), 2); }
945
946 #[test]
947 fn test_issue_78_tables_after_fenced_code_blocks() {
948 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
951
952```plain
953some code block longer than 20 chars length
954```
955
956this is a very long line
957
958| column A | column B |
959| -------- | -------- |
960| `var` | `val` |
961| value 1 | value 2 |
962
963correct length line"#;
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
965 let result = rule.check(&ctx).unwrap();
966
967 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
969 assert_eq!(result[0].line, 7, "Should flag line 7");
970 assert!(result[0].message.contains("24 exceeds 20"));
971 }
972
973 #[test]
974 fn test_issue_78_tables_with_inline_code() {
975 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"| column A | column B |
978| -------- | -------- |
979| `var with very long name` | `val exceeding limit` |
980| value 1 | value 2 |
981
982This line exceeds limit"#;
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
984 let result = rule.check(&ctx).unwrap();
985
986 assert_eq!(result.len(), 1, "Should only flag the non-table line");
988 assert_eq!(result[0].line, 6, "Should flag line 6");
989 }
990
991 #[test]
992 fn test_issue_78_indented_code_blocks() {
993 let rule = MD013LineLength::new(20, false, false, false, false); let content = r#"# heading
996
997 some code block longer than 20 chars length
998
999this is a very long line
1000
1001| column A | column B |
1002| -------- | -------- |
1003| value 1 | value 2 |
1004
1005correct length line"#;
1006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1007 let result = rule.check(&ctx).unwrap();
1008
1009 assert_eq!(result.len(), 1, "Should only flag 1 line (the non-table long line)");
1011 assert_eq!(result[0].line, 5, "Should flag line 5");
1012 }
1013
1014 #[test]
1015 fn test_url_exemption() {
1016 let rule = MD013LineLength::new(30, false, false, false, false);
1017 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1019 let result = rule.check(&ctx).unwrap();
1020
1021 assert_eq!(result.len(), 0);
1022 }
1023
1024 #[test]
1025 fn test_image_reference_exemption() {
1026 let rule = MD013LineLength::new(30, false, false, false, false);
1027 let content = "![This is a very long image alt text that exceeds limit][reference]";
1028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1029 let result = rule.check(&ctx).unwrap();
1030
1031 assert_eq!(result.len(), 0);
1032 }
1033
1034 #[test]
1035 fn test_link_reference_exemption() {
1036 let rule = MD013LineLength::new(30, false, false, false, false);
1037 let content = "[reference]: https://example.com/very/long/url/that/exceeds/limit";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1039 let result = rule.check(&ctx).unwrap();
1040
1041 assert_eq!(result.len(), 0);
1042 }
1043
1044 #[test]
1045 fn test_strict_mode() {
1046 let rule = MD013LineLength::new(30, false, false, false, true);
1047 let content = "https://example.com/this/is/a/very/long/url/that/exceeds/the/limit";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1049 let result = rule.check(&ctx).unwrap();
1050
1051 assert_eq!(result.len(), 1);
1053 }
1054
1055 #[test]
1056 fn test_blockquote_exemption() {
1057 let rule = MD013LineLength::new(30, false, false, false, false);
1058 let content = "> This is a very long line inside a blockquote that should be ignored.";
1059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1060 let result = rule.check(&ctx).unwrap();
1061
1062 assert_eq!(result.len(), 0);
1063 }
1064
1065 #[test]
1066 fn test_setext_heading_underline_exemption() {
1067 let rule = MD013LineLength::new(30, false, false, false, false);
1068 let content = "Heading\n========================================";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1070 let result = rule.check(&ctx).unwrap();
1071
1072 assert_eq!(result.len(), 0);
1074 }
1075
1076 #[test]
1077 fn test_no_fix_without_reflow() {
1078 let rule = MD013LineLength::new(60, false, false, false, false);
1079 let content = "This line has trailing whitespace that makes it too long ";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1081 let result = rule.check(&ctx).unwrap();
1082
1083 assert_eq!(result.len(), 1);
1084 assert!(result[0].fix.is_none());
1086
1087 let fixed = rule.fix(&ctx).unwrap();
1089 assert_eq!(fixed, content);
1090 }
1091
1092 #[test]
1093 fn test_character_vs_byte_counting() {
1094 let rule = MD013LineLength::new(10, false, false, false, false);
1095 let content = "你好世界这是测试文字超过限制"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1098 let result = rule.check(&ctx).unwrap();
1099
1100 assert_eq!(result.len(), 1);
1101 assert_eq!(result[0].line, 1);
1102 }
1103
1104 #[test]
1105 fn test_empty_content() {
1106 let rule = MD013LineLength::default();
1107 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1108 let result = rule.check(&ctx).unwrap();
1109
1110 assert_eq!(result.len(), 0);
1111 }
1112
1113 #[test]
1114 fn test_excess_range_calculation() {
1115 let rule = MD013LineLength::new(10, false, false, false, false);
1116 let content = "12345678901234567890"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1118 let result = rule.check(&ctx).unwrap();
1119
1120 assert_eq!(result.len(), 1);
1121 assert_eq!(result[0].column, 11);
1123 assert_eq!(result[0].end_column, 21);
1124 }
1125
1126 #[test]
1127 fn test_html_block_exemption() {
1128 let rule = MD013LineLength::new(30, false, false, false, false);
1129 let content = "<div>\nThis is a very long line inside an HTML block that should be ignored.\n</div>";
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1131 let result = rule.check(&ctx).unwrap();
1132
1133 assert_eq!(result.len(), 0);
1135 }
1136
1137 #[test]
1138 fn test_mixed_content() {
1139 let rule = MD013LineLength::new(30, false, false, false, false);
1141 let content = r#"# This heading is very long but should be exempt
1142
1143This regular paragraph line is too long and should trigger.
1144
1145```
1146Code block line that is very long but exempt.
1147```
1148
1149| Table | With very long content |
1150|-------|------------------------|
1151
1152Another long line that should trigger a warning."#;
1153
1154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1155 let result = rule.check(&ctx).unwrap();
1156
1157 assert_eq!(result.len(), 2);
1159 assert_eq!(result[0].line, 3);
1160 assert_eq!(result[1].line, 12);
1161 }
1162
1163 #[test]
1164 fn test_fix_without_reflow_preserves_content() {
1165 let rule = MD013LineLength::new(50, false, false, false, false);
1166 let content = "Line 1\nThis line has trailing spaces and is too long \nLine 3";
1167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1168
1169 let fixed = rule.fix(&ctx).unwrap();
1171 assert_eq!(fixed, content);
1172 }
1173
1174 #[test]
1175 fn test_content_detection() {
1176 let rule = MD013LineLength::default();
1177
1178 let long_line = "a".repeat(100);
1180 let ctx = LintContext::new(&long_line, crate::config::MarkdownFlavor::Standard);
1181 assert!(!rule.should_skip(&ctx)); let empty_ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1184 assert!(rule.should_skip(&empty_ctx)); }
1186
1187 #[test]
1188 fn test_rule_metadata() {
1189 let rule = MD013LineLength::default();
1190 assert_eq!(rule.name(), "MD013");
1191 assert_eq!(rule.description(), "Line length should not be excessive");
1192 assert_eq!(rule.category(), RuleCategory::Whitespace);
1193 }
1194
1195 #[test]
1196 fn test_url_embedded_in_text() {
1197 let rule = MD013LineLength::new(50, false, false, false, false);
1198
1199 let content = "Check the docs at https://example.com/very/long/url/that/exceeds/limit for info";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1202 let result = rule.check(&ctx).unwrap();
1203
1204 assert_eq!(result.len(), 0);
1206 }
1207
1208 #[test]
1209 fn test_multiple_urls_in_line() {
1210 let rule = MD013LineLength::new(50, false, false, false, false);
1211
1212 let content = "See https://first-url.com/long and https://second-url.com/also/very/long here";
1214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1215
1216 let result = rule.check(&ctx).unwrap();
1217
1218 assert_eq!(result.len(), 0);
1220 }
1221
1222 #[test]
1223 fn test_markdown_link_with_long_url() {
1224 let rule = MD013LineLength::new(50, false, false, false, false);
1225
1226 let content = "Check the [documentation](https://example.com/very/long/path/to/documentation/page) for details";
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1229 let result = rule.check(&ctx).unwrap();
1230
1231 assert_eq!(result.len(), 0);
1233 }
1234
1235 #[test]
1236 fn test_line_too_long_even_without_urls() {
1237 let rule = MD013LineLength::new(50, false, false, false, false);
1238
1239 let content = "This is a very long line with lots of text and https://url.com that still exceeds the limit";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1242 let result = rule.check(&ctx).unwrap();
1243
1244 assert_eq!(result.len(), 1);
1246 }
1247
1248 #[test]
1249 fn test_strict_mode_counts_urls() {
1250 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";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1255 let result = rule.check(&ctx).unwrap();
1256
1257 assert_eq!(result.len(), 1);
1259 }
1260
1261 #[test]
1262 fn test_documentation_example_from_md051() {
1263 let rule = MD013LineLength::new(80, false, false, false, false);
1264
1265 let content = r#"For more information, see the [CommonMark specification](https://spec.commonmark.org/0.30/#link-reference-definitions)."#;
1267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1268 let result = rule.check(&ctx).unwrap();
1269
1270 assert_eq!(result.len(), 0);
1272 }
1273
1274 #[test]
1275 fn test_text_reflow_simple() {
1276 let config = MD013Config {
1277 line_length: 30,
1278 reflow: true,
1279 ..Default::default()
1280 };
1281 let rule = MD013LineLength::from_config_struct(config);
1282
1283 let content = "This is a very long line that definitely exceeds thirty characters and needs to be wrapped.";
1284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1285
1286 let fixed = rule.fix(&ctx).unwrap();
1287
1288 for line in fixed.lines() {
1290 assert!(
1291 line.chars().count() <= 30,
1292 "Line too long: {} (len={})",
1293 line,
1294 line.chars().count()
1295 );
1296 }
1297
1298 let fixed_words: Vec<&str> = fixed.split_whitespace().collect();
1300 let original_words: Vec<&str> = content.split_whitespace().collect();
1301 assert_eq!(fixed_words, original_words);
1302 }
1303
1304 #[test]
1305 fn test_text_reflow_preserves_markdown_elements() {
1306 let config = MD013Config {
1307 line_length: 40,
1308 reflow: true,
1309 ..Default::default()
1310 };
1311 let rule = MD013LineLength::from_config_struct(config);
1312
1313 let content = "This paragraph has **bold text** and *italic text* and [a link](https://example.com) that should be preserved.";
1314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1315
1316 let fixed = rule.fix(&ctx).unwrap();
1317
1318 assert!(fixed.contains("**bold text**"), "Bold text not preserved in: {fixed}");
1320 assert!(fixed.contains("*italic text*"), "Italic text not preserved in: {fixed}");
1321 assert!(
1322 fixed.contains("[a link](https://example.com)"),
1323 "Link not preserved in: {fixed}"
1324 );
1325
1326 for line in fixed.lines() {
1328 assert!(line.len() <= 40, "Line too long: {line}");
1329 }
1330 }
1331
1332 #[test]
1333 fn test_text_reflow_preserves_code_blocks() {
1334 let config = MD013Config {
1335 line_length: 30,
1336 reflow: true,
1337 ..Default::default()
1338 };
1339 let rule = MD013LineLength::from_config_struct(config);
1340
1341 let content = r#"Here is some text.
1342
1343```python
1344def very_long_function_name_that_exceeds_limit():
1345 return "This should not be wrapped"
1346```
1347
1348More text after code block."#;
1349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1350
1351 let fixed = rule.fix(&ctx).unwrap();
1352
1353 assert!(fixed.contains("def very_long_function_name_that_exceeds_limit():"));
1355 assert!(fixed.contains("```python"));
1356 assert!(fixed.contains("```"));
1357 }
1358
1359 #[test]
1360 fn test_text_reflow_preserves_lists() {
1361 let config = MD013Config {
1362 line_length: 30,
1363 reflow: true,
1364 ..Default::default()
1365 };
1366 let rule = MD013LineLength::from_config_struct(config);
1367
1368 let content = r#"Here is a list:
1369
13701. First item with a very long line that needs wrapping
13712. Second item is short
13723. Third item also has a long line that exceeds the limit
1373
1374And a bullet list:
1375
1376- Bullet item with very long content that needs wrapping
1377- Short bullet"#;
1378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1379
1380 let fixed = rule.fix(&ctx).unwrap();
1381
1382 assert!(fixed.contains("1. "));
1384 assert!(fixed.contains("2. "));
1385 assert!(fixed.contains("3. "));
1386 assert!(fixed.contains("- "));
1387
1388 let lines: Vec<&str> = fixed.lines().collect();
1390 for (i, line) in lines.iter().enumerate() {
1391 if line.trim().starts_with("1.") || line.trim().starts_with("2.") || line.trim().starts_with("3.") {
1392 if i + 1 < lines.len()
1394 && !lines[i + 1].trim().is_empty()
1395 && !lines[i + 1].trim().starts_with(char::is_numeric)
1396 && !lines[i + 1].trim().starts_with("-")
1397 {
1398 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1400 }
1401 } else if line.trim().starts_with("-") {
1402 if i + 1 < lines.len()
1404 && !lines[i + 1].trim().is_empty()
1405 && !lines[i + 1].trim().starts_with(char::is_numeric)
1406 && !lines[i + 1].trim().starts_with("-")
1407 {
1408 assert!(lines[i + 1].starts_with(" ") || lines[i + 1].trim().is_empty());
1410 }
1411 }
1412 }
1413 }
1414
1415 #[test]
1416 fn test_issue_83_numbered_list_with_backticks() {
1417 let config = MD013Config {
1419 line_length: 100,
1420 reflow: true,
1421 ..Default::default()
1422 };
1423 let rule = MD013LineLength::from_config_struct(config);
1424
1425 let content = "1. List `manifest` to find the manifest with the largest ID. Say it's `00000000000000000002.manifest` in this example.";
1427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1428
1429 let fixed = rule.fix(&ctx).unwrap();
1430
1431 let expected = "1. List `manifest` to find the manifest with the largest ID. Say it's\n `00000000000000000002.manifest` in this example.";
1434
1435 assert_eq!(
1436 fixed, expected,
1437 "List should be properly reflowed with correct marker and indentation.\nExpected:\n{expected}\nGot:\n{fixed}"
1438 );
1439 }
1440
1441 #[test]
1442 fn test_text_reflow_disabled_by_default() {
1443 let rule = MD013LineLength::new(30, false, false, false, false);
1444
1445 let content = "This is a very long line that definitely exceeds thirty characters.";
1446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1447
1448 let fixed = rule.fix(&ctx).unwrap();
1449
1450 assert_eq!(fixed, content);
1453 }
1454
1455 #[test]
1456 fn test_reflow_with_hard_line_breaks() {
1457 let config = MD013Config {
1459 line_length: 40,
1460 reflow: true,
1461 ..Default::default()
1462 };
1463 let rule = MD013LineLength::from_config_struct(config);
1464
1465 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";
1467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1468 let fixed = rule.fix(&ctx).unwrap();
1469
1470 assert!(
1472 fixed.contains(" \n"),
1473 "Hard line break with exactly 2 spaces should be preserved"
1474 );
1475 }
1476
1477 #[test]
1478 fn test_reflow_preserves_reference_links() {
1479 let config = MD013Config {
1480 line_length: 40,
1481 reflow: true,
1482 ..Default::default()
1483 };
1484 let rule = MD013LineLength::from_config_struct(config);
1485
1486 let content = "This is a very long line with a [reference link][ref] that should not be broken apart when reflowing the text.
1487
1488[ref]: https://example.com";
1489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1490 let fixed = rule.fix(&ctx).unwrap();
1491
1492 assert!(fixed.contains("[reference link][ref]"));
1494 assert!(!fixed.contains("[ reference link]"));
1495 assert!(!fixed.contains("[ref ]"));
1496 }
1497
1498 #[test]
1499 fn test_reflow_with_nested_markdown_elements() {
1500 let config = MD013Config {
1501 line_length: 35,
1502 reflow: true,
1503 ..Default::default()
1504 };
1505 let rule = MD013LineLength::from_config_struct(config);
1506
1507 let content = "This text has **bold with `code` inside** and should handle it properly when wrapping";
1508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1509 let fixed = rule.fix(&ctx).unwrap();
1510
1511 assert!(fixed.contains("**bold with `code` inside**"));
1513 }
1514
1515 #[test]
1516 fn test_reflow_with_unbalanced_markdown() {
1517 let config = MD013Config {
1519 line_length: 30,
1520 reflow: true,
1521 ..Default::default()
1522 };
1523 let rule = MD013LineLength::from_config_struct(config);
1524
1525 let content = "This has **unbalanced bold that goes on for a very long time without closing";
1526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1527 let fixed = rule.fix(&ctx).unwrap();
1528
1529 assert!(!fixed.is_empty());
1533 for line in fixed.lines() {
1535 assert!(line.len() <= 30 || line.starts_with("**"), "Line exceeds limit: {line}");
1536 }
1537 }
1538
1539 #[test]
1540 fn test_reflow_fix_indicator() {
1541 let config = MD013Config {
1543 line_length: 30,
1544 reflow: true,
1545 ..Default::default()
1546 };
1547 let rule = MD013LineLength::from_config_struct(config);
1548
1549 let content = "This is a very long line that definitely exceeds the thirty character limit";
1550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1551 let warnings = rule.check(&ctx).unwrap();
1552
1553 assert!(!warnings.is_empty());
1555 assert!(
1556 warnings[0].fix.is_some(),
1557 "Should provide fix indicator when reflow is true"
1558 );
1559 }
1560
1561 #[test]
1562 fn test_no_fix_indicator_without_reflow() {
1563 let config = MD013Config {
1565 line_length: 30,
1566 reflow: false,
1567 ..Default::default()
1568 };
1569 let rule = MD013LineLength::from_config_struct(config);
1570
1571 let content = "This is a very long line that definitely exceeds the thirty character limit";
1572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1573 let warnings = rule.check(&ctx).unwrap();
1574
1575 assert!(!warnings.is_empty());
1577 assert!(warnings[0].fix.is_none(), "Should not provide fix when reflow is false");
1578 }
1579
1580 #[test]
1581 fn test_reflow_preserves_all_reference_link_types() {
1582 let config = MD013Config {
1583 line_length: 40,
1584 reflow: true,
1585 ..Default::default()
1586 };
1587 let rule = MD013LineLength::from_config_struct(config);
1588
1589 let content = "Test [full reference][ref] and [collapsed][] and [shortcut] reference links in a very long line.
1590
1591[ref]: https://example.com
1592[collapsed]: https://example.com
1593[shortcut]: https://example.com";
1594
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1596 let fixed = rule.fix(&ctx).unwrap();
1597
1598 assert!(fixed.contains("[full reference][ref]"));
1600 assert!(fixed.contains("[collapsed][]"));
1601 assert!(fixed.contains("[shortcut]"));
1602 }
1603
1604 #[test]
1605 fn test_reflow_handles_images_correctly() {
1606 let config = MD013Config {
1607 line_length: 40,
1608 reflow: true,
1609 ..Default::default()
1610 };
1611 let rule = MD013LineLength::from_config_struct(config);
1612
1613 let content = "This line has an  that should not be broken when reflowing.";
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1615 let fixed = rule.fix(&ctx).unwrap();
1616
1617 assert!(fixed.contains(""));
1619 }
1620
1621 #[test]
1622 fn test_normalize_mode_flags_short_lines() {
1623 let config = MD013Config {
1624 line_length: 100,
1625 reflow: true,
1626 reflow_mode: ReflowMode::Normalize,
1627 ..Default::default()
1628 };
1629 let rule = MD013LineLength::from_config_struct(config);
1630
1631 let content = "This is a short line.\nAnother short line.\nA third short line that could be combined.";
1633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1634 let warnings = rule.check(&ctx).unwrap();
1635
1636 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1638 assert!(warnings[0].message.contains("normalized"));
1639 }
1640
1641 #[test]
1642 fn test_normalize_mode_combines_short_lines() {
1643 let config = MD013Config {
1644 line_length: 100,
1645 reflow: true,
1646 reflow_mode: ReflowMode::Normalize,
1647 ..Default::default()
1648 };
1649 let rule = MD013LineLength::from_config_struct(config);
1650
1651 let content =
1653 "This is a line with\nmanual line breaks at\n80 characters that should\nbe combined into longer lines.";
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1655 let fixed = rule.fix(&ctx).unwrap();
1656
1657 let lines: Vec<&str> = fixed.lines().collect();
1659 assert_eq!(lines.len(), 1, "Should combine into single line");
1660 assert!(lines[0].len() > 80, "Should use more of the 100 char limit");
1661 }
1662
1663 #[test]
1664 fn test_normalize_mode_preserves_paragraph_breaks() {
1665 let config = MD013Config {
1666 line_length: 100,
1667 reflow: true,
1668 reflow_mode: ReflowMode::Normalize,
1669 ..Default::default()
1670 };
1671 let rule = MD013LineLength::from_config_struct(config);
1672
1673 let content = "First paragraph with\nshort lines.\n\nSecond paragraph with\nshort lines too.";
1674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1675 let fixed = rule.fix(&ctx).unwrap();
1676
1677 assert!(fixed.contains("\n\n"), "Should preserve paragraph breaks");
1679
1680 let paragraphs: Vec<&str> = fixed.split("\n\n").collect();
1681 assert_eq!(paragraphs.len(), 2, "Should have two paragraphs");
1682 }
1683
1684 #[test]
1685 fn test_default_mode_only_fixes_violations() {
1686 let config = MD013Config {
1687 line_length: 100,
1688 reflow: true,
1689 reflow_mode: ReflowMode::Default, ..Default::default()
1691 };
1692 let rule = MD013LineLength::from_config_struct(config);
1693
1694 let content = "This is a short line.\nAnother short line.\nA third short line.";
1696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1697 let warnings = rule.check(&ctx).unwrap();
1698
1699 assert!(warnings.is_empty(), "Should not flag short lines in default mode");
1701
1702 let fixed = rule.fix(&ctx).unwrap();
1704 assert_eq!(fixed.lines().count(), 3, "Should preserve line breaks in default mode");
1705 }
1706
1707 #[test]
1708 fn test_normalize_mode_with_lists() {
1709 let config = MD013Config {
1710 line_length: 80,
1711 reflow: true,
1712 reflow_mode: ReflowMode::Normalize,
1713 ..Default::default()
1714 };
1715 let rule = MD013LineLength::from_config_struct(config);
1716
1717 let content = r#"A paragraph with
1718short lines.
1719
17201. List item with
1721 short lines
17222. Another item"#;
1723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1724 let fixed = rule.fix(&ctx).unwrap();
1725
1726 let lines: Vec<&str> = fixed.lines().collect();
1728 assert!(lines[0].len() > 20, "First paragraph should be normalized");
1729 assert!(fixed.contains("1. "), "Should preserve list markers");
1730 assert!(fixed.contains("2. "), "Should preserve list markers");
1731 }
1732
1733 #[test]
1734 fn test_normalize_mode_with_code_blocks() {
1735 let config = MD013Config {
1736 line_length: 100,
1737 reflow: true,
1738 reflow_mode: ReflowMode::Normalize,
1739 ..Default::default()
1740 };
1741 let rule = MD013LineLength::from_config_struct(config);
1742
1743 let content = r#"A paragraph with
1744short lines.
1745
1746```
1747code block should not be normalized
1748even with short lines
1749```
1750
1751Another paragraph with
1752short lines."#;
1753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1754 let fixed = rule.fix(&ctx).unwrap();
1755
1756 assert!(fixed.contains("code block should not be normalized\neven with short lines"));
1758 let lines: Vec<&str> = fixed.lines().collect();
1760 assert!(lines[0].len() > 20, "First paragraph should be normalized");
1761 }
1762
1763 #[test]
1764 fn test_issue_76_use_case() {
1765 let config = MD013Config {
1767 line_length: 999999, reflow: true,
1769 reflow_mode: ReflowMode::Normalize,
1770 ..Default::default()
1771 };
1772 let rule = MD013LineLength::from_config_struct(config);
1773
1774 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.";
1776
1777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1778
1779 let warnings = rule.check(&ctx).unwrap();
1781 assert!(!warnings.is_empty(), "Should flag paragraph for normalization");
1782
1783 let fixed = rule.fix(&ctx).unwrap();
1785 let lines: Vec<&str> = fixed.lines().collect();
1786 assert_eq!(lines.len(), 1, "Should combine into single line with high limit");
1787 assert!(!fixed.contains("\n"), "Should remove all line breaks within paragraph");
1788 }
1789
1790 #[test]
1791 fn test_normalize_mode_single_line_unchanged() {
1792 let config = MD013Config {
1794 line_length: 100,
1795 reflow: true,
1796 reflow_mode: ReflowMode::Normalize,
1797 ..Default::default()
1798 };
1799 let rule = MD013LineLength::from_config_struct(config);
1800
1801 let content = "This is a single line that should not be changed.";
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1803
1804 let warnings = rule.check(&ctx).unwrap();
1805 assert!(warnings.is_empty(), "Single line should not be flagged");
1806
1807 let fixed = rule.fix(&ctx).unwrap();
1808 assert_eq!(fixed, content, "Single line should remain unchanged");
1809 }
1810
1811 #[test]
1812 fn test_normalize_mode_with_inline_code() {
1813 let config = MD013Config {
1814 line_length: 80,
1815 reflow: true,
1816 reflow_mode: ReflowMode::Normalize,
1817 ..Default::default()
1818 };
1819 let rule = MD013LineLength::from_config_struct(config);
1820
1821 let content =
1822 "This paragraph has `inline code` and\nshould still be normalized properly\nwithout breaking the code.";
1823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1824
1825 let warnings = rule.check(&ctx).unwrap();
1826 assert!(!warnings.is_empty(), "Multi-line paragraph should be flagged");
1827
1828 let fixed = rule.fix(&ctx).unwrap();
1829 assert!(fixed.contains("`inline code`"), "Inline code should be preserved");
1830 assert!(fixed.lines().count() < 3, "Lines should be combined");
1831 }
1832
1833 #[test]
1834 fn test_normalize_mode_with_emphasis() {
1835 let config = MD013Config {
1836 line_length: 100,
1837 reflow: true,
1838 reflow_mode: ReflowMode::Normalize,
1839 ..Default::default()
1840 };
1841 let rule = MD013LineLength::from_config_struct(config);
1842
1843 let content = "This has **bold** and\n*italic* text that\nshould be preserved.";
1844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1845
1846 let fixed = rule.fix(&ctx).unwrap();
1847 assert!(fixed.contains("**bold**"), "Bold should be preserved");
1848 assert!(fixed.contains("*italic*"), "Italic should be preserved");
1849 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
1850 }
1851
1852 #[test]
1853 fn test_normalize_mode_respects_hard_breaks() {
1854 let config = MD013Config {
1855 line_length: 100,
1856 reflow: true,
1857 reflow_mode: ReflowMode::Normalize,
1858 ..Default::default()
1859 };
1860 let rule = MD013LineLength::from_config_struct(config);
1861
1862 let content = "First line with hard break \nSecond line after break\nThird line";
1864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1865
1866 let fixed = rule.fix(&ctx).unwrap();
1867 assert!(fixed.contains(" \n"), "Hard break should be preserved");
1869 assert!(
1871 fixed.contains("Second line after break Third line"),
1872 "Lines without hard break should combine"
1873 );
1874 }
1875
1876 #[test]
1877 fn test_normalize_mode_with_links() {
1878 let config = MD013Config {
1879 line_length: 100,
1880 reflow: true,
1881 reflow_mode: ReflowMode::Normalize,
1882 ..Default::default()
1883 };
1884 let rule = MD013LineLength::from_config_struct(config);
1885
1886 let content =
1887 "This has a [link](https://example.com) that\nshould be preserved when\nnormalizing the paragraph.";
1888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1889
1890 let fixed = rule.fix(&ctx).unwrap();
1891 assert!(
1892 fixed.contains("[link](https://example.com)"),
1893 "Link should be preserved"
1894 );
1895 assert_eq!(fixed.lines().count(), 1, "Should be combined into one line");
1896 }
1897
1898 #[test]
1899 fn test_normalize_mode_empty_lines_between_paragraphs() {
1900 let config = MD013Config {
1901 line_length: 100,
1902 reflow: true,
1903 reflow_mode: ReflowMode::Normalize,
1904 ..Default::default()
1905 };
1906 let rule = MD013LineLength::from_config_struct(config);
1907
1908 let content = "First paragraph\nwith multiple lines.\n\n\nSecond paragraph\nwith multiple lines.";
1909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1910
1911 let fixed = rule.fix(&ctx).unwrap();
1912 assert!(fixed.contains("\n\n\n"), "Multiple empty lines should be preserved");
1914 let parts: Vec<&str> = fixed.split("\n\n\n").collect();
1916 assert_eq!(parts.len(), 2, "Should have two parts");
1917 assert_eq!(parts[0].lines().count(), 1, "First paragraph should be one line");
1918 assert_eq!(parts[1].lines().count(), 1, "Second paragraph should be one line");
1919 }
1920
1921 #[test]
1922 fn test_normalize_mode_mixed_list_types() {
1923 let config = MD013Config {
1924 line_length: 80,
1925 reflow: true,
1926 reflow_mode: ReflowMode::Normalize,
1927 ..Default::default()
1928 };
1929 let rule = MD013LineLength::from_config_struct(config);
1930
1931 let content = r#"Paragraph before list
1932with multiple lines.
1933
1934- Bullet item
1935* Another bullet
1936+ Plus bullet
1937
19381. Numbered item
19392. Another number
1940
1941Paragraph after list
1942with multiple lines."#;
1943
1944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1945 let fixed = rule.fix(&ctx).unwrap();
1946
1947 assert!(fixed.contains("- Bullet item"), "Dash list should be preserved");
1949 assert!(fixed.contains("* Another bullet"), "Star list should be preserved");
1950 assert!(fixed.contains("+ Plus bullet"), "Plus list should be preserved");
1951 assert!(fixed.contains("1. Numbered item"), "Numbered list should be preserved");
1952
1953 assert!(
1955 fixed.starts_with("Paragraph before list with multiple lines."),
1956 "First paragraph should be normalized"
1957 );
1958 assert!(
1959 fixed.ends_with("Paragraph after list with multiple lines."),
1960 "Last paragraph should be normalized"
1961 );
1962 }
1963
1964 #[test]
1965 fn test_normalize_mode_with_horizontal_rules() {
1966 let config = MD013Config {
1967 line_length: 100,
1968 reflow: true,
1969 reflow_mode: ReflowMode::Normalize,
1970 ..Default::default()
1971 };
1972 let rule = MD013LineLength::from_config_struct(config);
1973
1974 let content = "Paragraph before\nhorizontal rule.\n\n---\n\nParagraph after\nhorizontal rule.";
1975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1976
1977 let fixed = rule.fix(&ctx).unwrap();
1978 assert!(fixed.contains("---"), "Horizontal rule should be preserved");
1979 assert!(
1980 fixed.contains("Paragraph before horizontal rule."),
1981 "First paragraph normalized"
1982 );
1983 assert!(
1984 fixed.contains("Paragraph after horizontal rule."),
1985 "Second paragraph normalized"
1986 );
1987 }
1988
1989 #[test]
1990 fn test_normalize_mode_with_indented_code() {
1991 let config = MD013Config {
1992 line_length: 100,
1993 reflow: true,
1994 reflow_mode: ReflowMode::Normalize,
1995 ..Default::default()
1996 };
1997 let rule = MD013LineLength::from_config_struct(config);
1998
1999 let content = "Paragraph before\nindented code.\n\n This is indented code\n Should not be normalized\n\nParagraph after\nindented code.";
2000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2001
2002 let fixed = rule.fix(&ctx).unwrap();
2003 assert!(
2004 fixed.contains(" This is indented code\n Should not be normalized"),
2005 "Indented code preserved"
2006 );
2007 assert!(
2008 fixed.contains("Paragraph before indented code."),
2009 "First paragraph normalized"
2010 );
2011 assert!(
2012 fixed.contains("Paragraph after indented code."),
2013 "Second paragraph normalized"
2014 );
2015 }
2016
2017 #[test]
2018 fn test_normalize_mode_disabled_without_reflow() {
2019 let config = MD013Config {
2021 line_length: 100,
2022 reflow: false, reflow_mode: ReflowMode::Normalize,
2024 ..Default::default()
2025 };
2026 let rule = MD013LineLength::from_config_struct(config);
2027
2028 let content = "This is a line\nwith breaks that\nshould not be changed.";
2029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2030
2031 let warnings = rule.check(&ctx).unwrap();
2032 assert!(warnings.is_empty(), "Should not flag when reflow is disabled");
2033
2034 let fixed = rule.fix(&ctx).unwrap();
2035 assert_eq!(fixed, content, "Content should be unchanged when reflow is disabled");
2036 }
2037
2038 #[test]
2039 fn test_default_mode_with_long_lines() {
2040 let config = MD013Config {
2043 line_length: 50,
2044 reflow: true,
2045 reflow_mode: ReflowMode::Default,
2046 ..Default::default()
2047 };
2048 let rule = MD013LineLength::from_config_struct(config);
2049
2050 let content = "Short line.\nThis is a very long line that definitely exceeds the fifty character limit and needs wrapping.\nAnother short line.";
2051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2052
2053 let warnings = rule.check(&ctx).unwrap();
2054 assert_eq!(warnings.len(), 1, "Should flag the paragraph with long line");
2055 assert_eq!(warnings[0].line, 2, "Should flag line 2 that exceeds limit");
2057
2058 let fixed = rule.fix(&ctx).unwrap();
2059 assert!(
2061 fixed.contains("Short line. This is"),
2062 "Should combine and reflow the paragraph"
2063 );
2064 assert!(
2065 fixed.contains("wrapping. Another short"),
2066 "Should include all paragraph content"
2067 );
2068 }
2069
2070 #[test]
2071 fn test_normalize_vs_default_mode_same_content() {
2072 let content = "This is a paragraph\nwith multiple lines\nthat could be combined.";
2073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2074
2075 let default_config = MD013Config {
2077 line_length: 100,
2078 reflow: true,
2079 reflow_mode: ReflowMode::Default,
2080 ..Default::default()
2081 };
2082 let default_rule = MD013LineLength::from_config_struct(default_config);
2083 let default_warnings = default_rule.check(&ctx).unwrap();
2084 let default_fixed = default_rule.fix(&ctx).unwrap();
2085
2086 let normalize_config = MD013Config {
2088 line_length: 100,
2089 reflow: true,
2090 reflow_mode: ReflowMode::Normalize,
2091 ..Default::default()
2092 };
2093 let normalize_rule = MD013LineLength::from_config_struct(normalize_config);
2094 let normalize_warnings = normalize_rule.check(&ctx).unwrap();
2095 let normalize_fixed = normalize_rule.fix(&ctx).unwrap();
2096
2097 assert!(default_warnings.is_empty(), "Default mode should not flag short lines");
2099 assert!(
2100 !normalize_warnings.is_empty(),
2101 "Normalize mode should flag multi-line paragraphs"
2102 );
2103
2104 assert_eq!(
2105 default_fixed, content,
2106 "Default mode should not change content without violations"
2107 );
2108 assert_ne!(
2109 normalize_fixed, content,
2110 "Normalize mode should change multi-line paragraphs"
2111 );
2112 assert_eq!(
2113 normalize_fixed.lines().count(),
2114 1,
2115 "Normalize should combine into single line"
2116 );
2117 }
2118
2119 #[test]
2120 fn test_normalize_mode_with_reference_definitions() {
2121 let config = MD013Config {
2122 line_length: 100,
2123 reflow: true,
2124 reflow_mode: ReflowMode::Normalize,
2125 ..Default::default()
2126 };
2127 let rule = MD013LineLength::from_config_struct(config);
2128
2129 let content =
2130 "This paragraph uses\na reference [link][ref]\nacross multiple lines.\n\n[ref]: https://example.com";
2131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2132
2133 let fixed = rule.fix(&ctx).unwrap();
2134 assert!(fixed.contains("[link][ref]"), "Reference link should be preserved");
2135 assert!(
2136 fixed.contains("[ref]: https://example.com"),
2137 "Reference definition should be preserved"
2138 );
2139 assert!(
2140 fixed.starts_with("This paragraph uses a reference [link][ref] across multiple lines."),
2141 "Paragraph should be normalized"
2142 );
2143 }
2144
2145 #[test]
2146 fn test_normalize_mode_with_html_comments() {
2147 let config = MD013Config {
2148 line_length: 100,
2149 reflow: true,
2150 reflow_mode: ReflowMode::Normalize,
2151 ..Default::default()
2152 };
2153 let rule = MD013LineLength::from_config_struct(config);
2154
2155 let content = "Paragraph before\nHTML comment.\n\n<!-- This is a comment -->\n\nParagraph after\nHTML comment.";
2156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2157
2158 let fixed = rule.fix(&ctx).unwrap();
2159 assert!(
2160 fixed.contains("<!-- This is a comment -->"),
2161 "HTML comment should be preserved"
2162 );
2163 assert!(
2164 fixed.contains("Paragraph before HTML comment."),
2165 "First paragraph normalized"
2166 );
2167 assert!(
2168 fixed.contains("Paragraph after HTML comment."),
2169 "Second paragraph normalized"
2170 );
2171 }
2172
2173 #[test]
2174 fn test_normalize_mode_line_starting_with_number() {
2175 let config = MD013Config {
2177 line_length: 100,
2178 reflow: true,
2179 reflow_mode: ReflowMode::Normalize,
2180 ..Default::default()
2181 };
2182 let rule = MD013LineLength::from_config_struct(config);
2183
2184 let content = "This line mentions\n80 characters which\nshould not break the paragraph.";
2185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2186
2187 let fixed = rule.fix(&ctx).unwrap();
2188 assert_eq!(fixed.lines().count(), 1, "Should be combined into single line");
2189 assert!(
2190 fixed.contains("80 characters"),
2191 "Number at start of line should be preserved"
2192 );
2193 }
2194
2195 #[test]
2196 fn test_default_mode_preserves_list_structure() {
2197 let config = MD013Config {
2199 line_length: 80,
2200 reflow: true,
2201 reflow_mode: ReflowMode::Default,
2202 ..Default::default()
2203 };
2204 let rule = MD013LineLength::from_config_struct(config);
2205
2206 let content = r#"- This is a bullet point that has
2207 some text on multiple lines
2208 that should stay separate
2209
22101. Numbered list item with
2211 multiple lines that should
2212 also stay separate"#;
2213
2214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2215 let fixed = rule.fix(&ctx).unwrap();
2216
2217 let lines: Vec<&str> = fixed.lines().collect();
2219 assert_eq!(
2220 lines[0], "- This is a bullet point that has",
2221 "First line should be unchanged"
2222 );
2223 assert_eq!(
2224 lines[1], " some text on multiple lines",
2225 "Continuation should be preserved"
2226 );
2227 assert_eq!(
2228 lines[2], " that should stay separate",
2229 "Second continuation should be preserved"
2230 );
2231 }
2232
2233 #[test]
2234 fn test_normalize_mode_multi_line_list_items_no_extra_spaces() {
2235 let config = MD013Config {
2237 line_length: 80,
2238 reflow: true,
2239 reflow_mode: ReflowMode::Normalize,
2240 ..Default::default()
2241 };
2242 let rule = MD013LineLength::from_config_struct(config);
2243
2244 let content = r#"- This is a bullet point that has
2245 some text on multiple lines
2246 that should be combined
2247
22481. Numbered list item with
2249 multiple lines that need
2250 to be properly combined
22512. Second item"#;
2252
2253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2254 let fixed = rule.fix(&ctx).unwrap();
2255
2256 assert!(
2258 !fixed.contains("lines that"),
2259 "Should not have double spaces in bullet list"
2260 );
2261 assert!(
2262 !fixed.contains("need to"),
2263 "Should not have double spaces in numbered list"
2264 );
2265
2266 assert!(
2268 fixed.contains("- This is a bullet point that has some text on multiple lines that should be"),
2269 "Bullet list should be properly combined"
2270 );
2271 assert!(
2272 fixed.contains("1. Numbered list item with multiple lines that need to be properly combined"),
2273 "Numbered list should be properly combined"
2274 );
2275 }
2276
2277 #[test]
2278 fn test_normalize_mode_actual_numbered_list() {
2279 let config = MD013Config {
2281 line_length: 100,
2282 reflow: true,
2283 reflow_mode: ReflowMode::Normalize,
2284 ..Default::default()
2285 };
2286 let rule = MD013LineLength::from_config_struct(config);
2287
2288 let content = "Paragraph before list\nwith multiple lines.\n\n1. First item\n2. Second item\n10. Tenth item";
2289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2290
2291 let fixed = rule.fix(&ctx).unwrap();
2292 assert!(fixed.contains("1. First item"), "Numbered list 1 should be preserved");
2293 assert!(fixed.contains("2. Second item"), "Numbered list 2 should be preserved");
2294 assert!(fixed.contains("10. Tenth item"), "Numbered list 10 should be preserved");
2295 assert!(
2296 fixed.starts_with("Paragraph before list with multiple lines."),
2297 "Paragraph should be normalized"
2298 );
2299 }
2300
2301 #[test]
2302 fn test_sentence_per_line_detection() {
2303 let config = MD013Config {
2304 reflow: true,
2305 reflow_mode: ReflowMode::SentencePerLine,
2306 ..Default::default()
2307 };
2308 let rule = MD013LineLength::from_config_struct(config.clone());
2309
2310 let content = "This is sentence one. This is sentence two. And sentence three!";
2312 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2313
2314 assert!(!rule.should_skip(&ctx), "Should not skip for sentence-per-line mode");
2316
2317 let result = rule.check(&ctx).unwrap();
2318
2319 assert!(!result.is_empty(), "Should detect multiple sentences on one line");
2320 assert_eq!(
2321 result[0].message,
2322 "Line contains multiple sentences (one sentence per line expected)"
2323 );
2324 }
2325
2326 #[test]
2327 fn test_sentence_per_line_fix() {
2328 let config = MD013Config {
2329 reflow: true,
2330 reflow_mode: ReflowMode::SentencePerLine,
2331 ..Default::default()
2332 };
2333 let rule = MD013LineLength::from_config_struct(config);
2334
2335 let content = "First sentence. Second sentence.";
2336 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2337 let result = rule.check(&ctx).unwrap();
2338
2339 assert!(!result.is_empty(), "Should detect violation");
2340 assert!(result[0].fix.is_some(), "Should provide a fix");
2341
2342 let fix = result[0].fix.as_ref().unwrap();
2343 assert_eq!(fix.replacement.trim(), "First sentence.\nSecond sentence.");
2344 }
2345
2346 #[test]
2347 fn test_sentence_per_line_abbreviations() {
2348 let config = MD013Config {
2349 reflow: true,
2350 reflow_mode: ReflowMode::SentencePerLine,
2351 ..Default::default()
2352 };
2353 let rule = MD013LineLength::from_config_struct(config);
2354
2355 let content = "Mr. Smith met Dr. Jones at 3:00 PM.";
2357 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2358 let result = rule.check(&ctx).unwrap();
2359
2360 assert!(
2361 result.is_empty(),
2362 "Should not detect abbreviations as sentence boundaries"
2363 );
2364 }
2365
2366 #[test]
2367 fn test_sentence_per_line_with_markdown() {
2368 let config = MD013Config {
2369 reflow: true,
2370 reflow_mode: ReflowMode::SentencePerLine,
2371 ..Default::default()
2372 };
2373 let rule = MD013LineLength::from_config_struct(config);
2374
2375 let content = "# Heading\n\nSentence with **bold**. Another with [link](url).";
2376 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2377 let result = rule.check(&ctx).unwrap();
2378
2379 assert!(!result.is_empty(), "Should detect multiple sentences with markdown");
2380 assert_eq!(result[0].line, 3); }
2382
2383 #[test]
2384 fn test_sentence_per_line_questions_exclamations() {
2385 let config = MD013Config {
2386 reflow: true,
2387 reflow_mode: ReflowMode::SentencePerLine,
2388 ..Default::default()
2389 };
2390 let rule = MD013LineLength::from_config_struct(config);
2391
2392 let content = "Is this a question? Yes it is! And a statement.";
2393 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2394 let result = rule.check(&ctx).unwrap();
2395
2396 assert!(!result.is_empty(), "Should detect sentences with ? and !");
2397
2398 let fix = result[0].fix.as_ref().unwrap();
2399 let lines: Vec<&str> = fix.replacement.trim().lines().collect();
2400 assert_eq!(lines.len(), 3);
2401 assert_eq!(lines[0], "Is this a question?");
2402 assert_eq!(lines[1], "Yes it is!");
2403 assert_eq!(lines[2], "And a statement.");
2404 }
2405
2406 #[test]
2407 fn test_sentence_per_line_in_lists() {
2408 let config = MD013Config {
2409 reflow: true,
2410 reflow_mode: ReflowMode::SentencePerLine,
2411 ..Default::default()
2412 };
2413 let rule = MD013LineLength::from_config_struct(config);
2414
2415 let content = "- List item one. With two sentences.\n- Another item.";
2416 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
2417 let result = rule.check(&ctx).unwrap();
2418
2419 assert!(!result.is_empty(), "Should detect sentences in list items");
2420 let fix = result[0].fix.as_ref().unwrap();
2422 assert!(fix.replacement.starts_with("- "), "Should preserve list marker");
2423 }
2424}