1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3#[derive(Debug, Clone, Default)]
28pub struct MD038NoSpaceInCode {
29 pub enabled: bool,
30}
31
32impl MD038NoSpaceInCode {
33 pub fn new() -> Self {
34 Self { enabled: true }
35 }
36
37 fn is_likely_nested_backticks(&self, ctx: &crate::lint_context::LintContext, span_index: usize) -> bool {
39 let code_spans = ctx.code_spans();
42 let current_span = &code_spans[span_index];
43 let current_line = current_span.line;
44
45 let same_line_spans: Vec<_> = code_spans
47 .iter()
48 .enumerate()
49 .filter(|(i, s)| s.line == current_line && *i != span_index)
50 .collect();
51
52 if same_line_spans.is_empty() {
53 return false;
54 }
55
56 let line_idx = current_line - 1; if line_idx >= ctx.lines.len() {
60 return false;
61 }
62
63 let line_content = &ctx.lines[line_idx].content(ctx.content);
64
65 for (_, other_span) in &same_line_spans {
67 let start = current_span.end_col.min(other_span.end_col);
68 let end = current_span.start_col.max(other_span.start_col);
69
70 if start < end && end <= line_content.len() {
71 let between = &line_content[start..end];
72 if between.contains("code") || between.contains("backtick") {
75 return true;
76 }
77 }
78 }
79
80 false
81 }
82}
83
84impl Rule for MD038NoSpaceInCode {
85 fn name(&self) -> &'static str {
86 "MD038"
87 }
88
89 fn description(&self) -> &'static str {
90 "Spaces inside code span elements"
91 }
92
93 fn category(&self) -> RuleCategory {
94 RuleCategory::Other
95 }
96
97 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
98 if !self.enabled {
99 return Ok(vec![]);
100 }
101
102 let mut warnings = Vec::new();
103
104 let code_spans = ctx.code_spans();
106 for (i, code_span) in code_spans.iter().enumerate() {
107 let code_content = &code_span.content;
108
109 if code_content.is_empty() {
111 continue;
112 }
113
114 let has_leading_space = code_content.chars().next().is_some_and(|c| c.is_whitespace());
116 let has_trailing_space = code_content.chars().last().is_some_and(|c| c.is_whitespace());
117
118 if !has_leading_space && !has_trailing_space {
119 continue;
120 }
121
122 let trimmed = code_content.trim();
123
124 if code_content != trimmed {
126 if trimmed.contains('`') {
129 continue;
130 }
131
132 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
135 && trimmed.starts_with('r')
136 && trimmed.len() > 1
137 && trimmed.chars().nth(1).is_some_and(|c| c.is_whitespace())
138 {
139 continue;
140 }
141
142 if self.is_likely_nested_backticks(ctx, i) {
145 continue;
146 }
147
148 warnings.push(LintWarning {
149 rule_name: Some(self.name().to_string()),
150 line: code_span.line,
151 column: code_span.start_col + 1, end_line: code_span.line,
153 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
155 severity: Severity::Warning,
156 fix: Some(Fix {
157 range: code_span.byte_offset..code_span.byte_end,
158 replacement: format!(
159 "{}{}{}",
160 "`".repeat(code_span.backtick_count),
161 trimmed,
162 "`".repeat(code_span.backtick_count)
163 ),
164 }),
165 });
166 }
167 }
168
169 Ok(warnings)
170 }
171
172 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
173 let content = ctx.content;
174 if !self.enabled {
175 return Ok(content.to_string());
176 }
177
178 if !content.contains('`') {
180 return Ok(content.to_string());
181 }
182
183 let warnings = self.check(ctx)?;
185 if warnings.is_empty() {
186 return Ok(content.to_string());
187 }
188
189 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
191 .into_iter()
192 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
193 .collect();
194
195 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
196
197 let mut result = content.to_string();
199 for (range, replacement) in fixes {
200 result.replace_range(range, &replacement);
201 }
202
203 Ok(result)
204 }
205
206 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
208 !ctx.likely_has_code()
209 }
210
211 fn as_any(&self) -> &dyn std::any::Any {
212 self
213 }
214
215 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
216 where
217 Self: Sized,
218 {
219 Box::new(MD038NoSpaceInCode { enabled: true })
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn test_md038_readme_false_positives() {
229 let rule = MD038NoSpaceInCode::new();
231 let valid_cases = vec![
232 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
233 "#### Effective Configuration (`rumdl config`)",
234 "- Blue: `.rumdl.toml`",
235 "### Defaults Only (`rumdl config --defaults`)",
236 ];
237
238 for case in valid_cases {
239 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
240 let result = rule.check(&ctx).unwrap();
241 assert!(
242 result.is_empty(),
243 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
244 case,
245 result.len()
246 );
247 }
248 }
249
250 #[test]
251 fn test_md038_valid() {
252 let rule = MD038NoSpaceInCode::new();
253 let valid_cases = vec![
254 "This is `code` in a sentence.",
255 "This is a `longer code span` in a sentence.",
256 "This is `code with internal spaces` which is fine.",
257 "Code span at `end of line`",
258 "`Start of line` code span",
259 "Multiple `code spans` in `one line` are fine",
260 "Code span with `symbols: !@#$%^&*()`",
261 "Empty code span `` is technically valid",
262 ];
263 for case in valid_cases {
264 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
265 let result = rule.check(&ctx).unwrap();
266 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
267 }
268 }
269
270 #[test]
271 fn test_md038_invalid() {
272 let rule = MD038NoSpaceInCode::new();
273 let invalid_cases = vec![
275 "Type ` y ` to confirm.",
276 "Use ` git commit -m \"message\" ` to commit.",
277 "The variable ` $HOME ` contains home path.",
278 "The pattern ` *.txt ` matches text files.",
279 "This is ` random word ` with unnecessary spaces.",
280 "Text with ` plain text ` should be flagged.",
281 "Code with ` just code ` here.",
282 "Multiple ` word ` spans with ` text ` in one line.",
283 "This is ` code` with leading space.",
284 "This is `code ` with trailing space.",
285 "This is ` code ` with both leading and trailing space.",
286 ];
287 for case in invalid_cases {
288 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
289 let result = rule.check(&ctx).unwrap();
290 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
291 }
292 }
293
294 #[test]
295 fn test_md038_fix() {
296 let rule = MD038NoSpaceInCode::new();
297 let test_cases = vec![
298 (
299 "This is ` code` with leading space.",
300 "This is `code` with leading space.",
301 ),
302 (
303 "This is `code ` with trailing space.",
304 "This is `code` with trailing space.",
305 ),
306 ("This is ` code ` with both spaces.", "This is `code` with both spaces."),
307 (
308 "Multiple ` code ` and `spans ` to fix.",
309 "Multiple `code` and `spans` to fix.",
310 ),
311 ];
312 for (input, expected) in test_cases {
313 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard);
314 let result = rule.fix(&ctx).unwrap();
315 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
316 }
317 }
318
319 #[test]
320 fn test_check_invalid_leading_space() {
321 let rule = MD038NoSpaceInCode::new();
322 let input = "This has a ` leading space` in code";
323 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard);
324 let result = rule.check(&ctx).unwrap();
325 assert_eq!(result.len(), 1);
326 assert_eq!(result[0].line, 1);
327 assert!(result[0].fix.is_some());
328 }
329
330 #[test]
331 fn test_code_span_parsing_nested_backticks() {
332 let content = "Code with ` nested `code` example ` should preserve backticks";
333 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
334
335 println!("Content: {content}");
336 println!("Code spans found:");
337 let code_spans = ctx.code_spans();
338 for (i, span) in code_spans.iter().enumerate() {
339 println!(
340 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
341 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
342 );
343 }
344
345 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
347 }
348
349 #[test]
350 fn test_nested_backtick_detection() {
351 let rule = MD038NoSpaceInCode::new();
352
353 let content = "Code with `` `backticks` inside `` should not be flagged";
355 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
356 let result = rule.check(&ctx).unwrap();
357 assert!(result.is_empty(), "Code spans with backticks should be skipped");
358 }
359
360 #[test]
361 fn test_quarto_inline_r_code() {
362 let rule = MD038NoSpaceInCode::new();
364
365 let content = r#"The result is `r nchar("test")` which equals 4."#;
368
369 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto);
371 let result_quarto = rule.check(&ctx_quarto).unwrap();
372 assert!(
373 result_quarto.is_empty(),
374 "Quarto inline R code should not trigger warnings. Got {} warnings",
375 result_quarto.len()
376 );
377
378 let content_other = "This has ` plain text ` with spaces.";
380 let ctx_other = crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto);
381 let result_other = rule.check(&ctx_other).unwrap();
382 assert_eq!(
383 result_other.len(),
384 1,
385 "Quarto should still flag non-R code spans with improper spaces"
386 );
387 }
388}