1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::regex_cache::get_cached_regex;
3use pulldown_cmark::LinkType;
4
5const ALL_WHITESPACE_STR: &str = r"^\s*$";
7
8#[derive(Debug, Default, Clone)]
15pub struct MD039NoSpaceInLinks;
16
17const 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 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 #[inline]
42 fn needs_trimming(&self, text: &str) -> bool {
43 text != text.trim_matches(|c: char| c.is_whitespace())
45 }
46
47 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 #[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 for link in ctx.links() {
112 if link.is_reference {
114 continue;
115 }
116
117 if matches!(link.link_type, LinkType::WikiLink { .. }) {
121 continue;
122 }
123
124 if ctx.is_in_jinja_range(link.byte_offset) {
126 continue;
127 }
128
129 if ctx.is_in_jsx_expression(link.byte_offset) || ctx.is_in_mdx_comment(link.byte_offset) {
131 continue;
132 }
133
134 if !self.needs_trimming(&link.text) {
136 continue;
137 }
138
139 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 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, end_line: link.end_line,
168 end_column: link.end_col + 1, message: WARNING_MESSAGE.to_string(),
170 severity: Severity::Warning,
171 fix,
172 });
173 }
174 }
175
176 for image in ctx.images() {
178 if image.is_reference {
180 continue;
181 }
182
183 if ctx.is_in_jsx_expression(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
185 continue;
186 }
187
188 if ctx.is_in_jinja_range(image.byte_offset) {
190 continue;
191 }
192
193 if !self.needs_trimming(&image.alt_text) {
195 continue;
196 }
197
198 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, end_line: image.end_line,
225 end_column: image.end_col + 1, 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 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 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 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 let mut content = String::with_capacity(100_000);
398
399 for i in 0..500 {
401 content.push_str(&format!("Line {i} with [ spaced link {i} ](url{i}) and text.\n"));
402 }
403
404 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 let _ = rule.check(&ctx).unwrap();
424
425 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 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 assert_eq!(warnings_count, 500, "Should find 500 warnings for links with spaces");
464 }
465}