# frozen_string_literal: true require 'json' require 'digest/sha1' require 'base64' require 'securerandom' require 'stringio' require 'open3' require 'tmpdir' require 'tempfile' require 'timeout' module DEBUGGER__ module UI_CDP SHOW_PROTOCOL = ENV['RUBY_DEBUG_CDP_SHOW_PROTOCOL'] == '1' class UnsupportedError < StandardError; end class NotFoundChromeEndpointError < StandardError; end class << self def setup_chrome addr, uuid return if CONFIG[:chrome_path] == '' port, path, pid = run_new_chrome begin s = Socket.tcp '127.0.0.1', port rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL return end ws_client = WebSocketClient.new(s) ws_client.handshake port, path ws_client.send id: 1, method: 'Target.getTargets' loop do res = ws_client.extract_data case res['id'] when 1 target_info = res.dig('result', 'targetInfos') page = target_info.find{|t| t['type'] == 'page'} ws_client.send id: 2, method: 'Target.attachToTarget', params: { targetId: page['targetId'], flatten: true } when 2 s_id = res.dig('result', 'sessionId') # TODO: change id ws_client.send sessionId: s_id, id: 100, method: 'Network.enable' ws_client.send sessionId: s_id, id: 3, method: 'Page.enable' when 3 s_id = res['sessionId'] ws_client.send sessionId: s_id, id: 4, method: 'Page.getFrameTree' when 4 s_id = res['sessionId'] f_id = res.dig('result', 'frameTree', 'frame', 'id') ws_client.send sessionId: s_id, id: 5, method: 'Page.navigate', params: { url: "devtools://devtools/bundled/inspector.html?v8only=true&panel=sources&ws=#{addr}/#{uuid}", frameId: f_id } when 101 break else if res['method'] == 'Network.webSocketWillSendHandshakeRequest' s_id = res['sessionId'] # Display the console by entering ESC key ws_client.send sessionId: s_id, id: 101, # TODO: change id method:"Input.dispatchKeyEvent", params: { type:"keyDown", windowsVirtualKeyCode:27 # ESC key } end end end pid rescue Errno::ENOENT, UnsupportedError, NotFoundChromeEndpointError nil end TIMEOUT_SEC = 5 def run_new_chrome path = CONFIG[:chrome_path] data = nil port = nil wait_thr = nil # The process to check OS is based on `selenium` project. case RbConfig::CONFIG['host_os'] when /mswin|msys|mingw|cygwin|emc/ if path.nil? candidates = ['C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'] path = get_chrome_path candidates end # The path is based on https://github.com/sindresorhus/open/blob/v8.4.0/index.js#L128. stdin, stdout, stderr, wait_thr = *Open3.popen3("#{ENV['SystemRoot']}\\System32\\WindowsPowerShell\\v1.0\\powershell") tf = Tempfile.create(['debug-', '.txt']) stdin.puts("Start-process '#{path}' -Argumentlist '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', '--user-data-dir=C:\\temp' -Wait -RedirectStandardError #{tf.path}") stdin.close stdout.close stderr.close port, path = get_devtools_endpoint(tf.path) at_exit{ DEBUGGER__.skip_all stdin, stdout, stderr, wait_thr = *Open3.popen3("#{ENV['SystemRoot']}\\System32\\WindowsPowerShell\\v1.0\\powershell") stdin.puts("Stop-process -Name chrome") stdin.close stdout.close stderr.close tf.close begin File.unlink(tf) rescue Errno::EACCES end } when /darwin|mac os/ path = path || '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome' dir = Dir.mktmpdir # The command line flags are based on: https://developer.mozilla.org/en-US/docs/Tools/Remote_Debugging/Chrome_Desktop#connecting. stdin, stdout, stderr, wait_thr = *Open3.popen3("#{path} --remote-debugging-port=0 --no-first-run --no-default-browser-check --user-data-dir=#{dir}") stdin.close stdout.close data = stderr.readpartial 4096 stderr.close if data.match /DevTools listening on ws:\/\/127.0.0.1:(\d+)(.*)/ port = $1 path = $2 end at_exit{ DEBUGGER__.skip_all FileUtils.rm_rf dir } when /linux/ path = path || 'google-chrome' dir = Dir.mktmpdir # The command line flags are based on: https://developer.mozilla.org/en-US/docs/Tools/Remote_Debugging/Chrome_Desktop#connecting. stdin, stdout, stderr, wait_thr = *Open3.popen3("#{path} --remote-debugging-port=0 --no-first-run --no-default-browser-check --user-data-dir=#{dir}") stdin.close stdout.close data = '' begin Timeout.timeout(TIMEOUT_SEC) do until data.match?(/DevTools listening on ws:\/\/127.0.0.1:\d+.*/) data = stderr.readpartial 4096 end end rescue Exception raise NotFoundChromeEndpointError end stderr.close if data.match /DevTools listening on ws:\/\/127.0.0.1:(\d+)(.*)/ port = $1 path = $2 end at_exit{ DEBUGGER__.skip_all FileUtils.rm_rf dir } else raise UnsupportedError end [port, path, wait_thr.pid] end def get_chrome_path candidates candidates.each{|c| if File.exist? c return c end } raise UnsupportedError end ITERATIONS = 50 def get_devtools_endpoint tf i = 1 while i < ITERATIONS i += 1 if File.exist?(tf) && data = File.read(tf) if data.match /DevTools listening on ws:\/\/127.0.0.1:(\d+)(.*)/ port = $1 path = $2 return [port, path] end end sleep 0.1 end raise NotFoundChromeEndpointError end end def send_chrome_response req @repl = false case req when /^GET\s\/json\/version\sHTTP\/1.1/ body = { Browser: "ruby/v#{RUBY_VERSION}", 'Protocol-Version': "1.1" } send_http_res body raise UI_ServerBase::RetryConnection when /^GET\s\/json\sHTTP\/1.1/ @uuid = @uuid || SecureRandom.uuid addr = @local_addr.inspect_sockaddr body = [{ description: "ruby instance", devtoolsFrontendUrl: "devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=#{addr}/#{@uuid}", id: @uuid, title: $0, type: "node", url: "file://#{File.absolute_path($0)}", webSocketDebuggerUrl: "ws://#{addr}/#{@uuid}" }] send_http_res body raise UI_ServerBase::RetryConnection when /^GET\s\/(\w{8}-\w{4}-\w{4}-\w{4}-\w{12})\sHTTP\/1.1/ raise 'Incorrect uuid' unless $1 == @uuid @need_pause_at_first = false CONFIG.set_config no_color: true @ws_server = WebSocketServer.new(@sock) @ws_server.handshake end end def send_http_res body json = JSON.generate body header = "HTTP/1.0 200 OK\r\nContent-Type: application/json; charset=UTF-8\r\nCache-Control: no-cache\r\nContent-Length: #{json.bytesize}\r\n\r\n" @sock.puts "#{header}#{json}" end module WebSocketUtils class Frame attr_reader :b def initialize @b = ''.b end def << obj case obj when String @b << obj.b when Enumerable obj.each{|e| self << e} end end def char bytes @b << bytes end def ulonglong bytes @b << [bytes].pack('Q>') end def uint16 bytes @b << [bytes].pack('n*') end end def show_protocol dir, msg if DEBUGGER__::UI_CDP::SHOW_PROTOCOL $stderr.puts "\#[#{dir}] #{msg}" end end end class WebSocketClient include WebSocketUtils def initialize s @sock = s end def handshake port, path key = SecureRandom.hex(11) req = "GET #{path} HTTP/1.1\r\nHost: 127.0.0.1:#{port}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: #{key}==\r\n\r\n" show_protocol :>, req @sock.print req res = @sock.readpartial 4092 show_protocol :<, res if res.match /^Sec-WebSocket-Accept: (.*)\r\n/ correct_key = Base64.strict_encode64 Digest::SHA1.digest "#{key}==258EAFA5-E914-47DA-95CA-C5AB0DC85B11" raise "The Sec-WebSocket-Accept value: #{$1} is not valid" unless $1 == correct_key else raise "Unknown response: #{res}" end end def send **msg msg = JSON.generate(msg) show_protocol :>, msg frame = Frame.new fin = 0b10000000 opcode = 0b00000001 frame.char fin + opcode mask = 0b10000000 # A client must mask all frames in a WebSocket Protocol. bytesize = msg.bytesize if bytesize < 126 payload_len = bytesize frame.char mask + payload_len elsif bytesize < 2 ** 16 payload_len = 0b01111110 frame.char mask + payload_len frame.uint16 bytesize elsif bytesize < 2 ** 64 payload_len = 0b01111111 frame.char mask + payload_len frame.ulonglong bytesize else raise 'Bytesize is too big.' end masking_key = 4.times.map{ key = rand(1..255) frame.char key key } msg.bytes.each_with_index do |b, i| frame.char(b ^ masking_key[i % 4]) end @sock.print frame.b end def extract_data first_group = @sock.getbyte fin = first_group & 0b10000000 != 128 raise 'Unsupported' if fin opcode = first_group & 0b00001111 raise "Unsupported: #{opcode}" unless opcode == 1 second_group = @sock.getbyte mask = second_group & 0b10000000 == 128 raise 'The server must not mask any frames' if mask payload_len = second_group & 0b01111111 # TODO: Support other payload_lengths if payload_len == 126 payload_len = @sock.read(2).unpack('n*')[0] end msg = @sock.read payload_len show_protocol :<, msg JSON.parse msg end end class Detach < StandardError end class WebSocketServer include WebSocketUtils def initialize s @sock = s end def handshake req = @sock.readpartial 4096 show_protocol '>', req if req.match /^Sec-WebSocket-Key: (.*)\r\n/ accept = Base64.strict_encode64 Digest::SHA1.digest "#{$1}258EAFA5-E914-47DA-95CA-C5AB0DC85B11" res = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: #{accept}\r\n\r\n" @sock.print res show_protocol :<, res else "Unknown request: #{req}" end end def send **msg msg = JSON.generate(msg) show_protocol :<, msg frame = Frame.new fin = 0b10000000 opcode = 0b00000001 frame.char fin + opcode mask = 0b00000000 # A server must not mask any frames in a WebSocket Protocol. bytesize = msg.bytesize if bytesize < 126 payload_len = bytesize frame.char mask + payload_len elsif bytesize < 2 ** 16 payload_len = 0b01111110 frame.char mask + payload_len frame.uint16 bytesize elsif bytesize < 2 ** 64 payload_len = 0b01111111 frame.char mask + payload_len frame.ulonglong bytesize else raise 'Bytesize is too big.' end frame << msg @sock.print frame.b end def extract_data first_group = @sock.getbyte fin = first_group & 0b10000000 != 128 raise 'Unsupported' if fin opcode = first_group & 0b00001111 raise Detach if opcode == 8 raise "Unsupported: #{opcode}" unless opcode == 1 second_group = @sock.getbyte mask = second_group & 0b10000000 == 128 raise 'The client must mask all frames' unless mask payload_len = second_group & 0b01111111 # TODO: Support other payload_lengths if payload_len == 126 payload_len = @sock.gets(2).unpack('n*')[0] end masking_key = [] 4.times { masking_key << @sock.getbyte } unmasked = [] payload_len.times do |n| masked = @sock.getbyte unmasked << (masked ^ masking_key[n % 4]) end msg = unmasked.pack 'c*' show_protocol :>, msg JSON.parse msg end end def send_response req, **res @ws_server.send id: req['id'], result: res end def send_fail_response req, **res @ws_server.send id: req['id'], error: res end def send_event method, **params @ws_server.send method: method, params: params end INVALID_REQUEST = -32600 def process bps = {} @src_map = {} loop do req = @ws_server.extract_data case req['method'] ## boot/configuration when 'Debugger.getScriptSource' @q_msg << req when 'Debugger.enable' send_response req @q_msg << req when 'Runtime.enable' send_response req send_event 'Runtime.executionContextCreated', context: { id: SecureRandom.hex(16), origin: "http://#{@local_addr.inspect_sockaddr}", name: '' } when 'Runtime.getIsolateId' send_response req, id: SecureRandom.hex when 'Runtime.terminateExecution' send_response req exit when 'Page.startScreencast', 'Emulation.setTouchEmulationEnabled', 'Emulation.setEmitTouchEventsForMouse', 'Runtime.compileScript', 'Page.getResourceContent', 'Overlay.setPausedInDebuggerMessage', 'Runtime.releaseObjectGroup', 'Runtime.discardConsoleEntries', 'Log.clear', 'Runtime.runIfWaitingForDebugger' send_response req ## control when 'Debugger.resume' send_response req send_event 'Debugger.resumed' @q_msg << 'c' @q_msg << req when 'Debugger.stepOver' begin @session.check_postmortem send_response req send_event 'Debugger.resumed' @q_msg << 'n' rescue PostmortemError send_fail_response req, code: INVALID_REQUEST, message: "'stepOver' is not supported while postmortem mode" ensure @q_msg << req end when 'Debugger.stepInto' begin @session.check_postmortem send_response req send_event 'Debugger.resumed' @q_msg << 's' rescue PostmortemError send_fail_response req, code: INVALID_REQUEST, message: "'stepInto' is not supported while postmortem mode" ensure @q_msg << req end when 'Debugger.stepOut' begin @session.check_postmortem send_response req send_event 'Debugger.resumed' @q_msg << 'fin' rescue PostmortemError send_fail_response req, code: INVALID_REQUEST, message: "'stepOut' is not supported while postmortem mode" ensure @q_msg << req end when 'Debugger.setSkipAllPauses' skip = req.dig('params', 'skip') if skip deactivate_bp else activate_bp bps end send_response req when 'Debugger.pause' send_response req Process.kill(UI_ServerBase::TRAP_SIGNAL, Process.pid) # breakpoint when 'Debugger.getPossibleBreakpoints' @q_msg << req when 'Debugger.setBreakpointByUrl' line = req.dig('params', 'lineNumber') if regexp = req.dig('params', 'urlRegex') b_id = "1:#{line}:#{regexp}" bps[b_id] = bps.size path = regexp.match(/(.*)\|/)[1].gsub("\\", "") add_line_breakpoint(req, b_id, path) elsif url = req.dig('params', 'url') b_id = "#{line}:#{url}" # When breakpoints are set in Script snippet, non-existent path such as "snippet:///Script%20snippet%20%231" sent. # That's why we need to check it here. if File.exist? url bps[b_id] = bps.size add_line_breakpoint(req, b_id, url) else send_response req, breakpointId: b_id, locations: [] end else if hash = req.dig('params', 'scriptHash') b_id = "#{line}:#{hash}" send_response req, breakpointId: b_id, locations: [] else raise 'Unsupported' end end when 'Debugger.removeBreakpoint' b_id = req.dig('params', 'breakpointId') bps = del_bp bps, b_id send_response req when 'Debugger.setBreakpointsActive' active = req.dig('params', 'active') if active activate_bp bps else deactivate_bp # TODO: Change this part because catch breakpoints should not be deactivated. end send_response req when 'Debugger.setPauseOnExceptions' state = req.dig('params', 'state') ex = 'Exception' case state when 'none' @q_msg << 'config postmortem = false' bps = del_bp bps, ex when 'uncaught' @q_msg << 'config postmortem = true' bps = del_bp bps, ex when 'all' @q_msg << 'config postmortem = false' SESSION.add_catch_breakpoint ex bps[ex] = bps.size end send_response req when 'Debugger.evaluateOnCallFrame', 'Runtime.getProperties' @q_msg << req end end rescue Detach @q_msg << 'continue' end def add_line_breakpoint req, b_id, path cond = req.dig('params', 'condition') line = req.dig('params', 'lineNumber') src = get_source_code path end_line = src.lines.count line = end_line if line > end_line if cond != '' SESSION.add_line_breakpoint(path, line + 1, cond: cond) else SESSION.add_line_breakpoint(path, line + 1) end # Because we need to return scriptId, responses are returned in SESSION thread. req['params']['scriptId'] = path req['params']['lineNumber'] = line req['params']['breakpointId'] = b_id @q_msg << req end def del_bp bps, k return bps unless idx = bps[k] bps.delete k bps.each_key{|i| bps[i] -= 1 if bps[i] > idx} @q_msg << "del #{idx}" bps end def get_source_code path return @src_map[path] if @src_map[path] src = File.read(path) @src_map[path] = src src end def activate_bp bps bps.each_key{|k| if k.match /^\d+:(\d+):(.*)/ line = $1 path = $2 SESSION.add_line_breakpoint(path, line.to_i + 1) else SESSION.add_catch_breakpoint 'Exception' end } end def deactivate_bp @q_msg << 'del' @q_ans << 'y' end def cleanup_reader super Process.kill :KILL, @chrome_pid if @chrome_pid rescue Errno::ESRCH # continue if @chrome_pid process is not found end ## Called by the SESSION thread alias respond send_response alias respond_fail send_fail_response alias fire_event send_event def sock skip: false yield $stderr end def puts result='' # STDERR.puts "puts: #{result}" # send_event 'output', category: 'stderr', output: "PUTS!!: " + result.to_s end end class Session include GlobalVariablesHelper # FIXME: unify this method with ThreadClient#propertyDescriptor. def get_type obj case obj when Array ['object', 'array'] when Hash ['object', 'map'] when String ['string'] when TrueClass, FalseClass ['boolean'] when Symbol ['symbol'] when Integer, Float ['number'] when Exception ['object', 'error'] else ['object'] end end def fail_response req, **result @ui.respond_fail req, **result return :retry end INVALID_PARAMS = -32602 INTERNAL_ERROR = -32603 def process_protocol_request req case req['method'] when 'Debugger.stepOver', 'Debugger.stepInto', 'Debugger.stepOut', 'Debugger.resume', 'Debugger.enable' request_tc [:cdp, :backtrace, req] when 'Debugger.evaluateOnCallFrame' frame_id = req.dig('params', 'callFrameId') group = req.dig('params', 'objectGroup') if fid = @frame_map[frame_id] expr = req.dig('params', 'expression') request_tc [:cdp, :evaluate, req, fid, expr, group] else fail_response req, code: INVALID_PARAMS, message: "'callFrameId' is an invalid" end when 'Runtime.getProperties', 'Runtime.getExceptionDetails' oid = req.dig('params', 'objectId') || req.dig('params', 'errorObjectId') if ref = @obj_map[oid] case ref[0] when 'local' frame_id = ref[1] fid = @frame_map[frame_id] request_tc [:cdp, :scope, req, fid] when 'global' vars = safe_global_variables.sort.map do |name| begin gv = eval(name.to_s) rescue Errno::ENOENT gv = nil end prop = { name: name, value: { description: gv.inspect }, configurable: true, enumerable: true } type, subtype = get_type(gv) prop[:value][:type] = type prop[:value][:subtype] = subtype if subtype prop end @ui.respond req, result: vars return :retry when 'properties' request_tc [:cdp, :properties, req, oid] when 'exception' request_tc [:cdp, :exception, req, oid] when 'script' # TODO: Support script and global types @ui.respond req, result: [] return :retry else raise "Unknown type: #{ref.inspect}" end else fail_response req, code: INVALID_PARAMS, message: "'objectId' is an invalid" end when 'Debugger.getScriptSource' s_id = req.dig('params', 'scriptId') if src = @src_map[s_id] @ui.respond req, scriptSource: src else fail_response req, code: INVALID_PARAMS, message: "'scriptId' is an invalid" end return :retry when 'Debugger.getPossibleBreakpoints' s_id = req.dig('params', 'start', 'scriptId') if src = @src_map[s_id] lineno = req.dig('params', 'start', 'lineNumber') end_line = src.lines.count lineno = end_line if lineno > end_line @ui.respond req, locations: [{ scriptId: s_id, lineNumber: lineno }] else fail_response req, code: INVALID_PARAMS, message: "'scriptId' is an invalid" end return :retry when 'Debugger.setBreakpointByUrl' path = req.dig('params', 'scriptId') if s_id = @scr_id_map[path] lineno = req.dig('params', 'lineNumber') b_id = req.dig('params', 'breakpointId') @ui.respond req, breakpointId: b_id, locations: [{ scriptId: s_id, lineNumber: lineno }] else fail_response req, code: INTERNAL_ERROR, message: 'The target script is not found...' end return :retry end end def process_protocol_result args type, req, result = args case type when :backtrace result[:callFrames].each.with_index do |frame, i| frame_id = frame[:callFrameId] @frame_map[frame_id] = i path = frame[:url] unless s_id = @scr_id_map[path] s_id = (@scr_id_map.size + 1).to_s @scr_id_map[path] = s_id lineno = 0 src = '' if path && File.exist?(path) src = File.read(path) @src_map[s_id] = src lineno = src.lines.count end @ui.fire_event 'Debugger.scriptParsed', scriptId: s_id, url: path, startLine: 0, startColumn: 0, endLine: lineno, endColumn: 0, executionContextId: 1, hash: src.hash.inspect end frame[:location][:scriptId] = s_id frame[:functionLocation][:scriptId] = s_id frame[:scopeChain].each {|s| oid = s.dig(:object, :objectId) @obj_map[oid] = [s[:type], frame_id] } end if oid = result.dig(:data, :objectId) @obj_map[oid] = ['properties'] end @ui.fire_event 'Debugger.paused', **result when :evaluate message = result.delete :message if message fail_response req, code: INVALID_PARAMS, message: message else src = req.dig('params', 'expression') s_id = (@src_map.size + 1).to_s @src_map[s_id] = src lineno = src.lines.count @ui.fire_event 'Debugger.scriptParsed', scriptId: s_id, url: '', startLine: 0, startColumn: 0, endLine: lineno, endColumn: 0, executionContextId: 1, hash: src.hash.inspect if exc = result.dig(:response, :exceptionDetails) exc[:stackTrace][:callFrames].each{|frame| if frame[:url].empty? frame[:scriptId] = s_id else path = frame[:url] unless s_id = @scr_id_map[path] s_id = (@scr_id_map.size + 1).to_s @scr_id_map[path] = s_id end frame[:scriptId] = s_id end } if oid = exc[:exception][:objectId] @obj_map[oid] = ['exception'] end end rs = result.dig(:response, :result) [rs].each{|obj| if oid = obj[:objectId] @obj_map[oid] = ['properties'] end } @ui.respond req, **result[:response] out = result[:output] if out && !out.empty? @ui.fire_event 'Runtime.consoleAPICalled', type: 'log', args: [ type: out.class, value: out ], executionContextId: 1, # Change this number if something goes wrong. timestamp: Time.now.to_f end end when :scope result.each{|obj| if oid = obj.dig(:value, :objectId) @obj_map[oid] = ['properties'] end } @ui.respond req, result: result when :properties result.each_value{|v| v.each{|obj| if oid = obj.dig(:value, :objectId) @obj_map[oid] = ['properties'] end } } @ui.respond req, **result when :exception @ui.respond req, **result end end end class ThreadClient def process_cdp args type = args.shift req = args.shift case type when :backtrace exception = nil result = { reason: 'other', callFrames: @target_frames.map.with_index{|frame, i| exception = frame.raised_exception if frame == current_frame && frame.has_raised_exception path = frame.realpath || frame.path if frame.iseq.nil? lineno = 0 else lineno = frame.iseq.first_line - 1 end { callFrameId: SecureRandom.hex(16), functionName: frame.name, functionLocation: { # scriptId: N, # filled by SESSION lineNumber: lineno }, location: { # scriptId: N, # filled by SESSION lineNumber: frame.location.lineno - 1 # The line number is 0-based. }, url: path, scopeChain: [ { type: 'local', object: { type: 'object', objectId: rand.to_s } }, { type: 'script', object: { type: 'object', objectId: rand.to_s } }, { type: 'global', object: { type: 'object', objectId: rand.to_s } } ], this: { type: 'object' } } } } if exception result[:data] = evaluate_result exception result[:reason] = 'exception' end event! :protocol_result, :backtrace, req, result when :evaluate res = {} fid, expr, group = args frame = @target_frames[fid] message = nil if frame && (b = frame.eval_binding) special_local_variables frame do |name, var| b.local_variable_set(name, var) if /\%/ !~name end result = nil case group when 'popover' case expr # Chrome doesn't read instance variables when /\A\$\S/ safe_global_variables.each{|gvar| if gvar.to_s == expr result = eval(gvar.to_s) break false end } and (message = "Error: Not defined global variable: #{expr.inspect}") when /(\A((::[A-Z]|[A-Z])\w*)+)/ unless result = search_const(b, $1) message = "Error: Not defined constant: #{expr.inspect}" end else begin result = b.local_variable_get(expr) rescue NameError # try to check method if M_RESPOND_TO_P.bind_call(b.receiver, expr, include_all: true) result = M_METHOD.bind_call(b.receiver, expr) else message = "Error: Can not evaluate: #{expr.inspect}" end end end when 'console', 'watch-group' begin orig_stdout = $stdout $stdout = StringIO.new result = b.eval(expr.to_s, '(DEBUG CONSOLE)') rescue Exception => e result = e res[:exceptionDetails] = exceptionDetails(e, 'Uncaught') ensure output = $stdout.string $stdout = orig_stdout end else message = "Error: unknown objectGroup: #{group}" end else result = Exception.new("Error: Can not evaluate on this frame") end res[:result] = evaluate_result(result) event! :protocol_result, :evaluate, req, message: message, response: res, output: output when :scope fid = args.shift frame = @target_frames[fid] if b = frame.binding vars = b.local_variables.map{|name| v = b.local_variable_get(name) variable(name, v) } special_local_variables frame do |name, val| vars.unshift variable(name, val) end vars.unshift variable('%self', b.receiver) elsif lvars = frame.local_variables vars = lvars.map{|var, val| variable(var, val) } else vars = [variable('%self', frame.self)] special_local_variables frame do |name, val| vars.unshift variable(name, val) end end event! :protocol_result, :scope, req, vars when :properties oid = args.shift result = [] prop = [] if obj = @obj_map[oid] case obj when Array result = obj.map.with_index{|o, i| variable i.to_s, o } when Hash result = obj.map{|k, v| variable(k, v) } when Struct result = obj.members.map{|m| variable(m, obj[m]) } when String prop = [ internalProperty('#length', obj.length), internalProperty('#encoding', obj.encoding) ] when Class, Module result = obj.instance_variables.map{|iv| variable(iv, obj.instance_variable_get(iv)) } prop = [internalProperty('%ancestors', obj.ancestors[1..])] when Range prop = [ internalProperty('#begin', obj.begin), internalProperty('#end', obj.end), ] end result += M_INSTANCE_VARIABLES.bind_call(obj).map{|iv| variable(iv, M_INSTANCE_VARIABLE_GET.bind_call(obj, iv)) } prop += [internalProperty('#class', M_CLASS.bind_call(obj))] end event! :protocol_result, :properties, req, result: result, internalProperties: prop when :exception oid = args.shift exc = nil if obj = @obj_map[oid] exc = exceptionDetails obj, obj.to_s end event! :protocol_result, :exception, req, exceptionDetails: exc end end def exceptionDetails exc, text frames = [ { columnNumber: 0, functionName: 'eval', lineNumber: 0, url: '' } ] exc.backtrace_locations&.each do |loc| break if loc.path == __FILE__ path = loc.absolute_path || loc.path frames << { columnNumber: 0, functionName: loc.base_label, lineNumber: loc.lineno - 1, url: path } end { exceptionId: 1, text: text, lineNumber: 0, columnNumber: 0, exception: evaluate_result(exc), stackTrace: { callFrames: frames } } end def search_const b, expr cs = expr.delete_prefix('::').split('::') [Object, *b.eval('::Module.nesting')].reverse_each{|mod| if cs.all?{|c| if mod.const_defined?(c) begin mod = mod.const_get(c) rescue Exception false end else false end } # if-body return mod end } false end def evaluate_result r v = variable nil, r v[:value] end def internalProperty name, obj v = variable name, obj v.delete :configurable v.delete :enumerable v end def propertyDescriptor_ name, obj, type, description: nil, subtype: nil description = DEBUGGER__.safe_inspect(obj, short: true) if description.nil? oid = rand.to_s @obj_map[oid] = obj prop = { name: name, value: { type: type, description: description, value: obj, objectId: oid }, configurable: true, # TODO: Change these parts because enumerable: true # they are not necessarily `true`. } if type == 'object' v = prop[:value] v.delete :value v[:subtype] = subtype if subtype v[:className] = (klass = M_CLASS.bind_call(obj)).name || klass.to_s end prop end def preview_ value, hash, overflow # The reason for not using "map" method is to prevent the object overriding it from causing bugs. # https://github.com/ruby/debug/issues/781 props = [] hash.each{|k, v| pd = propertyDescriptor k, v props << { name: pd[:name], type: pd[:value][:type], value: pd[:value][:description] } } { type: value[:type], subtype: value[:subtype], description: value[:description], overflow: overflow, properties: props } end def variable name, obj pd = propertyDescriptor name, obj case obj when Array pd[:value][:preview] = preview name, obj obj.each_with_index{|item, idx| if valuePreview = preview(idx.to_s, item) pd[:value][:preview][:properties][idx][:valuePreview] = valuePreview end } when Hash pd[:value][:preview] = preview name, obj obj.each_with_index{|item, idx| key, val = item if valuePreview = preview(key, val) pd[:value][:preview][:properties][idx][:valuePreview] = valuePreview end } end pd end def preview name, obj case obj when Array pd = propertyDescriptor name, obj overflow = false if obj.size > 100 obj = obj[0..99] overflow = true end hash = obj.each_with_index.to_h{|o, i| [i.to_s, o]} preview_ pd[:value], hash, overflow when Hash pd = propertyDescriptor name, obj overflow = false if obj.size > 100 obj = obj.to_a[0..99].to_h overflow = true end preview_ pd[:value], obj, overflow else nil end end def propertyDescriptor name, obj case obj when Array propertyDescriptor_ name, obj, 'object', subtype: 'array' when Hash propertyDescriptor_ name, obj, 'object', subtype: 'map' when String propertyDescriptor_ name, obj, 'string', description: obj when TrueClass, FalseClass propertyDescriptor_ name, obj, 'boolean' when Symbol propertyDescriptor_ name, obj, 'symbol' when Integer, Float propertyDescriptor_ name, obj, 'number' when Exception bt = '' if log = obj.backtrace_locations log.each do |loc| break if loc.path == __FILE__ bt += " #{loc}\n" end end propertyDescriptor_ name, obj, 'object', description: "#{obj.inspect}\n#{bt}", subtype: 'error' else propertyDescriptor_ name, obj, 'object' end end end end