1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use pulldown_cmark::LinkType;
3use std::borrow::Cow;
4
5#[derive(Debug, Default, Clone)]
12pub struct MD039NoSpaceInLinks;
13
14fn is_link_text_space(c: char) -> bool {
18 matches!(c, ' ' | '\t' | '\n' | '\r')
19}
20
21const WARNING_MESSAGE: &str = "Remove spaces inside link text";
23
24impl MD039NoSpaceInLinks {
25 pub fn new() -> Self {
26 Self
27 }
28
29 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 #[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 for link in ctx.links() {
98 if link.is_reference {
100 continue;
101 }
102
103 if matches!(link.link_type, LinkType::WikiLink { .. }) {
107 continue;
108 }
109
110 if ctx.is_in_jinja_range(link.byte_offset) {
112 continue;
113 }
114
115 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 if link.text.as_ref() == link.text.trim_matches(is_link_text_space) {
125 continue;
126 }
127
128 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, end_line: link.end_line,
145 end_column: link.end_col + 1, message: WARNING_MESSAGE.to_string(),
147 severity: Severity::Warning,
148 fix,
149 });
150 }
151
152 for image in ctx.images() {
154 if image.is_reference {
156 continue;
157 }
158
159 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 if ctx.is_in_jinja_range(image.byte_offset) {
169 continue;
170 }
171
172 if image.alt_text.as_ref() == image.alt_text.trim_matches(is_link_text_space) {
174 continue;
175 }
176
177 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, end_line: image.end_line,
197 end_column: image.end_col + 1, 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 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 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 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 let mut content = String::with_capacity(100_000);
373
374 for i in 0..500 {
376 content.push_str(&format!("Line {i} with [ spaced link {i} ](url{i}) and text.\n"));
377 }
378
379 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 let _ = rule.check(&ctx).unwrap();
399
400 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 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 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", "\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}