quickmark_core/rules/
md018.rs

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    /// Analyze all lines and store all violations for reporting via finalize()
25    fn analyze_all_lines(&mut self) {
26        let lines = self.context.lines.borrow();
27
28        // We need to identify lines that are in code blocks or HTML blocks to ignore them
29        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; // Skip lines in code blocks or HTML blocks
34            }
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    /// Get line numbers that should be ignored (inside code blocks or HTML blocks)
44    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,                   // Line consists only of hashes (e.g., "###")
79            Some(' ') | Some('\t') => false, // Correctly formatted with a space or tab
80            Some(_) => true,                 // Any other character indicates a missing space
81        }
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, // Note: byte offsets are not correctly handled here
91                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        // Analyze all lines when we see the document node
108        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: &[], // Line-based rules don't require specific nodes
125    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        // Check violation line numbers
268        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        // Check violation line numbers
286        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}