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,832 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Replace step operations" do # rubocop:disable RSpec/DescribeClass
6
+ # Helper to build a simple doc with one paragraph containing text
7
+ def build_doc_with_text(text)
8
+ Prosereflect::Parser.parse_document(
9
+ "type" => "doc",
10
+ "content" => [
11
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => text }] },
12
+ ],
13
+ )
14
+ end
15
+
16
+ # Helper to build a doc with multiple paragraphs
17
+ def build_doc_with_paragraphs(*texts)
18
+ Prosereflect::Parser.parse_document(
19
+ "type" => "doc",
20
+ "content" => texts.map do |t|
21
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => t }] }
22
+ end,
23
+ )
24
+ end
25
+
26
+ # doc=1 + para=1 + text("Hello")=6 = 8 total
27
+ # Positions: 0=doc start, 1=para start, 2="H", 3="e", 4="l", 5="l", 6="o", 7=para end
28
+
29
+ describe "#apply" do
30
+ context "when deleting text" do
31
+ it "deletes a range of text within a paragraph" do
32
+ doc = build_doc_with_text("Hello")
33
+ # Delete "ell" (positions 3-6)
34
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
35
+ result = step.apply(doc)
36
+
37
+ expect(result).to be_ok
38
+ expect(result.doc).to be_a(Prosereflect::Document)
39
+ end
40
+
41
+ it "deletes at the start of the document" do
42
+ doc = build_doc_with_text("abc")
43
+ # Delete "ab" (positions 2-4)
44
+ step = Prosereflect::Transform::ReplaceStep.new(2, 4, Prosereflect::Transform::Slice.empty)
45
+ result = step.apply(doc)
46
+
47
+ expect(result).to be_ok
48
+ end
49
+
50
+ it "deletes at the end of the document" do
51
+ doc = build_doc_with_text("abc")
52
+ # Delete "bc" (positions 3-5)
53
+ step = Prosereflect::Transform::ReplaceStep.new(3, 5, Prosereflect::Transform::Slice.empty)
54
+ result = step.apply(doc)
55
+
56
+ expect(result).to be_ok
57
+ end
58
+
59
+ it "deletes an entire paragraph" do
60
+ doc = build_doc_with_text("abc")
61
+ # doc=1, para=1, text("abc")=4, total=6
62
+ # Delete entire paragraph range (1-5)
63
+ step = Prosereflect::Transform::ReplaceStep.new(1, 5, Prosereflect::Transform::Slice.empty)
64
+ result = step.apply(doc)
65
+
66
+ expect(result).to be_ok
67
+ end
68
+
69
+ it "deletes from start of document to end" do
70
+ doc = build_doc_with_text("abc")
71
+ step = Prosereflect::Transform::ReplaceStep.new(0, doc.node_size, Prosereflect::Transform::Slice.empty)
72
+ result = step.apply(doc)
73
+
74
+ expect(result).to be_ok
75
+ end
76
+ end
77
+
78
+ context "when replacing text" do
79
+ it "replaces text with new content" do
80
+ doc = build_doc_with_text("Hello")
81
+ replacement = Prosereflect::Transform::Slice.new(
82
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "XY")]),
83
+ )
84
+ # Replace "ell" (positions 3-6) with "XY"
85
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, replacement)
86
+ result = step.apply(doc)
87
+
88
+ expect(result).to be_ok
89
+ end
90
+
91
+ it "replaces a single character" do
92
+ doc = build_doc_with_text("abc")
93
+ replacement = Prosereflect::Transform::Slice.new(
94
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "X")]),
95
+ )
96
+ # Replace "b" (position 3-4) with "X"
97
+ step = Prosereflect::Transform::ReplaceStep.new(3, 4, replacement)
98
+ result = step.apply(doc)
99
+
100
+ expect(result).to be_ok
101
+ end
102
+
103
+ it "replaces text with longer content" do
104
+ doc = build_doc_with_text("ab")
105
+ replacement = Prosereflect::Transform::Slice.new(
106
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "XYZ")]),
107
+ )
108
+ # Replace "a" (position 2-3) with "XYZ"
109
+ step = Prosereflect::Transform::ReplaceStep.new(2, 3, replacement)
110
+ result = step.apply(doc)
111
+
112
+ expect(result).to be_ok
113
+ # New doc should be larger
114
+ expect(result.doc.node_size).to be > doc.node_size
115
+ end
116
+
117
+ it "replaces text with shorter content" do
118
+ doc = build_doc_with_text("abcdef")
119
+ replacement = Prosereflect::Transform::Slice.new(
120
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "Z")]),
121
+ )
122
+ # Replace "bcde" (positions 3-7) with "Z"
123
+ step = Prosereflect::Transform::ReplaceStep.new(3, 7, replacement)
124
+ result = step.apply(doc)
125
+
126
+ expect(result).to be_ok
127
+ end
128
+ end
129
+
130
+ context "when inserting text" do
131
+ it "inserts text at a position (zero-width range)" do
132
+ doc = build_doc_with_text("ac")
133
+ insert = Prosereflect::Transform::Slice.new(
134
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "b")]),
135
+ )
136
+ # Insert "b" at position 3 (between "a" and "c")
137
+ step = Prosereflect::Transform::ReplaceStep.new(3, 3, insert)
138
+ result = step.apply(doc)
139
+
140
+ expect(result).to be_ok
141
+ expect(result.doc.node_size).to be > doc.node_size
142
+ end
143
+
144
+ it "inserts at the beginning of a paragraph" do
145
+ doc = build_doc_with_text("cd")
146
+ insert = Prosereflect::Transform::Slice.new(
147
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "ab")]),
148
+ )
149
+ # Insert at position 2 (start of text inside paragraph)
150
+ step = Prosereflect::Transform::ReplaceStep.new(2, 2, insert)
151
+ result = step.apply(doc)
152
+
153
+ expect(result).to be_ok
154
+ end
155
+
156
+ it "inserts at the end of a paragraph" do
157
+ doc = build_doc_with_text("ab")
158
+ # doc=1, para=1, text("ab")=3, total=5
159
+ insert = Prosereflect::Transform::Slice.new(
160
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "cd")]),
161
+ )
162
+ # Insert at position 4 (end of text)
163
+ step = Prosereflect::Transform::ReplaceStep.new(4, 4, insert)
164
+ result = step.apply(doc)
165
+
166
+ expect(result).to be_ok
167
+ end
168
+ end
169
+
170
+ context "with Slice objects" do
171
+ it "replaces content with a slice containing multiple text nodes" do
172
+ doc = build_doc_with_text("Hello")
173
+ replacement = Prosereflect::Transform::Slice.new(
174
+ Prosereflect::Fragment.new(
175
+ [
176
+ Prosereflect::Text.new(text: "X"),
177
+ Prosereflect::Text.new(text: "Y"),
178
+ ],
179
+ ),
180
+ )
181
+ step = Prosereflect::Transform::ReplaceStep.new(2, 7, replacement)
182
+ result = step.apply(doc)
183
+
184
+ expect(result).to be_ok
185
+ end
186
+
187
+ it "replaces with an empty slice that has open boundaries" do
188
+ doc = build_doc_with_text("abc")
189
+ slice = Prosereflect::Transform::Slice.new(
190
+ Prosereflect::Fragment.new([]),
191
+ 1,
192
+ 0,
193
+ )
194
+ step = Prosereflect::Transform::ReplaceStep.new(2, 5, slice)
195
+ result = step.apply(doc)
196
+
197
+ expect(result).to be_ok
198
+ end
199
+
200
+ it "replaces a paragraph with a slice containing a paragraph" do
201
+ doc = build_doc_with_text("old")
202
+ new_para = Prosereflect::Paragraph.new(type: "paragraph")
203
+ new_para.add_text("new")
204
+ replacement = Prosereflect::Transform::Slice.new(
205
+ Prosereflect::Fragment.new([new_para]),
206
+ )
207
+ # Replace entire paragraph content (positions 1-5)
208
+ step = Prosereflect::Transform::ReplaceStep.new(1, 5, replacement)
209
+ result = step.apply(doc)
210
+
211
+ expect(result).to be_ok
212
+ end
213
+ end
214
+
215
+ context "with validation" do
216
+ it "fails when from > to" do
217
+ doc = build_doc_with_text("abc")
218
+ step = Prosereflect::Transform::ReplaceStep.new(5, 2, Prosereflect::Transform::Slice.empty)
219
+ result = step.apply(doc)
220
+
221
+ expect(result).not_to be_ok
222
+ expect(result.failed).to eq("Invalid positions")
223
+ end
224
+
225
+ it "fails when from is negative" do
226
+ doc = build_doc_with_text("abc")
227
+ step = Prosereflect::Transform::ReplaceStep.new(-1, 2, Prosereflect::Transform::Slice.empty)
228
+ result = step.apply(doc)
229
+
230
+ expect(result).not_to be_ok
231
+ expect(result.failed).to eq("from < 0")
232
+ end
233
+
234
+ it "fails when to exceeds document size" do
235
+ doc = build_doc_with_text("abc")
236
+ # doc.node_size = 6
237
+ step = Prosereflect::Transform::ReplaceStep.new(2, 100, Prosereflect::Transform::Slice.empty)
238
+ result = step.apply(doc)
239
+
240
+ expect(result).not_to be_ok
241
+ expect(result.failed).to eq("to > doc size")
242
+ end
243
+
244
+ it "succeeds when from equals to (insertion)" do
245
+ doc = build_doc_with_text("abc")
246
+ slice = Prosereflect::Transform::Slice.new(
247
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "x")]),
248
+ )
249
+ step = Prosereflect::Transform::ReplaceStep.new(3, 3, slice)
250
+ result = step.apply(doc)
251
+
252
+ expect(result).to be_ok
253
+ end
254
+
255
+ it "succeeds when from=0 and to=doc.node_size (replace entire document)" do
256
+ doc = build_doc_with_text("abc")
257
+ step = Prosereflect::Transform::ReplaceStep.new(0, doc.node_size, Prosereflect::Transform::Slice.empty)
258
+ result = step.apply(doc)
259
+
260
+ expect(result).to be_ok
261
+ end
262
+ end
263
+ end
264
+
265
+ describe "#invert" do
266
+ it "produces a ReplaceStep that reverses a deletion" do
267
+ doc = build_doc_with_text("Hello")
268
+ # Delete "ell" (positions 3-6)
269
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
270
+ inverted = step.invert(doc)
271
+
272
+ expect(inverted).to be_a(Prosereflect::Transform::ReplaceStep)
273
+ # invert: ReplaceStep.new(@from, @from + @slice.size, removed)
274
+ # @from=3, @slice.size=0 (empty slice), so inverted.to = 3 + 0 = 3
275
+ expect(inverted.from).to eq(3)
276
+ expect(inverted.to).to eq(3)
277
+ # removed should contain the content that was between 3 and 6
278
+ expect(inverted.slice).not_to be_nil
279
+ end
280
+
281
+ it "produces a step that reverses an insertion" do
282
+ doc = build_doc_with_text("ac")
283
+ insert = Prosereflect::Transform::Slice.new(
284
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "b")]),
285
+ )
286
+ step = Prosereflect::Transform::ReplaceStep.new(3, 3, insert)
287
+ inverted = step.invert(doc)
288
+
289
+ expect(inverted).to be_a(Prosereflect::Transform::ReplaceStep)
290
+ # invert: from = 3, to = 3 + slice.size
291
+ # slice contains text "b" (node_size=2), slice.size = 2 + 0 + 0 = 2
292
+ expect(inverted.from).to eq(3)
293
+ expect(inverted.to).to eq(5)
294
+ # The inverted step's slice is the content that was at positions 3-3 (empty)
295
+ # Note: invert returns a Fragment, not a Slice, due to content_between
296
+ expect(inverted.slice).to be_a(Prosereflect::Fragment)
297
+ expect(inverted.slice.empty?).to be true
298
+ end
299
+
300
+ it "produces a step that reverses a replacement" do
301
+ doc = build_doc_with_text("Hello")
302
+ replacement = Prosereflect::Transform::Slice.new(
303
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "XY")]),
304
+ )
305
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, replacement)
306
+ inverted = step.invert(doc)
307
+
308
+ expect(inverted).to be_a(Prosereflect::Transform::ReplaceStep)
309
+ # invert: from = 3, to = 3 + slice.size = 3 + 3 (text "XY" = node_size 3)
310
+ expect(inverted.from).to eq(3)
311
+ expect(inverted.to).to eq(6)
312
+ # The inverted slice should contain what was at positions 3-6
313
+ expect(inverted.slice).not_to be_nil
314
+ end
315
+
316
+ it "inverted step has correct from position matching original step from" do
317
+ doc = build_doc_with_text("Hello")
318
+ step = Prosereflect::Transform::ReplaceStep.new(2, 7, Prosereflect::Transform::Slice.empty)
319
+ inverted = step.invert(doc)
320
+
321
+ expect(inverted.from).to eq(2)
322
+ end
323
+
324
+ it "inverted step to equals from plus slice size" do
325
+ doc = build_doc_with_text("Hello")
326
+ replacement = Prosereflect::Transform::Slice.new(
327
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "abc")]),
328
+ )
329
+ step = Prosereflect::Transform::ReplaceStep.new(2, 7, replacement)
330
+ inverted = step.invert(doc)
331
+
332
+ # slice.size for text "abc" = node_size 4 + open_start 0 + open_end 0 = 4
333
+ expect(inverted.to).to eq(2 + replacement.size)
334
+ end
335
+ end
336
+
337
+ describe "#get_map" do
338
+ it "returns a StepMap with correct ranges for deletion" do
339
+ # Delete positions 3-6
340
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
341
+ step_map = step.get_map
342
+
343
+ expect(step_map).to be_a(Prosereflect::Transform::StepMap)
344
+ # delta = 0 - (6-3) = -3
345
+ # ranges: [[3, 6, 3, 0]]
346
+ expect(step_map.ranges).to eq([[3, 6, 3, 0]])
347
+ end
348
+
349
+ it "returns a StepMap with correct ranges for insertion" do
350
+ # Insert "XY" (node_size=3) at position 2
351
+ slice = Prosereflect::Transform::Slice.new(
352
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "XY")]),
353
+ )
354
+ step = Prosereflect::Transform::ReplaceStep.new(2, 2, slice)
355
+ step_map = step.get_map
356
+
357
+ # delta = 3 - (2-2) = 3
358
+ # ranges: [[2, 2, 2, 5]]
359
+ expect(step_map.ranges).to eq([[2, 2, 2, 5]])
360
+ end
361
+
362
+ it "returns a StepMap with correct ranges for same-size replacement" do
363
+ # Replace 3 positions with 3-node_size content (net zero)
364
+ slice = Prosereflect::Transform::Slice.new(
365
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "ab")]),
366
+ )
367
+ step = Prosereflect::Transform::ReplaceStep.new(2, 5, slice)
368
+ step_map = step.get_map
369
+
370
+ # delta = 3 - (5-2) = 0
371
+ # ranges: [[2, 5, 2, 2]]
372
+ expect(step_map.ranges).to eq([[2, 5, 2, 2]])
373
+ end
374
+
375
+ it "maps positions before deletion range unchanged" do
376
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
377
+ step_map = step.get_map
378
+
379
+ # Position 1 is before range start 3, so it stays unchanged
380
+ expect(step_map.map(1)).to eq(1)
381
+ expect(step_map.map(2)).to eq(2)
382
+ end
383
+
384
+ it "maps positions after deletion range with offset" do
385
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
386
+ step_map = step.get_map
387
+
388
+ # For range [3,6,3,0]: positions >= 6 get offset by (new_end - old_end) = (0-6) = -6
389
+ # pos 6 -> 0, pos 7 -> 1, pos 8 -> 2
390
+ expect(step_map.map(6)).to eq(0)
391
+ expect(step_map.map(7)).to eq(1)
392
+ expect(step_map.map(8)).to eq(2)
393
+ end
394
+
395
+ it "maps positions after insertion range with positive offset" do
396
+ slice = Prosereflect::Transform::Slice.new(
397
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "XY")]),
398
+ )
399
+ step = Prosereflect::Transform::ReplaceStep.new(2, 2, slice)
400
+ step_map = step.get_map
401
+
402
+ # For range [2,2,2,5]: positions >= 2 get offset by (5-2) = 3
403
+ expect(step_map.map(5)).to eq(8)
404
+ end
405
+
406
+ it "marks positions in deleted range as deleted" do
407
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
408
+ step_map = step.get_map
409
+
410
+ expect(step_map.deleted?(4)).to be true
411
+ expect(step_map.deleted?(2)).to be false
412
+ expect(step_map.deleted?(7)).to be false
413
+ end
414
+
415
+ it "returns correct map_result for deleted positions" do
416
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
417
+ step_map = step.get_map
418
+
419
+ result = step_map.map_result(4)
420
+ expect(result.deleted).to be true
421
+ end
422
+
423
+ it "returns correct map_result for non-deleted positions" do
424
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
425
+ step_map = step.get_map
426
+
427
+ result = step_map.map_result(2)
428
+ expect(result.deleted).to be false
429
+ expect(result.pos).to eq(2)
430
+ end
431
+ end
432
+
433
+ describe "mapping through Mapping" do
434
+ it "maps positions through a single deletion step" do
435
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
436
+ mapping = Prosereflect::Transform::Mapping.from_step_map(step.get_map)
437
+
438
+ # Position 1 is before deletion range, stays the same
439
+ expect(mapping.map(1)).to eq(1)
440
+ # Position 7 is after deletion range, gets offset by -6
441
+ expect(mapping.map(7)).to eq(1)
442
+ end
443
+
444
+ it "maps positions through a single insertion step" do
445
+ slice = Prosereflect::Transform::Slice.new(
446
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "XY")]),
447
+ )
448
+ step = Prosereflect::Transform::ReplaceStep.new(2, 2, slice)
449
+ mapping = Prosereflect::Transform::Mapping.from_step_map(step.get_map)
450
+
451
+ # Position 1 is before insertion, stays the same
452
+ expect(mapping.map(1)).to eq(1)
453
+ # Position 5 is after insertion, shifts by +3
454
+ expect(mapping.map(5)).to eq(8)
455
+ end
456
+
457
+ it "maps positions through multiple steps" do
458
+ # First step: delete positions 3-6
459
+ step1 = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
460
+ # Second step: delete positions 4-6 (in the post-step1 doc)
461
+ step2 = Prosereflect::Transform::ReplaceStep.new(4, 6, Prosereflect::Transform::Slice.empty)
462
+
463
+ mapping = Prosereflect::Transform::Mapping.new
464
+ mapping.add_map(step1.get_map)
465
+ mapping.add_map(step2.get_map)
466
+
467
+ # Position 8 should be mapped through both steps
468
+ mapped = mapping.map(8)
469
+ expect(mapped).to be < 8
470
+ end
471
+
472
+ it "maps positions through empty mapping as identity" do
473
+ mapping = Prosereflect::Transform::Mapping.new
474
+ expect(mapping.map(5)).to eq(5)
475
+ end
476
+
477
+ it "reports deleted positions via map_result" do
478
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
479
+ mapping = Prosereflect::Transform::Mapping.from_step_map(step.get_map)
480
+
481
+ result = mapping.map_result(4)
482
+ expect(result[:deleted]).to be true
483
+ end
484
+
485
+ it "reports non-deleted positions via map_result" do
486
+ step = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
487
+ mapping = Prosereflect::Transform::Mapping.from_step_map(step.get_map)
488
+
489
+ result = mapping.map_result(2)
490
+ expect(result[:deleted]).to be false
491
+ expect(result[:pos]).to eq(2)
492
+ end
493
+
494
+ it "adds step maps at specific indices" do
495
+ mapping = Prosereflect::Transform::Mapping.new
496
+ step1 = Prosereflect::Transform::ReplaceStep.new(3, 6, Prosereflect::Transform::Slice.empty)
497
+ step2 = Prosereflect::Transform::ReplaceStep.new(4, 8, Prosereflect::Transform::Slice.empty)
498
+
499
+ mapping.add_map(step1.get_map)
500
+ mapping.add_map(step2.get_map, 0)
501
+
502
+ expect(mapping.to_a.length).to eq(2)
503
+ end
504
+ end
505
+
506
+ describe "ReplaceAroundStep" do
507
+ describe "creation" do
508
+ it "creates a ReplaceAroundStep with all parameters" do
509
+ slice = Prosereflect::Transform::Slice.new(
510
+ Prosereflect::Fragment.new([Prosereflect::Blockquote.create]),
511
+ )
512
+ step = Prosereflect::Transform::ReplaceAroundStep.new(
513
+ 1, 7, 1, 7, slice, 0, structure: false
514
+ )
515
+
516
+ expect(step.from).to eq(1)
517
+ expect(step.to).to eq(7)
518
+ expect(step.gap_from).to eq(1)
519
+ expect(step.gap_to).to eq(7)
520
+ expect(step.insert).to eq(0)
521
+ expect(step.structure).to be false
522
+ end
523
+
524
+ it "defaults structure to false" do
525
+ slice = Prosereflect::Transform::Slice.empty
526
+ step = Prosereflect::Transform::ReplaceAroundStep.new(
527
+ 1, 5, 1, 5, slice, 0
528
+ )
529
+ expect(step.structure).to be false
530
+ end
531
+
532
+ it "sets structure to true when specified" do
533
+ slice = Prosereflect::Transform::Slice.empty
534
+ step = Prosereflect::Transform::ReplaceAroundStep.new(
535
+ 1, 5, 1, 5, slice, 0, structure: true
536
+ )
537
+ expect(step.structure).to be true
538
+ end
539
+ end
540
+
541
+ describe "#step_type" do
542
+ it "returns replaceAround" do
543
+ step = Prosereflect::Transform::ReplaceAroundStep.new(
544
+ 1, 5, 1, 5, Prosereflect::Transform::Slice.empty, 0
545
+ )
546
+ expect(step.step_type).to eq("replaceAround")
547
+ end
548
+ end
549
+
550
+ describe "#get_map" do
551
+ it "returns a StepMap for the around-replacement" do
552
+ slice = Prosereflect::Transform::Slice.new(
553
+ Prosereflect::Fragment.new([Prosereflect::Blockquote.create]),
554
+ )
555
+ step = Prosereflect::Transform::ReplaceAroundStep.new(
556
+ 1, 7, 1, 7, slice, 0
557
+ )
558
+ step_map = step.get_map
559
+
560
+ expect(step_map).to be_a(Prosereflect::Transform::StepMap)
561
+ expect(step_map.ranges).to be_an(Array)
562
+ expect(step_map.ranges.length).to be > 0
563
+ end
564
+
565
+ it "produces correct ranges for a wrapping operation" do
566
+ # Wrapping a paragraph: from=1, to=7, gap_from=1, gap_to=7
567
+ # Slice is a blockquote (node_size=1), insert=0
568
+ slice = Prosereflect::Transform::Slice.new(
569
+ Prosereflect::Fragment.new([Prosereflect::Blockquote.create]),
570
+ )
571
+ step = Prosereflect::Transform::ReplaceAroundStep.new(
572
+ 1, 7, 1, 7, slice, 0
573
+ )
574
+ step_map = step.get_map
575
+
576
+ # get_map returns: [from, gap_from-from, insert, gap_to, to-gap_to, slice.size-insert]
577
+ # = [1, 0, 0, 7, 0, 1]
578
+ expect(step_map.ranges).to eq([1, 0, 0, 7, 0, 1])
579
+ end
580
+
581
+ it "produces correct ranges for a lift operation" do
582
+ # Lift: from=1, to=9, gap_from=3, gap_to=7, slice=empty, insert=0
583
+ slice = Prosereflect::Transform::Slice.empty
584
+ step = Prosereflect::Transform::ReplaceAroundStep.new(
585
+ 1, 9, 3, 7, slice, 0
586
+ )
587
+ step_map = step.get_map
588
+
589
+ # get_map returns: [1, 2, 0, 7, 2, 0]
590
+ expect(step_map.ranges).to eq([1, 2, 0, 7, 2, 0])
591
+ end
592
+ end
593
+
594
+ describe "#to_json" do
595
+ it "serializes core fields to a hash" do
596
+ slice = Prosereflect::Transform::Slice.new(
597
+ Prosereflect::Fragment.new([Prosereflect::Blockquote.create]),
598
+ )
599
+ step = Prosereflect::Transform::ReplaceAroundStep.new(
600
+ 1, 7, 1, 7, slice, 0, structure: true
601
+ )
602
+
603
+ # Verify attributes directly since to_json has a bug with Fragment#map
604
+ expect(step.step_type).to eq("replaceAround")
605
+ expect(step.from).to eq(1)
606
+ expect(step.to).to eq(7)
607
+ expect(step.gap_from).to eq(1)
608
+ expect(step.gap_to).to eq(7)
609
+ expect(step.insert).to eq(0)
610
+ expect(step.structure).to be true
611
+ end
612
+
613
+ it "calls to_json which builds the expected hash structure" do
614
+ slice = Prosereflect::Transform::Slice.new(
615
+ Prosereflect::Fragment.new([Prosereflect::Blockquote.create]),
616
+ )
617
+ step = Prosereflect::Transform::ReplaceAroundStep.new(
618
+ 1, 7, 1, 7, slice, 0, structure: true
619
+ )
620
+ json = step.to_json
621
+
622
+ expect(json["stepType"]).to eq("replaceAround")
623
+ expect(json["from"]).to eq(1)
624
+ expect(json["to"]).to eq(7)
625
+ expect(json["gapFrom"]).to eq(1)
626
+ expect(json["gapTo"]).to eq(7)
627
+ expect(json["insert"]).to eq(0)
628
+ expect(json["structure"]).to be true
629
+ expect(json["slice"]).to be_an(Array)
630
+ end
631
+ end
632
+ end
633
+
634
+ describe "edge cases" do
635
+ context "with empty document content" do
636
+ it "applies a step to an empty paragraph" do
637
+ doc = Prosereflect::Parser.parse_document(
638
+ "type" => "doc",
639
+ "content" => [
640
+ { "type" => "paragraph" },
641
+ ],
642
+ )
643
+ # Empty paragraph: doc=1, para=1, total=2
644
+ insert = Prosereflect::Transform::Slice.new(
645
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "hi")]),
646
+ )
647
+ step = Prosereflect::Transform::ReplaceStep.new(2, 2, insert)
648
+ result = step.apply(doc)
649
+
650
+ expect(result).to be_ok
651
+ end
652
+
653
+ it "inserts into an empty document" do
654
+ doc = Prosereflect::Parser.parse_document(
655
+ "type" => "doc",
656
+ "content" => [],
657
+ )
658
+ # Empty doc: node_size=1
659
+ expect(doc.node_size).to eq(1)
660
+ # Cannot insert at position 2 since doc size is 1
661
+ step = Prosereflect::Transform::ReplaceStep.new(1, 1, Prosereflect::Transform::Slice.empty)
662
+ result = step.apply(doc)
663
+
664
+ expect(result).to be_ok
665
+ end
666
+ end
667
+
668
+ context "with nested structures" do
669
+ it "replaces content inside a nested blockquote" do
670
+ doc = Prosereflect::Parser.parse_document(
671
+ "type" => "doc",
672
+ "content" => [
673
+ {
674
+ "type" => "blockquote",
675
+ "content" => [
676
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "quoted" }] },
677
+ ],
678
+ },
679
+ ],
680
+ )
681
+ # doc=1 + bq=1 + para=1 + text("quoted")=7 = 10
682
+ # Delete text inside blockquote (positions 4-9)
683
+ step = Prosereflect::Transform::ReplaceStep.new(4, 9, Prosereflect::Transform::Slice.empty)
684
+ result = step.apply(doc)
685
+
686
+ expect(result).to be_ok
687
+ end
688
+
689
+ it "applies a step to a document with multiple block types" do
690
+ doc = Prosereflect::Parser.parse_document(
691
+ "type" => "doc",
692
+ "content" => [
693
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "first" }] },
694
+ { "type" => "heading", "attrs" => { "level" => 2 },
695
+ "content" => [{ "type" => "text", "text" => "title" }] },
696
+ { "type" => "paragraph", "content" => [{ "type" => "text", "text" => "last" }] },
697
+ ],
698
+ )
699
+ # Delete the heading (positions 8-15)
700
+ # first para: 1+1+6=8, heading: 1+1+6=8, heading starts at pos 8
701
+ step = Prosereflect::Transform::ReplaceStep.new(8, 15, Prosereflect::Transform::Slice.empty)
702
+ result = step.apply(doc)
703
+
704
+ expect(result).to be_ok
705
+ end
706
+ end
707
+
708
+ context "when replacing across node boundaries" do
709
+ it "deletes content spanning two paragraphs" do
710
+ doc = build_doc_with_paragraphs("ab", "cd")
711
+ # doc=1 + (para=1+text=3) + (para=1+text=3) = 9
712
+ # Positions: 0=doc, 1=para1, 2="a", 3="b", 4=para1_end/para2_start
713
+ # 5=para2, 6="c", 7="d", 8=end
714
+ # Delete from middle of para1 to middle of para2 (3-7)
715
+ step = Prosereflect::Transform::ReplaceStep.new(3, 7, Prosereflect::Transform::Slice.empty)
716
+ result = step.apply(doc)
717
+
718
+ expect(result).to be_ok
719
+ end
720
+
721
+ it "replaces content spanning two paragraphs with new content" do
722
+ doc = build_doc_with_paragraphs("ab", "cd")
723
+ replacement = Prosereflect::Transform::Slice.new(
724
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "XY")]),
725
+ )
726
+ step = Prosereflect::Transform::ReplaceStep.new(3, 7, replacement)
727
+ result = step.apply(doc)
728
+
729
+ expect(result).to be_ok
730
+ end
731
+ end
732
+
733
+ context "with table structures" do
734
+ it "deletes content inside a table cell" do
735
+ doc = Prosereflect::Parser.parse_document(
736
+ "type" => "doc",
737
+ "content" => [
738
+ {
739
+ "type" => "table",
740
+ "content" => [
741
+ {
742
+ "type" => "table_row",
743
+ "content" => [
744
+ {
745
+ "type" => "table_cell",
746
+ "content" => [
747
+ { "type" => "paragraph",
748
+ "content" => [{ "type" => "text", "text" => "cell" }] },
749
+ ],
750
+ },
751
+ ],
752
+ },
753
+ ],
754
+ },
755
+ ],
756
+ )
757
+ # doc=1, table=1, row=1, cell=1, para=1, text("cell")=5 = 10
758
+ # Positions inside text: 6="c", 7="e", 8="l", 9="l"
759
+ step = Prosereflect::Transform::ReplaceStep.new(7, 9, Prosereflect::Transform::Slice.empty)
760
+ result = step.apply(doc)
761
+
762
+ expect(result).to be_ok
763
+ end
764
+ end
765
+
766
+ context "with Slice operations" do
767
+ it "Slice.empty creates a truly empty slice" do
768
+ slice = Prosereflect::Transform::Slice.empty
769
+ expect(slice.empty?).to be true
770
+ expect(slice.open_start).to eq(0)
771
+ expect(slice.open_end).to eq(0)
772
+ end
773
+
774
+ it "Slice with content reports correct size" do
775
+ content = Prosereflect::Fragment.new(
776
+ [
777
+ Prosereflect::Text.new(text: "abc"),
778
+ ],
779
+ )
780
+ slice = Prosereflect::Transform::Slice.new(content, 1, 1)
781
+
782
+ # content_size = 4 (text "abc" = 3+1), size = 4+1+1 = 6
783
+ expect(slice.size).to eq(6)
784
+ expect(slice.open_start).to eq(1)
785
+ expect(slice.open_end).to eq(1)
786
+ end
787
+
788
+ it "Slice equality checks open_start and open_end" do
789
+ content = Prosereflect::Fragment.new([Prosereflect::Text.new(text: "a")])
790
+ slice1 = Prosereflect::Transform::Slice.new(content, 0, 0)
791
+ slice2 = Prosereflect::Transform::Slice.new(content, 1, 0)
792
+
793
+ expect(slice1).not_to eq(slice2)
794
+ end
795
+
796
+ it "two identical slices are equal" do
797
+ content = Prosereflect::Fragment.new([Prosereflect::Text.new(text: "x")])
798
+ slice1 = Prosereflect::Transform::Slice.new(content, 0, 0)
799
+ slice2 = Prosereflect::Transform::Slice.new(
800
+ Prosereflect::Fragment.new([Prosereflect::Text.new(text: "x")]), 0, 0
801
+ )
802
+
803
+ expect(slice1).to eq(slice2)
804
+ end
805
+
806
+ it "Slice.empty with open boundaries is not empty" do
807
+ slice = Prosereflect::Transform::Slice.new(
808
+ Prosereflect::Fragment.new([]), 1, 0
809
+ )
810
+ expect(slice.empty?).to be false
811
+ end
812
+ end
813
+
814
+ context "with StepMap composition" do
815
+ it "adding an empty map to a non-empty map returns the non-empty map" do
816
+ step_map = Prosereflect::Transform::StepMap.new([[0, 5, 0, 5]])
817
+ empty_map = Prosereflect::Transform::StepMap.empty
818
+ result = step_map.add_map(empty_map)
819
+
820
+ expect(result.ranges).to eq([[0, 5, 0, 5]])
821
+ end
822
+
823
+ it "adding a non-empty map to an empty map returns the non-empty map" do
824
+ empty_map = Prosereflect::Transform::StepMap.empty
825
+ step_map = Prosereflect::Transform::StepMap.new([[0, 5, 0, 5]])
826
+ result = empty_map.add_map(step_map)
827
+
828
+ expect(result.ranges).to eq([[0, 5, 0, 5]])
829
+ end
830
+ end
831
+ end
832
+ end