Project

General

Profile

Actions

Bug #22235

closed

node_id/location for constant assignment failing while reading

Bug #22235: node_id/location for constant assignment failing while reading

Added by Eregon (Benoit Daloze) about 2 hours ago. Updated 29 minutes ago.

Status:
Rejected
Assignee:
-
Target version:
-
[ruby-core:<unknown>]

Description

namespace = Module.new
begin
  namespace::NotDefined += 1
rescue => e
  pp e.backtrace_locations[0]
  pp e.backtrace_locations[0].source_range
  pp e.backtrace_locations[0].syntax_tree
end

On CRuby master it gives:

$ ruby -v const_assignment_fail_while_reading.rb
ruby 4.1.0dev (2026-08-08T08:04:35Z master e56f452e46) +PRISM [x86_64-linux]
"const_assignment_fail_while_reading.rb:3:in '<main>'"
#<Ruby::SourceRange /.../const_assignment_fail_while_reading.rb:(3,2)-(3,28)>
@ ConstantPathOperatorWriteNode (location: (3,2)-(3,28))
├── flags: newline
├── target:
│   @ ConstantPathNode (location: (3,2)-(3,23))
│   ├── flags: ∅
│   ├── parent:
│   │   @ LocalVariableReadNode (location: (3,2)-(3,11))
│   │   ├── flags: ∅
│   │   ├── name: :namespace
│   │   └── depth: 0
│   ├── name: :NotDefined
│   ├── delimiter_loc: (3,11)-(3,13) = "::"
│   └── name_loc: (3,13)-(3,23) = "NotDefined"
├── binary_operator_loc: (3,24)-(3,26) = "+="
├── value:
│   @ IntegerNode (location: (3,27)-(3,28))
│   ├── flags: static_literal, decimal
│   └── value: 1
└── binary_operator: :+

So even though it's the read failing, it returns the ConstantPathOperatorWriteNode and the section is namespace::NotDefined += 1.

I'm implementing source_range & syntax_tree on TruffleRuby and there the result is:

"const_assignment_fail_while_reading.rb:3:in 'Module#const_missing'"
#<Ruby::SourceRange /.../const_assignment_fail_while_reading.rb:(3,2)-(3,23)>
@ ConstantPathNode (location: (3,2)-(3,23))
...

So this returns the ConstantPathNode and the section is namespace::NotDefined.

I think TruffleRuby is correct here, especially if we want to differentiate failing while reading or writing.
On CRuby it is currently impossible to differentiate because the same node is returned in both cases.
(UPDATE: it's possible to differentiate via the exception, but that seems quite brittle. The point is syntax_tree&source_range should point to the relevant reading node)

More information:

irb(main):001> namespace = Module.new
irb(main):002> namespace::NotDefined += 1
irb(main):003> e=_
irb(main):005> l=e.backtrace_locations[0]
irb(main):008> puts RubyVM::InstructionSequence.of(l).disasm
== disasm: #<ISeq:<compiled>@(irb):2 (2,0)-(2,26)>
0000 getlocal_WC_1                          namespace@0               (   2)[Li]
0002 dup
0003 putobject                              true
0005 getconstant                            :NotDefined
0007 putobject_INT2FIX_1_
0008 opt_plus                               <calldata!mid:+, argc:1, FCALL|ARGS_SIMPLE>[CcCr]
0010 swap
0011 topn                                   1
0013 swap
0014 setconstant                            :NotDefined
0016 leave
irb(main):011> RubyVM::InstructionSequence.of(l).to_a
=> 
["YARVInstructionSequence/SimpleDataFormat",
 4,
 1,
 1,
 {arg_size: 0,
  local_size: 0,
  stack_max: 3,
  node_id: 6,
  source_hash: 614720579064667961,
  code_location: [2, 0, 2, 26],
  node_ids: [2, 5, 5, 5, 4, 5, 5, 5, 5, 5, 6],
  parser: :prism},
 "<compiled>",
 "(irb)",
 nil,
 2,
 :eval,
 [],
 {},
 [],
 [2,
  :RUBY_EVENT_LINE,
  [:getlocal_WC_1, 3],
  [:dup],
  [:putobject, true],
  [:getconstant, :NotDefined],
  [:putobject_INT2FIX_1_],
  [:opt_plus, {mid: :+, flag: 20, orig_argc: 1}],
  [:swap],
  [:topn, 1],
  [:swap],
  [:setconstant, :NotDefined],
  [:leave]]]
irb(main):013> l.syntax_tree
=> 
@ ConstantPathOperatorWriteNode (location: (1,0)-(1,26))
...

The failing instruction must be getconstant (BTW, do we have any API to get the insn/insn index for an exception?).
That's given node_id 5 which is the ConstantPathOperatorWriteNode.

OK to fix the node_id of getconstant in such a case to point to the ConstantPathNode?

Updated by Eregon (Benoit Daloze) about 2 hours ago Actions #1

I found this via this spec: https://github.com/ruby/spec/blob/0f44fd58585fcdc652da70521ecb753f6fda80f9/core/thread/backtrace/location/source_range_spec.rb#L155-L158
It encodes the current CRuby behavior, but as described above I think that's suboptimal and we should change it and change the spec accordingly.
There are other similar cases of "assignment failing while reading" there.

Updated by Eregon (Benoit Daloze) about 1 hour ago · Edited Actions #2

Regarding failing while reading or writing, here is reading:

namespace = Module.new
begin
  namespace::NotDefined += 1
rescue => e
  p e
  pp e.backtrace_locations[0]
  pp e.backtrace_locations[0].source_range
  puts e.backtrace_locations[0].syntax_tree.inspect.lines.first
end
$ ruby -v const_assignment_fail_while_reading.rb 
ruby 4.1.0dev (2026-08-08T08:04:35Z master e56f452e46) +PRISM [x86_64-linux]
#<NameError: uninitialized constant #<Module:0x00007f9bb77fbc00>::NotDefined>
"const_assignment_fail_while_reading.rb:3:in '<main>'"
#<Ruby::SourceRange /.../const_assignment_fail_while_reading.rb:(3,2)-(3,28)>
@ ConstantPathOperatorWriteNode (location: (3,2)-(3,28))

And here is writing:

namespace = Module.new
namespace::A = 42
namespace.freeze
begin
  namespace::A += 1
rescue => e
  p e
  pp e.backtrace_locations[0]
  pp e.backtrace_locations[0].source_range
  puts e.backtrace_locations[0].syntax_tree.inspect.lines.first
end
$ ruby -v const_assignment_fail_while_writing.rb
ruby 4.1.0dev (2026-08-08T08:04:35Z master e56f452e46) +PRISM [x86_64-linux]
#<FrozenError: can't modify frozen Module: #<Module:0x00007fb0ee3fbc80>>
"const_assignment_fail_while_writing.rb:5:in '<main>'"
#<Ruby::SourceRange /.../const_assignment_fail_while_writing.rb:(5,2)-(5,19)>
@ ConstantPathOperatorWriteNode (location: (5,2)-(5,19))

Printing the exception ensures we are indeed failing while reading/writing.
Both reading and writing return the same node (a "write node", i.e. ConstantPathOperatorWriteNode) on CRuby currently.

Expected:

Reading:
#<Ruby::SourceRange /.../const_assignment_fail_while_reading.rb:(3,2)-(3,23)>
@ ConstantPathNode (location: (3,2)-(3,23))

Writing:
#<Ruby::SourceRange /.../const_assignment_fail_while_writing.rb:(5,2)-(5,19)>
@ ConstantPathOperatorWriteNode (location: (5,2)-(5,19))

Writing is already as expected and matching between CRuby & TruffleRuby.
Reading is the difference.

Updated by Eregon (Benoit Daloze) about 1 hour ago Actions #3

  • Description updated (diff)

Updated by Eregon (Benoit Daloze) about 1 hour ago Actions #4

  • Description updated (diff)

Updated by Eregon (Benoit Daloze) about 1 hour ago Actions #5 [ruby-core:126327]

To be complete, the other two "failing while reading" cases in the spec are:
($ marks the beginning and end of the source_range)

      "index operator assignments failing while reading" => <<-RUBY,
      value = nil
      $value[0] += 42$
      RUBY

      "attribute operator assignments failing while reading" => <<-RUBY,
      value = nil
      $value.foo += 42$
      RUBY

Those also currently return the write/assignment node, even though it fails while reading.
We should probably be consistent there and return the "read node" (value[0]/value.foo).

The trouble there is there isn't such a node in Prism:
1st example, no node with (3,0)-(3,8):

$ ruby -rprism -e 'pp Prism.parse("
value = nil
value[0] += 42 
").value' 
@ ProgramNode (location: (2,0)-(3,14))
├── flags: ∅
├── locals: [:value]
└── statements:
    @ StatementsNode (location: (2,0)-(3,14))
    ├── flags: ∅
    └── body: (length: 2)
        ├── @ LocalVariableWriteNode (location: (2,0)-(2,11))
        │   ├── flags: newline
        │   ├── name: :value
        │   ├── depth: 0
        │   ├── name_loc: (2,0)-(2,5) = "value"
        │   ├── value:
        │   │   @ NilNode (location: (2,8)-(2,11))
        │   │   └── flags: static_literal
        │   └── operator_loc: (2,6)-(2,7) = "="
        └── @ IndexOperatorWriteNode (location: (3,0)-(3,14))
            ├── flags: newline
            ├── receiver:
            │   @ LocalVariableReadNode (location: (3,0)-(3,5))
            │   ├── flags: ∅
            │   ├── name: :value
            │   └── depth: 0
            ├── call_operator_loc: ∅
            ├── opening_loc: (3,5)-(3,6) = "["
            ├── arguments:
            │   @ ArgumentsNode (location: (3,6)-(3,7))
            │   ├── flags: ∅
            │   └── arguments: (length: 1)
            │       └── @ IntegerNode (location: (3,6)-(3,7))
            │           ├── flags: static_literal, decimal
            │           └── value: 0
            ├── closing_loc: (3,7)-(3,8) = "]"
            ├── block: ∅
            ├── binary_operator: :+
            ├── binary_operator_loc: (3,9)-(3,11) = "+="
            └── value:
                @ IntegerNode (location: (3,12)-(3,14))
                ├── flags: static_literal, decimal
                └── value: 42

2nd example, no node with (3,0)-(3,9):

ruby -rprism -e 'pp Prism.parse("
value = nil
value.foo += 42
").value'
@ ProgramNode (location: (2,0)-(3,15))
├── flags: ∅
├── locals: [:value]
└── statements:
    @ StatementsNode (location: (2,0)-(3,15))
    ├── flags: ∅
    └── body: (length: 2)
        ├── @ LocalVariableWriteNode (location: (2,0)-(2,11))
        │   ├── flags: newline
        │   ├── name: :value
        │   ├── depth: 0
        │   ├── name_loc: (2,0)-(2,5) = "value"
        │   ├── value:
        │   │   @ NilNode (location: (2,8)-(2,11))
        │   │   └── flags: static_literal
        │   └── operator_loc: (2,6)-(2,7) = "="
        └── @ CallOperatorWriteNode (location: (3,0)-(3,15))
            ├── flags: newline
            ├── receiver:
            │   @ LocalVariableReadNode (location: (3,0)-(3,5))
            │   ├── flags: ∅
            │   ├── name: :value
            │   └── depth: 0
            ├── call_operator_loc: (3,5)-(3,6) = "."
            ├── message_loc: (3,6)-(3,9) = "foo"
            ├── read_name: :foo
            ├── write_name: :foo=
            ├── binary_operator: :+
            ├── binary_operator_loc: (3,10)-(3,12) = "+="
            └── value:
                @ IntegerNode (location: (3,13)-(3,15))
                ├── flags: static_literal, decimal
                └── value: 42

Updated by Eregon (Benoit Daloze) 29 minutes ago Actions #6 [ruby-core:126328]

  • Status changed from Open to Rejected

NotDefined += 1 also doesn't have a Prism node for the reading part (name_loc covers it but that's not a node):

ruby -rprism -e 'pp Prism.parse("
NotDefined += 1 
").value'
@ ProgramNode (location: (2,0)-(2,15))
├── flags: ∅
├── locals: []
└── statements:
    @ StatementsNode (location: (2,0)-(2,15))
    ├── flags: ∅
    └── body: (length: 1)
        └── @ ConstantOperatorWriteNode (location: (2,0)-(2,15))
            ├── flags: newline
            ├── name: :NotDefined
            ├── name_loc: (2,0)-(2,10) = "NotDefined"
            ├── binary_operator_loc: (2,11)-(2,13) = "+="
            ├── value:
            │   @ IntegerNode (location: (2,14)-(2,15))
            │   ├── flags: static_literal, decimal
            │   └── value: 1
            └── binary_operator: :+

So in general there is no node for the reading part, and ConstantPathOperatorWriteNode seems like the exception there.
So for consistency I now think it makes sense to use the full assignment/OperatorWriteNode when it fails, even for reading.
And differentiating failing while reading or writing must be done with the exception then.
It's a little bit unfortunate but I see no good solution, so closing this as rejected since the current state makes sense.

Sorry for the noise.
Although maybe this will help someone else realize why it is the way it is for ConstantPathOperatorWriteNode.

Actions

Also available in: PDF Atom