reconcile-text 0.12.1

Intelligent 3-way text merging with automated conflict resolution
Documentation
//! Expose the `reconcile` crate's functionality to WebAssembly.
use core::str;

use wasm_bindgen::prelude::*;

use crate::{BuiltinTokenizer, CursorPosition, EditedText, SpanWithHistory, TextWithCursors};

/// WASM wrapper around `crate::reconcile` for merging text
#[wasm_bindgen(js_name = reconcile)]
#[must_use]
pub fn reconcile(
    parent: &str,
    left: &TextWithCursors,
    right: &TextWithCursors,
    tokenizer: BuiltinTokenizer,
) -> TextWithCursors {
    set_panic_hook();

    crate::reconcile(parent, left, right, &*tokenizer).apply()
}

/// WASM wrapper around `crate::reconcile` that also returns provenance history
#[wasm_bindgen(js_name = reconcileWithHistory)]
#[must_use]
pub fn reconcile_with_history(
    parent: &str,
    left: &TextWithCursors,
    right: &TextWithCursors,
    tokenizer: BuiltinTokenizer,
) -> TextWithCursorsAndHistory {
    set_panic_hook();

    let reconciled = crate::reconcile(parent, left, right, &*tokenizer);
    let (text_with_cursors, history) = reconciled.apply_with_all();

    TextWithCursorsAndHistory {
        text_with_cursors,
        history,
    }
}

/// Merge two documents with a common parent. Relies on `reconcile::reconcile`
/// for texts and returns the right document as-is if either of the updated
/// documents is binary.
///
/// # Arguments
///
/// - `parent`: The common parent document
/// - `left`: The left document updated by one user
/// - `right`: The right document updated by another user
///
/// # Returns
///
/// The merged document
#[wasm_bindgen(js_name = genericReconcile)]
#[must_use]
pub fn generic_reconcile(
    parent: &[u8],
    left: &[u8],
    right: &[u8],
    tokenizer: BuiltinTokenizer,
) -> Vec<u8> {
    set_panic_hook();

    if let (Some(parent), Some(left), Some(right)) = (
        string_or_nothing(parent),
        string_or_nothing(left),
        string_or_nothing(right),
    ) {
        crate::reconcile(&parent, &left.into(), &right.into(), &*tokenizer)
            .apply()
            .text()
            .into_bytes()
    } else {
        right.to_vec()
    }
}

/// WASM wrapper around getting a compact diff representation of two texts as a
/// list of numbers and strings
///
/// # Errors
///
/// Returns a JS error if integer overflow occurs during diff computation.
#[wasm_bindgen(js_name = diff)]
pub fn diff(
    parent: &str,
    changed: &TextWithCursors,
    tokenizer: BuiltinTokenizer,
) -> Result<Vec<JsValue>, JsValue> {
    set_panic_hook();

    let edited_text = EditedText::from_strings_with_tokenizer(parent, changed, &*tokenizer);
    edited_text
        .to_diff()
        .map(|diff| diff.into_iter().map(std::convert::Into::into).collect())
        .map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Inverse of `diff`, applies a compact diff representation to a parent text
///
/// # Errors
///
/// Returns a JS error if the diff format is invalid or references ranges
/// exceeding the original text length.
#[wasm_bindgen(js_name = undiff)]
pub fn undiff(
    parent: &str,
    diff: Vec<JsValue>,
    tokenizer: BuiltinTokenizer,
) -> Result<String, JsValue> {
    set_panic_hook();

    let parsed_diff: Vec<_> = diff
        .into_iter()
        .map(std::convert::TryInto::try_into)
        .collect::<Result<_, _>>()
        .map_err(|e: crate::types::number_or_text::DeserialisationError| -> JsValue { e.into() })?;

    EditedText::from_diff(parent, parsed_diff, &*tokenizer)
        .map(|edited_text| edited_text.apply().text())
        .map_err(|e| JsValue::from_str(&e.to_string()))
}

fn set_panic_hook() {
    // https://github.com/rustwasm/console_error_panic_hook#readme
    #[cfg(feature = "console_error_panic_hook")]
    console_error_panic_hook::set_once();
}

/// WASM wrapper type for the return value of `reconcile_with_history`
#[wasm_bindgen]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TextWithCursorsAndHistory {
    text_with_cursors: TextWithCursors,
    history: Vec<SpanWithHistory>,
}

#[wasm_bindgen]
impl TextWithCursorsAndHistory {
    #[must_use]
    pub fn text(&self) -> String {
        self.text_with_cursors.text()
    }

    #[must_use]
    pub fn cursors(&self) -> Vec<CursorPosition> {
        self.text_with_cursors.cursors()
    }

    #[must_use]
    pub fn history(&self) -> Vec<SpanWithHistory> {
        self.history.clone()
    }
}

/// Returns the UTF8 parsed string if it's a text, or `None` if it's likely
/// binary.
#[must_use]
fn string_or_nothing(data: &[u8]) -> Option<String> {
    if data.contains(&0) {
        // Even though the NUL character is valid in UTF-8, it's highly suspicious in
        // human-readable text.
        return None;
    }

    std::str::from_utf8(data)
        .map(std::borrow::ToOwned::to_owned)
        .ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_string_or_nothing() {
        assert_eq!(string_or_nothing(&[0, 159, 146, 150]), None);
        assert_eq!(string_or_nothing(&[0, 12]), None);
        assert_eq!(string_or_nothing(b"hello"), Some("hello".into()));
    }
}