rumdl_lib/rules/
md038_no_space_in_code.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3/// Rule MD038: No space inside code span markers
4///
5/// See [docs/md038.md](../../docs/md038.md) for full documentation, configuration, and examples.
6///
7/// MD038: Spaces inside code span elements
8///
9/// This rule is triggered when there are spaces inside code span elements.
10///
11/// For example:
12///
13/// ``` markdown
14/// ` some text`
15/// `some text `
16/// ` some text `
17/// ```
18///
19/// To fix this issue, remove the leading and trailing spaces within the code span markers:
20///
21/// ``` markdown
22/// `some text`
23/// ```
24///
25/// Note: Code spans containing backticks (e.g., `` `backticks` inside ``) are not flagged
26/// to avoid breaking nested backtick structures used to display backticks in documentation.
27#[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    /// Check if a code span is likely part of a nested backtick structure
38    fn is_likely_nested_backticks(&self, ctx: &crate::lint_context::LintContext, span_index: usize) -> bool {
39        // If there are multiple code spans on the same line, and there's text
40        // between them that contains "code" or other indicators, it's likely nested
41        let code_spans = ctx.code_spans();
42        let current_span = &code_spans[span_index];
43        let current_line = current_span.line;
44
45        // Look for other code spans on the same line
46        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        // Check if there's content between spans that might indicate nesting
57        // Get the line content
58        let line_idx = current_line - 1; // Convert to 0-based
59        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 each pair of adjacent code spans, check what's between them
66        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 there's text containing "code" or similar patterns between spans,
73                // it's likely they're showing nested backticks
74                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        // Use centralized code spans from LintContext
105        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            // Skip empty code spans
110            if code_content.is_empty() {
111                continue;
112            }
113
114            // Early check: if no leading/trailing whitespace, skip
115            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            // Check if there are leading or trailing spaces
125            if code_content != trimmed {
126                // Check if the content itself contains backticks - if so, skip to avoid
127                // breaking nested backtick structures
128                if trimmed.contains('`') {
129                    continue;
130                }
131
132                // Skip inline R code in Quarto/RMarkdown: `r expression`
133                // This is a legitimate pattern where space is required after 'r'
134                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                // Check if this might be part of a nested backtick structure
143                // by looking for other code spans nearby that might indicate nesting
144                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, // Convert to 1-indexed
152                    end_line: code_span.line,
153                    end_column: code_span.end_col, // Don't add 1 to match test expectation
154                    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        // Early return if no backticks in content
179        if !content.contains('`') {
180            return Ok(content.to_string());
181        }
182
183        // Get warnings to identify what needs to be fixed
184        let warnings = self.check(ctx)?;
185        if warnings.is_empty() {
186            return Ok(content.to_string());
187        }
188
189        // Collect all fixes and sort by position (reverse order to avoid position shifts)
190        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        // Apply fixes - only allocate string when we have fixes to apply
198        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    /// Check if content is likely to have code spans
207    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        // These are the exact cases from README.md that are incorrectly flagged
230        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        // All spaces should be flagged (matching markdownlint behavior)
274        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        // This test reveals the issue - we're getting multiple separate code spans instead of one
346        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        // Test that code spans with backticks are skipped
354        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        // Test that Quarto-specific R code exception works
363        let rule = MD038NoSpaceInCode::new();
364
365        // Test inline R code - should NOT trigger warning in Quarto flavor
366        // The key pattern is "r " followed by code
367        let content = r#"The result is `r nchar("test")` which equals 4."#;
368
369        // Quarto flavor should allow R code
370        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        // Test that other code with spaces still gets flagged in Quarto
379        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}