lib/opal/repl.rb

Summary

Maintainability
B
5 hrs
Test Coverage
# frozen_string_literal: true

require 'opal'
require 'securerandom'
require 'stringio'
require 'fileutils'
require 'rbconfig'

module Opal
  class REPL
    HISTORY_PATH = File.expand_path('~/.opal-repl-history')

    attr_accessor :colorize

    def initialize
      @argv = []
      @colorize = true

      begin
        require 'readline'
      rescue LoadError
        abort 'opal-repl depends on readline, which is not currently available'
      end

      begin
        FileUtils.touch(HISTORY_PATH)
      rescue
        nil
      end
      @history = File.exist?(HISTORY_PATH)
    end

    def run(argv = [])
      @argv = argv

      savepoint = save_tty
      load_opal
      load_history
      run_input_loop
    ensure
      dump_history
      restore_tty(savepoint)
    end

    def load_opal
      runner = @argv.reject { |i| i == '--repl' }
      runner += ['-e', 'require "opal/repl_js"']
      runner = [RbConfig.ruby, "#{__dir__}/../../exe/opal"] + runner

      @pipe = IO.popen(runner, 'r+',
        # What I try to achieve here: let the runner ignore
        # interrupts. Those should be handled by a supervisor.
        pgroup: true,
        new_pgroup: true,
      )
    end

    def run_input_loop
      while (line = readline)
        eval_ruby(line)
      end
    rescue Interrupt
      @incomplete = nil
      retry
    ensure
      finish
    end

    def finish
      @pipe.close
    rescue
      nil
    end

    def eval_ruby(code)
      builder = Opal::Builder.new
      silencer = Silencer.new

      code = "#{@incomplete}#{code}"
      if code.start_with? 'ls '
        eval_code = code[3..-1]
        mode = :ls
      elsif code == 'ls'
        eval_code = 'self'
        mode = :ls
      elsif code.start_with? 'show '
        eval_code = code[5..-1]
        mode = :show
      else
        eval_code = code
        mode = :inspect
      end

      begin
        silencer.silence do
          builder.build_str(eval_code, '(irb)', irb: true, const_missing: true)
        end
        @incomplete = nil
      rescue Opal::SyntaxError => e
        if LINEBREAKS.include?(e.message)
          @incomplete = "#{code}\n"
        else
          @incomplete = nil
          if silencer.warnings.empty?
            warn e.full_message
          else
            # Most likely a parser error
            warn silencer.warnings
          end
        end
        return
      end
      builder.processed[0...-1].each { |js_code| eval_js(:silent, js_code.to_s) }
      last_processed_file = builder.processed.last.to_s

      if mode == :show
        puts last_processed_file
        return
      end

      eval_js(mode, last_processed_file)
    rescue Interrupt, SystemExit => e
      raise e
    rescue Exception => e # rubocop:disable Lint/RescueException
      puts e.full_message(highlight: true)
    end

    private

    LINEBREAKS = [
      'unexpected token $end',
      'unterminated string meets end of file'
    ].freeze

    class Silencer
      def initialize
        @stderr = $stderr
      end

      def silence
        @collector = StringIO.new
        $stderr = @collector
        yield
      ensure
        $stderr = @stderr
      end

      def warnings
        @collector.string
      end
    end

    def eval_js(mode, code)
      obj = { mode: mode, code: code, colors: colorize }.to_json
      @pipe.puts obj
      while (line = @pipe.readline)
        break if line.chomp == '<<<ready>>>'
        puts line
      end
    rescue Interrupt => e
      # A child stopped responding... let's create a new one
      warn "* Killing #{@pipe.pid}"
      Process.kill('-KILL', @pipe.pid)
      load_opal
      raise e
    rescue EOFError, Errno::EPIPE
      exit $?.nil? ? 0 : $?.exitstatus
    end

    def readline
      prompt = @incomplete ? '.. ' : '>> '
      Readline.readline prompt, true
    end

    def load_history
      return unless @history
      File.read(HISTORY_PATH).lines.each { |line| Readline::HISTORY.push line.strip }
    end

    def dump_history
      return unless @history
      length = Readline::HISTORY.size > 1000 ? 1000 : Readline::HISTORY.size
      File.write(HISTORY_PATH, Readline::HISTORY.to_a[-length..-1].join("\n"))
    end

    # How do we support Win32?
    def save_tty
      %x{stty -g}.chomp
    rescue
      nil
    end

    def restore_tty(savepoint)
      system('stty', savepoint)
    rescue
      nil
    end
  end
end