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,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