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.
@@ -52,8 +52,8 @@ pub use yjs_html_core::*;
52
52
  use yrs::types::Attrs;
53
53
  use yrs::types::text::YChange;
54
54
  use yrs::{
55
- Any, GetString, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef, XmlOut,
56
- XmlTextRef,
55
+ Any, GetString, Number, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
56
+ XmlOut, XmlTextRef,
57
57
  };
58
58
 
59
59
  // The block tree is walked on a heap stack, so depth can't overflow the
@@ -807,16 +807,25 @@ fn hex_to_rgb(value: &str) -> Option<String> {
807
807
  Some(format!("rgb({r}, {g}, {b})"))
808
808
  }
809
809
 
810
+ /// The schemes a link may use, as Tiptap's link mark allows them. It
811
+ /// renders a link with any other scheme with an empty `href`, since a
812
+ /// `javascript:` link would run in the page.
813
+ const LINK_SCHEMES: [&str; 10] = [
814
+ "http", "https", "ftp", "ftps", "mailto", "tel", "callto", "sms", "cid", "xmpp",
815
+ ];
816
+
810
817
  /// `<a>` with Tiptap's attribute order (target, rel, class, href, title),
811
818
  /// skipping any that are absent or null.
812
819
  fn link_open(link: &std::collections::HashMap<String, Any>) -> String {
813
820
  let mut out = String::from("<a");
814
821
  for key in ["target", "rel", "class", "href", "title"] {
815
822
  if let Some(Any::String(v)) = link.get(key) {
823
+ let unsafe_href = key == "href"
824
+ && url_scheme(v).is_some_and(|scheme| !LINK_SCHEMES.contains(&scheme.as_str()));
816
825
  out.push(' ');
817
826
  out.push_str(key);
818
827
  out.push_str("=\"");
819
- out.push_str(&escape_attr(v));
828
+ out.push_str(&escape_attr(if unsafe_href { "" } else { v }));
820
829
  out.push('"');
821
830
  }
822
831
  }
@@ -874,27 +883,47 @@ fn str_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String
874
883
 
875
884
  fn num_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<i64> {
876
885
  match e.get_attribute(txn, name) {
877
- Some(Out::Any(Any::Number(n))) => Some(n as i64),
878
- Some(Out::Any(Any::BigInt(n))) => Some(n),
886
+ Some(Out::Any(Any::Number(Number::Int(n)))) => Some(n),
887
+ Some(Out::Any(Any::Number(Number::Float(n)))) => Some(n as i64),
879
888
  _ => None,
880
889
  }
881
890
  }
882
891
 
883
892
  /// Text-content escaping, matching the browser serializer: `&`, `<`, `>`.
884
- fn escape_text(s: &str) -> String {
893
+ /// Public for splice callers. Values read from a deferred segment's
894
+ /// attributes come from collaborators, so escape anything you put into
895
+ /// markup.
896
+ pub fn escape_text(s: &str) -> String {
885
897
  s.replace('&', "&amp;")
886
898
  .replace('<', "&lt;")
887
899
  .replace('>', "&gt;")
888
900
  }
889
901
 
890
- /// Attribute-value escaping: text escaping plus `"`.
891
- fn escape_attr(s: &str) -> String {
902
+ /// Attribute-value escaping: text escaping plus `"`. Public for splice
903
+ /// callers, for document values interpolated into attribute positions.
904
+ pub fn escape_attr(s: &str) -> String {
892
905
  escape_text(s).replace('"', "&quot;")
893
906
  }
894
907
 
895
908
  #[cfg(test)]
896
909
  mod tests {
897
910
  use super::*;
911
+
912
+ #[test]
913
+ fn a_link_to_an_unsafe_scheme_has_an_empty_href() {
914
+ let link = |href: &str| {
915
+ link_open(&std::collections::HashMap::from([(
916
+ "href".to_string(),
917
+ Any::from(href),
918
+ )]))
919
+ };
920
+ assert_eq!(
921
+ link("https://example.com"),
922
+ r#"<a href="https://example.com">"#
923
+ );
924
+ assert_eq!(link("ftp://example.com"), r#"<a href="ftp://example.com">"#);
925
+ assert_eq!(link("javascript:alert(1)"), r#"<a href="">"#);
926
+ }
898
927
  use std::collections::HashMap;
899
928
  use std::sync::Arc;
900
929
  use yrs::updates::decoder::Decode;
@@ -0,0 +1,396 @@
1
+ //! Live `Y::Array` handles over a yrs `Array`: read and write the elements of a
2
+ //! shared list from Ruby.
3
+ //!
4
+ //! Same contract as `Y::Map`. Every operation opens its own transaction inside
5
+ //! `nogvl`, nothing caches a branch pointer, and Ruby values are only touched
6
+ //! with the GVL held.
7
+ //!
8
+ //! The method that earns this type its place is `get_map`, which hands back a
9
+ //! live handle to a map stored at an index. A list of records is the shape most
10
+ //! collaborative state actually has (a plan, a board, a set of rows), and
11
+ //! without a handle you could read one but never edit it in place. The handle
12
+ //! keeps the record's identity, so it stays on that record when others are
13
+ //! inserted or removed around it.
14
+
15
+ use magnus::{prelude::*, typed_data::Obj, Error, Ruby, Value};
16
+ use yrs::branch::BranchID;
17
+ use yrs::{Any, Array, ArrayRef, Doc, Transact};
18
+
19
+ use crate::map::RbMap;
20
+ use crate::nogvl;
21
+ use crate::read::out_to_any;
22
+ use crate::shared::{
23
+ any_to_ruby, element_index, guarded, identity, insert_index, resolve, ruby_to_invalue, to_in,
24
+ Child, ORIGIN,
25
+ };
26
+ use crate::text::RbText;
27
+
28
+ #[magnus::wrap(class = "Y::Array", free_immediately, size)]
29
+ pub struct RbArray {
30
+ doc: Doc,
31
+ id: BranchID,
32
+ }
33
+
34
+ impl RbArray {
35
+ pub fn new(doc: Doc, id: BranchID) -> Self {
36
+ RbArray { doc, id }
37
+ }
38
+
39
+ /// The shared value at `index`, resolved now: what `get_map`, `get_array`
40
+ /// and `get_text` narrow to a handle.
41
+ fn child(&self, index: i64) -> Result<Child, Error> {
42
+ let (doc, id) = (&self.doc, &self.id);
43
+ nogvl(move || {
44
+ let txn = doc.transact();
45
+ Child::of(
46
+ resolve::<ArrayRef, _>(&txn, id)
47
+ .and_then(|a| a.get(&txn, element_index(index, a.len(&txn))?)),
48
+ )
49
+ })
50
+ }
51
+
52
+ // --- reads ---
53
+
54
+ /// `array[i]`: a snapshot value. Nested maps and arrays come back as deep
55
+ /// `Hash`/`Array`; use `get_map` for a live handle instead.
56
+ fn get(&self, index: i64) -> Result<Value, Error> {
57
+ let (doc, id) = (&self.doc, &self.id);
58
+ let got: Option<Any> = nogvl(move || {
59
+ let txn = doc.transact();
60
+ let a = resolve::<ArrayRef, _>(&txn, id)?;
61
+ let i = element_index(index, a.len(&txn))?;
62
+ a.get(&txn, i).map(|v| out_to_any(&txn, &v))
63
+ })?;
64
+ let ruby = Ruby::get().unwrap();
65
+ Ok(match got {
66
+ Some(a) => any_to_ruby(&ruby, &a),
67
+ None => ruby.qnil().as_value(),
68
+ })
69
+ }
70
+
71
+ /// A live `Y::Map` for the element at `index`, or `nil` if that element is
72
+ /// absent or is not a map. Mutating it mutates the document.
73
+ fn get_map(&self, index: i64) -> Result<Option<RbMap>, Error> {
74
+ Ok(match self.child(index)? {
75
+ Child::Map(id) => Some(RbMap::new(self.doc.clone(), id)),
76
+ _ => None,
77
+ })
78
+ }
79
+
80
+ /// A live `Y::Text` for text stored at `index`. An agent appending to one
81
+ /// field of one record goes through here.
82
+ fn get_text(&self, index: i64) -> Result<Option<RbText>, Error> {
83
+ Ok(match self.child(index)? {
84
+ Child::Text(id) => Some(RbText::new(self.doc.clone(), id)),
85
+ _ => None,
86
+ })
87
+ }
88
+
89
+ /// A live `Y::Array` for a nested array at `index`.
90
+ fn get_array(&self, index: i64) -> Result<Option<Self>, Error> {
91
+ Ok(match self.child(index)? {
92
+ Child::Array(id) => Some(RbArray::new(self.doc.clone(), id)),
93
+ _ => None,
94
+ })
95
+ }
96
+
97
+ fn size(&self) -> Result<usize, Error> {
98
+ let (doc, id) = (&self.doc, &self.id);
99
+ nogvl(move || {
100
+ let txn = doc.transact();
101
+ resolve::<ArrayRef, _>(&txn, id)
102
+ .map(|a| a.len(&txn) as usize)
103
+ .unwrap_or(0)
104
+ })
105
+ }
106
+
107
+ fn is_empty(&self) -> Result<bool, Error> {
108
+ Ok(self.size()? == 0)
109
+ }
110
+
111
+ fn snapshot(&self) -> Result<Vec<Any>, Error> {
112
+ let (doc, id) = (&self.doc, &self.id);
113
+ nogvl(move || {
114
+ let txn = doc.transact();
115
+ match resolve::<ArrayRef, _>(&txn, id) {
116
+ Some(a) => a.iter(&txn).map(|v| out_to_any(&txn, &v)).collect(),
117
+ None => Vec::new(),
118
+ }
119
+ })
120
+ }
121
+
122
+ fn to_a(&self) -> Result<Value, Error> {
123
+ let ruby = Ruby::get().unwrap();
124
+ Ok(ruby
125
+ .ary_from_iter(self.snapshot()?.iter().map(|v| any_to_ruby(&ruby, v)))
126
+ .as_value())
127
+ }
128
+
129
+ /// `each { |value| }`: yields a snapshot of each element (read under
130
+ /// `nogvl`, yielded with the GVL held so the block can call into Ruby),
131
+ /// and returns the array. Without a block, an Enumerator.
132
+ fn each(ruby: &Ruby, rb_self: Obj<Self>) -> Result<Value, Error> {
133
+ if !ruby.block_given() {
134
+ return Ok(rb_self.enumeratorize("each", ()).as_value());
135
+ }
136
+ let block = ruby.block_proc()?;
137
+ for v in &rb_self.snapshot()? {
138
+ let _: Value = block.call((any_to_ruby(ruby, v),))?;
139
+ }
140
+ Ok(rb_self.as_value())
141
+ }
142
+
143
+ // --- writes ---
144
+
145
+ /// Append a value and return the array, as `Array#push` does. A Ruby
146
+ /// `Hash` becomes a real nested `Y.Map`, so the element can be handed back
147
+ /// later as a live handle by `get_map`.
148
+ fn push(rb_self: Obj<Self>, value: Value) -> Result<Obj<Self>, Error> {
149
+ rb_self.insert_at(-1, value)?;
150
+ Ok(rb_self)
151
+ }
152
+
153
+ /// Insert at `index` and return the array, as `Array#insert` does.
154
+ fn insert(rb_self: Obj<Self>, index: i64, value: Value) -> Result<Obj<Self>, Error> {
155
+ rb_self.insert_at(index, value)?;
156
+ Ok(rb_self)
157
+ }
158
+
159
+ /// Insert at `index`: a negative index counts from the end (-1 appends),
160
+ /// and an index past the end appends.
161
+ fn insert_at(&self, index: i64, value: Value) -> Result<(), Error> {
162
+ let ruby = Ruby::get().unwrap();
163
+ let iv = ruby_to_invalue(&ruby, value)?;
164
+ let (doc, id) = (&self.doc, &self.id);
165
+ guarded(move || {
166
+ let mut txn = doc.transact_mut_with(ORIGIN);
167
+ let a = resolve::<ArrayRef, _>(&txn, id).ok_or("array no longer exists")?;
168
+ let at = insert_index(index, a.len(&txn));
169
+ a.insert(&mut txn, at, to_in(iv));
170
+ Ok(())
171
+ })
172
+ }
173
+
174
+ /// Remove the element at `index`, returning its previous snapshot value.
175
+ fn delete_at(&self, index: i64) -> Result<Value, Error> {
176
+ let (doc, id) = (&self.doc, &self.id);
177
+ let prev: Option<Any> = guarded(move || {
178
+ let mut txn = doc.transact_mut_with(ORIGIN);
179
+ let Some(a) = resolve::<ArrayRef, _>(&txn, id) else {
180
+ return Ok(None);
181
+ };
182
+ let Some(i) = element_index(index, a.len(&txn)) else {
183
+ return Ok(None);
184
+ };
185
+ // Convert before removing: a removed shared ref would dangle.
186
+ let prev = a.get(&txn, i).map(|v| out_to_any(&txn, &v));
187
+ a.remove(&mut txn, i);
188
+ Ok(prev)
189
+ })?;
190
+ let ruby = Ruby::get().unwrap();
191
+ Ok(match prev {
192
+ Some(a) => any_to_ruby(&ruby, &a),
193
+ None => ruby.qnil().as_value(),
194
+ })
195
+ }
196
+
197
+ fn clear(&self) -> Result<(), Error> {
198
+ let (doc, id) = (&self.doc, &self.id);
199
+ guarded(move || {
200
+ let mut txn = doc.transact_mut_with(ORIGIN);
201
+ if let Some(a) = resolve::<ArrayRef, _>(&txn, id) {
202
+ let len = a.len(&txn);
203
+ if len > 0 {
204
+ a.remove_range(&mut txn, 0, len);
205
+ }
206
+ }
207
+ Ok(())
208
+ })
209
+ }
210
+
211
+ /// Replace the element at `index`. Yjs arrays have no in-place update, so
212
+ /// this is a remove plus an insert in one transaction. The element gets a
213
+ /// new identity, which is why a live handle to it must be re-fetched.
214
+ fn set(&self, index: i64, value: Value) -> Result<Value, Error> {
215
+ let ruby = Ruby::get().unwrap();
216
+ let iv = ruby_to_invalue(&ruby, value)?;
217
+ let (doc, id) = (&self.doc, &self.id);
218
+ guarded(move || {
219
+ let mut txn = doc.transact_mut_with(ORIGIN);
220
+ let a = resolve::<ArrayRef, _>(&txn, id).ok_or("array no longer exists")?;
221
+ let i = element_index(index, a.len(&txn)).ok_or("index out of range")?;
222
+ a.remove(&mut txn, i);
223
+ a.insert(&mut txn, i, to_in(iv));
224
+ Ok(())
225
+ })?;
226
+ Ok(value)
227
+ }
228
+ }
229
+
230
+ /// Ensure a root array exists and return a live handle. Called by
231
+ /// `Y::Doc#get_array`.
232
+ pub fn root_array(doc: &Doc, name: String) -> Result<RbArray, Error> {
233
+ let d = doc.clone();
234
+ let id = nogvl(move || identity(&d.get_or_insert_array(name.as_str())))?;
235
+ Ok(RbArray::new(doc.clone(), id))
236
+ }
237
+
238
+ pub fn define(ruby: &Ruby, module: magnus::RModule) -> Result<(), Error> {
239
+ let class = module.define_class("Array", ruby.class_object())?;
240
+ class.define_method("[]", magnus::method!(RbArray::get, 1))?;
241
+ class.define_method("get", magnus::method!(RbArray::get, 1))?;
242
+ class.define_method("[]=", magnus::method!(RbArray::set, 2))?;
243
+ class.define_method("get_map", magnus::method!(RbArray::get_map, 1))?;
244
+ class.define_method("get_array", magnus::method!(RbArray::get_array, 1))?;
245
+ class.define_method("get_text", magnus::method!(RbArray::get_text, 1))?;
246
+ class.define_method("push", magnus::method!(RbArray::push, 1))?;
247
+ class.define_method("<<", magnus::method!(RbArray::push, 1))?;
248
+ class.define_method("insert", magnus::method!(RbArray::insert, 2))?;
249
+ class.define_method("delete_at", magnus::method!(RbArray::delete_at, 1))?;
250
+ class.define_method("clear", magnus::method!(RbArray::clear, 0))?;
251
+ class.define_method("size", magnus::method!(RbArray::size, 0))?;
252
+ class.define_method("length", magnus::method!(RbArray::size, 0))?;
253
+ class.define_method("empty?", magnus::method!(RbArray::is_empty, 0))?;
254
+ class.define_method("to_a", magnus::method!(RbArray::to_a, 0))?;
255
+ class.define_method("each", magnus::method!(RbArray::each, 0))?;
256
+ Ok(())
257
+ }
258
+
259
+ #[cfg(test)]
260
+ mod tests {
261
+ //! Each method on a handle, called as Ruby calls it.
262
+ use super::*;
263
+ use crate::shared::test_support::{doc, hash, int, rb};
264
+ use rb_sys_test_helpers::ruby_test;
265
+ use yrs::{ArrayPrelim, TextPrelim, Transact};
266
+
267
+ fn list_of(values: &[i64]) -> RbArray {
268
+ let list = root_array(&doc(), "list".into()).unwrap();
269
+ for v in values {
270
+ list.insert_at(-1, rb(*v)).unwrap();
271
+ }
272
+ list
273
+ }
274
+
275
+ #[ruby_test]
276
+ fn a_fresh_array_is_empty() {
277
+ let list = list_of(&[]);
278
+ assert_eq!(list.size().unwrap(), 0);
279
+ assert!(list.is_empty().unwrap());
280
+ assert_eq!(list.to_a().unwrap().inspect(), "[]");
281
+ }
282
+
283
+ #[ruby_test]
284
+ fn insert_at_minus_one_appends() {
285
+ let list = list_of(&[1]);
286
+ list.insert_at(-1, rb(2)).unwrap();
287
+ assert_eq!(list.snapshot().unwrap(), vec![int(1), int(2)]);
288
+ }
289
+
290
+ #[ruby_test]
291
+ fn insert_reads_a_negative_index_from_the_end_and_clamps() {
292
+ let list = list_of(&[1, 3]);
293
+ list.insert_at(1, rb(2)).unwrap();
294
+ list.insert_at(-1, rb(4)).unwrap();
295
+ list.insert_at(99, rb(5)).unwrap();
296
+ list.insert_at(-99, rb(0)).unwrap();
297
+ assert_eq!(
298
+ list.snapshot().unwrap(),
299
+ vec![int(0), int(1), int(2), int(3), int(4), int(5)]
300
+ );
301
+ }
302
+
303
+ #[ruby_test]
304
+ fn get_reads_a_negative_index_and_is_nil_outside() {
305
+ let list = list_of(&[1, 2, 3]);
306
+ assert_eq!(list.get(0).unwrap().inspect(), "1");
307
+ assert_eq!(list.get(-1).unwrap().inspect(), "3");
308
+ assert_eq!(list.get(3).unwrap().inspect(), "nil");
309
+ assert_eq!(list.get(-4).unwrap().inspect(), "nil");
310
+ }
311
+
312
+ #[ruby_test]
313
+ fn set_replaces_in_place_and_raises_outside() {
314
+ let list = list_of(&[1, 2]);
315
+ list.set(-1, rb(9)).unwrap();
316
+ assert_eq!(list.snapshot().unwrap(), vec![int(1), int(9)]);
317
+ let err = list.set(2, rb(3)).unwrap_err();
318
+ assert!(err.to_string().contains("index out of range"), "{err}");
319
+ }
320
+
321
+ #[ruby_test]
322
+ fn delete_at_returns_the_removed_value_or_nil() {
323
+ let list = list_of(&[1, 2, 3]);
324
+ assert_eq!(list.delete_at(1).unwrap().inspect(), "2");
325
+ assert_eq!(list.delete_at(5).unwrap().inspect(), "nil");
326
+ assert_eq!(list.snapshot().unwrap(), vec![int(1), int(3)]);
327
+ }
328
+
329
+ #[ruby_test]
330
+ fn clear_removes_every_element() {
331
+ let list = list_of(&[1, 2, 3]);
332
+ list.clear().unwrap();
333
+ assert!(list.is_empty().unwrap());
334
+ }
335
+
336
+ #[ruby_test]
337
+ fn child_is_the_shared_value_at_an_index() {
338
+ let list = list_of(&[1]);
339
+ list.insert_at(-1, hash(&[("name", rb("Ada"))]).as_value())
340
+ .unwrap();
341
+ assert!(matches!(list.child(-1).unwrap(), Child::Map(_)));
342
+ assert!(matches!(list.child(0).unwrap(), Child::Other));
343
+ assert!(matches!(list.child(2).unwrap(), Child::Other));
344
+ }
345
+
346
+ #[ruby_test]
347
+ fn get_map_get_text_and_get_array_narrow_by_type() {
348
+ let list = list_of(&[]);
349
+ list.insert_at(-1, hash(&[("name", rb("Ada"))]).as_value())
350
+ .unwrap();
351
+ {
352
+ let mut txn = list.doc.transact_mut();
353
+ let array = resolve::<ArrayRef, _>(&txn, &list.id).unwrap();
354
+ array.push_back(&mut txn, TextPrelim::new("hi"));
355
+ array.push_back(&mut txn, ArrayPrelim::default());
356
+ }
357
+ assert!(list.get_map(0).unwrap().is_some());
358
+ assert_eq!(list.get(0).unwrap().inspect(), r#"{"name" => "Ada"}"#);
359
+ assert!(list.get_text(0).unwrap().is_none());
360
+ assert!(list.get_text(1).unwrap().is_some());
361
+ assert!(list.get_map(1).unwrap().is_none());
362
+ let nested = list.get_array(-1).unwrap().expect("an array at the end");
363
+ nested.insert_at(-1, rb(7)).unwrap();
364
+ assert_eq!(list.get(2).unwrap().inspect(), "[7]");
365
+ }
366
+
367
+ #[ruby_test]
368
+ fn a_handle_to_a_deleted_array_reads_empty_and_refuses_writes() {
369
+ let outer = list_of(&[]);
370
+ {
371
+ let mut txn = outer.doc.transact_mut();
372
+ let array = resolve::<ArrayRef, _>(&txn, &outer.id).unwrap();
373
+ array.push_back(&mut txn, ArrayPrelim::default());
374
+ }
375
+ let inner = outer.get_array(0).unwrap().unwrap();
376
+ inner.insert_at(-1, rb(1)).unwrap();
377
+ outer.delete_at(0).unwrap();
378
+ assert_eq!(inner.size().unwrap(), 0);
379
+ assert!(inner.snapshot().unwrap().is_empty());
380
+ let err = inner.insert_at(-1, rb(2)).unwrap_err();
381
+ assert!(err.to_string().contains("array no longer exists"), "{err}");
382
+ }
383
+
384
+ #[ruby_test]
385
+ fn root_array_returns_the_same_array_for_a_name() {
386
+ let doc = doc();
387
+ root_array(&doc, "list".into())
388
+ .unwrap()
389
+ .insert_at(-1, rb(1))
390
+ .unwrap();
391
+ assert_eq!(
392
+ root_array(&doc, "list".into()).unwrap().snapshot().unwrap(),
393
+ vec![int(1)]
394
+ );
395
+ }
396
+ }