33This is applied to MyST type references only, such as ``[text](target)``,
44and allows for nested syntax
55"""
6+ from __future__ import annotations
7+
68import re
7- from typing import Any , List , Optional , Tuple , cast
9+ from typing import Any , cast
810
911from docutils import nodes
1012from docutils .nodes import Element , document
13+ from markdown_it .common .normalize_url import normalizeLink
1114from sphinx import addnodes
1215from sphinx .addnodes import pending_xref
1316from sphinx .domains .std import StandardDomain
1417from sphinx .errors import NoUri
15- from sphinx .locale import __
18+ from sphinx .ext . intersphinx import InventoryAdapter
1619from sphinx .transforms .post_transforms import ReferencesResolver
1720from sphinx .util import docname_join , logging
1821from sphinx .util .nodes import clean_astext , make_refnode
1922
23+ from myst_parser import inventory
2024from myst_parser ._compat import findall
2125from myst_parser .warnings_ import MystWarnings
2226
2327LOGGER = logging .getLogger (__name__ )
2428
2529
26- def log_warning (msg : str , subtype : MystWarnings , ** kwargs : Any ):
27- """Log a warning, with a myst type and specific subtype."""
28- LOGGER .warning (
29- msg + f" [myst.{ subtype .value } ]" , type = "myst" , subtype = subtype .value , ** kwargs
30- )
31-
32-
3330class MystReferenceResolver (ReferencesResolver ):
3431 """Resolves cross-references on doctrees.
3532
@@ -38,6 +35,41 @@ class MystReferenceResolver(ReferencesResolver):
3835
3936 default_priority = 9 # higher priority than ReferencesResolver (10)
4037
38+ def log_warning (
39+ self , target : None | str , msg : str , subtype : MystWarnings , ** kwargs : Any
40+ ):
41+ """Log a warning, with a myst type and specific subtype."""
42+
43+ # MyST references are warned about by default (the same as the `any` role)
44+ # However, warnings can also be ignored by adding ("myst", target)
45+ # nitpick_ignore/nitpick_ignore_regex lists
46+ # https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-nitpicky
47+ if (
48+ target
49+ and self .config .nitpick_ignore
50+ and ("myst" , target ) in self .config .nitpick_ignore
51+ ):
52+ return
53+ if (
54+ target
55+ and self .config .nitpick_ignore_regex
56+ and any (
57+ (
58+ re .fullmatch (ignore_type , "myst" )
59+ and re .fullmatch (ignore_target , target )
60+ )
61+ for ignore_type , ignore_target in self .config .nitpick_ignore_regex
62+ )
63+ ):
64+ return
65+
66+ LOGGER .warning (
67+ msg + f" [myst.{ subtype .value } ]" ,
68+ type = "myst" ,
69+ subtype = subtype .value ,
70+ ** kwargs ,
71+ )
72+
4173 def run (self , ** kwargs : Any ) -> None :
4274 self .document : document
4375 for node in findall (self .document )(addnodes .pending_xref ):
@@ -48,41 +80,45 @@ def run(self, **kwargs: Any) -> None:
4880 self .resolve_myst_ref_doc (node )
4981 continue
5082
51- contnode = cast (nodes .TextElement , node [0 ].deepcopy ())
5283 newnode = None
53-
84+ contnode = cast ( nodes . TextElement , node [ 0 ]. deepcopy ())
5485 target = node ["reftarget" ]
5586 refdoc = node .get ("refdoc" , self .env .docname )
87+ search_domains : None | list [str ] = self .env .config .myst_ref_domains
5688
89+ # try to resolve the reference within the local project,
90+ # this asks all domains to resolve the reference,
91+ # return None if no domain could resolve the reference
92+ # or returns the first result, and logs a warning if
93+ # multiple domains resolved the reference
5794 try :
58- newnode = self .resolve_myst_ref_any (refdoc , node , contnode )
59- if newnode is None :
60- # no new node found? try the missing-reference event
61- # but first we change the the reftype to 'any'
62- # this means it is picked up by extensions like intersphinx
63- node ["reftype" ] = "any"
64- try :
65- newnode = self .app .emit_firstresult (
66- "missing-reference" ,
67- self .env ,
68- node ,
69- contnode ,
70- allowed_exceptions = (NoUri ,),
71- )
72- finally :
73- node ["reftype" ] = "myst"
74- if newnode is None :
75- # still not found? warn if node wishes to be warned about or
76- # we are in nit-picky mode
77- self ._warn_missing_reference (target , node )
95+ newnode = self .resolve_myst_ref_any (
96+ refdoc , node , contnode , search_domains
97+ )
7898 except NoUri :
7999 newnode = contnode
100+ if newnode is None :
101+ # If no local domain could resolve the reference, try to
102+ # resolve it as an inter-sphinx reference
103+ newnode = self ._resolve_myst_ref_intersphinx (
104+ node , contnode , target , search_domains
105+ )
106+ if newnode is None :
107+ # if still not resolved, log a warning,
108+ self .log_warning (
109+ target ,
110+ f"'myst' cross-reference target not found: { target !r} " ,
111+ MystWarnings .XREF_MISSING ,
112+ location = node ,
113+ )
80114
115+ # if the target could not be found, then default to using an external link
81116 if not newnode :
82117 newnode = nodes .reference ()
83- newnode ["refid" ] = target
118+ newnode ["refid" ] = normalizeLink ( target )
84119 newnode .append (node [0 ].deepcopy ())
85120
121+ # ensure the output node has some content
86122 if (
87123 len (newnode .children ) == 1
88124 and isinstance (newnode [0 ], nodes .inline )
@@ -94,46 +130,17 @@ def run(self, **kwargs: Any) -> None:
94130
95131 node .replace_self (newnode )
96132
97- def _warn_missing_reference (self , target : str , node : pending_xref ) -> None :
98- """Warn about a missing reference."""
99- dtype = "myst"
100- if not node .get ("refwarn" ):
101- return
102- if (
103- self .config .nitpicky
104- and self .config .nitpick_ignore
105- and (dtype , target ) in self .config .nitpick_ignore
106- ):
107- return
108- if (
109- self .config .nitpicky
110- and self .config .nitpick_ignore_regex
111- and any (
112- (
113- re .fullmatch (ignore_type , dtype )
114- and re .fullmatch (ignore_target , target )
115- )
116- for ignore_type , ignore_target in self .config .nitpick_ignore_regex
117- )
118- ):
119- return
120-
121- log_warning (
122- f"'myst' cross-reference target not found: { target !r} " ,
123- MystWarnings .XREF_MISSING ,
124- location = node ,
125- )
126-
127133 def resolve_myst_ref_doc (self , node : pending_xref ):
128134 """Resolve a reference, from a markdown link, to another document,
129135 optionally with a target id within that document.
130136 """
131137 from_docname = node .get ("refdoc" , self .env .docname )
132138 ref_docname : str = node ["reftarget" ]
133- ref_id : Optional [ str ] = node ["reftargetid" ]
139+ ref_id : str | None = node ["reftargetid" ]
134140
135141 if ref_docname not in self .env .all_docs :
136- log_warning (
142+ self .log_warning (
143+ ref_docname ,
137144 f"Unknown source document { ref_docname !r} " ,
138145 MystWarnings .XREF_MISSING ,
139146 location = node ,
@@ -148,7 +155,8 @@ def resolve_myst_ref_doc(self, node: pending_xref):
148155 if ref_id :
149156 slug_to_section = self .env .metadata [ref_docname ].get ("myst_slugs" , {})
150157 if ref_id not in slug_to_section :
151- log_warning (
158+ self .log_warning (
159+ ref_id ,
152160 f"local id not found in doc { ref_docname !r} : { ref_id !r} " ,
153161 MystWarnings .XREF_MISSING ,
154162 location = node ,
@@ -176,9 +184,13 @@ def resolve_myst_ref_doc(self, node: pending_xref):
176184 node .replace_self (ref_node )
177185
178186 def resolve_myst_ref_any (
179- self , refdoc : str , node : pending_xref , contnode : Element
180- ) -> Element :
181- """Resolve reference generated by the "myst" role; ``[text](reference)``.
187+ self ,
188+ refdoc : str ,
189+ node : pending_xref ,
190+ contnode : Element ,
191+ only_domains : None | list [str ],
192+ ) -> Element | None :
193+ """Resolve reference generated by the "myst" role; ``[text](#reference)``.
182194
183195 This builds on the sphinx ``any`` role to also resolve:
184196
@@ -189,7 +201,7 @@ def resolve_myst_ref_any(
189201
190202 """
191203 target : str = node ["reftarget" ]
192- results : List [ Tuple [str , Element ]] = []
204+ results : list [ tuple [str , Element ]] = []
193205
194206 # resolve standard references
195207 res = self ._resolve_ref_nested (node , refdoc )
@@ -201,13 +213,10 @@ def resolve_myst_ref_any(
201213 if res :
202214 results .append (("std:doc" , res ))
203215
204- # get allowed domains for referencing
205- ref_domains = self .env .config .myst_ref_domains
206-
207216 assert self .app .builder
208217
209218 # next resolve for any other standard reference objects
210- if ref_domains is None or "std" in ref_domains :
219+ if only_domains is None or "std" in only_domains :
211220 stddomain = cast (StandardDomain , self .env .get_domain ("std" ))
212221 for objtype in stddomain .object_types :
213222 key = (objtype , target )
@@ -225,7 +234,7 @@ def resolve_myst_ref_any(
225234 for domain in self .env .domains .values ():
226235 if domain .name == "std" :
227236 continue # we did this one already
228- if ref_domains is not None and domain .name not in ref_domains :
237+ if only_domains is not None and domain .name not in only_domains :
229238 continue
230239 try :
231240 results .extend (
@@ -237,7 +246,8 @@ def resolve_myst_ref_any(
237246 # the domain doesn't yet support the new interface
238247 # we have to manually collect possible references (SLOW)
239248 if not (getattr (domain , "__module__" , "" ).startswith ("sphinx." )):
240- log_warning (
249+ self .log_warning (
250+ None ,
241251 f"Domain '{ domain .__module__ } ::{ domain .name } ' has not "
242252 "implemented a `resolve_any_xref` method" ,
243253 MystWarnings .LEGACY_DOMAIN ,
@@ -260,11 +270,10 @@ def stringify(name, node):
260270 return f":{ name } :`{ reftitle } `"
261271
262272 candidates = " or " .join (stringify (name , role ) for name , role in results )
263- log_warning (
264- __ (
265- f"more than one target found for 'myst' cross-reference { target } : "
266- f"could be { candidates } "
267- ),
273+ self .log_warning (
274+ target ,
275+ f"more than one target found for 'myst' cross-reference { target } : "
276+ f"could be { candidates } " ,
268277 MystWarnings .XREF_AMBIGUOUS ,
269278 location = node ,
270279 )
@@ -283,7 +292,7 @@ def stringify(name, node):
283292
284293 def _resolve_ref_nested (
285294 self , node : pending_xref , fromdocname : str , target = None
286- ) -> Optional [ Element ] :
295+ ) -> Element | None :
287296 """This is the same as ``sphinx.domains.std._resolve_ref_xref``,
288297 but allows for nested syntax, rather than converting the inner node to raw text.
289298 """
@@ -311,7 +320,7 @@ def _resolve_ref_nested(
311320
312321 def _resolve_doc_nested (
313322 self , node : pending_xref , fromdocname : str
314- ) -> Optional [ Element ] :
323+ ) -> Element | None :
315324 """This is the same as ``sphinx.domains.std._resolve_doc_xref``,
316325 but allows for nested syntax, rather than converting the inner node to raw text.
317326
@@ -332,3 +341,58 @@ def _resolve_doc_nested(
332341
333342 assert self .app .builder
334343 return make_refnode (self .app .builder , fromdocname , docname , "" , innernode )
344+
345+ def _resolve_myst_ref_intersphinx (
346+ self ,
347+ node : nodes .Element ,
348+ contnode : nodes .Element ,
349+ target : str ,
350+ only_domains : list [str ] | None ,
351+ ) -> None | nodes .reference :
352+ """Resolve a myst reference to an intersphinx inventory."""
353+ matches = [
354+ m
355+ for m in inventory .filter_sphinx_inventories (
356+ InventoryAdapter (self .env ).named_inventory ,
357+ targets = target ,
358+ )
359+ if only_domains is None or m .domain in only_domains
360+ ]
361+ if not matches :
362+ return None
363+ if len (matches ) > 1 :
364+ # log a warning if there are multiple matches
365+ show_num = 3
366+ matches_str = ", " .join (
367+ [
368+ inventory .filter_string (m .inv , m .domain , m .otype , m .name )
369+ for m in matches [:show_num ]
370+ ]
371+ )
372+ if len (matches ) > show_num :
373+ matches_str += ", ..."
374+ self .log_warning (
375+ target ,
376+ f"Multiple matches found for { target !r} : { matches_str } " ,
377+ MystWarnings .IREF_AMBIGUOUS ,
378+ location = node ,
379+ )
380+ # get the first match and create a reference node
381+ match = matches [0 ]
382+ newnode = nodes .reference ("" , "" , internal = False , refuri = match .loc )
383+ if "reftitle" in node :
384+ newnode ["reftitle" ] = node ["reftitle" ]
385+ else :
386+ newnode ["reftitle" ] = f"{ match .project } { match .version } " .strip ()
387+ if node .get ("refexplicit" ):
388+ newnode .append (contnode )
389+ elif match .text :
390+ newnode .append (
391+ contnode .__class__ (match .text , match .text , classes = ["iref" , "myst" ])
392+ )
393+ else :
394+ newnode .append (
395+ contnode .__class__ (match .name , match .name , classes = ["iref" , "myst" ])
396+ )
397+
398+ return newnode
0 commit comments