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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +22 -1
- data/Gemfile +3 -0
- data/Gemfile.example +3 -0
- data/README.md +74 -25
- data/Rakefile.example +1 -1
- data/gemfiles/modular/style.gemfile.example +1 -1
- data/gemfiles/modular/templating.gemfile +3 -0
- data/lib/kettle/dev/changelog_cli.rb +13 -0
- data/lib/kettle/dev/modular_gemfiles.rb +12 -3
- data/lib/kettle/dev/prism_appraisals.rb +306 -0
- data/lib/kettle/dev/prism_gemfile.rb +136 -0
- data/lib/kettle/dev/prism_gemspec.rb +284 -0
- data/lib/kettle/dev/prism_utils.rb +201 -0
- data/lib/kettle/dev/readme_backers.rb +1 -4
- data/lib/kettle/dev/setup_cli.rb +17 -28
- data/lib/kettle/dev/source_merger.rb +458 -0
- data/lib/kettle/dev/tasks/template_task.rb +32 -86
- data/lib/kettle/dev/template_helpers.rb +74 -338
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev.rb +23 -3
- data/sig/kettle/dev/appraisals_ast_merger.rbs +72 -0
- data/sig/kettle/dev/changelog_cli.rbs +64 -0
- data/sig/kettle/dev/prism_utils.rbs +56 -0
- data/sig/kettle/dev/source_merger.rbs +86 -0
- data/sig/kettle/dev/versioning.rbs +21 -0
- data.tar.gz.sig +0 -0
- metadata +16 -5
- metadata.gz.sig +0 -0
- /data/sig/kettle/dev/{dvcscli.rbs → dvcs_cli.rbs} +0 -0
|
@@ -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
|
-
|
|
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
|
data/lib/kettle/dev/setup_cli.rb
CHANGED
|
@@ -197,37 +197,26 @@ module Kettle
|
|
|
197
197
|
end
|
|
198
198
|
end
|
|
199
199
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
lines
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
215
|
+
say("Development dependencies already up to date.")
|
|
224
216
|
end
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|