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,458 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "set"
|
|
5
|
+
require "prism"
|
|
6
|
+
|
|
7
|
+
module Kettle
|
|
8
|
+
module Dev
|
|
9
|
+
# Prism-based AST merging for templated Ruby files.
|
|
10
|
+
# Handles universal freeze reminders, kettle-dev:freeze blocks, and
|
|
11
|
+
# strategy dispatch (skip/replace/append/merge).
|
|
12
|
+
#
|
|
13
|
+
# Uses Prism for parsing with first-class comment support, enabling
|
|
14
|
+
# preservation of inline and leading comments throughout the merge process.
|
|
15
|
+
module SourceMerger
|
|
16
|
+
FREEZE_START = /#\s*kettle-dev:freeze/i
|
|
17
|
+
FREEZE_END = /#\s*kettle-dev:unfreeze/i
|
|
18
|
+
FREEZE_BLOCK = Regexp.new("(#{FREEZE_START.source}).*?(#{FREEZE_END.source})", Regexp::IGNORECASE | Regexp::MULTILINE)
|
|
19
|
+
FREEZE_REMINDER = <<~RUBY
|
|
20
|
+
# To retain during kettle-dev templating:
|
|
21
|
+
# kettle-dev:freeze
|
|
22
|
+
# # ... your code
|
|
23
|
+
# kettle-dev:unfreeze
|
|
24
|
+
RUBY
|
|
25
|
+
BUG_URL = "https://github.com/kettle-rb/kettle-dev/issues"
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Apply a templating strategy to merge source and destination Ruby files
|
|
30
|
+
#
|
|
31
|
+
# @param strategy [Symbol] Merge strategy - :skip, :replace, :append, or :merge
|
|
32
|
+
# @param src [String] Template source content
|
|
33
|
+
# @param dest [String] Destination file content
|
|
34
|
+
# @param path [String] File path (for error messages)
|
|
35
|
+
# @return [String] Merged content with freeze blocks and comments preserved
|
|
36
|
+
# @raise [Kettle::Dev::Error] If strategy is unknown or merge fails
|
|
37
|
+
# @example
|
|
38
|
+
# SourceMerger.apply(
|
|
39
|
+
# strategy: :merge,
|
|
40
|
+
# src: 'gem "foo"',
|
|
41
|
+
# dest: 'gem "bar"',
|
|
42
|
+
# path: "Gemfile"
|
|
43
|
+
# )
|
|
44
|
+
def apply(strategy:, src:, dest:, path:)
|
|
45
|
+
strategy = normalize_strategy(strategy)
|
|
46
|
+
dest ||= ""
|
|
47
|
+
src_with_reminder = ensure_reminder(src)
|
|
48
|
+
content =
|
|
49
|
+
case strategy
|
|
50
|
+
when :skip
|
|
51
|
+
src_with_reminder
|
|
52
|
+
when :replace
|
|
53
|
+
normalize_source(src_with_reminder)
|
|
54
|
+
when :append
|
|
55
|
+
apply_append(src_with_reminder, dest)
|
|
56
|
+
when :merge
|
|
57
|
+
apply_merge(src_with_reminder, dest)
|
|
58
|
+
else
|
|
59
|
+
raise Kettle::Dev::Error, "Unknown templating strategy '#{strategy}' for #{path}."
|
|
60
|
+
end
|
|
61
|
+
content = merge_freeze_blocks(content, dest)
|
|
62
|
+
content = restore_custom_leading_comments(dest, content)
|
|
63
|
+
ensure_trailing_newline(content)
|
|
64
|
+
rescue StandardError => error
|
|
65
|
+
warn_bug(path, error)
|
|
66
|
+
raise Kettle::Dev::Error, "Template merge failed for #{path}: #{error.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Ensure freeze reminder comment is present at the top of content
|
|
70
|
+
#
|
|
71
|
+
# @param content [String] Ruby source content
|
|
72
|
+
# @return [String] Content with freeze reminder prepended if missing
|
|
73
|
+
# @api private
|
|
74
|
+
def ensure_reminder(content)
|
|
75
|
+
return content if reminder_present?(content)
|
|
76
|
+
insertion_index = reminder_insertion_index(content)
|
|
77
|
+
before = content[0...insertion_index]
|
|
78
|
+
after = content[insertion_index..-1]
|
|
79
|
+
snippet = FREEZE_REMINDER
|
|
80
|
+
snippet += "\n" unless snippet.end_with?("\n\n")
|
|
81
|
+
[before, snippet, after].join
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Normalize source code while preserving formatting
|
|
85
|
+
#
|
|
86
|
+
# @param source [String] Ruby source code
|
|
87
|
+
# @return [String] Normalized source with trailing newline
|
|
88
|
+
# @api private
|
|
89
|
+
def normalize_source(source)
|
|
90
|
+
parse_result = PrismUtils.parse_with_comments(source)
|
|
91
|
+
return ensure_trailing_newline(source) unless parse_result.success?
|
|
92
|
+
|
|
93
|
+
# Use Prism's slice to preserve original formatting
|
|
94
|
+
ensure_trailing_newline(source)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def reminder_present?(content)
|
|
98
|
+
content.include?(FREEZE_REMINDER.lines.first.strip)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reminder_insertion_index(content)
|
|
102
|
+
cursor = 0
|
|
103
|
+
lines = content.lines
|
|
104
|
+
lines.each do |line|
|
|
105
|
+
break unless shebang?(line) || frozen_comment?(line)
|
|
106
|
+
cursor += line.length
|
|
107
|
+
end
|
|
108
|
+
cursor
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def shebang?(line)
|
|
112
|
+
line.start_with?("#!")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def frozen_comment?(line)
|
|
116
|
+
line.match?(/#\s*frozen_string_literal:/)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Merge kettle-dev:freeze blocks from destination into source content
|
|
120
|
+
# Preserves user customizations wrapped in freeze/unfreeze markers
|
|
121
|
+
#
|
|
122
|
+
# @param src_content [String] Template source content
|
|
123
|
+
# @param dest_content [String] Destination file content
|
|
124
|
+
# @return [String] Merged content with freeze blocks from destination
|
|
125
|
+
# @api private
|
|
126
|
+
def merge_freeze_blocks(src_content, dest_content)
|
|
127
|
+
dest_blocks = freeze_blocks(dest_content)
|
|
128
|
+
return src_content if dest_blocks.empty?
|
|
129
|
+
src_blocks = freeze_blocks(src_content)
|
|
130
|
+
updated = src_content.dup
|
|
131
|
+
# Replace matching freeze sections by textual markers rather than index ranges
|
|
132
|
+
dest_blocks.each do |dest_block|
|
|
133
|
+
marker = dest_block[:text]
|
|
134
|
+
next if updated.include?(marker)
|
|
135
|
+
# If the template had a placeholder block, replace the first occurrence of a freeze stub
|
|
136
|
+
placeholder = src_blocks.find { |blk| blk[:start_marker] == dest_block[:start_marker] }
|
|
137
|
+
if placeholder
|
|
138
|
+
updated.sub!(placeholder[:text], marker)
|
|
139
|
+
else
|
|
140
|
+
updated << "\n" unless updated.end_with?("\n")
|
|
141
|
+
updated << marker
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
updated
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def freeze_blocks(text)
|
|
148
|
+
return [] unless text&.match?(FREEZE_START)
|
|
149
|
+
blocks = []
|
|
150
|
+
text.to_enum(:scan, FREEZE_BLOCK).each do
|
|
151
|
+
match = Regexp.last_match
|
|
152
|
+
start_idx = match&.begin(0)
|
|
153
|
+
end_idx = match&.end(0)
|
|
154
|
+
next unless start_idx && end_idx
|
|
155
|
+
segment = match[0]
|
|
156
|
+
start_marker = segment.lines.first&.strip
|
|
157
|
+
blocks << {range: start_idx...end_idx, text: segment, start_marker: start_marker}
|
|
158
|
+
end
|
|
159
|
+
blocks
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def normalize_strategy(strategy)
|
|
163
|
+
return :skip if strategy.nil?
|
|
164
|
+
strategy.to_s.downcase.strip.to_sym
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def warn_bug(path, error)
|
|
168
|
+
puts "ERROR: kettle-dev templating failed for #{path}: #{error.message}"
|
|
169
|
+
puts "Please file a bug at #{BUG_URL} with the file contents so we can improve the AST merger."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def ensure_trailing_newline(text)
|
|
173
|
+
return "" if text.nil?
|
|
174
|
+
text.end_with?("\n") ? text : text + "\n"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def apply_append(src_content, dest_content)
|
|
178
|
+
prism_merge(src_content, dest_content) do |src_nodes, dest_nodes, _src_result, _dest_result|
|
|
179
|
+
existing = Set.new(dest_nodes.map { |node| node_signature(node[:node]) })
|
|
180
|
+
appended = dest_nodes.dup
|
|
181
|
+
src_nodes.each do |node_info|
|
|
182
|
+
sig = node_signature(node_info[:node])
|
|
183
|
+
next if existing.include?(sig)
|
|
184
|
+
appended << node_info
|
|
185
|
+
existing << sig
|
|
186
|
+
end
|
|
187
|
+
appended
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def apply_merge(src_content, dest_content)
|
|
192
|
+
prism_merge(src_content, dest_content) do |src_nodes, dest_nodes, _src_result, _dest_result|
|
|
193
|
+
src_map = src_nodes.each_with_object({}) do |node_info, memo|
|
|
194
|
+
sig = node_signature(node_info[:node])
|
|
195
|
+
memo[sig] ||= node_info
|
|
196
|
+
end
|
|
197
|
+
merged = dest_nodes.map do |node_info|
|
|
198
|
+
sig = node_signature(node_info[:node])
|
|
199
|
+
if (src_node_info = src_map[sig])
|
|
200
|
+
merge_node_info(sig, node_info, src_node_info)
|
|
201
|
+
else
|
|
202
|
+
node_info
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
existing = merged.map { |ni| node_signature(ni[:node]) }.to_set
|
|
206
|
+
src_nodes.each do |node_info|
|
|
207
|
+
sig = node_signature(node_info[:node])
|
|
208
|
+
next if existing.include?(sig)
|
|
209
|
+
merged << node_info
|
|
210
|
+
existing << sig
|
|
211
|
+
end
|
|
212
|
+
merged
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def merge_node_info(signature, _dest_node_info, src_node_info)
|
|
217
|
+
return src_node_info unless signature.is_a?(Array)
|
|
218
|
+
case signature[1]
|
|
219
|
+
when :gem_specification
|
|
220
|
+
merge_block_node_info(src_node_info)
|
|
221
|
+
else
|
|
222
|
+
src_node_info
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def merge_block_node_info(src_node_info)
|
|
227
|
+
# For block merging, we need to merge the statements within the block
|
|
228
|
+
# This is complex - for now, prefer template version
|
|
229
|
+
# TODO: Implement deep block statement merging with comment preservation
|
|
230
|
+
src_node_info
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def prism_merge(src_content, dest_content)
|
|
234
|
+
src_result = PrismUtils.parse_with_comments(src_content)
|
|
235
|
+
dest_result = PrismUtils.parse_with_comments(dest_content)
|
|
236
|
+
|
|
237
|
+
# If src parsing failed, return src unchanged to avoid losing content
|
|
238
|
+
unless src_result.success?
|
|
239
|
+
puts "WARNING: Source content parse failed, returning unchanged"
|
|
240
|
+
return src_content
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
src_nodes = extract_nodes_with_comments(src_result)
|
|
244
|
+
dest_nodes = extract_nodes_with_comments(dest_result)
|
|
245
|
+
|
|
246
|
+
merged_nodes = yield(src_nodes, dest_nodes, src_result, dest_result)
|
|
247
|
+
|
|
248
|
+
# Extract magic comments from source (frozen_string_literal, etc.)
|
|
249
|
+
magic_comments = extract_magic_comments(src_result)
|
|
250
|
+
|
|
251
|
+
# Extract file-level leading comments (comments before first statement)
|
|
252
|
+
file_leading_comments = extract_file_leading_comments(src_result)
|
|
253
|
+
|
|
254
|
+
build_source_from_nodes(merged_nodes, magic_comments: magic_comments, file_leading_comments: file_leading_comments)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def extract_magic_comments(parse_result)
|
|
258
|
+
return [] unless parse_result.success?
|
|
259
|
+
|
|
260
|
+
magic_comments = []
|
|
261
|
+
source_lines = parse_result.source.lines
|
|
262
|
+
|
|
263
|
+
# Magic comments appear at the very top of the file (possibly after shebang)
|
|
264
|
+
# They must be on the first or second line
|
|
265
|
+
source_lines.first(2).each do |line|
|
|
266
|
+
stripped = line.strip
|
|
267
|
+
# Check for shebang
|
|
268
|
+
if stripped.start_with?("#!")
|
|
269
|
+
magic_comments << line.rstrip
|
|
270
|
+
# Check for magic comments like frozen_string_literal, encoding, etc.
|
|
271
|
+
elsif stripped.start_with?("#") &&
|
|
272
|
+
(stripped.include?("frozen_string_literal:") ||
|
|
273
|
+
stripped.include?("encoding:") ||
|
|
274
|
+
stripped.include?("warn_indent:") ||
|
|
275
|
+
stripped.include?("shareable_constant_value:"))
|
|
276
|
+
magic_comments << line.rstrip
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
magic_comments
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def extract_file_leading_comments(parse_result)
|
|
284
|
+
return [] unless parse_result.success?
|
|
285
|
+
|
|
286
|
+
statements = PrismUtils.extract_statements(parse_result.value.statements)
|
|
287
|
+
return [] if statements.empty?
|
|
288
|
+
|
|
289
|
+
first_stmt = statements.first
|
|
290
|
+
first_stmt_line = first_stmt.location.start_line
|
|
291
|
+
|
|
292
|
+
# Extract file-level comments that appear after magic comments (line 1-2)
|
|
293
|
+
# but before the first executable statement. These are typically documentation
|
|
294
|
+
# comments describing the file's purpose.
|
|
295
|
+
parse_result.comments.select do |comment|
|
|
296
|
+
comment.location.start_line > 2 &&
|
|
297
|
+
comment.location.start_line < first_stmt_line
|
|
298
|
+
end.map { |comment| comment.slice.rstrip }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def extract_nodes_with_comments(parse_result)
|
|
302
|
+
return [] unless parse_result.success?
|
|
303
|
+
|
|
304
|
+
statements = PrismUtils.extract_statements(parse_result.value.statements)
|
|
305
|
+
return [] if statements.empty?
|
|
306
|
+
|
|
307
|
+
source_lines = parse_result.source.lines
|
|
308
|
+
|
|
309
|
+
statements.map.with_index do |stmt, idx|
|
|
310
|
+
prev_stmt = (idx > 0) ? statements[idx - 1] : nil
|
|
311
|
+
body_node = parse_result.value.statements
|
|
312
|
+
|
|
313
|
+
# Count blank lines before this statement
|
|
314
|
+
blank_lines_before = count_blank_lines_before(source_lines, stmt, prev_stmt, body_node)
|
|
315
|
+
|
|
316
|
+
{
|
|
317
|
+
node: stmt,
|
|
318
|
+
leading_comments: PrismUtils.find_leading_comments(parse_result, stmt, prev_stmt, body_node),
|
|
319
|
+
inline_comments: PrismUtils.inline_comments_for_node(parse_result, stmt),
|
|
320
|
+
blank_lines_before: blank_lines_before,
|
|
321
|
+
}
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def count_blank_lines_before(source_lines, current_stmt, prev_stmt, body_node)
|
|
326
|
+
# Determine the starting line to search from
|
|
327
|
+
start_line = if prev_stmt
|
|
328
|
+
prev_stmt.location.end_line
|
|
329
|
+
else
|
|
330
|
+
# For the first statement, start from the beginning of the body
|
|
331
|
+
body_node.location.start_line
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
end_line = current_stmt.location.start_line
|
|
335
|
+
|
|
336
|
+
# Count consecutive blank lines before the current statement
|
|
337
|
+
# (after any comments and the previous statement)
|
|
338
|
+
blank_count = 0
|
|
339
|
+
(start_line...end_line).each do |line_num|
|
|
340
|
+
line_idx = line_num - 1
|
|
341
|
+
next if line_idx < 0 || line_idx >= source_lines.length
|
|
342
|
+
|
|
343
|
+
line = source_lines[line_idx]
|
|
344
|
+
# Skip comment lines (they're handled separately)
|
|
345
|
+
next if line.strip.start_with?("#")
|
|
346
|
+
|
|
347
|
+
# Count blank lines
|
|
348
|
+
if line.strip.empty?
|
|
349
|
+
blank_count += 1
|
|
350
|
+
else
|
|
351
|
+
# Reset count if we hit a non-blank, non-comment line
|
|
352
|
+
# This ensures we only count consecutive blank lines immediately before the statement
|
|
353
|
+
blank_count = 0
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
blank_count
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def build_source_from_nodes(node_infos, magic_comments: [], file_leading_comments: [])
|
|
361
|
+
return "" if node_infos.empty?
|
|
362
|
+
|
|
363
|
+
lines = []
|
|
364
|
+
|
|
365
|
+
# Add magic comments at the top (frozen_string_literal, etc.)
|
|
366
|
+
if magic_comments.any?
|
|
367
|
+
lines.concat(magic_comments)
|
|
368
|
+
lines << "" # Add blank line after magic comments
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Add file-level leading comments (comments before first statement)
|
|
372
|
+
if file_leading_comments.any?
|
|
373
|
+
lines.concat(file_leading_comments)
|
|
374
|
+
lines << "" # Add blank line after file-level comments
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
node_infos.each do |node_info|
|
|
378
|
+
# Add blank lines before this statement (for visual grouping)
|
|
379
|
+
blank_lines = node_info[:blank_lines_before] || 0
|
|
380
|
+
blank_lines.times { lines << "" }
|
|
381
|
+
|
|
382
|
+
# Add leading comments
|
|
383
|
+
node_info[:leading_comments].each do |comment|
|
|
384
|
+
lines << comment.slice.rstrip
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Add the node's source
|
|
388
|
+
node_source = PrismUtils.node_to_source(node_info[:node])
|
|
389
|
+
|
|
390
|
+
# Add inline comments on the same line
|
|
391
|
+
if node_info[:inline_comments].any?
|
|
392
|
+
inline = node_info[:inline_comments].map { |c| c.slice.strip }.join(" ")
|
|
393
|
+
node_source = node_source.rstrip + " " + inline
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
lines << node_source
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
lines.join("\n")
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def node_signature(node)
|
|
403
|
+
return [:nil] unless node
|
|
404
|
+
|
|
405
|
+
case node
|
|
406
|
+
when Prism::CallNode
|
|
407
|
+
method_name = node.name
|
|
408
|
+
if node.block
|
|
409
|
+
# Block call
|
|
410
|
+
first_arg = PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
|
|
411
|
+
receiver_name = PrismUtils.extract_const_name(node.receiver)
|
|
412
|
+
|
|
413
|
+
if receiver_name == "Gem::Specification" && method_name == :new
|
|
414
|
+
[:block, :gem_specification]
|
|
415
|
+
elsif method_name == :task
|
|
416
|
+
[:block, :task, first_arg]
|
|
417
|
+
elsif method_name == :git_source
|
|
418
|
+
[:block, :git_source, first_arg]
|
|
419
|
+
else
|
|
420
|
+
[:block, method_name, first_arg, node.slice]
|
|
421
|
+
end
|
|
422
|
+
elsif [:source, :git_source, :gem, :eval_gemfile].include?(method_name)
|
|
423
|
+
# Simple call
|
|
424
|
+
first_literal = PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
|
|
425
|
+
[:send, method_name, first_literal]
|
|
426
|
+
else
|
|
427
|
+
[:send, method_name, node.slice]
|
|
428
|
+
end
|
|
429
|
+
else
|
|
430
|
+
# Other node types
|
|
431
|
+
[node.class.name.split("::").last.to_sym, node.slice]
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def restore_custom_leading_comments(dest_content, merged_content)
|
|
436
|
+
block = leading_comment_block(dest_content)
|
|
437
|
+
return merged_content if block.strip.empty?
|
|
438
|
+
return merged_content if merged_content.start_with?(block)
|
|
439
|
+
|
|
440
|
+
# Insert after shebang / frozen string literal comments (same place reminder goes)
|
|
441
|
+
insertion_index = reminder_insertion_index(merged_content)
|
|
442
|
+
block = ensure_trailing_newline(block)
|
|
443
|
+
merged_content.dup.insert(insertion_index, block)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def leading_comment_block(content)
|
|
447
|
+
lines = content.to_s.lines
|
|
448
|
+
collected = []
|
|
449
|
+
lines.each do |line|
|
|
450
|
+
stripped = line.strip
|
|
451
|
+
break unless stripped.empty? || stripped.start_with?("#")
|
|
452
|
+
collected << line
|
|
453
|
+
end
|
|
454
|
+
collected.join
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
@@ -252,7 +252,8 @@ module Kettle
|
|
|
252
252
|
|
|
253
253
|
# If a destination gemspec already exists, get metadata from GemSpecReader via helpers
|
|
254
254
|
orig_meta = nil
|
|
255
|
-
|
|
255
|
+
dest_existed = File.exist?(dest_gemspec)
|
|
256
|
+
if dest_existed
|
|
256
257
|
begin
|
|
257
258
|
orig_meta = helpers.gemspec_metadata(File.dirname(dest_gemspec))
|
|
258
259
|
rescue StandardError => e
|
|
@@ -281,88 +282,28 @@ module Kettle
|
|
|
281
282
|
end
|
|
282
283
|
|
|
283
284
|
if orig_meta
|
|
284
|
-
#
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
pre = Regexp.last_match(1)
|
|
289
|
-
q = '"'
|
|
290
|
-
pre + q + v.gsub('"', '\\"') + q
|
|
291
|
-
end
|
|
285
|
+
# Build replacements using AST-aware helper to carry over fields
|
|
286
|
+
repl = {}
|
|
287
|
+
if (name = orig_meta[:gem_name]) && !name.to_s.empty?
|
|
288
|
+
repl[:name] = name.to_s
|
|
292
289
|
end
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
txt.gsub(/(\bspec\.#{Regexp.escape(field)}\s*=\s*)\[[^\]]*\]/, "\\1#{literal}")
|
|
301
|
-
else
|
|
302
|
-
# If no existing assignment, insert a new line after spec.version if possible
|
|
303
|
-
insert_after = (txt =~ /^\s*spec\.version\s*=.*$/) ? :version : nil
|
|
304
|
-
if insert_after == :version
|
|
305
|
-
txt.sub(/^(\s*spec\.version\s*=.*$)/) { |line| line + "\n spec.#{field} = #{literal}" }
|
|
306
|
-
else
|
|
307
|
-
txt + "\n spec.#{field} = #{literal}\n"
|
|
308
|
-
end
|
|
309
|
-
end
|
|
290
|
+
repl[:authors] = Array(orig_meta[:authors]).map(&:to_s) if orig_meta[:authors]
|
|
291
|
+
repl[:email] = Array(orig_meta[:email]).map(&:to_s) if orig_meta[:email]
|
|
292
|
+
repl[:summary] = orig_meta[:summary].to_s if orig_meta[:summary]
|
|
293
|
+
repl[:description] = orig_meta[:description].to_s if orig_meta[:description]
|
|
294
|
+
repl[:licenses] = Array(orig_meta[:licenses]).map(&:to_s) if orig_meta[:licenses]
|
|
295
|
+
if orig_meta[:required_ruby_version]
|
|
296
|
+
repl[:required_ruby_version] = orig_meta[:required_ruby_version].to_s
|
|
310
297
|
end
|
|
298
|
+
repl[:require_paths] = Array(orig_meta[:require_paths]).map(&:to_s) if orig_meta[:require_paths]
|
|
299
|
+
repl[:bindir] = orig_meta[:bindir].to_s if orig_meta[:bindir]
|
|
300
|
+
repl[:executables] = Array(orig_meta[:executables]).map(&:to_s) if orig_meta[:executables]
|
|
311
301
|
|
|
312
302
|
begin
|
|
313
|
-
|
|
314
|
-
if (name = orig_meta[:gem_name]) && !name.to_s.empty?
|
|
315
|
-
c = replace_string_field.call(c, "name", name)
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
# 2. spec.authors — retain original, normalize to array
|
|
319
|
-
orig_auth = orig_meta[:authors]
|
|
320
|
-
c = replace_array_field.call(c, "authors", orig_auth)
|
|
321
|
-
|
|
322
|
-
# 3. spec.email — retain original, normalize to array
|
|
323
|
-
orig_email = orig_meta[:email]
|
|
324
|
-
c = replace_array_field.call(c, "email", orig_email)
|
|
325
|
-
|
|
326
|
-
# 4. spec.summary — retain original; grapheme emoji prefix handled by "install" task
|
|
327
|
-
if (sum = orig_meta[:summary]) && !sum.to_s.empty?
|
|
328
|
-
c = replace_string_field.call(c, "summary", sum)
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
# 5. spec.description — retain original; grapheme emoji prefix handled by "install" task
|
|
332
|
-
if (desc = orig_meta[:description]) && !desc.to_s.empty?
|
|
333
|
-
c = replace_string_field.call(c, "description", desc)
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
# 6. spec.licenses — retain original, normalize to array
|
|
337
|
-
lic = orig_meta[:licenses]
|
|
338
|
-
if lic && !lic.empty?
|
|
339
|
-
c = replace_array_field.call(c, "licenses", lic)
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
# 7. spec.required_ruby_version — retain original
|
|
343
|
-
if (rrv = orig_meta[:required_ruby_version].to_s) && !rrv.empty?
|
|
344
|
-
c = replace_string_field.call(c, "required_ruby_version", rrv)
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
# 8. spec.require_paths — retain original, normalize to array
|
|
348
|
-
req_paths = orig_meta[:require_paths]
|
|
349
|
-
unless req_paths.empty?
|
|
350
|
-
c = replace_array_field.call(c, "require_paths", req_paths)
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
# 9. spec.bindir — retain original
|
|
354
|
-
if (bd = orig_meta[:bindir]) && !bd.to_s.empty?
|
|
355
|
-
c = replace_string_field.call(c, "bindir", bd)
|
|
356
|
-
end
|
|
357
|
-
|
|
358
|
-
# 10. spec.executables — retain original, normalize to array
|
|
359
|
-
exes = orig_meta[:executables]
|
|
360
|
-
unless exes.empty?
|
|
361
|
-
c = replace_array_field.call(c, "executables", exes)
|
|
362
|
-
end
|
|
303
|
+
c = Kettle::Dev::PrismGemspec.replace_gemspec_fields(c, repl)
|
|
363
304
|
rescue StandardError => e
|
|
364
305
|
Kettle::Dev.debug_error(e, __method__)
|
|
365
|
-
# Best-effort carry-over; ignore
|
|
306
|
+
# Best-effort carry-over; ignore failure and keep c as-is
|
|
366
307
|
end
|
|
367
308
|
end
|
|
368
309
|
|
|
@@ -372,21 +313,26 @@ module Kettle
|
|
|
372
313
|
# Strip any dependency lines that name the destination gem.
|
|
373
314
|
begin
|
|
374
315
|
if gem_name && !gem_name.to_s.empty?
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
# spec.add_development_dependency "my-gem"
|
|
381
|
-
# spec.add_development_dependency 'my-gem', ">= 0"
|
|
382
|
-
self_dep_re = /\A\s*spec\.add_(?:development_)?dependency(?:\s*\(|\s+)\s*["']#{name_escaped}["'][^\n]*\)?\s*\z/
|
|
383
|
-
c = c.lines.reject { |ln| self_dep_re =~ ln }.join
|
|
316
|
+
begin
|
|
317
|
+
c = Kettle::Dev::PrismGemspec.remove_spec_dependency(c, gem_name)
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
320
|
+
end
|
|
384
321
|
end
|
|
385
322
|
rescue StandardError => e
|
|
386
323
|
Kettle::Dev.debug_error(e, __method__)
|
|
387
324
|
# If anything goes wrong, keep the content as-is rather than failing the task
|
|
388
325
|
end
|
|
389
326
|
|
|
327
|
+
if dest_existed
|
|
328
|
+
begin
|
|
329
|
+
merged = helpers.apply_strategy(c, dest_gemspec)
|
|
330
|
+
c = merged if merged.is_a?(String) && !merged.empty?
|
|
331
|
+
rescue StandardError => e
|
|
332
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
390
336
|
c
|
|
391
337
|
end
|
|
392
338
|
end
|