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}