Skip to content

Analyze the body of a lambda literal - #481

Open
ahogappa wants to merge 4 commits into
ruby:masterfrom
ahogappa:lambda-method-args
Open

Analyze the body of a lambda literal#481
ahogappa wants to merge 4 commits into
ruby:masterfrom
ahogappa:lambda-method-args

Conversation

@ahogappa

Copy link
Copy Markdown
Contributor

Motivation

A lambda literal was a blind spot: LambdaNode#install0 returned a bare Proc and never looked inside.
No diagnostics in the body, no class or method defined there registered, and every .call untyped — which spreads through whatever the result reaches.
lambda {} had none of these gaps, because it goes through the block handling of a call.

-> { undefined_method }       # no diagnostic  ->  undefined method
->(*x) { x }.call(1, "str")   # untyped        ->  Array[Integer | String]
->(x, y) { x }.call([1, 2])   # untyped        ->  wrong number of arguments

Changes

  • Block handling moves out of CallBaseNode into BlockNode, which a call now holds as a subnode.
  • LambdaNode is a BlockNode — the same scope and parameters. It is not modeled as a lambda call, because -> must not dispatch to a user-defined lambda. Its return and break leave the lambda rather than the enclosing method, so they join the value #call returns.
  • Proc#call on a lambda binds arguments like a method's: pass_arguments moves from MethodDefBox onto FormalArguments, so rest, post and keyword parameters bind and arity is checked.

lambda { } is left alone: which method a call reaches is known only after inference, while where an escape goes is wired when the node is built.

Verification

New scenarios under scenario/lambda/. Analyzing lib/typeprof/core with and without the change gives byte-identical output, so the block path is untouched.

🤖 Generated with Claude Code

The parsing of block parameters, the block-local scope, and the
installation of the block body lived inside CallBaseNode. Nothing there
depends on being a call, and a lambda literal needs exactly the same
handling without being a call, so it moves into its own node that a call
holds as a subnode. Block parameters now go through AST.parse_params and
multi-target binding through a shared Node helper, both of which DefNode
already used for the same job.

Every node now answers ret_code_range, so the escape box no longer picks
a code-range method by node class; a body-bearing node points at its
last statement, the rest at themselves. A diagnostic on an empty block
therefore points at the block instead of the whole call, and a block on
`super do ... end` no longer falls through to a debug `pp`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`-> {}` was a stub that produced a bare Proc and never looked inside, so
method calls in the body got no diagnostics, classes and methods defined
there were not registered, and parameters were untyped. `lambda {}` had
none of these gaps because it goes through the block handling of a call.

LambdaNode is a BlockNode: a lambda literal builds its scope, parameters
and body exactly like a block. It is not modeled as a `lambda` call
because `->` is syntax and must not dispatch to a user-defined `lambda`
method.

Where a lambda differs from a block is how the body leaves. A block's
`return` exits the enclosing method and its `break` exits the method
that yielded; a lambda's `return` and `break` both exit the lambda, so
its body gets its own return boxes and all three escapes join the value
the caller of #call receives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A lambda literal carried a Block, which models what a yielding method
hands to a block: positionals only. So a lambda whose parameters a block
cannot express — rest, post, keywords — bound nothing, and the body read
those parameters as nil. Its arity was not checked either, and a sole
array argument was deconstructed over the parameters the way a block
deconstructs one, which a lambda does not do.

A lambda is entered like a method, so it now carries the formals a method
carries and Proc#call binds against them. The binding itself is the one
a method definition already used: pass_arguments moves off MethodDefBox
onto FormalArguments, which is what both now hold.

Proc#call already received the whole ActualArguments and passed on only
the positionals, so the keywords and splat flags were there all along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extracting the block parameters into BlockNode routed them through
parse_params, which reads the rest parameter. The previous block-only
code read just the requireds and the optionals, so it never met the node
`{ |a,| }` puts there: Prism::ImplicitRestNode, which has no #name.

A trailing comma is the only way to write a rest without naming it in a
block, and it cannot appear in a method definition or a lambda literal,
where it is a syntax error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants