1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241
|
# frozen_string_literal: true
# This test is going to use the RubyVM::InstructionSequence class to compile
# local tables and compare against them to ensure we have the same locals in the
# same order. This is important to guarantee that we compile indices correctly
# on CRuby (in terms of compatibility).
#
# There have also been changes made in other versions of Ruby, so we only want
# to test on the most recent versions.
return if !defined?(RubyVM::InstructionSequence) || RUBY_VERSION < "3.4.0"
# If we're on Ruby 3.4.0 and the default parser is Prism, then there is no point
# in comparing the locals because they will be the same.
return if RubyVM::InstructionSequence.compile("").to_a[4][:parser] == :prism
# In Ruby 3.4.0, the local table for method forwarding changed. But 3.4.0 can
# refer to the dev version, so while 3.4.0 still isn't released, we need to
# check if we have a high enough revision.
return if RubyVM::InstructionSequence.compile("def foo(...); end").to_a[13][2][2][10].length != 1
# Omit tests if running on a 32-bit machine because there is a bug with how
# Ruby is handling large ISeqs on 32-bit machines
return if RUBY_PLATFORM =~ /i686/
require_relative "test_helper"
module Prism
class LocalsTest < TestCase
except = [
# Skip this fixture because it has a different number of locals because
# CRuby is eliminating dead code.
"whitequark/ruby_bug_10653.txt"
]
Fixture.each(except: except) do |fixture|
define_method(fixture.test_name) { assert_locals(fixture) }
end
def setup
@previous_default_external = Encoding.default_external
ignore_warnings { Encoding.default_external = Encoding::UTF_8 }
end
def teardown
ignore_warnings { Encoding.default_external = @previous_default_external }
end
private
def assert_locals(fixture)
source = fixture.read
expected = cruby_locals(source)
actual = prism_locals(source)
assert_equal(expected, actual)
end
# A wrapper around a RubyVM::InstructionSequence that provides a more
# convenient interface for accessing parts of the iseq.
class ISeq
attr_reader :parts
def initialize(parts)
@parts = parts
end
def type
parts[0]
end
def local_table
parts[10]
end
def instructions
parts[13]
end
def each_child
instructions.each do |instruction|
# Only look at arrays. Other instructions are line numbers or
# tracepoint events.
next unless instruction.is_a?(Array)
instruction.each do |opnd|
# Only look at arrays. Other operands are literals.
next unless opnd.is_a?(Array)
# Only look at instruction sequences. Other operands are literals.
next unless opnd[0] == "YARVInstructionSequence/SimpleDataFormat"
yield ISeq.new(opnd)
end
end
end
end
# Used to hold the place of a local that will be in the local table but
# cannot be accessed directly from the source code. For example, the
# iteration variable in a for loop or the positional parameter on a method
# definition that is destructured.
AnonymousLocal = Object.new
# For the given source, compiles with CRuby and returns a list of all of the
# sets of local variables that were encountered.
def cruby_locals(source)
locals = [] #: Array[Array[Symbol | Integer]]
stack = [ISeq.new(ignore_warnings { RubyVM::InstructionSequence.compile(source) }.to_a)]
while (iseq = stack.pop)
names = [*iseq.local_table]
names.map!.with_index do |name, index|
# When an anonymous local variable is present in the iseq's local
# table, it is represented as the stack offset from the top.
# However, when these are dumped to binary and read back in, they
# are replaced with the symbol :#arg_rest. To consistently handle
# this, we replace them here with their index.
if name == :"#arg_rest"
names.length - index + 1
else
name
end
end
locals << names
iseq.each_child { |child| stack << child }
end
locals
end
# For the given source, parses with prism and returns a list of all of the
# sets of local variables that were encountered.
def prism_locals(source)
locals = [] #: Array[Array[Symbol | Integer]]
stack = [Prism.parse(source).value] #: Array[Prism::node]
while (node = stack.pop)
case node
when BlockNode, DefNode, LambdaNode
names = node.locals
params = nil
if node.is_a?(DefNode)
params = node.parameters
elsif node.parameters.is_a?(NumberedParametersNode)
# nothing
elsif node.parameters.is_a?(ItParametersNode)
names.unshift(AnonymousLocal)
else
params = node.parameters&.parameters
end
# prism places parameters in the same order that they appear in the
# source. CRuby places them in the order that they need to appear
# according to their own internal calling convention. We mimic that
# order here so that we can compare properly.
if params
sorted = [
*params.requireds.map do |required|
if required.is_a?(RequiredParameterNode)
required.name
else
AnonymousLocal
end
end,
*params.optionals.map(&:name),
*((params.rest.name || :*) if params.rest && !params.rest.is_a?(ImplicitRestNode)),
*params.posts.map do |post|
if post.is_a?(RequiredParameterNode)
post.name
else
AnonymousLocal
end
end,
*params.keywords.grep(RequiredKeywordParameterNode).map(&:name),
*params.keywords.grep(OptionalKeywordParameterNode).map(&:name),
]
sorted << AnonymousLocal if params.keywords.any?
if params.keyword_rest.is_a?(ForwardingParameterNode)
if sorted.length == 0
sorted.push(:"...")
else
sorted.push(:*, :**, :&, :"...")
end
elsif params.keyword_rest.is_a?(KeywordRestParameterNode)
sorted << (params.keyword_rest.name || :**)
end
# Recurse down the parameter tree to find any destructured
# parameters and add them after the other parameters.
param_stack = params.requireds.concat(params.posts).grep(MultiTargetNode).reverse
while (param = param_stack.pop)
case param
when MultiTargetNode
param_stack.concat(param.rights.reverse)
param_stack << param.rest if param.rest&.expression && !sorted.include?(param.rest.expression.name)
param_stack.concat(param.lefts.reverse)
when RequiredParameterNode
sorted << param.name
when SplatNode
sorted << param.expression.name
end
end
if params.block
sorted << (params.block.name || :&)
end
names = sorted.concat(names - sorted)
end
names.map!.with_index do |name, index|
if name == AnonymousLocal
names.length - index + 1
else
name
end
end
locals << names
when ClassNode, ModuleNode, ProgramNode, SingletonClassNode
locals << node.locals
when ForNode
locals << [2]
when PostExecutionNode
locals.push([], [])
when InterpolatedRegularExpressionNode
locals << [] if node.once?
end
stack.concat(node.compact_child_nodes)
end
locals
end
end
end
|