prosereflect 0.1.1 → 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 (158) 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/.github/workflows/rake.yml +4 -0
  5. data/.github/workflows/release.yml +5 -0
  6. data/.gitignore +4 -0
  7. data/.rubocop.yml +19 -1
  8. data/.rubocop_todo.yml +119 -183
  9. data/CLAUDE.md +78 -0
  10. data/Gemfile +8 -4
  11. data/README.adoc +2 -0
  12. data/Rakefile +3 -3
  13. data/docs/Gemfile +10 -0
  14. data/docs/INDEX.adoc +45 -0
  15. data/docs/_advanced/index.adoc +15 -0
  16. data/docs/_advanced/schema.adoc +112 -0
  17. data/docs/_advanced/step-map.adoc +66 -0
  18. data/docs/_advanced/steps.adoc +88 -0
  19. data/docs/_advanced/test-builder.adoc +61 -0
  20. data/docs/_advanced/transform.adoc +92 -0
  21. data/docs/_config.yml +174 -0
  22. data/docs/_features/html-input.adoc +69 -0
  23. data/docs/_features/html-output.adoc +45 -0
  24. data/docs/_features/index.adoc +15 -0
  25. data/docs/_features/marks.adoc +86 -0
  26. data/docs/_features/node-types.adoc +124 -0
  27. data/docs/_features/user-mentions.adoc +47 -0
  28. data/docs/_guides/custom-nodes.adoc +107 -0
  29. data/docs/_guides/index.adoc +13 -0
  30. data/docs/_guides/round-trip-html.adoc +91 -0
  31. data/docs/_guides/serialization.adoc +109 -0
  32. data/docs/_pages/index.adoc +67 -0
  33. data/docs/_reference/document-api.adoc +49 -0
  34. data/docs/_reference/index.adoc +14 -0
  35. data/docs/_reference/node-api.adoc +79 -0
  36. data/docs/_reference/schema-api.adoc +95 -0
  37. data/docs/_reference/transform-api.adoc +77 -0
  38. data/docs/_understanding/document-model.adoc +65 -0
  39. data/docs/_understanding/fragment.adoc +52 -0
  40. data/docs/_understanding/index.adoc +14 -0
  41. data/docs/_understanding/resolved-position.adoc +53 -0
  42. data/docs/_understanding/slice.adoc +54 -0
  43. data/docs/lychee.toml +63 -0
  44. data/lib/prosereflect/attribute/base.rb +4 -6
  45. data/lib/prosereflect/attribute/bold.rb +2 -4
  46. data/lib/prosereflect/attribute/href.rb +1 -3
  47. data/lib/prosereflect/attribute/id.rb +7 -7
  48. data/lib/prosereflect/attribute.rb +4 -7
  49. data/lib/prosereflect/blockquote.rb +19 -11
  50. data/lib/prosereflect/bullet_list.rb +36 -29
  51. data/lib/prosereflect/code_block.rb +23 -27
  52. data/lib/prosereflect/code_block_wrapper.rb +12 -13
  53. data/lib/prosereflect/document.rb +14 -22
  54. data/lib/prosereflect/fragment.rb +249 -0
  55. data/lib/prosereflect/hard_break.rb +6 -6
  56. data/lib/prosereflect/heading.rb +14 -15
  57. data/lib/prosereflect/horizontal_rule.rb +23 -14
  58. data/lib/prosereflect/image.rb +32 -23
  59. data/lib/prosereflect/input/html.rb +179 -104
  60. data/lib/prosereflect/input.rb +7 -0
  61. data/lib/prosereflect/list_item.rb +11 -12
  62. data/lib/prosereflect/mark/base.rb +9 -11
  63. data/lib/prosereflect/mark/bold.rb +1 -3
  64. data/lib/prosereflect/mark/code.rb +1 -3
  65. data/lib/prosereflect/mark/italic.rb +1 -3
  66. data/lib/prosereflect/mark/link.rb +1 -3
  67. data/lib/prosereflect/mark/strike.rb +1 -3
  68. data/lib/prosereflect/mark/subscript.rb +1 -3
  69. data/lib/prosereflect/mark/superscript.rb +1 -3
  70. data/lib/prosereflect/mark/underline.rb +1 -3
  71. data/lib/prosereflect/mark.rb +9 -5
  72. data/lib/prosereflect/node.rb +171 -33
  73. data/lib/prosereflect/ordered_list.rb +17 -14
  74. data/lib/prosereflect/output/html.rb +279 -50
  75. data/lib/prosereflect/output.rb +7 -0
  76. data/lib/prosereflect/paragraph.rb +11 -13
  77. data/lib/prosereflect/parser.rb +56 -66
  78. data/lib/prosereflect/resolved_pos.rb +256 -0
  79. data/lib/prosereflect/schema/attribute.rb +57 -0
  80. data/lib/prosereflect/schema/content_match.rb +656 -0
  81. data/lib/prosereflect/schema/fragment.rb +166 -0
  82. data/lib/prosereflect/schema/mark.rb +121 -0
  83. data/lib/prosereflect/schema/mark_type.rb +130 -0
  84. data/lib/prosereflect/schema/node.rb +236 -0
  85. data/lib/prosereflect/schema/node_type.rb +274 -0
  86. data/lib/prosereflect/schema/schema_main.rb +190 -0
  87. data/lib/prosereflect/schema/spec.rb +92 -0
  88. data/lib/prosereflect/schema.rb +39 -0
  89. data/lib/prosereflect/table.rb +12 -13
  90. data/lib/prosereflect/table_cell.rb +13 -13
  91. data/lib/prosereflect/table_header.rb +17 -17
  92. data/lib/prosereflect/table_row.rb +12 -12
  93. data/lib/prosereflect/text.rb +35 -11
  94. data/lib/prosereflect/transform/attr_step.rb +157 -0
  95. data/lib/prosereflect/transform/insert_step.rb +115 -0
  96. data/lib/prosereflect/transform/mapping.rb +82 -0
  97. data/lib/prosereflect/transform/mark_step.rb +269 -0
  98. data/lib/prosereflect/transform/replace_around_step.rb +181 -0
  99. data/lib/prosereflect/transform/replace_step.rb +157 -0
  100. data/lib/prosereflect/transform/slice.rb +91 -0
  101. data/lib/prosereflect/transform/step.rb +89 -0
  102. data/lib/prosereflect/transform/step_map.rb +126 -0
  103. data/lib/prosereflect/transform/structure.rb +120 -0
  104. data/lib/prosereflect/transform/transform.rb +341 -0
  105. data/lib/prosereflect/transform.rb +26 -0
  106. data/lib/prosereflect/user.rb +15 -15
  107. data/lib/prosereflect/version.rb +1 -1
  108. data/lib/prosereflect.rb +30 -17
  109. data/prosereflect.gemspec +17 -16
  110. data/spec/fixtures/documents/formatted_text.yaml +14 -0
  111. data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
  112. data/spec/fixtures/documents/lists_doc.yaml +32 -0
  113. data/spec/fixtures/documents/mixed_content.yaml +40 -0
  114. data/spec/fixtures/documents/nested_doc.yaml +20 -0
  115. data/spec/fixtures/documents/simple_doc.yaml +6 -0
  116. data/spec/fixtures/documents/table_doc.yaml +32 -0
  117. data/spec/fixtures/documents/transform_test.yaml +14 -0
  118. data/spec/fixtures/schema/custom_schema.rb +37 -0
  119. data/spec/fixtures/schema/test_schema.rb +46 -0
  120. data/spec/fixtures/test_builder/helpers.rb +212 -0
  121. data/spec/prosereflect/document_spec.rb +332 -330
  122. data/spec/prosereflect/fragment_spec.rb +273 -0
  123. data/spec/prosereflect/hard_break_spec.rb +125 -125
  124. data/spec/prosereflect/input/html_spec.rb +718 -522
  125. data/spec/prosereflect/node_spec.rb +311 -182
  126. data/spec/prosereflect/output/html_spec.rb +105 -105
  127. data/spec/prosereflect/output/whitespace_spec.rb +248 -0
  128. data/spec/prosereflect/paragraph_spec.rb +275 -274
  129. data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
  130. data/spec/prosereflect/parser_spec.rb +185 -180
  131. data/spec/prosereflect/resolved_pos_spec.rb +74 -0
  132. data/spec/prosereflect/schema/conftest.rb +68 -0
  133. data/spec/prosereflect/schema/content_match_spec.rb +237 -0
  134. data/spec/prosereflect/schema/mark_spec.rb +274 -0
  135. data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
  136. data/spec/prosereflect/schema/node_type_spec.rb +142 -0
  137. data/spec/prosereflect/schema/schema_spec.rb +194 -0
  138. data/spec/prosereflect/table_cell_spec.rb +183 -183
  139. data/spec/prosereflect/table_row_spec.rb +149 -149
  140. data/spec/prosereflect/table_spec.rb +320 -318
  141. data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
  142. data/spec/prosereflect/text_spec.rb +133 -132
  143. data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
  144. data/spec/prosereflect/transform/mapping_spec.rb +226 -0
  145. data/spec/prosereflect/transform/replace_spec.rb +832 -0
  146. data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
  147. data/spec/prosereflect/transform/slice_spec.rb +48 -0
  148. data/spec/prosereflect/transform/step_map_spec.rb +70 -0
  149. data/spec/prosereflect/transform/step_spec.rb +211 -0
  150. data/spec/prosereflect/transform/structure_spec.rb +98 -0
  151. data/spec/prosereflect/transform/transform_spec.rb +238 -0
  152. data/spec/prosereflect/user_spec.rb +31 -28
  153. data/spec/prosereflect_spec.rb +28 -26
  154. data/spec/spec_helper.rb +7 -6
  155. data/spec/support/matchers.rb +6 -6
  156. data/spec/support/shared_examples.rb +49 -49
  157. metadata +96 -5
  158. data/spec/prosereflect/version_spec.rb +0 -11
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "step"
4
+ require_relative "step_map"
5
+
6
+ module Prosereflect
7
+ module Transform
8
+ # Base class for mark-related steps
9
+ class MarkStep < Step
10
+ attr_reader :from, :to, :mark
11
+
12
+ def initialize(from, to, mark)
13
+ super()
14
+ @from = from
15
+ @to = to
16
+ @mark = mark
17
+ end
18
+
19
+ def get_map
20
+ StepMap.new
21
+ end
22
+ end
23
+
24
+ # Add a mark to all content in a range
25
+ class AddMarkStep < MarkStep
26
+ def apply(doc)
27
+ return Result.fail("Invalid positions") if @from > @to || @from.negative?
28
+
29
+ begin
30
+ new_doc = add_mark_to_range(doc)
31
+ Result.ok(new_doc)
32
+ rescue StandardError => e
33
+ Result.fail(e.message)
34
+ end
35
+ end
36
+
37
+ def invert(_doc)
38
+ RemoveMarkStep.new(@from, @to, @mark)
39
+ end
40
+
41
+ def merge(other)
42
+ return nil unless other.is_a?(AddMarkStep)
43
+ return nil unless other.mark == @mark
44
+
45
+ if @to == other.from
46
+ AddMarkStep.new(@from, other.to, @mark)
47
+ elsif @from == other.to
48
+ AddMarkStep.new(other.from, @to, @mark)
49
+ end
50
+ end
51
+
52
+ def step_type
53
+ "addMark"
54
+ end
55
+
56
+ def to_json(*_args)
57
+ json = super
58
+ json["mark"] = @mark.to_h
59
+ json
60
+ end
61
+
62
+ def self.from_json(_schema, json)
63
+ mark = Prosereflect::Mark.from_h(json["mark"])
64
+ new(json["from"], json["to"], mark)
65
+ end
66
+
67
+ private
68
+
69
+ def add_mark_to_range(doc)
70
+ new_content = doc.content.map { |node| apply_mark_to_node(node) }
71
+ doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
72
+ end
73
+
74
+ def apply_mark_to_node(node)
75
+ return node unless node.is_a?(Prosereflect::Text)
76
+
77
+ Prosereflect::Text.new(
78
+ text: node.text,
79
+ marks: (node.marks || []) + [@mark],
80
+ attrs: node.attrs.dup,
81
+ )
82
+ end
83
+
84
+ def remove_mark_from_range(doc)
85
+ new_content = doc.content.map { |node| remove_mark_from_node_single(node) }
86
+ doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
87
+ end
88
+
89
+ def remove_mark_from_node_single(node)
90
+ return node unless node.is_a?(Prosereflect::Text)
91
+
92
+ new_marks = (node.marks || []).reject { |m| m.type == @mark.type }
93
+ Prosereflect::Text.new(
94
+ text: node.text,
95
+ marks: new_marks,
96
+ attrs: node.attrs.dup,
97
+ )
98
+ end
99
+ end
100
+
101
+ # Remove a mark from all content in a range
102
+ class RemoveMarkStep < MarkStep
103
+ def apply(doc)
104
+ return Result.fail("Invalid positions") if @from > @to || @from.negative?
105
+
106
+ begin
107
+ new_doc = remove_mark_from_range(doc)
108
+ Result.ok(new_doc)
109
+ rescue StandardError => e
110
+ Result.fail(e.message)
111
+ end
112
+ end
113
+
114
+ def invert(_doc)
115
+ AddMarkStep.new(@from, @to, @mark)
116
+ end
117
+
118
+ def merge(other)
119
+ return nil unless other.is_a?(RemoveMarkStep)
120
+ return nil unless other.mark == @mark
121
+
122
+ if @to == other.from
123
+ RemoveMarkStep.new(@from, other.to, @mark)
124
+ elsif @from == other.to
125
+ RemoveMarkStep.new(other.from, @to, @mark)
126
+ end
127
+ end
128
+
129
+ def step_type
130
+ "removeMark"
131
+ end
132
+
133
+ def to_json(*_args)
134
+ json = super
135
+ json["mark"] = @mark.to_h
136
+ json
137
+ end
138
+
139
+ def self.from_json(_schema, json)
140
+ mark = Prosereflect::Mark.from_h(json["mark"])
141
+ new(json["from"], json["to"], mark)
142
+ end
143
+ end
144
+
145
+ # Add mark to a specific node (not range-based)
146
+ class AddNodeMarkStep < Step
147
+ attr_reader :pos, :mark
148
+
149
+ def initialize(pos, mark)
150
+ super()
151
+ @pos = pos
152
+ @mark = mark
153
+ end
154
+
155
+ def apply(doc)
156
+ return Result.fail("Invalid position") if @pos.negative? || @pos > doc.node_size
157
+
158
+ begin
159
+ new_doc = add_mark_to_node(doc)
160
+ Result.ok(new_doc)
161
+ rescue StandardError => e
162
+ Result.fail(e.message)
163
+ end
164
+ end
165
+
166
+ def get_map
167
+ StepMap.new
168
+ end
169
+
170
+ def invert(_doc)
171
+ RemoveNodeMarkStep.new(@pos, @mark)
172
+ end
173
+
174
+ def step_type
175
+ "addNodeMark"
176
+ end
177
+
178
+ def to_json(*_args)
179
+ json = super
180
+ json["pos"] = @pos
181
+ json["mark"] = @mark.to_h
182
+ json
183
+ end
184
+
185
+ def self.from_json(_schema, json)
186
+ mark = Prosereflect::Mark.from_h(json["mark"])
187
+ new(json["pos"], mark)
188
+ end
189
+
190
+ private
191
+
192
+ def add_mark_to_node(doc)
193
+ new_content = doc.content.map { |node| add_mark_to_single_node(node) }
194
+ doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
195
+ end
196
+
197
+ def add_mark_to_single_node(node)
198
+ new_marks = (node.marks || []) + [@mark]
199
+ node.class.new(
200
+ content: node.content,
201
+ marks: new_marks,
202
+ attrs: node.attrs.dup,
203
+ )
204
+ end
205
+ end
206
+
207
+ # Remove mark from a specific node
208
+ class RemoveNodeMarkStep < Step
209
+ attr_reader :pos, :mark
210
+
211
+ def initialize(pos, mark)
212
+ super()
213
+ @pos = pos
214
+ @mark = mark
215
+ end
216
+
217
+ def apply(doc)
218
+ return Result.fail("Invalid position") if @pos.negative? || @pos > doc.node_size
219
+
220
+ begin
221
+ new_doc = remove_mark_from_node(doc)
222
+ Result.ok(new_doc)
223
+ rescue StandardError => e
224
+ Result.fail(e.message)
225
+ end
226
+ end
227
+
228
+ def get_map
229
+ StepMap.new
230
+ end
231
+
232
+ def invert(_doc)
233
+ AddNodeMarkStep.new(@pos, @mark)
234
+ end
235
+
236
+ def step_type
237
+ "removeNodeMark"
238
+ end
239
+
240
+ def to_json(*_args)
241
+ json = super
242
+ json["pos"] = @pos
243
+ json["mark"] = @mark.to_h
244
+ json
245
+ end
246
+
247
+ def self.from_json(_schema, json)
248
+ mark = Prosereflect::Mark.from_h(json["mark"])
249
+ new(json["pos"], mark)
250
+ end
251
+
252
+ private
253
+
254
+ def remove_mark_from_node(doc)
255
+ new_content = doc.content.map { |node| remove_mark_from_single_node(node) }
256
+ doc.class.new(content: Fragment.new(new_content), attrs: doc.attrs.dup)
257
+ end
258
+
259
+ def remove_mark_from_single_node(node)
260
+ new_marks = (node.marks || []).reject { |m| m.type == @mark.type }
261
+ node.class.new(
262
+ content: node.content,
263
+ marks: new_marks,
264
+ attrs: node.attrs.dup,
265
+ )
266
+ end
267
+ end
268
+ end
269
+ end
@@ -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