Skip to content

Commit

Permalink
Add cursor support
Browse files Browse the repository at this point in the history
Cursors can be used to obtain a reference to a position in a sequence or
text object which is stable across concurrent changes.
  • Loading branch information
alexjg committed Jan 11, 2024
1 parent 53e8665 commit 2f2a5cf
Show file tree
Hide file tree
Showing 10 changed files with 423 additions and 2 deletions.
11 changes: 11 additions & 0 deletions lib/src/main/java/org/automerge/AutomergeSys.java
Original file line number Diff line number Diff line change
Expand Up @@ -324,4 +324,15 @@ public static native void receiveSyncMessageLogPatches(SyncStatePointer syncStat
public static native void freePatchLog(PatchLogPointer pointer);

public static native ArrayList<Patch> diff(DocPointer doc, ChangeHash[] before, ChangeHash[] after);

public static native Cursor makeCursorInDoc(DocPointer doc, ObjectId obj, long index, Optional<ChangeHash[]> heads);

public static native Cursor makeCursorInTx(TransactionPointer tx, ObjectId obj, long index,
Optional<ChangeHash[]> heads);

public static native long lookupCursorIndexInDoc(DocPointer doc, ObjectId obj, Cursor cursor,
Optional<ChangeHash[]> heads);

public static native long lookupCursorIndexInTx(TransactionPointer tx, ObjectId obj, Cursor cursor,
Optional<ChangeHash[]> heads);
}
5 changes: 5 additions & 0 deletions lib/src/main/java/org/automerge/Cursor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.automerge;

public class Cursor {
private byte[] raw;
}
20 changes: 20 additions & 0 deletions lib/src/main/java/org/automerge/Document.java
Original file line number Diff line number Diff line change
Expand Up @@ -678,4 +678,24 @@ public synchronized HashMap<String, AmValue> getMarksAtIndex(ObjectId obj, int i
return AutomergeSys.getMarksAtIndexInDoc(this.pointer.get(), obj, index, Optional.of(heads));
}
}

@Override
public Cursor makeCursor(ObjectId obj, long index) {
return AutomergeSys.makeCursorInDoc(this.pointer.get(), obj, index, Optional.empty());
}

@Override
public Cursor makeCursor(ObjectId obj, long index, ChangeHash[] heads) {
return AutomergeSys.makeCursorInDoc(this.pointer.get(), obj, index, Optional.of(heads));
}

@Override
public long lookupCursorIndex(ObjectId obj, Cursor cursor) {
return AutomergeSys.lookupCursorIndexInDoc(this.pointer.get(), obj, cursor, Optional.empty());
}

@Override
public long lookupCursorIndex(ObjectId obj, Cursor cursor, ChangeHash[] heads) {
return AutomergeSys.lookupCursorIndexInDoc(this.pointer.get(), obj, cursor, Optional.of(heads));
}
}
70 changes: 70 additions & 0 deletions lib/src/main/java/org/automerge/Read.java
Original file line number Diff line number Diff line change
Expand Up @@ -338,4 +338,74 @@ public interface Read {
* this moment.
*/
public ChangeHash[] getHeads();

/**
* Get a cursor which refers to the given index in a list or text object
*
* @param obj
* - The ID of the list or text object to get the cursor for
* @param index
* - The index to get the cursor for
*
* @return The cursor
*
* @throws AutomergeException
* if the object ID refers to an object which is not a list or text
* object or if the index is out of range
*/
public Cursor makeCursor(ObjectId obj, long index);

/**
* Get a cursor which refers to the given index in a list or text object as at
* the given heads
*
* @param obj
* - The ID of the list or text object to get the cursor for
* @param index
* - The index to get the cursor for
* @param heads
* - The heads of the version of the document to make the cursor from
*
* @return The cursor
*
* @throws AutomergeException
* if the object ID refers to an object which is not a list or text
* object or if the index is out of range
*/
public Cursor makeCursor(ObjectId obj, long index, ChangeHash[] heads);

/**
* Given a cursor for an object, get the index the cursor points at
*
* @param obj
* - The ID of the object the cursor refers into
* @param cursor
* - The cursor
*
* @return The index the cursor points at
* @throws AutomergeException
* if the object ID refers to an object which is not a list or text
* object or if the cursor does not refer to an element in the
* object
*/
public long lookupCursorIndex(ObjectId obj, Cursor cursor);

/**
* Given a cursor for an object, get the index the cursor points at as at the
* given heads
*
* @param obj
* - The ID of the object the cursor refers into
* @param cursor
* - The cursor
* @param heads
* - The heads of the version of the document to make the cursor from
*
* @return The index the cursor points at
* @throws AutomergeException
* if the object ID refers to an object which is not a list or text
* object or if the cursor does not refer to an element in the
* object
*/
public long lookupCursorIndex(ObjectId obj, Cursor cursor, ChangeHash[] heads);
}
21 changes: 21 additions & 0 deletions lib/src/main/java/org/automerge/TransactionImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -351,4 +351,25 @@ public synchronized HashMap<String, AmValue> getMarksAtIndex(ObjectId obj, int i
public synchronized HashMap<String, AmValue> getMarksAtIndex(ObjectId obj, int index, ChangeHash[] heads) {
return AutomergeSys.getMarksAtIndexInTx(this.pointer.get(), obj, index, Optional.of(heads));
}

@Override
public Cursor makeCursor(ObjectId obj, long index) {
return AutomergeSys.makeCursorInTx(this.pointer.get(), obj, index, Optional.empty());
}

@Override
public Cursor makeCursor(ObjectId obj, long index, ChangeHash[] heads) {
return AutomergeSys.makeCursorInTx(this.pointer.get(), obj, index, Optional.of(heads));
}

@Override
public long lookupCursorIndex(ObjectId obj, Cursor cursor) {
return AutomergeSys.lookupCursorIndexInTx(this.pointer.get(), obj, cursor, Optional.empty());
}

@Override
public long lookupCursorIndex(ObjectId obj, Cursor cursor, ChangeHash[] heads) {
return AutomergeSys.lookupCursorIndexInTx(this.pointer.get(), obj, cursor, Optional.of(heads));
}

}
61 changes: 61 additions & 0 deletions lib/src/test/java/org/automerge/TestCursor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package org.automerge;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public final class TestCursor {
private Document doc;
private ObjectId text;

@BeforeEach
public void setup() {
doc = new Document();
try (Transaction tx = doc.startTransaction()) {
text = tx.set(ObjectId.ROOT, "text", ObjectType.TEXT);
tx.spliceText(text, 0, 0, "hello world");
tx.commit();
}
}

@Test
public void testCursorInDoc() {
Cursor cursor = doc.makeCursor(text, 3);
Assertions.assertEquals(doc.lookupCursorIndex(text, cursor), 3);

ChangeHash[] heads = doc.getHeads();

try (Transaction tx = doc.startTransaction()) {
tx.spliceText(text, 3, 0, "!");
tx.commit();
}

Assertions.assertEquals(doc.lookupCursorIndex(text, cursor), 4);
Assertions.assertEquals(doc.lookupCursorIndex(text, cursor, heads), 3);

Cursor oldCursor = doc.makeCursor(text, 3, heads);
Assertions.assertEquals(doc.lookupCursorIndex(text, oldCursor), 4);
Assertions.assertEquals(doc.lookupCursorIndex(text, oldCursor, heads), 3);

}

@Test
public void testCursorInTx() {
ChangeHash[] heads = doc.getHeads();
Cursor cursor;
try (Transaction tx = doc.startTransaction()) {
cursor = tx.makeCursor(text, 3);
Assertions.assertEquals(tx.lookupCursorIndex(text, cursor), 3);
tx.spliceText(text, 3, 0, "!");
Assertions.assertEquals(tx.lookupCursorIndex(text, cursor), 4);
tx.commit();
}

try (Transaction tx = doc.startTransaction()) {
Cursor oldCursor = tx.makeCursor(text, 3, heads);
Assertions.assertEquals(tx.lookupCursorIndex(text, oldCursor), 4);
Assertions.assertEquals(tx.lookupCursorIndex(text, oldCursor, heads), 3);
tx.commit();
}
}
}
115 changes: 115 additions & 0 deletions rust/src/cursor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
use automerge_jni_macros::jni_fn;
use jni::{
objects::{JObject, JString},
sys::{jbyteArray, jobject},
JNIEnv,
};

use crate::AUTOMERGE_EXCEPTION;

#[derive(Debug)]
pub struct Cursor(automerge::Cursor);

impl AsRef<automerge::Cursor> for Cursor {
fn as_ref(&self) -> &automerge::Cursor {
&self.0
}
}

impl From<automerge::Cursor> for Cursor {
fn from(i: automerge::Cursor) -> Self {
Self(i)
}
}

const CLASSNAME: &str = am_classname!("Cursor");

impl Cursor {
pub(crate) fn into_raw(self, env: &JNIEnv) -> Result<jobject, jni::errors::Error> {
Ok(self.into_jobject(env)?.into_raw())
}

pub(crate) fn into_jobject<'a>(
self,
env: &JNIEnv<'a>,
) -> Result<JObject<'a>, jni::errors::Error> {
let raw_obj = env.alloc_object(CLASSNAME)?;
let bytes = self.0.to_bytes();
let jbytes = env.byte_array_from_slice(&bytes)?;
env.set_field(
raw_obj,
"raw",
"[B",
unsafe { JObject::from_raw(jbytes) }.into(),
)?;
Ok(raw_obj)
}

pub(crate) unsafe fn from_raw(env: &JNIEnv<'_>, raw: jobject) -> Result<Self, errors::FromRaw> {
let obj = JObject::from_raw(raw);
let bytes_jobject = env
.get_field(obj, "raw", "[B")
.map_err(errors::FromRaw::GetRaw)?
.l()
.map_err(errors::FromRaw::RawPointerNotObject)?
.into_raw() as jbyteArray;
let arr = env
.get_byte_array_elements(bytes_jobject, jni::objects::ReleaseMode::NoCopyBack)
.map_err(errors::FromRaw::GetByteArray)?;
let bytes =
std::slice::from_raw_parts(arr.as_ptr() as *const u8, arr.size().unwrap() as usize);
let cursor: automerge::Cursor =
bytes.try_into().map_err(errors::FromRaw::Invalid)?;
Ok(Self(cursor))
}
}

#[no_mangle]
#[jni_fn]
pub unsafe extern "C" fn cursorToString(
env: jni::JNIEnv,
_class: jni::objects::JClass,
obj: jni::sys::jobject,
) -> jni::sys::jobject {
let cursor = Cursor::from_raw(&env, obj).unwrap();
let s = cursor.as_ref().to_string();
let jstr = env.new_string(s).unwrap();
jstr.into_raw()
}

#[no_mangle]
#[jni_fn]
pub unsafe extern "C" fn cursorFromString(
env: jni::JNIEnv,
_class: jni::objects::JClass,
s: jni::sys::jstring,
) -> jobject {
let s = env.get_string(JString::from_raw(s)).unwrap();
let Ok(s) = s.to_str() else {
env.throw_new(AUTOMERGE_EXCEPTION, "invalid cursor string")
.unwrap();
return JObject::null().into_raw();
};
let Ok(cursor) = automerge::Cursor::try_from(s) else {
env.throw_new(AUTOMERGE_EXCEPTION, "invalid cursor string")
.unwrap();
return JObject::null().into_raw();
};
Cursor::from(cursor).into_raw(&env).unwrap()
}

pub mod errors {
use super::CLASSNAME;

#[derive(Debug, thiserror::Error)]
pub(crate) enum FromRaw {
#[error("unable to get the 'raw' field: {0} for class {}", CLASSNAME)]
GetRaw(jni::errors::Error),
#[error("could not convert the 'raw' pointer to an object: {0}")]
RawPointerNotObject(jni::errors::Error),
#[error("error getting byte array from object: {0}")]
GetByteArray(jni::errors::Error),
#[error("invalid ID")]
Invalid(automerge::AutomergeError),
}
}
1 change: 1 addition & 0 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ macro_rules! am_classname {
}

mod conflicts;
mod cursor;
mod document;
mod expand_mark;
mod interop;
Expand Down
Loading

3 comments on commit 2f2a5cf

@eneroth
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice!

A thought occurs: If I need to communicate a cursor to a peer, is there a way to serialize it? Maybe .toString might suffice, especially if this gives interop with JS.

@alexjg
Copy link
Collaborator Author

@alexjg alexjg commented on 2f2a5cf Jan 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah I'll add both toString and toBytes (or similar). The bytes which the Cursor wraps are actually already serialized in a forward compatible form so would be preferable to use if interop is not a concern.

@eneroth
Copy link

@eneroth eneroth commented on 2f2a5cf Jan 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm up for anything that the JS counterpart can consume, so I'm not sure bytes will be immediately useful, but it can't hurt.

Speaking of which from bytes/string might be nice for symmetry.

Please sign in to comment.