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.
- checksums.yaml +4 -4
- data/.github/workflows/docs.yml +63 -0
- data/.github/workflows/links.yml +97 -0
- data/.gitignore +4 -0
- data/.rubocop_todo.yml +61 -75
- data/README.adoc +2 -0
- data/docs/Gemfile +10 -0
- data/docs/INDEX.adoc +45 -0
- data/docs/_advanced/index.adoc +15 -0
- data/docs/_advanced/schema.adoc +112 -0
- data/docs/_advanced/step-map.adoc +66 -0
- data/docs/_advanced/steps.adoc +88 -0
- data/docs/_advanced/test-builder.adoc +61 -0
- data/docs/_advanced/transform.adoc +92 -0
- data/docs/_config.yml +174 -0
- data/docs/_features/html-input.adoc +69 -0
- data/docs/_features/html-output.adoc +45 -0
- data/docs/_features/index.adoc +15 -0
- data/docs/_features/marks.adoc +86 -0
- data/docs/_features/node-types.adoc +124 -0
- data/docs/_features/user-mentions.adoc +47 -0
- data/docs/_guides/custom-nodes.adoc +107 -0
- data/docs/_guides/index.adoc +13 -0
- data/docs/_guides/round-trip-html.adoc +91 -0
- data/docs/_guides/serialization.adoc +109 -0
- data/docs/_pages/index.adoc +67 -0
- data/docs/_reference/document-api.adoc +49 -0
- data/docs/_reference/index.adoc +14 -0
- data/docs/_reference/node-api.adoc +79 -0
- data/docs/_reference/schema-api.adoc +95 -0
- data/docs/_reference/transform-api.adoc +77 -0
- data/docs/_understanding/document-model.adoc +65 -0
- data/docs/_understanding/fragment.adoc +52 -0
- data/docs/_understanding/index.adoc +14 -0
- data/docs/_understanding/resolved-position.adoc +53 -0
- data/docs/_understanding/slice.adoc +54 -0
- data/docs/lychee.toml +63 -0
- data/lib/prosereflect/blockquote.rb +9 -0
- data/lib/prosereflect/bullet_list.rb +25 -19
- data/lib/prosereflect/code_block.rb +1 -5
- data/lib/prosereflect/fragment.rb +249 -0
- data/lib/prosereflect/horizontal_rule.rb +9 -0
- data/lib/prosereflect/image.rb +9 -0
- data/lib/prosereflect/input/html.rb +96 -0
- data/lib/prosereflect/node.rb +141 -3
- data/lib/prosereflect/ordered_list.rb +2 -0
- data/lib/prosereflect/output/html.rb +227 -0
- data/lib/prosereflect/parser.rb +9 -0
- data/lib/prosereflect/resolved_pos.rb +256 -0
- data/lib/prosereflect/schema/attribute.rb +57 -0
- data/lib/prosereflect/schema/content_match.rb +656 -0
- data/lib/prosereflect/schema/fragment.rb +166 -0
- data/lib/prosereflect/schema/mark.rb +121 -0
- data/lib/prosereflect/schema/mark_type.rb +130 -0
- data/lib/prosereflect/schema/node.rb +236 -0
- data/lib/prosereflect/schema/node_type.rb +274 -0
- data/lib/prosereflect/schema/schema_main.rb +190 -0
- data/lib/prosereflect/schema/spec.rb +92 -0
- data/lib/prosereflect/schema.rb +39 -0
- data/lib/prosereflect/text.rb +24 -0
- data/lib/prosereflect/transform/attr_step.rb +157 -0
- data/lib/prosereflect/transform/insert_step.rb +115 -0
- data/lib/prosereflect/transform/mapping.rb +82 -0
- data/lib/prosereflect/transform/mark_step.rb +269 -0
- data/lib/prosereflect/transform/replace_around_step.rb +181 -0
- data/lib/prosereflect/transform/replace_step.rb +157 -0
- data/lib/prosereflect/transform/slice.rb +91 -0
- data/lib/prosereflect/transform/step.rb +89 -0
- data/lib/prosereflect/transform/step_map.rb +126 -0
- data/lib/prosereflect/transform/structure.rb +120 -0
- data/lib/prosereflect/transform/transform.rb +341 -0
- data/lib/prosereflect/transform.rb +26 -0
- data/lib/prosereflect/version.rb +1 -1
- data/lib/prosereflect.rb +3 -0
- data/spec/fixtures/documents/formatted_text.yaml +14 -0
- data/spec/fixtures/documents/heading_paragraph.yaml +16 -0
- data/spec/fixtures/documents/lists_doc.yaml +32 -0
- data/spec/fixtures/documents/mixed_content.yaml +40 -0
- data/spec/fixtures/documents/nested_doc.yaml +20 -0
- data/spec/fixtures/documents/simple_doc.yaml +6 -0
- data/spec/fixtures/documents/table_doc.yaml +32 -0
- data/spec/fixtures/documents/transform_test.yaml +14 -0
- data/spec/fixtures/schema/custom_schema.rb +37 -0
- data/spec/fixtures/schema/test_schema.rb +46 -0
- data/spec/fixtures/test_builder/helpers.rb +212 -0
- data/spec/prosereflect/document_spec.rb +1 -1
- data/spec/prosereflect/fragment_spec.rb +273 -0
- data/spec/prosereflect/input/html_spec.rb +197 -1
- data/spec/prosereflect/node_spec.rb +128 -0
- data/spec/prosereflect/output/whitespace_spec.rb +248 -0
- data/spec/prosereflect/parser/round_trip_spec.rb +472 -0
- data/spec/prosereflect/resolved_pos_spec.rb +74 -0
- data/spec/prosereflect/schema/conftest.rb +68 -0
- data/spec/prosereflect/schema/content_match_spec.rb +237 -0
- data/spec/prosereflect/schema/mark_spec.rb +274 -0
- data/spec/prosereflect/schema/mark_type_spec.rb +86 -0
- data/spec/prosereflect/schema/node_type_spec.rb +142 -0
- data/spec/prosereflect/schema/schema_spec.rb +194 -0
- data/spec/prosereflect/test_builder/marks_spec.rb +127 -0
- data/spec/prosereflect/transform/equivalence_spec.rb +487 -0
- data/spec/prosereflect/transform/mapping_spec.rb +226 -0
- data/spec/prosereflect/transform/replace_spec.rb +832 -0
- data/spec/prosereflect/transform/replace_step_spec.rb +157 -0
- data/spec/prosereflect/transform/slice_spec.rb +48 -0
- data/spec/prosereflect/transform/step_map_spec.rb +70 -0
- data/spec/prosereflect/transform/step_spec.rb +211 -0
- data/spec/prosereflect/transform/structure_spec.rb +98 -0
- data/spec/prosereflect/transform/transform_spec.rb +238 -0
- data/spec/spec_helper.rb +1 -0
- 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
|