yrby 0.8.1 → 0.9.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.
@@ -0,0 +1,749 @@
1
+ //! Live `Y::XmlText` handles: write rich text from Ruby.
2
+ //!
3
+ //! Rich-text editors built on Yjs (Lexical among them) keep their document in
4
+ //! `XmlText` nodes: a root, blocks embedded in it, and inside a block a run of
5
+ //! embeds and strings. This handle exposes exactly the operations that shape
6
+ //! needs: read the text, set attributes, insert strings, insert embeds, and
7
+ //! add a nested `XmlText` block and get a handle to it.
8
+ //!
9
+ //! Same contract as the other handles: a transaction per operation inside
10
+ //! `nogvl`, the branch resolved from its identity each time, Ruby touched only
11
+ //! with the GVL held. A handle to a block stays on that block while people
12
+ //! add and remove blocks around it; indexes inside a block are Ruby
13
+ //! characters, with each embed counting as one.
14
+
15
+ use magnus::{prelude::*, typed_data::Obj, Error, RArray, RHash, RString, Ruby, Value};
16
+ use yrs::branch::BranchID;
17
+ use yrs::{
18
+ Any, Assoc, Doc, GetString, IndexedSequence, Out, StickyIndex, Text, Transact, TransactionMut,
19
+ Xml, XmlTextPrelim, XmlTextRef,
20
+ };
21
+
22
+ use crate::nogvl;
23
+ use crate::shared::{
24
+ any_to_ruby, block_text, char_len, delete_span, element_index, embedded_xml_text_index,
25
+ embedded_xml_texts, end_or_units, ends_with_xml_text, guarded, hash_pairs, identity,
26
+ insert_index, resolve, ruby_to_invalue, to_in, to_map_prelim, to_units, InValue, Position,
27
+ MAX_NESTING, ORIGIN,
28
+ };
29
+ use crate::yrb_error;
30
+
31
+ #[magnus::wrap(class = "Y::XmlText", free_immediately, size)]
32
+ pub struct RbXmlText {
33
+ doc: Doc,
34
+ id: BranchID,
35
+ /// The root this block lives under, which an anchor carries.
36
+ root: String,
37
+ }
38
+
39
+ impl RbXmlText {
40
+ pub fn new(doc: Doc, id: BranchID, root: String) -> Self {
41
+ RbXmlText { doc, id, root }
42
+ }
43
+
44
+ fn child(&self, block: BranchID) -> Self {
45
+ RbXmlText::new(self.doc.clone(), block, self.root.clone())
46
+ }
47
+
48
+ // --- reads ---
49
+
50
+ fn to_s(&self) -> Result<String, Error> {
51
+ let (doc, id) = (&self.doc, &self.id);
52
+ nogvl(move || {
53
+ let txn = doc.transact();
54
+ resolve::<XmlTextRef, _>(&txn, id)
55
+ .map(|x| x.get_string(&txn))
56
+ .unwrap_or_default()
57
+ })
58
+ }
59
+
60
+ /// The text of this block: its strings, without the node markers, with
61
+ /// nested blocks (a list's items) joined by newlines. What a process reads
62
+ /// when it wants to know what a block says.
63
+ fn text(&self) -> Result<String, Error> {
64
+ let (doc, id) = (&self.doc, &self.id);
65
+ nogvl(move || {
66
+ let txn = doc.transact();
67
+ resolve::<XmlTextRef, _>(&txn, id)
68
+ .map(|x| block_text(&txn, &x))
69
+ .unwrap_or_default()
70
+ })
71
+ }
72
+
73
+ /// The length in characters, each embed counting as one.
74
+ fn length(&self) -> Result<usize, Error> {
75
+ let (doc, id) = (&self.doc, &self.id);
76
+ nogvl(move || {
77
+ let txn = doc.transact();
78
+ resolve::<XmlTextRef, _>(&txn, id)
79
+ .map(|x| char_len(&txn, &x) as usize)
80
+ .unwrap_or(0)
81
+ })
82
+ }
83
+
84
+ fn is_empty(&self) -> Result<bool, Error> {
85
+ let (doc, id) = (&self.doc, &self.id);
86
+ nogvl(move || {
87
+ let txn = doc.transact();
88
+ resolve::<XmlTextRef, _>(&txn, id).is_none_or(|x| x.len(&txn) == 0)
89
+ })
90
+ }
91
+
92
+ /// The name of the root this block lives under; an anchor carries it.
93
+ fn root_name(&self) -> String {
94
+ self.root.clone()
95
+ }
96
+
97
+ /// This block's attributes (`__type`, `__tag`, ...), as a Hash. What a
98
+ /// process keeps to put a block back the way it was.
99
+ fn attributes(&self) -> Result<RHash, Error> {
100
+ let (doc, id) = (&self.doc, &self.id);
101
+ let pairs: Vec<(String, Any)> = nogvl(move || {
102
+ let txn = doc.transact();
103
+ match resolve::<XmlTextRef, _>(&txn, id) {
104
+ Some(x) => x
105
+ .attributes(&txn)
106
+ .map(|(key, value)| {
107
+ let any = match value {
108
+ Out::Any(a) => a,
109
+ other => Any::from(other.to_string(&txn)),
110
+ };
111
+ (key.to_string(), any)
112
+ })
113
+ .collect(),
114
+ None => Vec::new(),
115
+ }
116
+ })?;
117
+ let ruby = Ruby::get().map_err(|e| yrb_error(e.to_string()))?;
118
+ let hash = ruby.hash_new();
119
+ for (key, any) in pairs {
120
+ hash.aset(key, any_to_ruby(&ruby, &any))?;
121
+ }
122
+ Ok(hash)
123
+ }
124
+
125
+ /// How many `XmlText` blocks this one embeds.
126
+ fn xml_text_count(&self) -> Result<usize, Error> {
127
+ let (doc, id) = (&self.doc, &self.id);
128
+ nogvl(move || {
129
+ let txn = doc.transact();
130
+ resolve::<XmlTextRef, _>(&txn, id)
131
+ .map(|x| embedded_xml_texts(&txn, &x).len())
132
+ .unwrap_or(0)
133
+ })
134
+ }
135
+
136
+ /// A live handle to the `n`-th embedded `XmlText` block, or nil when there
137
+ /// is none. A negative `n` counts from the last. The handle stays on that
138
+ /// block, wherever it moves.
139
+ fn xml_text(&self, n: i64) -> Result<Option<Self>, Error> {
140
+ let (doc, id) = (&self.doc, &self.id);
141
+ let block = nogvl(move || {
142
+ let txn = doc.transact();
143
+ let blocks = embedded_xml_texts(&txn, &resolve::<XmlTextRef, _>(&txn, id)?);
144
+ let n = element_index(n, blocks.len() as u32)?;
145
+ Some(identity(&blocks[n as usize]))
146
+ })?;
147
+ Ok(block.map(|block| RbXmlText::new(self.doc.clone(), block, self.root.clone())))
148
+ }
149
+
150
+ /// Whether the last thing in this block is an embedded block (a link).
151
+ /// Text appended there needs a text node of its own.
152
+ fn ends_with_xml_text(&self) -> Result<bool, Error> {
153
+ let (doc, id) = (&self.doc, &self.id);
154
+ nogvl(move || {
155
+ let txn = doc.transact();
156
+ resolve::<XmlTextRef, _>(&txn, id)
157
+ .map(|x| ends_with_xml_text(&txn, &x))
158
+ .unwrap_or(false)
159
+ })
160
+ }
161
+
162
+ // --- writes ---
163
+
164
+ /// Set one attribute (a string, number, or nil).
165
+ fn set_attribute(&self, key: String, value: Value) -> Result<Value, Error> {
166
+ let ruby = Ruby::get().map_err(|e| yrb_error(e.to_string()))?;
167
+ let iv = ruby_to_invalue(&ruby, value)?;
168
+ let (doc, id) = (&self.doc, &self.id);
169
+ guarded(move || {
170
+ let mut txn = doc.transact_mut_with(ORIGIN);
171
+ let x = resolve::<XmlTextRef, _>(&txn, id).ok_or("the block no longer exists")?;
172
+ x.insert_attribute(&mut txn, key, to_in(iv));
173
+ Ok(())
174
+ })?;
175
+ Ok(value)
176
+ }
177
+
178
+ /// Insert a string at character `index`, as `String#insert` does: a
179
+ /// negative index counts from the end (-1 appends). Clamped into range.
180
+ fn insert(&self, index: i64, chunk: String) -> Result<String, Error> {
181
+ let (doc, id) = (&self.doc, &self.id);
182
+ let text = chunk.clone();
183
+ guarded(move || {
184
+ let mut txn = doc.transact_mut_with(ORIGIN);
185
+ let x = resolve::<XmlTextRef, _>(&txn, id).ok_or("the block no longer exists")?;
186
+ let at = end_or_units(&txn, &x, index);
187
+ x.insert(&mut txn, at, &text);
188
+ Ok(())
189
+ })?;
190
+ Ok(chunk)
191
+ }
192
+
193
+ /// Insert an embed at `index`. A Hash becomes a nested shared map, which
194
+ /// is what an editor's node marker is (Lexical keeps a text node's
195
+ /// properties in a `Y.Map` embedded before its characters). A scalar is
196
+ /// embedded as a plain value.
197
+ fn insert_embed(&self, index: i64, value: Value) -> Result<Value, Error> {
198
+ let ruby = Ruby::get().map_err(|e| yrb_error(e.to_string()))?;
199
+ let iv = ruby_to_invalue(&ruby, value)?;
200
+ let (doc, id) = (&self.doc, &self.id);
201
+ guarded(move || {
202
+ let mut txn = doc.transact_mut_with(ORIGIN);
203
+ let x = resolve::<XmlTextRef, _>(&txn, id).ok_or("the block no longer exists")?;
204
+ let at = to_units(&txn, &x, insert_index(index, char_len(&txn, &x)));
205
+ match iv {
206
+ InValue::Map(entries) => {
207
+ x.insert_embed(&mut txn, at, to_map_prelim(entries));
208
+ }
209
+ InValue::Any(a) => {
210
+ x.insert_embed(&mut txn, at, a);
211
+ }
212
+ }
213
+ Ok(())
214
+ })?;
215
+ Ok(value)
216
+ }
217
+
218
+ /// The Yjs relative position of character `index` as the `{type, tname,
219
+ /// item, assoc}` hash editors put in awareness as a caret. `assoc` is
220
+ /// "after" (the default: the space before the item at `index`) or
221
+ /// "before".
222
+ fn native_relative_position(&self, index: i64, assoc: String) -> Result<RHash, Error> {
223
+ let ruby = Ruby::get().map_err(|e| yrb_error(e.to_string()))?;
224
+ let assoc = if assoc == "before" {
225
+ Assoc::Before
226
+ } else {
227
+ Assoc::After
228
+ };
229
+ let (doc, id) = (&self.doc, &self.id);
230
+ let position = guarded(move || {
231
+ let txn = doc.transact();
232
+ let x = resolve::<XmlTextRef, _>(&txn, id).ok_or("the block no longer exists")?;
233
+ let at = to_units(&txn, &x, insert_index(index, char_len(&txn, &x)));
234
+ // Past the last item there is nothing to name, so Yjs anchors the
235
+ // position to the type itself (the block, or the root). Same here.
236
+ let sticky = x
237
+ .sticky_index(&txn, at, assoc)
238
+ .unwrap_or_else(|| StickyIndex::from_type(&txn, &x, assoc));
239
+ Ok(Position::new(sticky))
240
+ })?;
241
+ position.to_ruby(&ruby)
242
+ }
243
+
244
+ /// The position that names this block itself: an anchor's position. It
245
+ /// resolves to this block for as long as the block exists, whatever
246
+ /// happens to the text inside it.
247
+ fn native_block_position(&self) -> Result<RHash, Error> {
248
+ let ruby = Ruby::get().map_err(|e| yrb_error(e.to_string()))?;
249
+ let (doc, id) = (&self.doc, &self.id);
250
+ let position = guarded(move || {
251
+ let txn = doc.transact();
252
+ let x = resolve::<XmlTextRef, _>(&txn, id).ok_or("the block no longer exists")?;
253
+ Ok(Position::new(StickyIndex::from_type(
254
+ &txn,
255
+ &x,
256
+ Assoc::After,
257
+ )))
258
+ })?;
259
+ position.to_ruby(&ruby)
260
+ }
261
+
262
+ /// Remove `length` characters from `index`, as `String#slice!` does: a
263
+ /// negative index counts from the end, and the length stops at the end.
264
+ fn delete(&self, index: i64, length: i64) -> Result<(), Error> {
265
+ let (doc, id) = (&self.doc, &self.id);
266
+ guarded(move || {
267
+ let mut txn = doc.transact_mut_with(ORIGIN);
268
+ let x = resolve::<XmlTextRef, _>(&txn, id).ok_or("the block no longer exists")?;
269
+ if let Some((from, to)) = delete_span(index, length, char_len(&txn, &x)) {
270
+ let (from, to) = (to_units(&txn, &x, from), to_units(&txn, &x, to));
271
+ if to > from {
272
+ x.remove_range(&mut txn, from, to - from);
273
+ }
274
+ }
275
+ Ok(())
276
+ })
277
+ }
278
+
279
+ /// Remove everything: markers, text, and embedded blocks.
280
+ fn clear(&self) -> Result<(), Error> {
281
+ let (doc, id) = (&self.doc, &self.id);
282
+ guarded(move || {
283
+ let mut txn = doc.transact_mut_with(ORIGIN);
284
+ let x = resolve::<XmlTextRef, _>(&txn, id).ok_or("the block no longer exists")?;
285
+ let len = x.len(&txn);
286
+ if len > 0 {
287
+ x.remove_range(&mut txn, 0, len);
288
+ }
289
+ Ok(())
290
+ })
291
+ }
292
+
293
+ /// Insert a nested `XmlText` block with `attributes` before the `n`-th
294
+ /// embedded block and return a live handle to it. A negative `n` counts
295
+ /// from the end, so -1 adds it after the last; past the end it goes last.
296
+ /// `content` (see `Piece`) is written into the block in the same
297
+ /// transaction, so no one sees the block before what it holds.
298
+ fn insert_xml_text(&self, n: i64, attributes: RHash, content: RArray) -> Result<Self, Error> {
299
+ let ruby = Ruby::get().map_err(|e| yrb_error(e.to_string()))?;
300
+ let attributes = hash_pairs(&ruby, attributes)?;
301
+ let content = Piece::read_all(&ruby, content, 0)?;
302
+ let (doc, id) = (&self.doc, &self.id);
303
+ let block = guarded(move || {
304
+ let mut txn = doc.transact_mut_with(ORIGIN);
305
+ let x = resolve::<XmlTextRef, _>(&txn, id).ok_or("the block no longer exists")?;
306
+ let count = embedded_xml_texts(&txn, &x).len() as u32;
307
+ let ordinal = insert_index(n, count);
308
+ let at = embedded_xml_text_index(&txn, &x, ordinal).unwrap_or_else(|| x.len(&txn));
309
+ let child = Piece::Block(attributes, content).write(&mut txn, &x, at);
310
+ // The block's identity, not the block: see `Child`.
311
+ Ok(identity(&child.expect("a block piece writes a block")))
312
+ })?;
313
+ Ok(self.child(block))
314
+ }
315
+
316
+ /// Append `content` (see `Piece`) to the end of this block, in one
317
+ /// transaction. Returns the block.
318
+ fn append(rb_self: Obj<Self>, content: RArray) -> Result<Obj<Self>, Error> {
319
+ rb_self.write_content(content, false)?;
320
+ Ok(rb_self)
321
+ }
322
+
323
+ /// Replace everything in this block with `content`, in one transaction,
324
+ /// so no one sees it empty in between. Returns the block.
325
+ fn replace(rb_self: Obj<Self>, content: RArray) -> Result<Obj<Self>, Error> {
326
+ rb_self.write_content(content, true)?;
327
+ Ok(rb_self)
328
+ }
329
+
330
+ fn write_content(&self, content: RArray, clear: bool) -> Result<(), Error> {
331
+ let ruby = Ruby::get().map_err(|e| yrb_error(e.to_string()))?;
332
+ let content = Piece::read_all(&ruby, content, 0)?;
333
+ let (doc, id) = (&self.doc, &self.id);
334
+ guarded(move || {
335
+ let mut txn = doc.transact_mut_with(ORIGIN);
336
+ let x = resolve::<XmlTextRef, _>(&txn, id).ok_or("the block no longer exists")?;
337
+ if clear {
338
+ let len = x.len(&txn);
339
+ if len > 0 {
340
+ x.remove_range(&mut txn, 0, len);
341
+ }
342
+ }
343
+ let mut at = x.len(&txn);
344
+ for piece in content {
345
+ piece.write(&mut txn, &x, at);
346
+ at = x.len(&txn);
347
+ }
348
+ Ok(())
349
+ })
350
+ }
351
+
352
+ /// Append `text` to the end of this block. When the block is empty or
353
+ /// ends with an embedded block, `marker` is embedded before the text:
354
+ /// Lexical keeps a text node's characters after its marker, and drops
355
+ /// text that has none. The check and the write are one transaction, so
356
+ /// another writer can't change the end in between. Returns the block.
357
+ fn append_text(rb_self: Obj<Self>, text: String, marker: RHash) -> Result<Obj<Self>, Error> {
358
+ let ruby = Ruby::get().map_err(|e| yrb_error(e.to_string()))?;
359
+ let marker = hash_pairs(&ruby, marker)?;
360
+ let (doc, id) = (&rb_self.doc, &rb_self.id);
361
+ guarded(move || {
362
+ let mut txn = doc.transact_mut_with(ORIGIN);
363
+ let x = resolve::<XmlTextRef, _>(&txn, id).ok_or("the block no longer exists")?;
364
+ let mut at = x.len(&txn);
365
+ if at == 0 || ends_with_xml_text(&txn, &x) {
366
+ x.insert_embed(&mut txn, at, to_map_prelim(marker));
367
+ at += 1;
368
+ }
369
+ x.insert(&mut txn, at, &text);
370
+ Ok(())
371
+ })?;
372
+ Ok(rb_self)
373
+ }
374
+
375
+ /// Remove the `n`-th embedded block (a negative `n` counts from the
376
+ /// last). Returns whether there was one.
377
+ fn delete_xml_text(&self, n: i64) -> Result<bool, Error> {
378
+ let (doc, id) = (&self.doc, &self.id);
379
+ guarded(move || {
380
+ let mut txn = doc.transact_mut_with(ORIGIN);
381
+ let x = resolve::<XmlTextRef, _>(&txn, id).ok_or("the block no longer exists")?;
382
+ let count = embedded_xml_texts(&txn, &x).len() as u32;
383
+ let Some(at) =
384
+ element_index(n, count).and_then(|n| embedded_xml_text_index(&txn, &x, n))
385
+ else {
386
+ return Ok(false);
387
+ };
388
+ x.remove_range(&mut txn, at, 1);
389
+ Ok(true)
390
+ })
391
+ }
392
+ }
393
+
394
+ /// A piece of a block's content, read from Ruby with the GVL held: a String
395
+ /// is text, a Hash an embed (Lexical's marker before a text node's
396
+ /// characters), and an `[attributes, content]` pair a block of its own.
397
+ enum Piece {
398
+ Text(String),
399
+ Embed(Vec<(String, InValue)>),
400
+ Block(Vec<(String, InValue)>, Vec<Piece>),
401
+ }
402
+
403
+ impl Piece {
404
+ fn read_all(ruby: &Ruby, content: RArray, depth: usize) -> Result<Vec<Piece>, Error> {
405
+ if depth > MAX_NESTING {
406
+ return Err(Error::new(
407
+ ruby.exception_arg_error(),
408
+ format!("blocks nest too deep (at most {MAX_NESTING})"),
409
+ ));
410
+ }
411
+ content
412
+ .into_iter()
413
+ .map(|value| Piece::read(ruby, value, depth))
414
+ .collect()
415
+ }
416
+
417
+ fn read(ruby: &Ruby, value: Value, depth: usize) -> Result<Piece, Error> {
418
+ if let Some(s) = RString::from_value(value) {
419
+ return Ok(Piece::Text(s.to_string()?));
420
+ }
421
+ if let Some(h) = RHash::from_value(value) {
422
+ return Ok(Piece::Embed(hash_pairs(ruby, h)?));
423
+ }
424
+ if let Some(pair) = RArray::from_value(value).filter(|a| a.len() == 2) {
425
+ let attributes: RHash = pair.entry(0)?;
426
+ let content: RArray = pair.entry(1)?;
427
+ return Ok(Piece::Block(
428
+ hash_pairs(ruby, attributes)?,
429
+ Piece::read_all(ruby, content, depth + 1)?,
430
+ ));
431
+ }
432
+ Err(Error::new(
433
+ ruby.exception_arg_error(),
434
+ format!(
435
+ "content is Strings, Hashes, and [attributes, content] pairs, not {}",
436
+ value.inspect()
437
+ ),
438
+ ))
439
+ }
440
+
441
+ /// Writes the piece into `x` at document index `at`. Returns the block a
442
+ /// block piece wrote.
443
+ fn write(self, txn: &mut TransactionMut, x: &XmlTextRef, at: u32) -> Option<XmlTextRef> {
444
+ match self {
445
+ Piece::Text(text) => {
446
+ x.insert(txn, at, &text);
447
+ None
448
+ }
449
+ Piece::Embed(entries) => {
450
+ x.insert_embed(txn, at, to_map_prelim(entries));
451
+ None
452
+ }
453
+ Piece::Block(attributes, content) => {
454
+ let block = x.insert_embed(txn, at, XmlTextPrelim::new(""));
455
+ for (k, v) in attributes {
456
+ block.insert_attribute(txn, k, to_in(v));
457
+ }
458
+ for piece in content {
459
+ let end = block.len(txn);
460
+ piece.write(txn, &block, end);
461
+ }
462
+ Some(block)
463
+ }
464
+ }
465
+ }
466
+ }
467
+
468
+ /// Ensure a root xml text exists and return a live handle. Called by
469
+ /// `Y::Doc#get_xml_text`.
470
+ pub fn root_xml_text(doc: &Doc, name: String) -> Result<RbXmlText, Error> {
471
+ let d = doc.clone();
472
+ let root = name.clone();
473
+ nogvl(move || {
474
+ d.get_or_insert_xml_fragment(root.as_str());
475
+ })?;
476
+ Ok(RbXmlText::new(
477
+ doc.clone(),
478
+ BranchID::Root(name.as_str().into()),
479
+ name,
480
+ ))
481
+ }
482
+
483
+ /// A handle to the block `id` names, under `root`. How `Doc#find` hands one
484
+ /// back.
485
+ pub fn handle(doc: &Doc, id: BranchID, root: String) -> RbXmlText {
486
+ RbXmlText::new(doc.clone(), id, root)
487
+ }
488
+
489
+ pub fn define(ruby: &Ruby, module: magnus::RModule) -> Result<(), Error> {
490
+ let class = module.define_class("XmlText", ruby.class_object())?;
491
+ class.define_method("to_s", magnus::method!(RbXmlText::to_s, 0))?;
492
+ class.define_method("to_str", magnus::method!(RbXmlText::to_s, 0))?;
493
+ class.define_method("text", magnus::method!(RbXmlText::text, 0))?;
494
+ class.define_method("length", magnus::method!(RbXmlText::length, 0))?;
495
+ class.define_method("size", magnus::method!(RbXmlText::length, 0))?;
496
+ class.define_method("empty?", magnus::method!(RbXmlText::is_empty, 0))?;
497
+ class.define_method("root_name", magnus::method!(RbXmlText::root_name, 0))?;
498
+ class.define_method("attributes", magnus::method!(RbXmlText::attributes, 0))?;
499
+ class.define_method(
500
+ "xml_text_count",
501
+ magnus::method!(RbXmlText::xml_text_count, 0),
502
+ )?;
503
+ class.define_method(
504
+ "ends_with_xml_text?",
505
+ magnus::method!(RbXmlText::ends_with_xml_text, 0),
506
+ )?;
507
+ class.define_method(
508
+ "set_attribute",
509
+ magnus::method!(RbXmlText::set_attribute, 2),
510
+ )?;
511
+ class.define_method("insert", magnus::method!(RbXmlText::insert, 2))?;
512
+ class.define_method("insert_embed", magnus::method!(RbXmlText::insert_embed, 2))?;
513
+ class.define_method("append", magnus::method!(RbXmlText::append, 1))?;
514
+ class.define_method("replace", magnus::method!(RbXmlText::replace, 1))?;
515
+ class.define_method("append_text", magnus::method!(RbXmlText::append_text, 2))?;
516
+ class.define_method("xml_text", magnus::method!(RbXmlText::xml_text, 1))?;
517
+ class.define_method("delete", magnus::method!(RbXmlText::delete, 2))?;
518
+ class.define_method("clear", magnus::method!(RbXmlText::clear, 0))?;
519
+ class.define_private_method(
520
+ "native_insert_xml_text",
521
+ magnus::method!(RbXmlText::insert_xml_text, 3),
522
+ )?;
523
+ class.define_method(
524
+ "delete_xml_text",
525
+ magnus::method!(RbXmlText::delete_xml_text, 1),
526
+ )?;
527
+ class.define_private_method(
528
+ "native_relative_position",
529
+ magnus::method!(RbXmlText::native_relative_position, 2),
530
+ )?;
531
+ class.define_private_method(
532
+ "native_block_position",
533
+ magnus::method!(RbXmlText::native_block_position, 0),
534
+ )?;
535
+ Ok(())
536
+ }
537
+
538
+ #[cfg(test)]
539
+ mod tests {
540
+ //! Each method on a handle, called as Ruby calls it: a root, the blocks
541
+ //! embedded in it, and the strings and embeds inside a block.
542
+ use super::*;
543
+ use crate::shared::test_support::{doc, hash, rb};
544
+ use rb_sys_test_helpers::ruby_test;
545
+
546
+ fn nothing() -> RArray {
547
+ Ruby::get().unwrap().ary_new()
548
+ }
549
+
550
+ fn root() -> RbXmlText {
551
+ root_xml_text(&doc(), "root".into()).unwrap()
552
+ }
553
+
554
+ fn paragraph(root: &RbXmlText, words: &str) -> RbXmlText {
555
+ let block = root
556
+ .insert_xml_text(-1, hash(&[("__type", rb("paragraph"))]), nothing())
557
+ .unwrap();
558
+ block.insert(-1, words.into()).unwrap();
559
+ block
560
+ }
561
+
562
+ #[ruby_test]
563
+ fn a_fresh_root_is_empty_and_knows_its_name() {
564
+ let root = root();
565
+ assert!(root.is_empty().unwrap());
566
+ assert_eq!(root.length().unwrap(), 0);
567
+ assert_eq!(root.xml_text_count().unwrap(), 0);
568
+ assert_eq!(root.root_name(), "root");
569
+ }
570
+
571
+ #[ruby_test]
572
+ fn push_xml_text_appends_a_block_with_its_attributes() {
573
+ let root = root();
574
+ let first = paragraph(&root, "one");
575
+ paragraph(&root, "two");
576
+ assert_eq!(root.xml_text_count().unwrap(), 2);
577
+ assert_eq!(first.text().unwrap(), "one");
578
+ assert_eq!(
579
+ first.attributes().unwrap().as_value().inspect(),
580
+ r#"{"__type" => "paragraph"}"#
581
+ );
582
+ assert_eq!(root.text().unwrap(), "one\ntwo");
583
+ }
584
+
585
+ #[ruby_test]
586
+ fn xml_text_reads_a_negative_index_and_is_nil_outside() {
587
+ let root = root();
588
+ paragraph(&root, "one");
589
+ paragraph(&root, "two");
590
+ assert_eq!(root.xml_text(-1).unwrap().unwrap().text().unwrap(), "two");
591
+ assert!(root.xml_text(2).unwrap().is_none());
592
+ assert!(root.xml_text(-3).unwrap().is_none());
593
+ }
594
+
595
+ #[ruby_test]
596
+ fn insert_xml_text_places_a_block_before_the_nth() {
597
+ let root = root();
598
+ paragraph(&root, "one");
599
+ paragraph(&root, "three");
600
+ let two = root
601
+ .insert_xml_text(1, hash(&[("__type", rb("paragraph"))]), nothing())
602
+ .unwrap();
603
+ two.insert(0, "two".into()).unwrap();
604
+ assert_eq!(root.text().unwrap(), "one\ntwo\nthree");
605
+ }
606
+
607
+ #[ruby_test]
608
+ fn delete_xml_text_removes_the_nth_block_and_says_whether_there_was_one() {
609
+ let root = root();
610
+ paragraph(&root, "one");
611
+ paragraph(&root, "two");
612
+ assert!(root.delete_xml_text(0).unwrap());
613
+ assert!(!root.delete_xml_text(5).unwrap());
614
+ assert_eq!(root.text().unwrap(), "two");
615
+ }
616
+
617
+ #[ruby_test]
618
+ fn a_block_handle_stays_on_its_block_as_others_come_and_go() {
619
+ let root = root();
620
+ paragraph(&root, "one");
621
+ let two = paragraph(&root, "two");
622
+ root.insert_xml_text(0, hash(&[("__type", rb("paragraph"))]), nothing())
623
+ .unwrap();
624
+ root.delete_xml_text(1).unwrap();
625
+ two.insert(-1, "!".into()).unwrap();
626
+ assert_eq!(root.xml_text(-1).unwrap().unwrap().text().unwrap(), "two!");
627
+ }
628
+
629
+ #[ruby_test]
630
+ fn insert_and_delete_count_characters_with_an_embed_as_one() {
631
+ let root = root();
632
+ let block = paragraph(&root, "ad");
633
+ block
634
+ .insert_embed(0, hash(&[("__type", rb("text"))]).as_value())
635
+ .unwrap();
636
+ assert_eq!(block.length().unwrap(), 3);
637
+ block.insert(2, "bc".into()).unwrap();
638
+ assert_eq!(block.text().unwrap(), "abcd");
639
+ block.delete(-2, 1).unwrap();
640
+ assert_eq!(block.text().unwrap(), "abd");
641
+ }
642
+
643
+ #[ruby_test]
644
+ fn set_attribute_writes_one_attribute() {
645
+ let block = paragraph(&root(), "one");
646
+ block.set_attribute("__format".into(), rb(1)).unwrap();
647
+ let attributes = block.attributes().unwrap();
648
+ assert_eq!(attributes.len(), 2);
649
+ assert_eq!(attributes.aref::<_, i64>("__format").unwrap(), 1);
650
+ }
651
+
652
+ #[ruby_test]
653
+ fn ends_with_xml_text_is_true_when_a_block_ends_a_block() {
654
+ let root = root();
655
+ let item = paragraph(&root, "see ");
656
+ assert!(!item.ends_with_xml_text().unwrap());
657
+ item.insert_xml_text(-1, hash(&[("__type", rb("link"))]), nothing())
658
+ .unwrap();
659
+ assert!(item.ends_with_xml_text().unwrap());
660
+ }
661
+
662
+ #[ruby_test]
663
+ fn clear_removes_text_markers_and_blocks() {
664
+ let root = root();
665
+ paragraph(&root, "one");
666
+ root.clear().unwrap();
667
+ assert!(root.is_empty().unwrap());
668
+ assert_eq!(root.xml_text_count().unwrap(), 0);
669
+ }
670
+
671
+ #[ruby_test]
672
+ fn a_block_position_names_the_block() {
673
+ let root = root();
674
+ paragraph(&root, "one");
675
+ let two = paragraph(&root, "two");
676
+ let position = two.native_block_position().unwrap();
677
+ let inspect = position.as_value().inspect();
678
+ assert!(inspect.contains("\"item\""), "{inspect}");
679
+ assert!(two.native_relative_position(1, "before".into()).is_ok());
680
+ }
681
+
682
+ #[ruby_test]
683
+ fn a_handle_to_a_deleted_block_reads_empty_and_refuses_writes() {
684
+ let root = root();
685
+ let block = paragraph(&root, "one");
686
+ root.delete_xml_text(0).unwrap();
687
+ assert_eq!(block.text().unwrap(), "");
688
+ assert!(block.xml_text(0).unwrap().is_none());
689
+ let err = block.insert(0, "x".into()).unwrap_err();
690
+ assert!(
691
+ err.to_string().contains("the block no longer exists"),
692
+ "{err}"
693
+ );
694
+ }
695
+
696
+ #[ruby_test]
697
+ fn handle_names_a_block_by_its_identity() {
698
+ let root = root();
699
+ let block = paragraph(&root, "one");
700
+ let again = handle(&block.doc, block.id.clone(), "root".into());
701
+ assert_eq!(again.text().unwrap(), "one");
702
+ assert_eq!(again.root_name(), "root");
703
+ }
704
+
705
+ fn content(pieces: &[Value]) -> RArray {
706
+ let content = nothing();
707
+ for piece in pieces {
708
+ content.push(*piece).unwrap();
709
+ }
710
+ content
711
+ }
712
+
713
+ #[ruby_test]
714
+ fn a_block_is_inserted_with_its_content() {
715
+ let root = root();
716
+ let marker = hash(&[("__type", rb("text"))]).as_value();
717
+ let link = content(&[
718
+ hash(&[("__type", rb("link"))]).as_value(),
719
+ content(&[marker, rb("docs")]).as_value(),
720
+ ]);
721
+ let block = root
722
+ .insert_xml_text(
723
+ -1,
724
+ hash(&[("__type", rb("paragraph"))]),
725
+ content(&[marker, rb("see "), link.as_value()]),
726
+ )
727
+ .unwrap();
728
+ assert_eq!(block.length().unwrap(), 6);
729
+ assert_eq!(block.xml_text_count().unwrap(), 1);
730
+ assert_eq!(block.text().unwrap(), "see docs");
731
+ }
732
+
733
+ #[ruby_test]
734
+ fn content_can_replace_a_block_or_be_appended_to_it() {
735
+ let block = paragraph(&root(), "old");
736
+ block.write_content(content(&[rb("new")]), true).unwrap();
737
+ assert_eq!(block.text().unwrap(), "new");
738
+ block.write_content(content(&[rb("er")]), false).unwrap();
739
+ assert_eq!(block.text().unwrap(), "newer");
740
+ }
741
+
742
+ #[ruby_test]
743
+ fn content_that_is_not_text_embeds_or_blocks_raises() {
744
+ let err = paragraph(&root(), "")
745
+ .write_content(content(&[rb(1)]), false)
746
+ .unwrap_err();
747
+ assert!(err.to_string().contains("content is Strings"), "{err}");
748
+ }
749
+ }