mdbook_lint_core/rules/standard/
md014.rs

1//! MD014: Dollar signs used before commands without showing output
2//!
3//! This rule checks that shell commands in code blocks don't include dollar signs
4//! as part of the command, which makes them harder to copy and paste.
5
6use crate::error::Result;
7use crate::rule::{AstRule, RuleCategory, RuleMetadata};
8use crate::{
9    Document,
10    violation::{Severity, Violation},
11};
12use comrak::nodes::{AstNode, NodeValue};
13
14/// Rule to check that shell commands don't include dollar signs
15pub 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        // Find all code block nodes
38        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                // Check if this is a shell-related code block
43                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                        // Skip empty lines and comments
51                        if trimmed.is_empty() || trimmed.starts_with('#') {
52                            continue;
53                        }
54
55                        // Check if line starts with $ (potentially with whitespace)
56                        if trimmed.starts_with('$') {
57                            // Make sure it's not just a variable or other valid use
58                            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; // +1 because code block content starts on next line
62                                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
79/// Check if the language info indicates a shell-related code block
80fn 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    // Check if the info string starts with any shell language
98    // (handles cases like "bash,no_run" or "sh copy")
99    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
111/// Check if a dollar sign is being used as a command prompt
112fn is_command_prompt_dollar(line: &str) -> bool {
113    let trimmed = line.trim();
114
115    // Must start with $
116    if !trimmed.starts_with('$') {
117        return false;
118    }
119
120    // Get the part after the $
121    let after_dollar = &trimmed[1..];
122
123    // If there's a space after $, it's likely a command prompt
124    if after_dollar.starts_with(' ') {
125        return true;
126    }
127
128    // If it's just $ followed by nothing, it's likely a prompt
129    if after_dollar.is_empty() {
130        return true;
131    }
132
133    // Don't flag common shell variable patterns
134    // Like $VAR, $(command), ${var}, $((math))
135    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    // Don't flag multiple dollar signs ($$, $$$, etc.) - these are less likely to be prompts
146    if after_dollar.starts_with('$') {
147        return false;
148    }
149
150    // For anything else that looks like a command (lowercase letter after $), flag it
151    // This catches cases like "$echo" or "$cd"
152    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        // Should flag: $, $ , $echo_no_space, $ echo "with space"
401        // Should not flag: $$, $$$multiple (these are less likely to be prompts)
402        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}