# frozen_string_literal: true require "forwardable" module SyntaxTree # This module provides an object representation of the YARV bytecode. module YARV class VM class Jump attr_reader :label def initialize(label) @label = label end end class Leave attr_reader :value def initialize(value) @value = value end end class Frame attr_reader :iseq, :parent, :stack_index, :_self, :nesting, :svars attr_accessor :line, :pc def initialize(iseq, parent, stack_index, _self, nesting) @iseq = iseq @parent = parent @stack_index = stack_index @_self = _self @nesting = nesting @svars = {} @line = iseq.line @pc = 0 end end class TopFrame < Frame def initialize(iseq) super(iseq, nil, 0, TOPLEVEL_BINDING.eval("self"), [Object]) end end class BlockFrame < Frame def initialize(iseq, parent, stack_index) super(iseq, parent, stack_index, parent._self, parent.nesting) end end class MethodFrame < Frame attr_reader :name, :block def initialize(iseq, nesting, parent, stack_index, _self, name, block) super(iseq, parent, stack_index, _self, nesting) @name = name @block = block end end class ClassFrame < Frame def initialize(iseq, parent, stack_index, _self) super(iseq, parent, stack_index, _self, parent.nesting + [_self]) end end class RescueFrame < Frame def initialize(iseq, parent, stack_index) super(iseq, parent, stack_index, parent._self, parent.nesting) end end class ThrownError < StandardError attr_reader :value def initialize(value, backtrace) super("This error was thrown by the Ruby VM.") @value = value set_backtrace(backtrace) end end class ReturnError < ThrownError end class BreakError < ThrownError end class NextError < ThrownError end class FrozenCore define_method("core#hash_merge_kwd") { |left, right| left.merge(right) } define_method("core#hash_merge_ptr") do |hash, *values| hash.merge(values.each_slice(2).to_h) end define_method("core#set_method_alias") do |clazz, new_name, old_name| clazz.alias_method(new_name, old_name) end define_method("core#set_variable_alias") do |new_name, old_name| # Using eval here since there isn't a reflection API to be able to # alias global variables. eval("alias #{new_name} #{old_name}", binding, __FILE__, __LINE__) end define_method("core#set_postexe") { |&block| END { block.call } } define_method("core#undef_method") do |clazz, name| clazz.undef_method(name) nil end end # This is the main entrypoint for events firing in the VM, which allows # us to implement tracing. class NullEvents def publish_frame_change(frame) end def publish_instruction(iseq, insn) end def publish_stack_change(stack) end def publish_tracepoint(event) end end # This is a simple implementation of tracing that prints to STDOUT. class STDOUTEvents attr_reader :disassembler def initialize @disassembler = Disassembler.new end def publish_frame_change(frame) puts "%-16s %s" % ["frame-change", "#{frame.iseq.file}@#{frame.line}"] end def publish_instruction(iseq, insn) disassembler.current_iseq = iseq puts "%-16s %s" % ["instruction", insn.disasm(disassembler)] end def publish_stack_change(stack) puts "%-16s %s" % ["stack-change", stack.values.inspect] end def publish_tracepoint(event) puts "%-16s %s" % ["tracepoint", event.inspect] end end # This represents the global VM stack. It effectively is an array, but # wraps mutating functions with instrumentation. class Stack attr_reader :events, :values def initialize(events) @events = events @values = [] end def concat(...) values.concat(...).tap { events.publish_stack_change(self) } end def last values.last end def length values.length end def push(...) values.push(...).tap { events.publish_stack_change(self) } end def pop(...) values.pop(...).tap { events.publish_stack_change(self) } end def slice!(...) values.slice!(...).tap { events.publish_stack_change(self) } end def [](...) values.[](...) end def []=(...) values.[]=(...).tap { events.publish_stack_change(self) } end end FROZEN_CORE = FrozenCore.new.freeze extend Forwardable attr_reader :events attr_reader :stack def_delegators :stack, :push, :pop attr_reader :frame def initialize(events = NullEvents.new) @events = events @stack = Stack.new(events) @frame = nil end def self.run(iseq) new.run_top_frame(iseq) end ########################################################################## # Helper methods for frames ########################################################################## def run_frame(frame) # First, set the current frame to the given value. previous = @frame @frame = frame events.publish_frame_change(@frame) # Next, set up the local table for the frame. This is actually incorrect # as it could use the values already on the stack, but for now we're # just doing this for simplicity. stack.concat(Array.new(frame.iseq.local_table.size)) # Yield so that some frame-specific setup can be done. start_label = yield if block_given? frame.pc = frame.iseq.insns.index(start_label) if start_label # Finally we can execute the instructions one at a time. If they return # jumps or leaves we will handle those appropriately. loop do case (insn = frame.iseq.insns[frame.pc]) when Integer frame.line = insn frame.pc += 1 when Symbol events.publish_tracepoint(insn) frame.pc += 1 when InstructionSequence::Label # skip labels frame.pc += 1 else begin events.publish_instruction(frame.iseq, insn) result = insn.call(self) rescue ReturnError => error raise if frame.iseq.type != :method stack.slice!(frame.stack_index..) @frame = frame.parent events.publish_frame_change(@frame) return error.value rescue BreakError => error raise if frame.iseq.type != :block catch_entry = find_catch_entry(frame, InstructionSequence::CatchBreak) raise unless catch_entry stack.slice!( ( frame.stack_index + frame.iseq.local_table.size + catch_entry.restore_sp ).. ) @frame = frame events.publish_frame_change(@frame) frame.pc = frame.iseq.insns.index(catch_entry.exit_label) push(result = error.value) rescue NextError => error raise if frame.iseq.type != :block catch_entry = find_catch_entry(frame, InstructionSequence::CatchNext) raise unless catch_entry stack.slice!( ( frame.stack_index + frame.iseq.local_table.size + catch_entry.restore_sp ).. ) @frame = frame events.publish_frame_change(@frame) frame.pc = frame.iseq.insns.index(catch_entry.exit_label) push(result = error.value) rescue Exception => error catch_entry = find_catch_entry(frame, InstructionSequence::CatchRescue) raise unless catch_entry stack.slice!( ( frame.stack_index + frame.iseq.local_table.size + catch_entry.restore_sp ).. ) @frame = frame events.publish_frame_change(@frame) frame.pc = frame.iseq.insns.index(catch_entry.exit_label) push(result = run_rescue_frame(catch_entry.iseq, frame, error)) end case result when Jump frame.pc = frame.iseq.insns.index(result.label) + 1 when Leave # this shouldn't be necessary, but is because we're not handling # the stack correctly at the moment stack.slice!(frame.stack_index..) # restore the previous frame @frame = previous || frame.parent events.publish_frame_change(@frame) if @frame return result.value else frame.pc += 1 end end end end def find_catch_entry(frame, type) iseq = frame.iseq iseq.catch_table.find do |catch_entry| next unless catch_entry.is_a?(type) begin_pc = iseq.insns.index(catch_entry.begin_label) end_pc = iseq.insns.index(catch_entry.end_label) (begin_pc...end_pc).cover?(frame.pc) end end def run_top_frame(iseq) run_frame(TopFrame.new(iseq)) end def run_block_frame(iseq, frame, *args, **kwargs, &block) run_frame(BlockFrame.new(iseq, frame, stack.length)) do setup_arguments(iseq, args, kwargs, block) end end def run_class_frame(iseq, clazz) run_frame(ClassFrame.new(iseq, frame, stack.length, clazz)) end def run_method_frame(name, nesting, iseq, _self, *args, **kwargs, &block) run_frame( MethodFrame.new( iseq, nesting, frame, stack.length, _self, name, block ) ) { setup_arguments(iseq, args, kwargs, block) } end def run_rescue_frame(iseq, frame, error) run_frame(RescueFrame.new(iseq, frame, stack.length)) do local_set(0, 0, error) nil end end def setup_arguments(iseq, args, kwargs, block) locals = [*args] local_index = 0 start_label = nil # First, set up all of the leading arguments. These are positional and # required arguments at the start of the argument list. if (lead_num = iseq.argument_options[:lead_num]) lead_num.times do local_set(local_index, 0, locals.shift) local_index += 1 end end # Next, set up all of the optional arguments. The opt array contains # the labels that the frame should start at if the optional is # present. The last element of the array is the label that the frame # should start at if all of the optional arguments are present. if (opt = iseq.argument_options[:opt]) opt[0...-1].each do |label| if locals.empty? start_label = label break else local_set(local_index, 0, locals.shift) local_index += 1 end start_label = opt.last if start_label.nil? end end # If there is a splat argument, then we'll set that up here. It will # grab up all of the remaining positional arguments. if (rest_start = iseq.argument_options[:rest_start]) if (post_start = iseq.argument_options[:post_start]) length = post_start - rest_start local_set(local_index, 0, locals[0...length]) locals = locals[length..] else local_set(local_index, 0, locals.dup) locals.clear end local_index += 1 end # Next, set up any post arguments. These are positional arguments that # come after the splat argument. if (post_num = iseq.argument_options[:post_num]) post_num.times do local_set(local_index, 0, locals.shift) local_index += 1 end end if (keyword_option = iseq.argument_options[:keyword]) # First, set up the keyword bits array. keyword_bits = keyword_option.map do |config| kwargs.key?(config.is_a?(Array) ? config[0] : config) end iseq.local_table.locals.each_with_index do |local, index| # If this is the keyword bits local, then set it appropriately. if local.name.is_a?(Integer) local_set(index, 0, keyword_bits) next end # First, find the configuration for this local in the keywords # list if it exists. name = local.name config = keyword_option.find do |keyword| keyword.is_a?(Array) ? keyword[0] == name : keyword == name end # If the configuration doesn't exist, then the local is not a # keyword local. next unless config if !config.is_a?(Array) # required keyword local_set(index, 0, kwargs.fetch(name)) elsif !config[1].nil? # optional keyword with embedded default value local_set(index, 0, kwargs.fetch(name, config[1])) else # optional keyword with expression default value local_set(index, 0, kwargs[name]) end end end local_set(local_index, 0, block) if iseq.argument_options[:block_start] start_label end ########################################################################## # Helper methods for instructions ########################################################################## def const_base frame.nesting.last end def frame_at(level) current = frame level.times { current = current.parent } current end def frame_svar current = frame current = current.parent while current.is_a?(BlockFrame) current end def frame_yield current = frame current = current.parent until current.is_a?(MethodFrame) current end def frozen_core FROZEN_CORE end def jump(label) Jump.new(label) end def leave Leave.new(pop) end def local_get(index, level) stack[frame_at(level).stack_index + index] end def local_set(index, level, value) stack[frame_at(level).stack_index + index] = value end ########################################################################## # Methods for overriding runtime behavior ########################################################################## DLEXT = ".#{RbConfig::CONFIG["DLEXT"]}" SOEXT = ".#{RbConfig::CONFIG["SOEXT"]}" def require_resolved(filepath) $LOADED_FEATURES << filepath iseq = RubyVM::InstructionSequence.compile_file(filepath) run_top_frame(InstructionSequence.from(iseq.to_a)) end def require_internal(filepath, loading: false) case (extname = File.extname(filepath)) when "" # search for all the extensions searching = filepath extensions = ["", ".rb", DLEXT, SOEXT] when ".rb", DLEXT, SOEXT # search only for the given extension name searching = File.basename(filepath, extname) extensions = [extname] else # we don't handle these extensions, raise a load error raise LoadError, "cannot load such file -- #{filepath}" end if filepath.start_with?("/") # absolute path, search only in the given directory directories = [File.dirname(searching)] searching = File.basename(searching) else # relative path, search in the load path directories = $LOAD_PATH end directories.each do |directory| extensions.each do |extension| absolute_path = File.join(directory, "#{searching}#{extension}") next unless File.exist?(absolute_path) if !loading && $LOADED_FEATURES.include?(absolute_path) return false elsif extension == ".rb" require_resolved(absolute_path) return true elsif loading return Kernel.send(:yarv_load, filepath) else return Kernel.send(:yarv_require, filepath) end end end if loading Kernel.send(:yarv_load, filepath) else Kernel.send(:yarv_require, filepath) end end def require(filepath) require_internal(filepath, loading: false) end def require_relative(filepath) Kernel.yarv_require_relative(filepath) end def load(filepath) require_internal(filepath, loading: true) end def eval( source, binding = TOPLEVEL_BINDING, filename = "(eval)", lineno = 1 ) Kernel.yarv_eval(source, binding, filename, lineno) end def throw(tag, value = nil) Kernel.throw(tag, value) end def catch(tag, &block) Kernel.catch(tag, &block) end end end end