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