fxrank_lang_python/functions.rs
1//! Collect every function-unit (`def`/`async def`, method, nested `def`, `lambda`)
2//! from a parsed Python `Module`.
3//!
4//! # Design
5//! - Build `SpanIndex` **once** per file; pass it by reference through the recursion.
6//! - Named units (`def`/`async def`): anchor via pointer-arithmetic on `name.value`
7//! (the libcst `Name.value: &str` borrows the original source buffer).
8//! - Lambda units: collect in pre-order; zip with `lambda_anchors(src)` by index —
9//! the k-th `lambda` keyword token (source order) corresponds to the k-th `Lambda`
10//! node encountered in pre-order. For this ordinal bijection to hold, the
11//! lambda-collection walk must be **exhaustive**: it visits EVERY `Lambda` node
12//! anywhere it can syntactically appear (a **superset** of the effect driver's
13//! descent in `detect/mod.rs` — collection descends even into lazy
14//! generator-expression element bodies and decorator/param-default/`with`-item
15//! positions, because a lambda there is still its own tokenized unit). `collect`
16//! guards the bijection with a `debug_assert_eq!` against the tokenizer count, so
17//! any future drift fails loudly instead of silently mis-anchoring.
18//!
19//! # Borrowed AST (lifetime)
20//! Unlike `syn`/`swc`, libcst's inflated tree **borrows** `&'a str` slices from the
21//! source buffer; it is not owned. So a `FnUnit` cannot retain an *owned* body — it
22//! borrows the body suite (or lambda body expression), parameters, and decorators
23//! from the live `Module`. Collection and analysis therefore run in a **single
24//! borrowed pass** (`PythonFrontend::analyze` keeps `module` + `src` alive while it
25//! iterates), and `analyze_unit` emits **owned** `Hotspot`s so nothing borrowed
26//! outlives the pass.
27
28use libcst_native::{
29 Annotation, Arg, ClassDef, CompoundStatement, Decorator, Expression, FunctionDef, Module,
30 Parameters, SmallStatement, Statement, Suite,
31};
32
33use crate::source::{SpanIndex, anchor_of_subslice};
34
35/// The body of a function-unit: a statement suite (`def`/method), a single
36/// expression (`lambda`), or a module's top-level statement list (the synthetic
37/// `<module>` unit). Borrowed from the parsed `Module`.
38pub enum FnBody<'a> {
39 /// A `def`/`async def`/method body — a statement suite.
40 Suite(&'a Suite<'a>),
41 /// A `lambda` body — a single expression.
42 Expr(&'a Expression<'a>),
43 /// The module's top-level statement list — used by the synthetic `<module>` unit
44 /// that scores import-time effects. The own-body walker treats this like a suite
45 /// of statements but does NOT descend into nested `def`/`class` bodies (those are
46 /// separate units), exactly matching the behaviour of `walk_compound` for nested
47 /// functions.
48 Module(&'a [Statement<'a>]),
49}
50
51/// A single function-shaped scope collected from the source.
52///
53/// `line` and `col` are 1-based (char column), matching `core::Hotspot`.
54///
55/// Borrows the body, parameters, and decorators from the parsed `Module`; valid
56/// only within the single borrowed collect-then-analyze pass.
57pub struct FnUnit<'a> {
58 pub symbol: String,
59 pub line: usize,
60 pub col: usize,
61 pub is_async: bool,
62 /// `true` when this unit should be treated as test code for the purpose of
63 /// source-based skipping (see `PythonFrontend::analyze`):
64 /// - a `test_*`-named top-level or nested function, OR
65 /// - a method whose enclosing class name starts with `Test`, OR
66 /// - a method whose enclosing class subclasses `unittest.TestCase`
67 /// (base named `TestCase` or `unittest.TestCase`).
68 ///
69 /// Lambdas are never test units. This flag is set at collection time when
70 /// the class context is available; `PythonFrontend::analyze` checks it.
71 pub is_test_unit: bool,
72 /// `true` ONLY for a `def`/`async def` directly at the module top level.
73 /// `false` for methods (inside a `ClassDef`), nested defs (inside another `def`),
74 /// lambdas, and the synthetic `<module>` unit.
75 ///
76 /// This is the *never-false-resolve guard* for `canonical_path`: Python
77 /// `FnUnit.symbol` is the BARE name (a method `def write` has symbol `"write"`),
78 /// so without this flag a method `write` would get `canonical_path = [pkg,util,write]`
79 /// and a `from pkg.util import write` call could false-resolve to the method.
80 /// Only module-level defs are importable as `module.<name>`.
81 pub is_module_level: bool,
82 /// The function body (suite or lambda expression) to walk for own-body effects.
83 pub body: FnBody<'a>,
84 /// The unit's own parameters — their **default** expressions are charged to it.
85 pub params: &'a Parameters<'a>,
86 /// The unit's own decorators — their expressions are charged to it (a `lambda`
87 /// has none, so this is empty for lambdas).
88 pub decorators: &'a [Decorator<'a>],
89 /// The unit's return annotation (`-> T`), if any. Used by `coverage` as the
90 /// return slot. `None` for lambdas and for `def`s without a return annotation.
91 pub returns: Option<&'a Annotation<'a>>,
92}
93
94/// Collect every `def`, `async def`, method, nested `def`, and `lambda` from `module`.
95///
96/// `src` must be the **same** BOM-stripped buffer that was passed to `parse_module`
97/// (so that `name.value` subslice pointer arithmetic stays valid).
98///
99/// `span` must be the `SpanIndex` built from the same `src` buffer. The caller
100/// builds it once and passes it here so only a **single** `SpanIndex` is
101/// constructed per file — this function no longer builds its own.
102///
103/// `anchors` must be the pre-computed `lambda_anchors(src)` result (unwrapped by the
104/// caller). Accepting anchors here (rather than calling `lambda_anchors` internally)
105/// ensures tokenization happens exactly **once** per file — the caller obtains the
106/// anchors, handles the `None` failure case, threads the slice in, and the count
107/// invariant below closes the silent-drop hole without a second tokenizer pass.
108///
109/// Returns `(units, lambda_node_count)` where `lambda_node_count` is the number of
110/// `Lambda` AST nodes encountered during the walk — incremented once per node,
111/// regardless of whether an anchor was available and a unit was emitted. The caller
112/// must compare this against `anchors.len()` to detect N≠M bijection breaks in
113/// release builds (the in-body `debug_assert_eq!` catches drift in debug/test only).
114pub fn collect<'a>(
115 module: &'a Module<'a>,
116 src: &str,
117 span: &SpanIndex,
118 anchors: &[(usize, usize)],
119) -> (Vec<FnUnit<'a>>, usize) {
120 let mut ctx = Ctx {
121 src,
122 span,
123 anchors,
124 lambda_idx: 0,
125 class_stack: Vec::new(),
126 def_depth: 0,
127 out: Vec::new(),
128 };
129
130 for stmt in &module.body {
131 collect_in_statement(stmt, &mut ctx);
132 }
133
134 // Safety invariant: the lambda-collection walk MUST visit exactly as many
135 // `Lambda` nodes as `lambda_anchors` counted `lambda` keyword tokens, in the
136 // same order. `lambda_idx` is incremented once per Lambda node encountered
137 // (even when `anchors.get(lambda_idx)` returns `None` and no unit is emitted),
138 // so it equals the total Lambda-node count. If the two walks ever drift (a new
139 // expression position holding a lambda that collection misses), this fails loudly
140 // in debug/test builds rather than silently mis-anchoring every subsequent lambda.
141 debug_assert_eq!(
142 ctx.lambda_idx,
143 anchors.len(),
144 "lambda collection ({}) drifted from tokenizer lambda count ({}); \
145 a Lambda-bearing expression position is not visited by collect_in_expr",
146 ctx.lambda_idx,
147 anchors.len(),
148 );
149
150 let lambda_node_count = ctx.lambda_idx;
151 (ctx.out, lambda_node_count)
152}
153
154/// Build a synthetic [`FnUnit`] representing a module's top-level initialisation
155/// code — the statements that execute when the module is first imported.
156///
157/// The unit gets symbol `"<module>"`, `line = 1`, `col = 1`, and
158/// `body = FnBody::Module(&module.body)`. `is_root` is always `false` at the
159/// frontend level — the CLI sets the real value for explicit-file entries.
160///
161/// **Own-body semantics are preserved**: the own-body walker walks each top-level
162/// statement but does NOT descend into nested `def`/`class` bodies (those are
163/// separate units). Import statements (`import`, `from … import`) have no own-body
164/// runtime effect and are simply skipped by the detectors' `walk_small`
165/// (`Pass`/`Import`/`ImportFrom`/`Global`/`Nonlocal` → no-op arm).
166///
167/// Returns `None` when the module has no top-level executable statements (e.g.
168/// a module containing only `import` declarations and function/class definitions),
169/// because the caller will score first and skip emission when there are no effects
170/// — returning `None` early avoids building empty units. Callers should additionally
171/// skip emitting the resulting `Hotspot` when `hotspot.effects.is_empty()`.
172pub fn module_init_unit<'a>(module: &'a Module<'a>) -> Option<FnUnit<'a>> {
173 // A module with only imports and function/class definitions has no top-level
174 // executable statements that can produce import-time effects. Detect this
175 // cheaply: if every statement is either an import or a compound def/class,
176 // return None early. (The walker itself would produce no effects anyway, but
177 // returning None avoids building the synthetic unit at all.)
178 let has_executable = module.body.iter().any(|stmt| {
179 match stmt {
180 Statement::Simple(line) => line.body.iter().any(|small| {
181 // Import* and Pass and Global/Nonlocal/Break/Continue carry no
182 // import-time effects; everything else (Expr, Assign, AugAssign,
183 // AnnAssign, Return, Raise, Assert, Del, TypeAlias) might.
184 !matches!(
185 small,
186 SmallStatement::Import(_)
187 | SmallStatement::ImportFrom(_)
188 | SmallStatement::Pass(_)
189 | SmallStatement::Global(_)
190 | SmallStatement::Nonlocal(_)
191 | SmallStatement::Break(_)
192 | SmallStatement::Continue(_)
193 )
194 }),
195 Statement::Compound(c) => {
196 // `def` at top level is its OWN unit; only the `def` statement itself
197 // runs at import time (no body effects) — not executable for module-init.
198 // A `class` at top level IS executable: its body runs at class-definition
199 // time (import time). `class C: DATA = load_config()` runs `load_config()`
200 // at import. (Methods inside are their own units and are handled by the
201 // walker, not here.)
202 // Other compound statements (if, for, while, try, with, match) also run
203 // at import time.
204 !matches!(c, CompoundStatement::FunctionDef(_))
205 }
206 }
207 });
208
209 if !has_executable {
210 return None;
211 }
212
213 Some(FnUnit {
214 symbol: "<module>".to_owned(),
215 line: 1,
216 col: 1,
217 is_async: false,
218 is_test_unit: false,
219 // The synthetic `<module>` unit is not a real importable function.
220 is_module_level: false,
221 body: FnBody::Module(&module.body),
222 params: &EMPTY_PARAMS,
223 decorators: &[],
224 returns: None,
225 })
226}
227
228/// A static empty `Parameters` used as the `params` field for the synthetic
229/// `<module>` unit (which has no parameters).
230///
231/// All fields are empty / `None`, so `Parameters<'static>` has no actual
232/// lifetime-dependent borrows. A `&'static Parameters<'static>` satisfies any
233/// `&'a Parameters<'a>` constraint via lifetime coercion (`'static: 'a`).
234static EMPTY_PARAMS: std::sync::LazyLock<libcst_native::Parameters<'static>> =
235 std::sync::LazyLock::new(|| libcst_native::Parameters {
236 params: vec![],
237 posonly_params: vec![],
238 star_arg: None,
239 kwonly_params: vec![],
240 star_kwarg: None,
241 posonly_ind: None,
242 });
243
244/// Enclosing class context for source-based test detection.
245///
246/// When collecting methods inside a `ClassDef`, we push a `ClassCtx` so that
247/// each emitted method `FnUnit` can be tagged `is_test_unit` if the class is a
248/// test class (name starts with `Test` or it subclasses `unittest.TestCase`).
249#[derive(Clone)]
250struct ClassCtx {
251 /// `true` when the enclosing class is a test class.
252 is_test_class: bool,
253}
254
255/// Shared traversal context — bundles the immutable lookup tables and the mutable
256/// lambda cursor + output, so the recursion signatures stay short.
257struct Ctx<'a, 'b> {
258 src: &'b str,
259 span: &'b SpanIndex<'b>,
260 anchors: &'b [(usize, usize)],
261 lambda_idx: usize,
262 /// Stack of enclosing class contexts (outermost first).
263 /// Empty when not inside any class body.
264 class_stack: Vec<ClassCtx>,
265 /// Number of enclosing `def`/`async def` scopes. A `def` at the module top
266 /// level has `def_depth == 0` at the point it is emitted; nested defs and
267 /// methods have `def_depth >= 1`. Used to set `FnUnit.is_module_level`.
268 def_depth: usize,
269 out: Vec<FnUnit<'a>>,
270}
271
272// ─── statement-level traversal ────────────────────────────────────────────────
273
274fn collect_in_statement<'a>(stmt: &'a Statement<'a>, ctx: &mut Ctx<'a, '_>) {
275 match stmt {
276 Statement::Simple(line) => {
277 for small in &line.body {
278 collect_in_small(small, ctx);
279 }
280 }
281 Statement::Compound(compound) => {
282 collect_in_compound(compound, ctx);
283 }
284 }
285}
286
287fn collect_in_compound<'a>(compound: &'a CompoundStatement<'a>, ctx: &mut Ctx<'a, '_>) {
288 match compound {
289 CompoundStatement::FunctionDef(f) => {
290 collect_funcdef(f, ctx);
291 }
292 CompoundStatement::ClassDef(c) => {
293 collect_classdef(c, ctx);
294 }
295 CompoundStatement::If(i) => {
296 collect_in_expr(&i.test, ctx);
297 collect_in_suite(&i.body, ctx);
298 // `elif`/`else` clauses are flattened into `orelse` — traverse if present
299 if let Some(orelse) = &i.orelse {
300 collect_in_or_else(orelse, ctx);
301 }
302 }
303 CompoundStatement::For(f) => {
304 collect_in_expr(&f.iter, ctx);
305 collect_in_suite(&f.body, ctx);
306 if let Some(orelse) = &f.orelse {
307 collect_in_suite(&orelse.body, ctx);
308 }
309 }
310 CompoundStatement::While(w) => {
311 collect_in_expr(&w.test, ctx);
312 collect_in_suite(&w.body, ctx);
313 if let Some(orelse) = &w.orelse {
314 collect_in_suite(&orelse.body, ctx);
315 }
316 }
317 CompoundStatement::Try(t) => {
318 collect_in_suite(&t.body, ctx);
319 for handler in &t.handlers {
320 collect_in_suite(&handler.body, ctx);
321 }
322 if let Some(orelse) = &t.orelse {
323 collect_in_suite(&orelse.body, ctx);
324 }
325 if let Some(finalbody) = &t.finalbody {
326 collect_in_suite(&finalbody.body, ctx);
327 }
328 }
329 CompoundStatement::TryStar(t) => {
330 collect_in_suite(&t.body, ctx);
331 for handler in &t.handlers {
332 collect_in_suite(&handler.body, ctx);
333 }
334 if let Some(orelse) = &t.orelse {
335 collect_in_suite(&orelse.body, ctx);
336 }
337 if let Some(finalbody) = &t.finalbody {
338 collect_in_suite(&finalbody.body, ctx);
339 }
340 }
341 CompoundStatement::With(w) => {
342 // `with`-item context expressions are evaluated here and may hold lambdas
343 // (e.g. `with (lambda: cm())() as c:`), so descend into them.
344 for item in &w.items {
345 collect_in_expr(&item.item, ctx);
346 }
347 collect_in_suite(&w.body, ctx);
348 }
349 CompoundStatement::Match(m) => {
350 collect_in_expr(&m.subject, ctx);
351 for case in &m.cases {
352 collect_in_suite(&case.body, ctx);
353 }
354 }
355 }
356}
357
358/// Traverse an `If`'s `orelse` field, which is itself an `Elif` or an `Else`.
359fn collect_in_or_else<'a>(orelse: &'a libcst_native::OrElse<'a>, ctx: &mut Ctx<'a, '_>) {
360 match orelse {
361 libcst_native::OrElse::Elif(elif) => {
362 collect_in_expr(&elif.test, ctx);
363 collect_in_suite(&elif.body, ctx);
364 if let Some(inner) = &elif.orelse {
365 collect_in_or_else(inner, ctx);
366 }
367 }
368 libcst_native::OrElse::Else(e) => {
369 collect_in_suite(&e.body, ctx);
370 }
371 }
372}
373
374fn collect_funcdef<'a>(f: &'a FunctionDef<'a>, ctx: &mut Ctx<'a, '_>) {
375 // Anchor on the function name subslice (borrows `src`).
376 let off = anchor_of_subslice(ctx.src, f.name.value);
377 let (line, col) = ctx.span.line_col(off);
378
379 // Source-based test detection:
380 // - `test_*` named function (at any nesting level), OR
381 // - method of an enclosing class that is a test class.
382 let in_test_class = ctx.class_stack.last().is_some_and(|c| c.is_test_class);
383 let is_test_unit = f.name.value.starts_with("test_") || in_test_class;
384
385 // Module-level guard: a def is module-level only when it is directly in the
386 // top-level statement list (def_depth == 0, class_stack empty). A method
387 // (class_stack non-empty) and a nested def (def_depth >= 1) are NOT
388 // module-level and thus not importable as `module.<name>`. (P2-1)
389 let is_module_level = ctx.def_depth == 0 && ctx.class_stack.is_empty();
390
391 ctx.out.push(FnUnit {
392 symbol: f.name.value.to_owned(),
393 line,
394 col,
395 is_async: f.asynchronous.is_some(),
396 is_test_unit,
397 is_module_level,
398 body: FnBody::Suite(&f.body),
399 params: &f.params,
400 decorators: &f.decorators,
401 returns: f.returns.as_ref(),
402 });
403 // Decorator expressions and parameter-default expressions are evaluated at
404 // def-time in the enclosing scope and may contain lambdas (each still its own
405 // unit + tokenized), so collection must descend into them — in source order:
406 // decorators precede the `def`, parameter defaults follow it.
407 for dec in &f.decorators {
408 collect_in_expr(&dec.decorator, ctx);
409 }
410 collect_in_params(&f.params, ctx);
411 // Recurse into body for nested defs and lambdas. Increment def_depth so
412 // any nested defs are NOT treated as module-level.
413 ctx.def_depth += 1;
414 collect_in_suite(&f.body, ctx);
415 ctx.def_depth -= 1;
416}
417
418/// Collect lambdas in parameter-default expressions (`def f(cb=lambda: 0)`).
419fn collect_in_params<'a>(params: &'a Parameters<'a>, ctx: &mut Ctx<'a, '_>) {
420 let positional = params
421 .posonly_params
422 .iter()
423 .chain(¶ms.params)
424 .chain(¶ms.kwonly_params);
425 for p in positional {
426 if let Some(default) = &p.default {
427 collect_in_expr(default, ctx);
428 }
429 }
430 if let Some(libcst_native::StarArg::Param(p)) = ¶ms.star_arg
431 && let Some(default) = &p.default
432 {
433 collect_in_expr(default, ctx);
434 }
435 if let Some(p) = ¶ms.star_kwarg
436 && let Some(default) = &p.default
437 {
438 collect_in_expr(default, ctx);
439 }
440}
441
442fn collect_classdef<'a>(c: &'a ClassDef<'a>, ctx: &mut Ctx<'a, '_>) {
443 // We do NOT emit a FnUnit for the class itself (classes aren't functions).
444 // Push class context so nested methods know their enclosing class.
445 let is_test_class = class_name_is_test(c.name.value) || class_bases_are_test_case(&c.bases);
446 // Class decorators, base-class arg values, and keyword args are evaluated at
447 // class-definition time in the enclosing scope and may contain lambdas (each
448 // still tokenized) — collection must descend so the bijection holds.
449 for dec in &c.decorators {
450 collect_in_expr(&dec.decorator, ctx);
451 }
452 for base in &c.bases {
453 collect_in_expr(&base.value, ctx);
454 }
455 for kw in &c.keywords {
456 collect_in_expr(&kw.value, ctx);
457 }
458 ctx.class_stack.push(ClassCtx { is_test_class });
459 collect_in_suite(&c.body, ctx);
460 ctx.class_stack.pop();
461}
462
463/// Return `true` if the class name signals it is a test class (`Test*` prefix).
464fn class_name_is_test(name: &str) -> bool {
465 name.starts_with("Test")
466}
467
468/// Return `true` if any base in `bases` names `TestCase` or `unittest.TestCase`.
469///
470/// Matches:
471/// - `class Foo(TestCase):` → base is a `Name("TestCase")`
472/// - `class Foo(unittest.TestCase):` → base is an `Attribute { value: Name("unittest"), attr: "TestCase" }`
473fn class_bases_are_test_case(bases: &[Arg<'_>]) -> bool {
474 bases.iter().any(|arg| match &arg.value {
475 Expression::Name(n) => n.value == "TestCase",
476 Expression::Attribute(a) => {
477 a.attr.value == "TestCase"
478 && matches!(&*a.value, Expression::Name(n) if n.value == "unittest")
479 }
480 _ => false,
481 })
482}
483
484fn collect_in_suite<'a>(suite: &'a Suite<'a>, ctx: &mut Ctx<'a, '_>) {
485 let stmts: &[Statement<'a>] = match suite {
486 Suite::IndentedBlock(b) => &b.body,
487 Suite::SimpleStatementSuite(s) => {
488 // A one-liner body (`def f(): return 1`). Walk small statements for lambdas.
489 for small in &s.body {
490 collect_in_small(small, ctx);
491 }
492 return;
493 }
494 };
495 for stmt in stmts {
496 collect_in_statement(stmt, ctx);
497 }
498}
499
500// ─── small-statement-level traversal ──────────────────────────────────────────
501
502/// Walk a `SmallStatement` for `Lambda` nodes (no new named functions here).
503fn collect_in_small<'a>(small: &'a SmallStatement<'a>, ctx: &mut Ctx<'a, '_>) {
504 match small {
505 SmallStatement::Assign(a) => {
506 collect_in_expr(&a.value, ctx);
507 }
508 SmallStatement::AnnAssign(a) => {
509 if let Some(v) = &a.value {
510 collect_in_expr(v, ctx);
511 }
512 }
513 SmallStatement::AugAssign(a) => {
514 collect_in_expr(&a.value, ctx);
515 }
516 SmallStatement::Return(r) => {
517 if let Some(v) = &r.value {
518 collect_in_expr(v, ctx);
519 }
520 }
521 SmallStatement::Expr(e) => {
522 collect_in_expr(&e.value, ctx);
523 }
524 SmallStatement::Raise(r) => {
525 if let Some(exc) = &r.exc {
526 collect_in_expr(exc, ctx);
527 }
528 if let Some(from) = &r.cause {
529 collect_in_expr(&from.item, ctx);
530 }
531 }
532 SmallStatement::Assert(a) => {
533 collect_in_expr(&a.test, ctx);
534 if let Some(msg) = &a.msg {
535 collect_in_expr(msg, ctx);
536 }
537 }
538 SmallStatement::Del(d) => {
539 collect_in_del_target(&d.target, ctx);
540 }
541 // Pass / Break / Continue / Import / ImportFrom / Global / Nonlocal /
542 // TypeAlias hold no function-shaped sub-expressions we need.
543 _ => {}
544 }
545}
546
547// ─── expression-level traversal ───────────────────────────────────────────────
548
549/// **Exhaustive** pre-order traversal of an expression tree, collecting every
550/// `Lambda` node and recursing into **every** child-expression of every variant
551/// that can contain a sub-expression.
552///
553/// # Bijection invariant (FIX 1)
554/// This walk MUST visit every `Lambda` node anywhere it can syntactically appear,
555/// so that the count and pre-order of collected lambdas EXACTLY matches the
556/// `lambda` keyword tokens that `lambda_anchors` (the tokenizer) counts. A missed
557/// lambda makes every subsequently-collected lambda consume the wrong anchor.
558///
559/// Lambda collection is therefore a **superset** of the effect driver's descent
560/// (`detect/mod.rs`): the driver skips *lazy* generator-expression element bodies
561/// for effect attribution, but a lambda living there is still its own unit and is
562/// still tokenized — so collection descends there unconditionally. Do not copy the
563/// driver's eager/lazy rules here.
564fn collect_in_expr<'a>(expr: &'a Expression<'a>, ctx: &mut Ctx<'a, '_>) {
565 match expr {
566 Expression::Lambda(l) => {
567 // Pre-order: emit this lambda BEFORE descending into its body.
568 // Lambdas are never considered test units or roots, and are never
569 // module-level (not importable as `module.<name>`).
570 if let Some(&(line, col)) = ctx.anchors.get(ctx.lambda_idx) {
571 ctx.out.push(FnUnit {
572 symbol: format!("<lambda@L{line}C{col}>"),
573 line,
574 col,
575 is_async: false,
576 is_test_unit: false,
577 is_module_level: false,
578 body: FnBody::Expr(&l.body),
579 params: &l.params,
580 decorators: &[],
581 returns: None,
582 });
583 }
584 ctx.lambda_idx += 1;
585 // A lambda's own parameter defaults are evaluated at def-time and may
586 // hold nested lambdas; descend into them and the body.
587 collect_in_params(&l.params, ctx);
588 collect_in_expr(&l.body, ctx);
589 }
590
591 // ── compound expressions that may contain lambdas ──────────────────────
592 Expression::BinaryOperation(b) => {
593 collect_in_expr(&b.left, ctx);
594 collect_in_expr(&b.right, ctx);
595 }
596 Expression::BooleanOperation(b) => {
597 collect_in_expr(&b.left, ctx);
598 collect_in_expr(&b.right, ctx);
599 }
600 Expression::UnaryOperation(u) => {
601 collect_in_expr(&u.expression, ctx);
602 }
603 Expression::Comparison(c) => {
604 collect_in_expr(&c.left, ctx);
605 for comp in &c.comparisons {
606 collect_in_expr(&comp.comparator, ctx);
607 }
608 }
609 Expression::IfExp(i) => {
610 collect_in_expr(&i.test, ctx);
611 collect_in_expr(&i.body, ctx);
612 collect_in_expr(&i.orelse, ctx);
613 }
614 Expression::Call(c) => {
615 collect_in_expr(&c.func, ctx);
616 // Every argument value — positional, keyword (`k=lambda…`), and starred
617 // (`*args` / `**kw`) — is held in `arg.value`.
618 for arg in &c.args {
619 collect_in_expr(&arg.value, ctx);
620 }
621 }
622 Expression::Attribute(a) => {
623 collect_in_expr(&a.value, ctx);
624 }
625 Expression::Subscript(s) => {
626 collect_in_expr(&s.value, ctx);
627 // The slice keys can hold lambdas (`d[(lambda: k)()]`).
628 for element in &s.slice {
629 collect_in_base_slice(&element.slice, ctx);
630 }
631 }
632 Expression::Tuple(t) => {
633 for el in &t.elements {
634 collect_in_element(el, ctx);
635 }
636 }
637 Expression::List(l) => {
638 for el in &l.elements {
639 collect_in_element(el, ctx);
640 }
641 }
642 Expression::Set(s) => {
643 for el in &s.elements {
644 collect_in_element(el, ctx);
645 }
646 }
647 Expression::Dict(d) => {
648 for el in &d.elements {
649 match el {
650 libcst_native::DictElement::Simple { key, value, .. } => {
651 collect_in_expr(key, ctx);
652 collect_in_expr(value, ctx);
653 }
654 libcst_native::DictElement::Starred(s) => {
655 collect_in_expr(&s.value, ctx);
656 }
657 }
658 }
659 }
660 // Comprehensions: descend into the element/key/value AND the full `for … in`
661 // clause(s) (iterable, `if` filters, nested fors). Unconditional — unlike the
662 // effect driver, collection does not treat generator expressions as lazy.
663 Expression::ListComp(l) => {
664 collect_in_expr(&l.elt, ctx);
665 collect_in_comp_for(&l.for_in, ctx);
666 }
667 Expression::SetComp(s) => {
668 collect_in_expr(&s.elt, ctx);
669 collect_in_comp_for(&s.for_in, ctx);
670 }
671 Expression::GeneratorExp(g) => {
672 collect_in_expr(&g.elt, ctx);
673 collect_in_comp_for(&g.for_in, ctx);
674 }
675 Expression::DictComp(d) => {
676 collect_in_expr(&d.key, ctx);
677 collect_in_expr(&d.value, ctx);
678 collect_in_comp_for(&d.for_in, ctx);
679 }
680 Expression::FormattedString(fs) => {
681 collect_in_fstring_parts(&fs.parts, ctx);
682 }
683 Expression::Yield(y) => {
684 if let Some(v) = &y.value {
685 match &**v {
686 libcst_native::YieldValue::Expression(e) => {
687 collect_in_expr(e, ctx);
688 }
689 libcst_native::YieldValue::From(f) => {
690 collect_in_expr(&f.item, ctx);
691 }
692 }
693 }
694 }
695 Expression::Await(a) => {
696 collect_in_expr(&a.expression, ctx);
697 }
698 Expression::NamedExpr(n) => {
699 collect_in_expr(&n.value, ctx);
700 }
701 Expression::StarredElement(s) => {
702 collect_in_expr(&s.value, ctx);
703 }
704
705 // Leaf expressions (Name, Ellipsis, Integer, Float, Imaginary, SimpleString,
706 // ConcatenatedString, TemplatedString) contain no lambdas.
707 _ => {}
708 }
709}
710
711/// Walk a comprehension's `for … in …` clause(s): the iterable, every `if` filter,
712/// and any nested `for`. All can contain lambdas.
713fn collect_in_comp_for<'a>(comp: &'a libcst_native::CompFor<'a>, ctx: &mut Ctx<'a, '_>) {
714 collect_in_expr(&comp.iter, ctx);
715 for cond in &comp.ifs {
716 collect_in_expr(&cond.test, ctx);
717 }
718 if let Some(inner) = &comp.inner_for_in {
719 collect_in_comp_for(inner, ctx);
720 }
721}
722
723/// Walk a subscript slice (`Index` value, or `Slice` lower/upper/step) for lambdas.
724fn collect_in_base_slice<'a>(slice: &'a libcst_native::BaseSlice<'a>, ctx: &mut Ctx<'a, '_>) {
725 match slice {
726 libcst_native::BaseSlice::Index(i) => collect_in_expr(&i.value, ctx),
727 libcst_native::BaseSlice::Slice(s) => {
728 if let Some(lower) = &s.lower {
729 collect_in_expr(lower, ctx);
730 }
731 if let Some(upper) = &s.upper {
732 collect_in_expr(upper, ctx);
733 }
734 if let Some(step) = &s.step {
735 collect_in_expr(step, ctx);
736 }
737 }
738 }
739}
740
741/// Walk f-string parts for lambdas — both the `{expr}` interpolations and any
742/// nested `format_spec` (which can itself contain further interpolations).
743fn collect_in_fstring_parts<'a>(
744 parts: &'a [libcst_native::FormattedStringContent<'a>],
745 ctx: &mut Ctx<'a, '_>,
746) {
747 for part in parts {
748 if let libcst_native::FormattedStringContent::Expression(e) = part {
749 collect_in_expr(&e.expression, ctx);
750 if let Some(spec) = &e.format_spec {
751 collect_in_fstring_parts(spec, ctx);
752 }
753 }
754 }
755}
756
757/// Walk a `del` target for lambdas (only `del d[(lambda: k)()]`-style subscripts
758/// can hold one, but the tokenizer still counts it, so descend for completeness).
759fn collect_in_del_target<'a>(
760 target: &'a libcst_native::DelTargetExpression<'a>,
761 ctx: &mut Ctx<'a, '_>,
762) {
763 match target {
764 libcst_native::DelTargetExpression::Attribute(a) => collect_in_expr(&a.value, ctx),
765 libcst_native::DelTargetExpression::Subscript(s) => {
766 collect_in_expr(&s.value, ctx);
767 for element in &s.slice {
768 collect_in_base_slice(&element.slice, ctx);
769 }
770 }
771 libcst_native::DelTargetExpression::Tuple(t) => {
772 for el in &t.elements {
773 collect_in_element(el, ctx);
774 }
775 }
776 libcst_native::DelTargetExpression::List(l) => {
777 for el in &l.elements {
778 collect_in_element(el, ctx);
779 }
780 }
781 libcst_native::DelTargetExpression::Name(_) => {}
782 }
783}
784
785fn collect_in_element<'a>(el: &'a libcst_native::Element<'a>, ctx: &mut Ctx<'a, '_>) {
786 match el {
787 libcst_native::Element::Simple { value, .. } => collect_in_expr(value, ctx),
788 libcst_native::Element::Starred(s) => collect_in_expr(&s.value, ctx),
789 }
790}
791
792// ─── tests ────────────────────────────────────────────────────────────────────
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797
798 #[test]
799 fn collects_all_named_and_lambda_units() {
800 let src = std::fs::read_to_string("tests/fixtures/functions.py").unwrap();
801 let module = libcst_native::parse_module(&src, None).unwrap();
802 let span = SpanIndex::new(&src);
803 let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
804 let (units, lambda_node_count) = collect(&module, &src, &span, &anchors);
805 assert_eq!(
806 lambda_node_count,
807 anchors.len(),
808 "lambda node count must equal tokenizer anchor count"
809 );
810 let symbols: Vec<&str> = units.iter().map(|u| u.symbol.as_str()).collect();
811 assert!(symbols.contains(&"top"));
812 assert!(symbols.contains(&"method"));
813 assert!(symbols.contains(&"fetcher"));
814 assert!(units.iter().any(|u| u.symbol.starts_with("<lambda@L")));
815 assert!(
816 units
817 .iter()
818 .find(|u| u.symbol == "fetcher")
819 .unwrap()
820 .is_async
821 );
822 // four lambdas (g, h, nested-outer, nested-inner), each a distinct anchor —
823 // proves empty-body (h) and nested (outer+inner) anchor via the ordinal bijection.
824 let mut lambdas: Vec<&str> = symbols
825 .iter()
826 .filter(|s| s.starts_with("<lambda@L"))
827 .cloned()
828 .collect();
829 assert_eq!(lambdas.len(), 4);
830 lambdas.sort();
831 lambdas.dedup();
832 assert_eq!(lambdas.len(), 4, "all lambda anchors distinct");
833 }
834
835 /// Regression for the lambda anchor bijection (FIX 1). The fixture places
836 /// lambdas in positions the original collection walk MISSED (comprehension
837 /// iterable/condition, generator-expression element body, subscript slice,
838 /// f-string expression, parameter default, `with`-item) BEFORE an effectful
839 /// trailing lambda. If any leading lambda is dropped, the ordinal bijection
840 /// drifts and the trailing lambda gets the wrong anchor.
841 #[test]
842 fn lambda_collection_count_matches_tokenizer_and_trailing_anchor_correct() {
843 let src = std::fs::read_to_string("tests/fixtures/lambda_positions.py").unwrap();
844 let module = libcst_native::parse_module(&src, None).unwrap();
845 let span = SpanIndex::new(&src);
846 let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
847 let (units, lambda_node_count) = collect(&module, &src, &span, &anchors);
848
849 let lambdas: Vec<&str> = units
850 .iter()
851 .map(|u| u.symbol.as_str())
852 .filter(|s| s.starts_with("<lambda@L"))
853 .collect();
854
855 // Count invariant: Lambda-node count (ctx.lambda_idx) == tokenizer anchor count.
856 // This uses the node count, not the emitted-unit count, so N>M (more nodes than
857 // anchors) is detected even when M units were still emitted successfully.
858 let anchor_count = anchors.len();
859 assert_eq!(
860 lambda_node_count, anchor_count,
861 "lambda node count must equal tokenizer count; got {lambdas:?}"
862 );
863
864 // The trailing `t = lambda z: requests.get(z)` lives on the last
865 // non-blank line. Find its true (line, col) directly from the source and
866 // assert the collected anchor matches.
867 let (line0, line_text) = src
868 .lines()
869 .enumerate()
870 .find(|(_, l)| l.starts_with("t = lambda"))
871 .expect("trailing lambda line present");
872 let line = line0 + 1;
873 let col = line_text.find("lambda").unwrap() + 1; // 1-based char col (ASCII line)
874 let expected = format!("<lambda@L{line}C{col}>");
875 assert!(
876 lambdas.contains(&expected.as_str()),
877 "trailing lambda must anchor to {expected}; got {lambdas:?}"
878 );
879 }
880}