Skip to main content

sphinx_ultra/py/
annotations.rs

1//! Annotation rendering: the `_parse_annotation` / `type_to_xref` port
2//! (`sphinx/domains/python/_annotations.py:30-251`, sphinx 9.1.0).
3//!
4//! `parse_annotation` turns one annotation string into the flat node list
5//! Sphinx splices into a `desc_sig_name` wrapper (parameter annotations), a
6//! `desc_returns` (return annotations) or a `desc_annotation` (`:type:`
7//! options): `pending_xref` for every name, `desc_sig_*` leaves for the
8//! punctuation/constants between them. The walk runs over the wave-4.5
9//! [`super::expr::PyExpr`] AST, parsed the way `_parse_annotation` parses
10//! it: `ast.parse(annotation, type_comments=True)` — **exec** mode, not
11//! eval ([`parse_py_expr_stmt`], `_annotations.py:232`). That is what
12//! makes a PEP 646 `*Ts` annotation a legal `Expr(Starred(…))` statement,
13//! what makes an empty annotation an empty node list rather than an
14//! empty-target xref, and what makes a leading indent an
15//! `IndentationError` whose xref keeps the unstripped text. Anything
16//! [`parse_py_expr_stmt`] rejects — and any node shape Sphinx's own
17//! `unparse` has no branch for (`ast.Add`, `ast.Not`, `ast.BoolOp`, sets,
18//! dicts, …, which raise `SyntaxError` there) — falls back to a single
19//! [`type_to_xref`] of the whole annotation text, exactly like Sphinx's
20//! `except SyntaxError` arm (`_annotations.py:250-251`).
21//!
22//! Ground truth, cited throughout as [PY §n] / [SIG §n]:
23//! - [PY] docs/superpowers/plans/2026-09-01-m2-wave4.5-research-spec-py-domain.md
24//!   (§2.3 semantics, §2.6 leaf classes, §1.6 probe outputs);
25//! - [SIG] docs/superpowers/plans/2026-09-01-m2-wave4.5-research-spec-signature-config.md
26//!   (§4.2 `pending_xref_condition` pair, §5.1 short literal chains).
27//!
28//! Every expected pformat in the test module is copied verbatim from probe
29//! runs against the pinned toolchain (sphinx 9.1.0 / docutils 0.22.4,
30//! harness3 conventions from tools/gen_sphinx_fixture.py; cases cited as
31//! `probe <app>/<case>`, logged in the task-4 report).
32//!
33//! Documented divergences (all conservative — we fall back to the same
34//! single-xref shape Sphinx uses for `SyntaxError`, never print something
35//! different):
36//! - constructs [`parse_py_expr_stmt`] rejects but `ast.parse` accepts and
37//!   Sphinx *would* render (complex literals, multi-statement strings such
38//!   as `int;` or `int\nstr`) fall back to one xref;
39//! - an `ast.Attribute` whose value's first fragment is not a text node
40//!   (`(1).x`, `'s'.x`) falls back instead of reproducing Python's
41//!   `str(Element)` garbage (`f'{unparse(node.value)[0]}.{node.attr}'`,
42//!   `_annotations.py:101`);
43//! - `Union[()]` / `Optional[()]` fall back where Sphinx raises an
44//!   uncaught `IndexError` (`_annotations.py:217`).
45
46use crate::doctree::{kinds, AttrValue, Node, Span};
47
48use super::expr::{self, parse_py_expr_stmt, PyConst, PyExpr, PyOp, PyUnaryOp};
49use super::PySigConfig;
50
51/// The slice of `env.ref_context` that `type_to_xref` copies onto every
52/// annotation xref (`_annotations.py:62-66`): the enclosing `py:module` /
53/// `py:class`, or Python `None` when unset — which docutils pformat renders
54/// as the `"True"` sentinel (same convention as the inline parser's role
55/// xrefs, src/rst/inline.rs) — plus the provenance the built nodes carry.
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct PyRefContext {
58    pub module: Option<String>,
59    pub class_: Option<String>,
60    /// The span every node this context builds is stamped with: the
61    /// enclosing `desc_signature`'s, i.e. the directive's own `(source,
62    /// line)`. Sphinx gives annotation xrefs no provenance of their own,
63    /// and a resolution warning on one then locates through docutils'
64    /// `get_source_line` ancestor walk — which stops at the signature,
65    /// because `ObjectDescription.run` calls `set_source_info(signode)`.
66    /// Stamping the signature's span directly yields the same location
67    /// without depending on the walk, and keeps the include case exact
68    /// (an annotation inside an included file names THAT file). A
69    /// `Span::ZERO` (line 0) means "unstamped", which the resolver treats
70    /// as "locate at the nearest stamped ancestor".
71    pub span: Span,
72}
73
74/// Port of `parse_reftarget` (`_annotations.py:30-55`), `suppress_prefix`
75/// fixed to `False`: returns `(reftype, reftarget, title, refspecific)`.
76///
77/// Leading `.` → strip + `refspecific`; leading `~` → strip + title = last
78/// dotted component; `typing.` prefix → stripped from the TITLE only; the
79/// reftype is `"obj"` for `None` and `typing.*` targets, else `"class"`
80/// [PY §2.3].
81pub fn parse_reftarget(target: &str) -> (String, String, String, bool) {
82    let (reftype, target, title, refspecific) = parse_reftarget_impl(target, false);
83    (reftype.to_string(), target, title, refspecific)
84}
85
86fn parse_reftarget_impl(
87    reftarget: &str,
88    suppress_prefix: bool,
89) -> (&'static str, String, String, bool) {
90    let mut refspecific = false;
91    let (target, title) = if let Some(stripped) = reftarget.strip_prefix('.') {
92        refspecific = true;
93        (stripped.to_string(), stripped.to_string())
94    } else if let Some(stripped) = reftarget.strip_prefix('~') {
95        (stripped.to_string(), last_component(stripped).to_string())
96    } else if suppress_prefix {
97        (reftarget.to_string(), last_component(reftarget).to_string())
98    } else if let Some(stripped) = reftarget.strip_prefix("typing.") {
99        (reftarget.to_string(), stripped.to_string())
100    } else {
101        (reftarget.to_string(), reftarget.to_string())
102    };
103
104    // typing module provides non-class types; obj references are good for
105    // them (`_annotations.py:49-53`). Tested on the STRIPPED target.
106    let reftype = if target == "None" || target.starts_with("typing.") {
107        "obj"
108    } else {
109        "class"
110    };
111
112    (reftype, target, title, refspecific)
113}
114
115/// Python `s.split('.')[-1]`.
116fn last_component(s: &str) -> &str {
117    s.rsplit('.').next().unwrap_or(s)
118}
119
120/// Port of `type_to_xref` (`_annotations.py:58-92`): one `pending_xref`
121/// carrying `refdomain`/`reftype`/`reftarget`/`refspecific` plus the
122/// `py:module`/`py:class` ref-context attrs, with a `Text(title)` child —
123/// or, under `python_use_unqualified_type_names`, the two
124/// `pending_xref_condition` children (`condition="resolved"` short name /
125/// `condition="*"` full title) [SIG §4.2].
126pub fn type_to_xref(target: &str, ctx: &PyRefContext, cfg: &PySigConfig) -> Node {
127    type_to_xref_impl(target, ctx, cfg, false)
128}
129
130fn type_to_xref_impl(
131    target: &str,
132    ctx: &PyRefContext,
133    cfg: &PySigConfig,
134    suppress_prefix: bool,
135) -> Node {
136    let (reftype, target, title, refspecific) = parse_reftarget_impl(target, suppress_prefix);
137
138    let mut node = Node::elem("pending_xref", ctx.span);
139    // Context attrs are Python None outside a py scope; pformat renders
140    // None as the "True" sentinel (same convention as src/rst/inline.rs).
141    node.set(
142        "py:class",
143        AttrValue::Str(ctx.class_.clone().unwrap_or_else(|| "True".to_string())),
144    );
145    node.set(
146        "py:module",
147        AttrValue::Str(ctx.module.clone().unwrap_or_else(|| "True".to_string())),
148    );
149    node.set("refdomain", AttrValue::Str("py".to_string()));
150    // A Python bool prints as 0/1 in pformat (`Element.starttag` casts
151    // bools to int), hence the Int here.
152    node.set("refspecific", AttrValue::Int(i64::from(refspecific)));
153    node.set("reftarget", AttrValue::Str(target));
154    node.set("reftype", AttrValue::Str(reftype.to_string()));
155
156    if cfg.python_use_unqualified_type_names {
157        // `shortname = title.split('.')[-1]` (`_annotations.py:76-80`).
158        let shortname = last_component(&title).to_string();
159        for (condition, text) in [("resolved", shortname), ("*", title)] {
160            let mut cond = Node::elem("pending_xref_condition", ctx.span);
161            cond.set("condition", AttrValue::Str(condition.to_string()));
162            cond.children.push(Node::text_node(text, ctx.span));
163            node.children.push(cond);
164        }
165    } else {
166        node.children.push(Node::text_node(title, ctx.span));
167    }
168    node
169}
170
171/// Port of `_parse_annotation` (`_annotations.py:95-251`): parse one
172/// annotation string and render it as a flat node list. On any parse or
173/// unparse failure the whole string becomes a single [`type_to_xref`]
174/// (`_annotations.py:250-251`) [PY §2.3].
175pub fn parse_annotation(text: &str, ctx: &PyRefContext, cfg: &PySigConfig) -> Vec<Node> {
176    let fallback = || vec![type_to_xref_impl(text, ctx, cfg, false)];
177
178    let parsed = match parse_py_expr_stmt(text) {
179        Ok(Some(parsed)) => parsed,
180        // `ast.parse('')` is `Module(body=[])`, and the `ast.Module` arm
181        // reduces an empty body to `[]` (`_annotations.py:150-151`) — no
182        // node at all, not an empty-target xref.
183        Ok(None) => return Vec::new(),
184        Err(_) => return fallback(),
185    };
186    let Ok(frags) = unparse_frags(&parsed, cfg.python_display_short_literal_types) else {
187        return fallback();
188    };
189
190    // Post-walk (`_annotations.py:233-249`): unwrap literal-protected
191    // text, convert every remaining non-blank text fragment into an xref,
192    // and let a `~` punctuation directly before a name suppress the title
193    // prefix.
194    let mut result: Vec<Node> = Vec::new();
195    for node in frags {
196        if node.kind == kinds::LITERAL {
197            // `result.append(node[0])` — the wrapper always holds exactly
198            // the one Text child it was built with.
199            result.extend(node.children);
200        } else if node.kind == kinds::TEXT {
201            let target = node.text.as_deref().unwrap_or("");
202            if target.trim().is_empty() {
203                result.push(node);
204                continue;
205            }
206            let suppress = result
207                .last()
208                .is_some_and(|last| last.kind == "desc_sig_punctuation" && last.astext() == "~");
209            if suppress {
210                result.pop();
211            }
212            result.push(type_to_xref_impl(target, ctx, cfg, suppress));
213        } else {
214            result.push(node);
215        }
216    }
217    result
218}
219
220// ---------------------------------------------------------------------------
221// The `unparse` walk (`_annotations.py:99-229`)
222// ---------------------------------------------------------------------------
223
224/// A node shape Sphinx's `unparse` raises `SyntaxError` for (or one of the
225/// module-doc divergences); the caller falls back to a whole-text xref.
226struct Unsupported;
227
228fn text_frag(text: impl Into<String>) -> Node {
229    Node::text_node(text, Span::ZERO)
230}
231
232/// `unparse(ast.BitOr())`: space, `|`, space.
233fn bitor_frags(out: &mut Vec<Node>) {
234    out.push(desc_sig_space());
235    out.push(desc_sig_punctuation("|"));
236    out.push(desc_sig_space());
237}
238
239/// `repr(value)` for a supported constant — [`expr::unparse`] of the bare
240/// constant, which is `ast.unparse`'s own repr path. A `u` prefix is
241/// cleared first: Sphinx goes through `repr(node.value)`, and repr does
242/// not know the string was `u`-prefixed (`_annotations.py:121`).
243fn const_repr(c: &PyConst) -> String {
244    let plain = match c {
245        PyConst::Str {
246            value,
247            quote,
248            u_prefix: true,
249        } => PyConst::Str {
250            value: value.clone(),
251            quote: *quote,
252            u_prefix: false,
253        },
254        other => other.clone(),
255    };
256    expr::unparse(&PyExpr::Constant(plain))
257}
258
259/// Comma+space-joined fragments of `elts`, the shared List/Tuple/Call join
260/// (`_annotations.py:142-147`).
261fn join_frags(
262    elts: &[PyExpr],
263    short_literals: bool,
264    out: &mut Vec<Node>,
265) -> Result<(), Unsupported> {
266    for (i, elt) in elts.iter().enumerate() {
267        if i > 0 {
268            out.push(desc_sig_punctuation(","));
269            out.push(desc_sig_space());
270        }
271        out.extend(unparse_frags(elt, short_literals)?);
272    }
273    Ok(())
274}
275
276fn unparse_frags(e: &PyExpr, short_literals: bool) -> Result<Vec<Node>, Unsupported> {
277    match e {
278        // `[Text(f'{unparse(node.value)[0]}.{node.attr}')]`
279        // (`_annotations.py:100-101`). Only a text first fragment is
280        // joinable; an element there is the module-doc divergence
281        // (Sphinx would interpolate `str(Element)` garbage — we fall
282        // back conservatively).
283        PyExpr::Attribute(value, attr) => {
284            let frags = unparse_frags(value, short_literals)?;
285            let first = frags.first().ok_or(Unsupported)?;
286            let base = first.text.as_deref().ok_or(Unsupported)?;
287            Ok(vec![text_frag(format!("{base}.{attr}"))])
288        }
289        // `unparse` has no `ast.BoolOp` branch, so `a or b` reaches the
290        // `raise SyntaxError` fallthrough (`_annotations.py:209-210`) and
291        // the whole annotation becomes one xref.
292        PyExpr::BoolOp { .. } => Err(Unsupported),
293        // Only `BitOr` has an unparse branch; any other operator raises
294        // SyntaxError in Sphinx (`_annotations.py:102-112`, `209-210`).
295        PyExpr::BinOp { left, op, right } => {
296            if *op != PyOp::BitOr {
297                return Err(Unsupported);
298            }
299            let mut out = unparse_frags(left, short_literals)?;
300            bitor_frags(&mut out);
301            out.extend(unparse_frags(right, short_literals)?);
302            Ok(out)
303        }
304        PyExpr::Constant(c) => Ok(vec![match c {
305            PyConst::Ellipsis => desc_sig_punctuation("..."),
306            PyConst::True => desc_sig_keyword("True"),
307            PyConst::False => desc_sig_keyword("False"),
308            PyConst::Int(digits) => desc_sig_literal_number(digits),
309            PyConst::Str { .. } => desc_sig_literal_string(&const_repr(c)),
310            // The `Text(repr(value))` fallthrough (`_annotations.py:
311            // 122-125`): None (xref'd later by the post-walk), floats,
312            // bytes.
313            PyConst::None => text_frag("None"),
314            PyConst::Float(_) | PyConst::Bytes(_) => text_frag(const_repr(c)),
315        }]),
316        // `desc_sig_operator('*')` + value (`_annotations.py:128-131`).
317        PyExpr::Starred(value) => {
318            let mut out = vec![desc_sig_operator("*")];
319            out.extend(unparse_frags(value, short_literals)?);
320            Ok(out)
321        }
322        PyExpr::List(elts) => {
323            let mut out = vec![desc_sig_punctuation("[")];
324            join_frags(elts, short_literals, &mut out)?;
325            out.push(desc_sig_punctuation("]"));
326            Ok(out)
327        }
328        PyExpr::Name(id) => Ok(vec![text_frag(id.clone())]),
329        PyExpr::Subscript { value, slice } => {
330            // `getattr(node.value, 'id', '')` — a bare Name only
331            // (`_annotations.py:155-158`); `typing.Optional` etc. take the
332            // plain subscript path.
333            if let PyExpr::Name(id) = value.as_ref() {
334                if id == "Optional" || id == "Union" || (short_literals && id == "Literal") {
335                    return unparse_pep_604(id, slice, short_literals);
336                }
337            }
338            let mut out = unparse_frags(value, short_literals)?;
339            out.push(desc_sig_punctuation("["));
340            out.extend(unparse_frags(slice, short_literals)?);
341            out.push(desc_sig_punctuation("]"));
342
343            // `result[0] in {'Literal', 'typing.Literal'}`: protect the
344            // member Text nodes from the xref post-walk by wrapping them
345            // in `nodes.literal` (`_annotations.py:164-168`).
346            let is_literal = matches!(
347                out[0].text.as_deref(),
348                Some("Literal") | Some("typing.Literal")
349            );
350            if is_literal {
351                for node in &mut out[1..] {
352                    if node.kind == kinds::TEXT {
353                        let mut wrapper = Node::elem(kinds::LITERAL, Span::ZERO);
354                        wrapper.children.push(std::mem::replace(
355                            node,
356                            Node::elem(kinds::LITERAL, Span::ZERO),
357                        ));
358                        *node = wrapper;
359                    }
360                }
361            }
362            Ok(out)
363        }
364        // Only `Invert` and `USub` have op branches (`_annotations.py:
365        // 132-135`, `170-171`); `UAdd`/`Not` raise SyntaxError.
366        PyExpr::UnaryOp { op, operand } => {
367            let punct = match op {
368                PyUnaryOp::Invert => desc_sig_punctuation("~"),
369                PyUnaryOp::USub => desc_sig_punctuation("-"),
370                PyUnaryOp::UAdd | PyUnaryOp::Not => return Err(Unsupported),
371            };
372            let mut out = vec![punct];
373            out.extend(unparse_frags(operand, short_literals)?);
374            Ok(out)
375        }
376        PyExpr::Tuple(elts) => {
377            if elts.is_empty() {
378                Ok(vec![desc_sig_punctuation("("), desc_sig_punctuation(")")])
379            } else {
380                let mut out = Vec::new();
381                join_frags(elts, short_literals, &mut out)?;
382                Ok(out)
383            }
384        }
385        // Annotated metadata calls (`_annotations.py:188-208`): positional
386        // args comma+space-joined, keywords as `name` `=` value with no
387        // spaces around the `=`.
388        PyExpr::Call { func, args, kwargs } => {
389            let mut out = unparse_frags(func, short_literals)?;
390            out.push(desc_sig_punctuation("("));
391            let mut inner = Vec::new();
392            join_frags(args, short_literals, &mut inner)?;
393            for (name, value) in kwargs {
394                if !inner.is_empty() {
395                    inner.push(desc_sig_punctuation(","));
396                    inner.push(desc_sig_space());
397                }
398                inner.push(desc_sig_name(name));
399                inner.push(desc_sig_operator("="));
400                inner.extend(unparse_frags(value, short_literals)?);
401            }
402            out.extend(inner);
403            out.push(desc_sig_punctuation(")"));
404            Ok(out)
405        }
406        // No unparse branch in Sphinx → SyntaxError → fallback.
407        PyExpr::Set(_) | PyExpr::Dict(_) => Err(Unsupported),
408    }
409}
410
411/// `_unparse_pep_604_annotation` (`_annotations.py:212-229`): flatten the
412/// subscript into a `|` chain; `Optional` appends `| None`. A short-literal
413/// `Literal` routes here too [SIG §5.1]. An empty tuple slice is the
414/// module-doc `IndexError` divergence — we fall back.
415fn unparse_pep_604(
416    value_id: &str,
417    slice: &PyExpr,
418    short_literals: bool,
419) -> Result<Vec<Node>, Unsupported> {
420    let mut out = Vec::new();
421    match slice {
422        PyExpr::Tuple(elts) => {
423            let (first, rest) = elts.split_first().ok_or(Unsupported)?;
424            out.extend(unparse_frags(first, short_literals)?);
425            for elt in rest {
426                bitor_frags(&mut out);
427                out.extend(unparse_frags(elt, short_literals)?);
428            }
429        }
430        // e.g. a Union[] inside an Optional[] (`_annotations.py:221-223`).
431        other => out.extend(unparse_frags(other, short_literals)?),
432    }
433    if value_id == "Optional" {
434        bitor_frags(&mut out);
435        out.push(text_frag("None"));
436    }
437    Ok(out)
438}
439
440// ---------------------------------------------------------------------------
441// desc_sig_* leaf builders ([PY §2.6] class map)
442// ---------------------------------------------------------------------------
443
444fn sig_leaf(kind: &'static str, class: &str, text: &str) -> Node {
445    let mut node = Node::elem(kind, Span::ZERO);
446    node.attrs.classes.push(class.to_string());
447    node.children.push(Node::text_node(text, Span::ZERO));
448    node
449}
450
451/// `desc_sig_space` (class `w`), always the single space Sphinx's default
452/// constructor inserts (`addnodes.py:341-346`).
453pub(crate) fn desc_sig_space() -> Node {
454    sig_leaf("desc_sig_space", "w", " ")
455}
456
457/// `desc_sig_name` (class `n`).
458pub(crate) fn desc_sig_name(text: &str) -> Node {
459    sig_leaf("desc_sig_name", "n", text)
460}
461
462/// `desc_sig_operator` (class `o`).
463pub(crate) fn desc_sig_operator(text: &str) -> Node {
464    sig_leaf("desc_sig_operator", "o", text)
465}
466
467/// `desc_sig_punctuation` (class `p`).
468pub(crate) fn desc_sig_punctuation(text: &str) -> Node {
469    sig_leaf("desc_sig_punctuation", "p", text)
470}
471
472/// `desc_sig_keyword` (class `k`).
473pub(crate) fn desc_sig_keyword(text: &str) -> Node {
474    sig_leaf("desc_sig_keyword", "k", text)
475}
476
477/// `desc_sig_literal_number` (class `m`).
478pub(crate) fn desc_sig_literal_number(text: &str) -> Node {
479    sig_leaf("desc_sig_literal_number", "m", text)
480}
481
482/// `desc_sig_literal_string` (class `s`).
483pub(crate) fn desc_sig_literal_string(text: &str) -> Node {
484    sig_leaf("desc_sig_literal_string", "s", text)
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    /// Wrap a fragment list in the probe's own parent so the assertion
492    /// text is byte-verbatim probe output (the brief's throwaway parent;
493    /// `desc_returns`/`desc_annotation` both carry `xml:space="preserve"`,
494    /// [PY §1.6]).
495    fn wrap(kind: &'static str, children: Vec<Node>) -> String {
496        let mut parent = Node::elem(kind, Span::ZERO);
497        parent.set("xml:space", AttrValue::Str("preserve".to_string()));
498        parent.children = children;
499        parent.pformat()
500    }
501
502    /// `parse_annotation` under a default context/config, rendered as the
503    /// probes' `.. py:function:: f(x) -> <annotation>` return fragment.
504    fn returns(annotation: &str) -> String {
505        returns_with(annotation, &PySigConfig::default())
506    }
507
508    fn returns_with(annotation: &str, cfg: &PySigConfig) -> String {
509        wrap(
510            "desc_returns",
511            parse_annotation(annotation, &PyRefContext::default(), cfg),
512        )
513    }
514
515    /// The probes' `.. py:data::` + `:type:` fragment: the caller-side
516    /// `: ` prefix ([PY §1.6] CASE attribute_typed) plus `parse_annotation`
517    /// output, so the assertion matches the probe's `desc_annotation`
518    /// verbatim.
519    fn type_option(annotation: &str) -> String {
520        let mut children = vec![desc_sig_punctuation(":"), desc_sig_space()];
521        children.extend(parse_annotation(
522            annotation,
523            &PyRefContext::default(),
524            &PySigConfig::default(),
525        ));
526        wrap("desc_annotation", children)
527    }
528
529    fn unqualified() -> PySigConfig {
530        PySigConfig {
531            python_use_unqualified_type_names: true,
532            ..PySigConfig::default()
533        }
534    }
535
536    fn short_literals() -> PySigConfig {
537        PySigConfig {
538            python_display_short_literal_types: true,
539            ..PySigConfig::default()
540        }
541    }
542
543    // -- parse_reftarget ----------------------------------------------------
544
545    /// [PY §2.3]: a plain (possibly dotted) name passes through untouched
546    /// and refers as a class.
547    #[test]
548    fn parse_reftarget_plain_name_is_class() {
549        assert_eq!(
550            parse_reftarget("pkg.Cls"),
551            (
552                "class".to_string(),
553                "pkg.Cls".to_string(),
554                "pkg.Cls".to_string(),
555                false
556            )
557        );
558        assert_eq!(
559            parse_reftarget("int"),
560            (
561                "class".to_string(),
562                "int".to_string(),
563                "int".to_string(),
564                false
565            )
566        );
567    }
568
569    /// [PY §2.3] + probe default/data_dot: a leading `.` is stripped from
570    /// target AND title, and sets the refspecific flag.
571    #[test]
572    fn parse_reftarget_leading_dot_sets_refspecific() {
573        assert_eq!(
574            parse_reftarget(".MyClass"),
575            (
576                "class".to_string(),
577                "MyClass".to_string(),
578                "MyClass".to_string(),
579                true
580            )
581        );
582    }
583
584    /// [PY §2.3] + probe default/tilde: a leading `~` is stripped from the
585    /// target and the title keeps only the last dotted component.
586    #[test]
587    fn parse_reftarget_tilde_title_is_last_component() {
588        assert_eq!(
589            parse_reftarget("~pkg.Cls"),
590            (
591                "class".to_string(),
592                "pkg.Cls".to_string(),
593                "Cls".to_string(),
594                false
595            )
596        );
597    }
598
599    /// [PY §2.3] + probe default/typing_prefix: `typing.` is stripped from
600    /// the TITLE only — the reftarget keeps the prefix — and the reftype is
601    /// "obj"; same for `None` (probe default/none_obj).
602    #[test]
603    fn parse_reftarget_none_and_typing_targets_are_obj() {
604        assert_eq!(
605            parse_reftarget("typing.Any"),
606            (
607                "obj".to_string(),
608                "typing.Any".to_string(),
609                "Any".to_string(),
610                false
611            )
612        );
613        assert_eq!(
614            parse_reftarget("None"),
615            (
616                "obj".to_string(),
617                "None".to_string(),
618                "None".to_string(),
619                false
620            )
621        );
622    }
623
624    // -- unions and the PEP-604 rewrite ------------------------------------
625
626    /// [PY §2.3] `X | Y` → xref, space, `|`, space, xref; the `None` arm is
627    /// an "obj" xref. Verbatim probe default/union.
628    #[test]
629    fn a_union_renders_xref_space_pipe_space_xref() {
630        assert_eq!(
631            returns("int | None"),
632            concat!(
633                "<desc_returns xml:space=\"preserve\">\n",
634                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
635                "        int\n",
636                "    <desc_sig_space classes=\"w\">\n",
637                "         \n",
638                "    <desc_sig_punctuation classes=\"p\">\n",
639                "        |\n",
640                "    <desc_sig_space classes=\"w\">\n",
641                "         \n",
642                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"None\" reftype=\"obj\">\n",
643                "        None\n",
644            )
645        );
646    }
647
648    /// [PY §2.3] `Optional[X]` rewrites to `X | None` — byte-identical to
649    /// the `int | None` shape. Verbatim probe default/optional.
650    #[test]
651    fn optional_rewrites_to_pep_604_with_obj_none() {
652        assert_eq!(returns("Optional[int]"), returns("int | None"));
653    }
654
655    /// [PY §2.3] `Union[X, Y]` rewrites to pipes. Verbatim probe
656    /// default/union_explicit.
657    #[test]
658    fn union_subscript_rewrites_to_pipes() {
659        assert_eq!(
660            returns("Union[int, str]"),
661            concat!(
662                "<desc_returns xml:space=\"preserve\">\n",
663                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
664                "        int\n",
665                "    <desc_sig_space classes=\"w\">\n",
666                "         \n",
667                "    <desc_sig_punctuation classes=\"p\">\n",
668                "        |\n",
669                "    <desc_sig_space classes=\"w\">\n",
670                "         \n",
671                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"str\" reftype=\"class\">\n",
672                "        str\n",
673            )
674        );
675    }
676
677    /// `Optional[Union[int, str]]` — the non-tuple slice recurses into the
678    /// inner `Union` rewrite ("e.g. a Union[] inside an Optional[]",
679    /// `_annotations.py:221-223`) and `Optional` still appends `| None`.
680    /// Verbatim probe default/union_of_optional.
681    #[test]
682    fn optional_of_union_flattens_and_appends_none() {
683        assert_eq!(
684            returns("Optional[Union[int, str]]"),
685            concat!(
686                "<desc_returns xml:space=\"preserve\">\n",
687                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
688                "        int\n",
689                "    <desc_sig_space classes=\"w\">\n",
690                "         \n",
691                "    <desc_sig_punctuation classes=\"p\">\n",
692                "        |\n",
693                "    <desc_sig_space classes=\"w\">\n",
694                "         \n",
695                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"str\" reftype=\"class\">\n",
696                "        str\n",
697                "    <desc_sig_space classes=\"w\">\n",
698                "         \n",
699                "    <desc_sig_punctuation classes=\"p\">\n",
700                "        |\n",
701                "    <desc_sig_space classes=\"w\">\n",
702                "         \n",
703                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"None\" reftype=\"obj\">\n",
704                "        None\n",
705            )
706        );
707    }
708
709    // -- subscripts ---------------------------------------------------------
710
711    /// [PY §2.3] a subscript renders `value [ slice ]` with punctuation
712    /// brackets. Verbatim probe default/subscript_simple.
713    #[test]
714    fn a_subscript_renders_value_bracket_slice_bracket() {
715        assert_eq!(
716            returns("list[str]"),
717            concat!(
718                "<desc_returns xml:space=\"preserve\">\n",
719                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"list\" reftype=\"class\">\n",
720                "        list\n",
721                "    <desc_sig_punctuation classes=\"p\">\n",
722                "        [\n",
723                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"str\" reftype=\"class\">\n",
724                "        str\n",
725                "    <desc_sig_punctuation classes=\"p\">\n",
726                "        ]\n",
727            )
728        );
729    }
730
731    /// [PY §2.3] a tuple slice joins members with `desc_sig_punctuation(",")`
732    /// + `desc_sig_space`. Verbatim probe default/subscript_tuple.
733    #[test]
734    fn a_tuple_slice_joins_with_comma_and_space() {
735        assert_eq!(
736            returns("dict[str, int]"),
737            concat!(
738                "<desc_returns xml:space=\"preserve\">\n",
739                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"dict\" reftype=\"class\">\n",
740                "        dict\n",
741                "    <desc_sig_punctuation classes=\"p\">\n",
742                "        [\n",
743                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"str\" reftype=\"class\">\n",
744                "        str\n",
745                "    <desc_sig_punctuation classes=\"p\">\n",
746                "        ,\n",
747                "    <desc_sig_space classes=\"w\">\n",
748                "         \n",
749                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
750                "        int\n",
751                "    <desc_sig_punctuation classes=\"p\">\n",
752                "        ]\n",
753            )
754        );
755    }
756
757    /// A nested subscript value recurses flat — no re-wrapping of the inner
758    /// value (the brief's ambiguity probe). Verbatim probe
759    /// default/subscript_nested.
760    #[test]
761    fn nested_subscripts_recurse_flat() {
762        assert_eq!(
763            returns("dict[str, list[int]]"),
764            concat!(
765                "<desc_returns xml:space=\"preserve\">\n",
766                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"dict\" reftype=\"class\">\n",
767                "        dict\n",
768                "    <desc_sig_punctuation classes=\"p\">\n",
769                "        [\n",
770                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"str\" reftype=\"class\">\n",
771                "        str\n",
772                "    <desc_sig_punctuation classes=\"p\">\n",
773                "        ,\n",
774                "    <desc_sig_space classes=\"w\">\n",
775                "         \n",
776                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"list\" reftype=\"class\">\n",
777                "        list\n",
778                "    <desc_sig_punctuation classes=\"p\">\n",
779                "        [\n",
780                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
781                "        int\n",
782                "    <desc_sig_punctuation classes=\"p\">\n",
783                "        ]\n",
784                "    <desc_sig_punctuation classes=\"p\">\n",
785                "        ]\n",
786            )
787        );
788    }
789
790    /// A list display inside a subscript renders punctuation brackets with
791    /// the same comma+space joins (`_annotations.py:136-149`). Verbatim
792    /// probe default/callable_list.
793    #[test]
794    fn a_list_display_renders_punctuation_brackets() {
795        assert_eq!(
796            returns("Callable[[int, str], bool]"),
797            concat!(
798                "<desc_returns xml:space=\"preserve\">\n",
799                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"Callable\" reftype=\"class\">\n",
800                "        Callable\n",
801                "    <desc_sig_punctuation classes=\"p\">\n",
802                "        [\n",
803                "    <desc_sig_punctuation classes=\"p\">\n",
804                "        [\n",
805                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
806                "        int\n",
807                "    <desc_sig_punctuation classes=\"p\">\n",
808                "        ,\n",
809                "    <desc_sig_space classes=\"w\">\n",
810                "         \n",
811                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"str\" reftype=\"class\">\n",
812                "        str\n",
813                "    <desc_sig_punctuation classes=\"p\">\n",
814                "        ]\n",
815                "    <desc_sig_punctuation classes=\"p\">\n",
816                "        ,\n",
817                "    <desc_sig_space classes=\"w\">\n",
818                "         \n",
819                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"bool\" reftype=\"class\">\n",
820                "        bool\n",
821                "    <desc_sig_punctuation classes=\"p\">\n",
822                "        ]\n",
823            )
824        );
825    }
826
827    /// An empty tuple slice renders as the `(` `)` punctuation pair
828    /// (`_annotations.py:181-185`). Verbatim probe default/tuple_empty.
829    #[test]
830    fn an_empty_tuple_slice_renders_paren_pair() {
831        assert_eq!(
832            returns("Tuple[()]"),
833            concat!(
834                "<desc_returns xml:space=\"preserve\">\n",
835                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"Tuple\" reftype=\"class\">\n",
836                "        Tuple\n",
837                "    <desc_sig_punctuation classes=\"p\">\n",
838                "        [\n",
839                "    <desc_sig_punctuation classes=\"p\">\n",
840                "        (\n",
841                "    <desc_sig_punctuation classes=\"p\">\n",
842                "        )\n",
843                "    <desc_sig_punctuation classes=\"p\">\n",
844                "        ]\n",
845            )
846        );
847    }
848
849    // -- Literal ------------------------------------------------------------
850
851    /// [PY §2.3] trap 15: `Literal` members stay `desc_sig_literal_string`
852    /// — never an xref — while `Literal` itself is one. Verbatim probe
853    /// default/literal_default.
854    #[test]
855    fn literal_members_stay_literal_strings_next_to_a_literal_xref() {
856        assert_eq!(
857            returns("Literal['a', 'b']"),
858            concat!(
859                "<desc_returns xml:space=\"preserve\">\n",
860                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"Literal\" reftype=\"class\">\n",
861                "        Literal\n",
862                "    <desc_sig_punctuation classes=\"p\">\n",
863                "        [\n",
864                "    <desc_sig_literal_string classes=\"s\">\n",
865                "        'a'\n",
866                "    <desc_sig_punctuation classes=\"p\">\n",
867                "        ,\n",
868                "    <desc_sig_space classes=\"w\">\n",
869                "         \n",
870                "    <desc_sig_literal_string classes=\"s\">\n",
871                "        'b'\n",
872                "    <desc_sig_punctuation classes=\"p\">\n",
873                "        ]\n",
874            )
875        );
876    }
877
878    /// A `None` member of `Literal[...]` is wrapped in `nodes.literal` and
879    /// unwrapped by the post-loop (`_annotations.py:164-168`, `235-236`),
880    /// so it lands as bare text — NOT an obj xref. Verbatim probe
881    /// default/literal_none_member.
882    #[test]
883    fn a_none_member_of_literal_stays_bare_text() {
884        assert_eq!(
885            returns("Literal[None]"),
886            concat!(
887                "<desc_returns xml:space=\"preserve\">\n",
888                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"Literal\" reftype=\"class\">\n",
889                "        Literal\n",
890                "    <desc_sig_punctuation classes=\"p\">\n",
891                "        [\n",
892                "    None\n",
893                "    <desc_sig_punctuation classes=\"p\">\n",
894                "        ]\n",
895            )
896        );
897    }
898
899    /// `typing.Literal[...]` — the literal-wrap check matches the joined
900    /// `typing.Literal` text too (`_annotations.py:165`) and the xref gets
901    /// the obj reftype with the `typing.`-stripped title. Verbatim probe
902    /// default/typing_literal.
903    #[test]
904    fn typing_literal_is_obj_with_stripped_title() {
905        assert_eq!(
906            returns("typing.Literal['a']"),
907            concat!(
908                "<desc_returns xml:space=\"preserve\">\n",
909                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"typing.Literal\" reftype=\"obj\">\n",
910                "        Literal\n",
911                "    <desc_sig_punctuation classes=\"p\">\n",
912                "        [\n",
913                "    <desc_sig_literal_string classes=\"s\">\n",
914                "        'a'\n",
915                "    <desc_sig_punctuation classes=\"p\">\n",
916                "        ]\n",
917            )
918        );
919    }
920
921    // -- tilde suppression --------------------------------------------------
922
923    /// [PY §2.3] a `~` punctuation directly before a name is popped and the
924    /// name's xref keeps only the last dotted component as its title
925    /// (`_annotations.py:237-244`). Verbatim probes default/tilde and
926    /// default/tilde_bare.
927    #[test]
928    fn a_tilde_before_a_name_suppresses_the_title_prefix() {
929        assert_eq!(
930            returns("~pkg.Cls"),
931            concat!(
932                "<desc_returns xml:space=\"preserve\">\n",
933                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"pkg.Cls\" reftype=\"class\">\n",
934                "        Cls\n",
935            )
936        );
937        assert_eq!(
938            returns("~Cls"),
939            concat!(
940                "<desc_returns xml:space=\"preserve\">\n",
941                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"Cls\" reftype=\"class\">\n",
942                "        Cls\n",
943            )
944        );
945    }
946
947    // -- names, typing.*, None ---------------------------------------------
948
949    /// [PY §2.3] `typing.Any` → obj reftype, full reftarget, stripped
950    /// title. Verbatim probe default/typing_prefix.
951    #[test]
952    fn typing_prefix_yields_obj_reftype() {
953        assert_eq!(
954            returns("typing.Any"),
955            concat!(
956                "<desc_returns xml:space=\"preserve\">\n",
957                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"typing.Any\" reftype=\"obj\">\n",
958                "        Any\n",
959            )
960        );
961    }
962
963    /// [PY §2.3] a bare `None` annotation is an obj xref. Verbatim probe
964    /// default/none_obj.
965    #[test]
966    fn bare_none_annotation_is_an_obj_xref() {
967        assert_eq!(
968            returns("None"),
969            concat!(
970                "<desc_returns xml:space=\"preserve\">\n",
971                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"None\" reftype=\"obj\">\n",
972                "        None\n",
973            )
974        );
975    }
976
977    // -- constants ----------------------------------------------------------
978
979    /// [PY §2.3] `...` → `desc_sig_punctuation("...")`. Verbatim probe
980    /// default/ellipsis.
981    #[test]
982    fn ellipsis_renders_punctuation() {
983        assert_eq!(
984            returns("..."),
985            concat!(
986                "<desc_returns xml:space=\"preserve\">\n",
987                "    <desc_sig_punctuation classes=\"p\">\n",
988                "        ...\n",
989            )
990        );
991    }
992
993    /// [PY §2.3] booleans → `desc_sig_keyword`. Verbatim probe
994    /// default/bool_true.
995    #[test]
996    fn true_renders_keyword() {
997        assert_eq!(
998            returns("True"),
999            concat!(
1000                "<desc_returns xml:space=\"preserve\">\n",
1001                "    <desc_sig_keyword classes=\"k\">\n",
1002                "        True\n",
1003            )
1004        );
1005    }
1006
1007    /// [PY §2.3] ints → `desc_sig_literal_number`. Verbatim probe
1008    /// default/int_const.
1009    #[test]
1010    fn an_int_renders_literal_number() {
1011        assert_eq!(
1012            returns("42"),
1013            concat!(
1014                "<desc_returns xml:space=\"preserve\">\n",
1015                "    <desc_sig_literal_number classes=\"m\">\n",
1016                "        42\n",
1017            )
1018        );
1019    }
1020
1021    /// `-1` is `USub` + constant: `desc_sig_punctuation("-")` then the
1022    /// number (`_annotations.py:134-135`). Verbatim probe default/neg_int.
1023    #[test]
1024    fn a_negative_int_renders_minus_punctuation_then_number() {
1025        assert_eq!(
1026            returns("-1"),
1027            concat!(
1028                "<desc_returns xml:space=\"preserve\">\n",
1029                "    <desc_sig_punctuation classes=\"p\">\n",
1030                "        -\n",
1031                "    <desc_sig_literal_number classes=\"m\">\n",
1032                "        1\n",
1033            )
1034        );
1035    }
1036
1037    /// [PY §2.3] trap 15: a string-literal annotation stays
1038    /// `desc_sig_literal_string` — never an xref. Verbatim probe
1039    /// default/str_const.
1040    #[test]
1041    fn a_string_annotation_stays_literal_string_never_an_xref() {
1042        assert_eq!(
1043            returns("'MyClass'"),
1044            concat!(
1045                "<desc_returns xml:space=\"preserve\">\n",
1046                "    <desc_sig_literal_string classes=\"s\">\n",
1047                "        'MyClass'\n",
1048            )
1049        );
1050    }
1051
1052    /// Sphinx renders string constants through `repr(node.value)`
1053    /// (`_annotations.py:121`), which drops a `u` prefix. Verbatim probe
1054    /// default/u_string.
1055    #[test]
1056    fn a_u_prefixed_string_drops_the_prefix_like_repr() {
1057        assert_eq!(
1058            returns("u'x'"),
1059            concat!(
1060                "<desc_returns xml:space=\"preserve\">\n",
1061                "    <desc_sig_literal_string classes=\"s\">\n",
1062                "        'x'\n",
1063            )
1064        );
1065    }
1066
1067    /// Float and bytes constants hit the `Text(repr(value))` fallthrough
1068    /// (`_annotations.py:122-125`) and so become XREFS of their repr text —
1069    /// not literal leaves. Verbatim probes default/float_const and
1070    /// default/bytes_const.
1071    #[test]
1072    fn float_and_bytes_constants_become_xrefs_via_repr_text() {
1073        assert_eq!(
1074            returns("1.5"),
1075            concat!(
1076                "<desc_returns xml:space=\"preserve\">\n",
1077                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"1.5\" reftype=\"class\">\n",
1078                "        1.5\n",
1079            )
1080        );
1081        assert_eq!(
1082            returns("b'x'"),
1083            concat!(
1084                "<desc_returns xml:space=\"preserve\">\n",
1085                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"b'x'\" reftype=\"class\">\n",
1086                "        b'x'\n",
1087            )
1088        );
1089    }
1090
1091    // -- calls (Annotated metadata) -----------------------------------------
1092
1093    /// A call renders `func ( args )` with comma+space joins; keywords are
1094    /// `desc_sig_name(arg)` + `desc_sig_operator("=")` + value, no spaces
1095    /// (`_annotations.py:188-208`). Verbatim probe default/annotated_call.
1096    #[test]
1097    fn a_call_renders_args_and_keywords() {
1098        assert_eq!(
1099            returns("Annotated[str, Validator(str, len=10)]"),
1100            concat!(
1101                "<desc_returns xml:space=\"preserve\">\n",
1102                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"Annotated\" reftype=\"class\">\n",
1103                "        Annotated\n",
1104                "    <desc_sig_punctuation classes=\"p\">\n",
1105                "        [\n",
1106                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"str\" reftype=\"class\">\n",
1107                "        str\n",
1108                "    <desc_sig_punctuation classes=\"p\">\n",
1109                "        ,\n",
1110                "    <desc_sig_space classes=\"w\">\n",
1111                "         \n",
1112                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"Validator\" reftype=\"class\">\n",
1113                "        Validator\n",
1114                "    <desc_sig_punctuation classes=\"p\">\n",
1115                "        (\n",
1116                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"str\" reftype=\"class\">\n",
1117                "        str\n",
1118                "    <desc_sig_punctuation classes=\"p\">\n",
1119                "        ,\n",
1120                "    <desc_sig_space classes=\"w\">\n",
1121                "         \n",
1122                "    <desc_sig_name classes=\"n\">\n",
1123                "        len\n",
1124                "    <desc_sig_operator classes=\"o\">\n",
1125                "        =\n",
1126                "    <desc_sig_literal_number classes=\"m\">\n",
1127                "        10\n",
1128                "    <desc_sig_punctuation classes=\"p\">\n",
1129                "        )\n",
1130                "    <desc_sig_punctuation classes=\"p\">\n",
1131                "        ]\n",
1132            )
1133        );
1134    }
1135
1136    // -- attributes ---------------------------------------------------------
1137
1138    /// `ast.Attribute` joins only its value's FIRST fragment with the attr
1139    /// (`_annotations.py:100-101`) — the rest of a subscript value is
1140    /// dropped. Verbatim probe default/dotted_attr_of_subscript.
1141    #[test]
1142    fn an_attribute_of_a_subscript_keeps_only_the_first_fragment() {
1143        assert_eq!(
1144            type_option("list[int].x"),
1145            concat!(
1146                "<desc_annotation xml:space=\"preserve\">\n",
1147                "    <desc_sig_punctuation classes=\"p\">\n",
1148                "        :\n",
1149                "    <desc_sig_space classes=\"w\">\n",
1150                "         \n",
1151                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"list.x\" reftype=\"class\">\n",
1152                "        list.x\n",
1153            )
1154        );
1155    }
1156
1157    // -- SyntaxError fallback ----------------------------------------------
1158
1159    /// [PY §2.3] a parse error turns the WHOLE annotation string into one
1160    /// `type_to_xref`. Verbatim probe default/data_syntax_error.
1161    #[test]
1162    fn a_syntax_error_falls_back_to_one_xref_of_the_whole_text() {
1163        assert_eq!(
1164            type_option("List[int"),
1165            concat!(
1166                "<desc_annotation xml:space=\"preserve\">\n",
1167                "    <desc_sig_punctuation classes=\"p\">\n",
1168                "        :\n",
1169                "    <desc_sig_space classes=\"w\">\n",
1170                "         \n",
1171                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"List[int\" reftype=\"class\">\n",
1172                "        List[int\n",
1173            )
1174        );
1175    }
1176
1177    /// [PY §2.3] `.MyClass` is a SyntaxError to `ast.parse`, so the
1178    /// fallback xref carries the leading-dot handling: stripped target +
1179    /// refspecific="1". Verbatim probe default/data_dot.
1180    #[test]
1181    fn a_leading_dot_falls_back_and_sets_refspecific() {
1182        assert_eq!(
1183            type_option(".MyClass"),
1184            concat!(
1185                "<desc_annotation xml:space=\"preserve\">\n",
1186                "    <desc_sig_punctuation classes=\"p\">\n",
1187                "        :\n",
1188                "    <desc_sig_space classes=\"w\">\n",
1189                "         \n",
1190                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"1\" reftarget=\"MyClass\" reftype=\"class\">\n",
1191                "        MyClass\n",
1192            )
1193        );
1194    }
1195
1196    /// Node shapes Sphinx's `unparse` has no branch for raise SyntaxError
1197    /// there (`_annotations.py:209-210`) and fall back the same way: a
1198    /// non-BitOr BinOp and a set display both become one whole-text xref.
1199    /// Verbatim probes default/binop_add and default/set_display.
1200    #[test]
1201    fn unsupported_node_shapes_fall_back_to_one_xref() {
1202        assert_eq!(
1203            returns("X + Y"),
1204            concat!(
1205                "<desc_returns xml:space=\"preserve\">\n",
1206                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"X + Y\" reftype=\"class\">\n",
1207                "        X + Y\n",
1208            )
1209        );
1210        assert_eq!(
1211            returns("{1, 2}"),
1212            concat!(
1213                "<desc_returns xml:space=\"preserve\">\n",
1214                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"{1, 2}\" reftype=\"class\">\n",
1215                "        {1, 2}\n",
1216            )
1217        );
1218    }
1219
1220    // -- ref context --------------------------------------------------------
1221
1222    /// The enclosing module/class land in the `py:module`/`py:class` attrs;
1223    /// unset halves keep the None sentinel. Verbatim probe
1224    /// default/ctx_module_class (pending_xref subtree).
1225    #[test]
1226    fn ref_context_lands_in_py_module_and_py_class_attrs() {
1227        let ctx = PyRefContext {
1228            module: Some("mymod".to_string()),
1229            class_: Some("C".to_string()),
1230            span: Span::ZERO,
1231        };
1232        assert_eq!(
1233            type_to_xref("int", &ctx, &PySigConfig::default()).pformat(),
1234            concat!(
1235                "<pending_xref py:class=\"C\" py:module=\"mymod\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
1236                "    int\n",
1237            )
1238        );
1239    }
1240
1241    // -- python_use_unqualified_type_names ----------------------------------
1242
1243    /// [SIG §4.2] under `python_use_unqualified_type_names` the xref content
1244    /// becomes TWO `pending_xref_condition` children — `condition="resolved"`
1245    /// short name / `condition="*"` full title — not a Text. Verbatim probe
1246    /// unqualified/unqualified_dotted.
1247    #[test]
1248    fn unqualified_config_emits_condition_pair() {
1249        assert_eq!(
1250            returns_with("pkg.Cls", &unqualified()),
1251            concat!(
1252                "<desc_returns xml:space=\"preserve\">\n",
1253                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"pkg.Cls\" reftype=\"class\">\n",
1254                "        <pending_xref_condition condition=\"resolved\">\n",
1255                "            Cls\n",
1256                "        <pending_xref_condition condition=\"*\">\n",
1257                "            pkg.Cls\n",
1258            )
1259        );
1260    }
1261
1262    /// The `condition="*"` child carries the TITLE — after `~` shortening
1263    /// both conditions show the short name. Verbatim probe
1264    /// unqualified/unqualified_tilde.
1265    #[test]
1266    fn unqualified_tilde_conditions_share_the_short_title() {
1267        assert_eq!(
1268            returns_with("~pkg.mod.Cls", &unqualified()),
1269            concat!(
1270                "<desc_returns xml:space=\"preserve\">\n",
1271                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"pkg.mod.Cls\" reftype=\"class\">\n",
1272                "        <pending_xref_condition condition=\"resolved\">\n",
1273                "            Cls\n",
1274                "        <pending_xref_condition condition=\"*\">\n",
1275                "            Cls\n",
1276            )
1277        );
1278    }
1279
1280    /// Same under a `typing.` title strip: both conditions show the
1281    /// stripped title, and the obj reftype is untouched. Verbatim probe
1282    /// unqualified/unqualified_typing.
1283    #[test]
1284    fn unqualified_typing_conditions_share_the_stripped_title() {
1285        assert_eq!(
1286            returns_with("typing.Any", &unqualified()),
1287            concat!(
1288                "<desc_returns xml:space=\"preserve\">\n",
1289                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"typing.Any\" reftype=\"obj\">\n",
1290                "        <pending_xref_condition condition=\"resolved\">\n",
1291                "            Any\n",
1292                "        <pending_xref_condition condition=\"*\">\n",
1293                "            Any\n",
1294            )
1295        );
1296    }
1297
1298    // -- python_display_short_literal_types ----------------------------------
1299
1300    /// [SIG §5.1] `Literal['a', 'b']` under the short-literal config becomes
1301    /// the `'a' | 'b'` chain — no `Literal` xref, no brackets. Verbatim
1302    /// probe short_literals/short_literal.
1303    #[test]
1304    fn short_literal_types_render_pipe_chain_without_literal_xref() {
1305        assert_eq!(
1306            returns_with("Literal['a', 'b']", &short_literals()),
1307            concat!(
1308                "<desc_returns xml:space=\"preserve\">\n",
1309                "    <desc_sig_literal_string classes=\"s\">\n",
1310                "        'a'\n",
1311                "    <desc_sig_space classes=\"w\">\n",
1312                "         \n",
1313                "    <desc_sig_punctuation classes=\"p\">\n",
1314                "        |\n",
1315                "    <desc_sig_space classes=\"w\">\n",
1316                "         \n",
1317                "    <desc_sig_literal_string classes=\"s\">\n",
1318                "        'b'\n",
1319            )
1320        );
1321    }
1322
1323    /// In the short-literal chain there is no literal-wrap step, so a
1324    /// `None` member DOES become an obj xref (unlike the bracketed form).
1325    /// Verbatim probe short_literals/short_literal_mixed.
1326    #[test]
1327    fn a_short_literal_none_member_becomes_an_obj_xref() {
1328        assert_eq!(
1329            returns_with("Literal[1, 'a', None]", &short_literals()),
1330            concat!(
1331                "<desc_returns xml:space=\"preserve\">\n",
1332                "    <desc_sig_literal_number classes=\"m\">\n",
1333                "        1\n",
1334                "    <desc_sig_space classes=\"w\">\n",
1335                "         \n",
1336                "    <desc_sig_punctuation classes=\"p\">\n",
1337                "        |\n",
1338                "    <desc_sig_space classes=\"w\">\n",
1339                "         \n",
1340                "    <desc_sig_literal_string classes=\"s\">\n",
1341                "        'a'\n",
1342                "    <desc_sig_space classes=\"w\">\n",
1343                "         \n",
1344                "    <desc_sig_punctuation classes=\"p\">\n",
1345                "        |\n",
1346                "    <desc_sig_space classes=\"w\">\n",
1347                "         \n",
1348                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"None\" reftype=\"obj\">\n",
1349                "        None\n",
1350            )
1351        );
1352    }
1353
1354    /// The short-literal route checks `getattr(node.value, 'id', '')` — a
1355    /// bare `Literal` Name only. `typing.Literal[...]` keeps the bracketed
1356    /// form even under the config. Verbatim probe
1357    /// short_literals/short_literal_typing.
1358    #[test]
1359    fn short_literal_config_ignores_typing_literal() {
1360        assert_eq!(
1361            returns_with("typing.Literal['a', 'b']", &short_literals()),
1362            concat!(
1363                "<desc_returns xml:space=\"preserve\">\n",
1364                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"typing.Literal\" reftype=\"obj\">\n",
1365                "        Literal\n",
1366                "    <desc_sig_punctuation classes=\"p\">\n",
1367                "        [\n",
1368                "    <desc_sig_literal_string classes=\"s\">\n",
1369                "        'a'\n",
1370                "    <desc_sig_punctuation classes=\"p\">\n",
1371                "        ,\n",
1372                "    <desc_sig_space classes=\"w\">\n",
1373                "         \n",
1374                "    <desc_sig_literal_string classes=\"s\">\n",
1375                "        'b'\n",
1376                "    <desc_sig_punctuation classes=\"p\">\n",
1377                "        ]\n",
1378            )
1379        );
1380    }
1381    // -- exec-mode parse (`_parse_annotation` uses `ast.parse`, not eval) ----
1382
1383    /// PEP 646: `*Ts` is a legal `Expr(Starred(Name))` statement in exec
1384    /// mode, so Sphinx renders `desc_sig_operator('*')` + the xref rather
1385    /// than falling back to one `reftarget="*Ts"` xref
1386    /// (`_annotations.py:128-131` + `:232`).
1387    ///
1388    // oracle: scratchpad A/p5.py, `_parse_annotation('*Ts', env)` under
1389    // sphinx 9.1.0 / docutils 0.22.4.
1390    #[test]
1391    fn pep_646_star_annotation_splits_the_operator() {
1392        assert_eq!(
1393            returns("*Ts"),
1394            concat!(
1395                "<desc_returns xml:space=\"preserve\">\n",
1396                "    <desc_sig_operator classes=\"o\">\n",
1397                "        *\n",
1398                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"Ts\" reftype=\"class\">\n",
1399                "        Ts\n",
1400            )
1401        );
1402    }
1403
1404    /// The bracketed unpack — the spelling autodoc emits — walks into the
1405    /// subscript as usual after the `*`.
1406    ///
1407    // oracle: scratchpad A/p5.py, `_parse_annotation('*tuple[int, ...]')`.
1408    #[test]
1409    fn pep_646_star_annotation_over_a_subscript() {
1410        assert_eq!(
1411            returns("*tuple[int, ...]"),
1412            concat!(
1413                "<desc_returns xml:space=\"preserve\">\n",
1414                "    <desc_sig_operator classes=\"o\">\n",
1415                "        *\n",
1416                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"tuple\" reftype=\"class\">\n",
1417                "        tuple\n",
1418                "    <desc_sig_punctuation classes=\"p\">\n",
1419                "        [\n",
1420                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
1421                "        int\n",
1422                "    <desc_sig_punctuation classes=\"p\">\n",
1423                "        ,\n",
1424                "    <desc_sig_space classes=\"w\">\n",
1425                "         \n",
1426                "    <desc_sig_punctuation classes=\"p\">\n",
1427                "        ...\n",
1428                "    <desc_sig_punctuation classes=\"p\">\n",
1429                "        ]\n",
1430            )
1431        );
1432    }
1433
1434    /// A bare starred tuple is an `Expr(Tuple([Starred, ...]))` statement.
1435    ///
1436    // oracle: scratchpad A/p5.py, `_parse_annotation('*a, b')`.
1437    #[test]
1438    fn exec_mode_renders_a_bare_starred_tuple() {
1439        assert_eq!(
1440            returns("*a, b"),
1441            concat!(
1442                "<desc_returns xml:space=\"preserve\">\n",
1443                "    <desc_sig_operator classes=\"o\">\n",
1444                "        *\n",
1445                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"a\" reftype=\"class\">\n",
1446                "        a\n",
1447                "    <desc_sig_punctuation classes=\"p\">\n",
1448                "        ,\n",
1449                "    <desc_sig_space classes=\"w\">\n",
1450                "         \n",
1451                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"b\" reftype=\"class\">\n",
1452                "        b\n",
1453            )
1454        );
1455    }
1456
1457    /// A leading indent is an `IndentationError` — a `SyntaxError`
1458    /// subclass — so the `except SyntaxError` arm xrefs the text
1459    /// UNSTRIPPED, spaces and all.
1460    ///
1461    // oracle: scratchpad A/p5.py, `_parse_annotation('  int')` → one
1462    // pending_xref with reftarget="  int".
1463    #[test]
1464    fn leading_indent_keeps_the_unstripped_text() {
1465        assert_eq!(
1466            returns("  int"),
1467            concat!(
1468                "<desc_returns xml:space=\"preserve\">\n",
1469                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"  int\" reftype=\"class\">\n",
1470                "          int\n",
1471            )
1472        );
1473    }
1474
1475    /// `ast.parse('')` is `Module(body=[])` and the `ast.Module` arm
1476    /// reduces an empty body to `[]` — no node at all, so no empty-target
1477    /// xref ever reaches the resolver.
1478    ///
1479    // oracle: scratchpad A/p5.py — `len(_parse_annotation(''))` and
1480    // `len(_parse_annotation(' '))` are both 0.
1481    #[test]
1482    fn an_empty_annotation_renders_no_nodes() {
1483        for text in ["", " ", "  ", "\n", "\t"] {
1484            assert!(
1485                parse_annotation(text, &PyRefContext::default(), &PySigConfig::default())
1486                    .is_empty(),
1487                "{text:?} must render no nodes"
1488            );
1489        }
1490    }
1491
1492    /// `unparse` has no `ast.BoolOp` branch, so `a or b` reaches the
1493    /// `raise SyntaxError` fallthrough and becomes one whole-text xref
1494    /// (even though [`super::expr::parse_py_expr_stmt`] now parses it).
1495    ///
1496    // oracle: `_parse_annotation('a or b', env)` → a single pending_xref
1497    // with reftarget="a or b" (sphinx 9.1.0, scratchpad A/p6.py).
1498    #[test]
1499    fn a_boolop_annotation_falls_back_to_one_xref() {
1500        assert_eq!(
1501            returns("a or b"),
1502            concat!(
1503                "<desc_returns xml:space=\"preserve\">\n",
1504                "    <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"a or b\" reftype=\"class\">\n",
1505                "        a or b\n",
1506            )
1507        );
1508    }
1509}