1use std::borrow::Cow;
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use crate::doctree::{kinds, AttrValue, Doctree, Node};
17use crate::env::numbers::clean_astext;
18use crate::env::std_domain::{DocumentIds, PropagatedIds};
19use crate::env::toctree::docname_join;
20use crate::env::BuildEnvironment;
21use crate::error::{BuildWarning, WarningType};
22use crate::intersphinx::{self, Diagnostic, HookOutcome, Intersphinx, XrefQuery};
23use crate::utils::py_repr_str;
24
25#[derive(Clone, Copy)]
28pub struct XrefRequest<'a> {
29 pub fromdoc: &'a str,
31 pub refdoc: &'a str,
35 pub reftype: &'a str,
36 pub reftarget: &'a str,
37 pub refexplicit: bool,
38 pub program: Option<&'a str>,
41 pub contnode_text: &'a str,
43}
44
45#[derive(Debug, PartialEq)]
50pub enum XrefOutcome {
51 Resolved(ResolvedXref),
53 Kept { warning: Option<String> },
57 Missing,
60}
61
62#[derive(Debug, PartialEq)]
64pub struct ResolvedXref {
65 pub kind: &'static str,
67 pub refid: Option<String>,
68 pub refuri: Option<String>,
69 pub title: Option<String>,
71 pub reftitle: Option<String>,
75 pub inner: Inner,
76}
77
78#[derive(Debug, PartialEq)]
80pub enum Inner {
81 Contnode,
84 Inline { text: String, classes: Vec<String> },
87 Children(Vec<Node>),
91}
92
93pub struct Resolver<'a> {
97 pub env: &'a BuildEnvironment,
98 pub numfig: bool,
99 pub numfig_format: &'a BTreeMap<String, String>,
100 pub doctree: &'a dyn Fn(&str) -> Option<Cow<'a, Doctree>>,
103 pub relative_uri: &'a dyn Fn(&str, &str) -> String,
105 pub intersphinx: &'a Intersphinx,
110}
111
112impl Resolver<'_> {
113 pub fn resolve_xref(&self, req: &XrefRequest<'_>) -> XrefOutcome {
116 match req.reftype {
117 "ref" => self.resolve_ref(req),
118 "numref" => self.resolve_numref(req),
119 "keyword" => self.resolve_keyword(req),
120 "doc" => self.resolve_doc(req),
121 "option" => self.resolve_option(req),
122 "term" => self.resolve_term(req),
123 _ => self.resolve_obj(req),
124 }
125 }
126
127 fn resolve_ref(&self, req: &XrefRequest<'_>) -> XrefOutcome {
129 let (docname, labelid, sectname) = if req.refexplicit {
130 match self.env.std.anonlabels.get(req.reftarget) {
132 Some((docname, labelid)) => (
133 docname.clone(),
134 labelid.clone(),
135 req.contnode_text.to_string(),
136 ),
137 None => return XrefOutcome::Missing,
138 }
139 } else {
140 match self.env.std.labels.get(req.reftarget) {
141 Some((docname, labelid, sectname)) => {
142 (docname.clone(), labelid.clone(), sectname.clone())
143 }
144 None => return XrefOutcome::Missing,
145 }
146 };
147 if docname.is_empty() {
148 return XrefOutcome::Missing;
149 }
150 XrefOutcome::Resolved(self.build_reference_node(
151 LabelTarget {
152 fromdoc: req.fromdoc,
153 docname: &docname,
154 labelid: &labelid,
155 },
156 §name,
157 "ref",
158 kinds::REFERENCE,
159 None,
160 ))
161 }
162
163 fn resolve_numref(&self, req: &XrefRequest<'_>) -> XrefOutcome {
166 let (docname, labelid, figname) = match self.env.std.labels.get(req.reftarget) {
168 Some((docname, labelid, figname)) => {
169 (docname.clone(), labelid.clone(), Some(figname.clone()))
170 }
171 None => match self.env.std.anonlabels.get(req.reftarget) {
172 Some((docname, labelid)) => (docname.clone(), labelid.clone(), None),
173 None => return XrefOutcome::Missing,
174 },
175 };
176 if docname.is_empty() {
177 return XrefOutcome::Missing;
178 }
179
180 let Some((figtype, target_ids)) = (self.doctree)(&docname).and_then(|doctree| {
183 let ids = DocumentIds::of(&doctree);
184 let node = ids.node(&labelid)?;
185 Some((
186 enumerable_node_type(node).map(str::to_string),
187 PropagatedIds::of(&doctree).effective_ids(node),
191 ))
192 }) else {
193 return XrefOutcome::Missing;
194 };
195 let Some(figtype) = figtype else {
196 return XrefOutcome::Missing;
197 };
198
199 if figtype != "section" && !self.numfig {
200 return XrefOutcome::Kept {
201 warning: Some("numfig is disabled. :numref: is ignored.".to_string()),
202 };
203 }
204
205 let fignumber = match self.fignumber(&figtype, &docname, &target_ids) {
206 Ok(Some(fignumber)) => fignumber,
207 Ok(None) => return XrefOutcome::Kept { warning: None },
209 Err(NoNumber) => {
210 return XrefOutcome::Kept {
211 warning: Some(format!(
212 "Failed to create a cross reference. Any number is not assigned: {labelid}"
213 )),
214 }
215 }
216 };
217
218 let title = if req.refexplicit {
219 req.contnode_text.to_string()
220 } else {
221 self.numfig_format
222 .get(&figtype)
223 .cloned()
224 .unwrap_or_default()
225 };
226 if figname.is_none() && title.contains("{name}") {
227 return XrefOutcome::Kept {
228 warning: Some(format!("the link has no caption: {title}")),
229 };
230 }
231 let fignum: Vec<String> = fignumber.iter().map(u32::to_string).collect();
232 let fignum = fignum.join(".");
233 let newtitle = if title.contains("{name}") || title.contains("number") {
234 let named = figname.as_deref().filter(|figname| !figname.is_empty());
240 match format_new_style(&title, named, &fignum) {
241 Ok(newtitle) => newtitle,
242 Err(KeyError(key)) => {
243 return XrefOutcome::Kept {
244 warning: Some(format!(
245 "invalid numfig_format: {title} (KeyError({}))",
246 py_repr_str(&key)
247 )),
248 }
249 }
250 }
251 } else {
252 match format_old_style(&title, &fignum) {
254 Ok(newtitle) => newtitle,
255 Err(TypeError) => {
256 return XrefOutcome::Kept {
257 warning: Some(format!("invalid numfig_format: {title}")),
258 }
259 }
260 }
261 };
262
263 XrefOutcome::Resolved(self.build_reference_node(
264 LabelTarget {
265 fromdoc: req.fromdoc,
266 docname: &docname,
267 labelid: &labelid,
268 },
269 &newtitle,
270 "numref",
271 "number_reference",
272 Some(title),
273 ))
274 }
275
276 fn fignumber(
279 &self,
280 figtype: &str,
281 docname: &str,
282 target_ids: &[String],
283 ) -> Result<Option<Vec<u32>>, NoNumber> {
284 if figtype == "section" {
285 let secnumbers = self.env.toc_secnumbers.get(docname).ok_or(NoNumber)?;
287 let anchorname = format!("#{}", target_ids.first().ok_or(NoNumber)?);
288 return Ok(secnumbers
289 .get(&anchorname)
290 .or_else(|| secnumbers.get(""))
291 .cloned());
292 }
293 let figure_id = target_ids.first().ok_or(NoNumber)?;
296 self.env
297 .toc_fignumbers
298 .get(docname)
299 .and_then(|per_type| per_type.get(figtype))
300 .and_then(|per_id| per_id.get(figure_id))
301 .cloned()
302 .map(Some)
303 .ok_or(NoNumber)
304 }
305
306 fn resolve_keyword(&self, req: &XrefRequest<'_>) -> XrefOutcome {
309 match self.env.std.labels.get(req.reftarget) {
310 Some((docname, labelid, _)) if !docname.is_empty() => {
311 XrefOutcome::Resolved(self.make_refnode(req.fromdoc, docname, Some(labelid)))
312 }
313 _ => XrefOutcome::Missing,
314 }
315 }
316
317 fn resolve_doc(&self, req: &XrefRequest<'_>) -> XrefOutcome {
319 let docname = docname_join(req.refdoc, req.reftarget);
320 if !self.env.all_docs.contains_key(&docname) {
321 return XrefOutcome::Missing;
322 }
323 let caption = if req.refexplicit {
324 req.contnode_text.to_string()
325 } else {
326 self.env
327 .titles
328 .get(&docname)
329 .map(clean_astext)
330 .unwrap_or_default()
331 };
332 let mut node = self.make_refnode(req.fromdoc, &docname, None);
333 node.inner = Inner::Inline {
334 text: caption,
335 classes: vec!["doc".to_string()],
336 };
337 XrefOutcome::Resolved(node)
338 }
339
340 fn resolve_option(&self, req: &XrefRequest<'_>) -> XrefOutcome {
344 let program = req.program.map(str::to_string);
345 let target = req.reftarget.trim();
346
347 let mut found = self.progoption(program.as_deref(), target);
348 if found.is_none() {
349 for needle in ["=", "[=", " "] {
351 if let Some((stem, _)) = target.split_once(needle) {
352 found = self.progoption(program.as_deref(), stem);
353 if found.is_some() {
354 break;
355 }
356 }
357 }
358 }
359 if found.is_none() {
360 let mut commands: Vec<&str> = Vec::new();
363 let mut rest = target;
364 while let Some((subcommand, tail)) = split_once_whitespace(rest) {
365 commands.push(subcommand);
366 rest = tail;
367 let progname = commands.join("-");
368 found = self.progoption(Some(&progname), rest);
369 if found.is_some() {
370 break;
371 }
372 }
373 }
374 match found {
375 Some((docname, labelid)) => {
376 XrefOutcome::Resolved(self.make_refnode(req.fromdoc, &docname, Some(&labelid)))
377 }
378 None => XrefOutcome::Missing,
379 }
380 }
381
382 fn progoption(&self, program: Option<&str>, name: &str) -> Option<(String, String)> {
383 self.env
384 .std
385 .progoptions
386 .get(&(program.map(str::to_string), name.to_string()))
387 .filter(|(docname, _)| !docname.is_empty())
388 .cloned()
389 }
390
391 fn resolve_term(&self, req: &XrefRequest<'_>) -> XrefOutcome {
394 if let XrefOutcome::Resolved(node) = self.resolve_obj(req) {
395 return XrefOutcome::Resolved(node);
396 }
397 match self.env.std.terms.get(&req.reftarget.to_lowercase()) {
398 Some((docname, labelid)) => {
399 XrefOutcome::Resolved(self.make_refnode(req.fromdoc, docname, Some(labelid)))
400 }
401 None => XrefOutcome::Missing,
402 }
403 }
404
405 fn resolve_obj(&self, req: &XrefRequest<'_>) -> XrefOutcome {
408 for objtype in objtypes_for_role(req.reftype) {
409 let key = (objtype.to_string(), req.reftarget.to_string());
410 if let Some((docname, labelid)) = self.env.std.objects.get(&key) {
411 if docname.is_empty() {
412 break;
413 }
414 return XrefOutcome::Resolved(self.make_refnode(
415 req.fromdoc,
416 docname,
417 Some(labelid),
418 ));
419 }
420 }
421 XrefOutcome::Missing
422 }
423
424 fn make_refnode(&self, fromdoc: &str, docname: &str, targetid: Option<&str>) -> ResolvedXref {
426 let mut node = ResolvedXref {
427 kind: kinds::REFERENCE,
428 refid: None,
429 refuri: None,
430 title: None,
431 reftitle: None,
432 inner: Inner::Contnode,
433 };
434 match targetid {
435 Some(targetid) if fromdoc == docname => node.refid = Some(targetid.to_string()),
436 Some(targetid) => {
437 node.refuri = Some(format!(
438 "{}#{targetid}",
439 (self.relative_uri)(fromdoc, docname)
440 ));
441 }
442 None => node.refuri = Some((self.relative_uri)(fromdoc, docname)),
443 }
444 node
445 }
446
447 fn py_refnode(
454 &self,
455 fromdoc: &str,
456 target: crate::env::py_domain::PyXrefTarget<'_>,
457 resolved_children: Option<Vec<Node>>,
458 ) -> ResolvedXref {
459 let targetid = Some(target.node_id).filter(|id| !id.is_empty());
461 let mut node = self.make_refnode(fromdoc, target.docname, targetid);
462 node.reftitle = Some(target.reftitle);
463 if !target.is_module {
464 if let Some(children) = resolved_children {
465 node.inner = Inner::Children(children);
466 }
467 }
468 node
469 }
470
471 fn resolve_any(
483 &self,
484 req: &XrefRequest<'_>,
485 py_module: Option<&str>,
486 py_class: Option<&str>,
487 resolved_children: Option<&Vec<Node>>,
488 ) -> Vec<AnyCandidate> {
489 let mut results: Vec<AnyCandidate> = Vec::new();
490 let mut push = |role: String, node: ResolvedXref| {
491 let label = node.reftitle.clone().unwrap_or_else(|| match &node.inner {
493 Inner::Inline { text, .. } => text.clone(),
494 Inner::Contnode => req.contnode_text.to_string(),
495 Inner::Children(children) => children.iter().map(Node::astext).collect(),
496 });
497 results.push(AnyCandidate { role, node, label });
498 };
499
500 if let XrefOutcome::Resolved(node) = self.resolve_doc(&XrefRequest {
502 reftype: "doc",
503 ..*req
504 }) {
505 push("doc".to_string(), node);
506 }
507
508 let ltarget = req.reftarget.to_lowercase();
513 if let XrefOutcome::Resolved(node) = self.resolve_ref(&XrefRequest {
514 reftype: "ref",
515 reftarget: <arget,
516 ..*req
517 }) {
518 push("std:ref".to_string(), node);
519 }
520 if let XrefOutcome::Resolved(node) = self.resolve_option(&XrefRequest {
521 reftype: "option",
522 ..*req
523 }) {
524 push("std:option".to_string(), node);
525 }
526 for (objtype, role) in STD_OBJECT_TYPE_ROLES {
527 let name = if *objtype == "term" {
528 ltarget.clone()
534 } else {
535 req.reftarget.to_string()
536 };
537 let key = ((*objtype).to_string(), name);
538 if let Some((docname, labelid)) = self.env.std.objects.get(&key) {
539 push(
540 format!("std:{role}"),
541 self.make_refnode(req.fromdoc, docname, Some(labelid)),
542 );
543 }
544 }
545
546 for (role, target) in crate::env::py_domain::resolve_any_xref(
549 &self.env.py,
550 py_module,
551 py_class,
552 req.reftarget,
553 ) {
554 let node = self.py_refnode(req.fromdoc, target, resolved_children.cloned());
555 push(role, node);
556 }
557 results
558 }
559
560 fn build_reference_node(
563 &self,
564 target: LabelTarget<'_>,
565 sectname: &str,
566 rolename: &str,
567 kind: &'static str,
568 title: Option<String>,
569 ) -> ResolvedXref {
570 let LabelTarget {
571 fromdoc,
572 docname,
573 labelid,
574 } = target;
575 let mut node = ResolvedXref {
576 kind,
577 refid: None,
578 refuri: None,
579 title,
580 reftitle: None,
581 inner: Inner::Inline {
582 text: sectname.to_string(),
583 classes: vec!["std".to_string(), format!("std-{rolename}")],
584 },
585 };
586 if docname == fromdoc {
589 node.refid = Some(labelid.to_string());
590 } else {
591 let mut refuri = (self.relative_uri)(fromdoc, docname);
592 if !labelid.is_empty() {
593 refuri.push('#');
594 refuri.push_str(labelid);
595 }
596 node.refuri = Some(refuri);
597 }
598 node
599 }
600}
601
602const STD_OBJECT_TYPE_ROLES: &[(&str, &str)] = &[
609 ("term", "term"),
610 ("token", "token"),
611 ("label", "ref"),
612 ("confval", "confval"),
613 ("envvar", "envvar"),
614 ("cmdoption", "option"),
615 ("doc", "doc"),
616];
617
618struct AnyCandidate {
622 role: String,
623 node: ResolvedXref,
624 label: String,
625}
626
627struct LabelTarget<'a> {
629 fromdoc: &'a str,
630 docname: &'a str,
631 labelid: &'a str,
632}
633
634#[derive(Debug)]
636struct NoNumber;
637
638#[derive(Debug)]
640struct KeyError(String);
641
642#[derive(Debug)]
644struct TypeError;
645
646fn format_new_style(title: &str, figname: Option<&str>, fignum: &str) -> Result<String, KeyError> {
649 let mut out = String::with_capacity(title.len());
650 let mut rest = title;
651 while let Some(open) = rest.find('{') {
652 out.push_str(&rest[..open]);
653 let after = &rest[open + 1..];
654 let Some(close) = after.find('}') else {
655 out.push_str(&rest[open..]);
659 return Ok(out);
660 };
661 let field = &after[..close];
662 match field {
663 "name" => match figname {
666 Some(figname) => out.push_str(figname),
667 None => return Err(KeyError("name".to_string())),
668 },
669 "number" => out.push_str(fignum),
670 other => return Err(KeyError(other.to_string())),
671 }
672 rest = &after[close + 1..];
673 }
674 out.push_str(rest);
675 Ok(out)
676}
677
678fn format_old_style(title: &str, fignum: &str) -> Result<String, TypeError> {
686 let mut out = String::with_capacity(title.len());
687 let mut rest = title;
688 let mut conversions = 0usize;
689 while let Some(percent) = rest.find('%') {
690 out.push_str(&rest[..percent]);
691 let mut chars = rest[percent + 1..].chars();
692 match chars.next() {
693 Some('%') => out.push('%'),
694 Some('s') => {
695 conversions += 1;
696 out.push_str(fignum);
697 }
698 _ => return Err(TypeError),
701 }
702 rest = &rest[percent + 2..];
703 }
704 if conversions != 1 {
705 return Err(TypeError);
707 }
708 out.push_str(rest);
709 Ok(out)
710}
711
712fn split_once_whitespace(target: &str) -> Option<(&str, &str)> {
717 let start = target.find(crate::utils::py_isspace)?;
718 let end = target[start..]
719 .find(|c: char| !crate::utils::py_isspace(c))
720 .map(|offset| start + offset)
721 .unwrap_or(target.len());
722 Some((&target[..start], &target[end..]))
723}
724
725fn objtypes_for_role(role: &str) -> &'static [&'static str] {
727 match role {
728 "term" => &["term"],
729 "token" => &["token"],
730 "ref" | "keyword" => &["label"],
731 "confval" => &["confval"],
732 "envvar" => &["envvar"],
733 "option" => &["cmdoption"],
734 "doc" => &["doc"],
735 _ => &[],
736 }
737}
738
739fn enumerable_node_type(node: &Node) -> Option<&'static str> {
743 match node.kind {
744 kinds::SECTION => Some("section"),
745 "figure" => Some("figure"),
746 kinds::TABLE => Some("table"),
747 "container" => Some("code-block"),
748 _ => None,
749 }
750}
751
752pub struct NitpickConfig<'a> {
759 pub nitpicky: bool,
760 pub ignore: &'a [(String, String)],
761 pub ignore_regex: &'a [(String, String)],
762}
763
764#[derive(Default)]
766pub struct DocumentResolution {
767 pub warnings: Vec<BuildWarning>,
768 pub unresolvable_domain_refs: usize,
772}
773
774pub fn resolve_document(
778 resolver: &Resolver<'_>,
779 nitpick: &NitpickConfig<'_>,
780 docname: &str,
781 doctree: &mut Doctree,
782 path: &Path,
783) -> DocumentResolution {
784 let mut out = DocumentResolution::default();
785 let sources = doctree.sources.clone();
789 resolve_children(
790 resolver,
791 nitpick,
792 docname,
793 &mut doctree.root,
794 &sources,
795 path,
796 None,
797 &mut out,
798 );
799 propagate_desc_domain(&mut doctree.root);
800 out
801}
802
803type Location = Option<(u16, u32)>;
814
815fn contributes_location(node: &Node) -> bool {
824 node.span.line != 0 && !matches!(node.kind, kinds::DOCUMENT | "desc" | "desc_content")
825}
826
827fn propagate_desc_domain(node: &mut Node) {
832 if node.kind == "desc" {
833 if let Some(AttrValue::Str(domain)) = node.get("domain") {
834 if !domain.is_empty() {
835 let domain = domain.clone();
836 for child in &mut node.children {
837 if child.kind == "desc_signature" {
838 child.attrs.classes.push(domain.clone());
839 }
840 }
841 }
842 }
843 }
844 for child in &mut node.children {
845 propagate_desc_domain(child);
846 }
847}
848
849#[allow(clippy::too_many_arguments)]
850fn resolve_children(
851 resolver: &Resolver<'_>,
852 nitpick: &NitpickConfig<'_>,
853 docname: &str,
854 node: &mut Node,
855 sources: &[String],
856 path: &Path,
857 inherited: Location,
858 out: &mut DocumentResolution,
859) {
860 let location = if contributes_location(node) {
861 Some((node.span.source, node.span.line))
862 } else {
863 inherited
864 };
865 for child in &mut node.children {
866 resolve_children(
867 resolver, nitpick, docname, child, sources, path, location, out,
868 );
869 }
870 if !node
871 .children
872 .iter()
873 .any(|child| child.kind == kinds::PENDING_XREF)
874 {
875 return;
876 }
877 let children = std::mem::take(&mut node.children);
878 for child in children {
879 if child.kind != kinds::PENDING_XREF {
880 node.children.push(child);
881 continue;
882 }
883 node.children.extend(resolve_one(
884 resolver, nitpick, docname, child, sources, path, location, out,
885 ));
886 }
887}
888
889#[allow(clippy::too_many_arguments)]
891fn resolve_one(
892 resolver: &Resolver<'_>,
893 nitpick: &NitpickConfig<'_>,
894 docname: &str,
895 node: Node,
896 sources: &[String],
897 doc_path: &Path,
898 inherited: Location,
899 out: &mut DocumentResolution,
900) -> Vec<Node> {
901 let span = node.span;
902 let location = if span.line != 0 {
909 Some((span.source, span.line))
910 } else {
911 inherited
912 };
913 let (source_path, line): (PathBuf, Option<usize>) = match location {
914 Some((source, line)) => (
915 sources
916 .get(source as usize)
917 .map(PathBuf::from)
918 .unwrap_or_else(|| doc_path.to_path_buf()),
919 Some(line as usize),
920 ),
921 None => (PathBuf::new(), None),
922 };
923 let path = source_path.as_path();
924 let refdomain = attr_str(&node, "refdomain").unwrap_or_default().to_string();
925 let reftype = attr_str(&node, "reftype").unwrap_or_default().to_string();
926 let reftarget = attr_str(&node, "reftarget").unwrap_or_default().to_string();
927 let refdoc = attr_str(&node, "refdoc").unwrap_or(docname).to_string();
928 let refexplicit = matches!(node.get("refexplicit"), Some(AttrValue::Int(1)));
929 let refwarn = matches!(node.get("refwarn"), Some(AttrValue::Int(1)));
930 let program = attr_str(&node, "std:program")
934 .filter(|program| !crate::env::std_domain::is_none_sentinel(program))
935 .map(str::to_string);
936 let external = matches!(node.get("intersphinx"), Some(AttrValue::Int(1)));
939 let inventory = attr_str(&node, "inventory").map(str::to_string);
940 let role_error = attr_str(&node, "intersphinx_role_error").map(str::to_string);
941 let py_module = attr_str(&node, "py:module")
945 .filter(|value| !crate::env::std_domain::is_none_sentinel(value) && !value.is_empty())
946 .map(str::to_string);
947 let py_class = attr_str(&node, "py:class")
948 .filter(|value| !crate::env::std_domain::is_none_sentinel(value) && !value.is_empty())
949 .map(str::to_string);
950 let searchmode: u8 = u8::from(node.get("refspecific").is_some());
955 let children = XrefChildren::split(node.children);
956 let contnode = children.contnode();
957 let contnode_text = contnode.as_ref().map(Node::astext).unwrap_or_default();
958
959 let query = XrefQuery {
960 refdomain: &refdomain,
961 reftype: &reftype,
962 reftarget: &reftarget,
963 refexplicit,
964 refdoc: &refdoc,
965 contnode_text: &contnode_text,
966 };
967
968 let self_referential = inventory.as_deref().is_some_and(|inventory| {
972 !resolver.intersphinx.resolve_self.is_empty()
973 && resolver.intersphinx.resolve_self == inventory
974 });
975 if external && !self_referential {
976 return resolve_external(
977 resolver,
978 &query,
979 inventory.as_deref(),
980 role_error.as_deref(),
981 contnode,
982 span,
983 line,
984 path,
985 out,
986 );
987 }
988
989 if refdomain.is_empty() && reftype == "any" {
993 return resolve_any_ref(
994 resolver,
995 nitpick,
996 docname,
997 &query,
998 PyRefContext {
999 module: py_module.as_deref(),
1000 class: py_class.as_deref(),
1001 searchmode: 1,
1002 refwarn,
1003 },
1004 program.as_deref(),
1005 children,
1006 span,
1007 line,
1008 path,
1009 out,
1010 );
1011 }
1012
1013 if !matches!(refdomain.as_str(), "" | "std" | "py") {
1019 let mut diagnostics = Vec::new();
1020 let outcome = resolver
1021 .intersphinx
1022 .resolve_detect(&query, &mut diagnostics);
1023 report(out, diagnostics, line, path);
1024 if let HookOutcome::Resolved(resolution) = outcome {
1025 return vec![intersphinx_node(resolution, contnode, span)];
1026 }
1027 out.unresolvable_domain_refs += 1;
1028 return children.fallback(out, line, path);
1029 }
1030 if refdomain == "py" {
1031 return resolve_py(
1032 resolver,
1033 nitpick,
1034 docname,
1035 &query,
1036 PyRefContext {
1037 module: py_module.as_deref(),
1038 class: py_class.as_deref(),
1039 searchmode,
1040 refwarn,
1041 },
1042 children,
1043 span,
1044 line,
1045 path,
1046 out,
1047 );
1048 }
1049 if reftype == "doc" && is_url(&reftarget) {
1054 return contnode.into_iter().collect();
1055 }
1056
1057 let req = XrefRequest {
1058 fromdoc: docname,
1059 refdoc: &refdoc,
1060 reftype: &reftype,
1061 reftarget: &reftarget,
1062 refexplicit,
1063 program: program.as_deref(),
1064 contnode_text: &contnode_text,
1065 };
1066 let outcome = resolver.resolve_xref(&req);
1067
1068 match outcome {
1069 XrefOutcome::Resolved(resolved) => {
1070 vec![reference_node(resolved, contnode, span)]
1071 }
1072 XrefOutcome::Kept { warning } => {
1073 if let Some(message) = warning {
1074 out.warnings.push(
1075 BuildWarning::new(
1076 path.to_path_buf(),
1077 line,
1078 message,
1079 WarningType::BrokenCrossReference,
1080 )
1081 .with_category(None),
1082 );
1083 }
1084 contnode.into_iter().collect()
1085 }
1086 XrefOutcome::Missing => {
1087 let mut diagnostics = Vec::new();
1090 let outcome = resolver
1091 .intersphinx
1092 .resolve_detect(&query, &mut diagnostics);
1093 report(out, diagnostics, line, path);
1094 match outcome {
1095 HookOutcome::Resolved(resolution) => {
1096 return vec![intersphinx_node(resolution, contnode, span)];
1097 }
1098 HookOutcome::SelfReferential(stripped) => {
1102 let retry = resolver.resolve_xref(&XrefRequest {
1103 reftarget: &stripped,
1104 ..req
1105 });
1106 if let XrefOutcome::Resolved(resolved) = retry {
1107 return vec![reference_node(resolved, contnode, span)];
1108 }
1109 }
1110 HookOutcome::Missing => {}
1111 }
1112 if let Some(message) = missing_reference_warning(
1113 resolver.env,
1114 nitpick,
1115 &refdomain,
1116 &reftype,
1117 &reftarget,
1118 refwarn,
1119 ) {
1120 out.warnings.push(
1121 BuildWarning::new(
1122 path.to_path_buf(),
1123 line,
1124 message,
1125 WarningType::BrokenCrossReference,
1126 )
1127 .with_category(Some(format!("ref.{reftype}"))),
1129 );
1130 }
1131 children.fallback(out, line, path)
1132 }
1133 }
1134}
1135
1136struct PyRefContext<'a> {
1138 module: Option<&'a str>,
1141 class: Option<&'a str>,
1142 searchmode: u8,
1143 refwarn: bool,
1144}
1145
1146#[allow(clippy::too_many_arguments)]
1154fn resolve_py(
1155 resolver: &Resolver<'_>,
1156 nitpick: &NitpickConfig<'_>,
1157 docname: &str,
1158 query: &XrefQuery<'_>,
1159 ctx: PyRefContext<'_>,
1160 children: XrefChildren,
1161 span: crate::doctree::Span,
1162 line: Option<usize>,
1163 path: &Path,
1164 out: &mut DocumentResolution,
1165) -> Vec<Node> {
1166 use crate::env::py_domain;
1167
1168 let reftype = query.reftype;
1169 let contnode = children.contnode();
1170
1171 let resolve = |target: &str, out: &mut DocumentResolution| {
1174 let (found, ambiguity) = py_domain::resolve_xref(
1175 &resolver.env.py,
1176 ctx.module,
1177 ctx.class,
1178 reftype,
1179 target,
1180 ctx.searchmode,
1181 );
1182 if let Some(message) = ambiguity {
1183 out.warnings.push(
1184 BuildWarning::new(
1185 path.to_path_buf(),
1186 line,
1187 message,
1188 WarningType::BrokenCrossReference,
1189 )
1190 .with_category(Some("ref.python".to_string())),
1192 );
1193 }
1194 found
1195 };
1196 if let Some(target) = resolve(query.reftarget, out) {
1197 let resolved = resolver.py_refnode(docname, target, children.resolved.clone());
1198 return vec![reference_node(resolved, contnode, span)];
1199 }
1200
1201 let mut diagnostics = Vec::new();
1203 let outcome = resolver.intersphinx.resolve_detect(query, &mut diagnostics);
1204 report(out, diagnostics, line, path);
1205 match outcome {
1206 HookOutcome::Resolved(resolution) => {
1207 return vec![intersphinx_node(resolution, contnode, span)];
1208 }
1209 HookOutcome::SelfReferential(stripped) => {
1210 if py_domain::builtin_resolver(reftype, &stripped) {
1213 return children.contnode().into_iter().collect();
1214 }
1215 if let Some(target) = resolve(&stripped, out) {
1218 let resolved = resolver.py_refnode(docname, target, children.resolved.clone());
1219 return vec![reference_node(resolved, contnode, span)];
1220 }
1221 }
1222 HookOutcome::Missing => {
1223 if py_domain::builtin_resolver(reftype, query.reftarget) {
1224 return children.contnode().into_iter().collect();
1229 }
1230 }
1231 }
1232
1233 if let Some(message) = missing_reference_warning(
1234 resolver.env,
1235 nitpick,
1236 "py",
1237 reftype,
1238 query.reftarget,
1239 ctx.refwarn,
1240 ) {
1241 out.warnings.push(
1242 BuildWarning::new(
1243 path.to_path_buf(),
1244 line,
1245 message,
1246 WarningType::BrokenCrossReference,
1247 )
1248 .with_category(Some(format!("ref.{reftype}"))),
1250 );
1251 }
1252 children.fallback(out, line, path)
1253}
1254
1255#[allow(clippy::too_many_arguments)]
1264fn resolve_any_ref(
1265 resolver: &Resolver<'_>,
1266 nitpick: &NitpickConfig<'_>,
1267 docname: &str,
1268 query: &XrefQuery<'_>,
1269 ctx: PyRefContext<'_>,
1270 program: Option<&str>,
1271 children: XrefChildren,
1272 span: crate::doctree::Span,
1273 line: Option<usize>,
1274 path: &Path,
1275 out: &mut DocumentResolution,
1276) -> Vec<Node> {
1277 let contnode = children.contnode();
1278 let contnode_text = contnode.as_ref().map(Node::astext).unwrap_or_default();
1279 let req = XrefRequest {
1280 fromdoc: docname,
1281 refdoc: query.refdoc,
1282 reftype: "any",
1283 reftarget: query.reftarget,
1284 refexplicit: query.refexplicit,
1285 program,
1286 contnode_text: &contnode_text,
1287 };
1288
1289 let attempt = |target: &str, out: &mut DocumentResolution| -> Option<Node> {
1292 let mut results = resolver.resolve_any(
1293 &XrefRequest {
1294 reftarget: target,
1295 ..req
1296 },
1297 ctx.module,
1298 ctx.class,
1299 children.resolved.as_ref(),
1300 );
1301 if results.is_empty() {
1302 return None;
1303 }
1304 if results.len() > 1 {
1305 let candidates = results
1306 .iter()
1307 .map(|candidate| format!(":{}:`{}`", candidate.role, candidate.label))
1308 .collect::<Vec<_>>()
1309 .join(" or ");
1310 out.warnings.push(
1311 BuildWarning::new(
1312 path.to_path_buf(),
1313 line,
1314 format!(
1315 "more than one target found for 'any' cross-reference {}: \
1316 could be {candidates}",
1317 py_repr_str(target)
1318 ),
1319 WarningType::BrokenCrossReference,
1320 )
1321 .with_category(Some("ref.any".to_string())),
1323 );
1324 }
1325 let AnyCandidate { role, node, .. } = results.remove(0);
1326 let mut built = reference_node(node, children.contnode(), span);
1327 if let Some(first) = built.children.first_mut() {
1334 if first.kind != kinds::TEXT && !first.attrs.classes.is_empty() {
1335 let domain_half = role.split(':').next().unwrap_or_default().to_string();
1336 first.attrs.classes.push(domain_half);
1337 first.attrs.classes.push(role.replace(':', "-"));
1338 }
1339 }
1340 Some(built)
1341 };
1342
1343 if let Some(node) = attempt(query.reftarget, out) {
1344 return vec![node];
1345 }
1346
1347 let mut diagnostics = Vec::new();
1350 let outcome = resolver.intersphinx.resolve_detect(query, &mut diagnostics);
1351 report(out, diagnostics, line, path);
1352 match outcome {
1353 HookOutcome::Resolved(resolution) => {
1354 return vec![intersphinx_node(resolution, contnode, span)];
1355 }
1356 HookOutcome::SelfReferential(stripped) => {
1357 if let Some(node) = attempt(&stripped, out) {
1358 return vec![node];
1359 }
1360 }
1361 HookOutcome::Missing => {}
1362 }
1363
1364 if let Some(message) = missing_reference_warning(
1365 resolver.env,
1366 nitpick,
1367 "",
1368 "any",
1369 query.reftarget,
1370 ctx.refwarn,
1371 ) {
1372 out.warnings.push(
1373 BuildWarning::new(
1374 path.to_path_buf(),
1375 line,
1376 message,
1377 WarningType::BrokenCrossReference,
1378 )
1379 .with_category(Some("ref.any".to_string())),
1381 );
1382 }
1383 children.fallback(out, line, path)
1384}
1385
1386#[allow(clippy::too_many_arguments)]
1392fn resolve_external(
1393 resolver: &Resolver<'_>,
1394 query: &XrefQuery<'_>,
1395 inventory: Option<&str>,
1396 role_error: Option<&str>,
1397 contnode: Option<Node>,
1398 span: crate::doctree::Span,
1399 line: Option<usize>,
1400 path: &Path,
1401 out: &mut DocumentResolution,
1402) -> Vec<Node> {
1403 if let Some(inventory) = inventory {
1404 if let Some(diagnostic) =
1405 intersphinx::external_inventory_missing(resolver.intersphinx, inventory)
1406 {
1407 report(out, vec![diagnostic], line, path);
1408 return Vec::new();
1411 }
1412 }
1413 if let Some(message) = role_error {
1414 report(
1415 out,
1416 vec![Diagnostic {
1417 message: message.to_string(),
1418 category: Some("intersphinx.external".to_string()),
1419 }],
1420 line,
1421 path,
1422 );
1423 return Vec::new();
1424 }
1425
1426 let mut diagnostics = Vec::new();
1427 let resolution = match inventory {
1428 Some(inventory) => {
1429 resolver
1430 .intersphinx
1431 .resolve_in_inventory(inventory, query, &mut diagnostics)
1432 }
1433 None => resolver
1436 .intersphinx
1437 .resolve_any(false, query, &mut diagnostics),
1438 };
1439 report(out, diagnostics, line, path);
1440
1441 match resolution {
1442 Some(resolution) => vec![intersphinx_node(resolution, contnode, span)],
1443 None => {
1444 report(
1445 out,
1446 vec![intersphinx::external_not_found(query)],
1447 line,
1448 path,
1449 );
1450 contnode.into_iter().collect()
1451 }
1452 }
1453}
1454
1455struct XrefChildren {
1461 contnode: Option<Node>,
1462 resolved: Option<Vec<Node>>,
1465 star: Option<Vec<Node>>,
1468 first_is_condition: bool,
1471}
1472
1473impl XrefChildren {
1474 fn split(children: Vec<Node>) -> Self {
1485 let first_is_condition = children
1486 .first()
1487 .is_some_and(|child| child.kind == "pending_xref_condition");
1488 let find = |condition: &str| -> Option<Vec<Node>> {
1489 children
1490 .iter()
1491 .find(|child| {
1492 child.kind == "pending_xref_condition"
1493 && !child.children.is_empty()
1494 && matches!(child.get("condition"),
1495 Some(AttrValue::Str(value)) if value == condition)
1496 })
1497 .map(|child| child.children.clone())
1498 };
1499 let resolved = find("resolved");
1500 let star = find("*");
1501 let contnode = resolved
1502 .as_ref()
1503 .or(star.as_ref())
1504 .map(|content| content[0].clone())
1505 .or_else(|| children.into_iter().next());
1508 XrefChildren {
1509 contnode,
1510 resolved,
1511 star,
1512 first_is_condition,
1513 }
1514 }
1515
1516 fn contnode(&self) -> Option<Node> {
1518 self.contnode.clone()
1519 }
1520
1521 fn fallback(self, out: &mut DocumentResolution, line: Option<usize>, path: &Path) -> Vec<Node> {
1526 if self.first_is_condition {
1527 if let Some(star) = self.star {
1528 return star;
1529 }
1530 out.warnings.push(
1531 BuildWarning::new(
1532 path.to_path_buf(),
1533 line,
1534 "Could not determine the fallback text for the cross-reference. \
1535 Might be a bug."
1536 .to_string(),
1537 WarningType::BrokenCrossReference,
1538 )
1539 .with_category(None),
1541 );
1542 }
1543 self.contnode.into_iter().collect()
1544 }
1545}
1546
1547fn report(
1549 out: &mut DocumentResolution,
1550 diagnostics: Vec<Diagnostic>,
1551 line: Option<usize>,
1552 path: &Path,
1553) {
1554 for diagnostic in diagnostics {
1555 out.warnings.push(
1556 BuildWarning::new(
1557 path.to_path_buf(),
1558 line,
1559 diagnostic.message,
1560 WarningType::BrokenCrossReference,
1561 )
1562 .with_category(diagnostic.category),
1563 );
1564 }
1565}
1566
1567fn intersphinx_node(
1572 resolution: crate::intersphinx::Resolution,
1573 contnode: Option<Node>,
1574 span: crate::doctree::Span,
1575) -> Node {
1576 let mut node = Node::elem(kinds::REFERENCE, span);
1577 node.set("internal", AttrValue::Int(0));
1578 node.set("refuri", AttrValue::Str(resolution.refuri));
1579 node.set("reftitle", AttrValue::Str(resolution.reftitle));
1580 match resolution.title {
1581 Some(title) => {
1584 let kind = contnode.as_ref().map_or(kinds::LITERAL, |node| node.kind);
1585 let mut inner = Node::elem(kind, span);
1586 inner.children.push(Node::text_node(title, span));
1587 node.children.push(inner);
1588 }
1589 None => node.children.extend(contnode),
1590 }
1591 node
1592}
1593
1594fn missing_reference_warning(
1605 env: &BuildEnvironment,
1606 nitpick: &NitpickConfig<'_>,
1607 refdomain: &str,
1608 typ: &str,
1609 target: &str,
1610 refwarn: bool,
1611) -> Option<String> {
1612 let py = refdomain == "py";
1613 let mut warn = refwarn;
1614 if nitpick.nitpicky {
1615 warn = true;
1616 let dtype = if py {
1619 format!("py:{typ}")
1620 } else if refdomain.is_empty() {
1621 typ.to_string()
1622 } else {
1623 format!("std:{typ}")
1624 };
1625 let bare = !py;
1626 let ignored =
1627 nitpick.ignore.iter().any(|(ityp, itarget)| {
1628 (ityp == &dtype || (bare && ityp == typ)) && itarget == target
1629 }) || nitpick.ignore_regex.iter().any(|(ityp, itarget)| {
1630 (full_match(ityp, &dtype) || (bare && full_match(ityp, typ)))
1631 && full_match(itarget, target)
1632 });
1633 if ignored {
1634 warn = false;
1635 }
1636 }
1637 if !warn {
1638 return None;
1639 }
1640
1641 if py {
1646 return Some(format!("py:{typ} reference target not found: {target}"));
1647 }
1648
1649 if typ == "ref" {
1652 return Some(if env.std.anonlabels.contains_key(target) {
1653 format!(
1654 "Failed to create a cross reference. A title or caption not found: {}",
1655 py_repr_str(target)
1656 )
1657 } else {
1658 format!("undefined label: {}", py_repr_str(target))
1659 });
1660 }
1661 let message = match typ {
1663 "term" => Some(format!("term not in glossary: {}", py_repr_str(target))),
1664 "numref" => Some(format!("undefined label: {}", py_repr_str(target))),
1665 "keyword" => Some(format!("unknown keyword: {}", py_repr_str(target))),
1666 "doc" => Some(format!("unknown document: {}", py_repr_str(target))),
1667 "option" => Some(format!("unknown option: {}", py_repr_str(target))),
1668 _ => None,
1669 };
1670 Some(message.unwrap_or_else(|| {
1671 format!("{} reference target not found: {target}", py_repr_str(typ))
1675 }))
1676}
1677
1678fn full_match(pattern: &str, text: &str) -> bool {
1680 regex::Regex::new(&format!("^(?:{pattern})$"))
1681 .map(|re| re.is_match(text))
1682 .unwrap_or(false)
1683}
1684
1685fn is_url(target: &str) -> bool {
1686 target.starts_with("http://") || target.starts_with("https://") || target.starts_with("file://")
1687}
1688
1689fn attr_str<'a>(node: &'a Node, key: &'static str) -> Option<&'a str> {
1690 match node.get(key) {
1691 Some(AttrValue::Str(value)) => Some(value.as_str()),
1692 _ => None,
1693 }
1694}
1695
1696fn reference_node(
1698 resolved: ResolvedXref,
1699 contnode: Option<Node>,
1700 span: crate::doctree::Span,
1701) -> Node {
1702 let mut node = Node::elem(resolved.kind, span);
1703 node.set("internal", AttrValue::Int(1));
1704 if let Some(refid) = resolved.refid {
1705 node.set("refid", AttrValue::Str(refid));
1706 }
1707 if let Some(refuri) = resolved.refuri {
1708 node.set("refuri", AttrValue::Str(refuri));
1709 }
1710 if let Some(title) = resolved.title {
1711 node.set("title", AttrValue::Str(title));
1712 }
1713 if let Some(reftitle) = resolved.reftitle {
1714 node.set("reftitle", AttrValue::Str(reftitle));
1715 }
1716 match resolved.inner {
1717 Inner::Contnode => node.children.extend(contnode),
1718 Inner::Inline { text, classes } => {
1719 let mut inner = Node::elem("inline", span);
1720 inner.attrs.classes = classes;
1721 inner.children.push(Node::text_node(text, span));
1722 node.children.push(inner);
1723 }
1724 Inner::Children(children) => node.children.extend(children),
1725 }
1726 node
1727}
1728
1729#[cfg(test)]
1730mod intersphinx_tests;
1731
1732#[cfg(test)]
1733mod tests {
1734 use super::*;
1735
1736 #[test]
1742 fn subcommand_folding_splits_on_python_whitespace() {
1743 assert_eq!(
1744 split_once_whitespace("git\x1fadd -x"),
1745 Some(("git", "add -x"))
1746 );
1747 assert_eq!(split_once_whitespace("add -x"), Some(("add", "-x")));
1748 assert_eq!(
1749 split_once_whitespace("git \x1f\t add"),
1750 Some(("git", "add"))
1751 );
1752 assert_eq!(split_once_whitespace("-x"), None);
1753 }
1754
1755 static INERT: Intersphinx = Intersphinx {
1759 data: crate::intersphinx::IntersphinxData {
1760 main: crate::inventory::Inventory {
1761 data: BTreeMap::new(),
1762 },
1763 named: BTreeMap::new(),
1764 },
1765 disabled_reftypes: std::collections::BTreeSet::new(),
1766 resolve_self: String::new(),
1767 };
1768
1769 fn env_with_label() -> BuildEnvironment {
1770 let mut env = BuildEnvironment::default();
1771 env.std.labels.insert(
1772 "the-label".to_string(),
1773 (
1774 "a".to_string(),
1775 "the-label".to_string(),
1776 "The Section".to_string(),
1777 ),
1778 );
1779 env.std.anonlabels.insert(
1780 "the-label".to_string(),
1781 ("a".to_string(), "the-label".to_string()),
1782 );
1783 env.all_docs.insert("a".to_string(), 0);
1784 env.all_docs.insert("b".to_string(), 0);
1785 env
1786 }
1787
1788 fn resolver<'a>(
1789 env: &'a BuildEnvironment,
1790 numfig_format: &'a BTreeMap<String, String>,
1791 ) -> Resolver<'a> {
1792 Resolver {
1793 env,
1794 numfig: true,
1795 numfig_format,
1796 doctree: &|_| None,
1797 relative_uri: &|_, _| String::new(),
1798 intersphinx: &INERT,
1799 }
1800 }
1801
1802 fn request<'a>(fromdoc: &'a str, reftype: &'a str, reftarget: &'a str) -> XrefRequest<'a> {
1803 XrefRequest {
1804 fromdoc,
1805 refdoc: fromdoc,
1806 reftype,
1807 reftarget,
1808 refexplicit: false,
1809 program: None,
1810 contnode_text: reftarget,
1811 }
1812 }
1813
1814 #[test]
1820 fn an_option_resolves_against_the_program_in_scope_where_it_was_written() {
1821 let mut env = BuildEnvironment::default();
1822 env.std
1823 .add_program_option(Some("myprog"), "--verbose", "a", "cmdoption-myprog-verbose");
1824 env.all_docs.insert("a".to_string(), 0);
1825 let formats = BTreeMap::new();
1826 let resolver = resolver(&env, &formats);
1827
1828 let mut scoped = request("a", "option", "--verbose");
1829 scoped.program = Some("myprog");
1830 assert_eq!(
1831 resolver.resolve_xref(&scoped),
1832 XrefOutcome::Resolved(ResolvedXref {
1833 kind: kinds::REFERENCE,
1834 refid: Some("cmdoption-myprog-verbose".to_string()),
1835 refuri: None,
1836 title: None,
1837 reftitle: None,
1838 inner: Inner::Contnode,
1839 })
1840 );
1841
1842 assert_eq!(
1845 resolver.resolve_xref(&request("a", "option", "--verbose")),
1846 XrefOutcome::Missing
1847 );
1848 }
1849
1850 #[test]
1851 fn a_same_document_ref_uses_refid_and_a_cross_document_one_uses_refuri() {
1852 let env = env_with_label();
1853 let formats = BTreeMap::new();
1854 let resolver = resolver(&env, &formats);
1855
1856 let same = resolver.resolve_xref(&request("a", "ref", "the-label"));
1857 assert_eq!(
1858 same,
1859 XrefOutcome::Resolved(ResolvedXref {
1860 kind: kinds::REFERENCE,
1861 refid: Some("the-label".to_string()),
1862 refuri: None,
1863 title: None,
1864 reftitle: None,
1865 inner: Inner::Inline {
1866 text: "The Section".to_string(),
1867 classes: vec!["std".to_string(), "std-ref".to_string()],
1868 },
1869 })
1870 );
1871
1872 let cross = resolver.resolve_xref(&request("b", "ref", "the-label"));
1873 let XrefOutcome::Resolved(cross) = cross else {
1874 panic!("expected a resolved reference, got {cross:?}")
1875 };
1876 assert_eq!(cross.refuri.as_deref(), Some("#the-label"));
1877 assert_eq!(cross.refid, None);
1878 }
1879
1880 #[test]
1881 fn an_explicit_ref_titles_itself_from_the_anonymous_label() {
1882 let env = env_with_label();
1883 let formats = BTreeMap::new();
1884 let resolver = resolver(&env, &formats);
1885 let mut req = request("a", "ref", "the-label");
1886 req.refexplicit = true;
1887 req.contnode_text = "My Own Words";
1888
1889 let XrefOutcome::Resolved(resolved) = resolver.resolve_xref(&req) else {
1890 panic!("expected a resolved reference")
1891 };
1892 assert_eq!(
1893 resolved.inner,
1894 Inner::Inline {
1895 text: "My Own Words".to_string(),
1896 classes: vec!["std".to_string(), "std-ref".to_string()],
1897 }
1898 );
1899 }
1900
1901 #[test]
1902 fn a_doc_reference_joins_the_target_against_the_referencing_document() {
1903 let mut env = BuildEnvironment::default();
1904 env.all_docs.insert("sub/c".to_string(), 0);
1905 let mut title = Node::elem(kinds::TITLE, crate::doctree::Span::ZERO);
1906 title
1907 .children
1908 .push(Node::text_node("Sub C", crate::doctree::Span::ZERO));
1909 env.titles.insert("sub/c".to_string(), title);
1910 let formats = BTreeMap::new();
1911 let resolver = resolver(&env, &formats);
1912
1913 let relative = resolver.resolve_xref(&request("sub/b", "doc", "c"));
1914 assert_eq!(
1915 relative,
1916 XrefOutcome::Resolved(ResolvedXref {
1917 kind: kinds::REFERENCE,
1918 refid: None,
1919 refuri: Some(String::new()),
1920 title: None,
1921 reftitle: None,
1922 inner: Inner::Inline {
1923 text: "Sub C".to_string(),
1924 classes: vec!["doc".to_string()],
1925 },
1926 }),
1927 "the caption comes from the target's title, not the written target"
1928 );
1929 assert_eq!(
1930 resolver.resolve_xref(&request("sub/b", "doc", "/sub/c")),
1931 relative,
1932 "an absolute target names the same document"
1933 );
1934 assert_eq!(
1935 resolver.resolve_xref(&request("sub/b", "doc", "nope")),
1936 XrefOutcome::Missing
1937 );
1938 }
1939
1940 #[test]
1941 fn option_resolution_folds_leading_words_into_the_program_name() {
1942 let mut env = BuildEnvironment::default();
1943 env.std
1944 .add_program_option(Some("myprog"), "--verbose", "a", "cmdoption-myprog-verbose");
1945 env.std
1946 .add_program_option(None, "--global", "a", "cmdoption-global");
1947 let formats = BTreeMap::new();
1948 let resolver = resolver(&env, &formats);
1949
1950 let XrefOutcome::Resolved(scoped) =
1951 resolver.resolve_xref(&request("b", "option", "myprog --verbose"))
1952 else {
1953 panic!("`myprog --verbose` must resolve through the program fallback")
1954 };
1955 assert_eq!(scoped.refuri.as_deref(), Some("#cmdoption-myprog-verbose"));
1956
1957 let XrefOutcome::Resolved(global) =
1958 resolver.resolve_xref(&request("b", "option", "--global"))
1959 else {
1960 panic!("an unscoped option resolves under the `None` program")
1961 };
1962 assert_eq!(global.refuri.as_deref(), Some("#cmdoption-global"));
1963
1964 assert_eq!(
1965 resolver.resolve_xref(&request("b", "option", "--missing")),
1966 XrefOutcome::Missing
1967 );
1968 }
1969
1970 #[test]
1971 fn option_resolution_strips_an_option_value() {
1972 let mut env = BuildEnvironment::default();
1973 env.std
1974 .add_program_option(None, "-foo", "a", "cmdoption-foo");
1975 let formats = BTreeMap::new();
1976 let resolver = resolver(&env, &formats);
1977 for target in ["-foo=bar", "-foo[=bar]"] {
1978 let outcome = resolver.resolve_xref(&request("b", "option", target));
1979 assert!(
1980 matches!(outcome, XrefOutcome::Resolved(_)),
1981 "{target} must fall back to the option stem, got {outcome:?}"
1982 );
1983 }
1984 }
1985
1986 #[test]
1987 fn term_resolution_falls_back_to_a_case_insensitive_match() {
1988 let mut env = BuildEnvironment::default();
1989 env.std.note_term("environment", "a", "term-environment");
1990 let formats = BTreeMap::new();
1991 let resolver = resolver(&env, &formats);
1992
1993 let exact = resolver.resolve_xref(&request("b", "term", "environment"));
1994 let other_case = resolver.resolve_xref(&request("b", "term", "Environment"));
1995 assert!(matches!(exact, XrefOutcome::Resolved(_)));
1996 assert_eq!(exact, other_case);
1997 assert_eq!(
1998 resolver.resolve_xref(&request("b", "term", "nonexistent term")),
1999 XrefOutcome::Missing
2000 );
2001 }
2002
2003 #[test]
2004 fn numfig_off_keeps_the_content_node_and_says_so_once() {
2005 let mut env = BuildEnvironment::default();
2006 env.std.labels.insert(
2007 "fig-a".to_string(),
2008 ("a".to_string(), "fig-a".to_string(), "A Figure".to_string()),
2009 );
2010 let mut figure = Node::elem("figure", crate::doctree::Span::ZERO);
2011 figure.attrs.ids.push("fig-a".to_string());
2012 let mut root = Node::elem(kinds::DOCUMENT, crate::doctree::Span::ZERO);
2013 root.children.push(figure);
2014 let doctree = Doctree {
2015 root,
2016 sources: vec!["<test>".to_string()],
2017 };
2018 let formats = BTreeMap::new();
2019 let resolver = Resolver {
2020 env: &env,
2021 numfig: false,
2022 numfig_format: &formats,
2023 doctree: &|_| Some(Cow::Borrowed(&doctree)),
2024 relative_uri: &|_, _| String::new(),
2025 intersphinx: &INERT,
2026 };
2027
2028 assert_eq!(
2029 resolver.resolve_xref(&request("b", "numref", "fig-a")),
2030 XrefOutcome::Kept {
2031 warning: Some("numfig is disabled. :numref: is ignored.".to_string())
2032 }
2033 );
2034 }
2035
2036 #[test]
2041 fn a_nameless_numref_target_reports_the_format_it_could_not_fill() {
2042 let mut env = BuildEnvironment::default();
2043 env.std.labels.insert(
2044 "captionless".to_string(),
2045 ("a".to_string(), "captionless".to_string(), String::new()),
2046 );
2047 env.std
2048 .anonlabels
2049 .insert("anon".to_string(), ("a".to_string(), "anon".to_string()));
2050 env.toc_fignumbers.insert(
2051 "a".to_string(),
2052 BTreeMap::from([(
2053 "figure".to_string(),
2054 BTreeMap::from([
2055 ("captionless".to_string(), vec![1]),
2056 ("anon".to_string(), vec![2]),
2057 ]),
2058 )]),
2059 );
2060 let mut root = Node::elem(kinds::DOCUMENT, crate::doctree::Span::ZERO);
2061 for id in ["captionless", "anon"] {
2062 let mut figure = Node::elem("figure", crate::doctree::Span::ZERO);
2063 figure.attrs.ids.push(id.to_string());
2064 root.children.push(figure);
2065 }
2066 let doctree = Doctree {
2067 root,
2068 sources: vec!["<test>".to_string()],
2069 };
2070 let formats = BTreeMap::from([("figure".to_string(), "Fig. {name} {number}".to_string())]);
2071 let resolver = Resolver {
2072 env: &env,
2073 numfig: true,
2074 numfig_format: &formats,
2075 doctree: &|_| Some(Cow::Borrowed(&doctree)),
2076 relative_uri: &|_, _| String::new(),
2077 intersphinx: &INERT,
2078 };
2079
2080 assert_eq!(
2081 resolver.resolve_xref(&request("b", "numref", "anon")),
2082 XrefOutcome::Kept {
2083 warning: Some("the link has no caption: Fig. {name} {number}".to_string())
2084 },
2085 "an anonymous-only label has no caption to name"
2086 );
2087 assert_eq!(
2088 resolver.resolve_xref(&request("b", "numref", "captionless")),
2089 XrefOutcome::Kept {
2090 warning: Some(
2091 "invalid numfig_format: Fig. {name} {number} (KeyError('name'))".to_string()
2092 )
2093 },
2094 "an empty caption is falsy, so `name` is never passed to format()"
2095 );
2096 }
2097
2098 #[test]
2101 fn a_numref_target_with_no_number_names_the_label_it_could_not_number() {
2102 let mut env = BuildEnvironment::default();
2103 env.std.labels.insert(
2104 "fig-a".to_string(),
2105 ("a".to_string(), "fig-a".to_string(), "A Figure".to_string()),
2106 );
2107 let mut figure = Node::elem("figure", crate::doctree::Span::ZERO);
2108 figure.attrs.ids.push("fig-a".to_string());
2109 let mut root = Node::elem(kinds::DOCUMENT, crate::doctree::Span::ZERO);
2110 root.children.push(figure);
2111 let doctree = Doctree {
2112 root,
2113 sources: vec!["<test>".to_string()],
2114 };
2115 let formats = BTreeMap::from([("figure".to_string(), "Fig. %s".to_string())]);
2116 let resolver = Resolver {
2117 env: &env,
2118 numfig: true,
2119 numfig_format: &formats,
2120 doctree: &|_| Some(Cow::Borrowed(&doctree)),
2121 relative_uri: &|_, _| String::new(),
2122 intersphinx: &INERT,
2123 };
2124
2125 assert_eq!(
2126 resolver.resolve_xref(&request("b", "numref", "fig-a")),
2127 XrefOutcome::Kept {
2128 warning: Some(
2129 "Failed to create a cross reference. Any number is not assigned: fig-a"
2130 .to_string()
2131 )
2132 }
2133 );
2134 }
2135
2136 #[test]
2137 fn numref_renders_both_format_styles_and_reports_broken_ones() {
2138 assert_eq!(format_old_style("Fig. %s", "1.2").unwrap(), "Fig. 1.2");
2139 assert!(
2140 format_old_style("Fig.", "1").is_err(),
2141 "no conversion: TypeError"
2142 );
2143 assert!(
2144 format_old_style("%s %s", "1").is_err(),
2145 "two conversions for one argument: TypeError"
2146 );
2147 assert_eq!(
2148 format_new_style("Custom {name} number {number}", Some("Cap"), "1").unwrap(),
2149 "Custom Cap number 1"
2150 );
2151 assert_eq!(
2152 format_new_style("Table {number}", None, "3").unwrap(),
2153 "Table 3"
2154 );
2155 let err = format_new_style("{nope}", Some("Cap"), "1").err().unwrap();
2156 assert_eq!(err.0, "nope");
2157 }
2158
2159 #[test]
2160 fn dangling_warnings_use_the_exact_sphinx_texts() {
2161 let env = env_with_label();
2162 let nitpick = NitpickConfig {
2163 nitpicky: false,
2164 ignore: &[],
2165 ignore_regex: &[],
2166 };
2167 let warn = |typ: &str, target: &str| {
2168 missing_reference_warning(&env, &nitpick, "std", typ, target, true)
2169 };
2170 assert_eq!(
2171 warn("doc", "missing-doc").unwrap(),
2172 "unknown document: 'missing-doc'"
2173 );
2174 assert_eq!(
2175 warn("term", "nonexistent term").unwrap(),
2176 "term not in glossary: 'nonexistent term'"
2177 );
2178 assert_eq!(warn("option", "--x").unwrap(), "unknown option: '--x'");
2179 assert_eq!(warn("keyword", "k").unwrap(), "unknown keyword: 'k'");
2180 assert_eq!(warn("numref", "fig").unwrap(), "undefined label: 'fig'");
2181 assert_eq!(warn("ref", "nope").unwrap(), "undefined label: 'nope'");
2182 assert_eq!(
2183 warn("ref", "the-label").unwrap(),
2184 "Failed to create a cross reference. A title or caption not found: 'the-label'",
2185 "a label that exists but has no title takes the other branch"
2186 );
2187 assert_eq!(
2188 warn("envvar", "PATH").unwrap(),
2189 "'envvar' reference target not found: PATH",
2190 "a role with no dangling_warnings entry takes the generic form"
2191 );
2192 }
2193
2194 #[test]
2195 fn a_role_that_is_not_warn_dangling_only_warns_under_nitpicky() {
2196 let env = env_with_label();
2197 let quiet = NitpickConfig {
2198 nitpicky: false,
2199 ignore: &[],
2200 ignore_regex: &[],
2201 };
2202 assert_eq!(
2203 missing_reference_warning(&env, &quiet, "std", "envvar", "PATH", false),
2204 None
2205 );
2206 let nitpicky = NitpickConfig {
2207 nitpicky: true,
2208 ignore: &[],
2209 ignore_regex: &[],
2210 };
2211 assert!(
2212 missing_reference_warning(&env, &nitpicky, "std", "envvar", "PATH", false).is_some()
2213 );
2214 }
2215
2216 fn any_roles(resolver: &Resolver<'_>, req: &XrefRequest<'_>) -> Vec<(String, String)> {
2220 resolver
2221 .resolve_any(req, None, None, None)
2222 .into_iter()
2223 .map(|candidate| (candidate.role, candidate.label))
2224 .collect()
2225 }
2226
2227 #[test]
2231 fn any_walks_doc_then_ref_then_option_then_the_objects_table() {
2232 let mut env = BuildEnvironment::default();
2233 env.all_docs.insert("same".to_string(), 0);
2234 let mut title = Node::elem(kinds::TITLE, crate::doctree::Span::ZERO);
2235 title
2236 .children
2237 .push(Node::text_node("Doc Title", crate::doctree::Span::ZERO));
2238 env.titles.insert("same".to_string(), title);
2239 env.std.labels.insert(
2240 "same".to_string(),
2241 ("a".to_string(), "same".to_string(), "Sect".to_string()),
2242 );
2243 env.std.note_object("envvar", "same", "a", "envvar-same");
2244 env.py.note_object(
2245 "m.same",
2246 crate::env::py_domain::PyObjectEntry {
2247 docname: "a".to_string(),
2248 node_id: "m.same".to_string(),
2249 objtype: "function".to_string(),
2250 aliased: false,
2251 },
2252 );
2253 let formats = BTreeMap::new();
2254 let resolver = resolver(&env, &formats);
2255
2256 let req = request("a", "any", "same");
2257 assert_eq!(
2258 any_roles(&resolver, &req),
2259 vec![
2260 ("doc".to_string(), "Doc Title".to_string()),
2261 ("std:ref".to_string(), "Sect".to_string()),
2262 ("std:envvar".to_string(), "same".to_string()),
2264 ("py:func".to_string(), "m.same".to_string()),
2266 ]
2267 );
2268 }
2269
2270 #[test]
2275 fn any_lowercases_the_ref_arm_and_the_term_key_only() {
2276 let mut env = BuildEnvironment::default();
2277 env.std.labels.insert(
2278 "mixed".to_string(),
2279 ("a".to_string(), "mixed".to_string(), "Sect".to_string()),
2280 );
2281 env.std.note_term("Aterm", "a", "term-Aterm");
2282 env.std.note_term("bterm", "a", "term-bterm");
2283 let formats = BTreeMap::new();
2284 let resolver = resolver(&env, &formats);
2285
2286 assert_eq!(
2287 any_roles(&resolver, &request("a", "any", "MIXED"))
2288 .iter()
2289 .map(|(role, _)| role.as_str())
2290 .collect::<Vec<_>>(),
2291 vec!["std:ref"],
2292 "the ref arm sees the lowercased target"
2293 );
2294 assert!(
2295 any_roles(&resolver, &request("a", "any", "Aterm")).is_empty(),
2296 "objects holds ('term', 'Aterm') but the walk asks for ('term', 'aterm')"
2297 );
2298 assert!(
2299 any_roles(&resolver, &request("a", "any", "aterm")).is_empty(),
2300 "and 'aterm' was never registered"
2301 );
2302 assert_eq!(
2303 any_roles(&resolver, &request("a", "any", "BTERM"))
2304 .iter()
2305 .map(|(role, _)| role.as_str())
2306 .collect::<Vec<_>>(),
2307 vec!["std:term"],
2308 "a lowercase-registered term is reachable under any case"
2309 );
2310 }
2311
2312 #[test]
2315 fn any_module_candidates_label_with_the_synopsis_reftitle() {
2316 let mut env = BuildEnvironment::default();
2317 env.py.note_object(
2318 "syn",
2319 crate::env::py_domain::PyObjectEntry {
2320 docname: "a".to_string(),
2321 node_id: "module-syn".to_string(),
2322 objtype: "module".to_string(),
2323 aliased: false,
2324 },
2325 );
2326 env.py.note_module(
2327 "syn",
2328 crate::env::py_domain::PyModuleEntry {
2329 docname: "a".to_string(),
2330 node_id: "module-syn".to_string(),
2331 synopsis: "The syn module.".to_string(),
2332 platform: String::new(),
2333 deprecated: false,
2334 },
2335 );
2336 let formats = BTreeMap::new();
2337 let resolver = resolver(&env, &formats);
2338 assert_eq!(
2339 any_roles(&resolver, &request("a", "any", "syn")),
2340 vec![("py:mod".to_string(), "syn: The syn module.".to_string())]
2341 );
2342 }
2343
2344 #[test]
2345 fn a_missing_any_reference_warns_with_the_domainless_spelling() {
2346 let env = BuildEnvironment::default();
2347 let quiet = NitpickConfig {
2348 nitpicky: false,
2349 ignore: &[],
2350 ignore_regex: &[],
2351 };
2352 assert_eq!(
2353 missing_reference_warning(&env, &quiet, "", "any", "missing_thing", true).unwrap(),
2354 "'any' reference target not found: missing_thing"
2355 );
2356 let ignore = vec![("any".to_string(), "missing_thing".to_string())];
2359 let nitpicky = NitpickConfig {
2360 nitpicky: true,
2361 ignore: &ignore,
2362 ignore_regex: &[],
2363 };
2364 assert_eq!(
2365 missing_reference_warning(&env, &nitpicky, "", "any", "missing_thing", true),
2366 None
2367 );
2368 }
2369
2370 #[test]
2371 fn nitpick_ignore_filters_by_exact_pair_and_by_regex() {
2372 let env = env_with_label();
2373 let exact = vec![("std:doc".to_string(), "missing".to_string())];
2374 let config = NitpickConfig {
2375 nitpicky: true,
2376 ignore: &exact,
2377 ignore_regex: &[],
2378 };
2379 assert_eq!(
2380 missing_reference_warning(&env, &config, "std", "doc", "missing", true),
2381 None
2382 );
2383 assert!(missing_reference_warning(&env, &config, "std", "doc", "other", true).is_some());
2384
2385 let domainless = vec![("doc".to_string(), "missing".to_string())];
2387 let config = NitpickConfig {
2388 nitpicky: true,
2389 ignore: &domainless,
2390 ignore_regex: &[],
2391 };
2392 assert_eq!(
2393 missing_reference_warning(&env, &config, "std", "doc", "missing", true),
2394 None
2395 );
2396
2397 let regex = vec![("std:.*".to_string(), "miss.*".to_string())];
2398 let config = NitpickConfig {
2399 nitpicky: true,
2400 ignore: &[],
2401 ignore_regex: ®ex,
2402 };
2403 assert_eq!(
2404 missing_reference_warning(&env, &config, "std", "doc", "missing", true),
2405 None
2406 );
2407 assert!(
2408 missing_reference_warning(&env, &config, "std", "doc", "hit", true).is_some(),
2409 "the regexes must both full-match, not merely find"
2410 );
2411 }
2412}