Skip to main content

sphinx_ultra/rst/
inline.rs

1//! The inline parser (M2 wave 2): a port of docutils `states.py Inliner`
2//! for emphasis/strong/literal recognition, escape semantics, and the
3//! problematic/unclosed machinery. References, roles, and footnote
4//! references extend this module in later wave-2 tasks.
5//!
6//! Escape model (probe-verified): backslash escapes convert to a `\x00`
7//! marker + char before recognition (`escape2null`); at Text emission the
8//! markers strip (`\x00 ` and `\x00\n` remove BOTH chars, joining words;
9//! bare `\x00` drops, keeping the escaped char). Inline literals restore
10//! backslashes instead. Behavior sources: the wave-2 probe notes
11//! (2026-08-07-m2-wave2-probes.md) and the differential fixture — never
12//! memory.
13
14use crate::doctree::ids::IdRegistry;
15use crate::doctree::{kinds, messages, AttrValue, Node, Span};
16
17use super::punctuation;
18
19const NULL: char = '\u{0}';
20
21/// docutils `utils.escape2null`: every `\` + char becomes `\x00` + char; a
22/// trailing lone backslash becomes a bare `\x00`.
23pub fn escape2null(text: &str) -> String {
24    let mut out = String::with_capacity(text.len());
25    let mut chars = text.chars();
26    while let Some(c) = chars.next() {
27        if c == '\\' {
28            out.push(NULL);
29            if let Some(next) = chars.next() {
30                out.push(next);
31            }
32        } else {
33            out.push(c);
34        }
35    }
36    out
37}
38
39/// docutils `nodes.unescape`: drop `\x00 ` and `\x00\n` (both chars),
40/// then bare `\x00` markers. With `restore_backslashes` every marker
41/// becomes a literal `\` instead (inline literals).
42pub fn unescape(text: &str, restore_backslashes: bool) -> String {
43    if restore_backslashes {
44        return text.replace(NULL, "\\");
45    }
46    let mut out = String::with_capacity(text.len());
47    let mut chars = text.chars().peekable();
48    while let Some(c) = chars.next() {
49        if c == NULL {
50            match chars.peek() {
51                Some(' ') | Some('\n') => {
52                    chars.next();
53                }
54                _ => {}
55            }
56        } else {
57            out.push(c);
58        }
59    }
60    out
61}
62
63/// Result of inline-parsing one text block: the paragraph/title children,
64/// plus system_messages the caller attaches AFTER the enclosing element.
65pub struct InlineResult {
66    pub nodes: Vec<Node>,
67    pub messages: Vec<Node>,
68    /// Role occurrences (sphinx mode only) for the build pipeline.
69    pub roles: Vec<super::RoleRecord>,
70}
71
72/// `ws_re.sub(' ', target)` — the base `XRefRole.process_link` munging
73/// (`sphinx/roles.py`): every whitespace run becomes one space. `ws_re`
74/// is `re.compile(r'\s+')` (`sphinx/util/__init__.py:17`), and Python's
75/// `\s` is `str.isspace` — which admits `\x1c`-`\x1f` where Rust's
76/// `is_whitespace` does not ([`crate::utils::py_isspace`]; probed:
77/// `:doc:`a\x1fb`` reaches sphinx's resolver as `'a b'`).
78fn collapse_whitespace(text: &str) -> String {
79    let mut out = String::with_capacity(text.len());
80    let mut in_ws = false;
81    for c in text.chars() {
82        if crate::utils::py_isspace(c) {
83            in_ws = true;
84        } else {
85            if in_ws {
86                out.push(' ');
87                in_ws = false;
88            }
89            out.push(c);
90        }
91    }
92    if in_ws {
93        out.push(' ');
94    }
95    out
96}
97
98/// Whether an xref role's `process_link` reaches the base
99/// `XRefRole.process_link` — the one that runs
100/// `ws_re.sub(' ', target)` (`roles.py:165`).
101///
102/// Collapsing is the default, because it is what the base class does; a
103/// role only escapes it by overriding `process_link` WITHOUT calling
104/// `super()`. In sphinx 9.1.0 that is exactly:
105///
106/// * every `py:` role — `PyXRefRole` (`domains/python/__init__.py:559-585`)
107///   and its `_PyDecoXRefRole` subclass, which chains to `PyXRefRole`, not
108///   to the base (`:588-600`);
109/// * `js:`, `c:` and `cpp:` roles, which all copy `PyXRefRole`'s shape
110///   (`domains/javascript.py:387`, `domains/c/__init__.py:705`,
111///   `domains/cpp/__init__.py:853`);
112/// * `std:option` (`OptionXRefRole`, `domains/std/__init__.py:351-361`)
113///   and `std:token` (`TokenXRefRole`, `:703-718`).
114///
115/// `AnyXRefRole` (`roles.py:182-193`) and `MathReferenceRole`
116/// (`domains/math.py:32`) DO reach the base, as do every remaining `std:`
117/// role (`term`, `doc`, `keyword`, `confval`, `envvar`, …) and the `rst:`
118/// domain's roles. `std:ref`/`std:numref` also reach it, but the caller
119/// handles them separately: `lowercase=True` makes their munging
120/// `fully_normalize_name`.
121fn target_collapses_whitespace(domain: &str, reftype: &str) -> bool {
122    !matches!(
123        (domain, reftype),
124        ("py" | "js" | "c" | "cpp", _) | ("std", "option" | "token")
125    )
126}
127
128fn is_start_prefix_ok(prev: Option<char>) -> bool {
129    match prev {
130        None => true,
131        Some(c) => {
132            // `\s` in a Python `str` pattern is `str.isspace`, which admits
133            // the C0 separators `\x1c`-`\x1f` (see `crate::utils::py_isspace`).
134            crate::utils::py_isspace(c)
135                || punctuation::OPENERS.contains(&c)
136                || punctuation::DELIMITERS.contains(&c)
137        }
138    }
139}
140
141fn is_end_suffix_ok(next: Option<char>) -> bool {
142    match next {
143        None => true,
144        Some(c) => {
145            crate::utils::py_isspace(c)
146                || c == NULL
147                || punctuation::CLOSING_DELIMITERS.contains(&c)
148                || punctuation::DELIMITERS.contains(&c)
149                || punctuation::CLOSERS.contains(&c)
150        }
151    }
152}
153
154/// docutils `punctuation_chars.match_chars(c1, c2)`: positional pairing on
155/// the RAW opener/closer strings plus the extra quote pairs.
156fn match_chars(c1: char, c2: char) -> bool {
157    match punctuation::OPENERS_RAW.iter().position(|c| *c == c1) {
158        None => false,
159        Some(i) => {
160            if punctuation::CLOSERS_RAW.get(i) == Some(&c2) {
161                return true;
162            }
163            punctuation::QUOTE_PAIRS
164                .iter()
165                .any(|(k, v)| *k == c1 && v.contains(c2))
166        }
167    }
168}
169
170fn is_word_char(c: char) -> bool {
171    c.is_alphanumeric() || c == '_'
172}
173
174/// docutils `simplename`: `(?:(?!_)\w)+(?:[-._+:](?:(?!_)\w)+)*` — word-char
175/// atoms (underscore excluded) joined by single `-._+:` separators. Returns
176/// the char length of the match starting at `at`, or None.
177fn match_simplename(chars: &[char], at: usize) -> Option<usize> {
178    let n = chars.len();
179    let mut i = at;
180    let atom = |c: char| is_word_char(c) && c != '_';
181    if i >= n || !atom(chars[i]) {
182        return None;
183    }
184    while i < n && atom(chars[i]) {
185        i += 1;
186    }
187    loop {
188        if i < n && matches!(chars[i], '-' | '.' | '_' | '+' | ':') {
189            let sep_end = i + 1;
190            if sep_end < n && atom(chars[sep_end]) {
191                i = sep_end + 1;
192                while i < n && atom(chars[i]) {
193                    i += 1;
194                }
195                continue;
196            }
197        }
198        break;
199    }
200    Some(i - at)
201}
202
203fn is_urilast(c: char) -> bool {
204    matches!(c, '_' | '~' | '*' | '/' | '=' | '+') || c.is_ascii_alphanumeric()
205}
206
207fn is_uric(c: char) -> bool {
208    matches!(
209        c,
210        '-' | '_'
211            | '.'
212            | '!'
213            | '~'
214            | '*'
215            | '\''
216            | '('
217            | ')'
218            | '['
219            | ']'
220            | ';'
221            | '/'
222            | ':'
223            | '@'
224            | '&'
225            | '='
226            | '+'
227            | '$'
228            | ','
229            | '%'
230            | '\u{0}'
231    ) || c.is_ascii_alphanumeric()
232}
233
234fn is_emailc(c: char) -> bool {
235    matches!(
236        c,
237        '-' | '_'
238            | '!'
239            | '~'
240            | '*'
241            | '\''
242            | '{'
243            | '|'
244            | '}'
245            | '/'
246            | '#'
247            | '?'
248            | '^'
249            | '`'
250            | '&'
251            | '='
252            | '+'
253            | '$'
254            | '%'
255            | '\u{0}'
256    ) || c.is_ascii_alphanumeric()
257}
258
259/// The slice of sphinx's `env.ref_context` the inline roles read: the
260/// values `OptionXRefRole`/`PyXRefRole` stamp on their pending_xrefs, plus
261/// the key-*existence* flags `AnyXRefRole.process_link`'s blanket
262/// `refnode.attributes.update(env.ref_context)` (`sphinx/roles.py`) needs —
263/// a ref_context key can exist holding Python `None` (pformat's `"True"`
264/// sentinel) or a list that the balanced directive nesting has drained back
265/// to `[]` (pformat `""`) by the time the doctree is serialized, so a bare
266/// `Option<&str>` cannot distinguish "absent" from those.
267#[derive(Clone, Copy, Default)]
268pub struct RefContext<'a> {
269    /// `env.ref_context['std:program']` — the `.. program::` in scope, which
270    /// `OptionXRefRole.process_link` stamps on every `:option:` reference
271    /// (`domains/std/__init__.py:351-364`).
272    pub program: Option<&'a str>,
273    /// `env.ref_context['py:module']` / `['py:class']` — the enclosing
274    /// module/class scope `PyXRefRole.process_link` stamps on every py
275    /// pending_xref (`domains/python/__init__.py:568-569`).
276    ///
277    /// Like [`Self::py_class`], this `None` cannot by itself distinguish
278    /// "the key is absent" from "the key exists holding `None`" — which is
279    /// why [`Self::py_module_key`] exists. The distinction is reachable:
280    /// `before_content` pushes `ref_context.get('py:module')`, which is
281    /// `None` when a `:module:`-carrying directive has no enclosing module
282    /// scope, and `after_content` then ASSIGNS that `None` back
283    /// (`if modules:` is true for a one-element list holding `None`,
284    /// `_object.py:498-503`) instead of popping the key. Research spec §8
285    /// trap 14 says the same. The `:any:` role's blanket `ref_context`
286    /// copy is where it shows, as the `"True"` sentinel.
287    pub py_module: Option<&'a str>,
288    pub py_class: Option<&'a str>,
289    /// `'py:class' in env.ref_context`: true once any py object directive
290    /// set a class prefix (`before_content`) or completed (`after_content`
291    /// always assigns the key, `_object.py:482-503`) — the value may be
292    /// `None` (`py_class` absent), which the `:any:` role still stamps.
293    pub py_class_key: bool,
294    /// `'py:module' in env.ref_context`: true while a module scope is set
295    /// AND after a `:module:`-carrying directive has closed, where the key
296    /// survives holding `None`. Cleared by `.. py:currentmodule:: None`
297    /// (`ref_context.pop`).
298    pub py_module_key: bool,
299    /// `'py:classes' in env.ref_context`: created by `before_content` for
300    /// nesting kinds and unconditionally by `after_content`'s `setdefault`.
301    /// The *list* is what `AnyXRefRole` copies onto the node, and balanced
302    /// nesting drains it to `[]` by end of parse — so it renders `""`.
303    pub py_classes_key: bool,
304    /// `'py:modules' in env.ref_context`: created when a `:module:` option
305    /// pushed (`before_content`/`after_content`, `_object.py:477-480`).
306    /// Same aliased-list story as `py_classes_key`: always drained.
307    pub py_modules_key: bool,
308}
309
310struct Inliner<'a> {
311    /// escape2null'd text as a char vector (positions are char indices).
312    chars: Vec<char>,
313    /// Earliest position from which an end-string search is known to fail,
314    /// per (end char, length) — validity is position-local, so one failed
315    /// full scan means all later scans fail too (kills the O(n^2)
316    /// unclosed-markup pathology).
317    failed_end_search: std::collections::HashMap<(char, usize), usize>,
318    span: Span,
319    lineno: u32,
320    source_path: &'a str,
321    registry: &'a mut IdRegistry,
322    /// Sphinx mode: unknown-in-docutils roles become pending_xref nodes
323    /// (no messages) and every role occurrence is recorded.
324    sphinx: bool,
325    docname: &'a str,
326    /// The enclosing document's ref_context state (see [`RefContext`]).
327    ctx: RefContext<'a>,
328    /// The py-domain configuration the roles read; today only
329    /// `add_function_parentheses`, in [`Self::emit_xref_node`].
330    py: &'a crate::py::PySigConfig,
331    roles: Vec<super::RoleRecord>,
332    nodes: Vec<Node>,
333    messages: Vec<Node>,
334    /// Text accumulated since the last emitted inline node.
335    pending: String,
336}
337
338impl<'a> Inliner<'a> {
339    /// docutils `quoted_start`: the start-string is suppressed silently
340    /// when enclosed in a matching delimiter pair, or when it sits at the
341    /// very end of the text.
342    fn quoted_start(&self, start: usize, len: usize) -> bool {
343        let post = match self.chars.get(start + len) {
344            None => return true, // start-string at end of text: silent skip
345            Some(c) => *c,
346        };
347        if start == 0 {
348            return false;
349        }
350        let pre = self.chars[start - 1];
351        match_chars(pre, post)
352    }
353
354    fn start_ok(&self, at: usize, len: usize) -> bool {
355        let prev = at.checked_sub(1).map(|i| self.chars[i]);
356        if !is_start_prefix_ok(prev) {
357            return false;
358        }
359        // non_whitespace_after: the char following the start-string.
360        match self.chars.get(at + len) {
361            Some(c) if crate::utils::py_isspace(*c) => false,
362            _ => !self.quoted_start(at, len),
363        }
364    }
365
366    /// Find the end-string: the FIRST position satisfying both the
367    /// lookbehind and lookahead, searching from `from`, with non-empty
368    /// content (docutils `endmatch.start(1) >= 1`). Lookbehind: emphasis/
369    /// strong forbid whitespace AND `\x00` before the end-string; literals
370    /// forbid only whitespace (`allow_null_before`).
371    fn find_end(
372        &mut self,
373        from: usize,
374        end_str: &[char],
375        allow_null_before: bool,
376    ) -> Option<usize> {
377        let key = (end_str[0], end_str.len());
378        if let Some(fail_from) = self.failed_end_search.get(&key) {
379            if from >= *fail_from {
380                return None;
381            }
382        }
383        let n = self.chars.len();
384        let len = end_str.len();
385        // content non-empty: end-string can start at from+1 at the earliest
386        let mut i = from + 1;
387        while i + len <= n {
388            if self.chars[i..i + len] == *end_str {
389                let prev = self.chars[i - 1];
390                let ok_behind = if allow_null_before {
391                    !crate::utils::py_isspace(prev)
392                } else {
393                    !crate::utils::py_isspace(prev) && prev != NULL
394                };
395                let ok_ahead = is_end_suffix_ok(self.chars.get(i + len).copied());
396                if ok_behind && ok_ahead {
397                    return Some(i);
398                }
399            }
400            i += 1;
401        }
402        self.failed_end_search.insert(key, from);
403        None
404    }
405
406    fn flush_text(&mut self) {
407        if !self.pending.is_empty() {
408            let pending = std::mem::take(&mut self.pending);
409            self.implicit_inline(&pending);
410        }
411    }
412
413    /// docutils `implicit_inline`: standalone URIs and emails inside plain
414    /// text runs become reference nodes.
415    fn implicit_inline(&mut self, text: &str) {
416        // Cheap pre-checks: without ':' no scheme URI can match; without
417        // '@' no email can match (kills quadratic rescans over ordinary
418        // hyphenated text).
419        if !text.contains(':') && !text.contains('@') {
420            let out = unescape(text, false);
421            if !out.is_empty() {
422                self.nodes.push(Node::text_node(out, self.span));
423            }
424            return;
425        }
426        let chars: Vec<char> = text.chars().collect();
427        let n = chars.len();
428        let mut emitted_upto = 0usize;
429        let mut i = 0usize;
430        while i < n {
431            let prev = i.checked_sub(1).map(|p| chars[p]);
432            let at_start = is_start_prefix_ok(prev);
433            if at_start {
434                if let Some((len, refuri, display)) = match_standalone_uri(&chars, i) {
435                    let before: String = chars[emitted_upto..i].iter().collect();
436                    let before = unescape(&before, false);
437                    if !before.is_empty() {
438                        self.nodes.push(Node::text_node(before, self.span));
439                    }
440                    let mut r = Node::elem(kinds::REFERENCE, self.span);
441                    r.set("refuri", AttrValue::Str(unescape(&refuri, false)));
442                    r.children
443                        .push(Node::text_node(unescape(&display, false), self.span));
444                    self.nodes.push(r);
445                    i += len;
446                    emitted_upto = i;
447                    continue;
448                }
449            }
450            i += 1;
451        }
452        let rest: String = chars[emitted_upto..].iter().collect();
453        let rest = unescape(&rest, false);
454        if !rest.is_empty() {
455            self.nodes.push(Node::text_node(rest, self.span));
456        }
457    }
458
459    fn emit_inline(&mut self, kind: &'static str, content: &str, restore: bool) {
460        self.flush_text();
461        let mut node = Node::elem(kind, self.span);
462        node.children
463            .push(Node::text_node(unescape(content, restore), self.span));
464        self.nodes.push(node);
465    }
466
467    /// Unclosed start-string: `problematic` holding only the start-string
468    /// + a WARNING message; the rest of the text re-scans.
469    fn emit_problematic(&mut self, start_str: &str, construct: &str) {
470        self.flush_text();
471        let msg_id = self.registry.allocate_auto_id();
472        let prob_id = self.registry.allocate_auto_id();
473        let mut prob = Node::elem(kinds::PROBLEMATIC, self.span);
474        prob.attrs.ids.push(prob_id.clone());
475        prob.set("refid", AttrValue::Str(msg_id.clone()));
476        prob.children
477            .push(Node::text_node(start_str.to_string(), self.span));
478        self.nodes.push(prob);
479        let mut msg = messages::system_message(
480            messages::WARNING,
481            &format!("Inline {construct} start-string without end-string."),
482            self.lineno,
483            self.source_path,
484        );
485        msg.attrs.ids.push(msg_id);
486        msg.attrs.backrefs.push(prob_id);
487        self.messages.push(msg);
488    }
489
490    fn run(&mut self) {
491        let n = self.chars.len();
492        let mut i = 0usize;
493        while i < n {
494            let c = self.chars[i];
495            // simple span constructs: strong / emphasis / literal
496            let simple: Option<(usize, &'static str, &[char], &str, bool)> =
497                if c == '*' && self.chars.get(i + 1) == Some(&'*') {
498                    Some((2, kinds::STRONG, &['*', '*'], "strong", false))
499                } else if c == '*' {
500                    Some((1, kinds::EMPHASIS, &['*'], "emphasis", false))
501                } else if c == '`' && self.chars.get(i + 1) == Some(&'`') {
502                    Some((2, kinds::LITERAL, &['`', '`'], "literal", true))
503                } else {
504                    None
505                };
506            if let Some((start_len, kind, end_str, construct, restore)) = simple {
507                if !self.start_ok(i, start_len) {
508                    self.pending.push(c);
509                    i += 1;
510                    continue;
511                }
512                let content_from = i + start_len;
513                match self.find_end(content_from, end_str, restore) {
514                    Some(end) => {
515                        let content: String = self.chars[content_from..end].iter().collect();
516                        self.emit_inline(kind, &content, restore);
517                        i = end + end_str.len();
518                    }
519                    None => {
520                        let start_str: String = self.chars[i..i + start_len].iter().collect();
521                        self.emit_problematic(&start_str, construct);
522                        i += start_len;
523                    }
524                }
525                continue;
526            }
527            if c == '_' && self.chars.get(i + 1) == Some(&'`') && self.start_ok(i, 2) {
528                i = self.inline_internal_target(i);
529                continue;
530            }
531            if c == '`' && self.start_ok(i, 1) {
532                i = self.backtick_construct(i);
533                continue;
534            }
535            if c == '[' && self.start_ok(i, 1) {
536                if let Some(next_i) = self.try_footnote_or_citation(i) {
537                    i = next_i;
538                    continue;
539                }
540                self.pending.push(c);
541                i += 1;
542                continue;
543            }
544            if c == '|' && self.start_ok(i, 1) {
545                i = self.substitution_ref(i);
546                continue;
547            }
548            if is_word_char(c) && c != '_' {
549                let prev = i.checked_sub(1).map(|p| self.chars[p]);
550                if is_start_prefix_ok(prev) {
551                    if let Some(next_i) = self.try_word_reference(i) {
552                        i = next_i;
553                        continue;
554                    }
555                }
556                // consume the whole word so inner chars never re-dispatch
557                let mut j = i;
558                while j < n && is_word_char(self.chars[j]) {
559                    self.pending.push(self.chars[j]);
560                    j += 1;
561                }
562                i = j;
563                continue;
564            }
565            self.pending.push(c);
566            i += 1;
567        }
568        self.flush_text();
569    }
570
571    /// `word_` / `word__` references.
572    fn try_word_reference(&mut self, i: usize) -> Option<usize> {
573        let len = match_simplename(&self.chars, i)?;
574        let mut end = i + len;
575        let mut underscores = 0usize;
576        while underscores < 2 && self.chars.get(end) == Some(&'_') {
577            end += 1;
578            underscores += 1;
579        }
580        if underscores == 0 || !is_end_suffix_ok(self.chars.get(end).copied()) {
581            return None;
582        }
583        // the name itself must not end with the separator swallowing the
584        // ref underscores: match_simplename never consumes trailing '_'
585        let name: String = self.chars[i..i + len].iter().collect();
586        if name.contains(NULL) {
587            return None;
588        }
589        self.flush_text();
590        let mut r = Node::elem(kinds::REFERENCE, self.span);
591        if underscores == 2 {
592            r.set("anonymous", AttrValue::Int(1));
593            r.set(
594                "name",
595                AttrValue::Str(crate::doctree::ids::whitespace_normalize_name(&name)),
596            );
597        } else {
598            r.set(
599                "name",
600                AttrValue::Str(crate::doctree::ids::whitespace_normalize_name(&name)),
601            );
602            r.set(
603                "refname",
604                AttrValue::Str(crate::doctree::ids::fully_normalize_name(&name)),
605            );
606        }
607        r.children.push(Node::text_node(name, self.span));
608        self.nodes.push(r);
609        Some(end)
610    }
611
612    /// Find an end char that may carry 0..=max_underscores trailing
613    /// underscores BEFORE the end-suffix check (phrase refs, substitution
614    /// refs: the underscores are part of the end-string pattern). Returns
615    /// (end_index, underscore_count).
616    fn find_end_with_underscores(
617        &self,
618        from: usize,
619        end_char: char,
620        max_underscores: usize,
621    ) -> Option<(usize, usize)> {
622        let n = self.chars.len();
623        let mut i = from + 1;
624        while i < n {
625            if self.chars[i] == end_char {
626                let prev = self.chars[i - 1];
627                if !crate::utils::py_isspace(prev) && prev != NULL {
628                    let mut after = i + 1;
629                    let mut u = 0usize;
630                    while u < max_underscores && self.chars.get(after) == Some(&'_') {
631                        after += 1;
632                        u += 1;
633                    }
634                    if is_end_suffix_ok(self.chars.get(after).copied()) {
635                        return Some((i, u));
636                    }
637                }
638            }
639            i += 1;
640        }
641        None
642    }
643
644    /// `_`marked text`` inline internal targets.
645    fn inline_internal_target(&mut self, i: usize) -> usize {
646        let content_from = i + 2;
647        match self.find_end(content_from, &['`'], false) {
648            Some(end) => {
649                let raw: String = self.chars[content_from..end].iter().collect();
650                self.flush_text();
651                let mut t = Node::elem(kinds::TARGET, self.span);
652                t.attrs
653                    .names
654                    .push(crate::doctree::ids::fully_normalize_name(&unescape(
655                        &raw, false,
656                    )));
657                let msg = self.registry.set_id_explicit(
658                    &mut t,
659                    self.lineno,
660                    self.source_path,
661                    true,
662                    None,
663                );
664                t.children
665                    .push(Node::text_node(unescape(&raw, false), self.span));
666                self.nodes.push(t);
667                if let Some(m) = msg {
668                    self.messages.push(m);
669                }
670                end + 1
671            }
672            None => {
673                // `'Inline %s start-string without end-string.' %
674                // nodeclass.__name__` (states.py:839) — the class is
675                // `nodes.target`, so the word is "target", not
676                // "internal target" (probed against docutils 0.22.4).
677                self.emit_problematic("_`", "target");
678                i + 2
679            }
680        }
681    }
682
683    /// Backtick constructs: phrase references (trailing `_`/`__`),
684    /// role-prefixed/suffixed interpreted text, or the default role.
685    fn backtick_construct(&mut self, i: usize) -> usize {
686        // Detect a `:name:` role prefix ending exactly at `i`.
687        let prefix_role = self.role_prefix_before(i);
688        let content_from = i + 1;
689        let (end, underscores) = match self.find_end_with_underscores(content_from, '`', 2) {
690            Some(r) => r,
691            None => {
692                self.emit_problematic("`", "interpreted text or phrase reference");
693                return i + 1;
694            }
695        };
696        let raw: String = self.chars[content_from..end].iter().collect();
697        if underscores > 0 && prefix_role.is_none() {
698            self.phrase_reference(&raw, underscores);
699            return end + 1 + underscores;
700        }
701        // Suffix role `text`:name: (only when no trailing underscores).
702        let suffix_role = if underscores == 0 {
703            self.role_suffix_after(end)
704        } else {
705            None
706        };
707        match (prefix_role, suffix_role) {
708            (Some((pstart, pname)), Some((send, sname))) => {
709                let _ = (pname, sname);
710                // Both prefix and suffix: WARNING + problematic over the span.
711                self.trim_pending(i - pstart);
712                let rawsource: String = self.chars[pstart..send].iter().collect();
713                self.role_problematic(
714                    &rawsource,
715                    None,
716                    messages::WARNING,
717                    "Multiple roles in interpreted text (both prefix and suffix present; only one allowed).",
718                );
719                send
720            }
721            (Some((pstart, name)), None) => {
722                self.trim_pending(i - pstart);
723                let rawsource: String = self.chars[pstart..end + 1].iter().collect();
724                self.apply_role(&name, &raw, &rawsource);
725                end + 1
726            }
727            (None, Some((send, name))) => {
728                let rawsource: String = self.chars[i..send].iter().collect();
729                self.apply_role(&name, &raw, &rawsource);
730                send
731            }
732            (None, None) => {
733                self.emit_inline(kinds::TITLE_REFERENCE, &raw, false);
734                end + 1
735            }
736        }
737    }
738
739    /// `:name:` immediately before position `i` with a valid construct
740    /// prefix before it. The name may be COLON-JOINED (`:py:func:` — ':'
741    /// is a simplename separator), so scan back through the whole
742    /// word/separator run to the OUTERMOST candidate colon (docutils'
743    /// leftmost-match semantics). Returns (construct_start, role_name).
744    fn role_prefix_before(&self, i: usize) -> Option<(usize, String)> {
745        if i < 3 || self.chars[i - 1] != ':' {
746            return None;
747        }
748        // Scan backward over word chars and simplename separators to find
749        // the earliest possible opening colon.
750        let mut j = i - 1;
751        while j > 0 {
752            let c = self.chars[j - 1];
753            if is_word_char(c) || matches!(c, '-' | '.' | '+' | ':') {
754                j -= 1;
755            } else {
756                break;
757            }
758        }
759        // Try opening colons from the OUTERMOST inward.
760        let mut k = j;
761        while k + 1 < i - 1 {
762            if self.chars[k] == ':' {
763                let name: String = self.chars[k + 1..i - 1].iter().collect();
764                let name_chars: Vec<char> = name.chars().collect();
765                if !name_chars.is_empty()
766                    && match_simplename(&name_chars, 0) == Some(name_chars.len())
767                {
768                    let prev = k.checked_sub(1).map(|p| self.chars[p]);
769                    if is_start_prefix_ok(prev) {
770                        return Some((k, name));
771                    }
772                }
773            }
774            k += 1;
775        }
776        None
777    }
778
779    /// `:name:` immediately after the closing backtick at `end`. Returns
780    /// (construct_end_exclusive, role_name).
781    fn role_suffix_after(&self, end: usize) -> Option<(usize, String)> {
782        let mut j = end + 1;
783        if self.chars.get(j) != Some(&':') {
784            return None;
785        }
786        j += 1;
787        let len = match_simplename(&self.chars, j)?;
788        j += len;
789        if self.chars.get(j) != Some(&':') {
790            return None;
791        }
792        j += 1;
793        if !is_end_suffix_ok(self.chars.get(j).copied()) {
794            return None;
795        }
796        let name: String = self.chars[end + 2..j - 1].iter().collect();
797        Some((j, name))
798    }
799
800    /// Remove the last `n` chars from pending (a role prefix that turned
801    /// out to be part of the construct).
802    fn trim_pending(&mut self, n: usize) {
803        for _ in 0..n {
804            self.pending.pop();
805        }
806    }
807
808    /// problematic + message pair for role errors. `info` precedes the
809    /// main message without ids/backrefs.
810    fn role_problematic(&mut self, rawsource: &str, info: Option<String>, level: u8, text: &str) {
811        self.flush_text();
812        if let Some(info_text) = info {
813            self.messages.push(messages::system_message(
814                messages::INFO,
815                &info_text,
816                self.lineno,
817                self.source_path,
818            ));
819        }
820        let msg_id = self.registry.allocate_auto_id();
821        let prob_id = self.registry.allocate_auto_id();
822        let mut prob = Node::elem(kinds::PROBLEMATIC, self.span);
823        prob.attrs.ids.push(prob_id.clone());
824        prob.set("refid", AttrValue::Str(msg_id.clone()));
825        prob.children
826            .push(Node::text_node(unescape(rawsource, true), self.span));
827        self.nodes.push(prob);
828        let mut msg = messages::system_message(level, text, self.lineno, self.source_path);
829        msg.attrs.ids.push(msg_id);
830        msg.attrs.backrefs.push(prob_id);
831        self.messages.push(msg);
832    }
833
834    /// sphinx PEP/RFC/CVE/CWE roles (sphinx/roles.py): an index entry +
835    /// anonymous index-N target + external reference wrapping a strong.
836    fn emit_sphinx_extlink(&mut self, kind: &str, raw: &str) {
837        let text = unescape(raw, false);
838        let (target, title, explicit) = match (text.rfind('<'), text.ends_with('>')) {
839            (Some(lt), true) => (
840                text[lt + 1..text.len() - 1].trim().to_string(),
841                text[..lt].trim_end().to_string(),
842                true,
843            ),
844            _ => (text.clone(), text.clone(), false),
845        };
846        let (numpart, anchor) = match target.split_once('#') {
847            Some((a, b)) => (a.to_string(), Some(b.to_string())),
848            None => (target.clone(), None),
849        };
850        let (kind_key, index_text, refuri, default_title) = match kind {
851            "pep" => {
852                let Ok(num) = numpart.parse::<u32>() else {
853                    self.role_problematic(
854                        raw,
855                        None,
856                        messages::ERROR,
857                        &format!("invalid PEP number {target}"),
858                    );
859                    return;
860                };
861                let mut uri = format!("https://peps.python.org/pep-{num:04}/");
862                if let Some(a) = &anchor {
863                    uri.push('#');
864                    uri.push_str(a);
865                }
866                (
867                    "pep",
868                    format!("Python Enhancement Proposals; PEP {target}"),
869                    uri,
870                    format!("PEP {title}"),
871                )
872            }
873            "rfc" => {
874                let Ok(num) = numpart.parse::<u32>() else {
875                    self.role_problematic(
876                        raw,
877                        None,
878                        messages::ERROR,
879                        &format!("invalid RFC number {target}"),
880                    );
881                    return;
882                };
883                let formatted = match &anchor {
884                    Some(a) => {
885                        let mut f = None;
886                        for prefix in ["appendix", "page", "section"] {
887                            let cap = {
888                                let mut c = prefix.chars();
889                                let f0 = c.next().expect("nonempty").to_uppercase();
890                                format!("{f0}{}", c.as_str())
891                            };
892                            if a == prefix {
893                                f = Some(format!("RFC {numpart} {cap}"));
894                                break;
895                            }
896                            if let Some(rest) = a.strip_prefix(&format!("{prefix}-")) {
897                                f = Some(format!("RFC {numpart} {cap} {rest}"));
898                                break;
899                            }
900                        }
901                        f.unwrap_or_else(|| format!("RFC {target}"))
902                    }
903                    None => format!("RFC {numpart}"),
904                };
905                let mut uri = format!("https://datatracker.ietf.org/doc/html/rfc{num}.html");
906                if let Some(a) = &anchor {
907                    uri.push('#');
908                    uri.push_str(a);
909                }
910                (
911                    "rfc",
912                    format!("RFC; {formatted}"),
913                    uri,
914                    if explicit { title.clone() } else { formatted },
915                )
916            }
917            "cve" => {
918                let mut uri = format!("https://www.cve.org/CVERecord?id=CVE-{numpart}");
919                if let Some(a) = &anchor {
920                    uri.push('#');
921                    uri.push_str(a);
922                }
923                (
924                    "cve",
925                    format!("Common Vulnerabilities and Exposures; CVE {target}"),
926                    uri,
927                    format!("CVE {title}"),
928                )
929            }
930            _ => {
931                let Ok(num) = numpart.parse::<u32>() else {
932                    self.role_problematic(
933                        raw,
934                        None,
935                        messages::ERROR,
936                        &format!("invalid CWE number {target}"),
937                    );
938                    return;
939                };
940                let mut uri = format!("https://cwe.mitre.org/data/definitions/{num}.html");
941                if let Some(a) = &anchor {
942                    uri.push('#');
943                    uri.push_str(a);
944                }
945                (
946                    "cwe",
947                    format!("Common Weakness Enumeration; CWE {target}"),
948                    uri,
949                    format!("CWE {title}"),
950                )
951            }
952        };
953        let serial = self.registry.new_index_serialno();
954        let target_id = format!("index-{serial}");
955        self.flush_text();
956        let mut index = Node::elem("index", self.span);
957        index.set(
958            "entries",
959            AttrValue::List(vec![super::block::index_entry_tuple(
960                "single",
961                &index_text,
962                &target_id,
963                "",
964                None,
965            )]),
966        );
967        self.nodes.push(index);
968        let mut tnode = Node::elem(kinds::TARGET, self.span);
969        tnode.attrs.ids.push(target_id.clone());
970        // `self.inliner.document.note_explicit_target(target)` (`roles.py`):
971        // the id joins `document.ids`, so a later `make_id` cannot reuse it.
972        self.registry.note_explicit_id(&target_id);
973        self.nodes.push(tnode);
974        let mut reference = Node::elem(kinds::REFERENCE, self.span);
975        reference.attrs.classes.push(kind_key.to_string());
976        reference.set("internal", AttrValue::Int(0));
977        reference.set("refuri", AttrValue::Str(refuri));
978        let mut strong = Node::elem(kinds::STRONG, self.span);
979        strong.children.push(Node::text_node(
980            if explicit { title } else { default_title },
981            self.span,
982        ));
983        reference.children.push(strong);
984        self.nodes.push(reference);
985    }
986
987    /// sphinx.roles.XRefRole anatomy (probe-verified via :doc: in the
988    /// wave-3 probes): pending_xref with refdoc/refdomain/refexplicit/
989    /// reftarget/reftype/refwarn attrs and an `inline classes="xref
990    /// {domain} {domain}-{type}"` child. Role→domain mapping covers the
991    /// std and py domains; explicit `domain:role` names pass through.
992    fn emit_sphinx_xref(&mut self, lower: &str, raw: &str) {
993        let (domain, reftype) = match lower.rsplit_once(':') {
994            Some((d, t)) => (d.to_string(), t.to_string()),
995            None => {
996                let d = match lower {
997                    "doc" | "ref" | "term" | "option" | "envvar" | "numref" | "keyword"
998                    | "token" | "program" | "confval" => "std",
999                    // The eleven `PythonDomain.roles` keys
1000                    // (`domains/python/__init__.py:755-767`). An
1001                    // unqualified role name resolves against
1002                    // `primary_domain` (default `py`) BEFORE the std
1003                    // fallback (`util/docutils.py`, `sphinx_domains.role`),
1004                    // and `type` is a py role with no std counterpart.
1005                    "func" | "class" | "meth" | "mod" | "attr" | "data" | "exc" | "obj"
1006                    | "const" | "deco" | "type" => "py",
1007                    // `AnyXRefRole` is registered domainless (`roles.py`
1008                    // `specific_docroles`), so `XRefRole.run`'s name split
1009                    // leaves `refdomain=''` — which is what routes the node
1010                    // into `_resolve_pending_any_xref` instead of a domain.
1011                    "any" => "",
1012                    // `:eq:` is the math domain's, registered domainless
1013                    // like `:any:` (`app.add_role('eq',
1014                    // MathReferenceRole(warn_dangling=True))`,
1015                    // `domains/math.py:163`); `result_nodes` then stamps
1016                    // `refdomain = 'math'` onto the node (`:32-44`).
1017                    "eq" => "math",
1018                    _ => "std",
1019                };
1020                (d.to_string(), lower.to_string())
1021            }
1022        };
1023        self.emit_xref_node(&domain, &reftype, raw, None);
1024    }
1025
1026    /// The `:external:`/`:external+inv:` roles, which
1027    /// `IntersphinxDispatcher` claims *before* docutils' own role lookup
1028    /// (`ext/intersphinx/_resolve.py:350-366`) — and therefore before the
1029    /// generic `domain:role` split above, which would otherwise read
1030    /// `external:py:func` as domain `external:py`.
1031    ///
1032    /// The name is the one the author wrote, not the lowercased one: the
1033    /// inventory name inside it is case-sensitive
1034    /// (`IntersphinxRole.orig_name`).
1035    ///
1036    /// A name that does not name a usable role produces no content at all in
1037    /// Sphinx, and a warning located at the role. Here the warning text is
1038    /// parked on a marker node instead: the parse layer knows the role
1039    /// grammar but not which inventories loaded, and Sphinx checks the
1040    /// inventory *first* — so deferring the whole report to resolution is
1041    /// what keeps the precedence between the two right.
1042    fn emit_external_xref(&mut self, given_name: &str, raw: &str) {
1043        match crate::intersphinx::external_role(given_name) {
1044            crate::intersphinx::ExternalRole::Xref {
1045                inventory,
1046                domain,
1047                role,
1048            } => self.emit_xref_node(&domain, &role, raw, Some(inventory)),
1049            crate::intersphinx::ExternalRole::Failed(diagnostic) => {
1050                self.flush_text();
1051                let mut node = Node::elem("pending_xref", self.span);
1052                node.set("refdoc", AttrValue::Str(self.docname.to_string()));
1053                node.set("intersphinx", AttrValue::Int(1));
1054                node.set("intersphinx_role_error", AttrValue::Str(diagnostic.message));
1055                self.nodes.push(node);
1056            }
1057        }
1058    }
1059
1060    /// `XRefRole.update_title_and_target` (`roles.py:87-98`), run for the
1061    /// roles constructed with `fix_parens=True`:
1062    ///
1063    /// ```python
1064    /// if not self.has_explicit_title:
1065    ///     if self.config.add_function_parentheses:
1066    ///         if not title.endswith('()'):
1067    ///             title += '()'
1068    ///     else:
1069    ///         title = title.removesuffix('()')
1070    /// target = target.removesuffix('()')
1071    /// ```
1072    ///
1073    /// So: an IMPLICIT title ends with exactly one `()` when the config is
1074    /// on and with none when it is off — either way the parens the author
1075    /// wrote are normalised away first, which is why an already
1076    /// parenthesized title is not doubled (probe P3). An EXPLICIT title is
1077    /// untouched (P5). The TARGET loses one trailing pair unconditionally,
1078    /// explicit titles included and under both settings (P3/P4, Q4/Q5) —
1079    /// one pair only, never more (Q10/Q11).
1080    fn update_title_and_target(
1081        target: String,
1082        title: String,
1083        explicit: bool,
1084        add_function_parentheses: bool,
1085    ) -> (String, String) {
1086        let mut title = title;
1087        if !explicit {
1088            if add_function_parentheses {
1089                if !title.ends_with("()") {
1090                    title.push_str("()");
1091                }
1092            } else if let Some(stripped) = title.strip_suffix("()") {
1093                title = stripped.to_string();
1094            }
1095        }
1096        let target = match target.strip_suffix("()") {
1097            Some(stripped) => stripped.to_string(),
1098            None => target,
1099        };
1100        (target, title)
1101    }
1102
1103    /// The body of [`Self::emit_sphinx_xref`], with the domain and role
1104    /// already decided. `external` is `Some(inventory)` for a node the
1105    /// `:external:` role produced — `Some(None)` when that role named no
1106    /// inventory.
1107    fn emit_xref_node(
1108        &mut self,
1109        domain: &str,
1110        reftype: &str,
1111        raw: &str,
1112        external: Option<Option<String>>,
1113    ) {
1114        let text = unescape(raw, false);
1115        let (domain, reftype) = (domain.to_string(), reftype.to_string());
1116        // `Title <target>` explicit form — `ReferenceRole.explicit_title_re`
1117        // (`util/docutils.py:730`, `^(.+?)\s*(?<!\x00)<(.*?)>$`): the title
1118        // loses its trailing `\s*` (Python's set), the target between the
1119        // brackets is taken VERBATIM — padding included, `:term:`x < foo
1120        // bar >`` carries reftarget `" foo bar "` after the base
1121        // `process_link` collapse and `" f() "` keeps its parens through
1122        // `update_title_and_target` (both probed against sphinx 9.1.0).
1123        let (target, display, explicit) = match (text.rfind('<'), text.ends_with('>')) {
1124            (Some(lt), true) => (
1125                text[lt + 1..text.len() - 1].to_string(),
1126                text[..lt]
1127                    .trim_end_matches(crate::utils::py_isspace)
1128                    .to_string(),
1129                true,
1130            ),
1131            _ => (text.clone(), text.clone(), false),
1132        };
1133        // `XRefRole.update_title_and_target` (`roles.py:87-98`), which
1134        // `create_xref_node` runs on a `fix_parens` role BEFORE
1135        // `process_link` (`roles.py:127-140`) — the ordering probes Q2/Q3
1136        // of the task-2 brief pin the consequence: the target loses its
1137        // `()` before the `~` shortening below splits it.
1138        //
1139        // Only `:py:func:` and `:py:meth:` are constructed with
1140        // `fix_parens=True` (`domains/python/__init__.py:758,764`);
1141        // `:py:deco:`, `:py:obj:` and the rest are not (probes Q8/Q9).
1142        let py = domain == "py";
1143        let (target, display) = if py && matches!(reftype.as_str(), "func" | "meth") {
1144            Self::update_title_and_target(
1145                target,
1146                display,
1147                explicit,
1148                self.py.add_function_parentheses,
1149            )
1150        } else {
1151            (target, display)
1152        };
1153        // `XRefRole.lowercase` (`roles.py:122-124`): the target — never the
1154        // title — is lowercased, for `:ref:` *and* `:numref:`
1155        // (`domains/std/__init__.py:752-760`, both `lowercase=True`).
1156        // `XRefRole.process_link` then collapses whitespace runs in the
1157        // target (`roles.py:165`, `ws_re.sub(' ', target)`). That is NOT
1158        // `fully_normalize_name`: docutils' spelling strips the ends as
1159        // well (`' '.join(name.lower().split())`), and sphinx never calls
1160        // it on an xref target. An IMPLICIT target cannot show the
1161        // difference (a space after the opening backtick is "start-string
1162        // without end-string", verified against docutils 0.22.4), but an
1163        // explicit `Title < target >` can, and EVERY role keeps the
1164        // padding — `:ref:`/`:numref:` included, lowercased but not
1165        // stripped (probed against sphinx 9.1.0: `:term:`x < foo   bar >``
1166        // -> `" foo bar "`, `:ref:`r < L abc >`` -> `" l abc "`,
1167        // `:ref:`ABC`` -> `"abc"`). py targets drop a leading `~` from
1168        // the target while the title keeps only the last dotted segment.
1169        //
1170        // Every OTHER role runs the base `process_link` and so collapses —
1171        // `:any:` (via `AnyXRefRole`'s `super()` call, `roles.py:183-193`)
1172        // as much as `:term:`, `:doc:`, `:keyword:`, `:confval:`,
1173        // `:envvar:`, `:eq:` and the `rst:` domain's roles. The collapse is
1174        // therefore the DEFAULT here, with [`target_collapses_whitespace`]
1175        // naming the opt-outs.
1176        let any = domain.is_empty() && reftype == "any";
1177        let (target, display) = match (domain.as_str(), reftype.as_str()) {
1178            ("std", "ref" | "numref") => (collapse_whitespace(&target.to_lowercase()), display),
1179            (d, t) if target_collapses_whitespace(d, t) => (collapse_whitespace(&target), display),
1180            _ => (target, display),
1181        };
1182        // `PyXRefRole.process_link` (`domains/python/__init__.py:559-585`),
1183        // which `create_xref_node` runs AFTER `update_title_and_target`
1184        // (`roles.py:127-140`).
1185        let mut refspecific = false;
1186        let (target, display) = if py {
1187            let mut title = display;
1188            let mut target = target;
1189            if !explicit {
1190                // `title.lstrip('.')` "only has a meaning for the target";
1191                // `target.lstrip('~')` "only has a meaning for the title" —
1192                // both strip EVERY leading occurrence (probes
1193                // role_title_lstrip_dots / role_target_lstrip_tilde).
1194                title = title.trim_start_matches('.').to_string();
1195                target = target.trim_start_matches('~').to_string();
1196                // ONE leading `~` on the title reduces it to its last
1197                // dotted component. The shortening runs on the TITLE, not
1198                // the target: for a `fix_parens` role the title is the
1199                // half that already carries the `()` decision (Q2/Q3).
1200                if title.starts_with('~') {
1201                    let rest = title[1..].to_string();
1202                    title = match rest.rfind('.') {
1203                        Some(dot) => rest[dot + 1..].to_string(),
1204                        None => rest,
1205                    };
1206                }
1207            }
1208            // A `.`-prefixed target — explicit titles included — loses the
1209            // dot and searches more specific namespaces first
1210            // (`__init__.py:582-584`, probes roles_tilde_dot /
1211            // role_dot_explicit_title).
1212            if target.starts_with('.') {
1213                target = target[1..].to_string();
1214                refspecific = true;
1215            }
1216            // `_PyDecoXRefRole` (`__init__.py:588-600`) prefixes `@`
1217            // UNCONDITIONALLY — explicit titles included (probe
1218            // role_deco_explicit: `@custom`).
1219            if reftype == "deco" {
1220                title = format!("@{title}");
1221            }
1222            (target, title)
1223        } else {
1224            (target, display)
1225        };
1226        let mut node = Node::elem("pending_xref", self.span);
1227        if py {
1228            // `refnode['py:module']`/`['py:class']` from the enclosing
1229            // ref_context (`__init__.py:568-569`); None outside a py scope,
1230            // which pformat renders as "True".
1231            node.set(
1232                "py:class",
1233                AttrValue::Str(self.ctx.py_class.unwrap_or("True").to_string()),
1234            );
1235            node.set(
1236                "py:module",
1237                AttrValue::Str(self.ctx.py_module.unwrap_or("True").to_string()),
1238            );
1239        }
1240        if any {
1241            // `AnyXRefRole.process_link`: "add all possible context info
1242            // (i.e. std:program, py:module etc.)" —
1243            // `refnode.attributes.update(env.ref_context)`. Only keys that
1244            // EXIST are copied (no "True" sentinels for absent ones, unlike
1245            // `PyXRefRole`), and the two *list* values (`py:classes`,
1246            // `py:modules`) are copied by reference, so the balanced
1247            // directive nesting has drained them back to `[]` — rendering
1248            // `""` — by the time the doctree is serialized (probe: an
1249            // `:any:` inside a class body still shows `py:classes=""`).
1250            if self.ctx.py_class_key {
1251                node.set(
1252                    "py:class",
1253                    AttrValue::Str(self.ctx.py_class.unwrap_or("True").to_string()),
1254                );
1255            }
1256            if self.ctx.py_classes_key {
1257                node.set("py:classes", AttrValue::Str(String::new()));
1258            }
1259            // `py_module_key` carries the key-exists-with-`None` shape a
1260            // bare `Option` cannot (see [`RefContext::py_module`]); an
1261            // absent value under a present key renders as the same `"True"`
1262            // sentinel `py:class` uses.
1263            if self.ctx.py_module_key {
1264                node.set(
1265                    "py:module",
1266                    AttrValue::Str(self.ctx.py_module.unwrap_or("True").to_string()),
1267                );
1268            }
1269            if self.ctx.py_modules_key {
1270                node.set("py:modules", AttrValue::Str(String::new()));
1271            }
1272            if let Some(program) = self.ctx.program {
1273                node.set("std:program", AttrValue::Str(program.to_string()));
1274            }
1275        }
1276        node.set("refdoc", AttrValue::Str(self.docname.to_string()));
1277        node.set("refdomain", AttrValue::Str(domain.clone()));
1278        // `node['intersphinx'] = True; node['inventory'] = inv_name`
1279        // (`_resolve.py:474-483`) — the stamp that sends this node through
1280        // `IntersphinxRoleResolver` instead of ordinary domain resolution.
1281        if let Some(inventory) = external {
1282            node.set("intersphinx", AttrValue::Int(1));
1283            if let Some(inventory) = inventory {
1284                node.set("inventory", AttrValue::Str(inventory));
1285            }
1286        }
1287        node.set("refexplicit", AttrValue::Int(i64::from(explicit)));
1288        if refspecific {
1289            // `refnode['refspecific'] = True` is set ONLY on the dot
1290            // branch — other py xrefs carry no refspecific attribute at
1291            // all (probe roles_basic vs roles_tilde_dot).
1292            node.set("refspecific", AttrValue::Int(1));
1293        }
1294        node.set("reftarget", AttrValue::Str(target));
1295        node.set("reftype", AttrValue::Str(reftype.clone()));
1296        // `XRefRole.warn_dangling` (`roles.py:134`), which is what makes a
1297        // role report a dangling reference outside nitpicky mode. Only these
1298        // seven std roles carry it (`domains/std/__init__.py:748-766`); no py
1299        // role does.
1300        //
1301        // The list is exhaustive on purpose. Everything else that lands here
1302        // is a role this crate has no implementation for — including
1303        // Sphinx's own non-xref roles (`:kbd:`, `:file:`, `:guilabel:`,
1304        // `:command:`, `:abbr:`, `:program:`, ..., `roles.py:28-36` and
1305        // `:608-626`), which produce plain inline nodes in real Sphinx and
1306        // resolve nothing. Defaulting those to `warn_dangling` made every
1307        // document that used one warn `'kbd' reference target not found`.
1308        // ... plus `:any:`, constructed `AnyXRefRole(warn_dangling=True)`
1309        // (`roles.py`, `specific_docroles`).
1310        // ... and `:eq:`, `MathReferenceRole(warn_dangling=True)`
1311        // (`domains/math.py:163`).
1312        let warn_dangling = matches!(
1313            (domain.as_str(), reftype.as_str()),
1314            (
1315                "std",
1316                "ref" | "numref" | "doc" | "term" | "keyword" | "option" | "confval"
1317            ) | ("", "any")
1318                | ("math", "eq")
1319        );
1320        node.set("refwarn", AttrValue::Int(i64::from(warn_dangling)));
1321        // `OptionXRefRole.process_link` (`domains/std/__init__.py:351-364`).
1322        // Outside a `.. program::` scope the value is Python None, which
1323        // pformat renders as the "True" sentinel.
1324        if domain == "std" && reftype == "option" {
1325            node.set(
1326                "std:program",
1327                AttrValue::Str(self.ctx.program.unwrap_or("True").to_string()),
1328            );
1329        }
1330        // `XRefRole.innernodeclass` (`roles.py:67`): `literal` unless the
1331        // role overrides it, which in the std domain only `ref`, `term` and
1332        // `doc` do (`domains/std/__init__.py:752-765`).
1333        let inline_inner = matches!(
1334            (domain.as_str(), reftype.as_str()),
1335            ("std", "ref" | "term" | "doc")
1336        );
1337        let mut inner = Node::elem(
1338            if inline_inner {
1339                "inline"
1340            } else {
1341                kinds::LITERAL
1342            },
1343            self.span,
1344        );
1345        // `XRefRole.run` (`roles.py:101-103`): a role REGISTERED without a
1346        // domain prefix gets `['xref', reftype]` — probe: `:any:` yields
1347        // `classes="xref any"`, and so does `:eq:` (`classes="xref eq"`),
1348        // whose `refdomain` is only stamped afterwards by `result_nodes`.
1349        let registered_domainless = domain.is_empty() || (domain == "math" && reftype == "eq");
1350        inner.attrs.classes = if registered_domainless {
1351            vec!["xref".to_string(), reftype.clone()]
1352        } else {
1353            vec![
1354                "xref".to_string(),
1355                domain.clone(),
1356                format!("{domain}-{reftype}"),
1357            ]
1358        };
1359        inner.children.push(Node::text_node(display, self.span));
1360        node.children.push(inner);
1361        self.flush_text();
1362        // `EnvVarXRefRole.result_nodes` (`domains/std/__init__.py:91-112`):
1363        // an `:envvar:` reference also *indexes* the variable, under its bare
1364        // name and under the same 'environment variable; %s' heading the
1365        // `.. envvar::` directive uses, both anchored on a fresh
1366        // `index-N` target placed just before the reference.
1367        // `EnvVarXRefRole.result_nodes` is gated on `is_ref`
1368        // (`domains/std/__init__.py:99-102`), which `XRefRole.run` clears for
1369        // a `!`-prefixed role text: `ReferenceRole.run` sets
1370        // `self.disabled = text.startswith('!')` and `create_non_xref_node`
1371        // then calls `result_nodes(..., is_ref=False)`, which returns the bare
1372        // node — no index entries, no target, and no `index` serial consumed.
1373        // (The rest of `disabled` — emitting the literal alone, without a
1374        // pending_xref, and with the `!` stripped — is not modelled yet; this
1375        // guard keeps the un-modelled half from also corrupting document-wide
1376        // `index-N` numbering.)
1377        if domain == "std" && reftype == "envvar" && !text.starts_with('!') {
1378            let varname = match node.get("reftarget") {
1379                Some(AttrValue::Str(target)) => target.clone(),
1380                _ => String::new(),
1381            };
1382            let target_id = format!("index-{}", self.registry.new_index_serialno());
1383            let mut index = Node::elem("index", self.span);
1384            index.set(
1385                "entries",
1386                AttrValue::List(vec![
1387                    super::block::index_entry_tuple("single", &varname, &target_id, "", None),
1388                    super::block::index_entry_tuple(
1389                        "single",
1390                        &format!("environment variable; {varname}"),
1391                        &target_id,
1392                        "",
1393                        None,
1394                    ),
1395                ]),
1396            );
1397            self.nodes.push(index);
1398            let mut tnode = Node::elem(kinds::TARGET, self.span);
1399            tnode.attrs.ids.push(target_id.clone());
1400            self.registry.note_explicit_id(&target_id);
1401            self.nodes.push(tnode);
1402        }
1403        self.nodes.push(node);
1404    }
1405
1406    /// Apply a built-in role by (raw, unlowercased) name.
1407    fn apply_role(&mut self, given_name: &str, raw: &str, rawsource: &str) {
1408        let lower = given_name.to_lowercase();
1409        if self.sphinx {
1410            // Record EVERY role occurrence for the build pipeline
1411            // (validation + nitpicky), M1-scanner display-split semantics.
1412            let text = unescape(raw, false);
1413            let (target, display) = match (text.rfind('<'), text.ends_with('>')) {
1414                (Some(lt), true) => (
1415                    text[lt + 1..text.len() - 1].trim().to_string(),
1416                    Some(text[..lt].trim().to_string()),
1417                ),
1418                _ => (text.clone(), None),
1419            };
1420            let last_segment = lower.rsplit(':').next().unwrap_or(&lower).to_string();
1421            self.roles.push(super::RoleRecord {
1422                source: self.span.source,
1423                name: last_segment,
1424                full_name: given_name.to_string(),
1425                target,
1426                display,
1427                line: self.lineno,
1428            });
1429        }
1430        // en language aliases -> canonical names
1431        let canonical = match lower.as_str() {
1432            "abbreviation" | "ab" => "abbreviation",
1433            "acronym" | "ac" => "acronym",
1434            "code" => "code",
1435            "emphasis" => "emphasis",
1436            "literal" => "literal",
1437            "math" => "math",
1438            "pep-reference" | "pep" => "pep-reference",
1439            "rfc-reference" | "rfc" => "rfc-reference",
1440            "strong" => "strong",
1441            "subscript" | "sub" => "subscript",
1442            "superscript" | "sup" => "superscript",
1443            "title-reference" | "title" | "t" => "title-reference",
1444            "raw" => "raw",
1445            "index" | "i" => "index",
1446            "named-reference" => "named-reference",
1447            "anonymous-reference" => "anonymous-reference",
1448            "footnote-reference" => "footnote-reference",
1449            "citation-reference" => "citation-reference",
1450            "substitution-reference" => "substitution-reference",
1451            "target" => "target",
1452            "uri-reference" | "uri" | "url" => "uri-reference",
1453            _ if self.sphinx && crate::intersphinx::is_external_role(given_name) => {
1454                self.emit_external_xref(given_name, raw);
1455                return;
1456            }
1457            _ if self.sphinx && matches!(lower.as_str(), "cve" | "cwe") => {
1458                self.emit_sphinx_extlink(&lower, raw);
1459                return;
1460            }
1461            _ if self.sphinx => {
1462                // Sphinx mode: non-docutils roles resolve through the
1463                // domain registries; at parse layer they become
1464                // pending_xref nodes and NEVER message. (Genuinely unknown
1465                // roles would error in real Sphinx — hardening note.)
1466                self.emit_sphinx_xref(&lower, raw);
1467                return;
1468            }
1469            _ => {
1470                // Not in the language module: INFO + canonical lookup, which
1471                // also fails for anything we do not know -> ERROR.
1472                let info = format!(
1473                    "No role entry for \"{given_name}\" in module \"docutils.parsers.rst.languages.en\".\nTrying \"{given_name}\" as canonical role name."
1474                );
1475                if lower == "restructuredtext-unimplemented-role" {
1476                    self.role_problematic(
1477                        rawsource,
1478                        Some(info),
1479                        messages::ERROR,
1480                        &format!("Interpreted text role \"{given_name}\" not implemented."),
1481                    );
1482                } else {
1483                    self.role_problematic(
1484                        rawsource,
1485                        Some(info),
1486                        messages::ERROR,
1487                        &format!("Unknown interpreted text role \"{given_name}\"."),
1488                    );
1489                }
1490                return;
1491            }
1492        };
1493        match canonical {
1494            "emphasis" => self.emit_inline(kinds::EMPHASIS, raw, false),
1495            "strong" => self.emit_inline(kinds::STRONG, raw, false),
1496            "literal" => self.emit_inline(kinds::LITERAL, raw, false),
1497            "subscript" => self.emit_inline(kinds::SUBSCRIPT, raw, false),
1498            "superscript" => self.emit_inline(kinds::SUPERSCRIPT, raw, false),
1499            "title-reference" => self.emit_inline(kinds::TITLE_REFERENCE, raw, false),
1500            "abbreviation" => self.emit_inline(kinds::ABBREVIATION, raw, false),
1501            "acronym" => self.emit_inline(kinds::ACRONYM, raw, false),
1502            "math" => self.emit_inline(kinds::MATH, raw, true),
1503            "code" => {
1504                self.flush_text();
1505                let mut node = Node::elem(kinds::LITERAL, self.span);
1506                node.attrs.classes.push("code".to_string());
1507                node.children
1508                    .push(Node::text_node(unescape(raw, true), self.span));
1509                self.nodes.push(node);
1510            }
1511            "pep-reference" => {
1512                if self.sphinx {
1513                    // Sphinx overrides pep with an index-emitting external.
1514                    self.emit_sphinx_extlink("pep", raw);
1515                    return;
1516                }
1517                let text = unescape(raw, false);
1518                match text.parse::<u32>().ok().filter(|n| *n <= 9999) {
1519                    Some(n) => {
1520                        self.flush_text();
1521                        let mut r = Node::elem(kinds::REFERENCE, self.span);
1522                        r.set(
1523                            "refuri",
1524                            AttrValue::Str(format!("https://peps.python.org/pep-{n:04}")),
1525                        );
1526                        r.children
1527                            .push(Node::text_node(format!("PEP {text}"), self.span));
1528                        self.nodes.push(r);
1529                    }
1530                    None => self.role_problematic(
1531                        rawsource,
1532                        None,
1533                        messages::ERROR,
1534                        &format!(
1535                            "PEP number must be a number from 0 to 9999; \"{text}\" is invalid."
1536                        ),
1537                    ),
1538                }
1539            }
1540            "rfc-reference" => {
1541                if self.sphinx {
1542                    self.emit_sphinx_extlink("rfc", raw);
1543                    return;
1544                }
1545                let text = unescape(raw, false);
1546                let (numpart, fragment) = match text.split_once('#') {
1547                    Some((a, b)) => (a.to_string(), Some(b.to_string())),
1548                    None => (text.clone(), None),
1549                };
1550                match numpart.parse::<u32>().ok().filter(|n| *n >= 1) {
1551                    Some(n) => {
1552                        self.flush_text();
1553                        let mut r = Node::elem(kinds::REFERENCE, self.span);
1554                        let mut uri = format!("https://tools.ietf.org/html/rfc{n}.html");
1555                        if let Some(f) = fragment {
1556                            uri = format!("{uri}#{f}");
1557                        }
1558                        r.set("refuri", AttrValue::Str(uri));
1559                        r.children
1560                            .push(Node::text_node(format!("RFC {n}"), self.span));
1561                        self.nodes.push(r);
1562                    }
1563                    None => self.role_problematic(
1564                        rawsource,
1565                        None,
1566                        messages::ERROR,
1567                        &format!(
1568                            "RFC number must be a number greater than or equal to 1; \"{numpart}\" is invalid."
1569                        ),
1570                    ),
1571                }
1572            }
1573            "raw" => self.role_problematic(
1574                rawsource,
1575                None,
1576                messages::ERROR,
1577                "No format (Writer name) is associated with this role: \"raw\".\nThe \"raw\" role cannot be used directly.\nInstead, use the \"role\" directive to create a new role with an associated format.",
1578            ),
1579            other => self.role_problematic(
1580                rawsource,
1581                None,
1582                messages::ERROR,
1583                &format!("Interpreted text role \"{other}\" not implemented."),
1584            ),
1585        }
1586    }
1587
1588    /// Phrase reference body handling incl. embedded `<uri>`/`<alias_>`.
1589    fn phrase_reference(&mut self, raw: &str, underscores: usize) {
1590        self.flush_text();
1591        let ids = |s: &str| crate::doctree::ids::fully_normalize_name(s);
1592        let wsn = |s: &str| crate::doctree::ids::whitespace_normalize_name(s);
1593
1594        // embedded link: unescaped `<...>` at the very end, preceded by
1595        // whitespace (or the whole content).
1596        let embedded = find_embedded_link(raw);
1597        let mut r = Node::elem(kinds::REFERENCE, self.span);
1598        match embedded {
1599            Some((text_part, link)) => {
1600                let is_alias = link.ends_with('_')
1601                    && !link.ends_with("\u{0}_")
1602                    && !link
1603                        .chars()
1604                        .rev()
1605                        .nth(1)
1606                        .map(|c| c == NULL)
1607                        .unwrap_or(false)
1608                    && !looks_like_uri(&link);
1609                let display_text;
1610                if is_alias {
1611                    let alias = ids(&unescape(&link[..link.len() - 1], false));
1612                    display_text = if text_part.is_empty() {
1613                        unescape(&link[..link.len() - 1], false)
1614                    } else {
1615                        unescape(&text_part, false)
1616                    };
1617                    r.set("name", AttrValue::Str(wsn(&display_text)));
1618                    r.set("refname", AttrValue::Str(alias.clone()));
1619                    r.children
1620                        .push(Node::text_node(display_text.clone(), self.span));
1621                    self.nodes.push(r);
1622                    if underscores == 1 {
1623                        let mut t = Node::elem(kinds::TARGET, self.span);
1624                        t.attrs.names.push(ids(&display_text));
1625                        let msg =
1626                            self.registry
1627                                .set_id_implicit(&mut t, self.lineno, self.source_path);
1628                        t.set("refname", AttrValue::Str(alias));
1629                        self.nodes.push(t);
1630                        if let Some(m) = msg {
1631                            self.messages.push(m);
1632                        }
1633                    }
1634                } else {
1635                    // URI: strip escaped trailing underscore marker, remove
1636                    // whitespace line-by-line (escaped spaces survive).
1637                    let mut uri_src = link.clone();
1638                    if uri_src.ends_with('_')
1639                        && uri_src
1640                            .chars()
1641                            .rev()
1642                            .nth(1)
1643                            .map(|c| c == NULL)
1644                            .unwrap_or(false)
1645                    {
1646                        // `alias\_` -> literal underscore URI
1647                        uri_src = format!("{}_", &uri_src[..uri_src.len() - 2]);
1648                    }
1649                    let uri = clean_uri(&uri_src);
1650                    let uri = if looks_like_email(&uri) && !uri.starts_with("mailto:") {
1651                        format!("mailto:{uri}")
1652                    } else {
1653                        uri
1654                    };
1655                    display_text = if text_part.is_empty() {
1656                        uri.clone()
1657                    } else {
1658                        unescape(&text_part, false)
1659                    };
1660                    r.set("name", AttrValue::Str(wsn(&display_text)));
1661                    r.set("refuri", AttrValue::Str(uri.clone()));
1662                    r.children
1663                        .push(Node::text_node(display_text.clone(), self.span));
1664                    self.nodes.push(r);
1665                    if underscores == 1 {
1666                        let mut t = Node::elem(kinds::TARGET, self.span);
1667                        t.attrs.names.push(ids(&display_text));
1668                        let msg =
1669                            self.registry
1670                                .set_id_implicit(&mut t, self.lineno, self.source_path);
1671                        t.set("refuri", AttrValue::Str(uri));
1672                        self.nodes.push(t);
1673                        if let Some(m) = msg {
1674                            self.messages.push(m);
1675                        }
1676                    }
1677                }
1678            }
1679            None => {
1680                let text = unescape(raw, false);
1681                r.set("name", AttrValue::Str(wsn(&text)));
1682                if underscores == 2 {
1683                    r.set("anonymous", AttrValue::Int(1));
1684                } else {
1685                    r.set("refname", AttrValue::Str(ids(&text)));
1686                }
1687                r.children.push(Node::text_node(text, self.span));
1688                self.nodes.push(r);
1689            }
1690        }
1691    }
1692
1693    /// `[label]_` footnote and citation references.
1694    fn try_footnote_or_citation(&mut self, i: usize) -> Option<usize> {
1695        let n = self.chars.len();
1696        let mut j = i + 1;
1697        // label: '#simplename' | '#' | '*' | digits | simplename
1698        let label_start = j;
1699        match self.chars.get(j) {
1700            Some(&'#') => {
1701                j += 1;
1702                if let Some(len) = match_simplename(&self.chars, j) {
1703                    j += len;
1704                }
1705            }
1706            Some(&'*') => j += 1,
1707            _ => j += match_simplename(&self.chars, j)?,
1708        }
1709        if self.chars.get(j) != Some(&']') || self.chars.get(j + 1) != Some(&'_') {
1710            return None;
1711        }
1712        let after = j + 2;
1713        if !is_end_suffix_ok(self.chars.get(after).copied()) {
1714            return None;
1715        }
1716        let _ = n;
1717        let label: String = self.chars[label_start..j].iter().collect();
1718        self.flush_text();
1719        let is_citation =
1720            !label.starts_with('#') && label != "*" && !label.chars().all(|c| c.is_ascii_digit());
1721        let id = self.registry.allocate_auto_id();
1722        let mut node = if is_citation {
1723            let mut c = Node::elem(kinds::CITATION_REFERENCE, self.span);
1724            c.set(
1725                "refname",
1726                AttrValue::Str(crate::doctree::ids::fully_normalize_name(&label)),
1727            );
1728            c.children.push(Node::text_node(label.clone(), self.span));
1729            c
1730        } else {
1731            let mut f = Node::elem(kinds::FOOTNOTE_REFERENCE, self.span);
1732            if label == "*" {
1733                f.set("auto", AttrValue::Str("*".to_string()));
1734            } else if let Some(rest) = label.strip_prefix('#') {
1735                f.set("auto", AttrValue::Int(1));
1736                if !rest.is_empty() {
1737                    f.set(
1738                        "refname",
1739                        AttrValue::Str(crate::doctree::ids::fully_normalize_name(rest)),
1740                    );
1741                }
1742            } else {
1743                f.set(
1744                    "refname",
1745                    AttrValue::Str(crate::doctree::ids::fully_normalize_name(&label)),
1746                );
1747                f.children.push(Node::text_node(label.clone(), self.span));
1748            }
1749            f
1750        };
1751        node.attrs.ids.push(id);
1752        self.nodes.push(node);
1753        Some(after)
1754    }
1755
1756    /// `|sub|`, `|sub|_`, `|sub|__` substitution references.
1757    fn substitution_ref(&mut self, i: usize) -> usize {
1758        let content_from = i + 1;
1759        let (end, underscores) = match self.find_end_with_underscores(content_from, '|', 2) {
1760            Some(r) => r,
1761            None => {
1762                self.emit_problematic("|", "substitution_reference");
1763                return i + 1;
1764            }
1765        };
1766        let after = end + 1 + underscores;
1767        let raw: String = self.chars[content_from..end].iter().collect();
1768        let text = unescape(&raw, false);
1769        self.flush_text();
1770        let mut subref = Node::elem(kinds::SUBSTITUTION_REFERENCE, self.span);
1771        subref.set(
1772            "refname",
1773            AttrValue::Str(crate::doctree::ids::whitespace_normalize_name(&text)),
1774        );
1775        subref
1776            .children
1777            .push(Node::text_node(text.clone(), self.span));
1778        if underscores == 0 {
1779            self.nodes.push(subref);
1780        } else {
1781            let mut outer = Node::elem(kinds::REFERENCE, self.span);
1782            if underscores == 2 {
1783                outer.set("anonymous", AttrValue::Int(1));
1784            } else {
1785                outer.set(
1786                    "refname",
1787                    AttrValue::Str(crate::doctree::ids::fully_normalize_name(&text)),
1788                );
1789            }
1790            outer.children.push(subref);
1791            self.nodes.push(outer);
1792        }
1793        after
1794    }
1795}
1796
1797/// Remove whitespace per line from a URI (escaped whitespace survives as
1798/// literal after unescape). docutils' `''.join(unescape(part).split())` is
1799/// Python's `str.split()`, so `\x1f` goes too ([`crate::utils::py_isspace`]).
1800fn clean_uri(link: &str) -> String {
1801    let joined: String = link
1802        .split('\n')
1803        .map(|part| {
1804            part.chars()
1805                .filter(|c| !crate::utils::py_isspace(*c))
1806                .collect::<String>()
1807        })
1808        .collect();
1809    unescape(&joined, false)
1810}
1811
1812fn looks_like_uri(link: &str) -> bool {
1813    let mut chars = link.chars();
1814    match chars.next() {
1815        Some(c) if c.is_ascii_alphabetic() => {}
1816        _ => return looks_like_email(link),
1817    }
1818    let scheme: String = link
1819        .chars()
1820        .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-'))
1821        .collect();
1822    link[scheme.len()..].starts_with(':')
1823        && punctuation::URI_SCHEMES.contains(&scheme.to_lowercase().as_str())
1824        || looks_like_email(link)
1825}
1826
1827fn looks_like_email(s: &str) -> bool {
1828    let s = s.trim_end_matches('_');
1829    let Some(at) = s.find('@') else { return false };
1830    let (local, domain) = (&s[..at], &s[at + 1..]);
1831    !local.is_empty()
1832        && !domain.is_empty()
1833        && domain.contains('.')
1834        && local.chars().all(|c| is_emailc(c) || c == '.')
1835        && domain.chars().all(|c| is_emailc(c) || c == '.')
1836}
1837
1838/// Embedded link inside a phrase: unescaped `<...>` at the very END,
1839/// preceded by unescaped whitespace (or being the whole content).
1840/// Returns (text_part_trimmed, link_content).
1841fn find_embedded_link(raw: &str) -> Option<(String, String)> {
1842    if !raw.ends_with('>') {
1843        return None;
1844    }
1845    let chars: Vec<char> = raw.chars().collect();
1846    let n = chars.len();
1847    // `%(non_whitespace_escape_before)s>` — the closing '>' must be
1848    // preceded by neither an escape null nor Python whitespace
1849    // (`(?<![\s\x00])`, states.py:780).
1850    if n >= 2 && (chars[n - 2] == NULL || crate::utils::py_isspace(chars[n - 2])) {
1851        return None;
1852    }
1853    // find matching unescaped '<' scanning backward
1854    let mut open = None;
1855    for k in (0..n - 1).rev() {
1856        if chars[k] == '<' && (k == 0 || chars[k - 1] != NULL) {
1857            open = Some(k);
1858            break;
1859        }
1860    }
1861    let open = open?;
1862    // docutils: link content is ([^<>]|\x00[<>])+ — any unescaped '<' or
1863    // '>' inside invalidates the whole embedded link.
1864    let mut m = open + 1;
1865    while m < n - 1 {
1866        let c = chars[m];
1867        if c == NULL {
1868            m += 2;
1869            continue;
1870        }
1871        if c == '<' || c == '>' {
1872            return None;
1873        }
1874        m += 1;
1875    }
1876    if open > 0 {
1877        // `(?:[ \n]+|^)` — LITERAL space or newline, not `\s`.
1878        let before = chars[open - 1];
1879        if !(before == ' ' || before == '\n') {
1880            return None;
1881        }
1882    }
1883    // `<%(non_whitespace_after)s` — `(?!\s)` after the open bracket.
1884    if chars
1885        .get(open + 1)
1886        .is_some_and(|c| crate::utils::py_isspace(*c))
1887    {
1888        return None;
1889    }
1890    let link: String = chars[open + 1..n - 1].iter().collect();
1891    if link.is_empty() {
1892        return None;
1893    }
1894    // `escaped[:match.start(0)]`, and the match starts at the leftmost of
1895    // the `[ \n]+` run.
1896    let text: String = chars[..open].iter().collect();
1897    Some((text.trim_end_matches([' ', '\n']).to_string(), link))
1898}
1899
1900/// Standalone URI or email starting at `at`. Returns (consumed_len,
1901/// refuri, display_text).
1902fn match_standalone_uri(chars: &[char], at: usize) -> Option<(usize, String, String)> {
1903    // absolute URI: scheme ':' hierarchical [?query] [#fragment]
1904    if chars[at].is_ascii_alphabetic() {
1905        let mut j = at;
1906        while j < chars.len()
1907            && (chars[j].is_ascii_alphanumeric() || matches!(chars[j], '.' | '+' | '-'))
1908        {
1909            j += 1;
1910        }
1911        if j < chars.len() && chars[j] == ':' {
1912            let scheme: String = chars[at..j].iter().collect();
1913            if punctuation::URI_SCHEMES.contains(&scheme.to_lowercase().as_str()) {
1914                let mut k = j + 1;
1915                let seg_end = |k: usize, stop: &[char]| -> usize {
1916                    let mut e = k;
1917                    while e < chars.len() && is_uric(chars[e]) && !stop.contains(&chars[e]) {
1918                        e += 1;
1919                    }
1920                    // segment must end on a urilast char: trim back
1921                    let mut last = e;
1922                    while last > k && !is_urilast(chars[last - 1]) {
1923                        last -= 1;
1924                    }
1925                    last
1926                };
1927                let h_end = seg_end(k, &['?', '#']);
1928                if h_end == k {
1929                    return None;
1930                }
1931                k = h_end;
1932                if chars.get(k) == Some(&'?') {
1933                    let q_end = seg_end(k + 1, &['#']);
1934                    if q_end > k + 1 {
1935                        k = q_end;
1936                    }
1937                }
1938                if chars.get(k) == Some(&'#') {
1939                    let f_end = seg_end(k + 1, &[]);
1940                    if f_end > k + 1 {
1941                        k = f_end;
1942                    }
1943                }
1944                if !is_end_suffix_ok(chars.get(k).copied()) && chars.get(k) != Some(&'>') {
1945                    return None;
1946                }
1947                let uri: String = chars[at..k].iter().collect();
1948                return Some((k - at, uri.clone(), uri));
1949            }
1950        }
1951    }
1952    // bare email ('@' is a literal separator in the docutils pattern, not
1953    // an emailc member — consume it here, validate via looks_like_email)
1954    {
1955        let mut j = at;
1956        while j < chars.len() && (is_emailc(chars[j]) || chars[j] == '.' || chars[j] == '@') {
1957            j += 1;
1958        }
1959        // trim to urilast
1960        while j > at && !is_urilast(chars[j - 1]) {
1961            j -= 1;
1962        }
1963        let cand: String = chars[at..j].iter().collect();
1964        if cand.contains('@')
1965            && looks_like_email(&cand)
1966            && (is_end_suffix_ok(chars.get(j).copied()) || chars.get(j) == Some(&'>'))
1967        {
1968            let refuri = if cand.starts_with("mailto:") {
1969                cand.clone()
1970            } else {
1971                format!("mailto:{cand}")
1972            };
1973            return Some((j - at, refuri, cand));
1974        }
1975    }
1976    None
1977}
1978
1979/// Inline-parse one logical text block (a paragraph's joined lines, a
1980/// title, a term…). `lineno` anchors any generated messages.
1981pub fn parse_inline(
1982    text: &str,
1983    span: Span,
1984    lineno: u32,
1985    registry: &mut IdRegistry,
1986    source_path: &str,
1987) -> InlineResult {
1988    // Docutils mode: no sphinx role reads the py configuration, so the
1989    // defaults are as good as any.
1990    let py = crate::py::PySigConfig::default();
1991    parse_inline_ext(
1992        text,
1993        span,
1994        lineno,
1995        registry,
1996        source_path,
1997        false,
1998        "index",
1999        RefContext::default(),
2000        &py,
2001    )
2002}
2003
2004#[allow(clippy::too_many_arguments)]
2005pub fn parse_inline_ext(
2006    text: &str,
2007    span: Span,
2008    lineno: u32,
2009    registry: &mut IdRegistry,
2010    source_path: &str,
2011    sphinx: bool,
2012    docname: &str,
2013    ctx: RefContext<'_>,
2014    py: &crate::py::PySigConfig,
2015) -> InlineResult {
2016    let escaped = escape2null(text);
2017    let mut inliner = Inliner {
2018        chars: escaped.chars().collect(),
2019        span,
2020        lineno,
2021        source_path,
2022        registry,
2023        sphinx,
2024        docname,
2025        ctx,
2026        py,
2027        roles: Vec::new(),
2028        nodes: Vec::new(),
2029        messages: Vec::new(),
2030        pending: String::new(),
2031        failed_end_search: std::collections::HashMap::new(),
2032    };
2033    inliner.run();
2034    InlineResult {
2035        nodes: inliner.nodes,
2036        messages: inliner.messages,
2037        roles: inliner.roles,
2038    }
2039}
2040
2041#[cfg(test)]
2042mod tests {
2043    use super::*;
2044
2045    #[test]
2046    fn escape2null_and_unescape_roundtrip() {
2047        assert_eq!(escape2null("a\\*b"), "a\u{0}*b");
2048        assert_eq!(unescape("a\u{0}*b", false), "a*b");
2049        assert_eq!(unescape("one\u{0} two", false), "onetwo"); // escaped space joins
2050        assert_eq!(unescape("a\u{0}\\b", true), "a\\\\b"); // restore: marker -> backslash
2051    }
2052
2053    fn pi(text: &str) -> (Vec<Node>, Vec<Node>) {
2054        let mut reg = IdRegistry::new();
2055        let r = parse_inline(text, Span::ZERO, 1, &mut reg, "<snippet>");
2056        (r.nodes, r.messages)
2057    }
2058
2059    #[test]
2060    fn plain_text_stays_single_node() {
2061        let (nodes, msgs) = pi("just text");
2062        assert_eq!(nodes.len(), 1);
2063        assert_eq!(nodes[0].text.as_deref(), Some("just text"));
2064        assert!(msgs.is_empty());
2065    }
2066
2067    #[test]
2068    fn simple_emphasis_strong_literal() {
2069        let (nodes, _) = pi("before *emph* after");
2070        assert_eq!(nodes.len(), 3);
2071        assert_eq!(nodes[0].text.as_deref(), Some("before "));
2072        assert_eq!(nodes[1].kind, kinds::EMPHASIS);
2073        assert_eq!(nodes[1].astext(), "emph");
2074        assert_eq!(nodes[2].text.as_deref(), Some(" after"));
2075
2076        let (nodes, _) = pi("*a* **b** ``c``");
2077        assert_eq!(nodes.len(), 5); // em, " ", strong, " ", literal
2078        assert_eq!(nodes[2].kind, kinds::STRONG);
2079        assert_eq!(nodes[4].kind, kinds::LITERAL);
2080    }
2081
2082    #[test]
2083    fn word_chars_block_recognition() {
2084        let (nodes, msgs) = pi("a*b*c");
2085        assert_eq!(nodes.len(), 1);
2086        assert_eq!(nodes[0].text.as_deref(), Some("a*b*c"));
2087        assert!(msgs.is_empty());
2088    }
2089
2090    #[test]
2091    fn quoted_start_suppresses_silently() {
2092        let (nodes, msgs) = pi("\"*\"");
2093        assert_eq!(nodes.len(), 1);
2094        assert_eq!(nodes[0].text.as_deref(), Some("\"*\""));
2095        assert!(msgs.is_empty());
2096        let (nodes, msgs) = pi("word *");
2097        assert_eq!(nodes[0].text.as_deref(), Some("word *"));
2098        assert!(msgs.is_empty());
2099    }
2100
2101    #[test]
2102    fn unclosed_produces_problematic_and_message() {
2103        let (nodes, msgs) = pi("*oops");
2104        assert_eq!(nodes.len(), 2);
2105        assert_eq!(nodes[0].kind, kinds::PROBLEMATIC);
2106        assert_eq!(nodes[0].astext(), "*");
2107        assert_eq!(nodes[0].attrs.ids, vec!["id2"]);
2108        assert_eq!(nodes[0].get("refid"), Some(&AttrValue::Str("id1".into())));
2109        assert_eq!(nodes[1].text.as_deref(), Some("oops"));
2110        assert_eq!(msgs.len(), 1);
2111        assert_eq!(msgs[0].attrs.ids, vec!["id1"]);
2112        assert_eq!(msgs[0].attrs.backrefs, vec!["id2"]);
2113        assert_eq!(
2114            msgs[0].children[0].astext(),
2115            "Inline emphasis start-string without end-string."
2116        );
2117    }
2118
2119    #[test]
2120    fn no_nesting_and_first_end_wins() {
2121        let (nodes, _) = pi("**a *b* c**");
2122        assert_eq!(nodes[0].kind, kinds::STRONG);
2123        assert_eq!(nodes[0].astext(), "a *b* c");
2124        let (nodes, _) = pi("*word *word*");
2125        assert_eq!(nodes[0].kind, kinds::EMPHASIS);
2126        assert_eq!(nodes[0].astext(), "word *word");
2127        let (nodes, _) = pi("***x***");
2128        assert_eq!(nodes[0].kind, kinds::STRONG);
2129        assert_eq!(nodes[0].astext(), "*x*");
2130    }
2131
2132    #[test]
2133    fn literal_restores_backslashes() {
2134        let (nodes, _) = pi("``a\\*b``");
2135        assert_eq!(nodes[0].kind, kinds::LITERAL);
2136        assert_eq!(nodes[0].astext(), "a\\*b");
2137    }
2138
2139    #[test]
2140    fn escapes_disappear_in_plain_text() {
2141        let (nodes, _) = pi("\\*not markup\\*");
2142        assert_eq!(nodes.len(), 1);
2143        assert_eq!(nodes[0].text.as_deref(), Some("*not markup*"));
2144    }
2145
2146    /// Sphinx-mode inline parse, for the roles that only exist there.
2147    fn sphinx_nodes(text: &str) -> Vec<Node> {
2148        sphinx_nodes_with(text, &crate::py::PySigConfig::default())
2149    }
2150
2151    /// The same, under a chosen py-domain configuration.
2152    fn sphinx_nodes_with(text: &str, py: &crate::py::PySigConfig) -> Vec<Node> {
2153        sphinx_nodes_in(text, RefContext::default(), py)
2154    }
2155
2156    /// The same, under a chosen ref_context.
2157    fn sphinx_nodes_in(text: &str, ctx: RefContext<'_>, py: &crate::py::PySigConfig) -> Vec<Node> {
2158        let mut reg = IdRegistry::new();
2159        parse_inline_ext(
2160            text,
2161            Span::ZERO,
2162            1,
2163            &mut reg,
2164            "<snippet>",
2165            true,
2166            "index",
2167            ctx,
2168            py,
2169        )
2170        .nodes
2171    }
2172
2173    fn attr<'a>(node: &'a Node, key: &'static str) -> Option<&'a AttrValue> {
2174        node.get(key)
2175    }
2176
2177    /// `:external:py:func:` must not be split by the generic
2178    /// `rsplit_once(':')` domain rule, which would read it as domain
2179    /// `external:py` and role `func`.
2180    #[test]
2181    fn an_external_role_is_intercepted_before_the_generic_domain_split() {
2182        let nodes = sphinx_nodes(":external:py:func:`os.path.join`");
2183        assert_eq!(nodes.len(), 1);
2184        let xref = &nodes[0];
2185        assert_eq!(xref.kind, "pending_xref");
2186        assert_eq!(attr(xref, "refdomain"), Some(&AttrValue::Str("py".into())));
2187        assert_eq!(attr(xref, "reftype"), Some(&AttrValue::Str("func".into())));
2188        assert_eq!(
2189            attr(xref, "reftarget"),
2190            Some(&AttrValue::Str("os.path.join".into()))
2191        );
2192        assert_eq!(
2193            attr(xref, "intersphinx"),
2194            Some(&AttrValue::Int(1)),
2195            "the node must be stamped so the resolver treats it as external"
2196        );
2197        assert_eq!(
2198            attr(xref, "inventory"),
2199            None,
2200            "`external:` names no inventory"
2201        );
2202    }
2203
2204    #[test]
2205    fn an_external_role_can_name_its_inventory_and_omit_the_domain() {
2206        let nodes = sphinx_nodes(":external+other:ref:`some-label`");
2207        let xref = &nodes[0];
2208        assert_eq!(xref.kind, "pending_xref");
2209        assert_eq!(attr(xref, "refdomain"), Some(&AttrValue::Str("std".into())));
2210        assert_eq!(attr(xref, "reftype"), Some(&AttrValue::Str("ref".into())));
2211        assert_eq!(
2212            attr(xref, "inventory"),
2213            Some(&AttrValue::Str("other".into())),
2214            "the inventory name keeps its case"
2215        );
2216    }
2217
2218    /// Sphinx's role emits no nodes at all for a name it cannot use; the
2219    /// warning is carried on a marker the resolver reports and drops, since
2220    /// only the resolver knows where the document was and what loaded.
2221    #[test]
2222    fn a_malformed_external_role_carries_its_warning_to_resolution() {
2223        for (text, expected) in [
2224            (
2225                ":external:a:b:c:`x`",
2226                "invalid external cross-reference suffix: 'a:b:c'",
2227            ),
2228            (
2229                ":external:py:function:`x`",
2230                "role for external cross-reference not found in domain 'py': 'function' \
2231                 (perhaps you meant one of: 'func', 'obj')",
2232            ),
2233        ] {
2234            let nodes = sphinx_nodes(text);
2235            assert_eq!(nodes.len(), 1, "{text}");
2236            assert_eq!(
2237                attr(&nodes[0], "intersphinx_role_error"),
2238                Some(&AttrValue::Str(expected.to_string())),
2239                "{text}"
2240            );
2241            assert!(
2242                nodes[0].children.is_empty(),
2243                "a failed role contributes no content: {text}"
2244            );
2245        }
2246    }
2247
2248    /// The interception is by name, so an ordinary role whose name merely
2249    /// starts with the same letters is untouched.
2250    #[test]
2251    fn a_role_named_like_external_is_still_an_ordinary_xref() {
2252        let nodes = sphinx_nodes(":externally:`x`");
2253        assert_eq!(
2254            attr(&nodes[0], "reftype"),
2255            Some(&AttrValue::Str("externally".into()))
2256        );
2257        assert_eq!(attr(&nodes[0], "intersphinx"), None);
2258    }
2259
2260    // --- XRefRole.update_title_and_target (`roles.py:87-98`) ---------------
2261    //
2262    // Expected shapes below are the probe-verified pformat of the research
2263    // spec §3.2 (probes P1-P7, full outputs in its appendix A.3) plus the
2264    // ordering probes Q1-Q11 recorded in the task-2 brief's "Probe
2265    // outcomes". `add_function_parentheses` is the ONLY config value these
2266    // read; everything else in the bundle stays at its default.
2267
2268    fn with_parens(add_function_parentheses: bool) -> crate::py::PySigConfig {
2269        crate::py::PySigConfig {
2270            add_function_parentheses,
2271            ..crate::py::PySigConfig::default()
2272        }
2273    }
2274
2275    /// `(literal text, reftarget)` of the single xref one snippet produces.
2276    fn xref_title_and_target(text: &str, add_function_parentheses: bool) -> (String, String) {
2277        let nodes = sphinx_nodes_with(text, &with_parens(add_function_parentheses));
2278        assert_eq!(nodes.len(), 1, "{text}");
2279        let xref = &nodes[0];
2280        let target = match attr(xref, "reftarget") {
2281            Some(AttrValue::Str(target)) => target.clone(),
2282            other => panic!("reftarget on {text}: {other:?}"),
2283        };
2284        (xref.astext(), target)
2285    }
2286
2287    /// P1/P6: an implicit `:py:func:`/`:py:meth:` title gains `()` under the
2288    /// default config. P7: `:py:class:` has no `fix_parens` and never does.
2289    #[test]
2290    fn an_implicit_fix_parens_title_gains_parens_by_default() {
2291        assert_eq!(
2292            xref_title_and_target(":py:func:`mymod.myfunc`", true),
2293            ("mymod.myfunc()".to_string(), "mymod.myfunc".to_string()),
2294            "P1"
2295        );
2296        assert_eq!(
2297            xref_title_and_target(":py:meth:`Obj.method`", true),
2298            ("Obj.method()".to_string(), "Obj.method".to_string()),
2299            "P6"
2300        );
2301        assert_eq!(
2302            xref_title_and_target(":py:class:`mymod.MyClass`", true),
2303            ("mymod.MyClass".to_string(), "mymod.MyClass".to_string()),
2304            "P7: no fix_parens on the class role"
2305        );
2306    }
2307
2308    /// P3: the parens the author wrote are not doubled — sphinx strips a
2309    /// trailing `()` before deciding whether to append one.
2310    #[test]
2311    fn an_already_parenthesized_implicit_title_is_not_doubled() {
2312        assert_eq!(
2313            xref_title_and_target(":py:func:`mymod.myfunc()`", true),
2314            ("mymod.myfunc()".to_string(), "mymod.myfunc".to_string()),
2315            "P3"
2316        );
2317    }
2318
2319    /// P2/P4: with `add_function_parentheses = False` an implicit title
2320    /// loses its parens — including parens the author wrote themselves.
2321    #[test]
2322    fn add_function_parentheses_false_removes_the_parens_the_author_wrote() {
2323        assert_eq!(
2324            xref_title_and_target(":py:func:`mymod.myfunc`", false),
2325            ("mymod.myfunc".to_string(), "mymod.myfunc".to_string()),
2326            "P2"
2327        );
2328        assert_eq!(
2329            xref_title_and_target(":py:func:`mymod.myfunc()`", false),
2330            ("mymod.myfunc".to_string(), "mymod.myfunc".to_string()),
2331            "P4"
2332        );
2333        assert_eq!(
2334            xref_title_and_target(":py:meth:`Obj.method()`", false),
2335            ("Obj.method".to_string(), "Obj.method".to_string()),
2336            "Q7"
2337        );
2338    }
2339
2340    /// P5/Q6: an explicit title is never touched, under either setting —
2341    /// the guard is `if not self.has_explicit_title` (`roles.py:88`).
2342    #[test]
2343    fn an_explicit_title_is_untouched_under_both_settings() {
2344        for add_parens in [true, false] {
2345            assert_eq!(
2346                xref_title_and_target(":py:func:`custom title <mymod.myfunc>`", add_parens),
2347                ("custom title".to_string(), "mymod.myfunc".to_string()),
2348                "P5, add_function_parentheses={add_parens}"
2349            );
2350            assert_eq!(
2351                xref_title_and_target(":py:func:`other() <mymod.myfunc>`", add_parens),
2352                ("other()".to_string(), "mymod.myfunc".to_string()),
2353                "Q6: an explicit title keeps parens it wrote itself"
2354            );
2355        }
2356    }
2357
2358    /// The target strip sits OUTSIDE the explicit-title guard
2359    /// (`roles.py:97`), so it happens under both settings and for explicit
2360    /// titles too (Q4/Q5) — and it removes exactly one pair (Q10/Q11).
2361    #[test]
2362    fn the_target_always_loses_one_trailing_paren_pair() {
2363        for add_parens in [true, false] {
2364            assert_eq!(
2365                xref_title_and_target(":py:func:`custom title <mymod.myfunc()>`", add_parens).1,
2366                "mymod.myfunc",
2367                "Q4/Q5, add_function_parentheses={add_parens}"
2368            );
2369        }
2370        assert_eq!(
2371            xref_title_and_target(":py:func:`mymod.myfunc()()`", true),
2372            ("mymod.myfunc()()".to_string(), "mymod.myfunc()".to_string()),
2373            "Q10: one pair off the target, and a title already ending in \
2374             `()` gains nothing"
2375        );
2376        assert_eq!(
2377            xref_title_and_target(":py:func:`mymod.myfunc()()`", false),
2378            ("mymod.myfunc()".to_string(), "mymod.myfunc()".to_string()),
2379            "Q11: one pair off each"
2380        );
2381    }
2382
2383    /// Q8/Q9: a py role without `fix_parens` keeps its parens in BOTH the
2384    /// title and the target — the strip is not a py-domain-wide rule.
2385    #[test]
2386    fn a_py_role_without_fix_parens_keeps_its_parens() {
2387        for (text, expected) in [
2388            (":py:obj:`mymod.thing()`", "mymod.thing()"),
2389            (":py:data:`mymod.thing()`", "mymod.thing()"),
2390        ] {
2391            assert_eq!(
2392                xref_title_and_target(text, true),
2393                (expected.to_string(), expected.to_string()),
2394                "{text}"
2395            );
2396        }
2397    }
2398
2399    /// Q2/Q3: `update_title_and_target` runs BEFORE
2400    /// `PyXRefRole.process_link`, so the target loses its `()` before the
2401    /// `~` shortening splits it — and the shortening runs on the title,
2402    /// which by then carries the decided parens.
2403    #[test]
2404    fn the_tilde_shortening_sees_the_title_fix_parens_already_produced() {
2405        assert_eq!(
2406            xref_title_and_target(":py:func:`~mymod.myfunc`", true),
2407            ("myfunc()".to_string(), "mymod.myfunc".to_string()),
2408            "Q1"
2409        );
2410        assert_eq!(
2411            xref_title_and_target(":py:func:`~mymod.myfunc()`", true),
2412            ("myfunc()".to_string(), "mymod.myfunc".to_string()),
2413            "Q2"
2414        );
2415        assert_eq!(
2416            xref_title_and_target(":py:func:`~mymod.myfunc()`", false),
2417            ("myfunc".to_string(), "mymod.myfunc".to_string()),
2418            "Q3"
2419        );
2420    }
2421
2422    // --- PyXRefRole.process_link (`domains/python/__init__.py:559-600`) ----
2423    //
2424    // Expected shapes pasted from this task's probe_t6 run ([PY §3.1]
2425    // probes roles_basic / roles_tilde_dot / role_deco and the
2426    // role_*_lstrip / role_dot_explicit_title cases).
2427
2428    /// [PY §3.1 roles_basic]: every py xref carries the ref_context attrs
2429    /// (None → "True" sentinel) and NO refspecific attribute at all.
2430    #[test]
2431    fn a_py_xref_outside_any_scope_stamps_the_none_sentinels() {
2432        let nodes = sphinx_nodes(":py:func:`target`");
2433        let xref = &nodes[0];
2434        assert_eq!(attr(xref, "py:class"), Some(&AttrValue::Str("True".into())));
2435        assert_eq!(
2436            attr(xref, "py:module"),
2437            Some(&AttrValue::Str("True".into()))
2438        );
2439        assert_eq!(attr(xref, "refspecific"), None, "absent, not 0");
2440    }
2441
2442    /// [PY §3.1 roles_tilde_dot]: a `.`-prefixed target strips the dot and
2443    /// stamps `refspecific="1"`; the implicit title lost the dot too.
2444    #[test]
2445    fn a_dot_prefixed_target_becomes_refspecific() {
2446        let nodes = sphinx_nodes(":py:meth:`.Cls.meth`");
2447        let xref = &nodes[0];
2448        assert_eq!(attr(xref, "refspecific"), Some(&AttrValue::Int(1)));
2449        assert_eq!(
2450            attr(xref, "reftarget"),
2451            Some(&AttrValue::Str("Cls.meth".into()))
2452        );
2453        assert_eq!(xref.astext(), "Cls.meth()");
2454
2455        // The dot branch sits OUTSIDE the implicit-title guard: an
2456        // explicit title keeps its text while the target still turns
2457        // refspecific (probe role_dot_explicit_title).
2458        let nodes = sphinx_nodes(":py:meth:`M <.Cls.meth>`");
2459        let xref = &nodes[0];
2460        assert_eq!(attr(xref, "refspecific"), Some(&AttrValue::Int(1)));
2461        assert_eq!(
2462            attr(xref, "reftarget"),
2463            Some(&AttrValue::Str("Cls.meth".into()))
2464        );
2465        assert_eq!(xref.astext(), "M");
2466    }
2467
2468    /// Probe role_title_lstrip_dots: `..pkg.f` — the implicit title loses
2469    /// EVERY leading dot, the target loses exactly ONE (the refspecific
2470    /// branch), leaving `reftarget=".pkg.f"`.
2471    #[test]
2472    fn title_lstrips_all_dots_while_the_target_loses_one() {
2473        let nodes = sphinx_nodes(":py:func:`..pkg.f`");
2474        let xref = &nodes[0];
2475        assert_eq!(xref.astext(), "pkg.f()");
2476        assert_eq!(
2477            attr(xref, "reftarget"),
2478            Some(&AttrValue::Str(".pkg.f".into()))
2479        );
2480        assert_eq!(attr(xref, "refspecific"), Some(&AttrValue::Int(1)));
2481    }
2482
2483    /// Probe role_target_lstrip_tilde: the target `lstrip`s every leading
2484    /// `~` while the title's shortening consumes one and keeps the last
2485    /// dotted component.
2486    #[test]
2487    fn the_target_lstrips_every_tilde() {
2488        let nodes = sphinx_nodes(":py:func:`~~pkg.f`");
2489        let xref = &nodes[0];
2490        assert_eq!(xref.astext(), "f()");
2491        assert_eq!(
2492            attr(xref, "reftarget"),
2493            Some(&AttrValue::Str("pkg.f".into()))
2494        );
2495        assert_eq!(attr(xref, "refspecific"), None);
2496    }
2497
2498    /// [PY §3.1 role_deco]: `_PyDecoXRefRole` prefixes `@` to the title —
2499    /// UNCONDITIONALLY, explicit titles included (probe role_deco_explicit)
2500    /// — while the target stays bare; the inner literal is `xref py py-deco`.
2501    #[test]
2502    fn the_deco_role_prefixes_an_at_sign() {
2503        let nodes = sphinx_nodes(":py:deco:`mydeco`");
2504        let xref = &nodes[0];
2505        assert_eq!(xref.astext(), "@mydeco");
2506        assert_eq!(
2507            attr(xref, "reftarget"),
2508            Some(&AttrValue::Str("mydeco".into()))
2509        );
2510        let inner = &xref.children[0];
2511        assert_eq!(inner.kind, kinds::LITERAL);
2512        assert_eq!(
2513            inner.attrs.classes,
2514            vec!["xref".to_string(), "py".to_string(), "py-deco".to_string()]
2515        );
2516
2517        let nodes = sphinx_nodes(":py:deco:`custom <target>`");
2518        let xref = &nodes[0];
2519        assert_eq!(xref.astext(), "@custom");
2520        assert_eq!(
2521            attr(xref, "reftarget"),
2522            Some(&AttrValue::Str("target".into()))
2523        );
2524    }
2525
2526    /// The ref_context stamping path: a parse carrying a module/class
2527    /// scope lands both on the xref instead of the sentinels.
2528    #[test]
2529    fn a_py_xref_inside_a_scope_stamps_the_ref_context() {
2530        let py = crate::py::PySigConfig::default();
2531        let nodes = sphinx_nodes_in(
2532            ":py:func:`target`",
2533            RefContext {
2534                py_module: Some("mymod"),
2535                py_class: Some("C"),
2536                ..RefContext::default()
2537            },
2538            &py,
2539        );
2540        let xref = &nodes[0];
2541        assert_eq!(attr(xref, "py:class"), Some(&AttrValue::Str("C".into())));
2542        assert_eq!(
2543            attr(xref, "py:module"),
2544            Some(&AttrValue::Str("mymod".into()))
2545        );
2546    }
2547
2548    // --- AnyXRefRole (`sphinx/roles.py`), probe-verified 2026-09-02 --------
2549
2550    /// `:any:` is registered domainless: `refdomain=""`, `reftype="any"`,
2551    /// `warn_dangling=True`, inner `literal classes="xref any"` — and with
2552    /// NO ref_context in scope, no context attribute at all (unlike
2553    /// `PyXRefRole`'s "True" sentinels).
2554    #[test]
2555    fn an_any_role_emits_a_domainless_pending_xref() {
2556        let nodes = sphinx_nodes(":any:`target`");
2557        let xref = &nodes[0];
2558        assert_eq!(xref.kind, "pending_xref");
2559        assert_eq!(attr(xref, "refdomain"), Some(&AttrValue::Str("".into())));
2560        assert_eq!(attr(xref, "reftype"), Some(&AttrValue::Str("any".into())));
2561        assert_eq!(attr(xref, "refwarn"), Some(&AttrValue::Int(1)));
2562        assert_eq!(
2563            attr(xref, "reftarget"),
2564            Some(&AttrValue::Str("target".into()))
2565        );
2566        for absent in [
2567            "py:class",
2568            "py:classes",
2569            "py:module",
2570            "py:modules",
2571            "std:program",
2572        ] {
2573            assert_eq!(attr(xref, absent), None, "{absent} must not be stamped");
2574        }
2575        let inner = &xref.children[0];
2576        assert_eq!(inner.kind, kinds::LITERAL);
2577        assert_eq!(
2578            inner.attrs.classes,
2579            vec!["xref".to_string(), "any".to_string()]
2580        );
2581    }
2582
2583    /// `AnyXRefRole.process_link` copies every EXISTING ref_context key:
2584    /// values for the strings, `""` for the (drained) list keys, and the
2585    /// `"True"` sentinel for a `py:class` key holding `None`.
2586    #[test]
2587    fn an_any_role_copies_the_ref_context_keys_that_exist() {
2588        let py = crate::py::PySigConfig::default();
2589        let nodes = sphinx_nodes_in(
2590            ":any:`target`",
2591            RefContext {
2592                program: Some("prog"),
2593                py_module: Some("m"),
2594                py_class: None,
2595                py_class_key: true,
2596                py_module_key: true,
2597                py_classes_key: true,
2598                py_modules_key: false,
2599            },
2600            &py,
2601        );
2602        let xref = &nodes[0];
2603        assert_eq!(attr(xref, "py:class"), Some(&AttrValue::Str("True".into())));
2604        assert_eq!(attr(xref, "py:classes"), Some(&AttrValue::Str("".into())));
2605        assert_eq!(attr(xref, "py:module"), Some(&AttrValue::Str("m".into())));
2606        assert_eq!(attr(xref, "py:modules"), None);
2607        assert_eq!(
2608            attr(xref, "std:program"),
2609            Some(&AttrValue::Str("prog".into()))
2610        );
2611    }
2612
2613    /// The key-exists-holding-`None` shape `after_content` leaves behind
2614    /// after a `:module:`-carrying directive with no enclosing module
2615    /// scope: sphinx copies the key and docutils renders the `None` value
2616    /// as `"True"`, exactly like `py:class`.
2617    ///
2618    // oracle: sphinx 9.1.0 full build of `.. py:function:: f()` +
2619    // `:module: mymod` + body + ``After :any:`x`.`` -> the pending_xref
2620    // carries py:module="True" (scratchpad A/p8.py).
2621    #[test]
2622    fn an_any_role_stamps_a_present_but_none_py_module() {
2623        let py = crate::py::PySigConfig::default();
2624        let nodes = sphinx_nodes_in(
2625            ":any:`target`",
2626            RefContext {
2627                program: None,
2628                py_module: None,
2629                py_class: None,
2630                py_class_key: true,
2631                py_module_key: true,
2632                py_classes_key: true,
2633                py_modules_key: true,
2634            },
2635            &py,
2636        );
2637        let xref = &nodes[0];
2638        assert_eq!(
2639            attr(xref, "py:module"),
2640            Some(&AttrValue::Str("True".into()))
2641        );
2642        assert_eq!(attr(xref, "py:modules"), Some(&AttrValue::Str("".into())));
2643    }
2644
2645    /// ... and an absent key still stamps nothing.
2646    #[test]
2647    fn an_any_role_omits_an_absent_py_module() {
2648        let py = crate::py::PySigConfig::default();
2649        let nodes = sphinx_nodes_in(
2650            ":any:`target`",
2651            RefContext {
2652                program: None,
2653                py_module: None,
2654                py_class: None,
2655                py_class_key: false,
2656                py_module_key: false,
2657                py_classes_key: false,
2658                py_modules_key: false,
2659            },
2660            &py,
2661        );
2662        assert_eq!(attr(&nodes[0], "py:module"), None);
2663    }
2664
2665    /// The base `XRefRole.process_link` munging is the ONLY one `:any:`
2666    /// takes: whitespace runs collapse, case is kept, parens are kept, and
2667    /// an explicit title stays untouched.
2668    #[test]
2669    fn an_any_target_collapses_whitespace_and_nothing_else() {
2670        let nodes = sphinx_nodes(":any:`Foo()`");
2671        assert_eq!(
2672            attr(&nodes[0], "reftarget"),
2673            Some(&AttrValue::Str("Foo()".into()))
2674        );
2675        let nodes = sphinx_nodes(":any:`two\nlines`");
2676        assert_eq!(
2677            attr(&nodes[0], "reftarget"),
2678            Some(&AttrValue::Str("two lines".into()))
2679        );
2680        let nodes = sphinx_nodes(":any:`Click <target>`");
2681        let xref = &nodes[0];
2682        assert_eq!(attr(xref, "refexplicit"), Some(&AttrValue::Int(1)));
2683        assert_eq!(
2684            attr(xref, "reftarget"),
2685            Some(&AttrValue::Str("target".into()))
2686        );
2687        assert_eq!(xref.astext(), "Click");
2688    }
2689}