Skip to main content

rumdl_lib/rules/
md095_link_confusables.rs

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
8/// ASCII-skeleton mappings from Unicode UTS #39, version 18.0.0,
9/// selected by the Latin, Greek, Cyrillic and Armenian Script properties,
10/// plus mathematical/fullwidth scalars and four URL punctuation look-alikes.
11/// Includes raw confusables-table rows in those scopes whose target is
12/// printable ASCII; normalization-only mappings are outside this focused list.
13/// Data: <https://www.unicode.org/Public/security/latest/confusables.txt>
14/// Scripts: <https://www.unicode.org/Public/UCD/latest/ucd/Scripts.txt>
15/// Unicode data © 2026 Unicode, Inc.; <https://www.unicode.org/terms_of_use.html>
16/// Confusables SHA-256: 6ed3ee967c9dfdf6677d563c9985182fbc50a2efb7d6059cd57b2e2ce18f5b92
17/// Scripts SHA-256: 0071fd81b6aeae25f6e8bce8efec3066a6476a91b49bdb2f52dc76e817862a6a
18const 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    // U+3002 is also a URL-host separator look-alike, even though its UTS #39
321    // skeleton is not ASCII. IDNA maps it to the ordinary domain separator.
322    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
336// Legacy semicolon-less HTML references, longest name first.
337// Source: https://html.spec.whatwg.org/entities.json
338const 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
447/// Read one entity, once. Markdown requires a semicolon and bounds numeric
448/// references; HTML also accepts a numeric reference without a semicolon.
449fn 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        // HTML replaces C1 numeric references with their Windows-1252 values.
470        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
513/// Return the exact source span of the first decoded confusable. Mapping as
514/// we scan avoids guessing by searching labels/titles for the decoded URL.
515fn 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
543/// Isolate the raw destination, excluding surrounding syntax and titles.
544fn 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
591/// Discover start tags inside HTML regions recognized by CommonMark.
592/// A code example or an escaped `<` cannot open a tag that consumes a later
593/// real link. Complete tags are consumed once within each parser-bounded span.
594fn 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            // Browsers treat these elements' bodies as text, not nested tags.
638            // Consume their closing tag before looking for another start tag.
639            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        // HTML href values may be relative and contain no Markdown link or
708        // recognized URL scheme. Our own source check handles that case.
709        false
710    }
711
712    fn fix_capability(&self) -> FixCapability {
713        FixCapability::Unfixable
714    }
715
716    fn should_skip(&self, ctx: &LintContext) -> bool {
717        !ctx.content.contains('&') && !ctx.content.chars().any(ascii_confusable)
718    }
719
720    fn check(&self, ctx: &LintContext) -> LintResult {
721        let mut warnings = Vec::new();
722        let mut reported = HashSet::new();
723
724        let mut report = |offset: usize, source: &str, kind: &str, syntax: DestinationSyntax| {
725            let Some((character_range, character)) = first_source_confusable(source, syntax) else {
726                return;
727            };
728            let character_byte = offset + character_range.start;
729            if !reported.insert(character_byte) {
730                return;
731            }
732            let (line, column) = ctx.offset_to_line_col(character_byte);
733            if ctx.is_byte_offset_in_code_span(character_byte)
734                || ctx.is_in_html_comment(character_byte)
735                || ctx.line_info(line).is_some_and(|info| {
736                    info.in_code_block
737                        || info.in_front_matter
738                        || info.in_obsidian_comment
739                        || info.in_mdx_comment
740                        || info.is_myst_comment
741                })
742            {
743                return;
744            }
745            let (end_line, end_column) = ctx.offset_to_line_col(offset + character_range.end);
746            warnings.push(LintWarning {
747                rule_name: Some(self.name().to_string()),
748                line,
749                column,
750                end_line,
751                end_column,
752                message: format!(
753                    "{kind} contains confusable non-ASCII character U+{:04X} ({character:?})",
754                    character as u32
755                ),
756                severity: Severity::Warning,
757                fix: None,
758            });
759        };
760
761        let native = pulldown_cmark::Parser::new_ext(ctx.content, crate::utils::rumdl_parser_options());
762        let native_definitions: Vec<_> = native
763            .reference_definitions()
764            .iter()
765            .map(|(_, definition)| (definition.span.clone(), definition.dest.chars().any(ascii_confusable)))
766            .collect();
767        let mut html_ranges = Vec::new();
768        let mut html_bytes = vec![b' '; ctx.content.len()];
769        for (event, span) in native.into_offset_iter() {
770            match event {
771                pulldown_cmark::Event::Start(pulldown_cmark::Tag::HtmlBlock) => html_ranges.push(span),
772                pulldown_cmark::Event::Html(text) | pulldown_cmark::Event::InlineHtml(text) => {
773                    // Inline HTML can have a single source span containing
774                    // container prefixes omitted from the rendered event text.
775                    // Match exact line suffixes; never strip a literal `>`.
776                    let source = &ctx.content[span.clone()];
777                    html_bytes[span.clone()].copy_from_slice(source.as_bytes());
778                    let mut offset = span.start;
779                    for (raw_line, rendered_line) in source.split_inclusive('\n').zip(text.split_inclusive('\n')) {
780                        let raw = raw_line.trim_end_matches(['\r', '\n']);
781                        let rendered = rendered_line.trim_end_matches(['\r', '\n']);
782                        if raw.ends_with(rendered) {
783                            let omitted = raw.len() - rendered.len();
784                            html_bytes[offset..offset + omitted].fill(b' ');
785                        }
786                        offset += raw_line.len();
787                    }
788                    html_ranges.push(span);
789                }
790                _ => {}
791            }
792        }
793        let html_source = String::from_utf8(html_bytes).expect("HTML source spans preserve UTF-8 boundaries");
794        let html_spans = merge_source_spans(html_ranges);
795        let in_raw_html = |offset| {
796            let after = html_spans.partition_point(|span| span.start <= offset);
797            after > 0
798                && offset < html_spans[after - 1].end
799                && !ctx
800                    .line_info(ctx.offset_to_line_col(offset).0)
801                    .is_some_and(crate::lint_context::types::LineInfo::in_mkdocs_container)
802        };
803
804        let links = ctx.links();
805        for link in links {
806            if link.is_reference || in_raw_html(link.byte_offset) {
807                continue;
808            }
809            let raw_link = &ctx.content[link.byte_offset..link.byte_end];
810            let (range, syntax) = match link.link_type {
811                pulldown_cmark::LinkType::Inline => {
812                    let Some(range) = destination_range(raw_link, link.text.len() + 3, true) else {
813                        continue;
814                    };
815                    (range, DestinationSyntax::Markdown)
816                }
817                _ => {
818                    let Some(start) = raw_link.find(link.url.as_ref()) else {
819                        continue;
820                    };
821                    (start..start + link.url.len(), DestinationSyntax::Literal)
822                }
823            };
824            report(
825                link.byte_offset + range.start,
826                &raw_link[range],
827                "Link destination",
828                syntax,
829            );
830        }
831
832        let definitions = ctx.reference_definitions();
833        let mut reference_ranges: Vec<_> = definitions
834            .iter()
835            .map(|definition| definition.byte_offset..definition.byte_end)
836            .collect();
837        for definition in definitions {
838            if in_raw_html(definition.byte_offset) {
839                continue;
840            }
841            let raw_definition = &ctx.content[definition.byte_offset..definition.byte_end];
842            if let Some(start) = reference_destination_start(raw_definition)
843                && let Some(range) = destination_range(raw_definition, start, false)
844            {
845                report(
846                    definition.byte_offset + range.start,
847                    &raw_definition[range],
848                    "Link reference destination",
849                    DestinationSyntax::Markdown,
850                );
851            }
852        }
853        // The context's regex definitions cover flavor fallbacks, but not
854        // CommonMark destinations on the next line. Native spans include
855        // those, including blockquote continuation prefixes.
856        for (span, has_confusable) in native_definitions {
857            reference_ranges.push(span.clone());
858            if !has_confusable {
859                continue;
860            }
861            let raw = &ctx.content[span.clone()];
862            if let Some(start) = reference_destination_start(raw) {
863                // The parsed destination is known to contain a confusable:
864                // its first raw occurrence precedes the optional title.
865                report(
866                    span.start + start,
867                    &raw[start..],
868                    "Link reference destination",
869                    DestinationSyntax::Markdown,
870                );
871            }
872        }
873        let reference_spans = merge_source_spans(reference_ranges);
874        let source_tags = html_source_tags(ctx, &html_source, &html_spans);
875        let image_spans = merge_source_spans(
876            ctx.images()
877                .iter()
878                .map(|image| image.byte_offset..image.byte_end)
879                .collect(),
880        );
881        // MD034's cached bare URLs deliberately drop surrounding delimiters.
882        // Scan the shared URL pattern directly so parenthesized prose links
883        // remain visible, then exclude actual Markdown and HTML syntax by span.
884        for url in URL_SIMPLE_REGEX.find_iter(ctx.content) {
885            // Attribute values remain HTML syntax even in MkDocs containers
886            // whose surrounding body permits Markdown and prose autolinks.
887            let after = source_tags.partition_point(|(_, span)| span.start <= url.start());
888            // A scheme followed by `://` produces a colon-ending candidate
889            // name, not HTML markup, inside MkDocs Markdown containers.
890            if after > 0 && url.start() < source_tags[after - 1].1.end && !source_tags[after - 1].0.ends_with(':') {
891                continue;
892            }
893            if in_raw_html(url.start()) {
894                continue;
895            }
896            let after = links.partition_point(|link| link.byte_offset <= url.start());
897            if after > 0 && url.start() < links[after - 1].byte_end {
898                continue;
899            }
900            let after = image_spans.partition_point(|span| span.start <= url.start());
901            if after > 0 && url.start() < image_spans[after - 1].end {
902                continue;
903            }
904            let after = reference_spans.partition_point(|span| span.start <= url.start());
905            if after > 0 && url.start() < reference_spans[after - 1].end {
906                continue;
907            }
908            let mut destination = url.as_str().trim_end_matches(['.', ',', ';', ':', '!', '?']);
909            let opens = destination.bytes().filter(|b| *b == b'(').count();
910            let mut closes = destination.bytes().filter(|b| *b == b')').count();
911            while closes > opens && destination.ends_with(')') {
912                destination = &destination[..destination.len() - 1];
913                closes -= 1;
914            }
915            report(url.start(), destination, "Bare URL", DestinationSyntax::Literal);
916        }
917
918        for (name, span) in &source_tags {
919            if !["a", "area", "link"]
920                .iter()
921                .any(|expected| name.eq_ignore_ascii_case(expected))
922            {
923                continue;
924            }
925            let raw_tag = &html_source[span.clone()];
926            let Some((value, value_range)) = extract_attribute_with_range(raw_tag, "href") else {
927                continue;
928            };
929            let absolute_offset = span.start + value_range.start;
930            if ctx.is_byte_offset_in_code_span(absolute_offset) || ctx.is_in_html_comment(absolute_offset) {
931                continue;
932            }
933            report(
934                absolute_offset,
935                &value,
936                "HTML link destination",
937                DestinationSyntax::Html,
938            );
939        }
940
941        warnings.sort_by_key(|warning| (warning.line, warning.column));
942        Ok(warnings)
943    }
944
945    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
946        Ok(ctx.content.to_string())
947    }
948
949    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
950    where
951        Self: Sized,
952    {
953        Box::new(Self)
954    }
955
956    fn as_any(&self) -> &dyn std::any::Any {
957        self
958    }
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964    use crate::config::MarkdownFlavor;
965
966    #[test]
967    fn mkdocs_angle_autolinks_are_not_html_attribute_spans() {
968        for content in [
969            "<div markdown>\n<https://еxample.com>\n</div>\n",
970            "<div class=\"grid cards\" markdown>\n- See <https://еxample.com>\n</div>\n",
971        ] {
972            let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
973            let warnings = MD095LinkConfusables.check(&ctx).unwrap();
974            assert_eq!(warnings.len(), 1, "{content}");
975            assert_eq!(warnings[0].line, 2);
976            let line = content.lines().nth(1).unwrap();
977            assert_eq!(warnings[0].column, line[..line.find('е').unwrap()].chars().count() + 1);
978        }
979        let content = "<div>\n<https://еxample.com>\n</div>\n";
980        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
981        assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
982    }
983
984    #[test]
985    fn html_attribute_names_and_mkdocs_titles_keep_browser_semantics() {
986        let content = "<p><a href\u{000B} href=\"https://еxample.com\">Docs</a></p>";
987        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
988        assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1);
989        let content =
990            "<div markdown>\n<a title=\"https://еxample.com\" href=\"https://example.com\">Docs</a>\n</div>\n";
991        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
992        assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
993        let content = "<div markdown>\n<a href=\"https://еxample.com\">Docs</a>\n</div>\n";
994        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
995        let warnings = MD095LinkConfusables.check(&ctx).unwrap();
996        assert_eq!(warnings.len(), 1);
997        assert!(warnings[0].message.starts_with("HTML link destination"));
998    }
999
1000    #[test]
1001    fn multiline_inline_html_uses_rendered_prefixes_with_exact_source_spans() {
1002        for (content, marker) in [
1003            ("> <a\n> href=\"https://еxample.com\">Docs</a>\n", "е"),
1004            ("> <a\n> href='&iecy;'>x</a>\n", "&iecy;"),
1005            ("> <a\r\n> href='&iecy;'>x</a>\r\n", "&iecy;"),
1006        ] {
1007            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1008            let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1009            assert_eq!(warnings.len(), 1, "{content}");
1010            assert_eq!(warnings[0].line, 2);
1011            let line = content.lines().nth(1).unwrap();
1012            assert_eq!(
1013                warnings[0].column,
1014                line[..line.find(marker).unwrap()].chars().count() + 1
1015            );
1016            assert_eq!(warnings[0].end_column - warnings[0].column, marker.chars().count());
1017        }
1018        for content in ["<a\n>Docs</a>", "<a\n> href='&iecy;'>Docs</a>"] {
1019            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1020            assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1021        }
1022    }
1023
1024    #[test]
1025    fn multiline_html_attributes_in_blockquotes_keep_original_source_columns() {
1026        let content = "> <div>\n> <a\n> href=\"https://&iecy;xample.com\">Docs</a>\n> </div>\n";
1027        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1028        let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1029        assert_eq!(warnings.len(), 1);
1030        assert_eq!(warnings[0].line, 3);
1031        assert_eq!(
1032            warnings[0].column,
1033            content.lines().nth(2).unwrap().find("&iecy;").unwrap() + 1
1034        );
1035        assert_eq!(warnings[0].end_column - warnings[0].column, "&iecy;".len());
1036    }
1037
1038    #[test]
1039    fn native_html_boundaries_preserve_following_links_and_mkdocs_markdown() {
1040        for (content, flavor) in [
1041            (
1042                "<pre>\nx\n</pre>\n[Download](https://еxample.com/setup.exe)\n",
1043                MarkdownFlavor::Standard,
1044            ),
1045            (
1046                "<script>\nvar a = 1;\n</script>\n<https://еxample.com>\n",
1047                MarkdownFlavor::Standard,
1048            ),
1049            (
1050                "<div class=\"grid cards\" markdown>\n- [Docs](https://еxample.com)\n</div>\n",
1051                MarkdownFlavor::MkDocs,
1052            ),
1053            (
1054                "- <div>\n  <a\n  href=\"https://еxample.com\">Docs</a>\n  </div>\n",
1055                MarkdownFlavor::Standard,
1056            ),
1057        ] {
1058            let ctx = LintContext::new(content, flavor, None);
1059            assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{content}");
1060        }
1061        let content = "<span>\n[id]: https://еxample.com\n";
1062        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1063        assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
1064    }
1065
1066    #[test]
1067    fn markdown_like_text_in_raw_html_is_not_a_link() {
1068        for content in [
1069            "<div>\n[id]: https://еxample.com\n</div>\n",
1070            "<div>\n[x](https://еxample.com)\n</div>\n",
1071            "<div>\nSee https://еxample.com today.\n</div>\n",
1072        ] {
1073            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1074            assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1075        }
1076    }
1077
1078    #[test]
1079    fn raw_text_html_bodies_do_not_hide_following_anchors_or_create_links() {
1080        for name in ["script", "style", "textarea"] {
1081            let content = format!(
1082                "<{name}>\nconst template = '<b title=\"draft';\n</{name}>\n<a href=\"https://еxample.com\">Docs</a>\n"
1083            );
1084            let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
1085            assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{content}");
1086            let content = format!("<{name}>\n<a href=\"https://еxample.com\">Text only</a>\n</{name}>\n");
1087            let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
1088            assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1089        }
1090    }
1091
1092    #[test]
1093    fn code_and_comment_text_cannot_swallow_later_real_links() {
1094        for content in [
1095            "Use `a <b c` here.\n\n<a href=\"https://еxample.com\">Docs</a>",
1096            "```sh\nkubectl apply -f - <<EOF\nkind: Pod\nEOF\n```\n\n<a href=\"https://еxample.com\">Docs</a>",
1097            "```sh\ncat <<EOF\nhi\nEOF\n```\n\nSee https://еxample.com today.\n\n> quote",
1098            "<!-- note: <x title=\"draft -->\n\n<a href=\"https://еxample.com\">Docs</a>",
1099            "Use \\<b here.\n\n<a href=\"https://еxample.com\">Docs</a>",
1100        ] {
1101            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1102            assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{content}");
1103        }
1104    }
1105
1106    #[test]
1107    fn image_destinations_and_alt_text_are_excluded() {
1108        for content in [
1109            "![logo](https://еxample.com/logo.png)",
1110            "![https://еxample.com](logo.png)",
1111            "![logo](<https://еxample.com/logo.png>)",
1112        ] {
1113            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1114            assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1115        }
1116        let content = "[![b](https://example.com/b.svg)](https://еxample.com)";
1117        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1118        let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1119        assert_eq!(warnings.len(), 1);
1120        assert!(warnings[0].message.starts_with("Link destination"));
1121    }
1122
1123    #[test]
1124    fn slash_after_tag_name_and_parenthesized_urls_are_checked() {
1125        for content in [
1126            "<p><a/href=\"https://еxample.com\">Docs</a></p>",
1127            "<p><a/ href=\"https://еxample.com\">Docs</a></p>",
1128            "<div>\n<a/title=\"x\" href=\"https://&iecy;xample.com\">Docs</a>\n</div>",
1129            "See the docs (https://еxample.com).",
1130            "Mirror (https://exаmple.com/download)",
1131            "[t](https://еxample.com)",
1132        ] {
1133            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134            let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1135            assert_eq!(warnings.len(), 1, "{content}");
1136        }
1137        for content in [
1138            "`<a/href=\"https://еxample.com\">Docs</a>`",
1139            "`(https://еxample.com)`",
1140            "<!-- <a/href=\"https://еxample.com\">Docs</a> -->",
1141            "```html\n<a/href=\"https://еxample.com\">Docs</a>\n```",
1142            "<a title=\"(https://еxample.com)\" href=\"https://example.com\">Docs</a>",
1143            "[t](https://example.com \"(https://еxample.com)\")",
1144        ] {
1145            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146            assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1147        }
1148    }
1149
1150    #[test]
1151    fn html_legacy_entities_and_c1_numeric_references_follow_attribute_rules() {
1152        assert_eq!(
1153            decode_entity("&ordf/path", DestinationSyntax::Html),
1154            Some(("ª".into(), 5))
1155        );
1156        assert_eq!(decode_entity("&ordfx", DestinationSyntax::Html), None);
1157        assert_eq!(decode_entity("&ordf=", DestinationSyntax::Html), None);
1158        assert_eq!(decode_entity("&#140;", DestinationSyntax::Html), Some(("Œ".into(), 6)));
1159        assert_eq!(
1160            decode_entity("&#140;", DestinationSyntax::Markdown),
1161            Some(("\u{008C}".into(), 6))
1162        );
1163        assert_eq!(decode_entity("&ordf/path", DestinationSyntax::Markdown), None);
1164    }
1165
1166    #[test]
1167    fn detects_markdown_html_bare_and_angle_links() {
1168        let content = concat!(
1169            "[markdown](https://еxample.com)\n",
1170            "<https://example.com/dοcs>\n",
1171            "Visit https://example.com\n",
1172            "<a href=\"https://exаmple.com\">HTML</a>\n",
1173            "<a href=https://еxample.com>Unquoted HTML</a>\n",
1174        );
1175        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1176        let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1177
1178        assert_eq!(warnings.len(), 5);
1179        assert!(warnings.iter().all(|warning| warning.fix.is_none()));
1180    }
1181
1182    #[test]
1183    fn leaves_legitimate_non_ascii_destination_alone() {
1184        let content = "[Munich](https://münchen.de/straße)";
1185        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1186        assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
1187    }
1188
1189    #[test]
1190    fn detects_uts39_ascii_confusables() {
1191        let content = concat!(
1192            "[Cyrillic](https://Еxample.com)\n",
1193            "[Greek](https://Αlpha.example)\n",
1194            "[Latin](https://ıong.example)\n",
1195            "[Punctuation](https://example。com)\n",
1196        );
1197        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1198        let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1199
1200        assert_eq!(warnings.len(), 4);
1201    }
1202
1203    #[test]
1204    fn repeated_url_in_label_does_not_steal_destination_location() {
1205        let url = "https://еxample.com";
1206        let content = format!("[{url}]({url})");
1207        let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
1208        let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1209        assert_eq!(warnings.len(), 1);
1210        let destination_character = content.rfind('е').unwrap();
1211        let (line, column) = ctx.offset_to_line_col(destination_character);
1212        assert_eq!((warnings[0].line, warnings[0].column), (line, column));
1213    }
1214    #[test]
1215    fn escaped_destination_keeps_source_column() {
1216        for content in [
1217            "[x](https://example.com/a\\*е)",
1218            "[x](https://example.com/a\\*е \"https://example.com/a*е\")",
1219        ] {
1220            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1221            let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1222            assert_eq!(warnings.len(), 1);
1223            let expected = ctx.offset_to_line_col(content.find('е').unwrap());
1224            assert_eq!((warnings[0].line, warnings[0].column), expected);
1225        }
1226    }
1227
1228    #[test]
1229    fn html_attribute_urls_only_report_href() {
1230        let content = "<a title=\"https://еxample.com\" href=\"https://еxample.com\">x</a>";
1231        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1232        let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1233        assert_eq!(warnings.len(), 1);
1234        let expected = ctx.offset_to_line_col(content.rfind('е').unwrap());
1235        assert_eq!((warnings[0].line, warnings[0].column), expected);
1236        assert!(warnings[0].message.starts_with("HTML link destination"));
1237
1238        let safe = content.replacen("href=\"https://еxample.com", "href=\"https://example.com", 1);
1239        let ctx = LintContext::new(&safe, MarkdownFlavor::Standard, None);
1240        assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty());
1241    }
1242    #[test]
1243    fn repeated_reference_id_url_does_not_steal_destination_location() {
1244        for content in [
1245            "[https://еxample.com]: https://еxample.com\n\n[x][https://еxample.com]",
1246            "[escaped\\]:https://еxample.com]: https://еxample.com\n",
1247        ] {
1248            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1249            let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1250            assert_eq!(warnings.len(), 1);
1251            let destination_line = content.lines().next().unwrap();
1252            let expected = ctx.offset_to_line_col(destination_line.rfind('е').unwrap());
1253            assert_eq!((warnings[0].line, warnings[0].column), expected);
1254        }
1255    }
1256    #[test]
1257    fn encoded_confusables_are_checked_without_a_literal_character() {
1258        for (content, entity) in [
1259            ("[x](https://&#x435;xample.com)", "&#x435;"),
1260            ("[x](https://&#1077;xample.com)", "&#1077;"),
1261            ("[x](https://&iecy;xample.com)", "&iecy;"),
1262            ("[x](<https://&iecy;xample.com>)", "&iecy;"),
1263            ("[id]: https://&iecy;xample.com\n\n[x][id]", "&iecy;"),
1264            ("[id]: https://&iecy;xample.com\n", "&iecy;"),
1265            ("<a href=\"https://&#x435;xample.com\">x</a>", "&#x435;"),
1266            ("<a href='https://&iecy;xample.com'>x</a>", "&iecy;"),
1267            ("<a href=https://&#1077xample.com>x</a>", "&#1077"),
1268        ] {
1269            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1270            assert!(!MD095LinkConfusables.should_skip(&ctx), "{content}");
1271            let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1272            assert_eq!(warnings.len(), 1, "{content}");
1273            let start = content.find(entity).unwrap();
1274            assert_eq!(
1275                (warnings[0].line, warnings[0].column),
1276                ctx.offset_to_line_col(start),
1277                "{content}"
1278            );
1279            assert_eq!(
1280                (warnings[0].end_line, warnings[0].end_column),
1281                ctx.offset_to_line_col(start + entity.len()),
1282                "{content}"
1283            );
1284            assert!(warnings[0].message.contains("U+0435"), "{content}");
1285            assert_eq!(MD095LinkConfusables.fix(&ctx).unwrap(), content);
1286        }
1287    }
1288
1289    #[test]
1290    fn entity_mapping_preserves_the_location_after_other_escapes() {
1291        for content in [
1292            "é [x](https://example.com/\\*&amp;&#x435; \"&#x435;\")",
1293            "é [&#x435;](https://example.com/&amp;е \"е\")",
1294            "é <a title=\"&iecy;\" href=\"https://example.com/&amp;&iecy;\">x</a>",
1295            "[id]: <https://example.com/\\*&amp;&iecy;> \"&iecy;\"\n",
1296        ] {
1297            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1298            let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1299            assert_eq!(warnings.len(), 1, "{content}");
1300            let start = if content.contains("&amp;&#x435;") {
1301                content.find("&#x435;").unwrap()
1302            } else if content.contains("&amp;&iecy;") {
1303                content.find("&amp;&iecy;").unwrap() + "&amp;".len()
1304            } else {
1305                content.find('е').unwrap()
1306            };
1307            assert_eq!(
1308                (warnings[0].line, warnings[0].column),
1309                ctx.offset_to_line_col(start),
1310                "{content}"
1311            );
1312        }
1313    }
1314
1315    #[test]
1316    fn literal_link_forms_do_not_decode_entities_or_percent_escapes() {
1317        for content in [
1318            "<https://&iecy;xample.com>",
1319            "Visit https://&#x435;xample.com",
1320            "[x](https://%D0%B5xample.com)",
1321            "[x](https://example.com/&amp;#x435;)",
1322            "[x](https://example.com/\\&iecy;)",
1323            "<a href=\"https://example.com/&amp;#x435;\">x</a>",
1324            "<a href=safe href=\"https://еxample.com\">x</a>",
1325            "[x](https://example.com/&#x435)",
1326        ] {
1327            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1328            assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1329        }
1330    }
1331
1332    #[test]
1333    fn code_comments_and_visible_text_are_excluded() {
1334        for content in [
1335            "`[x](https://&iecy;xample.com)`",
1336            "```md\n[x](https://еxample.com)\n<a href='https://&iecy;xample.com'>x</a>\n```",
1337            "    [x](https://еxample.com)",
1338            "<!-- [x](https://еxample.com) <a href='https://&iecy;xample.com'>x</a> -->",
1339            "[е](https://example.com \"е\")",
1340            "Just Cyrillic е or &iecy; in prose.",
1341        ] {
1342            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1343            assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1344        }
1345    }
1346
1347    #[test]
1348    fn malformed_and_long_numeric_entities_do_not_panic() {
1349        for content in [
1350            "[x](https://&#x;xample.com)",
1351            "[x](https://&#99999999;xample.com)",
1352            "<a href='https://&#999999999999999999999999999999999999999999999;'>x</a>",
1353            "<a href='https://&#xD800;xample.com'>x</a>",
1354        ] {
1355            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1356            assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{content}");
1357        }
1358    }
1359    #[test]
1360    fn mathematical_ranges_only_include_actual_ascii_skeletons() {
1361        assert!(ascii_confusable('\u{1D400}')); // mathematical bold A
1362        assert!(ascii_confusable('\u{1D6C2}')); // mathematical bold alpha
1363        assert!(!ascii_confusable('\u{1D6C3}')); // beta has a non-ASCII skeleton
1364        assert!(!ascii_confusable('\u{1D455}')); // unassigned mathematical scalar
1365        assert!(!ascii_confusable('β'));
1366        assert!(ASCII_CONFUSABLE_RANGES.windows(2).all(|pair| pair[0].1 < pair[1].0));
1367        assert!(
1368            ASCII_CONFUSABLE_RANGES
1369                .iter()
1370                .all(|(start, end)| !start.is_ascii() && start <= end)
1371        );
1372    }
1373    #[test]
1374    fn full_engine_reports_relative_html_destinations_and_respects_suppression() {
1375        let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD095LinkConfusables)];
1376        for content in ["<a href='е'>x</a>", "<a href='&iecy;'>x</a>", "<area href='&#x435;'>"] {
1377            let warnings = crate::lint(content, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
1378            assert_eq!(warnings.len(), 1, "{content}");
1379        }
1380        let content = "<!-- rumdl-disable MD095 -->\n<a href='&iecy;'>x</a>";
1381        assert!(
1382            crate::lint(content, &rules, false, MarkdownFlavor::Standard, None, None)
1383                .unwrap()
1384                .is_empty()
1385        );
1386    }
1387    #[test]
1388    fn covers_common_ascii_skeletons_in_every_documented_script() {
1389        for c in ['Р', 'Ѕ', 'ѕ', 'һ', 'ԁ', 'ԛ', 'ԝ', 'γ', 'σ', 'ϲ', 'ϳ', 'Ɩ', 'ո'] {
1390            assert!(ascii_confusable(c), "U+{:04X}", c as u32);
1391            let source = format!("[x](https://{c}example.com)");
1392            let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1393            assert_eq!(MD095LinkConfusables.check(&ctx).unwrap().len(), 1, "{source}");
1394        }
1395    }
1396
1397    #[test]
1398    fn unicode_whitespace_in_a_destination_does_not_hide_a_confusable() {
1399        for whitespace in ['\u{00A0}', '\u{3000}'] {
1400            let source = format!("[x](https://example.com/a{whitespace}е)");
1401            let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
1402            let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1403            assert_eq!(warnings.len(), 1, "{source}");
1404            assert_eq!(
1405                (warnings[0].line, warnings[0].column),
1406                ctx.offset_to_line_col(source.find('е').unwrap())
1407            );
1408        }
1409    }
1410    #[test]
1411    fn multiline_reference_destinations_keep_their_source_location() {
1412        for source in [
1413            "[id]:\n  https://&iecy;xample.com\n\n[x][id]",
1414            "> [id]:\n> https://&iecy;xample.com\n\n[x][id]",
1415            "[id]:\n  https://еxample.com \"https://еxample.com\"\n",
1416        ] {
1417            let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1418            let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1419            assert_eq!(warnings.len(), 1, "{source}");
1420            let offset = source.find("&iecy;").or_else(|| source.find('е')).unwrap();
1421            assert_eq!(
1422                (warnings[0].line, warnings[0].column),
1423                ctx.offset_to_line_col(offset),
1424                "{source}"
1425            );
1426        }
1427    }
1428
1429    #[test]
1430    fn reference_titles_and_hidden_definitions_are_not_destinations() {
1431        for source in [
1432            "[id]:\n  https://example.com \"https://еxample.com\"\n\n[x][id]",
1433            "<!--\n[id]: https://&iecy;xample.com\n-->",
1434            "---\n[id]: https://&iecy;xample.com\n---",
1435        ] {
1436            let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1437            assert!(MD095LinkConfusables.check(&ctx).unwrap().is_empty(), "{source}");
1438        }
1439    }
1440
1441    #[test]
1442    fn malformed_but_rendered_html_links_cannot_hide_their_href() {
1443        for source in [
1444            "<p><a / href=\"https://еxample.com\">Docs</a></p>",
1445            "<p><a title=\"x\"/href=\"https://еxample.com\">Docs</a></p>",
1446            "<p><a =x href=\"https://еxample.com\">Docs</a></p>",
1447            "<p><a title=\"x>y\" / href=\"https://еxample.com\">Docs</a></p>",
1448        ] {
1449            let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1450            let warnings = MD095LinkConfusables.check(&ctx).unwrap();
1451            assert_eq!(warnings.len(), 1, "{source}");
1452            assert_eq!(
1453                (warnings[0].line, warnings[0].column),
1454                ctx.offset_to_line_col(source.find('е').unwrap())
1455            );
1456        }
1457    }
1458}