yrby 0.5.0 → 0.6.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.
data/ext/yrby/src/lib.rs CHANGED
@@ -1,5 +1,6 @@
1
1
  use magnus::{
2
- function, method, prelude::*, Error, ExceptionClass, RString, Ruby, TryConvert, Value,
2
+ function, method, prelude::*, Error, ExceptionClass, IntoValue, RArray, RString, Ruby,
3
+ TryConvert, Value,
3
4
  };
4
5
  use yrs::sync::{Message, SyncMessage};
5
6
  use yrs::updates::decoder::Decode;
@@ -10,10 +11,12 @@ mod lexical_html;
10
11
  mod prosemirror_html;
11
12
  mod protocol;
12
13
  mod read;
14
+ mod render_rules;
13
15
  use protocol::{
14
16
  classify_message, has_pending, integrated_update, merged_doc_update, update_advances_doc,
15
17
  update_is_ready,
16
18
  };
19
+ use render_rules::{Rules, Segment};
17
20
 
18
21
  /// Wrapper around yrs Doc.
19
22
  ///
@@ -337,49 +340,77 @@ impl RbDoc {
337
340
  // Y::Lexical — schema-pinned rendering of Lexical/Lexxy documents
338
341
  // ============================================================================
339
342
 
340
- /// A Lexical/Lexxy view over a `Y::Doc`. The schema knowledge (Lexxy 0.9.x
341
- /// node set and serializer semantics) lives here rather than on the
342
- /// schema-agnostic `Doc`. Holds a cheap clone of the doc (yrs `Doc` is an Arc
343
- /// handle), so it reads live state.
343
+ /// A Lexical view over a `Y::Doc`. The schema knowledge lives here rather
344
+ /// than on the schema-agnostic `Doc`: core Lexical natively, everything else
345
+ /// through the render rules compiled at construction (see `render_rules`
346
+ /// the `Y::Lexxy` facade's rule set arrives that way). Holds a cheap clone of
347
+ /// the doc (yrs `Doc` is an Arc handle), so it reads live state.
344
348
  ///
345
349
  /// Thread safety matches `Y::Doc`: every method opens its own transaction
346
- /// inside `nogvl` and holds no lock across the GVL boundary.
347
- #[magnus::wrap(class = "Y::Lexical", free_immediately, size)]
348
- struct RbLexical(Doc);
350
+ /// inside `nogvl` and holds no lock across the GVL boundary. Callback rules
351
+ /// keep that discipline: the render emits deferred segments, and the Ruby
352
+ /// layer runs the app's blocks only after the transaction has closed.
353
+ #[magnus::wrap(class = "Y::NativeLexical", free_immediately, size)]
354
+ struct RbLexical {
355
+ doc: Doc,
356
+ rules: Rules,
357
+ }
349
358
 
350
359
  impl RbLexical {
351
- /// `Y::Lexical.new(doc)`
352
- fn new(doc: &RbDoc) -> Self {
353
- RbLexical(doc.0.clone())
360
+ /// `Y::NativeLexical.new(doc, rules_json)` — the Y::Lexical facade
361
+ /// compiles its `nodes:` config to the rules JSON.
362
+ fn native_new(doc: &RbDoc, rules_json: String) -> Result<Self, Error> {
363
+ Ok(RbLexical {
364
+ doc: doc.0.clone(),
365
+ rules: parse_rules(&rules_json)?,
366
+ })
354
367
  }
355
368
 
356
369
  /// Render the document's XML root (default `"root"`, Lexical's standard
357
- /// collab root name) to HTML, natively — no Node process or headless
358
- /// editor. Output matches Lexxy's own serializer (the HTML a lexxy-editor
359
- /// submits to Rails) byte-for-byte on the reference fixture; see
360
- /// `lexical_html.rs` for the schema coverage and caveats. Returns nil when
361
- /// the root is missing or not Lexical-shaped, e.g. a ProseMirror document.
362
- fn to_html(&self, args: &[Value]) -> Result<Option<String>, Error> {
363
- if args.len() > 1 {
364
- let ruby = Ruby::get().unwrap();
365
- return Err(Error::new(
366
- ruby.exception_arg_error(),
367
- format!(
368
- "wrong number of arguments (given {}, expected 0..1)",
369
- args.len()
370
- ),
371
- ));
372
- }
373
- let name: String = match args.first() {
374
- Some(arg) => TryConvert::try_convert(*arg)?,
375
- None => "root".to_string(),
376
- };
377
- let doc = &self.0;
378
- Ok(nogvl(move || {
370
+ /// collab root name) natively — no Node process or headless editor. The
371
+ /// native side renders core Lexical plus whatever the rules cover; with
372
+ /// the rule set `Y::Lexxy` passes, output matches Lexxy's own serializer
373
+ /// byte-for-byte on the reference fixtures (see `lexical_html.rs`). Returns nil when the root is missing or not
374
+ /// Lexical-shaped, e.g. a ProseMirror document; a String when no
375
+ /// callback rule fired; otherwise the nested segment arrays the Ruby
376
+ /// layer splices.
377
+ fn native_to_html(&self, args: &[Value]) -> Result<Value, Error> {
378
+ let name = root_name_arg(args, "root")?;
379
+ let doc = &self.doc;
380
+ let rules = &self.rules;
381
+ let segments = nogvl(move || {
379
382
  let txn = doc.transact();
380
383
  let fragment = txn.get_xml_fragment(name.as_str())?;
381
- lexical_html::render(&txn, &fragment)
382
- }))
384
+ lexical_html::render_segments(&txn, &fragment, rules)
385
+ });
386
+ segments_result(segments)
387
+ }
388
+
389
+ /// The document's node types as observed facts, JSON-encoded — the
390
+ /// native half of the facade's `node_types` discovery aid. Nil when the
391
+ /// root is missing or not Lexical-shaped.
392
+ fn node_types(&self, args: &[Value]) -> Result<Value, Error> {
393
+ let name = root_name_arg(args, "root")?;
394
+ let doc = &self.doc;
395
+ let map = nogvl(move || {
396
+ let txn = doc.transact();
397
+ let fragment = txn.get_xml_fragment(name.as_str())?;
398
+ lexical_html::collect_node_types(&txn, &fragment)
399
+ });
400
+ let ruby = Ruby::get().unwrap();
401
+ match map {
402
+ None => Ok(ruby.qnil().as_value()),
403
+ Some(map) => Ok(render_rules::type_map_json(&map, |ty| {
404
+ if self.rules.nodes.contains_key(ty) {
405
+ Some("rule")
406
+ } else if lexical_html::is_builtin(ty) {
407
+ Some("builtin")
408
+ } else {
409
+ None
410
+ }
411
+ })
412
+ .into_value_with(&ruby)),
413
+ }
383
414
  }
384
415
  }
385
416
 
@@ -387,49 +418,145 @@ impl RbLexical {
387
418
  // Y::ProseMirror — schema-pinned rendering of ProseMirror/Tiptap documents
388
419
  // ============================================================================
389
420
 
390
- /// A ProseMirror/Tiptap view over a `Y::Doc`. The schema knowledge (node/mark
391
- /// names, Tiptap's serializer semantics) lives here rather than on the
392
- /// schema-agnostic `Doc`. Holds a cheap clone of the doc (yrs `Doc` is an Arc
393
- /// handle), so it reads live state.
421
+ /// A ProseMirror view over a `Y::Doc`. The schema knowledge lives here rather
422
+ /// than on the schema-agnostic `Doc`: core ProseMirror natively, everything
423
+ /// else through the render rules compiled at construction (see
424
+ /// `render_rules` the `Y::Tiptap` facade's rule set arrives that way).
425
+ /// Holds a cheap clone of the doc (yrs `Doc` is an Arc handle), so it reads
426
+ /// live state.
394
427
  ///
395
428
  /// Thread safety matches `Y::Doc`: every method opens its own transaction
396
- /// inside `nogvl` and holds no lock across the GVL boundary.
397
- #[magnus::wrap(class = "Y::ProseMirror", free_immediately, size)]
398
- struct RbProseMirror(Doc);
429
+ /// inside `nogvl` and holds no lock across the GVL boundary. Callback rules
430
+ /// keep that discipline: the render emits deferred segments, and the Ruby
431
+ /// layer runs the app's blocks only after the transaction has closed.
432
+ #[magnus::wrap(class = "Y::NativeProseMirror", free_immediately, size)]
433
+ struct RbProseMirror {
434
+ doc: Doc,
435
+ rules: Rules,
436
+ }
399
437
 
400
438
  impl RbProseMirror {
401
- /// `Y::ProseMirror.new(doc)`
402
- fn new(doc: &RbDoc) -> Self {
403
- RbProseMirror(doc.0.clone())
439
+ /// `Y::NativeProseMirror.new(doc, rules_json)` — the Y::ProseMirror facade
440
+ /// compiles its `nodes:`/`marks:` config to the rules JSON.
441
+ fn native_new(doc: &RbDoc, rules_json: String) -> Result<Self, Error> {
442
+ Ok(RbProseMirror {
443
+ doc: doc.0.clone(),
444
+ rules: parse_rules(&rules_json)?,
445
+ })
404
446
  }
405
447
 
406
448
  /// Render an XML root (default `"default"`, the fragment name Tiptap's
407
- /// Collaboration extension uses) to HTML. Output follows tiptap-php and
408
- /// matches Tiptap's `getHTML()` on the reference fixture; see
409
- /// `prosemirror_html.rs` for coverage and caveats. Returns nil when the
410
- /// root is missing or not ProseMirror-shaped (e.g. a Lexical document).
411
- fn to_html(&self, args: &[Value]) -> Result<Option<String>, Error> {
412
- if args.len() > 1 {
413
- let ruby = Ruby::get().unwrap();
414
- return Err(Error::new(
415
- ruby.exception_arg_error(),
416
- format!(
417
- "wrong number of arguments (given {}, expected 0..1)",
418
- args.len()
419
- ),
420
- ));
421
- }
422
- let name: String = match args.first() {
423
- Some(arg) => TryConvert::try_convert(*arg)?,
424
- None => "default".to_string(),
425
- };
426
- let doc = &self.0;
427
- Ok(nogvl(move || {
449
+ /// Collaboration extension uses). The native side renders core
450
+ /// ProseMirror plus whatever the rules cover; with the rule set
451
+ /// `Y::Tiptap` passes, output matches Tiptap's own `getHTML()`
452
+ /// byte-for-byte on the reference fixtures (see `prosemirror_html.rs`
453
+ /// for coverage and caveats). Returns nil when the root is missing or
454
+ /// not ProseMirror-shaped (e.g. a Lexical document); a String when no
455
+ /// callback rule fired; otherwise the nested segment arrays the Ruby
456
+ /// layer splices.
457
+ fn native_to_html(&self, args: &[Value]) -> Result<Value, Error> {
458
+ let name = root_name_arg(args, "default")?;
459
+ let doc = &self.doc;
460
+ let rules = &self.rules;
461
+ let segments = nogvl(move || {
462
+ let txn = doc.transact();
463
+ let fragment = txn.get_xml_fragment(name.as_str())?;
464
+ prosemirror_html::render_segments(&txn, &fragment, rules)
465
+ });
466
+ segments_result(segments)
467
+ }
468
+
469
+ /// The document's node types as observed facts, JSON-encoded — the
470
+ /// native half of the facade's `node_types` discovery aid. Nil when the
471
+ /// root is missing or not ProseMirror-shaped.
472
+ fn node_types(&self, args: &[Value]) -> Result<Value, Error> {
473
+ let name = root_name_arg(args, "default")?;
474
+ let doc = &self.doc;
475
+ let map = nogvl(move || {
428
476
  let txn = doc.transact();
429
477
  let fragment = txn.get_xml_fragment(name.as_str())?;
430
- prosemirror_html::render(&txn, &fragment)
431
- }))
478
+ prosemirror_html::collect_node_types(&txn, &fragment)
479
+ });
480
+ let ruby = Ruby::get().unwrap();
481
+ match map {
482
+ None => Ok(ruby.qnil().as_value()),
483
+ Some(map) => Ok(render_rules::type_map_json(&map, |ty| {
484
+ if self.rules.nodes.contains_key(ty) {
485
+ Some("rule")
486
+ } else if prosemirror_html::is_builtin(ty) {
487
+ Some("builtin")
488
+ } else {
489
+ None
490
+ }
491
+ })
492
+ .into_value_with(&ruby)),
493
+ }
494
+ }
495
+ }
496
+
497
+ /// The optional positional root-fragment name both renderers take.
498
+ fn root_name_arg(args: &[Value], default: &str) -> Result<String, Error> {
499
+ if args.len() > 1 {
500
+ let ruby = Ruby::get().unwrap();
501
+ return Err(Error::new(
502
+ ruby.exception_arg_error(),
503
+ format!(
504
+ "wrong number of arguments (given {}, expected 0..1)",
505
+ args.len()
506
+ ),
507
+ ));
508
+ }
509
+ match args.first() {
510
+ Some(arg) => TryConvert::try_convert(*arg),
511
+ None => Ok(default.to_string()),
512
+ }
513
+ }
514
+
515
+ fn parse_rules(json: &str) -> Result<Rules, Error> {
516
+ Rules::parse(json).map_err(|e| {
517
+ let ruby = Ruby::get().unwrap();
518
+ Error::new(ruby.exception_arg_error(), e)
519
+ })
520
+ }
521
+
522
+ /// A render's result as a Ruby value: nil (root missing or foreign-shaped),
523
+ /// a String when every segment is finished HTML, or nested arrays of
524
+ /// `String | [node_type, attrs_json, content, child_types]` for the Ruby
525
+ /// layer to splice.
526
+ fn segments_result(segments: Option<Vec<Segment>>) -> Result<Value, Error> {
527
+ let ruby = Ruby::get().unwrap();
528
+ match segments {
529
+ None => Ok(ruby.qnil().as_value()),
530
+ Some(segs) => match render_rules::flatten(segs) {
531
+ render_rules::Flattened::Html(html) => Ok(html.into_value_with(&ruby)),
532
+ render_rules::Flattened::Deferred(segs) => {
533
+ Ok(segments_to_ruby(&ruby, segs)?.as_value())
534
+ }
535
+ },
536
+ }
537
+ }
538
+
539
+ fn segments_to_ruby(ruby: &Ruby, segments: Vec<Segment>) -> Result<RArray, Error> {
540
+ let arr = ruby.ary_new();
541
+ for seg in segments {
542
+ match seg {
543
+ Segment::Html(s) => arr.push(s)?,
544
+ Segment::Deferred {
545
+ node_type,
546
+ attrs_json,
547
+ child_types,
548
+ content,
549
+ } => {
550
+ let entry = ruby.ary_new();
551
+ entry.push(node_type)?;
552
+ entry.push(attrs_json)?;
553
+ entry.push(segments_to_ruby(ruby, content)?)?;
554
+ entry.push(child_types)?;
555
+ arr.push(entry)?;
556
+ }
557
+ }
432
558
  }
559
+ Ok(arr)
433
560
  }
434
561
 
435
562
  // ============================================================================
@@ -512,14 +639,17 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
512
639
  "handle_sync_message",
513
640
  method!(RbDoc::handle_sync_message, 1),
514
641
  )?;
515
- // Y::Lexical: schema-pinned Lexical/Lexxy rendering over a Doc.
516
- let lexical_class = module.define_class("Lexical", ruby.class_object())?;
517
- lexical_class.define_singleton_method("new", function!(RbLexical::new, 1))?;
518
- lexical_class.define_method("to_html", method!(RbLexical::to_html, -1))?;
519
- // Y::ProseMirror: schema-pinned ProseMirror/Tiptap rendering over a Doc.
520
- let prosemirror_class = module.define_class("ProseMirror", ruby.class_object())?;
521
- prosemirror_class.define_singleton_method("new", function!(RbProseMirror::new, 1))?;
522
- prosemirror_class.define_method("to_html", method!(RbProseMirror::to_html, -1))?;
642
+ // The native renderers are the handles the Ruby facades (Y::Lexical /
643
+ // Y::Lexxy and Y::ProseMirror / Y::Tiptap in lib/y/) hold; the Ruby
644
+ // layer marks these classes private_constant.
645
+ let lexical_class = module.define_class("NativeLexical", ruby.class_object())?;
646
+ lexical_class.define_singleton_method("new", function!(RbLexical::native_new, 2))?;
647
+ lexical_class.define_method("to_html", method!(RbLexical::native_to_html, -1))?;
648
+ lexical_class.define_method("node_types", method!(RbLexical::node_types, -1))?;
649
+ let prosemirror_class = module.define_class("NativeProseMirror", ruby.class_object())?;
650
+ prosemirror_class.define_singleton_method("new", function!(RbProseMirror::native_new, 2))?;
651
+ prosemirror_class.define_method("to_html", method!(RbProseMirror::native_to_html, -1))?;
652
+ prosemirror_class.define_method("node_types", method!(RbProseMirror::node_types, -1))?;
523
653
 
524
654
  // Stateless protocol codec, as Y module functions.
525
655
  module.define_module_function("wrap_update", function!(wrap_update, 1))?;