ast-merge 7.1.9 → 7.1.10

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: 04bdf1ce1f71ec916ca922099d490d0fe67091d20c7a8114dc1b62fedd2a8d5f
4
- data.tar.gz: 722576217cd11484781d8ff0e86ec00ad854a359df87c116749fc33cc369154f
3
+ metadata.gz: 54246c2a0a295278fc701f94b29176973d1a12f0a7241b82d0e0a9490cb4248d
4
+ data.tar.gz: 598d2c26c795c77e0d66d4105885210b0908755aa97e69155e7a8c3a7b1af853
5
5
  SHA512:
6
- metadata.gz: e13565697fb9d3304133b1a3673723d038c1985165c7e2fd7742edfb62bdbc1edf575ba37930a05e3dbfcd25696abd0d5a27efcea8eef1ada2658d33d7ee2a16
7
- data.tar.gz: c05d2fa15953c8158ff8a7bfa08a6a239265b4388593be42b4a4f7cac3b9852862fc878446e96d5c96deb56ec456b50033b59827bef7793c0e3044376321177c
6
+ metadata.gz: 570d218b5cb765f6d61f4bc7169ee1ffca21a032b61ee408e6556b285223a55715486387cb0f73d99abd7d3388bbec67e484f3407f0a2d1adc7fe224c15b26c7
7
+ data.tar.gz: c7868ee942c0a708f8af61c1020b1957433ca8715ee16e4a0c0506ccb5a5f5b8f26a9c9c63cc9f14fb5517147a0e1c504292a397cbdbba494126003dd57213ce
checksums.yaml.gz.sig CHANGED
Binary file
@@ -133,9 +133,9 @@ module Ast
133
133
  operations = normalize_identifiers(values)
134
134
  unknown = operations - OPERATIONS
135
135
  raise InvalidProviderError, "Unknown provider operations: #{unknown.join(', ')}" unless unknown.empty?
136
- unless operations.sort == OPERATIONS.sort
137
- raise InvalidProviderError, "Provider must implement all operations: #{OPERATIONS.join(', ')}"
138
- end
136
+ # Methods remain required so direct unsupported calls fail consistently;
137
+ # advertised capabilities must reflect only implemented operations.
138
+ raise InvalidProviderError, 'Provider must advertise at least one operation' if operations.empty?
139
139
 
140
140
  operations
141
141
  end
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
6
+
7
+ module Ast
8
+ module Merge
9
+ # Opt-in provider adapter for operations implemented by the compiled Rust
10
+ # host. Concrete format gems supply the host method names and metadata.
11
+ class RustHostProvider
12
+ class << self
13
+ def available?(required_methods = [])
14
+ require 'structuredmerge_core' unless defined?(::StructuredmergeCore)
15
+ required_methods.all? { |method_name| ::StructuredmergeCore.respond_to?(method_name) }
16
+ rescue LoadError
17
+ false
18
+ end
19
+ end
20
+
21
+ def initialize(provider_id:, family:, dialect:, package:, package_version:, host_methods:)
22
+ @provider_id = provider_id
23
+ @family = family
24
+ @dialect = dialect
25
+ @package = package
26
+ @package_version = package_version
27
+ @host_methods = host_methods.freeze
28
+ end
29
+
30
+ attr_reader :provider_id, :family
31
+
32
+ def capabilities
33
+ {
34
+ operations: ProviderContract::OPERATIONS,
35
+ dialects: [@dialect],
36
+ backends: [:rust_tslp],
37
+ profiles: [:source_preserving],
38
+ role: :workflow,
39
+ source_preservation: %i[exact_source declaration_fragments line_provenance reparse]
40
+ }.freeze
41
+ end
42
+
43
+ def analyze(request)
44
+ raw = call_host(:analyze, request.fetch(:source), request.fetch(:dialect).to_s)
45
+ return failure(:analyze, request, raw) unless raw.fetch('ok')
46
+
47
+ analysis = raw.fetch('analysis')
48
+ owners = Array(analysis['declarations'])
49
+ owners = Array(analysis['owners']) if owners.empty?
50
+ success(
51
+ :analyze,
52
+ request,
53
+ analysis: {
54
+ backend: :rust_tslp,
55
+ valid: true,
56
+ declarations: owners.map do |owner|
57
+ {
58
+ path: logical_owner_path(owner),
59
+ signature: logical_owner_path(owner),
60
+ source_role: :source,
61
+ line_range: owner.fetch('line_range', line_range(request.fetch(:source), owner['source_fragment']))
62
+ }
63
+ end
64
+ },
65
+ verification: { source_parsed: true, rust_host: true }
66
+ )
67
+ rescue KeyError, JSON::ParserError => e
68
+ failure(:analyze, request, error_payload('parse_error', e.message, 'source'))
69
+ end
70
+
71
+ def diff2(request)
72
+ before = analysis_for(request.fetch(:before_source), request.fetch(:dialect).to_s)
73
+ return failure(:diff2, request, before) unless before.fetch('ok')
74
+
75
+ after = analysis_for(request.fetch(:after_source), request.fetch(:dialect).to_s)
76
+ return failure(:diff2, request, after) unless after.fetch('ok')
77
+
78
+ before_owners = owner_map(
79
+ before,
80
+ source: request.fetch(:before_source),
81
+ source_role: :before
82
+ )
83
+ after_owners = owner_map(
84
+ after,
85
+ source: request.fetch(:after_source),
86
+ source_role: :after
87
+ )
88
+ changes = (before_owners.keys | after_owners.keys).filter_map do |path|
89
+ before_owner = before_owners[path]
90
+ after_owner = after_owners[path]
91
+ next if same_owner?(before_owner, after_owner)
92
+
93
+ change = if before_owners.key?(path)
94
+ after_owners.key?(path) ? :edited : :deleted
95
+ else
96
+ :added
97
+ end
98
+ {
99
+ path: path,
100
+ before: revision_record(before_owner, :before),
101
+ after: revision_record(after_owner, :after),
102
+ change: change
103
+ }
104
+ end
105
+ success(
106
+ :diff2,
107
+ request,
108
+ envelope: {
109
+ changes: changes,
110
+ verification: { before_parsed: true, after_parsed: true, rust_host: true }
111
+ },
112
+ diff: { changes: changes }
113
+ )
114
+ rescue KeyError, JSON::ParserError => e
115
+ failure(:diff2, request, error_payload('parse_error', e.message, 'source'))
116
+ end
117
+
118
+ def merge2(request)
119
+ raw = call_host(
120
+ :merge2,
121
+ request.fetch(:current_source),
122
+ request.fetch(:incoming_source),
123
+ request.fetch(:dialect).to_s
124
+ )
125
+ merge_result(:merge2, request, raw)
126
+ rescue KeyError, JSON::ParserError => e
127
+ failure(:merge2, request, error_payload('parse_error', e.message, 'source'))
128
+ end
129
+
130
+ def merge3(request)
131
+ raw = call_host(
132
+ :merge3,
133
+ request.fetch(:base_source),
134
+ request.fetch(:ours_source),
135
+ request.fetch(:theirs_source),
136
+ request.fetch(:dialect).to_s
137
+ )
138
+ merge_result(:merge3, request, raw)
139
+ rescue KeyError, JSON::ParserError => e
140
+ failure(:merge3, request, error_payload('parse_error', e.message, 'source'))
141
+ end
142
+
143
+ private
144
+
145
+ def host
146
+ require 'structuredmerge_core' unless defined?(::StructuredmergeCore)
147
+ ::StructuredmergeCore
148
+ end
149
+
150
+ def call_host(operation, *args)
151
+ JSON.parse(host.public_send(@host_methods.fetch(operation), *args))
152
+ end
153
+
154
+ def analysis_for(source, dialect)
155
+ call_host(:analyze, source, dialect)
156
+ end
157
+
158
+ def owner_map(raw, source:, source_role:)
159
+ analysis = raw.fetch('analysis')
160
+ declarations = Array(analysis['declarations'])
161
+ unless declarations.empty?
162
+ return declarations.to_h do |declaration|
163
+ path = logical_owner_path(declaration)
164
+ [path, declaration.merge(
165
+ 'path' => path,
166
+ 'line_range' => line_range(source, declaration['text'] || declaration['source_fragment']),
167
+ 'source_role' => source_role
168
+ )]
169
+ end
170
+ end
171
+
172
+ analysis.fetch('owners').to_h do |owner|
173
+ path = logical_owner_path(owner)
174
+ [path, owner.merge(
175
+ 'path' => path,
176
+ 'line_range' => line_range(source, owner['text'] || owner['source_fragment']),
177
+ 'source_role' => source_role
178
+ )]
179
+ end
180
+ end
181
+
182
+ def same_owner?(before_owner, after_owner)
183
+ return true if before_owner.nil? && after_owner.nil?
184
+ return false if before_owner.nil? || after_owner.nil?
185
+
186
+ if before_owner.key?('text') || after_owner.key?('text') ||
187
+ before_owner.key?('source_fragment') || after_owner.key?('source_fragment')
188
+ (before_owner['text'] || before_owner['source_fragment']) ==
189
+ (after_owner['text'] || after_owner['source_fragment'])
190
+ else
191
+ before_owner == after_owner
192
+ end
193
+ end
194
+
195
+ def revision_record(owner, source_role)
196
+ return { present: false, source_role: source_role, line_range: [nil, nil] } unless owner
197
+
198
+ { present: true, source_role: source_role, line_range: owner.fetch('line_range', [nil, nil]) }
199
+ end
200
+
201
+ def line_range(source, fragment)
202
+ return [nil, nil] if fragment.nil?
203
+
204
+ # Host declaration fragments include their terminating newline. The
205
+ # public provider contract reports the declaration's content lines,
206
+ # not the separator owned by the following region.
207
+ content = fragment.sub(/\r?\n+\z/, '')
208
+ start = source.index(fragment)
209
+ return [nil, nil] unless start
210
+
211
+ end_index = start + content.length
212
+ start_line = source[0...start].count("\n") + 1
213
+ end_line = source[0...end_index].count("\n") + 1
214
+ [start_line, end_line]
215
+ end
216
+
217
+ def logical_owner_path(owner)
218
+ match_key = owner['match_key']
219
+ return owner.fetch('path') if match_key.nil? || match_key.empty?
220
+
221
+ kind = if owner['declaration_kind']
222
+ owner['declaration_kind']
223
+ elsif owner['owner_kind'].nil? || owner['owner_kind'] == 'declaration'
224
+ 'function'
225
+ else
226
+ owner.fetch('owner_kind')
227
+ end
228
+ "[:#{kind}, #{match_key.inspect}]"
229
+ end
230
+
231
+ def merge_result(operation, request, raw)
232
+ diagnostics = raw.fetch('diagnostics', []).map { |diagnostic| normalize_diagnostic(diagnostic) }
233
+ conflicts = raw.fetch('conflicts', [])
234
+ success = operation == :merge2 ? raw.fetch('ok') : raw.fetch('outcome') == 'clean'
235
+ envelope = {
236
+ provider: provider_metadata(request),
237
+ profile: { profile_id: request[:profile_id] || :source_preserving },
238
+ diagnostics: diagnostics,
239
+ conflicts: conflicts,
240
+ verification: {
241
+ rust_host: true,
242
+ source_preserving: true,
243
+ base_participated: operation == :merge3
244
+ }
245
+ }
246
+ ProviderResult.build(operation: operation, success: success, envelope: envelope, output: raw['output'])
247
+ end
248
+
249
+ def success(operation, request, envelope: {}, **payload)
250
+ ProviderResult.build(operation: operation, success: true, envelope: {
251
+ provider: provider_metadata(request),
252
+ profile: { profile_id: request[:profile_id] || :source_preserving },
253
+ verification: payload.delete(:verification) || {}
254
+ }.merge(envelope), **payload)
255
+ end
256
+
257
+ def failure(operation, request, raw)
258
+ diagnostics = Array(raw['diagnostics']).map { |diagnostic| normalize_diagnostic(diagnostic) }
259
+ diagnostics = [normalize_diagnostic(raw)] if diagnostics.empty?
260
+ ProviderResult.build(
261
+ operation: operation,
262
+ success: false,
263
+ envelope: {
264
+ provider: provider_metadata(request),
265
+ profile: { profile_id: request[:profile_id] || :source_preserving },
266
+ diagnostics: diagnostics,
267
+ verification: { rust_host: true }
268
+ }
269
+ )
270
+ end
271
+
272
+ def error_payload(category, message, source_role)
273
+ {
274
+ 'diagnostics' => [{
275
+ 'category' => category,
276
+ 'message' => "#{source_role} parse error: #{message}",
277
+ 'severity' => 'error'
278
+ }]
279
+ }
280
+ end
281
+
282
+ def normalize_diagnostic(diagnostic)
283
+ diagnostic.transform_keys(&:to_sym).merge(
284
+ category: diagnostic.fetch('category', 'parse_error').to_sym,
285
+ severity: diagnostic.fetch('severity', 'error').to_sym,
286
+ blocking: true
287
+ )
288
+ end
289
+
290
+ def provider_metadata(request)
291
+ {
292
+ provider_id: provider_id,
293
+ family: family,
294
+ dialect: request[:dialect] || @dialect,
295
+ backend: :rust_tslp,
296
+ package: @package,
297
+ package_version: @package_version
298
+ }
299
+ end
300
+ end
301
+ end
302
+ end
303
+
304
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
@@ -61,6 +61,17 @@ It keeps the source-preserving removal behavior passive while also recording:
61
61
  - which adjacent owners survived
62
62
  - which removed comment/layout attachments should be promoted rather than dropped
63
63
 
64
+ Blank lines at the junction are capped rather than preserved. Removal's
65
+ replacement is always empty, so nothing needs separating from the content that
66
+ follows, and the blank run that preceded the removed range already separates the
67
+ survivors. Re-emitting the removed owner's trailing separator would either
68
+ duplicate it, giving two blank lines at a junction where the source had one, or
69
+ invent a leading blank line when the range started the file. `SplicePlan`
70
+ therefore ignores `preserve_removed_trailing_blank_lines` for an empty
71
+ replacement, and caps any remaining junction run at the longest single
72
+ contributing run so a genuine multi-blank separator that lies outside the removed
73
+ range survives intact.
74
+
64
75
  Minimal example:
65
76
 
66
77
  ```ruby
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module StructuralEdit
6
+ # Blank-line invariants for structural splices and removals.
7
+ #
8
+ # This module owns the separator arithmetic a splice must respect so that
9
+ # editing a line range cannot invent blank lines the source never had. It
10
+ # stays parser-agnostic, working purely on source text the same way
11
+ # +BoundarySupport+ works purely on line ranges and attachments.
12
+ #
13
+ # The two rules here are complementary:
14
+ #
15
+ # - {#missing_trailing_blank_line_chunks} decides which trailing blanks a
16
+ # replacement should re-emit so it is not left butting against the
17
+ # following content.
18
+ # - {#cap_junction_blank_runs} then limits the blank run formed where the
19
+ # spliced head meets the untouched tail, so the two sides of a junction
20
+ # never stack into a longer run than either had.
21
+ #
22
+ # Removal is what makes both rules necessary. A replacement supplies its own
23
+ # text, so the head usually ends in content and no junction run forms.
24
+ # Deletion supplies none, which is exactly when separators on both sides of
25
+ # the removed range become adjacent.
26
+ module BlankLineSupport
27
+ module_function
28
+
29
+ # Trailing blank lines of +text+, as an array of line chunks.
30
+ #
31
+ # The chunks keep their trailing newlines so callers can rejoin them
32
+ # losslessly.
33
+ #
34
+ # @param text [String] source fragment
35
+ # @return [Array<String>] trailing blank lines, in source order
36
+ def trailing_blank_line_chunks(text)
37
+ text.lines.reverse.take_while { |line| line.strip.empty? }.reverse
38
+ end
39
+
40
+ # Leading blank lines of +text+.
41
+ #
42
+ # @param text [String] source fragment
43
+ # @return [Array<String>] leading blank lines, in source order
44
+ def leading_blank_line_chunks(text)
45
+ text.lines.take_while { |line| line.strip.empty? }
46
+ end
47
+
48
+ # Trailing blanks from a removed range that a replacement should re-emit,
49
+ # so the replacement is not left butting against the following content.
50
+ #
51
+ # Returns nothing when the replacement already ends in at least as many
52
+ # blanks as the removed range did, or when the tail opens with a blank and
53
+ # so needs no separator.
54
+ #
55
+ # @param removed_content [String] source removed by the splice
56
+ # @param replacement [String] text substituted for the removed range
57
+ # @param after_content [String] untouched source following the range
58
+ # @return [Array<String>] trailing blank chunks to append to the replacement
59
+ def missing_trailing_blank_line_chunks(removed_content:, replacement:, after_content:)
60
+ removed_chunks = trailing_blank_line_chunks(removed_content)
61
+ kept_chunks = trailing_blank_line_chunks(replacement)
62
+ return [] if removed_chunks.empty?
63
+ return [] if kept_chunks.length >= removed_chunks.length
64
+ return [] if after_content.start_with?("\n")
65
+
66
+ removed_chunks[kept_chunks.length..]
67
+ end
68
+
69
+ # Cap the blank-line run formed where a spliced head meets the untouched
70
+ # tail, so a splice never emits a longer run than any single contributor
71
+ # already had.
72
+ #
73
+ # Capping at the longest contributing run rather than at one is what keeps
74
+ # a source that genuinely separates sections with two blanks intact, and
75
+ # leaves in-place replacement unaffected, since its head ends with the
76
+ # replacement text and forms no junction run at all. This mirrors the
77
+ # excess-blank trimming the *-merge emitters already perform, keeping one
78
+ # rule across the stack instead of a second, weaker one for structural
79
+ # edits.
80
+ #
81
+ # This is a line-count invariant, not full blank-line ownership.
82
+ # Layout::Gap#effective_controller can attribute a shared gap to a
83
+ # surviving owner, but only where the analysis populates gaps. Psych-backed
84
+ # analyses report empty layout attachments for nested mapping entries, so
85
+ # ownership has no data to consult there. Wiring gap ownership into removal
86
+ # needs gap population for those owners first.
87
+ #
88
+ # @param head [String] spliced source up to the junction
89
+ # @param tail [String] untouched source after the junction
90
+ # @param preserved_count [Integer] blanks appended to the replacement
91
+ # @param before_content [String] untouched source before the range
92
+ # @param replacement [String] text substituted for the removed range
93
+ # @return [String] head and tail joined with the junction run capped
94
+ def cap_junction_blank_runs(head:, tail:, preserved_count:, before_content:, replacement:)
95
+ trailing_run = trailing_blank_line_chunks(head).length
96
+ return head + tail if tail.empty? || trailing_run.zero?
97
+
98
+ leading_run = leading_blank_line_chunks(tail).length
99
+ allowed = max_contributing_blank_run(before_content, replacement, preserved_count, leading_run)
100
+ excess = (trailing_run + leading_run) - allowed
101
+ return head + tail unless excess.positive?
102
+
103
+ # +allowed+ counts +leading_run+ among the contributors, so
104
+ # +excess+ is always at most +trailing_run+ and never pops past the
105
+ # head's own blanks. +trailing_run+ is nonzero because of the guard above.
106
+ pop_blank_lines(head, count: excess) + tail
107
+ end
108
+
109
+ # Longest blank run any single contributor brings to a junction.
110
+ #
111
+ # The contributors are the blanks before the range, the blanks the
112
+ # replacement itself ends with, the blanks preserved from the removed
113
+ # range, and the blanks the tail opens with.
114
+ #
115
+ # @return [Integer]
116
+ # @api private
117
+ def max_contributing_blank_run(before_content, replacement, preserved_count, leading_run)
118
+ [
119
+ trailing_blank_line_chunks(before_content).length,
120
+ trailing_blank_line_chunks(replacement).length,
121
+ preserved_count,
122
+ leading_run
123
+ ].max
124
+ end
125
+
126
+ # Drop the last +count+ blank lines from +head+.
127
+ #
128
+ # @return [String]
129
+ # @api private
130
+ def pop_blank_lines(head, count:)
131
+ head_lines = head.lines
132
+ head_lines.pop(count)
133
+ head_lines.join
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -24,6 +24,10 @@ module Ast
24
24
  # @param metadata [Hash] base metadata
25
25
  # @param preserve_removed_trailing_blank_lines [Boolean] whether trailing blank lines should survive in the splice
26
26
  # @param options [Hash] extra metadata merged into +metadata+
27
+ #
28
+ # @note Removal always passes an empty replacement, so trailing blank
29
+ # preservation is not applied; the junction blank run is capped instead.
30
+ # See BlankLineSupport.
27
31
  def initialize(source:, remove_start_line:, remove_end_line:, leading_boundary: nil, trailing_boundary: nil,
28
32
  removed_attachments: [], removed_owners: nil, retained_owners: nil, metadata: {}, preserve_removed_trailing_blank_lines: true, **options)
29
33
  normalized_removed_attachments = Array(removed_attachments).compact.freeze
@@ -39,6 +39,12 @@ module Ast
39
39
  # @param preserve_removed_trailing_blank_lines [Boolean] whether to preserve trailing blank lines from removed content
40
40
  # @param metadata [Hash] base metadata
41
41
  # @param options [Hash] extra metadata merged into +metadata+
42
+ #
43
+ # @note An empty replacement never preserves trailing blanks: a deletion
44
+ # contributes no text to separate from the following content, and the
45
+ # blank run preceding the range already separates the survivors. The
46
+ # junction blank run is also capped at the longest single contributing
47
+ # run. See BlankLineSupport for both rules.
42
48
  def initialize(source:, replacement:, replace_start_line:, replace_end_line:, leading_boundary: nil,
43
49
  trailing_boundary: nil, preserve_removed_trailing_blank_lines: true, metadata: {}, **options)
44
50
  @source = source.to_s
@@ -92,7 +98,9 @@ module Ast
92
98
  #
93
99
  # @return [String]
94
100
  def merged_content
95
- +before_content + replacement_with_preserved_boundary_layout + after_content
101
+ preserved = preserved_trailing_blank_line_chunks
102
+ head = +before_content + replacement_with_preserved_boundary_layout(preserved)
103
+ capped_junction_content(head, after_content, preserved.length)
96
104
  end
97
105
 
98
106
  def changed?
@@ -134,33 +142,52 @@ module Ast
134
142
 
135
143
  private
136
144
 
137
- def replacement_with_preserved_boundary_layout
145
+ def replacement_with_preserved_boundary_layout(preserved_chunks)
138
146
  result = +replacement
139
-
140
- result << missing_trailing_blank_line_chunks.join if preserve_removed_trailing_blank_lines?
147
+ result << preserved_chunks.join unless preserved_chunks.empty?
141
148
 
142
149
  result
143
150
  end
144
151
 
145
- def preserve_removed_trailing_blank_lines?
146
- return false unless preserve_removed_trailing_blank_lines
147
-
148
- !after_content.empty? &&
149
- !missing_trailing_blank_line_chunks.empty?
150
- end
151
-
152
- def missing_trailing_blank_line_chunks
153
- removed_blank_chunks = trailing_blank_line_chunks(removed_content)
154
- replacement_blank_chunks = trailing_blank_line_chunks(replacement)
155
- return [] if removed_blank_chunks.empty?
156
- return [] if replacement_blank_chunks.length >= removed_blank_chunks.length
157
- return [] if after_content.start_with?("\n")
158
-
159
- removed_blank_chunks[replacement_blank_chunks.length..]
152
+ # Trailing blank chunks from the removed range that the replacement should
153
+ # re-emit, so it is not left butting against the following content.
154
+ #
155
+ # An empty replacement preserves nothing: a deletion contributes no text of
156
+ # its own to separate, and the blank run that preceded the range already
157
+ # separates the survivors. Re-emitting the removed owner's trailing
158
+ # separator instead either duplicates it -- two blanks at a junction where
159
+ # the source had one -- or, when the range starts the file, invents a
160
+ # leading blank the source never had. Deleting a job from a GitHub Actions
161
+ # workflow produced the first; deleting a leading block produced the second.
162
+ def preserved_trailing_blank_line_chunks
163
+ return [] unless preserve_removed_trailing_blank_lines
164
+ return [] if replacement.empty?
165
+ return [] if after_content.empty?
166
+
167
+ BlankLineSupport.missing_trailing_blank_line_chunks(
168
+ removed_content: removed_content,
169
+ replacement: replacement,
170
+ after_content: after_content
171
+ )
160
172
  end
161
173
 
162
- def trailing_blank_line_chunks(text)
163
- text.lines.reverse.take_while { |line| line.strip.empty? }.reverse
174
+ # Cap the blank run where the spliced head meets the untouched tail.
175
+ #
176
+ # Removal is what exposes this: a blank on each side of the removed owner
177
+ # was separated by that owner's own text, and deleting it makes the two
178
+ # runs adjacent. Observed in both shapes -- removing a Ruby `appraise`
179
+ # block, whose owner range ends at the statement so the separator on either
180
+ # side survives, and removing a GitHub Actions job, whose range includes
181
+ # the job's trailing separator so it stacks on the blank that preceded it.
182
+ # In each case the source had a single blank and the splice emitted two.
183
+ def capped_junction_content(head, tail, preserved_count)
184
+ BlankLineSupport.cap_junction_blank_runs(
185
+ head: head,
186
+ tail: tail,
187
+ preserved_count: preserved_count,
188
+ before_content: before_content,
189
+ replacement: replacement
190
+ )
164
191
  end
165
192
 
166
193
  def validate_range!
@@ -8,6 +8,7 @@ module Ast
8
8
  # boundaries and splice plans without taking ownership of parser-specific
9
9
  # traversal or post-processing behavior.
10
10
  module StructuralEdit
11
+ autoload :BlankLineSupport, 'ast/merge/structural_edit/blank_line_support'
11
12
  autoload :BoundarySupport, 'ast/merge/structural_edit/boundary_support'
12
13
  autoload :Boundary, 'ast/merge/structural_edit/boundary'
13
14
  autoload :PlanSet, 'ast/merge/structural_edit/plan_set'
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Ast
7
+ module Merge
8
+ # Shared typed transport adaptation. Subclasses select explicit Rust profiles.
9
+ class TypedCoreProvider
10
+ DIALECTS = %i[json jsonc json5].freeze
11
+ ROLES = { analyze: %i[source], diff2: %i[before after], merge2: %i[incoming current],
12
+ merge3: %i[base ours theirs] }.freeze
13
+ VERIFICATION_FIELDS = %i[consumed_source_roles directional_roles_preserved base_participated
14
+ classification_reached output_reparsed structural_equivalence preservation retained_source_regions].freeze
15
+ # Only generated read-only data records are projected. Registry objects,
16
+ # operation controls and executable handles must never be traversed.
17
+ DATA_RECORDS = %w[OperationResult ResultProvider ResultProfile ResultParserSelection
18
+ ResultVerification PreservationProperty ResultSourceRegion ResultRange ResultSpan ResultPoint
19
+ ResultAnalysis ResultDiff ResultChange ResultConflict ResultDiagnostic
20
+ DiagnosticRecord PortableDiagnostic DiagnosticOrigin DiagnosticSourceRef DiagnosticSubjectRef
21
+ ConflictRecord PortableConflict ConflictSubject ConflictSourceAlternative ConflictClassification
22
+ ConflictLocalization ConflictResolution ExactConflictRegion NativeExtension].freeze
23
+
24
+ def self.available?
25
+ require 'structuredmerge_core' unless defined?(::StructuredmergeCore)
26
+ ::StructuredmergeCore.respond_to?(:execute_operation) && TreeHaver::Backends::RustTslp.available?
27
+ rescue LoadError
28
+ false
29
+ end
30
+
31
+ def provider_id = self.class::PROVIDER_ID
32
+ def family = self.class::FAMILY
33
+
34
+ def capabilities
35
+ { operations: self.class::OPERATIONS, dialects: self.class::DIALECTS, backends: [:rust_tslp],
36
+ profiles: [:source_preserving], role: :workflow,
37
+ source_preservation: %i[exact_source byte_spans reparse] }.freeze
38
+ end
39
+
40
+ ROLES.each_key do |operation|
41
+ define_method(operation) { |request| execute(operation, request) }
42
+ end
43
+
44
+ private
45
+
46
+ def core = ::StructuredmergeCore
47
+
48
+ def execute(operation, request)
49
+ unless capabilities[:operations].include?(operation)
50
+ return Ast::Merge::ProviderResult.unsupported(operation: operation,
51
+ message: 'Typed profile does not implement this operation')
52
+ end
53
+
54
+ fields = ROLES.fetch(operation).map { |role| role == :source ? :source : :"#{role}_source" }
55
+ allowed = fields + %i[dialect provider_id family backend profile_id request_id] + additional_fields
56
+ unless (request.keys - allowed).empty? &&
57
+ self.class::DIALECTS.include?(request.fetch(:dialect, default_dialect).to_s.to_sym) &&
58
+ [nil, 'rust_tslp'].include?(request[:backend]&.to_s) &&
59
+ [nil, 'source_preserving'].include?(request[:profile_id]&.to_s) &&
60
+ [nil, provider_id].include?(request[:provider_id]) && [nil, family].include?(request[:family]&.to_s)
61
+ return Ast::Merge::ProviderResult.unsupported(operation: operation,
62
+ message: 'Typed provider cannot honor the requested fields or selectors')
63
+ end
64
+ unless self.class.available?
65
+ return Ast::Merge::ProviderResult.unsupported(operation: operation,
66
+ message: 'structuredmerge-core is unavailable')
67
+ end
68
+
69
+ dialect = request.fetch(:dialect, default_dialect).to_s
70
+ parser_id = TreeHaver::Backends::RustTslp.register_language_parser(parser_language(dialect))
71
+ sources = ROLES.fetch(operation).zip(fields).to_h do |role, field|
72
+ text = request.fetch(field)
73
+ unless text.is_a?(String) && [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].include?(text.encoding)
74
+ raise ArgumentError, 'Source must contain UTF-8 bytes'
75
+ end
76
+
77
+ text = text.dup.force_encoding(Encoding::UTF_8)
78
+ raise ArgumentError, 'Source must be valid UTF-8' unless text.valid_encoding?
79
+
80
+ text.freeze
81
+ [role, core::OperationSource.new(source_id: role.to_s, role: role, content: text,
82
+ encoding: 'utf-8', byte_length: text.bytesize, sha256: Digest::SHA256.hexdigest(text), extra: {})]
83
+ end
84
+ typed_request = core::OperationRequest.new(schema: 'structuredmerge.operation-request/v1',
85
+ request_id: request[:request_id] || "ruby-#{provider_id}-#{operation}", operation: policy(operation, request), sources: sources,
86
+ provider_selection: core::MergeProviderSelection.new(provider_id: self.class::CORE_PROVIDER, family: family,
87
+ dialect: dialect, profile_id: self.class::CORE_PROFILE, required_capabilities: [operation.to_s], extra: {}),
88
+ parser_selection: core::OperationParserSelection.new(backend: parser_id, preference: [], required_capabilities: [], extra: {}),
89
+ extensions: [], metadata: {}, extra: {})
90
+ result = core.execute_operation(typed_request,
91
+ core::ParseLimits.new(max_batch_items: 3, max_input_bytes: 64 * 1024 * 1024, max_nodes: 1_000_000,
92
+ max_diagnostics: 1000))
93
+ project(operation, request, result)
94
+ rescue KeyError, ArgumentError, TypeError, RangeError => e
95
+ Ast::Merge::ProviderResult.invalid_request(operation: operation, message: e.message)
96
+ rescue RuntimeError => e
97
+ Ast::Merge::ProviderResult.build(operation: operation, success: false,
98
+ envelope: { diagnostics: [{ category: :provider_failure, severity: :error, blocking: true, message: e.message }] })
99
+ end
100
+
101
+ def additional_fields = []
102
+ def default_dialect = self.class::DIALECTS.first
103
+ def parser_language(dialect) = dialect == 'json' ? 'json' : 'json5'
104
+ def owner_path(owner) = owner.fetch('path')
105
+
106
+ def policy(operation, _request)
107
+ case operation
108
+ when :analyze
109
+ core::OperationPolicy.from_analyze(core::AnalyzePolicy.new(extra: {}))
110
+ when :diff2
111
+ core::OperationPolicy.from_diff2(core::DiffPolicy.new(comparison_profile: 'exact-source-owners',
112
+ equivalence: ['exact-source'], extra: {}))
113
+ when :merge2
114
+ core::OperationPolicy.from_merge2(core::DirectionalMergePolicy.new(
115
+ directional_merge: 'template-into-current', render_policy: 'source-preserving', extra: {}
116
+ ))
117
+ when :merge3
118
+ core::OperationPolicy.from_merge3(core::ThreeWayMergePolicy.new(render_policy: 'source-preserving',
119
+ fallback_policy: 'none', extra: {}))
120
+ end
121
+ end
122
+
123
+ # Only open metadata values are encoded by the generated binding; never
124
+ # send or decode a whole-operation JSON string.
125
+ def metadata(values)
126
+ values.to_h { |key, value| [key, JSON.parse(value)] }
127
+ end
128
+
129
+ def project(operation, request, result)
130
+ changes = result.changes.map do |change|
131
+ states = metadata(change.role_states)
132
+ { id: change.id, path: change.path, subject_ref: change.subject_ref, change: change.classification.to_sym,
133
+ before: revision(states['before'], :before, change.source_spans), after: revision(states['after'], :after, change.source_spans),
134
+ source_spans: change.source_spans, metadata: metadata(change.metadata) }
135
+ end
136
+ diagnostics = result.diagnostics.map do |record|
137
+ diagnostic = record.canonical || record.migration
138
+ { id: diagnostic.id, code: diagnostic.code, category: diagnostic.category.to_sym,
139
+ severity: diagnostic.severity.to_sym, message: diagnostic.message, blocking: diagnostic.blocking, typed_record: record }
140
+ end
141
+ conflicts = result.conflicts.map do |record|
142
+ canonical = record.canonical
143
+ native = canonical && metadata(canonical.classification.extra)['native_conflict']
144
+ native || { typed_record: record }
145
+ end
146
+ verification = VERIFICATION_FIELDS.to_h { |field| [field, result.verification.public_send(field)] }
147
+ verification[:rust_core] = true
148
+ payload = { output: result.output, conflicted_output: result.conflicted_output, typed_result: result }
149
+ if result.analysis
150
+ facts = metadata(result.analysis.extra)
151
+ payload[:analysis] = { backend: :rust_tslp, valid: true, facts: facts,
152
+ declarations: facts.fetch('owners').map do |owner|
153
+ { path: owner_path(owner), signature: owner_path(owner), source_role: :source,
154
+ line_range: line_range(owner.fetch('span')), byte_range: owner.fetch('span').fetch('range') }
155
+ end }
156
+ end
157
+ payload[:diff] = { changes: changes } if result.diff
158
+ projected = Ast::Merge::ProviderResult.build(operation: operation, success: result.ok,
159
+ envelope: { provider: { provider_id: provider_id, family: family, dialect: request.fetch(:dialect, default_dialect),
160
+ backend: :rust_tslp, package: self.class::PACKAGE, package_version: package_version },
161
+ profile: { profile_id: :source_preserving, core_profile_id: result.profile.profile_id },
162
+ diagnostics: diagnostics, changes: changes, conflicts: conflicts,
163
+ render_report: metadata(result.render_report), verification: verification }, **payload)
164
+ portable(projected)
165
+ end
166
+
167
+ def portable(value)
168
+ case value
169
+ when Hash then value.sort_by { |key, _item| key.to_s }.to_h { |key, item| [key, portable(item)] }
170
+ when Array then value.map { |item| portable(item) }
171
+ when String, Symbol, Numeric, TrueClass, FalseClass, NilClass then value
172
+ else
173
+ name = value.class.name
174
+ unless name&.start_with?('StructuredmergeCore::') && DATA_RECORDS.include?(name.delete_prefix('StructuredmergeCore::'))
175
+ raise TypeError, 'Unsupported typed core data record'
176
+ end
177
+
178
+ value.class.public_instance_methods(false).sort.to_h do |field|
179
+ raise TypeError, 'Unexpected executable method on core data record' unless value.method(field).arity.zero?
180
+
181
+ item = value.public_send(field)
182
+ item = metadata(item) if %i[extra metadata data role_states render_report].include?(field)
183
+ item = item.map { |entry| metadata(entry) } if field == :fallbacks
184
+ item = JSON.parse(item) if field == :payload
185
+ [field, portable(item)]
186
+ end
187
+ end
188
+ end
189
+
190
+ def revision(state, role, source_spans)
191
+ return { present: false, source_role: role, line_range: [nil, nil] } unless state
192
+
193
+ span = state.fetch('owner', state)['span']
194
+ unless span
195
+ # Native-owner diffs carry regions rather than JSON owner records.
196
+ # Line points come from the kernel's typed span, never host text scans.
197
+ typed = source_spans.fetch(role)
198
+ span = { 'range' => { 'start_byte' => typed.range.start_byte, 'end_byte' => typed.range.end_byte },
199
+ 'start_point' => { 'row' => typed.start_point.row, 'column' => typed.start_point.column },
200
+ 'end_point' => { 'row' => typed.end_point.row, 'column' => typed.end_point.column } }
201
+ end
202
+ { present: true, source_role: role, line_range: line_range(span), byte_range: span.fetch('range') }
203
+ end
204
+
205
+ def line_range(span)
206
+ start = span.fetch('start_point')
207
+ finish = span.fetch('end_point')
208
+ end_row = finish.fetch('row')
209
+ end_row -= 1 if finish.fetch('column').zero? && end_row > start.fetch('row')
210
+ [start.fetch('row') + 1, end_row + 1]
211
+ end
212
+ end
213
+ end
214
+ end
@@ -5,7 +5,7 @@ module Ast
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = '7.1.9'
8
+ VERSION = '7.1.10'
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
data/lib/ast/merge.rb CHANGED
@@ -86,6 +86,7 @@ module Ast
86
86
  autoload :PortableBenchmarkContract, 'ast/merge/portable_benchmark_contract'
87
87
  autoload :ProviderContract, 'ast/merge/provider_contract'
88
88
  autoload :ProviderRegistry, 'ast/merge/provider_registry'
89
+ autoload :RustHostProvider, 'ast/merge/rust_host_provider'
89
90
  autoload :ProviderResult, 'ast/merge/provider_result'
90
91
  autoload :SectionTyping, 'ast/merge/section_typing'
91
92
  autoload :SmartMergerBase, 'ast/merge/smart_merger_base'
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ast-merge
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.1.9
4
+ version: 7.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -77,14 +77,14 @@ dependencies:
77
77
  requirements:
78
78
  - - '='
79
79
  - !ruby/object:Gem::Version
80
- version: 7.1.9
80
+ version: 7.1.10
81
81
  type: :runtime
82
82
  prerelease: false
83
83
  version_requirements: !ruby/object:Gem::Requirement
84
84
  requirements:
85
85
  - - '='
86
86
  - !ruby/object:Gem::Version
87
- version: 7.1.9
87
+ version: 7.1.10
88
88
  - !ruby/object:Gem::Dependency
89
89
  name: version_gem
90
90
  requirement: !ruby/object:Gem::Requirement
@@ -111,20 +111,20 @@ dependencies:
111
111
  requirements:
112
112
  - - "~>"
113
113
  - !ruby/object:Gem::Version
114
- version: '3.0'
114
+ version: '3.1'
115
115
  - - ">="
116
116
  - !ruby/object:Gem::Version
117
- version: 3.0.34
117
+ version: 3.1.8
118
118
  type: :development
119
119
  prerelease: false
120
120
  version_requirements: !ruby/object:Gem::Requirement
121
121
  requirements:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
- version: '3.0'
124
+ version: '3.1'
125
125
  - - ">="
126
126
  - !ruby/object:Gem::Version
127
- version: 3.0.34
127
+ version: 3.1.8
128
128
  - !ruby/object:Gem::Dependency
129
129
  name: bundler-audit
130
130
  requirement: !ruby/object:Gem::Requirement
@@ -202,7 +202,7 @@ dependencies:
202
202
  version: '3.2'
203
203
  - - ">="
204
204
  - !ruby/object:Gem::Version
205
- version: 3.2.3
205
+ version: 3.2.5
206
206
  type: :development
207
207
  prerelease: false
208
208
  version_requirements: !ruby/object:Gem::Requirement
@@ -212,7 +212,7 @@ dependencies:
212
212
  version: '3.2'
213
213
  - - ">="
214
214
  - !ruby/object:Gem::Version
215
- version: 3.2.3
215
+ version: 3.2.5
216
216
  - !ruby/object:Gem::Dependency
217
217
  name: kettle-test
218
218
  requirement: !ruby/object:Gem::Requirement
@@ -222,7 +222,7 @@ dependencies:
222
222
  version: '2.0'
223
223
  - - ">="
224
224
  - !ruby/object:Gem::Version
225
- version: 2.0.21
225
+ version: 2.0.23
226
226
  type: :development
227
227
  prerelease: false
228
228
  version_requirements: !ruby/object:Gem::Requirement
@@ -232,7 +232,7 @@ dependencies:
232
232
  version: '2.0'
233
233
  - - ">="
234
234
  - !ruby/object:Gem::Version
235
- version: 2.0.21
235
+ version: 2.0.23
236
236
  - !ruby/object:Gem::Dependency
237
237
  name: turbo_tests2
238
238
  requirement: !ruby/object:Gem::Requirement
@@ -242,7 +242,7 @@ dependencies:
242
242
  version: '3.2'
243
243
  - - ">="
244
244
  - !ruby/object:Gem::Version
245
- version: 3.2.7
245
+ version: 3.2.14
246
246
  type: :development
247
247
  prerelease: false
248
248
  version_requirements: !ruby/object:Gem::Requirement
@@ -252,7 +252,7 @@ dependencies:
252
252
  version: '3.2'
253
253
  - - ">="
254
254
  - !ruby/object:Gem::Version
255
- version: 3.2.7
255
+ version: 3.2.14
256
256
  - !ruby/object:Gem::Dependency
257
257
  name: ruby-progressbar
258
258
  requirement: !ruby/object:Gem::Requirement
@@ -276,7 +276,7 @@ dependencies:
276
276
  version: '1.0'
277
277
  - - ">="
278
278
  - !ruby/object:Gem::Version
279
- version: 1.0.8
279
+ version: 1.0.9
280
280
  type: :development
281
281
  prerelease: false
282
282
  version_requirements: !ruby/object:Gem::Requirement
@@ -286,7 +286,7 @@ dependencies:
286
286
  version: '1.0'
287
287
  - - ">="
288
288
  - !ruby/object:Gem::Version
289
- version: 1.0.8
289
+ version: 1.0.9
290
290
  - !ruby/object:Gem::Dependency
291
291
  name: gitmoji-regex
292
292
  requirement: !ruby/object:Gem::Requirement
@@ -296,7 +296,7 @@ dependencies:
296
296
  version: '2.0'
297
297
  - - ">="
298
298
  - !ruby/object:Gem::Version
299
- version: 2.0.12
299
+ version: 2.0.15
300
300
  type: :development
301
301
  prerelease: false
302
302
  version_requirements: !ruby/object:Gem::Requirement
@@ -306,7 +306,7 @@ dependencies:
306
306
  version: '2.0'
307
307
  - - ">="
308
308
  - !ruby/object:Gem::Version
309
- version: 2.0.12
309
+ version: 2.0.15
310
310
  description: "☯️ Core diagnostics, conformance, planning, reporting, and review-state
311
311
  contracts for Structured Merge."
312
312
  email:
@@ -452,6 +452,7 @@ files:
452
452
  - lib/ast/merge/runtime/root_session_support.rb
453
453
  - lib/ast/merge/runtime/session.rb
454
454
  - lib/ast/merge/runtime/surface.rb
455
+ - lib/ast/merge/rust_host_provider.rb
455
456
  - lib/ast/merge/section_typing.rb
456
457
  - lib/ast/merge/smart_merger_base.rb
457
458
  - lib/ast/merge/source_region_report_support.rb
@@ -462,6 +463,7 @@ files:
462
463
  - lib/ast/merge/source_render/result.rb
463
464
  - lib/ast/merge/structural_edit.rb
464
465
  - lib/ast/merge/structural_edit/README.md
466
+ - lib/ast/merge/structural_edit/blank_line_support.rb
465
467
  - lib/ast/merge/structural_edit/boundary.rb
466
468
  - lib/ast/merge/structural_edit/boundary_support.rb
467
469
  - lib/ast/merge/structural_edit/plan_set.rb
@@ -486,6 +488,7 @@ files:
486
488
  - lib/ast/merge/trailing_groups/alignment_sort.rb
487
489
  - lib/ast/merge/trailing_groups/core.rb
488
490
  - lib/ast/merge/trailing_groups/dest_iterate.rb
491
+ - lib/ast/merge/typed_core_provider.rb
489
492
  - lib/ast/merge/unresolved_policy.rb
490
493
  - lib/ast/merge/unresolved_review_state.rb
491
494
  - lib/ast/merge/unresolved_support.rb
@@ -497,10 +500,10 @@ licenses:
497
500
  - PolyForm-Small-Business-1.0.0
498
501
  metadata:
499
502
  homepage_uri: https://structuredmerge.org
500
- source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.9
501
- changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.9/CHANGELOG.md
503
+ source_code_uri: https://github.com/structuredmerge/structuredmerge-ruby/tree/v7.1.10
504
+ changelog_uri: https://github.com/structuredmerge/structuredmerge-ruby/blob/v7.1.10/CHANGELOG.md
502
505
  bug_tracker_uri: https://github.com/structuredmerge/structuredmerge-ruby/issues
503
- documentation_uri: https://www.rubydoc.info/gems/ast-merge/7.1.9
506
+ documentation_uri: https://www.rubydoc.info/gems/ast-merge/7.1.10
504
507
  funding_uri: https://github.com/sponsors/pboling
505
508
  wiki_uri: https://github.com/structuredmerge/structuredmerge-ruby/wiki
506
509
  news_uri: https://www.railsbling.com/tags/ast-merge
@@ -530,7 +533,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
530
533
  - !ruby/object:Gem::Version
531
534
  version: '0'
532
535
  requirements: []
533
- rubygems_version: 4.0.17
536
+ rubygems_version: 4.0.20
534
537
  specification_version: 4
535
538
  summary: "☯️ Structured Merge AST/core contracts for the Ruby monorepo"
536
539
  test_files: []
metadata.gz.sig CHANGED
Binary file