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