1use std::collections::HashSet;
2
3use crate::lint_context::LintContext;
4use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::html_elements::{extract_attribute_with_range, is_html_whitespace, start_tag_end};
6use crate::utils::regex_cache::URL_SIMPLE_REGEX;
7
8const ASCII_CONFUSABLE_RANGES: &[(char, char)] = &[
19 ('\u{00C6}', '\u{00C6}'),
20 ('\u{00E6}', '\u{00E6}'),
21 ('\u{00FE}', '\u{00FE}'),
22 ('\u{0131}', '\u{0133}'),
23 ('\u{0149}', '\u{0149}'),
24 ('\u{0152}', '\u{0153}'),
25 ('\u{017F}', '\u{017F}'),
26 ('\u{0181}', '\u{0181}'),
27 ('\u{0184}', '\u{0184}'),
28 ('\u{0187}', '\u{0187}'),
29 ('\u{018A}', '\u{018A}'),
30 ('\u{018D}', '\u{018D}'),
31 ('\u{0192}', '\u{0193}'),
32 ('\u{0196}', '\u{0196}'),
33 ('\u{0198}', '\u{0198}'),
34 ('\u{01A4}', '\u{01A4}'),
35 ('\u{01A6}', '\u{01A7}'),
36 ('\u{01AC}', '\u{01AC}'),
37 ('\u{01B3}', '\u{01B3}'),
38 ('\u{01B7}', '\u{01B7}'),
39 ('\u{01BC}', '\u{01BD}'),
40 ('\u{01BF}', '\u{01C1}'),
41 ('\u{01C3}', '\u{01C3}'),
42 ('\u{01C7}', '\u{01CC}'),
43 ('\u{01F1}', '\u{01F3}'),
44 ('\u{021C}', '\u{021C}'),
45 ('\u{0222}', '\u{0223}'),
46 ('\u{0237}', '\u{0237}'),
47 ('\u{0241}', '\u{0241}'),
48 ('\u{024C}', '\u{024C}'),
49 ('\u{0251}', '\u{0251}'),
50 ('\u{0261}', '\u{0261}'),
51 ('\u{0263}', '\u{0263}'),
52 ('\u{0269}', '\u{026A}'),
53 ('\u{026F}', '\u{026F}'),
54 ('\u{0284}', '\u{0284}'),
55 ('\u{028B}', '\u{028B}'),
56 ('\u{028F}', '\u{028F}'),
57 ('\u{0294}', '\u{0294}'),
58 ('\u{02A3}', '\u{02A3}'),
59 ('\u{02A6}', '\u{02A6}'),
60 ('\u{02AA}', '\u{02AB}'),
61 ('\u{037F}', '\u{037F}'),
62 ('\u{0384}', '\u{0384}'),
63 ('\u{0391}', '\u{0392}'),
64 ('\u{0395}', '\u{0397}'),
65 ('\u{0399}', '\u{039A}'),
66 ('\u{039C}', '\u{039D}'),
67 ('\u{039F}', '\u{039F}'),
68 ('\u{03A1}', '\u{03A1}'),
69 ('\u{03A4}', '\u{03A5}'),
70 ('\u{03A7}', '\u{03A7}'),
71 ('\u{03B1}', '\u{03B1}'),
72 ('\u{03B3}', '\u{03B3}'),
73 ('\u{03B9}', '\u{03B9}'),
74 ('\u{03BD}', '\u{03BD}'),
75 ('\u{03BF}', '\u{03BF}'),
76 ('\u{03C1}', '\u{03C1}'),
77 ('\u{03C3}', '\u{03C3}'),
78 ('\u{03C5}', '\u{03C5}'),
79 ('\u{03D2}', '\u{03D2}'),
80 ('\u{03DC}', '\u{03DC}'),
81 ('\u{03F1}', '\u{03F3}'),
82 ('\u{03F8}', '\u{03FA}'),
83 ('\u{0405}', '\u{0406}'),
84 ('\u{0408}', '\u{0408}'),
85 ('\u{0410}', '\u{0410}'),
86 ('\u{0412}', '\u{0412}'),
87 ('\u{0415}', '\u{0415}'),
88 ('\u{0417}', '\u{0417}'),
89 ('\u{041A}', '\u{041A}'),
90 ('\u{041C}', '\u{041E}'),
91 ('\u{0420}', '\u{0423}'),
92 ('\u{0425}', '\u{0425}'),
93 ('\u{042B}', '\u{042C}'),
94 ('\u{042E}', '\u{042E}'),
95 ('\u{0430}', '\u{0431}'),
96 ('\u{0433}', '\u{0433}'),
97 ('\u{0435}', '\u{0435}'),
98 ('\u{043E}', '\u{043E}'),
99 ('\u{0440}', '\u{0441}'),
100 ('\u{0443}', '\u{0443}'),
101 ('\u{0445}', '\u{0445}'),
102 ('\u{0448}', '\u{0448}'),
103 ('\u{0455}', '\u{0456}'),
104 ('\u{0458}', '\u{0458}'),
105 ('\u{0461}', '\u{0461}'),
106 ('\u{0474}', '\u{0475}'),
107 ('\u{0478}', '\u{0479}'),
108 ('\u{0491}', '\u{0491}'),
109 ('\u{04AE}', '\u{04AF}'),
110 ('\u{04BA}', '\u{04BB}'),
111 ('\u{04BD}', '\u{04BD}'),
112 ('\u{04C0}', '\u{04C0}'),
113 ('\u{04CF}', '\u{04CF}'),
114 ('\u{04D4}', '\u{04D5}'),
115 ('\u{04E0}', '\u{04E0}'),
116 ('\u{0501}', '\u{0501}'),
117 ('\u{050C}', '\u{050C}'),
118 ('\u{051A}', '\u{051D}'),
119 ('\u{0545}', '\u{0545}'),
120 ('\u{054D}', '\u{054D}'),
121 ('\u{054F}', '\u{054F}'),
122 ('\u{0555}', '\u{0555}'),
123 ('\u{055A}', '\u{055B}'),
124 ('\u{055D}', '\u{055D}'),
125 ('\u{0560}', '\u{0561}'),
126 ('\u{0563}', '\u{0563}'),
127 ('\u{0566}', '\u{0566}'),
128 ('\u{0570}', '\u{0570}'),
129 ('\u{0575}', '\u{0575}'),
130 ('\u{0578}', '\u{0578}'),
131 ('\u{057C}', '\u{057D}'),
132 ('\u{0581}', '\u{0582}'),
133 ('\u{0584}', '\u{0586}'),
134 ('\u{0589}', '\u{0589}'),
135 ('\u{1C82}', '\u{1C84}'),
136 ('\u{1D04}', '\u{1D04}'),
137 ('\u{1D0F}', '\u{1D0F}'),
138 ('\u{1D11}', '\u{1D11}'),
139 ('\u{1D1C}', '\u{1D1C}'),
140 ('\u{1D20}', '\u{1D22}'),
141 ('\u{1D26}', '\u{1D26}'),
142 ('\u{1D6B}', '\u{1D6B}'),
143 ('\u{1D83}', '\u{1D83}'),
144 ('\u{1D8C}', '\u{1D8C}'),
145 ('\u{1DBA}', '\u{1DBA}'),
146 ('\u{1E9A}', '\u{1E9A}'),
147 ('\u{1E9D}', '\u{1E9D}'),
148 ('\u{1EFA}', '\u{1EFA}'),
149 ('\u{1EFF}', '\u{1EFF}'),
150 ('\u{1FBD}', '\u{1FBD}'),
151 ('\u{1FBF}', '\u{1FC0}'),
152 ('\u{1FFE}', '\u{1FFE}'),
153 ('\u{2010}', '\u{2011}'),
154 ('\u{2044}', '\u{2044}'),
155 ('\u{2160}', '\u{217F}'),
156 ('\u{2215}', '\u{2215}'),
157 ('\u{2C6B}', '\u{2C6C}'),
158 ('\u{A644}', '\u{A644}'),
159 ('\u{A647}', '\u{A647}'),
160 ('\u{A698}', '\u{A699}'),
161 ('\u{A728}', '\u{A728}'),
162 ('\u{A731}', '\u{A73D}'),
163 ('\u{A74E}', '\u{A74F}'),
164 ('\u{A75A}', '\u{A75A}'),
165 ('\u{A76A}', '\u{A76A}'),
166 ('\u{A76E}', '\u{A76F}'),
167 ('\u{A777}', '\u{A778}'),
168 ('\u{A781}', '\u{A781}'),
169 ('\u{A78B}', '\u{A78C}'),
170 ('\u{A798}', '\u{A799}'),
171 ('\u{A79F}', '\u{A79F}'),
172 ('\u{A7AB}', '\u{A7AB}'),
173 ('\u{A7AE}', '\u{A7AE}'),
174 ('\u{A7B2}', '\u{A7B4}'),
175 ('\u{A7FA}', '\u{A7FA}'),
176 ('\u{A7FE}', '\u{A7FE}'),
177 ('\u{AB32}', '\u{AB32}'),
178 ('\u{AB35}', '\u{AB35}'),
179 ('\u{AB3D}', '\u{AB3D}'),
180 ('\u{AB43}', '\u{AB43}'),
181 ('\u{AB47}', '\u{AB48}'),
182 ('\u{AB4E}', '\u{AB4E}'),
183 ('\u{AB52}', '\u{AB52}'),
184 ('\u{AB5A}', '\u{AB5A}'),
185 ('\u{AB63}', '\u{AB64}'),
186 ('\u{FB00}', '\u{FB06}'),
187 ('\u{FF01}', '\u{FF04}'),
188 ('\u{FF06}', '\u{FF0C}'),
189 ('\u{FF0E}', '\u{FF5E}'),
190 ('\u{10140}', '\u{10141}'),
191 ('\u{1015B}', '\u{1015B}'),
192 ('\u{1017E}', '\u{1017E}'),
193 ('\u{1018B}', '\u{1018B}'),
194 ('\u{1D206}', '\u{1D207}'),
195 ('\u{1D20C}', '\u{1D20D}'),
196 ('\u{1D20F}', '\u{1D20F}'),
197 ('\u{1D212}', '\u{1D213}'),
198 ('\u{1D216}', '\u{1D216}'),
199 ('\u{1D22A}', '\u{1D22A}'),
200 ('\u{1D236}', '\u{1D237}'),
201 ('\u{1D23A}', '\u{1D23B}'),
202 ('\u{1D400}', '\u{1D454}'),
203 ('\u{1D456}', '\u{1D49C}'),
204 ('\u{1D49E}', '\u{1D49F}'),
205 ('\u{1D4A2}', '\u{1D4A2}'),
206 ('\u{1D4A5}', '\u{1D4A6}'),
207 ('\u{1D4A9}', '\u{1D4AC}'),
208 ('\u{1D4AE}', '\u{1D4B9}'),
209 ('\u{1D4BB}', '\u{1D4BB}'),
210 ('\u{1D4BD}', '\u{1D4C3}'),
211 ('\u{1D4C5}', '\u{1D505}'),
212 ('\u{1D507}', '\u{1D50A}'),
213 ('\u{1D50D}', '\u{1D514}'),
214 ('\u{1D516}', '\u{1D51C}'),
215 ('\u{1D51E}', '\u{1D539}'),
216 ('\u{1D53B}', '\u{1D53E}'),
217 ('\u{1D540}', '\u{1D544}'),
218 ('\u{1D546}', '\u{1D546}'),
219 ('\u{1D54A}', '\u{1D550}'),
220 ('\u{1D552}', '\u{1D6A5}'),
221 ('\u{1D6A8}', '\u{1D6A9}'),
222 ('\u{1D6AC}', '\u{1D6AE}'),
223 ('\u{1D6B0}', '\u{1D6B1}'),
224 ('\u{1D6B3}', '\u{1D6B4}'),
225 ('\u{1D6B6}', '\u{1D6B6}'),
226 ('\u{1D6B8}', '\u{1D6B8}'),
227 ('\u{1D6BB}', '\u{1D6BC}'),
228 ('\u{1D6BE}', '\u{1D6BE}'),
229 ('\u{1D6C2}', '\u{1D6C2}'),
230 ('\u{1D6C4}', '\u{1D6C4}'),
231 ('\u{1D6CA}', '\u{1D6CA}'),
232 ('\u{1D6CE}', '\u{1D6CE}'),
233 ('\u{1D6D0}', '\u{1D6D0}'),
234 ('\u{1D6D2}', '\u{1D6D2}'),
235 ('\u{1D6D4}', '\u{1D6D4}'),
236 ('\u{1D6D6}', '\u{1D6D6}'),
237 ('\u{1D6E0}', '\u{1D6E0}'),
238 ('\u{1D6E2}', '\u{1D6E3}'),
239 ('\u{1D6E6}', '\u{1D6E8}'),
240 ('\u{1D6EA}', '\u{1D6EB}'),
241 ('\u{1D6ED}', '\u{1D6EE}'),
242 ('\u{1D6F0}', '\u{1D6F0}'),
243 ('\u{1D6F2}', '\u{1D6F2}'),
244 ('\u{1D6F5}', '\u{1D6F6}'),
245 ('\u{1D6F8}', '\u{1D6F8}'),
246 ('\u{1D6FC}', '\u{1D6FC}'),
247 ('\u{1D6FE}', '\u{1D6FE}'),
248 ('\u{1D704}', '\u{1D704}'),
249 ('\u{1D708}', '\u{1D708}'),
250 ('\u{1D70A}', '\u{1D70A}'),
251 ('\u{1D70C}', '\u{1D70C}'),
252 ('\u{1D70E}', '\u{1D70E}'),
253 ('\u{1D710}', '\u{1D710}'),
254 ('\u{1D71A}', '\u{1D71A}'),
255 ('\u{1D71C}', '\u{1D71D}'),
256 ('\u{1D720}', '\u{1D722}'),
257 ('\u{1D724}', '\u{1D725}'),
258 ('\u{1D727}', '\u{1D728}'),
259 ('\u{1D72A}', '\u{1D72A}'),
260 ('\u{1D72C}', '\u{1D72C}'),
261 ('\u{1D72F}', '\u{1D730}'),
262 ('\u{1D732}', '\u{1D732}'),
263 ('\u{1D736}', '\u{1D736}'),
264 ('\u{1D738}', '\u{1D738}'),
265 ('\u{1D73E}', '\u{1D73E}'),
266 ('\u{1D742}', '\u{1D742}'),
267 ('\u{1D744}', '\u{1D744}'),
268 ('\u{1D746}', '\u{1D746}'),
269 ('\u{1D748}', '\u{1D748}'),
270 ('\u{1D74A}', '\u{1D74A}'),
271 ('\u{1D754}', '\u{1D754}'),
272 ('\u{1D756}', '\u{1D757}'),
273 ('\u{1D75A}', '\u{1D75C}'),
274 ('\u{1D75E}', '\u{1D75F}'),
275 ('\u{1D761}', '\u{1D762}'),
276 ('\u{1D764}', '\u{1D764}'),
277 ('\u{1D766}', '\u{1D766}'),
278 ('\u{1D769}', '\u{1D76A}'),
279 ('\u{1D76C}', '\u{1D76C}'),
280 ('\u{1D770}', '\u{1D770}'),
281 ('\u{1D772}', '\u{1D772}'),
282 ('\u{1D778}', '\u{1D778}'),
283 ('\u{1D77C}', '\u{1D77C}'),
284 ('\u{1D77E}', '\u{1D77E}'),
285 ('\u{1D780}', '\u{1D780}'),
286 ('\u{1D782}', '\u{1D782}'),
287 ('\u{1D784}', '\u{1D784}'),
288 ('\u{1D78E}', '\u{1D78E}'),
289 ('\u{1D790}', '\u{1D791}'),
290 ('\u{1D794}', '\u{1D796}'),
291 ('\u{1D798}', '\u{1D799}'),
292 ('\u{1D79B}', '\u{1D79C}'),
293 ('\u{1D79E}', '\u{1D79E}'),
294 ('\u{1D7A0}', '\u{1D7A0}'),
295 ('\u{1D7A3}', '\u{1D7A4}'),
296 ('\u{1D7A6}', '\u{1D7A6}'),
297 ('\u{1D7AA}', '\u{1D7AA}'),
298 ('\u{1D7AC}', '\u{1D7AC}'),
299 ('\u{1D7B2}', '\u{1D7B2}'),
300 ('\u{1D7B6}', '\u{1D7B6}'),
301 ('\u{1D7B8}', '\u{1D7B8}'),
302 ('\u{1D7BA}', '\u{1D7BA}'),
303 ('\u{1D7BC}', '\u{1D7BC}'),
304 ('\u{1D7BE}', '\u{1D7BE}'),
305 ('\u{1D7C8}', '\u{1D7C8}'),
306 ('\u{1D7CA}', '\u{1D7CA}'),
307 ('\u{1D7CE}', '\u{1D7FF}'),
308 ('\u{1DF5A}', '\u{1DF5A}'),
309 ('\u{1DF5D}', '\u{1DF5F}'),
310 ('\u{1DF64}', '\u{1DF65}'),
311 ('\u{1DF6A}', '\u{1DF6A}'),
312 ('\u{1DF7D}', '\u{1DF7D}'),
313 ('\u{1DF81}', '\u{1DF81}'),
314];
315
316fn ascii_confusable(c: char) -> bool {
317 if c.is_ascii() {
318 return false;
319 }
320 if c == '\u{3002}' {
323 return true;
324 }
325 let after = ASCII_CONFUSABLE_RANGES.partition_point(|(start, _)| *start <= c);
326 after > 0 && c <= ASCII_CONFUSABLE_RANGES[after - 1].1
327}
328
329#[derive(Clone, Copy, PartialEq)]
330enum DestinationSyntax {
331 Literal,
332 Markdown,
333 Html,
334}
335
336const HTML_LEGACY_ENTITIES: &[(&str, &str)] = &[
339 ("Aacute", "\u{C1}"),
340 ("Agrave", "\u{C0}"),
341 ("Atilde", "\u{C3}"),
342 ("Ccedil", "\u{C7}"),
343 ("Eacute", "\u{C9}"),
344 ("Egrave", "\u{C8}"),
345 ("Iacute", "\u{CD}"),
346 ("Igrave", "\u{CC}"),
347 ("Ntilde", "\u{D1}"),
348 ("Oacute", "\u{D3}"),
349 ("Ograve", "\u{D2}"),
350 ("Oslash", "\u{D8}"),
351 ("Otilde", "\u{D5}"),
352 ("Uacute", "\u{DA}"),
353 ("Ugrave", "\u{D9}"),
354 ("Yacute", "\u{DD}"),
355 ("aacute", "\u{E1}"),
356 ("agrave", "\u{E0}"),
357 ("atilde", "\u{E3}"),
358 ("brvbar", "\u{A6}"),
359 ("ccedil", "\u{E7}"),
360 ("curren", "\u{A4}"),
361 ("divide", "\u{F7}"),
362 ("eacute", "\u{E9}"),
363 ("egrave", "\u{E8}"),
364 ("frac12", "\u{BD}"),
365 ("frac14", "\u{BC}"),
366 ("frac34", "\u{BE}"),
367 ("iacute", "\u{ED}"),
368 ("igrave", "\u{EC}"),
369 ("iquest", "\u{BF}"),
370 ("middot", "\u{B7}"),
371 ("ntilde", "\u{F1}"),
372 ("oacute", "\u{F3}"),
373 ("ograve", "\u{F2}"),
374 ("oslash", "\u{F8}"),
375 ("otilde", "\u{F5}"),
376 ("plusmn", "\u{B1}"),
377 ("uacute", "\u{FA}"),
378 ("ugrave", "\u{F9}"),
379 ("yacute", "\u{FD}"),
380 ("AElig", "\u{C6}"),
381 ("Acirc", "\u{C2}"),
382 ("Aring", "\u{C5}"),
383 ("Ecirc", "\u{CA}"),
384 ("Icirc", "\u{CE}"),
385 ("Ocirc", "\u{D4}"),
386 ("THORN", "\u{DE}"),
387 ("Ucirc", "\u{DB}"),
388 ("acirc", "\u{E2}"),
389 ("acute", "\u{B4}"),
390 ("aelig", "\u{E6}"),
391 ("aring", "\u{E5}"),
392 ("cedil", "\u{B8}"),
393 ("ecirc", "\u{EA}"),
394 ("icirc", "\u{EE}"),
395 ("iexcl", "\u{A1}"),
396 ("laquo", "\u{AB}"),
397 ("micro", "\u{B5}"),
398 ("ocirc", "\u{F4}"),
399 ("pound", "\u{A3}"),
400 ("raquo", "\u{BB}"),
401 ("szlig", "\u{DF}"),
402 ("thorn", "\u{FE}"),
403 ("times", "\u{D7}"),
404 ("ucirc", "\u{FB}"),
405 ("Auml", "\u{C4}"),
406 ("COPY", "\u{A9}"),
407 ("Euml", "\u{CB}"),
408 ("Iuml", "\u{CF}"),
409 ("Ouml", "\u{D6}"),
410 ("QUOT", "\u{22}"),
411 ("Uuml", "\u{DC}"),
412 ("auml", "\u{E4}"),
413 ("cent", "\u{A2}"),
414 ("copy", "\u{A9}"),
415 ("euml", "\u{EB}"),
416 ("iuml", "\u{EF}"),
417 ("macr", "\u{AF}"),
418 ("nbsp", "\u{A0}"),
419 ("ordf", "\u{AA}"),
420 ("ordm", "\u{BA}"),
421 ("ouml", "\u{F6}"),
422 ("para", "\u{B6}"),
423 ("quot", "\u{22}"),
424 ("sect", "\u{A7}"),
425 ("sup1", "\u{B9}"),
426 ("sup2", "\u{B2}"),
427 ("sup3", "\u{B3}"),
428 ("uuml", "\u{FC}"),
429 ("yuml", "\u{FF}"),
430 ("AMP", "\u{26}"),
431 ("ETH", "\u{D0}"),
432 ("REG", "\u{AE}"),
433 ("amp", "\u{26}"),
434 ("deg", "\u{B0}"),
435 ("eth", "\u{F0}"),
436 ("not", "\u{AC}"),
437 ("reg", "\u{AE}"),
438 ("shy", "\u{AD}"),
439 ("uml", "\u{A8}"),
440 ("yen", "\u{A5}"),
441 ("GT", "\u{3E}"),
442 ("LT", "\u{3C}"),
443 ("gt", "\u{3E}"),
444 ("lt", "\u{3C}"),
445];
446
447fn decode_entity(raw: &str, syntax: DestinationSyntax) -> Option<(String, usize)> {
450 if syntax == DestinationSyntax::Literal || !raw.starts_with('&') {
451 return None;
452 }
453 if syntax == DestinationSyntax::Html && raw.starts_with("&#") {
454 let hex = raw.as_bytes().get(2).is_some_and(|b| matches!(b, b'x' | b'X'));
455 let start = if hex { 3 } else { 2 };
456 let radix = if hex { 16 } else { 10 };
457 let mut end = start;
458 let mut value = 0u32;
459 while let Some(digit) = raw.as_bytes().get(end).and_then(|b| (*b as char).to_digit(radix)) {
460 value = value.saturating_mul(radix).saturating_add(digit);
461 end += 1;
462 }
463 if end == start {
464 return None;
465 }
466 if raw.as_bytes().get(end) == Some(&b';') {
467 end += 1;
468 }
469 const C1: [u32; 32] = [
471 0x20AC, 0x81, 0x201A, 0x192, 0x201E, 0x2026, 0x2020, 0x2021, 0x2C6, 0x2030, 0x160, 0x2039, 0x152, 0x8D,
472 0x17D, 0x8F, 0x90, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x2DC, 0x2122, 0x161, 0x203A,
473 0x153, 0x9D, 0x17E, 0x178,
474 ];
475 let value = if (0x80..=0x9F).contains(&value) {
476 C1[(value - 0x80) as usize]
477 } else {
478 value
479 };
480 let character = char::from_u32(value).filter(|c| *c != '\0').unwrap_or('\u{FFFD}');
481 return Some((character.to_string(), end));
482 }
483 if syntax == DestinationSyntax::Html {
484 for (name, value) in HTML_LEGACY_ENTITIES {
485 if let Some(rest) = raw[1..].strip_prefix(name)
486 && rest
487 .as_bytes()
488 .first()
489 .is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'=')
490 && !rest.starts_with(';')
491 {
492 return Some(((*value).to_string(), name.len() + 1));
493 }
494 }
495 }
496 let end = raw.as_bytes().iter().take(34).position(|b| *b == b';')? + 1;
497 let entity = &raw[..end];
498 if !entity[1..end - 1]
499 .bytes()
500 .all(|b| b.is_ascii_alphanumeric() || b == b'#')
501 {
502 return None;
503 }
504 let decoded: String = pulldown_cmark::Parser::new(entity)
505 .filter_map(|event| match event {
506 pulldown_cmark::Event::Text(text) => Some(text.into_string()),
507 _ => None,
508 })
509 .collect();
510 (decoded != entity).then_some((decoded, end))
511}
512
513fn first_source_confusable(raw: &str, syntax: DestinationSyntax) -> Option<(std::ops::Range<usize>, char)> {
516 let mut offset = 0;
517 while offset < raw.len() {
518 let c = raw[offset..].chars().next()?;
519 if syntax == DestinationSyntax::Markdown
520 && c == '\\'
521 && raw.as_bytes().get(offset + 1).is_some_and(u8::is_ascii_punctuation)
522 {
523 offset += 2;
524 continue;
525 }
526 if c == '&'
527 && let Some((decoded, consumed)) = decode_entity(&raw[offset..], syntax)
528 {
529 if let Some(character) = decoded.chars().find(|c| ascii_confusable(*c)) {
530 return Some((offset..offset + consumed, character));
531 }
532 offset += consumed;
533 continue;
534 }
535 if ascii_confusable(c) {
536 return Some((offset..offset + c.len_utf8(), c));
537 }
538 offset += c.len_utf8();
539 }
540 None
541}
542
543fn destination_range(source: &str, start: usize, inline: bool) -> Option<std::ops::Range<usize>> {
545 let rest = source.get(start..)?;
546 let start = start + rest.len() - rest.trim_start_matches([' ', '\t', '\r', '\n']).len();
547 let angle = source.as_bytes().get(start) == Some(&b'<');
548 let begin = start + usize::from(angle);
549 let mut depth = 0usize;
550 let mut chars = source.get(begin..)?.char_indices();
551 while let Some((i, c)) = chars.next() {
552 if c == '\\'
553 && source
554 .as_bytes()
555 .get(begin + i + 1)
556 .is_some_and(u8::is_ascii_punctuation)
557 {
558 chars.next();
559 continue;
560 }
561 if angle {
562 if c == '>' {
563 return Some(begin..begin + i);
564 }
565 } else if (c == ' ' || c.is_ascii_control()) || (inline && c == ')' && depth == 0) {
566 return Some(begin..begin + i);
567 } else if inline && c == '(' {
568 depth += 1;
569 } else if inline && c == ')' {
570 depth = depth.saturating_sub(1);
571 }
572 }
573 (!angle).then_some(begin..source.len())
574}
575
576fn reference_destination_start(raw: &str) -> Option<usize> {
577 let mut chars = raw.char_indices();
578 while let Some((offset, c)) = chars.next() {
579 if c == '\\' {
580 chars.next();
581 } else if c == ']' && raw.as_bytes().get(offset + 1) == Some(&b':') {
582 return Some(offset + 2);
583 }
584 }
585 None
586}
587
588#[derive(Debug, Default, Clone)]
589pub struct MD095LinkConfusables;
590
591fn html_source_tags<'a>(
595 ctx: &LintContext<'_>,
596 content: &'a str,
597 html_regions: &[std::ops::Range<usize>],
598) -> Vec<(&'a str, std::ops::Range<usize>)> {
599 let mut tags = Vec::new();
600 for region in html_regions {
601 let mut offset = region.start;
602 while let Some(relative) = content[offset..region.end].find('<') {
603 let start = offset + relative;
604 offset = start + 1;
605 let (line, _) = ctx.offset_to_line_col(start);
606 if ctx.is_in_html_comment(start)
607 || ctx.is_byte_offset_in_code_span(start)
608 || ctx.line_info(line).is_some_and(|info| {
609 info.in_code_block
610 || info.in_front_matter
611 || info.in_obsidian_comment
612 || info.in_mdx_comment
613 || info.is_myst_comment
614 })
615 {
616 continue;
617 }
618 let bytes = content.as_bytes();
619 if !bytes.get(offset).is_some_and(u8::is_ascii_alphabetic) {
620 continue;
621 }
622 let name_start = offset;
623 while offset < region.end && (bytes[offset].is_ascii_alphanumeric() || matches!(bytes[offset], b'-' | b':'))
624 {
625 offset += 1;
626 }
627 let name_end = offset;
628 if offset >= region.end || !(is_html_whitespace(&bytes[offset]) || matches!(bytes[offset], b'/' | b'>')) {
629 continue;
630 }
631 let Some(length) = start_tag_end(&content[start..region.end]) else {
632 break;
633 };
634 offset = start + length;
635 let name = &content[name_start..name_end];
636 tags.push((name, start..offset));
637 if [
640 "script", "style", "textarea", "title", "xmp", "iframe", "noembed", "noframes",
641 ]
642 .iter()
643 .any(|expected| name.eq_ignore_ascii_case(expected))
644 {
645 let closing = format!("</{}", name.to_ascii_lowercase());
646 let remaining = &content[offset..region.end];
647 let mut search = 0;
648 let mut end = None;
649 while let Some(relative) = remaining.as_bytes()[search..]
650 .windows(closing.len())
651 .position(|candidate| candidate.eq_ignore_ascii_case(closing.as_bytes()))
652 {
653 let candidate = search + relative;
654 let after_name = candidate + closing.len();
655 if remaining
656 .as_bytes()
657 .get(after_name)
658 .is_some_and(|b| is_html_whitespace(b) || matches!(b, b'/' | b'>'))
659 {
660 let Some(close) = remaining[after_name..].find('>') else {
661 break;
662 };
663 end = Some(offset + after_name + close + 1);
664 break;
665 }
666 search = after_name;
667 }
668 let Some(end) = end else {
669 break;
670 };
671 offset = end;
672 }
673 }
674 }
675 tags
676}
677
678fn merge_source_spans(mut spans: Vec<std::ops::Range<usize>>) -> Vec<std::ops::Range<usize>> {
679 spans.sort_by_key(|span| (span.start, span.end));
680 let mut merged: Vec<std::ops::Range<usize>> = Vec::new();
681 for span in spans {
682 if let Some(previous) = merged.last_mut()
683 && span.start <= previous.end
684 {
685 previous.end = previous.end.max(span.end);
686 } else {
687 merged.push(span);
688 }
689 }
690 merged
691}
692
693impl Rule for MD095LinkConfusables {
694 fn name(&self) -> &'static str {
695 "MD095"
696 }
697
698 fn description(&self) -> &'static str {
699 "Link destinations should not contain confusable non-ASCII characters"
700 }
701
702 fn category(&self) -> RuleCategory {
703 RuleCategory::Link
704 }
705
706 fn skippable_by_category(&self) -> bool {
707 false
710 }
711
712 fn fix_capability(&self) -> FixCapability {
713 FixCapability::Unfixable
714 }
715
716 fn should_skip(&self, ctx: &LintContext) -> bool {
717 !ctx.content.contains('&') && !ctx.content.chars().any(ascii_confusable)
718 }
719
720 fn check(&self, ctx: &LintContext) -> LintResult {
721 let mut warnings = Vec::new();
722 let mut reported = HashSet::new();
723
724 let mut report = |offset: usize, source: &str, kind: &str, syntax: DestinationSyntax| {
725 let Some((character_range, character)) = first_source_confusable(source, syntax) else {
726 return;
727 };
728 let character_byte = offset + character_range.start;
729 if !reported.insert(character_byte) {
730 return;
731 }
732 let (line, column) = ctx.offset_to_line_col(character_byte);
733 if ctx.is_byte_offset_in_code_span(character_byte)
734 || ctx.is_in_html_comment(character_byte)
735 || ctx.line_info(line).is_some_and(|info| {
736 info.in_code_block
737 || info.in_front_matter
738 || info.in_obsidian_comment
739 || info.in_mdx_comment
740 || info.is_myst_comment
741 })
742 {
743 return;
744 }
745 let (end_line, end_column) = ctx.offset_to_line_col(offset + character_range.end);
746 warnings.push(LintWarning {
747 rule_name: Some(self.name().to_string()),
748 line,
749 column,
750 end_line,
751 end_column,
752 message: format!(
753 "{kind} contains confusable non-ASCII character U+{:04X} ({character:?})",
754 character as u32
755 ),
756 severity: Severity::Warning,
757 fix: None,
758 });
759 };
760
761 let native = pulldown_cmark::Parser::new_ext(ctx.content, crate::utils::rumdl_parser_options());
762 let native_definitions: Vec<_> = native
763 .reference_definitions()
764 .iter()
765 .map(|(_, definition)| (definition.span.clone(), definition.dest.chars().any(ascii_confusable)))
766 .collect();
767 let mut html_ranges = Vec::new();
768 let mut html_bytes = vec![b' '; ctx.content.len()];
769 for (event, span) in native.into_offset_iter() {
770 match event {
771 pulldown_cmark::Event::Start(pulldown_cmark::Tag::HtmlBlock) => html_ranges.push(span),
772 pulldown_cmark::Event::Html(text) | pulldown_cmark::Event::InlineHtml(text) => {
773 let source = &ctx.content[span.clone()];
777 html_bytes[span.clone()].copy_from_slice(source.as_bytes());
778 let mut offset = span.start;
779 for (raw_line, rendered_line) in source.split_inclusive('\n').zip(text.split_inclusive('\n')) {
780 let raw = raw_line.trim_end_matches(['\r', '\n']);
781 let rendered = rendered_line.trim_end_matches(['\r', '\n']);
782 if raw.ends_with(rendered) {
783 let omitted = raw.len() - rendered.len();
784 html_bytes[offset..offset + omitted].fill(b' ');
785 }
786 offset += raw_line.len();
787 }
788 html_ranges.push(span);
789 }
790 _ => {}
791 }
792 }
793 let html_source = String::from_utf8(html_bytes).expect("HTML source spans preserve UTF-8 boundaries");
794 let html_spans = merge_source_spans(html_ranges);
795 let in_raw_html = |offset| {
796 let after = html_spans.partition_point(|span| span.start <= offset);
797 after > 0
798 && offset < html_spans[after - 1].end
799 && !ctx
800 .line_info(ctx.offset_to_line_col(offset).0)
801 .is_some_and(crate::lint_context::types::LineInfo::in_mkdocs_container)
802 };
803
804 let links = ctx.links();
805 for link in links {
806 if link.is_reference || in_raw_html(link.byte_offset) {
807 continue;
808 }
809 let raw_link = &ctx.content[link.byte_offset..link.byte_end];
810 let (range, syntax) = match link.link_type {
811 pulldown_cmark::LinkType::Inline => {
812 let Some(range) = destination_range(raw_link, link.text.len() + 3, true) else {
813 continue;
814 };
815 (range, DestinationSyntax::Markdown)
816 }
817 _ => {
818 let Some(start) = raw_link.find(link.url.as_ref()) else {
819 continue;
820 };
821 (start..start + link.url.len(), DestinationSyntax::Literal)
822 }
823 };
824 report(
825 link.byte_offset + range.start,
826 &raw_link[range],
827 "Link destination",
828 syntax,
829 );
830 }
831
832 let definitions = ctx.reference_definitions();
833 let mut reference_ranges: Vec<_> = definitions
834 .iter()
835 .map(|definition| definition.byte_offset..definition.byte_end)
836 .collect();
837 for definition in definitions {
838 if in_raw_html(definition.byte_offset) {
839 continue;
840 }
841 let raw_definition = &ctx.content[definition.byte_offset..definition.byte_end];
842 if let Some(start) = reference_destination_start(raw_definition)
843 && let Some(range) = destination_range(raw_definition, start, false)
844 {
845 report(
846 definition.byte_offset + range.start,
847 &raw_definition[range],
848 "Link reference destination",
849 DestinationSyntax::Markdown,
850 );
851 }
852 }
853 for (span, has_confusable) in native_definitions {
857 reference_ranges.push(span.clone());
858 if !has_confusable {
859 continue;
860 }
861 let raw = &ctx.content[span.clone()];
862 if let Some(start) = reference_destination_start(raw) {
863 report(
866 span.start + start,
867 &raw[start..],
868 "Link reference destination",
869 DestinationSyntax::Markdown,
870 );
871 }
872 }
873 let reference_spans = merge_source_spans(reference_ranges);
874 let source_tags = html_source_tags(ctx, &html_source, &html_spans);
875 let image_spans = merge_source_spans(
876 ctx.images()
877 .iter()
878 .map(|image| image.byte_offset..image.byte_end)
879 .collect(),
880 );
881 for url in URL_SIMPLE_REGEX.find_iter(ctx.content) {
885 let after = source_tags.partition_point(|(_, span)| span.start <= url.start());
888 if after > 0 && url.start() < source_tags[after - 1].1.end && !source_tags[after - 1].0.ends_with(':') {
891 continue;
892 }
893 if in_raw_html(url.start()) {
894 continue;
895 }
896 let after = links.partition_point(|link| link.byte_offset <= url.start());
897 if after > 0 && url.start() < links[after - 1].byte_end {
898 continue;
899 }
900 let after = image_spans.partition_point(|span| span.start <= url.start());
901 if after > 0 && url.start() < image_spans[after - 1].end {
902 continue;
903 }
904 let after = reference_spans.partition_point(|span| span.start <= url.start());
905 if after > 0 && url.start() < reference_spans[after - 1].end {
906 continue;
907 }
908 let mut destination = url.as_str().trim_end_matches(['.', ',', ';', ':', '!', '?']);
909 let opens = destination.bytes().filter(|b| *b == b'(').count();
910 let mut closes = destination.bytes().filter(|b| *b == b')').count();
911 while closes > opens && destination.ends_with(')') {
912 destination = &destination[..destination.len() - 1];
913 closes -= 1;
914 }
915 report(url.start(), destination, "Bare URL", DestinationSyntax::Literal);
916 }
917
918 for (name, span) in &source_tags {
919 if !["a", "area", "link"]
920 .iter()
921 .any(|expected| name.eq_ignore_ascii_case(expected))
922 {
923 continue;
924 }
925 let raw_tag = &html_source[span.clone()];
926 let Some((value, value_range)) = extract_attribute_with_range(raw_tag, "href") else {
927 continue;
928 };
929 let absolute_offset = span.start + value_range.start;
930 if ctx.is_byte_offset_in_code_span(absolute_offset) || ctx.is_in_html_comment(absolute_offset) {
931 continue;
932 }
933 report(
934 absolute_offset,
935 &value,
936 "HTML link destination",
937 DestinationSyntax::Html,
938 );
939 }
940
941 warnings.sort_by_key(|warning| (warning.line, warning.column));
942 Ok(warnings)
943 }
944
945 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
946 Ok(ctx.content.to_string())
947 }
948
949 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
950 where
951 Self: Sized,
952 {
953 Box::new(Self)
954 }
955
956 fn as_any(&self) -> &dyn std::any::Any {
957 self
958 }
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964 use crate::config::MarkdownFlavor;
965
966 #[test]
967 fn mkdocs_angle_autolinks_are_not_html_attribute_spans() {
968 for content in [
969 "<div markdown>\n<https://еxample.com>\n</div>\n",
970 "<div class=\"grid cards\" markdown>\n- See <https://еxample.com>\n</div>\n",
971 ] {
972 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
973 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
974 assert_eq!(warnings.len(), 1, "{content}");
975 assert_eq!(warnings[0].line, 2);
976 let line = content.lines().nth(1).unwrap();
977 assert_eq!(warnings[0].column, line[..line.find('е').unwrap()].chars().count() + 1);
978 }
979 let content = "<div>\n<https://еxample.com>\n</div>\n";
980 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
981 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
982 }
983
984 #[test]
985 fn html_attribute_names_and_mkdocs_titles_keep_browser_semantics() {
986 let content = "<p><a href\u{000B} href=\"https://еxample.com\">Docs</a></p>";
987 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
988 assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1);
989 let content =
990 "<div markdown>\n<a title=\"https://еxample.com\" href=\"https://example.com\">Docs</a>\n</div>\n";
991 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
992 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
993 let content = "<div markdown>\n<a href=\"https://еxample.com\">Docs</a>\n</div>\n";
994 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
995 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
996 assert_eq!(warnings.len(), 1);
997 assert!(warnings[0].message.starts_with("HTML link destination"));
998 }
999
1000 #[test]
1001 fn multiline_inline_html_uses_rendered_prefixes_with_exact_source_spans() {
1002 for (content, marker) in [
1003 ("> <a\n> href=\"https://еxample.com\">Docs</a>\n", "е"),
1004 ("> <a\n> href='е'>x</a>\n", "е"),
1005 ("> <a\r\n> href='е'>x</a>\r\n", "е"),
1006 ] {
1007 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1008 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1009 assert_eq!(warnings.len(), 1, "{content}");
1010 assert_eq!(warnings[0].line, 2);
1011 let line = content.lines().nth(1).unwrap();
1012 assert_eq!(
1013 warnings[0].column,
1014 line[..line.find(marker).unwrap()].chars().count() + 1
1015 );
1016 assert_eq!(warnings[0].end_column - warnings[0].column, marker.chars().count());
1017 }
1018 for content in ["<a\n>Docs</a>", "<a\n> href='е'>Docs</a>"] {
1019 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1020 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1021 }
1022 }
1023
1024 #[test]
1025 fn multiline_html_attributes_in_blockquotes_keep_original_source_columns() {
1026 let content = "> <div>\n> <a\n> href=\"https://еxample.com\">Docs</a>\n> </div>\n";
1027 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1028 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1029 assert_eq!(warnings.len(), 1);
1030 assert_eq!(warnings[0].line, 3);
1031 assert_eq!(
1032 warnings[0].column,
1033 content.lines().nth(2).unwrap().find("е").unwrap() + 1
1034 );
1035 assert_eq!(warnings[0].end_column - warnings[0].column, "е".len());
1036 }
1037
1038 #[test]
1039 fn native_html_boundaries_preserve_following_links_and_mkdocs_markdown() {
1040 for (content, flavor) in [
1041 (
1042 "<pre>\nx\n</pre>\n[Download](https://еxample.com/setup.exe)\n",
1043 MarkdownFlavor::Standard,
1044 ),
1045 (
1046 "<script>\nvar a = 1;\n</script>\n<https://еxample.com>\n",
1047 MarkdownFlavor::Standard,
1048 ),
1049 (
1050 "<div class=\"grid cards\" markdown>\n- [Docs](https://еxample.com)\n</div>\n",
1051 MarkdownFlavor::MkDocs,
1052 ),
1053 (
1054 "- <div>\n <a\n href=\"https://еxample.com\">Docs</a>\n </div>\n",
1055 MarkdownFlavor::Standard,
1056 ),
1057 ] {
1058 let ctx = LintContext::new(content, flavor, None);
1059 assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{content}");
1060 }
1061 let content = "<span>\n[id]: https://еxample.com\n";
1062 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1063 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
1064 }
1065
1066 #[test]
1067 fn markdown_like_text_in_raw_html_is_not_a_link() {
1068 for content in [
1069 "<div>\n[id]: https://еxample.com\n</div>\n",
1070 "<div>\n[x](https://еxample.com)\n</div>\n",
1071 "<div>\nSee https://еxample.com today.\n</div>\n",
1072 ] {
1073 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1074 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1075 }
1076 }
1077
1078 #[test]
1079 fn raw_text_html_bodies_do_not_hide_following_anchors_or_create_links() {
1080 for name in ["script", "style", "textarea"] {
1081 let content = format!(
1082 "<{name}>\nconst template = '<b title=\"draft';\n</{name}>\n<a href=\"https://еxample.com\">Docs</a>\n"
1083 );
1084 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
1085 assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{content}");
1086 let content = format!("<{name}>\n<a href=\"https://еxample.com\">Text only</a>\n</{name}>\n");
1087 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
1088 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1089 }
1090 }
1091
1092 #[test]
1093 fn code_and_comment_text_cannot_swallow_later_real_links() {
1094 for content in [
1095 "Use `a <b c` here.\n\n<a href=\"https://еxample.com\">Docs</a>",
1096 "```sh\nkubectl apply -f - <<EOF\nkind: Pod\nEOF\n```\n\n<a href=\"https://еxample.com\">Docs</a>",
1097 "```sh\ncat <<EOF\nhi\nEOF\n```\n\nSee https://еxample.com today.\n\n> quote",
1098 "<!-- note: <x title=\"draft -->\n\n<a href=\"https://еxample.com\">Docs</a>",
1099 "Use \\<b here.\n\n<a href=\"https://еxample.com\">Docs</a>",
1100 ] {
1101 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1102 assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{content}");
1103 }
1104 }
1105
1106 #[test]
1107 fn image_destinations_and_alt_text_are_excluded() {
1108 for content in [
1109 "",
1110 "",
1111 "",
1112 ] {
1113 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1114 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1115 }
1116 let content = "[](https://еxample.com)";
1117 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1118 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1119 assert_eq!(warnings.len(), 1);
1120 assert!(warnings[0].message.starts_with("Link destination"));
1121 }
1122
1123 #[test]
1124 fn slash_after_tag_name_and_parenthesized_urls_are_checked() {
1125 for content in [
1126 "<p><a/href=\"https://еxample.com\">Docs</a></p>",
1127 "<p><a/ href=\"https://еxample.com\">Docs</a></p>",
1128 "<div>\n<a/title=\"x\" href=\"https://еxample.com\">Docs</a>\n</div>",
1129 "See the docs (https://еxample.com).",
1130 "Mirror (https://exаmple.com/download)",
1131 "[t](https://еxample.com)",
1132 ] {
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1135 assert_eq!(warnings.len(), 1, "{content}");
1136 }
1137 for content in [
1138 "`<a/href=\"https://еxample.com\">Docs</a>`",
1139 "`(https://еxample.com)`",
1140 "<!-- <a/href=\"https://еxample.com\">Docs</a> -->",
1141 "```html\n<a/href=\"https://еxample.com\">Docs</a>\n```",
1142 "<a title=\"(https://еxample.com)\" href=\"https://example.com\">Docs</a>",
1143 "[t](https://example.com \"(https://еxample.com)\")",
1144 ] {
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1147 }
1148 }
1149
1150 #[test]
1151 fn html_legacy_entities_and_c1_numeric_references_follow_attribute_rules() {
1152 assert_eq!(
1153 decode_entity("ª/path", DestinationSyntax::Html),
1154 Some(("ª".into(), 5))
1155 );
1156 assert_eq!(decode_entity("ªx", DestinationSyntax::Html), None);
1157 assert_eq!(decode_entity("ª=", DestinationSyntax::Html), None);
1158 assert_eq!(decode_entity("Œ", DestinationSyntax::Html), Some(("Œ".into(), 6)));
1159 assert_eq!(
1160 decode_entity("Œ", DestinationSyntax::Markdown),
1161 Some(("\u{008C}".into(), 6))
1162 );
1163 assert_eq!(decode_entity("ª/path", DestinationSyntax::Markdown), None);
1164 }
1165
1166 #[test]
1167 fn detects_markdown_html_bare_and_angle_links() {
1168 let content = concat!(
1169 "[markdown](https://еxample.com)\n",
1170 "<https://example.com/dοcs>\n",
1171 "Visit https://example.com\n",
1172 "<a href=\"https://exаmple.com\">HTML</a>\n",
1173 "<a href=https://еxample.com>Unquoted HTML</a>\n",
1174 );
1175 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1176 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1177
1178 assert_eq!(warnings.len(), 5);
1179 assert!(warnings.iter().all(|warning| warning.fix.is_none()));
1180 }
1181
1182 #[test]
1183 fn leaves_legitimate_non_ascii_destination_alone() {
1184 let content = "[Munich](https://münchen.de/straße)";
1185 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1186 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
1187 }
1188
1189 #[test]
1190 fn detects_uts39_ascii_confusables() {
1191 let content = concat!(
1192 "[Cyrillic](https://Еxample.com)\n",
1193 "[Greek](https://Αlpha.example)\n",
1194 "[Latin](https://ıong.example)\n",
1195 "[Punctuation](https://example。com)\n",
1196 );
1197 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1198 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1199
1200 assert_eq!(warnings.len(), 4);
1201 }
1202
1203 #[test]
1204 fn repeated_url_in_label_does_not_steal_destination_location() {
1205 let url = "https://еxample.com";
1206 let content = format!("[{url}]({url})");
1207 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
1208 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1209 assert_eq!(warnings.len(), 1);
1210 let destination_character = content.rfind('е').unwrap();
1211 let (line, column) = ctx.offset_to_line_col(destination_character);
1212 assert_eq!((warnings[0].line, warnings[0].column), (line, column));
1213 }
1214 #[test]
1215 fn escaped_destination_keeps_source_column() {
1216 for content in [
1217 "[x](https://example.com/a\\*е)",
1218 "[x](https://example.com/a\\*е \"https://example.com/a*е\")",
1219 ] {
1220 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1221 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1222 assert_eq!(warnings.len(), 1);
1223 let expected = ctx.offset_to_line_col(content.find('е').unwrap());
1224 assert_eq!((warnings[0].line, warnings[0].column), expected);
1225 }
1226 }
1227
1228 #[test]
1229 fn html_attribute_urls_only_report_href() {
1230 let content = "<a title=\"https://еxample.com\" href=\"https://еxample.com\">x</a>";
1231 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1232 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1233 assert_eq!(warnings.len(), 1);
1234 let expected = ctx.offset_to_line_col(content.rfind('е').unwrap());
1235 assert_eq!((warnings[0].line, warnings[0].column), expected);
1236 assert!(warnings[0].message.starts_with("HTML link destination"));
1237
1238 let safe = content.replacen("href=\"https://еxample.com", "href=\"https://example.com", 1);
1239 let ctx = LintContext::new(&safe, MarkdownFlavor::Standard, None);
1240 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
1241 }
1242 #[test]
1243 fn repeated_reference_id_url_does_not_steal_destination_location() {
1244 for content in [
1245 "[https://еxample.com]: https://еxample.com\n\n[x][https://еxample.com]",
1246 "[escaped\\]:https://еxample.com]: https://еxample.com\n",
1247 ] {
1248 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1249 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1250 assert_eq!(warnings.len(), 1);
1251 let destination_line = content.lines().next().unwrap();
1252 let expected = ctx.offset_to_line_col(destination_line.rfind('е').unwrap());
1253 assert_eq!((warnings[0].line, warnings[0].column), expected);
1254 }
1255 }
1256 #[test]
1257 fn encoded_confusables_are_checked_without_a_literal_character() {
1258 for (content, entity) in [
1259 ("[x](https://еxample.com)", "е"),
1260 ("[x](https://еxample.com)", "е"),
1261 ("[x](https://еxample.com)", "е"),
1262 ("[x](<https://еxample.com>)", "е"),
1263 ("[id]: https://еxample.com\n\n[x][id]", "е"),
1264 ("[id]: https://еxample.com\n", "е"),
1265 ("<a href=\"https://еxample.com\">x</a>", "е"),
1266 ("<a href='https://еxample.com'>x</a>", "е"),
1267 ("<a href=https://еxample.com>x</a>", "е"),
1268 ] {
1269 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1270 assert!(!MD095LinkConfusables.should_skip(&ctx), "{content}");
1271 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1272 assert_eq!(warnings.len(), 1, "{content}");
1273 let start = content.find(entity).unwrap();
1274 assert_eq!(
1275 (warnings[0].line, warnings[0].column),
1276 ctx.offset_to_line_col(start),
1277 "{content}"
1278 );
1279 assert_eq!(
1280 (warnings[0].end_line, warnings[0].end_column),
1281 ctx.offset_to_line_col(start + entity.len()),
1282 "{content}"
1283 );
1284 assert!(warnings[0].message.contains("U+0435"), "{content}");
1285 assert_eq!(MD095LinkConfusables.fix(&ctx).unwrap(), content);
1286 }
1287 }
1288
1289 #[test]
1290 fn entity_mapping_preserves_the_location_after_other_escapes() {
1291 for content in [
1292 "é [x](https://example.com/\\*&е \"е\")",
1293 "é [е](https://example.com/&е \"е\")",
1294 "é <a title=\"е\" href=\"https://example.com/&е\">x</a>",
1295 "[id]: <https://example.com/\\*&е> \"е\"\n",
1296 ] {
1297 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1298 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1299 assert_eq!(warnings.len(), 1, "{content}");
1300 let start = if content.contains("&е") {
1301 content.find("е").unwrap()
1302 } else if content.contains("&е") {
1303 content.find("&е").unwrap() + "&".len()
1304 } else {
1305 content.find('е').unwrap()
1306 };
1307 assert_eq!(
1308 (warnings[0].line, warnings[0].column),
1309 ctx.offset_to_line_col(start),
1310 "{content}"
1311 );
1312 }
1313 }
1314
1315 #[test]
1316 fn literal_link_forms_do_not_decode_entities_or_percent_escapes() {
1317 for content in [
1318 "<https://еxample.com>",
1319 "Visit https://еxample.com",
1320 "[x](https://%D0%B5xample.com)",
1321 "[x](https://example.com/&#x435;)",
1322 "[x](https://example.com/\\е)",
1323 "<a href=\"https://example.com/&#x435;\">x</a>",
1324 "<a href=safe href=\"https://еxample.com\">x</a>",
1325 "[x](https://example.com/е)",
1326 ] {
1327 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1328 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1329 }
1330 }
1331
1332 #[test]
1333 fn code_comments_and_visible_text_are_excluded() {
1334 for content in [
1335 "`[x](https://еxample.com)`",
1336 "```md\n[x](https://еxample.com)\n<a href='https://еxample.com'>x</a>\n```",
1337 " [x](https://еxample.com)",
1338 "<!-- [x](https://еxample.com) <a href='https://еxample.com'>x</a> -->",
1339 "[е](https://example.com \"е\")",
1340 "Just Cyrillic е or е in prose.",
1341 ] {
1342 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1343 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1344 }
1345 }
1346
1347 #[test]
1348 fn malformed_and_long_numeric_entities_do_not_panic() {
1349 for content in [
1350 "[x](https://&#x;xample.com)",
1351 "[x](https://�xample.com)",
1352 "<a href='https://�'>x</a>",
1353 "<a href='https://�xample.com'>x</a>",
1354 ] {
1355 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1356 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1357 }
1358 }
1359 #[test]
1360 fn mathematical_ranges_only_include_actual_ascii_skeletons() {
1361 assert!(ascii_confusable('\u{1D400}')); assert!(ascii_confusable('\u{1D6C2}')); assert!(!ascii_confusable('\u{1D6C3}')); assert!(!ascii_confusable('\u{1D455}')); assert!(!ascii_confusable('β'));
1366 assert!(ASCII_CONFUSABLE_RANGES.windows(2).all(|pair| pair[0].1 < pair[1].0));
1367 assert!(
1368 ASCII_CONFUSABLE_RANGES
1369 .iter()
1370 .all(|(start, end)| !start.is_ascii() && start <= end)
1371 );
1372 }
1373 #[test]
1374 fn full_engine_reports_relative_html_destinations_and_respects_suppression() {
1375 let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD095LinkConfusables)];
1376 for content in ["<a href='е'>x</a>", "<a href='е'>x</a>", "<area href='е'>"] {
1377 let warnings = crate::lint(content, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
1378 assert_eq!(warnings.len(), 1, "{content}");
1379 }
1380 let content = "<!-- rumdl-disable MD095 -->\n<a href='е'>x</a>";
1381 assert!(
1382 crate::lint(content, &rules, false, MarkdownFlavor::Standard, None, None)
1383 .unwrap()
1384 .is_empty()
1385 );
1386 }
1387 #[test]
1388 fn covers_common_ascii_skeletons_in_every_documented_script() {
1389 for c in ['Р', 'Ѕ', 'ѕ', 'һ', 'ԁ', 'ԛ', 'ԝ', 'γ', 'σ', 'ϲ', 'ϳ', 'Ɩ', 'ո'] {
1390 assert!(ascii_confusable(c), "U+{:04X}", c as u32);
1391 let source = format!("[x](https://{c}example.com)");
1392 let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1393 assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{source}");
1394 }
1395 }
1396
1397 #[test]
1398 fn unicode_whitespace_in_a_destination_does_not_hide_a_confusable() {
1399 for whitespace in ['\u{00A0}', '\u{3000}'] {
1400 let source = format!("[x](https://example.com/a{whitespace}е)");
1401 let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1402 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1403 assert_eq!(warnings.len(), 1, "{source}");
1404 assert_eq!(
1405 (warnings[0].line, warnings[0].column),
1406 ctx.offset_to_line_col(source.find('е').unwrap())
1407 );
1408 }
1409 }
1410 #[test]
1411 fn multiline_reference_destinations_keep_their_source_location() {
1412 for source in [
1413 "[id]:\n https://еxample.com\n\n[x][id]",
1414 "> [id]:\n> https://еxample.com\n\n[x][id]",
1415 "[id]:\n https://еxample.com \"https://еxample.com\"\n",
1416 ] {
1417 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1418 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1419 assert_eq!(warnings.len(), 1, "{source}");
1420 let offset = source.find("е").or_else(|| source.find('е')).unwrap();
1421 assert_eq!(
1422 (warnings[0].line, warnings[0].column),
1423 ctx.offset_to_line_col(offset),
1424 "{source}"
1425 );
1426 }
1427 }
1428
1429 #[test]
1430 fn reference_titles_and_hidden_definitions_are_not_destinations() {
1431 for source in [
1432 "[id]:\n https://example.com \"https://еxample.com\"\n\n[x][id]",
1433 "<!--\n[id]: https://еxample.com\n-->",
1434 "---\n[id]: https://еxample.com\n---",
1435 ] {
1436 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1437 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{source}");
1438 }
1439 }
1440
1441 #[test]
1442 fn malformed_but_rendered_html_links_cannot_hide_their_href() {
1443 for source in [
1444 "<p><a / href=\"https://еxample.com\">Docs</a></p>",
1445 "<p><a title=\"x\"/href=\"https://еxample.com\">Docs</a></p>",
1446 "<p><a =x href=\"https://еxample.com\">Docs</a></p>",
1447 "<p><a title=\"x>y\" / href=\"https://еxample.com\">Docs</a></p>",
1448 ] {
1449 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1450 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1451 assert_eq!(warnings.len(), 1, "{source}");
1452 assert_eq!(
1453 (warnings[0].line, warnings[0].column),
1454 ctx.offset_to_line_col(source.find('е').unwrap())
1455 );
1456 }
1457 }
1458}