ruby-merge 7.0.0 → 7.1.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,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'block_binding_support'
4
+
5
+ module Ruby
6
+ module Merge
7
+ module GemspecSupport
8
+ GEMSPEC_VAR_PLACEHOLDER = :__gemspec_var__
9
+
10
+ module_function
11
+
12
+ def effective_receiver(receiver, gemspec_block_var)
13
+ BlockBindingSupport.effective_receiver(receiver, gemspec_block_var, placeholder: GEMSPEC_VAR_PLACEHOLDER)
14
+ end
15
+
16
+ def preferred_block_var(template_var, dest_var)
17
+ BlockBindingSupport.preferred_block_var(template_var, dest_var)
18
+ end
19
+
20
+ def merged_block_var(var, preferred_var)
21
+ BlockBindingSupport.merged_block_var(var, preferred_var)
22
+ end
23
+
24
+ def opening_line_with_preferred_block_var(opening_line, dest_var:, preferred_var:, node_preference:)
25
+ return opening_line unless preferred_var && dest_var && dest_var != preferred_var
26
+ return opening_line unless node_preference == :destination
27
+
28
+ opening_line.sub("|#{dest_var}|", "|#{preferred_var}|")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Merge
5
+ # Ruby magic comment detection and file-header prefix handling.
6
+ module MagicCommentSupport
7
+ MAGIC_COMMENT_PATTERNS = {
8
+ frozen_string_literal: /^frozen_string_literal:\s*(true|false)$/i,
9
+ encoding: /^(encoding|coding):\s*\S+$/i,
10
+ warn_indent: /^warn_indent:\s*(true|false)$/i,
11
+ shareable_constant_value: /^shareable_constant_value:\s*\S+$/i
12
+ }.freeze
13
+
14
+ module_function
15
+
16
+ def magic_comment_type_for_text(text)
17
+ stripped = DocCommentSupport.normalize_comment_content(text)
18
+
19
+ MAGIC_COMMENT_PATTERNS.each do |type, pattern|
20
+ return type if stripped.match?(pattern)
21
+ end
22
+
23
+ nil
24
+ end
25
+
26
+ def comment_only_prefix_info(lines)
27
+ entries = []
28
+ suppressed_line_nums = Set.new
29
+ duplicate_magic_line_nums = Set.new
30
+
31
+ if shebang_line?(lines.first)
32
+ entries << { line_num: 1, text: lines.first.to_s, kind: :shebang }
33
+ suppressed_line_nums << 1
34
+ end
35
+
36
+ header_magic_types = header_magic_comment_types_for_lines(lines)
37
+ seen_magic_types = Set.new
38
+
39
+ header_magic_types.keys.sort.each do |line_num|
40
+ magic_type = header_magic_types[line_num]
41
+ suppressed_line_nums << line_num
42
+ entries << { line_num: line_num, text: lines[line_num - 1].to_s.chomp, kind: :magic }
43
+
44
+ if seen_magic_types.include?(magic_type)
45
+ duplicate_magic_line_nums << line_num
46
+ else
47
+ seen_magic_types << magic_type
48
+ end
49
+ end
50
+
51
+ if header_magic_types.any?
52
+ blank_line_num = header_magic_types.keys.max + 1
53
+
54
+ while blank_line_num <= lines.length && lines[blank_line_num - 1].to_s.rstrip.empty?
55
+ entries << { line_num: blank_line_num, text: lines[blank_line_num - 1].to_s, kind: :blank }
56
+ suppressed_line_nums << blank_line_num
57
+ blank_line_num += 1
58
+ end
59
+ end
60
+
61
+ {
62
+ entries: entries,
63
+ suppressed_line_nums: suppressed_line_nums,
64
+ duplicate_magic_line_nums: duplicate_magic_line_nums,
65
+ header_magic_comment_types: header_magic_types
66
+ }
67
+ end
68
+
69
+ def header_magic_comment_types_for_lines(lines)
70
+ types = {}
71
+ index = shebang_line?(lines.first) ? 1 : 0
72
+ previous_line_num = shebang_line?(lines.first) ? 1 : nil
73
+
74
+ while index < lines.length
75
+ line_num = index + 1
76
+ stripped = lines[index].to_s.rstrip
77
+ break if stripped.empty?
78
+
79
+ expected_line_num = previous_line_num ? previous_line_num + 1 : 1
80
+ break unless line_num == expected_line_num
81
+
82
+ magic_type = magic_comment_type_for_text(stripped)
83
+ break unless magic_type
84
+
85
+ types[line_num] = magic_type
86
+ previous_line_num = line_num
87
+ index += 1
88
+ end
89
+
90
+ types
91
+ end
92
+
93
+ def prefix_comment_line_numbers_for_comments(comments)
94
+ prefix_line_nums = Set.new
95
+ previous_line_num = nil
96
+ index = 0
97
+
98
+ if shebang_comment?(comments.first)
99
+ prefix_line_nums << 1
100
+ previous_line_num = 1
101
+ index = 1
102
+ end
103
+
104
+ while index < comments.length
105
+ comment = comments[index]
106
+ line_num = comment.location.start_line
107
+ expected_line_num = previous_line_num ? previous_line_num + 1 : 1
108
+ break unless line_num == expected_line_num
109
+ break unless magic_comment_type_for_text(comment.slice)
110
+
111
+ prefix_line_nums << line_num
112
+ previous_line_num = line_num
113
+ index += 1
114
+ end
115
+
116
+ prefix_line_nums
117
+ end
118
+
119
+ def shebang_line?(line)
120
+ line.to_s.start_with?('#!')
121
+ end
122
+
123
+ def shebang_comment?(comment)
124
+ comment&.slice&.start_with?('#!')
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Merge
5
+ class MethodSimilarity
6
+ DEFAULT_NAME_WEIGHT = 0.7
7
+ DEFAULT_PARAMS_WEIGHT = 0.3
8
+
9
+ attr_reader :name_weight, :params_weight
10
+
11
+ def initialize(name_weight: DEFAULT_NAME_WEIGHT, params_weight: DEFAULT_PARAMS_WEIGHT)
12
+ @name_weight = name_weight
13
+ @params_weight = params_weight
14
+ end
15
+
16
+ def call(template_name:, template_params:, dest_name:, dest_params:)
17
+ name_score = string_similarity(template_name.to_s, dest_name.to_s)
18
+ param_score = param_similarity(Array(template_params), Array(dest_params))
19
+
20
+ (name_score * name_weight) + (param_score * params_weight)
21
+ end
22
+
23
+ def param_similarity(template_params, dest_params)
24
+ return 1.0 if template_params.empty? && dest_params.empty?
25
+ return 0.0 if template_params.empty? || dest_params.empty?
26
+
27
+ common = (template_params & dest_params).size
28
+ total = [template_params.size, dest_params.size].max
29
+ count_ratio = [template_params.size, dest_params.size].min.to_f / total
30
+ name_match_ratio = common.to_f / total
31
+
32
+ (name_match_ratio * 0.7) + (count_ratio * 0.3)
33
+ end
34
+
35
+ def string_similarity(str1, str2)
36
+ return 1.0 if str1 == str2
37
+ return 0.0 if str1.empty? || str2.empty?
38
+
39
+ distance = levenshtein_distance(str1, str2)
40
+ max_len = [str1.length, str2.length].max
41
+
42
+ 1.0 - (distance.to_f / max_len)
43
+ end
44
+
45
+ def levenshtein_distance(str1, str2)
46
+ return str2.length if str1.empty?
47
+ return str1.length if str2.empty?
48
+
49
+ str1, str2 = str2, str1 if str1.length > str2.length
50
+
51
+ m = str1.length
52
+ n = str2.length
53
+ previous_row = (0..m).to_a
54
+ current_row = Array.new(m + 1, 0)
55
+
56
+ (1..n).each do |j|
57
+ current_row[0] = j
58
+
59
+ (1..m).each do |i|
60
+ cost = str1[i - 1] == str2[j - 1] ? 0 : 1
61
+ current_row[i] = [
62
+ previous_row[i] + 1,
63
+ current_row[i - 1] + 1,
64
+ previous_row[i - 1] + cost
65
+ ].min
66
+ end
67
+
68
+ previous_row, current_row = current_row, previous_row
69
+ end
70
+
71
+ previous_row[m]
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Merge
5
+ # Shared Ruby synthetic node for a balanced coverage-exclusion block.
6
+ #
7
+ # Parser-specific merge gems subclass this when they need native AST location
8
+ # or comment attachment behavior. The structural Ruby semantics live here:
9
+ # nocov blocks follow file preference and match by their inner content.
10
+ class NocovNodeBase
11
+ include Ast::Merge::BlockDirective
12
+
13
+ InvalidStructureError = Class.new(StandardError)
14
+
15
+ Location = Struct.new(:start_line, :end_line) do
16
+ def cover?(line)
17
+ (start_line..end_line).cover?(line)
18
+ end
19
+ end
20
+
21
+ attr_reader :start_line, :end_line, :nodes, :analysis, :start_marker, :close_marker
22
+
23
+ def initialize(start_line:, end_line:, analysis:, nodes: [], start_marker: nil, close_marker: nil)
24
+ @start_line = start_line
25
+ @end_line = end_line
26
+ @analysis = analysis
27
+ @nodes = nodes
28
+ @start_marker = start_marker
29
+ @close_marker = close_marker
30
+ end
31
+
32
+ def kind = :nocov
33
+
34
+ def children = @nodes
35
+
36
+ def merge_policy = nil
37
+
38
+ def location
39
+ @location ||= Location.new(@start_line, @end_line)
40
+ end
41
+
42
+ def slice
43
+ return unless @analysis
44
+
45
+ lines = @analysis.lines
46
+ return unless lines
47
+
48
+ lines[(@start_line - 1)..(@end_line - 1)]&.join
49
+ end
50
+
51
+ def signature
52
+ return [:NocovNode, nil] if @nodes.empty? || @analysis.nil?
53
+
54
+ if @nodes.length == 1
55
+ @analysis.generate_signature(@nodes.first)
56
+ else
57
+ inner_lines = @analysis.lines && @analysis.lines[@start_line..(@end_line - 2)]
58
+ [:nocov_multi, inner_lines&.map(&:strip)&.join("\n")]
59
+ end
60
+ end
61
+
62
+ def merge_type = :nocov_block
63
+
64
+ alias type merge_type
65
+
66
+ def nocov_node? = true
67
+
68
+ def inspect
69
+ "#<#{self.class} lines=#{@start_line}..#{@end_line} nodes=#{@nodes.length}>"
70
+ end
71
+
72
+ alias to_s inspect
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Merge
5
+ # Shared Ruby wrapper for nodes carrying unbalanced or inline nocov markers.
6
+ #
7
+ # The wrapped parser node remains responsible for structural identity; this
8
+ # wrapper only marks it as a Ruby coverage directive participant.
9
+ class NocovWrapperBase
10
+ include Ast::Merge::BlockDirective
11
+
12
+ attr_reader :node, :merge_type
13
+
14
+ def initialize(node, merge_type = :nocov)
15
+ @node = node
16
+ @merge_type = merge_type
17
+ end
18
+
19
+ def kind = :nocov
20
+ def children = []
21
+ def merge_policy = nil
22
+
23
+ def start_line
24
+ @node.location&.start_line
25
+ end
26
+
27
+ def end_line
28
+ @node.location&.end_line
29
+ end
30
+
31
+ def unwrap = @node
32
+
33
+ def location = @node.location
34
+
35
+ def slice = @node.slice
36
+
37
+ def nocov_wrapper? = true
38
+ def nocov_node? = false
39
+ def block_directive? = true
40
+
41
+ def inspect
42
+ "#<#{self.class} merge_type=#{@merge_type.inspect} node=#{@node.inspect}>"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Merge
5
+ class RescueSemantics
6
+ def initialize(source_defined_exception_definitions: [])
7
+ @source_defined_exception_definitions = source_defined_exception_definitions
8
+ end
9
+
10
+ def merge_ordered_clause_types(primary_types, secondary_types)
11
+ ordered = primary_types.dup
12
+
13
+ secondary_types.each_with_index do |clause_type, secondary_index|
14
+ next if ordered.include?(clause_type)
15
+
16
+ previous_shared = secondary_types[0...secondary_index].reverse.find { |type| ordered.include?(type) }
17
+ next_shared = secondary_types[(secondary_index + 1)..]&.find { |type| ordered.include?(type) }
18
+
19
+ if previous_shared
20
+ insert_at = ordered.index(previous_shared) + 1
21
+ ordered.insert(insert_at, clause_type)
22
+ elsif next_shared
23
+ insert_at = ordered.index(next_shared)
24
+ ordered.insert(insert_at, clause_type)
25
+ else
26
+ ordered << clause_type
27
+ end
28
+ end
29
+
30
+ ordered
31
+ end
32
+
33
+ def canonicalize_rescue_clause_order(clause_types)
34
+ rescue_clause_types = clause_types.select { |clause_type| rescue_clause_type?(clause_type) }
35
+ return clause_types if rescue_clause_types.length < 2
36
+
37
+ ordered_rescue_types = rescue_clause_types.dup
38
+
39
+ if rescue_clause_types.any? { |clause_type| broad_rescue_clause_type?(clause_type) } &&
40
+ rescue_clause_types.any? { |clause_type| !broad_rescue_clause_type?(clause_type) }
41
+ specific_rescue_types = ordered_rescue_types.reject { |clause_type| broad_rescue_clause_type?(clause_type) }
42
+ broad_rescue_types = ordered_rescue_types.select { |clause_type| broad_rescue_clause_type?(clause_type) }
43
+ ordered_rescue_types = specific_rescue_types + broad_rescue_types
44
+ end
45
+
46
+ loop do
47
+ swapped = false
48
+
49
+ (0...(ordered_rescue_types.length - 1)).each do |index|
50
+ left_clause_type = ordered_rescue_types[index]
51
+ right_clause_type = ordered_rescue_types[index + 1]
52
+ next unless broader_rescue_clause_type_than?(left_clause_type, right_clause_type)
53
+
54
+ ordered_rescue_types[index] = right_clause_type
55
+ ordered_rescue_types[index + 1] = left_clause_type
56
+ swapped = true
57
+ end
58
+
59
+ break unless swapped
60
+ end
61
+
62
+ clause_types.map do |clause_type|
63
+ rescue_clause_type?(clause_type) ? ordered_rescue_types.shift : clause_type
64
+ end
65
+ end
66
+
67
+ def canonicalize_begin_clause_kind_order(clause_types)
68
+ clause_types.each_with_index
69
+ .sort_by { |(clause_type, index)| [clause_kind_sort_key(clause_type), index] }
70
+ .map(&:first)
71
+ end
72
+
73
+ def rescue_clause_signature(exception_names)
74
+ normalized_exceptions = Array(exception_names).filter_map { |exception| normalize_exception_name(exception) }
75
+ if normalized_exceptions.empty? || normalized_exceptions == ['StandardError']
76
+ [:standard_error]
77
+ else
78
+ normalized_exceptions.sort
79
+ end
80
+ end
81
+
82
+ def rescue_clause_type?(clause_type)
83
+ clause_type.is_a?(Array) && clause_type.first == :rescue_clause
84
+ end
85
+
86
+ def broad_rescue_clause_type?(clause_type)
87
+ rescue_clause_type?(clause_type) && clause_type[1] == [:standard_error]
88
+ end
89
+
90
+ def clause_kind_sort_key(clause_type)
91
+ return 0 if rescue_clause_type?(clause_type)
92
+ return 1 if clause_type == :else_clause
93
+ return 2 if clause_type == :ensure_clause
94
+
95
+ 3
96
+ end
97
+
98
+ def normalize_exception_name(exception_name)
99
+ return 'StandardError' if exception_name == :standard_error
100
+
101
+ name = exception_name.to_s.sub(/\A::/, '')
102
+ name.empty? ? nil : name
103
+ end
104
+
105
+ def qualify_source_constant_name(constant_name, namespace = nil)
106
+ normalized_name = normalize_exception_name(constant_name)
107
+ return if normalized_name.nil?
108
+ return normalized_name if constant_name.to_s.start_with?('::') || namespace.nil? || namespace.empty?
109
+
110
+ "#{namespace}::#{normalized_name}"
111
+ end
112
+
113
+ def source_defined_exception_hierarchy
114
+ @source_defined_exception_hierarchy ||= begin
115
+ definitions = @source_defined_exception_definitions
116
+ defined_names = definitions.map { |definition| definition[:name] }.compact.to_set
117
+
118
+ definitions.each_with_object({}) do |definition, hierarchy|
119
+ next unless definition[:name] && definition[:superclass]
120
+
121
+ superclass_name = if definition[:superclass].to_s.start_with?('::')
122
+ normalize_exception_name(definition[:superclass])
123
+ else
124
+ candidate_name = qualify_source_constant_name(definition[:superclass],
125
+ definition[:namespace])
126
+ defined_names.include?(candidate_name) ? candidate_name : normalize_exception_name(definition[:superclass])
127
+ end
128
+
129
+ hierarchy[definition[:name]] ||= superclass_name if superclass_name
130
+ end
131
+ end
132
+ end
133
+
134
+ def resolve_exception_constant(exception_name)
135
+ return ::StandardError if exception_name == :standard_error
136
+ return unless exception_name.is_a?(String) && !exception_name.empty?
137
+
138
+ exception_name.split('::').reject(&:empty?).inject(Object) { |scope, const_name| scope.const_get(const_name) }
139
+ rescue NameError
140
+ nil
141
+ end
142
+
143
+ def rescue_clause_exception_names(clause_type)
144
+ return [] unless rescue_clause_type?(clause_type)
145
+
146
+ Array(clause_type[1]).filter_map { |exception_name| normalize_exception_name(exception_name) }
147
+ end
148
+
149
+ def rescue_clause_exception_constants(clause_type)
150
+ rescue_clause_exception_names(clause_type).filter_map do |exception_name|
151
+ resolve_exception_constant(exception_name)
152
+ end
153
+ end
154
+
155
+ def exception_constant_covers?(covering_constant, covered_constant)
156
+ return true if covering_constant == covered_constant
157
+
158
+ covered_constant < covering_constant
159
+ rescue StandardError
160
+ false
161
+ end
162
+
163
+ def source_defined_exception_covers?(covering_name, covered_name)
164
+ normalized_covering = normalize_exception_name(covering_name)
165
+ current_name = normalize_exception_name(covered_name)
166
+ return false if normalized_covering.nil? || current_name.nil?
167
+ return true if normalized_covering == current_name
168
+
169
+ while (current_name = source_defined_exception_hierarchy[current_name])
170
+ return true if current_name == normalized_covering
171
+ end
172
+
173
+ false
174
+ end
175
+
176
+ def exception_name_covers?(covering_name, covered_name)
177
+ covering_constant = resolve_exception_constant(covering_name)
178
+ covered_constant = resolve_exception_constant(covered_name)
179
+
180
+ if covering_constant && covered_constant
181
+ exception_constant_covers?(covering_constant, covered_constant)
182
+ else
183
+ source_defined_exception_covers?(covering_name, covered_name)
184
+ end
185
+ end
186
+
187
+ def rescue_clause_covers?(covering_clause_type, covered_clause_type)
188
+ return false unless rescue_clause_type?(covering_clause_type) && rescue_clause_type?(covered_clause_type)
189
+
190
+ covering_names = rescue_clause_exception_names(covering_clause_type)
191
+ covered_names = rescue_clause_exception_names(covered_clause_type)
192
+ return false if covering_names.empty? || covered_names.empty?
193
+
194
+ covered_names.all? do |covered_name|
195
+ covering_names.any? do |covering_name|
196
+ exception_name_covers?(covering_name, covered_name)
197
+ end
198
+ end
199
+ end
200
+
201
+ def broader_rescue_clause_type_than?(left_clause_type, right_clause_type)
202
+ return false unless rescue_clause_type?(left_clause_type) && rescue_clause_type?(right_clause_type)
203
+
204
+ rescue_clause_covers?(left_clause_type, right_clause_type) &&
205
+ !rescue_clause_covers?(right_clause_type, left_clause_type)
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Merge
5
+ module ScaffoldChunkSupport
6
+ ChunkSpec = Struct.new(
7
+ :anchor_type,
8
+ :anchor_value,
9
+ :satellite_patterns,
10
+ :jaccard_threshold,
11
+ :max_lookahead,
12
+ :max_lookbehind,
13
+ keyword_init: true
14
+ )
15
+
16
+ BUNDLER_GEM_TASKS_SPEC = ChunkSpec.new(
17
+ anchor_type: :require_call,
18
+ anchor_value: 'bundler/gem_tasks',
19
+ satellite_patterns: [],
20
+ jaccard_threshold: 0.35,
21
+ max_lookahead: 0,
22
+ max_lookbehind: 0
23
+ )
24
+
25
+ RSPEC_SPEC = ChunkSpec.new(
26
+ anchor_type: :require_call,
27
+ anchor_value: 'rspec/core/rake_task',
28
+ satellite_patterns: ['RSpec::Core::RakeTask.new'],
29
+ jaccard_threshold: 0.35,
30
+ max_lookahead: 5,
31
+ max_lookbehind: 2
32
+ )
33
+
34
+ RUBOCOP_SPEC = ChunkSpec.new(
35
+ anchor_type: :require_call,
36
+ anchor_value: 'rubocop/rake_task',
37
+ satellite_patterns: ['RuboCop::RakeTask.new'],
38
+ jaccard_threshold: 0.35,
39
+ max_lookahead: 5,
40
+ max_lookbehind: 2
41
+ )
42
+
43
+ DEFAULT_TASK_SPEC = ChunkSpec.new(
44
+ anchor_type: :task_call,
45
+ anchor_value: 'default',
46
+ satellite_patterns: [],
47
+ jaccard_threshold: 0.35,
48
+ max_lookahead: 0,
49
+ max_lookbehind: 0
50
+ )
51
+
52
+ ALL_SPECS = [BUNDLER_GEM_TASKS_SPEC, RSPEC_SPEC, RUBOCOP_SPEC, DEFAULT_TASK_SPEC].freeze
53
+
54
+ module_function
55
+
56
+ def jaccard_tokens(text)
57
+ text.scan(/[A-Za-z0-9_]+/).to_set
58
+ end
59
+
60
+ def jaccard(a_set, b_set)
61
+ union = a_set | b_set
62
+ return 0.0 if union.empty?
63
+
64
+ (a_set & b_set).size.to_f / union.size
65
+ end
66
+
67
+ def task_anchor_match?(source, anchor_value, threshold)
68
+ node_tokens = jaccard_tokens(source.to_s)
69
+ pattern_tokens = jaccard_tokens("task #{anchor_value}")
70
+ jaccard(pattern_tokens, node_tokens) >= threshold
71
+ end
72
+ end
73
+ end
74
+ end