use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule};
use regex::Regex;
use lazy_static::lazy_static;
#[derive(Debug, Default)]
pub struct MD038NoSpaceInCode;
impl MD038NoSpaceInCode {
fn is_in_code_block(&self, content: &str, line_num: usize) -> bool {
lazy_static! {
static ref FENCED_START: Regex = Regex::new(r"^(?P<indent>\s*)(?P<fence>```|~~~)").unwrap();
}
let mut in_code_block = false;
let mut current_fence: Option<String> = None;
for (i, line) in content.lines().enumerate() {
if i + 1 > line_num {
break;
}
if !in_code_block {
if let Some(caps) = FENCED_START.captures(line) {
in_code_block = true;
current_fence = Some(caps.name("fence").unwrap().as_str().to_string());
}
} else if let Some(fence) = ¤t_fence {
if line.trim_start().starts_with(fence) && line.trim_end().ends_with(fence) {
in_code_block = false;
current_fence = None;
}
}
}
in_code_block
}
fn check_line(&self, line: &str) -> Vec<(usize, String, String)> {
let mut issues = Vec::new();
let mut in_code = false;
let mut start_pos = 0;
let chars: Vec<char> = line.chars().collect();
let char_to_byte_indices: Vec<usize> = line.char_indices().map(|(byte_idx, _)| byte_idx).collect();
let byte_length = line.len();
for (i, &c) in chars.iter().enumerate() {
if c == '`' {
if !in_code {
start_pos = i;
in_code = true;
} else {
in_code = false;
if i > 0 && chars[i - 1] == '`' {
continue;
}
if i < chars.len() - 1 && chars[i + 1] == '`' {
continue;
}
let start_byte = char_to_byte_indices[start_pos];
let end_byte = if i + 1 < char_to_byte_indices.len() {
char_to_byte_indices[i + 1] - 1
} else {
byte_length - 1
};
let span = &line[start_byte..=end_byte];
let content_start_idx = if start_pos + 1 < chars.len() { start_pos + 1 } else { start_pos };
let content_end_idx = if i > 0 { i - 1 } else { i };
if content_start_idx <= content_end_idx {
let content_start_byte = char_to_byte_indices[content_start_idx];
let content_end_byte = if content_end_idx + 1 < char_to_byte_indices.len() {
char_to_byte_indices[content_end_idx + 1] - 1
} else {
byte_length - 1
};
let content = &line[content_start_byte..=content_end_byte];
if content.starts_with(' ') || content.ends_with(' ') {
let trimmed = content.trim();
if !trimmed.is_empty() {
let fixed = format!("`{}`", trimmed);
issues.push((char_to_byte_indices[start_pos] + 1, span.to_string(), fixed));
}
}
}
}
}
}
issues
}
}
impl Rule for MD038NoSpaceInCode {
fn name(&self) -> &'static str {
"MD038"
}
fn description(&self) -> &'static str {
"Spaces inside code span elements"
}
fn check(&self, content: &str) -> LintResult {
let mut warnings = Vec::new();
for (i, line) in content.lines().enumerate() {
if !self.is_in_code_block(content, i + 1) {
for (column, original, fixed) in self.check_line(line) {
warnings.push(LintWarning {
message: format!("Spaces inside code span elements: '{}'", original),
line: i + 1,
column,
fix: Some(Fix {
line: i + 1,
column,
replacement: fixed,
}),
});
}
}
}
Ok(warnings)
}
fn fix(&self, content: &str) -> Result<String, LintError> {
let lines: Vec<&str> = content.lines().collect();
let mut result = String::new();
for (i, &line) in lines.iter().enumerate() {
let mut current_line = line.to_string();
if !self.is_in_code_block(content, i + 1) {
let mut issues = self.check_line(line);
issues.sort_by(|a, b| b.0.cmp(&a.0));
for (pos, original, fixed) in issues {
let prefix = ¤t_line[..pos - 1];
let suffix = ¤t_line[pos - 1 + original.len()..];
current_line = format!("{}{}{}", prefix, fixed, suffix);
}
}
result.push_str(¤t_line);
if i < lines.len() - 1 {
result.push('\n');
}
}
if content.ends_with('\n') {
result.push('\n');
}
Ok(result)
}
}