use regex::Regex;
use std::rc::Rc;
use tree_sitter::Node;
use crate::linter::{CharPosition, Context, Range, RuleLinter, RuleViolation};
use super::{Rule, RuleType};
const VIOLATION_MESSAGE: &str = "Dollar signs used before commands without showing output";
pub(crate) struct MD014Linter {
context: Rc<Context>,
violations: Vec<RuleViolation>,
dollar_regex: Regex,
}
impl MD014Linter {
pub fn new(context: Rc<Context>) -> Self {
Self {
context,
violations: Vec::new(),
dollar_regex: Regex::new(r"^(\s*)\$\s+").unwrap(),
}
}
fn analyze_all_code_blocks(&mut self) {
let node_cache = self.context.node_cache.borrow();
let lines = self.context.lines.borrow();
if let Some(fenced_blocks) = node_cache.get("fenced_code_block") {
for node_info in fenced_blocks {
if let Some(violation) = self.check_code_block_info(node_info, &lines, true) {
self.violations.push(violation);
}
}
}
if let Some(indented_blocks) = node_cache.get("indented_code_block") {
for node_info in indented_blocks {
if let Some(violation) = self.check_code_block_info(node_info, &lines, false) {
self.violations.push(violation);
}
}
}
}
fn check_code_block_info(
&self,
node_info: &crate::linter::NodeInfo,
lines: &[String],
is_fenced: bool,
) -> Option<RuleViolation> {
let start_line = node_info.line_start;
let end_line = node_info.line_end;
let mut content_lines = Vec::new();
let (content_start, content_end) = if is_fenced {
(start_line + 1, end_line.saturating_sub(1))
} else {
(start_line, end_line)
};
for line_idx in content_start..=content_end {
if line_idx < lines.len() {
let line = &lines[line_idx];
if !line.trim().is_empty() {
if !is_fenced {
if !line.starts_with(" ") && !line.starts_with(' ') {
continue;
}
}
content_lines.push((line_idx, line));
}
}
}
if content_lines.is_empty() {
return None;
}
let all_have_dollar = content_lines
.iter()
.all(|(_, line)| self.dollar_regex.is_match(line));
if all_have_dollar {
if let Some((first_line_idx, first_line)) = content_lines.first() {
let range = Range {
start: CharPosition {
line: *first_line_idx,
character: 0,
},
end: CharPosition {
line: *first_line_idx,
character: first_line.len(),
},
};
return Some(RuleViolation::new(
&MD014,
VIOLATION_MESSAGE.to_string(),
self.context.file_path.clone(),
range,
));
}
}
None
}
}
impl RuleLinter for MD014Linter {
fn feed(&mut self, node: &Node) {
if node.kind() == "document" {
self.analyze_all_code_blocks();
}
}
fn finalize(&mut self) -> Vec<RuleViolation> {
std::mem::take(&mut self.violations)
}
}
pub const MD014: Rule = Rule {
id: "MD014",
alias: "commands-show-output",
tags: &["code"],
description: "Dollar signs used before commands without showing output",
rule_type: RuleType::Document,
required_nodes: &["fenced_code_block", "indented_code_block"],
new_linter: |context| Box::new(MD014Linter::new(context)),
};
#[cfg(test)]
mod test {
use std::path::PathBuf;
use crate::config::RuleSeverity;
use crate::linter::MultiRuleLinter;
use crate::test_utils::test_helpers::test_config_with_settings;
fn test_config() -> crate::config::QuickmarkConfig {
test_config_with_settings(
vec![
("commands-show-output", RuleSeverity::Error),
("heading-style", RuleSeverity::Off),
("heading-increment", RuleSeverity::Off),
],
Default::default(),
)
}
#[test]
fn test_violation_all_lines_with_dollar_signs() {
let config = test_config();
let input = "```bash
$ git status
$ ls -la
$ pwd
```";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
assert!(violations[0].message().contains("Dollar signs"));
}
#[test]
fn test_no_violation_with_command_output() {
let config = test_config();
let input = "```bash
$ git status
On branch main
nothing to commit
$ ls -la
total 8
drwxr-xr-x 2 user user 4096 Jan 1 00:00 .
```";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_no_violation_no_dollar_signs() {
let config = test_config();
let input = "```bash
git status
ls -la
pwd
```";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_violation_indented_code_block() {
let config = test_config();
let input = "Some text:
$ git status
$ ls -la
$ pwd
More text.";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
assert!(violations[0].message().contains("Dollar signs"));
}
#[test]
fn test_no_violation_mixed_dollar_signs() {
let config = test_config();
let input = "```bash
$ git status
ls -la
$ pwd
```";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_violation_with_whitespace_before_dollar() {
let config = test_config();
let input = "```bash
$ git status
$ ls -la
$ pwd
```";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
assert!(violations[0].message().contains("Dollar signs"));
}
#[test]
fn test_no_violation_empty_code_block() {
let config = test_config();
let input = "```bash
```";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_no_violation_blank_lines_only() {
let config = test_config();
let input = "```bash
```";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_violation_with_blank_lines_between_commands() {
let config = test_config();
let input = "```bash
$ git status
$ ls -la
$ pwd
```";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
assert!(violations[0].message().contains("Dollar signs"));
}
}