Skip to main content

rumdl_lib/rules/
md095_link_confusables.rs

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