quickmark_core/rules/
md027.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// MD027-specific configuration types
12#[derive(Debug, PartialEq, Clone, Deserialize)]
13pub struct MD027BlockquoteSpacesTable {
14    #[serde(default)]
15    pub list_items: bool,
16}
17
18impl Default for MD027BlockquoteSpacesTable {
19    fn default() -> Self {
20        Self { list_items: true }
21    }
22}
23
24/// MD027 Multiple Spaces After Blockquote Symbol Rule Linter
25///
26/// **SINGLE-USE CONTRACT**: This linter is designed for one-time use only.
27/// After processing a document (via feed() calls and finalize()), the linter
28/// should be discarded. The violations state is not cleared between uses.
29pub(crate) struct MD027Linter {
30    context: Rc<Context>,
31    violations: Vec<RuleViolation>,
32}
33
34impl MD027Linter {
35    pub fn new(context: Rc<Context>) -> Self {
36        Self {
37            context,
38            violations: Vec::new(),
39        }
40    }
41
42    /// Analyze all lines with AST-aware code block exclusion
43    fn analyze_all_lines(&mut self) {
44        let settings = self
45            .context
46            .config
47            .linters
48            .settings
49            .blockquote_spaces
50            .clone();
51        let lines = self.context.lines.borrow();
52
53        // Get code block lines to exclude using AST
54        let code_block_lines = self.get_code_block_lines();
55
56        for (line_index, line) in lines.iter().enumerate() {
57            let line_number = line_index + 1;
58
59            // Skip lines that are inside code blocks
60            if code_block_lines.contains(&line_number) {
61                continue;
62            }
63
64            // Check if line contains blockquote violations
65            if let Some(violation) = self.check_blockquote_line(line, line_index, &settings) {
66                self.violations.push(violation);
67            }
68        }
69    }
70
71    /// Check if a line violates the MD027 rule using improved logic
72    fn check_blockquote_line(
73        &self,
74        line: &str,
75        line_index: usize,
76        settings: &crate::config::MD027BlockquoteSpacesTable,
77    ) -> Option<RuleViolation> {
78        // Find blockquote markers and check for multiple spaces after each '>'
79        let mut current_line = line;
80        let mut current_offset = 0;
81
82        // Skip leading whitespace
83        let leading_whitespace = current_line.len() - current_line.trim_start().len();
84        current_line = current_line.trim_start();
85        current_offset += leading_whitespace;
86
87        // Process each '>' character in sequence (for nested blockquotes)
88        while current_line.starts_with('>') {
89            let after_gt = &current_line[1..]; // Everything after this '>'
90
91            // Check if there are multiple spaces after this '>'
92            if after_gt.starts_with("  ") {
93                // Count consecutive spaces
94                let space_count = after_gt.chars().take_while(|&c| c == ' ').count();
95
96                // If list_items is false, check if this line contains a list item
97                if !settings.list_items && self.is_list_item_content(after_gt) {
98                    return None;
99                }
100
101                // Create violation pointing to the first extra space
102                // Position points to the second space character (first extra space)
103                let start_column = current_offset + 2; // Position of second space (after '>' and first space)
104                let end_column = start_column + space_count - 2; // End at last extra space
105
106                let violation = RuleViolation::new(
107                    &MD027,
108                    "Multiple spaces after blockquote symbol".to_string(),
109                    self.context.file_path.clone(),
110                    range_from_tree_sitter(&tree_sitter::Range {
111                        start_byte: 0,
112                        end_byte: 0,
113                        start_point: tree_sitter::Point {
114                            row: line_index,
115                            column: start_column,
116                        },
117                        end_point: tree_sitter::Point {
118                            row: line_index,
119                            column: end_column,
120                        },
121                    }),
122                );
123
124                return Some(violation);
125            }
126
127            // Move to the next character after '>'
128            current_line = &current_line[1..];
129            current_offset += 1;
130
131            // Skip exactly one space if present (normal blockquote formatting)
132            if current_line.starts_with(' ') {
133                current_line = &current_line[1..];
134                current_offset += 1;
135            }
136
137            // Skip to next '>' if there's another one immediately
138            if !current_line.starts_with('>') {
139                break;
140            }
141        }
142
143        None
144    }
145
146    /// Returns a set of line numbers that are part of code blocks using AST
147    fn get_code_block_lines(&self) -> std::collections::HashSet<usize> {
148        let node_cache = self.context.node_cache.borrow();
149        let mut code_block_lines = std::collections::HashSet::new();
150
151        // Add indented code block lines
152        if let Some(indented_blocks) = node_cache.get("indented_code_block") {
153            for node_info in indented_blocks {
154                code_block_lines.extend((node_info.line_start + 1)..=(node_info.line_end + 1));
155            }
156        }
157
158        // Add fenced code block lines
159        if let Some(fenced_blocks) = node_cache.get("fenced_code_block") {
160            for node_info in fenced_blocks {
161                code_block_lines.extend((node_info.line_start + 1)..=(node_info.line_end + 1));
162            }
163        }
164
165        // Add HTML comment lines
166        if let Some(html_comments) = node_cache.get("html_block") {
167            for node_info in html_comments {
168                code_block_lines.extend((node_info.line_start + 1)..=(node_info.line_end + 1));
169            }
170        }
171
172        code_block_lines
173    }
174
175    /// Checks if the given text is an ordered list marker.
176    fn is_ordered_list_marker(&self, text: &str, delimiter: char) -> bool {
177        if let Some(pos) = text.find(delimiter) {
178            if pos > 0 {
179                let prefix = &text[..pos];
180                if prefix.chars().all(|c| c.is_ascii_digit())
181                    || (prefix.len() == 1 && prefix.chars().all(|c| c.is_ascii_alphabetic()))
182                {
183                    return text.chars().nth(pos + 1).is_some_and(|c| c.is_whitespace());
184                }
185            }
186        }
187        false
188    }
189
190    /// Check if content represents a list item using AST-aware detection
191    fn is_list_item_content(&self, content: &str) -> bool {
192        let trimmed = content.trim_start();
193
194        // Check for unordered list markers
195        if trimmed.starts_with('-') || trimmed.starts_with('+') || trimmed.starts_with('*') {
196            return trimmed.chars().nth(1).is_some_and(|c| c.is_whitespace());
197        }
198
199        // Check for ordered list markers
200        if self.is_ordered_list_marker(trimmed, '.') || self.is_ordered_list_marker(trimmed, ')') {
201            return true;
202        }
203
204        false
205    }
206}
207
208impl RuleLinter for MD027Linter {
209    fn feed(&mut self, node: &Node) {
210        // Use hybrid approach: process on document node but with AST awareness for code blocks
211        if node.kind() == "document" {
212            self.analyze_all_lines();
213        }
214    }
215
216    fn finalize(&mut self) -> Vec<RuleViolation> {
217        std::mem::take(&mut self.violations)
218    }
219}
220
221pub const MD027: Rule = Rule {
222    id: "MD027",
223    alias: "no-multiple-space-blockquote",
224    tags: &["blockquote", "whitespace", "indentation"],
225    description: "Multiple spaces after blockquote symbol",
226    rule_type: RuleType::Hybrid,
227    // This rule uses hybrid analysis: line-based with AST-aware code block exclusion
228    required_nodes: &["indented_code_block", "fenced_code_block", "html_block"],
229    new_linter: |context| Box::new(MD027Linter::new(context)),
230};
231
232#[cfg(test)]
233mod test {
234    use std::path::PathBuf;
235
236    use crate::config::{LintersSettingsTable, MD027BlockquoteSpacesTable, RuleSeverity};
237    use crate::linter::MultiRuleLinter;
238    use crate::test_utils::test_helpers::{test_config_with_rules, test_config_with_settings};
239
240    fn test_config() -> crate::config::QuickmarkConfig {
241        test_config_with_rules(vec![
242            ("no-multiple-space-blockquote", RuleSeverity::Error),
243            ("heading-style", RuleSeverity::Off),
244            ("heading-increment", RuleSeverity::Off),
245        ])
246    }
247
248    fn test_config_with_blockquote_spaces(
249        blockquote_spaces_config: MD027BlockquoteSpacesTable,
250    ) -> crate::config::QuickmarkConfig {
251        test_config_with_settings(
252            vec![
253                ("no-multiple-space-blockquote", RuleSeverity::Error),
254                ("heading-style", RuleSeverity::Off),
255                ("heading-increment", RuleSeverity::Off),
256            ],
257            LintersSettingsTable {
258                blockquote_spaces: blockquote_spaces_config,
259                ..Default::default()
260            },
261        )
262    }
263
264    #[test]
265    fn test_basic_multiple_space_violation() {
266        let input = "> This is correct\n>  This has multiple spaces";
267
268        let config = test_config();
269        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
270        let violations = linter.analyze();
271        assert_eq!(1, violations.len());
272
273        let violation = &violations[0];
274        assert_eq!("MD027", violation.rule().id);
275        assert!(violation.message().contains("Multiple spaces"));
276    }
277
278    #[test]
279    fn test_no_violation_single_space() {
280        let input = "> This is correct\n> This is also correct";
281
282        let config = test_config();
283        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
284        let violations = linter.analyze();
285        assert_eq!(0, violations.len());
286    }
287
288    #[test]
289    fn test_list_items_configuration() {
290        let input = ">  - Item with multiple spaces\n> - Normal item";
291
292        // With list_items = true (default), should violate
293        let config =
294            test_config_with_blockquote_spaces(MD027BlockquoteSpacesTable { list_items: true });
295        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
296        let violations = linter.analyze();
297        assert_eq!(1, violations.len());
298
299        // With list_items = false, should not violate for list items
300        let config =
301            test_config_with_blockquote_spaces(MD027BlockquoteSpacesTable { list_items: false });
302        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
303        let violations = linter.analyze();
304        assert_eq!(0, violations.len());
305    }
306
307    #[test]
308    fn test_indented_code_blocks_excluded() {
309        let input = "    > This is in an indented code block with multiple spaces";
310
311        let config = test_config();
312        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
313        let violations = linter.analyze();
314        assert_eq!(0, violations.len()); // Should be excluded
315    }
316
317    #[test]
318    fn test_nested_blockquotes() {
319        let input = "> First level\n>>  Second level with multiple spaces\n> > Another second level with multiple spaces";
320
321        let config = test_config();
322        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
323        let violations = linter.analyze();
324        assert_eq!(1, violations.len()); // Only the second line should violate (multiple spaces after second >)
325
326        // Verify the violation is on the correct line
327        let violation = &violations[0];
328        assert_eq!("MD027", violation.rule().id);
329        assert_eq!(1, violation.location().range.start.line); // Line 2 (0-indexed)
330    }
331
332    #[test]
333    fn test_blockquote_with_leading_spaces() {
334        let input = "  >  Text with multiple spaces after >";
335
336        let config = test_config();
337        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
338        let violations = linter.analyze();
339        assert_eq!(1, violations.len());
340    }
341
342    #[test]
343    fn test_ordered_list_in_blockquote() {
344        let input = ">  1. Item with multiple spaces";
345
346        // With list_items = true (default), should violate
347        let config =
348            test_config_with_blockquote_spaces(MD027BlockquoteSpacesTable { list_items: true });
349        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
350        let violations = linter.analyze();
351        assert_eq!(1, violations.len());
352
353        // With list_items = false, should not violate
354        let config =
355            test_config_with_blockquote_spaces(MD027BlockquoteSpacesTable { list_items: false });
356        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
357        let violations = linter.analyze();
358        assert_eq!(0, violations.len());
359    }
360
361    #[test]
362    fn test_edge_cases() {
363        // Empty blockquote with multiple spaces
364        let input1 = ">  ";
365        let config = test_config();
366        let mut linter =
367            MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config.clone(), input1);
368        let violations = linter.analyze();
369        assert_eq!(1, violations.len());
370
371        // Blockquote with only one space (should not violate)
372        let input2 = "> ";
373        let mut linter =
374            MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config.clone(), input2);
375        let violations = linter.analyze();
376        assert_eq!(0, violations.len());
377
378        // Blockquote with no space (should not violate)
379        let input3 = ">";
380        let mut linter =
381            MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input3);
382        let violations = linter.analyze();
383        assert_eq!(0, violations.len());
384    }
385
386    #[test]
387    fn test_mixed_content() {
388        let input = r#"> Good blockquote
389>  Bad blockquote with multiple spaces
390> Another good one
391>   Another bad one with three spaces"#;
392
393        let config = test_config();
394        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
395        let violations = linter.analyze();
396        assert_eq!(2, violations.len()); // Two lines should violate
397    }
398
399    /// Test corner cases discovered during parity validation
400    mod corner_cases {
401        use super::*;
402
403        #[test]
404        fn test_empty_blockquote_with_trailing_spaces() {
405            // Test empty blockquotes with different amounts of trailing spaces
406            let input = r#">  
407>   
408>    "#;
409
410            let config = test_config();
411            let mut linter =
412                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
413            let violations = linter.analyze();
414
415            // All three lines should violate - empty blockquotes with multiple spaces
416            assert_eq!(3, violations.len());
417
418            // Verify line numbers
419            let line_numbers: Vec<usize> = violations
420                .iter()
421                .map(|v| v.location().range.start.line + 1)
422                .collect();
423            assert_eq!(vec![1, 2, 3], line_numbers);
424        }
425
426        #[test]
427        fn test_blockquote_with_no_space_after_gt() {
428            // Test blockquotes with no space after > (should not violate)
429            let input = r#">No space after gt
430>Another line without space
431>>Nested without space"#;
432
433            let config = test_config();
434            let mut linter =
435                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
436            let violations = linter.analyze();
437            assert_eq!(0, violations.len()); // No violations expected
438        }
439
440        #[test]
441        fn test_complex_nested_blockquotes_with_violations() {
442            // Test complex nesting patterns that were found in parity validation
443            let input = r#"> > > All correct
444>>  > Middle violation
445> >>  Last violation
446> > >  All positions violation"#;
447
448            let config = test_config();
449            let mut linter =
450                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
451            let violations = linter.analyze();
452
453            // Should find violations on lines 2, 3, and 4
454            assert_eq!(3, violations.len());
455
456            let line_numbers: Vec<usize> = violations
457                .iter()
458                .map(|v| v.location().range.start.line + 1)
459                .collect();
460            assert_eq!(vec![2, 3, 4], line_numbers);
461        }
462
463        #[test]
464        fn test_list_items_with_different_markers() {
465            // Test all different list item markers in blockquotes
466            let input = r#">  - Dash list item
467>   + Plus list item  
468>    * Asterisk list item
469>     1. Ordered list item
470>      2) Parenthesis ordered item"#;
471
472            // Test with list_items = true (should violate all)
473            let config =
474                test_config_with_blockquote_spaces(MD027BlockquoteSpacesTable { list_items: true });
475            let mut linter =
476                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
477            let violations = linter.analyze();
478            assert_eq!(5, violations.len());
479
480            // Test with list_items = false (should violate none)
481            let config = test_config_with_blockquote_spaces(MD027BlockquoteSpacesTable {
482                list_items: false,
483            });
484            let mut linter =
485                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
486            let violations = linter.analyze();
487            assert_eq!(0, violations.len());
488        }
489
490        #[test]
491        fn test_malformed_list_items_in_blockquotes() {
492            // Test malformed list items (missing space after marker)
493            let input = r#">  -No space after dash
494>   +No space after plus
495>    *No space after asterisk
496>     1.No space after number"#;
497
498            // These should violate even with list_items = false because they're not proper list items
499            let config = test_config_with_blockquote_spaces(MD027BlockquoteSpacesTable {
500                list_items: false,
501            });
502            let mut linter =
503                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
504            let violations = linter.analyze();
505            assert_eq!(4, violations.len()); // All should violate as they're not proper list items
506        }
507
508        #[test]
509        fn test_blockquotes_with_leading_whitespace_variations() {
510            // Test different amounts of leading whitespace before blockquotes
511            let input = r#" >  One leading space
512  >   Two leading spaces  
513   >    Three leading spaces
514    >     Four leading spaces"#;
515
516            let config = test_config();
517            let mut linter =
518                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
519            let violations = linter.analyze();
520            assert_eq!(4, violations.len()); // All should violate
521        }
522
523        #[test]
524        fn test_fenced_code_blocks_with_blockquote_syntax() {
525            // Test that fenced code blocks are properly excluded
526            let input = r#"```
527>  This should be ignored
528>   Multiple spaces in fenced block
529>    Should not trigger violations
530```
531
532    >  This should also be ignored
533    >   Indented code block with blockquote syntax
534    >    Multiple lines
535
536> But this should violate
537>  And this too"#;
538
539            let config = test_config();
540            let mut linter =
541                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
542            let violations = linter.analyze();
543            // Only the line with multiple spaces should violate (outside code blocks)
544            assert_eq!(1, violations.len());
545
546            let line_numbers: Vec<usize> = violations
547                .iter()
548                .map(|v| v.location().range.start.line + 1)
549                .collect();
550            assert!(line_numbers.contains(&12)); // ">  And this too" (line 12 has multiple spaces)
551        }
552
553        #[test]
554        fn test_edge_case_single_gt_symbol() {
555            // Test just a single > symbol with various space patterns
556            let input = r#">
557> 
558>  
559>   "#;
560
561            let config = test_config();
562            let mut linter =
563                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
564            let violations = linter.analyze();
565            // Lines 1 and 2 should not violate (0 and 1 space respectively)
566            // Lines 3 and 4 should violate (2 and 3 spaces respectively)
567            assert_eq!(2, violations.len());
568
569            let line_numbers: Vec<usize> = violations
570                .iter()
571                .map(|v| v.location().range.start.line + 1)
572                .collect();
573            assert_eq!(vec![3, 4], line_numbers);
574        }
575
576        #[test]
577        fn test_column_position_accuracy() {
578            // Test that column positions are reported correctly
579            let input = r#">  Two spaces
580 >   Leading space plus three
581  >    Two leading plus four"#;
582
583            let config = test_config();
584            let mut linter =
585                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
586            let violations = linter.analyze();
587
588            assert_eq!(3, violations.len());
589
590            // Check column positions
591            let columns: Vec<usize> = violations
592                .iter()
593                .map(|v| v.location().range.start.character + 1) // Convert to 1-based
594                .collect();
595
596            // Expected columns where violations start (after > and first space)
597            assert_eq!(vec![3, 4, 5], columns); // 1-based column numbers
598        }
599
600        #[test]
601        fn test_very_deeply_nested_blockquotes() {
602            // Test deeply nested blockquotes
603            let input = r#"> > > > > Level 5
604>>>>>>  Level 6 with violation  
605> > > > >  Level 5 with violation
606> > > > > > Level 6 correct"#;
607
608            let config = test_config();
609            let mut linter =
610                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
611            let violations = linter.analyze();
612            // Should find violations on lines with extra spaces
613            assert_eq!(2, violations.len());
614        }
615
616        #[test]
617        fn test_blockquote_followed_by_inline_code() {
618            // Test blockquotes with inline code that might confuse parsing
619            let input = r#">  This has `code` with multiple spaces
620> This has `code` with correct spacing
621>   This has `more code` with violation"#;
622
623            let config = test_config();
624            let mut linter =
625                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
626            let violations = linter.analyze();
627            assert_eq!(2, violations.len()); // Lines 1 and 3 should violate
628        }
629
630        #[test]
631        fn test_unicode_content_in_blockquotes() {
632            // Test blockquotes with unicode content
633            let input = r#">  Unicode: 你好世界
634> Unicode correct: 你好世界  
635>   More unicode: こんにちは"#;
636
637            let config = test_config();
638            let mut linter =
639                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
640            let violations = linter.analyze();
641            assert_eq!(2, violations.len()); // Lines 1 and 3 should violate
642        }
643
644        #[test]
645        fn test_blockquote_with_html_entities() {
646            // Test blockquotes containing HTML entities
647            let input = r#">  This has &amp; entity
648> This has &copy; correct
649>   This has &lt; violation"#;
650
651            let config = test_config();
652            let mut linter =
653                MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
654            let violations = linter.analyze();
655            assert_eq!(2, violations.len());
656        }
657
658        /// Tests that are expected to fail due to known differences with markdownlint
659        ///
660        /// These tests document cases where our implementation differs from markdownlint.
661        /// They serve as:
662        /// 1. Documentation of current limitations
663        /// 2. Regression tests for future improvements
664        /// 3. Clear specification of expected behavior differences
665        ///
666        /// As of current implementation: 14 tests fail, 44 tests pass
667        /// This represents excellent coverage with clear documentation of edge cases
668        mod known_differences {
669            use super::*;
670
671            #[test]
672            fn test_micromark_vs_tree_sitter_parsing_differences() {
673                // This test documents cases where tree-sitter and micromark parse differently
674                // Leading to different behavior between quickmark and markdownlint
675
676                // Example case where parsing might differ
677                let input = r#"> > Text
678> >  Text with spaces that might be parsed differently"#;
679
680                let config = test_config();
681                let mut linter =
682                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
683                let violations = linter.analyze();
684
685                // This assertion might fail if our parsing differs from markdownlint
686                // The exact expected count would need to be determined by running both linters
687                assert_eq!(
688                    1,
689                    violations.len(),
690                    "Tree-sitter parsing might differ from micromark"
691                );
692            }
693
694            #[test]
695            fn test_complex_nested_list_detection_limitation() {
696                // This documents a case where our list item detection might be less sophisticated
697                // than markdownlint's AST-based detection
698
699                let input = r#">  1. Item
700>     a. Sub-item that might not be detected as list"#;
701
702                let config = test_config_with_blockquote_spaces(MD027BlockquoteSpacesTable {
703                    list_items: false,
704                });
705                let mut linter =
706                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
707                let violations = linter.analyze();
708
709                // Our regex-based detection might miss complex nested lists
710                // that markdownlint's AST-based detection would catch
711                assert_eq!(
712                    0,
713                    violations.len(),
714                    "Complex nested list detection may differ"
715                );
716            }
717
718            #[test]
719            fn test_edge_case_with_mixed_blockquote_styles() {
720                // This documents an edge case where behavior might differ
721                let input = r#"> Normal blockquote
722>  > Mixed style that might confuse our parser
723>> Different nesting style"#;
724
725                let config = test_config();
726                let mut linter =
727                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
728                let violations = linter.analyze();
729
730                // The exact behavior in this edge case might differ - second line should violate
731                assert_eq!(
732                    1,
733                    violations.len(),
734                    "This will fail - edge case behavior difference"
735                );
736            }
737
738            #[test]
739            fn test_tab_characters_in_blockquotes() {
740                // Test how tab characters are handled in blockquotes
741                // This might differ between our implementation and markdownlint
742                let input = ">\t\tText with tabs after blockquote";
743
744                let config = test_config();
745                let mut linter =
746                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
747                let violations = linter.analyze();
748
749                // markdownlint might handle tabs differently than our space-based detection
750                assert_eq!(
751                    0,
752                    violations.len(),
753                    "Tab handling might differ from markdownlint"
754                );
755            }
756
757            #[test]
758            fn test_mixed_spaces_and_tabs_in_blockquotes() {
759                // Test mixed spaces and tabs which might be parsed differently
760                let input = r#"> 	Text with space then tab
761>	 Text with tab then space"#;
762
763                let config = test_config();
764                let mut linter =
765                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
766                let violations = linter.analyze();
767
768                // Our space-counting logic might not match markdownlint's tab handling
769                assert_eq!(
770                    0,
771                    violations.len(),
772                    "Mixed space/tab handling likely differs"
773                );
774            }
775
776            #[test]
777            fn test_zero_width_characters_in_blockquotes() {
778                // Test zero-width characters that might affect parsing
779                let input = ">  Text with zero-width space\u{200B}";
780
781                let config = test_config();
782                let mut linter =
783                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
784                let violations = linter.analyze();
785
786                // Zero-width characters might be handled differently - should violate due to 2 spaces
787                assert_eq!(
788                    1,
789                    violations.len(),
790                    "Zero-width character handling might differ"
791                );
792            }
793
794            #[test]
795            fn test_blockquote_with_continuation_lines() {
796                // Test blockquotes with line continuation that might be parsed differently
797                let input = r#"> This is a long line \
798>  that continues on next line
799> This is normal"#;
800
801                let config = test_config();
802                let mut linter =
803                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
804                let violations = linter.analyze();
805
806                // Line continuation handling might differ - second line should violate
807                assert_eq!(
808                    1,
809                    violations.len(),
810                    "Line continuation parsing might differ"
811                );
812            }
813
814            #[test]
815            fn test_blockquote_inside_html_comments() {
816                // Test blockquotes inside HTML comments
817                let input = r#"<!--
818>  This blockquote is inside a comment
819>   Multiple spaces here
820-->"#;
821
822                let config = test_config();
823                let mut linter =
824                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
825                let violations = linter.analyze();
826
827                // HTML comment parsing might differ between implementations
828                assert_eq!(
829                    0,
830                    violations.len(),
831                    "HTML comment content handling might differ"
832                );
833            }
834
835            #[test]
836            fn test_blockquote_with_reference_links() {
837                // Test blockquotes containing reference links that might affect parsing
838                let input = r#">  See [this link][ref] for more info
839>   Another [reference link][ref2]
840
841[ref]: http://example.com
842[ref2]: http://example.org"#;
843
844                let config = test_config();
845                let mut linter =
846                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
847                let violations = linter.analyze();
848
849                // Reference link parsing context might affect blockquote detection - both lines should violate
850                assert_eq!(
851                    2,
852                    violations.len(),
853                    "Reference link interaction might differ"
854                );
855            }
856
857            #[test]
858            fn test_blockquote_with_autolinks() {
859                // Test blockquotes with autolinks that might be parsed differently
860                let input = r#">  Visit <http://example.com> for info
861>   Another autolink: <mailto:test@example.com>"#;
862
863                let config = test_config();
864                let mut linter =
865                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
866                let violations = linter.analyze();
867
868                // Autolink parsing might affect space detection - both lines should violate
869                assert_eq!(
870                    2,
871                    violations.len(),
872                    "Autolink parsing interaction might differ"
873                );
874            }
875
876            #[test]
877            fn test_blockquote_in_table_cells() {
878                // Test blockquotes inside table cells (if supported)
879                let input = r#"| Column 1 | Column 2 |
880|----------|----------|
881| >  Quote | Normal   |
882| >   More | Text     |"#;
883
884                let config = test_config();
885                let mut linter =
886                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
887                let violations = linter.analyze();
888
889                // Table parsing context might affect blockquote detection
890                assert_eq!(0, violations.len(), "Table context parsing might differ");
891            }
892
893            #[test]
894            fn test_blockquote_with_footnotes() {
895                // Test blockquotes with footnotes (if supported)
896                let input = r#">  This has a footnote[^1]
897>   Another footnote reference[^note]
898
899[^1]: Footnote text
900[^note]: Another footnote"#;
901
902                let config = test_config();
903                let mut linter =
904                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
905                let violations = linter.analyze();
906
907                // Footnote parsing might affect detection - both blockquote lines should violate
908                assert_eq!(
909                    2,
910                    violations.len(),
911                    "Footnote parsing interaction might differ"
912                );
913            }
914
915            #[test]
916            fn test_complex_whitespace_patterns() {
917                // Test complex whitespace patterns that might be interpreted differently
918                let input = r#">   	  Mixed spaces and tabs
919> 	 	Tab sandwich
920>     		Trailing tab after spaces"#;
921
922                let config = test_config();
923                let mut linter =
924                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
925                let violations = linter.analyze();
926
927                // Complex whitespace handling might differ significantly
928                assert_eq!(
929                    0,
930                    violations.len(),
931                    "Complex whitespace patterns might differ"
932                );
933            }
934
935            #[test]
936            fn test_blockquote_with_math_expressions() {
937                // Test blockquotes with math expressions (if supported)
938                let input = r#">  Math inline: $x^2 + y^2 = z^2$
939>   Display math: $$\sum_{i=1}^n i = \frac{n(n+1)}{2}$$"#;
940
941                let config = test_config();
942                let mut linter =
943                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
944                let violations = linter.analyze();
945
946                // Math expression parsing might affect space detection - both lines should violate
947                assert_eq!(2, violations.len(), "Math expression parsing might differ");
948            }
949
950            #[test]
951            fn test_blockquote_line_ending_variations() {
952                // Test different line ending styles
953                let input_crlf = ">  Windows CRLF line\r\n>   Another CRLF line\r\n";
954                let input_lf = ">  Unix LF line\n>   Another LF line\n";
955
956                let config = test_config();
957
958                // Test CRLF
959                let mut linter = MultiRuleLinter::new_for_document(
960                    PathBuf::from("test.md"),
961                    config.clone(),
962                    input_crlf,
963                );
964                let violations_crlf = linter.analyze();
965
966                // Test LF
967                let mut linter =
968                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input_lf);
969                let violations_lf = linter.analyze();
970
971                // Line ending handling might affect parsing
972                assert_eq!(
973                    violations_crlf.len(),
974                    violations_lf.len(),
975                    "Line ending handling might differ"
976                );
977            }
978        }
979
980        /// Tests for performance edge cases
981        mod performance_edge_cases {
982            use super::*;
983
984            #[test]
985            fn test_very_long_line_in_blockquote() {
986                // Test performance with very long lines
987                let long_content = "a".repeat(10000);
988                let input = format!(">  {long_content}");
989
990                let config = test_config();
991                let mut linter =
992                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
993                let violations = linter.analyze();
994                assert_eq!(1, violations.len()); // Should still detect the violation
995            }
996
997            #[test]
998            fn test_many_nested_blockquotes() {
999                // Test performance with many levels of nesting
1000                let mut input = String::new();
1001                for i in 0..100 {
1002                    let prefix = ">".repeat(i + 1);
1003                    if i % 10 == 0 {
1004                        input.push_str(&format!("{prefix}  Line {i} with violation\n"));
1005                    } else {
1006                        input.push_str(&format!("{prefix} Line {i} correct\n"));
1007                    }
1008                }
1009
1010                let config = test_config();
1011                let mut linter =
1012                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
1013                let violations = linter.analyze();
1014                assert_eq!(10, violations.len()); // Should find 10 violations (every 10th line)
1015            }
1016
1017            #[test]
1018            fn test_many_lines_with_blockquotes() {
1019                // Test performance with many lines
1020                let mut input = String::new();
1021                for i in 0..1000 {
1022                    if i % 2 == 0 {
1023                        input.push_str(&format!(">  Line {i} with violation\n"));
1024                    } else {
1025                        input.push_str(&format!("> Line {i} correct\n"));
1026                    }
1027                }
1028
1029                let config = test_config();
1030                let mut linter =
1031                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
1032                let violations = linter.analyze();
1033                assert_eq!(500, violations.len()); // Should find 500 violations (every other line)
1034            }
1035        }
1036
1037        /// Additional edge cases discovered during implementation
1038        mod additional_edge_cases {
1039            use super::*;
1040
1041            #[test]
1042            fn test_blockquote_with_escaped_characters() {
1043                // Test blockquotes with escaped characters
1044                let input = r#">  Text with \> escaped gt
1045>   Text with \* escaped asterisk
1046>    Text with \\ escaped backslash"#;
1047
1048                let config = test_config();
1049                let mut linter =
1050                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1051                let violations = linter.analyze();
1052                assert_eq!(3, violations.len()); // All should violate regardless of escaped chars
1053            }
1054
1055            #[test]
1056            fn test_blockquote_with_setext_headings() {
1057                // Test blockquotes containing setext-style headings
1058                let input = r#">  Heading Level 1
1059>  ================
1060>   Heading Level 2
1061>   ----------------"#;
1062
1063                let config = test_config();
1064                let mut linter =
1065                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1066                let violations = linter.analyze();
1067                assert_eq!(4, violations.len()); // All lines should violate
1068            }
1069
1070            #[test]
1071            fn test_blockquote_with_horizontal_rules() {
1072                // Test blockquotes containing horizontal rules
1073                let input = r#">  Text before rule
1074>   ---
1075>    Text after rule
1076>     ***"#;
1077
1078                let config = test_config();
1079                let mut linter =
1080                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1081                let violations = linter.analyze();
1082                assert_eq!(4, violations.len()); // All should violate
1083            }
1084
1085            #[test]
1086            fn test_blockquote_with_atx_headings() {
1087                // Test blockquotes containing ATX headings
1088                let input = r#">  # Heading 1
1089>   ## Heading 2
1090>    ### Heading 3 ###"#;
1091
1092                let config = test_config();
1093                let mut linter =
1094                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1095                let violations = linter.analyze();
1096                assert_eq!(3, violations.len()); // All should violate
1097            }
1098
1099            #[test]
1100            fn test_blockquote_with_definition_lists() {
1101                // Test blockquotes with definition list syntax (if supported)
1102                let input = r#">  Term 1
1103>   : Definition 1
1104>    Term 2
1105>     : Definition 2"#;
1106
1107                let config = test_config();
1108                let mut linter =
1109                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1110                let violations = linter.analyze();
1111                assert_eq!(4, violations.len()); // All should violate
1112            }
1113
1114            #[test]
1115            fn test_blockquote_with_line_breaks() {
1116                // Test blockquotes with explicit line breaks
1117                let input = r#">  Line with two spaces at end  
1118>   Line with backslash at end\
1119>    Normal line"#;
1120
1121                let config = test_config();
1122                let mut linter =
1123                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1124                let violations = linter.analyze();
1125                assert_eq!(3, violations.len()); // All should violate
1126            }
1127
1128            #[test]
1129            fn test_blockquote_with_emphasis_variations() {
1130                // Test blockquotes with various emphasis styles
1131                let input = r#">  Text with *emphasis*
1132>   Text with **strong**
1133>    Text with ***strong emphasis***
1134>     Text with _underscore emphasis_
1135>      Text with __strong underscore__"#;
1136
1137                let config = test_config();
1138                let mut linter =
1139                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1140                let violations = linter.analyze();
1141                assert_eq!(5, violations.len()); // All should violate
1142            }
1143
1144            #[test]
1145            fn test_blockquote_with_strikethrough() {
1146                // Test blockquotes with strikethrough text (if supported)
1147                let input = r#">  Text with ~~strikethrough~~
1148>   More ~~deleted~~ text"#;
1149
1150                let config = test_config();
1151                let mut linter =
1152                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1153                let violations = linter.analyze();
1154                assert_eq!(2, violations.len()); // Both should violate
1155            }
1156
1157            #[test]
1158            fn test_blockquote_with_multiple_code_spans() {
1159                // Test blockquotes with multiple inline code spans
1160                let input = r#">  Code `one` and `two` and `three`
1161>   More `code` with `spans`"#;
1162
1163                let config = test_config();
1164                let mut linter =
1165                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1166                let violations = linter.analyze();
1167                assert_eq!(2, violations.len()); // Both should violate
1168            }
1169
1170            #[test]
1171            fn test_blockquote_with_nested_quotes() {
1172                // Test blockquotes with nested quote characters
1173                let input = r#">  He said "Hello" to me
1174>   She replied 'Goodbye' back
1175>    Mixed "quotes' in text"#;
1176
1177                let config = test_config();
1178                let mut linter =
1179                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1180                let violations = linter.analyze();
1181                assert_eq!(3, violations.len()); // All should violate
1182            }
1183
1184            #[test]
1185            fn test_blockquote_with_numeric_entities() {
1186                // Test blockquotes with numeric character entities
1187                let input = r#">  Text with &#39; apostrophe
1188>   Text with &#34; quote
1189>    Text with &#8594; arrow"#;
1190
1191                let config = test_config();
1192                let mut linter =
1193                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1194                let violations = linter.analyze();
1195                assert_eq!(3, violations.len()); // All should violate
1196            }
1197
1198            #[test]
1199            fn test_blockquote_with_emoji_unicode() {
1200                // Test blockquotes with emoji unicode characters
1201                let input = r#">  Text with emoji 😀
1202>   More emoji 🎉 and 🚀
1203>    Unicode symbols ♠ ♥ ♦ ♣"#;
1204
1205                let config = test_config();
1206                let mut linter =
1207                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1208                let violations = linter.analyze();
1209                assert_eq!(3, violations.len()); // All should violate
1210            }
1211
1212            #[test]
1213            fn test_blockquote_with_non_breaking_spaces() {
1214                // Test blockquotes with non-breaking spaces (U+00A0)
1215                let input = ">  Text with non-breaking\u{00A0}space";
1216
1217                let config = test_config();
1218                let mut linter =
1219                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1220                let violations = linter.analyze();
1221                assert_eq!(1, violations.len()); // Should violate
1222            }
1223
1224            #[test]
1225            fn test_blockquote_boundary_conditions() {
1226                // Test boundary conditions for space counting
1227                let input = r#">
1228> 
1229>  
1230>   
1231>    
1232>     
1233"#;
1234
1235                let config = test_config();
1236                let mut linter =
1237                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1238                let violations = linter.analyze();
1239                // Lines with 2+ spaces should violate (lines 3, 4, 5, 6)
1240                assert_eq!(4, violations.len());
1241
1242                let line_numbers: Vec<usize> = violations
1243                    .iter()
1244                    .map(|v| v.location().range.start.line + 1)
1245                    .collect();
1246                assert_eq!(vec![3, 4, 5, 6], line_numbers);
1247            }
1248
1249            #[test]
1250            fn test_list_item_edge_cases_with_spaces() {
1251                // Test edge cases for list item detection with various spacing
1252                let input = r#">  1.Item without space after number
1253>   2. Item with space
1254>    10. Double digit number
1255>     100. Triple digit number
1256>      a. Letter list item
1257>       A. Capital letter list item"#;
1258
1259                // Test with list_items = false
1260                let config = test_config_with_blockquote_spaces(MD027BlockquoteSpacesTable {
1261                    list_items: false,
1262                });
1263                let mut linter =
1264                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1265                let violations = linter.analyze();
1266
1267                // Only proper list items (with space after marker) should be skipped
1268                // Line 1: "1.Item" - no space, should violate
1269                // Line 2: "2. Item" - proper list, should not violate
1270                // Line 3: "10. Double" - proper list, should not violate
1271                // Line 4: "100. Triple" - proper list, should not violate
1272                // Line 5: "a. Letter" - proper list, should not violate
1273                // Line 6: "A. Capital" - proper list, should not violate
1274                assert_eq!(1, violations.len()); // Only line 1 should violate
1275            }
1276
1277            #[test]
1278            fn test_ordered_list_parenthesis_variations() {
1279                // Test ordered lists with parenthesis instead of period
1280                let input = r#">  1) Item with parenthesis
1281>   2) Another item
1282>    10) Double digit with paren
1283>     a) Letter with paren
1284>      A) Capital with paren"#;
1285
1286                let config = test_config_with_blockquote_spaces(MD027BlockquoteSpacesTable {
1287                    list_items: false,
1288                });
1289                let mut linter =
1290                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1291                let violations = linter.analyze();
1292                assert_eq!(0, violations.len()); // All should be recognized as list items
1293            }
1294
1295            #[test]
1296            fn test_unordered_list_marker_variations() {
1297                // Test all unordered list marker variations
1298                let input = r#">  - Dash marker
1299>   + Plus marker
1300>    * Asterisk marker
1301>     -Item without space
1302>      +Item without space
1303>       *Item without space"#;
1304
1305                let config = test_config_with_blockquote_spaces(MD027BlockquoteSpacesTable {
1306                    list_items: false,
1307                });
1308                let mut linter =
1309                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1310                let violations = linter.analyze();
1311                // Lines 4, 5, 6 don't have space after marker, so should violate
1312                assert_eq!(3, violations.len());
1313            }
1314
1315            #[test]
1316            fn test_mixed_content_complex_nesting() {
1317                // Test complex mixed content scenarios
1318                let input = r#"> Normal text
1319>  Text with violation
1320> > Nested blockquote correct
1321> >  Nested blockquote violation
1322> > > Triple nested correct
1323> > >  Triple nested violation
1324>  Back to single level violation
1325> Back to single level correct"#;
1326
1327                let config = test_config();
1328                let mut linter =
1329                    MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1330                let violations = linter.analyze();
1331                // Lines 2, 4, 6, 7 should violate
1332                assert_eq!(4, violations.len());
1333            }
1334        }
1335    }
1336}