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,294 @@
1
+ //! Live `Y::Text` handles over a yrs `Text`: append and edit shared text from
2
+ //! Ruby.
3
+ //!
4
+ //! Same contract as the other handles: a transaction per operation inside
5
+ //! `nogvl`, the branch resolved from its identity each time, Ruby touched only
6
+ //! with the GVL held. Indexes are Ruby characters (see `shared`).
7
+ //!
8
+ //! This is the type an agent streams into. `push` is the hot path, and it is a
9
+ //! CRDT insert at the current end rather than a whole-document write, so a
10
+ //! person typing in the same paragraph while the agent appends does not lose
11
+ //! their edit and neither does the agent.
12
+
13
+ use magnus::{prelude::*, typed_data::Obj, Error, RHash, Ruby};
14
+ use yrs::branch::{Branch, BranchID, BranchPtr};
15
+ use yrs::{Assoc, Doc, GetString, IndexedSequence, StickyIndex, Text, TextRef, Transact};
16
+
17
+ use crate::nogvl;
18
+ use crate::shared::{
19
+ char_len, delete_span, end_or_units, guarded, identity, insert_index, resolve, to_chars,
20
+ to_units, Position, ORIGIN,
21
+ };
22
+ use crate::yrb_error;
23
+
24
+ #[magnus::wrap(class = "Y::Text", free_immediately, size)]
25
+ pub struct RbText {
26
+ doc: Doc,
27
+ id: BranchID,
28
+ }
29
+
30
+ impl RbText {
31
+ pub fn new(doc: Doc, id: BranchID) -> Self {
32
+ RbText { doc, id }
33
+ }
34
+
35
+ // --- reads ---
36
+
37
+ fn to_s(&self) -> Result<String, Error> {
38
+ let (doc, id) = (&self.doc, &self.id);
39
+ nogvl(move || {
40
+ let txn = doc.transact();
41
+ resolve::<TextRef, _>(&txn, id)
42
+ .map(|t| t.get_string(&txn))
43
+ .unwrap_or_default()
44
+ })
45
+ }
46
+
47
+ /// The length in characters, as `String#length` counts.
48
+ fn length(&self) -> Result<usize, Error> {
49
+ let (doc, id) = (&self.doc, &self.id);
50
+ nogvl(move || {
51
+ let txn = doc.transact();
52
+ resolve::<TextRef, _>(&txn, id)
53
+ .map(|t| char_len(&txn, &t) as usize)
54
+ .unwrap_or(0)
55
+ })
56
+ }
57
+
58
+ fn is_empty(&self) -> Result<bool, Error> {
59
+ let (doc, id) = (&self.doc, &self.id);
60
+ nogvl(move || {
61
+ let txn = doc.transact();
62
+ resolve::<TextRef, _>(&txn, id).is_none_or(|t| t.len(&txn) == 0)
63
+ })
64
+ }
65
+
66
+ // --- writes ---
67
+
68
+ /// Append to the end and return the text, as `String#<<` does. The
69
+ /// operation an agent streaming output performs over and over, so it stays
70
+ /// a single insert at the current length.
71
+ fn push(rb_self: Obj<Self>, chunk: String) -> Result<Obj<Self>, Error> {
72
+ rb_self.insert_at(-1, chunk)?;
73
+ Ok(rb_self)
74
+ }
75
+
76
+ /// Insert at character `index` and return the text, as `String#insert`
77
+ /// does.
78
+ fn insert(rb_self: Obj<Self>, index: i64, chunk: String) -> Result<Obj<Self>, Error> {
79
+ rb_self.insert_at(index, chunk)?;
80
+ Ok(rb_self)
81
+ }
82
+
83
+ /// Insert at character `index`: a negative index counts from the end (-1
84
+ /// appends). Clamped into range.
85
+ fn insert_at(&self, index: i64, text: String) -> Result<(), Error> {
86
+ let (doc, id) = (&self.doc, &self.id);
87
+ guarded(move || {
88
+ let mut txn = doc.transact_mut_with(ORIGIN);
89
+ let t = resolve::<TextRef, _>(&txn, id).ok_or("text no longer exists")?;
90
+ let at = end_or_units(&txn, &t, index);
91
+ t.insert(&mut txn, at, &text);
92
+ Ok(())
93
+ })
94
+ }
95
+
96
+ /// The Yjs relative position of character `index` as the `{type, tname,
97
+ /// item, assoc}` hash editors put in awareness as a caret. `assoc` is
98
+ /// "after" (the default) or "before". Past the end the position names the
99
+ /// text itself.
100
+ fn native_relative_position(&self, index: i64, assoc: String) -> Result<RHash, Error> {
101
+ let ruby = Ruby::get().map_err(|e| yrb_error(e.to_string()))?;
102
+ let assoc = if assoc == "before" {
103
+ Assoc::Before
104
+ } else {
105
+ Assoc::After
106
+ };
107
+ let (doc, id) = (&self.doc, &self.id);
108
+ let position = guarded(move || {
109
+ let txn = doc.transact();
110
+ let text = resolve::<TextRef, _>(&txn, id).ok_or("text no longer exists")?;
111
+ let at = to_units(&txn, &text, insert_index(index, char_len(&txn, &text)));
112
+ let sticky = text
113
+ .sticky_index(&txn, at, assoc)
114
+ .unwrap_or_else(|| StickyIndex::from_type(&txn, &text, assoc));
115
+ Ok(Position::new(sticky))
116
+ })?;
117
+ position.to_ruby(&ruby)
118
+ }
119
+
120
+ /// The character index a relative position (a peer's caret from
121
+ /// awareness, or one from `relative_position`) falls at in this text now,
122
+ /// or nil when it points somewhere else or its item is gone.
123
+ fn index_at(&self, position: RHash) -> Result<Option<u32>, Error> {
124
+ let ruby = Ruby::get().map_err(|e| yrb_error(e.to_string()))?;
125
+ let sticky = crate::sticky_from_ruby(&ruby, position)?;
126
+ let (doc, id) = (&self.doc, &self.id);
127
+ nogvl(move || {
128
+ let txn = doc.transact();
129
+ let text = resolve::<TextRef, _>(&txn, id)?;
130
+ let offset = sticky.get_offset(&txn)?;
131
+ let branch: &Branch = text.as_ref();
132
+ (BranchPtr::from(branch) == offset.branch).then(|| to_chars(&txn, &text, offset.index))
133
+ })
134
+ }
135
+
136
+ /// Remove `length` characters from `index`, as `String#slice!` does: a
137
+ /// negative index counts from the end, and the length stops at the end.
138
+ fn delete(&self, index: i64, length: i64) -> Result<(), Error> {
139
+ let (doc, id) = (&self.doc, &self.id);
140
+ guarded(move || {
141
+ let mut txn = doc.transact_mut_with(ORIGIN);
142
+ let t = resolve::<TextRef, _>(&txn, id).ok_or("text no longer exists")?;
143
+ if let Some((from, to)) = delete_span(index, length, char_len(&txn, &t)) {
144
+ let (from, to) = (to_units(&txn, &t, from), to_units(&txn, &t, to));
145
+ if to > from {
146
+ t.remove_range(&mut txn, from, to - from);
147
+ }
148
+ }
149
+ Ok(())
150
+ })
151
+ }
152
+
153
+ fn clear(&self) -> Result<(), Error> {
154
+ let (doc, id) = (&self.doc, &self.id);
155
+ guarded(move || {
156
+ let mut txn = doc.transact_mut_with(ORIGIN);
157
+ if let Some(t) = resolve::<TextRef, _>(&txn, id) {
158
+ let len = t.len(&txn);
159
+ if len > 0 {
160
+ t.remove_range(&mut txn, 0, len);
161
+ }
162
+ }
163
+ Ok(())
164
+ })
165
+ }
166
+ }
167
+
168
+ /// Ensure a root text exists and return a live handle. Called by
169
+ /// `Y::Doc#get_text`.
170
+ pub fn root_text(doc: &Doc, name: String) -> Result<RbText, Error> {
171
+ let d = doc.clone();
172
+ let id = nogvl(move || identity(&d.get_or_insert_text(name.as_str())))?;
173
+ Ok(RbText::new(doc.clone(), id))
174
+ }
175
+
176
+ pub fn define(ruby: &Ruby, module: magnus::RModule) -> Result<(), Error> {
177
+ let class = module.define_class("Text", ruby.class_object())?;
178
+ class.define_method("to_s", magnus::method!(RbText::to_s, 0))?;
179
+ class.define_method("to_str", magnus::method!(RbText::to_s, 0))?;
180
+ class.define_method("push", magnus::method!(RbText::push, 1))?;
181
+ class.define_method("<<", magnus::method!(RbText::push, 1))?;
182
+ class.define_method("insert", magnus::method!(RbText::insert, 2))?;
183
+ class.define_method("delete", magnus::method!(RbText::delete, 2))?;
184
+ class.define_method("clear", magnus::method!(RbText::clear, 0))?;
185
+ class.define_method("length", magnus::method!(RbText::length, 0))?;
186
+ class.define_method("size", magnus::method!(RbText::length, 0))?;
187
+ class.define_method("empty?", magnus::method!(RbText::is_empty, 0))?;
188
+ class.define_method("index_at", magnus::method!(RbText::index_at, 1))?;
189
+ class.define_private_method(
190
+ "native_relative_position",
191
+ magnus::method!(RbText::native_relative_position, 2),
192
+ )?;
193
+ Ok(())
194
+ }
195
+
196
+ #[cfg(test)]
197
+ mod tests {
198
+ //! Each method on a handle, called as Ruby calls it. Indexes count
199
+ //! characters, as Ruby's do, though the document counts UTF-16 units.
200
+ use super::*;
201
+ use crate::shared::test_support::doc;
202
+ use rb_sys_test_helpers::ruby_test;
203
+ use yrs::{Map, MapRef, TextPrelim, Transact};
204
+
205
+ fn text(initial: &str) -> RbText {
206
+ let text = root_text(&doc(), "t".into()).unwrap();
207
+ text.insert_at(-1, initial.into()).unwrap();
208
+ text
209
+ }
210
+
211
+ #[ruby_test]
212
+ fn a_fresh_text_is_empty() {
213
+ let t = text("");
214
+ assert_eq!(t.to_s().unwrap(), "");
215
+ assert_eq!(t.length().unwrap(), 0);
216
+ assert!(t.is_empty().unwrap());
217
+ }
218
+
219
+ #[ruby_test]
220
+ fn appending_at_minus_one_and_length_counting_characters() {
221
+ let t = text("hé");
222
+ t.insert_at(-1, "😀!".into()).unwrap();
223
+ assert_eq!(t.to_s().unwrap(), "hé😀!");
224
+ assert_eq!(t.length().unwrap(), 4);
225
+ }
226
+
227
+ #[ruby_test]
228
+ fn insert_reads_a_negative_index_from_the_end_and_clamps() {
229
+ let t = text("a😀c");
230
+ t.insert_at(2, "b".into()).unwrap();
231
+ t.insert_at(-1, "d".into()).unwrap();
232
+ t.insert_at(99, "e".into()).unwrap();
233
+ t.insert_at(-99, "_".into()).unwrap();
234
+ assert_eq!(t.to_s().unwrap(), "_a😀bcde");
235
+ }
236
+
237
+ #[ruby_test]
238
+ fn delete_removes_characters_as_slice_bang_does() {
239
+ let t = text("a😀bcd");
240
+ t.delete(1, 2).unwrap();
241
+ assert_eq!(t.to_s().unwrap(), "acd");
242
+ t.delete(-1, 5).unwrap();
243
+ assert_eq!(t.to_s().unwrap(), "ac");
244
+ t.delete(9, 1).unwrap();
245
+ assert_eq!(t.to_s().unwrap(), "ac");
246
+ }
247
+
248
+ #[ruby_test]
249
+ fn clear_removes_everything() {
250
+ let t = text("hello");
251
+ t.clear().unwrap();
252
+ assert!(t.is_empty().unwrap());
253
+ }
254
+
255
+ #[ruby_test]
256
+ fn a_relative_position_follows_its_character() {
257
+ let t = text("a😀c");
258
+ let position = t.native_relative_position(2, "after".into()).unwrap();
259
+ assert_eq!(t.index_at(position).unwrap(), Some(2));
260
+ t.insert_at(0, "xy".into()).unwrap();
261
+ assert_eq!(t.index_at(position).unwrap(), Some(4));
262
+ }
263
+
264
+ #[ruby_test]
265
+ fn a_position_in_another_text_is_nil() {
266
+ let doc = doc();
267
+ let a = root_text(&doc, "a".into()).unwrap();
268
+ let b = root_text(&doc, "b".into()).unwrap();
269
+ a.insert_at(-1, "one".into()).unwrap();
270
+ b.insert_at(-1, "two".into()).unwrap();
271
+ let position = a.native_relative_position(1, "after".into()).unwrap();
272
+ assert_eq!(b.index_at(position).unwrap(), None);
273
+ }
274
+
275
+ #[ruby_test]
276
+ fn a_handle_to_a_deleted_text_reads_empty_and_refuses_writes() {
277
+ let doc = doc();
278
+ let id = {
279
+ let map: MapRef = doc.get_or_insert_map("state");
280
+ let mut txn = doc.transact_mut();
281
+ identity(&map.insert(&mut txn, "notes", TextPrelim::new("hi")))
282
+ };
283
+ let notes = RbText::new(doc.clone(), id);
284
+ assert_eq!(notes.to_s().unwrap(), "hi");
285
+ {
286
+ let map: MapRef = doc.get_or_insert_map("state");
287
+ map.remove(&mut doc.transact_mut(), "notes");
288
+ }
289
+ assert_eq!(notes.to_s().unwrap(), "");
290
+ assert!(notes.is_empty().unwrap());
291
+ let err = notes.insert_at(-1, "more".into()).unwrap_err();
292
+ assert!(err.to_string().contains("text no longer exists"), "{err}");
293
+ }
294
+ }