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(
762 ctx.content,
763 crate::utils::parser_options::rumdl_parser_options_for(ctx.content),
764 );
765 let native_definitions: Vec<_> = native
766 .reference_definitions()
767 .iter()
768 .map(|(_, definition)| (definition.span.clone(), definition.dest.chars().any(ascii_confusable)))
769 .collect();
770 let mut html_ranges = Vec::new();
771 let mut html_bytes = vec![b' '; ctx.content.len()];
772 for (event, span) in native.into_offset_iter() {
773 match event {
774 pulldown_cmark::Event::Start(pulldown_cmark::Tag::HtmlBlock) => html_ranges.push(span),
775 pulldown_cmark::Event::Html(text) | pulldown_cmark::Event::InlineHtml(text) => {
776 let source = &ctx.content[span.clone()];
780 html_bytes[span.clone()].copy_from_slice(source.as_bytes());
781 let mut offset = span.start;
782 for (raw_line, rendered_line) in source.split_inclusive('\n').zip(text.split_inclusive('\n')) {
783 let raw = raw_line.trim_end_matches(['\r', '\n']);
784 let rendered = rendered_line.trim_end_matches(['\r', '\n']);
785 if raw.ends_with(rendered) {
786 let omitted = raw.len() - rendered.len();
787 html_bytes[offset..offset + omitted].fill(b' ');
788 }
789 offset += raw_line.len();
790 }
791 html_ranges.push(span);
792 }
793 _ => {}
794 }
795 }
796 let html_source = String::from_utf8(html_bytes).expect("HTML source spans preserve UTF-8 boundaries");
797 let html_spans = merge_source_spans(html_ranges);
798 let in_raw_html = |offset| {
799 let after = html_spans.partition_point(|span| span.start <= offset);
800 after > 0
801 && offset < html_spans[after - 1].end
802 && !ctx
803 .line_info(ctx.offset_to_line_col(offset).0)
804 .is_some_and(crate::lint_context::types::LineInfo::in_mkdocs_container)
805 };
806
807 let links = ctx.links();
808 for link in links {
809 if link.is_reference || in_raw_html(link.byte_offset) {
810 continue;
811 }
812 let raw_link = &ctx.content[link.byte_offset..link.byte_end];
813 let (range, syntax) = match link.link_type {
814 pulldown_cmark::LinkType::Inline => {
815 let Some(range) = destination_range(raw_link, link.text.len() + 3, true) else {
816 continue;
817 };
818 (range, DestinationSyntax::Markdown)
819 }
820 _ => {
821 let Some(start) = raw_link.find(link.url.as_ref()) else {
822 continue;
823 };
824 (start..start + link.url.len(), DestinationSyntax::Literal)
825 }
826 };
827 report(
828 link.byte_offset + range.start,
829 &raw_link[range],
830 "Link destination",
831 syntax,
832 );
833 }
834
835 let definitions = ctx.reference_definitions();
836 let mut reference_ranges: Vec<_> = definitions
837 .iter()
838 .map(|definition| definition.byte_offset..definition.byte_end)
839 .collect();
840 for definition in definitions {
841 if in_raw_html(definition.byte_offset) {
842 continue;
843 }
844 let raw_definition = &ctx.content[definition.byte_offset..definition.byte_end];
845 if let Some(start) = reference_destination_start(raw_definition)
846 && let Some(range) = destination_range(raw_definition, start, false)
847 {
848 report(
849 definition.byte_offset + range.start,
850 &raw_definition[range],
851 "Link reference destination",
852 DestinationSyntax::Markdown,
853 );
854 }
855 }
856 for (span, has_confusable) in native_definitions {
860 reference_ranges.push(span.clone());
861 if !has_confusable {
862 continue;
863 }
864 let raw = &ctx.content[span.clone()];
865 if let Some(start) = reference_destination_start(raw) {
866 report(
869 span.start + start,
870 &raw[start..],
871 "Link reference destination",
872 DestinationSyntax::Markdown,
873 );
874 }
875 }
876 let reference_spans = merge_source_spans(reference_ranges);
877 let source_tags = html_source_tags(ctx, &html_source, &html_spans);
878 let image_spans = merge_source_spans(
879 ctx.images()
880 .iter()
881 .map(|image| image.byte_offset..image.byte_end)
882 .collect(),
883 );
884 for url in URL_SIMPLE_REGEX.find_iter(ctx.content) {
888 let after = source_tags.partition_point(|(_, span)| span.start <= url.start());
891 if after > 0 && url.start() < source_tags[after - 1].1.end && !source_tags[after - 1].0.ends_with(':') {
894 continue;
895 }
896 if in_raw_html(url.start()) {
897 continue;
898 }
899 let after = links.partition_point(|link| link.byte_offset <= url.start());
900 if after > 0 && url.start() < links[after - 1].byte_end {
901 continue;
902 }
903 let after = image_spans.partition_point(|span| span.start <= url.start());
904 if after > 0 && url.start() < image_spans[after - 1].end {
905 continue;
906 }
907 let after = reference_spans.partition_point(|span| span.start <= url.start());
908 if after > 0 && url.start() < reference_spans[after - 1].end {
909 continue;
910 }
911 let mut destination = url.as_str().trim_end_matches(['.', ',', ';', ':', '!', '?']);
912 let opens = destination.bytes().filter(|b| *b == b'(').count();
913 let mut closes = destination.bytes().filter(|b| *b == b')').count();
914 while closes > opens && destination.ends_with(')') {
915 destination = &destination[..destination.len() - 1];
916 closes -= 1;
917 }
918 report(url.start(), destination, "Bare URL", DestinationSyntax::Literal);
919 }
920
921 for (name, span) in &source_tags {
922 if !["a", "area", "link"]
923 .iter()
924 .any(|expected| name.eq_ignore_ascii_case(expected))
925 {
926 continue;
927 }
928 let raw_tag = &html_source[span.clone()];
929 let Some((value, value_range)) = extract_attribute_with_range(raw_tag, "href") else {
930 continue;
931 };
932 let absolute_offset = span.start + value_range.start;
933 if ctx.is_byte_offset_in_code_span(absolute_offset) || ctx.is_in_html_comment(absolute_offset) {
934 continue;
935 }
936 report(
937 absolute_offset,
938 &value,
939 "HTML link destination",
940 DestinationSyntax::Html,
941 );
942 }
943
944 warnings.sort_by_key(|warning| (warning.line, warning.column));
945 Ok(warnings)
946 }
947
948 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
949 Ok(ctx.content.to_string())
950 }
951
952 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
953 where
954 Self: Sized,
955 {
956 Box::new(Self)
957 }
958
959 fn as_any(&self) -> &dyn std::any::Any {
960 self
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967 use crate::config::MarkdownFlavor;
968
969 #[test]
970 fn mkdocs_angle_autolinks_are_not_html_attribute_spans() {
971 for content in [
972 "<div markdown>\n<https://еxample.com>\n</div>\n",
973 "<div class=\"grid cards\" markdown>\n- See <https://еxample.com>\n</div>\n",
974 ] {
975 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
976 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
977 assert_eq!(warnings.len(), 1, "{content}");
978 assert_eq!(warnings[0].line, 2);
979 let line = content.lines().nth(1).unwrap();
980 assert_eq!(warnings[0].column, line[..line.find('е').unwrap()].chars().count() + 1);
981 }
982 let content = "<div>\n<https://еxample.com>\n</div>\n";
983 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
984 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
985 }
986
987 #[test]
988 fn html_attribute_names_and_mkdocs_titles_keep_browser_semantics() {
989 let content = "<p><a href\u{000B} href=\"https://еxample.com\">Docs</a></p>";
990 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
991 assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1);
992 let content =
993 "<div markdown>\n<a title=\"https://еxample.com\" href=\"https://example.com\">Docs</a>\n</div>\n";
994 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
995 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
996 let content = "<div markdown>\n<a href=\"https://еxample.com\">Docs</a>\n</div>\n";
997 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
998 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
999 assert_eq!(warnings.len(), 1);
1000 assert!(warnings[0].message.starts_with("HTML link destination"));
1001 }
1002
1003 #[test]
1004 fn multiline_inline_html_uses_rendered_prefixes_with_exact_source_spans() {
1005 for (content, marker) in [
1006 ("> <a\n> href=\"https://еxample.com\">Docs</a>\n", "е"),
1007 ("> <a\n> href='е'>x</a>\n", "е"),
1008 ("> <a\r\n> href='е'>x</a>\r\n", "е"),
1009 ] {
1010 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1011 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1012 assert_eq!(warnings.len(), 1, "{content}");
1013 assert_eq!(warnings[0].line, 2);
1014 let line = content.lines().nth(1).unwrap();
1015 assert_eq!(
1016 warnings[0].column,
1017 line[..line.find(marker).unwrap()].chars().count() + 1
1018 );
1019 assert_eq!(warnings[0].end_column - warnings[0].column, marker.chars().count());
1020 }
1021 for content in ["<a\n>Docs</a>", "<a\n> href='е'>Docs</a>"] {
1022 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1023 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1024 }
1025 }
1026
1027 #[test]
1028 fn multiline_html_attributes_in_blockquotes_keep_original_source_columns() {
1029 let content = "> <div>\n> <a\n> href=\"https://еxample.com\">Docs</a>\n> </div>\n";
1030 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1031 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1032 assert_eq!(warnings.len(), 1);
1033 assert_eq!(warnings[0].line, 3);
1034 assert_eq!(
1035 warnings[0].column,
1036 content.lines().nth(2).unwrap().find("е").unwrap() + 1
1037 );
1038 assert_eq!(warnings[0].end_column - warnings[0].column, "е".len());
1039 }
1040
1041 #[test]
1042 fn native_html_boundaries_preserve_following_links_and_mkdocs_markdown() {
1043 for (content, flavor) in [
1044 (
1045 "<pre>\nx\n</pre>\n[Download](https://еxample.com/setup.exe)\n",
1046 MarkdownFlavor::Standard,
1047 ),
1048 (
1049 "<script>\nvar a = 1;\n</script>\n<https://еxample.com>\n",
1050 MarkdownFlavor::Standard,
1051 ),
1052 (
1053 "<div class=\"grid cards\" markdown>\n- [Docs](https://еxample.com)\n</div>\n",
1054 MarkdownFlavor::MkDocs,
1055 ),
1056 (
1057 "- <div>\n <a\n href=\"https://еxample.com\">Docs</a>\n </div>\n",
1058 MarkdownFlavor::Standard,
1059 ),
1060 ] {
1061 let ctx = LintContext::new(content, flavor, None);
1062 assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{content}");
1063 }
1064 let content = "<span>\n[id]: https://еxample.com\n";
1065 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1066 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
1067 }
1068
1069 #[test]
1070 fn markdown_like_text_in_raw_html_is_not_a_link() {
1071 for content in [
1072 "<div>\n[id]: https://еxample.com\n</div>\n",
1073 "<div>\n[x](https://еxample.com)\n</div>\n",
1074 "<div>\nSee https://еxample.com today.\n</div>\n",
1075 ] {
1076 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1077 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1078 }
1079 }
1080
1081 #[test]
1082 fn raw_text_html_bodies_do_not_hide_following_anchors_or_create_links() {
1083 for name in ["script", "style", "textarea"] {
1084 let content = format!(
1085 "<{name}>\nconst template = '<b title=\"draft';\n</{name}>\n<a href=\"https://еxample.com\">Docs</a>\n"
1086 );
1087 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
1088 assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{content}");
1089 let content = format!("<{name}>\n<a href=\"https://еxample.com\">Text only</a>\n</{name}>\n");
1090 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
1091 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1092 }
1093 }
1094
1095 #[test]
1096 fn code_and_comment_text_cannot_swallow_later_real_links() {
1097 for content in [
1098 "Use `a <b c` here.\n\n<a href=\"https://еxample.com\">Docs</a>",
1099 "```sh\nkubectl apply -f - <<EOF\nkind: Pod\nEOF\n```\n\n<a href=\"https://еxample.com\">Docs</a>",
1100 "```sh\ncat <<EOF\nhi\nEOF\n```\n\nSee https://еxample.com today.\n\n> quote",
1101 "<!-- note: <x title=\"draft -->\n\n<a href=\"https://еxample.com\">Docs</a>",
1102 "Use \\<b here.\n\n<a href=\"https://еxample.com\">Docs</a>",
1103 ] {
1104 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1105 assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{content}");
1106 }
1107 }
1108
1109 #[test]
1110 fn image_destinations_and_alt_text_are_excluded() {
1111 for content in [
1112 "",
1113 "",
1114 "",
1115 ] {
1116 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1117 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1118 }
1119 let content = "[](https://еxample.com)";
1120 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1121 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1122 assert_eq!(warnings.len(), 1);
1123 assert!(warnings[0].message.starts_with("Link destination"));
1124 }
1125
1126 #[test]
1127 fn slash_after_tag_name_and_parenthesized_urls_are_checked() {
1128 for content in [
1129 "<p><a/href=\"https://еxample.com\">Docs</a></p>",
1130 "<p><a/ href=\"https://еxample.com\">Docs</a></p>",
1131 "<div>\n<a/title=\"x\" href=\"https://еxample.com\">Docs</a>\n</div>",
1132 "See the docs (https://еxample.com).",
1133 "Mirror (https://exаmple.com/download)",
1134 "[t](https://еxample.com)",
1135 ] {
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1137 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1138 assert_eq!(warnings.len(), 1, "{content}");
1139 }
1140 for content in [
1141 "`<a/href=\"https://еxample.com\">Docs</a>`",
1142 "`(https://еxample.com)`",
1143 "<!-- <a/href=\"https://еxample.com\">Docs</a> -->",
1144 "```html\n<a/href=\"https://еxample.com\">Docs</a>\n```",
1145 "<a title=\"(https://еxample.com)\" href=\"https://example.com\">Docs</a>",
1146 "[t](https://example.com \"(https://еxample.com)\")",
1147 ] {
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1150 }
1151 }
1152
1153 #[test]
1154 fn html_legacy_entities_and_c1_numeric_references_follow_attribute_rules() {
1155 assert_eq!(
1156 decode_entity("ª/path", DestinationSyntax::Html),
1157 Some(("ª".into(), 5))
1158 );
1159 assert_eq!(decode_entity("ªx", DestinationSyntax::Html), None);
1160 assert_eq!(decode_entity("ª=", DestinationSyntax::Html), None);
1161 assert_eq!(decode_entity("Œ", DestinationSyntax::Html), Some(("Œ".into(), 6)));
1162 assert_eq!(
1163 decode_entity("Œ", DestinationSyntax::Markdown),
1164 Some(("\u{008C}".into(), 6))
1165 );
1166 assert_eq!(decode_entity("ª/path", DestinationSyntax::Markdown), None);
1167 }
1168
1169 #[test]
1170 fn detects_markdown_html_bare_and_angle_links() {
1171 let content = concat!(
1172 "[markdown](https://еxample.com)\n",
1173 "<https://example.com/dοcs>\n",
1174 "Visit https://example.com\n",
1175 "<a href=\"https://exаmple.com\">HTML</a>\n",
1176 "<a href=https://еxample.com>Unquoted HTML</a>\n",
1177 );
1178 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1179 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1180
1181 assert_eq!(warnings.len(), 5);
1182 assert!(warnings.iter().all(|warning| warning.fix.is_none()));
1183 }
1184
1185 #[test]
1186 fn leaves_legitimate_non_ascii_destination_alone() {
1187 let content = "[Munich](https://münchen.de/straße)";
1188 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1189 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
1190 }
1191
1192 #[test]
1193 fn detects_uts39_ascii_confusables() {
1194 let content = concat!(
1195 "[Cyrillic](https://Еxample.com)\n",
1196 "[Greek](https://Αlpha.example)\n",
1197 "[Latin](https://ıong.example)\n",
1198 "[Punctuation](https://example。com)\n",
1199 );
1200 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1201 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1202
1203 assert_eq!(warnings.len(), 4);
1204 }
1205
1206 #[test]
1207 fn repeated_url_in_label_does_not_steal_destination_location() {
1208 let url = "https://еxample.com";
1209 let content = format!("[{url}]({url})");
1210 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
1211 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1212 assert_eq!(warnings.len(), 1);
1213 let destination_character = content.rfind('е').unwrap();
1214 let (line, column) = ctx.offset_to_line_col(destination_character);
1215 assert_eq!((warnings[0].line, warnings[0].column), (line, column));
1216 }
1217 #[test]
1218 fn escaped_destination_keeps_source_column() {
1219 for content in [
1220 "[x](https://example.com/a\\*е)",
1221 "[x](https://example.com/a\\*е \"https://example.com/a*е\")",
1222 ] {
1223 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1224 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1225 assert_eq!(warnings.len(), 1);
1226 let expected = ctx.offset_to_line_col(content.find('е').unwrap());
1227 assert_eq!((warnings[0].line, warnings[0].column), expected);
1228 }
1229 }
1230
1231 #[test]
1232 fn html_attribute_urls_only_report_href() {
1233 let content = "<a title=\"https://еxample.com\" href=\"https://еxample.com\">x</a>";
1234 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1235 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1236 assert_eq!(warnings.len(), 1);
1237 let expected = ctx.offset_to_line_col(content.rfind('е').unwrap());
1238 assert_eq!((warnings[0].line, warnings[0].column), expected);
1239 assert!(warnings[0].message.starts_with("HTML link destination"));
1240
1241 let safe = content.replacen("href=\"https://еxample.com", "href=\"https://example.com", 1);
1242 let ctx = LintContext::new(&safe, MarkdownFlavor::Standard, None);
1243 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
1244 }
1245 #[test]
1246 fn repeated_reference_id_url_does_not_steal_destination_location() {
1247 for content in [
1248 "[https://еxample.com]: https://еxample.com\n\n[x][https://еxample.com]",
1249 "[escaped\\]:https://еxample.com]: https://еxample.com\n",
1250 ] {
1251 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1252 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1253 assert_eq!(warnings.len(), 1);
1254 let destination_line = content.lines().next().unwrap();
1255 let expected = ctx.offset_to_line_col(destination_line.rfind('е').unwrap());
1256 assert_eq!((warnings[0].line, warnings[0].column), expected);
1257 }
1258 }
1259 #[test]
1260 fn encoded_confusables_are_checked_without_a_literal_character() {
1261 for (content, entity) in [
1262 ("[x](https://еxample.com)", "е"),
1263 ("[x](https://еxample.com)", "е"),
1264 ("[x](https://еxample.com)", "е"),
1265 ("[x](<https://еxample.com>)", "е"),
1266 ("[id]: https://еxample.com\n\n[x][id]", "е"),
1267 ("[id]: https://еxample.com\n", "е"),
1268 ("<a href=\"https://еxample.com\">x</a>", "е"),
1269 ("<a href='https://еxample.com'>x</a>", "е"),
1270 ("<a href=https://еxample.com>x</a>", "е"),
1271 ] {
1272 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1273 assert!(!MD095LinkConfusables.should_skip(&ctx), "{content}");
1274 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1275 assert_eq!(warnings.len(), 1, "{content}");
1276 let start = content.find(entity).unwrap();
1277 assert_eq!(
1278 (warnings[0].line, warnings[0].column),
1279 ctx.offset_to_line_col(start),
1280 "{content}"
1281 );
1282 assert_eq!(
1283 (warnings[0].end_line, warnings[0].end_column),
1284 ctx.offset_to_line_col(start + entity.len()),
1285 "{content}"
1286 );
1287 assert!(warnings[0].message.contains("U+0435"), "{content}");
1288 assert_eq!(MD095LinkConfusables.fix(&ctx).unwrap(), content);
1289 }
1290 }
1291
1292 #[test]
1293 fn entity_mapping_preserves_the_location_after_other_escapes() {
1294 for content in [
1295 "é [x](https://example.com/\\*&е \"е\")",
1296 "é [е](https://example.com/&е \"е\")",
1297 "é <a title=\"е\" href=\"https://example.com/&е\">x</a>",
1298 "[id]: <https://example.com/\\*&е> \"е\"\n",
1299 ] {
1300 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1301 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1302 assert_eq!(warnings.len(), 1, "{content}");
1303 let start = if content.contains("&е") {
1304 content.find("е").unwrap()
1305 } else if content.contains("&е") {
1306 content.find("&е").unwrap() + "&".len()
1307 } else {
1308 content.find('е').unwrap()
1309 };
1310 assert_eq!(
1311 (warnings[0].line, warnings[0].column),
1312 ctx.offset_to_line_col(start),
1313 "{content}"
1314 );
1315 }
1316 }
1317
1318 #[test]
1319 fn literal_link_forms_do_not_decode_entities_or_percent_escapes() {
1320 for content in [
1321 "<https://еxample.com>",
1322 "Visit https://еxample.com",
1323 "[x](https://%D0%B5xample.com)",
1324 "[x](https://example.com/&#x435;)",
1325 "[x](https://example.com/\\е)",
1326 "<a href=\"https://example.com/&#x435;\">x</a>",
1327 "<a href=safe href=\"https://еxample.com\">x</a>",
1328 "[x](https://example.com/е)",
1329 ] {
1330 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1331 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1332 }
1333 }
1334
1335 #[test]
1336 fn code_comments_and_visible_text_are_excluded() {
1337 for content in [
1338 "`[x](https://еxample.com)`",
1339 "```md\n[x](https://еxample.com)\n<a href='https://еxample.com'>x</a>\n```",
1340 " [x](https://еxample.com)",
1341 "<!-- [x](https://еxample.com) <a href='https://еxample.com'>x</a> -->",
1342 "[е](https://example.com \"е\")",
1343 "Just Cyrillic е or е in prose.",
1344 ] {
1345 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1346 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1347 }
1348 }
1349
1350 #[test]
1351 fn malformed_and_long_numeric_entities_do_not_panic() {
1352 for content in [
1353 "[x](https://&#x;xample.com)",
1354 "[x](https://�xample.com)",
1355 "<a href='https://�'>x</a>",
1356 "<a href='https://�xample.com'>x</a>",
1357 ] {
1358 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1359 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1360 }
1361 }
1362 #[test]
1363 fn mathematical_ranges_only_include_actual_ascii_skeletons() {
1364 assert!(ascii_confusable('\u{1D400}')); assert!(ascii_confusable('\u{1D6C2}')); assert!(!ascii_confusable('\u{1D6C3}')); assert!(!ascii_confusable('\u{1D455}')); assert!(!ascii_confusable('β'));
1369 assert!(ASCII_CONFUSABLE_RANGES.windows(2).all(|pair| pair[0].1 < pair[1].0));
1370 assert!(
1371 ASCII_CONFUSABLE_RANGES
1372 .iter()
1373 .all(|(start, end)| !start.is_ascii() && start <= end)
1374 );
1375 }
1376 #[test]
1377 fn full_engine_reports_relative_html_destinations_and_respects_suppression() {
1378 let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD095LinkConfusables)];
1379 for content in ["<a href='е'>x</a>", "<a href='е'>x</a>", "<area href='е'>"] {
1380 let warnings = crate::lint(content, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
1381 assert_eq!(warnings.len(), 1, "{content}");
1382 }
1383 let content = "<!-- rumdl-disable MD095 -->\n<a href='е'>x</a>";
1384 assert!(
1385 crate::lint(content, &rules, false, MarkdownFlavor::Standard, None, None)
1386 .unwrap()
1387 .is_empty()
1388 );
1389 }
1390 #[test]
1391 fn covers_common_ascii_skeletons_in_every_documented_script() {
1392 for c in ['Р', 'Ѕ', 'ѕ', 'һ', 'ԁ', 'ԛ', 'ԝ', 'γ', 'σ', 'ϲ', 'ϳ', 'Ɩ', 'ո'] {
1393 assert!(ascii_confusable(c), "U+{:04X}", c as u32);
1394 let source = format!("[x](https://{c}example.com)");
1395 let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1396 assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{source}");
1397 }
1398 }
1399
1400 #[test]
1401 fn unicode_whitespace_in_a_destination_does_not_hide_a_confusable() {
1402 for whitespace in ['\u{00A0}', '\u{3000}'] {
1403 let source = format!("[x](https://example.com/a{whitespace}е)");
1404 let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1405 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1406 assert_eq!(warnings.len(), 1, "{source}");
1407 assert_eq!(
1408 (warnings[0].line, warnings[0].column),
1409 ctx.offset_to_line_col(source.find('е').unwrap())
1410 );
1411 }
1412 }
1413 #[test]
1414 fn multiline_reference_destinations_keep_their_source_location() {
1415 for source in [
1416 "[id]:\n https://еxample.com\n\n[x][id]",
1417 "> [id]:\n> https://еxample.com\n\n[x][id]",
1418 "[id]:\n https://еxample.com \"https://еxample.com\"\n",
1419 ] {
1420 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1421 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1422 assert_eq!(warnings.len(), 1, "{source}");
1423 let offset = source.find("е").or_else(|| source.find('е')).unwrap();
1424 assert_eq!(
1425 (warnings[0].line, warnings[0].column),
1426 ctx.offset_to_line_col(offset),
1427 "{source}"
1428 );
1429 }
1430 }
1431
1432 #[test]
1433 fn reference_titles_and_hidden_definitions_are_not_destinations() {
1434 for source in [
1435 "[id]:\n https://example.com \"https://еxample.com\"\n\n[x][id]",
1436 "<!--\n[id]: https://еxample.com\n-->",
1437 "---\n[id]: https://еxample.com\n---",
1438 ] {
1439 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1440 assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{source}");
1441 }
1442 }
1443
1444 #[test]
1445 fn malformed_but_rendered_html_links_cannot_hide_their_href() {
1446 for source in [
1447 "<p><a / href=\"https://еxample.com\">Docs</a></p>",
1448 "<p><a title=\"x\"/href=\"https://еxample.com\">Docs</a></p>",
1449 "<p><a =x href=\"https://еxample.com\">Docs</a></p>",
1450 "<p><a title=\"x>y\" / href=\"https://еxample.com\">Docs</a></p>",
1451 ] {
1452 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1453 let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1454 assert_eq!(warnings.len(), 1, "{source}");
1455 assert_eq!(
1456 (warnings[0].line, warnings[0].column),
1457 ctx.offset_to_line_col(source.find('е').unwrap())
1458 );
1459 }
1460 }
1461}