mdbook_lint_core/rules/standard/
md014.rs1use crate::error::Result;
7use crate::rule::{AstRule, RuleCategory, RuleMetadata};
8use crate::{
9 Document,
10 violation::{Severity, Violation},
11};
12use comrak::nodes::{AstNode, NodeValue};
13
14pub struct MD014;
16
17impl AstRule for MD014 {
18 fn id(&self) -> &'static str {
19 "MD014"
20 }
21
22 fn name(&self) -> &'static str {
23 "no-dollar-signs"
24 }
25
26 fn description(&self) -> &'static str {
27 "Dollar signs used before commands without showing output"
28 }
29
30 fn metadata(&self) -> RuleMetadata {
31 RuleMetadata::stable(RuleCategory::Content).introduced_in("mdbook-lint v0.1.0")
32 }
33
34 fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
35 let mut violations = Vec::new();
36
37 for node in ast.descendants() {
39 if let NodeValue::CodeBlock(code_block) = &node.data.borrow().value {
40 let info = code_block.info.trim().to_lowercase();
41
42 if is_shell_language(&info) {
44 let content = &code_block.literal;
45 let lines: Vec<&str> = content.lines().collect();
46
47 for (line_idx, line) in lines.iter().enumerate() {
48 let trimmed = line.trim();
49
50 if trimmed.is_empty() || trimmed.starts_with('#') {
52 continue;
53 }
54
55 if trimmed.starts_with('$') {
57 if is_command_prompt_dollar(trimmed)
59 && let Some((base_line, _)) = document.node_position(node)
60 {
61 let actual_line = base_line + line_idx + 1; violations.push(self.create_violation(
63 format!("Shell command should not include dollar sign prompt: '{trimmed}'"),
64 actual_line,
65 1,
66 Severity::Warning,
67 ));
68 }
69 }
70 }
71 }
72 }
73 }
74
75 Ok(violations)
76 }
77}
78
79fn is_shell_language(info: &str) -> bool {
81 let shell_languages = [
82 "sh",
83 "bash",
84 "shell",
85 "zsh",
86 "fish",
87 "csh",
88 "tcsh",
89 "ksh",
90 "console",
91 "terminal",
92 "cmd",
93 "powershell",
94 "ps1",
95 ];
96
97 for lang in &shell_languages {
100 if info == *lang
101 || info.starts_with(&format!("{lang},"))
102 || info.starts_with(&format!("{lang} "))
103 {
104 return true;
105 }
106 }
107
108 false
109}
110
111fn is_command_prompt_dollar(line: &str) -> bool {
113 let trimmed = line.trim();
114
115 if !trimmed.starts_with('$') {
117 return false;
118 }
119
120 let after_dollar = &trimmed[1..];
122
123 if after_dollar.starts_with(' ') {
125 return true;
126 }
127
128 if after_dollar.is_empty() {
130 return true;
131 }
132
133 if after_dollar.starts_with('(')
136 || after_dollar.starts_with('{')
137 || after_dollar
138 .chars()
139 .next()
140 .is_some_and(|c| c.is_ascii_uppercase() || c == '_')
141 {
142 return false;
143 }
144
145 if after_dollar.starts_with('$') {
147 return false;
148 }
149
150 if let Some(first_char) = after_dollar.chars().next() {
153 first_char.is_ascii_lowercase()
154 } else {
155 false
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::Document;
163 use crate::rule::Rule;
164 use std::path::PathBuf;
165
166 #[test]
167 fn test_md014_no_violations() {
168 let content = r#"# Valid Shell Commands
169
170These shell commands should not trigger violations:
171
172```bash
173echo "Hello, world!"
174ls -la
175cd /home/user
176```
177
178```sh
179grep "pattern" file.txt
180find . -name "*.rs"
181```
182
183Variables and substitutions are fine:
184
185```bash
186echo $HOME
187echo $(date)
188echo ${USER}
189result=$((2 + 3))
190```
191
192Non-shell code blocks are ignored:
193
194```rust
195let x = "$not_a_shell_command";
196```
197
198```python
199print("$this is fine")
200```
201"#;
202 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
203 let rule = MD014;
204 let violations = rule.check(&document).unwrap();
205
206 assert_eq!(violations.len(), 0);
207 }
208
209 #[test]
210 fn test_md014_dollar_sign_violations() {
211 let content = r#"# Shell Commands with Dollar Signs
212
213These should trigger violations:
214
215```bash
216$ echo "Hello, world!"
217$ ls -la
218```
219
220```sh
221$ cd /home/user
222$ grep "pattern" file.txt
223```
224"#;
225 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
226 let rule = MD014;
227 let violations = rule.check(&document).unwrap();
228
229 assert_eq!(violations.len(), 4);
230 assert!(
231 violations[0]
232 .message
233 .contains("Shell command should not include dollar sign prompt")
234 );
235 assert!(violations[0].message.contains("$ echo \"Hello, world!\""));
236 }
237
238 #[test]
239 fn test_md014_mixed_valid_invalid() {
240 let content = r#"# Mixed Valid and Invalid
241
242```bash
243# This is a comment
244echo "This is fine"
245$ echo "This is not fine"
246ls -la
247$ cd /home
248export VAR="value"
249$ grep "pattern" file.txt
250```
251"#;
252 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
253 let rule = MD014;
254 let violations = rule.check(&document).unwrap();
255
256 assert_eq!(violations.len(), 3);
257 }
258
259 #[test]
260 fn test_md014_different_shell_languages() {
261 let content = r#"# Different Shell Languages
262
263```console
264$ echo "console command"
265```
266
267```terminal
268$ ls -la
269```
270
271```zsh
272$ cd /home
273```
274
275```fish
276$ grep "pattern" file.txt
277```
278
279```powershell
280$ Get-Process
281```
282"#;
283 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
284 let rule = MD014;
285 let violations = rule.check(&document).unwrap();
286
287 assert_eq!(violations.len(), 5);
288 }
289
290 #[test]
291 fn test_md014_variables_not_flagged() {
292 let content = r#"# Variable Usage
293
294```bash
295echo $HOME
296echo $USER
297echo ${HOME}/bin
298echo $(date)
299result=$((2 + 3))
300$VAR="something"
301$_PRIVATE_VAR="value"
302```
303
304These should not be flagged as they are valid shell syntax.
305"#;
306 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
307 let rule = MD014;
308 let violations = rule.check(&document).unwrap();
309
310 assert_eq!(violations.len(), 0);
311 }
312
313 #[test]
314 fn test_md014_empty_lines_and_comments() {
315 let content = r#"# Empty Lines and Comments
316
317```bash
318# This is a comment
319$ echo "This should be flagged"
320
321# Another comment
322
323$ ls -la
324echo "This is fine"
325# Final comment
326```
327"#;
328 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
329 let rule = MD014;
330 let violations = rule.check(&document).unwrap();
331
332 assert_eq!(violations.len(), 2);
333 }
334
335 #[test]
336 fn test_md014_non_shell_languages_ignored() {
337 let content = r#"# Non-Shell Languages
338
339```javascript
340console.log("$ this is fine");
341```
342
343```python
344print("$ also fine")
345```
346
347```rust
348println!("$ still fine");
349```
350
351```markdown
352$ This is in markdown, should be ignored
353```
354
355```
356$ This has no language specified, should be ignored
357```
358"#;
359 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
360 let rule = MD014;
361 let violations = rule.check(&document).unwrap();
362
363 assert_eq!(violations.len(), 0);
364 }
365
366 #[test]
367 fn test_md014_indented_dollar_signs() {
368 let content = r#"# Indented Dollar Signs
369
370```bash
371 $ echo "indented command"
372 $ echo "also indented"
373$ echo "not indented"
374```
375"#;
376 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
377 let rule = MD014;
378 let violations = rule.check(&document).unwrap();
379
380 assert_eq!(violations.len(), 3);
381 }
382
383 #[test]
384 fn test_md014_edge_cases() {
385 let content = r#"# Edge Cases
386
387```bash
388$
389$
390$echo_no_space
391$ echo "with space"
392$$
393$$$multiple
394```
395"#;
396 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
397 let rule = MD014;
398 let violations = rule.check(&document).unwrap();
399
400 assert_eq!(violations.len(), 4);
403 }
404
405 #[test]
406 fn test_shell_language_detection() {
407 assert!(is_shell_language("bash"));
408 assert!(is_shell_language("sh"));
409 assert!(is_shell_language("shell"));
410 assert!(is_shell_language("console"));
411 assert!(is_shell_language("bash,no_run"));
412 assert!(is_shell_language("sh copy"));
413
414 assert!(!is_shell_language("rust"));
415 assert!(!is_shell_language("python"));
416 assert!(!is_shell_language("javascript"));
417 assert!(!is_shell_language(""));
418 }
419
420 #[test]
421 fn test_command_prompt_dollar_detection() {
422 assert!(is_command_prompt_dollar("$ echo hello"));
423 assert!(is_command_prompt_dollar("$"));
424 assert!(is_command_prompt_dollar("$ "));
425 assert!(is_command_prompt_dollar("$command"));
426
427 assert!(!is_command_prompt_dollar("$VAR"));
428 assert!(!is_command_prompt_dollar("$HOME"));
429 assert!(!is_command_prompt_dollar("$(command)"));
430 assert!(!is_command_prompt_dollar("${var}"));
431 assert!(!is_command_prompt_dollar("$((math))"));
432 assert!(!is_command_prompt_dollar("$_PRIVATE"));
433 }
434}