Skip to main content

rumdl_lib/rules/
md039_no_space_in_links.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use pulldown_cmark::LinkType;
3use std::borrow::Cow;
4
5/// Rule MD039: No space inside link text
6///
7/// See [docs/md039.md](../../docs/md039.md) for full documentation, configuration, and examples.
8///
9/// This rule is triggered when link text has leading or trailing spaces which can cause
10/// unexpected rendering in some Markdown parsers.
11#[derive(Debug, Default, Clone)]
12pub struct MD039NoSpaceInLinks;
13
14// Static definition for the warning message
15const WARNING_MESSAGE: &str = "Remove spaces inside link text";
16
17impl MD039NoSpaceInLinks {
18    pub fn new() -> Self {
19        Self
20    }
21
22    /// The destination of `span`, delimiters included, so a fix can carry it
23    /// over verbatim: `(url "title")` for an inline link, `[ref]` for a
24    /// reference one.
25    ///
26    /// `text` is the source slice between the brackets and `open` its offset
27    /// within `span` (1 for a link, 2 for an image), so the `]` that closes it
28    /// sits exactly one byte past its end. Searching for the first `](`
29    /// instead would stop at the destination of a *nested* image and splice
30    /// that tail back into the document.
31    fn destination_of<'a>(span: &'a str, text: &str, open: usize) -> Option<&'a str> {
32        let close = open + text.len();
33        if span.as_bytes().get(close) != Some(&b']') {
34            return None;
35        }
36        Some(&span[close + 1..])
37    }
38
39    /// Unescape link text for the whitespace-only check.
40    #[inline]
41    fn unescape(text: &str) -> Cow<'_, str> {
42        if !text.contains('\\') {
43            return Cow::Borrowed(text);
44        }
45
46        let mut result = String::with_capacity(text.len());
47        let mut chars = text.chars().peekable();
48
49        while let Some(c) = chars.next() {
50            if c == '\\' {
51                if let Some(&next) = chars.peek() {
52                    result.push(next);
53                    chars.next();
54                } else {
55                    result.push(c);
56                }
57            } else {
58                result.push(c);
59            }
60        }
61        Cow::Owned(result)
62    }
63}
64
65impl Rule for MD039NoSpaceInLinks {
66    fn name(&self) -> &'static str {
67        "MD039"
68    }
69
70    fn description(&self) -> &'static str {
71        "Spaces inside link text"
72    }
73
74    fn category(&self) -> RuleCategory {
75        RuleCategory::Link
76    }
77
78    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
79        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
80    }
81
82    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
83        let mut warnings = Vec::new();
84
85        // Use centralized link parsing from LintContext
86        for link in ctx.links() {
87            // Skip reference links (markdownlint doesn't check these)
88            if link.is_reference {
89                continue;
90            }
91
92            // A wikilink writes its display text as `[[target|text]]`, which
93            // this rule cannot rewrite: the fix below emits `[text](dest)`
94            // syntax, and a wikilink has no destination to carry over.
95            if matches!(link.link_type, LinkType::WikiLink { .. }) {
96                continue;
97            }
98
99            // Skip links inside Jinja templates
100            if ctx.is_in_jinja_range(link.byte_offset) {
101                continue;
102            }
103
104            // Skip links inside JSX expressions or MDX comments
105            if ctx.is_in_jsx_expression(link.byte_offset) || ctx.is_in_mdx_comment(link.byte_offset) {
106                continue;
107            }
108
109            // Fast check if trimming is needed
110            if link.text.as_ref() == link.text.trim() {
111                continue;
112            }
113
114            // Preserve the original destination, including titles and attributes.
115            let original = &ctx.content[link.byte_offset..link.byte_end];
116            let fix = Self::destination_of(original, &link.text, 1).map(|dest_portion| {
117                let fixed = if Self::unescape(&link.text).trim().is_empty() {
118                    format!("[]{dest_portion}")
119                } else {
120                    let trimmed = link.text.trim();
121                    format!("[{trimmed}]{dest_portion}")
122                };
123                Fix::new(link.byte_offset..link.byte_end, fixed)
124            });
125
126            warnings.push(LintWarning {
127                rule_name: Some(self.name().to_string()),
128                line: link.line,
129                column: link.start_col + 1, // Convert to 1-indexed
130                end_line: link.end_line,
131                end_column: link.end_col + 1, // Convert to 1-indexed
132                message: WARNING_MESSAGE.to_string(),
133                severity: Severity::Warning,
134                fix,
135            });
136        }
137
138        // Also check images
139        for image in ctx.images() {
140            // Skip reference images (markdownlint doesn't check these)
141            if image.is_reference {
142                continue;
143            }
144
145            // Skip images inside JSX expressions or MDX comments
146            if ctx.is_in_jsx_expression(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
147                continue;
148            }
149
150            // Skip images inside Jinja templates
151            if ctx.is_in_jinja_range(image.byte_offset) {
152                continue;
153            }
154
155            // Fast check if trimming is needed
156            if image.alt_text.as_ref() == image.alt_text.trim() {
157                continue;
158            }
159
160            // Preserve the original destination, including titles and attributes.
161            let original = &ctx.content[image.byte_offset..image.byte_end];
162            let fix = Self::destination_of(original, &image.alt_text, 2).map(|dest_portion| {
163                let fixed = if Self::unescape(&image.alt_text).trim().is_empty() {
164                    format!("![]{dest_portion}")
165                } else {
166                    let trimmed = image.alt_text.trim();
167                    format!("![{trimmed}]{dest_portion}")
168                };
169                Fix::new(image.byte_offset..image.byte_end, fixed)
170            });
171
172            warnings.push(LintWarning {
173                rule_name: Some(self.name().to_string()),
174                line: image.line,
175                column: image.start_col + 1, // Convert to 1-indexed
176                end_line: image.end_line,
177                end_column: image.end_col + 1, // Convert to 1-indexed
178                message: WARNING_MESSAGE.to_string(),
179                severity: Severity::Warning,
180                fix,
181            });
182        }
183
184        Ok(warnings)
185    }
186
187    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
188        if self.should_skip(ctx) {
189            return Ok(ctx.content.to_string());
190        }
191        let warnings = self.check(ctx)?;
192        if warnings.is_empty() {
193            return Ok(ctx.content.to_string());
194        }
195        let warnings =
196            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
197        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
198    }
199
200    fn as_any(&self) -> &dyn std::any::Any {
201        self
202    }
203
204    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
205    where
206        Self: Sized,
207    {
208        Box::new(Self)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_valid_links() {
218        let rule = MD039NoSpaceInLinks::new();
219        let content = "[link](url) and [another link](url) here";
220        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
221        let result = rule.check(&ctx).unwrap();
222        assert!(result.is_empty());
223    }
224
225    #[test]
226    fn test_spaces_both_ends() {
227        let rule = MD039NoSpaceInLinks::new();
228        let content = "[ link ](url) and [ another link ](url) here";
229        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
230        let result = rule.check(&ctx).unwrap();
231        assert_eq!(result.len(), 2);
232        let fixed = rule.fix(&ctx).unwrap();
233        assert_eq!(fixed, "[link](url) and [another link](url) here");
234    }
235
236    #[test]
237    fn test_space_at_start() {
238        let rule = MD039NoSpaceInLinks::new();
239        let content = "[ link](url) and [ another link](url) here";
240        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
241        let result = rule.check(&ctx).unwrap();
242        assert_eq!(result.len(), 2);
243        let fixed = rule.fix(&ctx).unwrap();
244        assert_eq!(fixed, "[link](url) and [another link](url) here");
245    }
246
247    #[test]
248    fn test_space_at_end() {
249        let rule = MD039NoSpaceInLinks::new();
250        let content = "[link ](url) and [another link ](url) here";
251        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
252        let result = rule.check(&ctx).unwrap();
253        assert_eq!(result.len(), 2);
254        let fixed = rule.fix(&ctx).unwrap();
255        assert_eq!(fixed, "[link](url) and [another link](url) here");
256    }
257
258    #[test]
259    fn test_link_in_code_block() {
260        let rule = MD039NoSpaceInLinks::new();
261        let content = "```
262[ link ](url)
263```
264[ link ](url)";
265        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
266        let result = rule.check(&ctx).unwrap();
267        assert_eq!(result.len(), 1);
268        let fixed = rule.fix(&ctx).unwrap();
269        assert_eq!(
270            fixed,
271            "```
272[ link ](url)
273```
274[link](url)"
275        );
276    }
277
278    #[test]
279    fn test_multiple_links() {
280        let rule = MD039NoSpaceInLinks::new();
281        let content = "[ link ](url) and [ another ](url) in one line";
282        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
283        let result = rule.check(&ctx).unwrap();
284        assert_eq!(result.len(), 2);
285        let fixed = rule.fix(&ctx).unwrap();
286        assert_eq!(fixed, "[link](url) and [another](url) in one line");
287    }
288
289    #[test]
290    fn test_link_with_internal_spaces() {
291        let rule = MD039NoSpaceInLinks::new();
292        let content = "[this is link](url) and [ this is also link ](url)";
293        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
294        let result = rule.check(&ctx).unwrap();
295        assert_eq!(result.len(), 1);
296        let fixed = rule.fix(&ctx).unwrap();
297        assert_eq!(fixed, "[this is link](url) and [this is also link](url)");
298    }
299
300    #[test]
301    fn test_link_with_punctuation() {
302        let rule = MD039NoSpaceInLinks::new();
303        let content = "[ link! ](url) and [ link? ](url) here";
304        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
305        let result = rule.check(&ctx).unwrap();
306        assert_eq!(result.len(), 2);
307        let fixed = rule.fix(&ctx).unwrap();
308        assert_eq!(fixed, "[link!](url) and [link?](url) here");
309    }
310
311    #[test]
312    fn test_parity_only_whitespace_and_newlines_minimal() {
313        let rule = MD039NoSpaceInLinks::new();
314        let content = "[   \n  ](url) and [\t\n\t](url)";
315        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
316        let fixed = rule.fix(&ctx).unwrap();
317        // markdownlint removes all whitespace, resulting in empty link text
318        assert_eq!(fixed, "[](url) and [](url)");
319    }
320
321    #[test]
322    fn test_parity_internal_newlines_minimal() {
323        let rule = MD039NoSpaceInLinks::new();
324        let content = "[link\ntext](url) and [ another\nlink ](url)";
325        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
326        let fixed = rule.fix(&ctx).unwrap();
327        // markdownlint trims only leading/trailing whitespace, preserves internal newlines
328        assert_eq!(fixed, "[link\ntext](url) and [another\nlink](url)");
329    }
330
331    #[test]
332    fn test_parity_escaped_brackets_minimal() {
333        let rule = MD039NoSpaceInLinks::new();
334        let content = "[link\\]](url) and [link\\[]](url)";
335        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
336        let fixed = rule.fix(&ctx).unwrap();
337        // markdownlint does not trim or remove escapes, so output should be unchanged
338        assert_eq!(fixed, "[link\\]](url) and [link\\[]](url)");
339    }
340
341    #[test]
342    fn test_performance_md039() {
343        use std::time::Instant;
344
345        let rule = MD039NoSpaceInLinks::new();
346
347        // Generate test content with many links
348        let mut content = String::with_capacity(100_000);
349
350        // Add links with spaces (should be detected and fixed)
351        for i in 0..500 {
352            content.push_str(&format!("Line {i} with [ spaced link {i} ](url{i}) and text.\n"));
353        }
354
355        // Add valid links (should be fast to skip)
356        for i in 0..500 {
357            content.push_str(&format!(
358                "Line {} with [valid link {}](url{}) and text.\n",
359                i + 500,
360                i,
361                i
362            ));
363        }
364
365        println!(
366            "MD039 Performance Test - Content: {} bytes, {} lines",
367            content.len(),
368            content.lines().count()
369        );
370
371        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
372
373        // Warm up
374        let _ = rule.check(&ctx).unwrap();
375
376        // Measure check performance
377        let mut total_duration = std::time::Duration::ZERO;
378        let runs = 5;
379        let mut warnings_count = 0;
380
381        for _ in 0..runs {
382            let start = Instant::now();
383            let warnings = rule.check(&ctx).unwrap();
384            total_duration += start.elapsed();
385            warnings_count = warnings.len();
386        }
387
388        let avg_check_duration = total_duration / runs;
389
390        println!("MD039 Optimized Performance:");
391        println!(
392            "- Average check time: {:?} ({:.2} ms)",
393            avg_check_duration,
394            avg_check_duration.as_secs_f64() * 1000.0
395        );
396        println!("- Found {warnings_count} warnings");
397        println!(
398            "- Lines per second: {:.0}",
399            content.lines().count() as f64 / avg_check_duration.as_secs_f64()
400        );
401        println!(
402            "- Microseconds per line: {:.2}",
403            avg_check_duration.as_micros() as f64 / content.lines().count() as f64
404        );
405
406        // Performance assertion - should complete reasonably fast
407        assert!(
408            avg_check_duration.as_millis() < 200,
409            "MD039 check should complete in under 200ms, took {}ms",
410            avg_check_duration.as_millis()
411        );
412
413        // Verify we're finding the expected number of warnings (500 links with spaces)
414        assert_eq!(warnings_count, 500, "Should find 500 warnings for links with spaces");
415    }
416}