mechanomeld 0.1.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 +7 -0
- data/CHANGELOG.md +21 -0
- data/Cargo.lock +617 -0
- data/Cargo.toml +3 -0
- data/README.md +68 -0
- data/Rakefile +26 -0
- data/checksums/mechanomeld-0.1.0.gem.sha512 +1 -0
- data/ext/mechanomeld/Cargo.toml +13 -0
- data/ext/mechanomeld/extconf.rb +6 -0
- data/ext/mechanomeld/src/classes.rs +24 -0
- data/ext/mechanomeld/src/document.rs +176 -0
- data/ext/mechanomeld/src/errors.rs +24 -0
- data/ext/mechanomeld/src/lib.rs +27 -0
- data/ext/mechanomeld/src/path.rs +117 -0
- data/ext/mechanomeld/src/read.rs +80 -0
- data/ext/mechanomeld/src/write.rs +142 -0
- data/lib/mechanomeld/document.rb +38 -0
- data/lib/mechanomeld/error.rb +5 -0
- data/lib/mechanomeld/scalars.rb +49 -0
- data/lib/mechanomeld/version.rb +5 -0
- data/lib/mechanomeld.rb +15 -0
- data/sig/mechanomeld.rbs +4 -0
- metadata +80 -0
data/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Mechanomeld
|
|
2
|
+
|
|
3
|
+
Read and write [Automerge](https://automerge.org) documents from Ruby. Mechanomeld is a native extension around the Rust `automerge` crate, so documents round-trip with JavaScript's `@automerge/automerge`.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bundle add mechanomeld
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Precompiled gems are published for arm64/x86_64 macOS and Linux on Ruby 3.2–4.0. Other platforms build from source and need a Rust toolchain.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "mechanomeld"
|
|
17
|
+
|
|
18
|
+
doc = Mechanomeld::Document.load(File.binread("todos.automerge"))
|
|
19
|
+
doc.get(["todos", 0, "title"]) # => #<Mechanomeld::Text "Buy milk">
|
|
20
|
+
doc.to_h # => {"todos" => [{"title" => #<Mechanomeld::Text "Buy milk">, ...}]}
|
|
21
|
+
doc.keys # => ["todos"]
|
|
22
|
+
doc.length("todos") # => 1
|
|
23
|
+
|
|
24
|
+
doc = Mechanomeld::Document.from({"title" => Mechanomeld::Text.new("Groceries"), "items" => []})
|
|
25
|
+
doc.change(message: "Add milk") do |d|
|
|
26
|
+
d["count"] = Mechanomeld::Counter.new(1)
|
|
27
|
+
end
|
|
28
|
+
File.binwrite("groceries.automerge", doc.save)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Values
|
|
32
|
+
|
|
33
|
+
| Automerge | Ruby |
|
|
34
|
+
|---|---|
|
|
35
|
+
| map / list | `Hash` (String keys) / `Array` |
|
|
36
|
+
| text object | `Mechanomeld::Text` |
|
|
37
|
+
| string | `String` |
|
|
38
|
+
| int / f64 / boolean / null | `Integer` / `Float` / `true`, `false` / `nil` |
|
|
39
|
+
| counter / uint | `Mechanomeld::Counter` / `Mechanomeld::Uint` |
|
|
40
|
+
| timestamp | `Mechanomeld::Timestamp` (milliseconds since the epoch) |
|
|
41
|
+
| bytes | `Mechanomeld::Bytes` |
|
|
42
|
+
|
|
43
|
+
JavaScript stores strings as text objects by default, so they read as `Mechanomeld::Text`. A Ruby `String` is written as an Automerge string, which JavaScript reads as an `ImmutableString`; write `Mechanomeld::Text.new("...")` to create text JavaScript reads as a plain string.
|
|
44
|
+
|
|
45
|
+
Errors from Automerge raise `Mechanomeld::Error`.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
Tools are pinned in `mise.toml`.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
mise trust
|
|
53
|
+
mise install
|
|
54
|
+
mise run setup # bundle install, npm ci for fixtures
|
|
55
|
+
mise run test # compile the extension and run the tests
|
|
56
|
+
mise run test:interop # check JavaScript reads Ruby-written documents
|
|
57
|
+
mise run fixtures # regenerate test/fixtures/*.automerge
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Check packaging with `gem build mechanomeld.gemspec`. Do not run `rake build` or `rake release` locally: reissue bumps the version and commits during `build`.
|
|
61
|
+
|
|
62
|
+
## Releasing
|
|
63
|
+
|
|
64
|
+
Releases run from the **Release gem to RubyGems.org** GitHub Actions workflow. Changelog entries and version bumps come from commit trailers (`Added:`, `Changed:`, `Fixed:`, `Version: minor`, ...). Run it with `dry_run` first.
|
|
65
|
+
|
|
66
|
+
## Contributing
|
|
67
|
+
|
|
68
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/SOFware/mechanomeld.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "minitest/test_task"
|
|
5
|
+
require "rb_sys/extensiontask"
|
|
6
|
+
require "reissue/gem"
|
|
7
|
+
|
|
8
|
+
GEMSPEC = Gem::Specification.load("mechanomeld.gemspec")
|
|
9
|
+
|
|
10
|
+
# No platform list: cross-gem builds set RUBY_TARGET, which rb_sys reads.
|
|
11
|
+
RbSys::ExtensionTask.new("mechanomeld", GEMSPEC) do |ext|
|
|
12
|
+
ext.lib_dir = "lib/mechanomeld"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Minitest::TestTask.create
|
|
16
|
+
task test: :compile
|
|
17
|
+
|
|
18
|
+
Reissue::Task.create :reissue do |task|
|
|
19
|
+
task.version_file = "lib/mechanomeld/version.rb"
|
|
20
|
+
task.fragment = :git
|
|
21
|
+
# The post-release bump goes on a pushed branch; the shared release workflow opens its PR.
|
|
22
|
+
# (0.5.1's default, stated explicitly: it is the "Option A" the shared workflow expects.)
|
|
23
|
+
task.push_reissue = :branch
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
task default: :test
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4a15ef106109b7d61c0c5f78cfc6f1599300b62a5410dbeb7e673cecd69731907125e006507dfc2b03eea8d6a738031569e5f430adcc0cb051fd36d7bdfcee4e
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//! Ruby classes defined in lib/, looked up once on first use.
|
|
2
|
+
|
|
3
|
+
use magnus::{exception::ExceptionClass, prelude::*, value::Lazy, RClass, RModule};
|
|
4
|
+
|
|
5
|
+
pub static MECHANOMELD: Lazy<RModule> =
|
|
6
|
+
Lazy::new(|ruby| ruby.define_module("Mechanomeld").unwrap());
|
|
7
|
+
|
|
8
|
+
pub static ERROR: Lazy<ExceptionClass> =
|
|
9
|
+
Lazy::new(|ruby| ruby.get_inner(&MECHANOMELD).const_get("Error").unwrap());
|
|
10
|
+
|
|
11
|
+
pub static TEXT: Lazy<RClass> =
|
|
12
|
+
Lazy::new(|ruby| ruby.get_inner(&MECHANOMELD).const_get("Text").unwrap());
|
|
13
|
+
|
|
14
|
+
pub static COUNTER: Lazy<RClass> =
|
|
15
|
+
Lazy::new(|ruby| ruby.get_inner(&MECHANOMELD).const_get("Counter").unwrap());
|
|
16
|
+
|
|
17
|
+
pub static TIMESTAMP: Lazy<RClass> =
|
|
18
|
+
Lazy::new(|ruby| ruby.get_inner(&MECHANOMELD).const_get("Timestamp").unwrap());
|
|
19
|
+
|
|
20
|
+
pub static UINT: Lazy<RClass> =
|
|
21
|
+
Lazy::new(|ruby| ruby.get_inner(&MECHANOMELD).const_get("Uint").unwrap());
|
|
22
|
+
|
|
23
|
+
pub static BYTES: Lazy<RClass> =
|
|
24
|
+
Lazy::new(|ruby| ruby.get_inner(&MECHANOMELD).const_get("Bytes").unwrap());
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
use std::cell::{Ref, RefCell, RefMut};
|
|
2
|
+
use std::str::FromStr;
|
|
3
|
+
|
|
4
|
+
use automerge::{
|
|
5
|
+
transaction::{CommitOptions, Transactable},
|
|
6
|
+
ActorId, AutoCommit, LoadOptions, ObjType, ReadDoc, TextEncoding, ROOT,
|
|
7
|
+
};
|
|
8
|
+
use magnus::{
|
|
9
|
+
prelude::*,
|
|
10
|
+
scan_args::{get_kwargs, scan_args},
|
|
11
|
+
typed_data::Obj,
|
|
12
|
+
Error, RArray, RHash, RString, Ruby, Value,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
use crate::errors::{arg_error, automerge_error, error};
|
|
16
|
+
use crate::{path, read, write};
|
|
17
|
+
|
|
18
|
+
/// Text indexes count Unicode code points, matching Ruby's `String#length`.
|
|
19
|
+
const ENCODING: TextEncoding = TextEncoding::UnicodeCodePoint;
|
|
20
|
+
|
|
21
|
+
#[magnus::wrap(class = "Mechanomeld::Document", free_immediately, size)]
|
|
22
|
+
pub struct Document {
|
|
23
|
+
inner: RefCell<AutoCommit>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
impl Document {
|
|
27
|
+
fn doc(&self, ruby: &Ruby) -> Result<Ref<'_, AutoCommit>, Error> {
|
|
28
|
+
self.inner
|
|
29
|
+
.try_borrow()
|
|
30
|
+
.map_err(|_| error(ruby, "document is being modified"))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fn doc_mut(&self, ruby: &Ruby) -> Result<RefMut<'_, AutoCommit>, Error> {
|
|
34
|
+
self.inner
|
|
35
|
+
.try_borrow_mut()
|
|
36
|
+
.map_err(|_| error(ruby, "document is already in use"))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// `Document.new(actor_id: nil)`
|
|
40
|
+
pub fn new(ruby: &Ruby, args: &[Value]) -> Result<Self, Error> {
|
|
41
|
+
let args = scan_args::<(), (), (), (), RHash, ()>(args)?;
|
|
42
|
+
let kwargs =
|
|
43
|
+
get_kwargs::<_, (), (Option<Option<String>>,), ()>(args.keywords, &[], &["actor_id"])?;
|
|
44
|
+
let mut doc = AutoCommit::new_with_encoding(ENCODING);
|
|
45
|
+
if let (Some(Some(actor_id)),) = kwargs.optional {
|
|
46
|
+
let actor = ActorId::from_str(&actor_id)
|
|
47
|
+
.map_err(|e| error(ruby, format!("invalid actor id: {e}")))?;
|
|
48
|
+
doc.set_actor(actor);
|
|
49
|
+
}
|
|
50
|
+
Ok(Self {
|
|
51
|
+
inner: RefCell::new(doc),
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// `Document.load(bytes)`
|
|
56
|
+
pub fn load(ruby: &Ruby, bytes: RString) -> Result<Self, Error> {
|
|
57
|
+
let data = unsafe { bytes.as_slice() }.to_vec();
|
|
58
|
+
let doc = AutoCommit::load_with_options(&data, LoadOptions::new().text_encoding(ENCODING))
|
|
59
|
+
.map_err(|e| error(ruby, format!("could not load Automerge document: {e}")))?;
|
|
60
|
+
Ok(Self {
|
|
61
|
+
inner: RefCell::new(doc),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// `doc.get(path)`: the value at `path`, or nil when anything along it is missing.
|
|
66
|
+
pub fn get(ruby: &Ruby, rb_self: &Self, path: Value) -> Result<Value, Error> {
|
|
67
|
+
let segments = path::segments(path)?;
|
|
68
|
+
let doc = rb_self.doc(ruby)?;
|
|
69
|
+
let Some((last, parents)) = segments.split_last() else {
|
|
70
|
+
return read::object(ruby, &doc, &ROOT, ObjType::Map);
|
|
71
|
+
};
|
|
72
|
+
let Some((parent, parent_type)) = path::resolve(ruby, &doc, parents)? else {
|
|
73
|
+
return Ok(ruby.qnil().as_value());
|
|
74
|
+
};
|
|
75
|
+
let prop = path::prop(ruby, parent_type, *last)?;
|
|
76
|
+
match doc
|
|
77
|
+
.get(&parent, prop)
|
|
78
|
+
.map_err(|e| automerge_error(ruby, e))?
|
|
79
|
+
{
|
|
80
|
+
Some((value, id)) => read::value(ruby, &doc, value, id),
|
|
81
|
+
None => Ok(ruby.qnil().as_value()),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// `doc.keys(path = [])`
|
|
86
|
+
pub fn keys(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<RArray, Error> {
|
|
87
|
+
let segments = optional_path(ruby, args)?;
|
|
88
|
+
let doc = rb_self.doc(ruby)?;
|
|
89
|
+
let (obj, obj_type) = path::resolve_existing(ruby, &doc, &segments)?;
|
|
90
|
+
if obj_type != ObjType::Map {
|
|
91
|
+
return Err(error(ruby, "keys target must be an Automerge map"));
|
|
92
|
+
}
|
|
93
|
+
Ok(ruby.ary_from_iter(doc.keys(&obj)))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// `doc.length(path = [])`
|
|
97
|
+
pub fn length(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<usize, Error> {
|
|
98
|
+
let segments = optional_path(ruby, args)?;
|
|
99
|
+
let doc = rb_self.doc(ruby)?;
|
|
100
|
+
let (obj, _) = path::resolve_existing(ruby, &doc, &segments)?;
|
|
101
|
+
Ok(doc.length(&obj))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// `doc.put(path, value)`
|
|
105
|
+
pub fn put(
|
|
106
|
+
ruby: &Ruby,
|
|
107
|
+
rb_self: Obj<Self>,
|
|
108
|
+
path: Value,
|
|
109
|
+
value: Value,
|
|
110
|
+
) -> Result<Obj<Self>, Error> {
|
|
111
|
+
let segments = path::segments(path)?;
|
|
112
|
+
let Some((last, parents)) = segments.split_last() else {
|
|
113
|
+
return Err(arg_error(ruby, "path must not be empty"));
|
|
114
|
+
};
|
|
115
|
+
{
|
|
116
|
+
let mut doc = rb_self.doc_mut(ruby)?;
|
|
117
|
+
let (parent, parent_type) = path::resolve_existing(ruby, &doc, parents)?;
|
|
118
|
+
let prop = path::write_prop(ruby, &doc, &parent, parent_type, *last)?;
|
|
119
|
+
write::write(ruby, &mut doc, &parent, write::Slot::Put(prop), value)?;
|
|
120
|
+
}
|
|
121
|
+
Ok(rb_self)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// `doc.delete(path)`
|
|
125
|
+
pub fn delete(ruby: &Ruby, rb_self: Obj<Self>, path: Value) -> Result<Obj<Self>, Error> {
|
|
126
|
+
let segments = path::segments(path)?;
|
|
127
|
+
let Some((last, parents)) = segments.split_last() else {
|
|
128
|
+
return Err(arg_error(ruby, "path must not be empty"));
|
|
129
|
+
};
|
|
130
|
+
{
|
|
131
|
+
let mut doc = rb_self.doc_mut(ruby)?;
|
|
132
|
+
let (parent, parent_type) = path::resolve_existing(ruby, &doc, parents)?;
|
|
133
|
+
let prop = path::write_prop(ruby, &doc, &parent, parent_type, *last)?;
|
|
134
|
+
doc.delete(&parent, prop)
|
|
135
|
+
.map_err(|e| automerge_error(ruby, e))?;
|
|
136
|
+
}
|
|
137
|
+
Ok(rb_self)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// `doc.commit(message: nil, timestamp: nil)`: the binary change hash, or nil.
|
|
141
|
+
/// `timestamp` is Unix seconds (automerge's commit time), unlike Timestamp values.
|
|
142
|
+
pub fn commit(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Option<RString>, Error> {
|
|
143
|
+
let args = scan_args::<(), (), (), (), RHash, ()>(args)?;
|
|
144
|
+
let kwargs = get_kwargs::<_, (), (Option<Option<String>>, Option<Option<i64>>), ()>(
|
|
145
|
+
args.keywords,
|
|
146
|
+
&[],
|
|
147
|
+
&["message", "timestamp"],
|
|
148
|
+
)?;
|
|
149
|
+
let (message, timestamp) = kwargs.optional;
|
|
150
|
+
let mut options = CommitOptions::default();
|
|
151
|
+
if let Some(Some(message)) = message {
|
|
152
|
+
options = options.with_message(message);
|
|
153
|
+
}
|
|
154
|
+
if let Some(Some(seconds)) = timestamp {
|
|
155
|
+
options = options.with_time(seconds);
|
|
156
|
+
}
|
|
157
|
+
let hash = rb_self.doc_mut(ruby)?.commit_with(options);
|
|
158
|
+
Ok(hash.map(|hash| ruby.str_from_slice(&hash.0)))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// `doc.rollback`: the number of pending operations discarded.
|
|
162
|
+
pub fn rollback(ruby: &Ruby, rb_self: &Self) -> Result<usize, Error> {
|
|
163
|
+
Ok(rb_self.doc_mut(ruby)?.rollback())
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// `doc.save`: the document as a binary String.
|
|
167
|
+
pub fn save(ruby: &Ruby, rb_self: &Self) -> Result<RString, Error> {
|
|
168
|
+
let bytes = rb_self.doc_mut(ruby)?.save();
|
|
169
|
+
Ok(ruby.str_from_slice(&bytes))
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fn optional_path(ruby: &Ruby, args: &[Value]) -> Result<Vec<Value>, Error> {
|
|
174
|
+
let args = scan_args::<(), (Option<Value>,), (), (), (), ()>(args)?;
|
|
175
|
+
path::segments(args.optional.0.unwrap_or_else(|| ruby.qnil().as_value()))
|
|
176
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
use std::borrow::Cow;
|
|
2
|
+
use std::fmt::Display;
|
|
3
|
+
|
|
4
|
+
use magnus::{Error, Ruby};
|
|
5
|
+
|
|
6
|
+
use crate::classes::ERROR;
|
|
7
|
+
|
|
8
|
+
/// A `Mechanomeld::Error`.
|
|
9
|
+
pub fn error<T: Into<Cow<'static, str>>>(ruby: &Ruby, message: T) -> Error {
|
|
10
|
+
Error::new(ruby.get_inner(&ERROR), message)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// A `Mechanomeld::Error` carrying an automerge error's message.
|
|
14
|
+
pub fn automerge_error(ruby: &Ruby, err: impl Display) -> Error {
|
|
15
|
+
error(ruby, err.to_string())
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
pub fn type_error<T: Into<Cow<'static, str>>>(ruby: &Ruby, message: T) -> Error {
|
|
19
|
+
Error::new(ruby.exception_type_error(), message)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
pub fn arg_error<T: Into<Cow<'static, str>>>(ruby: &Ruby, message: T) -> Error {
|
|
23
|
+
Error::new(ruby.exception_arg_error(), message)
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
mod classes;
|
|
2
|
+
mod document;
|
|
3
|
+
mod errors;
|
|
4
|
+
mod path;
|
|
5
|
+
mod read;
|
|
6
|
+
mod write;
|
|
7
|
+
|
|
8
|
+
use magnus::{function, method, prelude::*, Error, Ruby};
|
|
9
|
+
|
|
10
|
+
use crate::document::Document;
|
|
11
|
+
|
|
12
|
+
#[magnus::init]
|
|
13
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
14
|
+
let module = ruby.define_module("Mechanomeld")?;
|
|
15
|
+
let class = module.define_class("Document", ruby.class_object())?;
|
|
16
|
+
class.define_singleton_method("new", function!(Document::new, -1))?;
|
|
17
|
+
class.define_singleton_method("load", function!(Document::load, 1))?;
|
|
18
|
+
class.define_method("get", method!(Document::get, 1))?;
|
|
19
|
+
class.define_method("keys", method!(Document::keys, -1))?;
|
|
20
|
+
class.define_method("length", method!(Document::length, -1))?;
|
|
21
|
+
class.define_method("put", method!(Document::put, 2))?;
|
|
22
|
+
class.define_method("delete", method!(Document::delete, 1))?;
|
|
23
|
+
class.define_method("commit", method!(Document::commit, -1))?;
|
|
24
|
+
class.define_method("rollback", method!(Document::rollback, 0))?;
|
|
25
|
+
class.define_method("save", method!(Document::save, 0))?;
|
|
26
|
+
Ok(())
|
|
27
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
//! Turning Ruby paths like `["todos", 0, "title"]` into automerge objects and props.
|
|
2
|
+
|
|
3
|
+
use automerge::{AutoCommit, ObjId, ObjType, Prop, ReadDoc, Value as AmValue, ROOT};
|
|
4
|
+
use magnus::{prelude::*, Error, Integer, RArray, RString, Ruby, Symbol, Value};
|
|
5
|
+
|
|
6
|
+
use crate::errors::{arg_error, automerge_error, error, type_error};
|
|
7
|
+
|
|
8
|
+
/// `nil` is the root, an Array is a list of segments, anything else is a single segment.
|
|
9
|
+
///
|
|
10
|
+
/// The returned Values stay alive for the method call because the caller's `path`
|
|
11
|
+
/// argument still references them.
|
|
12
|
+
pub fn segments(path: Value) -> Result<Vec<Value>, Error> {
|
|
13
|
+
if path.is_nil() {
|
|
14
|
+
return Ok(Vec::new());
|
|
15
|
+
}
|
|
16
|
+
let Some(array) = RArray::from_value(path) else {
|
|
17
|
+
return Ok(vec![path]);
|
|
18
|
+
};
|
|
19
|
+
(0..array.len())
|
|
20
|
+
.map(|index| array.entry::<Value>(index as isize))
|
|
21
|
+
.collect()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
pub fn key(ruby: &Ruby, segment: Value) -> Result<String, Error> {
|
|
25
|
+
if let Some(symbol) = Symbol::from_value(segment) {
|
|
26
|
+
return Ok(symbol.name()?.into_owned());
|
|
27
|
+
}
|
|
28
|
+
if let Some(string) = RString::from_value(segment) {
|
|
29
|
+
return string.to_string();
|
|
30
|
+
}
|
|
31
|
+
Err(type_error(
|
|
32
|
+
ruby,
|
|
33
|
+
format!(
|
|
34
|
+
"map key must be a String or Symbol, got {}",
|
|
35
|
+
class_name(segment)
|
|
36
|
+
),
|
|
37
|
+
))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
pub fn index(ruby: &Ruby, segment: Value) -> Result<usize, Error> {
|
|
41
|
+
let Some(integer) = Integer::from_value(segment) else {
|
|
42
|
+
return Err(type_error(
|
|
43
|
+
ruby,
|
|
44
|
+
format!("list index must be an Integer, got {}", class_name(segment)),
|
|
45
|
+
));
|
|
46
|
+
};
|
|
47
|
+
usize::try_from(integer.to_i64()?)
|
|
48
|
+
.map_err(|_| arg_error(ruby, "list index must be non-negative"))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
pub fn class_name(value: Value) -> String {
|
|
52
|
+
unsafe { value.classname() }.into_owned()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// The prop addressing `segment` inside an object of `obj_type`.
|
|
56
|
+
pub fn prop(ruby: &Ruby, obj_type: ObjType, segment: Value) -> Result<Prop, Error> {
|
|
57
|
+
match obj_type {
|
|
58
|
+
ObjType::Map | ObjType::Table => Ok(Prop::Map(key(ruby, segment)?)),
|
|
59
|
+
ObjType::List => Ok(Prop::Seq(index(ruby, segment)?)),
|
|
60
|
+
ObjType::Text => Err(error(ruby, "cannot descend into Automerge text")),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Follows `segments` from the root. `None` when a key or index along the way is missing.
|
|
65
|
+
pub fn resolve(
|
|
66
|
+
ruby: &Ruby,
|
|
67
|
+
doc: &AutoCommit,
|
|
68
|
+
segments: &[Value],
|
|
69
|
+
) -> Result<Option<(ObjId, ObjType)>, Error> {
|
|
70
|
+
let mut obj = ROOT;
|
|
71
|
+
let mut obj_type = ObjType::Map;
|
|
72
|
+
for segment in segments {
|
|
73
|
+
let prop = prop(ruby, obj_type, *segment)?;
|
|
74
|
+
match doc.get(&obj, prop).map_err(|e| automerge_error(ruby, e))? {
|
|
75
|
+
None => return Ok(None),
|
|
76
|
+
Some((AmValue::Object(child_type), child)) => {
|
|
77
|
+
obj = child;
|
|
78
|
+
obj_type = child_type;
|
|
79
|
+
}
|
|
80
|
+
Some((AmValue::Scalar(_), _)) => {
|
|
81
|
+
return Err(error(ruby, "path does not resolve to an Automerge object"))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
Ok(Some((obj, obj_type)))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Like [`resolve`], but a missing key or index is an error.
|
|
89
|
+
pub fn resolve_existing(
|
|
90
|
+
ruby: &Ruby,
|
|
91
|
+
doc: &AutoCommit,
|
|
92
|
+
segments: &[Value],
|
|
93
|
+
) -> Result<(ObjId, ObjType), Error> {
|
|
94
|
+
resolve(ruby, doc, segments)?.ok_or_else(|| error(ruby, "path does not exist"))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// The prop for writing `segment` into `obj`. List indexes must already exist.
|
|
98
|
+
pub fn write_prop(
|
|
99
|
+
ruby: &Ruby,
|
|
100
|
+
doc: &AutoCommit,
|
|
101
|
+
obj: &ObjId,
|
|
102
|
+
obj_type: ObjType,
|
|
103
|
+
segment: Value,
|
|
104
|
+
) -> Result<Prop, Error> {
|
|
105
|
+
if obj_type != ObjType::List {
|
|
106
|
+
return prop(ruby, obj_type, segment);
|
|
107
|
+
}
|
|
108
|
+
let index = index(ruby, segment)?;
|
|
109
|
+
let length = doc.length(obj);
|
|
110
|
+
if index >= length {
|
|
111
|
+
return Err(error(
|
|
112
|
+
ruby,
|
|
113
|
+
format!("list index {index} is out of bounds (length {length})"),
|
|
114
|
+
));
|
|
115
|
+
}
|
|
116
|
+
Ok(Prop::Seq(index))
|
|
117
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//! Converting automerge values into Ruby values.
|
|
2
|
+
|
|
3
|
+
use automerge::{AutoCommit, ObjId, ObjType, ReadDoc, ScalarValue, Value as AmValue};
|
|
4
|
+
use magnus::{prelude::*, value::Lazy, Error, IntoValue, RClass, RObject, Ruby, Value};
|
|
5
|
+
|
|
6
|
+
use crate::classes::{BYTES, COUNTER, TEXT, TIMESTAMP, UINT};
|
|
7
|
+
use crate::errors::{automerge_error, error};
|
|
8
|
+
|
|
9
|
+
pub fn value(ruby: &Ruby, doc: &AutoCommit, value: AmValue<'_>, id: ObjId) -> Result<Value, Error> {
|
|
10
|
+
match value {
|
|
11
|
+
AmValue::Object(obj_type) => object(ruby, doc, &id, obj_type),
|
|
12
|
+
AmValue::Scalar(scalar_value) => scalar(ruby, &scalar_value),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
pub fn object(
|
|
17
|
+
ruby: &Ruby,
|
|
18
|
+
doc: &AutoCommit,
|
|
19
|
+
obj: &ObjId,
|
|
20
|
+
obj_type: ObjType,
|
|
21
|
+
) -> Result<Value, Error> {
|
|
22
|
+
match obj_type {
|
|
23
|
+
ObjType::Map => {
|
|
24
|
+
let hash = ruby.hash_new();
|
|
25
|
+
for key in doc.keys(obj) {
|
|
26
|
+
if let Some((child, id)) = doc
|
|
27
|
+
.get(obj, key.as_str())
|
|
28
|
+
.map_err(|e| automerge_error(ruby, e))?
|
|
29
|
+
{
|
|
30
|
+
hash.aset(key, value(ruby, doc, child, id)?)?;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
Ok(hash.as_value())
|
|
34
|
+
}
|
|
35
|
+
ObjType::List => {
|
|
36
|
+
let length = doc.length(obj);
|
|
37
|
+
let array = ruby.ary_new_capa(length);
|
|
38
|
+
for index in 0..length {
|
|
39
|
+
match doc.get(obj, index).map_err(|e| automerge_error(ruby, e))? {
|
|
40
|
+
Some((child, id)) => array.push(value(ruby, doc, child, id)?)?,
|
|
41
|
+
None => array.push(ruby.qnil())?,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
Ok(array.as_value())
|
|
45
|
+
}
|
|
46
|
+
ObjType::Text => {
|
|
47
|
+
let text = doc.text(obj).map_err(|e| automerge_error(ruby, e))?;
|
|
48
|
+
wrap(ruby, &TEXT, ruby.str_new(&text))
|
|
49
|
+
}
|
|
50
|
+
ObjType::Table => Err(error(ruby, "Automerge tables are not supported")),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
pub fn scalar(ruby: &Ruby, scalar: &ScalarValue) -> Result<Value, Error> {
|
|
55
|
+
match scalar {
|
|
56
|
+
ScalarValue::Str(string) => Ok(ruby.str_new(string.as_str()).as_value()),
|
|
57
|
+
ScalarValue::Int(int) => Ok(ruby.integer_from_i64(*int).as_value()),
|
|
58
|
+
ScalarValue::F64(float) => Ok(ruby.float_from_f64(*float).as_value()),
|
|
59
|
+
ScalarValue::Boolean(boolean) => Ok(boolean.into_value_with(ruby)),
|
|
60
|
+
ScalarValue::Null => Ok(ruby.qnil().as_value()),
|
|
61
|
+
ScalarValue::Counter(counter) => {
|
|
62
|
+
wrap(ruby, &COUNTER, ruby.integer_from_i64(i64::from(counter)))
|
|
63
|
+
}
|
|
64
|
+
ScalarValue::Timestamp(millis) => wrap(ruby, &TIMESTAMP, ruby.integer_from_i64(*millis)),
|
|
65
|
+
ScalarValue::Uint(uint) => wrap(ruby, &UINT, ruby.integer_from_u64(*uint)),
|
|
66
|
+
ScalarValue::Bytes(bytes) => wrap(ruby, &BYTES, ruby.str_from_slice(bytes)),
|
|
67
|
+
ScalarValue::Unknown { type_code, .. } => Err(error(
|
|
68
|
+
ruby,
|
|
69
|
+
format!("unsupported Automerge value type code {type_code}"),
|
|
70
|
+
)),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Builds a scalar wrapper by allocating it and setting `@value`, without running Ruby code.
|
|
75
|
+
fn wrap(ruby: &Ruby, class: &'static Lazy<RClass>, value: impl IntoValue) -> Result<Value, Error> {
|
|
76
|
+
let instance = ruby.get_inner(class).obj_alloc()?;
|
|
77
|
+
let object = RObject::try_convert(instance.as_value())?;
|
|
78
|
+
object.ivar_set("@value", value)?;
|
|
79
|
+
Ok(object.as_value())
|
|
80
|
+
}
|