Skip to main content

rumdl_lib/rules/
md039_no_space_in_links.rs

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