|
| 1 | +"""A post-transform for overriding the behaviour of sphinx reference resolution. |
| 2 | +
|
| 3 | +This is applied to MyST type references only, such as ``[text](target)``, |
| 4 | +and allows for nested syntax |
| 5 | +""" |
| 6 | +import os |
| 7 | +from typing import Any, List, Tuple |
| 8 | +from typing import cast |
| 9 | + |
| 10 | +from docutils import nodes |
| 11 | +from docutils.nodes import document, Element |
| 12 | + |
| 13 | +from sphinx import addnodes |
| 14 | +from sphinx.addnodes import pending_xref |
| 15 | +from sphinx.errors import NoUri |
| 16 | +from sphinx.locale import __ |
| 17 | +from sphinx.transforms.post_transforms import ReferencesResolver |
| 18 | +from sphinx.util import docname_join, logging |
| 19 | +from sphinx.util.nodes import clean_astext, make_refnode |
| 20 | + |
| 21 | +logger = logging.getLogger(__name__) |
| 22 | + |
| 23 | + |
| 24 | +class MystReferenceResolver(ReferencesResolver): |
| 25 | + """Resolves cross-references on doctrees. |
| 26 | +
|
| 27 | + Overrides default sphinx implementation, to allow for nested syntax |
| 28 | + """ |
| 29 | + |
| 30 | + default_priority = 9 # higher priority than ReferencesResolver (10) |
| 31 | + |
| 32 | + def run(self, **kwargs: Any) -> None: |
| 33 | + self.document: document |
| 34 | + for node in self.document.traverse(addnodes.pending_xref): |
| 35 | + if node["reftype"] != "myst": |
| 36 | + continue |
| 37 | + |
| 38 | + contnode = cast(nodes.TextElement, node[0].deepcopy()) |
| 39 | + newnode = None |
| 40 | + |
| 41 | + typ = node["reftype"] |
| 42 | + target = node["reftarget"] |
| 43 | + refdoc = node.get("refdoc", self.env.docname) |
| 44 | + domain = None |
| 45 | + |
| 46 | + try: |
| 47 | + newnode = self.resolve_myst_ref(refdoc, node, contnode) |
| 48 | + # no new node found? try the missing-reference event |
| 49 | + if newnode is None: |
| 50 | + newnode = self.app.emit_firstresult( |
| 51 | + "missing-reference", self.env, node, contnode |
| 52 | + ) |
| 53 | + # still not found? warn if node wishes to be warned about or |
| 54 | + # we are in nit-picky mode |
| 55 | + if newnode is None: |
| 56 | + self.warn_missing_reference(refdoc, typ, target, node, domain) |
| 57 | + except NoUri: |
| 58 | + newnode = contnode |
| 59 | + |
| 60 | + node.replace_self(newnode or contnode) |
| 61 | + |
| 62 | + def _resolve_ref_nested(self, node: pending_xref, fromdocname: str) -> Element: |
| 63 | + """This is the same as ``sphinx.domains.std._resolve_ref_xref``, |
| 64 | + but allows for nested syntax, |
| 65 | + rather than converting the inner nodes to raw text. |
| 66 | + """ |
| 67 | + stddomain = self.env.get_domain("std") |
| 68 | + target = node["reftarget"].lower() |
| 69 | + |
| 70 | + if node["refexplicit"]: |
| 71 | + # reference to anonymous label; the reference uses |
| 72 | + # the supplied link caption |
| 73 | + docname, labelid = stddomain.anonlabels.get(target, ("", "")) |
| 74 | + sectname = node.astext() |
| 75 | + innernode = nodes.inline(sectname, "") |
| 76 | + innernode.extend(node[0].children) |
| 77 | + else: |
| 78 | + # reference to named label; the final node will |
| 79 | + # contain the section name after the label |
| 80 | + docname, labelid, sectname = stddomain.labels.get(target, ("", "", "")) |
| 81 | + innernode = nodes.inline(sectname, sectname) |
| 82 | + |
| 83 | + if not docname: |
| 84 | + return None |
| 85 | + |
| 86 | + return make_refnode(self.app.builder, fromdocname, docname, labelid, innernode) |
| 87 | + |
| 88 | + def _resolve_doc_nested(self, node: pending_xref, fromdocname: str) -> Element: |
| 89 | + """This is the same as ``sphinx.domains.std._resolve_doc_xref``, |
| 90 | + but allows for nested syntax, |
| 91 | + rather than converting the inner nodes to raw text. |
| 92 | +
|
| 93 | + It also allows for extensions on document names. |
| 94 | + """ |
| 95 | + # directly reference to document by source name; can be absolute or relative |
| 96 | + refdoc = node.get("refdoc", fromdocname) |
| 97 | + docname = docname_join(refdoc, node["reftarget"]) |
| 98 | + |
| 99 | + if docname not in self.env.all_docs: |
| 100 | + # try stripping known extensions from doc name |
| 101 | + if os.path.splitext(docname)[1] in self.env.config.source_suffix: |
| 102 | + docname = os.path.splitext(docname)[0] |
| 103 | + if docname not in self.env.all_docs: |
| 104 | + return None |
| 105 | + |
| 106 | + if node["refexplicit"]: |
| 107 | + # reference with explicit title |
| 108 | + caption = node.astext() |
| 109 | + innernode = nodes.inline(caption, "", classes=["doc"]) |
| 110 | + innernode.extend(node[0].children) |
| 111 | + else: |
| 112 | + # TODO do we want nested syntax for titles? |
| 113 | + caption = clean_astext(self.env.titles[docname]) |
| 114 | + innernode = nodes.inline(caption, caption, classes=["doc"]) |
| 115 | + |
| 116 | + return make_refnode(self.app.builder, fromdocname, docname, None, innernode) |
| 117 | + |
| 118 | + def resolve_myst_ref( |
| 119 | + self, refdoc: str, node: pending_xref, contnode: Element |
| 120 | + ) -> Element: |
| 121 | + """Resolve reference generated by the "myst" role.""" |
| 122 | + |
| 123 | + stddomain = self.env.get_domain("std") |
| 124 | + target = node["reftarget"] |
| 125 | + results = [] # type: List[Tuple[str, Element]] |
| 126 | + |
| 127 | + # resolve standard references first |
| 128 | + res = self._resolve_ref_nested(node, refdoc) |
| 129 | + if res: |
| 130 | + results.append(("std:ref", res)) |
| 131 | + |
| 132 | + # next resolve doc names |
| 133 | + res = self._resolve_doc_nested(node, refdoc) |
| 134 | + if res: |
| 135 | + results.append(("std:doc", res)) |
| 136 | + |
| 137 | + # next resolve for any other standard reference object |
| 138 | + for objtype in stddomain.object_types: |
| 139 | + key = (objtype, target) |
| 140 | + if objtype == "term": |
| 141 | + key = (objtype, target.lower()) |
| 142 | + if key in stddomain.objects: |
| 143 | + docname, labelid = stddomain.objects[key] |
| 144 | + domain_role = "std:" + stddomain.role_for_objtype(objtype) |
| 145 | + ref_node = make_refnode( |
| 146 | + self.app.builder, refdoc, docname, labelid, contnode |
| 147 | + ) |
| 148 | + results.append((domain_role, ref_node)) |
| 149 | + |
| 150 | + # finally resolve for any other type of reference |
| 151 | + # TODO do we want to restrict this? |
| 152 | + for domain in self.env.domains.values(): |
| 153 | + if domain.name == "std": |
| 154 | + continue # we did this one already |
| 155 | + try: |
| 156 | + results.extend( |
| 157 | + domain.resolve_any_xref( |
| 158 | + self.env, refdoc, self.app.builder, target, node, contnode |
| 159 | + ) |
| 160 | + ) |
| 161 | + except NotImplementedError: |
| 162 | + # the domain doesn't yet support the new interface |
| 163 | + # we have to manually collect possible references (SLOW) |
| 164 | + for role in domain.roles: |
| 165 | + res = domain.resolve_xref( |
| 166 | + self.env, refdoc, self.app.builder, role, target, node, contnode |
| 167 | + ) |
| 168 | + if res and isinstance(res[0], nodes.Element): |
| 169 | + results.append((f"{domain.name}:{role}", res)) |
| 170 | + |
| 171 | + # now, see how many matches we got... |
| 172 | + if not results: |
| 173 | + return None |
| 174 | + if len(results) > 1: |
| 175 | + |
| 176 | + def stringify(name, node): |
| 177 | + reftitle = node.get("reftitle", node.astext()) |
| 178 | + return f":{name}:`{reftitle}`" |
| 179 | + |
| 180 | + candidates = " or ".join(stringify(name, role) for name, role in results) |
| 181 | + logger.warning( |
| 182 | + __( |
| 183 | + f"more than one target found for 'myst' cross-reference {target}: " |
| 184 | + f"could be {candidates}" |
| 185 | + ), |
| 186 | + location=node, |
| 187 | + ) |
| 188 | + |
| 189 | + res_role, newnode = results[0] |
| 190 | + # Override "myst" class with the actual role type to get the styling |
| 191 | + # approximately correct. |
| 192 | + res_domain = res_role.split(":")[0] |
| 193 | + if len(newnode) > 0 and isinstance(newnode[0], nodes.Element): |
| 194 | + newnode[0]["classes"] = newnode[0].get("classes", []) + [ |
| 195 | + res_domain, |
| 196 | + res_role.replace(":", "-"), |
| 197 | + ] |
| 198 | + |
| 199 | + return newnode |
0 commit comments