Project

General

Profile

Actions

Bug #22302

closed

Prism and parse.y disagree on the `:line` event for a bare `nil` method body

Bug #22302: Prism and parse.y disagree on the `:line` event for a bare `nil` method body

Added by Eregon (Benoit Daloze) 18 days ago. Updated 17 days ago.

Status:
Closed
Target version:
ruby -v:
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [x86_64-linux]
[ruby-core:126625]

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.

def m
  nil
end
  • With --parser=prism, executing m (or setting a TracePoint(: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"))
$ ruby --parser=prism   repro.rb
[1, 2]
$ ruby --parser=parse.y repro.rb
[1]

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
$ ruby --parser=prism   rt.rb
[1, 2, 4]
$ ruby --parser=parse.y rt.rb
[1, 4]

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 nil at 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 nil is reached through the branch of a conditional, e.g. a method whose body is if 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

ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [x86_64-linux]

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 Actions #1 [ruby-core:126639]

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 nil like an implicit nil and 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 nil the user wrote, that seems clearly undesirable, for cases like:
def m
  nil
end

See Instruction sequences above, there is anyway a putnil so it should be attributed to the user nil.
and

    def foo(expected, actual)
      if expected == actual
        nil
      elsif ...
        ...

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:

# body.rb
def m
  nil
end
m
require "coverage"
Coverage.start(lines: true)
load "./body.rb"
p Coverage.result.find { |k,| k.end_with?("body.rb") }.last[:lines]
$ ruby --parser=prism   run.rb
[1, 1, nil, 1]
$ ruby --parser=parse.y run.rb
[1, nil, nil, 1]

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 Actions #2 [ruby-core:126640]

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 Actions #3

  • 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:

def m
  nil    # <- no :line event, no coverage under parse.y
end

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

Updated by Eregon (Benoit Daloze) 17 days ago Actions #4 [ruby-core:126649]

  • 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 Actions #5

  • Assignee set to Eregon (Benoit Daloze)
  • Target version set to 4.1
Actions

Also available in: PDF Atom