3
3
This is applied to MyST type references only, such as ``[text](target)``,
4
4
and allows for nested syntax
5
5
"""
6
+ from __future__ import annotations
7
+
6
8
import re
7
- from typing import Any , List , Optional , Tuple , cast
9
+ from typing import Any , cast
8
10
9
11
from docutils import nodes
10
12
from docutils .nodes import Element , document
13
+ from markdown_it .common .normalize_url import normalizeLink
11
14
from sphinx import addnodes
12
15
from sphinx .addnodes import pending_xref
13
16
from sphinx .domains .std import StandardDomain
14
17
from sphinx .errors import NoUri
15
- from sphinx .locale import __
18
+ from sphinx .ext . intersphinx import InventoryAdapter
16
19
from sphinx .transforms .post_transforms import ReferencesResolver
17
20
from sphinx .util import docname_join , logging
18
21
from sphinx .util .nodes import clean_astext , make_refnode
19
22
23
+ from myst_parser import inventory
20
24
from myst_parser ._compat import findall
21
25
from myst_parser .warnings_ import MystWarnings
22
26
23
27
LOGGER = logging .getLogger (__name__ )
24
28
25
29
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
-
33
30
class MystReferenceResolver (ReferencesResolver ):
34
31
"""Resolves cross-references on doctrees.
35
32
@@ -38,6 +35,41 @@ class MystReferenceResolver(ReferencesResolver):
38
35
39
36
default_priority = 9 # higher priority than ReferencesResolver (10)
40
37
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
+
41
73
def run (self , ** kwargs : Any ) -> None :
42
74
self .document : document
43
75
for node in findall (self .document )(addnodes .pending_xref ):
@@ -48,41 +80,45 @@ def run(self, **kwargs: Any) -> None:
48
80
self .resolve_myst_ref_doc (node )
49
81
continue
50
82
51
- contnode = cast (nodes .TextElement , node [0 ].deepcopy ())
52
83
newnode = None
53
-
84
+ contnode = cast ( nodes . TextElement , node [ 0 ]. deepcopy ())
54
85
target = node ["reftarget" ]
55
86
refdoc = node .get ("refdoc" , self .env .docname )
87
+ search_domains : None | list [str ] = self .env .config .myst_ref_domains
56
88
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
57
94
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
+ )
78
98
except NoUri :
79
99
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
+ )
80
114
115
+ # if the target could not be found, then default to using an external link
81
116
if not newnode :
82
117
newnode = nodes .reference ()
83
- newnode ["refid" ] = target
118
+ newnode ["refid" ] = normalizeLink ( target )
84
119
newnode .append (node [0 ].deepcopy ())
85
120
121
+ # ensure the output node has some content
86
122
if (
87
123
len (newnode .children ) == 1
88
124
and isinstance (newnode [0 ], nodes .inline )
@@ -94,46 +130,17 @@ def run(self, **kwargs: Any) -> None:
94
130
95
131
node .replace_self (newnode )
96
132
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
-
127
133
def resolve_myst_ref_doc (self , node : pending_xref ):
128
134
"""Resolve a reference, from a markdown link, to another document,
129
135
optionally with a target id within that document.
130
136
"""
131
137
from_docname = node .get ("refdoc" , self .env .docname )
132
138
ref_docname : str = node ["reftarget" ]
133
- ref_id : Optional [ str ] = node ["reftargetid" ]
139
+ ref_id : str | None = node ["reftargetid" ]
134
140
135
141
if ref_docname not in self .env .all_docs :
136
- log_warning (
142
+ self .log_warning (
143
+ ref_docname ,
137
144
f"Unknown source document { ref_docname !r} " ,
138
145
MystWarnings .XREF_MISSING ,
139
146
location = node ,
@@ -148,7 +155,8 @@ def resolve_myst_ref_doc(self, node: pending_xref):
148
155
if ref_id :
149
156
slug_to_section = self .env .metadata [ref_docname ].get ("myst_slugs" , {})
150
157
if ref_id not in slug_to_section :
151
- log_warning (
158
+ self .log_warning (
159
+ ref_id ,
152
160
f"local id not found in doc { ref_docname !r} : { ref_id !r} " ,
153
161
MystWarnings .XREF_MISSING ,
154
162
location = node ,
@@ -176,9 +184,13 @@ def resolve_myst_ref_doc(self, node: pending_xref):
176
184
node .replace_self (ref_node )
177
185
178
186
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)``.
182
194
183
195
This builds on the sphinx ``any`` role to also resolve:
184
196
@@ -189,7 +201,7 @@ def resolve_myst_ref_any(
189
201
190
202
"""
191
203
target : str = node ["reftarget" ]
192
- results : List [ Tuple [str , Element ]] = []
204
+ results : list [ tuple [str , Element ]] = []
193
205
194
206
# resolve standard references
195
207
res = self ._resolve_ref_nested (node , refdoc )
@@ -201,13 +213,10 @@ def resolve_myst_ref_any(
201
213
if res :
202
214
results .append (("std:doc" , res ))
203
215
204
- # get allowed domains for referencing
205
- ref_domains = self .env .config .myst_ref_domains
206
-
207
216
assert self .app .builder
208
217
209
218
# 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 :
211
220
stddomain = cast (StandardDomain , self .env .get_domain ("std" ))
212
221
for objtype in stddomain .object_types :
213
222
key = (objtype , target )
@@ -225,7 +234,7 @@ def resolve_myst_ref_any(
225
234
for domain in self .env .domains .values ():
226
235
if domain .name == "std" :
227
236
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 :
229
238
continue
230
239
try :
231
240
results .extend (
@@ -237,7 +246,8 @@ def resolve_myst_ref_any(
237
246
# the domain doesn't yet support the new interface
238
247
# we have to manually collect possible references (SLOW)
239
248
if not (getattr (domain , "__module__" , "" ).startswith ("sphinx." )):
240
- log_warning (
249
+ self .log_warning (
250
+ None ,
241
251
f"Domain '{ domain .__module__ } ::{ domain .name } ' has not "
242
252
"implemented a `resolve_any_xref` method" ,
243
253
MystWarnings .LEGACY_DOMAIN ,
@@ -260,11 +270,10 @@ def stringify(name, node):
260
270
return f":{ name } :`{ reftitle } `"
261
271
262
272
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 } " ,
268
277
MystWarnings .XREF_AMBIGUOUS ,
269
278
location = node ,
270
279
)
@@ -283,7 +292,7 @@ def stringify(name, node):
283
292
284
293
def _resolve_ref_nested (
285
294
self , node : pending_xref , fromdocname : str , target = None
286
- ) -> Optional [ Element ] :
295
+ ) -> Element | None :
287
296
"""This is the same as ``sphinx.domains.std._resolve_ref_xref``,
288
297
but allows for nested syntax, rather than converting the inner node to raw text.
289
298
"""
@@ -311,7 +320,7 @@ def _resolve_ref_nested(
311
320
312
321
def _resolve_doc_nested (
313
322
self , node : pending_xref , fromdocname : str
314
- ) -> Optional [ Element ] :
323
+ ) -> Element | None :
315
324
"""This is the same as ``sphinx.domains.std._resolve_doc_xref``,
316
325
but allows for nested syntax, rather than converting the inner node to raw text.
317
326
@@ -332,3 +341,58 @@ def _resolve_doc_nested(
332
341
333
342
assert self .app .builder
334
343
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