The key point is that node_id is an allocation sequence number
(++parser->node_id), not an identity derived from a node's type or source
location.
Merely reordering node definitions in config.yml does not affect node_id.
Changing the number or order of node allocations does.
The reproduction from note #13 of Feature #21795 changed from 25 to 26 nodes between Prism versions.
This can be bisected to Ensure context terminators terminate expressions:
case 1
in 2
A.print message:
in 3
A.print message:
endBefore the fix, the second in was represented by a MatchPredicateNode
nested inside the first clause. After the fix, it was represented by a second
InNode.
The commit was an ordinary six-line parser fix and did not change
config.yml. Consequently, a node-definition ABI version would not have
detected this change.
Internal support for node_id was added on February 8, 2024. Less than two
weeks later,
ItParametersNode was added.
The unchanged source:
-> { it }gained an extra node. This shifted the LambdaNode and every node allocated
after it.
A month later,
ShareableConstantNode was added:
# shareable_constant_value: literal
A = 1
def target
endThe constant write gained a ShareableConstantNode wrapper before target,
shifting the DefNode ID.
It is also believable that this node could disappear in the future. For example, the shareability mode could be stored as flags directly on the constant-write nodes instead of using a wrapper. The same thing could happen if this behavior became the default.
Shortly afterward,
ConstantPathNode#child was replaced with name and name_loc:
Foo::Bar
def target
endPreviously, Foo::Bar contained a child ConstantReadNode for Bar. After
the change, Bar was stored directly as a name and location on the
ConstantPathNode.
This removed one node allocation, shifting the ID of target down by one.
The commit explicitly described itself as an API-breaking representation
change.
The instability is broader than changes to the final AST. Nodes that are allocated and later discarded still consume IDs.
For example:
A = 1
def target
endcurrently has visible nodes with IDs resembling:
StatementsNode 1
# ID 2: temporary ConstantReadNode, absent from the final tree
IntegerNode 3
ConstantWriteNode 4
DefNode 5
ProgramNode 6
Prism first parses A as a read/target and then converts it into a write. A
reasonable optimization could construct the ConstantWriteNode directly,
eliminating the invisible allocation. That would change every later ID
without changing the final AST or the node definitions at all.
This means an ABI version that changes only when config.yml changes would
not be sufficient.
IDs can also move when the same nodes are allocated in a different order.
For example, DefNode, LambdaNode, and BlockNode are currently allocated
after their bodies have been parsed. A parser refactor could instead allocate
the outer node when its opening token is encountered and fill in its body
later.
Such a refactor would change the ID of exactly the node returned by
Method#ast or Proc#ast, without adding or removing any nodes.
Similarly, a CallNode is commonly allocated before its arguments. Building
the arguments first and the call afterward would change the CallNode ID
relevant to Thread::Backtrace::Location#ast, even if the final AST were
structurally equivalent.
ImplicitNode and ImplicitRestNode are particularly plausible candidates.
Prism describes these as synthetic nodes that do not directly correspond to
source syntax:
{ foo: } # ImplicitNode
foo { |bar,| } # ImplicitRestNode
foo in [bar,] # ImplicitRestNode
foo, = bar # ImplicitRestNodeThese could naturally be replaced with flags or nullable fields on nodes such
as AssocNode, ParametersNode, ArrayPatternNode, or MultiTargetNode.
Other plausible areas include:
-
ShareableConstantNode, which could be folded into constant-write flags. -
ItParametersNodeandNumberedParametersNode, which could be represented as modes on another parameter node. -
Grouping nodes such as
StatementsNode,ArgumentsNode,BlockParametersNode, andKeywordHashNode. -
The many specialized target and write nodes, which could be merged, split, or replaced with flags.
There are historical precedents for these kinds of changes even before
node_id was introduced: Prism split CaseNode and CaseMatchNode, split
CallNode in target position, and replaced
RequiredDestructuredParameterNode with MultiTargetNode.
Stable node_id values require the exact same parser implementation, parser
options, and source—not merely synchronized node definitions.
Normal bug fixes, temporary-node optimizations, representation changes, and allocation-order refactors can all change IDs. Some of these changes do not alter the node ABI or even the shape of the final AST.
Start and end source offsets are materially more robust across Prism versions because they describe actual positions in the source rather than incidental parser allocation order.
Imaginary-literal
node_idchanges between Ruby 4.0.6 and Prism masterRuby 4.0.6 embeds Prism 1.8.1. Imaginary literals have different node IDs
when the same source is parsed with Prism master.
For example:
Run it with the Prism embedded in Ruby 4.0.6:
~/.rubies/ruby-4.0.6/bin/ruby --disable-gems example.rbThe result is:
Run the same program with Prism master from the current checkout:
~/.rubies/ruby-4.0.6/bin/ruby -Ilib example.rbThe result is:
The nodes have the same structure and the total number of allocated nodes is
unchanged, but the IDs of the wrapper and its numeric child are swapped:
ImaginaryNode("1i")IntegerNode("1")The same change occurs for the other imaginary-literal forms:
1iIntegerNode1.0iFloatNode1riRationalNode1.0riRationalNodeIn each case, Prism 1.8.1 assigns ID 2 to the
ImaginaryNodeand ID 3 tothe numeric child. Prism master assigns ID 3 to the
ImaginaryNodeand ID 2to the child.
The change was introduced by
b548656979,“Template out node creation functions.” The refactor changed the allocation
order of the
ImaginaryNodewrapper and its numeric child without adding orremoving a node or changing the final AST shape.
Limitation
This is evidence that
node_idcan change solely because allocation orderchanged, but it is not by itself a
Prism.findfailure.ImaginaryNodeandits numeric child do not represent a method, proc, or an operation whose ID is
exposed through a backtrace location. Their IDs are therefore not supplied to
Prism.findby CRuby. The swap also leaves subsequent IDs unchanged.