ladybug 0.0.1.alpha → 0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 19fccf01c424b4f35e62b84e6d7aaf3978e0253d
4
- data.tar.gz: 5a12abd7a1eb3148e9fd067675ffacb4012f4dd4
3
+ metadata.gz: d05e6df669dd8a7fd238743c6480d6d9e54138a7
4
+ data.tar.gz: a77994e9bee36a609e79359749047cbccebcf7be
5
5
  SHA512:
6
- metadata.gz: 481c6763053abdaea1057eba44c38003bc8475010a9684a02c82e5f5e55b217b960f93f29250c19a6cd5306aca4aac8bc6f644c1cd709e86017355a09504f3ac
7
- data.tar.gz: 61f3f7a14e828a8192a21836f8f9325ebba844a091390b46e3540673476d8492db06f68f1bf70fa420ee9755beb22308276cdb1aa34abe5356a5dad8190c2f96
6
+ metadata.gz: a183844d91ee89fbbb524eb5a52c64978787062d00b7e25f4c218f6f2e2401e2fd6ae65523e683d54896db06b425d6f2bbf7519f32d36e44103d624c8c5b57b0
7
+ data.tar.gz: d50b4ac8ea8079587d4a5be1a7d006f8e2cbe7fb169156103b03878baf10f4501a0859c8ac1f7dc6141d2c40282ef2853823cc3e1c07a51924cd1d05e4ccdd5a
data/README.md CHANGED
@@ -1 +1,62 @@
1
- # Ladybug
1
+ # 🐞 ladybug
2
+
3
+ ladybug is a visual debugger for Ruby web applications that uses
4
+ Chrome Devtools as a user interface.
5
+
6
+ It aims to provide a rich backend debugging experience in a UI that many
7
+ web developers are already familiar with for debugging frontend Javascript.
8
+
9
+ **This project is currently in a very early experimental phase.** Expect many limitations and bugs, and use at your own risk. If you try it out, please file
10
+ Github issues or [email me](mailto:gklitt@gmail.com) to help make this a
11
+ more useful tool.
12
+
13
+ ![screenshot](screenshots/demo.png)
14
+
15
+ ## Get started
16
+
17
+ 1) Install the gem:
18
+
19
+ `gem install --pre ladybug`
20
+
21
+ 2) ladybug is implemented as a Rack middleware, so you'll need to add
22
+ `Ladybug::Middleware` to the Rack middleware stack.
23
+ For example, in Rails 5, add
24
+ the following line to `config/application.rb`:
25
+
26
+ ```
27
+ config.middleware.insert_before(Rack::Sendfile, Ladybug::Middleware)
28
+ ```
29
+
30
+ 3) Make sure you're using the puma web server, which is currently the
31
+ only server that ladybug has been tested with.
32
+
33
+ To use puma in Rails: add `gem 'puma'` to your `Gemfile` and `bundle install`.
34
+
35
+ 4) Then start up your server and try making a request.
36
+ You should see your server program output something like:
37
+
38
+ `Debug in Chrome: chrome-devtools://devtools/bundled/inspector.html?ws=localhost:3000`
39
+
40
+ 5) Navigate to that URL, and you'll see a Chrome Devtools window.
41
+ In the Sources tab, you can view your Ruby source code.
42
+ If you set a breakpoint and then make another request to your server,
43
+ it should pause on the breakpoint and you'll be able to inspect
44
+ some variables in Devtools.
45
+
46
+ **Security warning:** This debugger should only be run in local development.
47
+ Running it on a server open to the internet could allow anyone to
48
+ execute code on your server without authenticating.
49
+
50
+ ## Development status
51
+
52
+ * basic pause/continue breakpoint control is supported, but "step over" and "step into" aren't fully supported yet.
53
+ * inspecting primtive objects like strings and numbers works okay; support for more complex objects is in development.
54
+ * So far, ladybug has only been tested with simple Rails applications running on
55
+ Rails 5 with the puma web server. Eventually it aims to support more Rack
56
+ applications and web servers (and perhaps even non-Rack applications).
57
+
58
+ ## Author
59
+
60
+ [@geoffreylitt](https://twitter.com/geoffreylitt)
61
+
62
+
@@ -1,9 +1,20 @@
1
1
  # A simple debugger using set_trace_func
2
2
  # Allows for external control while in a breakpoint
3
3
 
4
+ require 'parser/current'
5
+ Parser::Builders::Default.emit_lambda = true
6
+ Parser::Builders::Default.emit_procarg0 = true
7
+
8
+ require 'memoist'
9
+
4
10
  module Ladybug
5
11
  class Debugger
6
- def initialize
12
+ extend Memoist
13
+
14
+ # preload_paths (optional):
15
+ # paths to pre-parse into Ruby code so that
16
+ # later we can quickly respond to requests for breakpoint locations
17
+ def initialize(preload_paths: [])
7
18
  @breakpoints = []
8
19
 
9
20
  @to_main_thread = Queue.new
@@ -11,6 +22,15 @@ module Ladybug
11
22
 
12
23
  @on_pause = -> {}
13
24
  @on_resume = -> {}
25
+
26
+ @parsed_files = {}
27
+
28
+ # Todo: consider thread safety of mutating this hash
29
+ Thread.new do
30
+ preload_paths.each do |preload_path|
31
+ parse(preload_path)
32
+ end
33
+ end
14
34
  end
15
35
 
16
36
  def start
@@ -37,6 +57,14 @@ module Ladybug
37
57
  @to_main_thread.push({ command: 'step_over' })
38
58
  end
39
59
 
60
+ def step_into
61
+ @to_main_thread.push({ command: 'step_into' })
62
+ end
63
+
64
+ def step_out
65
+ @to_main_thread.push({ command: 'step_out' })
66
+ end
67
+
40
68
  def evaluate(expression)
41
69
  @to_main_thread.push({
42
70
  command: 'eval',
@@ -49,17 +77,34 @@ module Ladybug
49
77
  @from_main_thread.pop
50
78
  end
51
79
 
52
- # returns a breakpoint ID
80
+ # Sets a breakpoint and returns a breakpoint object.
53
81
  def set_breakpoint(filename:, line_number:)
82
+ possible_lines = line_numbers_with_code(filename)
83
+
84
+ # It's acceptable for a caller to request setting a breakpoint
85
+ # on a location where it's not possible to set a breakpoint.
86
+ # In this case, we set a breakpoint on the next possible location.
87
+ if !possible_lines.include?(line_number)
88
+ line_number = possible_lines.sort.find { |ln| ln > line_number }
89
+ end
90
+
91
+ # Sometimes the above check will fail and there's no possible breakpoint.
92
+ if line_number.nil?
93
+ raise InvalidBreakpointLocationError,
94
+ "invalid breakpoint line: #{filename}:#{line_number}"
95
+ end
96
+
54
97
  breakpoint = {
55
- filename: filename,
98
+ # We need to use the absolute path here
99
+ # because set_trace_func ends up checking against that
100
+ filename: File.absolute_path(filename),
56
101
  line_number: line_number,
57
- id: "filename:#{line_number}"
102
+ id: "#{filename}:#{line_number}"
58
103
  }
59
104
 
60
105
  @breakpoints.push(breakpoint)
61
106
 
62
- breakpoint[:id]
107
+ breakpoint
63
108
  end
64
109
 
65
110
  def remove_breakpoint(breakpoint_id)
@@ -69,11 +114,53 @@ module Ladybug
69
114
  @breakpoints.delete_if { |bp| bp[:id] == breakpoint_id }
70
115
  end
71
116
 
117
+ # Given a filename line number range of a requested breakpoint,
118
+ # give the line numbers of possible breakpoints.
119
+ #
120
+ # In practice, start and end number tend to be the same when
121
+ # Chrome devtools is the client.
122
+ #
123
+ # A breakpoint can be set at the beginning of any Ruby statement.
124
+ # (more details in #line_numbers_with_code)
125
+ def get_possible_breakpoints(path:, start_num:, end_num:)
126
+ (start_num..end_num).to_a & line_numbers_with_code(path)
127
+ end
128
+
72
129
  private
73
130
 
74
- def trace_func
75
- @step_over_file = nil
131
+ # remove ladybug code from a callstack and prepare it for comparison
132
+ # this is a hack implemenetation for now, can be made better
133
+ def clean(callstack)
134
+ callstack.drop_while { |frame| frame.to_s.include? "ladybug" }
135
+ end
136
+
137
+ # If we're in step over/in/out mode,
138
+ # detect if we should break even if there's not a breakpoint set here
139
+ def break_on_step?
140
+ # This is an important early return;
141
+ # we don't want to do anything w/ the callstack unless
142
+ # we're looking for a breakpoint, because
143
+ # that adds time to every single line of code execution
144
+ # which makes things really slow
145
+ return false if @break.nil?
146
+
147
+ bp_callstack = clean(@breakpoint_callstack)
148
+ current_callstack = clean(Thread.current.backtrace_locations)
149
+
150
+ if @break == 'step_over'
151
+ return bp_callstack[1].to_s == current_callstack[1].to_s
152
+ elsif @break == 'step_into'
153
+ return stacks_equal?(bp_callstack, current_callstack[1..-1])
154
+ elsif @break == 'step_out'
155
+ return stacks_equal?(current_callstack, bp_callstack[1..-1])
156
+ end
157
+ end
76
158
 
159
+ def stacks_equal?(stack1, stack2)
160
+ stack1.map(&:to_s) == stack2.map(&:to_s)
161
+ end
162
+
163
+ def trace_func
77
164
  proc { |event, filename, line_number, id, binding, klass, *rest|
78
165
  # This check is called a lot so perhaps worth making faster,
79
166
  # but might not matter much with small number of breakpoints in practice
@@ -81,9 +168,7 @@ module Ladybug
81
168
  bp[:filename] == filename && bp[:line_number] == line_number
82
169
  end
83
170
 
84
- break_on_step_over = (@step_over_file == filename)
85
-
86
- if breakpoint_hit || break_on_step_over
171
+ if breakpoint_hit || break_on_step?
87
172
  local_variables =
88
173
  binding.local_variables.each_with_object({}) do |lvar, hash|
89
174
  hash[lvar] = binding.local_variable_get(lvar)
@@ -129,12 +214,19 @@ module Ladybug
129
214
 
130
215
  case message[:command]
131
216
  when 'continue'
132
- @step_over_file = nil
217
+ @break = nil
133
218
  break
134
219
  when 'step_over'
135
- # todo: does "step over" really mean break in the same file,
136
- # or is there are a deeper meaning
137
- @step_over_file = filename
220
+ @break = 'step_over'
221
+ @breakpoint_callstack = Thread.current.backtrace_locations
222
+ break
223
+ when 'step_into'
224
+ @break = 'step_into'
225
+ @breakpoint_callstack = Thread.current.backtrace_locations
226
+ break
227
+ when 'step_out'
228
+ @break = 'step_out'
229
+ @breakpoint_callstack = Thread.current.backtrace_locations
138
230
  break
139
231
  when 'eval'
140
232
  evaluated =
@@ -152,5 +244,58 @@ module Ladybug
152
244
  end
153
245
  }
154
246
  end
247
+
248
+ # get valid breakpoint lines for a file, with a memoize cache
249
+ # todo: we don't really need to cache this; parsing is the slow part
250
+ def line_numbers_with_code(path)
251
+ ast = parse(path)
252
+ single_statement_lines(ast)
253
+ end
254
+
255
+ def parse(path)
256
+ if !@parsed_files.key?(path)
257
+ code = File.read(path)
258
+ @parsed_files[path] = Parser::CurrentRuby.parse(code)
259
+ end
260
+
261
+ @parsed_files[path]
262
+ end
263
+
264
+ # A breakpoint can be set at the beginning of any node where there is no
265
+ # begin (i.e. multi-line) node anywhere under the node
266
+ def single_statement_lines(ast)
267
+ child_types = deep_child_node_types(ast)
268
+
269
+ if !child_types.include?(:begin) && !child_types.include?(:kwbegin)
270
+ expr = ast.loc.expression
271
+
272
+ if !expr.nil?
273
+ expr.begin.line
274
+ else
275
+ nil
276
+ end
277
+ else
278
+ ast.children.
279
+ select { |child| child.is_a? AST::Node }.
280
+ flat_map { |child| single_statement_lines(child) }.
281
+ compact.
282
+ uniq
283
+ end
284
+ end
285
+
286
+ # Return all unique types of AST nodes under this node,
287
+ # including the node itself.
288
+ #
289
+ # We memoize this because we repeatedly hit this for each AST
290
+ def deep_child_node_types(ast)
291
+ types = ast.children.flat_map do |child|
292
+ deep_child_node_types(child) if child.is_a? AST::Node
293
+ end.compact + [ast.type]
294
+
295
+ types.uniq
296
+ end
297
+ memoize :deep_child_node_types
298
+
299
+ class InvalidBreakpointLocationError < StandardError; end
155
300
  end
156
301
  end
@@ -16,7 +16,8 @@ module Ladybug
16
16
  @scripts = {}
17
17
 
18
18
  @script_repository = ScriptRepository.new
19
- @debugger = Debugger.new
19
+ @debugger =
20
+ Debugger.new(preload_paths: @script_repository.all.map(&:path))
20
21
  @object_manager = ObjectManager.new
21
22
  end
22
23
 
@@ -79,8 +80,27 @@ module Ladybug
79
80
  scriptSource: File.new(path, "r").read
80
81
  }
81
82
  elsif data["method"] == "Debugger.getPossibleBreakpoints"
82
- # Just echo back the location the browser requested
83
- result = { locations: [ data["params"]["start"] ] }
83
+ script = @script_repository.find(id: data["params"]["start"]["scriptId"])
84
+
85
+ # we convert to/from 0-indexed line numbers in Chrome
86
+ # at the earliest/latest possible moment;
87
+ # in this gem, lines are 1-indexed
88
+ start_num = data["params"]["start"]["lineNumber"] + 1
89
+ end_num = data["params"]["end"]["lineNumber"] + 1
90
+
91
+ breakpoint_lines = @debugger.get_possible_breakpoints(
92
+ path: script.path, start_num: start_num, end_num: end_num
93
+ )
94
+
95
+ locations = breakpoint_lines.map do |breakpoint_line|
96
+ {
97
+ scriptId: script.id,
98
+ lineNumber: breakpoint_line - 1,
99
+ columnNumber: 0
100
+ }
101
+ end
102
+
103
+ result = { locations: locations }
84
104
  elsif data["method"] == "Debugger.setBreakpointByUrl"
85
105
  # Chrome gives us a virtual URL;
86
106
  # we need an absolute path to the file to match the API for set_trace_func
@@ -91,23 +111,27 @@ module Ladybug
91
111
  line_number = data["params"]["lineNumber"]
92
112
  ruby_line_number = line_number + 1
93
113
 
94
- puts "setting breakpoint on #{script.path}:#{ruby_line_number}"
95
-
96
- breakpoint_id = @debugger.set_breakpoint(
97
- filename: script.absolute_path,
98
- line_number: ruby_line_number
99
- )
114
+ begin
115
+ breakpoint = @debugger.set_breakpoint(
116
+ filename: script.path,
117
+ line_number: ruby_line_number
118
+ )
100
119
 
101
- result = {
102
- breakpointId: breakpoint_id,
103
- locations: [
104
- {
105
- scriptId: script.id,
106
- lineNumber: line_number,
107
- columnNumber: data["params"]["columnNumber"],
108
- }
109
- ]
110
- }
120
+ result = {
121
+ breakpointId: breakpoint[:id],
122
+ locations: [
123
+ {
124
+ scriptId: script.id,
125
+ # todo: need to get these +/- transformations centralized.
126
+ # a LineNumber class might be necessary...
127
+ lineNumber: breakpoint[:line_number] - 1,
128
+ columnNumber: data["params"]["columnNumber"],
129
+ }
130
+ ]
131
+ }
132
+ rescue Debugger::InvalidBreakpointLocationError
133
+ result = {}
134
+ end
111
135
  elsif data["method"] == "Debugger.resume"
112
136
  # Synchronously just ack the command;
113
137
  # we'll async hear back from the main thread when execution resumes
@@ -118,6 +142,16 @@ module Ladybug
118
142
  # we'll async hear back from the main thread when execution resumes
119
143
  @debugger.step_over
120
144
  result = {}
145
+ elsif data["method"] == "Debugger.stepInto"
146
+ # Synchronously just ack the command;
147
+ # we'll async hear back from the main thread when execution resumes
148
+ @debugger.step_into
149
+ result = {}
150
+ elsif data["method"] == "Debugger.stepOut"
151
+ # Synchronously just ack the command;
152
+ # we'll async hear back from the main thread when execution resumes
153
+ @debugger.step_out
154
+ result = {}
121
155
  elsif data["method"] == "Debugger.evaluateOnCallFrame"
122
156
  evaluated = @debugger.evaluate(data["params"]["expression"])
123
157
  result = {
metadata CHANGED
@@ -1,29 +1,57 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ladybug
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1.alpha
4
+ version: '0.1'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geoffrey Litt
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-01-21 00:00:00.000000000 Z
11
+ date: 2018-01-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faye-websocket
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
19
+ version: 0.10.7
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
26
+ version: 0.10.7
27
+ - !ruby/object:Gem::Dependency
28
+ name: parser
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.4.0.2
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.4.0.2
41
+ - !ruby/object:Gem::Dependency
42
+ name: memoist
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.14.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.14.0
27
55
  description: Debug Ruby code using Chrome Devtools
28
56
  email: gklitt@gmail.com
29
57
  executables: []
@@ -51,12 +79,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
51
79
  version: '0'
52
80
  required_rubygems_version: !ruby/object:Gem::Requirement
53
81
  requirements:
54
- - - ">"
82
+ - - ">="
55
83
  - !ruby/object:Gem::Version
56
- version: 1.3.1
84
+ version: '0'
57
85
  requirements: []
58
86
  rubyforge_project:
59
- rubygems_version: 2.6.10
87
+ rubygems_version: 2.6.7
60
88
  signing_key:
61
89
  specification_version: 4
62
90
  summary: Ladybug