1use std::collections::HashSet;
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
11pub(crate) struct MD018Linter {
12 context: Rc<Context>,
13 violations: Vec<RuleViolation>,
14}
15
16impl MD018Linter {
17 pub fn new(context: Rc<Context>) -> Self {
18 Self {
19 context,
20 violations: Vec::new(),
21 }
22 }
23
24 fn analyze_all_lines(&mut self) {
26 let lines = self.context.lines.borrow();
27
28 let ignore_lines = self.get_ignore_lines();
30
31 for (line_index, line) in lines.iter().enumerate() {
32 if ignore_lines.contains(&(line_index + 1)) {
33 continue; }
35
36 if self.is_md018_violation(line) {
37 let violation = self.create_violation_for_line(line, line_index);
38 self.violations.push(violation);
39 }
40 }
41 }
42
43 fn get_ignore_lines(&self) -> HashSet<usize> {
45 let mut ignore_lines = HashSet::new();
46 let node_cache = self.context.node_cache.borrow();
47
48 for node_type in ["fenced_code_block", "indented_code_block", "html_block"] {
49 if let Some(blocks) = node_cache.get(node_type) {
50 for node_info in blocks {
51 for line_num in (node_info.line_start + 1)..=(node_info.line_end + 1) {
52 ignore_lines.insert(line_num);
53 }
54 }
55 }
56 }
57
58 ignore_lines
59 }
60
61 fn is_md018_violation(&self, line: &str) -> bool {
62 let trimmed = line.trim_start();
63
64 if !trimmed.starts_with('#') {
65 return false;
66 }
67
68 if trimmed.starts_with("#️⃣") {
69 return false;
70 }
71
72 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
73 if hash_count == 0 {
74 return false;
75 }
76
77 match trimmed.chars().nth(hash_count) {
78 None => false, Some(' ') | Some('\t') => false, Some(_) => true, }
82 }
83
84 fn create_violation_for_line(&self, line: &str, line_number: usize) -> RuleViolation {
85 RuleViolation::new(
86 &MD018,
87 MD018.description.to_string(),
88 self.context.file_path.clone(),
89 range_from_tree_sitter(&tree_sitter::Range {
90 start_byte: 0, end_byte: line.len(),
92 start_point: tree_sitter::Point {
93 row: line_number,
94 column: 0,
95 },
96 end_point: tree_sitter::Point {
97 row: line_number,
98 column: line.len(),
99 },
100 }),
101 )
102 }
103}
104
105impl RuleLinter for MD018Linter {
106 fn feed(&mut self, node: &Node) {
107 if node.kind() == "document" {
109 self.analyze_all_lines();
110 }
111 }
112
113 fn finalize(&mut self) -> Vec<RuleViolation> {
114 std::mem::take(&mut self.violations)
115 }
116}
117
118pub const MD018: Rule = Rule {
119 id: "MD018",
120 alias: "no-missing-space-atx",
121 tags: &["atx", "headings", "spaces"],
122 description: "No space after hash on atx style heading",
123 rule_type: RuleType::Line,
124 required_nodes: &[], new_linter: |context| Box::new(MD018Linter::new(context)),
126};
127
128#[cfg(test)]
129mod test {
130 use std::path::PathBuf;
131
132 use crate::config::RuleSeverity;
133 use crate::linter::MultiRuleLinter;
134 use crate::test_utils::test_helpers::test_config_with_rules;
135
136 fn test_config() -> crate::config::QuickmarkConfig {
137 test_config_with_rules(vec![
138 ("no-missing-space-atx", RuleSeverity::Error),
139 ("heading-style", RuleSeverity::Off),
140 ])
141 }
142
143 #[test]
144 fn test_missing_space_after_hash() {
145 let input = "#Heading 1";
146
147 let config = test_config();
148 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
149 let violations = linter.analyze();
150 assert_eq!(1, violations.len());
151
152 let violation = &violations[0];
153 assert_eq!("MD018", violation.rule().id);
154 assert!(violation.message().contains("No space after hash"));
155 }
156
157 #[test]
158 fn test_missing_space_after_multiple_hashes() {
159 let input = "##Heading 2";
160
161 let config = test_config();
162 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
163 let violations = linter.analyze();
164 assert_eq!(1, violations.len());
165 }
166
167 #[test]
168 fn test_proper_space_after_hash() {
169 let input = "# Heading 1";
170
171 let config = test_config();
172 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
173 let violations = linter.analyze();
174 assert_eq!(0, violations.len());
175 }
176
177 #[test]
178 fn test_proper_space_after_multiple_hashes() {
179 let input = "## Heading 2";
180
181 let config = test_config();
182 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
183 let violations = linter.analyze();
184 assert_eq!(0, violations.len());
185 }
186
187 #[test]
188 fn test_hash_only_lines_ignored() {
189 let input = "#
190##
191###";
192
193 let config = test_config();
194 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
195 let violations = linter.analyze();
196 assert_eq!(0, violations.len());
197 }
198
199 #[test]
200 fn test_hash_with_only_whitespace_ignored() {
201 let input = "#
202##
203### ";
204
205 let config = test_config();
206 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
207 let violations = linter.analyze();
208 assert_eq!(0, violations.len());
209 }
210
211 #[test]
212 fn test_emoji_hashtag_ignored() {
213 let input = "#️⃣ This should not trigger";
214
215 let config = test_config();
216 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
217 let violations = linter.analyze();
218 assert_eq!(0, violations.len());
219 }
220
221 #[test]
222 fn test_code_blocks_ignored() {
223 let input = "```
224#NoSpaceHere
225```";
226
227 let config = test_config();
228 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
229 let violations = linter.analyze();
230 assert_eq!(0, violations.len());
231 }
232
233 #[test]
234 fn test_indented_code_blocks_ignored() {
235 let input = " #NoSpaceHere";
236
237 let config = test_config();
238 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
239 let violations = linter.analyze();
240 assert_eq!(0, violations.len());
241 }
242
243 #[test]
244 fn test_html_blocks_ignored() {
245 let input = "<div>
246#NoSpaceHere
247</div>";
248
249 let config = test_config();
250 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
251 let violations = linter.analyze();
252 assert_eq!(0, violations.len());
253 }
254
255 #[test]
256 fn test_multiple_violations() {
257 let input = "#Heading 1
258##Heading 2
259### Proper heading
260####Heading 4";
261
262 let config = test_config();
263 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
264 let violations = linter.analyze();
265 assert_eq!(3, violations.len());
266
267 assert_eq!(0, violations[0].location().range.start.line);
269 assert_eq!(1, violations[1].location().range.start.line);
270 assert_eq!(3, violations[2].location().range.start.line);
271 }
272
273 #[test]
274 fn test_mixed_valid_invalid() {
275 let input = "# Valid heading 1
276#Invalid heading
277## Valid heading 2
278###Also invalid";
279
280 let config = test_config();
281 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
282 let violations = linter.analyze();
283 assert_eq!(2, violations.len());
284
285 assert_eq!(1, violations[0].location().range.start.line);
287 assert_eq!(3, violations[1].location().range.start.line);
288 }
289
290 #[test]
291 fn test_hash_not_at_start_of_line() {
292 let input = "Some text #NotAHeading";
293
294 let config = test_config();
295 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
296 let violations = linter.analyze();
297 assert_eq!(0, violations.len());
298 }
299}