Skip to content

Commit 7ecf1bb

Browse files
authored
👌 Improve [](#target) resolution warnings (#716)
Previously, if `[](#target)` matched nothing locally, but multiple items in intersphinx inventories, then no warning would be logged, and the first match silently used. Resolution of intersphinx now proceeds (if no local matches found) the same as for `[](inv:#target)`, such that a `myst.iref_ambiguous` warning is emitted in this case (the first match is then still used). Warnings coming from resolution of `[](#target)` can also now be "individually" turned off using https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-nitpick_ignore, e.g. `nitpick_ignore = [("myst", "target")]`
1 parent 2902e07 commit 7ecf1bb

File tree

14 files changed

+339
-109
lines changed

14 files changed

+339
-109
lines changed

myst_parser/mdit_to_docutils/base.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,8 +1065,7 @@ def render_inventory_link(self, token: SyntaxTreeNode) -> None:
10651065

10661066
# create the docutils node
10671067
match = matches[0]
1068-
ref_node = nodes.reference()
1069-
ref_node["internal"] = False
1068+
ref_node = nodes.reference("", "", internal=False)
10701069
ref_node["inv_match"] = inventory.filter_string(
10711070
match.inv, match.domain, match.otype, match.name
10721071
)

myst_parser/mdit_to_docutils/sphinx_.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,13 @@ def render_internal_link(self, token: SyntaxTreeNode) -> None:
8383
text = ""
8484
else:
8585
wrap_node = addnodes.download_reference(
86-
refdomain=None, reftarget=path_dest, refwarn=False, **kwargs
86+
refdomain=None, reftarget=path_dest, **kwargs
8787
)
8888
classes = ["xref", "download", "myst"]
8989
text = destination if not token.children else ""
9090
else:
9191
wrap_node = addnodes.pending_xref(
92-
refdomain=None, reftarget=destination, refwarn=True, **kwargs
92+
refdomain=None, reftarget=destination, **kwargs
9393
)
9494
classes = ["xref", "myst"]
9595
text = ""

myst_parser/mdit_to_docutils/transforms.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ def apply(self, **kwargs: t.Any) -> None:
116116
refdomain=None,
117117
reftype="myst",
118118
reftarget=target,
119-
refwarn=True,
120119
refexplicit=bool(refnode.children),
121120
)
122121
inner_node = nodes.inline(

myst_parser/sphinx_ext/myst_refs.py

Lines changed: 146 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,30 @@
33
This is applied to MyST type references only, such as ``[text](target)``,
44
and allows for nested syntax
55
"""
6+
from __future__ import annotations
7+
68
import re
7-
from typing import Any, List, Optional, Tuple, cast
9+
from typing import Any, cast
810

911
from docutils import nodes
1012
from docutils.nodes import Element, document
13+
from markdown_it.common.normalize_url import normalizeLink
1114
from sphinx import addnodes
1215
from sphinx.addnodes import pending_xref
1316
from sphinx.domains.std import StandardDomain
1417
from sphinx.errors import NoUri
15-
from sphinx.locale import __
18+
from sphinx.ext.intersphinx import InventoryAdapter
1619
from sphinx.transforms.post_transforms import ReferencesResolver
1720
from sphinx.util import docname_join, logging
1821
from sphinx.util.nodes import clean_astext, make_refnode
1922

23+
from myst_parser import inventory
2024
from myst_parser._compat import findall
2125
from myst_parser.warnings_ import MystWarnings
2226

2327
LOGGER = 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-
3330
class 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

Comments
 (0)