bullematic 0.1.0 → 0.2.0

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -1
  3. data/README.md +23 -11
  4. data/exe/bullematic +7 -0
  5. data/lib/bullematic/ast/finder.rb +190 -35
  6. data/lib/bullematic/ast/parser.rb +1 -17
  7. data/lib/bullematic/ast/rewriter.rb +90 -28
  8. data/lib/bullematic/cli.rb +185 -0
  9. data/lib/bullematic/configuration.rb +15 -4
  10. data/lib/bullematic/detection.rb +110 -13
  11. data/lib/bullematic/evidence_store.rb +78 -0
  12. data/lib/bullematic/fixer.rb +258 -31
  13. data/lib/bullematic/integrations/minitest.rb +20 -6
  14. data/lib/bullematic/integrations/rails.rb +23 -9
  15. data/lib/bullematic/integrations/rspec.rb +19 -7
  16. data/lib/bullematic/logger.rb +24 -12
  17. data/lib/bullematic/notifier.rb +131 -36
  18. data/lib/bullematic/version.rb +1 -1
  19. data/lib/bullematic.rb +12 -4
  20. data/sig/generated/bullematic/ast/finder.rbs +70 -11
  21. data/sig/generated/bullematic/ast/parser.rbs +0 -6
  22. data/sig/generated/bullematic/ast/rewriter.rbs +28 -11
  23. data/sig/generated/bullematic/cli.rbs +56 -0
  24. data/sig/generated/bullematic/configuration.rbs +16 -20
  25. data/sig/generated/bullematic/detection.rbs +77 -7
  26. data/sig/generated/bullematic/evidence_store.rbs +35 -0
  27. data/sig/generated/bullematic/fixer.rbs +59 -3
  28. data/sig/generated/bullematic/integrations/rails.rbs +7 -0
  29. data/sig/generated/bullematic/logger.rbs +5 -0
  30. data/sig/generated/bullematic/notifier.rbs +17 -13
  31. data/sig/generated/bullematic.rbs +5 -0
  32. data/sig/stubs/rails.rbs +60 -0
  33. metadata +161 -8
  34. data/Rakefile +0 -26
  35. data/Steepfile +0 -15
  36. data/assets/logo-header.svg +0 -28
  37. data/rbs_collection.lock.yaml +0 -24
  38. data/rbs_collection.yaml +0 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e3046689e0ebe235c6c512c31f7ad7c6252dadb773a576f81bbad8d2e6010e7
4
- data.tar.gz: 10dff920eed6301bf55440c13530ad424cf64f29fa25344da634d7bd4ffe58df
3
+ metadata.gz: 491dc6d6f03d8043bbd7799463e7626a821cf22984bd90afcd15526184912214
4
+ data.tar.gz: d968aec38e36c5a90b548dfd654a2917e6faf1ce54d9cf34c3736d1a759a4952
5
5
  SHA512:
6
- metadata.gz: 8bc920365f940b1fdd4f5953d7b3c62a5bd2ca5094c99b22ec77122b69d44b8051985b003a0d9aa0b2963d1ba2ce13d88b324187c1aaf04d5170d9effe81b324
7
- data.tar.gz: 04ba28c1ea4ab1f335b5711e93b33cb6826b62240dc4b19039a654cb21099f0e449fb5ddf08c4443a40c364659c5accb33df47d11663748092f5d1651c43a496
6
+ metadata.gz: f49686338aab777ebd78762a8fb1f35729e2ed1d0ee229ed8dc341ffb4a0207ac169ff988783b0c7839ace99c615295fec816f78185f89502dc4981f0b60553b
7
+ data.tar.gz: f9c57f50dbceb5e8fbd26198c0c3549cd26f0c9e15e6beafb59992f788afd68ac138f49f84d20d3b7adcacbc4eed9a8c04facabf55ab0e2bf17fc9246cdf7104
data/CHANGELOG.md CHANGED
@@ -5,7 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.2.0] - 2026-08-29
9
+
10
+ ### Added
11
+
12
+ - Add a `bullematic` executable with explicit `record`, `plan`, `apply`, `verify`, `fix`, and `doctor` commands. Recorded evidence and commands persist across processes, and verification reruns the recorded command to confirm that N+1 warnings are gone.
13
+ - Support high-confidence fixes for direct Active Record queries assigned to local variables, namespaced models, and structurally verified nested associations.
14
+
15
+ ### Changed
16
+
17
+ - Disable source writes by default: automatic fixing is off, dry-run planning is on, and backups are enabled. Ambiguous, dynamic, or unverifiable query origins and associations are skipped instead of guessed.
18
+ - Record detections without retaining or applying them during normal Rails requests; source changes now require an explicit command or configuration.
19
+ - Support Bullet 6.x through 8.x and report installed dependency compatibility through `bullematic doctor`.
20
+
21
+ ### Fixed
22
+
23
+ - Preserve and deduplicate detections across complete RSpec and Minitest runs, and capture Rails notifications before Bullet clears them.
24
+ - Preserve existing eager-loading arguments, multibyte and legacy source encodings, and line endings while rejecting generated Ruby with invalid syntax.
25
+ - Reject stale, missing, read-only, symlinked, or newly excluded sources and unsafe backup paths, and replace files atomically on Unix and Windows without changing their permissions.
9
26
 
10
27
  ## [0.1.0] - 2026-01-13
11
28
 
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  <p align="center">
7
7
  <a href="https://badge.fury.io/rb/bullematic"><img src="https://badge.fury.io/rb/bullematic.svg" alt="Gem Version"></a>
8
- <a href="https://github.com/ydah/bullematic/actions/workflows/main.yml"><img src="https://github.com/ydah/bullematic/actions/workflows/main.yml/badge.svg" alt="CI"></a>
8
+ <a href="https://github.com/ydah/bullematic/actions/workflows/test.yml"><img src="https://github.com/ydah/bullematic/actions/workflows/test.yml/badge.svg" alt="CI"></a>
9
9
  </p>
10
10
 
11
11
  <p align="center">
@@ -23,7 +23,7 @@ Bullematic hooks into [Bullet](https://github.com/flyerhzm/bullet) notifications
23
23
 
24
24
  ## Features
25
25
 
26
- - Automatic N+1 fixes for Rails scopes and queries
26
+ - High-confidence N+1 fixes for direct Active Record queries
27
27
  - Runtime capture via Bullet notifications
28
28
  - AST-based rewrites powered by Prism
29
29
  - Dry-run mode and optional backups
@@ -46,7 +46,7 @@ bundle install
46
46
  ## Requirements
47
47
 
48
48
  - Ruby 3.1+
49
- - Bullet 6.0+
49
+ - Bullet 6.0 through 8.x
50
50
 
51
51
  ## Quick Start
52
52
 
@@ -61,18 +61,29 @@ Bullematic.configure do |config|
61
61
  config.auto_fix = true
62
62
  config.target_paths = %w[app/controllers app/models app/services]
63
63
  config.skip_paths = %w[app/controllers/admin]
64
- config.dry_run = false # Set to true to preview changes without applying
64
+ config.dry_run = true # Preview changes before explicitly enabling writes
65
65
  config.fix_strategy = :includes # :includes, :preload, or :eager_load
66
66
  config.backup = true # Create backup files before modifying
67
67
  end
68
68
  ```
69
69
 
70
+ After reviewing the planned changes, set `dry_run = false` to apply them. Ambiguous or unverifiable detections are skipped.
71
+
70
72
  2. Enable Bullematic at runtime:
71
73
 
72
74
  ```bash
73
75
  BULLEMATIC=1 bundle exec rspec
74
76
  ```
75
77
 
78
+ Or use the explicit record/apply workflow:
79
+
80
+ ```bash
81
+ bundle exec bullematic record -- bundle exec rspec spec/requests/posts_spec.rb
82
+ bundle exec bullematic plan
83
+ bundle exec bullematic apply
84
+ bundle exec bullematic verify
85
+ ```
86
+
76
87
  ## Integrations
77
88
 
78
89
  ### RSpec
@@ -104,17 +115,18 @@ end
104
115
  ### Rails
105
116
 
106
117
  Bullematic auto-loads via Railtie in development and test environments when the gem is loaded.
118
+ The development middleware records detections before Bullet clears its request collector when run through `bullematic record`; it never retains or rewrites detections inside a normal web request.
107
119
 
108
120
  ## Configuration
109
121
 
110
122
  | Option | Type | Default | Description |
111
123
  |--------|------|---------|-------------|
112
124
  | `enabled` | Boolean | `true` | Enable or disable Bullematic |
113
- | `auto_fix` | Boolean | `true` | Automatically apply fixes |
125
+ | `auto_fix` | Boolean | `false` | Automatically apply fixes |
114
126
  | `target_paths` | Array | `['app/controllers', 'app/models', 'app/services']` | Paths to consider for fixes |
115
127
  | `skip_paths` | Array | `[]` | Paths to skip |
116
- | `dry_run` | Boolean | `false` | Preview changes without applying |
117
- | `backup` | Boolean | `false` | Create `.bullematic.bak` backup files |
128
+ | `dry_run` | Boolean | `true` | Preview changes without applying |
129
+ | `backup` | Boolean | `true` | Create `.bullematic.bak` backup files |
118
130
  | `fix_strategy` | Symbol | `:includes` | Strategy: `:includes`, `:preload`, or `:eager_load` |
119
131
  | `logger` | Logger | Auto-configured | Custom logger instance |
120
132
  | `debug` | Boolean | `false` | Enable debug mode (raises errors) |
@@ -124,9 +136,9 @@ Bullematic auto-loads via Railtie in development and test environments when the
124
136
  1. Bullet detects an N+1 query during request or test execution
125
137
  2. Bullematic captures the notification and stack trace
126
138
  3. At the end of the run, Bullematic parses the file with Prism
127
- 4. The AST finder locates the query that triggered the N+1
139
+ 4. The AST finder requires an exact query location or traces the accessed relation variable to one query
128
140
  5. The AST rewriter inserts the appropriate `includes` call
129
- 6. The file is written back (or logged in dry-run mode)
141
+ 6. The file is parsed again and atomically replaced (or logged in dry-run mode)
130
142
 
131
143
  ### Example Transformation
132
144
 
@@ -152,8 +164,8 @@ end
152
164
 
153
165
  ## Limitations
154
166
 
155
- - Dynamic queries built with `send` or metaprogramming may not be fixable
156
- - Complex conditional branches can be hard to rewrite
167
+ - Dynamic scopes, `send`, metaprogramming, memoized relations, and cross-method/file relation builders are skipped
168
+ - Ambiguous query origins and associations that reflection cannot verify are skipped
157
169
  - Already-optimized queries are skipped
158
170
 
159
171
  ## Development
data/exe/bullematic ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bullematic"
5
+ require "bullematic/cli"
6
+
7
+ exit Bullematic::CLI.run(ARGV)
@@ -9,9 +9,9 @@ module Bullematic
9
9
  QUERY_METHODS = %i[all where find find_by first last order limit offset].freeze #: Array[Symbol]
10
10
 
11
11
  # @rbs!
12
- # type query_location = { node: untyped, location: untyped, receiver: untyped, method_name: Symbol }
12
+ # type query_location = { node: untyped, location: untyped, receiver: untyped, method_name: Symbol, target_name: Symbol? }
13
13
 
14
- QueryLocation = Struct.new(:node, :location, :receiver, :method_name, keyword_init: true)
14
+ QueryLocation = Struct.new(:node, :location, :receiver, :method_name, :target_name, keyword_init: true)
15
15
 
16
16
  # @rbs @parse_result: untyped
17
17
 
@@ -34,12 +34,32 @@ module Bullematic
34
34
  queries
35
35
  end
36
36
 
37
+ # @rbs model_class_name: String
37
38
  # @rbs line_number: Integer
38
- # @rbs return: QueryLocation?
39
- def find_query_at_line(line_number)
40
- queries = [] #: Array[QueryLocation]
41
- visit_all_queries(parse_result.value, queries, line_number)
42
- queries.first
39
+ # @rbs associations: Array[Symbol]
40
+ # @rbs return: Array[QueryLocation]
41
+ def find_model_queries_for_variables_at_line(model_class_name, line_number, associations)
42
+ names = association_receiver_names_at_line(parse_result.value, line_number, associations)
43
+ add_block_receiver_names(parse_result.value, line_number, names)
44
+ scope = method_scope_at_line(parse_result.value, line_number)
45
+ find_model_queries(model_class_name).select do |query|
46
+ query.target_name &&
47
+ names.include?(query.target_name) &&
48
+ !block_parameter_at_line?(parse_result.value, query.target_name, line_number) &&
49
+ query.location.end_line <= line_number &&
50
+ method_scope_at_line(parse_result.value, query.location.start_line).equal?(scope) &&
51
+ !variable_written_between?(scope || parse_result.value, query.target_name, query.location.end_line, line_number, scope)
52
+ end
53
+ end
54
+
55
+ # @rbs parent_association: Symbol
56
+ # @rbs child_associations: Array[Symbol]
57
+ # @rbs line_number: Integer?
58
+ # @rbs return: bool
59
+ def nested_association?(parent_association, child_associations, line_number)
60
+ return false unless line_number
61
+
62
+ nested_association_in_node?(parse_result.value, parent_association, child_associations, line_number)
43
63
  end
44
64
 
45
65
  private
@@ -55,7 +75,7 @@ module Bullematic
55
75
  return if skip_nodes.include?(node.object_id)
56
76
 
57
77
  case node
58
- when Prism::InstanceVariableWriteNode
78
+ when Prism::InstanceVariableWriteNode, Prism::LocalVariableWriteNode
59
79
  if check_assignment_node(node, queries, model_class_name, target_line)
60
80
  mark_descendant_calls(node.value, skip_nodes)
61
81
  end
@@ -81,30 +101,6 @@ module Bullematic
81
101
  end
82
102
  end
83
103
 
84
- # @rbs node: untyped
85
- # @rbs queries: Array[QueryLocation]
86
- # @rbs target_line: Integer?
87
- # @rbs return: void
88
- def visit_all_queries(node, queries, target_line)
89
- return unless node.respond_to?(:child_nodes)
90
-
91
- if node.is_a?(Prism::CallNode) && query_method?(node.name)
92
- node_line = node.location.start_line
93
- if target_line.nil? || node_line == target_line
94
- queries << QueryLocation.new(
95
- node: node,
96
- location: node.location,
97
- receiver: find_root_receiver(node),
98
- method_name: node.name
99
- )
100
- end
101
- end
102
-
103
- node.child_nodes.compact.each do |child|
104
- visit_all_queries(child, queries, target_line)
105
- end
106
- end
107
-
108
104
  # @rbs node: untyped
109
105
  # @rbs queries: Array[QueryLocation]
110
106
  # @rbs model_class_name: String
@@ -135,6 +131,7 @@ module Bullematic
135
131
 
136
132
  value = node.value
137
133
  return false unless value.is_a?(Prism::CallNode)
134
+ return false unless query_method?(value.name)
138
135
 
139
136
  root_receiver = find_root_receiver(value)
140
137
  return false unless model_constant?(root_receiver, model_class_name)
@@ -143,11 +140,101 @@ module Bullematic
143
140
  node: value,
144
141
  location: value.location,
145
142
  receiver: root_receiver,
146
- method_name: value.name
143
+ method_name: value.name,
144
+ target_name: node.name
147
145
  )
148
146
  true
149
147
  end
150
148
 
149
+ # @rbs node: untyped
150
+ # @rbs line_number: Integer
151
+ # @rbs associations: Array[Symbol]
152
+ # @rbs return: Array[Symbol]
153
+ def association_receiver_names_at_line(node, line_number, associations)
154
+ return [] unless node.respond_to?(:child_nodes)
155
+
156
+ names = [] #: Array[Symbol]
157
+ if node.is_a?(Prism::CallNode) && associations.include?(node.name) && node.location.start_line == line_number
158
+ receiver = find_root_receiver(node)
159
+ if receiver.is_a?(Prism::InstanceVariableReadNode) || receiver.is_a?(Prism::LocalVariableReadNode)
160
+ names << receiver.name
161
+ end
162
+ end
163
+ node.child_nodes.compact.each do |child|
164
+ names.concat(association_receiver_names_at_line(child, line_number, associations))
165
+ end
166
+ names.uniq
167
+ end
168
+
169
+ # @rbs node: untyped
170
+ # @rbs line_number: Integer
171
+ # @rbs names: Array[Symbol]
172
+ # @rbs return: void
173
+ def add_block_receiver_names(node, line_number, names)
174
+ return unless node.respond_to?(:child_nodes)
175
+
176
+ if node.is_a?(Prism::CallNode) && node.block &&
177
+ line_number.between?(node.block.location.start_line, node.block.location.end_line)
178
+ parameters = node.block.parameters&.parameters&.requireds || []
179
+ receiver = node.receiver
180
+ if parameters.any? { |parameter| names.include?(parameter.name) } &&
181
+ (receiver.is_a?(Prism::InstanceVariableReadNode) || receiver.is_a?(Prism::LocalVariableReadNode))
182
+ names << receiver.name
183
+ end
184
+ end
185
+
186
+ node.child_nodes.compact.each { |child| add_block_receiver_names(child, line_number, names) }
187
+ names.uniq!
188
+ end
189
+
190
+ # @rbs node: untyped
191
+ # @rbs name: Symbol
192
+ # @rbs line_number: Integer
193
+ # @rbs return: bool
194
+ def block_parameter_at_line?(node, name, line_number)
195
+ return false unless node.respond_to?(:child_nodes)
196
+
197
+ if node.is_a?(Prism::CallNode) && node.block &&
198
+ line_number.between?(node.block.location.start_line, node.block.location.end_line)
199
+ parameters = node.block.parameters&.parameters&.requireds || []
200
+ return true if parameters.any? { |parameter| parameter.name == name }
201
+ end
202
+
203
+ node.child_nodes.compact.any? { |child| block_parameter_at_line?(child, name, line_number) }
204
+ end
205
+
206
+ # @rbs node: untyped
207
+ # @rbs line_number: Integer
208
+ # @rbs return: untyped
209
+ def method_scope_at_line(node, line_number)
210
+ return unless node.respond_to?(:child_nodes)
211
+ return unless line_number.between?(node.location.start_line, node.location.end_line)
212
+
213
+ node.child_nodes.compact.each do |child|
214
+ scope = method_scope_at_line(child, line_number)
215
+ return scope if scope
216
+ end
217
+ node if node.is_a?(Prism::DefNode)
218
+ end
219
+
220
+ # @rbs node: untyped
221
+ # @rbs name: Symbol
222
+ # @rbs start_line: Integer
223
+ # @rbs end_line: Integer
224
+ # @rbs scope: untyped
225
+ # @rbs return: bool
226
+ def variable_written_between?(node, name, start_line, end_line, scope)
227
+ return false unless node.respond_to?(:child_nodes)
228
+ return false if node.is_a?(Prism::DefNode) && !node.equal?(scope)
229
+
230
+ written = node.respond_to?(:name) && node.name == name &&
231
+ node.class.name.match?(/\APrism::(?:Instance|Local)Variable(?:And|Operator|Or)?WriteNode\z/) &&
232
+ node.location.start_line > start_line && node.location.start_line <= end_line
233
+ written || node.child_nodes.compact.any? do |child|
234
+ variable_written_between?(child, name, start_line, end_line, scope)
235
+ end
236
+ end
237
+
151
238
  # @rbs node: untyped
152
239
  # @rbs return: untyped
153
240
  def find_root_receiver(node)
@@ -160,9 +247,9 @@ module Bullematic
160
247
  # @rbs model_class_name: String
161
248
  # @rbs return: bool
162
249
  def model_constant?(node, model_class_name)
163
- return false unless node.is_a?(Prism::ConstantReadNode)
250
+ return false unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
164
251
 
165
- node.name.to_s == model_class_name
252
+ node.full_name.delete_prefix("::") == model_class_name.delete_prefix("::")
166
253
  end
167
254
 
168
255
  # @rbs method_name: Symbol
@@ -170,6 +257,74 @@ module Bullematic
170
257
  def query_method?(method_name)
171
258
  QUERY_METHODS.include?(method_name)
172
259
  end
260
+
261
+ # @rbs node: untyped
262
+ # @rbs parent_association: Symbol
263
+ # @rbs child_associations: Array[Symbol]
264
+ # @rbs line_number: Integer
265
+ # @rbs return: bool
266
+ def nested_association_in_node?(node, parent_association, child_associations, line_number)
267
+ return false unless node.respond_to?(:child_nodes)
268
+
269
+ if node.is_a?(Prism::CallNode) && %i[each find_each].include?(node.name) && node.block &&
270
+ receiver_calls?(node.receiver, parent_association)
271
+ parameters = node.block.parameters&.parameters&.requireds || []
272
+ names = parameters.map(&:name)
273
+ return true if !variable_written?(node.block.body, names) && child_associations.all? do |association|
274
+ call_on_variable_at_line?(node.block.body, association, names, line_number)
275
+ end
276
+ end
277
+
278
+ node.child_nodes.compact.any? do |child|
279
+ nested_association_in_node?(child, parent_association, child_associations, line_number)
280
+ end
281
+ end
282
+
283
+ # @rbs node: untyped
284
+ # @rbs method_name: Symbol
285
+ # @rbs names: Array[Symbol]
286
+ # @rbs line_number: Integer
287
+ # @rbs return: bool
288
+ def call_on_variable_at_line?(node, method_name, names, line_number)
289
+ return false unless node.respond_to?(:child_nodes)
290
+
291
+ if node.is_a?(Prism::CallNode) && node.name == method_name && node.location.start_line == line_number
292
+ receiver = find_root_receiver(node)
293
+ return true if receiver.is_a?(Prism::LocalVariableReadNode) && names.include?(receiver.name)
294
+ end
295
+
296
+ if node.is_a?(Prism::CallNode) && node.block
297
+ parameters = node.block.parameters&.parameters&.requireds || []
298
+ return false if parameters.any? { |parameter| parameter.respond_to?(:name) && names.include?(parameter.name) }
299
+ end
300
+
301
+ node.child_nodes.compact.any? do |child|
302
+ call_on_variable_at_line?(child, method_name, names, line_number)
303
+ end
304
+ end
305
+
306
+ # @rbs node: untyped
307
+ # @rbs method_name: Symbol
308
+ # @rbs return: bool
309
+ def receiver_calls?(node, method_name)
310
+ current = node
311
+ while current.is_a?(Prism::CallNode)
312
+ return true if current.name == method_name
313
+
314
+ current = current.receiver
315
+ end
316
+ false
317
+ end
318
+
319
+ # @rbs node: untyped
320
+ # @rbs names: Array[Symbol]
321
+ # @rbs return: bool
322
+ def variable_written?(node, names)
323
+ return false unless node.respond_to?(:child_nodes)
324
+ return true if node.is_a?(Prism::LocalVariableWriteNode) && names.include?(node.name)
325
+
326
+ node.child_nodes.compact.any? { |child| variable_written?(child, names) }
327
+ end
173
328
  end
174
329
  end
175
330
  end
@@ -10,26 +10,10 @@ module Bullematic
10
10
  # @rbs @filepath: String?
11
11
 
12
12
  class << self
13
- #: () -> Hash[String, untyped]
14
- def cache
15
- @cache ||= {} #: Hash[String, untyped]
16
- end
17
-
18
- #: () -> void
19
- def clear_cache
20
- @cache = {} #: Hash[String, untyped]
21
- end
22
-
23
13
  # @rbs filepath: String
24
14
  # @rbs return: untyped
25
15
  def parse_file(filepath)
26
- return cache[filepath] if cache.key?(filepath)
27
-
28
- result = Prism.parse_file(filepath)
29
- raise ParseError, result.errors.map(&:message).join("\n") if result.failure?
30
-
31
- cache[filepath] = result
32
- result
16
+ new(File.binread(filepath), filepath: filepath).parse
33
17
  end
34
18
  end
35
19
 
@@ -25,13 +25,22 @@ module Bullematic
25
25
  end
26
26
 
27
27
  # @rbs query_location: Finder::QueryLocation
28
- # @rbs associations: Array[Symbol]
29
- # @rbs return: void
28
+ # @rbs associations: Array[untyped]
29
+ # @rbs return: Symbol
30
30
  def add_includes(query_location, associations)
31
- return if already_has_includes?(query_location, associations)
31
+ associations = associations.uniq
32
+ return :unsupported if associations.empty?
33
+
34
+ return :already_present if already_has_includes?(query_location, associations)
32
35
 
33
36
  strategy = Bullematic.configuration&.fix_strategy || :includes
34
37
  insert_point = find_insert_point(query_location)
38
+ pending = @modifications.find { |mod| mod.type == :insert && mod.offset == insert_point }
39
+ if pending
40
+ pending.associations = (pending.associations + associations).uniq
41
+ pending.new_text = ".#{strategy}(#{format_associations(pending.associations)})"
42
+ return :changed
43
+ end
35
44
 
36
45
  assoc_string = format_associations(associations)
37
46
  new_text = ".#{strategy}(#{assoc_string})"
@@ -43,24 +52,22 @@ module Bullematic
43
52
  new_text: new_text,
44
53
  associations: associations
45
54
  )
55
+ :changed
46
56
  end
47
57
 
48
58
  #: () -> String
49
59
  def rewrite
50
60
  return @source if @modifications.empty?
51
61
 
62
+ validate_modifications!
52
63
  sorted = @modifications.sort_by { |m| -m.offset }
53
64
 
54
65
  result = @source.dup
55
66
  sorted.each do |mod|
56
- case mod.type
57
- when :insert
58
- result.insert(mod.offset, mod.new_text)
59
- when :replace
60
- result[mod.offset, mod.byte_length] = mod.new_text
61
- when :delete
62
- result[mod.offset, mod.byte_length] = ""
63
- end
67
+ before = result.byteslice(0, mod.offset)
68
+ after = result.byteslice(mod.offset + mod.byte_length..)
69
+ replacement = mod.type == :delete ? "" : mod.new_text
70
+ result = before + replacement + after
64
71
  end
65
72
 
66
73
  result
@@ -74,7 +81,7 @@ module Bullematic
74
81
  node = query_location.node
75
82
  receiver = query_location.receiver
76
83
 
77
- if receiver.is_a?(Prism::ConstantReadNode)
84
+ if receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
78
85
  receiver.location.end_offset
79
86
  else
80
87
  find_chain_insert_point(node)
@@ -95,7 +102,7 @@ module Bullematic
95
102
  end
96
103
 
97
104
  # @rbs query_location: Finder::QueryLocation
98
- # @rbs associations: Array[Symbol]
105
+ # @rbs associations: Array[untyped]
99
106
  # @rbs return: bool
100
107
  def already_has_includes?(query_location, associations)
101
108
  existing = find_existing_includes(query_location.node)
@@ -121,31 +128,86 @@ module Bullematic
121
128
  end
122
129
 
123
130
  # @rbs call_node: untyped
124
- # @rbs return: Array[Symbol]
131
+ # @rbs return: Array[untyped]
125
132
  def extract_associations_from_call(call_node)
126
133
  return [] unless call_node.arguments
127
134
 
128
- associations = [] #: Array[Symbol]
129
- call_node.arguments.arguments.each do |arg|
130
- case arg
131
- when Prism::SymbolNode
132
- associations << arg.value.to_sym
133
- when Prism::ArrayNode
134
- arg.elements.each do |elem|
135
- associations << elem.value.to_sym if elem.is_a?(Prism::SymbolNode)
136
- end
135
+ associations = [] #: Array[untyped]
136
+ call_node.arguments.arguments.each { |arg| collect_literal_associations(arg, associations) }
137
+ associations
138
+ end
139
+
140
+ # @rbs node: untyped
141
+ # @rbs associations: Array[untyped]
142
+ # @rbs return: void
143
+ def collect_literal_associations(node, associations)
144
+ case node
145
+ when Prism::SymbolNode
146
+ associations << node.value.to_sym
147
+ when Prism::ArrayNode
148
+ node.elements.each { |element| collect_literal_associations(element, associations) }
149
+ when Prism::KeywordHashNode, Prism::HashNode
150
+ association = literal_association(node)
151
+ associations << association if association
152
+ end
153
+ end
154
+
155
+ # @rbs node: untyped
156
+ # @rbs return: untyped
157
+ def literal_association(node)
158
+ case node
159
+ when Prism::SymbolNode
160
+ node.value.to_sym
161
+ when Prism::ArrayNode
162
+ values = node.elements.map { |element| literal_association(element) }
163
+ values unless values.any?(&:nil?)
164
+ when Prism::KeywordHashNode, Prism::HashNode
165
+ pairs = node.elements.map do |element|
166
+ next unless element.is_a?(Prism::AssocNode)
167
+
168
+ key = literal_association(element.key)
169
+ value = literal_association(element.value)
170
+ [key, value] if key && value
137
171
  end
172
+ pairs.to_h if pairs.none?(&:nil?)
138
173
  end
139
- associations
140
174
  end
141
175
 
142
- # @rbs associations: Array[Symbol]
176
+ # @rbs associations: Array[untyped]
143
177
  # @rbs return: String
144
178
  def format_associations(associations)
145
- if associations.size == 1
146
- ":#{associations.first}"
179
+ associations.map { |association| format_association(association, false) }.join(", ")
180
+ end
181
+
182
+ # @rbs association: untyped
183
+ # @rbs braces: bool
184
+ # @rbs return: String
185
+ def format_association(association, braces = true)
186
+ case association
187
+ when Hash
188
+ contents = association.map do |key, value|
189
+ key_source = key.to_s.match?(/\A[a-z_]\w*\z/) ? "#{key}:" : "#{key.inspect} =>"
190
+ "#{key_source} #{format_association(value)}"
191
+ end.join(", ")
192
+ braces ? "{ #{contents} }" : contents
193
+ when Array
194
+ "[#{association.map { |nested| format_association(nested) }.join(', ')}]"
147
195
  else
148
- associations.map { |a| ":#{a}" }.join(", ")
196
+ association.inspect
197
+ end
198
+ end
199
+
200
+ #: () -> void
201
+ def validate_modifications!
202
+ @modifications.each do |modification|
203
+ limit = modification.offset + modification.byte_length
204
+ raise FixError, "edit is outside the source" if modification.offset.negative? || limit > @source.bytesize
205
+ end
206
+
207
+ ranges = @modifications.reject { |modification| modification.byte_length.zero? }
208
+ ranges.combination(2) do |left, right|
209
+ overlap = left.offset < right.offset + right.byte_length && right.offset < left.offset + left.byte_length
210
+ raise FixError, "overlapping edits" if overlap
149
211
  end
150
212
  end
151
213
  end