1use serde::Deserialize;
2use std::rc::Rc;
3
4use tree_sitter::Node;
5
6use crate::{
7 linter::{range_from_tree_sitter, RuleViolation},
8 rules::{Context, Rule, RuleLinter, RuleType},
9};
10
11#[derive(Debug, PartialEq, Clone, Deserialize)]
13pub struct MD013LineLengthTable {
14 #[serde(default)]
15 pub line_length: usize,
16 #[serde(default)]
17 pub code_block_line_length: usize,
18 #[serde(default)]
19 pub heading_line_length: usize,
20 #[serde(default)]
21 pub code_blocks: bool,
22 #[serde(default)]
23 pub headings: bool,
24 #[serde(default)]
25 pub tables: bool,
26 #[serde(default)]
27 pub strict: bool,
28 #[serde(default)]
29 pub stern: bool,
30}
31
32impl Default for MD013LineLengthTable {
33 fn default() -> Self {
34 Self {
35 line_length: 80,
36 code_block_line_length: 80,
37 heading_line_length: 80,
38 code_blocks: true,
39 headings: true,
40 tables: true,
41 strict: false,
42 stern: false,
43 }
44 }
45}
46
47pub(crate) struct MD013Linter {
53 context: Rc<Context>,
54 violations: Vec<RuleViolation>,
55}
56
57impl MD013Linter {
58 pub fn new(context: Rc<Context>) -> Self {
59 Self {
60 context,
61 violations: Vec::new(),
62 }
63 }
64
65 fn analyze_all_lines(&mut self) {
68 let lines = self.context.lines.borrow();
69
70 for (line_index, line) in lines.iter().enumerate() {
71 let node_kind = self.context.get_node_type_for_line(line_index);
72 let should_check = self.should_check_node_type(&node_kind);
73 let should_violate = if should_check {
74 self.should_violate_line(line, line_index, &node_kind)
75 } else {
76 false
77 };
78
79 if should_violate {
80 let violation = self.create_violation_for_line(line, line_index, &node_kind);
81 self.violations.push(violation);
82 }
83 }
84 }
85
86 fn is_link_reference_definition(&self, line: &str) -> bool {
87 line.trim_start().starts_with('[') && line.contains("]:") && line.contains("http")
88 }
89
90 fn is_standalone_link_or_image(&self, line: &str) -> bool {
91 let trimmed = line.trim();
92 if trimmed.starts_with('[') && trimmed.contains("](") && trimmed.ends_with(')') {
94 return true;
95 }
96 if trimmed.starts_with(" && trimmed.ends_with(')') {
98 return true;
99 }
100 false
101 }
102
103 fn has_no_spaces_beyond_limit(&self, line: &str, limit: usize) -> bool {
104 if line.len() <= limit {
105 return false;
106 }
107
108 let mut char_boundary = limit;
111 while char_boundary < line.len() && !line.is_char_boundary(char_boundary) {
112 char_boundary += 1;
113 }
114
115 if char_boundary >= line.len() {
117 return true; }
119
120 let beyond_limit = &line[char_boundary..];
121 !beyond_limit.contains(' ')
122 }
123
124 fn should_check_node_type(&self, node_kind: &str) -> bool {
125 let settings = &self.context.config.linters.settings.line_length;
126 match node_kind {
127 s if s.starts_with("atx_h") && s.ends_with("_marker") => settings.headings,
129 s if s.starts_with("setext_h") && s.ends_with("_underline") => settings.headings,
130 "atx_heading" | "setext_heading" => settings.headings,
131 "fenced_code_block" | "indented_code_block" | "code_fence_content" => {
133 settings.code_blocks
134 }
135 "table" | "table_row" => settings.tables,
137 _ => true, }
139 }
140
141 fn is_heading_line(&self, line: &str) -> bool {
142 let trimmed = line.trim_start();
143 trimmed.starts_with('#') && (trimmed.len() > 1 && trimmed.chars().nth(1) == Some(' '))
145 }
146
147 fn get_line_limit(&self, node_kind: &str) -> usize {
148 let settings = &self.context.config.linters.settings.line_length;
149 match node_kind {
150 s if s.starts_with("atx_h") && s.ends_with("_marker") => settings.heading_line_length,
152 s if s.starts_with("setext_h") && s.ends_with("_underline") => {
153 settings.heading_line_length
154 }
155 "atx_heading" | "setext_heading" => settings.heading_line_length,
156 "fenced_code_block" | "indented_code_block" | "code_fence_content" => {
158 settings.code_block_line_length
159 }
160 _ => settings.line_length,
161 }
162 }
163
164 fn should_violate_line(&self, line: &str, _line_number: usize, node_kind: &str) -> bool {
165 let settings = &self.context.config.linters.settings.line_length;
166
167 if self.is_heading_line(line) && !settings.headings {
169 return false;
170 }
171
172 if !self.should_check_node_type(node_kind) {
174 return false;
175 }
176
177 let limit = self.get_line_limit(node_kind);
178
179 if line.len() <= limit {
181 return false;
182 }
183
184 if self.is_link_reference_definition(line) {
186 return false;
187 }
188
189 if self.is_standalone_link_or_image(line) {
190 return false;
191 }
192
193 if settings.strict {
195 return true;
196 }
197
198 if settings.stern {
200 if self.has_no_spaces_beyond_limit(line, limit) {
203 return false;
204 }
205 return true;
207 }
208
209 if self.has_no_spaces_beyond_limit(line, limit) {
211 return false;
212 }
213
214 true
215 }
216
217 fn create_violation_for_line(
218 &self,
219 line: &str,
220 line_number: usize,
221 node_kind: &str,
222 ) -> RuleViolation {
223 let limit = self.get_line_limit(node_kind);
224 RuleViolation::new(
225 &MD013,
226 format!(
227 "{} [Expected: <= {}; Actual: {}]",
228 MD013.description,
229 limit,
230 line.len()
231 ),
232 self.context.file_path.clone(),
233 range_from_tree_sitter(&tree_sitter::Range {
234 start_byte: 0,
235 end_byte: line.len(),
236 start_point: tree_sitter::Point {
237 row: line_number,
238 column: 0,
239 },
240 end_point: tree_sitter::Point {
241 row: line_number,
242 column: line.len(),
243 },
244 }),
245 )
246 }
247}
248
249impl RuleLinter for MD013Linter {
250 fn feed(&mut self, node: &Node) {
251 if node.kind() == "document" {
254 self.analyze_all_lines();
255 }
256 }
257
258 fn finalize(&mut self) -> Vec<RuleViolation> {
259 std::mem::take(&mut self.violations)
261 }
262}
263
264pub const MD013: Rule = Rule {
265 id: "MD013",
266 alias: "line-length",
267 tags: &["line_length"],
268 description: "Line length should not exceed the configured limit",
269 rule_type: RuleType::Line,
270 required_nodes: &[], new_linter: |context| Box::new(MD013Linter::new(context)),
272};
273
274#[cfg(test)]
275mod test {
276 use std::path::PathBuf;
277
278 use crate::config::{LintersSettingsTable, MD013LineLengthTable, RuleSeverity};
279 use crate::linter::MultiRuleLinter;
280 use crate::test_utils::test_helpers::{test_config_with_rules, test_config_with_settings};
281
282 fn test_config() -> crate::config::QuickmarkConfig {
283 test_config_with_rules(vec![
284 ("line-length", RuleSeverity::Error),
285 ("heading-style", RuleSeverity::Off),
286 ("heading-increment", RuleSeverity::Off),
287 ])
288 }
289
290 fn test_config_with_line_length(
291 line_length_config: MD013LineLengthTable,
292 ) -> crate::config::QuickmarkConfig {
293 test_config_with_settings(
294 vec![
295 ("line-length", RuleSeverity::Error),
296 ("heading-style", RuleSeverity::Off),
297 ("heading-increment", RuleSeverity::Off),
298 ],
299 LintersSettingsTable {
300 line_length: line_length_config,
301 ..Default::default()
302 },
303 )
304 }
305
306 #[test]
307 fn test_line_length_violation() {
308 let input = "This is a line that is definitely longer than eighty characters and should trigger a violation.";
309
310 let config = test_config();
311 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
312 let violations = linter.analyze();
313 assert_eq!(1, violations.len());
314
315 let violation = &violations[0];
316 assert_eq!("MD013", violation.rule().id);
317 assert!(violation.message().contains("Expected: <= 80"));
318 assert!(violation
319 .message()
320 .contains(&format!("Actual: {}", input.len())));
321 }
322
323 #[test]
324 fn test_line_length_no_violation() {
325 let mut input =
326 "This line should be exactly eighty characters long and not trigger".to_string();
327 while input.len() < 80 {
328 input.push('x');
329 }
330 assert_eq!(80, input.len());
331
332 let config = test_config();
333 let mut linter =
334 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
335 let violations = linter.analyze();
336 assert_eq!(0, violations.len());
337 }
338
339 #[test]
340 fn test_link_reference_definition_exception() {
341 let input = "[very-long-link-reference-that-exceeds-eighty-characters]: https://example.com/very-long-url-that-should-be-exempted";
342
343 let config = test_config();
344 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
345 let violations = linter.analyze();
346 assert_eq!(0, violations.len());
347 }
348
349 #[test]
350 fn test_standalone_link_exception() {
351 let input = "[This is a very long link text that definitely exceeds eighty characters](https://example.com)";
352
353 let config = test_config();
354 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
355 let violations = linter.analyze();
356 assert_eq!(0, violations.len());
357 }
358
359 #[test]
360 fn test_standalone_image_exception() {
361 let input = "";
362
363 let config = test_config();
364 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
365 let violations = linter.analyze();
366 assert_eq!(0, violations.len());
367 }
368
369 #[test]
370 fn test_no_spaces_beyond_limit_exception() {
371 let input = "This line has exactly eighty characters and then continues without spaces: https://example.com/very-long-url-without-spaces";
372
373 let config = test_config();
374 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
375 let violations = linter.analyze();
376 assert_eq!(0, violations.len());
377 }
378
379 #[test]
380 fn test_spaces_beyond_limit_violation() {
381 let mut input =
383 "This line has exactly eighty characters and should trigger violation".to_string();
384 while input.len() < 80 {
385 input.push('x');
386 }
387 input.push(' '); let config = test_config();
390 let mut linter =
391 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
392 let violations = linter.analyze();
393 assert_eq!(1, violations.len());
394 }
395
396 #[test]
397 fn test_strict_mode() {
398 let line_length_config = MD013LineLengthTable {
399 strict: true,
400 ..MD013LineLengthTable::default()
401 };
402
403 let input = "This line has exactly eighty characters and then continues without spaces like: https://example.com/url";
404
405 let config = test_config_with_line_length(line_length_config);
406 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
407 let violations = linter.analyze();
408 assert_eq!(1, violations.len()); }
410
411 #[test]
412 fn test_stern_mode_with_spaces_beyond_limit() {
413 let config = MD013LineLengthTable {
414 stern: true,
415 ..MD013LineLengthTable::default()
416 };
417
418 let mut input =
421 "This line has exactly eighty characters and should trigger violations".to_string();
422 while input.len() < 80 {
423 input.push('x');
424 }
425 input.push_str(" with spaces"); let full_config = test_config_with_line_length(config);
428 let mut linter =
429 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), full_config, &input);
430 let violations = linter.analyze();
431 assert_eq!(1, violations.len()); }
433
434 #[test]
435 fn test_stern_mode_without_spaces_beyond_limit() {
436 let config = MD013LineLengthTable {
437 stern: true,
438 ..MD013LineLengthTable::default()
439 };
440
441 let input = "This line has exactly eighty characters and then continues without spaces: https://example.com/very-long-url-without-spaces";
443
444 let full_config = test_config_with_line_length(config);
445 let mut linter =
446 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), full_config, input);
447 let violations = linter.analyze();
448 assert_eq!(0, violations.len()); }
450
451 #[test]
452 fn test_stern_mode_vs_default_mode() {
453 let mut input =
455 "This line has exactly eighty characters and then continues with".to_string();
456 while input.len() < 80 {
457 input.push('x');
458 }
459 input.push_str(" spaces beyond"); let default_config = MD013LineLengthTable::default();
463 let default_full_config = test_config_with_line_length(default_config);
464 let mut default_linter = MultiRuleLinter::new_for_document(
465 PathBuf::from("test.md"),
466 default_full_config,
467 &input,
468 );
469 let default_violations = default_linter.analyze();
470
471 let stern_config = MD013LineLengthTable {
473 stern: true,
474 ..MD013LineLengthTable::default()
475 };
476 let stern_full_config = test_config_with_line_length(stern_config);
477 let mut stern_linter =
478 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), stern_full_config, &input);
479 let stern_violations = stern_linter.analyze();
480
481 assert_eq!(1, default_violations.len()); assert_eq!(1, stern_violations.len()); }
485
486 #[test]
487 fn test_stern_vs_strict_vs_default_comprehensive() {
488 let mut case1 =
490 "This line has exactly eighty characters and then continues with".to_string();
491 while case1.len() < 80 {
492 case1.push('x');
493 }
494 case1.push_str(" spaces"); let case2 = "This line has exactly eighty characters and then continues without spaces: https://example.com/url".to_string();
498
499 let case3 = "This line is within the eighty character limit".to_string();
501
502 let test_cases = vec![
503 (&case1, true, true, true), (&case2, false, false, true), (&case3, false, false, false), ];
507
508 for (input, expect_default, expect_stern, expect_strict) in test_cases {
509 let default_config = MD013LineLengthTable::default();
511 let default_full_config = test_config_with_line_length(default_config);
512 let mut default_linter = MultiRuleLinter::new_for_document(
513 PathBuf::from("test.md"),
514 default_full_config,
515 input,
516 );
517 let default_violations = default_linter.analyze();
518 assert_eq!(
519 expect_default,
520 !default_violations.is_empty(),
521 "Default mode failed for: {input}"
522 );
523
524 let stern_config = MD013LineLengthTable {
526 stern: true,
527 ..MD013LineLengthTable::default()
528 };
529 let stern_full_config = test_config_with_line_length(stern_config);
530 let mut stern_linter = MultiRuleLinter::new_for_document(
531 PathBuf::from("test.md"),
532 stern_full_config,
533 input,
534 );
535 let stern_violations = stern_linter.analyze();
536 assert_eq!(
537 expect_stern,
538 !stern_violations.is_empty(),
539 "Stern mode failed for: {input}"
540 );
541
542 let strict_config = MD013LineLengthTable {
544 strict: true,
545 ..MD013LineLengthTable::default()
546 };
547 let strict_full_config = test_config_with_line_length(strict_config);
548 let mut strict_linter = MultiRuleLinter::new_for_document(
549 PathBuf::from("test.md"),
550 strict_full_config,
551 input,
552 );
553 let strict_violations = strict_linter.analyze();
554 assert_eq!(
555 expect_strict,
556 !strict_violations.is_empty(),
557 "Strict mode failed for: {input}"
558 );
559 }
560 }
561
562 #[test]
563 fn test_custom_line_length() {
564 let line_length_config = MD013LineLengthTable {
565 line_length: 50,
566 ..MD013LineLengthTable::default()
567 };
568
569 let input = "This line is longer than fifty characters and should violate";
570
571 let config = test_config_with_line_length(line_length_config);
572 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
573 let violations = linter.analyze();
574 assert_eq!(1, violations.len());
575 assert!(violations[0].message().contains("Expected: <= 50"));
576 }
577
578 #[test]
579 fn test_headings_disabled() {
580 let line_length_config = MD013LineLengthTable {
581 headings: false,
582 ..MD013LineLengthTable::default()
583 };
584
585 let input = "# This is a very long heading that definitely exceeds the eighty character limit and should not trigger a violation";
586
587 let config = test_config_with_line_length(line_length_config);
588 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
589 let violations = linter.analyze();
590 assert_eq!(0, violations.len());
591 }
592
593 #[test]
594 fn test_multiple_lines() {
595 let input = "This is a short line.
596This is a very long line that definitely exceeds the eighty character limit and should trigger a violation.
597Another short line.";
598
599 let config = test_config();
600 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
601 let violations = linter.analyze();
602 assert_eq!(1, violations.len());
603 }
604
605 #[test]
606 fn test_demonstrates_potential_bug_scenario() {
607 let input = "A\nB\nC\n"; let mut parser = tree_sitter::Parser::new();
614 parser
615 .set_language(&tree_sitter_md::LANGUAGE.into())
616 .unwrap();
617 let tree = parser.parse(input, None).unwrap();
618 let mut node_count = 0;
619 let walker = crate::tree_sitter_walker::TreeSitterWalker::new(&tree);
620 walker.walk(|_node| {
621 node_count += 1;
622 });
623
624 println!("Even a 3-line minimal document creates {node_count} AST nodes");
625 println!("This explains why our MD013 implementation works correctly");
626
627 assert!(
629 node_count >= 3,
630 "Even minimal documents create multiple AST nodes"
631 );
632 }
633
634 #[test]
635 fn test_extreme_violations_vs_minimal_nodes() {
636 let mut input = String::new();
639
640 let long_line = "This line is definitely longer than 80 characters and should trigger a line length violation every single time.\n";
642 assert!(
643 long_line.len() > 80,
644 "Test line should exceed 80 chars, got {}",
645 long_line.len()
646 );
647
648 for i in 0..100 {
649 input.push_str(&format!("Violation line {}: {}", i + 1, long_line));
650 }
651
652 println!("Total input length: {} chars", input.len());
653 println!("Number of lines: {}", input.lines().count());
654
655 let mut parser = tree_sitter::Parser::new();
657 parser
658 .set_language(&tree_sitter_md::LANGUAGE.into())
659 .unwrap();
660 let tree = parser.parse(&input, None).unwrap();
661 let mut node_count = 0;
662 let walker = crate::tree_sitter_walker::TreeSitterWalker::new(&tree);
663 walker.walk(|_node| {
664 node_count += 1;
665 });
666 println!("Total AST nodes: {node_count}");
667
668 let config = test_config();
669 let mut linter =
670 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
671 let violations = linter.analyze();
672
673 println!("Violations found: {}", violations.len());
674
675 println!(
678 "Ratio: {} violations vs {} nodes",
679 violations.len(),
680 node_count
681 );
682
683 assert_eq!(100, violations.len(),
685 "Expected 100 line length violations but found {}. The improved MD013 should never lose violations!",
686 violations.len()
687 );
688 }
689
690 #[test]
691 fn test_violation_node_mismatch_scenario() {
692 let mut input = "# Header\n\n".to_string(); for i in 0..50 {
699 input.push_str(&format!("Line {} with text that is definitely over eighty characters and should trigger MD013 violation\n", i + 1));
700 }
701
702 let mut parser = tree_sitter::Parser::new();
703 parser
704 .set_language(&tree_sitter_md::LANGUAGE.into())
705 .unwrap();
706 let tree = parser.parse(&input, None).unwrap();
707 let mut node_count = 0;
708 let walker = crate::tree_sitter_walker::TreeSitterWalker::new(&tree);
709 walker.walk(|_node| {
710 node_count += 1;
711 });
712
713 let config = test_config();
714 let mut linter =
715 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
716 let violations = linter.analyze();
717
718 println!(
719 "Stress test: {} violations vs {} nodes",
720 violations.len(),
721 node_count
722 );
723
724 assert_eq!(
726 50,
727 violations.len(),
728 "Expected 50 violations but found {}. Improved MD013 must not lose violations!",
729 violations.len()
730 );
731
732 for (i, violation) in violations.iter().enumerate() {
734 let expected_line = i + 2; assert_eq!(
736 expected_line,
737 violation.location().range.start.line,
738 "Violation {} should be on line {} but was on line {}",
739 i + 1,
740 expected_line,
741 violation.location().range.start.line
742 );
743 }
744 }
745
746 #[test]
747 fn test_many_violations_vs_few_nodes() {
748 let mut input = "# Short heading\n\n".to_string();
751
752 let long_line = "This line is definitely longer than 80 characters and should trigger a line length violation every time it appears.\n";
754 assert!(
755 long_line.len() > 80,
756 "Test line should exceed 80 chars, got {}",
757 long_line.len()
758 );
759
760 for i in 0..20 {
761 input.push_str(&format!("Line {}: {}", i + 1, long_line));
762 }
763
764 println!("Total input length: {} chars", input.len());
765 println!("Number of lines: {}", input.lines().count());
766
767 let config = test_config();
768 let mut linter =
769 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
770 let violations = linter.analyze();
771
772 println!("Violations found: {}", violations.len());
774 for (i, violation) in violations.iter().enumerate() {
775 println!(
776 " Violation {}: line {}",
777 i + 1,
778 violation.location().range.start.line
779 );
780 }
781
782 assert_eq!(20, violations.len(),
785 "Expected 20 line length violations but found {}. This suggests violations were lost due to insufficient AST nodes.",
786 violations.len()
787 );
788
789 for (i, violation) in violations.iter().enumerate() {
791 let expected_line = i + 2; assert_eq!(
793 expected_line,
794 violation.location().range.start.line,
795 "Violation {} should be on line {} but was on line {}",
796 i + 1,
797 expected_line,
798 violation.location().range.start.line
799 );
800 }
801 }
802
803 #[test]
804 fn test_utf8_character_boundary_fix() {
805 let input = "| View allowed and denied licenses **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |";
809
810 assert!(input.len() > 80, "Line should exceed 80 characters");
812 let char_at_79 = input.as_bytes()[79];
813 assert!(
815 char_at_79 >= 0x80,
816 "Should have multi-byte UTF-8 character near position 80"
817 );
818
819 let config = test_config();
820 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
821 let violations = linter.analyze();
823
824 assert_eq!(1, violations.len(), "Should find one line length violation");
826 assert_eq!("MD013", violations[0].rule().id);
827 assert!(violations[0].message().contains("Expected: <= 80"));
828 }
829}