quickmark_core/rules/
md014.rs

1use regex::Regex;
2use std::rc::Rc;
3use tree_sitter::Node;
4
5use crate::linter::{CharPosition, Context, Range, RuleLinter, RuleViolation};
6
7use super::{Rule, RuleType};
8
9const VIOLATION_MESSAGE: &str = "Dollar signs used before commands without showing output";
10
11pub(crate) struct MD014Linter {
12    context: Rc<Context>,
13    violations: Vec<RuleViolation>,
14    dollar_regex: Regex,
15}
16
17impl MD014Linter {
18    pub fn new(context: Rc<Context>) -> Self {
19        Self {
20            context,
21            violations: Vec::new(),
22            dollar_regex: Regex::new(r"^(\s*)\$\s+").unwrap(),
23        }
24    }
25
26    /// Analyze all code blocks using cached nodes
27    fn analyze_all_code_blocks(&mut self) {
28        let node_cache = self.context.node_cache.borrow();
29        let lines = self.context.lines.borrow();
30
31        // Check fenced code blocks
32        if let Some(fenced_blocks) = node_cache.get("fenced_code_block") {
33            for node_info in fenced_blocks {
34                if let Some(violation) = self.check_code_block_info(node_info, &lines, true) {
35                    self.violations.push(violation);
36                }
37            }
38        }
39
40        // Check indented code blocks
41        if let Some(indented_blocks) = node_cache.get("indented_code_block") {
42            for node_info in indented_blocks {
43                if let Some(violation) = self.check_code_block_info(node_info, &lines, false) {
44                    self.violations.push(violation);
45                }
46            }
47        }
48    }
49
50    fn check_code_block_info(
51        &self,
52        node_info: &crate::linter::NodeInfo,
53        lines: &[String],
54        is_fenced: bool,
55    ) -> Option<RuleViolation> {
56        let start_line = node_info.line_start;
57        let end_line = node_info.line_end;
58
59        // Extract content lines from the code block
60        let mut content_lines = Vec::new();
61
62        // For fenced code blocks, skip the fence lines
63        let (content_start, content_end) = if is_fenced {
64            // Skip first and last line (fence markers)
65            (start_line + 1, end_line.saturating_sub(1))
66        } else {
67            // For indented code blocks, include all lines
68            (start_line, end_line)
69        };
70
71        // Collect non-empty lines
72        for line_idx in content_start..=content_end {
73            if line_idx < lines.len() {
74                let line = &lines[line_idx];
75                if !line.trim().is_empty() {
76                    // For indented code blocks, filter lines that don't have proper indentation
77                    // This works around tree-sitter-md parsing inconsistencies
78                    if !is_fenced {
79                        // Check if line starts with at least 4 spaces (indented code block requirement)
80                        if !line.starts_with("    ") && !line.starts_with(' ') {
81                            continue;
82                        }
83                    }
84                    content_lines.push((line_idx, line));
85                }
86            }
87        }
88
89        // If no non-empty lines, no violation
90        if content_lines.is_empty() {
91            return None;
92        }
93
94        // Check if ALL non-empty lines start with dollar sign
95        let all_have_dollar = content_lines
96            .iter()
97            .all(|(_, line)| self.dollar_regex.is_match(line));
98
99        if all_have_dollar {
100            // Report violation on the first line with dollar sign
101            if let Some((first_line_idx, first_line)) = content_lines.first() {
102                let range = Range {
103                    start: CharPosition {
104                        line: *first_line_idx,
105                        character: 0,
106                    },
107                    end: CharPosition {
108                        line: *first_line_idx,
109                        character: first_line.len(),
110                    },
111                };
112
113                return Some(RuleViolation::new(
114                    &MD014,
115                    VIOLATION_MESSAGE.to_string(),
116                    self.context.file_path.clone(),
117                    range,
118                ));
119            }
120        }
121
122        None
123    }
124}
125
126impl RuleLinter for MD014Linter {
127    fn feed(&mut self, node: &Node) {
128        // This is a document-level rule, so we run the analysis when we see the document node.
129        if node.kind() == "document" {
130            self.analyze_all_code_blocks();
131        }
132    }
133
134    fn finalize(&mut self) -> Vec<RuleViolation> {
135        std::mem::take(&mut self.violations)
136    }
137}
138
139pub const MD014: Rule = Rule {
140    id: "MD014",
141    alias: "commands-show-output",
142    tags: &["code"],
143    description: "Dollar signs used before commands without showing output",
144    rule_type: RuleType::Document,
145    required_nodes: &["fenced_code_block", "indented_code_block"],
146    new_linter: |context| Box::new(MD014Linter::new(context)),
147};
148
149#[cfg(test)]
150mod test {
151    use std::path::PathBuf;
152
153    use crate::config::RuleSeverity;
154    use crate::linter::MultiRuleLinter;
155    use crate::test_utils::test_helpers::test_config_with_settings;
156
157    fn test_config() -> crate::config::QuickmarkConfig {
158        test_config_with_settings(
159            vec![
160                ("commands-show-output", RuleSeverity::Error),
161                ("heading-style", RuleSeverity::Off),
162                ("heading-increment", RuleSeverity::Off),
163            ],
164            Default::default(),
165        )
166    }
167
168    #[test]
169    fn test_violation_all_lines_with_dollar_signs() {
170        let config = test_config();
171
172        let input = "```bash
173$ git status
174$ ls -la
175$ pwd
176```";
177        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
178        let violations = linter.analyze();
179        assert_eq!(1, violations.len());
180        assert!(violations[0].message().contains("Dollar signs"));
181    }
182
183    #[test]
184    fn test_no_violation_with_command_output() {
185        let config = test_config();
186
187        let input = "```bash
188$ git status
189On branch main
190nothing to commit
191
192$ ls -la
193total 8
194drwxr-xr-x 2 user user 4096 Jan 1 00:00 .
195```";
196        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
197        let violations = linter.analyze();
198        assert_eq!(0, violations.len());
199    }
200
201    #[test]
202    fn test_no_violation_no_dollar_signs() {
203        let config = test_config();
204
205        let input = "```bash
206git status
207ls -la
208pwd
209```";
210        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
211        let violations = linter.analyze();
212        assert_eq!(0, violations.len());
213    }
214
215    #[test]
216    fn test_violation_indented_code_block() {
217        let config = test_config();
218
219        let input = "Some text:
220
221    $ git status
222    $ ls -la
223    $ pwd
224
225More text.";
226        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
227        let violations = linter.analyze();
228        assert_eq!(1, violations.len());
229        assert!(violations[0].message().contains("Dollar signs"));
230    }
231
232    #[test]
233    fn test_no_violation_mixed_dollar_signs() {
234        let config = test_config();
235
236        let input = "```bash
237$ git status
238ls -la
239$ pwd
240```";
241        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
242        let violations = linter.analyze();
243        assert_eq!(0, violations.len());
244    }
245
246    #[test]
247    fn test_violation_with_whitespace_before_dollar() {
248        let config = test_config();
249
250        let input = "```bash
251  $ git status
252  $ ls -la
253  $ pwd
254```";
255        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
256        let violations = linter.analyze();
257        assert_eq!(1, violations.len());
258        assert!(violations[0].message().contains("Dollar signs"));
259    }
260
261    #[test]
262    fn test_no_violation_empty_code_block() {
263        let config = test_config();
264
265        let input = "```bash
266```";
267        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
268        let violations = linter.analyze();
269        assert_eq!(0, violations.len());
270    }
271
272    #[test]
273    fn test_no_violation_blank_lines_only() {
274        let config = test_config();
275
276        let input = "```bash
277
278
279
280```";
281        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
282        let violations = linter.analyze();
283        assert_eq!(0, violations.len());
284    }
285
286    #[test]
287    fn test_violation_with_blank_lines_between_commands() {
288        let config = test_config();
289
290        let input = "```bash
291$ git status
292
293$ ls -la
294
295$ pwd
296```";
297        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
298        let violations = linter.analyze();
299        assert_eq!(1, violations.len());
300        assert!(violations[0].message().contains("Dollar signs"));
301    }
302}