lib/debug/config.rb



# frozen_string_literal: true

module DEBUGGER__
  LOG_LEVELS = {
    UNKNOWN: 0,
    FATAL:   1,
    ERROR:   2,
    WARN:    3,
    INFO:    4,
    DEBUG:   5
  }.freeze

  CONFIG_SET = {
    # UI setting
    log_level:      ['RUBY_DEBUG_LOG_LEVEL',      "UI: Log level same as Logger",               :loglevel, "WARN"],
    show_src_lines: ['RUBY_DEBUG_SHOW_SRC_LINES', "UI: Show n lines source code on breakpoint", :int, "10"],
    show_evaledsrc: ['RUBY_DEBUG_SHOW_EVALEDSRC', "UI: Show actually evaluated source",         :bool, "false"],
    show_frames:    ['RUBY_DEBUG_SHOW_FRAMES',    "UI: Show n frames on breakpoint",            :int, "2"],
    use_short_path: ['RUBY_DEBUG_USE_SHORT_PATH', "UI: Show shorten PATH (like $(Gem)/foo.rb)", :bool, "false"],
    no_color:       ['RUBY_DEBUG_NO_COLOR',       "UI: Do not use colorize",                    :bool, "false"],
    no_sigint_hook: ['RUBY_DEBUG_NO_SIGINT_HOOK', "UI: Do not suspend on SIGINT",               :bool, "false"],
    no_reline:      ['RUBY_DEBUG_NO_RELINE',      "UI: Do not use Reline library",              :bool, "false"],
    no_hint:        ['RUBY_DEBUG_NO_HINT',        "UI: Do not show the hint on the REPL",       :bool, "false"],
    no_lineno:      ['RUBY_DEBUG_NO_LINENO',      "UI: Do not show line numbers",               :bool, "false"],
    irb_console:    ["RUBY_DEBUG_IRB_CONSOLE",    "UI: Use IRB as the console",                 :bool, "false"],

    # control setting
    skip_path:      ['RUBY_DEBUG_SKIP_PATH',      "CONTROL: Skip showing/entering frames for given paths", :path],
    skip_nosrc:     ['RUBY_DEBUG_SKIP_NOSRC',     "CONTROL: Skip on no source code lines",              :bool, "false"],
    keep_alloc_site:['RUBY_DEBUG_KEEP_ALLOC_SITE',"CONTROL: Keep allocation site and p, pp shows it",   :bool, "false"],
    postmortem:     ['RUBY_DEBUG_POSTMORTEM',     "CONTROL: Enable postmortem debug",                   :bool, "false"],
    fork_mode:      ['RUBY_DEBUG_FORK_MODE',      "CONTROL: Control which process activates a debugger after fork (both/parent/child)", :forkmode, "both"],
    sigdump_sig:    ['RUBY_DEBUG_SIGDUMP_SIG',    "CONTROL: Sigdump signal", :bool, "false"],

    # boot setting
    nonstop:        ['RUBY_DEBUG_NONSTOP',     "BOOT: Nonstop mode",                                                :bool, "false"],
    stop_at_load:   ['RUBY_DEBUG_STOP_AT_LOAD',"BOOT: Stop at just loading location",                               :bool, "false"],
    init_script:    ['RUBY_DEBUG_INIT_SCRIPT', "BOOT: debug command script path loaded at first stop"],
    commands:       ['RUBY_DEBUG_COMMANDS',    "BOOT: debug commands invoked at first stop. Commands should be separated by `;;`"],
    no_rc:          ['RUBY_DEBUG_NO_RC',       "BOOT: ignore loading ~/.rdbgrc(.rb)",                               :bool, "false"],
    history_file:   ['RUBY_DEBUG_HISTORY_FILE',"BOOT: history file",               :string, "~/.rdbg_history"],
    save_history:   ['RUBY_DEBUG_SAVE_HISTORY',"BOOT: maximum save history lines", :int, "10000"],

    # remote setting
    open:           ['RUBY_DEBUG_OPEN',         "REMOTE: Open remote port (same as `rdbg --open` option)"],
    port:           ['RUBY_DEBUG_PORT',         "REMOTE: TCP/IP remote debugging: port"],
    host:           ['RUBY_DEBUG_HOST',         "REMOTE: TCP/IP remote debugging: host", :string, "127.0.0.1"],
    sock_path:      ['RUBY_DEBUG_SOCK_PATH',    "REMOTE: UNIX Domain Socket remote debugging: socket path"],
    sock_dir:       ['RUBY_DEBUG_SOCK_DIR',     "REMOTE: UNIX Domain Socket remote debugging: socket directory"],
    local_fs_map:   ['RUBY_DEBUG_LOCAL_FS_MAP', "REMOTE: Specify local fs map", :path_map],
    skip_bp:        ['RUBY_DEBUG_SKIP_BP',      "REMOTE: Skip breakpoints if no clients are attached", :bool, 'false'],
    cookie:         ['RUBY_DEBUG_COOKIE',       "REMOTE: Cookie for negotiation"],
    session_name:   ['RUBY_DEBUG_SESSION_NAME', "REMOTE: Session name for differentiating multiple sessions"],
    chrome_path:    ['RUBY_DEBUG_CHROME_PATH',  "REMOTE: Platform dependent path of Chrome (For more information, See [here](https://github.com/ruby/debug/pull/334/files#diff-5fc3d0a901379a95bc111b86cf0090b03f857edfd0b99a0c1537e26735698453R55-R64))"],

    # obsolete
    parent_on_fork: ['RUBY_DEBUG_PARENT_ON_FORK', "OBSOLETE: Keep debugging parent process on fork",     :bool, "false"],
  }.freeze

  CONFIG_MAP = CONFIG_SET.map{|k, (ev, _)| [k, ev]}.to_h.freeze

  class Config
    @config = nil

    def self.config
      @config
    end

    def initialize argv
      if self.class.config
        raise 'Can not make multiple configurations in one process'
      end

      config = self.class.parse_argv(argv)

      # apply defaults
      CONFIG_SET.each do |k, config_detail|
        unless config.key?(k)
          default_value = config_detail[3]
          config[k] = parse_config_value(k, default_value)
        end
      end

      update config
    end

    def inspect
      config.inspect
    end

    def [](key)
      config[key]
    end

    def []=(key, val)
      set_config(key => val)
    end

    def set_config(**kw)
      conf = config.dup
      kw.each{|k, v|
        if CONFIG_MAP[k]
          conf[k] = parse_config_value(k, v) # TODO: ractor support
        else
          raise "Unknown configuration: #{k}"
        end
      }

      update conf
    end

    def append_config key, val
      conf = config.dup

      if CONFIG_SET[key]
        if CONFIG_SET[key][2] == :path
          conf[key] = [*conf[key], *parse_config_value(key, val)];
        else
          raise "not an Array type: #{key}"
        end
      else
        raise "Unknown configuration: #{key}"
      end

      update conf
    end

    def update conf
      old_conf = self.class.instance_variable_get(:@config) || {}

      # TODO: Use Ractor.make_shareable(conf)
      self.class.instance_variable_set(:@config, conf.freeze)

      # Post process
      if_updated old_conf, conf, :keep_alloc_site do |old, new|
        if new
          require 'objspace'
          ObjectSpace.trace_object_allocations_start
        end

        if old && !new
          ObjectSpace.trace_object_allocations_stop
        end
      end

      if_updated old_conf, conf, :postmortem do |_, new_p|
        if defined?(SESSION)
          SESSION.postmortem = new_p
        end
      end

      if_updated old_conf, conf, :sigdump_sig do |old_sig, new_sig|
        setup_sigdump old_sig, new_sig
      end

      if_updated old_conf, conf, :no_sigint_hook do |old, new|
        if defined?(SESSION)
          SESSION.set_no_sigint_hook old, new
        end
      end
    end

    private def if_updated old_conf, new_conf, key
      old, new = old_conf[key], new_conf[key]
      yield old, new if old != new
    end

    private def enable_sigdump sig
      @sigdump_sig_prev = trap(sig) do
        str = []
        str << "Simple sigdump on #{Process.pid}"
        Thread.list.each{|th|
          str << "Thread: #{th}"
          th.backtrace.each{|loc|
            str << "  #{loc}"
          }
          str << ''
        }

        STDERR.puts str
      end
    end

    private def disable_sigdump old_sig
      trap(old_sig, @sigdump_sig_prev)
      @sigdump_sig_prev = nil
    end

    # emergency simple sigdump.
    # Use `sigdump` gem for more rich features.
    private def setup_sigdump old_sig = nil, sig = CONFIG[:sigdump_sig]
      if !old_sig && sig
        enable_sigdump sig
      elsif old_sig && !sig
        disable_sigdump old_sig
      elsif old_sig && sig
        disable_sigdump old_sig
        enable_sigdump sig
      end
    end

    private def config
      self.class.config
    end

    private def parse_config_value name, valstr
      self.class.parse_config_value name, valstr
    end

    def self.parse_config_value name, valstr
      return valstr unless valstr.kind_of? String

      case CONFIG_SET[name][2]
      when :bool
        case valstr
        when '1', 'true', 'TRUE', 'T'
          true
        else
          false
        end
      when :int
        valstr.to_i
      when :loglevel
        if DEBUGGER__::LOG_LEVELS[s = valstr.to_sym]
          s
        else
          raise "Unknown loglevel: #{valstr}"
        end
      when :forkmode
        case sym = valstr.to_sym
        when :parent, :child, :both, nil
          sym
        else
          raise "unknown fork mode: #{sym}"
        end
      when :path # array of String
        valstr.split(/:/).map{|e|
          if /\A\/(.+)\/\z/ =~ e
            Regexp.compile $1
          else
            e
          end
        }
      when :path_map
        valstr.split(',').map{|e| e.split(':')}
      else
        valstr
      end
    end

    def self.parse_argv argv
      config = {
        mode: :start,
        no_color: (nc = ENV['NO_COLOR']) && !nc.empty?,
      }
      CONFIG_MAP.each{|key, evname|
        if val = ENV[evname]
          config[key] = parse_config_value(key, val)
        end
      }
      return config if !argv || argv.empty?

      if argv.kind_of? String
        require 'shellwords'
        argv = Shellwords.split(argv)
      end

      require 'optparse'
      require_relative 'version'

      have_shown_version = false

      opt = OptionParser.new do |o|
        o.banner = "#{$0} [options] -- [debuggee options]"
        o.separator ''
        o.version = ::DEBUGGER__::VERSION

        o.separator 'Debug console mode:'
        o.on('-n', '--nonstop', 'Do not stop at the beginning of the script.') do
          config[:nonstop] = '1'
        end

        o.on('-e DEBUG_COMMAND', 'Execute debug command at the beginning of the script.') do |cmd|
          config[:commands] ||= ''
          config[:commands] += cmd + ';;'
        end

        o.on('-x FILE', '--init-script=FILE', 'Execute debug command in the FILE.') do |file|
          config[:init_script] = file
        end
        o.on('--no-rc', 'Ignore ~/.rdbgrc') do
          config[:no_rc] = true
        end
        o.on('--no-color', 'Disable colorize') do
          config[:no_color] = true
        end
        o.on('--no-sigint-hook', 'Disable to trap SIGINT') do
          config[:no_sigint_hook] = true
        end

        o.on('-c', '--command', 'Enable command mode.',
                                'The first argument should be a command name in $PATH.',
                                'Example: \'rdbg -c bundle exec rake test\'') do
          config[:command] = true
        end

        o.separator ''

        o.on('-O', '--open=[FRONTEND]', 'Start remote debugging with opening the network port.',
                                        'If TCP/IP options are not given, a UNIX domain socket will be used.',
                                        'If FRONTEND is given, prepare for the FRONTEND.',
                                        'Now rdbg, vscode and chrome is supported.') do |f|

          case f # some format patterns are not documented yet
          when nil
            config[:open] = true
          when /\A\d\z/
            config[:open] = true
            config[:port] = f.to_i
          when /\A(\S+):(\d+)\z/
            config[:open] = true
            config[:host] = $1
            config[:port] = $2.to_i
          when 'tcp'
            config[:open] = true
            config[:port] ||= 0
          when 'vscode', 'chrome', 'cdp'
            config[:open] = f&.downcase
          else
            raise "Unknown option for --open: #{f}"
          end
        end
        o.on('--sock-path=SOCK_PATH', 'UNIX Domain socket path') do |path|
          config[:sock_path] = path
        end
        o.on('--port=PORT', 'Listening TCP/IP port') do |port|
          config[:port] = port
        end
        o.on('--host=HOST', 'Listening TCP/IP host') do |host|
          config[:host] = host
        end
        o.on('--cookie=COOKIE', 'Set a cookie for connection') do |c|
          config[:cookie] = c
        end
        o.on('--session-name=NAME', 'Session name') do |name|
          config[:session_name] = name
        end

        rdbg = 'rdbg'

        o.separator ''
        o.separator '  Debug console mode runs Ruby program with the debug console.'
        o.separator ''
        o.separator "  '#{rdbg} target.rb foo bar'                starts like 'ruby target.rb foo bar'."
        o.separator "  '#{rdbg} -- -r foo -e bar'                 starts like 'ruby -r foo -e bar'."
        o.separator "  '#{rdbg} -c rake test'                     starts like 'rake test'."
        o.separator "  '#{rdbg} -c -- rake test -t'               starts like 'rake test -t'."
        o.separator "  '#{rdbg} -c bundle exec rake test'         starts like 'bundle exec rake test'."
        o.separator "  '#{rdbg} -O target.rb foo bar'             starts and accepts attaching with UNIX domain socket."
        o.separator "  '#{rdbg} -O --port 1234 target.rb foo bar' starts accepts attaching with TCP/IP localhost:1234."
        o.separator "  '#{rdbg} -O --port 1234 -- -r foo -e bar'  starts accepts attaching with TCP/IP localhost:1234."
        o.separator "  '#{rdbg} target.rb -O chrome --port 1234'  starts and accepts connecting from Chrome Devtools with localhost:1234."

        o.separator ''
        o.separator 'Attach mode:'
        o.on('-A', '--attach', 'Attach to debuggee process.') do
          config[:mode] = :attach
        end

        o.separator ''
        o.separator '  Attach mode attaches the remote debug console to the debuggee process.'
        o.separator ''
        o.separator "  '#{rdbg} -A'           tries to connect via UNIX domain socket."
        o.separator "  #{' ' * rdbg.size}                If there are multiple processes are waiting for the"
        o.separator "  #{' ' * rdbg.size}                debugger connection, list possible debuggee names."
        o.separator "  '#{rdbg} -A path'      tries to connect via UNIX domain socket with given path name."
        o.separator "  '#{rdbg} -A port'      tries to connect to localhost:port via TCP/IP."
        o.separator "  '#{rdbg} -A host port' tries to connect to host:port via TCP/IP."

        o.separator ''
        o.separator 'Other options:'

        o.on('-v', 'Show version number') do
          puts o.ver
          have_shown_version = true
        end

        o.on('--version', 'Show version number and exit') do
          puts o.ver
          exit
        end

        o.on("-h", "--help", "Print help") do
          puts o
          exit
        end

        o.on('--util=NAME', 'Utility mode (used by tools)') do |name|
          require_relative 'client'
          Client.util(name)
          exit
        end

        o.on('--stop-at-load', 'Stop immediately when the debugging feature is loaded.') do
          config[:stop_at_load] = true
        end

        o.separator ''
        o.separator 'NOTE'
        o.separator '  All messages communicated between a debugger and a debuggee are *NOT* encrypted.'
        o.separator '  Please use the remote debugging feature carefully.'
      end

      opt.parse!(argv)

      if argv.empty?
        case
        when have_shown_version && config[:mode] == :start
          exit
        end
      end

      config
    end

    def self.config_to_env_hash config
      CONFIG_MAP.each_with_object({}){|(key, evname), env|
        unless config[key].nil?
          case CONFIG_SET[key][2]
          when :path
            valstr = config[key].map{|e| e.kind_of?(Regexp) ? e.inspect : e}.join(':')
          when :path_map
            valstr = config[key].map{|e| e.join(':')}.join(',')
          else
            valstr = config[key].to_s
          end
          env[evname] = valstr
        end
      }
    end
  end

  CONFIG = Config.new ENV['RUBY_DEBUG_OPT']

  ## Unix domain socket configuration

  def self.check_dir_authority path
    fs = File.stat(path)

    unless (dir_uid = fs.uid) == (uid = Process.uid)
      raise "#{path} uid is #{dir_uid}, but Process.uid is #{uid}"
    end

    if fs.world_writable? && !fs.sticky?
      raise "#{path} is world writable but not sticky"
    end

    path
  end

  def self.unix_domain_socket_tmpdir
    require 'tmpdir'

    if tmpdir = Dir.tmpdir
      path = File.join(tmpdir, "rdbg-#{Process.uid}")

      unless File.exist?(path)
        d = Dir.mktmpdir
        File.rename(d, path)
      end

      check_dir_authority(path)
    end
  end

  def self.unix_domain_socket_homedir
    if home = ENV['HOME']
      path = File.join(home, '.rdbg-sock')

      unless File.exist?(path)
        Dir.mkdir(path, 0700)
      end

      check_dir_authority(path)
    end
  end

  def self.unix_domain_socket_dir
    case
    when path = CONFIG[:sock_dir]
    when path = ENV['XDG_RUNTIME_DIR']
    when path = unix_domain_socket_tmpdir
    when path = unix_domain_socket_homedir
    else
      raise 'specify RUBY_DEBUG_SOCK_DIR environment variable.'
    end

    path
  end

  def self.create_unix_domain_socket_name_prefix(base_dir = unix_domain_socket_dir)
    File.join(base_dir, "rdbg")
  end

  def self.create_unix_domain_socket_name(base_dir = unix_domain_socket_dir)
    suffix = "-#{Process.pid}"
    name = CONFIG[:session_name]
    suffix << "-#{name}" if name
    create_unix_domain_socket_name_prefix(base_dir) + suffix
  end

  ## Help

  def self.parse_help
    helps = Hash.new{|h, k| h[k] = []}
    desc = cat = nil
    cmds = Hash.new

    File.read(File.join(__dir__, 'session.rb'), encoding: Encoding::UTF_8).each_line do |line|
      case line
      when /\A\s*### (.+)/
        cat = $1
        break if $1 == 'END'
      when /\A      register_command (.+)/
        next unless cat
        next unless desc

        ws = []
        $1.gsub(/'([a-z]+)'/){|w|
          ws << $1
        }
        helps[cat] << [ws, desc]
        desc = nil
        max_w = ws.max_by{|w| w.length}
        ws.each{|w|
          cmds[w] = max_w
        }
      when /\A\s+# (\s*\*.+)/
        if desc
          desc << "\n" + $1
        else
          desc = $1
        end
      end
    end
    @commands = cmds
    @helps = helps
  end

  def self.helps
    (defined?(@helps) && @helps) || parse_help
  end

  def self.commands
    (defined?(@commands) && @commands) || (parse_help; @commands)
  end

  def self.help
    r = []
    self.helps.each{|cat, cmds|
      r << "### #{cat}"
      r << ''
      cmds.each{|_, desc|
        r << desc
      }
      r << ''
    }
    r.join("\n")
  end
end