Skip to main content

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(&params.params)
424        .chain(&params.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)) = &params.star_arg
431        && let Some(default) = &p.default
432    {
433        collect_in_expr(default, ctx);
434    }
435    if let Some(p) = &params.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}