Skip to main content

sphinx_ultra/py/
pycode.rs

1//! `sphinx.pycode` — the `ModuleAnalyzer`/`DefinitionFinder` surface the
2//! `literalinclude` `:pyobject:` filter consumes.
3//!
4//! [`find_tags`] is the port of `ModuleAnalyzer.find_tags()`
5//! (`SP/pycode/__init__.py:167-170` → `Parser.parse_definition`,
6//! `SP/pycode/parser.py:623-627` → `DefinitionFinder`,
7//! `SP/pycode/parser.py:514-588`, sphinx 9.1.0). Ground truth: research spec
8//! §3.5 (cited as [INC §3.5]),
9//! `docs/superpowers/plans/2026-09-01-m2-wave4.5-research-spec-include-literalinclude.md`.
10//!
11//! `DefinitionFinder` is a **token-stream scanner, not an AST walk**: it reads
12//! the `tokenize` stream and keeps two stacks — a dotted `context` of
13//! class/def names and an `indents` stack of open blocks. That mechanic, not
14//! Python's grammar, is what decides every line number, so the port keeps the
15//! same shape:
16//!
17//! - a definition's start line is the `@` line of its FIRST decorator when one
18//!   is pending, else the line of the NAME after `class`/`def` (`:563-567`);
19//! - a one-liner (`def f(): return 1`) ends at the NAME's line — even when its
20//!   signature spans continuation lines (`:573-576`, probe `oneliner_multiline_sig`);
21//! - a block definition ends at `DEDENT.end_row - 1`, walked back over every
22//!   line matching `emptyline_re = ^\s*(#.*)?$` — blank lines AND comment-only
23//!   lines (`:578-588`, probe `comment_trap`);
24//! - `add_definition` drops a `def` whose immediately enclosing open block is
25//!   also a `def` (`:526-532`), so nested functions vanish but methods survive
26//!   as `Class.method` — and a `def` nested in a `def` through an intervening
27//!   `if`/`for`/`with` block SURVIVES, because that block pushed an `'other'`
28//!   entry (probe `def_in_if_in_def`: `outer.inner` is recorded);
29//! - the context stack tracks only class/def names, so a `def` inside a
30//!   module-level `if` is recorded under its bare name (probe `def_in_if`);
31//! - `async def` needs no special case: `async` is an ordinary NAME token and
32//!   `def` drives the scan (probe `deco_async`).
33//!
34//! ## The tokenizer simplification and its bounds
35//!
36//! Sphinx runs CPython's `tokenize`; this module runs a line-oriented scanner
37//! that produces only what `DefinitionFinder` consumes: `INDENT`/`DEDENT` with
38//! CPython's two-column (tabs-as-8 and tabs-as-1) bookkeeping, `NEWLINE` vs
39//! `NL`, `COMMENT`, `NAME`, `NUMBER`, `STRING` and maximal-munch `OP`. It is
40//! deliberately NOT a Python parser. Tested bounds (each pinned below against
41//! the real `ModuleAnalyzer`):
42//!
43//! - logical vs physical lines: a bracketed continuation emits `NL`, not
44//!   `NEWLINE`, and suppresses indentation processing (probe `continuation_sig`);
45//!   so does a backslash continuation, which makes `def f(a): \` + `return a` a
46//!   ONE-LINER (probe `backslash_continuation`);
47//! - blank and comment-only lines never produce `INDENT`/`DEDENT`, which is why
48//!   a `# comment` at column 0 inside a class does not close it (probe
49//!   `def_then_dedent_to_comment_col0`);
50//! - strings (all prefixes, triple quotes, escapes) are skipped whole, so
51//!   `def `/`class ` inside a docstring creates no tag (probe
52//!   `triple_quoted_trap`);
53//! - `@` only starts a decorator when the previous token is `NEWLINE`/`NL`/
54//!   `INDENT`/`DEDENT` or the stream just started — the matrix-multiplication
55//!   operator inside a statement is not a decorator (probe `matmul_at`), but
56//!   one after an in-bracket `NL` IS mistaken for one by sphinx, and this port
57//!   reproduces that (probe `at_after_nl_in_parens`: start line 2, not 3);
58//! - `filter_whitespace` (`parser.py:31-32`) replaces form feeds with spaces
59//!   BEFORE lines are split, and line splitting is `str.splitlines(True)`, both
60//!   mirrored here so line numbers agree (probe `formfeed`).
61//!
62//! Three places where the scanner is knowingly coarser than `tokenize`, none of
63//! which can change a tag in a file that is valid Python:
64//!
65//! - number munching is greedy over alphanumerics, so `1if x else 2` becomes
66//!   ONE `NUMBER` where CPython emits `NUMBER 1` + `NAME if`. Nothing the
67//!   finder keys on (`def`/`class`/`@`/`:`) can be swallowed that way, since a
68//!   number never precedes them at a header's top level;
69//! - a name of 1-2 letters from `rRbBuUfF` immediately before a quote is taken
70//!   for a string prefix, so an INVALID combination (`bb"x"`, `uu'y'`) is lexed
71//!   as one `STRING` where CPython emits `NAME` + `STRING`. Such a file is a
72//!   syntax error in Python, so sphinx warns on it anyway (the
73//!   tokenizes-but-does-not-parse class below);
74//! - the dedent branch checks CPython's two conditions in CPython's order —
75//!   unindent-matches-no-outer-level first, tab inconsistency second — so which
76//!   failure fires is faithful; only the rendered detail differs, since sphinx
77//!   surfaces them wrapped in `IndentationError`/`TabError` reprs (below).
78//!
79//! ## Errors, and the divergence they carry
80//!
81//! Sphinx's `Parser.parse()` runs `ast.parse` BEFORE `DefinitionFinder`
82//! (`parser.py:607-621`), so `find_tags` fails for EVERY file that is not
83//! valid Python, with `PycodeError(f'parsing {srcname!r} failed: {exc!r}')`
84//! (`__init__.py:158-160`) — rendered, probed verbatim, as
85//! `parsing '/abs/broken.py' failed: SyntaxError('invalid syntax',
86//! ('<unknown>', 1, 7, 'def f(:\n', 1, 8))`. That tail is a CPython
87//! `SyntaxError` repr: version-specific wording, `'<unknown>'` from
88//! `ast.parse`'s default filename, and byte offsets. This port has no Python
89//! parser, so it cannot reproduce either the tail or the full failure SET.
90//! The decision (task 15, evidence above):
91//!
92//! - our failures are a strict SUBSET of sphinx's — only what the scanner
93//!   itself cannot get past (unterminated string, unclosed bracket at EOF,
94//!   unindent that matches no outer level, inconsistent tabs/spaces), each of
95//!   which also fails `ast.parse`;
96//! - the caller ([`crate::rst`]'s literalinclude reader) keeps sphinx's
97//!   `parsing %r failed: ` prefix and substitutes this error's [`Display`] for
98//!   the un-reproducible `{exc!r}` tail;
99//! - a file that tokenizes but does not PARSE (`x = = 1` after a clean `def`)
100//!   yields tags here and a warning in sphinx. Documented divergence, not a
101//!   bug to fix without a Python parser.
102//!
103//! Other documented divergences from the sphinx path:
104//!
105//! - sphinx reads the file itself with `tokenize.open` (BOM + PEP 263 coding
106//!   cookie); this interface takes the reader's already-decoded text, so a
107//!   non-UTF-8 file with a coding cookie and no `:encoding:` option differs;
108//! - the reader has already applied `:tab-width:` expansion when it hands the
109//!   text over (`read_file`, `SP/directives/code.py:221-240`), while sphinx's
110//!   analyzer re-reads the RAW file. Expansion never changes the line COUNT,
111//!   but at a `tab-width` other than 8 it can change the indentation COLUMNS
112//!   this scanner measures, and with them the block structure and every end
113//!   line derived from it: a `\t` is column 8 to CPython's tokenizer but four
114//!   spaces after `expandtabs(4)`, so against a six-space line it flips from
115//!   deeper to shallower — an `INDENT` where sphinx sees a `DEDENT`, or a
116//!   clean parse here where sphinx raises `IndentationError`. Triggering it
117//!   takes all three of `:tab-width:` (≠ 8), `:pyobject:`, and a file that
118//!   mixes tab and space indentation;
119//! - PEP 701 f-strings that reuse the outer quote inside a replacement field
120//!   (`f"{"a"}"`) are lexed here as pre-3.12 string literals.
121
122use std::collections::BTreeMap;
123
124/// The first member of a `find_tags` entry — sphinx's `'class'` / `'def'`
125/// tag strings.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum TagKind {
128    Class,
129    Def,
130}
131
132impl TagKind {
133    /// The sphinx tag string this kind renders as.
134    pub fn as_str(self) -> &'static str {
135        match self {
136            TagKind::Class => "class",
137            TagKind::Def => "def",
138        }
139    }
140}
141
142/// `sphinx.errors.PycodeError` — every analyzer failure funnels through
143/// `LiteralInclude.run()`'s broad `except` into a reporter warning whose
144/// text is this error's `Display`.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct PycodeError(pub String);
147
148impl std::fmt::Display for PycodeError {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.write_str(&self.0)
151    }
152}
153
154impl std::error::Error for PycodeError {}
155
156/// `ModuleAnalyzer.find_tags()` over already-decoded source text:
157/// `dotted_name -> (kind, start_line, end_line)`, both line numbers
158/// 1-based inclusive, exactly as `lines[start - 1:end]` expects.
159///
160/// Total: any input either yields a map or a [`PycodeError`]; nothing panics.
161pub fn find_tags(source: &str) -> Result<BTreeMap<String, (TagKind, u32, u32)>, PycodeError> {
162    // `Parser.__init__` (`parser.py:597-598`) filters form feeds, and
163    // `parse_definition` (`:625`) splits with `str.splitlines(True)`; the
164    // tokenizer then reads those buffers back one line at a time, so BOTH
165    // steps decide the line numbering the tags carry.
166    let code = filter_whitespace(source);
167    let lines = splitlines_keepends(&code);
168    let tokens = tokenize(&lines)?;
169    DefinitionFinder::new(&tokens, &lines).parse()
170}
171
172/// `filter_whitespace` (`parser.py:31-32`): FF becomes a space. Runs before
173/// line splitting, so a form feed never ends a line (unlike bare
174/// `str.splitlines`).
175fn filter_whitespace(code: &str) -> String {
176    code.replace('\x0c', " ")
177}
178
179/// Python `str.splitlines(keepends=True)`: the full boundary set, `\r\n` kept
180/// as one ending. (Mirrors the literalinclude reader's helper; kept local so
181/// `py::` does not depend on `rst::`.)
182fn splitlines_keepends(text: &str) -> Vec<String> {
183    let mut out = Vec::new();
184    let mut start = 0usize;
185    let mut chars = text.char_indices().peekable();
186    while let Some((i, c)) = chars.next() {
187        if is_line_boundary(c) {
188            let mut end = i + c.len_utf8();
189            if c == '\r' {
190                if let Some(&(j, '\n')) = chars.peek() {
191                    chars.next();
192                    end = j + 1;
193                }
194            }
195            out.push(text[start..end].to_string());
196            start = end;
197        }
198    }
199    if start < text.len() {
200        out.push(text[start..].to_string());
201    }
202    out
203}
204
205fn is_line_boundary(c: char) -> bool {
206    matches!(
207        c,
208        '\n' | '\r'
209            | '\x0b'
210            | '\x0c'
211            | '\x1c'
212            | '\x1d'
213            | '\x1e'
214            | '\u{85}'
215            | '\u{2028}'
216            | '\u{2029}'
217    )
218}
219
220/// `emptyline_re = ^\s*(#.*)?$` (`parser.py:28`) applied with `re.match` to a
221/// keepends line: whitespace only, or whitespace then a comment.
222fn is_emptyline(line: &str) -> bool {
223    let body = line.strip_suffix('\n').unwrap_or(line);
224    let rest = body.trim_start_matches(char::is_whitespace);
225    rest.is_empty() || rest.starts_with('#')
226}
227
228// ---------------------------------------------------------------------------
229// The tokenizer (the `tokenize` stand-in; see the module docs for its bounds)
230// ---------------------------------------------------------------------------
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233enum Kind {
234    Name,
235    Number,
236    Str,
237    Op,
238    Comment,
239    /// End of a logical line.
240    Newline,
241    /// Non-logical newline: blank line, comment-only line, or a line break
242    /// inside brackets.
243    Nl,
244    Indent,
245    Dedent,
246}
247
248#[derive(Debug, Clone)]
249struct Tok {
250    kind: Kind,
251    /// Token text — only `Name` and `Op` are ever compared by value, so the
252    /// other kinds carry an empty string.
253    text: String,
254    start_row: u32,
255    end_row: u32,
256}
257
258const OPS3: &[&str] = &["**=", "//=", "<<=", ">>=", "..."];
259const OPS2: &[&str] = &[
260    "**", "//", "<<", ">>", "<=", ">=", "==", "!=", "->", ":=", "+=", "-=", "*=", "/=", "%=", "@=",
261    "&=", "|=", "^=",
262];
263
264fn is_name_start(c: char) -> bool {
265    c.is_alphabetic() || c == '_'
266}
267
268fn is_name_continue(c: char) -> bool {
269    c.is_alphanumeric() || c == '_'
270}
271
272/// Python string-literal prefixes: 1-2 letters from `rRbBuUfF`.
273fn is_string_prefix(word: &str) -> bool {
274    !word.is_empty() && word.len() <= 2 && word.chars().all(|c| "rRbBuUfF".contains(c))
275}
276
277struct Tokenizer {
278    /// Lines with their terminators stripped, as char vectors.
279    body: Vec<Vec<char>>,
280    toks: Vec<Tok>,
281    /// CPython's `indstack`/`altindstack` pair: column with tabs rounded up to
282    /// multiples of 8, and column with tabs counted as 1. A mismatch between
283    /// the two is CPython's `TabError`.
284    indents: Vec<(usize, usize)>,
285    /// Open brackets and the row each was opened on.
286    brackets: Vec<(char, u32)>,
287    continued: bool,
288    line_has_tokens: bool,
289    row: usize,
290    col: usize,
291}
292
293fn tokenize(lines: &[String]) -> Result<Vec<Tok>, PycodeError> {
294    let body: Vec<Vec<char>> = lines
295        .iter()
296        .map(|l| line_body(l).chars().collect())
297        .collect();
298    let mut tk = Tokenizer {
299        body,
300        toks: Vec::new(),
301        indents: vec![(0, 0)],
302        brackets: Vec::new(),
303        continued: false,
304        line_has_tokens: false,
305        row: 0,
306        col: 0,
307    };
308    tk.run()?;
309    Ok(tk.toks)
310}
311
312/// Drop the line terminator `splitlines_keepends` left on.
313fn line_body(line: &str) -> &str {
314    if let Some(rest) = line.strip_suffix("\r\n") {
315        rest
316    } else {
317        line.strip_suffix(is_line_boundary).unwrap_or(line)
318    }
319}
320
321impl Tokenizer {
322    fn push(&mut self, kind: Kind, text: &str, start_row: usize, end_row: usize) {
323        self.toks.push(Tok {
324            kind,
325            text: text.to_string(),
326            start_row: start_row as u32 + 1,
327            end_row: end_row as u32 + 1,
328        });
329    }
330
331    fn cur(&self) -> Option<char> {
332        self.body[self.row].get(self.col).copied()
333    }
334
335    fn at(&self, offset: usize) -> Option<char> {
336        self.body[self.row].get(self.col + offset).copied()
337    }
338
339    fn line_len(&self) -> usize {
340        self.body[self.row].len()
341    }
342
343    fn run(&mut self) -> Result<(), PycodeError> {
344        let mut at_line_start = true;
345        while self.row < self.body.len() {
346            if at_line_start {
347                at_line_start = false;
348                if self.brackets.is_empty() && !self.continued {
349                    if self.start_of_logical_line()? {
350                        // Blank or comment-only line: no indentation
351                        // processing, no tokens beyond the COMMENT/NL pair.
352                        self.row += 1;
353                        self.col = 0;
354                        at_line_start = true;
355                        continue;
356                    }
357                } else {
358                    self.col = 0;
359                }
360                self.continued = false;
361            }
362            if self.col >= self.line_len() {
363                self.end_of_physical_line();
364                self.row += 1;
365                self.col = 0;
366                at_line_start = true;
367                continue;
368            }
369            self.scan_token()?;
370        }
371        self.finish()
372    }
373
374    /// Indentation processing for a fresh logical line. Returns `true` when
375    /// the line is blank or comment-only, which CPython's tokenizer skips
376    /// entirely (no `INDENT`/`DEDENT`).
377    fn start_of_logical_line(&mut self) -> Result<bool, PycodeError> {
378        let (col, altcol, first) = self.measure_indent();
379        self.col = first;
380        match self.body[self.row].get(first) {
381            None => {
382                self.push(Kind::Nl, "", self.row, self.row);
383                return Ok(true);
384            }
385            Some('#') => {
386                self.push(Kind::Comment, "", self.row, self.row);
387                self.push(Kind::Nl, "", self.row, self.row);
388                return Ok(true);
389            }
390            Some(_) => {}
391        }
392        // CPython `tokenizer.c`: compare against the top of the stack, with
393        // the alternate (tabs-as-1) column policing tab/space consistency.
394        let &(top, alttop) = self.indents.last().expect("indent stack is never empty");
395        if col == top {
396            if altcol != alttop {
397                return Err(self.tab_error());
398            }
399        } else if col > top {
400            if altcol <= alttop {
401                return Err(self.tab_error());
402            }
403            self.indents.push((col, altcol));
404            self.push(Kind::Indent, "", self.row, self.row);
405        } else {
406            while self.indents.len() > 1 && col < self.indents[self.indents.len() - 1].0 {
407                self.indents.pop();
408                self.push(Kind::Dedent, "", self.row, self.row);
409            }
410            let &(top, alttop) = self.indents.last().expect("indent stack is never empty");
411            if col != top {
412                return Err(PycodeError(
413                    "unindent does not match any outer indentation level".to_string(),
414                ));
415            }
416            if altcol != alttop {
417                return Err(self.tab_error());
418            }
419        }
420        Ok(false)
421    }
422
423    /// CPython's indentation measurement: a tab advances to the next multiple
424    /// of 8 in `col` and by one in `altcol`. (Form feeds cannot appear —
425    /// `filter_whitespace` turned them into spaces.)
426    fn measure_indent(&self) -> (usize, usize, usize) {
427        let (mut col, mut altcol, mut i) = (0usize, 0usize, 0usize);
428        let line = &self.body[self.row];
429        while let Some(&c) = line.get(i) {
430            match c {
431                ' ' => {
432                    col += 1;
433                    altcol += 1;
434                }
435                '\t' => {
436                    col = (col / 8 + 1) * 8;
437                    altcol += 1;
438                }
439                _ => break,
440            }
441            i += 1;
442        }
443        (col, altcol, i)
444    }
445
446    fn tab_error(&self) -> PycodeError {
447        PycodeError("inconsistent use of tabs and spaces in indentation".to_string())
448    }
449
450    fn end_of_physical_line(&mut self) {
451        if self.continued {
452            return;
453        }
454        if !self.brackets.is_empty() {
455            self.push(Kind::Nl, "", self.row, self.row);
456        } else if self.line_has_tokens {
457            self.push(Kind::Newline, "", self.row, self.row);
458            self.line_has_tokens = false;
459        } else {
460            self.push(Kind::Nl, "", self.row, self.row);
461        }
462    }
463
464    fn scan_token(&mut self) -> Result<(), PycodeError> {
465        let c = self.cur().expect("caller checked the column");
466        if c == ' ' || c == '\t' || c == '\r' {
467            self.col += 1;
468            return Ok(());
469        }
470        if c == '#' {
471            self.push(Kind::Comment, "", self.row, self.row);
472            self.col = self.line_len();
473            return Ok(());
474        }
475        if c == '\\' && self.col + 1 >= self.line_len() {
476            self.continued = true;
477            self.col = self.line_len();
478            return Ok(());
479        }
480        if is_name_start(c) {
481            let start = self.col;
482            while self.cur().is_some_and(is_name_continue) {
483                self.col += 1;
484            }
485            let word: String = self.body[self.row][start..self.col].iter().collect();
486            if matches!(self.cur(), Some('\'' | '"')) && is_string_prefix(&word) {
487                return self.lex_string();
488            }
489            self.line_has_tokens = true;
490            self.push(Kind::Name, &word, self.row, self.row);
491            return Ok(());
492        }
493        if c.is_ascii_digit() || (c == '.' && self.at(1).is_some_and(|d| d.is_ascii_digit())) {
494            self.lex_number();
495            return Ok(());
496        }
497        if c == '\'' || c == '"' {
498            return self.lex_string();
499        }
500        self.lex_op();
501        Ok(())
502    }
503
504    /// A numeric literal, consumed whole so that `1e-5` cannot leak a `-`
505    /// operator into the stream. Boundaries mirror `arglist::lex_number`.
506    fn lex_number(&mut self) {
507        let line = &self.body[self.row];
508        let start = self.col;
509        let radix_prefixed = line.get(start) == Some(&'0')
510            && matches!(line.get(start + 1), Some('x' | 'X' | 'b' | 'B' | 'o' | 'O'));
511        let mut prev = line[start];
512        self.col += 1;
513        while let Some(d) = self.cur() {
514            let continues = d.is_ascii_alphanumeric()
515                || d == '_'
516                || d == '.'
517                || ((d == '+' || d == '-') && matches!(prev, 'e' | 'E') && !radix_prefixed);
518            if !continues {
519                break;
520            }
521            prev = d;
522            self.col += 1;
523        }
524        self.line_has_tokens = true;
525        self.push(Kind::Number, "", self.row, self.row);
526    }
527
528    /// A string literal starting at the opening quote (any prefix already
529    /// consumed). Triple-quoted literals and backslash-escaped newlines walk
530    /// to later rows; the token's `end_row` is where the closing quote sits.
531    fn lex_string(&mut self) -> Result<(), PycodeError> {
532        let start_row = self.row;
533        let quote = self.cur().expect("caller peeked the quote");
534        self.col += 1;
535        let triple = self.cur() == Some(quote) && self.at(1) == Some(quote);
536        if triple {
537            self.col += 2;
538        }
539        loop {
540            let Some(c) = self.cur() else {
541                // End of a physical line inside the literal: legal for a
542                // triple-quoted one, and for a backslash-escaped newline
543                // (handled below), never otherwise.
544                if !triple {
545                    return Err(PycodeError(format!(
546                        "unterminated string literal (detected at line {})",
547                        start_row + 1
548                    )));
549                }
550                if self.row + 1 >= self.body.len() {
551                    return Err(PycodeError(format!(
552                        "unterminated triple-quoted string literal (detected at line {})",
553                        self.body.len()
554                    )));
555                }
556                self.row += 1;
557                self.col = 0;
558                continue;
559            };
560            if c == '\\' {
561                self.col += 1;
562                if self.cur().is_none() {
563                    if self.row + 1 >= self.body.len() {
564                        return Err(PycodeError(format!(
565                            "unterminated string literal (detected at line {})",
566                            start_row + 1
567                        )));
568                    }
569                    self.row += 1;
570                    self.col = 0;
571                } else {
572                    self.col += 1;
573                }
574                continue;
575            }
576            if c == quote {
577                if !triple {
578                    self.col += 1;
579                    break;
580                }
581                if self.at(1) == Some(quote) && self.at(2) == Some(quote) {
582                    self.col += 3;
583                    break;
584                }
585            }
586            self.col += 1;
587        }
588        self.line_has_tokens = true;
589        let end_row = self.row;
590        self.push(Kind::Str, "", start_row, end_row);
591        Ok(())
592    }
593
594    /// Maximal-munch operator, 3-2-1 characters, tracking bracket depth. An
595    /// unmatched closer is ignored (CPython errors; such a file never parses,
596    /// so sphinx warns instead — see the module docs).
597    fn lex_op(&mut self) {
598        let rest: String = self.body[self.row][self.col..].iter().collect();
599        let text = OPS3
600            .iter()
601            .chain(OPS2.iter())
602            .find(|cand| rest.starts_with(**cand))
603            .map(|cand| (*cand).to_string())
604            .unwrap_or_else(|| rest.chars().next().into_iter().collect());
605        self.col += text.chars().count();
606        match text.as_str() {
607            "(" | "[" | "{" => self
608                .brackets
609                .push((text.chars().next().expect("one char"), self.row as u32 + 1)),
610            ")" | "]" | "}" => {
611                self.brackets.pop();
612            }
613            _ => {}
614        }
615        self.line_has_tokens = true;
616        self.push(Kind::Op, &text, self.row, self.row);
617    }
618
619    /// EOF: CPython closes the last logical line, then emits one `DEDENT` per
620    /// open level at row `len(lines) + 1` (probed: that row holds whether the
621    /// file ends with a newline or not).
622    fn finish(&mut self) -> Result<(), PycodeError> {
623        if let Some(&(opener, row)) = self.brackets.first() {
624            return Err(PycodeError(format!(
625                "'{opener}' was never closed (opened at line {row})"
626            )));
627        }
628        let eof_row = self.body.len();
629        while self.indents.len() > 1 {
630            self.indents.pop();
631            self.push(Kind::Dedent, "", eof_row, eof_row);
632        }
633        Ok(())
634    }
635}
636
637// ---------------------------------------------------------------------------
638// DefinitionFinder (`parser.py:514-588`)
639// ---------------------------------------------------------------------------
640
641/// An entry on `DefinitionFinder.indents`: `'other'` for a plain indented
642/// block, `'class'`/`'def'` for a definition body.
643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644enum Block {
645    Other,
646    Class,
647    Def,
648}
649
650impl Block {
651    fn of(kind: TagKind) -> Self {
652        match kind {
653            TagKind::Class => Block::Class,
654            TagKind::Def => Block::Def,
655        }
656    }
657}
658
659/// The `fetch_until` terminator: `[OP, ':']` or the `INDENT` kind.
660#[derive(Debug, Clone, Copy)]
661enum Cond {
662    Colon,
663    Indent,
664}
665
666struct DefinitionFinder<'a> {
667    toks: &'a [Tok],
668    lines: &'a [String],
669    next: usize,
670    current: Option<usize>,
671    previous: Option<usize>,
672    decorator: Option<u32>,
673    context: Vec<String>,
674    indents: Vec<(Block, String, u32)>,
675    definitions: BTreeMap<String, (TagKind, u32, u32)>,
676}
677
678impl<'a> DefinitionFinder<'a> {
679    fn new(toks: &'a [Tok], lines: &'a [String]) -> Self {
680        Self {
681            toks,
682            lines,
683            next: 0,
684            current: None,
685            previous: None,
686            decorator: None,
687            context: Vec::new(),
688            indents: Vec::new(),
689            definitions: BTreeMap::new(),
690        }
691    }
692
693    fn tok(&self, i: usize) -> &'a Tok {
694        &self.toks[i]
695    }
696
697    /// `TokenProcessor.fetch_token` (`parser.py:156-167`): `previous` shifts
698    /// even on exhaustion, when `current` becomes `None`.
699    fn fetch(&mut self) -> Option<usize> {
700        self.previous = self.current;
701        self.current = (self.next < self.toks.len()).then(|| {
702            let i = self.next;
703            self.next += 1;
704            i
705        });
706        self.current
707    }
708
709    fn parse(mut self) -> Result<BTreeMap<String, (TagKind, u32, u32)>, PycodeError> {
710        while let Some(i) = self.fetch() {
711            let t = self.tok(i);
712            match t.kind {
713                Kind::Comment => {}
714                Kind::Op if t.text == "@" => {
715                    if self.decorator.is_none() && self.previous_ends_a_line() {
716                        self.decorator = Some(t.start_row);
717                    }
718                }
719                Kind::Name if t.text == "class" => self.parse_definition(TagKind::Class)?,
720                Kind::Name if t.text == "def" => self.parse_definition(TagKind::Def)?,
721                Kind::Indent => self.indents.push((Block::Other, String::new(), 0)),
722                Kind::Dedent => self.finalize_block()?,
723                _ => {}
724            }
725        }
726        Ok(self.definitions)
727    }
728
729    /// `self.previous is None or self.previous.match(NEWLINE, NL, INDENT,
730    /// DEDENT)` (`:542-545`) — the guard that keeps `a @ b` from starting a
731    /// definition.
732    fn previous_ends_a_line(&self) -> bool {
733        match self.previous {
734            None => true,
735            Some(i) => matches!(
736                self.tok(i).kind,
737                Kind::Newline | Kind::Nl | Kind::Indent | Kind::Dedent
738            ),
739        }
740    }
741
742    /// `add_definition` (`:526-532`): a `def` directly inside a `def` body is
743    /// dropped — but the entry has already been popped by the caller, so the
744    /// test looks at the ENCLOSING block.
745    fn add_definition(&mut self, name: String, entry: (TagKind, u32, u32)) {
746        if entry.0 == TagKind::Def && self.indents.last().map(|e| e.0) == Some(Block::Def) {
747            return;
748        }
749        self.definitions.insert(name, entry);
750    }
751
752    /// `parse_definition` (`:557-576`).
753    fn parse_definition(&mut self, typ: TagKind) -> Result<(), PycodeError> {
754        let Some(ni) = self.fetch() else {
755            return Err(PycodeError(
756                "unexpected end of file after a definition keyword".to_string(),
757            ));
758        };
759        let name = self.tok(ni);
760        let (name_text, name_end) = (name.text.clone(), name.end_row);
761        let start_pos = self.decorator.take().unwrap_or(name.start_row);
762        self.context.push(name_text);
763        let funcname = self.context.join(".");
764
765        self.fetch_until(Cond::Colon);
766        let Some(ti) = self.fetch() else {
767            return Err(PycodeError(
768                "unexpected end of file inside a definition header".to_string(),
769            ));
770        };
771        if matches!(self.tok(ti).kind, Kind::Comment | Kind::Newline) {
772            self.fetch_until(Cond::Indent);
773            self.indents.push((Block::of(typ), funcname, start_pos));
774        } else {
775            // One-liner: ends at the NAME's line, however far the signature
776            // ran (`:573-576`).
777            self.add_definition(funcname, (typ, start_pos, name_end));
778            let _ = self.context.pop();
779        }
780        Ok(())
781    }
782
783    /// `finalize_block` (`:578-588`).
784    fn finalize_block(&mut self) -> Result<(), PycodeError> {
785        let Some((block, funcname, start_pos)) = self.indents.pop() else {
786            return Err(PycodeError(
787                "unbalanced indentation while scanning definitions".to_string(),
788            ));
789        };
790        if block == Block::Other {
791            return Ok(());
792        }
793        let dedent_row = self.current.map_or(0, |i| self.tok(i).end_row);
794        let mut end_pos = dedent_row.saturating_sub(1);
795        while end_pos >= 1
796            && (end_pos as usize) <= self.lines.len()
797            && is_emptyline(&self.lines[end_pos as usize - 1])
798        {
799            end_pos -= 1;
800        }
801        let typ = if block == Block::Class {
802            TagKind::Class
803        } else {
804            TagKind::Def
805        };
806        self.add_definition(funcname, (typ, start_pos, end_pos));
807        let _ = self.context.pop();
808        Ok(())
809    }
810
811    /// `fetch_until` (`:169-186`), iterative: the original recurses one level
812    /// per bracket, an explicit closer stack keeps totality on adversarial
813    /// nesting. The terminator is tested BEFORE the openers, exactly as the
814    /// original does at each level.
815    fn fetch_until(&mut self, cond: Cond) {
816        let mut closers: Vec<&'static str> = Vec::new();
817        while let Some(i) = self.fetch() {
818            let t = self.tok(i);
819            let hit = match closers.last() {
820                Some(closer) => t.kind == Kind::Op && t.text == *closer,
821                None => match cond {
822                    Cond::Colon => t.kind == Kind::Op && t.text == ":",
823                    Cond::Indent => t.kind == Kind::Indent,
824                },
825            };
826            if hit {
827                if closers.pop().is_none() {
828                    return;
829                }
830                continue;
831            }
832            if t.kind == Kind::Op {
833                match t.text.as_str() {
834                    "(" => closers.push(")"),
835                    "{" => closers.push("}"),
836                    "[" => closers.push("]"),
837                    _ => {}
838                }
839            }
840        }
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    //! Every expected tag dict here is verbatim output of the REAL
847    //! `sphinx.pycode.ModuleAnalyzer` (sphinx 9.1.0 on CPython 3.12.2), run
848    //! in batch over these exact sources; the probe case name is cited on
849    //! each test. Nothing here was written from memory.
850
851    use super::*;
852
853    /// The tag map as a sorted `(name, kind, start, end)` list — the same
854    /// order and shape as the probe's `pprint(..., sort_dicts=True)`.
855    fn tags(src: &str) -> Vec<(String, &'static str, u32, u32)> {
856        find_tags(src)
857            .expect("find_tags must succeed")
858            .into_iter()
859            .map(|(name, (kind, start, end))| (name, kind.as_str(), start, end))
860            .collect()
861    }
862
863    fn t(name: &str, kind: &'static str, start: u32, end: u32) -> (String, &'static str, u32, u32) {
864        (name.to_string(), kind, start, end)
865    }
866
867    const FIXTURE: &str = include_str!("../../tests/fixtures/literalinclude/example.py");
868
869    /// probe `fixture`:
870    /// `{'Foo': ('class', 11, 17), 'Foo.method': ('def', 16, 17),
871    ///   'tail': ('def', 20, 21), 'top': ('def', 6, 8)}` — `Foo` ends at 17
872    /// because the trailing blank lines are trimmed.
873    #[test]
874    fn fixture_module_tags_match_the_probe() {
875        assert_eq!(
876            tags(FIXTURE),
877            vec![
878                t("Foo", "class", 11, 17),
879                t("Foo.method", "def", 16, 17),
880                t("tail", "def", 20, 21),
881                t("top", "def", 6, 8),
882            ]
883        );
884    }
885
886    // ---- decorators --------------------------------------------------
887
888    /// probe `deco_plain`: `{'Weird': ('class', 9, 11), 'cached': ('def', 4, 6)}`
889    /// — the `@` line, not the `def` line, is the start.
890    #[test]
891    fn a_decorator_line_becomes_the_start_for_defs_and_classes() {
892        let src = "import functools\n\n\n@functools.cache\ndef cached(x):\n    return x\n\n\n\
893                   @staticmethod\nclass Weird:\n    pass\n";
894        assert_eq!(
895            tags(src),
896            vec![t("Weird", "class", 9, 11), t("cached", "def", 4, 6)]
897        );
898    }
899
900    /// probes `deco_args_multiline` `{'f': ('def', 1, 6)}`, `deco_stacked`
901    /// `{'f': ('def', 1, 5)}` (only the FIRST `@` counts) and
902    /// `deco_blank_and_comment_between` `{'f': ('def', 1, 5)}`.
903    #[test]
904    fn argumented_stacked_and_detached_decorators_all_start_at_the_first_at() {
905        assert_eq!(
906            tags("@decorator(\n    \"a\",\n    \"b\",\n)\ndef f():\n    return 1\n"),
907            vec![t("f", "def", 1, 6)]
908        );
909        assert_eq!(
910            tags("@one\n@two(3)\n@three\ndef f():\n    pass\n"),
911            vec![t("f", "def", 1, 5)]
912        );
913        assert_eq!(
914            tags("@deco\n\n# a comment\ndef f():\n    pass\n"),
915            vec![t("f", "def", 1, 5)]
916        );
917    }
918
919    /// probe `deco_async`: `{'f': ('def', 1, 3)}` — `async` is a plain NAME,
920    /// the `def` after it drives the scan.
921    #[test]
922    fn async_def_needs_no_special_case() {
923        assert_eq!(
924            tags("@deco\nasync def f(a, b):\n    await g()\n"),
925            vec![t("f", "def", 1, 3)]
926        );
927        // probe `oneliner_async`: `{'f': ('def', 1, 1)}`
928        assert_eq!(tags("async def f(): return 1\n"), vec![t("f", "def", 1, 1)]);
929        // probe `async_block`: the inner `async def` is suppressed like any
930        // nested def; the class between them is not.
931        assert_eq!(
932            tags(
933                "async def outer():\n    async def inner():\n        pass\n\n\
934                 \x20   class Inner:\n        async def m(self):\n            pass\n"
935            ),
936            vec![
937                t("outer", "def", 1, 7),
938                t("outer.Inner", "class", 5, 7),
939                t("outer.Inner.m", "def", 6, 7),
940            ]
941        );
942    }
943
944    /// probe `matmul_at`: `{'f': ('def', 3, 4)}` — `c = a @ b` is not a
945    /// decorator, because the previous token is a NAME.
946    #[test]
947    fn matrix_multiplication_is_not_a_decorator() {
948        assert_eq!(
949            tags("a = b\nc = a @ b\ndef f():\n    pass\n"),
950            vec![t("f", "def", 3, 4)]
951        );
952    }
953
954    /// probe `at_after_nl_in_parens`: `{'f': ('def', 2, 4)}`. Sphinx's guard
955    /// accepts NL — the newline INSIDE brackets — so a line-leading `@`
956    /// operator in a bracketed expression IS taken for a decorator and moves
957    /// the next definition's start line. Faithfully reproduced.
958    #[test]
959    fn an_at_after_an_in_bracket_newline_is_mistaken_for_a_decorator() {
960        assert_eq!(
961            tags("x = (a\n@ b)\ndef f():\n    pass\n"),
962            vec![t("f", "def", 2, 4)]
963        );
964        // probe `at_after_nl_in_parens_matrix`: `{'f': ('def', 3, 8)}`
965        assert_eq!(
966            tags("m = (\n    a\n    @ b\n)\n\n\ndef f():\n    pass\n"),
967            vec![t("f", "def", 3, 8)]
968        );
969    }
970
971    // ---- one-liners --------------------------------------------------
972
973    /// probe `oneliner`: `{'C': ('class', 2, 2), 'f': ('def', 1, 1),
974    /// 'g': ('def', 3, 4)}`.
975    #[test]
976    fn one_liners_end_at_their_header_line() {
977        assert_eq!(
978            tags("def f(): return 1\nclass C: pass\ndef g():\n    pass\n"),
979            vec![
980                t("C", "class", 2, 2),
981                t("f", "def", 1, 1),
982                t("g", "def", 3, 4),
983            ]
984        );
985    }
986
987    /// probe `oneliner_multiline_sig`: `{'f': ('def', 1, 1)}` — the end line
988    /// is the NAME's line, so a one-liner whose signature wrapped ends
989    /// BEFORE its own body. Sphinx quirk, kept.
990    #[test]
991    fn a_one_liner_with_a_wrapped_signature_ends_at_the_name_line() {
992        assert_eq!(
993            tags("def f(a,\n      b): return a + b\nx = 1\n"),
994            vec![t("f", "def", 1, 1)]
995        );
996    }
997
998    /// probe `backslash_continuation`: `{'f': ('def', 1, 1)}` — the
999    /// backslash keeps the logical line open, so this is a one-liner.
1000    #[test]
1001    fn a_backslash_continued_body_is_still_a_one_liner() {
1002        assert_eq!(
1003            tags("def f(a): \\\n    return a\n\n\nx = 1\n"),
1004            vec![t("f", "def", 1, 1)]
1005        );
1006    }
1007
1008    /// probe `oneliner_semicolons`: `{'f': ('def', 1, 1), 'g': ('def', 2, 2)}`.
1009    #[test]
1010    fn semicolon_bodies_stay_one_liners() {
1011        assert_eq!(
1012            tags("def f(): x = 1; return x\ndef g(): pass\n"),
1013            vec![t("f", "def", 1, 1), t("g", "def", 2, 2)]
1014        );
1015    }
1016
1017    // ---- nesting -----------------------------------------------------
1018
1019    /// probes `nested_def` `{'outer': ('def', 1, 4)}` and
1020    /// `nested_def_oneliner` `{'outer': ('def', 1, 3)}`.
1021    #[test]
1022    fn a_def_directly_inside_a_def_is_dropped() {
1023        assert_eq!(
1024            tags("def outer():\n    def inner():\n        pass\n    return inner\n"),
1025            vec![t("outer", "def", 1, 4)]
1026        );
1027        assert_eq!(
1028            tags("def outer():\n    def inner(): pass\n    return inner\n"),
1029            vec![t("outer", "def", 1, 3)]
1030        );
1031    }
1032
1033    /// probes `def_in_if_in_def` `{'outer': ('def', 1, 5),
1034    /// 'outer.inner': ('def', 3, 4)}` and `oneliner_def_in_if_in_def`
1035    /// `{'outer': ('def', 1, 4), 'outer.inner': ('def', 3, 3)}`. The
1036    /// suppression test looks only at the IMMEDIATELY enclosing block, and
1037    /// the `if` pushed an `'other'` one — so the nested def SURVIVES, under
1038    /// its dotted name.
1039    #[test]
1040    fn an_intervening_if_block_defeats_the_nested_def_suppression() {
1041        assert_eq!(
1042            tags("def outer():\n    if True:\n        def inner():\n            pass\n    return 1\n"),
1043            vec![t("outer", "def", 1, 5), t("outer.inner", "def", 3, 4)]
1044        );
1045        assert_eq!(
1046            tags("def outer():\n    if True:\n        def inner(): pass\n    return 1\n"),
1047            vec![t("outer", "def", 1, 4), t("outer.inner", "def", 3, 3)]
1048        );
1049    }
1050
1051    /// probe `def_in_if`: `{'g': ('def', 2, 3), 'h': ('def', 5, 6)}` — the
1052    /// context stack holds only class/def names, so a def inside a
1053    /// module-level `if` is recorded under its BARE name (no `if` prefix).
1054    /// probe `def_in_try_with_for`: `{'a': ('def', 2, 3), 'b': ('def', 8, 9),
1055    /// 'c': ('def', 12, 13)}` — same for `try`/`with`/`for`.
1056    #[test]
1057    fn a_def_inside_a_plain_block_keeps_its_bare_name() {
1058        assert_eq!(
1059            tags("if True:\n    def g():\n        pass\nelse:\n    def h():\n        pass\n"),
1060            vec![t("g", "def", 2, 3), t("h", "def", 5, 6)]
1061        );
1062        assert_eq!(
1063            tags(
1064                "try:\n    def a():\n        pass\nexcept Exception:\n    pass\n\n\
1065                 with open('x') as f:\n    def b():\n        pass\n\n\
1066                 for i in range(3):\n    def c():\n        pass\n"
1067            ),
1068            vec![
1069                t("a", "def", 2, 3),
1070                t("b", "def", 8, 9),
1071                t("c", "def", 12, 13),
1072            ]
1073        );
1074    }
1075
1076    /// probe `class_in_class`: `{'Outer': ('class', 1, 7),
1077    /// 'Outer.Inner': ('class', 2, 6), 'Outer.Inner.method': ('def', 3, 4)}`.
1078    #[test]
1079    fn nested_classes_dot_their_names_and_trim_their_own_tails() {
1080        assert_eq!(
1081            tags(
1082                "class Outer:\n    class Inner:\n        def method(self):\n            pass\n\n\
1083                 \x20       attr = 1\n    x = 2\n"
1084            ),
1085            vec![
1086                t("Outer", "class", 1, 7),
1087                t("Outer.Inner", "class", 2, 6),
1088                t("Outer.Inner.method", "def", 3, 4),
1089            ]
1090        );
1091    }
1092
1093    /// probe `class_in_def`: `{'outer': ('def', 1, 5),
1094    /// 'outer.Inner': ('class', 2, 4), 'outer.Inner.m': ('def', 3, 4)}` — a
1095    /// CLASS inside a def is kept (the suppression is def-in-def only), and
1096    /// its methods come with it.
1097    #[test]
1098    fn a_class_inside_a_def_survives_with_its_methods() {
1099        assert_eq!(
1100            tags("def outer():\n    class Inner:\n        def m(self):\n            pass\n    return Inner\n"),
1101            vec![
1102                t("outer", "def", 1, 5),
1103                t("outer.Inner", "class", 2, 4),
1104                t("outer.Inner.m", "def", 3, 4),
1105            ]
1106        );
1107    }
1108
1109    /// probe `deep_nesting`: `{'A': ('class', 1, 7), 'A.B': ('class', 2, 7),
1110    /// 'A.B.C': ('class', 3, 7), 'A.B.C.m': ('def', 4, 7)}` — every EOF
1111    /// dedent lands on the same end line, and `inner` is dropped.
1112    #[test]
1113    fn eof_dedents_close_every_open_block_at_the_same_line() {
1114        assert_eq!(
1115            tags(
1116                "class A:\n    class B:\n        class C:\n            def m(self):\n\
1117                 \x20               def inner():\n                    pass\n                return inner\n"
1118            ),
1119            vec![
1120                t("A", "class", 1, 7),
1121                t("A.B", "class", 2, 7),
1122                t("A.B.C", "class", 3, 7),
1123                t("A.B.C.m", "def", 4, 7),
1124            ]
1125        );
1126    }
1127
1128    // ---- string / comment traps --------------------------------------
1129
1130    /// probe `triple_quoted_trap`: `{'real': ('def', 10, 16)}` — `def`/`class`
1131    /// text inside a module string and inside a docstring creates no tags.
1132    #[test]
1133    fn definitions_inside_strings_are_not_tags() {
1134        let src = "DOC = \"\"\"\ndef fake():\n    pass\n\nclass Fake:\n    pass\n\"\"\"\n\n\n\
1135                   def real():\n    \"\"\"Doc with def inside.\n\n    class AlsoFake:\n        pass\n    \"\"\"\n    return 1\n";
1136        assert_eq!(tags(src), vec![t("real", "def", 10, 16)]);
1137    }
1138
1139    /// probe `string_prefixes`: `{'real': ('def', 7, 8)}` — f/r/b prefixed
1140    /// literals are skipped whole.
1141    #[test]
1142    fn prefixed_string_literals_are_skipped_whole() {
1143        let src = "s = f\"\"\"def nope():\n    pass\"\"\"\nr = r\"\"\"class Nope: pass\"\"\"\n\
1144                   b = b\"def nope2(): pass\"\n\n\ndef real():\n    pass\n";
1145        assert_eq!(tags(src), vec![t("real", "def", 7, 8)]);
1146    }
1147
1148    /// probe `def_string_in_body`: `{'f': ('def', 1, 4)}`.
1149    #[test]
1150    fn single_quoted_strings_in_a_body_are_skipped() {
1151        assert_eq!(
1152            tags("def f():\n    s = \"def nope(): pass\"\n    t = 'class Nope: pass'\n    return s + t\n"),
1153            vec![t("f", "def", 1, 4)]
1154        );
1155    }
1156
1157    /// probe `comment_trap`: `{'real': ('def', 3, 4)}` — commented-out defs
1158    /// create no tag, and the trailing comment line is trimmed off the end
1159    /// because `emptyline_re` matches comment-only lines too.
1160    #[test]
1161    fn comment_lines_make_no_tags_and_are_trimmed_from_block_ends() {
1162        assert_eq!(
1163            tags("# def commented():\n#     pass\ndef real():\n    pass\n# def trailing():\n"),
1164            vec![t("real", "def", 3, 4)]
1165        );
1166        // probe `trailing_comment_in_block`:
1167        // `{'f': ('def', 1, 2), 'g': ('def', 9, 10)}`
1168        assert_eq!(
1169            tags(
1170                "def f():\n    pass\n    # trailing comment\n    # another\n\n\
1171                 \x20   # after a blank\n\n\ndef g():\n    pass\n"
1172            ),
1173            vec![t("f", "def", 1, 2), t("g", "def", 9, 10)]
1174        );
1175    }
1176
1177    /// probe `def_then_dedent_to_comment_col0`: `{'C': ('class', 1, 6),
1178    /// 'C.m': ('def', 2, 3), 'C.n': ('def', 5, 6)}` — a column-0 comment
1179    /// inside a class body does NOT dedent it.
1180    #[test]
1181    fn a_column_zero_comment_does_not_close_a_block() {
1182        assert_eq!(
1183            tags(
1184                "class C:\n    def m(self):\n        pass\n# comment at col 0\n\
1185                 \x20   def n(self):\n        pass\n"
1186            ),
1187            vec![
1188                t("C", "class", 1, 6),
1189                t("C.m", "def", 2, 3),
1190                t("C.n", "def", 5, 6),
1191            ]
1192        );
1193    }
1194
1195    // ---- header parsing ----------------------------------------------
1196
1197    /// probe `continuation_sig`: `{'C': ('class', 8, 11), 'f': ('def', 1, 5)}`
1198    /// — a wrapped signature ends where the block does, and the bracketed
1199    /// newlines never dedent anything.
1200    #[test]
1201    fn continuation_line_signatures_span_to_the_block_end() {
1202        assert_eq!(
1203            tags(
1204                "def f(\n    a,\n    b,\n):\n    return a\n\n\nclass C(\n    Base,\n):\n    pass\n"
1205            ),
1206            vec![t("C", "class", 8, 11), t("f", "def", 1, 5)]
1207        );
1208    }
1209
1210    /// probes `annotations_colons` `{'f': ('def', 1, 2), 'g': ('def', 5, 6)}`,
1211    /// `lambda_default_colon` `{'f': ('def', 1, 2)}`,
1212    /// `walrus_and_dict_colon` `{'f': ('def', 1, 4)}`,
1213    /// `nested_parens_dict_set` `{'f': ('def', 1, 2)}` and
1214    /// `fstring_format_spec_default` `{'f': ('def', 1, 2)}`: only the
1215    /// TOP-LEVEL colon closes a header.
1216    #[test]
1217    fn only_the_top_level_colon_closes_a_definition_header() {
1218        assert_eq!(
1219            tags(
1220                "def f(a: int = 1, b: dict[str, int] = {}) -> dict[str, int]:\n    return b\n\n\n\
1221                 def g(h=lambda x: x):\n    return h\n"
1222            ),
1223            vec![t("f", "def", 1, 2), t("g", "def", 5, 6)]
1224        );
1225        assert_eq!(
1226            tags("def f(cb={'k': lambda x: x}):\n    pass\n"),
1227            vec![t("f", "def", 1, 2)]
1228        );
1229        assert_eq!(
1230            tags("def f():\n    d = {'a': 1}\n    if (n := len(d)) > 0:\n        return n\n"),
1231            vec![t("f", "def", 1, 4)]
1232        );
1233        assert_eq!(
1234            tags("def f(a={1: {2: 3}}, b=[1, 2], *, c: \"x\" = (1,)):\n    pass\n"),
1235            vec![t("f", "def", 1, 2)]
1236        );
1237        assert_eq!(
1238            tags("def f(x=f\"{1:>10}\"):\n    return x\n"),
1239            vec![t("f", "def", 1, 2)]
1240        );
1241    }
1242
1243    /// probe `type_params_pep695`: `{'C': ('class', 5, 6), 'f': ('def', 1, 2)}`
1244    /// — the `[T]` type-parameter list is bracket-skipped like any other.
1245    #[test]
1246    fn pep695_type_parameter_lists_are_skipped() {
1247        assert_eq!(
1248            tags("def f[T](x: T) -> T:\n    return x\n\n\nclass C[T]:\n    pass\n"),
1249            vec![t("C", "class", 5, 6), t("f", "def", 1, 2)]
1250        );
1251    }
1252
1253    /// probe `name_like_keywords`: `{'deffo': ('def', 6, 7)}` — `class_`,
1254    /// `define`, `defx` are ordinary names.
1255    #[test]
1256    fn keyword_prefixed_names_are_not_keywords() {
1257        assert_eq!(
1258            tags("class_ = 1\ndefine = 2\ndefx = 3\n\n\ndef deffo():\n    pass\n"),
1259            vec![t("deffo", "def", 6, 7)]
1260        );
1261    }
1262
1263    /// probes `redefinition` `{'f': ('def', 5, 6)}` and
1264    /// `decorator_then_class_method` `{'C': ('class', 1, 8),
1265    /// 'C.p': ('def', 6, 8)}` — the dict keeps the LAST definition of a name.
1266    #[test]
1267    fn a_redefined_name_keeps_the_last_definition() {
1268        assert_eq!(
1269            tags("def f():\n    pass\n\n\ndef f():\n    return 2\n"),
1270            vec![t("f", "def", 5, 6)]
1271        );
1272        assert_eq!(
1273            tags(
1274                "class C:\n    @property\n    def p(self):\n        return 1\n\n\
1275                 \x20   @p.setter\n    def p(self, v):\n        self._p = v\n"
1276            ),
1277            vec![t("C", "class", 1, 8), t("C.p", "def", 6, 8)]
1278        );
1279    }
1280
1281    /// probe `match_case`: `{'f': ('def', 1, 6)}` — soft keywords open plain
1282    /// `'other'` blocks.
1283    #[test]
1284    fn match_statements_are_plain_blocks() {
1285        assert_eq!(
1286            tags("def f(x):\n    match x:\n        case 1:\n            pass\n        case _:\n            pass\n"),
1287            vec![t("f", "def", 1, 6)]
1288        );
1289    }
1290
1291    // ---- whitespace, endings, degenerate files ------------------------
1292
1293    /// probes `tabs_indent` `{'f': ('def', 1, 2), 'g': ('def', 5, 6)}`,
1294    /// `tabs_mixed_8` `{'C': ('class', 1, 4), 'C.m': ('def', 2, 3)}` and
1295    /// `tab_and_spaces_same_block` `{'f': ('def', 1, 2)}` (an eight-space
1296    /// COMMENT line under a tab-indented body is fine — comment lines never
1297    /// take part in indentation).
1298    #[test]
1299    fn tab_indentation_works_like_cpythons() {
1300        assert_eq!(
1301            tags("def f():\n\treturn 1\n\n\ndef g():\n\tpass\n"),
1302            vec![t("f", "def", 1, 2), t("g", "def", 5, 6)]
1303        );
1304        assert_eq!(
1305            tags("class C:\n\tdef m(self):\n\t\tpass\n\tx = 1\n"),
1306            vec![t("C", "class", 1, 4), t("C.m", "def", 2, 3)]
1307        );
1308        assert_eq!(
1309            tags("def f():\n\tpass\n        # eight spaces comment\n"),
1310            vec![t("f", "def", 1, 2)]
1311        );
1312    }
1313
1314    /// probe `ERR_taberror`: sphinx raises
1315    /// `TabError('inconsistent use of tabs and spaces in indentation', ...)`
1316    /// for a tab-indented class body continued with eight spaces — the two
1317    /// columns agree at tabsize 8 but disagree at tabsize 1.
1318    #[test]
1319    fn inconsistent_tabs_and_spaces_err() {
1320        let err =
1321            find_tags("class C:\n\tdef m(self):\n\t\tpass\n        x = 1\n").expect_err("TabError");
1322        assert_eq!(
1323            err.to_string(),
1324            "inconsistent use of tabs and spaces in indentation"
1325        );
1326    }
1327
1328    /// probe `crlf`: `{'f': ('def', 1, 2), 'g': ('def', 5, 6)}` — identical
1329    /// to the LF file (the reader normalises endings before this anyway).
1330    #[test]
1331    fn crlf_line_endings_number_lines_the_same() {
1332        assert_eq!(
1333            tags("def f():\r\n    return 1\r\n\r\n\r\ndef g():\r\n    pass\r\n"),
1334            vec![t("f", "def", 1, 2), t("g", "def", 5, 6)]
1335        );
1336    }
1337
1338    /// probes `no_trailing_newline` `{'f': ('def', 1, 2)}`,
1339    /// `no_trailing_newline_oneliner` `{'f': ('def', 1, 1)}` and
1340    /// `trailing_blank_at_eof` `{'f': ('def', 1, 2)}`.
1341    #[test]
1342    fn missing_and_surplus_trailing_newlines_both_land_on_the_last_code_line() {
1343        assert_eq!(tags("def f():\n    return 1"), vec![t("f", "def", 1, 2)]);
1344        assert_eq!(tags("def f(): return 1"), vec![t("f", "def", 1, 1)]);
1345        assert_eq!(
1346            tags("def f():\n    pass\n\n\n\n"),
1347            vec![t("f", "def", 1, 2)]
1348        );
1349    }
1350
1351    /// probe `formfeed`: `{'f': ('def', 1, 2), 'g': ('def', 5, 6)}` —
1352    /// `filter_whitespace` turns the form feed into a space BEFORE the split,
1353    /// so line 3 is a blank line and gets trimmed.
1354    #[test]
1355    fn a_form_feed_becomes_a_blank_line_not_a_line_break() {
1356        assert_eq!(
1357            tags("def f():\n    pass\n\x0c\n\ndef g():\n    pass\n"),
1358            vec![t("f", "def", 1, 2), t("g", "def", 5, 6)]
1359        );
1360    }
1361
1362    /// probes `no_definitions`, `empty`, `only_comments`, `only_blank_lines`
1363    /// — all `{}`.
1364    #[test]
1365    fn files_without_definitions_yield_no_tags() {
1366        assert!(tags("x = 1\ny = 2\nprint(x + y)\n").is_empty());
1367        assert!(tags("").is_empty());
1368        assert!(tags("# hello\n# world\n").is_empty());
1369        assert!(tags("\n\n\n").is_empty());
1370    }
1371
1372    // ---- the error subset --------------------------------------------
1373
1374    /// The failures this port DOES detect, each of which also fails sphinx's
1375    /// `ast.parse` (probes `ERR_unterminated_triple`, `ERR_unterminated_single`,
1376    /// `ERR_unclosed_bracket_then_def`, `ERR_inconsistent_dedent` — sphinx
1377    /// reports them as `parsing %r failed: SyntaxError(...)`, whose CPython
1378    /// repr tail this port cannot reproduce; see the module docs).
1379    #[test]
1380    fn scanner_level_failures_err_with_their_own_detail() {
1381        assert_eq!(
1382            find_tags("x = \"\"\"abc\ndef f():\n    pass\n")
1383                .expect_err("unterminated triple")
1384                .to_string(),
1385            "unterminated triple-quoted string literal (detected at line 3)"
1386        );
1387        assert_eq!(
1388            find_tags("x = 'abc\ndef f():\n    pass\n")
1389                .expect_err("unterminated string")
1390                .to_string(),
1391            "unterminated string literal (detected at line 1)"
1392        );
1393        assert_eq!(
1394            find_tags("x = [1,\ndef f():\n    pass\n")
1395                .expect_err("unclosed bracket")
1396                .to_string(),
1397            "'[' was never closed (opened at line 1)"
1398        );
1399        assert_eq!(
1400            find_tags("def f():\n        pass\n    x = 1\n")
1401                .expect_err("bad dedent")
1402                .to_string(),
1403            "unindent does not match any outer indentation level"
1404        );
1405    }
1406
1407    /// The documented divergence, pinned so it cannot change silently: a file
1408    /// that TOKENIZES but does not PARSE yields tags here, while sphinx warns.
1409    /// probe `double_equals` — sphinx: `parsing '/abs/x.py' failed:
1410    /// SyntaxError('invalid syntax', ('<unknown>', 5, 5, 'x = = 1\n', 5, 6))`;
1411    /// here: the `f` tag. (`def f(:` is NOT such a case — its unclosed bracket
1412    /// fails both sides; probe `ERR_bad_syntax_tokenizable`.)
1413    #[test]
1414    fn a_tokenizable_but_unparsable_file_still_yields_tags_here() {
1415        assert_eq!(
1416            tags("def f():\n    return 1\n\n\nx = = 1\n"),
1417            vec![t("f", "def", 1, 2)]
1418        );
1419    }
1420
1421    // ---- totality -----------------------------------------------------
1422
1423    proptest::proptest! {
1424        /// Arbitrary text must never panic and must keep the tag invariants
1425        /// the reader's slice depends on: `1 <= start <= end`. `(?s)` is
1426        /// load-bearing — without it `.` excludes `\n` and the sweep never
1427        /// leaves a single logical line.
1428        #[test]
1429        fn arbitrary_source_never_panics(src in "(?s).{0,400}") {
1430            if let Ok(map) = find_tags(&src) {
1431                for (_, (_, start, end)) in map {
1432                    proptest::prop_assert!(start >= 1);
1433                    proptest::prop_assert!(start <= end);
1434                }
1435            }
1436        }
1437
1438        /// The same, over sources built from Python-ish fragments, which reach
1439        /// far deeper into the scanner than random text does.
1440        #[test]
1441        fn python_shaped_fragments_never_panic(
1442            parts in proptest::collection::vec(
1443                proptest::sample::select(vec![
1444                    "def f():", "class C:", "@deco", "async def g(): pass", "    pass",
1445                    "\tpass", "x = (", ")", "\"\"\"", "'", "#", "\\", "  # c", "",
1446                    "def h(a,", "):", "if True:", "        deep", "\x0c", "\r",
1447                ]),
1448                0..24,
1449            )
1450        ) {
1451            let src = parts.join("\n");
1452            if let Ok(map) = find_tags(&src) {
1453                for (_, (_, start, end)) in map {
1454                    proptest::prop_assert!(start >= 1);
1455                    proptest::prop_assert!(start <= end);
1456                }
1457            }
1458        }
1459    }
1460}