yrby 0.8.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +121 -0
- data/Cargo.lock +33 -11
- data/README.md +31 -9
- data/ext/yrby/Cargo.toml +3 -1
- data/ext/yrby/crates/html-core/Cargo.toml +2 -2
- data/ext/yrby/crates/html-core/src/lib.rs +34 -3
- data/ext/yrby/crates/lexical-html/Cargo.toml +3 -3
- data/ext/yrby/crates/lexical-html/src/lib.rs +82 -20
- data/ext/yrby/crates/prosemirror-html/Cargo.toml +3 -3
- data/ext/yrby/crates/prosemirror-html/src/lib.rs +37 -8
- data/ext/yrby/src/array.rs +396 -0
- data/ext/yrby/src/lib.rs +945 -36
- data/ext/yrby/src/map.rs +374 -0
- data/ext/yrby/src/read.rs +3 -3
- data/ext/yrby/src/shared.rs +561 -0
- data/ext/yrby/src/text.rs +294 -0
- data/ext/yrby/src/xml_text.rs +749 -0
- data/lib/y/anchor.rb +34 -0
- data/lib/y/collaborative/attribute.rb +36 -0
- data/lib/y/collaborative/helper.rb +13 -10
- data/lib/y/collaborative.rb +14 -11
- data/lib/y/doc.rb +36 -0
- data/lib/y/lexical_markdown.rb +175 -0
- data/lib/y/lexical_writer.rb +142 -0
- data/lib/y/lexxy.rb +27 -2
- data/lib/y/rendering.rb +27 -3
- data/lib/y/text.rb +13 -0
- data/lib/y/version.rb +1 -1
- data/lib/y/xml_text.rb +36 -0
- data/lib/y.rb +6 -0
- metadata +12 -1
|
@@ -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
|
+
}
|