kettle-dev 1.1.60 → 1.2.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.
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Dev
5
+ # Prism helpers for Gemfile-like merging.
6
+ module PrismGemfile
7
+ module_function
8
+
9
+ # Merge gem calls from src_content into dest_content.
10
+ # - Replaces dest `source` call with src's if present.
11
+ # - Replaces or inserts non-comment `git_source` definitions.
12
+ # - Appends missing `gem` calls (by name) from src to dest preserving dest content and newlines.
13
+ # This is a conservative, comment-preserving approach using Prism to detect call nodes.
14
+ def merge_gem_calls(src_content, dest_content)
15
+ src_res = PrismUtils.parse_with_comments(src_content)
16
+ dest_res = PrismUtils.parse_with_comments(dest_content)
17
+
18
+ src_stmts = PrismUtils.extract_statements(src_res.value.statements)
19
+ dest_stmts = PrismUtils.extract_statements(dest_res.value.statements)
20
+
21
+ # Find source nodes
22
+ src_source_node = src_stmts.find { |n| PrismUtils.call_to?(n, :source) }
23
+ dest_source_node = dest_stmts.find { |n| PrismUtils.call_to?(n, :source) }
24
+
25
+ out = dest_content.dup
26
+ dest_lines = out.lines
27
+
28
+ # Replace or insert source line
29
+ if src_source_node
30
+ src_src = src_source_node.slice
31
+ if dest_source_node
32
+ out = out.sub(dest_source_node.slice, src_src)
33
+ dest_lines = out.lines
34
+ else
35
+ # insert after any leading comment/blank block
36
+ insert_idx = 0
37
+ while insert_idx < dest_lines.length && (dest_lines[insert_idx].strip.empty? || dest_lines[insert_idx].lstrip.start_with?("#"))
38
+ insert_idx += 1
39
+ end
40
+ dest_lines.insert(insert_idx, src_src.rstrip + "\n")
41
+ out = dest_lines.join
42
+ dest_lines = out.lines
43
+ end
44
+ end
45
+
46
+ # --- Handle git_source replacement/insertion ---
47
+ src_git_nodes = src_stmts.select { |n| PrismUtils.call_to?(n, :git_source) }
48
+ if src_git_nodes.any?
49
+ # We'll operate on dest_lines for insertion; recompute dest_stmts if we changed out
50
+ dest_res = PrismUtils.parse_with_comments(out)
51
+ dest_stmts = PrismUtils.extract_statements(dest_res.value.statements)
52
+
53
+ # Iterate in reverse when inserting so that inserting at the same index
54
+ # preserves the original order from the source (we insert at a fixed index).
55
+ src_git_nodes.reverse_each do |gnode|
56
+ key = PrismUtils.statement_key(gnode) # => [:git_source, name]
57
+ name = key && key[1]
58
+ replaced = false
59
+
60
+ if name
61
+ dest_same_idx = dest_stmts.index { |d| PrismUtils.statement_key(d) && PrismUtils.statement_key(d)[0] == :git_source && PrismUtils.statement_key(d)[1] == name }
62
+ if dest_same_idx
63
+ # Replace the matching dest node slice
64
+ out = out.sub(dest_stmts[dest_same_idx].slice, gnode.slice)
65
+ replaced = true
66
+ end
67
+ end
68
+
69
+ # If not replaced, prefer to replace an existing github entry in destination
70
+ # (this mirrors previous behavior in template_helpers which favored replacing
71
+ # a github git_source when inserting others).
72
+ unless replaced
73
+ dest_github_idx = dest_stmts.index { |d| PrismUtils.statement_key(d) && PrismUtils.statement_key(d)[0] == :git_source && PrismUtils.statement_key(d)[1] == "github" }
74
+ if dest_github_idx
75
+ out = out.sub(dest_stmts[dest_github_idx].slice, gnode.slice)
76
+ replaced = true
77
+ end
78
+ end
79
+
80
+ unless replaced
81
+ # Insert below source line if present, else at top after comments
82
+ dest_lines = out.lines
83
+ insert_idx = dest_lines.index { |ln| !ln.strip.start_with?("#") && ln =~ /^\s*source\s+/ } || 0
84
+ insert_idx += 1 if insert_idx
85
+ dest_lines.insert(insert_idx, gnode.slice.rstrip + "\n")
86
+ out = dest_lines.join
87
+ end
88
+
89
+ # Recompute dest_stmts for subsequent iterations
90
+ dest_res = PrismUtils.parse_with_comments(out)
91
+ dest_stmts = PrismUtils.extract_statements(dest_res.value.statements)
92
+ end
93
+ end
94
+
95
+ # Collect gem names present in dest (top-level only)
96
+ dest_res = PrismUtils.parse_with_comments(out)
97
+ dest_stmts = PrismUtils.extract_statements(dest_res.value.statements)
98
+ dest_gem_names = dest_stmts.map { |n| PrismUtils.statement_key(n) }.compact.select { |k| k[0] == :gem }.map { |k| k[1] }.to_set
99
+
100
+ # Find gem call nodes in src and append missing ones (top-level only)
101
+ missing_nodes = src_stmts.select do |n|
102
+ k = PrismUtils.statement_key(n)
103
+ k && k.first == :gem && !dest_gem_names.include?(k[1])
104
+ end
105
+ if missing_nodes.any?
106
+ out << "\n" unless out.end_with?("\n") || out.empty?
107
+ missing_nodes.each do |n|
108
+ # Preserve inline comments for the source node when appending
109
+ inline = begin
110
+ PrismUtils.inline_comments_for_node(src_res, n)
111
+ rescue
112
+ []
113
+ end
114
+ line = n.slice.rstrip
115
+ if inline && inline.any?
116
+ inline_text = inline.map { |c| c.slice.strip }.join(" ")
117
+ # Only append the inline text if it's not already part of the slice
118
+ line = line + " " + inline_text unless line.include?(inline_text)
119
+ end
120
+ out << line + "\n"
121
+ end
122
+ end
123
+
124
+ out
125
+ rescue StandardError => e
126
+ # Use debug_log if available, otherwise Kettle::Dev.debug_error
127
+ if defined?(Kettle::Dev) && Kettle::Dev.respond_to?(:debug_error)
128
+ Kettle::Dev.debug_error(e, __method__)
129
+ else
130
+ Kernel.warn("[#{__method__}] #{e.class}: #{e.message}")
131
+ end
132
+ dest_content
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Dev
5
+ # Prism helpers for gemspec manipulation.
6
+ module PrismGemspec
7
+ module_function
8
+
9
+ # Emit a debug warning for rescued errors when kettle-dev debugging is enabled.
10
+ # Controlled by KETTLE_DEV_DEBUG=true (or DEBUG=true as fallback).
11
+ # @param error [Exception]
12
+ # @param context [String, Symbol, nil] optional label, often __method__
13
+ # @return [void]
14
+ def debug_error(error, context = nil)
15
+ Kettle::Dev.debug_error(error, context)
16
+ end
17
+
18
+ # Replace scalar or array assignments inside a Gem::Specification.new block.
19
+ # `replacements` is a hash mapping symbol field names to string or array values.
20
+ # Operates only inside the Gem::Specification block to avoid accidental matches.
21
+ def replace_gemspec_fields(content, replacements = {})
22
+ return content if replacements.nil? || replacements.empty?
23
+
24
+ result = PrismUtils.parse_with_comments(content)
25
+ stmts = PrismUtils.extract_statements(result.value.statements)
26
+
27
+ gemspec_call = stmts.find do |s|
28
+ s.is_a?(Prism::CallNode) && s.block && PrismUtils.extract_const_name(s.receiver) == "Gem::Specification" && s.name == :new
29
+ end
30
+ return content unless gemspec_call
31
+
32
+ call_src = gemspec_call.slice
33
+
34
+ # Try to detect block parameter name (e.g., |spec|)
35
+ blk_param = nil
36
+ begin
37
+ if gemspec_call.block && gemspec_call.block.params
38
+ # Attempt a few defensive ways to extract a param name
39
+ if gemspec_call.block.params.respond_to?(:parameters) && gemspec_call.block.params.parameters.respond_to?(:first)
40
+ p = gemspec_call.block.params.parameters.first
41
+ blk_param = p.name.to_s if p.respond_to?(:name)
42
+ elsif gemspec_call.block.params.respond_to?(:first)
43
+ p = gemspec_call.block.params.first
44
+ blk_param = p.name.to_s if p && p.respond_to?(:name)
45
+ end
46
+ end
47
+ rescue StandardError
48
+ blk_param = nil
49
+ end
50
+
51
+ # Fallback to crude parse of the call_src header
52
+ unless blk_param && !blk_param.to_s.empty?
53
+ hdr_m = call_src.match(/do\b[^\n]*\|([^|]+)\|/m)
54
+ blk_param = (hdr_m && hdr_m[1]) ? hdr_m[1].strip.split(/,\s*/).first : "spec"
55
+ end
56
+ blk_param = "spec" if blk_param.nil? || blk_param.empty?
57
+
58
+ # Extract AST-level statements inside the block body when available
59
+ body_node = gemspec_call.block&.body
60
+ body_src = ""
61
+ begin
62
+ # Try to extract the textual body from call_src using the do|...| ... end capture
63
+ body_src = if (m = call_src.match(/do\b[^\n]*\|[^|]*\|\s*(.*)end\s*\z/m))
64
+ m[1]
65
+ else
66
+ # Last resort: attempt to take slice of body node
67
+ body_node ? body_node.slice : ""
68
+ end
69
+ rescue StandardError
70
+ body_src = body_node ? body_node.slice : ""
71
+ end
72
+
73
+ new_body = body_src.dup
74
+
75
+ # Helper: build literal text for replacement values
76
+ build_literal = lambda do |v|
77
+ if v.is_a?(Array)
78
+ arr = v.compact.map(&:to_s).map { |e| '"' + e.gsub('"', '\\"') + '"' }
79
+ "[" + arr.join(", ") + "]"
80
+ else
81
+ '"' + v.to_s.gsub('"', '\\"') + '"'
82
+ end
83
+ end
84
+
85
+ # Extract existing statement nodes for more precise matching
86
+ stmt_nodes = PrismUtils.extract_statements(body_node)
87
+
88
+ replacements.each do |field_sym, value|
89
+ # Skip special internal keys that are not actual gemspec fields
90
+ next if field_sym == :_remove_self_dependency
91
+
92
+ field = field_sym.to_s
93
+
94
+ # Find an existing assignment node for this field: look for call nodes where
95
+ # receiver slice matches the block param and method name matches assignment
96
+ found_node = stmt_nodes.find do |n|
97
+ next false unless n.is_a?(Prism::CallNode)
98
+ begin
99
+ recv = n.receiver
100
+ recv_name = recv ? recv.slice.strip : nil
101
+ # match receiver variable name or literal slice
102
+ recv_name && recv_name.end_with?(blk_param) && n.name.to_s.start_with?(field)
103
+ rescue StandardError
104
+ false
105
+ end
106
+ end
107
+
108
+ if found_node
109
+ # Do not replace if the existing RHS is non-literal (e.g., computed expression)
110
+ existing_arg = found_node.arguments&.arguments&.first
111
+ existing_literal = begin
112
+ PrismUtils.extract_literal_value(existing_arg)
113
+ rescue
114
+ nil
115
+ end
116
+ if existing_literal.nil? && !value.nil?
117
+ # Skip replacing a non-literal RHS to avoid altering computed expressions.
118
+ debug_error(StandardError.new("Skipping replacement for #{field} because existing RHS is non-literal"), __method__)
119
+ else
120
+ # Replace the found node's slice in the body text with the updated assignment
121
+ indent = begin
122
+ found_node.slice.lines.first.match(/^(\s*)/)[1]
123
+ rescue
124
+ " "
125
+ end
126
+ rhs = build_literal.call(value)
127
+ replacement = "#{indent}#{blk_param}.#{field} = #{rhs}"
128
+ new_body = new_body.sub(found_node.slice, replacement)
129
+ end
130
+ else
131
+ # No existing assignment; insert after spec.version if present, else append
132
+ version_node = stmt_nodes.find do |n|
133
+ n.is_a?(Prism::CallNode) && n.name.to_s.start_with?("version", "version=") && n.receiver && n.receiver.slice.strip.end_with?(blk_param)
134
+ end
135
+
136
+ insert_line = " #{blk_param}.#{field} = #{build_literal.call(value)}\n"
137
+ new_body = if version_node
138
+ # Insert after the version node slice
139
+ new_body.sub(version_node.slice, version_node.slice + "\n" + insert_line)
140
+ elsif new_body.rstrip.end_with?('\n')
141
+ # Append before the final newline if present, else just append
142
+ new_body.rstrip + "\n" + insert_line
143
+ else
144
+ new_body.rstrip + "\n" + insert_line
145
+ end
146
+ end
147
+ end
148
+
149
+ # Handle removal of self-dependency if requested via :_remove_self_dependency
150
+ if replacements[:_remove_self_dependency]
151
+ name_to_remove = replacements[:_remove_self_dependency].to_s
152
+ # Find dependency call nodes to remove (add_dependency/add_development_dependency)
153
+ dep_nodes = stmt_nodes.select do |n|
154
+ next false unless n.is_a?(Prism::CallNode)
155
+ recv = begin
156
+ n.receiver
157
+ rescue
158
+ nil
159
+ end
160
+ next false unless recv && recv.slice.strip.end_with?(blk_param)
161
+ [:add_dependency, :add_development_dependency].include?(n.name)
162
+ end
163
+ dep_nodes.each do |dn|
164
+ # Check first argument literal
165
+ first_arg = dn.arguments&.arguments&.first
166
+ arg_val = begin
167
+ PrismUtils.extract_literal_value(first_arg)
168
+ rescue
169
+ nil
170
+ end
171
+ if arg_val && arg_val.to_s == name_to_remove
172
+ # Remove this node's slice from new_body
173
+ new_body = new_body.sub(dn.slice, "")
174
+ end
175
+ end
176
+ end
177
+
178
+ # Reassemble call source by replacing the captured body portion
179
+ new_call_src = call_src.sub(body_src, new_body)
180
+ content.sub(call_src, new_call_src)
181
+ rescue StandardError => e
182
+ debug_error(e, __method__)
183
+ content
184
+ end
185
+
186
+ # Remove spec.add_dependency / add_development_dependency calls that name the given gem
187
+ # Works by locating the Gem::Specification block and filtering out matching call lines.
188
+ def remove_spec_dependency(content, gem_name)
189
+ return content if gem_name.to_s.strip.empty?
190
+ replace_gemspec_fields(content, _remove_self_dependency: gem_name)
191
+ end
192
+
193
+ # Ensure development dependency lines in a gemspec match the desired lines.
194
+ # `desired` is a hash mapping gem_name => desired_line (string, without leading indentation).
195
+ # Returns the modified gemspec content (or original on error).
196
+ def ensure_development_dependencies(content, desired)
197
+ return content if desired.nil? || desired.empty?
198
+ result = PrismUtils.parse_with_comments(content)
199
+ stmts = PrismUtils.extract_statements(result.value.statements)
200
+ gemspec_call = stmts.find do |s|
201
+ s.is_a?(Prism::CallNode) && s.block && PrismUtils.extract_const_name(s.receiver) == "Gem::Specification" && s.name == :new
202
+ end
203
+
204
+ # If we couldn't locate the Gem::Specification.new block (e.g., empty or
205
+ # truncated gemspec), fall back to appending the desired development
206
+ # dependency lines to the end of the file so callers still get the
207
+ # expected dependency declarations.
208
+ unless gemspec_call
209
+ begin
210
+ out = content.dup
211
+ out << "\n" unless out.end_with?("\n") || out.empty?
212
+ desired.each do |_gem, line|
213
+ out << line.strip + "\n"
214
+ end
215
+ return out
216
+ rescue StandardError => e
217
+ debug_error(e, __method__)
218
+ return content
219
+ end
220
+ end
221
+
222
+ call_src = gemspec_call.slice
223
+ body_node = gemspec_call.block&.body
224
+ body_src = begin
225
+ if (m = call_src.match(/do\b[^\n]*\|[^|]*\|\s*(.*)end\s*\z/m))
226
+ m[1]
227
+ else
228
+ body_node ? body_node.slice : ""
229
+ end
230
+ rescue StandardError
231
+ body_node ? body_node.slice : ""
232
+ end
233
+
234
+ new_body = body_src.dup
235
+ stmt_nodes = PrismUtils.extract_statements(body_node)
236
+
237
+ # Find version node to choose insertion point
238
+ version_node = stmt_nodes.find do |n|
239
+ n.is_a?(Prism::CallNode) && n.name.to_s.start_with?("version") && n.receiver && n.receiver.slice.strip.end_with?("spec")
240
+ end
241
+
242
+ desired.each do |gem_name, desired_line|
243
+ # Skip commented occurrences - we only act on actual AST nodes
244
+ found = stmt_nodes.find do |n|
245
+ next false unless n.is_a?(Prism::CallNode)
246
+ next false unless [:add_development_dependency, :add_dependency].include?(n.name)
247
+ first_arg = n.arguments&.arguments&.first
248
+ val = begin
249
+ PrismUtils.extract_literal_value(first_arg)
250
+ rescue
251
+ nil
252
+ end
253
+ val && val.to_s == gem_name
254
+ end
255
+
256
+ if found
257
+ # Replace existing node slice with desired_line, preserving indent
258
+ indent = begin
259
+ found.slice.lines.first.match(/^(\s*)/)[1]
260
+ rescue
261
+ " "
262
+ end
263
+ replacement = indent + desired_line.strip + "\n"
264
+ new_body = new_body.sub(found.slice, replacement)
265
+ else
266
+ # Insert after version_node if present, else append before end
267
+ insert_line = " " + desired_line.strip + "\n"
268
+ new_body = if version_node
269
+ new_body.sub(version_node.slice, version_node.slice + "\n" + insert_line)
270
+ else
271
+ new_body.rstrip + "\n" + insert_line
272
+ end
273
+ end
274
+ end
275
+
276
+ new_call_src = call_src.sub(body_src, new_body)
277
+ content.sub(call_src, new_call_src)
278
+ rescue StandardError => e
279
+ debug_error(e, __method__)
280
+ content
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Kettle
6
+ module Dev
7
+ # Shared utilities for working with Prism AST nodes.
8
+ # Provides parsing, node inspection, and source generation helpers
9
+ # used by both PrismMerger and AppraisalsAstMerger.
10
+ #
11
+ # Uses Prism's native methods for source generation (via .slice) to preserve
12
+ # original formatting and comments. For normalized output (e.g., adding parentheses),
13
+ # use normalize_call_node instead.
14
+ module PrismUtils
15
+ module_function
16
+
17
+ # Parse Ruby source code and return Prism parse result with comments
18
+ # @param source [String] Ruby source code
19
+ # @return [Prism::ParseResult] Parse result containing AST and comments
20
+ def parse_with_comments(source)
21
+ Prism.parse(source)
22
+ end
23
+
24
+ # Extract statements from a Prism body node
25
+ # @param body_node [Prism::Node, nil] Body node (typically StatementsNode)
26
+ # @return [Array<Prism::Node>] Array of statement nodes
27
+ def extract_statements(body_node)
28
+ return [] unless body_node
29
+
30
+ if body_node.is_a?(Prism::StatementsNode)
31
+ body_node.body.compact
32
+ else
33
+ [body_node].compact
34
+ end
35
+ end
36
+
37
+ # Generate a unique key for a statement node to identify equivalent statements
38
+ # Used for merge/append operations to detect duplicates
39
+ # @param node [Prism::Node] Statement node
40
+ # @param tracked_methods [Array<Symbol>] Methods to track (default: gem, source, eval_gemfile, git_source)
41
+ # @return [Array, nil] Key array like [:gem, "foo"] or nil if not trackable
42
+ def statement_key(node, tracked_methods: %i[gem source eval_gemfile git_source])
43
+ return unless node.is_a?(Prism::CallNode)
44
+ return unless tracked_methods.include?(node.name)
45
+
46
+ first_arg = node.arguments&.arguments&.first
47
+ arg_value = extract_literal_value(first_arg)
48
+
49
+ [node.name, arg_value] if arg_value
50
+ end
51
+
52
+ # Extract literal value from string or symbol nodes
53
+ # @param node [Prism::Node, nil] Node to extract from
54
+ # @return [String, Symbol, nil] Literal value or nil
55
+ def extract_literal_value(node)
56
+ return unless node
57
+ case node
58
+ when Prism::StringNode then node.unescaped
59
+ when Prism::SymbolNode then node.unescaped
60
+ else
61
+ # Attempt to handle array literals
62
+ if node.respond_to?(:elements) && node.elements
63
+ arr = node.elements.map do |el|
64
+ case el
65
+ when Prism::StringNode then el.unescaped
66
+ when Prism::SymbolNode then el.unescaped
67
+ end
68
+ end
69
+ return arr if arr.all?
70
+ end
71
+ nil
72
+ end
73
+ end
74
+
75
+ # Extract qualified constant name from a constant node
76
+ # @param node [Prism::Node, nil] Constant node
77
+ # @return [String, nil] Qualified name like "Gem::Specification" or nil
78
+ def extract_const_name(node)
79
+ case node
80
+ when Prism::ConstantReadNode
81
+ node.name.to_s
82
+ when Prism::ConstantPathNode
83
+ parent = extract_const_name(node.parent)
84
+ child = node.name || node.child&.name
85
+ (parent && child) ? "#{parent}::#{child}" : child.to_s
86
+ end
87
+ end
88
+
89
+ # Find leading comments for a statement node
90
+ # Leading comments are those that appear after the previous statement
91
+ # and before the current statement
92
+ # @param parse_result [Prism::ParseResult] Parse result with comments
93
+ # @param current_stmt [Prism::Node] Current statement node
94
+ # @param prev_stmt [Prism::Node, nil] Previous statement node
95
+ # @param body_node [Prism::Node] Body containing the statements
96
+ # @return [Array<Prism::Comment>] Leading comments
97
+ def find_leading_comments(parse_result, current_stmt, prev_stmt, body_node)
98
+ start_line = prev_stmt ? prev_stmt.location.end_line : body_node.location.start_line
99
+ end_line = current_stmt.location.start_line
100
+
101
+ parse_result.comments.select do |comment|
102
+ comment.location.start_line > start_line &&
103
+ comment.location.start_line < end_line
104
+ end
105
+ end
106
+
107
+ # Find inline comments for a statement node
108
+ # Inline comments are those that appear on the same line as the statement's end
109
+ # @param parse_result [Prism::ParseResult] Parse result with comments
110
+ # @param stmt [Prism::Node] Statement node
111
+ # @return [Array<Prism::Comment>] Inline comments
112
+ def inline_comments_for_node(parse_result, stmt)
113
+ parse_result.comments.select do |comment|
114
+ comment.location.start_line == stmt.location.end_line &&
115
+ comment.location.start_offset > stmt.location.end_offset
116
+ end
117
+ end
118
+
119
+ # Convert a Prism AST node to Ruby source code
120
+ # Uses Prism's native slice method which preserves the original source exactly.
121
+ # This is preferable to Unparser for Prism nodes as it maintains original formatting
122
+ # and comments without requiring transformation.
123
+ # @param node [Prism::Node] AST node
124
+ # @return [String] Ruby source code
125
+ def node_to_source(node)
126
+ return "" unless node
127
+ # Prism nodes have a slice method that returns the original source
128
+ node.slice
129
+ end
130
+
131
+ # Normalize a call node to use parentheses format
132
+ # Converts `gem "foo"` to `gem("foo")` style
133
+ # @param node [Prism::CallNode] Call node
134
+ # @return [String] Normalized source code
135
+ def normalize_call_node(node)
136
+ return node.slice.strip unless node.is_a?(Prism::CallNode)
137
+
138
+ method_name = node.name
139
+ args = node.arguments&.arguments || []
140
+
141
+ if args.empty?
142
+ "#{method_name}()"
143
+ else
144
+ arg_strings = args.map { |arg| normalize_argument(arg) }
145
+ "#{method_name}(#{arg_strings.join(", ")})"
146
+ end
147
+ end
148
+
149
+ # Normalize an argument node to canonical format
150
+ # @param arg [Prism::Node] Argument node
151
+ # @return [String] Normalized argument source
152
+ def normalize_argument(arg)
153
+ case arg
154
+ when Prism::StringNode
155
+ "\"#{arg.unescaped}\""
156
+ when Prism::SymbolNode
157
+ ":#{arg.unescaped}"
158
+ when Prism::KeywordHashNode
159
+ # Handle hash arguments like {key: value}
160
+ pairs = arg.elements.map do |assoc|
161
+ key = case assoc.key
162
+ when Prism::SymbolNode then "#{assoc.key.unescaped}:"
163
+ when Prism::StringNode then "\"#{assoc.key.unescaped}\" =>"
164
+ else "#{assoc.key.slice} =>"
165
+ end
166
+ value = normalize_argument(assoc.value)
167
+ "#{key} #{value}"
168
+ end.join(", ")
169
+ pairs
170
+ when Prism::HashNode
171
+ # Handle explicit hash syntax
172
+ pairs = arg.elements.map do |assoc|
173
+ key_part = normalize_argument(assoc.key)
174
+ value_part = normalize_argument(assoc.value)
175
+ "#{key_part} => #{value_part}"
176
+ end.join(", ")
177
+ "{#{pairs}}"
178
+ else
179
+ # For other types (numbers, arrays, etc.), use the original source
180
+ arg.slice.strip
181
+ end
182
+ end
183
+
184
+ # Check if a node is a specific method call
185
+ # @param node [Prism::Node] Node to check
186
+ # @param method_name [Symbol] Method name to check for
187
+ # @return [Boolean] True if node is a call to the specified method
188
+ def call_to?(node, method_name)
189
+ node.is_a?(Prism::CallNode) && node.name == method_name
190
+ end
191
+
192
+ # Check if a node is a block call to a specific method
193
+ # @param node [Prism::Node] Node to check
194
+ # @param method_name [Symbol] Method name to check for
195
+ # @return [Boolean] True if node is a block call to the specified method
196
+ def block_call_to?(node, method_name)
197
+ node.is_a?(Prism::CallNode) && node.name == method_name && !node.block.nil?
198
+ end
199
+ end
200
+ end
201
+ end
@@ -33,10 +33,7 @@ module Kettle
33
33
  # @param msg [String]
34
34
  # @return [void]
35
35
  def debug_log(msg)
36
- return unless Kettle::Dev::DEBUGGING
37
- Kernel.warn("[readme_backers] #{msg}")
38
- rescue StandardError
39
- # never raise from a standard error within debug logging
36
+ Kettle::Dev.debug_log(msg)
40
37
  end
41
38
 
42
39
  public
@@ -197,37 +197,26 @@ module Kettle
197
197
  end
198
198
  end
199
199
 
200
- modified = target.dup
201
- wanted.each do |gem_name, desired_line|
202
- lines = modified.lines
203
- found = false
204
- lines.map! do |ln|
205
- if ln =~ /add_development_dependency\s*\(?\s*["']#{Regexp.escape(gem_name)}["']/
206
- found = true
207
- indent = ln[/^\s*/] || ""
208
- "#{indent}#{desired_line.strip}\n"
209
- else
210
- ln
211
- end
200
+ # Use Prism-based gemspec edit to ensure development dependencies match
201
+ begin
202
+ modified = Kettle::Dev::PrismGemspec.ensure_development_dependencies(target, wanted)
203
+ # Check if any actual changes were made to development dependency declarations.
204
+ # Extract dependency lines from both and compare sets to avoid false positives
205
+ # from whitespace/formatting differences.
206
+ extract_deps = lambda do |content|
207
+ content.to_s.lines.select { |ln| ln =~ /add_development_dependency\s*\(?/ }.map(&:strip).sort
212
208
  end
213
- modified = lines.join
214
-
215
- next if found
216
-
217
- if (idx = modified.rindex(/\nend\s*\z/))
218
- before = modified[0...idx]
219
- after = modified[idx..-1]
220
- insertion = "\n #{desired_line.strip}\n"
221
- modified = before + insertion + after
209
+ target_deps = extract_deps.call(target)
210
+ modified_deps = extract_deps.call(modified)
211
+ if modified_deps != target_deps
212
+ File.write(@gemspec_path, modified)
213
+ say("Updated development dependencies in #{@gemspec_path}.")
222
214
  else
223
- modified << "\n#{desired_line}\n"
215
+ say("Development dependencies already up to date.")
224
216
  end
225
- end
226
-
227
- if modified != target
228
- File.write(@gemspec_path, modified)
229
- say("Updated development dependencies in #{@gemspec_path}.")
230
- else
217
+ rescue StandardError => e
218
+ Kettle::Dev.debug_error(e, __method__)
219
+ # Fall back to previous behavior: write nothing and report up-to-date
231
220
  say("Development dependencies already up to date.")
232
221
  end
233
222
  end