lib/opal/cli.rb



# frozen_string_literal: true

require 'opal/requires'
require 'opal/builder'
require 'opal/cli_runners'

module Opal
  class CLI
    attr_reader :options, :file, :compiler_options, :evals, :load_paths, :argv,
      :output, :requires, :rbrequires, :gems, :stubs, :verbose, :runner_options,
      :preload, :filename, :debug, :no_exit, :lib_only, :missing_require_severity,
      :no_cache

    class << self
      attr_accessor :stdout
    end

    def initialize(options = nil)
      options ||= {}

      # Runner
      @runner_type    = options.delete(:runner)         || :nodejs
      @runner_options = options.delete(:runner_options) || {}

      @options     = options
      @sexp        = options.delete(:sexp)
      @repl        = options.delete(:repl)
      @file        = options.delete(:file)
      @no_exit     = options.delete(:no_exit)
      @lib_only    = options.delete(:lib_only)
      @argv        = options.delete(:argv)       { [] }
      @evals       = options.delete(:evals)      { [] }
      @load_paths  = options.delete(:load_paths) { [] }
      @gems        = options.delete(:gems)       { [] }
      @stubs       = options.delete(:stubs)      { [] }
      @preload     = options.delete(:preload)    { [] }
      @output      = options.delete(:output)     { self.class.stdout || $stdout }
      @verbose     = options.delete(:verbose)    { false }
      @debug       = options.delete(:debug)      { false }
      @filename    = options.delete(:filename)   { @file && @file.path }
      @requires    = options.delete(:requires)   { [] }
      @rbrequires  = options.delete(:rbrequires) { [] }
      @no_cache    = options.delete(:no_cache)   { false }

      @debug_source_map = options.delete(:debug_source_map) { false }

      @missing_require_severity = options.delete(:missing_require_severity) { Opal::Config.missing_require_severity }

      @requires.unshift('opal') unless options.delete(:skip_opal_require)

      @compiler_options = Hash[
        *compiler_option_names.map do |option|
          key = option.to_sym
          next unless options.key? key
          value = options.delete(key)
          [key, value]
        end.compact.flatten
      ]

      raise ArgumentError, 'no libraries to compile' if @lib_only && @requires.empty?
      raise ArgumentError, 'no runnable code provided (evals or file)' if @evals.empty? && @file.nil? && !@lib_only
      raise ArgumentError, "can't accept evals or file in `library only` mode" if (@evals.any? || @file) && @lib_only
      raise ArgumentError, "unknown options: #{options.inspect}" unless @options.empty?
    end

    def run
      return show_sexp if @sexp
      return debug_source_map if @debug_source_map
      return run_repl if @repl

      @exit_status = runner.call(
        options: runner_options,
        output: output,
        argv: argv,
        builder: builder,
      )
    end

    def runner
      CliRunners[@runner_type] ||
        raise(ArgumentError, "unknown runner: #{@runner_type.inspect}")
    end

    def run_repl
      require 'opal/repl'

      repl = REPL.new
      repl.run(OriginalARGV)
    end

    attr_reader :exit_status

    def builder
      @builder ||= create_builder
    end

    def create_builder
      rbrequires.each(&Kernel.method(:require))

      builder = Opal::Builder.new(
        stubs: stubs,
        compiler_options: compiler_options,
        missing_require_severity: missing_require_severity,
      )

      # --no-cache
      builder.cache = Opal::Cache::NullCache.new if no_cache

      # --include
      builder.append_paths(*load_paths)

      # --gem
      gems.each { |gem_name| builder.use_gem gem_name }

      # --require
      requires.each { |required| builder.build(required) }

      # --preload
      preload.each { |path| builder.build_require(path) }

      # --verbose
      builder.build_str '$VERBOSE = true', '(flags)' if verbose

      # --debug
      builder.build_str '$DEBUG = true', '(flags)' if debug

      # --eval / stdin / file
      evals_or_file { |source, filename| builder.build_str(source, filename) }

      # --no-exit
      builder.build_str '::Kernel.exit', '(exit)' unless no_exit

      builder
    end

    def show_sexp
      evals_or_file do |contents, filename|
        buffer = ::Opal::Parser::SourceBuffer.new(filename)
        buffer.source = contents
        sexp = Opal::Parser.default_parser.parse(buffer)
        output.puts sexp.inspect
      end
    end

    def debug_source_map
      evals_or_file do |contents, filename|
        compiler = Opal::Compiler.new(contents, file: filename, **compiler_options)

        compiler.compile

        result = compiler.result
        source_map = compiler.source_map.to_json

        b64 = [result, source_map, contents].map { |i| Base64.strict_encode64(i) }.join(',')

        output.puts "https://sokra.github.io/source-map-visualization/#base64,#{b64}"
      end
    end

    def compiler_option_names
      %w[
        method_missing
        arity_check
        dynamic_require_severity
        source_map_enabled
        irb_enabled
        inline_operators
        enable_source_location
        use_strict
        parse_comments
        esm
      ]
    end

    # Internal: Yields a string of source code and the proper filename for either
    #           evals, stdin or a filepath.
    def evals_or_file
      # --library
      return if lib_only

      if evals.any?
        yield evals.join("\n"), '-e'
      elsif file && (filename != '-' || evals.empty?)
        yield file.read, filename
      end
    end
  end
end