quickmark_core/rules/
md013.rs

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// MD013-specific configuration types
12#[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
47/// MD013 Line Length Rule Linter
48///
49/// **SINGLE-USE CONTRACT**: This linter is designed for one-time use only.
50/// After processing a document (via feed() calls and finalize()), the linter
51/// should be discarded. The pending_violations state is not cleared between uses.
52pub(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    /// Analyze all lines and store all violations for reporting via finalize()
66    /// Context cache is already initialized by MultiRuleLinter
67    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        // Check for standalone link: [text](url)
93        if trimmed.starts_with('[') && trimmed.contains("](") && trimmed.ends_with(')') {
94            return true;
95        }
96        // Check for standalone image: ![alt](url)
97        if trimmed.starts_with("![") && trimmed.contains("](") && 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        // Use character-aware slicing to avoid UTF-8 boundary panics
109        // Find the character boundary at or after the limit position
110        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 we've gone beyond the string length, there's nothing beyond the limit
116        if char_boundary >= line.len() {
117            return true; // No characters beyond limit, so no spaces
118        }
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            // Heading nodes
128            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            // Code block nodes
132            "fenced_code_block" | "indented_code_block" | "code_fence_content" => {
133                settings.code_blocks
134            }
135            // Table nodes
136            "table" | "table_row" => settings.tables,
137            _ => true, // Check regular text content
138        }
139    }
140
141    fn is_heading_line(&self, line: &str) -> bool {
142        let trimmed = line.trim_start();
143        // ATX headings start with #
144        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            // Heading nodes
151            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            // Code block nodes
157            "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        // Check if this is a heading line and headings are disabled
168        if self.is_heading_line(line) && !settings.headings {
169            return false;
170        }
171
172        // Skip if this node type shouldn't be checked
173        if !self.should_check_node_type(node_kind) {
174            return false;
175        }
176
177        let limit = self.get_line_limit(node_kind);
178
179        // Check if line exceeds limit
180        if line.len() <= limit {
181            return false;
182        }
183
184        // Apply exceptions
185        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        // Strict mode: all lines beyond limit are violations
194        if settings.strict {
195            return true;
196        }
197
198        // Stern mode: more aggressive than default, but allows lines without spaces beyond limit
199        if settings.stern {
200            // In stern mode, allow lines without spaces beyond limit (like default)
201            // but be more strict about other cases
202            if self.has_no_spaces_beyond_limit(line, limit) {
203                return false;
204            }
205            // If there are spaces beyond limit, it's a violation in stern mode
206            return true;
207        }
208
209        // Default mode: allow lines without spaces beyond the limit
210        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        // Analyze all lines when we see the document node
252        // Context cache is already initialized by MultiRuleLinter
253        if node.kind() == "document" {
254            self.analyze_all_lines();
255        }
256    }
257
258    fn finalize(&mut self) -> Vec<RuleViolation> {
259        // Return all pending violations at once
260        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: &[], // Line-based rules don't require specific nodes
271    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 = "![This is a very long image alt text that definitely exceeds eighty characters](https://example.com/image.jpg)";
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        // Create a string that exceeds 80 chars with a space beyond the limit
382        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(' '); // Add space beyond limit
388
389        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()); // Should violate in strict mode
409    }
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        // Line with spaces beyond limit - should violate in stern mode
419        // Make sure the line has exactly 80 chars, then add text with spaces beyond that
420        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"); // Add spaces beyond limit
426
427        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()); // Should violate in stern mode
432    }
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        // Line without spaces beyond limit - should NOT violate in stern mode
442        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()); // Should NOT violate in stern mode
449    }
450
451    #[test]
452    fn test_stern_mode_vs_default_mode() {
453        // Create line that exceeds limit with spaces beyond limit
454        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"); // Add spaces beyond limit
460
461        // Default mode - should violate because there are spaces beyond limit
462        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        // Stern mode - should violate because it's more aggressive about lines with spaces
472        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        // Both should catch this since it has spaces beyond limit
482        assert_eq!(1, default_violations.len()); // Default should catch this since it has spaces
483        assert_eq!(1, stern_violations.len()); // Stern should definitely catch this
484    }
485
486    #[test]
487    fn test_stern_vs_strict_vs_default_comprehensive() {
488        // Case 1: Line with spaces beyond limit - all modes should catch this
489        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"); // Add spaces beyond limit
495
496        // Case 2: Line without spaces beyond limit - only strict mode should catch this
497        let case2 = "This line has exactly eighty characters and then continues without spaces: https://example.com/url".to_string();
498
499        // Case 3: Line within limit - no mode should catch this
500        let case3 = "This line is within the eighty character limit".to_string();
501
502        let test_cases = vec![
503            (&case1, true, true, true),    // Has spaces beyond limit
504            (&case2, false, false, true),  // No spaces beyond limit
505            (&case3, false, false, false), // Within limit
506        ];
507
508        for (input, expect_default, expect_stern, expect_strict) in test_cases {
509            // Default mode
510            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            // Stern mode
525            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            // Strict mode
543            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        // This test demonstrates that our concern was valid in theory, but doesn't occur in practice
608        // because tree-sitter creates enough AST nodes for even simple documents
609
610        let input = "A\nB\nC\n"; // Minimal document - just 3 short lines
611
612        // Count AST nodes for this minimal document
613        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        // Even this tiny document creates multiple nodes (document, paragraph, text nodes, etc.)
628        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        // Create the most minimal AST possible: just plain text with no structure
637        // This should create minimal AST nodes but many violations
638        let mut input = String::new();
639
640        // Add 100 long lines of plain text (no markdown structure at all)
641        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        // Count how many AST nodes are created by parsing this document
656        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        // This is the critical test: with the improved MD013, we should ALWAYS find all violations
676        // regardless of the node count, because violations are tied to line numbers, not node traversal order
677        println!(
678            "Ratio: {} violations vs {} nodes",
679            violations.len(),
680            node_count
681        );
682
683        // We should find exactly 100 violations
684        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        // This test creates a scenario where violations > nodes to ensure our fix works
693        // Create a document with minimal structure but maximum line violations
694
695        let mut input = "# Header\n\n".to_string(); // Creates multiple AST nodes
696
697        // Add 50 long lines that should violate but may not have corresponding unique AST nodes
698        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        // Should find exactly 50 violations (one per long line), regardless of node count
725        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        // Verify each violation is on the correct line
733        for (i, violation) in violations.iter().enumerate() {
734            let expected_line = i + 2; // Lines 2, 3, 4, ..., 51 (line 0 is header, line 1 is empty)
735            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        // Create a document with many line violations but few AST nodes
749        // Structure: simple heading followed by many long lines of plain text
750        let mut input = "# Short heading\n\n".to_string();
751
752        // Add 20 long lines that should each trigger violations
753        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        // Debug: print actual violations found
773        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        // We should find exactly 20 violations (one per long line)
783        // If we find fewer, it means some violations were lost due to the bug
784        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        // Verify violations are on the correct lines (lines 2-21, since line 0 is heading, line 1 is empty)
790        for (i, violation) in violations.iter().enumerate() {
791            let expected_line = i + 2; // Lines 2, 3, 4, ..., 21
792            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        // Test that UTF-8 character boundary issues are properly handled
806        // Create a line that has a multi-byte UTF-8 character at position 79-82 (checkmark ✓)
807        // This previously caused a panic when slicing at position 80
808        let input = "| View allowed and denied licenses **(ULTIMATE)** | ✓ (*1*) | ✓          | ✓           | ✓        | ✓      |";
809
810        // Verify the test setup: checkmark should be at the boundary where slicing fails
811        assert!(input.len() > 80, "Line should exceed 80 characters");
812        let char_at_79 = input.as_bytes()[79];
813        // UTF-8 checkmark starts at byte 79, so slicing at 80 would panic without the fix
814        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        // This should NOT panic with the UTF-8 boundary fix
822        let violations = linter.analyze();
823
824        // Should find exactly 1 violation for the long line
825        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}