prosereflect 0.2.0 → 0.3.0

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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +63 -0
  3. data/.github/workflows/links.yml +97 -0
  4. data/.gitignore +4 -0
  5. data/.rubocop_todo.yml +61 -75
  6. data/README.adoc +2 -0
  7. data/docs/Gemfile +10 -0
  8. data/docs/INDEX.adoc +45 -0
  9. data/docs/_advanced/index.adoc +15 -0
  10. data/docs/_advanced/schema.adoc +112 -0
  11. data/docs/_advanced/step-map.adoc +66 -0
  12. data/docs/_advanced/steps.adoc +88 -0
  13. data/docs/_advanced/test-builder.adoc +61 -0
  14. data/docs/_advanced/transform.adoc +92 -0
  15. data/docs/_config.yml +174 -0
  16. data/docs/_features/html-input.adoc +69 -0
  17. data/docs/_features/html-output.adoc +45 -0
  18. data/docs/_features/index.adoc +15 -0
  19. data/docs/_features/marks.adoc +86 -0
  20. data/docs/_features/node-types.adoc +124 -0
  21. data/docs/_features/user-mentions.adoc +47 -0
  22. data/docs/_guides/custom-nodes.adoc +107 -0
  23. data/docs/_guides/index.adoc +13 -0
  24. data/docs/_guides/round-trip-html.adoc +91 -0
  25. data/docs/_guides/serialization.adoc +109 -0
  26. data/docs/_pages/index.adoc +67 -0
  27. data/docs/_reference/document-api.adoc +49 -0
  28. data/docs/_reference/index.adoc +14 -0
  29. data/docs/_reference/node-api.adoc +79 -0
  30. data/docs/_reference/schema-api.adoc +95 -0
  31. data/docs/_reference/transform-api.adoc +77 -0
  32. data/docs/_understanding/document-model.adoc +65 -0
  33. data/docs/_understanding/fragment.adoc +52 -0
  34. data/docs/_understanding/index.adoc +14 -0
  35. data/docs/_understanding/resolved-position.adoc +53 -0
  36. data/docs/_understanding/slice.adoc +54 -0
  37. data/docs/lychee.toml +63 -0
  38. data/lib/prosereflect/blockquote.rb +9 -0
  39. data/lib/prosereflect/bullet_list.rb +25 -19
  40. data/lib/prosereflect/code_block.rb +1 -5
  41. data/lib/prosereflect/fragment.rb +249 -0
  42. data/lib/prosereflect/horizontal_rule.rb +9 -0
  43. data/lib/prosereflect/image.rb +9 -0
  44. data/lib/prosereflect/input/html.rb +96 -0
  45. data/lib/prosereflect/node.rb +141 -3
  46. data/lib/prosereflect/ordered_list.rb +2 -0
  47. data/lib/prosereflect/output/html.rb +227 -0
  48. data/lib/prosereflect/parser.rb +9 -0
  49. data/lib/prosereflect/resolved_pos.rb +256 -0
  50. data/lib/prosereflect/schema/attribute.rb +57 -0
  51. data/lib/prosereflect/schema/content_match.rb +656 -0
  52. data/lib/prosereflect/schema/fragment.rb +166 -0
  53. data/lib/prosereflect/schema/mark.rb +121 -0
  54. data/lib/prosereflect/schema/mark_type.rb +130 -0
  55. data/lib/prosereflect/schema/node.rb +236 -0
  56. data/lib/prosereflect/schema/node_type.rb +274 -0
  57. data/lib/prosereflect/schema/schema_main.rb +190 -0
  58. data/lib/prosereflect/schema/spec.rb +92 -0
  59. data/lib/prosereflect/schema.rb +39 -0
  60. data/lib/prosereflect/text.rb +24 -0
  61. data/lib/prosereflect/transform/attr_step.rb +157 -0
  62. data/lib/prosereflect/transform/insert_step.rb +115 -0
  63. data/lib/prosereflect/transform/mapping.rb +82 -0
  64. data/lib/prosereflect/transform/mark_step.rb +269 -0
  65. data/lib/prosereflect/transform/replace_around_step.rb +181 -0
  66. data/lib/prosereflect/transform/replace_step.rb +157 -0
  67. data/lib/prosereflect/transform/slice.rb +91 -0
  68. data/lib/prosereflect/transform/step.rb +89 -0
  69. data/lib/prosereflect/transform/step_map.rb +126 -0
  70. data/lib/prosereflect/transform/structure.rb +120 -0
  71. data/lib/prosereflect/transform/transform.rb +341 -0
  72. data/lib/prosereflect/transform.rb +26 -0
  73. data/lib/prosereflect/version.rb +1 -1
  74. data/lib/prosereflect.rb +3 -0
  75. data/spec/fixtures/documents/formatted_text.yaml +14 -0
  76. data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
  77. data/spec/fixtures/documents/lists_doc.yaml +32 -0
  78. data/spec/fixtures/documents/mixed_content.yaml +40 -0
  79. data/spec/fixtures/documents/nested_doc.yaml +20 -0
  80. data/spec/fixtures/documents/simple_doc.yaml +6 -0
  81. data/spec/fixtures/documents/table_doc.yaml +32 -0
  82. data/spec/fixtures/documents/transform_test.yaml +14 -0
  83. data/spec/fixtures/schema/custom_schema.rb +37 -0
  84. data/spec/fixtures/schema/test_schema.rb +46 -0
  85. data/spec/fixtures/test_builder/helpers.rb +212 -0
  86. data/spec/prosereflect/document_spec.rb +1 -1
  87. data/spec/prosereflect/fragment_spec.rb +273 -0
  88. data/spec/prosereflect/input/html_spec.rb +197 -1
  89. data/spec/prosereflect/node_spec.rb +128 -0
  90. data/spec/prosereflect/output/whitespace_spec.rb +248 -0
  91. data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
  92. data/spec/prosereflect/resolved_pos_spec.rb +74 -0
  93. data/spec/prosereflect/schema/conftest.rb +68 -0
  94. data/spec/prosereflect/schema/content_match_spec.rb +237 -0
  95. data/spec/prosereflect/schema/mark_spec.rb +274 -0
  96. data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
  97. data/spec/prosereflect/schema/node_type_spec.rb +142 -0
  98. data/spec/prosereflect/schema/schema_spec.rb +194 -0
  99. data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
  100. data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
  101. data/spec/prosereflect/transform/mapping_spec.rb +226 -0
  102. data/spec/prosereflect/transform/replace_spec.rb +832 -0
  103. data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
  104. data/spec/prosereflect/transform/slice_spec.rb +48 -0
  105. data/spec/prosereflect/transform/step_map_spec.rb +70 -0
  106. data/spec/prosereflect/transform/step_spec.rb +211 -0
  107. data/spec/prosereflect/transform/structure_spec.rb +98 -0
  108. data/spec/prosereflect/transform/transform_spec.rb +238 -0
  109. data/spec/spec_helper.rb +1 -0
  110. metadata +90 -2
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "step"
4
+
5
+ module Prosereflect
6
+ module Transform
7
+ # Replaces a range of the document with a slice of content,
8
+ # but also replaces the content before and after the gap.
9
+ # Used by lift and wrap operations.
10
+ class ReplaceAroundStep < Step
11
+ attr_reader :from, :to, :gap_from, :gap_to, :slice, :insert, :structure
12
+
13
+ def initialize(from, to, gap_from, gap_to, slice, insert, structure: false)
14
+ super()
15
+ @from = from
16
+ @to = to
17
+ @gap_from = gap_from
18
+ @gap_to = gap_to
19
+ @slice = slice
20
+ @insert = insert
21
+ @structure = structure
22
+ end
23
+
24
+ def apply(doc)
25
+ # Check structure constraint
26
+ if @structure && (content_between(doc, @from, @gap_from) || content_between(doc, @gap_to, @to))
27
+ return Result.fail("Structure gap-replace would overwrite content")
28
+ end
29
+
30
+ # Get the gap content
31
+ gap = doc.slice(@gap_from, @gap_to)
32
+ if gap.open_start || gap.open_end
33
+ return Result.fail("Gap is not a flat range")
34
+ end
35
+
36
+ # Try to insert slice into gap
37
+ inserted = @slice.insert_at(@insert, gap.content)
38
+ unless inserted
39
+ return Result.fail("Content does not fit in gap")
40
+ end
41
+
42
+ # Apply the replacement
43
+ new_doc = apply_replace_around(doc, inserted)
44
+ Result.ok(new_doc)
45
+ rescue StandardError => e
46
+ Result.fail(e.message)
47
+ end
48
+
49
+ def get_map
50
+ StepMap.new([
51
+ @from,
52
+ @gap_from - @from,
53
+ @insert,
54
+ @gap_to,
55
+ @to - @gap_to,
56
+ @slice.size - @insert,
57
+ ])
58
+ end
59
+
60
+ def invert(doc)
61
+ gap = @gap_to - @gap_from
62
+ removed = doc.slice(@from, @to).remove_between(
63
+ @gap_from - @from,
64
+ @gap_to - @from,
65
+ )
66
+ ReplaceAroundStep.new(
67
+ @from,
68
+ @from + @slice.size + gap,
69
+ @from + @insert,
70
+ @from + @insert + gap,
71
+ removed,
72
+ @gap_from - @from,
73
+ @structure,
74
+ )
75
+ end
76
+
77
+ def map(mapping)
78
+ from_mapped = mapping.map_result(@from, 1)
79
+ to_mapped = mapping.map_result(@to, -1)
80
+
81
+ gap_from_mapped = if @from == @gap_from
82
+ from_mapped.pos
83
+ else
84
+ mapping.map(@gap_from, -1)
85
+ end
86
+
87
+ gap_to_mapped = if @to == @gap_to
88
+ to_mapped.pos
89
+ else
90
+ mapping.map(@gap_to, 1)
91
+ end
92
+
93
+ if (from_mapped.deleted && to_mapped.deleted) || gap_from_mapped < from_mapped.pos || gap_to_mapped > to_mapped.pos
94
+ return nil
95
+ end
96
+
97
+ ReplaceAroundStep.new(
98
+ from_mapped.pos,
99
+ to_mapped.pos,
100
+ gap_from_mapped,
101
+ gap_to_mapped,
102
+ @slice,
103
+ @insert,
104
+ @structure,
105
+ )
106
+ end
107
+
108
+ def step_type
109
+ "replaceAround"
110
+ end
111
+
112
+ def to_json(*_args)
113
+ json = super
114
+ json["from"] = @from
115
+ json["to"] = @to
116
+ json["gapFrom"] = @gap_from
117
+ json["gapTo"] = @gap_to
118
+ json["slice"] = @slice.content.to_a.map(&:to_h)
119
+ json["insert"] = @insert
120
+ json["structure"] = @structure
121
+ json
122
+ end
123
+
124
+ def self.from_json(_schema, json)
125
+ from_val = json["from"]
126
+ to_val = json["to"]
127
+ gap_from_val = json["gapFrom"]
128
+ gap_to_val = json["gapTo"]
129
+ insert_val = json["insert"]
130
+ structure_val = json["structure"] || false
131
+
132
+ slice_json = json["slice"] || []
133
+ slice_content = slice_json.map { |h| Prosereflect::Node.from_h(h) }
134
+ slice = Slice.new(Fragment.new(slice_content))
135
+
136
+ new(from_val, to_val, gap_from_val, gap_to_val, slice, insert_val, structure: structure_val)
137
+ end
138
+
139
+ private
140
+
141
+ def content_between(doc, from, to)
142
+ return nil if from >= to
143
+
144
+ result = []
145
+ doc.nodes_between(from, to) { |node| result << node }
146
+ result.empty? ? nil : Fragment.new(result)
147
+ end
148
+
149
+ def apply_replace_around(doc, inserted)
150
+ # Get content before and after the replaced range
151
+ before = content_before(doc, @from)
152
+ after = content_after(doc, @to)
153
+
154
+ # Build new document
155
+ new_content = []
156
+ new_content.concat(before) unless before.empty?
157
+ new_content.concat(inserted.content.to_a) unless inserted.empty?
158
+ new_content.concat(after) unless after.empty?
159
+
160
+ rebuild_doc(doc, new_content)
161
+ end
162
+
163
+ def content_before(doc, pos)
164
+ result = []
165
+ doc.nodes_between(0, pos) { |node| result << node }
166
+ result
167
+ end
168
+
169
+ def content_after(doc, pos)
170
+ result = []
171
+ doc.nodes_between(pos, doc.node_size) { |node| result << node }
172
+ result
173
+ end
174
+
175
+ def rebuild_doc(doc, new_content)
176
+ attrs = doc.attrs.dup
177
+ doc.class.new(content: Fragment.new(new_content), attrs: attrs)
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "step"
4
+
5
+ module Prosereflect
6
+ module Transform
7
+ # Replaces a range of the document with a slice of content.
8
+ class ReplaceStep < Step
9
+ attr_reader :from, :to, :slice
10
+
11
+ def initialize(from, to, slice = Slice.empty)
12
+ super()
13
+ @from = from
14
+ @to = to
15
+ @slice = slice
16
+ end
17
+
18
+ def apply(doc)
19
+ # Validate positions
20
+ return Result.fail("Invalid positions") if @from > @to
21
+ return Result.fail("from < 0") if @from.negative?
22
+ return Result.fail("to > doc size") if @to > doc.node_size
23
+
24
+ # Build the new document
25
+ new_doc = apply_replace(doc)
26
+ Result.ok(new_doc)
27
+ rescue StandardError => e
28
+ Result.fail(e.message)
29
+ end
30
+
31
+ def get_map
32
+ delta = @slice.size - (@to - @from)
33
+ StepMap.new([[@from, @to, @from, @from + delta]])
34
+ end
35
+
36
+ def invert(doc)
37
+ # Find what was removed
38
+ removed = content_between(doc, @from, @to)
39
+ ReplaceStep.new(@from, @from + @slice.size, removed)
40
+ end
41
+
42
+ def merge(other)
43
+ return nil unless other.is_a?(ReplaceStep)
44
+
45
+ return extend_deletion(other) if can_extend_deletion?(other)
46
+ return prepend_deletion(other) if can_prepend_deletion?(other)
47
+ return append_content(other) if can_append_content?(other)
48
+ return prepend_content(other) if can_prepend_content?(other)
49
+
50
+ nil
51
+ end
52
+
53
+ def can_extend_deletion?(other)
54
+ @to == other.from && @slice.empty?
55
+ end
56
+
57
+ def extend_deletion(other)
58
+ ReplaceStep.new(@from, other.to, Slice.empty)
59
+ end
60
+
61
+ def can_prepend_deletion?(other)
62
+ other.to == @from && other.slice.empty?
63
+ end
64
+
65
+ def prepend_deletion(other)
66
+ ReplaceStep.new(other.from, @to, Slice.empty)
67
+ end
68
+
69
+ def can_append_content?(other)
70
+ @to == other.from && !other.slice.empty?
71
+ end
72
+
73
+ def append_content(other)
74
+ new_content = join_slices(@slice, other.slice)
75
+ ReplaceStep.new(@from, other.to, new_content)
76
+ end
77
+
78
+ def can_prepend_content?(other)
79
+ other.to == @from && !other.slice.empty?
80
+ end
81
+
82
+ def prepend_content(other)
83
+ new_content = join_slices(other.slice, @slice)
84
+ ReplaceStep.new(other.from, @to, new_content)
85
+ end
86
+
87
+ def step_type
88
+ "replace"
89
+ end
90
+
91
+ def to_json(*_args)
92
+ json = super
93
+ json["from"] = @from
94
+ json["to"] = @to
95
+ json["slice"] = @slice.content.map(&:to_h)
96
+ json
97
+ end
98
+
99
+ def self.from_json(_schema, json)
100
+ from_val = json["from"]
101
+ to_val = json["to"]
102
+ slice_json = json["slice"] || []
103
+ slice_content = slice_json.map { |h| Prosereflect::Node.from_h(h) }
104
+ slice = Slice.new(Fragment.new(slice_content))
105
+ new(from_val, to_val, slice)
106
+ end
107
+
108
+ private
109
+
110
+ def apply_replace(doc)
111
+ # Get content before, during, and after the replaced range
112
+ before = content_before(doc, @from)
113
+ after = content_after(doc, @to)
114
+
115
+ # Build new document
116
+ new_content = []
117
+ new_content.concat(before) unless before.empty?
118
+ new_content.concat(@slice.content.to_a) unless @slice.empty?
119
+ new_content.concat(after) unless after.empty?
120
+
121
+ rebuild_doc(doc, new_content)
122
+ end
123
+
124
+ def content_before(doc, pos)
125
+ result = []
126
+ doc.nodes_between(0, pos) { |node| result << node }
127
+ result
128
+ end
129
+
130
+ def content_after(doc, pos)
131
+ result = []
132
+ doc.nodes_between(pos, doc.node_size) { |node| result << node }
133
+ result
134
+ end
135
+
136
+ def content_between(doc, from, to)
137
+ result = []
138
+ doc.nodes_between(from, to) { |node| result << node }
139
+ Fragment.new(result)
140
+ end
141
+
142
+ def join_slices(left, right)
143
+ new_content = Fragment.new(left.content.to_a + right.content.to_a)
144
+ Slice.new(new_content, left.open_start, right.open_end)
145
+ end
146
+
147
+ def rebuild_doc(doc, new_content)
148
+ # Create a new document with the same structure but new content
149
+ attrs = doc.attrs.dup
150
+ Fragment.new(new_content)
151
+ # For simplicity, return a new Document with the new content
152
+ # In reality this would preserve the doc type
153
+ doc.class.new(content: Fragment.new(new_content), attrs: attrs)
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ module Transform
5
+ # Represents a slice of a document - a contiguous portion that can be
6
+ # inserted, deleted, or moved. Tracks open boundaries for proper joining.
7
+ class Slice
8
+ attr_reader :content, :open_start, :open_end
9
+
10
+ def initialize(content, open_start = 0, open_end = 0)
11
+ @content = content
12
+ @open_start = open_start
13
+ @open_end = open_end
14
+ end
15
+
16
+ # Check if this slice is empty (no content and no open boundaries)
17
+ def empty?
18
+ @content.empty? && @open_start.zero? && @open_end.zero?
19
+ end
20
+
21
+ # Total size of the slice including open boundaries
22
+ def size
23
+ content_size + @open_start + @open_end
24
+ end
25
+
26
+ # Size of just the content
27
+ def content_size
28
+ size = 0
29
+ @content.each { |node| size += node.node_size }
30
+ size
31
+ end
32
+
33
+ # Cut the slice at given boundaries
34
+ def cut(from = 0, to = nil)
35
+ to ||= size
36
+
37
+ if from.zero? && to == size
38
+ return self
39
+ end
40
+
41
+ result = cut_internal(from, to)
42
+ Slice.new(result[:content], result[:open_start], result[:open_end])
43
+ end
44
+
45
+ # Check equality
46
+ def eq?(other)
47
+ return false unless other.is_a?(Slice)
48
+
49
+ @open_start == other.open_start &&
50
+ @open_end == other.open_end &&
51
+ @content.to_a.map(&:to_h) == other.content.to_a.map(&:to_h)
52
+ end
53
+
54
+ alias == eq?
55
+
56
+ def to_s
57
+ "<Slice open_start=#{@open_start} open_end=#{@open_end} content=#{@content.length} items>"
58
+ end
59
+
60
+ def inspect
61
+ to_s
62
+ end
63
+
64
+ # Create an empty slice
65
+ def self.empty
66
+ new(Fragment.new([]), 0, 0)
67
+ end
68
+
69
+ private
70
+
71
+ def cut_internal(from, to)
72
+ return { content: @content, open_start: @open_start, open_end: @open_end } if from >= to
73
+
74
+ # Simplified cut - just adjusts open flags
75
+ new_open_start = @open_start
76
+ new_open_end = @open_end
77
+ new_content = @content
78
+
79
+ if from.positive?
80
+ new_open_start = [new_open_start - from, 0].max
81
+ end
82
+
83
+ if to < size
84
+ new_open_end = [new_open_end - (size - to), 0].max
85
+ end
86
+
87
+ { content: new_content, open_start: new_open_start, open_end: new_open_end }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "step_map"
4
+ require_relative "mapping"
5
+
6
+ module Prosereflect
7
+ module Transform
8
+ # Base class for all document transformations.
9
+ # A step represents an atomic document change.
10
+ class Step
11
+ # Apply this step to a document
12
+ # Returns a Result with the new document or an error
13
+ def apply(_doc)
14
+ raise NotImplementedError, "#{self.class} must implement #apply"
15
+ end
16
+
17
+ # Get the step map for position tracking
18
+ def get_map
19
+ raise NotImplementedError, "#{self.class} must implement #get_map"
20
+ end
21
+
22
+ # Merge this step with another if possible
23
+ # Returns a new step or nil if not mergeable
24
+ def merge(_other)
25
+ nil
26
+ end
27
+
28
+ # Return an inverted step that undoes this one
29
+ # Takes the document as input to compute the inverse
30
+ def invert(_doc)
31
+ raise NotImplementedError, "#{self.class} must implement #invert"
32
+ end
33
+
34
+ # Get a JSON representation
35
+ def to_json(*_args)
36
+ {
37
+ "stepType" => step_type,
38
+ "pos" => pos,
39
+ "to" => to,
40
+ }.compact
41
+ end
42
+
43
+ # Create a step from JSON
44
+ def self.from_json(_schema, _json)
45
+ raise NotImplementedError, "#{self.class} must implement #from_json"
46
+ end
47
+
48
+ # The type name of this step
49
+ def step_type
50
+ raise NotImplementedError, "#{self.class} must implement #step_type"
51
+ end
52
+
53
+ # Position where this step applies
54
+ def pos
55
+ 0
56
+ end
57
+
58
+ # End position (for range steps)
59
+ def to
60
+ pos
61
+ end
62
+
63
+ # Result of applying a step
64
+ class Result
65
+ attr_reader :doc, :failed
66
+
67
+ def initialize(doc: nil, failed: nil)
68
+ @doc = doc
69
+ @failed = failed
70
+ end
71
+
72
+ # Check if the step was successfully applied
73
+ def ok?
74
+ !@failed && @doc
75
+ end
76
+
77
+ # Create a successful result
78
+ def self.ok(doc)
79
+ new(doc: doc)
80
+ end
81
+
82
+ # Create a failed result
83
+ def self.fail(reason)
84
+ new(failed: reason)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosereflect
4
+ module Transform
5
+ # Maps positions through a step.
6
+ # Represents how positions change when a step is applied.
7
+ class StepMap
8
+ attr_reader :ranges # Array of [old_start, old_end, new_start, new_end]
9
+
10
+ def initialize(ranges = [])
11
+ @ranges = ranges
12
+ end
13
+
14
+ # Map a position through this step map
15
+ # Returns the new position
16
+ def map(pos)
17
+ offset = 0
18
+ @ranges.each do |old_start, old_end, new_start, new_end|
19
+ if pos <= old_start
20
+ return pos + (new_start - old_start)
21
+ elsif pos < old_end
22
+ return new_start + (pos - old_start)
23
+ elsif pos >= old_end
24
+ offset += (new_end - old_end)
25
+ end
26
+ end
27
+ pos + offset
28
+ end
29
+
30
+ # Map a position, returning result with deletion information
31
+ def map_result(pos, on_del: nil) # rubocop:disable Lint:UnusedMethodArgument
32
+ new_pos = map(pos)
33
+ deleted = deleted?(pos)
34
+ Result.new(pos: new_pos, deleted: deleted, transformed: new_pos != pos)
35
+ end
36
+
37
+ # Check if a position was deleted by this step
38
+ def deleted?(pos)
39
+ @ranges.any? do |old_start, old_end, _new_start, _new_end|
40
+ pos >= old_start && pos < old_end
41
+ end
42
+ end
43
+
44
+ # Add another map to this one (composition)
45
+ def add_map(other)
46
+ return StepMap.new(other.ranges.dup) if @ranges.empty?
47
+ return StepMap.new(@ranges.dup) if other.ranges.empty?
48
+
49
+ StepMap.new(merge_ranges_arrays(@ranges.dup, other.ranges.dup))
50
+ end
51
+
52
+ def merge_ranges_arrays(ranges1, ranges2)
53
+ return ranges1 if ranges2.empty?
54
+ return ranges2 if ranges1.empty?
55
+
56
+ head1 = ranges1.first
57
+ head2 = ranges2.first
58
+ merged_head = compute_merged_head(head1, head2, ranges1, ranges2)
59
+ tail = compute_tail(head1, head2, ranges1, ranges2)
60
+ [merged_head] + tail
61
+ end
62
+
63
+ def compute_merged_head(head1, head2, _ranges1, _ranges2)
64
+ if head1[1] <= head2[0]
65
+ head1
66
+ elsif head2[1] <= head1[0]
67
+ head2
68
+ else
69
+ merge_ranges(head1, head2)
70
+ end
71
+ end
72
+
73
+ def compute_tail(head1, head2, ranges1, ranges2)
74
+ if head1[1] <= head2[0]
75
+ merge_ranges_arrays(ranges1[1..], ranges2)
76
+ elsif head2[1] <= head1[0]
77
+ merge_ranges_arrays(ranges1, ranges2[1..])
78
+ else
79
+ merge_ranges_arrays(ranges1[1..], ranges2[1..])
80
+ end
81
+ end
82
+
83
+ # Create an empty step map
84
+ def self.empty
85
+ new
86
+ end
87
+
88
+ # Create a step map for a single deletion
89
+ def self.delete(from, to)
90
+ new([[from, to, from, from]])
91
+ end
92
+
93
+ # Create a step map for a single replacement
94
+ def self.replace(from, to, target_from, target_to)
95
+ delta = target_to - target_from
96
+ new([[from, to, target_from, target_from + delta]])
97
+ end
98
+
99
+ def to_s
100
+ "<StepMap #{@ranges.inspect}>"
101
+ end
102
+
103
+ def inspect
104
+ to_s
105
+ end
106
+
107
+ # Result of mapping a position
108
+ Result = Struct.new(:pos, :deleted, :transformed, keyword_init: true) do
109
+ def initialize(pos: 0, deleted: false, transformed: false)
110
+ super
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def merge_ranges(range1, range2)
117
+ [
118
+ [range1[0], range2[0]].min,
119
+ [range1[1], range2[1]].max,
120
+ [range1[2], range2[2]].min,
121
+ [range1[3], range2[3]].max,
122
+ ]
123
+ end
124
+ end
125
+ end
126
+ end