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,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prosereflect"
|
|
4
|
+
require "prosereflect/schema"
|
|
5
|
+
|
|
6
|
+
# Custom schema with mark exclusion rules
|
|
7
|
+
# Used for testing that link excludes emoji
|
|
8
|
+
module CustomSchemaFixture
|
|
9
|
+
CUSTOM_SCHEMA_NODES = {
|
|
10
|
+
"doc" => { "content" => "block+" },
|
|
11
|
+
"paragraph" => { "content" => "inline*" },
|
|
12
|
+
"text" => {},
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
CUSTOM_SCHEMA_MARKS = {
|
|
16
|
+
"link" => { "excludes" => "emoji" },
|
|
17
|
+
"bold" => {},
|
|
18
|
+
"italic" => {},
|
|
19
|
+
"emoji" => {},
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def self.build
|
|
23
|
+
Prosereflect::Schema.new(
|
|
24
|
+
nodes_spec: CUSTOM_SCHEMA_NODES,
|
|
25
|
+
marks_spec: CUSTOM_SCHEMA_MARKS,
|
|
26
|
+
top_node: "doc",
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.nodes
|
|
31
|
+
CUSTOM_SCHEMA_NODES
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.marks
|
|
35
|
+
CUSTOM_SCHEMA_MARKS
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prosereflect"
|
|
4
|
+
require "prosereflect/schema"
|
|
5
|
+
|
|
6
|
+
# Test schema matching prosemirror-py test_schema
|
|
7
|
+
# Used for testing content expressions, mark handling, etc.
|
|
8
|
+
module TestSchemaFixture
|
|
9
|
+
TEST_SCHEMA_NODES = {
|
|
10
|
+
"doc" => { "content" => "block+" },
|
|
11
|
+
"paragraph" => { "content" => "inline*" },
|
|
12
|
+
"code_block" => { "content" => "text*", "marks" => "" },
|
|
13
|
+
"heading" => { "content" => "inline*", "attrs" => { "level" => { "default" => 1 } } },
|
|
14
|
+
"blockquote" => { "content" => "block+", "defining" => true },
|
|
15
|
+
"horizontal_rule" => {},
|
|
16
|
+
"text" => {},
|
|
17
|
+
"image" => { "attrs" => { "src" => {}, "alt" => { "default" => "" }, "title" => { "default" => "" } } },
|
|
18
|
+
"hard_break" => { "selectable" => false },
|
|
19
|
+
"ordered_list" => { "content" => "list_item+", "attrs" => { "order" => { "default" => 1 } }, "group" => "block" },
|
|
20
|
+
"bullet_list" => { "content" => "list_item+", "group" => "block" },
|
|
21
|
+
"list_item" => { "content" => "paragraph block*", "defining" => true },
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
TEST_SCHEMA_MARKS = {
|
|
25
|
+
"link" => { "attrs" => { "href" => {}, "title" => { "default" => "" } }, "inclusive" => false },
|
|
26
|
+
"em" => {},
|
|
27
|
+
"strong" => {},
|
|
28
|
+
"code" => {},
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def self.build
|
|
32
|
+
Prosereflect::Schema.new(
|
|
33
|
+
nodes_spec: TEST_SCHEMA_NODES,
|
|
34
|
+
marks_spec: TEST_SCHEMA_MARKS,
|
|
35
|
+
top_node: "doc",
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.nodes
|
|
40
|
+
TEST_SCHEMA_NODES
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.marks
|
|
44
|
+
TEST_SCHEMA_MARKS
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# TestBuilder - Port of prosemirror-py test_builder/build.py
|
|
4
|
+
# Provides helpers for creating test nodes from string representations
|
|
5
|
+
|
|
6
|
+
module TestBuilder
|
|
7
|
+
# Parse a test string into a document
|
|
8
|
+
# Examples:
|
|
9
|
+
# TestBuilder.parse("doc(p(\"hello world\"))")
|
|
10
|
+
# TestBuilder.parse("doc(p(\"hello<|a> world\"))")
|
|
11
|
+
def self.parse(str)
|
|
12
|
+
builder = Builder.new
|
|
13
|
+
builder.parse(str)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Create a builder for a specific schema
|
|
17
|
+
def self.for_schema(schema)
|
|
18
|
+
Builder.new(schema: schema)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Builder
|
|
22
|
+
attr_reader :schema
|
|
23
|
+
|
|
24
|
+
def initialize(schema: nil)
|
|
25
|
+
@schema = schema
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse(str)
|
|
29
|
+
# Parse the string into a node tree
|
|
30
|
+
# This is a simplified parser for test strings like:
|
|
31
|
+
# doc(p("hello", em("world")))
|
|
32
|
+
# doc(p("hello<|a> world"))
|
|
33
|
+
content = extract_content(str)
|
|
34
|
+
return nil if content.nil?
|
|
35
|
+
|
|
36
|
+
parse_content(content)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse_content(str)
|
|
40
|
+
# Simple recursive descent parser for test strings
|
|
41
|
+
# Handles: doc(...), p(...), em(...), strong(...), etc.
|
|
42
|
+
# And string literals: "hello world"
|
|
43
|
+
tokens = tokenize(str)
|
|
44
|
+
parse_tokens(tokens)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def tokenize(str)
|
|
50
|
+
# Simple tokenizer
|
|
51
|
+
tokens = []
|
|
52
|
+
i = 0
|
|
53
|
+
while i < str.length
|
|
54
|
+
case str[i]
|
|
55
|
+
when /\s/
|
|
56
|
+
i += 1
|
|
57
|
+
when /[a-z_]/
|
|
58
|
+
# Identifier
|
|
59
|
+
start = i
|
|
60
|
+
i += 1
|
|
61
|
+
while i < str.length && str[i] =~ /[a-z0-9_]/
|
|
62
|
+
i += 1
|
|
63
|
+
end
|
|
64
|
+
tokens << [:identifier, str[start...i]]
|
|
65
|
+
when '"'
|
|
66
|
+
# String literal
|
|
67
|
+
i += 1
|
|
68
|
+
start = i
|
|
69
|
+
while i < str.length && str[i] != '"'
|
|
70
|
+
i += 1
|
|
71
|
+
end
|
|
72
|
+
tokens << [:string, str[start...i]]
|
|
73
|
+
i += 1 if i < str.length
|
|
74
|
+
when "(", ")", "<", ">", "|", ","
|
|
75
|
+
tokens << [str[i], str[i]]
|
|
76
|
+
i += 1
|
|
77
|
+
else
|
|
78
|
+
i += 1
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
tokens
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_tokens(tokens)
|
|
85
|
+
return nil if tokens.empty?
|
|
86
|
+
|
|
87
|
+
token = tokens.first
|
|
88
|
+
return nil unless token
|
|
89
|
+
|
|
90
|
+
if token[0] == :identifier
|
|
91
|
+
name = token[1]
|
|
92
|
+
tokens.shift
|
|
93
|
+
consume("(", tokens)
|
|
94
|
+
if name == "doc"
|
|
95
|
+
children = parse_children(tokens)
|
|
96
|
+
consume(")", tokens)
|
|
97
|
+
build_doc(children)
|
|
98
|
+
elsif name == "text"
|
|
99
|
+
str_token = tokens.shift
|
|
100
|
+
consume(")", tokens)
|
|
101
|
+
build_text(str_token ? str_token[1] : "")
|
|
102
|
+
else
|
|
103
|
+
# Assume it's a paragraph or other node
|
|
104
|
+
children = parse_children(tokens)
|
|
105
|
+
consume(")", tokens)
|
|
106
|
+
build_node(name, children)
|
|
107
|
+
end
|
|
108
|
+
elsif token[0] == :string
|
|
109
|
+
tokens.shift
|
|
110
|
+
build_text(token[1])
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_children(tokens)
|
|
115
|
+
children = []
|
|
116
|
+
while tokens.any? && tokens.first[0] != ")"
|
|
117
|
+
child = parse_tokens(tokens)
|
|
118
|
+
children << child if child
|
|
119
|
+
break if tokens.empty?
|
|
120
|
+
|
|
121
|
+
if tokens.first[0] == ","
|
|
122
|
+
tokens.shift
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
children
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def consume(expected, tokens)
|
|
129
|
+
return if tokens.empty?
|
|
130
|
+
|
|
131
|
+
token = tokens.first
|
|
132
|
+
return unless token && token[0] == expected
|
|
133
|
+
|
|
134
|
+
tokens.shift
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_doc(children)
|
|
138
|
+
nodes = children.flat_map do |child|
|
|
139
|
+
if child.is_a?(Array)
|
|
140
|
+
child
|
|
141
|
+
else
|
|
142
|
+
[child].compact
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
Prosereflect::Document.new(content: nodes)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_node(type, children)
|
|
149
|
+
case type
|
|
150
|
+
when "p"
|
|
151
|
+
content = children.flat_map do |child|
|
|
152
|
+
if child.is_a?(Prosereflect::Node)
|
|
153
|
+
[child]
|
|
154
|
+
else
|
|
155
|
+
[]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
Prosereflect::Paragraph.new(content: content)
|
|
159
|
+
when "text"
|
|
160
|
+
children.first || ""
|
|
161
|
+
else
|
|
162
|
+
# Generic node - return children as content
|
|
163
|
+
children.flatten.compact.first
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_text(str)
|
|
168
|
+
return str if str.is_a?(String)
|
|
169
|
+
|
|
170
|
+
Prosereflect::Text.new(text: str.to_s)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def extract_content(str)
|
|
174
|
+
# Extract content between outermost parentheses
|
|
175
|
+
paren_depth = 0
|
|
176
|
+
start = nil
|
|
177
|
+
str.each_char.with_index do |char, i|
|
|
178
|
+
if char == "(" && paren_depth == 0
|
|
179
|
+
start = i + 1
|
|
180
|
+
paren_depth += 1
|
|
181
|
+
elsif char == "("
|
|
182
|
+
paren_depth += 1
|
|
183
|
+
elsif char == ")"
|
|
184
|
+
paren_depth -= 1
|
|
185
|
+
if paren_depth == 0 && start
|
|
186
|
+
return str[start...i]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Extract position markers from a test string
|
|
195
|
+
# Returns [content_string, positions_hash]
|
|
196
|
+
# Example: "doc(p(\"hello<|a> world<|b>\"))" => ["doc(p(\"hello world\"))", {"a" => 6, "b" => 12}]
|
|
197
|
+
def self.extract_markers(str)
|
|
198
|
+
positions = {}
|
|
199
|
+
marker_regex = /<\|([a-z0-9]+)>|<([0-9]+)>|<\|>/
|
|
200
|
+
result = str.gsub(marker_regex) do |_match|
|
|
201
|
+
if $1
|
|
202
|
+
positions[$1] = $~.offset(0)[0]
|
|
203
|
+
elsif $2
|
|
204
|
+
positions[$2.to_i] = $~.offset(0)[0]
|
|
205
|
+
else
|
|
206
|
+
positions[:cursor] = $~.offset(0)[0]
|
|
207
|
+
end
|
|
208
|
+
""
|
|
209
|
+
end
|
|
210
|
+
[result, positions]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -282,9 +282,9 @@ RSpec.describe Prosereflect::Document do
|
|
|
282
282
|
"content" => [{
|
|
283
283
|
"type" => "code_block",
|
|
284
284
|
"attrs" => {
|
|
285
|
-
"content" => "def example\n puts 'Hello'\nend",
|
|
286
285
|
"language" => "ruby",
|
|
287
286
|
},
|
|
287
|
+
"content" => ["def example\n puts 'Hello'\nend"],
|
|
288
288
|
}],
|
|
289
289
|
}, {
|
|
290
290
|
"type" => "blockquote",
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Prosereflect::Fragment do
|
|
6
|
+
describe "creation" do
|
|
7
|
+
it "creates empty fragment" do
|
|
8
|
+
frag = described_class.new
|
|
9
|
+
expect(frag.empty?).to be true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "creates fragment with content" do
|
|
13
|
+
node = Prosereflect::Paragraph.new
|
|
14
|
+
frag = described_class.new([node])
|
|
15
|
+
expect(frag.empty?).to be false
|
|
16
|
+
expect(frag.length).to eq(1)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "creates fragment from single node" do
|
|
20
|
+
node = Prosereflect::Text.new(text: "hello")
|
|
21
|
+
frag = described_class.new(node)
|
|
22
|
+
expect(frag.length).to eq(1)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe ".empty" do
|
|
27
|
+
it "returns a shared empty fragment" do
|
|
28
|
+
frag1 = described_class.empty
|
|
29
|
+
frag2 = described_class.empty
|
|
30
|
+
expect(frag1).to equal(frag2)
|
|
31
|
+
expect(frag1.empty?).to be true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe ".from" do
|
|
36
|
+
it "returns same fragment when given a fragment" do
|
|
37
|
+
frag = described_class.new
|
|
38
|
+
expect(described_class.from(frag)).to equal(frag)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "wraps array in fragment" do
|
|
42
|
+
node = Prosereflect::Text.new(text: "x")
|
|
43
|
+
frag = described_class.from([node])
|
|
44
|
+
expect(frag).to be_a(described_class)
|
|
45
|
+
expect(frag.length).to eq(1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "wraps single node in fragment" do
|
|
49
|
+
node = Prosereflect::Text.new(text: "x")
|
|
50
|
+
frag = described_class.from(node)
|
|
51
|
+
expect(frag.length).to eq(1)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe "size" do
|
|
56
|
+
it "returns 0 for empty fragment" do
|
|
57
|
+
expect(described_class.new.size).to eq(0)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "returns total node sizes" do
|
|
61
|
+
text = Prosereflect::Text.new(text: "hello")
|
|
62
|
+
frag = described_class.new([text])
|
|
63
|
+
# text "hello" has node_size = 5 + 1 = 6
|
|
64
|
+
expect(frag.size).to eq(6)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "sums multiple nodes" do
|
|
68
|
+
t1 = Prosereflect::Text.new(text: "ab")
|
|
69
|
+
t2 = Prosereflect::Text.new(text: "cd")
|
|
70
|
+
frag = described_class.new([t1, t2])
|
|
71
|
+
# 3 + 3 = 6
|
|
72
|
+
expect(frag.size).to eq(6)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
describe "append" do
|
|
77
|
+
it "appends another fragment" do
|
|
78
|
+
t1 = Prosereflect::Text.new(text: "a")
|
|
79
|
+
t2 = Prosereflect::Text.new(text: "b")
|
|
80
|
+
frag1 = described_class.new([t1])
|
|
81
|
+
frag2 = described_class.new([t2])
|
|
82
|
+
result = frag1.append(frag2)
|
|
83
|
+
expect(result.length).to eq(2)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "appends a single node" do
|
|
87
|
+
t1 = Prosereflect::Text.new(text: "a")
|
|
88
|
+
t2 = Prosereflect::Text.new(text: "b")
|
|
89
|
+
frag = described_class.new([t1])
|
|
90
|
+
result = frag.append(t2)
|
|
91
|
+
expect(result.length).to eq(2)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "does not mutate original" do
|
|
95
|
+
t1 = Prosereflect::Text.new(text: "a")
|
|
96
|
+
frag = described_class.new([t1])
|
|
97
|
+
frag.append(described_class.new)
|
|
98
|
+
expect(frag.length).to eq(1)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
describe "cut" do
|
|
103
|
+
it "returns empty fragment for empty cut" do
|
|
104
|
+
frag = described_class.new
|
|
105
|
+
result = frag.cut(0, 0)
|
|
106
|
+
expect(result).to be_a(described_class)
|
|
107
|
+
expect(result.empty?).to be true
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "returns empty when from >= to" do
|
|
111
|
+
text = Prosereflect::Text.new(text: "hello")
|
|
112
|
+
frag = described_class.new([text])
|
|
113
|
+
result = frag.cut(3, 3)
|
|
114
|
+
expect(result.empty?).to be true
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe "replace_child" do
|
|
119
|
+
it "replaces child at index" do
|
|
120
|
+
t1 = Prosereflect::Text.new(text: "a")
|
|
121
|
+
t2 = Prosereflect::Text.new(text: "b")
|
|
122
|
+
replacement = Prosereflect::Text.new(text: "c")
|
|
123
|
+
frag = described_class.new([t1, t2])
|
|
124
|
+
result = frag.replace_child(0, replacement)
|
|
125
|
+
expect(result[0]).to eq(replacement)
|
|
126
|
+
expect(result[1]).to eq(t2)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "does not mutate original" do
|
|
130
|
+
t1 = Prosereflect::Text.new(text: "a")
|
|
131
|
+
replacement = Prosereflect::Text.new(text: "c")
|
|
132
|
+
frag = described_class.new([t1])
|
|
133
|
+
frag.replace_child(0, replacement)
|
|
134
|
+
expect(frag[0]).to eq(t1)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
describe "index access" do
|
|
139
|
+
it "returns nil for out of bounds" do
|
|
140
|
+
frag = described_class.new
|
|
141
|
+
expect(frag[0]).to be_nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "returns node at index" do
|
|
145
|
+
node = Prosereflect::Text.new(text: "x")
|
|
146
|
+
frag = described_class.new([node])
|
|
147
|
+
expect(frag[0]).to eq(node)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe "iteration" do
|
|
152
|
+
it "iterates over content" do
|
|
153
|
+
node = Prosereflect::Paragraph.new
|
|
154
|
+
frag = described_class.new([node])
|
|
155
|
+
count = 0
|
|
156
|
+
frag.each { count += 1 }
|
|
157
|
+
expect(count).to eq(1)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "returns count" do
|
|
161
|
+
frag = described_class.new([Prosereflect::Text.new(text: "a"),
|
|
162
|
+
Prosereflect::Text.new(text: "b")])
|
|
163
|
+
expect(frag.count).to eq(2)
|
|
164
|
+
expect(frag.length).to eq(2)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
describe "equality" do
|
|
169
|
+
it "compares equal fragments" do
|
|
170
|
+
frag1 = described_class.new
|
|
171
|
+
frag2 = described_class.new
|
|
172
|
+
expect(frag1).to eq(frag2)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "compares unequal fragments" do
|
|
176
|
+
t1 = Prosereflect::Text.new(text: "a")
|
|
177
|
+
t2 = Prosereflect::Text.new(text: "b")
|
|
178
|
+
frag1 = described_class.new([t1])
|
|
179
|
+
frag2 = described_class.new([t2])
|
|
180
|
+
expect(frag1).not_to eq(frag2)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
describe "text_between" do
|
|
185
|
+
it "extracts text from nodes in range" do
|
|
186
|
+
text = Prosereflect::Text.new(text: "hello")
|
|
187
|
+
frag = described_class.new([text])
|
|
188
|
+
expect(frag.text_between(0, 5)).to eq("hello")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it "joins multiple nodes" do
|
|
192
|
+
t1 = Prosereflect::Text.new(text: "hello")
|
|
193
|
+
t2 = Prosereflect::Text.new(text: "world")
|
|
194
|
+
frag = described_class.new([t1, t2])
|
|
195
|
+
expect(frag.text_between(0, 10)).to eq("helloworld")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "joins with separator" do
|
|
199
|
+
t1 = Prosereflect::Text.new(text: "hello")
|
|
200
|
+
t2 = Prosereflect::Text.new(text: "world")
|
|
201
|
+
frag = described_class.new([t1, t2])
|
|
202
|
+
expect(frag.text_between(0, 10, " ")).to eq("hello world")
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
describe "find_diff_start" do
|
|
207
|
+
it "returns nil for identical fragments" do
|
|
208
|
+
t1 = Prosereflect::Text.new(text: "hello")
|
|
209
|
+
t2 = Prosereflect::Text.new(text: "hello")
|
|
210
|
+
frag1 = described_class.new([t1])
|
|
211
|
+
frag2 = described_class.new([t2])
|
|
212
|
+
expect(frag1.find_diff_start(frag2)).to be_nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it "returns 0 for completely different first nodes" do
|
|
216
|
+
t1 = Prosereflect::Text.new(text: "a")
|
|
217
|
+
t2 = Prosereflect::Text.new(text: "b")
|
|
218
|
+
frag1 = described_class.new([t1])
|
|
219
|
+
frag2 = described_class.new([t2])
|
|
220
|
+
expect(frag1.find_diff_start(frag2)).to eq(0)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it "returns position where fragments differ" do
|
|
224
|
+
t1 = Prosereflect::Text.new(text: "hello")
|
|
225
|
+
t2 = Prosereflect::Text.new(text: "hello")
|
|
226
|
+
t3 = Prosereflect::Text.new(text: "world")
|
|
227
|
+
frag1 = described_class.new([t1])
|
|
228
|
+
frag2 = described_class.new([t2, t3])
|
|
229
|
+
# Same first node, but different lengths
|
|
230
|
+
expect(frag1.find_diff_start(frag2)).to eq(6) # node_size of "hello"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
describe "find_diff_end" do
|
|
235
|
+
it "returns nil for identical fragments" do
|
|
236
|
+
t1 = Prosereflect::Text.new(text: "hello")
|
|
237
|
+
t2 = Prosereflect::Text.new(text: "hello")
|
|
238
|
+
frag1 = described_class.new([t1])
|
|
239
|
+
frag2 = described_class.new([t2])
|
|
240
|
+
expect(frag1.find_diff_end(frag2)).to be_nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it "returns position where trailing content differs" do
|
|
244
|
+
t1 = Prosereflect::Text.new(text: "ab")
|
|
245
|
+
t2 = Prosereflect::Text.new(text: "cd")
|
|
246
|
+
t3 = Prosereflect::Text.new(text: "ef")
|
|
247
|
+
frag1 = described_class.new([t1, t2])
|
|
248
|
+
frag2 = described_class.new([t3, t2])
|
|
249
|
+
# The last nodes ("cd") are the same, first nodes differ
|
|
250
|
+
# find_diff_end walks backward from the end and returns where they differ
|
|
251
|
+
result = frag1.find_diff_end(frag2)
|
|
252
|
+
expect(result).not_to be_nil
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
describe "to_a" do
|
|
257
|
+
it "returns a copy of the content array" do
|
|
258
|
+
node = Prosereflect::Text.new(text: "x")
|
|
259
|
+
frag = described_class.new([node])
|
|
260
|
+
arr = frag.to_a
|
|
261
|
+
expect(arr).to eq([node])
|
|
262
|
+
expect(arr).not_to equal(frag.content)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
describe "to_s / inspect" do
|
|
267
|
+
it "returns string representation" do
|
|
268
|
+
frag = described_class.new([Prosereflect::Text.new(text: "x")])
|
|
269
|
+
expect(frag.to_s).to include("Fragment")
|
|
270
|
+
expect(frag.inspect).to eq(frag.to_s)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|