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.
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