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
14const WARNING_MESSAGE: &str = "Remove spaces inside link text";
16
17impl MD039NoSpaceInLinks {
18 pub fn new() -> Self {
19 Self
20 }
21
22 fn destination_of<'a>(span: &'a str, text: &str, open: usize) -> Option<&'a str> {
32 let close = open + text.len();
33 if span.as_bytes().get(close) != Some(&b']') {
34 return None;
35 }
36 Some(&span[close + 1..])
37 }
38
39 #[inline]
41 fn unescape(text: &str) -> Cow<'_, str> {
42 if !text.contains('\\') {
43 return Cow::Borrowed(text);
44 }
45
46 let mut result = String::with_capacity(text.len());
47 let mut chars = text.chars().peekable();
48
49 while let Some(c) = chars.next() {
50 if c == '\\' {
51 if let Some(&next) = chars.peek() {
52 result.push(next);
53 chars.next();
54 } else {
55 result.push(c);
56 }
57 } else {
58 result.push(c);
59 }
60 }
61 Cow::Owned(result)
62 }
63}
64
65impl Rule for MD039NoSpaceInLinks {
66 fn name(&self) -> &'static str {
67 "MD039"
68 }
69
70 fn description(&self) -> &'static str {
71 "Spaces inside link text"
72 }
73
74 fn category(&self) -> RuleCategory {
75 RuleCategory::Link
76 }
77
78 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
79 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
80 }
81
82 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
83 let mut warnings = Vec::new();
84
85 for link in ctx.links() {
87 if link.is_reference {
89 continue;
90 }
91
92 if matches!(link.link_type, LinkType::WikiLink { .. }) {
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 link.text.as_ref() == link.text.trim() {
111 continue;
112 }
113
114 let original = &ctx.content[link.byte_offset..link.byte_end];
116 let fix = Self::destination_of(original, &link.text, 1).map(|dest_portion| {
117 let fixed = if Self::unescape(&link.text).trim().is_empty() {
118 format!("[]{dest_portion}")
119 } else {
120 let trimmed = link.text.trim();
121 format!("[{trimmed}]{dest_portion}")
122 };
123 Fix::new(link.byte_offset..link.byte_end, fixed)
124 });
125
126 warnings.push(LintWarning {
127 rule_name: Some(self.name().to_string()),
128 line: link.line,
129 column: link.start_col + 1, end_line: link.end_line,
131 end_column: link.end_col + 1, message: WARNING_MESSAGE.to_string(),
133 severity: Severity::Warning,
134 fix,
135 });
136 }
137
138 for image in ctx.images() {
140 if image.is_reference {
142 continue;
143 }
144
145 if ctx.is_in_jsx_expression(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
147 continue;
148 }
149
150 if ctx.is_in_jinja_range(image.byte_offset) {
152 continue;
153 }
154
155 if image.alt_text.as_ref() == image.alt_text.trim() {
157 continue;
158 }
159
160 let original = &ctx.content[image.byte_offset..image.byte_end];
162 let fix = Self::destination_of(original, &image.alt_text, 2).map(|dest_portion| {
163 let fixed = if Self::unescape(&image.alt_text).trim().is_empty() {
164 format!("![]{dest_portion}")
165 } else {
166 let trimmed = image.alt_text.trim();
167 format!("![{trimmed}]{dest_portion}")
168 };
169 Fix::new(image.byte_offset..image.byte_end, fixed)
170 });
171
172 warnings.push(LintWarning {
173 rule_name: Some(self.name().to_string()),
174 line: image.line,
175 column: image.start_col + 1, end_line: image.end_line,
177 end_column: image.end_col + 1, message: WARNING_MESSAGE.to_string(),
179 severity: Severity::Warning,
180 fix,
181 });
182 }
183
184 Ok(warnings)
185 }
186
187 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
188 if self.should_skip(ctx) {
189 return Ok(ctx.content.to_string());
190 }
191 let warnings = self.check(ctx)?;
192 if warnings.is_empty() {
193 return Ok(ctx.content.to_string());
194 }
195 let warnings =
196 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
197 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
198 }
199
200 fn as_any(&self) -> &dyn std::any::Any {
201 self
202 }
203
204 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
205 where
206 Self: Sized,
207 {
208 Box::new(Self)
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn test_valid_links() {
218 let rule = MD039NoSpaceInLinks::new();
219 let content = "[link](url) and [another link](url) here";
220 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
221 let result = rule.check(&ctx).unwrap();
222 assert!(result.is_empty());
223 }
224
225 #[test]
226 fn test_spaces_both_ends() {
227 let rule = MD039NoSpaceInLinks::new();
228 let content = "[ link ](url) and [ another link ](url) here";
229 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
230 let result = rule.check(&ctx).unwrap();
231 assert_eq!(result.len(), 2);
232 let fixed = rule.fix(&ctx).unwrap();
233 assert_eq!(fixed, "[link](url) and [another link](url) here");
234 }
235
236 #[test]
237 fn test_space_at_start() {
238 let rule = MD039NoSpaceInLinks::new();
239 let content = "[ link](url) and [ another link](url) here";
240 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
241 let result = rule.check(&ctx).unwrap();
242 assert_eq!(result.len(), 2);
243 let fixed = rule.fix(&ctx).unwrap();
244 assert_eq!(fixed, "[link](url) and [another link](url) here");
245 }
246
247 #[test]
248 fn test_space_at_end() {
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_eq!(result.len(), 2);
254 let fixed = rule.fix(&ctx).unwrap();
255 assert_eq!(fixed, "[link](url) and [another link](url) here");
256 }
257
258 #[test]
259 fn test_link_in_code_block() {
260 let rule = MD039NoSpaceInLinks::new();
261 let content = "```
262[ link ](url)
263```
264[ link ](url)";
265 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
266 let result = rule.check(&ctx).unwrap();
267 assert_eq!(result.len(), 1);
268 let fixed = rule.fix(&ctx).unwrap();
269 assert_eq!(
270 fixed,
271 "```
272[ link ](url)
273```
274[link](url)"
275 );
276 }
277
278 #[test]
279 fn test_multiple_links() {
280 let rule = MD039NoSpaceInLinks::new();
281 let content = "[ link ](url) and [ another ](url) in one line";
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](url) in one line");
287 }
288
289 #[test]
290 fn test_link_with_internal_spaces() {
291 let rule = MD039NoSpaceInLinks::new();
292 let content = "[this is link](url) and [ this is also link ](url)";
293 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
294 let result = rule.check(&ctx).unwrap();
295 assert_eq!(result.len(), 1);
296 let fixed = rule.fix(&ctx).unwrap();
297 assert_eq!(fixed, "[this is link](url) and [this is also link](url)");
298 }
299
300 #[test]
301 fn test_link_with_punctuation() {
302 let rule = MD039NoSpaceInLinks::new();
303 let content = "[ link! ](url) and [ link? ](url) here";
304 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
305 let result = rule.check(&ctx).unwrap();
306 assert_eq!(result.len(), 2);
307 let fixed = rule.fix(&ctx).unwrap();
308 assert_eq!(fixed, "[link!](url) and [link?](url) here");
309 }
310
311 #[test]
312 fn test_parity_only_whitespace_and_newlines_minimal() {
313 let rule = MD039NoSpaceInLinks::new();
314 let content = "[ \n ](url) and [\t\n\t](url)";
315 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
316 let fixed = rule.fix(&ctx).unwrap();
317 assert_eq!(fixed, "[](url) and [](url)");
319 }
320
321 #[test]
322 fn test_parity_internal_newlines_minimal() {
323 let rule = MD039NoSpaceInLinks::new();
324 let content = "[link\ntext](url) and [ another\nlink ](url)";
325 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
326 let fixed = rule.fix(&ctx).unwrap();
327 assert_eq!(fixed, "[link\ntext](url) and [another\nlink](url)");
329 }
330
331 #[test]
332 fn test_parity_escaped_brackets_minimal() {
333 let rule = MD039NoSpaceInLinks::new();
334 let content = "[link\\]](url) and [link\\[]](url)";
335 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
336 let fixed = rule.fix(&ctx).unwrap();
337 assert_eq!(fixed, "[link\\]](url) and [link\\[]](url)");
339 }
340
341 #[test]
342 fn test_performance_md039() {
343 use std::time::Instant;
344
345 let rule = MD039NoSpaceInLinks::new();
346
347 let mut content = String::with_capacity(100_000);
349
350 for i in 0..500 {
352 content.push_str(&format!("Line {i} with [ spaced link {i} ](url{i}) and text.\n"));
353 }
354
355 for i in 0..500 {
357 content.push_str(&format!(
358 "Line {} with [valid link {}](url{}) and text.\n",
359 i + 500,
360 i,
361 i
362 ));
363 }
364
365 println!(
366 "MD039 Performance Test - Content: {} bytes, {} lines",
367 content.len(),
368 content.lines().count()
369 );
370
371 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
372
373 let _ = rule.check(&ctx).unwrap();
375
376 let mut total_duration = std::time::Duration::ZERO;
378 let runs = 5;
379 let mut warnings_count = 0;
380
381 for _ in 0..runs {
382 let start = Instant::now();
383 let warnings = rule.check(&ctx).unwrap();
384 total_duration += start.elapsed();
385 warnings_count = warnings.len();
386 }
387
388 let avg_check_duration = total_duration / runs;
389
390 println!("MD039 Optimized Performance:");
391 println!(
392 "- Average check time: {:?} ({:.2} ms)",
393 avg_check_duration,
394 avg_check_duration.as_secs_f64() * 1000.0
395 );
396 println!("- Found {warnings_count} warnings");
397 println!(
398 "- Lines per second: {:.0}",
399 content.lines().count() as f64 / avg_check_duration.as_secs_f64()
400 );
401 println!(
402 "- Microseconds per line: {:.2}",
403 avg_check_duration.as_micros() as f64 / content.lines().count() as f64
404 );
405
406 assert!(
408 avg_check_duration.as_millis() < 200,
409 "MD039 check should complete in under 200ms, took {}ms",
410 avg_check_duration.as_millis()
411 );
412
413 assert_eq!(warnings_count, 500, "Should find 500 warnings for links with spaces");
415 }
416}