ruby-merge 7.1.3 → 7.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa7e44e52e059397107051f945efaceb307742e3e40c36f61bf900ac06dd84e6
4
- data.tar.gz: 1bb04482ae836481586da92f9266f0e03781d351b5ac25eb7eaf7fd83ad4f649
3
+ metadata.gz: e110369618446503145375467571c154f6f20f6a9a9e2ea1aec9fc87c2557618
4
+ data.tar.gz: ec6e31d2f85b299976139c80f2dee4574d6ef70fb721bcb514b36eddd995a5a8
5
5
  SHA512:
6
- metadata.gz: 1828b958a01c2017de25ef21616c5a650cf3483b20804e93c54f9158a40c8eac146bdd6cde14118fae50b2fc6e85b439072a5bce5596cadb0ee2248213fab509
7
- data.tar.gz: 12f7bb144511bc0d9ff4ba0f42241666ce394f416e1e2444fd329930aae8444ce158cbee7a950a5ddccb93398816b466083ab2f40d7faf032825fdb4a934e713
6
+ metadata.gz: b15e47b8e03c0da7a237edb9826ea7c05d4cc7c510cbd9c6d31b32d0a4787a2510d869b86cf6bd7c9b9c836bdcd6d4a9b57a921bb679f74d9a665daac2730a4e
7
+ data.tar.gz: c69a81757def5610a400599c3fa9b3459557b5c88c09be084c37e240cdfa00a8d50f7909bf63cf1b30ccc34ab20890d6fa3c6d4d49bca747246340216c7e1b03
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -267,7 +267,7 @@ If none of the available licenses suit your use case, please [contact us](mailto
267
267
  [📌gitmoji]: https://gitmoji.dev
268
268
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
269
269
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
270
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-1.989-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
270
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-2.006-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
271
271
  [🔐security]: https://github.com/structuredmerge/structuredmerge-ruby/blob/main/SECURITY.md
272
272
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
273
273
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Merge
5
+ # Parser-neutral naming policy for a block-local receiver.
6
+ module BlockBindingSupport
7
+ module_function
8
+
9
+ def effective_receiver(receiver, block_var, placeholder: :__block_binding__)
10
+ block_var && receiver == block_var ? placeholder : receiver
11
+ end
12
+
13
+ def preferred_block_var(template_var, destination_var)
14
+ template_var if template_var && destination_var && template_var != destination_var
15
+ end
16
+
17
+ def merged_block_var(var, preferred_var)
18
+ preferred_var || var
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Merge
5
+ # Detects Ruby comment block directive pairs in source lines.
6
+ #
7
+ # This is Ruby-specific substrate behavior shared by Ruby parser providers.
8
+ # Parser-specific gems may consume the spans and project them into their own
9
+ # node types, but directive token semantics should live here.
10
+ # Directive scanning intentionally keeps the paired-stack validation in one
11
+ # class so every caller receives identical malformed-input diagnostics.
12
+ # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/ClassLength
13
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
14
+ class BlockDirectiveDetector
15
+ Span = Struct.new(:kind, :start_line, :end_line, :open_marker, :close_marker, keyword_init: true)
16
+
17
+ NOCOV_TOKEN = ':nocov:'
18
+ SIMPLECOV_DISABLE_RE = /\A\s*#\s*simplecov\s*:\s*disable\b/i
19
+ SIMPLECOV_ENABLE_RE = /\A\s*#\s*simplecov\s*:\s*enable\b/i
20
+ DIRECTIVE_CONTENT_RE = /\A(?::nocov:|[\w-]+:(?:freeze|unfreeze))\z/i
21
+
22
+ class << self
23
+ def coverage_directive_line?(line, nocov_token: NOCOV_TOKEN)
24
+ stripped = line.to_s.chomp
25
+ stripped.match?(simplecov_disable_re) ||
26
+ stripped.match?(simplecov_enable_re) ||
27
+ stripped.match?(nocov_re(nocov_token))
28
+ end
29
+
30
+ def directive_content?(content)
31
+ DIRECTIVE_CONTENT_RE.match?(content.to_s.strip) ||
32
+ coverage_directive_line?("# #{content}")
33
+ end
34
+
35
+ def simplecov_disable_re
36
+ SIMPLECOV_DISABLE_RE
37
+ end
38
+
39
+ def simplecov_enable_re
40
+ SIMPLECOV_ENABLE_RE
41
+ end
42
+
43
+ def nocov_re(nocov_token = NOCOV_TOKEN)
44
+ /\A\s*#\s?#{Regexp.escape(nocov_token)}\s*\z/i
45
+ end
46
+ end
47
+
48
+ def initialize(lines, freeze_token: nil, nocov_token: NOCOV_TOKEN, source_label: nil)
49
+ @lines = lines
50
+ @freeze_token = freeze_token
51
+ @nocov_token = nocov_token
52
+ @source_label = source_label
53
+ end
54
+
55
+ def detect_spans
56
+ raw_spans = []
57
+ raw_spans.concat(detect_freeze_spans) if @freeze_token
58
+ raw_spans.concat(detect_nocov_spans)
59
+
60
+ validate_no_crossing(raw_spans.sort_by(&:start_line))
61
+ end
62
+
63
+ private
64
+
65
+ def detector_name
66
+ 'ruby-merge'
67
+ end
68
+
69
+ def directive_error_class
70
+ Ast::Merge::Error
71
+ end
72
+
73
+ def warn_prefix
74
+ if @source_label
75
+ "[#{detector_name}] BlockDirectiveDetector (#{@source_label}):"
76
+ else
77
+ "[#{detector_name}] BlockDirectiveDetector:"
78
+ end
79
+ end
80
+
81
+ def report_unbalanced(message)
82
+ raise directive_error_class, "#{warn_prefix} #{message}" if @source_label
83
+
84
+ warn("#{warn_prefix} #{message} - ignoring")
85
+ end
86
+
87
+ def detect_freeze_spans
88
+ freeze_pat = /\A\s*#\s?#{Regexp.escape(@freeze_token)}:freeze\b/i
89
+ unfreeze_pat = /\A\s*#\s?#{Regexp.escape(@freeze_token)}:unfreeze\b/i
90
+
91
+ spans = []
92
+ stack = []
93
+
94
+ @lines.each_with_index do |line, index|
95
+ line_num = index + 1
96
+ stripped = line.to_s.chomp
97
+ if stripped.match?(freeze_pat)
98
+ stack.push({ start_line: line_num, open_marker: stripped })
99
+ elsif stripped.match?(unfreeze_pat)
100
+ if (open = stack.pop)
101
+ spans << Span.new(
102
+ kind: :freeze,
103
+ start_line: open[:start_line],
104
+ end_line: line_num,
105
+ open_marker: open[:open_marker],
106
+ close_marker: stripped
107
+ )
108
+ else
109
+ report_unbalanced("unmatched #{@freeze_token}:unfreeze at line #{line_num}")
110
+ end
111
+ end
112
+ end
113
+
114
+ stack.each do |open|
115
+ report_unbalanced("unclosed #{@freeze_token}:freeze at line #{open[:start_line]}")
116
+ end
117
+
118
+ spans
119
+ end
120
+
121
+ def detect_nocov_spans
122
+ return [] unless @nocov_token
123
+
124
+ nocov_pat = self.class.nocov_re(@nocov_token)
125
+
126
+ spans = []
127
+ stack = []
128
+
129
+ @lines.each_with_index do |line, index|
130
+ line_num = index + 1
131
+ stripped = line.to_s.chomp
132
+ if stripped.match?(self.class.simplecov_disable_re)
133
+ stack.push({ start_line: line_num, open_marker: stripped })
134
+ elsif stripped.match?(self.class.simplecov_enable_re)
135
+ if (open = stack.pop)
136
+ spans << Span.new(
137
+ kind: :nocov,
138
+ start_line: open[:start_line],
139
+ end_line: line_num,
140
+ open_marker: open[:open_marker],
141
+ close_marker: stripped
142
+ )
143
+ else
144
+ report_unbalanced("unmatched simplecov:enable at line #{line_num}")
145
+ end
146
+ elsif stripped.match?(nocov_pat)
147
+ if stack.empty?
148
+ stack.push({ start_line: line_num, open_marker: stripped })
149
+ else
150
+ open = stack.pop
151
+ spans << Span.new(
152
+ kind: :nocov,
153
+ start_line: open[:start_line],
154
+ end_line: line_num,
155
+ open_marker: open[:open_marker],
156
+ close_marker: stripped
157
+ )
158
+ end
159
+ end
160
+ end
161
+
162
+ stack.each do |open|
163
+ report_unbalanced("unclosed coverage directive at line #{open[:start_line]}")
164
+ end
165
+
166
+ spans
167
+ end
168
+
169
+ def validate_no_crossing(spans)
170
+ valid = []
171
+ invalid_indices = Set.new
172
+
173
+ spans.each_with_index do |first, first_index|
174
+ next if invalid_indices.include?(first_index)
175
+
176
+ crossing = false
177
+ spans.each_with_index do |second, second_index|
178
+ next if first_index == second_index || invalid_indices.include?(second_index)
179
+ next unless crossing_spans?(first, second)
180
+
181
+ report_unbalanced(
182
+ "offset-overlapping #{first.kind} block (lines #{first.start_line}..#{first.end_line}) and " \
183
+ "#{second.kind} block (lines #{second.start_line}..#{second.end_line}) - both treated as plain comments"
184
+ )
185
+ invalid_indices.add(first_index)
186
+ invalid_indices.add(second_index)
187
+ crossing = true
188
+ break
189
+ end
190
+
191
+ valid << first unless crossing
192
+ end
193
+
194
+ valid
195
+ end
196
+
197
+ def crossing_spans?(first, second)
198
+ first_crosses_second = first.start_line < second.start_line &&
199
+ first.end_line > second.start_line &&
200
+ first.end_line < second.end_line
201
+ second_crosses_first = second.start_line < first.start_line &&
202
+ second.end_line > first.start_line &&
203
+ second.end_line < first.end_line
204
+ first_crosses_second || second_crosses_first
205
+ end
206
+
207
+ def top_level_spans_only(spans)
208
+ spans.reject do |span|
209
+ spans.any? do |other|
210
+ other != span &&
211
+ other.start_line <= span.start_line &&
212
+ other.end_line >= span.end_line
213
+ end
214
+ end
215
+ end
216
+ end
217
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
218
+ # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/ClassLength
219
+ end
220
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Merge
5
+ # Ruby-specific doc-comment semantics shared by Ruby parser providers.
6
+ module DocCommentSupport
7
+ TAG_PREFIX = /\A@[a-z_]+\b/
8
+ EXAMPLE_TAG = /\A@example\b(?<rest>.*)\z/
9
+ MAGIC_COMMENT_PREFIXES = %w[
10
+ coding
11
+ encoding
12
+ frozen_string_literal
13
+ shareable_constant_value
14
+ typed
15
+ warn_indent
16
+ ].freeze
17
+
18
+ module_function
19
+
20
+ def normalize_comment_content(raw)
21
+ raw.to_s.sub(/\A\s*#\s?/, '').strip
22
+ end
23
+
24
+ def comment_prefix_for(raw)
25
+ raw.to_s[/\A\s*#\s*/] || '# '
26
+ end
27
+
28
+ def doc_comment_content?(raw, magic_comment: false)
29
+ content = normalize_comment_content(raw)
30
+ return false if content.empty?
31
+ return false if BlockDirectiveDetector.directive_content?(content)
32
+ return false if magic_comment || magic_comment_content?(content)
33
+
34
+ true
35
+ end
36
+
37
+ def magic_comment_content?(content)
38
+ MAGIC_COMMENT_PREFIXES.any? { |prefix| content.to_s.start_with?("#{prefix}:") }
39
+ end
40
+
41
+ def declared_example_language(rest)
42
+ match = rest.to_s.strip.match(/\A\[(?<language>[^\]]+)\]/)
43
+ normalize_language(match && match[:language])
44
+ end
45
+
46
+ def declared_example_language_for_tag(content)
47
+ match = EXAMPLE_TAG.match(content.to_s)
48
+ return unless match
49
+
50
+ declared_example_language(match[:rest])
51
+ end
52
+
53
+ def normalize_language(language)
54
+ return if language.nil?
55
+
56
+ normalized = language.to_s.strip.downcase.tr('-', '_')
57
+ return if normalized.empty?
58
+
59
+ normalized
60
+ end
61
+
62
+ def next_tag_index(normalized_lines, start_index)
63
+ normalized_lines.each_with_index do |content, index|
64
+ next if index < start_index
65
+
66
+ return index if TAG_PREFIX.match?(content)
67
+ end
68
+ nil
69
+ end
70
+
71
+ # Example extraction deliberately combines tag scanning and body slicing
72
+ # so the returned indexes remain tied to the original comment entries.
73
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
74
+ def example_blocks(entries)
75
+ normalized = entries.map { |entry| normalize_comment_content(entry[:raw]) }
76
+ normalized.each_with_index.filter_map do |content, tag_index|
77
+ match = EXAMPLE_TAG.match(content)
78
+ next unless match
79
+
80
+ body_start_index = tag_index + 1
81
+ body_end_index = next_tag_index(normalized, body_start_index) || normalized.length
82
+ next if body_start_index >= body_end_index
83
+
84
+ body_entries = entries[body_start_index...body_end_index]
85
+ next if body_entries.nil? || body_entries.empty?
86
+
87
+ {
88
+ tag_index: tag_index,
89
+ tag_line: entries[tag_index][:line],
90
+ tag_text: normalized[tag_index],
91
+ body_start_index: body_start_index,
92
+ body_end_index: body_end_index,
93
+ body_entries: body_entries,
94
+ declared_language: declared_example_language(match[:rest])
95
+ }
96
+ end
97
+ end
98
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'block_binding_support'
4
+
5
+ module Ruby
6
+ module Merge
7
+ # Shared gemspec block-variable normalization for Ruby templating.
8
+ module GemspecSupport
9
+ GEMSPEC_VAR_PLACEHOLDER = :__gemspec_var__
10
+
11
+ module_function
12
+
13
+ def effective_receiver(receiver, gemspec_block_var)
14
+ BlockBindingSupport.effective_receiver(receiver, gemspec_block_var, placeholder: GEMSPEC_VAR_PLACEHOLDER)
15
+ end
16
+
17
+ def preferred_block_var(template_var, dest_var)
18
+ BlockBindingSupport.preferred_block_var(template_var, dest_var)
19
+ end
20
+
21
+ def merged_block_var(var, preferred_var)
22
+ BlockBindingSupport.merged_block_var(var, preferred_var)
23
+ end
24
+
25
+ def opening_line_with_preferred_block_var(opening_line, dest_var:, preferred_var:, node_preference:)
26
+ return opening_line unless preferred_var && dest_var && dest_var != preferred_var
27
+ return opening_line unless node_preference == :destination
28
+
29
+ opening_line.sub("|#{dest_var}|", "|#{preferred_var}|")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Merge
5
+ # Ruby magic comment detection and file-header prefix handling.
6
+ # Header scanning intentionally preserves source order and duplicate entries
7
+ # because those details affect comment ownership during reconstruction.
8
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
9
+ module MagicCommentSupport
10
+ MAGIC_COMMENT_PATTERNS = {
11
+ frozen_string_literal: /^frozen_string_literal:\s*(true|false)$/i,
12
+ encoding: /^(encoding|coding):\s*\S+$/i,
13
+ warn_indent: /^warn_indent:\s*(true|false)$/i,
14
+ shareable_constant_value: /^shareable_constant_value:\s*\S+$/i
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ def magic_comment_type_for_text(text)
20
+ stripped = DocCommentSupport.normalize_comment_content(text)
21
+
22
+ MAGIC_COMMENT_PATTERNS.each do |type, pattern|
23
+ return type if stripped.match?(pattern)
24
+ end
25
+
26
+ nil
27
+ end
28
+
29
+ def comment_only_prefix_info(lines)
30
+ entries = []
31
+ suppressed_line_nums = Set.new
32
+ duplicate_magic_line_nums = Set.new
33
+
34
+ if shebang_line?(lines.first)
35
+ entries << { line_num: 1, text: lines.first.to_s, kind: :shebang }
36
+ suppressed_line_nums << 1
37
+ end
38
+
39
+ header_magic_types = header_magic_comment_types_for_lines(lines)
40
+ seen_magic_types = Set.new
41
+
42
+ header_magic_types.keys.sort.each do |line_num|
43
+ magic_type = header_magic_types[line_num]
44
+ suppressed_line_nums << line_num
45
+ entries << { line_num: line_num, text: lines[line_num - 1].to_s.chomp, kind: :magic }
46
+
47
+ if seen_magic_types.include?(magic_type)
48
+ duplicate_magic_line_nums << line_num
49
+ else
50
+ seen_magic_types << magic_type
51
+ end
52
+ end
53
+
54
+ if header_magic_types.any?
55
+ blank_line_num = header_magic_types.keys.max + 1
56
+
57
+ while blank_line_num <= lines.length && lines[blank_line_num - 1].to_s.rstrip.empty?
58
+ entries << { line_num: blank_line_num, text: lines[blank_line_num - 1].to_s, kind: :blank }
59
+ suppressed_line_nums << blank_line_num
60
+ blank_line_num += 1
61
+ end
62
+ end
63
+
64
+ {
65
+ entries: entries,
66
+ suppressed_line_nums: suppressed_line_nums,
67
+ duplicate_magic_line_nums: duplicate_magic_line_nums,
68
+ header_magic_comment_types: header_magic_types
69
+ }
70
+ end
71
+
72
+ def header_magic_comment_types_for_lines(lines)
73
+ types = {}
74
+ index = shebang_line?(lines.first) ? 1 : 0
75
+ previous_line_num = shebang_line?(lines.first) ? 1 : nil
76
+
77
+ while index < lines.length
78
+ line_num = index + 1
79
+ stripped = lines[index].to_s.rstrip
80
+ break if stripped.empty?
81
+
82
+ expected_line_num = previous_line_num ? previous_line_num + 1 : 1
83
+ break unless line_num == expected_line_num
84
+
85
+ magic_type = magic_comment_type_for_text(stripped)
86
+ break unless magic_type
87
+
88
+ types[line_num] = magic_type
89
+ previous_line_num = line_num
90
+ index += 1
91
+ end
92
+
93
+ types
94
+ end
95
+
96
+ def prefix_comment_line_numbers_for_comments(comments)
97
+ prefix_line_nums = Set.new
98
+ previous_line_num = nil
99
+ index = 0
100
+
101
+ if shebang_comment?(comments.first)
102
+ prefix_line_nums << 1
103
+ previous_line_num = 1
104
+ index = 1
105
+ end
106
+
107
+ while index < comments.length
108
+ comment = comments[index]
109
+ line_num = comment.location.start_line
110
+ expected_line_num = previous_line_num ? previous_line_num + 1 : 1
111
+ break unless line_num == expected_line_num
112
+ break unless magic_comment_type_for_text(comment.slice)
113
+
114
+ prefix_line_nums << line_num
115
+ previous_line_num = line_num
116
+ index += 1
117
+ end
118
+
119
+ prefix_line_nums
120
+ end
121
+
122
+ def shebang_line?(line)
123
+ line.to_s.start_with?('#!')
124
+ end
125
+
126
+ def shebang_comment?(comment)
127
+ comment&.slice&.start_with?('#!')
128
+ end
129
+ end
130
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
131
+ end
132
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Merge
5
+ # Computes stable name/parameter similarity scores for Ruby methods.
6
+ #
7
+ # The dynamic-programming distance calculation is intentionally kept local
8
+ # to this value object so the matching contract stays easy to audit.
9
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
+ class MethodSimilarity
11
+ DEFAULT_NAME_WEIGHT = 0.7
12
+ DEFAULT_PARAMS_WEIGHT = 0.3
13
+
14
+ attr_reader :name_weight, :params_weight
15
+
16
+ def initialize(name_weight: DEFAULT_NAME_WEIGHT, params_weight: DEFAULT_PARAMS_WEIGHT)
17
+ @name_weight = name_weight
18
+ @params_weight = params_weight
19
+ end
20
+
21
+ def call(template_name:, template_params:, dest_name:, dest_params:)
22
+ name_score = string_similarity(template_name.to_s, dest_name.to_s)
23
+ param_score = param_similarity(Array(template_params), Array(dest_params))
24
+
25
+ (name_score * name_weight) + (param_score * params_weight)
26
+ end
27
+
28
+ def param_similarity(template_params, dest_params)
29
+ return 1.0 if template_params.empty? && dest_params.empty?
30
+ return 0.0 if template_params.empty? || dest_params.empty?
31
+
32
+ common = (template_params & dest_params).size
33
+ total = [template_params.size, dest_params.size].max
34
+ count_ratio = [template_params.size, dest_params.size].min.to_f / total
35
+ name_match_ratio = common.to_f / total
36
+
37
+ (name_match_ratio * 0.7) + (count_ratio * 0.3)
38
+ end
39
+
40
+ def string_similarity(str1, str2)
41
+ return 1.0 if str1 == str2
42
+ return 0.0 if str1.empty? || str2.empty?
43
+
44
+ distance = levenshtein_distance(str1, str2)
45
+ max_len = [str1.length, str2.length].max
46
+
47
+ 1.0 - (distance.to_f / max_len)
48
+ end
49
+
50
+ def levenshtein_distance(str1, str2)
51
+ return str2.length if str1.empty?
52
+ return str1.length if str2.empty?
53
+
54
+ str1, str2 = str2, str1 if str1.length > str2.length
55
+
56
+ m = str1.length
57
+ n = str2.length
58
+ previous_row = (0..m).to_a
59
+ current_row = Array.new(m + 1, 0)
60
+
61
+ (1..n).each do |j|
62
+ current_row[0] = j
63
+
64
+ (1..m).each do |i|
65
+ cost = str1[i - 1] == str2[j - 1] ? 0 : 1
66
+ current_row[i] = [
67
+ previous_row[i] + 1,
68
+ current_row[i - 1] + 1,
69
+ previous_row[i - 1] + cost
70
+ ].min
71
+ end
72
+
73
+ previous_row, current_row = current_row, previous_row
74
+ end
75
+
76
+ previous_row[m]
77
+ end
78
+ end
79
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
80
+ end
81
+ end
@@ -0,0 +1,79 @@
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
+ # Nocov nodes preserve source locations and marker text as one structural
11
+ # unit; the initializer mirrors the node's complete serialized shape.
12
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity
13
+ class NocovNodeBase
14
+ include Ast::Merge::BlockDirective
15
+
16
+ InvalidStructureError = Class.new(StandardError)
17
+
18
+ Location = Struct.new(:start_line, :end_line) do
19
+ def cover?(line)
20
+ (start_line..end_line).cover?(line)
21
+ end
22
+ end
23
+
24
+ attr_reader :start_line, :end_line, :nodes, :analysis, :start_marker, :close_marker
25
+
26
+ def initialize(start_line:, end_line:, analysis:, nodes: [], start_marker: nil, close_marker: nil)
27
+ @start_line = start_line
28
+ @end_line = end_line
29
+ @analysis = analysis
30
+ @nodes = nodes
31
+ @start_marker = start_marker
32
+ @close_marker = close_marker
33
+ end
34
+
35
+ def kind = :nocov
36
+
37
+ def children = @nodes
38
+
39
+ def merge_policy = nil
40
+
41
+ def location
42
+ @location ||= Location.new(@start_line, @end_line)
43
+ end
44
+
45
+ def slice
46
+ return unless @analysis
47
+
48
+ lines = @analysis.lines
49
+ return unless lines
50
+
51
+ lines[(@start_line - 1)..(@end_line - 1)]&.join
52
+ end
53
+
54
+ def signature
55
+ return [:NocovNode, nil] if @nodes.empty? || @analysis.nil?
56
+
57
+ if @nodes.length == 1
58
+ @analysis.generate_signature(@nodes.first)
59
+ else
60
+ inner_lines = @analysis.lines && @analysis.lines[@start_line..(@end_line - 2)]
61
+ [:nocov_multi, inner_lines&.map(&:strip)&.join("\n")]
62
+ end
63
+ end
64
+
65
+ def merge_type = :nocov_block
66
+
67
+ alias type merge_type
68
+
69
+ def nocov_node? = true
70
+
71
+ def inspect
72
+ "#<#{self.class} lines=#{@start_line}..#{@end_line} nodes=#{@nodes.length}>"
73
+ end
74
+
75
+ alias to_s inspect
76
+ end
77
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity
78
+ end
79
+ end