Class: ScoutApm::AutoInstrument::PrismImplementation::Rewriter

Inherits:
Object
  • Object
show all
Defined in:
lib/scout_apm/auto_instrument/prism.rb

Instance Method Summary collapse

Constructor Details

#initialize(path, code) ⇒ Rewriter

Returns a new instance of Rewriter.



16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/scout_apm/auto_instrument/prism.rb', line 16

def initialize(path, code)
  @path = path
  # Set encoding to ASCII-8bit as Prism uses byte offsets
  @code = code.b
  @replacements = []
  @instrumented_nodes = Set.new

  # Keeps track of the parent - child relationship between nodes:
  @nesting = []

  # The stack of method nodes:
  @method = []

  # The stack of class nodes:
  @scope = []

  @cache = Cache.new
end

Instance Method Details

#add_replacement(start_offset, end_offset, new_text) ⇒ Object



55
56
57
# File 'lib/scout_apm/auto_instrument/prism.rb', line 55

def add_replacement(start_offset, end_offset, new_text)
  @replacements << {start: start_offset, end: end_offset, new_text: new_text}
end

#apply_replacementsObject



40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/scout_apm/auto_instrument/prism.rb', line 40

def apply_replacements
  # Sort replacements by start position in reverse order
  # This ensures we apply replacements from end to start, avoiding offset issues
  # when we modify the string
  sorted_replacements = @replacements.sort_by { |r| -r[:start] }

  result = @code.dup
  sorted_replacements.each do |replacement|
    result[replacement[:start]...replacement[:end]] = replacement[:new_text].b
  end
  # ::RubyVM::InstructionSequence.compile will infer the encoding when compiling
  # and will compile with ASCII-8bit correctly.
  result
end

#has_instrumented_descendant?(node) ⇒ Boolean

Returns:

  • (Boolean)


105
106
107
108
109
# File 'lib/scout_apm/auto_instrument/prism.rb', line 105

def has_instrumented_descendant?(node)
  node.compact_child_nodes.any? do |child|
    @instrumented_nodes.include?(child) || has_instrumented_descendant?(child)
  end
end

#instrument(source, file_name, line) ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/scout_apm/auto_instrument/prism.rb', line 59

def instrument(source, file_name, line)
  # Don't log huge chunks of code... just the first line:
  if lines = source.lines and lines.count > 1
    source = lines.first.chomp + "..."
  end

  method_name = @method.last.name
  bt = ["#{file_name}:#{line}:in `#{method_name}'"]

  return [
    "::ScoutApm::AutoInstrument("+ source.dump + ",#{bt}){",
    "}"
  ]
end

#parent_type?(type, up = 1) ⇒ Boolean

Look up 1 or more nodes to check if the parent exists and matches the given type.

Parameters:

  • type (Class)

    the node class to match.

  • up (Integer) (defaults to: 1)

    how far up to look.

Returns:

  • (Boolean)


77
78
79
# File 'lib/scout_apm/auto_instrument/prism.rb', line 77

def parent_type?(type, up = 1)
  parent = @nesting[@nesting.size - up - 1] and parent.is_a?(type)
end

#process(node) ⇒ Object



283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
# File 'lib/scout_apm/auto_instrument/prism.rb', line 283

def process(node)
  return unless node

  # We are nesting inside this node:
  @nesting.push(node)

  case node
  when Prism::DefNode
    # If the node is a method, push it on the method stack as well:
    @method.push(node)
    process_children(node)
    @method.pop
  when Prism::ClassNode
    # If the node is a method, push it on the scope stack as well:
    @scope.push(node.name)
    process_children(node)
    @scope.pop
  when Prism::BlockNode
    visit_block_node(node)
  when Prism::MultiTargetNode
    visit_multi_target_node(node)
  when Prism::CallNode
    visit_call_node(node)
  when Prism::SuperNode
    visit_super_node(node)
  when Prism::ForwardingSuperNode
    visit_forwarding_super_node(node)
  when Prism::HashNode
    visit_hash_node(node)
  when Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode
    # For op assignment nodes, only process the value
    process(node.value)
  else
    process_children(node)
  end

  @nesting.pop
end

#process_children(node) ⇒ Object



322
323
324
325
326
# File 'lib/scout_apm/auto_instrument/prism.rb', line 322

def process_children(node)
  node.compact_child_nodes.each do |child|
    process(child)
  end
end

#rewrite(node) ⇒ Object



35
36
37
38
# File 'lib/scout_apm/auto_instrument/prism.rb', line 35

def rewrite(node)
  process(node)
  apply_replacements
end

#visit_block_node(node) ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/scout_apm/auto_instrument/prism.rb', line 111

def visit_block_node(node)
  # If we are not in a method, don't do any instrumentation:
  return process_children(node) if @method.empty?

  # If this block is attached to a CallNode, don't wrap it separately
  # The CallNode will wrap the entire call including the block
  return process_children(node) if parent_type?(Prism::CallNode)

  # If this block is attached to a SuperNode or ForwardingSuperNode, don't wrap it separately
  # The super node will wrap the entire call including the block
  return process_children(node) if parent_type?(Prism::SuperNode) || parent_type?(Prism::ForwardingSuperNode)

  wrap_node(node)
end

#visit_call_node(node) ⇒ Object



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/scout_apm/auto_instrument/prism.rb', line 131

def visit_call_node(node)
  # We aren't interested in top level function calls:
  return process_children(node) if @method.empty?

  if @cache.local_assignments?(node)
    return process_children(node)
  end

  # This ignores both initial block method invocation and subsequent nested invocations:
  return process_children(node) if parent_type?(Prism::BlockNode)

  wrap_node(node)

  # Process children to handle nested calls, but blocks attached to this call
  # won't be wrapped separately (handled by visit_block_node check)
  process_children(node)
end

#visit_forwarding_super_node(node) ⇒ Object



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/scout_apm/auto_instrument/prism.rb', line 167

def visit_forwarding_super_node(node)
  # We aren't interested in top level super calls:
  return process_children(node) if @method.empty?

  # This ignores super calls inside blocks:
  return process_children(node) if parent_type?(Prism::BlockNode)

  # Only wrap super calls that have a block attached
  # Bare super calls are just delegation and shouldn't be instrumented
  if node.block
    wrap_node(node)
  end

  # Process children to handle nested calls, but blocks attached to this super
  # won't be wrapped separately (handled by visit_block_node check)
  process_children(node)
end

#visit_hash_node(node) ⇒ Object

This is meant to mirror that of the parser implementation. See test/unit/auto_instrument/hash_shorthand_controller-instrumented.rb Non-nil receiver is handled in visit_call_node.



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/scout_apm/auto_instrument/prism.rb', line 188

def visit_hash_node(node)
  # If this hash is a descendant of a CallNode (at any level), don't instrument individual elements
  # The parent CallNode will wrap the entire expression
  # This allows hashes in local variable assignments to be instrumented,
  # but hashes in method calls to be wrapped as a unit
  in_call_node = @nesting.any? { |n| n.is_a?(Prism::CallNode) }

  node.elements.each do |element|
    if element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode)
      value_node = element.value

      # Only instrument hash element values if we're not in a CallNode
      # Handles shorthand syntax like `shorthand:` → line 6
      if !in_call_node && value_node.is_a?(Prism::ImplicitNode)
        key = element.key.unescaped
        inner_call = value_node.value

        line = element.location.start_line
        source = @code[element.location.start_offset...element.location.end_offset]
        file_name = @path
        method_name = @method.last.name
        bt = ["#{file_name}:#{line}:in `#{method_name}'"]

        instrument_before = "::ScoutApm::AutoInstrument(#{source.dump},#{bt}){"
        instrument_after = "}"
        new_text = "#{key}: #{instrument_before}#{key}#{instrument_after}"
        add_replacement(element.location.start_offset, element.location.end_offset, new_text)

        @instrumented_nodes.add(value_node)
        @instrumented_nodes.add(inner_call)
        next
      elsif !in_call_node && value_node.is_a?(Prism::CallNode) && value_node.receiver.nil?
        line = element.location.start_line
        key = element.key.unescaped
        value_name = value_node.name.to_s
        pair_source = @code[element.location.start_offset...element.location.end_offset]
        value_source = @code[value_node.location.start_offset...value_node.location.end_offset]
        key_source = @code[element.key.location.start_offset...element.key.location.end_offset]
        file_name = @path
        method_name = @method.last.name
        bt = ["#{file_name}:#{line}:in `#{method_name}'"]

        has_arguments = value_node.arguments && !value_node.arguments.arguments.empty?

        # Handles hash_rocket w/ same key/value name and no arguments.
        # See test for more info on backward compatibility on this one.
        # e.g. `hash_rocket: hash_rocket` → line 9
        if key == value_name && !has_arguments && key_source.start_with?(':')
          source_for_dump = pair_source
          instrument_before = "::ScoutApm::AutoInstrument(#{source_for_dump.dump},#{bt}){"
          instrument_after = "}"
          instrumented_value = "#{instrument_before}#{value_source}#{instrument_after}"
          new_text = "#{key}: #{instrumented_value}"
          add_replacement(element.location.start_offset, element.location.end_offset, new_text)

        # If key == value_name and no arguments → direct shorthand pair
        # e.g. `longhand: longhand` → line 7
        elsif key == value_name && !has_arguments && !key_source.start_with?(':')
          source_for_dump = pair_source
          instrument_before = "::ScoutApm::AutoInstrument(#{source_for_dump.dump},#{bt}){"
          instrument_after = "}"
          instrumented_value = "#{instrument_before}#{value_source}#{instrument_after}"
          add_replacement(value_node.location.start_offset, value_node.location.end_offset, instrumented_value)

        # If key != value_name → “different key/value name” case
        # e.g. `longhand_different_key: longhand` → line 8  
        # or `:hash_rocket_different_key => hash_rocket` → line 10
        elsif key != value_name && !has_arguments
          source_for_dump = value_source
          instrument_before = "::ScoutApm::AutoInstrument(#{source_for_dump.dump},#{bt}){"
          instrument_after = "}"
          instrumented_value = "#{instrument_before}#{value_source}#{instrument_after}"
          add_replacement(value_node.location.start_offset, value_node.location.end_offset, instrumented_value)

        # If value_node has arguments → method call with params
        # e.g. `nested_call(params["timestamp"])` → line 15
        elsif has_arguments
          source_for_dump = value_source
          instrument_before = "::ScoutApm::AutoInstrument(#{source_for_dump.dump},#{bt}){"
          instrument_after = "}"
          instrumented_value = "#{instrument_before}#{value_source}#{instrument_after}"
          add_replacement(value_node.location.start_offset, value_node.location.end_offset, instrumented_value)
        end

        @instrumented_nodes.add(value_node)
        next
      end
    end

    element.compact_child_nodes.each do |child|
      process(child)
    end
  end
end

#visit_multi_target_node(node) ⇒ Object



126
127
128
129
# File 'lib/scout_apm/auto_instrument/prism.rb', line 126

def visit_multi_target_node(node)
  # Ignore / don't instrument multiple assignment (LHS).
  return
end

#visit_super_node(node) ⇒ Object



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/scout_apm/auto_instrument/prism.rb', line 149

def visit_super_node(node)
  # We aren't interested in top level super calls:
  return process_children(node) if @method.empty?

  # This ignores super calls inside blocks:
  return process_children(node) if parent_type?(Prism::BlockNode)

  # Only wrap super calls that have a block attached
  # Bare super calls (with or without arguments) are just delegation and shouldn't be instrumented
  if node.block
    wrap_node(node)
  end

  # Process children to handle nested calls, but blocks attached to this super
  # won't be wrapped separately (handled by visit_block_node check)
  process_children(node)
end

#wrap_node(node) ⇒ Object



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/scout_apm/auto_instrument/prism.rb', line 81

def wrap_node(node)
  # Skip if this node or any parent has already been instrumented
  return if @instrumented_nodes.include?(node)

  # Skip if any ancestor node has been instrumented (to avoid overlapping replacements)
  @nesting.each do |ancestor|
    return if @instrumented_nodes.include?(ancestor)
  end

  # Skip if any descendant node has already been instrumented (to avoid overlapping replacements)
  # This prevents a parent node from being wrapped when a child node has already been modified
  return if has_instrumented_descendant?(node)

  start_offset = node.location.start_offset
  end_offset = node.location.end_offset
  line = node.location.start_line
  source = @code[start_offset...end_offset]

  instrument_before, instrument_after = instrument(source, @path, line)
  new_text = "#{instrument_before}#{source}#{instrument_after}"
  add_replacement(start_offset, end_offset, new_text)
  @instrumented_nodes.add(node)
end