kettle-dev 1.1.60 → 1.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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +15 -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/appraisals_ast_merger.rb +383 -0
- data/lib/kettle/dev/changelog_cli.rb +13 -0
- data/lib/kettle/dev/modular_gemfiles.rb +11 -3
- data/lib/kettle/dev/prism_utils.rb +188 -0
- data/lib/kettle/dev/source_merger.rb +345 -0
- data/lib/kettle/dev/tasks/template_task.rb +11 -1
- data/lib/kettle/dev/template_helpers.rb +70 -226
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev.rb +2 -0
- 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 +14 -5
- metadata.gz.sig +0 -0
- /data/sig/kettle/dev/{dvcscli.rbs → dvcs_cli.rbs} +0 -0
|
@@ -22,25 +22,31 @@ module Kettle
|
|
|
22
22
|
def sync!(helpers:, project_root:, gem_checkout_root:, min_ruby: nil)
|
|
23
23
|
# 4a) gemfiles/modular/*.gemfile except style.gemfile (handled below)
|
|
24
24
|
# Note: `injected.gemfile` is only intended for testing this gem, and isn't even actively used there. It is not part of the template.
|
|
25
|
+
# Note: `style.gemfile` is handled separately below.
|
|
25
26
|
modular_gemfiles = %w[
|
|
26
27
|
coverage
|
|
27
28
|
debug
|
|
28
29
|
documentation
|
|
29
30
|
optional
|
|
30
31
|
runtime_heads
|
|
32
|
+
templating
|
|
31
33
|
x_std_libs
|
|
32
34
|
]
|
|
33
35
|
modular_gemfiles.each do |base|
|
|
34
36
|
modular_gemfile = "#{base}.gemfile"
|
|
35
37
|
src = helpers.prefer_example(File.join(gem_checkout_root, MODULAR_GEMFILE_DIR, modular_gemfile))
|
|
36
38
|
dest = File.join(project_root, MODULAR_GEMFILE_DIR, modular_gemfile)
|
|
37
|
-
|
|
39
|
+
existing = File.exist?(dest) ? File.read(dest) : nil
|
|
40
|
+
helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
|
|
41
|
+
existing ? helpers.merge_gemfile_dependencies(content, existing) : content
|
|
42
|
+
end
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
# 4b) gemfiles/modular/style.gemfile with dynamic rubocop constraints
|
|
41
46
|
modular_gemfile = "style.gemfile"
|
|
42
47
|
src = helpers.prefer_example(File.join(gem_checkout_root, MODULAR_GEMFILE_DIR, modular_gemfile))
|
|
43
48
|
dest = File.join(project_root, MODULAR_GEMFILE_DIR, modular_gemfile)
|
|
49
|
+
existing_style = File.exist?(dest) ? File.read(dest) : nil
|
|
44
50
|
if File.basename(src).sub(/\.example\z/, "") == "style.gemfile"
|
|
45
51
|
helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
|
|
46
52
|
# Adjust rubocop-lts constraint based on min_ruby
|
|
@@ -92,10 +98,12 @@ module Kettle
|
|
|
92
98
|
token = "{RUBOCOP|RUBY|GEM}"
|
|
93
99
|
content.gsub!(token, "rubocop-ruby#{rubocop_ruby_gem_version}") if content.include?(token)
|
|
94
100
|
end
|
|
95
|
-
content
|
|
101
|
+
existing_style ? helpers.merge_gemfile_dependencies(content, existing_style) : content
|
|
96
102
|
end
|
|
97
103
|
else
|
|
98
|
-
helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
|
|
104
|
+
helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
|
|
105
|
+
existing_style ? helpers.merge_gemfile_dependencies(content, existing_style) : content
|
|
106
|
+
end
|
|
99
107
|
end
|
|
100
108
|
|
|
101
109
|
# 4c) Copy modular directories with nested/versioned files
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
case node
|
|
57
|
+
when Prism::StringNode then node.unescaped
|
|
58
|
+
when Prism::SymbolNode then node.unescaped
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Extract qualified constant name from a constant node
|
|
63
|
+
# @param node [Prism::Node, nil] Constant node
|
|
64
|
+
# @return [String, nil] Qualified name like "Gem::Specification" or nil
|
|
65
|
+
def extract_const_name(node)
|
|
66
|
+
case node
|
|
67
|
+
when Prism::ConstantReadNode
|
|
68
|
+
node.name.to_s
|
|
69
|
+
when Prism::ConstantPathNode
|
|
70
|
+
parent = extract_const_name(node.parent)
|
|
71
|
+
child = node.name || node.child&.name
|
|
72
|
+
(parent && child) ? "#{parent}::#{child}" : child.to_s
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Find leading comments for a statement node
|
|
77
|
+
# Leading comments are those that appear after the previous statement
|
|
78
|
+
# and before the current statement
|
|
79
|
+
# @param parse_result [Prism::ParseResult] Parse result with comments
|
|
80
|
+
# @param current_stmt [Prism::Node] Current statement node
|
|
81
|
+
# @param prev_stmt [Prism::Node, nil] Previous statement node
|
|
82
|
+
# @param body_node [Prism::Node] Body containing the statements
|
|
83
|
+
# @return [Array<Prism::Comment>] Leading comments
|
|
84
|
+
def find_leading_comments(parse_result, current_stmt, prev_stmt, body_node)
|
|
85
|
+
start_line = prev_stmt ? prev_stmt.location.end_line : body_node.location.start_line
|
|
86
|
+
end_line = current_stmt.location.start_line
|
|
87
|
+
|
|
88
|
+
parse_result.comments.select do |comment|
|
|
89
|
+
comment.location.start_line > start_line &&
|
|
90
|
+
comment.location.start_line < end_line
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Find inline comments for a statement node
|
|
95
|
+
# Inline comments are those that appear on the same line as the statement's end
|
|
96
|
+
# @param parse_result [Prism::ParseResult] Parse result with comments
|
|
97
|
+
# @param stmt [Prism::Node] Statement node
|
|
98
|
+
# @return [Array<Prism::Comment>] Inline comments
|
|
99
|
+
def inline_comments_for_node(parse_result, stmt)
|
|
100
|
+
parse_result.comments.select do |comment|
|
|
101
|
+
comment.location.start_line == stmt.location.end_line &&
|
|
102
|
+
comment.location.start_offset > stmt.location.end_offset
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Convert a Prism AST node to Ruby source code
|
|
107
|
+
# Uses Prism's native slice method which preserves the original source exactly.
|
|
108
|
+
# This is preferable to Unparser for Prism nodes as it maintains original formatting
|
|
109
|
+
# and comments without requiring transformation.
|
|
110
|
+
# @param node [Prism::Node] AST node
|
|
111
|
+
# @return [String] Ruby source code
|
|
112
|
+
def node_to_source(node)
|
|
113
|
+
return "" unless node
|
|
114
|
+
# Prism nodes have a slice method that returns the original source
|
|
115
|
+
node.slice
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Normalize a call node to use parentheses format
|
|
119
|
+
# Converts `gem "foo"` to `gem("foo")` style
|
|
120
|
+
# @param node [Prism::CallNode] Call node
|
|
121
|
+
# @return [String] Normalized source code
|
|
122
|
+
def normalize_call_node(node)
|
|
123
|
+
return node.slice.strip unless node.is_a?(Prism::CallNode)
|
|
124
|
+
|
|
125
|
+
method_name = node.name
|
|
126
|
+
args = node.arguments&.arguments || []
|
|
127
|
+
|
|
128
|
+
if args.empty?
|
|
129
|
+
"#{method_name}()"
|
|
130
|
+
else
|
|
131
|
+
arg_strings = args.map { |arg| normalize_argument(arg) }
|
|
132
|
+
"#{method_name}(#{arg_strings.join(", ")})"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Normalize an argument node to canonical format
|
|
137
|
+
# @param arg [Prism::Node] Argument node
|
|
138
|
+
# @return [String] Normalized argument source
|
|
139
|
+
def normalize_argument(arg)
|
|
140
|
+
case arg
|
|
141
|
+
when Prism::StringNode
|
|
142
|
+
"\"#{arg.unescaped}\""
|
|
143
|
+
when Prism::SymbolNode
|
|
144
|
+
":#{arg.unescaped}"
|
|
145
|
+
when Prism::KeywordHashNode
|
|
146
|
+
# Handle hash arguments like {key: value}
|
|
147
|
+
pairs = arg.elements.map do |assoc|
|
|
148
|
+
key = case assoc.key
|
|
149
|
+
when Prism::SymbolNode then "#{assoc.key.unescaped}:"
|
|
150
|
+
when Prism::StringNode then "\"#{assoc.key.unescaped}\" =>"
|
|
151
|
+
else "#{assoc.key.slice} =>"
|
|
152
|
+
end
|
|
153
|
+
value = normalize_argument(assoc.value)
|
|
154
|
+
"#{key} #{value}"
|
|
155
|
+
end.join(", ")
|
|
156
|
+
pairs
|
|
157
|
+
when Prism::HashNode
|
|
158
|
+
# Handle explicit hash syntax
|
|
159
|
+
pairs = arg.elements.map do |assoc|
|
|
160
|
+
key_part = normalize_argument(assoc.key)
|
|
161
|
+
value_part = normalize_argument(assoc.value)
|
|
162
|
+
"#{key_part} => #{value_part}"
|
|
163
|
+
end.join(", ")
|
|
164
|
+
"{#{pairs}}"
|
|
165
|
+
else
|
|
166
|
+
# For other types (numbers, arrays, etc.), use the original source
|
|
167
|
+
arg.slice.strip
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Check if a node is a specific method call
|
|
172
|
+
# @param node [Prism::Node] Node to check
|
|
173
|
+
# @param method_name [Symbol] Method name to check for
|
|
174
|
+
# @return [Boolean] True if node is a call to the specified method
|
|
175
|
+
def call_to?(node, method_name)
|
|
176
|
+
node.is_a?(Prism::CallNode) && node.name == method_name
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Check if a node is a block call to a specific method
|
|
180
|
+
# @param node [Prism::Node] Node to check
|
|
181
|
+
# @param method_name [Symbol] Method name to check for
|
|
182
|
+
# @return [Boolean] True if node is a block call to the specified method
|
|
183
|
+
def block_call_to?(node, method_name)
|
|
184
|
+
node.is_a?(Prism::CallNode) && node.name == method_name && !node.block.nil?
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "set"
|
|
5
|
+
require "prism"
|
|
6
|
+
require "kettle/dev/prism_utils"
|
|
7
|
+
|
|
8
|
+
module Kettle
|
|
9
|
+
module Dev
|
|
10
|
+
# Prism-based AST merging for templated Ruby files.
|
|
11
|
+
# Handles universal freeze reminders, kettle-dev:freeze blocks, and
|
|
12
|
+
# strategy dispatch (skip/replace/append/merge).
|
|
13
|
+
#
|
|
14
|
+
# Uses Prism for parsing with first-class comment support, enabling
|
|
15
|
+
# preservation of inline and leading comments throughout the merge process.
|
|
16
|
+
module SourceMerger
|
|
17
|
+
FREEZE_START = /#\s*kettle-dev:freeze/i
|
|
18
|
+
FREEZE_END = /#\s*kettle-dev:unfreeze/i
|
|
19
|
+
FREEZE_BLOCK = Regexp.new("(#{FREEZE_START.source}).*?(#{FREEZE_END.source})", Regexp::IGNORECASE | Regexp::MULTILINE)
|
|
20
|
+
FREEZE_REMINDER = <<~RUBY
|
|
21
|
+
# To retain during kettle-dev templating:
|
|
22
|
+
# kettle-dev:freeze
|
|
23
|
+
# # ... your code
|
|
24
|
+
# kettle-dev:unfreeze
|
|
25
|
+
RUBY
|
|
26
|
+
BUG_URL = "https://github.com/kettle-rb/kettle-dev/issues"
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Apply a templating strategy to merge source and destination Ruby files
|
|
31
|
+
#
|
|
32
|
+
# @param strategy [Symbol] Merge strategy - :skip, :replace, :append, or :merge
|
|
33
|
+
# @param src [String] Template source content
|
|
34
|
+
# @param dest [String] Destination file content
|
|
35
|
+
# @param path [String] File path (for error messages)
|
|
36
|
+
# @return [String] Merged content with freeze blocks and comments preserved
|
|
37
|
+
# @raise [Kettle::Dev::Error] If strategy is unknown or merge fails
|
|
38
|
+
# @example
|
|
39
|
+
# SourceMerger.apply(
|
|
40
|
+
# strategy: :merge,
|
|
41
|
+
# src: 'gem "foo"',
|
|
42
|
+
# dest: 'gem "bar"',
|
|
43
|
+
# path: "Gemfile"
|
|
44
|
+
# )
|
|
45
|
+
def apply(strategy:, src:, dest:, path:)
|
|
46
|
+
strategy = normalize_strategy(strategy)
|
|
47
|
+
dest ||= ""
|
|
48
|
+
src_with_reminder = ensure_reminder(src)
|
|
49
|
+
content =
|
|
50
|
+
case strategy
|
|
51
|
+
when :skip
|
|
52
|
+
src_with_reminder
|
|
53
|
+
when :replace
|
|
54
|
+
normalize_source(src_with_reminder)
|
|
55
|
+
when :append
|
|
56
|
+
apply_append(src_with_reminder, dest)
|
|
57
|
+
when :merge
|
|
58
|
+
apply_merge(src_with_reminder, dest)
|
|
59
|
+
else
|
|
60
|
+
raise Kettle::Dev::Error, "Unknown templating strategy '#{strategy}' for #{path}."
|
|
61
|
+
end
|
|
62
|
+
content = merge_freeze_blocks(content, dest)
|
|
63
|
+
content = restore_custom_leading_comments(dest, content)
|
|
64
|
+
ensure_trailing_newline(content)
|
|
65
|
+
rescue StandardError => error
|
|
66
|
+
warn_bug(path, error)
|
|
67
|
+
raise Kettle::Dev::Error, "Template merge failed for #{path}: #{error.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Ensure freeze reminder comment is present at the top of content
|
|
71
|
+
#
|
|
72
|
+
# @param content [String] Ruby source content
|
|
73
|
+
# @return [String] Content with freeze reminder prepended if missing
|
|
74
|
+
# @api private
|
|
75
|
+
def ensure_reminder(content)
|
|
76
|
+
return content if reminder_present?(content)
|
|
77
|
+
insertion_index = reminder_insertion_index(content)
|
|
78
|
+
before = content[0...insertion_index]
|
|
79
|
+
after = content[insertion_index..-1]
|
|
80
|
+
snippet = FREEZE_REMINDER
|
|
81
|
+
snippet += "\n" unless snippet.end_with?("\n\n")
|
|
82
|
+
[before, snippet, after].join
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Normalize source code while preserving formatting
|
|
86
|
+
#
|
|
87
|
+
# @param source [String] Ruby source code
|
|
88
|
+
# @return [String] Normalized source with trailing newline
|
|
89
|
+
# @api private
|
|
90
|
+
def normalize_source(source)
|
|
91
|
+
parse_result = PrismUtils.parse_with_comments(source)
|
|
92
|
+
return ensure_trailing_newline(source) unless parse_result.success?
|
|
93
|
+
|
|
94
|
+
# Use Prism's slice to preserve original formatting
|
|
95
|
+
ensure_trailing_newline(source)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def reminder_present?(content)
|
|
99
|
+
content.include?(FREEZE_REMINDER.lines.first.strip)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def reminder_insertion_index(content)
|
|
103
|
+
cursor = 0
|
|
104
|
+
lines = content.lines
|
|
105
|
+
lines.each do |line|
|
|
106
|
+
break unless shebang?(line) || frozen_comment?(line)
|
|
107
|
+
cursor += line.length
|
|
108
|
+
end
|
|
109
|
+
cursor
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def shebang?(line)
|
|
113
|
+
line.start_with?("#!")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def frozen_comment?(line)
|
|
117
|
+
line.match?(/#\s*frozen_string_literal:/)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Merge kettle-dev:freeze blocks from destination into source content
|
|
121
|
+
# Preserves user customizations wrapped in freeze/unfreeze markers
|
|
122
|
+
#
|
|
123
|
+
# @param src_content [String] Template source content
|
|
124
|
+
# @param dest_content [String] Destination file content
|
|
125
|
+
# @return [String] Merged content with freeze blocks from destination
|
|
126
|
+
# @api private
|
|
127
|
+
def merge_freeze_blocks(src_content, dest_content)
|
|
128
|
+
dest_blocks = freeze_blocks(dest_content)
|
|
129
|
+
return src_content if dest_blocks.empty?
|
|
130
|
+
src_blocks = freeze_blocks(src_content)
|
|
131
|
+
updated = src_content.dup
|
|
132
|
+
# Replace matching freeze sections by textual markers rather than index ranges
|
|
133
|
+
dest_blocks.each do |dest_block|
|
|
134
|
+
marker = dest_block[:text]
|
|
135
|
+
next if updated.include?(marker)
|
|
136
|
+
# If the template had a placeholder block, replace the first occurrence of a freeze stub
|
|
137
|
+
placeholder = src_blocks.find { |blk| blk[:start_marker] == dest_block[:start_marker] }
|
|
138
|
+
if placeholder
|
|
139
|
+
updated.sub!(placeholder[:text], marker)
|
|
140
|
+
else
|
|
141
|
+
updated << "\n" unless updated.end_with?("\n")
|
|
142
|
+
updated << marker
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
updated
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def freeze_blocks(text)
|
|
149
|
+
return [] unless text&.match?(FREEZE_START)
|
|
150
|
+
blocks = []
|
|
151
|
+
text.to_enum(:scan, FREEZE_BLOCK).each do
|
|
152
|
+
match = Regexp.last_match
|
|
153
|
+
start_idx = match&.begin(0)
|
|
154
|
+
end_idx = match&.end(0)
|
|
155
|
+
next unless start_idx && end_idx
|
|
156
|
+
segment = match[0]
|
|
157
|
+
start_marker = segment.lines.first&.strip
|
|
158
|
+
blocks << {range: start_idx...end_idx, text: segment, start_marker: start_marker}
|
|
159
|
+
end
|
|
160
|
+
blocks
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def normalize_strategy(strategy)
|
|
164
|
+
return :skip if strategy.nil?
|
|
165
|
+
strategy.to_s.downcase.strip.to_sym
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def warn_bug(path, error)
|
|
169
|
+
puts "ERROR: kettle-dev templating failed for #{path}: #{error.message}"
|
|
170
|
+
puts "Please file a bug at #{BUG_URL} with the file contents so we can improve the AST merger."
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def ensure_trailing_newline(text)
|
|
174
|
+
return "" if text.nil?
|
|
175
|
+
text.end_with?("\n") ? text : text + "\n"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def apply_append(src_content, dest_content)
|
|
179
|
+
prism_merge(src_content, dest_content) do |src_nodes, dest_nodes, _src_result, _dest_result|
|
|
180
|
+
existing = Set.new(dest_nodes.map { |node| node_signature(node[:node]) })
|
|
181
|
+
appended = dest_nodes.dup
|
|
182
|
+
src_nodes.each do |node_info|
|
|
183
|
+
sig = node_signature(node_info[:node])
|
|
184
|
+
next if existing.include?(sig)
|
|
185
|
+
appended << node_info
|
|
186
|
+
existing << sig
|
|
187
|
+
end
|
|
188
|
+
appended
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def apply_merge(src_content, dest_content)
|
|
193
|
+
prism_merge(src_content, dest_content) do |src_nodes, dest_nodes, _src_result, _dest_result|
|
|
194
|
+
src_map = src_nodes.each_with_object({}) do |node_info, memo|
|
|
195
|
+
sig = node_signature(node_info[:node])
|
|
196
|
+
memo[sig] ||= node_info
|
|
197
|
+
end
|
|
198
|
+
merged = dest_nodes.map do |node_info|
|
|
199
|
+
sig = node_signature(node_info[:node])
|
|
200
|
+
if (src_node_info = src_map[sig])
|
|
201
|
+
merge_node_info(sig, node_info, src_node_info)
|
|
202
|
+
else
|
|
203
|
+
node_info
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
existing = merged.map { |ni| node_signature(ni[:node]) }.to_set
|
|
207
|
+
src_nodes.each do |node_info|
|
|
208
|
+
sig = node_signature(node_info[:node])
|
|
209
|
+
next if existing.include?(sig)
|
|
210
|
+
merged << node_info
|
|
211
|
+
existing << sig
|
|
212
|
+
end
|
|
213
|
+
merged
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def merge_node_info(signature, _dest_node_info, src_node_info)
|
|
218
|
+
return src_node_info unless signature.is_a?(Array)
|
|
219
|
+
case signature[1]
|
|
220
|
+
when :gem_specification
|
|
221
|
+
merge_block_node_info(src_node_info)
|
|
222
|
+
else
|
|
223
|
+
src_node_info
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def merge_block_node_info(src_node_info)
|
|
228
|
+
# For block merging, we need to merge the statements within the block
|
|
229
|
+
# This is complex - for now, prefer template version
|
|
230
|
+
# TODO: Implement deep block statement merging with comment preservation
|
|
231
|
+
src_node_info
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def prism_merge(src_content, dest_content)
|
|
235
|
+
src_result = PrismUtils.parse_with_comments(src_content)
|
|
236
|
+
dest_result = PrismUtils.parse_with_comments(dest_content)
|
|
237
|
+
|
|
238
|
+
src_nodes = extract_nodes_with_comments(src_result)
|
|
239
|
+
dest_nodes = extract_nodes_with_comments(dest_result)
|
|
240
|
+
|
|
241
|
+
merged_nodes = yield(src_nodes, dest_nodes, src_result, dest_result)
|
|
242
|
+
|
|
243
|
+
build_source_from_nodes(merged_nodes)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def extract_nodes_with_comments(parse_result)
|
|
247
|
+
return [] unless parse_result.success?
|
|
248
|
+
|
|
249
|
+
statements = PrismUtils.extract_statements(parse_result.value.statements)
|
|
250
|
+
return [] if statements.empty?
|
|
251
|
+
|
|
252
|
+
statements.map.with_index do |stmt, idx|
|
|
253
|
+
prev_stmt = (idx > 0) ? statements[idx - 1] : nil
|
|
254
|
+
body_node = parse_result.value.statements
|
|
255
|
+
|
|
256
|
+
{
|
|
257
|
+
node: stmt,
|
|
258
|
+
leading_comments: PrismUtils.find_leading_comments(parse_result, stmt, prev_stmt, body_node),
|
|
259
|
+
inline_comments: PrismUtils.inline_comments_for_node(parse_result, stmt),
|
|
260
|
+
}
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def build_source_from_nodes(node_infos)
|
|
265
|
+
return "" if node_infos.empty?
|
|
266
|
+
|
|
267
|
+
lines = []
|
|
268
|
+
node_infos.each do |node_info|
|
|
269
|
+
# Add leading comments
|
|
270
|
+
node_info[:leading_comments].each do |comment|
|
|
271
|
+
lines << comment.slice.rstrip
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Add the node's source
|
|
275
|
+
node_source = PrismUtils.node_to_source(node_info[:node])
|
|
276
|
+
|
|
277
|
+
# Add inline comments on the same line
|
|
278
|
+
if node_info[:inline_comments].any?
|
|
279
|
+
inline = node_info[:inline_comments].map { |c| c.slice.strip }.join(" ")
|
|
280
|
+
node_source = node_source.rstrip + " " + inline
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
lines << node_source
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
lines.join("\n")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def node_signature(node)
|
|
290
|
+
return [:nil] unless node
|
|
291
|
+
|
|
292
|
+
case node
|
|
293
|
+
when Prism::CallNode
|
|
294
|
+
method_name = node.name
|
|
295
|
+
if node.block
|
|
296
|
+
# Block call
|
|
297
|
+
first_arg = PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
|
|
298
|
+
receiver_name = PrismUtils.extract_const_name(node.receiver)
|
|
299
|
+
|
|
300
|
+
if receiver_name == "Gem::Specification" && method_name == :new
|
|
301
|
+
[:block, :gem_specification]
|
|
302
|
+
elsif method_name == :task
|
|
303
|
+
[:block, :task, first_arg]
|
|
304
|
+
elsif method_name == :git_source
|
|
305
|
+
[:block, :git_source, first_arg]
|
|
306
|
+
else
|
|
307
|
+
[:block, method_name, first_arg, node.slice]
|
|
308
|
+
end
|
|
309
|
+
elsif [:source, :git_source, :gem, :eval_gemfile].include?(method_name)
|
|
310
|
+
# Simple call
|
|
311
|
+
first_literal = PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
|
|
312
|
+
[:send, method_name, first_literal]
|
|
313
|
+
else
|
|
314
|
+
[:send, method_name, node.slice]
|
|
315
|
+
end
|
|
316
|
+
else
|
|
317
|
+
# Other node types
|
|
318
|
+
[node.class.name.split("::").last.to_sym, node.slice]
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def restore_custom_leading_comments(dest_content, merged_content)
|
|
323
|
+
block = leading_comment_block(dest_content)
|
|
324
|
+
return merged_content if block.strip.empty?
|
|
325
|
+
return merged_content if merged_content.start_with?(block)
|
|
326
|
+
|
|
327
|
+
# Insert after shebang / frozen string literal comments (same place reminder goes)
|
|
328
|
+
insertion_index = reminder_insertion_index(merged_content)
|
|
329
|
+
block = ensure_trailing_newline(block)
|
|
330
|
+
merged_content.dup.insert(insertion_index, block)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def leading_comment_block(content)
|
|
334
|
+
lines = content.to_s.lines
|
|
335
|
+
collected = []
|
|
336
|
+
lines.each do |line|
|
|
337
|
+
stripped = line.strip
|
|
338
|
+
break unless stripped.empty? || stripped.start_with?("#")
|
|
339
|
+
collected << line
|
|
340
|
+
end
|
|
341
|
+
collected.join
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
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
|
|
@@ -387,6 +388,15 @@ module Kettle
|
|
|
387
388
|
# If anything goes wrong, keep the content as-is rather than failing the task
|
|
388
389
|
end
|
|
389
390
|
|
|
391
|
+
if dest_existed
|
|
392
|
+
begin
|
|
393
|
+
merged = helpers.apply_strategy(c, dest_gemspec)
|
|
394
|
+
c = merged if merged.is_a?(String) && !merged.empty?
|
|
395
|
+
rescue StandardError => e
|
|
396
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
390
400
|
c
|
|
391
401
|
end
|
|
392
402
|
end
|