1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::regex_cache::get_cached_regex;
3
4const ALL_WHITESPACE_STR: &str = r"^\s*$";
6
7#[derive(Debug, Default, Clone)]
14pub struct MD039NoSpaceInLinks;
15
16const WARNING_MESSAGE: &str = "Remove spaces inside link text";
18
19impl MD039NoSpaceInLinks {
20 pub fn new() -> Self {
21 Self
22 }
23
24 #[inline]
25 fn trim_link_text_preserve_escapes(text: &str) -> &str {
26 let start = text
28 .char_indices()
29 .find(|&(_, c)| !c.is_whitespace())
30 .map_or(text.len(), |(i, _)| i);
31 let end = text
32 .char_indices()
33 .rev()
34 .find(|&(_, c)| !c.is_whitespace())
35 .map_or(0, |(i, c)| i + c.len_utf8());
36 if start >= end { "" } else { &text[start..end] }
37 }
38
39 #[inline]
41 fn needs_trimming(&self, text: &str) -> bool {
42 text != text.trim_matches(|c: char| c.is_whitespace())
44 }
45
46 #[inline]
48 fn unescape_fast(&self, text: &str) -> String {
49 if !text.contains('\\') {
50 return text.to_string();
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 result
69 }
70}
71
72impl Rule for MD039NoSpaceInLinks {
73 fn name(&self) -> &'static str {
74 "MD039"
75 }
76
77 fn description(&self) -> &'static str {
78 "Spaces inside link text"
79 }
80
81 fn category(&self) -> RuleCategory {
82 RuleCategory::Link
83 }
84
85 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
86 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
87 }
88
89 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
90 let mut warnings = Vec::new();
91
92 for link in &ctx.links {
94 if link.is_reference {
96 continue;
97 }
98
99 if ctx.is_in_jinja_range(link.byte_offset) {
101 continue;
102 }
103
104 if ctx.is_in_jsx_expression(link.byte_offset) || ctx.is_in_mdx_comment(link.byte_offset) {
106 continue;
107 }
108
109 if !self.needs_trimming(&link.text) {
111 continue;
112 }
113
114 let unescaped = self.unescape_fast(&link.text);
116
117 let needs_warning = if get_cached_regex(ALL_WHITESPACE_STR).is_ok_and(|re| re.is_match(&unescaped)) {
118 true
119 } else {
120 let trimmed = link.text.trim_matches(|c: char| c.is_whitespace());
121 link.text.as_ref() != trimmed
122 };
123
124 if needs_warning {
125 let original = &ctx.content[link.byte_offset..link.byte_end];
129 let dest_start = original
130 .find("](")
131 .or_else(|| original.find("]["))
132 .map_or(original.len(), |p| p + 1);
133 let dest_portion = &original[dest_start..];
134
135 let fixed = if get_cached_regex(ALL_WHITESPACE_STR).is_ok_and(|re| re.is_match(&unescaped)) {
136 format!("[]{dest_portion}")
137 } else {
138 let trimmed = Self::trim_link_text_preserve_escapes(&link.text);
139 format!("[{trimmed}]{dest_portion}")
140 };
141
142 warnings.push(LintWarning {
143 rule_name: Some(self.name().to_string()),
144 line: link.line,
145 column: link.start_col + 1, end_line: link.line,
147 end_column: link.end_col + 1, message: WARNING_MESSAGE.to_string(),
149 severity: Severity::Warning,
150 fix: Some(Fix::new(link.byte_offset..link.byte_end, fixed)),
151 });
152 }
153 }
154
155 for image in &ctx.images {
157 if image.is_reference {
159 continue;
160 }
161
162 if ctx.is_in_jsx_expression(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
164 continue;
165 }
166
167 if ctx.is_in_jinja_range(image.byte_offset) {
169 continue;
170 }
171
172 if !self.needs_trimming(&image.alt_text) {
174 continue;
175 }
176
177 let unescaped = self.unescape_fast(&image.alt_text);
179
180 let needs_warning = if get_cached_regex(ALL_WHITESPACE_STR).is_ok_and(|re| re.is_match(&unescaped)) {
181 true
182 } else {
183 let trimmed = image.alt_text.trim_matches(|c: char| c.is_whitespace());
184 image.alt_text.as_ref() != trimmed
185 };
186
187 if needs_warning {
188 let original = &ctx.content[image.byte_offset..image.byte_end];
189 let dest_start = original
190 .find("](")
191 .or_else(|| original.find("]["))
192 .map_or(original.len(), |p| p + 1);
193 let dest_portion = &original[dest_start..];
194
195 let fixed = if get_cached_regex(ALL_WHITESPACE_STR).is_ok_and(|re| re.is_match(&unescaped)) {
196 format!("![]{dest_portion}")
197 } else {
198 let trimmed = Self::trim_link_text_preserve_escapes(&image.alt_text);
199 format!("![{trimmed}]{dest_portion}")
200 };
201
202 warnings.push(LintWarning {
203 rule_name: Some(self.name().to_string()),
204 line: image.line,
205 column: image.start_col + 1, end_line: image.line,
207 end_column: image.end_col + 1, message: WARNING_MESSAGE.to_string(),
209 severity: Severity::Warning,
210 fix: Some(Fix::new(image.byte_offset..image.byte_end, fixed)),
211 });
212 }
213 }
214
215 Ok(warnings)
216 }
217
218 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
219 if self.should_skip(ctx) {
220 return Ok(ctx.content.to_string());
221 }
222 let warnings = self.check(ctx)?;
223 if warnings.is_empty() {
224 return Ok(ctx.content.to_string());
225 }
226 let warnings =
227 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
228 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
229 }
230
231 fn as_any(&self) -> &dyn std::any::Any {
232 self
233 }
234
235 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
236 where
237 Self: Sized,
238 {
239 Box::new(Self)
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn test_valid_links() {
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!(result.is_empty());
254 }
255
256 #[test]
257 fn test_spaces_both_ends() {
258 let rule = MD039NoSpaceInLinks::new();
259 let content = "[ link ](url) and [ another link ](url) here";
260 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
261 let result = rule.check(&ctx).unwrap();
262 assert_eq!(result.len(), 2);
263 let fixed = rule.fix(&ctx).unwrap();
264 assert_eq!(fixed, "[link](url) and [another link](url) here");
265 }
266
267 #[test]
268 fn test_space_at_start() {
269 let rule = MD039NoSpaceInLinks::new();
270 let content = "[ link](url) and [ another link](url) here";
271 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
272 let result = rule.check(&ctx).unwrap();
273 assert_eq!(result.len(), 2);
274 let fixed = rule.fix(&ctx).unwrap();
275 assert_eq!(fixed, "[link](url) and [another link](url) here");
276 }
277
278 #[test]
279 fn test_space_at_end() {
280 let rule = MD039NoSpaceInLinks::new();
281 let content = "[link ](url) and [another link ](url) here";
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 link](url) here");
287 }
288
289 #[test]
290 fn test_link_in_code_block() {
291 let rule = MD039NoSpaceInLinks::new();
292 let content = "```
293[ link ](url)
294```
295[ link ](url)";
296 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
297 let result = rule.check(&ctx).unwrap();
298 assert_eq!(result.len(), 1);
299 let fixed = rule.fix(&ctx).unwrap();
300 assert_eq!(
301 fixed,
302 "```
303[ link ](url)
304```
305[link](url)"
306 );
307 }
308
309 #[test]
310 fn test_multiple_links() {
311 let rule = MD039NoSpaceInLinks::new();
312 let content = "[ link ](url) and [ another ](url) in one line";
313 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
314 let result = rule.check(&ctx).unwrap();
315 assert_eq!(result.len(), 2);
316 let fixed = rule.fix(&ctx).unwrap();
317 assert_eq!(fixed, "[link](url) and [another](url) in one line");
318 }
319
320 #[test]
321 fn test_link_with_internal_spaces() {
322 let rule = MD039NoSpaceInLinks::new();
323 let content = "[this is link](url) and [ this is also link ](url)";
324 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
325 let result = rule.check(&ctx).unwrap();
326 assert_eq!(result.len(), 1);
327 let fixed = rule.fix(&ctx).unwrap();
328 assert_eq!(fixed, "[this is link](url) and [this is also link](url)");
329 }
330
331 #[test]
332 fn test_link_with_punctuation() {
333 let rule = MD039NoSpaceInLinks::new();
334 let content = "[ link! ](url) and [ link? ](url) here";
335 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
336 let result = rule.check(&ctx).unwrap();
337 assert_eq!(result.len(), 2);
338 let fixed = rule.fix(&ctx).unwrap();
339 assert_eq!(fixed, "[link!](url) and [link?](url) here");
340 }
341
342 #[test]
343 fn test_parity_only_whitespace_and_newlines_minimal() {
344 let rule = MD039NoSpaceInLinks::new();
345 let content = "[ \n ](url) and [\t\n\t](url)";
346 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
347 let fixed = rule.fix(&ctx).unwrap();
348 assert_eq!(fixed, "[](url) and [](url)");
350 }
351
352 #[test]
353 fn test_parity_internal_newlines_minimal() {
354 let rule = MD039NoSpaceInLinks::new();
355 let content = "[link\ntext](url) and [ another\nlink ](url)";
356 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
357 let fixed = rule.fix(&ctx).unwrap();
358 assert_eq!(fixed, "[link\ntext](url) and [another\nlink](url)");
360 }
361
362 #[test]
363 fn test_parity_escaped_brackets_minimal() {
364 let rule = MD039NoSpaceInLinks::new();
365 let content = "[link\\]](url) and [link\\[]](url)";
366 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367 let fixed = rule.fix(&ctx).unwrap();
368 assert_eq!(fixed, "[link\\]](url) and [link\\[]](url)");
370 }
371
372 #[test]
373 fn test_performance_md039() {
374 use std::time::Instant;
375
376 let rule = MD039NoSpaceInLinks::new();
377
378 let mut content = String::with_capacity(100_000);
380
381 for i in 0..500 {
383 content.push_str(&format!("Line {i} with [ spaced link {i} ](url{i}) and text.\n"));
384 }
385
386 for i in 0..500 {
388 content.push_str(&format!(
389 "Line {} with [valid link {}](url{}) and text.\n",
390 i + 500,
391 i,
392 i
393 ));
394 }
395
396 println!(
397 "MD039 Performance Test - Content: {} bytes, {} lines",
398 content.len(),
399 content.lines().count()
400 );
401
402 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
403
404 let _ = rule.check(&ctx).unwrap();
406
407 let mut total_duration = std::time::Duration::ZERO;
409 let runs = 5;
410 let mut warnings_count = 0;
411
412 for _ in 0..runs {
413 let start = Instant::now();
414 let warnings = rule.check(&ctx).unwrap();
415 total_duration += start.elapsed();
416 warnings_count = warnings.len();
417 }
418
419 let avg_check_duration = total_duration / runs;
420
421 println!("MD039 Optimized Performance:");
422 println!(
423 "- Average check time: {:?} ({:.2} ms)",
424 avg_check_duration,
425 avg_check_duration.as_secs_f64() * 1000.0
426 );
427 println!("- Found {warnings_count} warnings");
428 println!(
429 "- Lines per second: {:.0}",
430 content.lines().count() as f64 / avg_check_duration.as_secs_f64()
431 );
432 println!(
433 "- Microseconds per line: {:.2}",
434 avg_check_duration.as_micros() as f64 / content.lines().count() as f64
435 );
436
437 assert!(
439 avg_check_duration.as_millis() < 200,
440 "MD039 check should complete in under 200ms, took {}ms",
441 avg_check_duration.as_millis()
442 );
443
444 assert_eq!(warnings_count, 500, "Should find 500 warnings for links with spaces");
446 }
447}