quickmark_core/rules/
md054.rs

1use serde::Deserialize;
2use std::collections::HashSet;
3use std::rc::Rc;
4
5use once_cell::sync::Lazy;
6use regex::Regex;
7use tree_sitter::Node;
8
9use crate::{
10    linter::{range_from_tree_sitter, RuleViolation},
11    rules::{Context, Rule, RuleLinter, RuleType},
12};
13
14// MD054-specific configuration types
15#[derive(Debug, PartialEq, Clone, Deserialize)]
16pub struct MD054LinkImageStyleTable {
17    #[serde(default)]
18    pub autolink: bool,
19    #[serde(default)]
20    pub inline: bool,
21    #[serde(default)]
22    pub full: bool,
23    #[serde(default)]
24    pub collapsed: bool,
25    #[serde(default)]
26    pub shortcut: bool,
27    #[serde(default)]
28    pub url_inline: bool,
29}
30
31impl Default for MD054LinkImageStyleTable {
32    fn default() -> Self {
33        Self {
34            autolink: true,
35            inline: true,
36            full: true,
37            collapsed: true,
38            shortcut: true,
39            url_inline: true,
40        }
41    }
42}
43
44// Combined regular expressions for detecting different link and image styles.
45// This improves performance by reducing the number of passes over the text.
46// Groups are used to differentiate between image and link matches.
47
48// Style: [text](url)
49static RE_INLINE: Lazy<Regex> = Lazy::new(|| {
50    Regex::new(r"(!\[([^\]]*)\]\(([^)]*)\))|((?:^|[^!])\[([^\]]*)\]\(([^)]*)\))").unwrap()
51});
52
53// Style: [text][ref]
54static RE_FULL_REFERENCE: Lazy<Regex> = Lazy::new(|| {
55    Regex::new(r"(!\[([^\]]*)\]\[([^\]]+)\])|((?:^|[^!])\[([^\]]*)\]\[([^\]]+)\])").unwrap()
56});
57
58// Style: [ref][]
59static RE_COLLAPSED_REFERENCE: Lazy<Regex> =
60    Lazy::new(|| Regex::new(r"(!\[([^\]]+)\]\[\])|((?:^|[^!])\[([^\]]+)\]\[\])").unwrap());
61
62// Style: [ref]
63static RE_SHORTCUT_REFERENCE: Lazy<Regex> =
64    Lazy::new(|| Regex::new(r"(!\[([^\]]+)\])|((?:^|[^!])\[([^\]]+)\])").unwrap());
65
66// Style: <url>
67static RE_AUTOLINK: Lazy<Regex> = Lazy::new(|| Regex::new(r"<(https?://[^>]+)>").unwrap());
68
69/// MD054 - Link and image style
70///
71/// This rule controls which styles of links and images are allowed in the document.
72pub(crate) struct MD054Linter {
73    context: Rc<Context>,
74    violations: Vec<RuleViolation>,
75}
76
77impl MD054Linter {
78    pub fn new(context: Rc<Context>) -> Self {
79        Self {
80            context,
81            violations: Vec::new(),
82        }
83    }
84}
85
86impl RuleLinter for MD054Linter {
87    fn feed(&mut self, node: &Node) {
88        if node.kind() == "inline" {
89            self.check_inline_content(node);
90        }
91    }
92
93    fn finalize(&mut self) -> Vec<RuleViolation> {
94        std::mem::take(&mut self.violations)
95    }
96}
97
98impl MD054Linter {
99    fn check_inline_content(&mut self, node: &Node) {
100        let content = {
101            let document_content = self.context.document_content.borrow();
102            node.utf8_text(document_content.as_bytes())
103                .unwrap_or("")
104                .to_string()
105        };
106
107        if !content.is_empty() {
108            self.check_content_for_violations(&content, node);
109        }
110    }
111
112    fn check_content_for_violations(&mut self, content: &str, node: &Node) {
113        let config = self
114            .context
115            .config
116            .linters
117            .settings
118            .link_image_style
119            .clone();
120        let mut found_violations = HashSet::new();
121
122        // Check for autolinks
123        if !config.autolink {
124            for caps in RE_AUTOLINK.captures_iter(content) {
125                let start = caps.get(0).unwrap().start();
126                if found_violations.insert(("autolink", start)) {
127                    self.create_violation_at_offset(
128                        node,
129                        content,
130                        start,
131                        "Autolinks are not allowed".to_string(),
132                    );
133                }
134            }
135        }
136
137        // Check for inline style
138        for caps in RE_INLINE.captures_iter(content) {
139            // Group 1: Image match `![]()`
140            if let Some(image_match) = caps.get(1) {
141                if !config.inline && found_violations.insert(("inline_image", image_match.start()))
142                {
143                    self.create_violation_at_offset(
144                        node,
145                        content,
146                        image_match.start(),
147                        "Inline images are not allowed".to_string(),
148                    );
149                }
150            }
151            // Group 4: Link match `[]()`
152            else if let Some(link_match) = caps.get(4) {
153                let mut start = link_match.start();
154                if !link_match.as_str().starts_with('[') {
155                    start += 1; // Adjust for `(?:^|[^!])`
156                }
157
158                if !config.inline {
159                    if found_violations.insert(("inline_link", start)) {
160                        self.create_violation_at_offset(
161                            node,
162                            content,
163                            start,
164                            "Inline links are not allowed".to_string(),
165                        );
166                    }
167                    continue; // If disallowed, no need for further checks
168                }
169
170                // Check for url_inline: [https://...](https://...)
171                if !config.url_inline {
172                    if let (Some(text), Some(url)) = (caps.get(5), caps.get(6)) {
173                        if text.as_str() == url.as_str()
174                            && found_violations.insert(("url_inline", start))
175                        {
176                            self.create_violation_at_offset(
177                                node,
178                                content,
179                                start,
180                                "Inline links with matching URL text are not allowed".to_string(),
181                            );
182                        }
183                    }
184                }
185            }
186        }
187
188        // Check for full reference style
189        if !config.full {
190            for caps in RE_FULL_REFERENCE.captures_iter(content) {
191                if let Some(image_match) = caps.get(1) {
192                    if found_violations.insert(("full_image", image_match.start())) {
193                        self.create_violation_at_offset(
194                            node,
195                            content,
196                            image_match.start(),
197                            "Full reference images are not allowed".to_string(),
198                        );
199                    }
200                } else if let Some(link_match) = caps.get(4) {
201                    let mut start = link_match.start();
202                    if !link_match.as_str().starts_with('[') {
203                        start += 1;
204                    }
205                    if found_violations.insert(("full_link", start)) {
206                        self.create_violation_at_offset(
207                            node,
208                            content,
209                            start,
210                            "Full reference links are not allowed".to_string(),
211                        );
212                    }
213                }
214            }
215        }
216
217        // Check for collapsed reference style
218        if !config.collapsed {
219            for caps in RE_COLLAPSED_REFERENCE.captures_iter(content) {
220                if let Some(image_match) = caps.get(1) {
221                    if found_violations.insert(("collapsed_image", image_match.start())) {
222                        self.create_violation_at_offset(
223                            node,
224                            content,
225                            image_match.start(),
226                            "Collapsed reference images are not allowed".to_string(),
227                        );
228                    }
229                } else if let Some(link_match) = caps.get(3) {
230                    let mut start = link_match.start();
231                    if !link_match.as_str().starts_with('[') {
232                        start += 1;
233                    }
234                    if found_violations.insert(("collapsed_link", start)) {
235                        self.create_violation_at_offset(
236                            node,
237                            content,
238                            start,
239                            "Collapsed reference links are not allowed".to_string(),
240                        );
241                    }
242                }
243            }
244        }
245
246        // Check for shortcut reference style
247        if !config.shortcut {
248            for caps in RE_SHORTCUT_REFERENCE.captures_iter(content) {
249                let whole_match = caps.get(0).unwrap();
250                let end_offset = whole_match.end();
251
252                // Check character after match to avoid false positives for other link types
253                if end_offset < content.len() {
254                    if let Some(next_char) = content[end_offset..].chars().next() {
255                        if next_char == '(' || next_char == '[' {
256                            continue;
257                        }
258                    }
259                }
260
261                if let Some(image_match) = caps.get(1) {
262                    if found_violations.insert(("shortcut_image", image_match.start())) {
263                        self.create_violation_at_offset(
264                            node,
265                            content,
266                            image_match.start(),
267                            "Shortcut reference images are not allowed".to_string(),
268                        );
269                    }
270                } else if let Some(link_match) = caps.get(3) {
271                    let mut start = link_match.start();
272                    if !link_match.as_str().starts_with('[') {
273                        start += 1;
274                    }
275                    if found_violations.insert(("shortcut_link", start)) {
276                        self.create_violation_at_offset(
277                            node,
278                            content,
279                            start,
280                            "Shortcut reference links are not allowed".to_string(),
281                        );
282                    }
283                }
284            }
285        }
286    }
287
288    fn create_violation_at_offset(
289        &mut self,
290        node: &Node,
291        content: &str,
292        offset: usize,
293        message: String,
294    ) {
295        // Calculate the line number within the content where the violation occurs
296        let lines_before_offset = content[..offset].matches('\n').count();
297        let node_start_row = node.start_position().row;
298        let violation_row = node_start_row + lines_before_offset;
299
300        // Create a custom range for this specific violation
301        let violation_range = tree_sitter::Range {
302            start_byte: node.start_byte() + offset,
303            end_byte: node.start_byte() + offset + 1, // Just mark the start of the violation
304            start_point: tree_sitter::Point {
305                row: violation_row,
306                column: if lines_before_offset == 0 {
307                    node.start_position().column + offset
308                } else {
309                    // Calculate column position for this line
310                    let line_start = content[..offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
311                    offset - line_start
312                },
313            },
314            end_point: tree_sitter::Point {
315                row: violation_row,
316                column: if lines_before_offset == 0 {
317                    node.start_position().column + offset + 1
318                } else {
319                    let line_start = content[..offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
320                    offset - line_start + 1
321                },
322            },
323        };
324
325        self.violations.push(RuleViolation::new(
326            &MD054,
327            message,
328            self.context.file_path.clone(),
329            range_from_tree_sitter(&violation_range),
330        ));
331    }
332}
333
334pub const MD054: Rule = Rule {
335    id: "MD054",
336    alias: "link-image-style",
337    tags: &["links", "images"],
338    description: "Link and image style",
339    rule_type: RuleType::Token,
340    required_nodes: &["inline"],
341    new_linter: |context| Box::new(MD054Linter::new(context)),
342};
343
344#[cfg(test)]
345mod test {
346    use std::path::PathBuf;
347
348    use crate::config::{MD054LinkImageStyleTable, RuleSeverity};
349    use crate::linter::MultiRuleLinter;
350    use crate::test_utils::test_helpers::test_config_with_rules;
351
352    fn test_config() -> crate::config::QuickmarkConfig {
353        test_config_with_rules(vec![
354            ("link-image-style", RuleSeverity::Error),
355            ("heading-style", RuleSeverity::Off),
356            ("heading-increment", RuleSeverity::Off),
357            ("line-length", RuleSeverity::Off),
358        ])
359    }
360
361    fn test_config_with_settings(
362        settings: MD054LinkImageStyleTable,
363    ) -> crate::config::QuickmarkConfig {
364        let mut config = test_config();
365        config.linters.settings.link_image_style = settings;
366        config
367    }
368
369    // Test cases for autolinks
370    #[test]
371    fn test_autolink_allowed() {
372        let input = "<https://example.com>";
373        let config = test_config_with_settings(MD054LinkImageStyleTable {
374            autolink: true,
375            ..Default::default()
376        });
377        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
378        let violations = linter.analyze();
379        assert_eq!(0, violations.len());
380    }
381
382    #[test]
383    fn test_autolink_disallowed() {
384        let input = "<https://example.com>";
385        let config = test_config_with_settings(MD054LinkImageStyleTable {
386            autolink: false,
387            ..Default::default()
388        });
389        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
390        let violations = linter.analyze();
391        assert_eq!(1, violations.len());
392        let violation = &violations[0];
393        assert_eq!("MD054", violation.rule().id);
394        assert!(violation.message().to_lowercase().contains("autolink"));
395    }
396
397    // Test cases for inline links
398    #[test]
399    fn test_inline_link_allowed() {
400        let input = "[example](https://example.com)";
401        let config = test_config_with_settings(MD054LinkImageStyleTable {
402            inline: true,
403            ..Default::default()
404        });
405        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
406        let violations = linter.analyze();
407        assert_eq!(0, violations.len());
408    }
409
410    #[test]
411    fn test_inline_link_disallowed() {
412        let input = "[example](https://example.com)";
413        let config = test_config_with_settings(MD054LinkImageStyleTable {
414            inline: false,
415            ..Default::default()
416        });
417        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
418        let violations = linter.analyze();
419        assert_eq!(1, violations.len());
420        let violation = &violations[0];
421        assert_eq!("MD054", violation.rule().id);
422        assert!(violation.message().to_lowercase().contains("inline"));
423    }
424
425    // Test cases for inline images
426    #[test]
427    fn test_inline_image_allowed() {
428        let input = "![alt text](https://example.com/image.jpg)";
429        let config = test_config_with_settings(MD054LinkImageStyleTable {
430            inline: true,
431            ..Default::default()
432        });
433        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
434        let violations = linter.analyze();
435        assert_eq!(0, violations.len());
436    }
437
438    #[test]
439    fn test_inline_image_disallowed() {
440        let input = "![alt text](https://example.com/image.jpg)";
441        let config = test_config_with_settings(MD054LinkImageStyleTable {
442            inline: false,
443            ..Default::default()
444        });
445        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
446        let violations = linter.analyze();
447        assert_eq!(1, violations.len());
448        let violation = &violations[0];
449        assert_eq!("MD054", violation.rule().id);
450        assert!(violation.message().to_lowercase().contains("inline"));
451    }
452
453    // Test cases for full reference links
454    #[test]
455    fn test_full_reference_link_allowed() {
456        let input = "[example][ref]\n\n[ref]: https://example.com";
457        let config = test_config_with_settings(MD054LinkImageStyleTable {
458            full: true,
459            ..Default::default()
460        });
461        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
462        let violations = linter.analyze();
463        assert_eq!(0, violations.len());
464    }
465
466    #[test]
467    fn test_full_reference_link_disallowed() {
468        let input = "[example][ref]\n\n[ref]: https://example.com";
469        let config = test_config_with_settings(MD054LinkImageStyleTable {
470            full: false,
471            ..Default::default()
472        });
473        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
474        let violations = linter.analyze();
475        assert_eq!(1, violations.len());
476        let violation = &violations[0];
477        assert_eq!("MD054", violation.rule().id);
478        assert!(violation
479            .message()
480            .to_lowercase()
481            .contains("full reference"));
482    }
483
484    // Test cases for full reference images
485    #[test]
486    fn test_full_reference_image_allowed() {
487        let input = "![alt text][ref]\n\n[ref]: https://example.com/image.jpg";
488        let config = test_config_with_settings(MD054LinkImageStyleTable {
489            full: true,
490            ..Default::default()
491        });
492        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
493        let violations = linter.analyze();
494        assert_eq!(0, violations.len());
495    }
496
497    #[test]
498    fn test_full_reference_image_disallowed() {
499        let input = "![alt text][ref]\n\n[ref]: https://example.com/image.jpg";
500        let config = test_config_with_settings(MD054LinkImageStyleTable {
501            full: false,
502            ..Default::default()
503        });
504        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
505        let violations = linter.analyze();
506        assert_eq!(1, violations.len());
507        let violation = &violations[0];
508        assert_eq!("MD054", violation.rule().id);
509        assert!(violation
510            .message()
511            .to_lowercase()
512            .contains("full reference"));
513    }
514
515    // Test cases for collapsed reference links
516    #[test]
517    fn test_collapsed_reference_link_allowed() {
518        let input = "[example][]\n\n[example]: https://example.com";
519        let config = test_config_with_settings(MD054LinkImageStyleTable {
520            collapsed: true,
521            ..Default::default()
522        });
523        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
524        let violations = linter.analyze();
525        assert_eq!(0, violations.len());
526    }
527
528    #[test]
529    fn test_collapsed_reference_link_disallowed() {
530        let input = "[example][]\n\n[example]: https://example.com";
531        let config = test_config_with_settings(MD054LinkImageStyleTable {
532            collapsed: false,
533            ..Default::default()
534        });
535        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
536        let violations = linter.analyze();
537        assert_eq!(1, violations.len());
538        let violation = &violations[0];
539        assert_eq!("MD054", violation.rule().id);
540        assert!(violation
541            .message()
542            .to_lowercase()
543            .contains("collapsed reference"));
544    }
545
546    // Test cases for collapsed reference images
547    #[test]
548    fn test_collapsed_reference_image_allowed() {
549        let input = "![example][]\n\n[example]: https://example.com/image.jpg";
550        let config = test_config_with_settings(MD054LinkImageStyleTable {
551            collapsed: true,
552            ..Default::default()
553        });
554        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
555        let violations = linter.analyze();
556        assert_eq!(0, violations.len());
557    }
558
559    #[test]
560    fn test_collapsed_reference_image_disallowed() {
561        let input = "![example][]\n\n[example]: https://example.com/image.jpg";
562        let config = test_config_with_settings(MD054LinkImageStyleTable {
563            collapsed: false,
564            ..Default::default()
565        });
566        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
567        let violations = linter.analyze();
568        assert_eq!(1, violations.len());
569        let violation = &violations[0];
570        assert_eq!("MD054", violation.rule().id);
571        assert!(violation
572            .message()
573            .to_lowercase()
574            .contains("collapsed reference"));
575    }
576
577    // Test cases for shortcut reference links
578    #[test]
579    fn test_shortcut_reference_link_allowed() {
580        let input = "[example]\n\n[example]: https://example.com";
581        let config = test_config_with_settings(MD054LinkImageStyleTable {
582            shortcut: true,
583            ..Default::default()
584        });
585        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
586        let violations = linter.analyze();
587        assert_eq!(0, violations.len());
588    }
589
590    #[test]
591    fn test_shortcut_reference_link_disallowed() {
592        let input = "[example]\n\n[example]: https://example.com";
593        let config = test_config_with_settings(MD054LinkImageStyleTable {
594            shortcut: false,
595            ..Default::default()
596        });
597        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
598        let violations = linter.analyze();
599        assert_eq!(1, violations.len());
600        let violation = &violations[0];
601        assert_eq!("MD054", violation.rule().id);
602        assert!(violation
603            .message()
604            .to_lowercase()
605            .contains("shortcut reference"));
606    }
607
608    // Test cases for shortcut reference images
609    #[test]
610    fn test_shortcut_reference_image_allowed() {
611        let input = "![example]\n\n[example]: https://example.com/image.jpg";
612        let config = test_config_with_settings(MD054LinkImageStyleTable {
613            shortcut: true,
614            ..Default::default()
615        });
616        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
617        let violations = linter.analyze();
618        assert_eq!(0, violations.len());
619    }
620
621    #[test]
622    fn test_shortcut_reference_image_disallowed() {
623        let input = "![example]\n\n[example]: https://example.com/image.jpg";
624        let config = test_config_with_settings(MD054LinkImageStyleTable {
625            shortcut: false,
626            ..Default::default()
627        });
628        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
629        let violations = linter.analyze();
630        assert_eq!(1, violations.len());
631        let violation = &violations[0];
632        assert_eq!("MD054", violation.rule().id);
633        assert!(violation
634            .message()
635            .to_lowercase()
636            .contains("shortcut reference"));
637    }
638
639    // Test cases for url_inline
640    #[test]
641    fn test_url_inline_link_allowed() {
642        let input = "[https://example.com](https://example.com)";
643        let config = test_config_with_settings(MD054LinkImageStyleTable {
644            url_inline: true,
645            ..Default::default()
646        });
647        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
648        let violations = linter.analyze();
649        assert_eq!(0, violations.len());
650    }
651
652    #[test]
653    fn test_url_inline_link_disallowed() {
654        let input = "[https://example.com](https://example.com)";
655        let config = test_config_with_settings(MD054LinkImageStyleTable {
656            url_inline: false,
657            ..Default::default()
658        });
659        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
660        let violations = linter.analyze();
661        assert_eq!(1, violations.len());
662        let violation = &violations[0];
663        assert_eq!("MD054", violation.rule().id);
664        assert!(violation
665            .message()
666            .to_lowercase()
667            .contains("matching url text"));
668    }
669
670    // Test multiple configuration options disabled
671    #[test]
672    fn test_multiple_styles_disallowed() {
673        let input = r#"
674[inline link](https://example.com)
675<https://example.com>
676[reference][ref]
677
678[ref]: https://example.com
679"#;
680        let config = test_config_with_settings(MD054LinkImageStyleTable {
681            autolink: false,
682            inline: false,
683            full: false,
684            collapsed: true,
685            shortcut: true,
686            url_inline: true,
687        });
688        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
689        let violations = linter.analyze();
690        assert_eq!(3, violations.len()); // Should catch inline, autolink, and full reference
691        for violation in &violations {
692            assert_eq!("MD054", violation.rule().id);
693        }
694    }
695
696    // Test all styles allowed (default)
697    #[test]
698    fn test_all_styles_allowed() {
699        let input = r#"
700[inline link](https://example.com)
701<https://example.com>
702[reference][ref]
703[collapsed][]
704[shortcut]
705[https://example.com](https://example.com)
706
707[ref]: https://example.com
708[collapsed]: https://example.com
709[shortcut]: https://example.com
710"#;
711        let config = test_config(); // Default config allows all
712        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
713        let violations = linter.analyze();
714        assert_eq!(0, violations.len());
715    }
716
717    // Test line number accuracy for multiline content
718    #[test]
719    fn test_line_numbers_multiline_content() {
720        let input = "Here is some text.\n\n[Link 1](https://example.com)\n\nSome more text here.\n\n[Link 2](https://github.com)";
721        let config = test_config_with_settings(MD054LinkImageStyleTable {
722            inline: false,
723            ..Default::default()
724        });
725        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
726        let violations = linter.analyze();
727        assert_eq!(2, violations.len());
728
729        // Verify line numbers match exactly what original markdownlint reports
730        assert_eq!(2, violations[0].location().range.start.line); // Line 3 (0-indexed)
731        assert_eq!(6, violations[1].location().range.start.line); // Line 7 (0-indexed)
732    }
733
734    // Test bracket offset calculation with preceding text
735    #[test]
736    fn test_bracket_offset_with_preceding_text() {
737        let input = "Text [link](url) more text";
738        let config = test_config_with_settings(MD054LinkImageStyleTable {
739            inline: false,
740            ..Default::default()
741        });
742        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
743        let violations = linter.analyze();
744        assert_eq!(1, violations.len());
745
746        // Should point to the '[' character, not any preceding character
747        assert_eq!(5, violations[0].location().range.start.character); // Column should be at start of line (tree-sitter groups text)
748    }
749
750    // Test newline handling in regex patterns
751    #[test]
752    fn test_newline_before_link() {
753        let input = "\n[Link text](https://example.com)\n[GitHub](https://github.com)";
754        let config = test_config_with_settings(MD054LinkImageStyleTable {
755            inline: false,
756            ..Default::default()
757        });
758        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
759        let violations = linter.analyze();
760        assert_eq!(2, violations.len());
761
762        // Line numbers should be correct even with leading newlines
763        assert_eq!(1, violations[0].location().range.start.line); // Second line (0-indexed)
764        assert_eq!(2, violations[1].location().range.start.line); // Third line (0-indexed)
765    }
766
767    // Test multiple links on same line
768    #[test]
769    fn test_multiple_links_same_line() {
770        let input = "[Link1](url1) and [Link2](url2)";
771        let config = test_config_with_settings(MD054LinkImageStyleTable {
772            inline: false,
773            ..Default::default()
774        });
775        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
776        let violations = linter.analyze();
777        assert_eq!(2, violations.len());
778
779        // Both violations should be on line 1
780        assert_eq!(0, violations[0].location().range.start.line);
781        assert_eq!(0, violations[1].location().range.start.line);
782    }
783
784    // Test reference link bracket offset calculation
785    #[test]
786    fn test_reference_link_bracket_offset() {
787        let input = "Text [ref link][reference] more";
788        let config = test_config_with_settings(MD054LinkImageStyleTable {
789            full: false,
790            ..Default::default()
791        });
792        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
793        let violations = linter.analyze();
794        assert_eq!(1, violations.len());
795
796        // Should correctly identify the full reference link
797        assert!(violations[0].message().contains("Full reference"));
798    }
799
800    // Test collapsed reference bracket offset
801    #[test]
802    fn test_collapsed_reference_bracket_offset() {
803        let input = "Text [collapsed][] more text";
804        let config = test_config_with_settings(MD054LinkImageStyleTable {
805            collapsed: false,
806            ..Default::default()
807        });
808        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
809        let violations = linter.analyze();
810        assert_eq!(1, violations.len());
811
812        // Should correctly identify the collapsed reference link
813        assert!(violations[0].message().contains("Collapsed reference"));
814    }
815
816    // Test shortcut reference bracket offset
817    #[test]
818    fn test_shortcut_reference_bracket_offset() {
819        let input = "Text [shortcut] more text\n\n[shortcut]: https://example.com";
820        let config = test_config_with_settings(MD054LinkImageStyleTable {
821            shortcut: false,
822            ..Default::default()
823        });
824        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
825        let violations = linter.analyze();
826        assert_eq!(1, violations.len());
827
828        // Should correctly identify the shortcut reference link
829        assert!(violations[0].message().contains("Shortcut reference"));
830    }
831
832    // Test regex patterns that start with non-bracket characters
833    #[test]
834    fn test_regex_non_bracket_start() {
835        let input = "!\n[Link after exclamation](url)";
836        let config = test_config_with_settings(MD054LinkImageStyleTable {
837            inline: false,
838            ..Default::default()
839        });
840        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
841        let violations = linter.analyze();
842        assert_eq!(1, violations.len());
843
844        // Should be reported on line 2 where the actual link is, not line 1 with the !
845        assert_eq!(1, violations[0].location().range.start.line);
846    }
847
848    // Test line number accuracy parity with original markdownlint
849    #[test]
850    fn test_parity_line_numbers() {
851        // This input matches what we tested against original markdownlint
852        let input = "# MD054 Violations Test Cases\n\nThis file contains examples.\n\n<https://example.com>\n[Link text](https://example.com)";
853        let config = test_config_with_settings(MD054LinkImageStyleTable {
854            autolink: false,
855            inline: false,
856            ..Default::default()
857        });
858        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
859        let violations = linter.analyze();
860        assert_eq!(2, violations.len());
861
862        // These line numbers should match exactly what original markdownlint reports
863        assert_eq!(4, violations[0].location().range.start.line); // Autolink on line 5 (0-indexed: 4)
864        assert_eq!(5, violations[1].location().range.start.line); // Inline link on line 6 (0-indexed: 5)
865    }
866
867    // Test image bracket offset calculation (images don't use the (?:^|[^!]) pattern)
868    #[test]
869    fn test_image_bracket_offset() {
870        let input = "Text ![alt](image.jpg) more text";
871        let config = test_config_with_settings(MD054LinkImageStyleTable {
872            inline: false,
873            ..Default::default()
874        });
875        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
876        let violations = linter.analyze();
877        assert_eq!(1, violations.len());
878
879        // Should correctly identify the inline image
880        assert!(violations[0].message().contains("Inline images"));
881        assert_eq!(0, violations[0].location().range.start.line);
882    }
883}