Skip to main content

sphinx_ultra/py/
arglist.rs

1//! Arglist and PEP-695 type-parameter-list parsing for the py domain: the
2//! `_parse_arglist` / `_pseudo_parse_arglist` / `_parse_type_list` port
3//! (`sphinx/domains/python/_annotations.py:254-619`, sphinx 9.1.0) plus the
4//! `signature_from_str` grammar (`sphinx/util/inspect.py:967-1038`) and the
5//! `multi_line_parameter_list` measurement (`_object.py:291-312`).
6//!
7//! Ground truth, cited throughout as [PY §n] / [SIG §n]:
8//! - [PY] docs/superpowers/plans/2026-09-01-m2-wave4.5-research-spec-py-domain.md
9//!   (§2.1 arglist node shapes, §2.2 pseudo fallback, §2.4 type parameter
10//!   lists, §1.6 probe outputs);
11//! - [SIG] docs/superpowers/plans/2026-09-01-m2-wave4.5-research-spec-signature-config.md
12//!   (§1.3 measurement, §1.4 attr placement, appendix A.1 matrix).
13//!
14//! Expected pformats in the test module are verbatim probe output against the
15//! pinned toolchain (sphinx 9.1.0 / docutils 0.22.4, harness3 conventions);
16//! cases cited as `probe <case>` come from the spec's §1.6/§A.1 blocks or the
17//! task-5 probe run (`probe task5/<case>`, logged in the task-5 report).
18//!
19//! ## The two render pipelines (trap)
20//!
21//! Defaults and annotations do NOT round through CPython's `ast.unparse`
22//! (which task 3's [`expr::unparse`] mirrors): `signature_from_str` routes
23//! them through `sphinx.pycode.ast.unparse` — "a greatly cut-down version of
24//! `ast._Unparser`" — whose rules differ observably (probe
25//! task5/default_normalized: `f(x=0xFF, y=[1,2])` renders `0xFF` and
26//! `[1, 2]`):
27//! - int/float constants keep their SOURCE text via `ast.get_source_segment`
28//!   (`0xFF`, `1_000`, `1e5`), falling back to `repr` only when the segment
29//!   is unavailable;
30//! - no precedence parentheses at all (`(a+b)*c` → `a + b * c`);
31//! - `**` is rendered without surrounding spaces (`a**b`);
32//! - unary operators never parenthesize (`-(a+b)` → `-a + b`);
33//! - a `u''` string prefix is dropped (`repr` of the value).
34//!
35//! [`pycode_unparse`] implements those rules over the task-3 [`PyExpr`] AST;
36//! annotation *nodes* are still rendered by task 4's
37//! [`parse_annotation`], which re-parses the pycode-normalized string exactly
38//! as `_parse_annotation(param.annotation)` does (`_annotations.py:495`).
39//!
40//! ## Documented divergences (all conservative)
41//!
42//! - Expressions outside the task-3 subset (lambdas, comparisons, ternaries,
43//!   slices, f-strings, starred `**` dict unpacks, complex literals) make
44//!   [`signature_from_str`] return [`SigParseError::Syntax`], routing task
45//!   6 into the silent pseudo fallback. Sphinx renders most of these (or
46//!   warns via `NotImplementedError`/`ValueError` for `a == b`, `a if b
47//!   else c`, f-strings and `{**a}`), so the fallback shape — and a missing
48//!   warning — can diverge for such signatures.
49//! - A top-level `lambda` keyword in the arglist is rejected before comma
50//!   splitting (its parameter commas are not bracket-protected, so no naive
51//!   split is faithful); sphinx parses it and prints `lambda a, b: ...`.
52//! - Numeric source recovery maps number tokens to constants in render
53//!   order and verifies each token re-parses to the same value; when the
54//!   mapping is ambiguous the whole fragment falls back to `repr` form
55//!   (normalized digits) where sphinx would keep source text.
56//!   `visit_Constant` recovers its text by AST POSITION
57//!   (`ast.get_source_segment`), so render order only has to agree with
58//!   source order — which it now does everywhere except one shape:
59//!   `ast.Call` splits `args` from `keywords`, losing their interleaving,
60//!   so a positional (necessarily a `*` unpack) written AFTER a keyword
61//!   argument is rendered before it and consumes the earlier token. The
62//!   text order still matches sphinx (`visit_Call` reorders identically),
63//!   but a non-decimal spelling in such a fragment lands in the `repr`
64//!   fallback: `f(k=0x1, *a(0x2))` prints `f(*a(2), k=1)` where sphinx
65//!   keeps `f(*a(0x2), k=0x1)`. Fixing it needs source spans on
66//!   [`PyConst`], not a render-order tweak.
67//! - Type-parameter tokenization errors surface as
68//!   [`SigParseError::Syntax`] with an approximate message where sphinx's
69//!   warning embeds the exact `tokenize.TokenError` text.
70//! - [`pseudo_parse_arglist`] takes `(ctx, cfg)` in addition to the brief's
71//!   `(arglist, multi_line)`: the pseudo path renders annotation xrefs
72//!   through `_parse_annotation` and stamps `multi_line_trailing_comma`
73//!   from `python_trailing_comma_in_multi_line_signatures`
74//!   (`_object.py:363-380`), neither of which is derivable without them.
75//! - [`multi_line_flags`] measures Python `len(sig)` — Unicode scalar
76//!   count — while [`PySigMatch`] spans are byte offsets; widths are
77//!   computed as the char count of the spanned slice, so non-ASCII
78//!   signatures measure exactly as CPython does.
79
80use std::collections::HashSet;
81use std::fmt;
82
83use crate::doctree::{kinds, AttrValue, Node, Span};
84
85use super::annotations::{
86    desc_sig_operator, desc_sig_punctuation, desc_sig_space, parse_annotation, PyRefContext,
87};
88use super::expr::{
89    self, parse_py_expr, parse_py_star_annotation, PyBoolOp, PyConst, PyExpr, PyOp, PyUnaryOp,
90};
91use super::PySigConfig;
92
93/// `inspect._ParameterKind`, as classified by `signature_from_ast`
94/// (`sphinx/util/inspect.py:976-1025`).
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum ParamKind {
97    PositionalOnly,
98    PositionalOrKeyword,
99    VarPositional,
100    KeywordOnly,
101    VarKeyword,
102}
103
104/// One parameter of a parsed arglist. `annotation` and `default` are
105/// `sphinx.pycode.ast.unparse`-normalized source strings, exactly what
106/// `inspect.Parameter.annotation` / `DefaultValue` carry in sphinx
107/// (`util/inspect.py:1027-1038`).
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct PyParam {
110    pub name: String,
111    pub kind: ParamKind,
112    pub annotation: Option<String>,
113    pub default: Option<String>,
114}
115
116/// Why an arglist / type-parameter list failed to parse. The two variants
117/// carry sphinx's two observable failure channels ([PY §1.3 step 5],
118/// `_object.py:355-381`):
119///
120/// - [`SigParseError::Syntax`] — `ast.parse` `SyntaxError`: task 6 logs at
121///   debug level (invisible) and falls back to [`pseudo_parse_arglist`];
122///   for a tp-list, any failure is a warning (`_object.py:342-345`).
123/// - [`SigParseError::Duplicate`] — `inspect.Signature`'s
124///   `ValueError('duplicate parameter name: ...')`: task 6 emits WARNING
125///   `could not parse arglist (%r): %s` and falls back to pseudo. Note
126///   `ast.parse` accepts duplicate `def` parameters (the check lives in the
127///   symtable pass), so this genuinely reaches `Signature.__init__`.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum SigParseError {
130    /// A `SyntaxError`-equivalent; the message approximates CPython's and
131    /// is only ever surfaced on the (warning) tp-list path.
132    Syntax(String),
133    /// Duplicate parameter name (the payload is the offending name).
134    Duplicate(String),
135}
136
137impl fmt::Display for SigParseError {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        match self {
140            SigParseError::Syntax(msg) => f.write_str(msg),
141            // CPython `inspect.Signature`: 'duplicate parameter name: {name!r}'.
142            SigParseError::Duplicate(name) => write!(f, "duplicate parameter name: '{name}'"),
143        }
144    }
145}
146
147impl std::error::Error for SigParseError {}
148
149/// The `py_sig_re` match carrier (`_object.py:41-50`): groups (prefix,
150/// name, tp_list, arglist, retann) plus the two inner-text spans the
151/// multi-line measurement subtracts (`_object.py:300-311`). Task 6's
152/// `handle_py_signature` constructs this from its matcher; spans are BYTE
153/// offsets of the group's inner text within the stripped signature,
154/// `(0, 0)` when the group did not participate (Python's `(-1, -1)` span
155/// normalizes to width 0 either way).
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct PySigMatch {
158    /// Group 1: dotted class prefix including the trailing `.`.
159    pub prefix: Option<String>,
160    /// Group 2: the object name.
161    pub name: String,
162    /// Group 3: inner text of the `[type params]` brackets.
163    pub tp_list: Option<String>,
164    /// Group 4: inner text of the `(...)` parens.
165    pub arglist: Option<String>,
166    /// Group 5: return annotation after `->`.
167    pub retann: Option<String>,
168    /// Byte span of group 3's inner text within the stripped sig.
169    pub tp_span: (usize, usize),
170    /// Byte span of group 4's inner text within the stripped sig.
171    pub arg_span: (usize, usize),
172}
173
174/// The two `single-line-*` directive flags (`_object.py:180-181`); each
175/// suppresses only its own list ([SIG §1.5], probes D1/D2).
176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
177pub struct SingleLineOpts {
178    /// `:single-line-parameter-list:` present.
179    pub parameter_list: bool,
180    /// `:single-line-type-parameter-list:` present.
181    pub type_parameter_list: bool,
182}
183
184/// The `(multi_line_parameter_list, multi_line_type_parameter_list)` pair
185/// (`_object.py:300-311`, [SIG §1.3]): each flag is
186/// `!single-line-option && (len(sig) - width(other group)) > max_len > 0`,
187/// strictly greater, with `max_len` resolved by [`PySigConfig::max_len`].
188/// Lengths are Python `len()` — Unicode scalar counts — computed from the
189/// byte spans' slices; an out-of-range span counts as width 0.
190pub fn multi_line_flags(
191    sig: &str,
192    m: &PySigMatch,
193    opts: SingleLineOpts,
194    cfg: &PySigConfig,
195) -> (bool, bool) {
196    let max_len = cfg.max_len();
197    let sig_len = sig.chars().count() as i64;
198    let width = |span: (usize, usize)| -> i64 {
199        sig.get(span.0..span.1)
200            .map(|s| s.chars().count() as i64)
201            .unwrap_or(0)
202    };
203    let parameter_list =
204        !opts.parameter_list && (sig_len - width(m.tp_span)) > max_len && max_len > 0;
205    let type_parameter_list =
206        !opts.type_parameter_list && (sig_len - width(m.arg_span)) > max_len && max_len > 0;
207    (parameter_list, type_parameter_list)
208}
209
210/// Parse `arglist` with the grammar of `def func(<arglist>): pass`
211/// (`sphinx/util/inspect.py:967-974`): positional-only via `/`,
212/// keyword-only after a bare `*` or `*args`, `**kwargs` last. Defaults and
213/// annotations are validated by task 3's [`parse_py_expr`] and rendered by
214/// [`pycode_unparse`]; any rejected fragment or grammar violation is
215/// [`SigParseError::Syntax`], and duplicate parameter names — which
216/// `ast.parse` accepts — are [`SigParseError::Duplicate`] exactly as
217/// `inspect.Signature.__init__` raises `ValueError` after a clean parse.
218pub fn signature_from_str(arglist: &str) -> Result<Vec<PyParam>, SigParseError> {
219    if arglist.trim().is_empty() {
220        return Ok(Vec::new());
221    }
222    let toks = lex(arglist).ok_or_else(invalid_syntax)?;
223    let items = split_top_level(&toks)?;
224
225    let mut params: Vec<PyParam> = Vec::new();
226    let mut seen_slash = false;
227    let mut seen_star = false;
228    let mut bare_star = false;
229    let mut seen_kwargs = false;
230    let mut kwonly_count = 0usize;
231    let mut pos_default_seen = false;
232
233    let last = items.len() - 1;
234    for (i, item) in items.iter().enumerate() {
235        let shape = classify_item(item);
236        if matches!(shape, ItemShape::Empty) {
237            // A trailing comma leaves one empty final item; any other empty
238            // item is CPython's plain "invalid syntax".
239            if i == last && i > 0 {
240                continue;
241            }
242            return Err(invalid_syntax());
243        }
244        if seen_kwargs {
245            return Err(SigParseError::Syntax(
246                "arguments cannot follow var-keyword argument".to_string(),
247            ));
248        }
249        match shape {
250            ItemShape::Empty => unreachable!("handled above"),
251            ItemShape::Slash => {
252                if seen_star {
253                    return Err(SigParseError::Syntax("/ must be ahead of *".to_string()));
254                }
255                if seen_slash {
256                    return Err(SigParseError::Syntax("/ may appear only once".to_string()));
257                }
258                if params.is_empty() {
259                    return Err(SigParseError::Syntax(
260                        "at least one argument must precede /".to_string(),
261                    ));
262                }
263                seen_slash = true;
264                for p in &mut params {
265                    p.kind = ParamKind::PositionalOnly;
266                }
267            }
268            ItemShape::BareStar => {
269                if seen_star {
270                    return Err(SigParseError::Syntax(
271                        "* argument may appear only once".to_string(),
272                    ));
273                }
274                seen_star = true;
275                bare_star = true;
276            }
277            ItemShape::VarArgs(rest) => {
278                if seen_star {
279                    return Err(SigParseError::Syntax(
280                        "* argument may appear only once".to_string(),
281                    ));
282                }
283                seen_star = true;
284                let raw = parse_param_tokens(rest, arglist, true)?;
285                if raw.default.is_some() {
286                    return Err(SigParseError::Syntax(
287                        "var-positional argument cannot have default value".to_string(),
288                    ));
289                }
290                params.push(PyParam {
291                    name: raw.name,
292                    kind: ParamKind::VarPositional,
293                    annotation: raw.annotation,
294                    default: None,
295                });
296            }
297            ItemShape::KwArgs(rest) => {
298                let raw = parse_param_tokens(rest, arglist, false)?;
299                if raw.default.is_some() {
300                    return Err(SigParseError::Syntax(
301                        "var-keyword argument cannot have default value".to_string(),
302                    ));
303                }
304                seen_kwargs = true;
305                params.push(PyParam {
306                    name: raw.name,
307                    kind: ParamKind::VarKeyword,
308                    annotation: raw.annotation,
309                    default: None,
310                });
311            }
312            ItemShape::Plain(toks) => {
313                let raw = parse_param_tokens(toks, arglist, false)?;
314                let kind = if seen_star {
315                    kwonly_count += 1;
316                    ParamKind::KeywordOnly
317                } else {
318                    // The non-default-after-default rule spans `/` but not
319                    // `*`: `f(a=1, /, b)` is a SyntaxError while
320                    // `f(a=1, *, b)` is fine (probed, task-5 report).
321                    if raw.default.is_none() && pos_default_seen {
322                        return Err(SigParseError::Syntax(
323                            "parameter without a default follows parameter with a default"
324                                .to_string(),
325                        ));
326                    }
327                    if raw.default.is_some() {
328                        pos_default_seen = true;
329                    }
330                    ParamKind::PositionalOrKeyword
331                };
332                params.push(PyParam {
333                    name: raw.name,
334                    kind,
335                    annotation: raw.annotation,
336                    default: raw.default,
337                });
338            }
339        }
340    }
341    if bare_star && kwonly_count == 0 {
342        return Err(SigParseError::Syntax(
343            "named arguments must follow bare *".to_string(),
344        ));
345    }
346
347    // `inspect.Signature.__init__`: first duplicate in sequence order wins.
348    let mut seen: HashSet<&str> = HashSet::new();
349    for p in &params {
350        if !seen.insert(p.name.as_str()) {
351            return Err(SigParseError::Duplicate(p.name.clone()));
352        }
353    }
354    Ok(params)
355}
356
357/// Port of `_parse_arglist` (`_annotations.py:462-516`, [PY §2.1]): a
358/// `desc_parameterlist` carrying `multi_line_parameter_list` /
359/// `multi_line_trailing_comma` UNCONDITIONALLY ([SIG §1.4]), one
360/// `desc_parameter` per param, `/` and `*` separator parameters between
361/// kind transitions, and the trailing-`/` epilogue when the list ends
362/// positional-only.
363pub fn parse_arglist(
364    arglist: &str,
365    multi_line: bool,
366    ctx: &PyRefContext,
367    cfg: &PySigConfig,
368) -> Result<Node, SigParseError> {
369    let sig_params = signature_from_str(arglist)?;
370    let mut params = attr_list_node("desc_parameterlist", multi_line, cfg);
371    let mut last_kind: Option<ParamKind> = None;
372    for param in &sig_params {
373        if param.kind != ParamKind::PositionalOnly && last_kind == Some(ParamKind::PositionalOnly) {
374            params.children.push(positional_only_separator());
375        }
376        if param.kind == ParamKind::KeywordOnly
377            && matches!(
378                last_kind,
379                Some(ParamKind::PositionalOrKeyword) | Some(ParamKind::PositionalOnly) | None
380            )
381        {
382            params.children.push(keyword_only_separator());
383        }
384
385        let mut node = desc_parameter();
386        match param.kind {
387            ParamKind::VarPositional => {
388                node.children.push(desc_sig_operator("*"));
389                node.children.push(desc_sig_name_text(&param.name));
390            }
391            ParamKind::VarKeyword => {
392                node.children.push(desc_sig_operator("**"));
393                node.children.push(desc_sig_name_text(&param.name));
394            }
395            _ => node.children.push(desc_sig_name_text(&param.name)),
396        }
397        if let Some(ann) = &param.annotation {
398            node.children.push(desc_sig_punctuation(":"));
399            node.children.push(desc_sig_space());
400            node.children
401                .push(annotation_wrapper(parse_annotation(ann, ctx, cfg)));
402        }
403        if let Some(default) = &param.default {
404            if param.annotation.is_some() {
405                node.children.push(desc_sig_space());
406                node.children.push(desc_sig_operator("="));
407                node.children.push(desc_sig_space());
408            } else {
409                node.children.push(desc_sig_operator("="));
410            }
411            node.children.push(default_value_inline(default));
412        }
413        params.children.push(node);
414        last_kind = Some(param.kind);
415    }
416    // Loop epilogue (`_annotations.py:513-514`): `func(a, /)`.
417    if last_kind == Some(ParamKind::PositionalOnly) {
418        params.children.push(positional_only_separator());
419    }
420    Ok(params)
421}
422
423/// Port of `_pseudo_parse_arglist` (`_annotations.py:541-619`, [PY §2.2]):
424/// comma-split fallback with `[`/`]` push/pop of `desc_optional`,
425/// `name[:annotation][=default]` partition, `=` always a bare
426/// `desc_sig_operator` (space-wrapped only when annotated), and — on total
427/// bracket imbalance — a fresh paramlist containing the raw arglist as one
428/// `desc_parameter`, with NO multi_line attributes (the one attr-less
429/// exception, [SIG §1.4]).
430///
431/// Signature note: sphinx's version takes `(signode, arglist, *,
432/// multi_line_parameter_list, trailing_comma, env)`; ours returns the
433/// paramlist node and reads `trailing_comma` from `cfg` / annotation
434/// context from `ctx` (see module docs).
435pub fn pseudo_parse_arglist(
436    arglist: &str,
437    multi_line: bool,
438    ctx: &PyRefContext,
439    cfg: &PySigConfig,
440) -> Node {
441    let list = attr_list_node("desc_parameterlist", multi_line, cfg);
442    match pseudo_build(arglist, list, ctx, cfg) {
443        Some(done) => done,
444        None => {
445            // "just give up and treat the whole argument list as one
446            // argument" (`_annotations.py:609-617`): fresh list, no attrs.
447            let mut fresh = Node::elem("desc_parameterlist", Span::ZERO);
448            fresh.set("xml:space", AttrValue::Str("preserve".to_string()));
449            let mut par = desc_parameter();
450            if !arglist.is_empty() {
451                par.children.push(Node::text_node(arglist, Span::ZERO));
452            }
453            fresh.children.push(par);
454            fresh
455        }
456    }
457}
458
459/// Port of `_parse_type_list` + `_TypeParameterListParser`
460/// (`_annotations.py:254-459`, [PY §2.4]): a `desc_type_parameter_list`
461/// with the same two multi_line attrs, one `desc_type_parameter` per
462/// param, `*`/`**` operators for variadics, bounds/constraints after
463/// `:`+space inside a `desc_sig_name` wrapper (constraints
464/// re-parenthesized), and `␣=␣` + `default_value` inline defaults.
465pub fn parse_type_list(
466    tp_list: &str,
467    multi_line: bool,
468    ctx: &PyRefContext,
469    cfg: &PySigConfig,
470) -> Result<Node, SigParseError> {
471    // `_TypeParameterListParser.__init__`: sig.replace('\n', '').strip().
472    let cleaned = tp_list.replace('\n', "");
473    let cleaned = cleaned.trim();
474    let toks = lex(cleaned).ok_or_else(invalid_syntax)?;
475    // `tokenize` raises TokenError('unexpected EOF in multi-line
476    // statement', (lnum, 0)) for unclosed brackets (extra closers are
477    // lenient); the parser's caller catches any Exception into the
478    // tp-list warning, where `%s` renders the two-arg exception as its
479    // args-tuple repr — and `lnum` is always 1 because the parser joins
480    // the tp-list to one line first. Probe tp_list_tokerror pins the
481    // rendered bytes verbatim.
482    let mut level = 0i64;
483    for t in &toks {
484        if t.kind == TokKind::Op {
485            match t.text.as_str() {
486                "(" | "[" | "{" => level += 1,
487                ")" | "]" | "}" => level -= 1,
488                _ => {}
489            }
490        }
491    }
492    if level > 0 {
493        return Err(SigParseError::Syntax(
494            "('unexpected EOF in multi-line statement', (1, 0))".to_string(),
495        ));
496    }
497
498    let type_params = tp_parse(&toks)?;
499
500    let mut list = attr_list_node("desc_type_parameter_list", multi_line, cfg);
501    for tp in &type_params {
502        let mut node = Node::elem("desc_type_parameter", Span::ZERO);
503        node.set("xml:space", AttrValue::Str("preserve".to_string()));
504        match tp.kind {
505            ParamKind::VarPositional => node.children.push(desc_sig_operator("*")),
506            ParamKind::VarKeyword => node.children.push(desc_sig_operator("**")),
507            _ => {}
508        }
509        node.children.push(desc_sig_name_text(&tp.name));
510
511        if let Some(ann_text) = &tp.annotation {
512            let children = parse_annotation(ann_text, ctx, cfg);
513            if children.is_empty() {
514                // `if not annotation: continue` (`_annotations.py:428-430`)
515                // drops the whole parameter, default included.
516                continue;
517            }
518            node.children.push(desc_sig_punctuation(":"));
519            node.children.push(desc_sig_space());
520            let wrapper = annotation_wrapper(children);
521            // A type bound is `T: U`; constraints are parenthesized
522            // `T: (U, V)` — and `_parse_annotation` loses tuple parens, so
523            // they are re-added around the wrapper (`_annotations.py:434-445`).
524            if ann_text.starts_with('(') && ann_text.ends_with(')') {
525                let text = wrapper.astext();
526                if text.starts_with('(') && text.ends_with(')') {
527                    node.children.push(wrapper);
528                } else {
529                    node.children.push(desc_sig_punctuation("("));
530                    node.children.push(wrapper);
531                    node.children.push(desc_sig_punctuation(")"));
532                }
533            } else {
534                node.children.push(wrapper);
535            }
536        }
537        if let Some(default) = &tp.default {
538            // "Always surround '=' with spaces, even if there is no
539            // annotation" (`_annotations.py:449-456`) — unlike arglists.
540            node.children.push(desc_sig_space());
541            node.children.push(desc_sig_operator("="));
542            node.children.push(desc_sig_space());
543            node.children.push(default_value_inline(default));
544        }
545        list.children.push(node);
546    }
547    Ok(list)
548}
549
550// ---------------------------------------------------------------------------
551// Node builders ([PY §2.1/§2.6] shapes)
552// ---------------------------------------------------------------------------
553
554/// `desc_parameterlist` / `desc_type_parameter_list` with the two
555/// multi_line attributes set unconditionally ([SIG §1.4]) and docutils'
556/// `FixedTextElement` `xml:space="preserve"`.
557fn attr_list_node(kind: &'static str, multi_line: bool, cfg: &PySigConfig) -> Node {
558    let mut node = Node::elem(kind, Span::ZERO);
559    node.set(
560        "multi_line_parameter_list",
561        AttrValue::Int(i64::from(multi_line)),
562    );
563    node.set(
564        "multi_line_trailing_comma",
565        AttrValue::Int(i64::from(
566            cfg.python_trailing_comma_in_multi_line_signatures,
567        )),
568    );
569    node.set("xml:space", AttrValue::Str("preserve".to_string()));
570    node
571}
572
573fn desc_parameter() -> Node {
574    let mut node = Node::elem("desc_parameter", Span::ZERO);
575    node.set("xml:space", AttrValue::Str("preserve".to_string()));
576    node
577}
578
579fn desc_optional() -> Node {
580    let mut node = Node::elem("desc_optional", Span::ZERO);
581    node.set("xml:space", AttrValue::Str("preserve".to_string()));
582    node
583}
584
585/// `desc_sig_name(text)` that mirrors docutils `TextElement('', text)`:
586/// an empty text adds NO text child (the pseudo parser can produce empty
587/// parameter names, e.g. for `=x`).
588fn desc_sig_name_text(text: &str) -> Node {
589    if text.is_empty() {
590        let mut node = Node::elem("desc_sig_name", Span::ZERO);
591        node.attrs.classes.push("n".to_string());
592        node
593    } else {
594        super::annotations::desc_sig_name(text)
595    }
596}
597
598/// `desc_sig_name('', '', *children)` — the classes-`n` wrapper around a
599/// rendered annotation (`_annotations.py:498`).
600fn annotation_wrapper(children: Vec<Node>) -> Node {
601    let mut node = Node::elem("desc_sig_name", Span::ZERO);
602    node.attrs.classes.push("n".to_string());
603    node.children = children;
604    node
605}
606
607/// `nodes.inline('', text, classes=['default_value'],
608/// support_smartquotes=False)` (`_annotations.py:505-508`).
609fn default_value_inline(text: &str) -> Node {
610    let mut node = Node::elem("inline", Span::ZERO);
611    node.attrs.classes.push("default_value".to_string());
612    node.set("support_smartquotes", AttrValue::Int(0));
613    if !text.is_empty() {
614        node.children.push(Node::text_node(text, Span::ZERO));
615    }
616    node
617}
618
619/// `_positional_only_separator()` / `_keyword_only_separator()`
620/// (`_annotations.py:519-538`): `desc_parameter > desc_sig_operator(classes
621/// [<separator class>, "o"]) > abbreviation(explanation=<PEP text>)`.
622fn separator(op_text: &str, class: &str, explanation: &str) -> Node {
623    let mut abbr = Node::elem(kinds::ABBREVIATION, Span::ZERO);
624    abbr.set("explanation", AttrValue::Str(explanation.to_string()));
625    abbr.children.push(Node::text_node(op_text, Span::ZERO));
626    let mut op = Node::elem("desc_sig_operator", Span::ZERO);
627    op.attrs.classes.push(class.to_string());
628    op.attrs.classes.push("o".to_string());
629    op.children.push(abbr);
630    let mut par = desc_parameter();
631    par.children.push(op);
632    par
633}
634
635fn positional_only_separator() -> Node {
636    separator(
637        "/",
638        "positional-only-separator",
639        "Positional-only parameter separator (PEP 570)",
640    )
641}
642
643fn keyword_only_separator() -> Node {
644    separator(
645        "*",
646        "keyword-only-separator",
647        "Keyword-only parameters separator (PEP 3102)",
648    )
649}
650
651// ---------------------------------------------------------------------------
652// Pseudo parser internals ([PY §2.2])
653// ---------------------------------------------------------------------------
654
655/// The happy path of `_pseudo_parse_arglist`; `None` reproduces every
656/// `IndexError` route into the give-up fallback (too many `]`, unclosed
657/// `[`, operations after the root was popped).
658fn pseudo_build(arglist: &str, list: Node, ctx: &PyRefContext, cfg: &PySigConfig) -> Option<Node> {
659    let mut stack: Vec<Node> = vec![list];
660    for argument in arglist.split(',') {
661        let mut argument = argument.trim();
662        let mut ends_open = 0usize;
663        let mut ends_close = 0usize;
664        while let Some(rest) = argument.strip_prefix('[') {
665            stack_push(&mut stack)?;
666            argument = rest.trim();
667        }
668        while let Some(rest) = argument.strip_prefix(']') {
669            stack_pop(&mut stack)?;
670            argument = rest.trim();
671        }
672        while argument.ends_with(']') && !argument.ends_with("[]") {
673            ends_close += 1;
674            argument = argument[..argument.len() - 1].trim();
675        }
676        while let Some(rest) = argument.strip_suffix('[') {
677            ends_open += 1;
678            argument = rest.trim();
679        }
680        if !argument.is_empty() {
681            // `argument.partition('=')` then `.partition(':')` — first
682            // occurrence, no bracket awareness (`_annotations.py:578-600`).
683            let (param_with_annotation, default_value) = match argument.split_once('=') {
684                Some((head, tail)) => (head, tail),
685                None => (argument, ""),
686            };
687            let (param_name, annotation) = match param_with_annotation.split_once(':') {
688                Some((head, tail)) => (head, tail),
689                None => (param_with_annotation, ""),
690            };
691            let mut node = desc_parameter();
692            node.children.push(desc_sig_name_text(param_name.trim()));
693            if !annotation.is_empty() {
694                node.children.push(desc_sig_punctuation(":"));
695                node.children.push(desc_sig_space());
696                node.children.push(annotation_wrapper(parse_annotation(
697                    annotation.trim(),
698                    ctx,
699                    cfg,
700                )));
701            }
702            if !default_value.is_empty() {
703                if !annotation.is_empty() {
704                    node.children.push(desc_sig_space());
705                }
706                node.children.push(desc_sig_operator("="));
707                if !annotation.is_empty() {
708                    node.children.push(desc_sig_space());
709                }
710                node.children
711                    .push(default_value_inline(default_value.trim()));
712            }
713            stack.last_mut()?.children.push(node);
714        }
715        for _ in 0..ends_open {
716            stack_push(&mut stack)?;
717        }
718        for _ in 0..ends_close {
719            stack_pop(&mut stack)?;
720        }
721    }
722    if stack.len() == 1 {
723        stack.pop()
724    } else {
725        None
726    }
727}
728
729/// Python push (`stack.append(desc_optional()); stack[-2] += stack[-1]`):
730/// attaching to a missing parent is the IndexError give-up.
731fn stack_push(stack: &mut Vec<Node>) -> Option<()> {
732    if stack.is_empty() {
733        return None;
734    }
735    stack.push(desc_optional());
736    Some(())
737}
738
739/// Python `stack.pop()`. Children are attached at pop time (Python attaches
740/// at push time by reference; behaviorally identical because the give-up
741/// path discards the whole tree). Popping the root succeeds — exactly like
742/// Python — and every subsequent operation then fails.
743fn stack_pop(stack: &mut Vec<Node>) -> Option<()> {
744    let top = stack.pop()?;
745    if let Some(parent) = stack.last_mut() {
746        parent.children.push(top);
747    }
748    Some(())
749}
750
751// ---------------------------------------------------------------------------
752// Lexer: Python-shaped tokens for arglist splitting, numeric source
753// recovery and the tp-list TokenProcessor mirror
754// ---------------------------------------------------------------------------
755
756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757enum TokKind {
758    Name,
759    Number,
760    Str,
761    Op,
762}
763
764#[derive(Debug, Clone)]
765struct Tok {
766    kind: TokKind,
767    text: String,
768    /// Byte span in the lexed string.
769    start: usize,
770    end: usize,
771}
772
773fn is_name_start(c: char) -> bool {
774    c.is_alphabetic() || c == '_'
775}
776
777fn is_name_continue(c: char) -> bool {
778    c.is_alphanumeric() || c == '_'
779}
780
781/// Python string-literal prefixes: 1-2 letters from rRbBuUfF.
782fn is_string_prefix(word: &str) -> bool {
783    !word.is_empty() && word.len() <= 2 && word.chars().all(|c| "rRbBuUfF".contains(c))
784}
785
786const OPS3: &[&str] = &["**=", "//=", "<<=", ">>=", "..."];
787const OPS2: &[&str] = &[
788    "**", "//", "<<", ">>", "<=", ">=", "==", "!=", "->", ":=", "+=", "-=", "*=", "/=", "%=", "@=",
789    "&=", "|=", "^=",
790];
791
792/// Tokenize with Python-`tokenize`-shaped boundaries: names (Unicode),
793/// numbers (source text kept verbatim), strings (prefixes, triple quotes,
794/// backslash escapes) and maximal-munch operators. Whitespace separates.
795/// `None` on an unterminated string. Unknown characters become single-char
796/// Op tokens (Python's ERRORTOKEN leniency); real validity is decided by
797/// [`parse_py_expr`] on the fragments.
798fn lex(src: &str) -> Option<Vec<Tok>> {
799    let mut toks: Vec<Tok> = Vec::new();
800    let mut iter = src.char_indices().peekable();
801    while let Some(&(i, c)) = iter.peek() {
802        if c.is_whitespace() {
803            iter.next();
804            continue;
805        }
806        if is_name_start(c) {
807            let mut end = i + c.len_utf8();
808            iter.next();
809            while let Some(&(j, d)) = iter.peek() {
810                if is_name_continue(d) {
811                    end = j + d.len_utf8();
812                    iter.next();
813                } else {
814                    break;
815                }
816            }
817            let word = &src[i..end];
818            if let Some(&(_, q)) = iter.peek() {
819                if (q == '\'' || q == '"') && is_string_prefix(word) {
820                    let send = lex_string(&mut iter)?;
821                    toks.push(Tok {
822                        kind: TokKind::Str,
823                        text: src[i..send].to_string(),
824                        start: i,
825                        end: send,
826                    });
827                    continue;
828                }
829            }
830            toks.push(Tok {
831                kind: TokKind::Name,
832                text: word.to_string(),
833                start: i,
834                end,
835            });
836            continue;
837        }
838        if c.is_ascii_digit() || (c == '.' && peek2_is_digit(&iter)) {
839            let end = lex_number(src, &mut iter);
840            toks.push(Tok {
841                kind: TokKind::Number,
842                text: src[i..end].to_string(),
843                start: i,
844                end,
845            });
846            continue;
847        }
848        if c == '\'' || c == '"' {
849            let send = lex_string(&mut iter)?;
850            toks.push(Tok {
851                kind: TokKind::Str,
852                text: src[i..send].to_string(),
853                start: i,
854                end: send,
855            });
856            continue;
857        }
858        // Operators: maximal munch 3-2-1.
859        let rest = &src[i..];
860        let mut matched = None;
861        for cand in OPS3.iter().chain(OPS2.iter()) {
862            if rest.starts_with(*cand) {
863                matched = Some(*cand);
864                break;
865            }
866        }
867        let op_len = matched.map_or(c.len_utf8(), str::len);
868        let end = i + op_len;
869        toks.push(Tok {
870            kind: TokKind::Op,
871            text: src[i..end].to_string(),
872            start: i,
873            end,
874        });
875        for _ in 0..src[i..end].chars().count() {
876            iter.next();
877        }
878    }
879    Some(toks)
880}
881
882fn peek2_is_digit(iter: &std::iter::Peekable<std::str::CharIndices<'_>>) -> bool {
883    let mut it = iter.clone();
884    it.next();
885    matches!(it.peek(), Some(&(_, d)) if d.is_ascii_digit())
886}
887
888/// Consume a numeric literal, returning its end byte offset. Keeps the
889/// source text verbatim (that is the whole point — `ast.get_source_segment`
890/// parity); boundaries approximate Python's number token: alnum, `_`, `.`,
891/// plus a sign directly after an exponent `e`/`E` (never in radix-prefixed
892/// literals, so `0xEF+1` splits after `0xEF`).
893fn lex_number(src: &str, iter: &mut std::iter::Peekable<std::str::CharIndices<'_>>) -> usize {
894    let (start, first) = *iter.peek().expect("caller peeked a digit");
895    let radix_prefixed = {
896        let rest = &src[start..];
897        rest.len() >= 2
898            && rest.starts_with('0')
899            && matches!(
900                rest[1..].chars().next(),
901                Some('x' | 'X' | 'b' | 'B' | 'o' | 'O')
902            )
903    };
904    let mut end = start + first.len_utf8();
905    let mut prev = first;
906    iter.next();
907    while let Some(&(j, d)) = iter.peek() {
908        let continues = d.is_ascii_alphanumeric()
909            || d == '_'
910            || d == '.'
911            || ((d == '+' || d == '-') && matches!(prev, 'e' | 'E') && !radix_prefixed);
912        if continues {
913            end = j + d.len_utf8();
914            prev = d;
915            iter.next();
916        } else {
917            break;
918        }
919    }
920    end
921}
922
923/// Consume a string literal starting at the opening quote; returns the end
924/// byte offset past the closing quote, or `None` when unterminated. A
925/// backslash always escapes the next character for termination purposes
926/// (true even for raw strings in Python's tokenizer).
927fn lex_string(iter: &mut std::iter::Peekable<std::str::CharIndices<'_>>) -> Option<usize> {
928    let (_, quote) = iter.next()?;
929    // Triple quote?
930    let mut probe = iter.clone();
931    let triple = matches!(
932        (probe.next(), probe.next()),
933        (Some((_, a)), Some((_, b))) if a == quote && b == quote
934    );
935    if triple {
936        iter.next();
937        iter.next();
938        loop {
939            let (_, c) = iter.next()?;
940            if c == '\\' {
941                iter.next();
942                continue;
943            }
944            if c == quote {
945                let mut probe = iter.clone();
946                if matches!(
947                    (probe.next(), probe.next()),
948                    (Some((_, a)), Some((_, b))) if a == quote && b == quote
949                ) {
950                    iter.next();
951                    let (j, q) = iter.next().expect("probed above");
952                    return Some(j + q.len_utf8());
953                }
954            }
955        }
956    } else {
957        loop {
958            let (j, c) = iter.next()?;
959            if c == '\\' {
960                iter.next();
961                continue;
962            }
963            if c == quote {
964                return Some(j + c.len_utf8());
965            }
966            if c == '\n' {
967                return None;
968            }
969        }
970    }
971}
972
973// ---------------------------------------------------------------------------
974// Arglist splitting and per-parameter token parsing
975// ---------------------------------------------------------------------------
976
977fn invalid_syntax() -> SigParseError {
978    SigParseError::Syntax("invalid syntax".to_string())
979}
980
981/// Split the token stream at depth-0 commas. Bracket imbalance (either
982/// direction) is `ast.parse`'s SyntaxError; a depth-0 `lambda` keyword is
983/// rejected up front (see module docs — its parameter commas would defeat
984/// any comma split, and the task-3 subset rejects lambdas anyway).
985fn split_top_level(toks: &[Tok]) -> Result<Vec<&[Tok]>, SigParseError> {
986    let mut items: Vec<&[Tok]> = Vec::new();
987    let mut depth = 0i64;
988    let mut start = 0usize;
989    for (idx, t) in toks.iter().enumerate() {
990        match t.kind {
991            TokKind::Op => match t.text.as_str() {
992                "(" | "[" | "{" => depth += 1,
993                ")" | "]" | "}" => {
994                    depth -= 1;
995                    if depth < 0 {
996                        return Err(invalid_syntax());
997                    }
998                }
999                "," if depth == 0 => {
1000                    items.push(&toks[start..idx]);
1001                    start = idx + 1;
1002                }
1003                _ => {}
1004            },
1005            TokKind::Name if depth == 0 && t.text == "lambda" => {
1006                return Err(invalid_syntax());
1007            }
1008            _ => {}
1009        }
1010    }
1011    if depth != 0 {
1012        return Err(invalid_syntax());
1013    }
1014    items.push(&toks[start..]);
1015    Ok(items)
1016}
1017
1018enum ItemShape<'t> {
1019    Empty,
1020    Slash,
1021    BareStar,
1022    VarArgs(&'t [Tok]),
1023    KwArgs(&'t [Tok]),
1024    Plain(&'t [Tok]),
1025}
1026
1027fn classify_item<'t>(toks: &'t [Tok]) -> ItemShape<'t> {
1028    let Some(first) = toks.first() else {
1029        return ItemShape::Empty;
1030    };
1031    if first.kind == TokKind::Op {
1032        match first.text.as_str() {
1033            "/" if toks.len() == 1 => return ItemShape::Slash,
1034            "*" if toks.len() == 1 => return ItemShape::BareStar,
1035            "**" => return ItemShape::KwArgs(&toks[1..]),
1036            "*" => return ItemShape::VarArgs(&toks[1..]),
1037            _ => {}
1038        }
1039    }
1040    ItemShape::Plain(toks)
1041}
1042
1043struct RawParam {
1044    name: String,
1045    annotation: Option<String>,
1046    default: Option<String>,
1047}
1048
1049/// `name[: annotation][= default]` over one item's tokens. The name must
1050/// be a single Name token that task 3 parses as `PyExpr::Name` (rejecting
1051/// keywords, applying CPython's NFKC identifier normalization).
1052///
1053/// `star_annotation` selects CPython's `star_annotation` production for
1054/// the annotation slot: `def f(*args: *Ts)` is legal (PEP 646) and
1055/// `signature_from_str` hands sphinx the annotation string `*Ts`, while
1056/// `def f(x: *Ts)` and `def f(**k: *Ts)` are SyntaxErrors — so only the
1057/// var-positional caller passes `true`.
1058fn parse_param_tokens(
1059    toks: &[Tok],
1060    src: &str,
1061    star_annotation: bool,
1062) -> Result<RawParam, SigParseError> {
1063    if toks.is_empty() {
1064        return Err(invalid_syntax());
1065    }
1066    let mut depth = 0i64;
1067    let mut colon: Option<usize> = None;
1068    let mut eq: Option<usize> = None;
1069    for (i, t) in toks.iter().enumerate() {
1070        if t.kind == TokKind::Op {
1071            match t.text.as_str() {
1072                "(" | "[" | "{" => depth += 1,
1073                ")" | "]" | "}" => depth -= 1,
1074                ":" if depth == 0 && colon.is_none() && eq.is_none() => colon = Some(i),
1075                "=" if depth == 0 && eq.is_none() => eq = Some(i),
1076                _ => {}
1077            }
1078        }
1079    }
1080    let name_end = colon.or(eq).unwrap_or(toks.len());
1081    let name_toks = &toks[..name_end];
1082    if name_toks.len() != 1 || name_toks[0].kind != TokKind::Name {
1083        return Err(invalid_syntax());
1084    }
1085    let name = match parse_py_expr(&name_toks[0].text) {
1086        Ok(PyExpr::Name(n)) => n,
1087        _ => return Err(invalid_syntax()),
1088    };
1089    let ann_end = eq.unwrap_or(toks.len());
1090    let annotation = match colon {
1091        Some(ci) => Some(render_fragment(
1092            &toks[ci + 1..ann_end],
1093            src,
1094            star_annotation,
1095        )?),
1096        None => None,
1097    };
1098    let default = match eq {
1099        Some(ei) => Some(render_fragment(&toks[ei + 1..], src, false)?),
1100        None => None,
1101    };
1102    Ok(RawParam {
1103        name,
1104        annotation,
1105        default,
1106    })
1107}
1108
1109fn render_fragment(
1110    toks: &[Tok],
1111    src: &str,
1112    star_annotation: bool,
1113) -> Result<String, SigParseError> {
1114    if toks.is_empty() {
1115        return Err(invalid_syntax());
1116    }
1117    let fragment = &src[toks[0].start..toks[toks.len() - 1].end];
1118    pycode_unparse(fragment, star_annotation)
1119}
1120
1121// ---------------------------------------------------------------------------
1122// sphinx.pycode.ast.unparse over the task-3 AST (see module docs)
1123// ---------------------------------------------------------------------------
1124
1125enum RenderErr {
1126    /// The numeric-token pool did not line up with the tree; re-render in
1127    /// `repr` fallback mode.
1128    SourceMismatch,
1129    /// A shape sphinx's `_UnparseVisitor` cannot render either (`{**a}`
1130    /// dies in its strict zip).
1131    Unrenderable,
1132}
1133
1134struct NumPool {
1135    texts: Vec<String>,
1136    idx: usize,
1137}
1138
1139/// Parse one source fragment with task 3 and render it with
1140/// `sphinx.pycode.ast.unparse` semantics, recovering numeric source text
1141/// by order-mapping the fragment's number tokens onto the tree's numeric
1142/// constants (each verified by re-parsing; any mismatch falls back to
1143/// `repr` form for the whole fragment, mirroring `get_source_segment`'s
1144/// `or repr(...)` arm).
1145fn pycode_unparse(fragment: &str, star_annotation: bool) -> Result<String, SigParseError> {
1146    let parsed = if star_annotation {
1147        parse_py_star_annotation(fragment)
1148    } else {
1149        parse_py_expr(fragment)
1150    }
1151    .map_err(|_| invalid_syntax())?;
1152    let texts: Vec<String> = lex(fragment)
1153        .map(|toks| {
1154            toks.into_iter()
1155                .filter(|t| t.kind == TokKind::Number)
1156                .map(|t| t.text)
1157                .collect()
1158        })
1159        .unwrap_or_default();
1160    let mut pool = Some(NumPool { texts, idx: 0 });
1161    match render_pycode(&parsed, &mut pool) {
1162        Ok(s) if pool.as_ref().is_some_and(|p| p.idx == p.texts.len()) => Ok(s),
1163        Ok(_) | Err(RenderErr::SourceMismatch) => {
1164            let mut no_pool = None;
1165            render_pycode(&parsed, &mut no_pool).map_err(|_| invalid_syntax())
1166        }
1167        Err(RenderErr::Unrenderable) => Err(invalid_syntax()),
1168    }
1169}
1170
1171fn binop_text(op: PyOp) -> &'static str {
1172    match op {
1173        PyOp::Add => "+",
1174        PyOp::Sub => "-",
1175        PyOp::Mult => "*",
1176        PyOp::MatMult => "@",
1177        PyOp::Div => "/",
1178        PyOp::Mod => "%",
1179        PyOp::Pow => "**",
1180        PyOp::LShift => "<<",
1181        PyOp::RShift => ">>",
1182        PyOp::BitOr => "|",
1183        PyOp::BitXor => "^",
1184        PyOp::BitAnd => "&",
1185        PyOp::FloorDiv => "//",
1186    }
1187}
1188
1189fn render_join(elts: &[PyExpr], pool: &mut Option<NumPool>) -> Result<String, RenderErr> {
1190    let mut parts = Vec::with_capacity(elts.len());
1191    for e in elts {
1192        parts.push(render_pycode(e, pool)?);
1193    }
1194    Ok(parts.join(", "))
1195}
1196
1197fn render_pycode(e: &PyExpr, pool: &mut Option<NumPool>) -> Result<String, RenderErr> {
1198    Ok(match e {
1199        PyExpr::Name(id) => id.clone(),
1200        PyExpr::Attribute(value, attr) => format!("{}.{attr}", render_pycode(value, pool)?),
1201        PyExpr::BinOp { left, op, right } => {
1202            let l = render_pycode(left, pool)?;
1203            let r = render_pycode(right, pool)?;
1204            let o = binop_text(*op);
1205            // "Special case ``**`` to not have surrounding spaces."
1206            if matches!(op, PyOp::Pow) {
1207                format!("{l}{o}{r}")
1208            } else {
1209                format!("{l} {o} {r}")
1210            }
1211        }
1212        PyExpr::UnaryOp { op, operand } => {
1213            let s = render_pycode(operand, pool)?;
1214            match op {
1215                PyUnaryOp::Not => format!("not {s}"),
1216                PyUnaryOp::Invert => format!("~{s}"),
1217                PyUnaryOp::UAdd => format!("+{s}"),
1218                PyUnaryOp::USub => format!("-{s}"),
1219            }
1220        }
1221        PyExpr::Constant(c) => const_text(c, pool)?,
1222        PyExpr::Tuple(elts) => match elts.len() {
1223            0 => "()".to_string(),
1224            1 => format!("({},)", render_pycode(&elts[0], pool)?),
1225            _ => format!("({})", render_join(elts, pool)?),
1226        },
1227        PyExpr::List(elts) => format!("[{}]", render_join(elts, pool)?),
1228        PyExpr::Set(elts) => format!("{{{}}}", render_join(elts, pool)?),
1229        PyExpr::Dict(entries) => {
1230            let mut parts = Vec::with_capacity(entries.len());
1231            for (key, value) in entries {
1232                // `visit_Dict` skips None keys in its keys generator and
1233                // then zips strict → ValueError in sphinx.
1234                let Some(key) = key else {
1235                    return Err(RenderErr::Unrenderable);
1236                };
1237                parts.push(format!(
1238                    "{}: {}",
1239                    render_pycode(key, pool)?,
1240                    render_pycode(value, pool)?
1241                ));
1242            }
1243            format!("{{{}}}", parts.join(", "))
1244        }
1245        PyExpr::Call { func, args, kwargs } => {
1246            // The callee is rendered FIRST: it precedes the arguments in
1247            // the source, and `NumPool` hands out the fragment's number
1248            // tokens in render order. Rendering it after the arguments
1249            // (as `visit_Call`'s f-string reads, but sphinx recovers
1250            // numeric text by AST position instead) mis-pairs every
1251            // literal in `a(0x10).b(16)`.
1252            let f = render_pycode(func, pool)?;
1253            let mut parts: Vec<String> = Vec::with_capacity(args.len() + kwargs.len());
1254            for a in args {
1255                parts.push(render_pycode(a, pool)?);
1256            }
1257            for (k, v) in kwargs {
1258                parts.push(format!("{k}={}", render_pycode(v, pool)?));
1259            }
1260            format!("{f}({})", parts.join(", "))
1261        }
1262        // `visit_BoolOp`: `' and '` / `' or '` joined, never parenthesized
1263        // (sphinx's unparser has no precedence table, so `(a or b) and c`
1264        // renders as `a or b and c`).
1265        PyExpr::BoolOp { op, values } => {
1266            let sep = match op {
1267                PyBoolOp::And => " and ",
1268                PyBoolOp::Or => " or ",
1269            };
1270            let mut parts = Vec::with_capacity(values.len());
1271            for v in values {
1272                parts.push(render_pycode(v, pool)?);
1273            }
1274            parts.join(sep)
1275        }
1276        PyExpr::Starred(value) => format!("*{}", render_pycode(value, pool)?),
1277        PyExpr::Subscript { value, slice } => {
1278            let v = render_pycode(value, pool)?;
1279            match &**slice {
1280                // `is_simple_tuple`: non-empty, no Starred elements.
1281                PyExpr::Tuple(elts)
1282                    if !elts.is_empty()
1283                        && !elts.iter().any(|e| matches!(e, PyExpr::Starred(_))) =>
1284                {
1285                    format!("{v}[{}]", render_join(elts, pool)?)
1286                }
1287                other => format!("{v}[{}]", render_pycode(other, pool)?),
1288            }
1289        }
1290    })
1291}
1292
1293/// `visit_Constant`: source segment for int/float (verified pool token),
1294/// `...` for Ellipsis, `repr(value)` otherwise — which drops a `u` string
1295/// prefix (`repr` never knew about it).
1296fn const_text(c: &PyConst, pool: &mut Option<NumPool>) -> Result<String, RenderErr> {
1297    match c {
1298        PyConst::Ellipsis => Ok("...".to_string()),
1299        PyConst::Int(_) | PyConst::Float(_) => {
1300            if let Some(p) = pool.as_mut() {
1301                let tok = p.texts.get(p.idx).cloned();
1302                p.idx += 1;
1303                if let Some(tok) = tok {
1304                    if matches!(parse_py_expr(&tok), Ok(PyExpr::Constant(parsed)) if parsed == *c) {
1305                        return Ok(tok);
1306                    }
1307                }
1308                Err(RenderErr::SourceMismatch)
1309            } else {
1310                Ok(expr::unparse(&PyExpr::Constant(c.clone())))
1311            }
1312        }
1313        PyConst::Str {
1314            value,
1315            quote,
1316            u_prefix: _,
1317        } => Ok(expr::unparse(&PyExpr::Constant(PyConst::Str {
1318            value: value.clone(),
1319            quote: *quote,
1320            u_prefix: false,
1321        }))),
1322        other => Ok(expr::unparse(&PyExpr::Constant(other.clone()))),
1323    }
1324}
1325
1326// ---------------------------------------------------------------------------
1327// Type-parameter-list parser (`_TypeParameterListParser`, [PY §2.4])
1328// ---------------------------------------------------------------------------
1329
1330struct TpParam {
1331    name: String,
1332    kind: ParamKind,
1333    annotation: Option<String>,
1334    default: Option<String>,
1335}
1336
1337/// `TokenProcessor` mirror: `fetch_token` advances `current`/`previous`
1338/// exactly like `sphinx/pycode/parser.py` (on exhaustion `previous` still
1339/// shifts and `current` becomes `None`).
1340struct TpCursor<'t> {
1341    toks: &'t [Tok],
1342    next: usize,
1343    current: Option<usize>,
1344    previous: Option<usize>,
1345}
1346
1347impl<'t> TpCursor<'t> {
1348    fn new(toks: &'t [Tok]) -> Self {
1349        Self {
1350            toks,
1351            next: 0,
1352            current: None,
1353            previous: None,
1354        }
1355    }
1356
1357    fn fetch(&mut self) -> Option<usize> {
1358        self.previous = self.current;
1359        if self.next < self.toks.len() {
1360            self.current = Some(self.next);
1361            self.next += 1;
1362        } else {
1363            self.current = None;
1364        }
1365        self.current
1366    }
1367
1368    fn tok(&self, idx: usize) -> &'t Tok {
1369        &self.toks[idx]
1370    }
1371
1372    fn is_op(&self, idx: Option<usize>, text: &str) -> bool {
1373        idx.is_some_and(|i| self.toks[i].kind == TokKind::Op && self.toks[i].text == text)
1374    }
1375
1376    /// `fetch_until(rdelim)`, iterative (the sphinx original recurses per
1377    /// nesting level; an explicit closer stack keeps totality on
1378    /// adversarial nesting). Mismatched closers are collected and ignored,
1379    /// exactly like the original; exhaustion returns what was collected.
1380    fn fetch_until_into(&mut self, rdelim: &'static str, out: &mut Vec<usize>) {
1381        let mut expected: Vec<&'static str> = vec![rdelim];
1382        while let Some(i) = self.fetch() {
1383            out.push(i);
1384            let t = self.tok(i);
1385            if t.kind == TokKind::Op {
1386                if expected.last().copied() == Some(t.text.as_str()) {
1387                    expected.pop();
1388                    if expected.is_empty() {
1389                        return;
1390                    }
1391                    continue;
1392                }
1393                match t.text.as_str() {
1394                    "(" => expected.push(")"),
1395                    "{" => expected.push("}"),
1396                    "[" => expected.push("]"),
1397                    _ => {}
1398                }
1399            }
1400        }
1401    }
1402
1403    /// `fetch_type_param_spec`: collect until a top-level `:`, `=` or `,`
1404    /// (the terminator is consumed but not returned), balancing brackets.
1405    fn fetch_type_param_spec(&mut self) -> Vec<usize> {
1406        let mut tokens: Vec<usize> = Vec::new();
1407        while let Some(i) = self.fetch() {
1408            tokens.push(i);
1409            let t = self.tok(i);
1410            let mut handled = false;
1411            if t.kind == TokKind::Op {
1412                match t.text.as_str() {
1413                    "(" => {
1414                        self.fetch_until_into(")", &mut tokens);
1415                        handled = true;
1416                    }
1417                    "{" => {
1418                        self.fetch_until_into("}", &mut tokens);
1419                        handled = true;
1420                    }
1421                    "[" => {
1422                        self.fetch_until_into("]", &mut tokens);
1423                        handled = true;
1424                    }
1425                    _ => {}
1426                }
1427            }
1428            if !handled && t.kind == TokKind::Op && matches!(t.text.as_str(), ":" | "=" | ",") {
1429                tokens.pop();
1430                break;
1431            }
1432        }
1433        tokens
1434    }
1435}
1436
1437/// `_TypeParameterListParser.parse`: only NAME tokens start a parameter;
1438/// a `*`/`**` previous token selects the variadic kind; `:` fetches a
1439/// bound/constraint spec, a following `=` fetches a default. Non-NAME
1440/// junk at the top level is silently skipped (as in sphinx). A bound on a
1441/// variadic parameter raises — message verbatim (`_annotations.py:308-315`).
1442fn tp_parse(toks: &[Tok]) -> Result<Vec<TpParam>, SigParseError> {
1443    let mut cur = TpCursor::new(toks);
1444    let mut out: Vec<TpParam> = Vec::new();
1445    while let Some(i) = cur.fetch() {
1446        if cur.tok(i).kind != TokKind::Name {
1447            continue;
1448        }
1449        let name = cur.tok(i).text.clone();
1450        let kind = if cur.is_op(cur.previous, "*") {
1451            ParamKind::VarPositional
1452        } else if cur.is_op(cur.previous, "**") {
1453            ParamKind::VarKeyword
1454        } else {
1455            ParamKind::PositionalOrKeyword
1456        };
1457
1458        let mut annotation: Option<String> = None;
1459        let mut default: Option<String> = None;
1460        let next = cur.fetch();
1461        if cur.is_op(next, ":") || cur.is_op(next, "=") {
1462            if cur.is_op(next, ":") {
1463                let spec = cur.fetch_type_param_spec();
1464                annotation = Some(build_identifier(&spec, &cur));
1465            }
1466            if cur.is_op(cur.current, "=") {
1467                let spec = cur.fetch_type_param_spec();
1468                default = Some(build_identifier(&spec, &cur));
1469            }
1470        }
1471
1472        if kind != ParamKind::PositionalOrKeyword && annotation.is_some() {
1473            let desc = match kind {
1474                ParamKind::VarPositional => "variadic positional",
1475                ParamKind::VarKeyword => "variadic keyword",
1476                _ => unreachable!("guarded above"),
1477            };
1478            return Err(SigParseError::Syntax(format!(
1479                "type parameter bound or constraint is not allowed for {desc} parameters"
1480            )));
1481        }
1482        out.push(TpParam {
1483            name,
1484            kind,
1485            annotation,
1486            default,
1487        });
1488    }
1489    Ok(out)
1490}
1491
1492fn is_operand_left(t: &Tok) -> bool {
1493    matches!(t.kind, TokKind::Name | TokKind::Number | TokKind::Str)
1494        || (t.kind == TokKind::Op && matches!(t.text.as_str(), ")" | "]" | "}"))
1495}
1496
1497fn is_operand_right(t: Option<&Tok>) -> bool {
1498    t.is_some_and(|t| {
1499        matches!(t.kind, TokKind::Name | TokKind::Number | TokKind::Str)
1500            || (t.kind == TokKind::Op && matches!(t.text.as_str(), "(" | "[" | "{"))
1501    })
1502}
1503
1504/// `_TypeParameterListParser._build_identifier`: bound/default text is
1505/// reassembled from raw tokens with spacing rules — `:`/`,` get a trailing
1506/// space, binary-ish operators get surrounding spaces, an unpack `*`/`**`
1507/// (operator between a non-operand and an operand) stays flush. The
1508/// first-token unpack check matches only `*` (sphinx compares against a
1509/// nested list for `**` — a bug mirrored deliberately).
1510fn build_identifier(spec: &[usize], cur: &TpCursor<'_>) -> String {
1511    let toks: Vec<&Tok> = spec.iter().map(|&i| cur.tok(i)).collect();
1512    let mut idents: Vec<String> = Vec::new();
1513    let mut pos = 0usize;
1514    while pos < toks.len()
1515        && toks[pos].kind == TokKind::Op
1516        && matches!(toks[pos].text.as_str(), "(" | "[" | "{")
1517    {
1518        idents.push(toks[pos].text.clone());
1519        pos += 1;
1520    }
1521    if pos < toks.len() {
1522        let first = toks[pos];
1523        let is_unpack = first.kind == TokKind::Op && first.text == "*";
1524        idents.push(tp_pformat_token(first, is_unpack));
1525        pos += 1;
1526    }
1527    let rest = &toks[pos..];
1528    let mut is_unpack = false;
1529    for (j, tok) in rest.iter().enumerate() {
1530        idents.push(tp_pformat_token(tok, is_unpack));
1531        let op = rest.get(j + 1);
1532        let after = rest.get(j + 2).copied();
1533        is_unpack = op
1534            .is_some_and(|o| o.kind == TokKind::Op && matches!(o.text.as_str(), "*" | "**"))
1535            && !(is_operand_left(tok) && is_operand_right(after));
1536    }
1537    idents.concat().trim().to_string()
1538}
1539
1540/// `_TypeParameterListParser._pformat_token`.
1541fn tp_pformat_token(tok: &Tok, native: bool) -> String {
1542    if native {
1543        return tok.text.clone();
1544    }
1545    if tok.kind == TokKind::Op {
1546        if matches!(tok.text.as_str(), ":" | "," | "#") {
1547            return format!("{} ", tok.text);
1548        }
1549        if matches!(
1550            tok.text.as_str(),
1551            "=" | "|"
1552                | "&"
1553                | "^"
1554                | "<"
1555                | ">"
1556                | "+"
1557                | "-"
1558                | "*"
1559                | "**"
1560                | "@"
1561                | "/"
1562                | "//"
1563                | "%"
1564                | "<<"
1565                | ">>"
1566                | ">>>"
1567                | "<="
1568                | ">="
1569                | "=="
1570                | "!="
1571        ) {
1572            return format!(" {} ", tok.text);
1573        }
1574    }
1575    tok.text.clone()
1576}
1577
1578#[cfg(test)]
1579mod tests {
1580    use super::*;
1581
1582    fn dctx() -> PyRefContext {
1583        PyRefContext::default()
1584    }
1585
1586    fn dcfg() -> PySigConfig {
1587        PySigConfig::default()
1588    }
1589
1590    fn parsed(arglist: &str) -> String {
1591        parse_arglist(arglist, false, &dctx(), &dcfg())
1592            .expect("arglist parses")
1593            .pformat()
1594    }
1595
1596    fn pseudo(arglist: &str) -> String {
1597        pseudo_parse_arglist(arglist, false, &dctx(), &dcfg()).pformat()
1598    }
1599
1600    fn tp(tp_list: &str) -> String {
1601        parse_type_list(tp_list, false, &dctx(), &dcfg())
1602            .expect("tp list parses")
1603            .pformat()
1604    }
1605
1606    fn kinds_of(arglist: &str) -> Vec<ParamKind> {
1607        signature_from_str(arglist)
1608            .expect("arglist parses")
1609            .into_iter()
1610            .map(|p| p.kind)
1611            .collect()
1612    }
1613
1614    fn sig_match(tp_span: (usize, usize), arg_span: (usize, usize)) -> PySigMatch {
1615        PySigMatch {
1616            prefix: None,
1617            name: String::new(),
1618            tp_list: None,
1619            arglist: None,
1620            retann: None,
1621            tp_span,
1622            arg_span,
1623        }
1624    }
1625
1626    fn cfg_max(global: i64) -> PySigConfig {
1627        PySigConfig {
1628            maximum_signature_line_length: Some(global),
1629            ..PySigConfig::default()
1630        }
1631    }
1632
1633    /// `multi_line_flags` under a global max_len and no directive options.
1634    fn flags(
1635        sig: &str,
1636        tp_span: (usize, usize),
1637        arg_span: (usize, usize),
1638        max: i64,
1639    ) -> (bool, bool) {
1640        multi_line_flags(
1641            sig,
1642            &sig_match(tp_span, arg_span),
1643            SingleLineOpts::default(),
1644            &cfg_max(max),
1645        )
1646    }
1647
1648    /// The default attr head every parsed/pseudo list carries ([SIG §1.4]).
1649    const PL_HEAD: &str = "<desc_parameterlist multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n";
1650    const TPL_HEAD: &str = "<desc_type_parameter_list multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n";
1651
1652    // -- signature_from_str: grammar ---------------------------------------
1653
1654    /// `def func(): pass` and whitespace-only arglists have no parameters.
1655    #[test]
1656    fn sig_empty_arglist_is_no_params() {
1657        assert_eq!(signature_from_str(""), Ok(Vec::new()));
1658        assert_eq!(signature_from_str("   "), Ok(Vec::new()));
1659    }
1660
1661    /// Plain names classify POSITIONAL_OR_KEYWORD
1662    /// (`util/inspect.py:997-1001`).
1663    #[test]
1664    fn sig_plain_params_are_positional_or_keyword() {
1665        assert_eq!(
1666            signature_from_str("a, b").unwrap(),
1667            vec![
1668                PyParam {
1669                    name: "a".to_string(),
1670                    kind: ParamKind::PositionalOrKeyword,
1671                    annotation: None,
1672                    default: None,
1673                },
1674                PyParam {
1675                    name: "b".to_string(),
1676                    kind: ParamKind::PositionalOrKeyword,
1677                    annotation: None,
1678                    default: None,
1679                },
1680            ]
1681        );
1682    }
1683
1684    /// The full marker set of probe §1.6 function_full_markers: default,
1685    /// `*args`, annotated keyword-only default, `**kwargs`.
1686    #[test]
1687    fn sig_full_marker_classification() {
1688        assert_eq!(
1689            signature_from_str("a, b=1, *args, c: int = 2, **kwargs").unwrap(),
1690            vec![
1691                PyParam {
1692                    name: "a".to_string(),
1693                    kind: ParamKind::PositionalOrKeyword,
1694                    annotation: None,
1695                    default: None,
1696                },
1697                PyParam {
1698                    name: "b".to_string(),
1699                    kind: ParamKind::PositionalOrKeyword,
1700                    annotation: None,
1701                    default: Some("1".to_string()),
1702                },
1703                PyParam {
1704                    name: "args".to_string(),
1705                    kind: ParamKind::VarPositional,
1706                    annotation: None,
1707                    default: None,
1708                },
1709                PyParam {
1710                    name: "c".to_string(),
1711                    kind: ParamKind::KeywordOnly,
1712                    annotation: Some("int".to_string()),
1713                    default: Some("2".to_string()),
1714                },
1715                PyParam {
1716                    name: "kwargs".to_string(),
1717                    kind: ParamKind::VarKeyword,
1718                    annotation: None,
1719                    default: None,
1720                },
1721            ]
1722        );
1723    }
1724
1725    /// `/` reclassifies everything before it as positional-only; a bare
1726    /// `*` opens the keyword-only zone (probe §1.6 function_posonly).
1727    #[test]
1728    fn sig_slash_and_star_zones() {
1729        assert_eq!(
1730            kinds_of("a, /, b, *, c"),
1731            vec![
1732                ParamKind::PositionalOnly,
1733                ParamKind::PositionalOrKeyword,
1734                ParamKind::KeywordOnly,
1735            ]
1736        );
1737        assert_eq!(kinds_of("a, /"), vec![ParamKind::PositionalOnly]);
1738        assert_eq!(
1739            kinds_of("a, *args, b"),
1740            vec![
1741                ParamKind::PositionalOrKeyword,
1742                ParamKind::VarPositional,
1743                ParamKind::KeywordOnly,
1744            ]
1745        );
1746        assert_eq!(kinds_of("*, a"), vec![ParamKind::KeywordOnly]);
1747    }
1748
1749    /// Duplicate names are `inspect.Signature`'s ValueError — the warning
1750    /// channel, distinct from Syntax [PY §1.3 step 5]. `ast.parse` accepts
1751    /// them across every parameter kind (probed: `def f(a, /, a)` and
1752    /// `def f(*a, **a)` both parse).
1753    #[test]
1754    fn sig_duplicate_names_are_duplicate_errors() {
1755        for arglist in ["a, a", "a, /, a", "a, *a", "a, **a", "a, *, a"] {
1756            assert_eq!(
1757                signature_from_str(arglist),
1758                Err(SigParseError::Duplicate("a".to_string())),
1759                "arglist {arglist:?}"
1760            );
1761        }
1762        // First duplicate in sequence order wins.
1763        assert_eq!(
1764            signature_from_str("x, y, y, x"),
1765            Err(SigParseError::Duplicate("y".to_string()))
1766        );
1767    }
1768
1769    /// CPython-parser grammar violations (each probed against ast.parse in
1770    /// the task-5 report) are the silent Syntax channel.
1771    #[test]
1772    fn sig_grammar_violations_are_syntax_errors() {
1773        for arglist in [
1774            "/",
1775            "/, a",
1776            "a, /, b, /",
1777            "*",
1778            "a, *,",
1779            "*, **kw",
1780            "**kw, a",
1781            "*a, *b",
1782            "*args, /",
1783            ",",
1784            "a, , b",
1785            "*args=1",
1786            "**kw=2",
1787            "a=1, b",
1788            "a=1, /, b",
1789            "a b",
1790            "if",
1791            "None",
1792            "x.y",
1793            "x()",
1794            "42",
1795        ] {
1796            assert!(
1797                matches!(signature_from_str(arglist), Err(SigParseError::Syntax(_))),
1798                "arglist {arglist:?}"
1799            );
1800        }
1801    }
1802
1803    /// Keyword-only parameters are exempt from the non-default-after-
1804    /// default rule (probed: `def f(a=1, *, b)` is valid, and so is a
1805    /// keyword-only gap like `def f(*, a=1, b)`).
1806    #[test]
1807    fn sig_keyword_only_zone_allows_default_gaps() {
1808        assert!(signature_from_str("a=1, *, b").is_ok());
1809        assert!(signature_from_str("*, a=1, b").is_ok());
1810        assert!(signature_from_str("a=1, *args, b").is_ok());
1811        assert!(signature_from_str("a=1, /, b=2").is_ok());
1812    }
1813
1814    /// Trailing commas are fine everywhere ast allows them.
1815    #[test]
1816    fn sig_trailing_comma_allowed() {
1817        assert_eq!(kinds_of("a,").len(), 1);
1818        assert_eq!(kinds_of("**kw, ").len(), 1);
1819        assert_eq!(kinds_of("*args,").len(), 1);
1820    }
1821
1822    // -- signature_from_str: pycode-unparse normalization ------------------
1823
1824    /// Defaults keep numeric SOURCE text (`get_source_segment`) but
1825    /// normalize container spacing — probe task5/default_normalized:
1826    /// `f(x=0xFF, y=[1,2])` renders `0xFF` and `[1, 2]`.
1827    #[test]
1828    fn sig_defaults_keep_numeric_source_text() {
1829        let params = signature_from_str("x=0xFF, y=[1,2], z=1_000, w=1e5").unwrap();
1830        let defaults: Vec<&str> = params
1831            .iter()
1832            .map(|p| p.default.as_deref().unwrap())
1833            .collect();
1834        assert_eq!(defaults, vec!["0xFF", "[1, 2]", "1_000", "1e5"]);
1835    }
1836
1837    /// `sphinx.pycode.ast._UnparseVisitor` differences from `ast.unparse`
1838    /// (module docs): no precedence parens, unspaced `**`, `u''` prefix
1839    /// dropped, string/bytes/bool via repr.
1840    #[test]
1841    fn sig_defaults_use_pycode_unparse_rules() {
1842        let params = signature_from_str(
1843            "a=(x+y)*z, b=x**y, c=u'v', d='s', e=-1, f=~x, g=(1,), h={1: 2}, i={3}, j=f2(4, k=5), k=x[1:2:3]",
1844        );
1845        // `x[1:2:3]` is a slice — outside the task-3 subset → Syntax.
1846        assert!(matches!(params, Err(SigParseError::Syntax(_))));
1847        let params = signature_from_str(
1848            "a=(x+y)*z, b=x**y, c=u'v', d='s', e=-1, f=~x, g=(1,), h={1: 2}, i={3}, j=f2(4, k=5)",
1849        )
1850        .unwrap();
1851        let defaults: Vec<&str> = params
1852            .iter()
1853            .map(|p| p.default.as_deref().unwrap())
1854            .collect();
1855        assert_eq!(
1856            defaults,
1857            vec![
1858                "x + y * z",
1859                "x**y",
1860                "'v'",
1861                "'s'",
1862                "-1",
1863                "~x",
1864                "(1,)",
1865                "{1: 2}",
1866                "{3}",
1867                "f2(4, k=5)",
1868            ]
1869        );
1870    }
1871
1872    /// Annotations round through the same normalizer before task 4
1873    /// re-parses them (`_annotations.py:495`).
1874    #[test]
1875    fn sig_annotations_are_pycode_normalized() {
1876        let params = signature_from_str("x: dict[str,int], y: 'T'").unwrap();
1877        assert_eq!(params[0].annotation.as_deref(), Some("dict[str, int]"));
1878        assert_eq!(params[1].annotation.as_deref(), Some("'T'"));
1879    }
1880
1881    /// Commas inside string literals do not split parameters (probe
1882    /// task5/string_comma_default).
1883    #[test]
1884    fn sig_string_protected_comma() {
1885        let params = signature_from_str("x='a,b'").unwrap();
1886        assert_eq!(params.len(), 1);
1887        assert_eq!(params[0].default.as_deref(), Some("'a,b'"));
1888    }
1889
1890    /// A depth-0 `lambda` is rejected up front (module docs: its parameter
1891    /// commas would defeat the comma split; sphinx renders
1892    /// `lambda a, b: ...` — documented divergence).
1893    #[test]
1894    fn sig_top_level_lambda_is_syntax() {
1895        assert!(matches!(
1896            signature_from_str("x=lambda a, b: 0"),
1897            Err(SigParseError::Syntax(_))
1898        ));
1899    }
1900
1901    // -- multi_line_flags: the [SIG A.1] matrix ----------------------------
1902
1903    /// Probe family A: what counts toward the length. `foo(aaaa)` len 9,
1904    /// arg inner span (4,8); strictly greater, so equality never flips.
1905    #[test]
1906    fn matrix_a_measurement() {
1907        // A1: len 9, max 9 → no flip.
1908        assert_eq!(flags("foo(aaaa)", (0, 0), (4, 8), 9), (false, false));
1909        // A2: len 9, max 8 → arglist flips.
1910        assert_eq!(flags("foo(aaaa)", (0, 0), (4, 8), 8), (true, false));
1911        // A3: 'foo(a) -> int' len 13 — the return annotation counts.
1912        assert_eq!(flags("foo(a) -> int", (0, 0), (4, 5), 12), (true, false));
1913        // A4: equal again → no flip.
1914        assert_eq!(flags("foo(a) -> int", (0, 0), (4, 5), 13), (false, false));
1915        // A5: 'Klass.foo(a)' len 12 — the dotted prefix counts.
1916        assert_eq!(flags("Klass.foo(a)", (0, 0), (10, 11), 11), (true, false));
1917        // A6: measurement happens on the STRIPPED signature (get_signatures
1918        // strips before py_sig_re runs) — same result as A1 by contract.
1919        assert_eq!(flags("foo(aaaa)", (0, 0), (4, 8), 9), (false, false));
1920    }
1921
1922    /// Probe family B: `foo[T](aaaa)` len 12, tp inner 'T' (4,5), arg
1923    /// inner 'aaaa' (7,11). Only the OTHER group's inner text is
1924    /// subtracted; the bracket characters themselves still count.
1925    #[test]
1926    fn matrix_b_span_subtraction() {
1927        // B1: arglist 12-1=11 > 10 flips; tp 12-4=8 > 10 doesn't.
1928        assert_eq!(flags("foo[T](aaaa)", (4, 5), (7, 11), 10), (true, false));
1929        // B2: both flip (11>7, 8>7).
1930        assert_eq!(flags("foo[T](aaaa)", (4, 5), (7, 11), 7), (true, true));
1931        // B3: neither (11>11 false, 8>11 false).
1932        assert_eq!(flags("foo[T](aaaa)", (4, 5), (7, 11), 11), (false, false));
1933    }
1934
1935    /// Probe family C: config precedence through [`PySigConfig::max_len`],
1936    /// including the falsy-zero trap (C4) and the `> max_len > 0` guard
1937    /// making the resolved 0 mean "off" (C5).
1938    #[test]
1939    fn matrix_c_config_precedence() {
1940        let m = sig_match((0, 0), (4, 8));
1941        let opts = SingleLineOpts::default();
1942        let run = |python: Option<i64>, global: Option<i64>| {
1943            let cfg = PySigConfig {
1944                python_maximum_signature_line_length: python,
1945                maximum_signature_line_length: global,
1946                ..PySigConfig::default()
1947            };
1948            multi_line_flags("foo(aaaa)", &m, opts, &cfg).0
1949        };
1950        assert!(!run(Some(1000), Some(1))); // C1: python wins, no flip
1951        assert!(run(Some(1), Some(1000))); // C2: python wins, flip
1952        assert!(run(None, Some(1))); // C3: fallback to global
1953        assert!(run(Some(0), Some(1))); // C4: falsy 0 falls through
1954        assert!(!run(None, None)); // C5: resolved 0 → feature off
1955    }
1956
1957    /// Probes D1/D2: each single-line-* option suppresses ONLY its own
1958    /// list.
1959    #[test]
1960    fn matrix_d_single_line_options_are_independent() {
1961        let m = sig_match((4, 5), (7, 11));
1962        let cfg = cfg_max(1);
1963        assert_eq!(
1964            multi_line_flags(
1965                "foo[T](aaaa)",
1966                &m,
1967                SingleLineOpts {
1968                    parameter_list: true,
1969                    type_parameter_list: false,
1970                },
1971                &cfg
1972            ),
1973            (false, true)
1974        );
1975        assert_eq!(
1976            multi_line_flags(
1977                "foo[T](aaaa)",
1978                &m,
1979                SingleLineOpts {
1980                    parameter_list: false,
1981                    type_parameter_list: true,
1982                },
1983                &cfg
1984            ),
1985            (true, false)
1986        );
1987    }
1988
1989    // -- parse_arglist: node shapes ([PY §2.1], probe-verbatim) ------------
1990
1991    /// Probe §1.6 function_plain_args: one `desc_parameter >
1992    /// desc_sig_name` per plain parameter; attrs unconditional.
1993    #[test]
1994    fn arglist_plain_params() {
1995        assert_eq!(
1996            parsed("a, b"),
1997            [
1998                PL_HEAD,
1999                concat!(
2000                    "    <desc_parameter xml:space=\"preserve\">\n",
2001                    "        <desc_sig_name classes=\"n\">\n",
2002                    "            a\n",
2003                    "    <desc_parameter xml:space=\"preserve\">\n",
2004                    "        <desc_sig_name classes=\"n\">\n",
2005                    "            b\n",
2006                )
2007            ]
2008            .concat()
2009        );
2010    }
2011
2012    /// Probe §1.6 function_full_markers: unannotated default is a bare
2013    /// `=` operator + `default_value` inline; annotated default wraps the
2014    /// `=` in spaces; `*args`/`**kwargs` get a leading operator.
2015    #[test]
2016    fn arglist_full_markers() {
2017        assert_eq!(
2018            parsed("a, b=1, *args, c: int = 2, **kwargs"),
2019            [
2020                PL_HEAD,
2021                concat!(
2022                    "    <desc_parameter xml:space=\"preserve\">\n",
2023                    "        <desc_sig_name classes=\"n\">\n",
2024                    "            a\n",
2025                    "    <desc_parameter xml:space=\"preserve\">\n",
2026                    "        <desc_sig_name classes=\"n\">\n",
2027                    "            b\n",
2028                    "        <desc_sig_operator classes=\"o\">\n",
2029                    "            =\n",
2030                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2031                    "            1\n",
2032                    "    <desc_parameter xml:space=\"preserve\">\n",
2033                    "        <desc_sig_operator classes=\"o\">\n",
2034                    "            *\n",
2035                    "        <desc_sig_name classes=\"n\">\n",
2036                    "            args\n",
2037                    "    <desc_parameter xml:space=\"preserve\">\n",
2038                    "        <desc_sig_name classes=\"n\">\n",
2039                    "            c\n",
2040                    "        <desc_sig_punctuation classes=\"p\">\n",
2041                    "            :\n",
2042                    "        <desc_sig_space classes=\"w\">\n",
2043                    "             \n",
2044                    "        <desc_sig_name classes=\"n\">\n",
2045                    "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
2046                    "                int\n",
2047                    "        <desc_sig_space classes=\"w\">\n",
2048                    "             \n",
2049                    "        <desc_sig_operator classes=\"o\">\n",
2050                    "            =\n",
2051                    "        <desc_sig_space classes=\"w\">\n",
2052                    "             \n",
2053                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2054                    "            2\n",
2055                    "    <desc_parameter xml:space=\"preserve\">\n",
2056                    "        <desc_sig_operator classes=\"o\">\n",
2057                    "            **\n",
2058                    "        <desc_sig_name classes=\"n\">\n",
2059                    "            kwargs\n",
2060                )
2061            ]
2062            .concat()
2063        );
2064    }
2065
2066    /// Probe §1.6 function_posonly: the separators are `desc_parameter >
2067    /// desc_sig_operator(classes [<sep>, "o"]) > abbreviation` with the
2068    /// exact PEP explanations.
2069    #[test]
2070    fn arglist_separator_shapes() {
2071        assert_eq!(
2072            parsed("a, /, b, *, c"),
2073            [
2074                PL_HEAD,
2075                concat!(
2076                    "    <desc_parameter xml:space=\"preserve\">\n",
2077                    "        <desc_sig_name classes=\"n\">\n",
2078                    "            a\n",
2079                    "    <desc_parameter xml:space=\"preserve\">\n",
2080                    "        <desc_sig_operator classes=\"positional-only-separator o\">\n",
2081                    "            <abbreviation explanation=\"Positional-only parameter separator (PEP 570)\">\n",
2082                    "                /\n",
2083                    "    <desc_parameter xml:space=\"preserve\">\n",
2084                    "        <desc_sig_name classes=\"n\">\n",
2085                    "            b\n",
2086                    "    <desc_parameter xml:space=\"preserve\">\n",
2087                    "        <desc_sig_operator classes=\"keyword-only-separator o\">\n",
2088                    "            <abbreviation explanation=\"Keyword-only parameters separator (PEP 3102)\">\n",
2089                    "                *\n",
2090                    "    <desc_parameter xml:space=\"preserve\">\n",
2091                    "        <desc_sig_name classes=\"n\">\n",
2092                    "            c\n",
2093                )
2094            ]
2095            .concat()
2096        );
2097    }
2098
2099    /// Probe task5/posonly_trailing: `func(a, /)` — the loop epilogue
2100    /// (`_annotations.py:513-514`) emits the trailing `/` separator.
2101    #[test]
2102    fn arglist_trailing_slash_epilogue() {
2103        assert_eq!(
2104            parsed("a, /"),
2105            [
2106                PL_HEAD,
2107                concat!(
2108                    "    <desc_parameter xml:space=\"preserve\">\n",
2109                    "        <desc_sig_name classes=\"n\">\n",
2110                    "            a\n",
2111                    "    <desc_parameter xml:space=\"preserve\">\n",
2112                    "        <desc_sig_operator classes=\"positional-only-separator o\">\n",
2113                    "            <abbreviation explanation=\"Positional-only parameter separator (PEP 570)\">\n",
2114                    "                /\n",
2115                )
2116            ]
2117            .concat()
2118        );
2119    }
2120
2121    /// Probe task5/posonly_then_kwonly: `f(a, /, *, b)` emits BOTH
2122    /// separators back to back (the `/` from the kind transition, the `*`
2123    /// because last_kind was positional-only).
2124    #[test]
2125    fn arglist_adjacent_separators() {
2126        assert_eq!(
2127            parsed("a, /, *, b"),
2128            [
2129                PL_HEAD,
2130                concat!(
2131                    "    <desc_parameter xml:space=\"preserve\">\n",
2132                    "        <desc_sig_name classes=\"n\">\n",
2133                    "            a\n",
2134                    "    <desc_parameter xml:space=\"preserve\">\n",
2135                    "        <desc_sig_operator classes=\"positional-only-separator o\">\n",
2136                    "            <abbreviation explanation=\"Positional-only parameter separator (PEP 570)\">\n",
2137                    "                /\n",
2138                    "    <desc_parameter xml:space=\"preserve\">\n",
2139                    "        <desc_sig_operator classes=\"keyword-only-separator o\">\n",
2140                    "            <abbreviation explanation=\"Keyword-only parameters separator (PEP 3102)\">\n",
2141                    "                *\n",
2142                    "    <desc_parameter xml:space=\"preserve\">\n",
2143                    "        <desc_sig_name classes=\"n\">\n",
2144                    "            b\n",
2145                )
2146            ]
2147            .concat()
2148        );
2149    }
2150
2151    /// Probe task5/kwonly_first: `f(*, a)` — the keyword-only separator
2152    /// also fires from last_kind None.
2153    #[test]
2154    fn arglist_kwonly_separator_first() {
2155        assert_eq!(
2156            parsed("*, a"),
2157            [
2158                PL_HEAD,
2159                concat!(
2160                    "    <desc_parameter xml:space=\"preserve\">\n",
2161                    "        <desc_sig_operator classes=\"keyword-only-separator o\">\n",
2162                    "            <abbreviation explanation=\"Keyword-only parameters separator (PEP 3102)\">\n",
2163                    "                *\n",
2164                    "    <desc_parameter xml:space=\"preserve\">\n",
2165                    "        <desc_sig_name classes=\"n\">\n",
2166                    "            a\n",
2167                )
2168            ]
2169            .concat()
2170        );
2171    }
2172
2173    /// Probe task5/starargs_then_kwonly: after `*args` NO `*` separator is
2174    /// inserted before keyword-only parameters.
2175    #[test]
2176    fn arglist_no_separator_after_varargs() {
2177        assert_eq!(
2178            parsed("a, *args, b"),
2179            [
2180                PL_HEAD,
2181                concat!(
2182                    "    <desc_parameter xml:space=\"preserve\">\n",
2183                    "        <desc_sig_name classes=\"n\">\n",
2184                    "            a\n",
2185                    "    <desc_parameter xml:space=\"preserve\">\n",
2186                    "        <desc_sig_operator classes=\"o\">\n",
2187                    "            *\n",
2188                    "        <desc_sig_name classes=\"n\">\n",
2189                    "            args\n",
2190                    "    <desc_parameter xml:space=\"preserve\">\n",
2191                    "        <desc_sig_name classes=\"n\">\n",
2192                    "            b\n",
2193                )
2194            ]
2195            .concat()
2196        );
2197    }
2198
2199    /// Probe §1.6 function_default_str + probe task5/default_normalized:
2200    /// defaults keep their source-ish form through pycode-unparse.
2201    #[test]
2202    fn arglist_default_value_text() {
2203        assert_eq!(
2204            parsed("name='x', items=[]"),
2205            [
2206                PL_HEAD,
2207                concat!(
2208                    "    <desc_parameter xml:space=\"preserve\">\n",
2209                    "        <desc_sig_name classes=\"n\">\n",
2210                    "            name\n",
2211                    "        <desc_sig_operator classes=\"o\">\n",
2212                    "            =\n",
2213                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2214                    "            'x'\n",
2215                    "    <desc_parameter xml:space=\"preserve\">\n",
2216                    "        <desc_sig_name classes=\"n\">\n",
2217                    "            items\n",
2218                    "        <desc_sig_operator classes=\"o\">\n",
2219                    "            =\n",
2220                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2221                    "            []\n",
2222                )
2223            ]
2224            .concat()
2225        );
2226        assert_eq!(
2227            parsed("x=0xFF, y=[1,2]"),
2228            [
2229                PL_HEAD,
2230                concat!(
2231                    "    <desc_parameter xml:space=\"preserve\">\n",
2232                    "        <desc_sig_name classes=\"n\">\n",
2233                    "            x\n",
2234                    "        <desc_sig_operator classes=\"o\">\n",
2235                    "            =\n",
2236                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2237                    "            0xFF\n",
2238                    "    <desc_parameter xml:space=\"preserve\">\n",
2239                    "        <desc_sig_name classes=\"n\">\n",
2240                    "            y\n",
2241                    "        <desc_sig_operator classes=\"o\">\n",
2242                    "            =\n",
2243                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2244                    "            [1, 2]\n",
2245                )
2246            ]
2247            .concat()
2248        );
2249    }
2250
2251    /// Probe task5/annotated_default_parsed: `f(x: int = 2)` space-wraps
2252    /// the `=` because the parameter is annotated.
2253    #[test]
2254    fn arglist_annotated_default_is_space_wrapped() {
2255        assert_eq!(
2256            parsed("x: int = 2"),
2257            [
2258                PL_HEAD,
2259                concat!(
2260                    "    <desc_parameter xml:space=\"preserve\">\n",
2261                    "        <desc_sig_name classes=\"n\">\n",
2262                    "            x\n",
2263                    "        <desc_sig_punctuation classes=\"p\">\n",
2264                    "            :\n",
2265                    "        <desc_sig_space classes=\"w\">\n",
2266                    "             \n",
2267                    "        <desc_sig_name classes=\"n\">\n",
2268                    "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
2269                    "                int\n",
2270                    "        <desc_sig_space classes=\"w\">\n",
2271                    "             \n",
2272                    "        <desc_sig_operator classes=\"o\">\n",
2273                    "            =\n",
2274                    "        <desc_sig_space classes=\"w\">\n",
2275                    "             \n",
2276                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2277                    "            2\n",
2278                )
2279            ]
2280            .concat()
2281        );
2282    }
2283
2284    /// Probe task5/string_comma_default: `f(x='a,b')` parses as ONE
2285    /// parameter through the real grammar.
2286    #[test]
2287    fn arglist_string_comma_default() {
2288        assert_eq!(
2289            parsed("x='a,b'"),
2290            [
2291                PL_HEAD,
2292                concat!(
2293                    "    <desc_parameter xml:space=\"preserve\">\n",
2294                    "        <desc_sig_name classes=\"n\">\n",
2295                    "            x\n",
2296                    "        <desc_sig_operator classes=\"o\">\n",
2297                    "            =\n",
2298                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2299                    "            'a,b'\n",
2300                )
2301            ]
2302            .concat()
2303        );
2304    }
2305
2306    /// Contract: parse_arglist ALWAYS sets both attrs, an empty arglist
2307    /// included. NOTE probe task5/empty_args: sphinx's `f()` renders a
2308    /// BARE `<desc_parameterlist xml:space="preserve">` with NEITHER attr,
2309    /// because py_sig_re's group 4 is `''` (falsy) and `handle_signature`
2310    /// takes the `needs_arglist()` branch (`_object.py:382-385`) — task 6
2311    /// must route empty parens there, never through parse_arglist.
2312    #[test]
2313    fn arglist_empty_still_carries_attrs() {
2314        assert_eq!(parsed(""), PL_HEAD);
2315        assert_eq!(parsed("  "), PL_HEAD);
2316    }
2317
2318    /// [SIG §1.4] probes E1/E2/E3: multi_line mirrors the measured flag,
2319    /// multi_line_trailing_comma mirrors
2320    /// `python_trailing_comma_in_multi_line_signatures`, and both are
2321    /// recorded even when nothing wraps.
2322    #[test]
2323    fn arglist_attr_values_follow_inputs() {
2324        let flipped = parse_arglist("aaaa", true, &dctx(), &dcfg())
2325            .unwrap()
2326            .pformat();
2327        assert!(flipped.starts_with(
2328            "<desc_parameterlist multi_line_parameter_list=\"1\" multi_line_trailing_comma=\"1\""
2329        ));
2330        let no_comma_cfg = PySigConfig {
2331            python_trailing_comma_in_multi_line_signatures: false,
2332            ..PySigConfig::default()
2333        };
2334        let no_comma = parse_arglist("aaaa", true, &dctx(), &no_comma_cfg)
2335            .unwrap()
2336            .pformat();
2337        assert!(no_comma.starts_with(
2338            "<desc_parameterlist multi_line_parameter_list=\"1\" multi_line_trailing_comma=\"0\""
2339        ));
2340    }
2341
2342    /// parse_arglist propagates both error channels for task 6's
2343    /// warning/debug split.
2344    #[test]
2345    fn arglist_error_channels() {
2346        assert_eq!(
2347            parse_arglist("a, a", false, &dctx(), &dcfg()),
2348            Err(SigParseError::Duplicate("a".to_string()))
2349        );
2350        assert!(matches!(
2351            parse_arglist("a[, b]", false, &dctx(), &dcfg()),
2352            Err(SigParseError::Syntax(_))
2353        ));
2354    }
2355
2356    /// Widths come from the spanned slice; degenerate or out-of-range
2357    /// spans count 0 and never panic (totality).
2358    #[test]
2359    fn matrix_span_edge_cases() {
2360        assert_eq!(flags("foo(aaaa)", (5, 2), (4, 8), 8), (true, false));
2361        assert_eq!(flags("foo(aaaa)", (3, 999), (4, 8), 8), (true, false));
2362        // Char counting: 'fóó(aaaa)' is 9 Python chars (11 bytes).
2363        assert_eq!(
2364            flags("f\u{f3}\u{f3}(aaaa)", (0, 0), (6, 10), 9),
2365            (false, false)
2366        );
2367        assert_eq!(
2368            flags("f\u{f3}\u{f3}(aaaa)", (0, 0), (6, 10), 8),
2369            (true, false)
2370        );
2371    }
2372
2373    // -- pseudo_parse_arglist ([PY §2.2], probe-verbatim) ------------------
2374
2375    /// Probe §1.6 function_brackets_fallback: `func(a[, b])` — brackets
2376    /// push/pop `desc_optional`; the pseudo list carries both attrs.
2377    #[test]
2378    fn pseudo_brackets_become_optional() {
2379        assert_eq!(
2380            pseudo("a[, b]"),
2381            [
2382                PL_HEAD,
2383                concat!(
2384                    "    <desc_parameter xml:space=\"preserve\">\n",
2385                    "        <desc_sig_name classes=\"n\">\n",
2386                    "            a\n",
2387                    "    <desc_optional xml:space=\"preserve\">\n",
2388                    "        <desc_parameter xml:space=\"preserve\">\n",
2389                    "            <desc_sig_name classes=\"n\">\n",
2390                    "                b\n",
2391                )
2392            ]
2393            .concat()
2394        );
2395    }
2396
2397    /// Nested optionals stack (`_annotations.py:564-576`, `602-607`).
2398    #[test]
2399    fn pseudo_nested_optionals() {
2400        assert_eq!(
2401            pseudo("a[, b[, c]]"),
2402            [
2403                PL_HEAD,
2404                concat!(
2405                    "    <desc_parameter xml:space=\"preserve\">\n",
2406                    "        <desc_sig_name classes=\"n\">\n",
2407                    "            a\n",
2408                    "    <desc_optional xml:space=\"preserve\">\n",
2409                    "        <desc_parameter xml:space=\"preserve\">\n",
2410                    "            <desc_sig_name classes=\"n\">\n",
2411                    "                b\n",
2412                    "        <desc_optional xml:space=\"preserve\">\n",
2413                    "            <desc_parameter xml:space=\"preserve\">\n",
2414                    "                <desc_sig_name classes=\"n\">\n",
2415                    "                    c\n",
2416                )
2417            ]
2418            .concat()
2419        );
2420    }
2421
2422    /// Unannotated pseudo default: `=` is a BARE desc_sig_operator, no
2423    /// spaces (`_annotations.py:592-599`).
2424    #[test]
2425    fn pseudo_bare_equals_when_unannotated() {
2426        assert_eq!(
2427            pseudo("n=1"),
2428            [
2429                PL_HEAD,
2430                concat!(
2431                    "    <desc_parameter xml:space=\"preserve\">\n",
2432                    "        <desc_sig_name classes=\"n\">\n",
2433                    "            n\n",
2434                    "        <desc_sig_operator classes=\"o\">\n",
2435                    "            =\n",
2436                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2437                    "            1\n",
2438                )
2439            ]
2440            .concat()
2441        );
2442    }
2443
2444    /// Probe task5/pseudo_annotated_default: `func(x: int=2[, y])` — the
2445    /// pseudo parser space-wraps `=` only when annotated and renders the
2446    /// annotation through `_parse_annotation`.
2447    #[test]
2448    fn pseudo_annotated_default() {
2449        assert_eq!(
2450            pseudo("x: int=2[, y]"),
2451            [
2452                PL_HEAD,
2453                concat!(
2454                    "    <desc_parameter xml:space=\"preserve\">\n",
2455                    "        <desc_sig_name classes=\"n\">\n",
2456                    "            x\n",
2457                    "        <desc_sig_punctuation classes=\"p\">\n",
2458                    "            :\n",
2459                    "        <desc_sig_space classes=\"w\">\n",
2460                    "             \n",
2461                    "        <desc_sig_name classes=\"n\">\n",
2462                    "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
2463                    "                int\n",
2464                    "        <desc_sig_space classes=\"w\">\n",
2465                    "             \n",
2466                    "        <desc_sig_operator classes=\"o\">\n",
2467                    "            =\n",
2468                    "        <desc_sig_space classes=\"w\">\n",
2469                    "             \n",
2470                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2471                    "            2\n",
2472                    "    <desc_optional xml:space=\"preserve\">\n",
2473                    "        <desc_parameter xml:space=\"preserve\">\n",
2474                    "            <desc_sig_name classes=\"n\">\n",
2475                    "                y\n",
2476                )
2477            ]
2478            .concat()
2479        );
2480    }
2481
2482    /// Probe task5/dup_param_warning: the pseudo fallback for `f(a, a)`
2483    /// still carries both attrs — they are unconditional on this path too
2484    /// ([SIG §1.4]).
2485    #[test]
2486    fn pseudo_attrs_are_unconditional() {
2487        assert_eq!(
2488            pseudo("a, a"),
2489            [
2490                PL_HEAD,
2491                concat!(
2492                    "    <desc_parameter xml:space=\"preserve\">\n",
2493                    "        <desc_sig_name classes=\"n\">\n",
2494                    "            a\n",
2495                    "    <desc_parameter xml:space=\"preserve\">\n",
2496                    "        <desc_sig_name classes=\"n\">\n",
2497                    "            a\n",
2498                )
2499            ]
2500            .concat()
2501        );
2502    }
2503
2504    /// Star prefixes survive verbatim inside the pseudo name (the
2505    /// partition puts them in param_name).
2506    #[test]
2507    fn pseudo_star_names_kept() {
2508        assert_eq!(
2509            pseudo("*args, **kw"),
2510            [
2511                PL_HEAD,
2512                concat!(
2513                    "    <desc_parameter xml:space=\"preserve\">\n",
2514                    "        <desc_sig_name classes=\"n\">\n",
2515                    "            *args\n",
2516                    "    <desc_parameter xml:space=\"preserve\">\n",
2517                    "        <desc_sig_name classes=\"n\">\n",
2518                    "            **kw\n",
2519                )
2520            ]
2521            .concat()
2522        );
2523    }
2524
2525    /// Probe task5/imbalance: `func(a[, b)` — total bracket imbalance
2526    /// discards the built list for a FRESH paramlist with NO multi_line
2527    /// attrs holding the raw arglist as one desc_parameter (the single
2528    /// attr-less exception, [SIG §1.4]).
2529    #[test]
2530    fn pseudo_imbalance_gives_attrless_raw_parameter() {
2531        assert_eq!(
2532            pseudo("a[, b"),
2533            concat!(
2534                "<desc_parameterlist xml:space=\"preserve\">\n",
2535                "    <desc_parameter xml:space=\"preserve\">\n",
2536                "        a[, b\n",
2537            )
2538        );
2539        // Too many closers hits the same give-up route.
2540        assert_eq!(
2541            pseudo("a], b"),
2542            concat!(
2543                "<desc_parameterlist xml:space=\"preserve\">\n",
2544                "    <desc_parameter xml:space=\"preserve\">\n",
2545                "        a], b\n",
2546            )
2547        );
2548    }
2549
2550    // -- parse_type_list ([PY §2.4], probe-verbatim) -----------------------
2551
2552    /// Probe task5/class_typeparams: `C[T, *Ts, **P]` — plain name,
2553    /// `*`/`**` operators before variadic names.
2554    #[test]
2555    fn tp_plain_and_variadic_params() {
2556        assert_eq!(
2557            tp("T, *Ts, **P"),
2558            [
2559                TPL_HEAD,
2560                concat!(
2561                    "    <desc_type_parameter xml:space=\"preserve\">\n",
2562                    "        <desc_sig_name classes=\"n\">\n",
2563                    "            T\n",
2564                    "    <desc_type_parameter xml:space=\"preserve\">\n",
2565                    "        <desc_sig_operator classes=\"o\">\n",
2566                    "            *\n",
2567                    "        <desc_sig_name classes=\"n\">\n",
2568                    "            Ts\n",
2569                    "    <desc_type_parameter xml:space=\"preserve\">\n",
2570                    "        <desc_sig_operator classes=\"o\">\n",
2571                    "            **\n",
2572                    "        <desc_sig_name classes=\"n\">\n",
2573                    "            P\n",
2574                )
2575            ]
2576            .concat()
2577        );
2578    }
2579
2580    /// Probe task5/typeparams_bound: bound after `:` + space inside one
2581    /// desc_sig_name wrapper.
2582    #[test]
2583    fn tp_bound() {
2584        assert_eq!(
2585            tp("T: int"),
2586            [
2587                TPL_HEAD,
2588                concat!(
2589                    "    <desc_type_parameter xml:space=\"preserve\">\n",
2590                    "        <desc_sig_name classes=\"n\">\n",
2591                    "            T\n",
2592                    "        <desc_sig_punctuation classes=\"p\">\n",
2593                    "            :\n",
2594                    "        <desc_sig_space classes=\"w\">\n",
2595                    "             \n",
2596                    "        <desc_sig_name classes=\"n\">\n",
2597                    "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
2598                    "                int\n",
2599                )
2600            ]
2601            .concat()
2602        );
2603    }
2604
2605    /// Probe task5/typeparams_constraint: `f[T: (int, str)]` — the tuple
2606    /// loses its parens in `_parse_annotation`, so they are re-added as
2607    /// punctuation around the wrapper.
2608    #[test]
2609    fn tp_constraint_reparenthesized() {
2610        assert_eq!(
2611            tp("T: (int, str)"),
2612            [
2613                TPL_HEAD,
2614                concat!(
2615                    "    <desc_type_parameter xml:space=\"preserve\">\n",
2616                    "        <desc_sig_name classes=\"n\">\n",
2617                    "            T\n",
2618                    "        <desc_sig_punctuation classes=\"p\">\n",
2619                    "            :\n",
2620                    "        <desc_sig_space classes=\"w\">\n",
2621                    "             \n",
2622                    "        <desc_sig_punctuation classes=\"p\">\n",
2623                    "            (\n",
2624                    "        <desc_sig_name classes=\"n\">\n",
2625                    "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
2626                    "                int\n",
2627                    "            <desc_sig_punctuation classes=\"p\">\n",
2628                    "                ,\n",
2629                    "            <desc_sig_space classes=\"w\">\n",
2630                    "                 \n",
2631                    "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"str\" reftype=\"class\">\n",
2632                    "                str\n",
2633                    "        <desc_sig_punctuation classes=\"p\">\n",
2634                    "            )\n",
2635                )
2636            ]
2637            .concat()
2638        );
2639    }
2640
2641    /// Probe task5/typeparams_default: `C[T = int]` — tp defaults always
2642    /// space-wrap `=` (unlike arglists) and the text is token-rebuilt, not
2643    /// pycode-unparsed.
2644    #[test]
2645    fn tp_default() {
2646        assert_eq!(
2647            tp("T = int"),
2648            [
2649                TPL_HEAD,
2650                concat!(
2651                    "    <desc_type_parameter xml:space=\"preserve\">\n",
2652                    "        <desc_sig_name classes=\"n\">\n",
2653                    "            T\n",
2654                    "        <desc_sig_space classes=\"w\">\n",
2655                    "             \n",
2656                    "        <desc_sig_operator classes=\"o\">\n",
2657                    "            =\n",
2658                    "        <desc_sig_space classes=\"w\">\n",
2659                    "             \n",
2660                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2661                    "            int\n",
2662                )
2663            ]
2664            .concat()
2665        );
2666    }
2667
2668    /// Probe task5/typeparams_bound_default: bound and default combine.
2669    #[test]
2670    fn tp_bound_and_default() {
2671        assert_eq!(
2672            tp("T: int = str"),
2673            [
2674                TPL_HEAD,
2675                concat!(
2676                    "    <desc_type_parameter xml:space=\"preserve\">\n",
2677                    "        <desc_sig_name classes=\"n\">\n",
2678                    "            T\n",
2679                    "        <desc_sig_punctuation classes=\"p\">\n",
2680                    "            :\n",
2681                    "        <desc_sig_space classes=\"w\">\n",
2682                    "             \n",
2683                    "        <desc_sig_name classes=\"n\">\n",
2684                    "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
2685                    "                int\n",
2686                    "        <desc_sig_space classes=\"w\">\n",
2687                    "             \n",
2688                    "        <desc_sig_operator classes=\"o\">\n",
2689                    "            =\n",
2690                    "        <desc_sig_space classes=\"w\">\n",
2691                    "             \n",
2692                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2693                    "            str\n",
2694                )
2695            ]
2696            .concat()
2697        );
2698    }
2699
2700    /// Probe task5/typeparams_star_default: `C[*Ts = *tuple[int, ...]]` —
2701    /// PEP 696 default on a TypeVarTuple; the unpack `*` stays flush
2702    /// (native) while `,` gets its trailing space in the token rebuild.
2703    #[test]
2704    fn tp_star_default_native_unpack() {
2705        assert_eq!(
2706            tp("*Ts = *tuple[int, ...]"),
2707            [
2708                TPL_HEAD,
2709                concat!(
2710                    "    <desc_type_parameter xml:space=\"preserve\">\n",
2711                    "        <desc_sig_operator classes=\"o\">\n",
2712                    "            *\n",
2713                    "        <desc_sig_name classes=\"n\">\n",
2714                    "            Ts\n",
2715                    "        <desc_sig_space classes=\"w\">\n",
2716                    "             \n",
2717                    "        <desc_sig_operator classes=\"o\">\n",
2718                    "            =\n",
2719                    "        <desc_sig_space classes=\"w\">\n",
2720                    "             \n",
2721                    "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
2722                    "            *tuple[int, ...]\n",
2723                )
2724            ]
2725            .concat()
2726        );
2727    }
2728
2729    /// Probe task5/typeparams_union_bound: `C[T: int | str]` — the `|`
2730    /// gets spaces in the token rebuild and xrefs in the annotation walk.
2731    #[test]
2732    fn tp_union_bound() {
2733        assert_eq!(
2734            tp("T: int | str"),
2735            [
2736                TPL_HEAD,
2737                concat!(
2738                    "    <desc_type_parameter xml:space=\"preserve\">\n",
2739                    "        <desc_sig_name classes=\"n\">\n",
2740                    "            T\n",
2741                    "        <desc_sig_punctuation classes=\"p\">\n",
2742                    "            :\n",
2743                    "        <desc_sig_space classes=\"w\">\n",
2744                    "             \n",
2745                    "        <desc_sig_name classes=\"n\">\n",
2746                    "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
2747                    "                int\n",
2748                    "            <desc_sig_space classes=\"w\">\n",
2749                    "                 \n",
2750                    "            <desc_sig_punctuation classes=\"p\">\n",
2751                    "                |\n",
2752                    "            <desc_sig_space classes=\"w\">\n",
2753                    "                 \n",
2754                    "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"str\" reftype=\"class\">\n",
2755                    "                str\n",
2756                )
2757            ]
2758            .concat()
2759        );
2760    }
2761
2762    /// A bound/constraint on a variadic type parameter is the exact
2763    /// SyntaxError sphinx raises (`_annotations.py:308-315`); its message
2764    /// feeds task 6's tp-list warning verbatim.
2765    #[test]
2766    fn tp_variadic_bound_is_error() {
2767        assert_eq!(
2768            parse_type_list("*Ts: int", false, &dctx(), &dcfg()),
2769            Err(SigParseError::Syntax(
2770                "type parameter bound or constraint is not allowed for variadic positional parameters"
2771                    .to_string()
2772            ))
2773        );
2774        assert_eq!(
2775            parse_type_list("**P: int", false, &dctx(), &dcfg()),
2776            Err(SigParseError::Syntax(
2777                "type parameter bound or constraint is not allowed for variadic keyword parameters"
2778                    .to_string()
2779            ))
2780        );
2781        // ...but a DEFAULT on a variadic is fine (PEP 696, probe
2782        // task5/typeparams_star_default).
2783        assert!(parse_type_list("*Ts = 1", false, &dctx(), &dcfg()).is_ok());
2784    }
2785
2786    /// Unclosed brackets mirror tokenize's TokenError (sphinx's tp-list
2787    /// arm catches ANY exception into its warning).
2788    #[test]
2789    fn tp_unclosed_bracket_is_error() {
2790        assert!(parse_type_list("T: (int", false, &dctx(), &dcfg()).is_err());
2791    }
2792
2793    /// The tp list carries the same unconditional attr pair (probes
2794    /// long_typeparams / long_typeparams_single, [PY §2.5]).
2795    #[test]
2796    fn tp_attr_values_follow_inputs() {
2797        let flipped = parse_type_list("T", true, &dctx(), &dcfg())
2798            .unwrap()
2799            .pformat();
2800        assert!(flipped.starts_with(
2801            "<desc_type_parameter_list multi_line_parameter_list=\"1\" multi_line_trailing_comma=\"1\""
2802        ));
2803        let no_comma_cfg = PySigConfig {
2804            python_trailing_comma_in_multi_line_signatures: false,
2805            ..PySigConfig::default()
2806        };
2807        let no_comma = parse_type_list("T", true, &dctx(), &no_comma_cfg)
2808            .unwrap()
2809            .pformat();
2810        assert!(no_comma.starts_with(
2811            "<desc_type_parameter_list multi_line_parameter_list=\"1\" multi_line_trailing_comma=\"0\""
2812        ));
2813    }
2814
2815    // -- post-commit probe pins (probe task5/probe_arglist2, see report) ---
2816
2817    /// Probe task5/neg_default: a unary minus renders flush against the
2818    /// numeric SOURCE segment — `y=- 2` becomes `-2`.
2819    #[test]
2820    fn sig_negative_defaults_render_flush() {
2821        let params = signature_from_str("x=-1, y=- 2").unwrap();
2822        assert_eq!(params[0].default.as_deref(), Some("-1"));
2823        assert_eq!(params[1].default.as_deref(), Some("-2"));
2824    }
2825
2826    /// Probe task5/none_default_str_ann: a string annotation survives as
2827    /// its repr (and task 4 renders it as a literal string, not an xref);
2828    /// `None` defaults are repr'd.
2829    #[test]
2830    fn sig_string_annotation_and_none_default() {
2831        let params = signature_from_str("x: 'A[int]' = None").unwrap();
2832        assert_eq!(params[0].annotation.as_deref(), Some("'A[int]'"));
2833        assert_eq!(params[0].default.as_deref(), Some("None"));
2834    }
2835
2836    /// Probe task5/varargs_annotated: annotations attach after the
2837    /// `*`/`**` operator + name pair inside the same desc_parameter.
2838    #[test]
2839    fn arglist_annotated_variadics() {
2840        assert_eq!(
2841            parsed("*args: int, **kw: str"),
2842            [
2843                PL_HEAD,
2844                concat!(
2845                    "    <desc_parameter xml:space=\"preserve\">\n",
2846                    "        <desc_sig_operator classes=\"o\">\n",
2847                    "            *\n",
2848                    "        <desc_sig_name classes=\"n\">\n",
2849                    "            args\n",
2850                    "        <desc_sig_punctuation classes=\"p\">\n",
2851                    "            :\n",
2852                    "        <desc_sig_space classes=\"w\">\n",
2853                    "             \n",
2854                    "        <desc_sig_name classes=\"n\">\n",
2855                    "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
2856                    "                int\n",
2857                    "    <desc_parameter xml:space=\"preserve\">\n",
2858                    "        <desc_sig_operator classes=\"o\">\n",
2859                    "            **\n",
2860                    "        <desc_sig_name classes=\"n\">\n",
2861                    "            kw\n",
2862                    "        <desc_sig_punctuation classes=\"p\">\n",
2863                    "            :\n",
2864                    "        <desc_sig_space classes=\"w\">\n",
2865                    "             \n",
2866                    "        <desc_sig_name classes=\"n\">\n",
2867                    "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"str\" reftype=\"class\">\n",
2868                    "                str\n",
2869                )
2870            ]
2871            .concat()
2872        );
2873    }
2874
2875    /// Probe task5/pseudo_empty_default: `f(a=[, b])` — the pseudo
2876    /// parser's `if default_value:` truthiness drops an empty default
2877    /// entirely (the `[` was already stripped as an optional-opener).
2878    #[test]
2879    fn pseudo_empty_default_is_dropped() {
2880        assert_eq!(
2881            pseudo("a=[, b]"),
2882            [
2883                PL_HEAD,
2884                concat!(
2885                    "    <desc_parameter xml:space=\"preserve\">\n",
2886                    "        <desc_sig_name classes=\"n\">\n",
2887                    "            a\n",
2888                    "    <desc_optional xml:space=\"preserve\">\n",
2889                    "        <desc_parameter xml:space=\"preserve\">\n",
2890                    "            <desc_sig_name classes=\"n\">\n",
2891                    "                b\n",
2892                )
2893            ]
2894            .concat()
2895        );
2896    }
2897
2898    /// Probe task5/eq_in_string_default + tuple_default: `=`/`:` inside
2899    /// string literals never split, and tuple defaults keep canonical
2900    /// parens.
2901    #[test]
2902    fn sig_string_and_tuple_default_edges() {
2903        let params = signature_from_str("x='a=b', y: str='c:d', z=(1, 2), w=()").unwrap();
2904        assert_eq!(params[0].default.as_deref(), Some("'a=b'"));
2905        assert_eq!(params[1].annotation.as_deref(), Some("str"));
2906        assert_eq!(params[1].default.as_deref(), Some("'c:d'"));
2907        assert_eq!(params[2].default.as_deref(), Some("(1, 2)"));
2908        assert_eq!(params[3].default.as_deref(), Some("()"));
2909    }
2910
2911    // -- totality ----------------------------------------------------------
2912
2913    /// No entry point panics on arbitrary garbage (grammar, lexer and
2914    /// nesting edge cases alike).
2915    #[test]
2916    fn totality_on_garbage_input() {
2917        let horrors = [
2918            "((((((",
2919            "]]]]",
2920            "'unterminated",
2921            "a=(",
2922            "\\",
2923            "\u{1f980}",
2924            "a: :",
2925            "=x",
2926            ":int",
2927            "x=='y'",
2928            "a[b[c[d[",
2929            "\"\"\"",
2930            "0x, 1_, 1e",
2931            "f'{a,b}'",
2932            "., .., ...",
2933        ];
2934        let deep_parens = "(".repeat(10_000);
2935        let deep_brackets = "[, ".repeat(5_000);
2936        for arglist in horrors
2937            .iter()
2938            .copied()
2939            .chain([deep_parens.as_str(), deep_brackets.as_str()])
2940        {
2941            let _ = signature_from_str(arglist);
2942            let _ = parse_arglist(arglist, false, &dctx(), &dcfg());
2943            let _ = pseudo_parse_arglist(arglist, true, &dctx(), &dcfg());
2944            let _ = parse_type_list(arglist, false, &dctx(), &dcfg());
2945        }
2946    }
2947
2948    /// PEP 646 `def f(*args: *Ts)`: CPython's `star_annotation`
2949    /// production makes `*args` the only slot a starred annotation may
2950    /// occupy, `signature_from_str` hands sphinx the annotation string
2951    /// `*Ts`, and `_parse_annotation` splits the `*` off into a
2952    /// `desc_sig_operator`.
2953    ///
2954    // oracle: `_parse_arglist('*args: *Ts', env)` under sphinx 9.1.0 /
2955    // docutils 0.22.4 (scratchpad A/p7.py).
2956    #[test]
2957    fn pep_646_star_annotation_on_var_positional() {
2958        assert_eq!(
2959            parsed("*args: *Ts"),
2960            concat!(
2961                "<desc_parameterlist multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
2962                "    <desc_parameter xml:space=\"preserve\">\n",
2963                "        <desc_sig_operator classes=\"o\">\n",
2964                "            *\n",
2965                "        <desc_sig_name classes=\"n\">\n",
2966                "            args\n",
2967                "        <desc_sig_punctuation classes=\"p\">\n",
2968                "            :\n",
2969                "        <desc_sig_space classes=\"w\">\n",
2970                "             \n",
2971                "        <desc_sig_name classes=\"n\">\n",
2972                "            <desc_sig_operator classes=\"o\">\n",
2973                "                *\n",
2974                "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"Ts\" reftype=\"class\">\n",
2975                "                Ts\n",
2976            )
2977        );
2978    }
2979
2980    /// The bracketed spelling autodoc emits. Before the
2981    /// `star_annotation` slot existed this fell all the way through to
2982    /// `pseudo_parse_arglist`, whose naive comma split then broke on the
2983    /// inner comma and produced an attribute-less parameter list.
2984    ///
2985    // oracle: `_parse_arglist('*args: *tuple[int, ...]', env)`
2986    // (scratchpad A/p7.py).
2987    #[test]
2988    fn pep_646_star_annotation_over_a_bracketed_unpack() {
2989        assert_eq!(
2990            parsed("*args: *tuple[int, ...]"),
2991            concat!(
2992                "<desc_parameterlist multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
2993                "    <desc_parameter xml:space=\"preserve\">\n",
2994                "        <desc_sig_operator classes=\"o\">\n",
2995                "            *\n",
2996                "        <desc_sig_name classes=\"n\">\n",
2997                "            args\n",
2998                "        <desc_sig_punctuation classes=\"p\">\n",
2999                "            :\n",
3000                "        <desc_sig_space classes=\"w\">\n",
3001                "             \n",
3002                "        <desc_sig_name classes=\"n\">\n",
3003                "            <desc_sig_operator classes=\"o\">\n",
3004                "                *\n",
3005                "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"tuple\" reftype=\"class\">\n",
3006                "                tuple\n",
3007                "            <desc_sig_punctuation classes=\"p\">\n",
3008                "                [\n",
3009                "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"int\" reftype=\"class\">\n",
3010                "                int\n",
3011                "            <desc_sig_punctuation classes=\"p\">\n",
3012                "                ,\n",
3013                "            <desc_sig_space classes=\"w\">\n",
3014                "                 \n",
3015                "            <desc_sig_punctuation classes=\"p\">\n",
3016                "                ...\n",
3017                "            <desc_sig_punctuation classes=\"p\">\n",
3018                "                ]\n",
3019            )
3020        );
3021    }
3022
3023    /// The rest of the list is unaffected by the starred annotation.
3024    ///
3025    // oracle: `_parse_arglist('a, *args: *Ts, b', env)` (scratchpad
3026    // A/p7.py).
3027    #[test]
3028    fn star_annotation_leaves_its_neighbours_alone() {
3029        assert_eq!(
3030            parsed("a, *args: *Ts, b"),
3031            concat!(
3032                "<desc_parameterlist multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3033                "    <desc_parameter xml:space=\"preserve\">\n",
3034                "        <desc_sig_name classes=\"n\">\n",
3035                "            a\n",
3036                "    <desc_parameter xml:space=\"preserve\">\n",
3037                "        <desc_sig_operator classes=\"o\">\n",
3038                "            *\n",
3039                "        <desc_sig_name classes=\"n\">\n",
3040                "            args\n",
3041                "        <desc_sig_punctuation classes=\"p\">\n",
3042                "            :\n",
3043                "        <desc_sig_space classes=\"w\">\n",
3044                "             \n",
3045                "        <desc_sig_name classes=\"n\">\n",
3046                "            <desc_sig_operator classes=\"o\">\n",
3047                "                *\n",
3048                "            <pending_xref py:class=\"True\" py:module=\"True\" refdomain=\"py\" refspecific=\"0\" reftarget=\"Ts\" reftype=\"class\">\n",
3049                "                Ts\n",
3050                "    <desc_parameter xml:space=\"preserve\">\n",
3051                "        <desc_sig_name classes=\"n\">\n",
3052                "            b\n",
3053            )
3054        );
3055    }
3056
3057    /// `visit_Constant` recovers numeric source text by AST position, so
3058    /// the callee's literals keep their own spellings. `NumPool` hands
3059    /// tokens out in render order instead, so the `Call` arm must render
3060    /// the callee BEFORE the arguments — otherwise `a(0x10).b(16)` comes
3061    /// back as `a(16).b(0x10)` (both re-parse to 16, so the pool's
3062    /// value check cannot catch the swap).
3063    ///
3064    // oracle: `_parse_arglist('x=a(0x10).b(16)', env)` (scratchpad
3065    // A/p7.py); `signature_from_str` gives the default 'a(0x10).b(16)'.
3066    #[test]
3067    fn numeric_source_recovery_follows_a_chained_call() {
3068        assert_eq!(
3069            parsed("x=a(0x10).b(16)"),
3070            concat!(
3071                "<desc_parameterlist multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3072                "    <desc_parameter xml:space=\"preserve\">\n",
3073                "        <desc_sig_name classes=\"n\">\n",
3074                "            x\n",
3075                "        <desc_sig_operator classes=\"o\">\n",
3076                "            =\n",
3077                "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
3078                "            a(0x10).b(16)\n",
3079            )
3080        );
3081    }
3082
3083    /// The same inversion with the callee itself a call.
3084    ///
3085    // oracle: `_parse_arglist('x=g(0xFF)(255)', env)` (scratchpad
3086    // A/p7.py).
3087    #[test]
3088    fn numeric_source_recovery_follows_a_called_call() {
3089        assert_eq!(
3090            parsed("x=g(0xFF)(255)"),
3091            concat!(
3092                "<desc_parameterlist multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3093                "    <desc_parameter xml:space=\"preserve\">\n",
3094                "        <desc_sig_name classes=\"n\">\n",
3095                "            x\n",
3096                "        <desc_sig_operator classes=\"o\">\n",
3097                "            =\n",
3098                "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
3099                "            g(0xFF)(255)\n",
3100            )
3101        );
3102    }
3103
3104    /// The other failure mode: mismatching values used to abandon the
3105    /// whole fragment to `repr` form, printing `P(493).mask(18)`.
3106    ///
3107    // oracle: `_parse_arglist('x=P(0o755).mask(0o022)', env)`
3108    // (scratchpad A/p7.py).
3109    #[test]
3110    fn numeric_source_recovery_keeps_octal_spellings_in_a_chain() {
3111        assert_eq!(
3112            parsed("x=P(0o755).mask(0o022)"),
3113            concat!(
3114                "<desc_parameterlist multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3115                "    <desc_parameter xml:space=\"preserve\">\n",
3116                "        <desc_sig_name classes=\"n\">\n",
3117                "            x\n",
3118                "        <desc_sig_operator classes=\"o\">\n",
3119                "            =\n",
3120                "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
3121                "            P(0o755).mask(0o022)\n",
3122            )
3123        );
3124    }
3125
3126    /// `sphinx.pycode.ast` has a first-class `visit_BoolOp`, so an
3127    /// `and`/`or` default renders through the AST path — it must not drop
3128    /// the whole list into `pseudo_parse_arglist`, which would replace the
3129    /// PEP 3102 separator's `abbreviation` with a bare `desc_sig_name`.
3130    ///
3131    // oracle: `_parse_arglist('a, *, x=A or B', env)` (scratchpad
3132    // A/p7.py).
3133    #[test]
3134    fn boolop_default_keeps_the_keyword_only_separator() {
3135        assert_eq!(
3136            parsed("a, *, x=A or B"),
3137            concat!(
3138                "<desc_parameterlist multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3139                "    <desc_parameter xml:space=\"preserve\">\n",
3140                "        <desc_sig_name classes=\"n\">\n",
3141                "            a\n",
3142                "    <desc_parameter xml:space=\"preserve\">\n",
3143                "        <desc_sig_operator classes=\"keyword-only-separator o\">\n",
3144                "            <abbreviation explanation=\"Keyword-only parameters separator (PEP 3102)\">\n",
3145                "                *\n",
3146                "    <desc_parameter xml:space=\"preserve\">\n",
3147                "        <desc_sig_name classes=\"n\">\n",
3148                "            x\n",
3149                "        <desc_sig_operator classes=\"o\">\n",
3150                "            =\n",
3151                "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
3152                "            A or B\n",
3153            )
3154        );
3155    }
3156
3157    /// The PEP 570 half of the same shape.
3158    ///
3159    // oracle: `_parse_arglist('a=A and B, /', env)` (scratchpad
3160    // A/p7.py).
3161    #[test]
3162    fn boolop_default_keeps_the_positional_only_separator() {
3163        assert_eq!(
3164            parsed("a=A and B, /"),
3165            concat!(
3166                "<desc_parameterlist multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3167                "    <desc_parameter xml:space=\"preserve\">\n",
3168                "        <desc_sig_name classes=\"n\">\n",
3169                "            a\n",
3170                "        <desc_sig_operator classes=\"o\">\n",
3171                "            =\n",
3172                "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
3173                "            A and B\n",
3174                "    <desc_parameter xml:space=\"preserve\">\n",
3175                "        <desc_sig_operator classes=\"positional-only-separator o\">\n",
3176                "            <abbreviation explanation=\"Positional-only parameter separator (PEP 570)\">\n",
3177                "                /\n",
3178            )
3179        );
3180    }
3181
3182    /// `visit_BoolOp` is a plain `' and '`/`' or '` join with no
3183    /// precedence table, so a mixed chain keeps its flat source spelling
3184    /// (which `ast.unparse` would parenthesize as `a and b or c`).
3185    ///
3186    // oracle: `_parse_arglist('x=a and b or c', env)` (scratchpad
3187    // A/p7.py).
3188    #[test]
3189    fn boolop_default_renders_without_parentheses() {
3190        assert_eq!(
3191            parsed("x=a and b or c"),
3192            concat!(
3193                "<desc_parameterlist multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3194                "    <desc_parameter xml:space=\"preserve\">\n",
3195                "        <desc_sig_name classes=\"n\">\n",
3196                "            x\n",
3197                "        <desc_sig_operator classes=\"o\">\n",
3198                "            =\n",
3199                "        <inline classes=\"default_value\" support_smartquotes=\"0\">\n",
3200                "            a and b or c\n",
3201            )
3202        );
3203    }
3204
3205    /// PEP 695 `f[T:]`: `_parse_annotation('')` is the empty node list, so
3206    /// `if not annotation: continue` (`_annotations.py:428-430`) drops the
3207    /// whole type parameter — default included. We used to render an
3208    /// empty-target `pending_xref` there, which also reached the resolver.
3209    ///
3210    // oracle: `_parse_type_list(tp, env)` for each spelling under sphinx
3211    // 9.1.0 / docutils 0.22.4 (scratchpad A/p7.py).
3212    #[test]
3213    fn an_empty_type_parameter_bound_drops_the_parameter() {
3214        assert_eq!(
3215            tp("T:"),
3216            "<desc_type_parameter_list multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3217            "empty bound in T:"
3218        );
3219        assert_eq!(
3220            tp("T: "),
3221            "<desc_type_parameter_list multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3222            "empty bound in T: "
3223        );
3224        assert_eq!(
3225            tp("T:, U"),
3226            concat!(
3227                "<desc_type_parameter_list multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3228                "    <desc_type_parameter xml:space=\"preserve\">\n",
3229                "        <desc_sig_name classes=\"n\">\n",
3230                "            U\n",
3231            ),
3232            "empty bound in T:, U"
3233        );
3234        assert_eq!(
3235            tp("T, U:"),
3236            concat!(
3237                "<desc_type_parameter_list multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3238                "    <desc_type_parameter xml:space=\"preserve\">\n",
3239                "        <desc_sig_name classes=\"n\">\n",
3240                "            T\n",
3241            ),
3242            "empty bound in T, U:"
3243        );
3244        assert_eq!(
3245            tp("T: = int"),
3246            "<desc_type_parameter_list multi_line_parameter_list=\"0\" multi_line_trailing_comma=\"1\" xml:space=\"preserve\">\n",
3247            "empty bound in T: = int"
3248        );
3249    }
3250}