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}