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