quickmark_core/rules/
md035.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// MD035-specific configuration types
12#[derive(Debug, PartialEq, Clone, Deserialize)]
13pub struct MD035HrStyleTable {
14    #[serde(default)]
15    pub style: String,
16}
17
18impl Default for MD035HrStyleTable {
19    fn default() -> Self {
20        Self {
21            style: "consistent".to_string(),
22        }
23    }
24}
25
26pub(crate) struct MD035Linter {
27    context: Rc<Context>,
28    violations: Vec<RuleViolation>,
29    expected_style: Option<String>,
30}
31
32impl MD035Linter {
33    pub fn new(context: Rc<Context>) -> Self {
34        Self {
35            context,
36            violations: Vec::new(),
37            expected_style: None,
38        }
39    }
40}
41
42impl RuleLinter for MD035Linter {
43    fn feed(&mut self, node: &Node) {
44        if node.kind() == "thematic_break" {
45            let content = self.context.document_content.borrow();
46            let text = match node.utf8_text(content.as_bytes()) {
47                Ok(text) => text.trim(),
48                Err(_) => return, // Ignore if text cannot be decoded
49            };
50
51            // Get the configured style from the context
52            let config_style = &self.context.config.linters.settings.hr_style.style;
53
54            // Determine or get the expected style
55            let expected = self.expected_style.get_or_insert_with(|| {
56                if config_style == "consistent" {
57                    text.to_string() // First one sets the style
58                } else {
59                    config_style.clone() // Use the configured style
60                }
61            });
62
63            // Check if the current style matches the expected one
64            if text != expected.as_str() {
65                self.violations.push(RuleViolation::new(
66                    &MD035,
67                    format!("Expected '{expected}', actual '{text}'"),
68                    self.context.file_path.clone(),
69                    range_from_tree_sitter(&node.range()),
70                ));
71            }
72        }
73    }
74
75    fn finalize(&mut self) -> Vec<RuleViolation> {
76        std::mem::take(&mut self.violations)
77    }
78}
79
80pub const MD035: Rule = Rule {
81    id: "MD035",
82    alias: "hr-style",
83    tags: &["hr"],
84    description: "Horizontal rule style",
85    rule_type: RuleType::Token,
86    required_nodes: &["thematic_break"],
87    new_linter: |context| Box::new(MD035Linter::new(context)),
88};
89
90#[cfg(test)]
91mod test {
92    use std::path::PathBuf;
93
94    use crate::config::RuleSeverity;
95    use crate::linter::MultiRuleLinter;
96    use crate::test_utils::test_helpers::test_config_with_rules;
97
98    fn test_config() -> crate::config::QuickmarkConfig {
99        test_config_with_rules(vec![
100            ("hr-style", RuleSeverity::Error),
101            ("heading-increment", RuleSeverity::Off),
102            ("heading-style", RuleSeverity::Off),
103            ("line-length", RuleSeverity::Off),
104        ])
105    }
106
107    #[test]
108    fn test_consistent_horizontal_rules_no_violation() {
109        let input = r#"# Heading
110
111---
112
113Some content
114
115---
116
117More content"#;
118
119        let config = test_config();
120        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
121        let violations = linter.analyze();
122
123        // Should not trigger violations for consistent styles
124        assert_eq!(0, violations.len());
125    }
126
127    #[test]
128    fn test_inconsistent_horizontal_rules_violation() {
129        let input = r#"# Heading
130
131---
132
133Some content
134
135***
136
137More content"#;
138
139        let config = test_config();
140        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
141        let violations = linter.analyze();
142
143        // Should trigger violation for inconsistent style
144        assert_eq!(1, violations.len());
145        let violation = &violations[0];
146        assert_eq!("MD035", violation.rule().id);
147        assert!(violation.message().contains("Expected '---', actual '***'"));
148    }
149
150    #[test]
151    fn test_multiple_inconsistent_styles() {
152        let input = r#"# Heading
153
154---
155
156Content
157
158***
159
160More content
161
162___
163
164Final content"#;
165
166        let config = test_config();
167        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
168        let violations = linter.analyze();
169
170        // Should trigger violations for both inconsistent styles
171        assert_eq!(2, violations.len());
172        assert_eq!("MD035", violations[0].rule().id);
173        assert_eq!("MD035", violations[1].rule().id);
174        assert!(violations[0]
175            .message()
176            .contains("Expected '---', actual '***'"));
177        assert!(violations[1]
178            .message()
179            .contains("Expected '---', actual '___'"));
180    }
181
182    #[test]
183    fn test_asterisk_consistent_no_violation() {
184        let input = r#"# Heading
185
186***
187
188Some content
189
190***
191
192More content"#;
193
194        let config = test_config();
195        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
196        let violations = linter.analyze();
197
198        // Should not trigger violations for consistent asterisk style
199        assert_eq!(0, violations.len());
200    }
201
202    #[test]
203    fn test_underscore_consistent_no_violation() {
204        let input = r#"# Heading
205
206___
207
208Some content
209
210___
211
212More content"#;
213
214        let config = test_config();
215        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
216        let violations = linter.analyze();
217
218        // Should not trigger violations for consistent underscore style
219        assert_eq!(0, violations.len());
220    }
221
222    #[test]
223    fn test_spaced_horizontal_rules_consistent() {
224        let input = r#"# Heading
225
226* * *
227
228Some content
229
230* * *
231
232More content"#;
233
234        let config = test_config();
235        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
236        let violations = linter.analyze();
237
238        // Should not trigger violations for consistent spaced style
239        assert_eq!(0, violations.len());
240    }
241
242    #[test]
243    fn test_spaced_vs_non_spaced_inconsistent() {
244        let input = r#"# Heading
245
246***
247
248Some content
249
250* * *
251
252More content"#;
253
254        let config = test_config();
255        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
256        let violations = linter.analyze();
257
258        // Should trigger violation for inconsistent spacing
259        assert_eq!(1, violations.len());
260        assert!(violations[0]
261            .message()
262            .contains("Expected '***', actual '* * *'"));
263    }
264
265    #[test]
266    fn test_single_horizontal_rule_no_violation() {
267        let input = r#"# Heading
268
269Some content
270
271---
272
273More content"#;
274
275        let config = test_config();
276        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
277        let violations = linter.analyze();
278
279        // Single horizontal rule should not trigger any violations
280        assert_eq!(0, violations.len());
281    }
282}