quickmark_core/rules/
md014.rs1use 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 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 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 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 let mut content_lines = Vec::new();
61
62 let (content_start, content_end) = if is_fenced {
64 (start_line + 1, end_line.saturating_sub(1))
66 } else {
67 (start_line, end_line)
69 };
70
71 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 if !is_fenced {
79 if !line.starts_with(" ") && !line.starts_with(' ') {
81 continue;
82 }
83 }
84 content_lines.push((line_idx, line));
85 }
86 }
87 }
88
89 if content_lines.is_empty() {
91 return None;
92 }
93
94 let all_have_dollar = content_lines
96 .iter()
97 .all(|(_, line)| self.dollar_regex.is_match(line));
98
99 if all_have_dollar {
100 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 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}