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