use std::rc::Rc;
use tree_sitter::Node;
use crate::linter::{Context, RuleLinter, RuleViolation};
use super::{Rule, RuleType};
pub(crate) struct MD019Linter {
context: Rc<Context>,
violations: Vec<RuleViolation>,
}
impl MD019Linter {
pub fn new(context: Rc<Context>) -> Self {
Self {
context,
violations: Vec::new(),
}
}
fn check_heading_spaces(&mut self, node: &Node) {
let source = self.context.get_document_content();
if let (Some(marker_child), Some(content_child)) = (node.child(0), node.child(1)) {
if marker_child.kind().starts_with("atx_h") && marker_child.kind().ends_with("_marker")
{
let marker_end = marker_child.end_byte();
let content_start = content_child.start_byte();
if content_start > marker_end {
let whitespace_text = &source[marker_end..content_start];
if whitespace_text.len() > 1 {
let line_num = node.start_position().row;
let start_col = node.start_position().column
+ marker_child
.utf8_text(source.as_bytes())
.unwrap_or("")
.len()
+ 1;
self.violations.push(RuleViolation::new(
&MD019,
format!(
"Multiple spaces after hash on atx style heading [Expected: 1; Actual: {}]",
whitespace_text.len()
),
self.context.file_path.clone(),
crate::linter::Range {
start: crate::linter::CharPosition { line: line_num, character: start_col },
end: crate::linter::CharPosition { line: line_num, character: content_child.start_position().column },
},
));
}
}
}
}
}
}
impl RuleLinter for MD019Linter {
fn feed(&mut self, node: &Node) {
if node.kind() == "atx_heading" {
self.check_heading_spaces(node);
}
}
fn finalize(&mut self) -> Vec<RuleViolation> {
std::mem::take(&mut self.violations)
}
}
pub const MD019: Rule = Rule {
id: "MD019",
alias: "no-multiple-space-atx",
tags: &["headings", "atx", "spaces"],
description: "Multiple spaces after hash on atx style heading",
rule_type: RuleType::Token,
required_nodes: &["atx_heading"],
new_linter: |context| Box::new(MD019Linter::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_rules;
fn test_config() -> crate::config::QuickmarkConfig {
test_config_with_rules(vec![
("no-multiple-space-atx", RuleSeverity::Error),
("heading-style", RuleSeverity::Off),
("heading-increment", RuleSeverity::Off),
])
}
#[test]
fn test_md019_multiple_spaces_violations() {
let config = test_config();
let input = "## Heading 2
### Heading 3
#### Heading 4
";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(violations.len(), 3);
for violation in &violations {
assert_eq!(violation.rule().id, "MD019");
}
}
#[test]
fn test_md019_single_space_no_violations() {
let config = test_config();
let input = "# Heading 1
## Heading 2
### Heading 3
#### Heading 4
";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md019_tabs_and_spaces_violations() {
let config = test_config();
let input = "##\t\tHeading with tabs
### \tHeading with space and tab
#### Heading with multiple spaces
";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(violations.len(), 3);
for violation in &violations {
assert_eq!(violation.rule().id, "MD019");
}
}
#[test]
fn test_md019_mixed_valid_and_invalid() {
let config = test_config();
let input = "# Valid heading 1
## Invalid heading 2
### Valid heading 3
#### Invalid heading 4
##### Valid heading 5
";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(violations.len(), 2);
for violation in &violations {
assert_eq!(violation.rule().id, "MD019");
}
}
#[test]
fn test_md019_no_space_violations() {
let config = test_config();
let input = "#Heading with no space
##Heading with no space
###Heading with no space
";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md019_closed_atx_violations() {
let config = test_config();
let input = "## Closed heading with multiple spaces ##
### Another closed heading ###
";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(violations.len(), 2);
for violation in &violations {
assert_eq!(violation.rule().id, "MD019");
}
}
#[test]
fn test_md019_only_atx_headings() {
let config = test_config();
let input = "Setext Heading 1
================
Setext Heading 2
----------------
## ATX heading with multiple spaces
";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule().id, "MD019");
}
}