quickmark_core/rules/
md035.rs1use serde::Deserialize;
2use std::rc::Rc;
3
4use tree_sitter::Node;
5
6use crate::{
7 linter::{range_from_tree_sitter, RuleViolation},
8 rules::{Context, Rule, RuleLinter, RuleType},
9};
10
11#[derive(Debug, PartialEq, Clone, Deserialize)]
13pub struct 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, };
50
51 let config_style = &self.context.config.linters.settings.hr_style.style;
53
54 let expected = self.expected_style.get_or_insert_with(|| {
56 if config_style == "consistent" {
57 text.to_string() } else {
59 config_style.clone() }
61 });
62
63 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 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 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 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 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 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 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 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 assert_eq!(0, violations.len());
281 }
282}