ast-crispr 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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +310 -0
- data/lib/ast/crispr/rspec/shared_examples/destination_profile.rb +32 -0
- data/lib/ast/crispr/rspec/shared_examples/match_profile.rb +30 -0
- data/lib/ast/crispr/rspec/shared_examples/operation_profile.rb +30 -0
- data/lib/ast/crispr/rspec/shared_examples/selection_profile.rb +28 -0
- data/lib/ast/crispr/rspec/shared_examples/structure_profile.rb +18 -0
- data/lib/ast/crispr/rspec.rb +7 -0
- data/lib/ast/crispr/version.rb +13 -0
- data/lib/ast/crispr.rb +1871 -0
- data/lib/ast-crispr.rb +9 -0
- data/sig/ast/crispr.rbs +8 -0
- data.tar.gz.sig +0 -0
- metadata +352 -0
- metadata.gz.sig +0 -0
data/lib/ast/crispr.rb
ADDED
|
@@ -0,0 +1,1871 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ast/merge'
|
|
4
|
+
require 'service_actor'
|
|
5
|
+
require_relative 'crispr/version'
|
|
6
|
+
|
|
7
|
+
module Ast
|
|
8
|
+
module Crispr
|
|
9
|
+
PACKAGE_NAME = 'ast-crispr'
|
|
10
|
+
|
|
11
|
+
class Error < StandardError
|
|
12
|
+
attr_reader :code, :details
|
|
13
|
+
|
|
14
|
+
def initialize(message, code: 'ast_crispr_error', details: {}, **options)
|
|
15
|
+
@code = code
|
|
16
|
+
@details = details.merge(options)
|
|
17
|
+
super(message)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Limit
|
|
22
|
+
Constraint = Struct.new(:description, :predicate, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def coerce(limit = nil, **options)
|
|
26
|
+
limit.is_a?(self) ? limit : new(limit, **options)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :constraints
|
|
31
|
+
|
|
32
|
+
def initialize(limit = nil, **options)
|
|
33
|
+
@constraints = normalize(limit, **options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def allows?(count)
|
|
37
|
+
constraints.all? { |constraint| constraint.predicate.call(count) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def describe
|
|
41
|
+
constraints.map(&:description).join(' and ')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def normalize(limit, **options)
|
|
47
|
+
spec = limit.nil? ? options.fetch(:default, { exactly: 1 }) : limit
|
|
48
|
+
|
|
49
|
+
case spec
|
|
50
|
+
when Limit
|
|
51
|
+
spec.constraints.dup
|
|
52
|
+
when Hash
|
|
53
|
+
normalize_hash(spec)
|
|
54
|
+
when Array
|
|
55
|
+
spec.flat_map { |entry| normalize(entry) }
|
|
56
|
+
when String
|
|
57
|
+
[constraint_for_operator(spec)]
|
|
58
|
+
else
|
|
59
|
+
raise Error.new('Unsupported CRISPR limit specification', code: 'ast_crispr_limit_unsupported',
|
|
60
|
+
details: { spec: spec.inspect, limit: spec.inspect })
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def normalize_hash(spec)
|
|
65
|
+
constraints = []
|
|
66
|
+
if spec.key?(:exactly)
|
|
67
|
+
constraints << constraint("== #{spec.fetch(:exactly)}") do |count|
|
|
68
|
+
count == spec.fetch(:exactly)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
if spec.key?(:at_most)
|
|
72
|
+
constraints << constraint("<= #{spec.fetch(:at_most)}") do |count|
|
|
73
|
+
count <= spec.fetch(:at_most)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
if spec.key?(:at_least)
|
|
77
|
+
constraints << constraint(">= #{spec.fetch(:at_least)}") do |count|
|
|
78
|
+
count >= spec.fetch(:at_least)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
if spec.key?(:between)
|
|
82
|
+
constraints << constraint("between #{spec.fetch(:between)}") do |count|
|
|
83
|
+
spec.fetch(:between).cover?(count)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
constraints << constraint('<= 1') { |count| count <= 1 } if spec[:none_or_one]
|
|
87
|
+
if constraints.empty?
|
|
88
|
+
raise Error.new('CRISPR limit must define at least one constraint', code: 'ast_crispr_limit_empty',
|
|
89
|
+
details: { spec: spec.inspect, limit: spec.inspect })
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
constraints
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def constraint_for_operator(spec)
|
|
96
|
+
match = /\A(==|!=|<=|>=|<|>)\s*(\d+)\z/.match(spec.strip)
|
|
97
|
+
unless match
|
|
98
|
+
raise Error.new('Invalid CRISPR limit expression', code: 'ast_crispr_limit_invalid_expression',
|
|
99
|
+
details: { spec: spec.inspect, limit: spec.inspect })
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
operator = match[1]
|
|
103
|
+
value = match[2].to_i
|
|
104
|
+
predicate = lambda do |count|
|
|
105
|
+
case operator
|
|
106
|
+
when '==' then count == value
|
|
107
|
+
when '!=' then count != value
|
|
108
|
+
when '<=' then count <= value
|
|
109
|
+
when '>=' then count >= value
|
|
110
|
+
when '<' then count < value
|
|
111
|
+
when '>' then count > value
|
|
112
|
+
else false
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
constraint("#{operator} #{value}", &predicate)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def constraint(description, &predicate)
|
|
119
|
+
Constraint.new(description: description, predicate: predicate)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class Match
|
|
124
|
+
attr_reader :target, :node, :start_line, :end_line, :metadata
|
|
125
|
+
|
|
126
|
+
def initialize(start_line:, end_line:, target: nil, node: nil, metadata: {}, **options)
|
|
127
|
+
@target = target
|
|
128
|
+
@node = node
|
|
129
|
+
@start_line = Integer(start_line)
|
|
130
|
+
@end_line = Integer(end_line)
|
|
131
|
+
@metadata = metadata.merge(options)
|
|
132
|
+
return unless @end_line < @start_line
|
|
133
|
+
|
|
134
|
+
raise Error.new('CRISPR match end_line must be >= start_line',
|
|
135
|
+
details: { start_line: @start_line, end_line: @end_line })
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def with_target(target)
|
|
139
|
+
return self if self.target.equal?(target)
|
|
140
|
+
|
|
141
|
+
self.class.new(
|
|
142
|
+
target: target,
|
|
143
|
+
node: node,
|
|
144
|
+
start_line: start_line,
|
|
145
|
+
end_line: end_line,
|
|
146
|
+
metadata: metadata
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def line_range
|
|
151
|
+
start_line..end_line
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def slice_from(content)
|
|
155
|
+
lines = content.to_s.lines
|
|
156
|
+
return '' if lines.empty?
|
|
157
|
+
|
|
158
|
+
lines[(start_line - 1)..(end_line - 1)].to_a.join
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def match_profile
|
|
162
|
+
MatchProfile.new(
|
|
163
|
+
start_boundary: metadata.fetch(:start_boundary, :owner_start),
|
|
164
|
+
end_boundary: metadata.fetch(:end_boundary, :owner_end),
|
|
165
|
+
payload_kind: metadata.fetch(:payload_kind, :structural_owner_body),
|
|
166
|
+
metadata: metadata
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
class MatchProfile
|
|
172
|
+
KNOWN_START_BOUNDARIES = {
|
|
173
|
+
owner_start: {
|
|
174
|
+
family: :structural_owner,
|
|
175
|
+
description: "Span starts at the structural owner's boundary"
|
|
176
|
+
},
|
|
177
|
+
comment_region_start: {
|
|
178
|
+
family: :comment_anchor,
|
|
179
|
+
description: 'Span starts at an owning comment-region boundary'
|
|
180
|
+
}
|
|
181
|
+
}.freeze
|
|
182
|
+
KNOWN_END_BOUNDARIES = {
|
|
183
|
+
owner_end: {
|
|
184
|
+
family: :structural_owner,
|
|
185
|
+
description: "Span ends at the structural owner's boundary"
|
|
186
|
+
},
|
|
187
|
+
owner_end_plus_trailing_gap: {
|
|
188
|
+
family: :gap_extension,
|
|
189
|
+
description: 'Span extends past the owner boundary to include trailing blank-line gap'
|
|
190
|
+
}
|
|
191
|
+
}.freeze
|
|
192
|
+
KNOWN_PAYLOAD_KINDS = {
|
|
193
|
+
structural_owner_body: {
|
|
194
|
+
family: :owner_body,
|
|
195
|
+
description: "Span represents a structural owner's body"
|
|
196
|
+
},
|
|
197
|
+
comment_owned_body: {
|
|
198
|
+
family: :comment_owned,
|
|
199
|
+
description: 'Span represents a structural owner body selected through an owning comment marker'
|
|
200
|
+
},
|
|
201
|
+
section_branch: {
|
|
202
|
+
family: :section_branch,
|
|
203
|
+
description: 'Span represents a heading-owned section branch payload'
|
|
204
|
+
}
|
|
205
|
+
}.freeze
|
|
206
|
+
|
|
207
|
+
attr_reader :start_boundary, :end_boundary, :payload_kind, :metadata
|
|
208
|
+
|
|
209
|
+
def initialize(start_boundary:, end_boundary:, payload_kind:, metadata: {}, **options)
|
|
210
|
+
@start_boundary = normalize_start_boundary(start_boundary)
|
|
211
|
+
@end_boundary = normalize_end_boundary(end_boundary)
|
|
212
|
+
@payload_kind = normalize_payload_kind(payload_kind)
|
|
213
|
+
@metadata = metadata.merge(options).freeze
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def start_boundary_metadata
|
|
217
|
+
KNOWN_START_BOUNDARIES[start_boundary]&.dup&.freeze
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def end_boundary_metadata
|
|
221
|
+
KNOWN_END_BOUNDARIES[end_boundary]&.dup&.freeze
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def start_boundary_family
|
|
225
|
+
start_boundary_metadata&.fetch(:family, nil)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def end_boundary_family
|
|
229
|
+
end_boundary_metadata&.fetch(:family, nil)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def known_start_boundary?
|
|
233
|
+
KNOWN_START_BOUNDARIES.key?(start_boundary)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def known_end_boundary?
|
|
237
|
+
KNOWN_END_BOUNDARIES.key?(end_boundary)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def payload_kind_metadata
|
|
241
|
+
KNOWN_PAYLOAD_KINDS[payload_kind]&.dup&.freeze
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def payload_family
|
|
245
|
+
payload_kind_metadata&.fetch(:family, nil)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def known_payload_kind?
|
|
249
|
+
KNOWN_PAYLOAD_KINDS.key?(payload_kind)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def comment_anchored?
|
|
253
|
+
start_boundary == :comment_region_start
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def trailing_gap_extended?
|
|
257
|
+
end_boundary == :owner_end_plus_trailing_gap
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def structural_owner_body?
|
|
261
|
+
payload_kind == :structural_owner_body
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def comment_owned_body?
|
|
265
|
+
payload_kind == :comment_owned_body
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def section_branch?
|
|
269
|
+
payload_kind == :section_branch
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def to_h
|
|
273
|
+
{
|
|
274
|
+
start_boundary: start_boundary,
|
|
275
|
+
start_boundary_family: start_boundary_family,
|
|
276
|
+
known_start_boundary: known_start_boundary?,
|
|
277
|
+
end_boundary: end_boundary,
|
|
278
|
+
end_boundary_family: end_boundary_family,
|
|
279
|
+
known_end_boundary: known_end_boundary?,
|
|
280
|
+
payload_kind: payload_kind,
|
|
281
|
+
payload_family: payload_family,
|
|
282
|
+
known_payload_kind: known_payload_kind?,
|
|
283
|
+
comment_anchored: comment_anchored?,
|
|
284
|
+
trailing_gap_extended: trailing_gap_extended?,
|
|
285
|
+
metadata: metadata
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
private
|
|
290
|
+
|
|
291
|
+
def normalize_start_boundary(value)
|
|
292
|
+
value&.to_sym
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def normalize_end_boundary(value)
|
|
296
|
+
value&.to_sym
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def normalize_payload_kind(value)
|
|
300
|
+
value&.to_sym
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
class DestinationProfile
|
|
305
|
+
KNOWN_RESOLUTION_KINDS = {
|
|
306
|
+
append_fallback: {
|
|
307
|
+
family: :append,
|
|
308
|
+
description: 'Insertion fell back to appending at the end of the document'
|
|
309
|
+
},
|
|
310
|
+
anchor_after_statement: {
|
|
311
|
+
family: :anchored,
|
|
312
|
+
description: 'Insertion resolved a statement anchor and spliced after it'
|
|
313
|
+
}
|
|
314
|
+
}.freeze
|
|
315
|
+
KNOWN_RESOLUTION_SOURCES = {
|
|
316
|
+
none: {
|
|
317
|
+
family: :implicit,
|
|
318
|
+
description: 'No destination object was provided'
|
|
319
|
+
},
|
|
320
|
+
callable: {
|
|
321
|
+
family: :callable,
|
|
322
|
+
description: 'Destination was resolved from a callable'
|
|
323
|
+
},
|
|
324
|
+
selector: {
|
|
325
|
+
family: :selector,
|
|
326
|
+
description: 'Destination was resolved from an owner selector anchor'
|
|
327
|
+
}
|
|
328
|
+
}.freeze
|
|
329
|
+
KNOWN_ANCHOR_BOUNDARIES = {
|
|
330
|
+
none: {
|
|
331
|
+
family: :none,
|
|
332
|
+
description: 'No anchor boundary was used'
|
|
333
|
+
},
|
|
334
|
+
statement_end_plus_following_gap: {
|
|
335
|
+
family: :gap_preserving_statement,
|
|
336
|
+
description: 'Insertion anchored after a statement and preserved its following blank-line gap'
|
|
337
|
+
}
|
|
338
|
+
}.freeze
|
|
339
|
+
|
|
340
|
+
attr_reader :resolution_kind, :resolution_source, :anchor_boundary, :metadata
|
|
341
|
+
|
|
342
|
+
def initialize(resolution_kind:, resolution_source:, anchor_boundary:, used_if_missing: false, metadata: {},
|
|
343
|
+
**options)
|
|
344
|
+
@resolution_kind = normalize_resolution_kind(resolution_kind)
|
|
345
|
+
@resolution_source = normalize_resolution_source(resolution_source)
|
|
346
|
+
@anchor_boundary = normalize_anchor_boundary(anchor_boundary)
|
|
347
|
+
@used_if_missing = used_if_missing ? true : false
|
|
348
|
+
@metadata = metadata.merge(options).freeze
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def resolution_kind_metadata
|
|
352
|
+
KNOWN_RESOLUTION_KINDS[resolution_kind]&.dup&.freeze
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def resolution_source_metadata
|
|
356
|
+
KNOWN_RESOLUTION_SOURCES[resolution_source]&.dup&.freeze
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def anchor_boundary_metadata
|
|
360
|
+
KNOWN_ANCHOR_BOUNDARIES[anchor_boundary]&.dup&.freeze
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def resolution_family
|
|
364
|
+
resolution_kind_metadata&.fetch(:family, nil)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def resolution_source_family
|
|
368
|
+
resolution_source_metadata&.fetch(:family, nil)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def anchor_boundary_family
|
|
372
|
+
anchor_boundary_metadata&.fetch(:family, nil)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def known_resolution_kind?
|
|
376
|
+
KNOWN_RESOLUTION_KINDS.key?(resolution_kind)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def known_resolution_source?
|
|
380
|
+
KNOWN_RESOLUTION_SOURCES.key?(resolution_source)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def known_anchor_boundary?
|
|
384
|
+
KNOWN_ANCHOR_BOUNDARIES.key?(anchor_boundary)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def append_fallback?
|
|
388
|
+
resolution_kind == :append_fallback
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def anchored?
|
|
392
|
+
resolution_kind == :anchor_after_statement
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def callable_resolved?
|
|
396
|
+
resolution_source == :callable
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def selector_resolved?
|
|
400
|
+
resolution_source == :selector
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def used_if_missing?
|
|
404
|
+
@used_if_missing
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def to_h
|
|
408
|
+
{
|
|
409
|
+
resolution_kind: resolution_kind,
|
|
410
|
+
resolution_family: resolution_family,
|
|
411
|
+
known_resolution_kind: known_resolution_kind?,
|
|
412
|
+
resolution_source: resolution_source,
|
|
413
|
+
resolution_source_family: resolution_source_family,
|
|
414
|
+
known_resolution_source: known_resolution_source?,
|
|
415
|
+
anchor_boundary: anchor_boundary,
|
|
416
|
+
anchor_boundary_family: anchor_boundary_family,
|
|
417
|
+
known_anchor_boundary: known_anchor_boundary?,
|
|
418
|
+
used_if_missing: used_if_missing?,
|
|
419
|
+
append_fallback: append_fallback?,
|
|
420
|
+
anchored: anchored?,
|
|
421
|
+
metadata: metadata
|
|
422
|
+
}
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
private
|
|
426
|
+
|
|
427
|
+
def normalize_resolution_kind(value)
|
|
428
|
+
value&.to_sym
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def normalize_resolution_source(value)
|
|
432
|
+
value&.to_sym
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def normalize_anchor_boundary(value)
|
|
436
|
+
value&.to_sym
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
class DestinationResolution
|
|
441
|
+
attr_reader :mode, :anchor, :destination_profile
|
|
442
|
+
|
|
443
|
+
def initialize(mode:, anchor:, destination_profile:)
|
|
444
|
+
@mode = mode&.to_sym
|
|
445
|
+
@anchor = anchor
|
|
446
|
+
@destination_profile = destination_profile
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def append_fallback?
|
|
450
|
+
mode == :append
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
class StructureProfile
|
|
455
|
+
attr_reader :owner_scope, :owner_selector, :supported_comment_regions, :metadata
|
|
456
|
+
|
|
457
|
+
def initialize(owner_scope:, owner_selector:, supported_comment_regions: [], metadata: {}, **options)
|
|
458
|
+
@owner_scope = owner_scope&.to_sym
|
|
459
|
+
@owner_selector = owner_selector&.to_sym
|
|
460
|
+
@supported_comment_regions = Array(supported_comment_regions).map(&:to_sym).freeze
|
|
461
|
+
@metadata = metadata.merge(options).freeze
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def owner_selector_metadata
|
|
465
|
+
Ast::Merge::Ruleset::ProfileVocabulary.owner_selector_metadata(owner_selector)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def owner_selector_family
|
|
469
|
+
owner_selector_metadata&.fetch(:family, nil)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def known_owner_selector?
|
|
473
|
+
Ast::Merge::Ruleset::ProfileVocabulary.known_owner_selector?(owner_selector)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def supports_comment_region?(region)
|
|
477
|
+
supported_comment_regions.include?(region.to_sym)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def to_h
|
|
481
|
+
{
|
|
482
|
+
owner_scope: owner_scope,
|
|
483
|
+
owner_selector: owner_selector,
|
|
484
|
+
owner_selector_family: owner_selector_family,
|
|
485
|
+
known_owner_selector: known_owner_selector?,
|
|
486
|
+
supported_comment_regions: supported_comment_regions,
|
|
487
|
+
metadata: metadata
|
|
488
|
+
}
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
class SelectionProfile
|
|
493
|
+
KNOWN_SELECTION_INTENTS = {
|
|
494
|
+
predicate_filter: {
|
|
495
|
+
family: :predicate,
|
|
496
|
+
description: 'Predicate-based structural owner filtering'
|
|
497
|
+
},
|
|
498
|
+
comment_anchored_owner: {
|
|
499
|
+
family: :comment_anchor,
|
|
500
|
+
description: 'Comment-region marker anchored owner selection'
|
|
501
|
+
},
|
|
502
|
+
section_branch: {
|
|
503
|
+
family: :section_branch,
|
|
504
|
+
description: 'Heading-owned section branch selection'
|
|
505
|
+
}
|
|
506
|
+
}.freeze
|
|
507
|
+
|
|
508
|
+
attr_reader :owner_scope, :selector_kind, :selection_intent, :comment_region, :include_trailing_gap,
|
|
509
|
+
:structure_profile, :metadata
|
|
510
|
+
|
|
511
|
+
def initialize(owner_scope:, selector_kind:, selection_intent:, structure_profile: nil, owner_selector: nil,
|
|
512
|
+
comment_region: nil,
|
|
513
|
+
include_trailing_gap: false, metadata: {}, **options)
|
|
514
|
+
@owner_scope = owner_scope&.to_sym
|
|
515
|
+
@selector_kind = selector_kind&.to_sym
|
|
516
|
+
@selection_intent = selection_intent&.to_sym
|
|
517
|
+
@comment_region = comment_region&.to_sym
|
|
518
|
+
@include_trailing_gap = include_trailing_gap ? true : false
|
|
519
|
+
@structure_profile = structure_profile || StructureProfile.new(
|
|
520
|
+
owner_scope: owner_scope,
|
|
521
|
+
owner_selector: owner_selector || :line_bound_statements,
|
|
522
|
+
supported_comment_regions: [comment_region].compact
|
|
523
|
+
)
|
|
524
|
+
@metadata = metadata.merge(options).freeze
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def selection_intent_metadata
|
|
528
|
+
KNOWN_SELECTION_INTENTS[selection_intent]&.dup&.freeze
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def owner_selector
|
|
532
|
+
structure_profile.owner_selector
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def owner_selector_family
|
|
536
|
+
structure_profile.owner_selector_family
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def selection_intent_family
|
|
540
|
+
selection_intent_metadata&.fetch(:family, nil)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def known_selection_intent?
|
|
544
|
+
KNOWN_SELECTION_INTENTS.key?(selection_intent)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def comment_anchored?
|
|
548
|
+
!comment_region.nil?
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def to_h
|
|
552
|
+
{
|
|
553
|
+
owner_scope: owner_scope,
|
|
554
|
+
owner_selector: structure_profile.owner_selector,
|
|
555
|
+
owner_selector_family: structure_profile.owner_selector_family,
|
|
556
|
+
selector_kind: selector_kind,
|
|
557
|
+
selection_intent: selection_intent,
|
|
558
|
+
selection_intent_family: selection_intent_family,
|
|
559
|
+
known_selection_intent: known_selection_intent?,
|
|
560
|
+
comment_region: comment_region,
|
|
561
|
+
include_trailing_gap: include_trailing_gap,
|
|
562
|
+
comment_anchored: comment_anchored?,
|
|
563
|
+
metadata: metadata
|
|
564
|
+
}
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
class OperationProfile
|
|
569
|
+
KNOWN_OPERATION_KINDS = {
|
|
570
|
+
replace: {
|
|
571
|
+
family: :rewrite,
|
|
572
|
+
description: 'Replace selected content with explicit replacement text'
|
|
573
|
+
},
|
|
574
|
+
merge_replace: {
|
|
575
|
+
family: :rewrite,
|
|
576
|
+
description: 'Replace selected content with the result of a merge-gem slice merge'
|
|
577
|
+
},
|
|
578
|
+
delete: {
|
|
579
|
+
family: :removal,
|
|
580
|
+
description: 'Delete selected content without inserting replacement text'
|
|
581
|
+
},
|
|
582
|
+
delete_batch: {
|
|
583
|
+
family: :removal,
|
|
584
|
+
description: 'Delete content selected by multiple structural selectors in one source pass'
|
|
585
|
+
},
|
|
586
|
+
insert: {
|
|
587
|
+
family: :insertion,
|
|
588
|
+
description: 'Insert explicit text at a destination anchor or append fallback'
|
|
589
|
+
},
|
|
590
|
+
move: {
|
|
591
|
+
family: :relocation,
|
|
592
|
+
description: 'Relocate selected content or explicit replacement text to a destination anchor'
|
|
593
|
+
}
|
|
594
|
+
}.freeze
|
|
595
|
+
KNOWN_REQUIREMENTS = %i[none optional required].freeze
|
|
596
|
+
KNOWN_REPLACEMENT_SOURCES = %i[none explicit_text captured_text_or_explicit merge_template_text].freeze
|
|
597
|
+
|
|
598
|
+
attr_reader :operation_kind, :source_requirement, :destination_requirement, :replacement_source, :metadata
|
|
599
|
+
|
|
600
|
+
def initialize(operation_kind:, source_requirement:, destination_requirement:, replacement_source:,
|
|
601
|
+
captures_source_text: false, supports_if_missing: false, metadata: {}, **options)
|
|
602
|
+
@operation_kind = operation_kind&.to_sym
|
|
603
|
+
@source_requirement = normalize_requirement(source_requirement, :source_requirement)
|
|
604
|
+
@destination_requirement = normalize_requirement(destination_requirement, :destination_requirement)
|
|
605
|
+
@replacement_source = normalize_replacement_source(replacement_source)
|
|
606
|
+
@captures_source_text = captures_source_text ? true : false
|
|
607
|
+
@supports_if_missing = supports_if_missing ? true : false
|
|
608
|
+
@metadata = metadata.merge(options).freeze
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def operation_kind_metadata
|
|
612
|
+
KNOWN_OPERATION_KINDS[operation_kind]&.dup&.freeze
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def operation_family
|
|
616
|
+
operation_kind_metadata&.fetch(:family, nil)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def known_operation_kind?
|
|
620
|
+
KNOWN_OPERATION_KINDS.key?(operation_kind)
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def selects_source?
|
|
624
|
+
source_requirement != :none
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def requires_source?
|
|
628
|
+
source_requirement == :required
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def supports_destination?
|
|
632
|
+
destination_requirement != :none
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def requires_destination?
|
|
636
|
+
destination_requirement == :required
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def captures_source_text?
|
|
640
|
+
@captures_source_text
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def supports_if_missing?
|
|
644
|
+
@supports_if_missing
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def explicit_replacement?
|
|
648
|
+
replacement_source == :explicit_text
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def may_reuse_captured_text?
|
|
652
|
+
replacement_source == :captured_text_or_explicit
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def to_h
|
|
656
|
+
{
|
|
657
|
+
operation_kind: operation_kind,
|
|
658
|
+
operation_family: operation_family,
|
|
659
|
+
known_operation_kind: known_operation_kind?,
|
|
660
|
+
source_requirement: source_requirement,
|
|
661
|
+
destination_requirement: destination_requirement,
|
|
662
|
+
replacement_source: replacement_source,
|
|
663
|
+
captures_source_text: captures_source_text?,
|
|
664
|
+
supports_if_missing: supports_if_missing?,
|
|
665
|
+
metadata: metadata
|
|
666
|
+
}
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
private
|
|
670
|
+
|
|
671
|
+
def normalize_requirement(value, _field_name)
|
|
672
|
+
value&.to_sym
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def normalize_replacement_source(value)
|
|
676
|
+
value&.to_sym
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
module Adapters
|
|
681
|
+
class Null
|
|
682
|
+
def read_ast(document)
|
|
683
|
+
raise Error.new('A CRISPR document adapter is required', details: { source_label: document.source_label })
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def structural_owners(document, owner_scope: :shared_default)
|
|
687
|
+
raise Error.new('A CRISPR document adapter is required',
|
|
688
|
+
details: { source_label: document.source_label, owner_scope: owner_scope })
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def comment_regions_for(document, owner, region: :leading, owner_scope: :shared_default)
|
|
692
|
+
raise Error.new(
|
|
693
|
+
'A CRISPR document adapter is required',
|
|
694
|
+
details: { source_label: document.source_label, owner: owner.inspect, region: region,
|
|
695
|
+
owner_scope: owner_scope }
|
|
696
|
+
)
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def comment_region_text(document, comment_region)
|
|
700
|
+
raise Error.new('A CRISPR document adapter is required',
|
|
701
|
+
details: { source_label: document.source_label, comment_region: comment_region.inspect })
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def structure_profile(owner_scope: :shared_default)
|
|
705
|
+
StructureProfile.new(
|
|
706
|
+
owner_scope: owner_scope,
|
|
707
|
+
owner_selector: owner_scope,
|
|
708
|
+
metadata: { source: :null_adapter }
|
|
709
|
+
)
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
class DocumentContext
|
|
715
|
+
attr_reader :content, :source_label, :metadata, :adapter
|
|
716
|
+
|
|
717
|
+
def initialize(content:, source_label: 'source', adapter: Adapters::Null.new, metadata: {}, **options)
|
|
718
|
+
@content = content.to_s
|
|
719
|
+
@source_label = source_label
|
|
720
|
+
@adapter = adapter
|
|
721
|
+
@metadata = metadata.merge(options)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def lines
|
|
725
|
+
@lines ||= content.lines
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def ast
|
|
729
|
+
@ast ||= adapter.read_ast(self)
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def structural_owners(owner_scope: :shared_default)
|
|
733
|
+
adapter.structural_owners(self, owner_scope: owner_scope)
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def comment_regions_for(owner, region: :leading, owner_scope: :shared_default)
|
|
737
|
+
adapter.comment_regions_for(self, owner, region: region, owner_scope: owner_scope)
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def comment_region_text(comment_region)
|
|
741
|
+
adapter.comment_region_text(self, comment_region)
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def structure_profile(owner_scope: :shared_default)
|
|
745
|
+
if adapter.respond_to?(:structure_profile)
|
|
746
|
+
adapter.structure_profile(owner_scope: owner_scope)
|
|
747
|
+
else
|
|
748
|
+
StructureProfile.new(
|
|
749
|
+
owner_scope: owner_scope,
|
|
750
|
+
owner_selector: owner_scope,
|
|
751
|
+
metadata: { source: :document_context_default }
|
|
752
|
+
)
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def location_slice(location)
|
|
757
|
+
content.byteslice(location.start_offset...location.end_offset).to_s
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def expand_following_gap(line_number)
|
|
761
|
+
last_line = line_number
|
|
762
|
+
last_line += 1 while line_blank?(last_line + 1)
|
|
763
|
+
last_line
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def line_blank?(line_number)
|
|
767
|
+
line = lines[line_number - 1]
|
|
768
|
+
!line.nil? && line.strip.empty?
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
Context = DocumentContext
|
|
773
|
+
|
|
774
|
+
class OwnerSelector
|
|
775
|
+
attr_reader :id, :locate, :owned_span, :anchor, :limit, :metadata
|
|
776
|
+
|
|
777
|
+
def initialize(id:, locate:, owned_span: nil, anchor: nil, limit: nil, metadata: {}, **options)
|
|
778
|
+
@id = id
|
|
779
|
+
@locate = locate
|
|
780
|
+
@owned_span = owned_span
|
|
781
|
+
@anchor = anchor
|
|
782
|
+
@limit = Limit.coerce(limit, default: { exactly: 1 })
|
|
783
|
+
@metadata = metadata.merge(options)
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def locate_matches(context)
|
|
787
|
+
Array(invoke(locate, context)).flatten.compact.map { |candidate| coerce_match(candidate) }
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def resolve_owned_match(context, match)
|
|
791
|
+
candidate = owned_span ? invoke(owned_span, context, match) : match
|
|
792
|
+
coerce_match(candidate).with_target(self)
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
def resolve_anchor(context, match = nil)
|
|
796
|
+
return unless anchor
|
|
797
|
+
|
|
798
|
+
invoke(anchor, context, match)
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
def owner_scope
|
|
802
|
+
metadata.fetch(:owner_scope, :shared_default)
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def structure_profile(context)
|
|
806
|
+
context.structure_profile(owner_scope: owner_scope)
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def selector_kind
|
|
810
|
+
metadata[:selector_kind]&.to_sym
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def selection_intent
|
|
814
|
+
metadata[:selection_intent]&.to_sym
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def comment_region
|
|
818
|
+
metadata[:comment_region]&.to_sym
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def include_trailing_gap?
|
|
822
|
+
metadata[:include_trailing_gap] ? true : false
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
def selection_profile(context)
|
|
826
|
+
SelectionProfile.new(
|
|
827
|
+
owner_scope: owner_scope,
|
|
828
|
+
selector_kind: selector_kind,
|
|
829
|
+
selection_intent: selection_intent,
|
|
830
|
+
comment_region: comment_region,
|
|
831
|
+
include_trailing_gap: include_trailing_gap?,
|
|
832
|
+
structure_profile: structure_profile(context),
|
|
833
|
+
metadata: metadata
|
|
834
|
+
)
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
private
|
|
838
|
+
|
|
839
|
+
def coerce_match(candidate)
|
|
840
|
+
case candidate
|
|
841
|
+
when Match
|
|
842
|
+
candidate.with_target(self)
|
|
843
|
+
when Hash
|
|
844
|
+
Match.new(target: self, **candidate)
|
|
845
|
+
else
|
|
846
|
+
unless candidate.respond_to?(:location) && candidate.location
|
|
847
|
+
raise Error.new('Unsupported CRISPR match result', details: { target: id, candidate: candidate.inspect })
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
Match.new(
|
|
851
|
+
target: self,
|
|
852
|
+
node: candidate,
|
|
853
|
+
start_line: candidate.location.start_line,
|
|
854
|
+
end_line: candidate.location.end_line
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
end
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
def invoke(callable, *args)
|
|
861
|
+
return callable.call(*args) if callable.arity.negative?
|
|
862
|
+
|
|
863
|
+
callable.call(*args.first(callable.arity))
|
|
864
|
+
end
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
Target = OwnerSelector
|
|
868
|
+
|
|
869
|
+
module Selectors
|
|
870
|
+
module_function
|
|
871
|
+
|
|
872
|
+
def owner_filter(id:, limit: nil, owner_scope: :shared_default, include_trailing_gap: false, adapter: nil,
|
|
873
|
+
metadata: {}, &block)
|
|
874
|
+
raise ArgumentError, 'owner_filter requires a block' unless block
|
|
875
|
+
|
|
876
|
+
OwnerSelector.new(
|
|
877
|
+
id: id,
|
|
878
|
+
limit: limit,
|
|
879
|
+
metadata: metadata_for(
|
|
880
|
+
metadata,
|
|
881
|
+
adapter,
|
|
882
|
+
owner_scope: owner_scope,
|
|
883
|
+
selector_kind: :owner_filter,
|
|
884
|
+
selection_intent: :predicate_filter,
|
|
885
|
+
include_trailing_gap: include_trailing_gap
|
|
886
|
+
),
|
|
887
|
+
locate: lambda do |context|
|
|
888
|
+
context.structural_owners(owner_scope: owner_scope).filter_map do |owner|
|
|
889
|
+
next unless owner.respond_to?(:location) && owner.location
|
|
890
|
+
|
|
891
|
+
match = block.call(context, owner)
|
|
892
|
+
next unless match
|
|
893
|
+
|
|
894
|
+
if match.is_a?(Match)
|
|
895
|
+
match
|
|
896
|
+
else
|
|
897
|
+
end_line = owner.location.end_line
|
|
898
|
+
end_line = context.expand_following_gap(end_line) if include_trailing_gap
|
|
899
|
+
base_metadata = {
|
|
900
|
+
start_boundary: :owner_start,
|
|
901
|
+
end_boundary: (include_trailing_gap ? :owner_end_plus_trailing_gap : :owner_end),
|
|
902
|
+
payload_kind: :structural_owner_body
|
|
903
|
+
}
|
|
904
|
+
Match.new(
|
|
905
|
+
node: owner,
|
|
906
|
+
start_line: owner.location.start_line,
|
|
907
|
+
end_line: end_line,
|
|
908
|
+
metadata: base_metadata.merge(match == true ? {} : match.to_h)
|
|
909
|
+
)
|
|
910
|
+
end
|
|
911
|
+
end
|
|
912
|
+
end
|
|
913
|
+
)
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def comment_region_owned_owner(marker:, id: nil, limit: nil, owner_scope: :shared_default,
|
|
917
|
+
comment_region: :leading, include_trailing_gap: true, adapter: nil, metadata: {}, **options)
|
|
918
|
+
marker_text = marker.to_s.rstrip
|
|
919
|
+
OwnerSelector.new(
|
|
920
|
+
id: id || marker_text,
|
|
921
|
+
limit: limit,
|
|
922
|
+
metadata: metadata_for(
|
|
923
|
+
metadata,
|
|
924
|
+
adapter,
|
|
925
|
+
owner_scope: owner_scope,
|
|
926
|
+
comment_region: comment_region,
|
|
927
|
+
selector_kind: :comment_region_owned_owner,
|
|
928
|
+
selection_intent: :comment_anchored_owner,
|
|
929
|
+
include_trailing_gap: include_trailing_gap
|
|
930
|
+
),
|
|
931
|
+
locate: lambda do |context|
|
|
932
|
+
context.structural_owners(owner_scope: owner_scope).filter_map do |owner|
|
|
933
|
+
marker_region = context.comment_regions_for(owner, region: comment_region,
|
|
934
|
+
owner_scope: owner_scope).find do |region|
|
|
935
|
+
context.comment_region_text(region) == marker_text
|
|
936
|
+
end
|
|
937
|
+
next unless marker_region
|
|
938
|
+
|
|
939
|
+
end_line = owner.location.end_line
|
|
940
|
+
end_line = context.expand_following_gap(end_line) if include_trailing_gap
|
|
941
|
+
Match.new(
|
|
942
|
+
node: owner,
|
|
943
|
+
start_line: marker_region.location.start_line,
|
|
944
|
+
end_line: end_line,
|
|
945
|
+
metadata: {
|
|
946
|
+
start_boundary: :comment_region_start,
|
|
947
|
+
end_boundary: (include_trailing_gap ? :owner_end_plus_trailing_gap : :owner_end),
|
|
948
|
+
payload_kind: :comment_owned_body,
|
|
949
|
+
marker: marker_text,
|
|
950
|
+
owner_scope: owner_scope,
|
|
951
|
+
comment_region: comment_region,
|
|
952
|
+
region: marker_region
|
|
953
|
+
}
|
|
954
|
+
)
|
|
955
|
+
end
|
|
956
|
+
end,
|
|
957
|
+
**options
|
|
958
|
+
)
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
def line_block(start_line_text:, end_line_text:, id: nil, limit: nil, include_trailing_gap: false, metadata: {},
|
|
962
|
+
**options)
|
|
963
|
+
OwnerSelector.new(
|
|
964
|
+
id: id || "line_block_#{start_line_text}",
|
|
965
|
+
limit: limit,
|
|
966
|
+
metadata: metadata_for(
|
|
967
|
+
metadata,
|
|
968
|
+
nil,
|
|
969
|
+
owner_scope: :text_lines,
|
|
970
|
+
selector_kind: :line_block,
|
|
971
|
+
selection_intent: :line_marker_block,
|
|
972
|
+
include_trailing_gap: include_trailing_gap
|
|
973
|
+
).merge(options),
|
|
974
|
+
locate: lambda do |context|
|
|
975
|
+
lines = context.content.to_s.lines
|
|
976
|
+
open_lines = lines.each_with_index.filter_map do |line, index|
|
|
977
|
+
index + 1 if line.chomp == start_line_text.to_s
|
|
978
|
+
end
|
|
979
|
+
close_lines = lines.each_with_index.filter_map do |line, index|
|
|
980
|
+
index + 1 if line.chomp == end_line_text.to_s
|
|
981
|
+
end
|
|
982
|
+
start_line = open_lines.first
|
|
983
|
+
end_line = close_lines.reverse.find { |candidate| start_line && candidate >= start_line }
|
|
984
|
+
next [] unless start_line && end_line
|
|
985
|
+
|
|
986
|
+
end_line = context.expand_following_gap(end_line) if include_trailing_gap
|
|
987
|
+
[
|
|
988
|
+
Match.new(
|
|
989
|
+
start_line: start_line,
|
|
990
|
+
end_line: end_line,
|
|
991
|
+
metadata: {
|
|
992
|
+
start_boundary: :owner_start,
|
|
993
|
+
end_boundary: (include_trailing_gap ? :owner_end_plus_trailing_gap : :owner_end),
|
|
994
|
+
payload_kind: :structural_owner_body,
|
|
995
|
+
start_line_text: start_line_text,
|
|
996
|
+
end_line_text: end_line_text
|
|
997
|
+
}
|
|
998
|
+
)
|
|
999
|
+
]
|
|
1000
|
+
end
|
|
1001
|
+
)
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def metadata_for(metadata, adapter, **options)
|
|
1005
|
+
payload = metadata.merge(options)
|
|
1006
|
+
adapter ? payload.merge(adapter: adapter) : payload
|
|
1007
|
+
end
|
|
1008
|
+
private_class_method :metadata_for
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
Targets = Selectors
|
|
1012
|
+
|
|
1013
|
+
module OperationSupport
|
|
1014
|
+
private
|
|
1015
|
+
|
|
1016
|
+
def normalize_matches(target, context)
|
|
1017
|
+
matches = target.locate_matches(context)
|
|
1018
|
+
enforce_limit!(target, matches.size)
|
|
1019
|
+
matches.map { |match| target.resolve_owned_match(context, match) }
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
def context_for(content:, source_label:, target: nil)
|
|
1023
|
+
adapter = target&.metadata&.[](:adapter)
|
|
1024
|
+
DocumentContext.new(content: content, source_label: source_label, adapter: adapter || Adapters::Null.new)
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
def context_for_targets(content:, source_label:, targets:)
|
|
1028
|
+
first_target = Array(targets).first
|
|
1029
|
+
context_for(content: content, source_label: source_label, target: first_target)
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
def enforce_limit!(target, count)
|
|
1033
|
+
return if target.limit.allows?(count)
|
|
1034
|
+
|
|
1035
|
+
raise Error.new(
|
|
1036
|
+
"CRISPR target #{target.id.inspect} matched #{count} node(s); expected #{target.limit.describe}",
|
|
1037
|
+
details: { target: target.id, count: count, limit: target.limit.describe }
|
|
1038
|
+
)
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1041
|
+
def replace_line_ranges(source, matches, replacement)
|
|
1042
|
+
assert_non_overlapping!(matches)
|
|
1043
|
+
plans = matches.map do |match|
|
|
1044
|
+
Ast::Merge::StructuralEdit::SplicePlan.new(
|
|
1045
|
+
source: source,
|
|
1046
|
+
replace_start_line: match.start_line,
|
|
1047
|
+
replace_end_line: match.end_line,
|
|
1048
|
+
replacement: replacement
|
|
1049
|
+
)
|
|
1050
|
+
end
|
|
1051
|
+
Ast::Merge::StructuralEdit::PlanSet.new(source: source, plans: plans).merged_content
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
def merge_replace_line_ranges(source, matches, replacement, merger_class:, merger_options:)
|
|
1055
|
+
assert_non_overlapping!(matches)
|
|
1056
|
+
merge_results = []
|
|
1057
|
+
plans = matches.map do |match|
|
|
1058
|
+
destination_slice = match.slice_from(source)
|
|
1059
|
+
merge_result = merge_replacement_slice(
|
|
1060
|
+
replacement.to_s,
|
|
1061
|
+
destination_slice,
|
|
1062
|
+
merger_class: merger_class,
|
|
1063
|
+
merger_options: merger_options
|
|
1064
|
+
)
|
|
1065
|
+
merge_results << merge_result
|
|
1066
|
+
|
|
1067
|
+
Ast::Merge::StructuralEdit::SplicePlan.new(
|
|
1068
|
+
source: source,
|
|
1069
|
+
replace_start_line: match.start_line,
|
|
1070
|
+
replace_end_line: match.end_line,
|
|
1071
|
+
replacement: merge_result.fetch(:content),
|
|
1072
|
+
metadata: { merge_result: merge_result, mode: :merge_replace }
|
|
1073
|
+
)
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
[
|
|
1077
|
+
Ast::Merge::StructuralEdit::PlanSet.new(source: source, plans: plans).merged_content,
|
|
1078
|
+
merge_results
|
|
1079
|
+
]
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
def merge_replacement_slice(template_slice, destination_slice, merger_class:, merger_options:)
|
|
1083
|
+
merger = merger_class.new(template_slice, destination_slice, **merger_options)
|
|
1084
|
+
result = merger.respond_to?(:merge_result) ? merger.merge_result : merger.merge
|
|
1085
|
+
content = merge_result_content(result)
|
|
1086
|
+
|
|
1087
|
+
{
|
|
1088
|
+
content: content,
|
|
1089
|
+
result: result,
|
|
1090
|
+
stats: merge_result_stats(result, merger),
|
|
1091
|
+
conflicts: merge_result_conflicts(result),
|
|
1092
|
+
unresolved: merge_result_unresolved?(result)
|
|
1093
|
+
}
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
def merge_result_content(result)
|
|
1097
|
+
return result if result.is_a?(String)
|
|
1098
|
+
return result.output if result.respond_to?(:output)
|
|
1099
|
+
return result.content if result.respond_to?(:content) && result.content.is_a?(String)
|
|
1100
|
+
|
|
1101
|
+
result.to_s
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
def merge_result_stats(result, merger)
|
|
1105
|
+
if result.respond_to?(:stats)
|
|
1106
|
+
stats = result.stats
|
|
1107
|
+
return stats unless stats.respond_to?(:empty?) && stats.empty?
|
|
1108
|
+
end
|
|
1109
|
+
return merger.stats if merger.respond_to?(:stats)
|
|
1110
|
+
|
|
1111
|
+
{}
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1114
|
+
def merge_result_conflicts(result)
|
|
1115
|
+
return result.conflicts if result.respond_to?(:conflicts)
|
|
1116
|
+
|
|
1117
|
+
[]
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
def merge_result_unresolved?(result)
|
|
1121
|
+
return result.unresolved? if result.respond_to?(:unresolved?)
|
|
1122
|
+
return result.review_required? if result.respond_to?(:review_required?)
|
|
1123
|
+
|
|
1124
|
+
false
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
def assert_non_overlapping!(matches)
|
|
1128
|
+
ranges = matches.map(&:line_range).sort_by(&:begin)
|
|
1129
|
+
ranges.each_cons(2) do |left, right|
|
|
1130
|
+
next if left.end < right.begin
|
|
1131
|
+
|
|
1132
|
+
raise Error.new('CRISPR target spans overlap', details: { left: left, right: right })
|
|
1133
|
+
end
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
def insertion_from(content, destination, if_missing:, source_label:)
|
|
1137
|
+
if destination.nil? && if_missing == :append
|
|
1138
|
+
return append_destination_resolution(source: :none,
|
|
1139
|
+
used_if_missing: true)
|
|
1140
|
+
end
|
|
1141
|
+
if destination.nil?
|
|
1142
|
+
raise Error.new('Missing CRISPR insertion destination',
|
|
1143
|
+
details: { source_label: source_label })
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
target = destination if destination.is_a?(OwnerSelector)
|
|
1147
|
+
context = context_for(content: content, source_label: source_label, target: target)
|
|
1148
|
+
anchor, resolution_source = if destination.is_a?(OwnerSelector)
|
|
1149
|
+
matches = normalize_matches(destination, context)
|
|
1150
|
+
if matches.empty?
|
|
1151
|
+
raise Error.new('CRISPR destination target cannot be empty',
|
|
1152
|
+
details: { target: destination.id })
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
[destination.resolve_anchor(context, matches.first), :selector]
|
|
1156
|
+
else
|
|
1157
|
+
[invoke_callable(destination, context), :callable]
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
if anchor.nil?
|
|
1161
|
+
if if_missing == :append
|
|
1162
|
+
return append_destination_resolution(source: resolution_source,
|
|
1163
|
+
used_if_missing: true)
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
raise Error.new('Unable to resolve CRISPR insertion destination', details: { source_label: source_label })
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
anchored_destination_resolution(anchor, source: resolution_source)
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
def insert_text(content, text, destination:, if_missing:, source_label:)
|
|
1173
|
+
resolution = insertion_from(content, destination, if_missing: if_missing, source_label: source_label)
|
|
1174
|
+
return [append_to_end_of_file(content, text), resolution.destination_profile] if resolution.append_fallback?
|
|
1175
|
+
|
|
1176
|
+
[splice_after_anchor(content, resolution.anchor, text), resolution.destination_profile]
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
def splice_after_anchor(content, injection_point, text)
|
|
1180
|
+
lines = content.lines
|
|
1181
|
+
start_line = statement_start_line(injection_point.anchor)
|
|
1182
|
+
end_line = expand_following_blank_lines(lines, statement_end_line(injection_point.anchor))
|
|
1183
|
+
raise Error, 'CRISPR insertion anchor is missing statement location' unless start_line && end_line
|
|
1184
|
+
|
|
1185
|
+
replacement = "#{lines[(start_line - 1)..(end_line - 1)].join}#{text.to_s.rstrip}\n\n"
|
|
1186
|
+
Ast::Merge::StructuralEdit::PlanSet.new(
|
|
1187
|
+
source: content,
|
|
1188
|
+
plans: [
|
|
1189
|
+
Ast::Merge::StructuralEdit::SplicePlan.new(
|
|
1190
|
+
source: content,
|
|
1191
|
+
replace_start_line: start_line,
|
|
1192
|
+
replace_end_line: end_line,
|
|
1193
|
+
replacement: replacement
|
|
1194
|
+
)
|
|
1195
|
+
]
|
|
1196
|
+
).merged_content
|
|
1197
|
+
end
|
|
1198
|
+
|
|
1199
|
+
def statement_start_line(statement)
|
|
1200
|
+
statement.start_line || statement.node&.location&.start_line
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
def statement_end_line(statement)
|
|
1204
|
+
statement.end_line || statement.node&.location&.end_line
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
def expand_following_blank_lines(lines, line_number)
|
|
1208
|
+
last_line = line_number
|
|
1209
|
+
last_line += 1 while !lines[last_line].nil? && lines[last_line].strip.empty?
|
|
1210
|
+
last_line
|
|
1211
|
+
end
|
|
1212
|
+
|
|
1213
|
+
def append_to_end_of_file(content, text)
|
|
1214
|
+
body = content.rstrip
|
|
1215
|
+
return text.to_s if body.empty?
|
|
1216
|
+
|
|
1217
|
+
"#{body}\n\n#{text}"
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
def capture_text(content, matches)
|
|
1221
|
+
matches.map { |match| match.slice_from(content).rstrip }.reject(&:empty?).join("\n\n")
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
def append_destination_resolution(source:, used_if_missing:)
|
|
1225
|
+
DestinationResolution.new(
|
|
1226
|
+
mode: :append,
|
|
1227
|
+
anchor: nil,
|
|
1228
|
+
destination_profile: DestinationProfile.new(
|
|
1229
|
+
resolution_kind: :append_fallback,
|
|
1230
|
+
resolution_source: source,
|
|
1231
|
+
anchor_boundary: :none,
|
|
1232
|
+
used_if_missing: used_if_missing
|
|
1233
|
+
)
|
|
1234
|
+
)
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
def anchored_destination_resolution(anchor, source:)
|
|
1238
|
+
DestinationResolution.new(
|
|
1239
|
+
mode: :anchor,
|
|
1240
|
+
anchor: anchor,
|
|
1241
|
+
destination_profile: DestinationProfile.new(
|
|
1242
|
+
resolution_kind: :anchor_after_statement,
|
|
1243
|
+
resolution_source: source,
|
|
1244
|
+
anchor_boundary: :statement_end_plus_following_gap,
|
|
1245
|
+
used_if_missing: false
|
|
1246
|
+
)
|
|
1247
|
+
)
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
def crispr_fail!(error)
|
|
1251
|
+
fail!(error: error.message, details: error.details)
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1254
|
+
def invoke_callable(callable, *args)
|
|
1255
|
+
return callable.call(*args) if callable.arity.negative?
|
|
1256
|
+
|
|
1257
|
+
callable.call(*args.first(callable.arity))
|
|
1258
|
+
end
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
module OperationProfilable
|
|
1262
|
+
def self.included(base)
|
|
1263
|
+
base.extend(ClassMethods)
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
def operation_profile
|
|
1267
|
+
self.class.operation_profile
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
module ClassMethods
|
|
1271
|
+
def declare_operation_profile(**attributes)
|
|
1272
|
+
@operation_profile = OperationProfile.new(**attributes)
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
def operation_profile
|
|
1276
|
+
@operation_profile || raise(Error.new('CRISPR operation profile is not declared',
|
|
1277
|
+
details: { operation_class: name }))
|
|
1278
|
+
end
|
|
1279
|
+
end
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
class MergeReplace < Actor
|
|
1283
|
+
include OperationSupport
|
|
1284
|
+
include OperationProfilable
|
|
1285
|
+
|
|
1286
|
+
declare_operation_profile(
|
|
1287
|
+
operation_kind: :merge_replace,
|
|
1288
|
+
source_requirement: :required,
|
|
1289
|
+
destination_requirement: :none,
|
|
1290
|
+
replacement_source: :merge_template_text,
|
|
1291
|
+
captures_source_text: true,
|
|
1292
|
+
supports_if_missing: false
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
input :content, type: String
|
|
1296
|
+
input :target, type: OwnerSelector
|
|
1297
|
+
input :replacement, type: String
|
|
1298
|
+
input :merger_class
|
|
1299
|
+
input :merger_options, default: -> { {} }
|
|
1300
|
+
input :source_label, type: String, default: 'source'
|
|
1301
|
+
|
|
1302
|
+
output :updated_content
|
|
1303
|
+
output :matches, default: -> { [] }
|
|
1304
|
+
output :match_count, type: Integer, default: 0
|
|
1305
|
+
output :changed, default: false
|
|
1306
|
+
output :captured_text, allow_nil: true, default: nil
|
|
1307
|
+
output :merge_results, default: -> { [] }
|
|
1308
|
+
output :operation_profile
|
|
1309
|
+
|
|
1310
|
+
def call
|
|
1311
|
+
self.operation_profile = self.class.operation_profile
|
|
1312
|
+
context = context_for(content: content, source_label: source_label, target: target)
|
|
1313
|
+
self.matches = normalize_matches(target, context)
|
|
1314
|
+
self.match_count = matches.size
|
|
1315
|
+
self.captured_text = capture_text(content, matches)
|
|
1316
|
+
if matches.empty?
|
|
1317
|
+
self.updated_content = content
|
|
1318
|
+
self.changed = false
|
|
1319
|
+
return
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
self.updated_content, self.merge_results = merge_replace_line_ranges(
|
|
1323
|
+
content,
|
|
1324
|
+
matches,
|
|
1325
|
+
replacement,
|
|
1326
|
+
merger_class: merger_class,
|
|
1327
|
+
merger_options: merger_options.to_h
|
|
1328
|
+
)
|
|
1329
|
+
self.changed = updated_content != content
|
|
1330
|
+
rescue Error => e
|
|
1331
|
+
crispr_fail!(e)
|
|
1332
|
+
end
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1335
|
+
class Replace < Actor
|
|
1336
|
+
include OperationSupport
|
|
1337
|
+
include OperationProfilable
|
|
1338
|
+
|
|
1339
|
+
declare_operation_profile(
|
|
1340
|
+
operation_kind: :replace,
|
|
1341
|
+
source_requirement: :required,
|
|
1342
|
+
destination_requirement: :none,
|
|
1343
|
+
replacement_source: :explicit_text,
|
|
1344
|
+
captures_source_text: true,
|
|
1345
|
+
supports_if_missing: false
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
input :content, type: String
|
|
1349
|
+
input :target, type: OwnerSelector
|
|
1350
|
+
input :replacement, allow_nil: true, default: nil
|
|
1351
|
+
input :source_label, type: String, default: 'source'
|
|
1352
|
+
|
|
1353
|
+
output :updated_content
|
|
1354
|
+
output :matches, default: -> { [] }
|
|
1355
|
+
output :match_count, type: Integer, default: 0
|
|
1356
|
+
output :changed, default: false
|
|
1357
|
+
output :captured_text, allow_nil: true, default: nil
|
|
1358
|
+
output :operation_profile
|
|
1359
|
+
|
|
1360
|
+
def call
|
|
1361
|
+
self.operation_profile = self.class.operation_profile
|
|
1362
|
+
context = context_for(content: content, source_label: source_label, target: target)
|
|
1363
|
+
self.matches = normalize_matches(target, context)
|
|
1364
|
+
self.match_count = matches.size
|
|
1365
|
+
self.captured_text = capture_text(content, matches)
|
|
1366
|
+
if matches.empty?
|
|
1367
|
+
self.updated_content = content
|
|
1368
|
+
self.changed = false
|
|
1369
|
+
return
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
self.updated_content = replace_line_ranges(content, matches, replacement.to_s)
|
|
1373
|
+
self.changed = updated_content != content
|
|
1374
|
+
rescue Error => e
|
|
1375
|
+
crispr_fail!(e)
|
|
1376
|
+
end
|
|
1377
|
+
end
|
|
1378
|
+
|
|
1379
|
+
class Delete < Actor
|
|
1380
|
+
include OperationSupport
|
|
1381
|
+
include OperationProfilable
|
|
1382
|
+
|
|
1383
|
+
declare_operation_profile(
|
|
1384
|
+
operation_kind: :delete,
|
|
1385
|
+
source_requirement: :required,
|
|
1386
|
+
destination_requirement: :none,
|
|
1387
|
+
replacement_source: :none,
|
|
1388
|
+
captures_source_text: true,
|
|
1389
|
+
supports_if_missing: false
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
input :content, type: String
|
|
1393
|
+
input :target, type: OwnerSelector
|
|
1394
|
+
input :source_label, type: String, default: 'source'
|
|
1395
|
+
|
|
1396
|
+
output :updated_content
|
|
1397
|
+
output :matches, default: -> { [] }
|
|
1398
|
+
output :match_count, type: Integer, default: 0
|
|
1399
|
+
output :changed, default: false
|
|
1400
|
+
output :captured_text, allow_nil: true, default: nil
|
|
1401
|
+
output :operation_profile
|
|
1402
|
+
|
|
1403
|
+
def call
|
|
1404
|
+
self.operation_profile = self.class.operation_profile
|
|
1405
|
+
actor = Replace.result(
|
|
1406
|
+
content: content,
|
|
1407
|
+
target: target,
|
|
1408
|
+
replacement: '',
|
|
1409
|
+
source_label: source_label
|
|
1410
|
+
)
|
|
1411
|
+
fail!(error: actor.error, details: actor.details) if actor.failure?
|
|
1412
|
+
|
|
1413
|
+
self.updated_content = actor.updated_content
|
|
1414
|
+
self.matches = actor.matches
|
|
1415
|
+
self.match_count = actor.match_count
|
|
1416
|
+
self.changed = actor.changed
|
|
1417
|
+
self.captured_text = actor.captured_text
|
|
1418
|
+
end
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1421
|
+
class DeleteBatch < Actor
|
|
1422
|
+
include OperationSupport
|
|
1423
|
+
include OperationProfilable
|
|
1424
|
+
|
|
1425
|
+
declare_operation_profile(
|
|
1426
|
+
operation_kind: :delete_batch,
|
|
1427
|
+
source_requirement: :required,
|
|
1428
|
+
destination_requirement: :none,
|
|
1429
|
+
replacement_source: :none,
|
|
1430
|
+
captures_source_text: true,
|
|
1431
|
+
supports_if_missing: false
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
input :content, type: String
|
|
1435
|
+
input :targets, default: -> { [] }
|
|
1436
|
+
input :source_label, type: String, default: 'source'
|
|
1437
|
+
|
|
1438
|
+
output :updated_content
|
|
1439
|
+
output :matches, default: -> { [] }
|
|
1440
|
+
output :match_count, type: Integer, default: 0
|
|
1441
|
+
output :changed, default: false
|
|
1442
|
+
output :captured_text, allow_nil: true, default: nil
|
|
1443
|
+
output :operation_profile
|
|
1444
|
+
|
|
1445
|
+
def call
|
|
1446
|
+
self.operation_profile = self.class.operation_profile
|
|
1447
|
+
selector_targets = Array(targets)
|
|
1448
|
+
if selector_targets.empty?
|
|
1449
|
+
self.updated_content = content
|
|
1450
|
+
self.changed = false
|
|
1451
|
+
return
|
|
1452
|
+
end
|
|
1453
|
+
|
|
1454
|
+
context = context_for_targets(content: content, source_label: source_label, targets: selector_targets)
|
|
1455
|
+
self.matches = selector_targets.flat_map { |target| normalize_matches(target, context) }
|
|
1456
|
+
self.match_count = matches.size
|
|
1457
|
+
self.captured_text = capture_text(content, matches)
|
|
1458
|
+
if matches.empty?
|
|
1459
|
+
self.updated_content = content
|
|
1460
|
+
self.changed = false
|
|
1461
|
+
return
|
|
1462
|
+
end
|
|
1463
|
+
|
|
1464
|
+
self.updated_content = replace_line_ranges(content, matches, '')
|
|
1465
|
+
self.changed = updated_content != content
|
|
1466
|
+
rescue Error => e
|
|
1467
|
+
crispr_fail!(e)
|
|
1468
|
+
end
|
|
1469
|
+
end
|
|
1470
|
+
|
|
1471
|
+
class Insert < Actor
|
|
1472
|
+
include OperationSupport
|
|
1473
|
+
include OperationProfilable
|
|
1474
|
+
|
|
1475
|
+
declare_operation_profile(
|
|
1476
|
+
operation_kind: :insert,
|
|
1477
|
+
source_requirement: :none,
|
|
1478
|
+
destination_requirement: :optional,
|
|
1479
|
+
replacement_source: :explicit_text,
|
|
1480
|
+
captures_source_text: false,
|
|
1481
|
+
supports_if_missing: true
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
input :content, type: String
|
|
1485
|
+
input :text, type: String
|
|
1486
|
+
input :destination, allow_nil: true, default: nil
|
|
1487
|
+
input :if_missing, type: Symbol, default: :raise
|
|
1488
|
+
input :source_label, type: String, default: 'source'
|
|
1489
|
+
|
|
1490
|
+
output :updated_content
|
|
1491
|
+
output :changed, default: false
|
|
1492
|
+
output :operation_profile
|
|
1493
|
+
output :destination_profile, allow_nil: true, default: nil
|
|
1494
|
+
|
|
1495
|
+
def call
|
|
1496
|
+
self.operation_profile = self.class.operation_profile
|
|
1497
|
+
self.updated_content, self.destination_profile =
|
|
1498
|
+
insert_text(content, text, destination: destination, if_missing: if_missing, source_label: source_label)
|
|
1499
|
+
self.changed = updated_content != content
|
|
1500
|
+
rescue Error => e
|
|
1501
|
+
crispr_fail!(e)
|
|
1502
|
+
end
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
class Move < Actor
|
|
1506
|
+
include OperationSupport
|
|
1507
|
+
include OperationProfilable
|
|
1508
|
+
|
|
1509
|
+
declare_operation_profile(
|
|
1510
|
+
operation_kind: :move,
|
|
1511
|
+
source_requirement: :optional,
|
|
1512
|
+
destination_requirement: :optional,
|
|
1513
|
+
replacement_source: :captured_text_or_explicit,
|
|
1514
|
+
captures_source_text: true,
|
|
1515
|
+
supports_if_missing: true
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
input :content, type: String
|
|
1519
|
+
input :source_target, type: OwnerSelector, allow_nil: true, default: nil
|
|
1520
|
+
input :destination, allow_nil: true, default: nil
|
|
1521
|
+
input :replacement, allow_nil: true, default: nil
|
|
1522
|
+
input :if_missing, type: Symbol, default: :raise
|
|
1523
|
+
input :source_label, type: String, default: 'source'
|
|
1524
|
+
|
|
1525
|
+
output :updated_content
|
|
1526
|
+
output :source_matches, default: -> { [] }
|
|
1527
|
+
output :source_match_count, type: Integer, default: 0
|
|
1528
|
+
output :changed, default: false
|
|
1529
|
+
output :captured_text, allow_nil: true, default: nil
|
|
1530
|
+
output :operation_profile
|
|
1531
|
+
output :destination_profile, allow_nil: true, default: nil
|
|
1532
|
+
|
|
1533
|
+
def call
|
|
1534
|
+
self.operation_profile = self.class.operation_profile
|
|
1535
|
+
working_content = content
|
|
1536
|
+
if source_target
|
|
1537
|
+
context = context_for(content: content, source_label: source_label, target: source_target)
|
|
1538
|
+
self.source_matches = normalize_matches(source_target, context)
|
|
1539
|
+
self.source_match_count = source_matches.size
|
|
1540
|
+
self.captured_text = capture_text(content, source_matches)
|
|
1541
|
+
working_content = replace_line_ranges(content, source_matches, '') unless source_matches.empty?
|
|
1542
|
+
end
|
|
1543
|
+
|
|
1544
|
+
text_to_insert = replacement.nil? ? captured_text.to_s : replacement.to_s
|
|
1545
|
+
self.updated_content =
|
|
1546
|
+
if text_to_insert.empty?
|
|
1547
|
+
working_content
|
|
1548
|
+
else
|
|
1549
|
+
updated_content, profile = insert_text(working_content, text_to_insert, destination: destination,
|
|
1550
|
+
if_missing: if_missing, source_label: source_label)
|
|
1551
|
+
self.destination_profile = profile
|
|
1552
|
+
updated_content
|
|
1553
|
+
end
|
|
1554
|
+
self.changed = updated_content != content
|
|
1555
|
+
rescue Error => e
|
|
1556
|
+
crispr_fail!(e)
|
|
1557
|
+
end
|
|
1558
|
+
end
|
|
1559
|
+
end
|
|
1560
|
+
end
|
|
1561
|
+
|
|
1562
|
+
module Ast
|
|
1563
|
+
module Crispr
|
|
1564
|
+
module ProfileReportCompatibility
|
|
1565
|
+
private
|
|
1566
|
+
|
|
1567
|
+
def stringify_report_value(value)
|
|
1568
|
+
case value
|
|
1569
|
+
when Symbol
|
|
1570
|
+
value.to_s
|
|
1571
|
+
when Hash
|
|
1572
|
+
value.transform_values { |inner| stringify_report_value(inner) }
|
|
1573
|
+
when Array
|
|
1574
|
+
value.map { |inner| stringify_report_value(inner) }
|
|
1575
|
+
else
|
|
1576
|
+
value
|
|
1577
|
+
end
|
|
1578
|
+
end
|
|
1579
|
+
|
|
1580
|
+
def stringified_report(hash)
|
|
1581
|
+
hash.reject { |key, _| key == :metadata }
|
|
1582
|
+
.transform_values { |value| stringify_report_value(value) }
|
|
1583
|
+
end
|
|
1584
|
+
end
|
|
1585
|
+
|
|
1586
|
+
class MatchProfile
|
|
1587
|
+
include ProfileReportCompatibility
|
|
1588
|
+
|
|
1589
|
+
def report
|
|
1590
|
+
data = to_h.merge(
|
|
1591
|
+
start_boundary_family: start_boundary_family || :unknown,
|
|
1592
|
+
end_boundary_family: end_boundary_family || :unknown,
|
|
1593
|
+
payload_family: payload_family || :unknown
|
|
1594
|
+
)
|
|
1595
|
+
stringified_report(data)
|
|
1596
|
+
end
|
|
1597
|
+
end
|
|
1598
|
+
|
|
1599
|
+
class SelectionProfile
|
|
1600
|
+
include ProfileReportCompatibility
|
|
1601
|
+
|
|
1602
|
+
KNOWN_SELECTOR_KINDS = {
|
|
1603
|
+
owner_filter: { family: :owner_filter },
|
|
1604
|
+
comment_region_owner: { family: :comment_anchor },
|
|
1605
|
+
comment_region_owned_owner: { family: :comment_anchor },
|
|
1606
|
+
heading_section: { family: :section_branch }
|
|
1607
|
+
}.freeze
|
|
1608
|
+
KNOWN_COMMENT_REGIONS = {
|
|
1609
|
+
leading: { family: :leading },
|
|
1610
|
+
trailing: { family: :trailing },
|
|
1611
|
+
inline: { family: :inline }
|
|
1612
|
+
}.freeze
|
|
1613
|
+
COMPAT_SELECTION_INTENT_FAMILIES = {
|
|
1614
|
+
predicate_filter: :predicate,
|
|
1615
|
+
comment_anchored_owner: :comment_anchor,
|
|
1616
|
+
comment_region_filter: :comment,
|
|
1617
|
+
section_branch: :section_branch,
|
|
1618
|
+
section_heading: :section
|
|
1619
|
+
}.freeze
|
|
1620
|
+
|
|
1621
|
+
def report
|
|
1622
|
+
data = to_h
|
|
1623
|
+
selector_kind_family = KNOWN_SELECTOR_KINDS[selector_kind]&.fetch(:family, nil)
|
|
1624
|
+
comment_region_family = comment_region.nil? ? :none : KNOWN_COMMENT_REGIONS[comment_region]&.fetch(:family, nil)
|
|
1625
|
+
owner_selector_family = owner_selector == :heading_sections ? :section : structure_profile.owner_selector_family
|
|
1626
|
+
known_comment_region = !comment_region.nil? && KNOWN_COMMENT_REGIONS.key?(comment_region)
|
|
1627
|
+
data.merge!(
|
|
1628
|
+
owner_selector_family: owner_selector_family || :unknown,
|
|
1629
|
+
known_owner_selector: structure_profile.known_owner_selector?,
|
|
1630
|
+
selector_kind_family: selector_kind_family || :unknown,
|
|
1631
|
+
known_selector_kind: KNOWN_SELECTOR_KINDS.key?(selector_kind),
|
|
1632
|
+
selection_intent_family: COMPAT_SELECTION_INTENT_FAMILIES.fetch(selection_intent, :unknown),
|
|
1633
|
+
known_selection_intent: COMPAT_SELECTION_INTENT_FAMILIES.key?(selection_intent),
|
|
1634
|
+
comment_region_family: comment_region_family || :unknown,
|
|
1635
|
+
known_comment_region: known_comment_region,
|
|
1636
|
+
comment_anchored: known_comment_region || selector_kind_family == :comment_anchor || COMPAT_SELECTION_INTENT_FAMILIES[selection_intent] == :comment
|
|
1637
|
+
)
|
|
1638
|
+
stringified_report(data)
|
|
1639
|
+
end
|
|
1640
|
+
end
|
|
1641
|
+
|
|
1642
|
+
class DestinationProfile
|
|
1643
|
+
include ProfileReportCompatibility
|
|
1644
|
+
|
|
1645
|
+
def report
|
|
1646
|
+
data = to_h.merge(
|
|
1647
|
+
known_resolution_kind: KNOWN_RESOLUTION_KINDS.key?(resolution_kind),
|
|
1648
|
+
known_resolution_source: KNOWN_RESOLUTION_SOURCES.key?(resolution_source),
|
|
1649
|
+
resolution_family: resolution_family || :unknown,
|
|
1650
|
+
resolution_source_family: resolution_source_family || :unknown,
|
|
1651
|
+
anchor_boundary_family: anchor_boundary_family || :unknown
|
|
1652
|
+
)
|
|
1653
|
+
stringified_report(data)
|
|
1654
|
+
end
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1657
|
+
class OperationProfile
|
|
1658
|
+
include ProfileReportCompatibility
|
|
1659
|
+
|
|
1660
|
+
def report
|
|
1661
|
+
data = to_h.merge(
|
|
1662
|
+
operation_family: operation_family || :unknown,
|
|
1663
|
+
known_source_requirement: KNOWN_REQUIREMENTS.include?(source_requirement),
|
|
1664
|
+
known_destination_requirement: KNOWN_REQUIREMENTS.include?(destination_requirement),
|
|
1665
|
+
known_replacement_source: KNOWN_REPLACEMENT_SOURCES.include?(replacement_source),
|
|
1666
|
+
selects_source: selects_source?,
|
|
1667
|
+
requires_source: requires_source?,
|
|
1668
|
+
supports_destination: supports_destination?,
|
|
1669
|
+
requires_destination: requires_destination?,
|
|
1670
|
+
explicit_replacement: explicit_replacement?,
|
|
1671
|
+
may_reuse_captured_text: may_reuse_captured_text?
|
|
1672
|
+
)
|
|
1673
|
+
stringified_report(data)
|
|
1674
|
+
end
|
|
1675
|
+
end
|
|
1676
|
+
|
|
1677
|
+
class StructureProfile
|
|
1678
|
+
include ProfileReportCompatibility
|
|
1679
|
+
|
|
1680
|
+
def report
|
|
1681
|
+
stringified_report(to_h)
|
|
1682
|
+
end
|
|
1683
|
+
end
|
|
1684
|
+
|
|
1685
|
+
class << self
|
|
1686
|
+
def ast_merge_contract_anchor
|
|
1687
|
+
'Ast::Merge.structured_edit'
|
|
1688
|
+
end
|
|
1689
|
+
|
|
1690
|
+
def boundary_report
|
|
1691
|
+
{
|
|
1692
|
+
package: PACKAGE_NAME,
|
|
1693
|
+
layer: 'structural_edit_tool',
|
|
1694
|
+
status: 'active_thin_package',
|
|
1695
|
+
base_contract_package: 'ast-merge',
|
|
1696
|
+
relationship: {
|
|
1697
|
+
ast_merge: [
|
|
1698
|
+
'owns portable structured-edit envelope contracts',
|
|
1699
|
+
'owns transport, report, replay, review, and provider handoff vocabulary',
|
|
1700
|
+
'remains the substrate for provider-neutral fixtures'
|
|
1701
|
+
],
|
|
1702
|
+
ast_crispr: [
|
|
1703
|
+
'owns ergonomic structural-edit selectors, profiles, and operation helpers',
|
|
1704
|
+
'wraps ast-merge contracts instead of forking them',
|
|
1705
|
+
'may grow compatibility helpers for old ast-crispr concepts after fixture-backed review'
|
|
1706
|
+
],
|
|
1707
|
+
provider_packages: [
|
|
1708
|
+
'own parser-specific execution and metadata projection',
|
|
1709
|
+
'may expose provider adapters consumed by ast-crispr',
|
|
1710
|
+
'keep raw parser details behind normalized tree metadata or semantic sidecars'
|
|
1711
|
+
],
|
|
1712
|
+
ast_template: [
|
|
1713
|
+
'orchestrates template and directory workflows',
|
|
1714
|
+
'invokes structural edits through ast-merge or ast-crispr registries/envelopes',
|
|
1715
|
+
'does not own parser-specific selectors'
|
|
1716
|
+
]
|
|
1717
|
+
},
|
|
1718
|
+
implementations: [
|
|
1719
|
+
{
|
|
1720
|
+
language: 'go',
|
|
1721
|
+
package_name: 'astcrispr',
|
|
1722
|
+
import: 'github.com/structuredmerge/structuredmerge-go/astcrispr'
|
|
1723
|
+
},
|
|
1724
|
+
{
|
|
1725
|
+
language: 'ruby',
|
|
1726
|
+
package_name: 'ast-crispr',
|
|
1727
|
+
require: 'ast/crispr'
|
|
1728
|
+
},
|
|
1729
|
+
{
|
|
1730
|
+
language: 'rust',
|
|
1731
|
+
package_name: 'ast-crispr',
|
|
1732
|
+
crate: 'ast_crispr'
|
|
1733
|
+
},
|
|
1734
|
+
{
|
|
1735
|
+
language: 'typescript',
|
|
1736
|
+
package_name: '@structuredmerge/ast-crispr',
|
|
1737
|
+
import: '@structuredmerge/ast-crispr'
|
|
1738
|
+
}
|
|
1739
|
+
],
|
|
1740
|
+
initial_exports: [
|
|
1741
|
+
'package identity',
|
|
1742
|
+
'boundary report',
|
|
1743
|
+
'ast-merge structured-edit contract anchor',
|
|
1744
|
+
'limit helpers',
|
|
1745
|
+
'match profile helpers',
|
|
1746
|
+
'selection profile helpers',
|
|
1747
|
+
'destination profile helpers',
|
|
1748
|
+
'operation profile helpers',
|
|
1749
|
+
'replace/delete/insert/move helpers',
|
|
1750
|
+
'batch operation helpers'
|
|
1751
|
+
],
|
|
1752
|
+
future_exports: [],
|
|
1753
|
+
metadata: {
|
|
1754
|
+
source: 'legacy_crispr_reference',
|
|
1755
|
+
decision: 'Keep ast-merge as the base contract layer and revive ast-crispr as a separate thin package in every implementation.'
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
end
|
|
1759
|
+
|
|
1760
|
+
def limit(spec = nil)
|
|
1761
|
+
Limit.coerce(spec)
|
|
1762
|
+
end
|
|
1763
|
+
|
|
1764
|
+
def match_profile(start_boundary: 'owner_start', end_boundary: 'owner_end', payload_kind: 'structural_owner_body')
|
|
1765
|
+
MatchProfile.new(start_boundary: start_boundary, end_boundary: end_boundary, payload_kind: payload_kind)
|
|
1766
|
+
end
|
|
1767
|
+
|
|
1768
|
+
def selection_profile(
|
|
1769
|
+
owner_scope: 'shared_default',
|
|
1770
|
+
owner_selector: 'line_bound_statements',
|
|
1771
|
+
selector_kind: 'owner_filter',
|
|
1772
|
+
selection_intent: 'predicate_filter',
|
|
1773
|
+
comment_region: nil,
|
|
1774
|
+
include_trailing_gap: false
|
|
1775
|
+
)
|
|
1776
|
+
SelectionProfile.new(
|
|
1777
|
+
owner_scope: owner_scope,
|
|
1778
|
+
owner_selector: owner_selector,
|
|
1779
|
+
selector_kind: selector_kind,
|
|
1780
|
+
selection_intent: selection_intent,
|
|
1781
|
+
comment_region: comment_region,
|
|
1782
|
+
include_trailing_gap: include_trailing_gap
|
|
1783
|
+
)
|
|
1784
|
+
end
|
|
1785
|
+
|
|
1786
|
+
def destination_profile(
|
|
1787
|
+
resolution_kind: 'append_fallback',
|
|
1788
|
+
resolution_source: 'none',
|
|
1789
|
+
anchor_boundary: 'none',
|
|
1790
|
+
used_if_missing: false
|
|
1791
|
+
)
|
|
1792
|
+
DestinationProfile.new(
|
|
1793
|
+
resolution_kind: resolution_kind,
|
|
1794
|
+
resolution_source: resolution_source,
|
|
1795
|
+
anchor_boundary: anchor_boundary,
|
|
1796
|
+
used_if_missing: used_if_missing
|
|
1797
|
+
)
|
|
1798
|
+
end
|
|
1799
|
+
|
|
1800
|
+
def operation_profile(
|
|
1801
|
+
operation_kind: 'replace',
|
|
1802
|
+
source_requirement: 'required',
|
|
1803
|
+
destination_requirement: 'none',
|
|
1804
|
+
replacement_source: 'explicit_text',
|
|
1805
|
+
captures_source_text: false,
|
|
1806
|
+
supports_if_missing: false
|
|
1807
|
+
)
|
|
1808
|
+
OperationProfile.new(
|
|
1809
|
+
operation_kind: operation_kind,
|
|
1810
|
+
source_requirement: source_requirement,
|
|
1811
|
+
destination_requirement: destination_requirement,
|
|
1812
|
+
replacement_source: replacement_source,
|
|
1813
|
+
captures_source_text: captures_source_text,
|
|
1814
|
+
supports_if_missing: supports_if_missing
|
|
1815
|
+
)
|
|
1816
|
+
end
|
|
1817
|
+
|
|
1818
|
+
def replace_operation
|
|
1819
|
+
OperationProfile.new(
|
|
1820
|
+
operation_kind: 'replace',
|
|
1821
|
+
source_requirement: 'required',
|
|
1822
|
+
destination_requirement: 'none',
|
|
1823
|
+
replacement_source: 'explicit_text',
|
|
1824
|
+
captures_source_text: true,
|
|
1825
|
+
supports_if_missing: false
|
|
1826
|
+
)
|
|
1827
|
+
end
|
|
1828
|
+
|
|
1829
|
+
def delete_operation
|
|
1830
|
+
OperationProfile.new(
|
|
1831
|
+
operation_kind: 'delete',
|
|
1832
|
+
source_requirement: 'required',
|
|
1833
|
+
destination_requirement: 'none',
|
|
1834
|
+
replacement_source: 'none',
|
|
1835
|
+
captures_source_text: true,
|
|
1836
|
+
supports_if_missing: false
|
|
1837
|
+
)
|
|
1838
|
+
end
|
|
1839
|
+
|
|
1840
|
+
def insert_operation
|
|
1841
|
+
OperationProfile.new(
|
|
1842
|
+
operation_kind: 'insert',
|
|
1843
|
+
source_requirement: 'none',
|
|
1844
|
+
destination_requirement: 'optional',
|
|
1845
|
+
replacement_source: 'explicit_text',
|
|
1846
|
+
captures_source_text: false,
|
|
1847
|
+
supports_if_missing: true
|
|
1848
|
+
)
|
|
1849
|
+
end
|
|
1850
|
+
|
|
1851
|
+
def move_operation
|
|
1852
|
+
OperationProfile.new(
|
|
1853
|
+
operation_kind: 'move',
|
|
1854
|
+
source_requirement: 'optional',
|
|
1855
|
+
destination_requirement: 'optional',
|
|
1856
|
+
replacement_source: 'captured_text_or_explicit',
|
|
1857
|
+
captures_source_text: true,
|
|
1858
|
+
supports_if_missing: true
|
|
1859
|
+
)
|
|
1860
|
+
end
|
|
1861
|
+
|
|
1862
|
+
def batch_operation_report(profiles)
|
|
1863
|
+
{
|
|
1864
|
+
operation_count: profiles.length,
|
|
1865
|
+
operation_kinds: profiles.map { |profile| profile.operation_kind.to_s },
|
|
1866
|
+
operation_profiles: profiles.map(&:report)
|
|
1867
|
+
}
|
|
1868
|
+
end
|
|
1869
|
+
end
|
|
1870
|
+
end
|
|
1871
|
+
end
|