use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::range_utils::calculate_single_line_range;
#[derive(Clone)]
pub struct MD019NoMultipleSpaceAtx;
impl Default for MD019NoMultipleSpaceAtx {
fn default() -> Self {
Self::new()
}
}
impl MD019NoMultipleSpaceAtx {
pub fn new() -> Self {
Self
}
fn count_spaces_after_marker(&self, line: &str, marker_len: usize) -> usize {
let after_marker = &line[marker_len..];
after_marker.chars().take_while(|c| *c == ' ' || *c == '\t').count()
}
}
impl Rule for MD019NoMultipleSpaceAtx {
fn name(&self) -> &'static str {
"MD019"
}
fn description(&self) -> &'static str {
"Multiple spaces after hash in heading"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut warnings = Vec::new();
for (line_num, line_info) in ctx.lines.iter().enumerate() {
if let Some(heading) = &line_info.heading {
if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
let line = line_info.content(ctx.content);
let trimmed = line.trim_start();
let marker_pos = line_info.indent + heading.marker.len();
if trimmed.len() > heading.marker.len() {
let space_count = self.count_spaces_after_marker(trimmed, heading.marker.len());
if space_count > 1 {
let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
line_num + 1, marker_pos + 1, space_count, );
let line_start_byte = ctx.line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
let original_line = line_info.content(ctx.content);
let marker_byte_pos = line_start_byte + line_info.indent + heading.marker.len();
let after_marker_start = line_info.indent + heading.marker.len();
let after_marker = &original_line[after_marker_start..];
let space_bytes = after_marker
.as_bytes()
.iter()
.take_while(|&&b| b == b' ' || b == b'\t')
.count();
let extra_spaces_start = marker_byte_pos;
let extra_spaces_end = marker_byte_pos + space_bytes;
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message: format!(
"Multiple spaces ({}) after {} in heading",
space_count,
"#".repeat(heading.level as usize)
),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
severity: Severity::Warning,
fix: Some(Fix {
range: extra_spaces_start..extra_spaces_end,
replacement: " ".to_string(), }),
});
}
}
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let mut lines = Vec::new();
for (i, line_info) in ctx.lines.iter().enumerate() {
let line_num = i + 1;
if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
lines.push(line_info.content(ctx.content).to_string());
continue;
}
let mut fixed = false;
if let Some(heading) = &line_info.heading {
if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
let line = line_info.content(ctx.content);
let trimmed = line.trim_start();
if trimmed.len() > heading.marker.len() {
let space_count = self.count_spaces_after_marker(trimmed, heading.marker.len());
if space_count > 1 {
let line = line_info.content(ctx.content);
let original_indent = &line[..line_info.indent];
lines.push(format!(
"{original_indent}{} {}",
heading.marker,
trimmed[heading.marker.len()..].trim_start()
));
fixed = true;
}
}
}
}
if !fixed {
lines.push(line_info.content(ctx.content).to_string());
}
}
let mut result = lines.join("\n");
if ctx.content.ends_with('\n') && !result.ends_with('\n') {
result.push('\n');
}
Ok(result)
}
fn category(&self) -> RuleCategory {
RuleCategory::Heading
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || !ctx.likely_has_headings()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(MD019NoMultipleSpaceAtx::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_functionality() {
let rule = MD019NoMultipleSpaceAtx::new();
let content = "# Multiple Spaces\n\nRegular content\n\n## More Spaces";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
assert_eq!(result[1].line, 5);
let content = "# Single Space\n\n## Also correct";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Properly formatted headings should not generate warnings"
);
}
}