Bug #22302
closedPrism and parse.y disagree on the `:line` event for a bare `nil` method body
Description
Summary¶
The Prism compiler and the parse.y compiler produce a different set of :line trace points (line events) for a bare nil literal that is the value-producing body of a method. Prism emits a line event on the nil's own line; parse.y does not.
- With
--parser=prism, executingm(or setting aTracePoint(:line)/ coverage / a debugger breakpoint) reports a line event on line 2. - With
--parser=parse.y, there is no line event on line 2.
Reproduction¶
Compile-time line events¶
def line_events(iseq)
events = iseq.trace_points.select { |_, event| event == :line }.map(&:first)
iseq.each_child { |child| events.concat(line_events(child)) }
events.sort
end
p line_events(RubyVM::InstructionSequence.compile("def m\n nil\nend\n"))
Runtime :line events¶
seen = []
TracePoint.new(:line) { |tp| seen << tp.lineno if tp.path.end_with?("body.rb") }.enable do
File.write("body.rb", "def m\n nil\nend\nm\n")
load "./body.rb"
end
p seen.sort
Under Prism a line event fires on line 2 (the nil) when m is called; under parse.y it does not.
Instruction sequences¶
The method body iseq differs only in the line attributed to the putnil, and whether it carries the line event ([Li]):
# --parser=parse.y
0000 putnil ( 1)[Ca]
0001 leave ( 3)[Re]
# --parser=prism
0000 putnil ( 2)[LiCa]
0001 leave ( 3)[Re]
parse.y attributes the method's implicit-nil putnil to the definition line (1) with only the RUBY_EVENT_CALL flag, so no line event is emitted for line 2.
Prism attributes the putnil to the nil's own line (2) and emits a line event there.
Scope of the difference¶
The difference is specific to a bare nil in method (def/defs) tail position. Other cases behave the same under both parsers:
- Other literals in the same position emit a line event under both parsers (
def m; true; end,def m; 1; end,def m; :a; end,def m; "s"; end). - A bare
nilat the top level, or as the body of a block or lambda, emits a line event under both parsers. - The failing shape also occurs when the tail
nilis reached through the branch of a conditional, e.g. a method whose body isif cond; nil; elsif ...; end.
The behavior is consistent across Ruby versions: parse.y has never emitted the line event for this case (checked back to 2.3), and Prism has emitted it since its compiler could be selected. This is a standing difference between the two compilers rather than a recent change; it only became visible by default when Prism became the default parser.
Note on the cause¶
This difference stems from void-expression elimination: parse.y treats the trailing nil as the method's implicit nil return and elides it (compiling a synthetic putnil with no line event), whereas Prism keeps it as an explicit value expression that carries a line event.
It is not ideal that whether a :line event is emitted depends on whether an expression is eliminated as void, but that coupling is already the established behavior in both compilers — an unused bare literal in non-tail statement position (e.g. 1 or :a followed by another statement) likewise loses its line event under both parsers — so this is not unique to the case here, only the one place the two happen to disagree.
Question¶
Which behavior is intended — should a bare nil in method tail position emit a :line event or not? Currently the two parsers disagree, which affects TracePoint(:line), line coverage, and debuggers depending on which parser compiled the code.
Environment¶
Also reproduced on ruby 3.4.0 with --parser=prism / --parser=parse.y.
(found in https://github.com/ruby/prism/pull/4223)
Updated by Eregon (Benoit Daloze) 17 days ago
I have tried to make Prism match parse.y, but after doing so I think that makes little sense:
- I think this is a parse.y issue, parse.y treats an explicit
nillike an implicitniland drops it entirely from the AST (no body,body: nil):
irb(main):001' RubyVM::AbstractSyntaxTree.parse('def m
irb(main):002' nil
irb(main):003> end')
=>
(SCOPE@1:0-3:3
tbl: []
args: nil
body:
(DEFN@1:0-3:3
mid: :m
body:
(SCOPE@1:0-3:3
tbl: []
args: (ARGS@1:5-1:5 pre_num: 0 pre_init: nil opt: nil first_post: nil post_num: 0 post_init: nil rest: nil kw: nil kwrest: nil block: nil)
body: nil)))
irb(main):004' RubyVM::AbstractSyntaxTree.parse('def m
irb(main):005' true
irb(main):006> end')
=>
(SCOPE@1:0-3:3
tbl: []
args: nil
body:
(DEFN@1:0-3:3
mid: :m
body:
(SCOPE@1:0-3:3
tbl: []
args: (ARGS@1:5-1:5 pre_num: 0 pre_init: nil opt: nil first_post: nil post_num: 0 post_init: nil rest: nil kw: nil kwrest: nil block: nil)
body: (TRUE@2:2-2:6))))
- This also means line Coverage is missing the
nilthe user wrote, that seems clearly undesirable, for cases like:
See Instruction sequences above, there is anyway a putnil so it should be attributed to the user nil.
and
prism_compile.cwould need to do extra work just to mimic parse.y: https://github.com/ruby/ruby/compare/master...eregon:ruby:prism-tail-nil-line-event- newline marking in Prism (available for both Ruby & Java) would need to do extra work just to mimic parse.y: https://github.com/ruby/prism/commit/f6b413ec4bac4f908a26c921893545baef6e1827. That means extra subtree walks, which is not OK performance-wise at least for TruffleRuby & JRuby line marking.
Claude's reasoning and repro follows:
I think Prism is correct here and parse.y has the bug: the nil line is executed, so it should produce a :line event and be counted by line coverage.
Line coverage makes this concrete. With:
require "coverage"
Coverage.start(lines: true)
load "./body.rb"
p Coverage.result.find { |k,| k.end_with?("body.rb") }.last[:lines]
Under Prism, line 2 is reported as executed (1); under parse.y it is reported as non-executable (nil), even though calling m runs putnil on line 2.
So parse.y under-counts coverage and a debugger cannot break on that line.
The same happens for a nil reached through a conditional branch that is the method's value, e.g. def m(x); if x; nil; end; end — Prism records line coverage for the nil, parse.y does not.
The cause is that parse.y eliminates the trailing nil at the AST level, so the information that line 2 is executable is simply lost:
$ ruby --parser=parse.y -e 'pp RubyVM::AbstractSyntaxTree.parse("def m\n nil\nend")'
(SCOPE@1:0-3:3
...
(DEFN@1:0-3:3
mid: :m
body: (SCOPE@1:0-3:3 tbl: [] args: (...) body: nil)))
The method body is nil (empty), so parse.y compiles a synthetic putnil that it attributes to the definition line rather than to the nil's own line.
Prism keeps the explicit NilNode and attributes execution to the correct line.
So rather than changing Prism to drop the event, I'd suggest treating this as a parse.y bug (a missing :line event / coverage entry for an executed line).
Prism's behavior is the more useful one for TracePoint, coverage, and debuggers.
Updated by Eregon (Benoit Daloze) 17 days ago
It turns out the parse.y fix is easy and sensible, let's do that: https://github.com/ruby/ruby/pull/18736
Updated by Eregon (Benoit Daloze) 17 days ago
- Status changed from Open to Closed
Applied in changeset git|8040d0659931d7230cad458429030492add43faf.
[Bug #22302] Emit a line event for a method's implicit nil return in parse.y
reduce_nodes eliminated a bare nil in a method's tail (value) position
down to an empty method body, so parse.y emitted no :line event and
recorded no line coverage for that line, even though it is executed:
The Prism compiler keeps the explicit nil and correctly emits the event.
Keep the nil in reduce_nodes when it carries a newline flag (i.e. it is
on its own line) so it still produces a line event, while the implicit
nil of an empty body (no newline flag) is still dropped. This makes
parse.y match Prism for TracePoint :line events and line coverage.
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Updated by Eregon (Benoit Daloze) 17 days ago
- Backport changed from 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN to 3.3: DONTNEED, 3.4: DONTNEED, 4.0: DONTNEED
Because this only affects parse.y and the missing :line event is not critical, I think no need to backport.
Updated by Eregon (Benoit Daloze) 17 days ago
- Assignee set to Eregon (Benoit Daloze)
- Target version set to 4.1