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(¶ms.params)
302 .chain(¶ms.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)) = ¶ms.star_arg
309 && let Some(default) = &p.default
310 {
311 collect_in_expr(default, ctx);
312 }
313 if let Some(p) = ¶ms.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}